FreeRTOS 队列与信号量使用陷阱:一次”跑三天就死”的问题,最后定位在 4 个 API 用法上
上个月帮朋友看一个采集器的稳定性问题:板子跑起来一切正常,压力测试也能过,但连续运行两三天必死,死法还各不相同——有时是某个任务再也不响应,有时是 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 */
}
三个必须记住的细节:
woken一定要初始化为pdFALSE。它是个累加标记,不初始化就是未定义行为,实测过一版忘了初始化,结果是”大部分时候没切换、偶尔切了”,排查了两天。portYIELD_FROM_ISR在 ISR 末尾只调一次,把所有 FromISR 调用攒的woken一次处理,比每调一个 API 就 yield 一次高效得多。- 互斥量没有 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”这套让非确定性问题变成确定性问题的方法。