← 返回教程库

FreeRTOS 同步原语:互斥锁、信号量、事件组

最后更新 2026-06-22
L3 · 联网与 IoT ⏱ 约 20 分钟 🟢 软件/低风险
你将学到
  • 亲手制造一次竞态——两个任务抢加同一个全局计数器,看着结果对不上
  • 用 xSemaphoreCreateMutex + Take/Give 把临界区锁住,把竞态修掉,并理解优先级继承怎么防优先级反转
  • 搞懂二值信号量怎么在 ISR 和任务之间做"出事件→去处理"的同步,比队列轻在哪
  • 把事件组讲透——回头看懂 WiFi 篇里那个 WIFI_CONNECTED_BIT 到底在等什么、WaitBits 四个参数各管什么

上一篇FreeRTOS 队列解决了一类问题:一个任务读到温度,要交给另一个任务上报——数据从 A 流到 B,队列管得很好。

但还有一类问题,队列管不了:几个任务不是"传东西给彼此",而是抢着用同一样东西。比如三个任务都要往同一根 I2C 总线上挂的传感器发命令;两个任务都要往同一个串口打日志;好几处代码都要改同一份全局状态结构体。它们不是流水线,是抢座位——谁都想坐,但一次只能坐一个,抢起来就乱套。

这一节讲 FreeRTOS 给"抢座位"准备的三件套:互斥锁(Mutex)管独占资源、二值信号量(Binary Semaphore)管任务和中断之间的同步、事件组(Event Group)管"等好几个条件凑齐"。前两个是新东西,事件组你其实已经在 WiFi 那篇见过了——那个 WIFI_CONNECTED_BIT,这篇把它彻底讲透。

读这篇前,你需要已经跑通过FreeRTOS 任务队列——会用 xTaskCreate 建多个任务、知道任务靠 vTaskDelay 让出 CPU、理解为什么不能让多个任务直接乱碰同一个全局变量。本篇不接任何线,全靠串口看 ESP_LOGI 打出来的数。


第一步:先亲手制造一次竞态

讲锁之前,得先让你疼一次——亲眼看见不加锁会出什么乱子。下面这段建两个任务,它俩都往同一个全局计数器 g_counter 上加 1,各加 100000 次。按理说最后应该是 200000,对吧?把它放进工程的 main/main.c

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"

static const char *TAG = "sync";
static volatile int g_counter = 0;        // 两个任务都来抢着加它

// 两个任务跑同一份代码:各把 g_counter 加 100000 次
void adder_task(void *arg)
{
    const char *who = (const char *)arg;
    for (int i = 0; i < 100000; i++) {
        g_counter = g_counter + 1;        // 危险动作:读-改-写不是一气呵成的
    }
    ESP_LOGI(TAG, "任务 %s 加完了", who);
    vTaskDelete(NULL);                    // 干完一次性的活,自删退场
}

void app_main(void)
{
    // 两个任务故意钉到同一个核上,让竞态更稳定地复现
    xTaskCreatePinnedToCore(adder_task, "A", 2048, "A", 5, NULL, 0);
    xTaskCreatePinnedToCore(adder_task, "B", 2048, "B", 5, NULL, 0);

    vTaskDelay(pdMS_TO_TICKS(2000));      // 等它俩都加完
    ESP_LOGI(TAG, "最终计数 = %d(本该是 200000)", g_counter);
}

编译、烧录、看日志:

idf.py build flash monitor

你应该看到什么

最后那行打出来的数,几乎肯定不是 200000,而是个比它小、还每次都不一样的数:

I (510) sync: 任务 A 加完了
I (512) sync: 任务 B 加完了
I (2020) sync: 最终计数 = 147823(本该是 200000)

复位重跑几次,147823162041151336……每次都不同。凭空丢了几万次累加。这就是竞态(race condition),是多任务编程里最阴险的一类 bug——它不报错、不崩溃,就是结果悄悄不对。

