外观
1.7 信号量与互斥锁
这节要搞懂:两个任务同时动一个东西会出什么事,怎么防。 需要基础:1.6 ⭐ 本章重点
一、先看会出什么事
有个全局变量 counter,两个任务都在给它加 1:
c
int counter = 0;
// 任务 A 和任务 B 都执行这一句
counter++;你以为加了两次就是 2?不一定。
因为 counter++ 在芯片里其实是三步:
① 把 counter 读出来 (读到 0)
② 加 1 (算出 1)
③ 写回去 (写入 1)如果任务 A 做完 ① 就被抢占了,任务 B 完整做完三步(counter 变成 1), 然后任务 A 回来继续做 ②③ —— 它手里还是老数据 0,加 1 写回去还是 1。
加了两次,结果是 1。数据丢了。
二、什么是竞态
竞态(Race Condition)指的是:多个任务同时访问同一份共享资源,最终结果取决于它们的执行顺序,从而导致不可预期的错误。
只要多个任务会碰同一个东西(变量、屏幕、串口、传感器),就必须保护。这是多任务编程最大的坑。
FreeRTOS 提供了两类工具来解决它:互斥锁和信号量。
三、什么是互斥锁
互斥锁(Mutex,Mutual Exclusion)是一种保护共享资源的机制。它保证同一时刻只有一个任务能访问被保护的资源,其他任务必须等待。
打个比方:互斥锁就像一间单人厕所的钥匙。
- 谁拿到钥匙谁进去
- 别人只能在外面等
- 出来必须把钥匙还回去
c
SemaphoreHandle_t counter_mutex; // 定义一把锁
void app_init(void)
{
counter_mutex = xSemaphoreCreateMutex(); // 创建(配一把钥匙)
}
void task_add(void *pvParameters)
{
while (1) {
// 拿钥匙,最多等 100 毫秒
if (xSemaphoreTake(counter_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
counter++; // 安全了,这段时间只有我能动
xSemaphoreGive(counter_mutex); // ⚠️ 一定要还钥匙!
} else {
printf("take mutex timeout\n"); // 等太久了,这次先不改
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}忘了还钥匙
xSemaphoreGive 忘了写 → 别人永远拿不到 → 整个程序卡死。
写 Take 的时候就把 Give 一起写好,养成习惯。
四、什么东西需要上锁
| 需要上锁 ✅ | 不需要上锁 ❌ |
|---|---|
| 多个任务都会改的全局变量 | 只在一个任务内部用的局部变量 |
| 屏幕、串口、I2C 这类共享硬件 | 只有一个任务会碰的硬件 |
五、什么是信号量
信号量(Semaphore)是一种任务间的同步与资源计数机制。它内部维护一个计数值:任务"获取"(Take)时计数减一,减到 0 就得等待;"释放"(Give)时计数加一。
换句话说:信号量本质上就是一个带等待功能的计数器。
按这个计数值的最大值,信号量分成两种:
| 类型 | 计数范围 | 用来干什么 |
|---|---|---|
| 二值信号量 | 0 或 1 | 通知:某件事发生了 |
| 计数信号量 | 0 ~ N | 管理 N 份资源:还剩几个可用 |
二值信号量其实是计数信号量的特例
最大值为 1 的计数信号量,就是二值信号量。
六、二值信号量:用来"通知"
二值信号量(Binary Semaphore)的计数值只有 0(没信号)和 1(有信号) 两种状态,用于表达"某件事发生了没有"。
打个比方:二值信号量就是一个铃。服务员按一下铃,后厨听到就出来端菜。铃不响,后厨就安静等着。
最典型的场景:按键触发中断,中断里不能干重活,就"按个铃"通知任务来处理。
c
SemaphoreHandle_t key_sem;
void app_init(void)
{
key_sem = xSemaphoreCreateBinary(); // 创建二值信号量
}
// 中断服务函数:按铃就走,绝不停留
void IRAM_ATTR key_isr_handler(void *arg)
{
xSemaphoreGiveFromISR(key_sem, NULL); // 注意函数名带 FromISR
}
// 任务:一直等铃响
void task_key(void *pvParameters)
{
while (1) {
// 铃不响就一直睡,不占 CPU
xSemaphoreTake(key_sem, portMAX_DELAY);
handle_key(); // 铃响了才干活
}
}中断里的规矩
在中断服务函数里,只能用名字带 FromISR 的版本,而且不能等待。用错了直接崩溃。
记法:中断里只按铃,不干活。
七、计数信号量:用来"管理 N 份资源"
计数信号量(Counting Semaphore)的计数值可以是 0 到 N,表示当前还有几份资源可用。每取走一份计数减一,归还一份计数加一,减到 0 时后来者必须等待。
打个比方:计数信号量就是一个有 3 个车位的停车场。
- 一辆车进去 → 剩余车位 3 → 2
- 又进两辆 → 剩余 0
- 第四辆车到了 → 只能在门口等,直到有车开走
c
SemaphoreHandle_t slot_sem;
void app_init(void)
{
// 参数:最大值 3,初始值 3(一开始 3 个车位全空)
slot_sem = xSemaphoreCreateCounting(3, 3);
}
void task_worker(void *pvParameters)
{
while (1) {
// 抢一个车位,没位置就等着
if (xSemaphoreTake(slot_sem, portMAX_DELAY) == pdTRUE) {
do_heavy_work(); // 最多只有 3 个任务同时干这件事
xSemaphoreGive(slot_sem); // 开走,把车位还回去
}
}
}常见用途:
| 场景 | 计数值代表什么 |
|---|---|
| 限制同时联网的任务数 | 还能同时发几个请求 |
| 管理一组缓冲区 | 还有几块缓冲区空着 |
| 生产者-消费者 | 已经生产了几件待处理 |
新手先了解就行
计数信号量在小项目里用得不多,我们的天气时钟一次都没用到。
知道"它是能数到 N 的信号量"就够了,遇到需要限流的场合再回来看。
八、三者对比 ⭐
| 互斥锁 | 二值信号量 | 计数信号量 | |
|---|---|---|---|
| 用来干嘛 | 保护共享资源 | 通知某件事发生 | 管理 N 份资源 |
| 计数范围 | 0 或 1 | 0 或 1 | 0 ~ N |
| 谁拿谁还 | 必须同一个任务 | A 给、B 拿,随便 | 随便 |
| 创建函数 | xSemaphoreCreateMutex() | xSemaphoreCreateBinary() | xSemaphoreCreateCounting(N, N) |
| 典型场景 | 保护屏幕、全局变量 | 中断通知任务 | 限制并发数量 |
互斥锁和二值信号量长得像,但别混用
两者计数范围都是 0/1,但用途完全不同:
- 保护资源用互斥锁 —— 它要求"谁拿谁还",而且内部有额外机制避免低优先级任务长期占着锁不放
- 通知用二值信号量 —— 它允许"中断给、任务拿",本来就不是同一个人
记住:保护用锁,通知用信号量。
通知还有一个更省的选择
如果只是通知某一个特定的任务,FreeRTOS 还提供了比信号量更快、更省内存的 任务通知。
1.8 会介绍它,以及什么时候该用它、什么时候还是得用信号量。
本节要点
- 竞态 = 多个任务抢同一份资源导致结果出错
- 互斥锁 = 保证同一时刻只有一个任务能访问,
Take之后一定要Give - 信号量本质是一个带等待功能的计数器,分两种:
- 二值信号量(0/1):用来通知,最常用
- 计数信号量(0~N):用来管理 N 份资源,新手用得少
- 中断里只能用
GiveFromISR,只按铃、不干活 - 保护用锁,通知用信号量
动手试试
- 两个任务各自给同一个变量加 10 万次,不加锁,打印最终结果,看看少了多少。
- 加上互斥锁再跑一次,验证结果正确了。
- 故意把
xSemaphoreGive注释掉,观察程序怎么卡死。 - 创建一个初始值为 2 的计数信号量,让 4 个任务去抢,打印它们进入和离开的时刻,观察是不是永远最多只有 2 个同时在里面。