FreeRTOS 队列与信号量使用陷阱:一次”跑三天就死”的问题,最后定位在 4 个 API 用法上

2026年9月21日 0 By admin

上个月帮朋友看一个采集器的稳定性问题:板子跑起来一切正常,压力测试也能过,但连续运行两三天必死,死法还各不相同——有时是某个任务再也不响应,有时是 HardFault,有时是内存被啃光只剩几百字节。

这种”时间相关、非确定性”的故障,九成以上出在 RTOS 的同步原语用法上。这次的根因也不复杂:4 处队列/信号量用法不合规,单独看每处都”好像能跑”,凑在一起就成了定时炸弹。

这篇把排查过程和这 4 个坑完整写出来,附上可以直接对照自查的清单。所有代码基于 FreeRTOS V10.4 / CMSIS-FreeRTOS,STM32F4 + GCC 环境验证。

先看症状:三种死法指向同一个源头

朋友的描述很典型,我整理成表:

现象 频率 初步猜测
上位机收不到数据,但心跳灯还在闪 最常见 采集任务卡死或被饿死
偶发 HardFault,PC 指向 memcpy 次之 野指针 / 队列项被踩
xPortGetFreeHeapSize() 单调下降直到耗尽 每次长跑必现 有对象被反复创建未删除

单个现象都能解释,但三个一起出现,说明问题不在某一处,而在同步机制的整体设计。

我的第一条经验:遇到这种”多症状”的稳定性问题,先别急着打日志找现场,先去通读一遍所有队列/信号量的创建与使用点。 通常半小时内就能看出端倪,比抓几天的 trace 效率高得多。

坑一:队列里传指针,但内存活不过接收方

第一个坑,也是踩得最深的一个。原代码大意是这样:

typedef struct {
    uint16_t len;
    uint8_t  data[256];
} sample_t;

/* 生产者:中断里采集完就发队列 */
void ADC_IRQHandler(void)
{
    sample_t s;                       /* 栈上变量! */
    s.len = 256;
    ADC_CopyBuffer(s.data, &s.len);
    xQueueSendFromISR(g_sample_q, &s, &woken);   /* 传的是 &s,队列拷贝的是指针? */
}

问题在队列创建时写错了:

/* 错误:队列项只有 4 字节(指针大小) */
g_sample_q = xQueueCreate(16, sizeof(sample_t *));

/* 正确:队列项是结构体本身,队列做值拷贝 */
g_sample_q = xQueueCreate(16, sizeof(sample_t));

sizeof(sample_t *) 是 4(32 位平台),于是队列只拷贝了栈地址的前 4 字节。中断返回后这个栈帧立刻被复用,消费者拿到的是随时会被覆盖的野指针。这就是那个 memcpy 里的 HardFault 的直接来源——它读的是一块已经被别的任务改掉的内存。

这里有两条路,选哪条取决于数据大小:

方案 队列项 优点 代价与风险
传值 sizeof(struct) 生命周期由队列保证,无悬垂风险 每次 send/recv 各拷贝一次,大结构开销大
传指针 sizeof(ptr) 拷贝开销固定 4 字节 必须自己管内存生命周期,易悬垂/泄漏

传指针不是不能用,但要满足三个条件,缺一不可:

/* 传指针的正确姿势:内存来自固定内存池,所有权随队列转移 */
static sample_t g_pool[POOL_N];
static QueueHandle_t g_free_q;     /* 空闲块队列 */

void producer(void)
{
    sample_t *blk;
    if (xQueueReceive(g_free_q, &blk, 0) != pdPASS) {
        g_drop_cnt++;               /* 池耗尽:计数丢弃,绝不 malloc */
        return;
    }
    fill_sample(blk);
    if (xQueueSend(g_sample_q, &blk, 0) != pdPASS) {
        xQueueSend(g_free_q, &blk, 0);   /* 发不出去必须还回池子 */
        g_drop_cnt++;
    }
}

void consumer(void)
{
    sample_t *blk;
    if (xQueueReceive(g_sample_q, &blk, portMAX_DELAY) == pdPASS) {
        process(blk);
        xQueueSend(g_free_q, &blk, 0);   /* 用完归还,所有权闭环 */
    }
}