为什么会丢?关键在 g_counter = g_counter + 1 这行。你以为它是"一个动作",CPU 底下其实拆成三步:① 把 g_counter 的值读进寄存器 → ② 寄存器加 1 → ③ 把寄存器写回 g_counter。问题就出在这三步不是一气呵成的——调度器随时可能在中间切走。设想:

  • 任务 A 读到 g_counter100(第①步),还没来得及写回,时间片到了,切走。
  • 任务 B 上来,也读到 100,加成 101,写回(①②③走完)。现在 g_counter101
  • 切回任务 A,它手里还攥着刚才读的 100,加成 101,写回。g_counter 还是 101

B 那次累加被 A 一脚覆盖了,凭空蒸发。 这种"读-改-写被打断"在十万次循环里发生几万次,累加就丢了几万。volatile 关键字防的是编译器优化掉读写,防不了这种打断——它根本不是一个原子操作。

🚧 避坑

很多人第一反应是"加个 volatile 不就行了"。不行。volatile 只保证每次都真去内存读/写、不被编译器缓存到寄存器里偷懒,但它管不了"读和写之间被切走"。要让"读-改-写"不被打断,得靠下面的互斥锁把这三步圈成一个谁都插不进来的整体。


第二步:用互斥锁把竞态修好

竞态的根源是"读-改-写"中间能被切走。修法很直接:给这段操作上一把锁,谁拿到锁谁才能进去改,改完把锁还回来,别人才进得去。 这把锁就是互斥锁(Mutex,mutual exclusion 的缩写)。被锁保护起来的那段代码,叫临界区(critical section)

改两处:开头建一把锁,加计数的地方先 Take(拿锁)后 Give(还锁)。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"              // 互斥锁/信号量都在这个头文件
#include "esp_log.h"

static const char *TAG = "sync";
static volatile int g_counter = 0;
static SemaphoreHandle_t g_lock;          // 一把互斥锁

void adder_task(void *arg)
{
    const char *who = (const char *)arg;
    for (int i = 0; i < 100000; i++) {
        xSemaphoreTake(g_lock, portMAX_DELAY);   // 拿锁,没拿到就阻塞等
        g_counter = g_counter + 1;               // 临界区:此刻只有我能改
        xSemaphoreGive(g_lock);                  // 还锁,放别人进来
    }
    ESP_LOGI(TAG, "任务 %s 加完了", who);
    vTaskDelete(NULL);
}

void app_main(void)
{
    g_lock = xSemaphoreCreateMutex();            // 建锁,必须在起任务之前
    xTaskCreatePinnedToCore(adder_task, "A", 2048, "A", 5, NULL, 0);
    xTaskCreatePinnedToCore(adder_task, "B", 2048, "B", 5, NULL, 0);

    vTaskDelay(pdMS_TO_TICKS(3000));
    ESP_LOGI(TAG, "最终计数 = %d(本该是 200000)", g_counter);
}

你应该看到什么

这回那个数稳稳地就是 200000,复位重跑多少次都是它:

I (2530) sync: 任务 A 加完了
I (2533) sync: 任务 B 加完了
I (3020) sync: 最终计数 = 200000(本该是 200000)

一个数都不丢了。原因是:xSemaphoreTake 拿到锁之后,到 xSemaphoreGive 还锁之前的这段,任何别的任务想 Take 这把锁都会被挡在外面阻塞等着——也就保证了"读-改-写"三步中间不会被另一个任务插进来改。读、改、写变成了一个不可分割的整体。

把这三句话刻进肌肉:

  • xSemaphoreCreateMutex() 建一把锁,要在起任务之前建好(不然任务跑起来锁还是 NULLTake 直接崩)。
  • xSemaphoreTake(锁, 超时) 进临界区前拿锁。portMAX_DELAY 是"没拿到就一直等"。也可以给个 pdMS_TO_TICKS(100) 当超时,超时没拿到返回 pdFALSE,你得判断返回值、别硬闯。
  • xSemaphoreGive(锁) 出临界区还锁。Take 和 Give 必须成对,临界区里任何一条退出路径(包括提前 return)都得记得还锁,否则锁被一个任务攥着不放,别人全卡死——这就是死锁。
