RTOS 进阶(二):队列 / 信号量 / 互斥量的高级误用与死锁预防

上一篇我们把"任务栈溢出、优先级反转、死锁长什么样"这些现象认了个脸熟。
这一篇往下挖一层:为什么你明明"用了信号量",程序还是会偶发数据错乱?
为什么队列明明是线程安全的,加上它以后系统反而变卡了?
到底什么时候用队列、什么时候用互斥量、什么时候该用任务通知?

适用人群:学完 FreeRTOS 基础(会 xQueueCreate / xSemaphoreTake / xTaskCreate),能跑通多任务例程,但一到"偶发 bug"就抓瞎的同学。

读完你能得到

  1. 一张"什么时候用哪个同步原语"的决策表(含"这个别拿来干什么"栏)
  2. 队列的 4 个进阶坑:隐性优先级反转、队列长度怎么算、FromISR 变体、传值 vs 传指针
  3. ISR 上下文的 5 条铁律(含 Cortex-M 中断优先级配置这个"新手必炸"点)
  4. 防死锁的组合拳:固定加锁顺序 + 超时 + 守门员任务 + 任务通知
  5. 6 个可以立刻上手的实验,其中 3 个是故意把程序搞炸,炸给你自己看

没看过前篇也能懂的核心(一句话)

“优先级反转”= 高优先级任务被低优先级任务间接卡住;“死锁”= 两个任务各拿一把锁、互相等对方那把,谁也不动了。这两句记住,本篇就能独立读完。