要点:内存池 + 空闲队列 + 所有路径都要归还。只要有一条路径漏了归还,池子就会慢慢枯竭——这正是那三个症状里”堆内存耗尽”在传指针方案下的另一种表现形式(不是 malloc 泄漏,而是池泄漏)。

顺带说一句:嵌入式里我基本不用 malloc/free 跨任务传数据。heap_4 虽然有合并机制,但碎片是不可预期的时间炸弹;固定内存池的行为是确定的、可审计的,出问题也能一眼看出是哪一段代码少归还了。

坑二:ISR 里用了非 FromISR 版本

第二处问题更隐蔽,编译器不会报错,跑起来大部分时候也正常:

/* 错误:中断里调用非 FromISR 版本的 API */
void TIM3_IRQHandler(void)
{
    BaseType_t woken;
    xQueueSend(g_cmd_q, &cmd, 10);        /* 危险:会去操作临界区/关中断 */
    xSemaphoreGive(g_bin_sem);            /* 更危险:可能触发任务切换 */
}

后果分两种:

  • 轻则时序错乱:非 ISR 版本内部用 taskENTER_CRITICAL() 关中断,在中断上下文里嵌套关中断,逻辑上不致命但拖长中断响应;
  • 重则直接在 ISR 里触发上下文切换:xSemaphoreGive 可能导致就绪表变化并请求调度,而中断上下文没有合法的任务栈可切,表现为随机 HardFault。

正确写法:

void TIM3_IRQHandler(void)
{
    BaseType_t woken = pdFALSE;          /* 必须初始化为 pdFALSE */

    xQueueSendFromISR(g_cmd_q, &cmd, &woken);
    xSemaphoreGiveFromISR(g_bin_sem, &woken);

    portYIELD_FROM_ISR(woken);           /* 攒够了一次性切换,别每个 API 都 yield */
}

三个必须记住的细节:

  1. woken 一定要初始化为 pdFALSE。它是个累加标记,不初始化就是未定义行为,实测过一版忘了初始化,结果是”大部分时候没切换、偶尔切了”,排查了两天。
  2. portYIELD_FROM_ISR 在 ISR 末尾只调一次,把所有 FromISR 调用攒的 woken 一次处理,比每调一个 API 就 yield 一次高效得多。
  3. 互斥量没有 FromISR 版本。xSemaphoreTakeFromISR 对互斥量是未定义行为(源码里有 configASSERT),因为优先级继承在中断上下文没有意义。ISR 里要同步,用二值信号量或任务通知。

坑三:拿二值信号量当”事件计数器”

第三处是设计层面的错误。原代码用一个二值信号量通知消费者”有新数据”,但生产者是每 1ms 来一次,消费者每 10ms 处理一批:

/* 错误设计:二值信号量不计数,多次 give 只算一次 */
xSemaphoreGiveFromISR(g_data_ready, &woken);   /* 1ms 一次 */

void consumer_task(void *arg)
{
    for (;;) {
        xSemaphoreTake(g_data_ready, portMAX_DELAY);
        process_one();                          /* 只处理一个 → 永远追不上 */
    }
}

二值信号量的语义是”有没有事件“,不是”有几个事件“。1ms 给一次、10ms 取一次,中间的 9 次 give 全部丢失。表现就是消费者永远在处理积压、数据包序号不连续,看起来”像丢帧又不像丢帧”。

按需求选择合适的原语:

原语 语义 典型用途 能否计数
二值信号量 有/无 ISR 唤醒任务做一次动作 否,多次 give 只保留一次
计数信号量 剩余数量 事件计数、资源池配额 是,上限创建时指定
互斥量 独占 + 优先级继承 多任务共享临界资源 否,不可重入
递归互斥量 独占 + 同任务可重入 递归函数保护的资源 否,不可在 ISR 用
任务通知 有/无(可带值) 一对一 ISR→任务 值可累加,速度最快
事件组 位标志组合 多事件同步(AND/OR) 位级,不是数值计数

这处改成计数信号量即可:

/* 修正:计数上限设成消费者一轮能处理的最大批次数 */
g_data_cnt = xSemaphoreCreateCounting(32, 0);