💡 提示

临界区要尽量短——只圈住真正要独占的那几行(这里就是加计数那一行)。别图省事把 ESP_LOGIvTaskDelay 这种耗时的也圈进锁里,那会让别的任务等得更久、白白拖慢系统。锁的原则:保护尽量小的范围,攥锁的时间尽量短。

为什么深一层:优先级继承,防"优先级反转"

互斥锁有个二值信号量没有的特殊本事——优先级继承(priority inheritance),专门防一个叫**优先级反转(priority inversion)**的坑。这事得讲清楚,是 RTOS 里的经典陷阱(NASA 的火星探路者号当年就栽在这上面)。

想象三个任务,优先级高 H、中 M、低 L

  1. 低优先级的 L 先拿到了锁,正在临界区里干活。
  2. 高优先级的 H 醒了,也要这把锁——但锁在 L 手里,H 只能阻塞等 L 还锁。
  3. 偏偏这时中优先级的 M 醒了。M 不需要锁,但它优先级比 L 高,调度器让 M 抢了 L 的 CPU。L 被 M 压着跑不动,自然也还不了锁。

结果:H(最高优先级)被 M(中优先级)间接卡住了——明明 H 比 M 急,却要干等 M 跑完。这就是优先级反转,高优先级任务被低优先级的间接拖死,在实时系统里可能是致命的。

FreeRTOS 的互斥锁自动帮你解这个局:当 H 阻塞在 L 持有的锁上时,调度器临时把 L 的优先级"提"到和 H 一样高,让 L 别被 M 抢走、赶紧跑完临界区还锁;L 还锁后优先级再降回去。这就是优先级继承。这也是"管独占资源就用 Mutex、别用二值信号量"的关键原因——二值信号量没有优先级继承这套机制。


第三步:二值信号量——任务和中断之间的"出事了,去处理"

互斥锁管的是"独占资源"。还有一类需求是同步:一件事发生了(通常在中断里),要"通知"一个任务去处理。这用**二值信号量(Binary Semaphore)**最合适——它比队列轻,因为你只要传"发生了"这个信号,不传具体数据。

典型场景:一个按钮接在 GPIO 上,按下触发中断(ISR)。中断处理函数(ISR)里不能干重活、不能 vTaskDelay、不能打一长串日志——ISR 必须快进快出。所以正确做法是:ISR 里只 Give 一个信号量"踢一脚",真正的处理放在一个专门的任务里,那任务平时 Take 阻塞着睡觉,被踢醒了才起来干活。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "driver/gpio.h"
#include "esp_log.h"

#define BTN GPIO_NUM_0                    // 多数开发板上的 BOOT 按钮就在 GPIO0
static const char *TAG = "sem";
static SemaphoreHandle_t g_btn_sem;

// 中断服务程序:必须极短。只 Give 一下信号量,把活甩给任务
static void IRAM_ATTR btn_isr(void *arg)
{
    BaseType_t hp_task_woken = pdFALSE;
    xSemaphoreGiveFromISR(g_btn_sem, &hp_task_woken);   // ISR 里必须用 FromISR 版本
    if (hp_task_woken) portYIELD_FROM_ISR();            // 若唤醒了更高优先级任务,立刻切过去
}

// 处理任务:平时阻塞睡觉,被按钮踢醒才干活
void btn_task(void *arg)
{
    for (;;) {
        if (xSemaphoreTake(g_btn_sem, portMAX_DELAY) == pdTRUE) {
            ESP_LOGI(TAG, "按钮被按了,干活!");        // 这里才放重活、日志、延时
        }
    }
}