平台说明:本文代码以 STM32F407 + FreeRTOS V10.x(CubeMX 生成的工程,configTICK_RATE_HZ = 1000 为主,涉及 ESP32(ESP-IDF v5.x)差异的地方我会单独标出来。FreeRTOS 内核 API 在两个平台上基本一致,差异主要在中断优先级配置和 SMP(双核)语义。

代码说明:文中片段以"说清楚问题"为目标,都是可以直接粘到工程里的代码片段而不是完整工程。像 read_sensor()build_frame()dma_rx_len()handle_frame() 这类函数,以及 frame_t / sensor_data_t 这类类型,需要你按自己的硬件补上;s_xxx 开头的静态计数器直接照抄即可。


一、为什么"能跑但偶发诡异"

1.1 我踩的那个坑:跑三小时才错一次

去年我给实验室做一个小玩意儿:STM32F407 上跑 FreeRTOS,一路 I2C 上挂了两个东西——SHT30 温湿度传感器和一块 0.96 寸 OLED。

任务是这么分的:

sensor_task  (优先级 3)  每 500 ms 读一次 SHT30
display_task (优先级 2)  每 200 ms 刷一次 OLED
key_task     (优先级 4)  按键扫描,10 ms 一次,有事就干活

I2C 总线是共享资源,我知道要保护,于是——我用了一个二值信号量

/* 我当时写的代码(有问题,别抄) */
SemaphoreHandle_t i2c_lock;

void app_init(void)
{
    i2c_lock = xSemaphoreCreateBinary();
    xSemaphoreGive(i2c_lock);      /* 二值信号量创建后是"空"的,得先 give 一次才能用 */
}

void sensor_task(void *arg)
{
    for (;;) {
        xSemaphoreTake(i2c_lock, portMAX_DELAY);
        sht30_read(&temp, &humi);        /* I2C 读,大约 15 ms */
        xSemaphoreGive(i2c_lock);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void display_task(void *arg)
{
    for (;;) {
        xSemaphoreTake(i2c_lock, portMAX_DELAY);
        oled_refresh(temp, humi);        /* I2C 写整屏,大约 30 ms */
        xSemaphoreGive(i2c_lock);
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

这段代码能跑。演示的时候也没问题。但让它连着跑几个小时,就会出现两种诡异现象:

  • OLED 偶尔刷出一屏雪花,下一次刷新又自己好了
  • 串口打印的温度偶尔跳成 -45.0(SHT30 转换公式里的最小值,说明读回来的原始数据是全 0 或者被截断了)

1.2 三个坑叠在一起

后来把逻辑分析仪挂到 SCL/SDA 上抓,才发现是三件事叠加:

坑 1:二值信号量没有优先级继承。
display_task(优先级 2)拿着"锁"在刷屏,要 30 ms。这中间 key_task(优先级 4)醒了,直接抢占了它。sensor_task(优先级 3)也醒了,想读传感器,被"锁"挡在外面。结果是:优先级 3 的任务在等优先级 2 的任务,而优先级 2 的任务被优先级 4 的任务压着跑不动。这就是典型的优先级反转,而二值信号量对此毫无办法——它压根不知道"谁是持有者"。

坑 2:信号量没有所有权,谁都能 give。
我在某个异常恢复分支里写了一句"保险起见再 give 一次"。二值信号量的 xSemaphoreGive() 不检查调用者是不是持有者——如果这一下恰好发生在别人正"持锁"的时候(计数为 0),计数就被拉回 1,等于凭空造出第二把钥匙。于是两个任务真的同时在操作 I2C,波形上看就是一帧传输被另一帧的 START 打断。雪花屏和 -45.0 就是这么来的。

(顺带一提:二值信号量计数上限是 1,所以在"锁空闲"的时候多 give 一次会返回失败——恰恰是没人注意的那个失败返回值,掩盖了这段代码有多危险。换成 mutex 就不会有这个问题:不是持有者根本 give 不了。)

坑 3:portMAX_DELAY 死等,出了事没人知道。
一旦真的锁死,两个任务一起卡在 xSemaphoreTake() 里,看门狗(如果没喂)会复位,日志里什么都留不下。我最开始查了两个晚上,就是因为现场什么信息都没有。

1.3 根源:原语选错,不是代码写错

把上面三条抽象一下,你会发现它们指向同一句话:

同步原语不是"随便挑一个能用的",每一种都带着自己的语义。选错了,编译器不会报错,链接器不会报错,跑起来大部分时候也不会报错——它只在时序刚好撞上的那一瞬间出错。

这就是"能跑但偶发诡异"的来源。下面这张表是我后来整理的"症状 → 嫌疑人"对照表,你以后遇到玄学问题可以对着排查:

症状头号嫌疑人往哪查
共享外设偶发数据错乱二值信号量当锁用 / 忘了加锁所有访问该外设的地方是否都加了同一把 mutex
高优先级任务偶尔响应变慢几十毫秒优先级反转(锁 or 队列阻塞)持锁期间干了慢活?中优先级任务在插队?
数据偶尔丢几帧队列长度不够 / FromISR 返回值没查xQueueSendFromISR() 的返回值有没有接
中断响应正常但任务处理延迟 1 个 tickpxHigherPriorityTaskWoken 没处理ISR 末尾有没有 portYIELD_FROM_ISR()
一进某个函数就死机/断言ISR 里调了非 FromISR API中断服务函数里的每一个 FreeRTOS 调用
系统跑着跑着全部任务不动了死锁 / 死等把每个任务的状态打出来,看是不是全 Blocked

接下来按"队列 → 选型 → ISR → 防死锁"的顺序,把这些坑一个个拆开。


二、队列的高级坑:它线程安全,但不代表你安全

队列是 FreeRTOS 里最好用的东西,官方文档管它叫"任务间通信的主要形式",而且明确说明它是 thread safe FIFO——内部已经做好了保护,你不需要再给它加锁。

但"队列本身安全"和"你用队列的方式安全"是两码事。下面 4 个坑,每一个我都亲手踩过。

2.1 坑一:阻塞在队列上,会产生"隐性优先级反转"

先说一个容易被忽略的事实:

互斥量有"持有者",所以内核能做优先级继承;队列没有"持有者",所以内核什么也做不了。

想象这么一条链路:

低优先级 producer_task (prio 1) ──xQueueSend──> [ 队列 ] ──xQueueReceive──> 高优先级 consumer_task (prio 5)
                                                                              ^
                                             中优先级 other_task (prio 3) ────┘ 随便抢占 producer

时间轴展开:

时间 →
prio 5  consumer : ████等队列(空)████████████████████░░░开始干活
prio 3  other    :        ██████████运行███████████
prio 1  producer : ███████            ░░░被抢占░░░  ███发数据

  1. consumer(最高优先级) 阻塞在空队列上等数据
  2. producer(最低) 正在准备数据
  3. other(中等) 就绪,抢占 producer
  4. producer 要等 other 跑完才能发数据 → consumer 一直等
  结果:最高优先级任务被中优先级任务间接拖住了

这个现象和上一篇讲的"优先级反转"长得一模一样,但内核帮不了你:队列不知道"consumer 在等 producer",它只知道"有个任务阻塞在这个队列上"。优先级继承机制在这里根本无从谈起——你继承给谁?队列又不属于任何人。

⚠️ 这里有个官方细节值得记住:如果多个任务同时阻塞在同一个队列上,被优先解除阻塞的是优先级最高的那个任务(“If more than one task block on the same queue then the task with the highest priority will be the task that is unblocked first”)。所以"谁先来谁先得"在 FreeRTOS 队列上不成立,是"谁优先级高谁先得"。

怎么办? 三个方向,按推荐顺序:

  1. 别让关键链路上出现"低优先级生产者 → 高优先级消费者"。要么把 producer 的优先级提上去(至少不低于链路上任何"闲杂任务"),要么承认这条链路不是硬实时的。
  2. 把生产环节挪到中断/DMA 里。数据在 ISR 里直接 xQueueSendFromISR() 投进来,就没有"低优先级任务准备数据"这个环节了。
  3. 消费者带超时,超时后有降级动作(用上一次的数据、报个警),而不是无限期地等。

2.2 坑二:队列长度到底设多少?

新手设队列长度基本靠感觉,常见两个极端:

  • 设 1 或 2 → 一有突发就满,xQueueSend() 要么阻塞(拖慢生产者)要么丢数据
  • 设 100 → RAM 被吃掉一大块,而且掩盖了"消费者太慢"这个真问题

我用的估算方法(不复杂,就一个乘法):

队列长度 ≥ 生产速率 × 消费者最坏响应时间 + 突发余量

举个具体的:
  生产:UART 每 10 ms 收一帧          → 100 帧/秒
  消费:解析任务最坏被别的任务压 50 ms → 0.05 s
  基本需求 = 100 × 0.05 = 5 帧
  留 1 倍余量,取 8。再往上加意义不大,
  如果 8 都不够,说明消费者是真的太慢了,加长度只是把爆炸时间往后推。

算完不算完,要在跑的时候量。FreeRTOS 给了现成的 API,把队列的"历史最高水位"记下来:

#include "FreeRTOS.h"
#include "queue.h"

static QueueHandle_t s_frame_q;
static UBaseType_t   s_q_peak;      /* 历史最高水位 */
static uint32_t      s_q_drop;      /* 因队列满而丢弃的帧数 */

#define FRAME_Q_LEN   8

/* 带水位统计的发送封装:给任务上下文用(不是 ISR) */
BaseType_t frame_q_send(const frame_t *f, TickType_t timeout)
{
    BaseType_t ok = xQueueSend(s_frame_q, f, timeout);

    if (ok != pdPASS) {             /* 官方返回值只有 pdPASS / errQUEUE_FULL */
        s_q_drop++;                 /* 丢了就记一笔,别装作没发生 */
        return ok;
    }

    UBaseType_t used = uxQueueMessagesWaiting(s_frame_q);
    if (used > s_q_peak) {
        s_q_peak = used;            /* 记录历史峰值 */
    }
    return pdPASS;
}

/* 调试任务里每 5 秒打一次,跑一整天再看这两个数 */
void queue_stat_print(void)
{
    printf("[Q] peak=%u/%u drop=%lu\r\n",
           (unsigned)s_q_peak, (unsigned)FRAME_Q_LEN, (unsigned long)s_q_drop);
}

跑一天以后看 peak

peak 值结论动作
长期 1~2队列开大了缩小,把 RAM 还给栈
稳定在 5~6(长度 8)刚刚好,留了余量不动
经常顶到 8,drop 在涨消费者跟不上先查消费者为什么慢,别急着加长度

💡 顺手推荐一个调试技巧:vQueueAddToRegistry(s_frame_q, "frame_q") 给队列起个名字(需要 configQUEUE_REGISTRY_SIZE > 0),在 Ozone / STM32CubeIDE 的 FreeRTOS 视图里就能看到这个队列的名字和当前长度,比对着一堆句柄地址猜要舒服得多。

还有一个"只要最新值"的场景:比如 ADC 采样,旧值没有任何意义。这时候别用长度 8 的队列,用长度 1 的队列 + xQueueOverwrite()

/* 只保留最新值:队列长度必须是 1 */
QueueHandle_t latest_q = xQueueCreate(1, sizeof(uint32_t));

uint32_t v = adc_read();
xQueueOverwrite(latest_q, &v);   /* 满了也写,直接覆盖旧值,永远返回 pdPASS */

⚠️ xQueueOverwrite() 只能用在长度为 1 的队列上。官方文档原话是强烈建议不要用在能装多个元素的队列上,而且"如果定义了 configASSERT() 会触发断言"。ISR 里对应的是 xQueueOverwriteFromISR()

2.3 坑三:ISR 里必须用 xQueueSendFromISR(),而且要处理那个"很长的参数"

官方文档对 xQueueSend() 写得清清楚楚:

“This function must not be called from an interrupt service routine. See xQueueSendFromISR() for an alternative which may be used in an ISR.”

而且在队列总览页还补了一刀:“Note that interrupts must NOT use API functions that do not end in ‘FromISR’.”

为什么? 两个硬原因:

  1. 普通版本会阻塞xQueueSend(q, &d, pdMS_TO_TICKS(10)) 里的"等 10 ms",实现方式是把当前任务挂到事件列表上然后触发调度。中断里没有"当前任务"这个概念可以挂——你要把谁挂起来?
  2. 普通版本用的临界区实现和中断里不一样。Cortex-M 端口的 vPortEnterCritical() 里带了一条 configASSERT(),专门用 IPSR/VECTACTIVE 判断"我现在是不是在中断上下文里",是的话直接断言停机。就算你没定义 configASSERT()(发布版常见),退出临界区时对 BASEPRI 的恢复操作也会把中断屏蔽状态搞乱——后果是随机的,可能几小时后才炸。

正确写法长这样(STM32 的 UART 空闲中断,收一帧丢给解析任务):

/* 以 STM32F407 + HAL 为例,huart1 由 CubeMX 生成 */
#include "FreeRTOS.h"
#include "queue.h"

/* s_frame_q 就是 2.2 节那个队列(假设中断和队列定义在同一个 .c 文件里,
   元素类型 frame_t,长度 8) */
static volatile uint32_t s_isr_drop;

void USART1_IRQHandler(void)
{
    /* 铁律 1:这个变量必须初始化成 pdFALSE */
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);

        frame_t f;
        uint16_t n = dma_rx_len();            /* 需自行实现:读 DMA 剩余计数算长度 */
        if (n > sizeof(f.buf)) {
            n = sizeof(f.buf);                /* 永远先夹一下上限,别信外部长度 */
        }
        f.len = n;
        memcpy(f.buf, g_dma_rx_buf, n);       /* 拷贝,别把 DMA buffer 的指针发出去 */

        /* 铁律 2:用 FromISR 变体;它不会阻塞,满了立刻返回 errQUEUE_FULL */
        if (xQueueSendFromISR(s_frame_q, &f, &xHigherPriorityTaskWoken) != pdPASS) {
            s_isr_drop++;                     /* 铁律 3:返回值必须查,丢帧要能统计到 */
        }
    }

    /* 铁律 4:唤醒了更高优先级任务就立刻切过去,不要等下一个 tick */
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

pxHigherPriorityTaskWoken 到底是干什么的?

官方定义:如果这次发送让某个任务解除了阻塞,并且那个任务的优先级比"被中断打断的那个任务"更高,函数就把 *pxHigherPriorityTaskWoken 置成 pdTRUE,意思是"你退出中断前应该请求一次上下文切换"。

不处理它会怎样?(假设 tick = 1 kHz)

  中断发生 ──> 数据入队 ──> 高优先级解析任务变成"就绪"
                                    │
                            但没人请求切换
                                    ▼
  中断返回到"原来那个低优先级任务"继续跑 ...
                                    │
                          最多等到下一个 tick 中断(≤1 ms)
                                    ▼
                            调度器才把解析任务换上来

  → 中断响应很快(几微秒),任务处理却慢了将近 1 ms,而且是随机抖动

对按键这种场景无所谓,对电机换相、编码器采样、通信协议超时判定这类场景,1 ms 的随机抖动足够让你怀疑人生。

📌 可以传 NULL 吗? 可以。官方明确写了"From FreeRTOS V7.3.0 pxHigherPriorityTaskWoken is an optional parameter and can be set to NULL"。但传 NULL 的代价就是上面那张图——切换延后到下一个 tick。除非你非常确定这条路径不在乎延迟,否则老老实实传变量。

📌 portYIELD_FROM_ISR() 这个宏名是端口相关的,官方示例里也写了可能叫 portEND_SWITCHING_ISR()taskYIELD_FROM_ISR()。STM32 的 ARM_CM4F 端口和 ESP-IDF 都提供 portYIELD_FROM_ISR(),直接用即可。

2.4 坑四:结构体传值 vs 传指针,指针的三种死法

队列是按值拷贝的(“Messages are sent through queues by copy”)。创建队列时你告诉它每个元素多大,之后每次 send 都会从你给的地址拷这么多字节进去。

这带来一个选择题:一个 200 字节的结构体,是整个传进去,还是传一个 4 字节的指针?

先说传值的代价:这个拷贝是在内核的临界区里完成的(内核要保证队列内部结构一致)。元素越大,临界区越长,中断被屏蔽的时间越长。200 字节在 168 MHz 的 F407 上大概几微秒,还能接受;2 KB 的图像缓冲就不行了。

再说传指针的三种死法,这三种我全踩过:

/* ❌ 死法 1:传栈上局部变量的地址 */
void producer_task(void *arg)
{
    for (;;) {
        frame_t f;                    /* 局部变量,在任务栈上 */
        build_frame(&f);
        frame_t *p = &f;
        xQueueSend(ptr_q, &p, 0);     /* 把栈地址发出去了 */
        /* 函数继续往下跑,f 所在的栈空间随时会被下一次函数调用覆盖
           消费者拿到指针时,指向的内容可能已经变成别的东西了 */
    }
}

/* ❌ 死法 2:所有人共用一个全局 buffer */
static frame_t g_buf;                 /* 只有一份 */
void producer_task(void *arg)
{
    for (;;) {
        build_frame(&g_buf);
        frame_t *p = &g_buf;
        xQueueSend(ptr_q, &p, 0);
        /* 生产者第 2 次写 g_buf 时,消费者可能还没读完第 1 帧
           → 读到"半新半旧"的数据,也就是数据撕裂 */
    }
}

/* ❌ 死法 3:谁 free 说不清 */
frame_t *p = pvPortMalloc(sizeof(frame_t));
if (xQueueSend(ptr_q, &p, 0) != pdPASS) {
    /* 这里如果忘了 vPortFree(p),就是每满一次队列泄漏一块内存;
       消费者那边如果也 free 一次,就是 double free —— 堆直接坏掉 */
}

正确姿势:静态内存池 + 明确的所有权移交约定。

/* ✅ 传指针的安全写法:定长内存池,所有权跟着指针走 */
#define POOL_N   4
static frame_t     s_pool[POOL_N];       /* 静态分配,不用堆,没有碎片 */
static QueueHandle_t s_free_q;           /* 空闲块队列:装 frame_t* */
static QueueHandle_t s_used_q;           /* 已填充队列:装 frame_t* */

void pool_init(void)
{
    s_free_q = xQueueCreate(POOL_N, sizeof(frame_t *));
    s_used_q = xQueueCreate(POOL_N, sizeof(frame_t *));
    configASSERT(s_free_q != NULL && s_used_q != NULL);

    for (int i = 0; i < POOL_N; i++) {
        frame_t *p = &s_pool[i];
        xQueueSend(s_free_q, &p, 0);     /* 开机时所有块都空闲 */
    }
}

void producer_task(void *arg)
{
    frame_t *p;
    for (;;) {
        /* 1. 先申请一个空闲块(拿不到就等,等不到就丢这一帧) */
        if (xQueueReceive(s_free_q, &p, pdMS_TO_TICKS(20)) != pdTRUE) {
            s_q_drop++;
            continue;
        }
        /* 2. 填数据。此刻这块内存"归我",别人不会碰 */
        build_frame(p);
        /* 3. 移交所有权:发出去之后我就不许再碰 p 了 */
        if (xQueueSend(s_used_q, &p, 0) != pdPASS) {
            xQueueSend(s_free_q, &p, 0); /* 发失败要还回去,否则块就漏了 */
        }
    }
}

void consumer_task(void *arg)
{
    frame_t *p;
    for (;;) {
        if (xQueueReceive(s_used_q, &p, portMAX_DELAY) == pdTRUE) {
            handle_frame(p);             /* 处理期间这块归我 */
            xQueueSend(s_free_q, &p, 0); /* 用完还池,所有权交回 */
        }
    }
}

这套"两个队列 + 一个静态池"的写法,把"谁拥有这块内存"变成了一条可以用代码检查的规则:指针在 free_q 里 = 没人用;在 used_q 里 = 归消费者。不需要 malloc/free,也就没有碎片和 double free。

⚠️ 结构体传值还要留意对齐(这一点和 C 语言专栏里讲的 #pragma pack 是同一个问题):队列元素大小是 sizeof(your_struct),编译器插的填充字节也会被一起拷贝,这没问题;有问题的是你为了省空间给结构体加了 __attribute__((packed)),然后在消费端对成员取地址再强转成 uint32_t* 解引用——Cortex-M0/M0+ 不支持非对齐访问,当场 HardFault;M3/M4 虽然容忍,但性能会掉。packed 结构体里的成员,老老实实用 memcpy 取出来。


三、六选一:什么时候用哪个同步原语

3.1 一张决策表(建议截图存手机)

原语本质ISR 里能用吗有优先级继承吗该用它的场景别拿它干这个
队列 Queue按值拷贝的 FIFO✅ 用 ...FromISR❌ 没有任务间传数据、ISR 投递事件别拿它当锁(想加锁就用 mutex)
二值信号量 Binary Sem0/1 的"有没有"xSemaphoreGiveFromISR❌ 没有ISR 通知任务干活别拿它保护共享资源
计数信号量 Counting Sem计数器✅ 同上❌ 没有资源池计数、事件可能堆积别拿它当锁
互斥量 Mutex带所有权的锁绝对不行✅ 有保护共享资源(I2C/SPI/全局表)别在 ISR 用,别用来做 ISR→任务同步
递归互斥量 Recursive Mutex可重入的锁绝对不行✅ 有同一任务里锁会嵌套的场景别用它掩盖糟糕的分层设计
事件组 Event Group一组标志位(24 位)⚠️ 有前提,见 3.5❌ 没有等"多个条件同时/任一满足"别用它传数据
任务通知 Task Notification任务自带的 32 位值...FromISR❌ 没有一对一通知,最省 RAM 最快别用于一对多广播

用文字版决策树再过一遍(这是我自己写代码时脑子里跑的流程):

我要解决的问题是……

1) 要把"数据"从 A 搬到 B?
   ├─ 数据小(≤ 几十字节)、要排队缓冲 ──────────> 队列(传值)
   ├─ 数据大 / 变长 ──────────────────────────> 队列传指针 + 静态内存池(见 2.4)
   └─ 只关心最新一个值 ────────────────────────> 长度 1 的队列 + xQueueOverwrite
                                                  或 任务通知的 eSetValueWithOverwrite

2) 只要"通知一下",不带数据(或只带 32 位)?
   ├─ 接收方只有一个任务 ──────────────────────> 任务通知(首选,最快最省)
   ├─ 接收方可能有多个 / 要广播 ────────────────> 事件组
   └─ 事件会堆积、要一次一次消化 ────────────────> 计数信号量 或 任务通知(eIncrement)

3) 要保护"同一时间只能一个人用"的东西?
   ├─ 一层锁,不嵌套 ─────────────────────────> 互斥量 Mutex
   ├─ 同一个任务会重复进锁(函数嵌套调用) ───────> 递归互斥量(但先想想能不能重构)
   └─ 中断里也要访问这个资源 ────────────────────> 不能用 mutex!
                                                  改成"关中断的极短临界区"或"守门员任务"

4) 要等"好几个条件都满足"? ────────────────────> 事件组(WaitBits 的 AND 模式)

3.2 重点中的重点:二值信号量当互斥量用,为什么必炸

这是本篇的头号主角,也是我 1.1 节栽的跟头。把区别摊开看:

对比项二值信号量互斥量
有"持有者"概念吗❌ 没有,谁都能 give✅ 有,只有拿锁的任务能还锁
优先级继承❌ 无✅ 有
创建后的初始状态空的(要先 give 一次才能 take)可用(直接就能 take)
能在 ISR 里 give 吗✅ 能❌ 不能
设计意图任务/中断之间的同步资源的互斥访问

官方文档写得很直白:

“Mutexes are binary semaphores that include a priority inheritance mechanism.”
“Mutexes should not be used from an interrupt because: they include a priority inheritance mechanism which only makes sense if the mutex is given and taken from a task, not an interrupt; an interrupt cannot block to wait for a resource that is guarded by a mutex to become available.”

优先级继承到底干了什么? 用我 1.1 节那三个任务画个对比图:

【用二值信号量】没有继承,反转真实发生
prio 4 key     :            ███████运行 20 ms███████
prio 3 sensor  :      ███等锁 ← 一直等 → ███████████████░░拿到锁
prio 2 display : ██持锁刷屏░░░░被 key 抢占░░░░░░░░░░░████刷完还锁
                 └───────── sensor 实际等了 20+ ms ─────────┘

【用互斥量】display 临时"继承" sensor 的优先级 3
prio 4 key     :                        ████运行████
prio 3 sensor  :      ███等锁███████░░拿到锁
prio 2 display : ██持锁(临时升到 3)刷完████还锁+降回 2
                 └── sensor 只等了 display 剩余的刷屏时间 ──┘
                 (key 优先级 4 仍然比继承后的 3 高,它照样能抢占,
                   继承挡住的是"优先级介于两者之间"的任务)

看懂这张图,你就明白继承不是"让高优先级任务不等",而是"让它等的时间尽可能短"。官方原话很硬核,值得抄在笔记本上:

“Priority inheritance does not cure priority inversion! It just minimises its effect in some situations. Hard real time applications should be designed such that priority inversion does not happen in the first place.”

还有几条 FreeRTOS"简化版继承"的实际行为,面试被追问时能答上来就很加分:

  • 一个任务持有多把 mutex 时,它会一直保持在最高的那个继承优先级,直到所有 mutex 都释放完,与释放顺序无关;
  • 即使等锁的高优先级任务已经超时走人了,持锁任务也不会立刻降回去,同样要等它把手里的锁全还完。

所以结论只有一句:保护共享资源,用 xSemaphoreCreateMutex(),不要用 xSemaphoreCreateBinary()。把 1.1 节那段代码改对,只需要动两行:

/* ✅ 正确:用互斥量保护 I2C 总线 */
SemaphoreHandle_t i2c_lock;

void app_init(void)
{
    i2c_lock = xSemaphoreCreateMutex();   /* 创建出来就是"可用"的,不用先 give */
    configASSERT(i2c_lock != NULL);       /* 堆不够会返回 NULL,必须查 */
}

void sensor_task(void *arg)
{
    for (;;) {
        /* 不用 portMAX_DELAY,给个上限;拿不到锁说明系统出问题了,要能看见 */
        if (xSemaphoreTake(i2c_lock, pdMS_TO_TICKS(100)) == pdTRUE) {
            sht30_read(&temp, &humi);
            xSemaphoreGive(i2c_lock);     /* 谁 take 谁 give,别在其他任务里 give */
        } else {
            s_i2c_lock_timeout++;         /* 记一笔,调试时就有线索了 */
        }
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

⚠️ 还有两条 mutex 的隐性规则:① 持锁期间不要 vTaskDelay(),也不要阻塞在另一个队列/信号量上——你把锁按在手里睡觉,等锁的人陪你一起睡。② 持锁的任务不能被删除vTaskDelete),锁会永远还不回来。

3.3 递归互斥量:什么时候真的需要它

先看一个非常常见的自锁死场景。你封装了一个带锁的日志函数:

/* ❌ 用普通 mutex,这段代码会把自己锁死 */
static SemaphoreHandle_t uart_lock;      /* xSemaphoreCreateMutex() 创建的 */

void uart_write(const char *s)
{
    xSemaphoreTake(uart_lock, portMAX_DELAY);
    HAL_UART_Transmit(&huart1, (uint8_t *)s, strlen(s), 100);
    xSemaphoreGive(uart_lock);
}

void log_printf(const char *fmt, ...)
{
    xSemaphoreTake(uart_lock, portMAX_DELAY);   /* 第 1 次 take,成功 */
    char buf[128];
    /* ...vsnprintf 格式化... */
    uart_write(buf);                            /* 里面又 take 了一次 → 卡死在这里 */
    xSemaphoreGive(uart_lock);
}

官方对非递归 mutex 的定义是:

“A non-recursive mutex can only be ‘taken’ by a task once. Any attempt by a task to take a non-recursive mutex that it already holds will fail.”

"fail"具体表现取决于你给的超时:写 portMAX_DELAY 就是永久卡死(自死锁),写超时就是等满超时后返回 pdFALSE。注意它不会给你任何"你重复加锁了"的提示,这也是它难查的原因。

递归互斥量就是为这种场景准备的:

/* ✅ 递归互斥量:同一个任务可以重复 take,但要 give 相同次数 */
/* FreeRTOSConfig.h 里必须有:#define configUSE_RECURSIVE_MUTEXES 1 */
#include "semphr.h"

static SemaphoreHandle_t uart_lock;

void uart_lock_init(void)
{
    uart_lock = xSemaphoreCreateRecursiveMutex();
    configASSERT(uart_lock != NULL);
}

void uart_write(const char *s)
{
    if (xSemaphoreTakeRecursive(uart_lock, pdMS_TO_TICKS(100)) != pdTRUE) {
        return;                                  /* 拿不到就放弃这条日志,不要死等 */
    }
    HAL_UART_Transmit(&huart1, (uint8_t *)s, strlen(s), 100);
    xSemaphoreGiveRecursive(uart_lock);          /* 配套的 Give 也是 Recursive 版本 */
}

void log_printf_simple(const char *s1, const char *s2)
{
    if (xSemaphoreTakeRecursive(uart_lock, pdMS_TO_TICKS(100)) != pdTRUE) {
        return;
    }
    uart_write(s1);                              /* 内层再 take 一次:OK,计数变 2 */
    uart_write(s2);                              /* 再进再出,计数回到 1 */
    xSemaphoreGiveRecursive(uart_lock);          /* 计数归 0,锁才真正释放 */
}

四条必须记住的规则(都来自官方文档):

  1. 必须 configUSE_RECURSIVE_MUTEXES = 1,否则这几个 API 根本不存在(编译报未定义);
  2. 必须配套用 xSemaphoreTakeRecursive() / xSemaphoreGiveRecursive(),官方原文"xSemaphoreTake() and xSemaphoreGive() must not be used";
  3. take 了几次就要 give 几次——“the recursive mutex will only be returned after the holding task has ‘given’ the mutex the same number of times it ‘took’ the mutex”;
  4. 递归互斥量同样不能在 ISR 里用(“Recursive mutexes cannot be used in interrupt service routines”),但它和普通 mutex 一样有优先级继承(“Like non-recursive mutexes, recursive mutexes implement a priority inheritance algorithm”)。

💡 我的建议:递归互斥量是灭火器,不是装修方案。 需要它,通常说明你的模块分层没设计好。更干净的做法是约定"带锁的函数只在最外层":

公开层(加锁)      uart_write_locked()   log_printf_locked()
                          │                     │
                          ▼                     ▼
内部层(不加锁)      uart_write_raw()  ← 只被已经持锁的代码调用,函数名带 _raw/_unlocked

这样只需要普通 mutex,也不用担心 give 次数对不上。递归深度失衡(take 3 次 give 2 次)导致的锁泄漏,比自死锁更难查。

3.4 计数信号量:别忘了它其实是"资源池计数器"

计数信号量最容易被当成"能累计的二值信号量",其实它更经典的用法是限制并发数

/* 场景:同时最多允许 2 个任务发起 HTTP 请求(内存只够 2 个 TLS 上下文) */
SemaphoreHandle_t http_slots = xSemaphoreCreateCounting(2, 2);  /* 上限 2,初值 2 */

void some_task(void *arg)
{
    if (xSemaphoreTake(http_slots, pdMS_TO_TICKS(3000)) == pdTRUE) {
        do_http_request();                /* 最多两个任务能同时进到这里 */
        xSemaphoreGive(http_slots);
    } else {
        /* 3 秒都没抢到槽位,降级处理:这次不上报 */
    }
}

注意 xSemaphoreCreateCounting(uxMaxCount, uxInitialCount) 的第二个参数:当"资源池"用时初值给满(2, 2),当"事件计数"用时初值给 0(如 (10, 0))。这两个用法初值刚好相反,写反了要么一开始就拿不到,要么凭空多出资源。

3.5 事件组:FromISR 版本有一个大多数人不知道的前提

事件组的 xEventGroupSetBits() 在任务里用没什么坑,但 ISR 版本非常特别:

void EXTI0_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;
    BaseType_t res;

    __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);

    res = xEventGroupSetBitsFromISR(sys_events, BIT_KEY_PRESSED, &woken);
    if (res != pdFAIL) {          /* ← 这个返回值一定要查! */
        portYIELD_FROM_ISR(woken);
    }
    /* 返回 pdFAIL 说明"定时器服务队列满了",这次置位丢了 */
}