void consumer_task(void *arg)
{
    for (;;) {
        /* 先以阻塞方式等第一个,再把已到的都取走,一批处理 */
        if (xSemaphoreTake(g_data_cnt, portMAX_DELAY) == pdPASS) {
            int n = 1;
            while (xSemaphoreTake(g_data_cnt, 0) == pdPASS) {
                n++;
            }
            process_batch(n);
        }
    }
}

或者干脆换成计数信号量 + 环形缓冲的组合:信号量只做”有活了”的提醒,真实数据放环形缓冲,消费者按自己的节奏取。这是我在实时系统里最常用的解耦模式,因为缓冲区长度是显式可控的,不会出现”信号量计数够了但数据被覆盖”的矛盾。

另外提一个性能优化:如果只是一对一的 ISR→任务通知,用任务通知比信号量快得多(官方数据是省约 45% 时间和内存):

/* ISR 侧 */
vTaskNotifyGiveFromISR(g_consumer_handle, &woken);

/* 任务侧 */
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);   /* pdTRUE: 取走后清零 */

限制是一对一:多个 ISR 通知同一个任务可以(计数累加),但一个任务通知只能对应一个接收任务。

坑四:互斥量加锁顺序不一致导致死锁

第四处是死锁,也是”跑三天才死一次”的典型。系统里有两个共享资源,两个任务都要用:

/* 任务 A */
xSemaphoreTake(g_mutex_uart, portMAX_DELAY);
xSemaphoreTake(g_mutex_flash, portMAX_DELAY);   /* 顺序:UART → FLASH */

/* 任务 B(顺序相反!) */
xSemaphoreTake(g_mutex_flash, portMAX_DELAY);
xSemaphoreTake(g_mutex_uart, portMAX_DELAY);    /* 顺序:FLASH → UART */

经典 AB-BA 死锁:A 拿了 UART 等 FLASH,B 拿了 FLASH 等 UART,两个都用 portMAX_DELAY 永久阻塞,谁也动不了。发生条件是两个任务恰好在同一时刻交错执行到这个点——概率极低,但长跑必然命中。

治本的办法是固定全局加锁顺序,把资源编号写进设计文档:

/* 约定:所有任务按编号从小到大加锁,编号写在头文件里,评审时检查 */
#define LOCK_ORDER_UART   1
#define LOCK_ORDER_FLASH  2
#define LOCK_ORDER_SD     3

xSemaphoreTake(g_mutex_uart,  portMAX_DELAY);   /* 1 */
xSemaphoreTake(g_mutex_flash, portMAX_DELAY);   /* 2,严格递增 */

配套的三条纪律:

  • 持锁期间绝不做阻塞操作。I/O 等待、vTaskDelay、send 到可能满的队列,都要挪到锁外。
  • 临界区尽量短,最好只包含内存拷贝和状态更新。
  • 尽量用带超时的 take(如 pdMS_TO_TICKS(50)),超时后打日志并走降级路径。portMAX_DELAY 意味着”永远等”,一旦死锁就是永久挂死,连日志都没有。

顺手挖出来的两个隐患

排查四处主因时还发现两个小坑,一起列出来。

队列深度拍脑袋定,缺少满/空处理。 原代码里 xQueueSend(..., 0) 的返回值被忽略了。队列满了 send 失败但没人知道,数据就这么静默丢了。所有 send 都必须处理返回值:

if (xQueueSend(g_q, &item, pdMS_TO_TICKS(5)) != pdPASS) {
    g_drop_cnt++;                  /* 计数,可读出来上报告警 */
    /* 或走紧急通道:覆盖最旧一项 / 直写 Flash / 降低采样率 */
}

删除队列时不检查是否还有任务阻塞在上面。 vQueueDelete 不会唤醒阻塞在该队列上的任务,它们会永久挂起(句柄失效后行为未定义)。正确顺序是:先发”退出”命令让生产者/消费者自然退出 → 确认无人阻塞 → 再删除。

调试这些问题的三个抓手

上面这些坑有个共同特点:静态看代码很难发现,动态复现又不确定。所以需要几个”让问题变确定”的手段。

其一,给队列和任务起名字,崩溃时能直接读出状态。 在 FreeRTOSConfig.h 里打开:

#define configQUEUE_REGISTRY_SIZE   16     /* 启用队列注册表 */
#define configUSE_TRACE_FACILITY    1
#define configUSE_STATS_FORMATTING_FUNCTIONS 1