void app_main(void)
{
    g_btn_sem = xSemaphoreCreateBinary();               // 注意:建二值信号量是 Binary 不是 Mutex

    gpio_config_t io = {
        .pin_bit_mask = 1ULL << BTN,
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
        .intr_type = GPIO_INTR_NEGEDGE,                 // 下降沿(按下)触发
    };
    gpio_config(&io);
    gpio_install_isr_service(0);
    gpio_isr_handler_add(BTN, btn_isr, NULL);

    xTaskCreate(btn_task, "btn", 2048, NULL, 10, NULL);
}

你应该看到什么

烧进去,按一下板上的 BOOT 键(GPIO0),串口立刻滚一行:

I (5230) sem: 按钮被按了,干活!
I (6840) sem: 按钮被按了,干活!

不按就一行不打——btn_task 一直阻塞在 xSemaphoreTake 上睡着,不耗 CPU,被 ISR Give 一下才醒来打日志。这就是"ISR 出事件、任务接事件"的标准分工:重活别在中断里干,用信号量把它甩到任务里。

这里有两个只在 ISR 里成立的硬规矩,记死:

  • ISR 里给信号量必须用 xSemaphoreGiveFromISR,不是普通的 xSemaphoreGive。所有能在中断里调的 FreeRTOS API 都带 FromISR 后缀——普通版本里有可能阻塞的逻辑,在中断上下文里调会直接让系统崩。
  • portYIELD_FROM_ISR() 那句的意思是:如果这次 Give 唤醒的任务(btn_task 优先级 10)比当前被中断打断的任务还高,就让中断返回后立刻切到那个高优先级任务,而不是等到下个时间片——这样事件处理的延迟最小。
📌 说明

真实按钮还有抖动(bounce)问题:机械触点按一下会在几毫秒内弹跳出好几个边沿,可能触发多次中断、日志刷好几行。生产里要在任务侧做软件去抖(被踢醒后 vTaskDelay 个 20ms 再读一次电平确认),或硬件加电容。本篇聚焦信号量机制,去抖先不展开,知道有这回事。


第四步:事件组——等好几个条件凑齐(讲透 WiFi 篇那个 BIT)

前两个工具一次只对付"一件事":一把锁护一个资源、一个信号量等一个事件。但有时你要等的是多个条件的组合——比如"WiFi 连上了 并且 时间同步好了 并且 配置加载完了,才开始上报数据"。一个个信号量串着等很笨。这时用事件组(Event Group)

事件组你可以理解成一排开关(bit),每个 bit 代表一个条件成立没成立。任务可以"点亮"某个 bit(SetBits),也可以"阻塞等某几个 bit 亮起来"(WaitBits)。

你其实已经用过它了。 翻回让 ESP32-S3 连上 WiFi 那篇,里面有这么几行:

#define WIFI_CONNECTED_BIT BIT0
#define WIFI_FAIL_BIT      BIT1
static EventGroupHandle_t s_wifi_event_group;

当时为了聚焦联网主线,只说它是"一组信号灯"。现在把它讲透——这就是个事件组,用了两个 bit:

  • WIFI_CONNECTED_BIT(BIT0):拿到 IP 了,在 IP_EVENT_STA_GOT_IP 回调里 xEventGroupSetBits 点亮。
  • WIFI_FAIL_BIT(BIT1):重连超上限、彻底失败,在断开回调里点亮。

wifi_init_sta() 末尾那行阻塞等待:

xEventGroupWaitBits(s_wifi_event_group,
                    WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,   // ① 等哪些 bit
                    pdFALSE,                              // ② 返回后要不要清掉这些 bit
                    pdFALSE,                              // ③ 是"全亮才返回"还是"任一亮就返回"
                    portMAX_DELAY);                       // ④ 最多等多久

四个参数现在能彻底看懂了:

  1. 等哪些 bitWIFI_CONNECTED_BIT | WIFI_FAIL_BIT,用按位或把要关心的 bit 拼起来。
  2. clearOnExitpdFALSE = 返回后清掉这些 bit,留着让别处也能查"现在连上没"。给 pdTRUE 则返回时自动清零、相当于"消费掉"这个事件。
  3. waitForAllpdFALSE = 任意一个 bit 亮就返回(连上失败,哪个先来都行)。给 pdTRUE 则要所有指定 bit 都亮才返回——这才是"等多个条件凑齐"的用法。
  4. 超时portMAX_DELAY 无限期等;也可给个具体时长,超时没等到就返回当前 bit 状态。