官方解释了为什么它这么怪:给事件组置位时,可能有不确定个数的任务在等这些位,这是个非确定性操作,而 FreeRTOS 不允许在中断或临界区里做非确定性操作。所以 xEventGroupSetBitsFromISR() 实际上是发一条消息给 RTOS 守护任务(也就是定时器服务任务),让它代劳

由此带来三个连锁后果:

后果说明
需要配置configUSE_TIMERSINCLUDE_xTimerPendFunctionCall 都必须为 1,否则这个函数不存在
可能失败定时器服务队列满了就返回 pdFAIL,这次置位直接丢失,必须查返回值
时序不确定置位真正生效的时刻取决于守护任务什么时候被调度;要想"立刻生效",configTIMER_TASK_PRIORITY 必须高于所有用这个事件组的应用任务

🔎 所以:ISR 里想通知任务,首选任务通知或队列,事件组是最后的选择。 顺带一提,事件组可用的位数是 24 位(configUSE_16_BIT_TICKS = 0 时;设成 1 就只有 8 位。FreeRTOS V11 起这个宏改名叫 configTICK_TYPE_WIDTH_IN_BITS)。

3.6 任务通知:最快的那个,但有 3 条使用限制

任务通知(direct to task notification)是 FreeRTOS V8.2.0 起提供的机制:每个任务自带一组"通知",每个通知有一个状态(pending / not pending)和一个 32 位值。不用创建任何对象,直接往任务身上发。