vQueueAddToRegistry(g_sample_q, "SampleQ");
vQueueAddToRegistry(g_cmd_q,    "CmdQ");

/* 任一任务里丢这段进去,串口就能打出各队列积压量 */
void dump_queues(void)
{
    char buf[512];
    vTaskList(buf);                        /* 任务状态、优先级、剩余栈 */
    LOG("tasks:\n%s", buf);
    LOG("SampleQ pending=%u, CmdQ pending=%u, drops=%lu",
        uxQueueMessagesWaiting(g_sample_q),
        uxQueueMessagesWaiting(g_cmd_q), g_drop_cnt);
}

调试期让这个 dump 每 30 秒跑一次,长跑问题会在日志里留下”积压量单调上升”或”剩余栈逼近 0″的痕迹,比事后猜要可靠得多。

其二,把 configASSERT 定义到死循环 + 断点。 FreeRTOS 内部大量 API 参数校验都挂在 configASSERT 上,比如”队列项大小是否合理””互斥量是否在 ISR 里被 take”。默认它是空的,等于把这些检查全关掉了:

#define configASSERT(x)  do { if ((x) == 0) { taskDISABLE_INTERRUPTS(); \
                                              LOGE("ASSERT %s:%d", __FILE__, __LINE__); \
                                              for (;;) {} } } while (0)

打开之后,坑二那种”ISR 里用错版本”会立刻在第一次调用时被抓到,省掉几天的排查。

其三,用 uxTaskGetStackHighWaterMark 观察栈水位。 传指针方案改错时,栈溢出和野指针经常同时出现。让每个任务的剩余栈都留 30% 以上余量,是避免这类问题最简单有效的手段。

一份可以直接抄的同步原语自查清单

写完这次整改,我把检查项固化成了一张表,新项目评审时逐条过。凡是写”否”的,都要给出理由:

  • ☐ 所有 xQueueCreate 的队列项大小用的是 sizeof(struct) 而不是 sizeof(指针)
  • ☐ 队列传指针时,内存来自固定内存池,且所有路径都归还(含发送失败路径)
  • ☐ 全局搜索确认 ISR 里只用 *FromISR 后缀的 API,互斥量绝不出现在 ISR 中
  • ☐ 每个 FromISR 调用的 pxHigherPriorityTaskWoken 都初始化为 pdFALSE
  • ☐ portYIELD_FROM_ISR 在 ISR 末尾只调用一次
  • ☐ 二值信号量没有被当作”事件计数器”使用(需要计数的地方一律用计数信号量)
  • ☐ 一对一 ISR→任务通知的场景,已评估任务通知替代(省 45% 时间/内存)
  • ☐ 所有资源的加锁顺序在文档里有明确编号,且代码中严格递增
  • ☐ 持锁期间无阻塞调用(I/O、delay、可能满的 send)
  • ☐ 所有 send 都检查返回值并有溢出计数,溢出行为是设计决定的(丢弃/覆盖/降级)
  • ☐ 所有 take 都带合理超时,portMAX_DELAY 只用于”设计上保证不会死锁”的路径
  • ☐ vQueueDelete 之前确认无任务阻塞在该队列上
  • ☐ 调试期打开 configASSERT 与队列注册表,长跑日志可导出队列积压量
  • ☐ 长跑测试至少 72 小时,并以”队列积压量不单调上升 + 堆剩余量不单调下降”作为通过条件

最后一条特别重要:稳定性问题必须用长跑来验收,短时间压力测试是过不去的。 这次四个坑里,死锁那个只在特定交错时序下触发,压力测试跑 2 小时连影子都没有,改成 72 小时长跑 + 周期 dump 状态之后,第一次跑就复现了。

下一期准备写差分升级(bsdiff/bdiff):怎么把 OTA 包从 800KB 压到 30KB,以及差分包在 MCU 上落地时的内存与校验设计。这块之前做 Bootloader 时留了尾巴,正好补完。

—

排查耗时参考:通读同步原语 0.5h → 定位四个根因 3h → 整改 + 72h 长跑验证。真正难的不是改代码,是建立”长跑 + 状态 dump”这套让非确定性问题变成确定性问题的方法。