WaitBits 阻塞期间任务是睡着的、不占 CPU,是别的任务/回调 SetBits 点亮 bit 才把它唤醒——和二值信号量一样的省 CPU 模型,区别是它能一次等一组条件。

举个"等多个条件全凑齐"的例子,把 waitForAll 设成 pdTRUE

#define BIT_WIFI  BIT0
#define BIT_TIME  BIT1
#define BIT_CFG   BIT2
// ……三个不同任务各自准备好后 xEventGroupSetBits(eg, BIT_WIFI / BIT_TIME / BIT_CFG)

// 主逻辑:等这三件事全干完才往下走
xEventGroupWaitBits(eg, BIT_WIFI | BIT_TIME | BIT_CFG,
                    pdFALSE, pdTRUE, portMAX_DELAY);   // pdTRUE = 三个 bit 全亮才返回
ESP_LOGI(TAG, "网络、时间、配置都就绪了,开始上报");

这就是事件组的杀手锏:多个独立任务各自报到,主逻辑等它们全报齐再统一往下。 用三个信号量串着等远不如这一行清楚。


故障排查:同步出问题,按这个查

同步类 bug 的特点是"诡异、间歇、不报错",照这张表认:

现象 / 日志 最可能的原因 怎么办
全局变量/计数器结果时对时不对、每次跑不一样 竞态:多任务读-改-写同一资源没加锁 把共享资源的读写圈进 xSemaphoreTake/Give 临界区;光加 volatile 没用
系统某一刻全部任务卡死、不再有任何日志 死锁:某任务 Take 了锁但忘了 Give(或临界区里提前 return 漏还锁) 检查每条退出路径都成对 Give;缩短临界区;Take 用带超时的版本别用 portMAX_DELAY 硬等
高优先级任务莫名其妙被拖慢、响应变卡 优先级反转:用了二值信号量当资源锁(没有优先级继承) 管"独占资源"一律用 xSemaphoreCreateMutex,别拿二值信号量当锁使
在中断里给信号量后系统直接崩/重启(Guru Meditation ISR 里用了FromISR 版本的 API 中断里所有 FreeRTOS 调用都换成 ...FromISR 版本,并配 portYIELD_FROM_ISR
xSemaphoreTake 一调用就崩 锁/信号量没建就用(句柄还是 NULL 确认 xSemaphoreCreateXxx() 在起任何用到它的任务之前就执行了
按钮按一下日志刷好几行 机械按键抖动,一次按下触发多次中断 任务侧软件去抖(醒来后 vTaskDelay(20ms) 再确认电平)或硬件加电容
🚧 避坑

死锁是这三件套里最难查的——它不报错,就是某一刻整个系统"安静地"停了,日志戛然而止。九成是某个任务 Take 了锁却没在所有路径上 Give(尤其临界区里有 if (err) return; 这种提前返回,最容易漏还锁)。养成习惯:TakeGive 写在一起、像括号一样配对,写完 Take 立刻把对应的 Give 也写上,再往中间填临界区代码。


变体:两个进阶玩法

变体一:计数信号量——管"有限个名额"

二值信号量只有"有/无"两个状态。**计数信号量(Counting Semaphore)**能从 N 开始往下减,管"一池子有限资源"。比如你想限制"最多同时 3 个任务访问某外设":

SemaphoreHandle_t pool = xSemaphoreCreateCounting(3, 3);   // 最大3,初始3个名额

// 任务里:
xSemaphoreTake(pool, portMAX_DELAY);   // 占一个名额,计数减1;减到0后续任务就得等
// ... 用外设 ...
xSemaphoreGive(pool);                  // 用完归还名额,计数加1

第四个任务来 Take 时计数已是 0,它就阻塞等,直到前面某个任务 Give 还出名额。这是"限流/连接池"类需求的标准做法。

变体二:事件组里 SetBits 通知一组任务同时动

事件组的 SetBits 是"广播"——一次点亮某个 bit,所有等这个 bit 的任务会被同时唤醒。这跟信号量"Give 一次只唤醒一个"不同。适合做"一声令下、多个任务一起启动":

// 多个任务都阻塞在等 BIT_GO
xEventGroupWaitBits(eg, BIT_GO, pdFALSE, pdTRUE, portMAX_DELAY);
// 某处一声令下,所有等 BIT_GO 的任务齐刷刷醒来
xEventGroupSetBits(eg, BIT_GO);

本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档与实物自校。xSemaphoreTake/GivexEventGroupWaitBits 的参数语义、FromISR 版本的用法随版本/配置可能微调,以官方 FreeRTOS 文档为准。


动手挑战

别只看,动手改:

  1. 回到第一步那段竞态代码,先确认你也能复现"结果不是 200000"。然后只把任务的钉核去掉xTaskCreate 代替 xTaskCreatePinnedToCore),让两个任务分到双核上跑,看丢得是不是更狠——双核真并行时竞态更凶。再把第二步的互斥锁加上,确认双核下也稳稳 200000。
  2. 用事件组做一个"三件事就绪才启动"的小实验:建三个任务,分别 vTaskDelay 1 秒、2 秒、3 秒后各 SetBits 一个 bit;主逻辑 WaitBits 三个 bit 全亮(waitForAll = pdTRUE)才打"全部就绪"。观察它是不是卡到第 3 秒(最慢那个报到)才放行。

卡住了?把你的代码、idf.py monitor 的完整日志和想要的效果一起发给 AI,重点描述"我看到的数对不对、在哪一刻卡住",它定位会准得多。


小结 · 你现在掌握了什么

  • 亲手制造并修复了一次竞态——明白了 g_counter = g_counter + 1 底下是"读-改-写"三步、会被调度器中途切走,volatile 防不住,得用互斥锁把临界区圈成不可分割的整体。
  • 你会用 xSemaphoreCreateMutex + Take/Give 保护独占资源(I2C 总线、串口、全局状态都是这么护),知道临界区要短Take/Give 必须成对,也理解了互斥锁靠优先级继承防优先级反转——这是它比二值信号量更适合当"资源锁"的原因。
  • 你搞懂了二值信号量怎么在 ISR 和任务间做同步:ISR 里 xSemaphoreGiveFromISR 只"踢一脚"、重活甩给阻塞等待的任务,比队列轻;也记住了 ISR 里必须用 FromISR 版本 + portYIELD_FROM_ISR
  • 你把事件组彻底吃透了——回头看懂了 WiFi 篇那个 WIFI_CONNECTED_BIT 在等什么,xEventGroupWaitBits 四个参数(等哪些 bit / 是否清 / 全亮还是任一亮 / 超时)各管什么,以及它"等多个条件凑齐"的杀手锏用法。

这三件套加上上一篇的队列,FreeRTOS 任务间协作的核心工具你就齐了:队列传数据、互斥锁护资源、信号量做同步、事件组等组合条件。 但还差一块——怎么让某件事"过 5 秒后自动做一次"或"每 100 毫秒周期性做一次",又不想为它专门起一个 vTaskDelay 死循环任务白占栈? 下一步学FreeRTOS 软件定时器,用一个回调把这类"定时干活"轻量地挂起来。

想看 L3 这一级还有哪些课、整条进阶路线长什么样,回L3 关卡总览完整路线图

📄 来源 / 自校链接

本文为公开资料整理,非亲测。关键参数与代码请结合实物与下列官方来源验证。

内容有错、看不懂、或想看下一期?告诉我们 →

本文为公开资料的学习整理,非亲测。涉接线/花钱/合规的步骤请结合实物与官方最新资料验证,风险自负。见免责声明