官方给的性能数字是:

“Unblocking an RTOS task with a direct notification is 45% faster† and uses less RAM than unblocking a task using an intermediary object such as a binary semaphore.”
†脚注:这个 45% 是拿 V8.1.2 的二值信号量实现、GCC -O2、且未定义 configASSERT() 测出来的;如果对比 V8.2.0 之后改进过的二值信号量实现,提升是 35%

写起来是这样(和 2.3 节那段 ISR 对比着看):

/* ISR 侧:不需要任何信号量对象,直接通知任务 */
TaskHandle_t g_parse_task;      /* xTaskCreate 时用第 6 个参数取回来的句柄 */

void USART1_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;

    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        vTaskNotifyGiveFromISR(g_parse_task, &woken);   /* 相当于 give 一个二值信号量 */
    }
    portYIELD_FROM_ISR(woken);
}

/* 任务侧 */
void parse_task(void *arg)
{
    for (;;) {
        /* 参数 1 = pdTRUE:取走后把通知值清零 → 行为等价于二值信号量
           (给 pdFALSE 则是"减 1",行为等价于计数信号量) */
        if (ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1000)) > 0) {
            drain_uart_frames();     /* 把外设里攒下的帧一次性处理干净 */
        } else {
            /* 1 秒没收到通知:可能是外设挂了,这里做健康检查/重init
               官方例程里也提醒:真实项目不要 portMAX_DELAY 死等 */
            uart_health_check();
        }
    }
}

要传一个 32 位值(比如 ADC 采样结果、事件类型),用 xTaskNotify() 家族:

/* 任务上下文发送;ISR 里用 xTaskNotifyFromISR() */
xTaskNotify(g_worker, EVT_BUTTON_LONG, eSetBits);            /* 当事件组用:置位 */
xTaskNotify(g_worker, adc_val,         eSetValueWithOverwrite); /* 当邮箱用:覆盖写 */

/* eSetValueWithoutOverwrite 是唯一可能返回 pdFAIL 的动作:
   目标任务上一条通知还没取走,这次就不覆盖,返回 pdFAIL —— 必须查返回值 */
if (xTaskNotify(g_worker, val, eSetValueWithoutOverwrite) != pdPASS) {
    /* 上一条还没被消费,这次的数据要么丢弃、要么自己缓存 */
}

3 条限制(官方 + 实践):

  1. 只能一对一。“RTOS task notifications can only be used when there is only one task that can be the recipient of the event.” 要广播给多个任务,老老实实用事件组。
  2. 发送方不能阻塞等待。队列满了发送方可以阻塞等空位;任务通知没有这个能力,发不进去(eSetValueWithoutOverwrite 时)就是立刻失败。
  3. 索引 0 有主了。官方 IMPORTANT NOTE:Stream Buffer 和 Message Buffer 内部就用了索引 0 的那个通知。如果你的任务同时在用流缓冲/消息缓冲,请改用 xTaskNotifyGiveIndexed(task, 1, ...) 这类带 Indexed 的版本(需要 configTASK_NOTIFICATION_ARRAY_ENTRIES > 1,V10.4.0 起支持)。

四、ISR 上下文的 5 条铁律

我们在项目实战篇里聊过一条原则:“在别人的任务里,只许干快活,不许等。” 中断上下文比那个还要严格——它连"任务"都不是。

铁律 1:只用 FromISR 结尾的 API,一个例外都没有

任务上下文能用            中断里必须换成
──────────────────────────────────────────────────
xQueueSend()          →  xQueueSendFromISR()
xQueueReceive()       →  xQueueReceiveFromISR()
xQueueOverwrite()     →  xQueueOverwriteFromISR()
xSemaphoreGive()      →  xSemaphoreGiveFromISR()
xTaskNotify()         →  xTaskNotifyFromISR()
xTaskNotifyGive()     →  vTaskNotifyGiveFromISR()
xEventGroupSetBits()  →  xEventGroupSetBitsFromISR()(注意 3.5 的前提)
xTaskGetTickCount()   →  xTaskGetTickCountFromISR()
──────────────────────────────────────────────────
xSemaphoreTake()      →  没有能在 ISR 里等的版本,中断不许等
vTaskDelay()          →  没有,中断不许睡
互斥量的任何操作        →  没有,mutex 与 ISR 无缘

为什么 FreeRTOS 要拆成两套 API 而不是内部自动判断?官方说得很实在:分开之后,API 实现里就不用每次都检查"我现在在什么上下文",省下来的是每一次调用的开销。代价就是这个责任落到了你头上。

铁律 2:pxHigherPriorityTaskWoken 初始化 pdFALSE,末尾 portYIELD_FROM_ISR()

这条 2.3 节已经详细讲过。补充一个多次调用的写法——一次中断里调了好几个 FromISR,共用同一个变量,最后统一 yield 一次

void DMA1_Stream5_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;      /* 只初始化一次 */

    if (dma_half_complete()) {
        xQueueSendFromISR(s_frame_q, &half_buf, &woken);      /* 可能置 pdTRUE */
    }
    if (dma_full_complete()) {
        xQueueSendFromISR(s_frame_q, &full_buf, &woken);      /* 也可能置 pdTRUE */
        vTaskNotifyGiveFromISR(g_stat_task, &woken);          /* 同一个变量继续传 */
    }

    portYIELD_FROM_ISR(woken);       /* 中断出口处切换一次就够了 */
}

注意这些函数只会把它置成 pdTRUE,不会清零,所以多次调用共用一个变量是安全的(官方队列示例里的 do...while 循环就是这么写的)。

铁律 3:中断里绝不调用会阻塞的 API,也不干重活

/* ❌ 中断里的四种作死写法 */
void EXTI0_IRQHandler(void)
{
    xQueueSend(q, &d, pdMS_TO_TICKS(10));   /* 1. 非 FromISR + 还带超时:双重犯规 */
    xSemaphoreTake(mutex, portMAX_DELAY);   /* 2. 中断怎么"等"?等谁来切走它? */
    vTaskDelay(pdMS_TO_TICKS(5));           /* 3. 中断不能睡 */
    printf("key pressed: %f\r\n", volt);    /* 4. 浮点格式化 + 阻塞式串口,几十毫秒起步 */
}

第 4 条不会立刻炸,但它会把中断延迟拉到几十毫秒——你的系统实时性直接报废,而且很多人查半天都想不到"打印一句日志"是元凶。

正确的姿势叫"延迟中断处理"(deferred interrupt processing):ISR 只做三件事——清标志、取数据、发通知/投队列,剩下的全部交给任务。

/* ✅ ISR 只干快活 */
void EXTI0_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;
    __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);      /* 1. 清中断标志 */
    uint32_t t = DWT->CYCCNT;                  /* 2. 抓一个时间戳(要开 DWT 计数器) */
    xQueueSendFromISR(s_key_q, &t, &woken);    /* 3. 投递,走人 */
    portYIELD_FROM_ISR(woken);
}

/* 重活在任务里干:消抖、判断长短按、打日志、发 MQTT,随便你多慢 */
void key_task(void *arg)
{
    uint32_t t;
    for (;;) {
        if (xQueueReceive(s_key_q, &t, portMAX_DELAY) == pdTRUE) {
            key_debounce_and_dispatch(t);
        }
    }
}

铁律 4(Cortex-M 专属,新手最容易炸在这):中断优先级必须配对

这条不写代码,只写配置,但配错了程序会以各种玄学方式崩溃,而且很难联想到是它。

Cortex-M 的规则是数值越小,优先级逻辑上越高(0 是最高)。FreeRTOS 用 BASEPRI 寄存器做临界区,于是划出一条线:

数值 0  ┬──────────────────────────────  逻辑优先级最高
        │  这一段的中断:FreeRTOS 永远不屏蔽它们
        │  → 响应最快,但【绝对不能调用任何 FreeRTOS API】
数值 5  ┼── configMAX_SYSCALL_INTERRUPT_PRIORITY(STM32 常见取值)
        │  这一段的中断:会被 FreeRTOS 临界区屏蔽
        │  → 可以放心调用 xxxFromISR() 系列
数值 15 ┴──────────────────────────────  逻辑优先级最低

官方规则原文:调用 RTOS API 的中断,其数值优先级必须大于等于 configMAX_SYSCALL_INTERRUPT_PRIORITY(也就是逻辑优先级不高于它)。另外 configMAX_SYSCALL_INTERRUPT_PRIORITY 不能设成 0——BASEPRI 写 0 表示"不屏蔽任何中断",临界区就失效了。

具体到 STM32F407(__NVIC_PRIO_BITS = 4,CubeMX 生成的 FreeRTOSConfig.h):

/* CubeMX 生成的默认配置,实际含义换算一下 */
#define configPRIO_BITS                              4
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5
#define configMAX_SYSCALL_INTERRUPT_PRIORITY \
        (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS))
        /* = 5 << 4 = 0x50 */

/* 结论:想在中断里调 FromISR,抢占优先级数值必须 ≥ 5(也就是 5~15) */
HAL_NVIC_SetPriority(USART1_IRQn, 6, 0);   /* ✅ 6 ≥ 5,可以调 FromISR */
HAL_NVIC_EnableIRQ(USART1_IRQn);

HAL_NVIC_SetPriority(TIM1_UP_TIM10_IRQn, 2, 0);
/* ⚠️ 2 < 5:这个中断响应更快、不会被 RTOS 屏蔽,
      但它里面【一行 FreeRTOS API 都不能调】,只能操作硬件寄存器/写 volatile 变量 */

三个必须知道的细节:

  1. Cortex-M 的中断默认优先级是 0(最高)。你用 CubeMX 使能了一个外设中断却没设优先级,它就是 0——然后你在里面调了 xQueueSendFromISR(),行为未定义。别信默认值。
  2. 优先级分组必须全给抢占优先级。官方推荐 NVIC_PriorityGroup_4(4 位全是抢占位,0 位子优先级)。CubeMX 生成的 HAL 工程里 HAL_Init() 默认就设成了 NVIC_PRIORITYGROUP_4,但如果你用的是老的标准外设库,要自己调 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4)
  3. 开发阶段一定要定义 configASSERT()。FreeRTOS V7.5.0 起,Cortex-M 端口在每个 FromISR 调用里都会做中断优先级校验(端口里那个函数叫 vPortValidateInterruptPriority()),配错了会直接断在断言上,告诉你"就是这里"。这比后面调试三个通宵划算太多:
/* FreeRTOSConfig.h —— 开发阶段务必打开 */
#define configASSERT(x) \
    if ((x) == 0) { taskDISABLE_INTERRUPTS(); \
                    printf("ASSERT %s:%d\r\n", __FILE__, __LINE__); \
                    for (;;); }

🔧 ESP32 不一样:ESP-IDF 的 FreeRTOS 是 SMP(双核)版本,临界区不是靠"关中断",而是关中断 + 自旋锁taskENTER_CRITICAL(&my_spinlock)(ISR 里用 taskENTER_CRITICAL_ISR(&my_spinlock)),锁的类型是 portMUX_TYPE。官方文档明确说:“in an SMP system, disabling interrupts is not a valid method of ensuring mutual exclusion”——你在 ESP32 上写 portDISABLE_INTERRUPTS() 保护共享变量,另一个核照样在改它。

铁律 5:回调 ≠ 中断,别把两件事搅在一起

这一条呼应项目实战篇里的血泪教训。BLE 的 GAP/GATTC 回调、MQTT 的事件回调、软件定时器的回调,它们跑在别人的任务里,不是中断上下文:

上下文用什么 API能阻塞吗
硬件中断(EXTI / UART / DMA / TIM)必须 xxxFromISR()绝对不行
SDK 回调(BLE / MQTT / HTTP 事件)普通 API,但超时填 0语法上可以,但会拖垮那个任务,别做
软件定时器回调(xTimerCreate 的回调)用普通 API,超时填 0绝对不行——它跑在守护任务里,一阻塞所有定时器全停
普通任务普通 API,带合理超时可以

一句话记法:“不能阻塞"不等于"是中断”。 在回调里用 xQueueSendFromISR() 是错误用法,正确做法是 xQueueSend(q, &d, 0)——语义对,还不阻塞。


五、防死锁的组合拳

死锁不是靠"小心一点"避免的,是靠规则避免的。下面五招按性价比排序,前两招做到位,你能挡掉 90% 的死锁。

招式 1:给锁编号,永远按号从小到大拿

经典死锁长这样(上一篇讲过,这里复习一秒):

任务 A:Take(锁1) → Take(锁2)
任务 B:Take(锁2) → Take(锁1)

如果 A 拿到 1、B 拿到 2 的瞬间发生切换 → 两边都在等对方手里那把 → 永久卡死

破解方法简单粗暴:规定一个全局顺序,所有任务都按这个顺序加锁,循环等待就不可能形成。

更进一步,可以把这个规则写成代码检查(我很喜欢这招,调试期能自动抓出违规):

/* 给每把锁一个层级号,规定:只能从低层级往高层级拿 */
typedef enum {
    LOCK_LVL_I2C   = 1,
    LOCK_LVL_CFG   = 2,
    LOCK_LVL_LOG   = 3,
} lock_level_t;

typedef struct {
    SemaphoreHandle_t sem;
    lock_level_t      level;
    const char       *name;
} guard_t;

/* 每个任务记录自己当前持有的最高层级(用任务本地存储或简单的数组都行) */
static lock_level_t s_held_level[configMAX_PRIORITIES];   /* 示意:按优先级索引 */

static inline lock_level_t *cur_held(void)
{
    return &s_held_level[uxTaskPriorityGet(NULL)];
}

BaseType_t guard_take(guard_t *g, TickType_t timeout)
{
    /* 调试期检查:正在拿的锁,层级必须比手里最高的那把更高 */
    configASSERT(g->level > *cur_held());

    BaseType_t ok = xSemaphoreTake(g->sem, timeout);
    if (ok == pdTRUE) {
        *cur_held() = g->level;
    } else {
        printf("[LOCK] take %s timeout\r\n", g->name);   /* 超时要留证据 */
    }
    return ok;
}

void guard_give(guard_t *g, lock_level_t prev_level)
{
    *cur_held() = prev_level;          /* 还原成拿这把锁之前的层级 */
    xSemaphoreGive(g->sem);
}

/* 用法:拿之前先把当前层级存下来,还锁时传回去 */
void some_task_body(void)
{
    lock_level_t saved = *cur_held();
    if (guard_take(&g_i2c, pdMS_TO_TICKS(100)) == pdTRUE) {
        sensor_read();
        guard_give(&g_i2c, saved);
    }
}

只要有人写出"先拿 LOG(3) 再拿 I2C(1)"的代码,在开发阶段就会当场断言停机,而不是等到量产后某个凌晨随机卡死。

⚠️ 上面这段 s_held_level 按优先级索引只是示意写法(同优先级多个任务会互相干扰)。正式用建议改成任务本地存储指针(vTaskSetThreadLocalStoragePointer(),需要 configNUM_THREAD_LOCAL_STORAGE_POINTERS > 0),或者用 xTaskGetCurrentTaskHandle() 做键去查一张小表。

招式 2:所有 Take 都带超时,超时后必须有动作

portMAX_DELAY 用起来最省事,也最容易让问题隐身。我的规矩是:

场景超时怎么给
等锁(mutex)给上限,比如 100 ms。拿不到 = 系统异常,要报警
等 ISR 通知给一个"心跳周期",比如 1000 ms,超时了做健康检查
等数据队列(消费者)可以长一点,但也别 portMAX_DELAY,超时用来喂看门狗
发数据队列(生产者)一般给 0 或很短,满了就丢并计数,不要拖住生产者
/* ✅ 带超时 + 有恢复动作的模板 */
if (xSemaphoreTake(i2c_lock, pdMS_TO_TICKS(100)) == pdTRUE) {
    sensor_read();
    xSemaphoreGive(i2c_lock);
    s_i2c_fail = 0;
} else {
    s_i2c_fail++;
    log_warn("i2c lock timeout, cnt=%lu", s_i2c_fail);
    if (s_i2c_fail >= 5) {
        i2c_bus_recover();      /* 需自行实现:软复位 I2C 外设 + 发 9 个 SCL 脉冲解总线 */
        s_i2c_fail = 0;
    }
}

注意最后那句:超时不是"打印一行就算了",得有恢复路径。否则你只是把死锁换成了"每 100 ms 打一行日志的活死锁"。

再配一个兜底:独立看门狗(IWDG)只在"所有关键任务都还活着"时才喂。真死锁了没人喂狗,设备自己复位,总比在现场变砖强。

/* 喂狗任务:每个关键任务定期把自己的位置 1,喂狗任务检查齐了才喂 */
#define ALIVE_ALL  (BIT_SENSOR | BIT_UPLOAD | BIT_KEY)
static volatile uint32_t s_alive;

void watchdog_task(void *arg)
{
    for (;;) {
        vTaskDelay(pdMS_TO_TICKS(500));
        if ((s_alive & ALIVE_ALL) == ALIVE_ALL) {
            HAL_IWDG_Refresh(&hiwdg);
            s_alive = 0;               /* 清零,下一轮重新收集 */
        }
        /* 少一个任务没打卡就不喂 → 到点复位 */
    }
}

招式 3:把临界区压到最小,锁里不干慢活

死锁和优先级反转的严重程度,都和"锁被持有多久"成正比。两个具体做法:

/* ❌ 锁里干慢活:把整个上报流程包进锁里,锁被持有几百毫秒 */
xSemaphoreTake(data_lock, portMAX_DELAY);
mqtt_publish(topic, &g_data, sizeof(g_data));   /* 网络操作,可能几百 ms */
xSemaphoreGive(data_lock);

/* ✅ 锁里只做拷贝,慢活拿到锁外面做 */
sensor_data_t snapshot;
xSemaphoreTake(data_lock, pdMS_TO_TICKS(50));
snapshot = g_data;                              /* 只拷一个结构体,几微秒 */
xSemaphoreGive(data_lock);
mqtt_publish(topic, &snapshot, sizeof(snapshot));  /* 慢活在锁外,谁也不挡 */

第二个做法:如果被保护的只是一个 32 位以内的变量,根本不需要锁,用极短的临界区就行:

uint32_t v;
taskENTER_CRITICAL();      /* Cortex-M 上就是抬高 BASEPRI,几条指令 */
v = g_counter;
g_counter = 0;
taskEXIT_CRITICAL();

⚠️ 别把"临界区"当锁滥用:临界区期间所有优先级数值 ≥ configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断都被屏蔽了,里面绝对不能调阻塞 API、不能干长活。ESP32(SMP)上还必须带自旋锁参数,见铁律 4 的说明。

招式 4:能不用锁就不用锁——守门员任务

最彻底的防死锁办法是根本不出现第二把锁。把某个共享资源交给唯一一个任务管理,别人只能通过队列给它发请求——这就是 FreeRTOS 官方教材里说的守门员任务(gatekeeper task)

              ┌──────────────┐
 sensor_task ─┤              │
              │   log_queue  ├──> log_task(唯一碰 UART 的任务)──> UART
 upload_task ─┤   (长度 16)  │
              │              │
 key_task    ─┤              │
              └──────────────┘
   谁都不用加锁,队列本身就是线程安全的,串行化天然完成
/* 守门员:全系统只有它能碰 UART,于是根本不需要 uart_mutex */
typedef struct { char text[80]; } log_msg_t;
static QueueHandle_t s_log_q;

void log_task(void *arg)
{
    log_msg_t m;
    for (;;) {
        if (xQueueReceive(s_log_q, &m, portMAX_DELAY) == pdTRUE) {
            HAL_UART_Transmit(&huart1, (uint8_t *)m.text, strlen(m.text), 100);
        }
    }
}

/* 任何任务都能调,不阻塞:队列满了宁可丢日志,也不能拖慢业务任务 */
void log_post(const char *s)
{
    log_msg_t m;
    strncpy(m.text, s, sizeof(m.text) - 1);
    m.text[sizeof(m.text) - 1] = '\0';
    if (xQueueSend(s_log_q, &m, 0) != pdPASS) {
        s_log_drop++;               /* 丢了要能统计到 */
    }
}

代价是多一个任务的栈(几百字节)和一次数据拷贝,换来的是这条路上永远不会死锁、不会优先级反转、也不会输出交错。日志、Flash 写入、显示屏刷新这三类资源,我现在一律用守门员。

招式 5:用任务通知替代二值信号量——顺便澄清一个误传

把 ISR → 任务的二值信号量换成任务通知,收益是实打实的:不用创建信号量对象(省 RAM),解除阻塞快 35%~45%(数据来源见 3.6 的官方脚注)。改造成本极低:

/* 改造前:需要一个信号量对象 */
SemaphoreHandle_t sem = xSemaphoreCreateBinary();
/* ISR */ xSemaphoreGiveFromISR(sem, &woken);
/* task */ xSemaphoreTake(sem, portMAX_DELAY);

/* 改造后:什么都不用创建 */
/* ISR */ vTaskNotifyGiveFromISR(g_task, &woken);
/* task */ ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(1000));

但这里要澄清一个流传很广的说法:“用任务通知替代二值信号量能省一次上下文切换。”

严格说,这句话在 ISR → 任务这条路径上是不成立的:不管你用信号量还是通知,都是"中断里唤醒任务 → 退出中断时切到那个任务",切换次数一模一样,省下的是函数内部的执行时间那几十字节的对象 RAM(一个信号量在 FreeRTOS 里本质就是一个 Queue_t 结构体)。

真正能省掉一次上下文切换的,是你原本多设计了一个"中转任务"的情况:

❌ 多一层中转:
   ISR ──sem──> dispatcher_task ──queue──> worker_task
                    ↑                          ↑
                切换 1 次                   切换 2 次

✅ 直接通知到干活的那个任务:
   ISR ──notify──> worker_task
                       ↑
                   切换 1 次   ← 这才是真正省下的那一次

所以正确的说法是:任务通知让你有能力砍掉中间层,从而省掉切换,而不是它本身自带"省一次切换"的魔法。面试时能把这层说清楚,比背一句"任务通知更快"有价值得多。

招式 6:万一还是卡了——现场怎么查

死锁现场的特征非常好认:所有相关任务都是 Blocked,而且永远不变。用 uxTaskGetSystemState() 把快照打出来:

/* 需要 configUSE_TRACE_FACILITY = 1 */
void dump_tasks(void)
{
    UBaseType_t n = uxTaskGetNumberOfTasks();
    TaskStatus_t *st = pvPortMalloc(n * sizeof(TaskStatus_t));
    if (st == NULL) {
        return;                       /* 堆不够就别硬来 */
    }

    n = uxTaskGetSystemState(st, n, NULL);
    for (UBaseType_t i = 0; i < n; i++) {
        /* eBlocked 表示这个任务正卡在某个队列/信号量/延时上
           StackHighWaterMark 顺便看一眼栈余量(单位:字) */
        printf("%-12s state=%d prio=%lu stackfree=%u\r\n",
               st[i].pcTaskName,
               (int)st[i].eCurrentState,
               (unsigned long)st[i].uxCurrentPriority,
               (unsigned)st[i].usStackHighWaterMark);
    }
    vPortFree(st);
}

把这个函数挂到一个低优先级的调试任务里,或者挂到某个按键上。看到"两个任务都是 eBlocked(=2)、优先级还被抬高了(说明发生过优先级继承)",基本就能锁定是哪两把锁了。

💡 更省事的办法:如果你的调试器支持(J-Link + Ozone、STM32CubeIDE 的 FreeRTOS 视图、SEGGER SystemView),直接看"任务状态 + 阻塞在哪个对象"的图形界面,几秒钟定位。前提是给队列/信号量用 vQueueAddToRegistry() 起了名字。


六、新手必踩的 16 个坑

前 14 条是高频款,几乎每个学 FreeRTOS 的人都会中一两枪:

#后果正确做法
1用二值信号量保护共享资源无优先级继承 → 优先级反转;无所有权 → 谁都能 give,锁形同虚设保护资源一律 xSemaphoreCreateMutex()
2二值信号量创建后直接 Take创建出来是"空"的,任务永久阻塞要么先 Give 一次,要么改用 mutex(创建即可用)
3在 ISR 里调 xQueueSend() / xSemaphoreTake()命中 configASSERT() 停机;没开断言则破坏中断屏蔽状态,随机崩中断里只用 ...FromISR(),且不能是会阻塞的 API
4pxHigherPriorityTaskWoken 不处理或传 NULL上下文切换拖到下一个 tick,任务响应多出最多 1 个 tick 的随机抖动初始化 pdFALSE,ISR 末尾 portYIELD_FROM_ISR(woken)
5中断优先级用默认值 0 就调 FromISRCortex-M 默认优先级 0 高于 configMAX_SYSCALL_INTERRUPT_PRIORITY,行为未定义数值 ≥ configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY(STM32 常见是 5)
6队列传指针,指向任务栈上的局部变量消费者读到已被覆盖的栈内存,数据是乱的传值,或"静态内存池 + 所有权移交"(见 2.4)
7队列传指针,多次复用同一个全局 buffer上一帧还没读完就被下一帧覆盖,数据撕裂用池化的多块 buffer,一块一帧
8xQueueSend / xQueueSendFromISR 返回值不查队列满了数据静默丢失,现场一点线索都没有判返回值,丢一帧计一次数,调试任务定期打印
9队列长度拍脑袋定太小丢数据、太大吃 RAM 还掩盖"消费者太慢"按"生产速率 × 最坏响应时间 + 余量"估,再用 uxQueueMessagesWaiting 量峰值
10xQueueOverwrite() 用在长度 > 1 的队列上官方明确禁止,开了 configASSERT() 会断言只对长度 1 的队列用;要缓冲多帧就用普通队列
11嵌套加锁用了普通 mutex同一任务第二次 take 必然失败:死等或超时返回,日志函数最常见用递归互斥量(configUSE_RECURSIVE_MUTEXES=1),或重构成"内层不加锁"
12递归互斥量用 xSemaphoreTake/Give官方明确禁止混用,计数逻辑不对必须成对用 xSemaphoreTakeRecursive/GiveRecursive,take 几次 give 几次
13持锁期间 vTaskDelay() 或做网络/Flash 慢操作锁被长时间占用,反转和死锁风险成倍放大锁里只做数据拷贝,慢活挪到锁外
14所有等待都写 portMAX_DELAY出问题时全系统静默卡死,没日志没线索给超时上限 + 超时后有恢复动作 + 看门狗兜底

后 2 条是进阶款,藏得深,但一旦中招极难查:

#后果正确做法
15在 ISR 里用 xEventGroupSetBitsFromISR() 却不查返回值定时器服务队列满时返回 pdFAIL,这次置位直接丢失查返回值;并确认 configUSE_TIMERSINCLUDE_xTimerPendFunctionCall 都为 1
16用任务通知,同时又用了 Stream/Message Buffer流缓冲内部占用索引 0 的通知,两边互相踩改用 ...Indexed() 版本,索引 ≥ 1(configTASK_NOTIFICATION_ARRAY_ENTRIES > 1

七、动手练一练

下面 6 个实验,第 2、3、5 个是故意把程序搞炸——不亲眼看见它怎么炸,你永远只是"知道",不是"懂"。

建议环境:STM32F407 开发板(F103 也行)+ CubeMX 生成的 FreeRTOS 工程 + 一个串口打印 + 一个逻辑分析仪(没有的话用 GPIO 翻转 + 示波器,再不行就用 DWT->CYCCNT 计时打印)。

实验 1:亲手复现"二值信号量当锁"的优先级反转

步骤:建三个任务,用二值信号量当锁:

/* 三个任务,模拟 1.1 节的场景。忙等函数用来占住 CPU(故意不 vTaskDelay) */
static void busy_ms(uint32_t ms)
{
    TickType_t t0 = xTaskGetTickCount();
    /* 用"差值比较"而不是"end 比较":TickType_t 会回绕,
       无符号数相减的写法在回绕时依然正确 */
    while ((xTaskGetTickCount() - t0) < pdMS_TO_TICKS(ms)) {
        __NOP();                                     /* 忙等,不让出 CPU */
    }
}

SemaphoreHandle_t lock;      /* 实验 A:xSemaphoreCreateBinary() + 先 Give 一次
                                实验 B:xSemaphoreCreateMutex() */

void low_task(void *arg)     /* 优先级 2 */
{
    for (;;) {
        xSemaphoreTake(lock, portMAX_DELAY);
        HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);    /* 探针:持锁开始 */
        busy_ms(50);                                           /* 假装在刷屏 */
        HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_RESET);
        xSemaphoreGive(lock);
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

void mid_task(void *arg)     /* 优先级 3,不碰锁,纯捣乱 */
{
    for (;;) {
        vTaskDelay(pdMS_TO_TICKS(20));
        HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_SET);
        busy_ms(30);                                           /* 抢占 low_task */
        HAL_GPIO_WritePin(GPIOA, GPIO_PIN_1, GPIO_PIN_RESET);
    }
}

void high_task(void *arg)    /* 优先级 4 */
{
    for (;;) {
        vTaskDelay(pdMS_TO_TICKS(100));
        TickType_t t0 = xTaskGetTickCount();
        xSemaphoreTake(lock, portMAX_DELAY);
        /* tick 转 ms:别默认 1 tick = 1 ms,按 configTICK_RATE_HZ 换算才通用 */
        uint32_t waited_ms = (uint32_t)(xTaskGetTickCount() - t0) * 1000U
                             / configTICK_RATE_HZ;
        printf("high waited %lu ms\r\n", (unsigned long)waited_ms);
        xSemaphoreGive(lock);
    }
}

预期现象

  • 用二值信号量(实验 A):high waited 经常打出 60~80 ms(50 ms 的锁 + 被 mid_task 插队的 30 ms)
  • 换成 mutex(实验 B):同样的代码,等待时间掉到 接近 50 ms(mid_task 插不进来了)

看波形更直观:PA0 高电平期间如果夹杂着 PA1 的高电平,就是反转正在发生。换 mutex 之后,PA0 高电平期间 PA1 应该是干净的低电平。

实验 2(故意搞破坏):在 ISR 里调用 xQueueSend()

步骤

  1. 先确认 FreeRTOSConfig.h 里定义了 configASSERT()(用铁律 4 那段带打印的版本);
  2. 找一个 EXTI 按键中断,把里面的 xQueueSendFromISR() 故意改成普通版本:
void EXTI0_IRQHandler(void)
{
    __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    uint32_t v = 1;
    xQueueSend(s_key_q, &v, 0);      /* ❌ 故意犯规:ISR 里用非 FromISR */
}
  1. 按一下按键。

预期现象:程序停在 configASSERT() 里(打印出 port.c 的某一行)。用调试器看调用栈,你会看到 xQueueGenericSend → vPortEnterCritical → configASSERT 这条链——那条断言就是在检查"当前是不是在中断上下文里"。

再进阶一步:把 configASSERT() 注释掉(模拟发布版),再按按键。你会发现程序好像没事,甚至能正常跑一阵子。这恰恰是最可怕的地方:它把一个必然的错误变成了一个偶发的错误。发布版关断言可以,开发期关断言等于自断双臂。

实验 3(故意搞破坏):不处理 pxHigherPriorityTaskWoken

步骤:拿实验 2 修好的中断,改成两个版本对比。任务侧在收到通知后立刻拉高 PA2,ISR 里进中断就拉高 PA3:

/* 版本 A:正确处理 */
void EXTI0_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;
    __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_SET);     /* 探针:进中断 */
    vTaskNotifyGiveFromISR(g_key_task, &woken);
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_RESET);
    portYIELD_FROM_ISR(woken);
}

/* 版本 B:传 NULL(语法完全合法,V7.3.0 起官方允许) */
void EXTI0_IRQHandler(void)
{
    __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_SET);
    vTaskNotifyGiveFromISR(g_key_task, NULL);               /* ❌ 不请求切换 */
    HAL_GPIO_WritePin(GPIOA, GPIO_PIN_3, GPIO_PIN_RESET);
}

再准备一个低优先级任务在后台忙等(busy_ms(1000) 那种),保证"被中断打断的"是个低优先级任务。

预期现象(tick = 1 kHz):

  • 版本 A:PA3 下降沿到 PA2 上升沿,间隔几微秒
  • 版本 B:这个间隔变成 0~1 ms 随机抖动——因为它在等下一个 tick 中断来触发调度

这就是 2.3 节那张图的实测版。把这两组波形截图存下来,面试讲"为什么 pxHigherPriorityTaskWoken 必须处理"时,比背概念有说服力得多。

实验 4:队列水位与丢帧统计

步骤

  1. 建一个长度 1 的队列,生产者每 5 ms 发一次,消费者每 20 ms 收一次;
  2. 用 2.2 节的 frame_q_send() 封装统计 peakdrop
  3. 跑 30 秒,看 drop 涨了多少(理论上每 20 ms 丢 3 帧,30 秒约 4500 帧);
  4. 把队列长度改成 8,再跑 30 秒;
  5. 把消费者改成每 5 ms 收一次,再跑 30 秒。

预期现象

  • 长度 1:drop 疯涨,peak 恒为 1
  • 长度 8:drop 依然在涨(因为消费者是真的慢,队列只是延后了爆炸时间)—— 这一条最值得体会:加长度治不了"消费者太慢"
  • 消费速度提上来之后:drop 停在 0,peak 稳定在 1~2

实验 5(故意搞破坏):普通 mutex 的嵌套自死锁

步骤:照 3.3 节那段 log_printf 写一遍(用 xSemaphoreCreateMutex()),在任务里调用它。

预期现象

  • 超时写 portMAX_DELAY:任务再也不出来了,dump_tasks() 打出来它是 eBlocked,而且它自己就是这把锁的持有者
  • 超时写 pdMS_TO_TICKS(100):每次调用都卡 100 ms 然后返回失败,日志缺一半——这种"半死不活"比彻底卡死更难查

然后修好它:两条路都试一次——① 换成递归互斥量;② 把 uart_write() 拆成加锁的外层和不加锁的 uart_write_raw()。体会一下哪种改法让代码更清楚。

实验 6:造一个死锁,再用三种方法救它

步骤:两个任务、两把 mutex,故意反着拿:

SemaphoreHandle_t lock_a, lock_b;      /* 都是 xSemaphoreCreateMutex() */

void task1(void *arg)
{
    for (;;) {
        xSemaphoreTake(lock_a, portMAX_DELAY);
        /* 持锁期间 vTaskDelay 正是招式 3 明令禁止的写法,
           这里是故意的——它能把"偶发死锁"变成"必现死锁",方便你观察 */
        vTaskDelay(pdMS_TO_TICKS(10));
        xSemaphoreTake(lock_b, portMAX_DELAY);  /* 大概率卡在这 */
        xSemaphoreGive(lock_b);
        xSemaphoreGive(lock_a);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void task2(void *arg)
{
    for (;;) {
        xSemaphoreTake(lock_b, portMAX_DELAY);  /* 顺序反了 */
        vTaskDelay(pdMS_TO_TICKS(10));
        xSemaphoreTake(lock_a, portMAX_DELAY);  /* 大概率卡在这 */
        xSemaphoreGive(lock_a);
        xSemaphoreGive(lock_b);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

预期现象:几秒内两个任务全部卡死。用招式 6 的 dump_tasks() 打快照,两个任务都是 eBlocked,而且再也不变了。

三种救法各试一遍

  1. 固定顺序:把 task2 改成也是"先 a 后 b"→ 死锁消失(这是根治)
  2. 加超时:两处都改成 pdMS_TO_TICKS(50),拿不到就把已持有的锁全部释放再重试 → 不死锁了,但会偶发失败重试(这是缓解,不是根治)
  3. 合并成一把锁:既然这两个资源总是一起用,那就用一把锁保护它们 → 死锁从根上不成立

做完这一题,"死锁的四个必要条件里,破坏’循环等待’最实际"这句话你就真懂了。


小结

这篇的核心其实就一句话:

同步原语选错不会报错,只会在时序刚好撞上的那一瞬间毁掉你的数据。

把最该记住的几条压缩成一张表:

记住这条原因
保护资源用 mutex,不用二值信号量二值信号量没有优先级继承、没有所有权
mutex 和递归 mutex 都不能在 ISR 里用中断不能阻塞,优先级继承对中断也没意义
队列没有优先级继承高优先级消费者等低优先级生产者时,内核帮不上忙
队列按值拷贝,传指针要自己管所有权栈地址、共享 buffer、谁 free,是三种典型死法
中断里只用 FromISR,且必须处理 pxHigherPriorityTaskWoken否则切换要拖到下一个 tick
Cortex-M 上中断优先级数值必须 ≥ configMAX_SYSCALL_INTERRUPT_PRIORITY默认优先级 0 会让 FromISR 行为未定义
所有等待都给超时,超时要有恢复动作portMAX_DELAY 会让故障静默
防死锁四件套:固定顺序 + 超时 + 缩小临界区 + 守门员任务靠规则,不靠"小心一点"
任务通知快 35%~45% 且省 RAM,但只能一对一它省的是执行时间和 RAM,砍掉中转任务才省得下上下文切换

我自己的体会是:RTOS 的坑,八成不在"不会用 API",而在"不知道这个 API 的语义边界在哪"。 上面这些边界,官方文档其实都写了,只是我们刚学的时候只会看函数怎么调,不会看那一段段"Note:“和"IMPORTANT:”。等你被偶发 bug 折磨过一次,回头再读那些注意事项,才会觉得每一句都是血。

本文的队列、信号量、任务通知语义都对照了 FreeRTOS 官方文档,那些 “Note:” 和 “IMPORTANT:” 段落才是真正容易踩坑的地方。

下一篇:RTOS_03_内存管理陷阱heap碎片与泄露定位

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值