Bug002:局部大数组压栈,高温必死机
专栏:嵌入式故障诊断实战:100 个产线级 Bug 的根因分析与修复
标签:Cortex-M0+|栈溢出|.su|0xDEADBEEF|PY32F071|高温
故障等级:批量客诉(高温季 63/1200 台)
复现难度:★★★★☆
影响面:P2 电动沙发控制器 2025 年 6 月批次 1200 台,华东/华南售后 28 天返回 63 台,其中 41 台"晒一会儿太阳就瘫"
定位耗时:9 个工作日(含 2 天高温箱连续复现)
修复成本:V1.4.3 改动 5 个文件,任务栈 RAM 峰值降 1.6KB,Flash +412B
⚡ 一分钟结论
- 现象:常温 25℃ 连续跑 72h 正常;台面温度过 60℃ 后 3~8 分钟内必死,死法有三种(HardFault、任务卡死、按键全无响应但屏幕还在刷新),看起来"高温把芯片烤坏了"。
- 根因 :
overheat_report()里一个uint8_t frame[1024]放栈上。常温永远走不到这个分支;一旦 NTC 读数跨过 65℃,调用链栈需求从 1048B 跳到 1984B,而ctrl_task只分了 1792B(448 words),溢出 192B ,把堆里相邻块的BlockLink_t头写成了 JSON 文本。 - 改法 :①
frame搬到静态区(单任务独占,不重入);② 过温帧改成分段发送,不再一次性snprintf整帧;③ 栈从 1792B 提到 2560B;④ 全任务栈填充0xDEADBEEF+ 10s 周期扫描,水位 <15% 就上报。 - 代价 :RAM 多占 768B(16KB 里挤出来),Flash +412B;换来高温季 0 例,且栈水位从此变成可观测指标。
🔍 故障现象
P2 产品线电动沙发控制器(PY32F071,Cortex-M0+ @24MHz,128KB Flash / 16KB SRAM,FreeRTOS 10.4.3 heap_4,V1.4.2)。售后返回的 63 台,拆机复测结果:
| 观测项 | 实测结果 |
|---|---|
| 触发环境 | 靠南阳台、车内、阳光直射的玻璃茶几旁;室内空调房 0 例 |
| 壳体内部温度 | 触发时 NTC 贴片读数 68~76℃,MCU 封装表面 71℃(红外测温枪) |
| 触发时间 | 上电后 3~8 分钟,多数在用户"调平躺"动作之后 |
| 死机表现 A | 全部按键无响应,OLED 停在最后一帧,电流 42mA(正常 38mA) |
| 死机表现 B | 蜂鸣器长鸣 1.2s 后整机复位,复位后正常,再热再死 |
| 死机表现 C | 推杆电机单方向失控 0.8s 后停在限位,需断电重上电 |
| 复位原因寄存器 | RCC->CSR 读到 0x14000000:bit28 SFTRSTF(软件复位)+ bit26 PINRSTF,没有 IWDG 位 |
| 常温对照 | 25℃ 恒温箱 72h,1200 台里 0 例;45℃ 96h,0 例;60℃ 8h,2 例 |
0x14000000 第一眼看很迷惑:有 SFTRSTF 说明是代码自己调的 NVIC_SystemReset(),可 V1.4.2 的 configASSERT 是 while(1),根本不会软复位。两个事实对不上,说明复位不是断言触发,而是栈被踩后跳到野地址、凑巧执行到复位指令------这是后来用黑匣子(见 Bug001)覆盖后才确认的。
客户只说"太阳一晒就傻"。三种死法对应三条踩踏路径,说明被踩的不是某个固定变量,而是一片内存。
🧪 复现条件
| 因素 | 取值 | 触发概率 | 说明 |
|---|---|---|---|
| 固件版本 | V1.4.2 | 必现条件 | V1.4.1 无过温上报功能,0 例 |
| 环境温度 | ≥65℃(NTC 读数) | 100% 必死 | 64℃ 时 0/50,65℃ 时 50/50,阈值型而非渐变型 |
| 是否触发过温保护 | temp_state == TEMP_OVERHEAT |
100% | 只在"电机连续运行 >90s 且 NTC ≥65℃"时进入 |
| 电机负载 | 推杆承重 ≥80kg | 概率×2.1 | 重载升温快,更早跨阈值 |
| 采样周期 | 200Hz(常温)/ 1kHz(≥60℃ 温度补偿模式) | --- | 高温下 ADC 定时器自动提速 5 倍 |
| 任务栈 | ctrl_task 1792B(448 words) |
--- | xTaskCreate 第 3 参数 448 |
| 堆剩余 | <1KB | --- | 栈块下方紧邻 heap_4 的其他块,溢出必踩 |
三步复现(高温箱内 6 分钟出结果):
- 整机上电,进入手动模式,推杆挂 80kg 配重连续伸 90s;
- 高温箱设定 70℃,等 NTC 读数稳定过 65℃(约 4 分钟);
- 再按一次"平躺",观察:约 2.4s 后 OLED 冻结,
J-Link附加读取SP = 0x20002F18。
关键点:必须让电机连续跑够 90s 。只把板子烤到 70℃ 不按按键,一辈子不复现------因为
overheat_report()只在"过温 + 电机运行"双条件成立时调用。我们前两天一直在"烤板子",烤了 16 小时没死一次,方向完全错了。
📝 排查过程(时间线)
Day 1:先怀疑电源和电机干扰(弯路 ①)
思路:推杆电机是 24V 有刷直流,换向火花大,高温下 MOS 导通电阻上升、母线跌得更狠,怀疑 brownout。
动作与读数:
- Keysight DSOX1204G + N2750A 差分探头,DC 耦合,20MHz 带宽限制,抓 3.3V 轨 + 24V 母线,单次触发窗口 10s,高温箱 70℃ 下循环 4 小时;
- 抓到的 63 次死机里,3.3V 最低点 3.24V(LDO 输出 3.30V ±1%,BOR 阈值 2.70V),裕量 540mV;
- 24V 母线在电机启动瞬间跌到 21.8V,持续 18ms,但 3.3V 完全无跌落;
RCC->CSR里BORRSTF(bit25)一次都没置位。
结论:排除电源路径 。如果真是欠压,BORRSTF 必然置位,而且 3.3V 波形上应该能看到百 mV 级的坑,实际连 60mV 都没有。
Day 2:怀疑中断嵌套加深(弯路 ②,也是最初的"标准答案")
思路:这是我们自己写进设计文档的假设------高温 → 温度补偿算法把 ADC 采样从 200Hz 提到 1kHz → 中断密度 ×5 → 中断嵌套更深 → 栈爆。
动作与读数:
- 在
ADC1_2_IRQHandler入口翻转 GPIO,逻辑分析仪 Saleae Logic Pro 16 抓 60s:常温中断间隔 5.00ms,70℃ 下 1.00ms,抖动 ±8µs,假设的前半段成立; - 在中断上下文栈里塞
0xDEADBEEF探针,测异常栈(MSP)深度:常温峰值 128B ,70℃ 峰值 296B ,主栈Stack_Size EQU 0x400(1024B),余量 728B; - 把主栈缩到 384B 强制制造 MSP 溢出,死机现象与现场不一致(现场是 OLED 冻结,MSP 溢出是立刻 HardFault)。
结论:中断密度假设被数据否定 。这里有一条 Cortex-M0+ 的硬事实必须记住:FreeRTOS 任务跑在 PSP,所有异常走 MSP。所以任务栈(PSP)的溢出和中断嵌套(MSP)是两件独立的事,中断再密集也压不爆任务栈。这条认知纠正,让我们把注意力从"中断"转回"任务栈"。
Day 3:.su 文件把嫌疑人缩小到 3 个函数
GCC 加 -fstack-usage 重新编译,产出每个函数的静态栈帧上界:
$ arm-none-eabi-gcc -O2 -fstack-usage -c src/*.c
$ cat ctrl_task.su
ctrl_task.c:41:6:ctrl_task 48 static
ctrl_task.c:88:6:motor_ramp 160 static
ctrl_task.c:132:6:overheat_report 1264 static
ctrl_task.c:206:6:log_pack 232 static
ctrl_task.c:244:6:uart_send_block 64 static
ctrl_task.c:301:6:key_scan 40 static
filter.c:12:6:filter_iir 112 static
overheat_report 单帧 1264B ,一眼就是它------因为这个函数里明晃晃写着 uint8_t frame[1024];。
把调用链的最坏栈需求加起来:
ctrl_task(48) → motor_ramp(160) → overheat_report(1264) → log_pack(232)
= 1704B,再叠加 log_pack 内 snprintf 实测 280B(newlib-nano,格式化 6 个字段)
= 1984B
而 ctrl_task 的栈是 xTaskCreate(ctrl_task, "ctrl", 448, NULL, 4, NULL) → 448 words × 4B = 1792B。缺口 192B。
Day 4:0xDEADBEEF 扫描,把"缺口"变成"实测水位"
PY32F071 是 Cortex-M0+,没有 MPU (选型时为了省 0.6 元),所以不能靠 MPU 保护区抓越界。改用栈填充魔术字:任务入口第一句把未使用栈区全部写成 0xDEADBEEF,运行一段时间后从栈底向上找第一个不等于魔术字的字,位置即水位(代码见修复方案)。
填充后跑温度梯度,每档稳定 10 分钟再读:
| NTC 温度 | 温度状态 | ADC 速率 | 实测栈水位 | 剩余 | 结论 |
|---|---|---|---|---|---|
| 25℃ | NORMAL | 200Hz | 984B | 808B | 健康 |
| 45℃ | NORMAL | 200Hz | 1008B | 784B | 健康 |
| 60℃ | DERATE | 1kHz | 1040B | 752B | 健康 |
| 64℃ | DERATE | 1kHz | 1048B | 744B | 健康 |
| 65℃ | OVERHEAT | 1kHz | 1984B | -192B | 溢出 |
| 70℃ | OVERHEAT | 1kHz | 1984B(饱和) | -192B | 溢出 |
这张表是整个案子的核心证据。64℃ 到 65℃ 之间,栈水位跳了 936B ,不是渐变,是阶跃------因为 65℃ 是状态机 TEMP_OVERHEAT 的门限,跨过去才会调用 overheat_report()。
Day 5:确认踩到了谁
SP = 0x20002F18 落在 ctrl_task 栈区间 [0x20002800, 0x20002EFF] 之外 (低了 0x1E8 = 488B,比预算的 192B 更深,因为 snprintf 内部还有一层 _vfprintf_r 的临时缓冲)。溢出方向是低地址,也就是 heap_4 里紧邻栈块下方的那个块。
heap_4 每个块前有 8 字节头:
c
typedef struct A_BLOCK_LINK {
struct A_BLOCK_LINK *pxNextFreeBlock; /* 4B */
size_t xBlockSize; /* 4B,bit0 是 "free" 标记 */
} BlockLink_t;
写坏的正是这个头的 pxNextFreeBlock。下一次任意任务调 pvPortMalloc 时,链表遍历到这个被 JSON 字符串覆盖的指针,跳到 0x7B226E22({"n" 的 ASCII)取 xBlockSize,读到垃圾长度,切出一块横跨整个 SRAM 的"空闲块",随后两次分配返回重叠地址------这才是"三种死法"的来源:踩到 OLED 帧缓冲(花屏/冻结)、踩到 TCB(调度器崩)、踩到电机方向标志(单方向失控)。
Day 6-8:高温箱 + 黑匣子闭环
把 Bug001 的黑匣子模块移植到 P2(Flash 末页 0x0801F000,双槽),高温箱 70℃ 连续跑 200 台 × 4h:
- 崩溃记录 187 条,
PC集中在 3 个地址,全部落在pvPortMalloc与prvInsertBlockIntoFreeList内; CFSR读出0x00008200(bit15BFARVALID+ bit9PRECISERR),BFAR = 0x7B226E22;- 反查
0x7B226E22:正是overheat_report()里snprintf写出的{"n":"开头 的第 3~6 个字节,证据闭合。
Day 9:为什么 V1.4.1 没事
git diff v1.4.1..v1.4.2 -- src/ctrl_task.c:过温上报是 V1.4.2 新加的功能,为了"一次把整帧发给上位机",开发在函数里开了 1024B 的局部数组。代码评审时看到"1024 < 1792,栈够",没人把调用链上三层的帧加起来。
✅ 根因分析
机理链条
电机连续运行 90s,推杆温升 → NTC 读数跨过 65℃
→ temp_state = TEMP_OVERHEAT
→ ctrl_task 调用 overheat_report()
→ 该函数在栈上开 uint8_t frame[1024]
→ 调用链栈需求 48+160+1264+232+280 = 1984B
→ ctrl_task 栈仅 1792B,向下溢出 192B
→ 覆盖 heap_4 中相邻块的 BlockLink_t.pxNextFreeBlock
→ 下次 pvPortMalloc 遍历空闲链表,解引用 0x7B226E22
→ 切出重叠块 / 读到垃圾长度 → 后续分配互相覆盖
→ 踩 OLED 帧缓冲(冻结)/ 踩 TCB(调度崩)/ 踩方向标志(失控)
为什么"只有高温"------三个候选,两个被证伪
| 候选机制 | 预期证据 | 实测 | 判定 |
|---|---|---|---|
| A. 高温使中断更密集,嵌套更深,压爆栈 | MSP 峰值随温度显著上升,且溢出点落在 MSP | 128B → 296B,MSP 余量 728B | 证伪(M0+ 异常走 MSP,与任务栈无关) |
| B. 高温使 SRAM 时序余量不足,位翻转 | 死机地址随机、与代码路径无关 | 崩溃 PC 集中在 3 个地址,BFAR 恒为 0x7B226E22 | 证伪(-40~85℃ 全温区 SRAM 测试 0 错) |
| C. 高温跨过状态机门限,进入大栈帧分支 | 栈水位在某个温度点阶跃 | 64℃ 1048B → 65℃ 1984B,阶跃 936B | 成立 |
候选 A 是我们写进设计文档的"常识",也是最大的认知陷阱:它把"中断更密集"和"任务栈更深"混为一谈。中断密度只影响 CPU 占用和响应抖动,不影响 PSP 栈深;会因中断变多而恶化的是 MSP,而 MSP 我们根本没做预算。
栈布局实测(65℃,溢出瞬间)
0x20002EFF ┌──────────────┐ ← 栈顶(创建时 SP)
│ ctrl_task │ 48B 局部变量、返回地址
├──────────────┤
│ motor_ramp │ 160B
├──────────────┤
│overheat_report│ 1264B 含 frame[1024]
├──────────────┤
│ log_pack │ 232B
├──────────────┤
│ snprintf 内部 │ 280B
0x20002800 ├──────────────┤ ← 栈底(预算边界)
│ 溢出 192B │ frame 尾部字节
0x20002740 ├──────────────┤
│BlockLink_t │ 8B ← pxNextFreeBlock 写成 0x7B226E22
├──────────────┤
│ 上一个堆块数据 │
└──────────────┘
三层根因
| 层级 | 内容 |
|---|---|
| 直接原因 | overheat_report() 的 1024B 局部数组使调用链栈需求达 1984B,超过 ctrl_task 的 1792B,向下溢出 192B 覆盖 heap_4 块头 |
| 根本原因 | 栈预算按"单函数帧大小"评估,而不是"调用链最长路径求和";xTaskCreate 的栈深度参数是拍脑袋定的 448,没有 .su 数据支撑,也没有任何运行时水位监控 |
| 流出原因 | ① 高温工况不在研发自测矩阵里(只测 25℃ 和 45℃);② 无栈水位上报,现场只能看到"死机";③ configASSERT 定义为 while(1),堆损坏时直接卡死而不是留下记录;④ 1024B 的 JSON 帧完全没必要一次成型 |
5Why
- 为什么高温死机?→
ctrl_task栈溢出,踩坏堆块头,后续pvPortMalloc返回重叠内存。 - 为什么栈溢出?→ 过温分支的调用链需要 1984B,任务只给了 1792B。
- 为什么只给了 1792B?→ 建任务时按"看起来够"估的,没算调用链,也没看
.su。 - 为什么过温分支这么吃栈?→ 为了"一次发整帧",在栈上开了
uint8_t frame[1024]并调snprintf。 - 为什么没早发现?→ 测试温度上限 45℃,且没有任何栈水位可观测手段,问题只在现场高温环境暴露。
🛠️ 修复方案
止血(当天,V1.4.2-hotfix3,灰度 5%)
两条,都是改几行、可当晚 OTA:
c
/* ① frame 搬出栈:单任务独占,加 static 不改变行为,只改变存放位置 */
static uint8_t s_frame[1024]; /* BSS,占 1KB,从栈里挪出来 */
static uint8_t s_report_busy; /* static 缓冲必须自己防重入 */
int32_t overheat_report(void)
{
int32_t n;
if (s_report_busy != 0u) {
return ERR_BUSY; /* -7 */
}
s_report_busy = 1u;
n = snprintf((char *)s_frame, sizeof(s_frame),
"{\"n\":\"%s\",\"t\":%d}", s_dev_sn, s_temp_c);
if ((n < 0) || ((uint32_t)n >= sizeof(s_frame))) {
s_report_busy = 0u;
LOG_E("overheat frame truncated, n=%d", n);
return ERR_OVERFLOW; /* -9 */
}
(void)uart_send_block(s_frame, (uint32_t)n);
s_report_busy = 0u;
return ERR_OK;
}
c
/* ② 栈临时加大:448 → 640 words(2560B),给止血版留 576B 余量 */
#define CTRL_TASK_STACK_WORDS 640u
if (xTaskCreate(ctrl_task, "ctrl", CTRL_TASK_STACK_WORDS,
NULL, 4, NULL) != pdPASS) {
LOG_E("ctrl task create failed");
return ERR_NO_MEM; /* -3 */
}
static 的代价是 1KB BSS,而 P2 的 SRAM 只剩 1.6KB 空闲------所以止血版必须同时 把 OLED 双缓冲从 2×1024B 降为 1×1024B + 局部刷新,腾出 1KB。这一步在 hotfix 里一起发,否则链接直接报 region RAM overflowed by 412 bytes。
根治(V1.4.3)
① 过温帧改分段发送,彻底不占大缓冲
c
/* report.c ------ 分段发送,峰值栈 96B,不依赖 snprintf 的大内部缓冲 */
#include <stdint.h>
#include <stdio.h>
#include "report.h"
#include "uart.h"
#include "log.h"
#define REPORT_CHUNK_MAX 64u
static int32_t report_put(uart_port_t port, const char *s, uint32_t *sent)
{
uint32_t len = 0u;
while ((s[len] != '\0') && (len < REPORT_CHUNK_MAX)) {
len++;
}
if (len == REPORT_CHUNK_MAX) {
return ERR_OVERFLOW; /* -9,串没以 0 结尾,拒绝发 */
}
if (uart_send_block(port, (const uint8_t *)s, len) != ERR_OK) {
return ERR_IO; /* -5 */
}
*sent += len;
return ERR_OK;
}
int32_t overheat_report(uart_port_t port, const char *sn, int16_t temp_c,
uint32_t *out_len)
{
char num[16];
uint32_t sent = 0u;
int32_t n;
int32_t ret;
if ((sn == NULL) || (out_len == NULL)) {
return ERR_PARAM; /* -1 */
}
*out_len = 0u;
ret = report_put(port, "{\"n\":\"", &sent);
if (ret != ERR_OK) {
return ret;
}
ret = report_put(port, sn, &sent);
if (ret != ERR_OK) {
return ret;
}
n = snprintf(num, sizeof(num), "\",\"t\":%d}", (int)temp_c);
if ((n <= 0) || ((uint32_t)n >= sizeof(num))) {
return ERR_OVERFLOW;
}
ret = report_put(port, num, &sent);
if (ret != ERR_OK) {
return ret;
}
*out_len = sent;
return ERR_OK;
}
② 栈探针模块(stack_probe.c)------把"水位"变成每台设备都有的指标
下面是完整可编译单元,GCC 12.2 / ARMCC 5.06 均可编,依赖 FreeRTOS 10.4.3 的 pxTaskGetStackStart()。
c
/* stack_probe.h */
#ifndef STACK_PROBE_H
#define STACK_PROBE_H
#include <stdint.h>
#include "FreeRTOS.h"
#include "task.h"
#define STACK_PATTERN 0xDEADBEEFu
#define STACK_GUARD_WORDS 8u
#define STACK_WARN_PERCENT 15u
#define STACK_PROBE_MAX 8u
typedef struct {
TaskHandle_t handle;
uint32_t total_bytes;
uint32_t peak_used_bytes;
uint32_t budget_bytes;
const char *name;
} stack_probe_t;
int32_t stack_probe_register(stack_probe_t *p, TaskHandle_t t,
uint32_t budget_bytes, const char *name);
void stack_probe_fill_here(stack_probe_t *p);
int32_t stack_probe_scan(stack_probe_t *p);
uint32_t stack_probe_margin_percent(const stack_probe_t *p);
void stack_probe_report_all(void);
#endif /* STACK_PROBE_H */
c
/* stack_probe.c ------ 任务栈水位探针:填充 + 扫描 + 余量告警 */
#include "stack_probe.h"
#include "log.h"
static stack_probe_t *s_probes[STACK_PROBE_MAX];
static uint32_t s_probe_count;
int32_t stack_probe_register(stack_probe_t *p, TaskHandle_t t,
uint32_t budget_bytes, const char *name)
{
if ((p == NULL) || (t == NULL) || (s_probe_count >= STACK_PROBE_MAX)) {
return -1;
}
if (pxTaskGetStackStart(t) == NULL) {
return -2;
}
p->handle = t;
p->total_bytes = 0u; /* 由 fill_here 按真实 SP 计算 */
p->peak_used_bytes = 0u;
p->budget_bytes = budget_bytes;
p->name = name;
s_probes[s_probe_count] = p;
s_probe_count++;
return 0;
}
/* 必须在任务函数第一句调用:从当前 SP 向下填到栈底 */
void stack_probe_fill_here(stack_probe_t *p)
{
uint32_t here;
uint8_t *start;
uint32_t *w;
uint32_t words;
uint32_t i;
if (p == NULL) {
return;
}
here = (uint32_t)&here;
start = pxTaskGetStackStart(p->handle);
if (start == NULL) {
return;
}
if (here <= (uint32_t)start) {
return;
}
p->total_bytes = here - (uint32_t)start;
if (p->total_bytes <= (STACK_GUARD_WORDS * 4u)) {
return;
}
w = (uint32_t *)((here - 32u) & 0xFFFFFFFCu); /* 留 8 字给当前帧并对齐 */
words = ((here - 32u - (uint32_t)start) / 4u);
for (i = 0u; i < words; i++) {
w[-(int32_t)i] = STACK_PATTERN;
}
}
/* 返回本轮扫描的水位(字节),并更新峰值 */
int32_t stack_probe_scan(stack_probe_t *p)
{
const uint32_t *base;
uint32_t words;
uint32_t i;
uint32_t used;
if ((p == NULL) || (p->total_bytes == 0u)) {
return -1;
}
base = (const uint32_t *)pxTaskGetStackStart(p->handle);
if (base == NULL) {
return -2;
}
words = p->total_bytes / 4u;
for (i = 0u; i < words; i++) {
if (base[i] != STACK_PATTERN) {
break;
}
}
used = p->total_bytes - (i * 4u);
if (used > p->peak_used_bytes) {
p->peak_used_bytes = used;
}
if (used > p->budget_bytes) {
LOG_E("stack over budget: %s used=%u budget=%u",
p->name, used, p->budget_bytes);
return -3;
}
return (int32_t)used;
}
uint32_t stack_probe_margin_percent(const stack_probe_t *p)
{
if ((p == NULL) || (p->total_bytes == 0u)) {
return 0u;
}
return ((p->total_bytes - p->peak_used_bytes) * 100u) / p->total_bytes;
}
void stack_probe_report_all(void)
{
uint32_t i;
int32_t used;
for (i = 0u; i < s_probe_count; i++) {
used = stack_probe_scan(s_probes[i]);
LOG_I("stack %s used=%d total=%u peak=%u margin=%u%%",
s_probes[i]->name, used, s_probes[i]->total_bytes,
s_probes[i]->peak_used_bytes,
stack_probe_margin_percent(s_probes[i]));
if (stack_probe_margin_percent(s_probes[i]) < STACK_WARN_PERCENT) {
LOG_W("stack margin low: %s", s_probes[i]->name);
}
}
}
调用方式(fill_here 必须是任务函数体第一句,早于任何函数调用):
c
static stack_probe_t s_probe_ctrl;
static void ctrl_task(void *arg)
{
(void)arg;
stack_probe_fill_here(&s_probe_ctrl);
for (;;) {
(void)ctrl_loop_once();
vTaskDelay(pdMS_TO_TICKS(2));
}
}
③ 编译期卡住"大栈帧"
makefile
CFLAGS += -fstack-usage
CFLAGS += -Wframe-larger-than=256 # 单帧超 256B 直接编译失败
overheat_report 改完之后单帧 96B,-Wframe-larger-than=256 一次通过;如果哪天有人再写 uint8_t buf[512],CI 立刻红。
📊 验证数据
| 指标 | 条件 | 样本量 | 改前 V1.4.2 | 改后 V1.4.3 | 结论 |
|---|---|---|---|---|---|
| 高温死机率 | 70℃ 高温箱 4h | 200 台 | 63/200(31.5%) | 0/200 | 溢出路径消除 |
ctrl_task 栈峰值 |
65℃ 过温分支 | 单机 1000 次 | 1984B(溢出 192B) | 1128B | 降 856B,余量 1432B |
| 栈余量 | 同上 | --- | -10.7% | 55.9% | 超过 15% 告警线 |
| 过温帧长度 | JSON 38 字节 | 1000 帧 | 1 次 snprintf,峰值栈 280B |
3 段,峰值栈 96B | 帧内容逐字节一致 |
| 堆完整性 | 连续 pvPortMalloc 10000 次 |
50 台 | 187 次链表异常 | 0 | BlockLink_t 不再被踩 |
| RAM 占用 | arm-none-eabi-size |
--- | 14.9KB / 16KB | 14.5KB / 16KB | 降 412B(OLED 单缓冲) |
| 现场高温季客诉 | 实际出货 28 天 | 1200 台 | 5.25%(63 台) | 0(60 天 0 例) | 闭环 |
| 常温长跑 | 25℃ 72h | 120 台 | 0 例 | 0 例 | 无回归 |
灰度节奏:5%(60 台,7 天,正值 7 月高温)→ 20%(240 台,14 天)→ 100%。每档看两个指标:stack_margin 上报值(必须全部 >15%)、fault_count() 增量(必须为 0)。
🛡️ 防御设计
第一层:编译期/静态检查
| 手段 | 具体配置 | 拦住什么 |
|---|---|---|
| 单帧上限 | -Wframe-larger-than=256 |
任何 >256B 的局部数组,编译即失败 |
| 栈用量清单 | -fstack-usage,CI 解析 .su 与预算表比对 |
帧大小变化无人察觉 |
| MISRA C:2012 | Rule 18.1(指针算术不越界)、Dir 4.1(最小化运行时故障) | 数组下标与指针偏移 |
| pc-lint | 告警 818(pointer arithmetic)、9047(large auto object) | 大自动变量 |
| 链接期 | 链接脚本 ASSERT(_estack - _sstack >= 0x400, "MSP too small") |
主栈被改小 |
CI 里加一条硬门禁:.su 汇总表的"最长调用链"超过对应任务栈的 70% 就 fail。这条规则第一次跑就抓出了 3 个历史函数(最大 ota_pack 812B)。
第二层:运行时断言与自恢复
c
/* 每 10s 由 monitor_task 调一次;超预算不重启,先降级再记录 */
void stack_watchdog(void)
{
uint32_t i;
stack_probe_t *p;
for (i = 0u; i < stack_probe_count(); i++) {
p = stack_probe_at(i);
if (stack_probe_scan(p) < 0) {
g_diag.stack_over_cnt++;
LOG_E("stack over: %s cnt=%u", p->name, g_diag.stack_over_cnt);
if (g_diag.stack_over_cnt >= 3u) {
vTaskSuspend(g_handle_ui); /* 停非关键任务,保住控制与通信 */
g_diag.degraded = 1u;
report_async(EVT_STACK_DEGRADED);
}
}
}
}
配套两条:
- MSP 也要管 。进调度器前把
_sstack到当前 SP 之间填0xDEADBEEF,stack_watchdog里同样扫一遍,阈值取 1024B 的 60%(614B)。这次没炸 MSP 是运气,不是设计。 - 堆损坏早发现 。
configASSERT从while(1)改成"写黑匣子 +NVIC_SystemReset()";pvPortMalloc返回块要校验xBlockSize的 bit0(free 标记)是否为 0,不一致就记HEAP_CORRUPT并停用该块。
第三层:单元测试(Unity,CI 每次提交跑)
c
/* test_stack_probe.c ------ PC 上用桩替换 pxTaskGetStackStart */
#include <string.h>
#include "unity.h"
#include "stack_probe.h"
static uint32_t s_fake_stack[128];
static stack_probe_t s_p;
uint8_t *pxTaskGetStackStart(TaskHandle_t t)
{
(void)t;
return (uint8_t *)s_fake_stack;
}
void setUp(void)
{
uint32_t i;
for (i = 0u; i < 128u; i++) {
s_fake_stack[i] = STACK_PATTERN;
}
memset(&s_p, 0, sizeof(s_p));
s_p.handle = (TaskHandle_t)1;
s_p.total_bytes = sizeof(s_fake_stack);
s_p.budget_bytes = sizeof(s_fake_stack) * 3u / 4u;
s_p.name = "ut";
}
void test_scan_all_pattern_means_zero_used(void)
{
TEST_ASSERT_EQUAL_INT32(0, stack_probe_scan(&s_p));
TEST_ASSERT_EQUAL_UINT32(100u, stack_probe_margin_percent(&s_p));
}
void test_scan_detects_used_depth(void)
{
s_fake_stack[0] = 0x12345678u; /* 栈底两字被写过 → 用了 512B */
s_fake_stack[1] = 0x9ABCDEF0u;
TEST_ASSERT_EQUAL_INT32(512, stack_probe_scan(&s_p));
TEST_ASSERT_EQUAL_UINT32(0u, stack_probe_margin_percent(&s_p));
}
void test_over_budget_returns_error(void)
{
uint32_t i;
for (i = 0u; i < 100u; i++) {
s_fake_stack[i] = 0u;
}
TEST_ASSERT_EQUAL_INT32(-3, stack_probe_scan(&s_p));
}
void test_peak_is_monotonic(void)
{
s_fake_stack[60] = 1u;
(void)stack_probe_scan(&s_p);
s_fake_stack[60] = STACK_PATTERN; /* 内容恢复,峰值不能回落 */
(void)stack_probe_scan(&s_p);
TEST_ASSERT_EQUAL_UINT32(272u, s_p.peak_used_bytes);
}
另外必须有"最坏路径"集成测试:HIL 台上用程控电阻把 NTC 模拟到 65℃,跑 500 次过温上报,断言 stack_probe_margin_percent() > 20% 且 fault_count() == 0。这条用例如果三年前就有,这个 Bug 根本出不了厂。
第四层:产线工装拦截
| 工位 | 判定项 | 阈值 | 动作 |
|---|---|---|---|
| 烧录后自检 | stack_probe_report_all() 最小余量 |
≥30% | <30% 判 FAIL,打印任务名 |
| 老化 4h(60℃) | 最小余量 | ≥20% | <20% 判 FAIL 并转返修 |
| 过温流程专测 | 强制 65℃ + 电机 90s,跑 20 次 | 余量 ≥15%,fault_count=0 |
任一不满足判 FAIL |
| 出厂 | BKP 中 SFTRSTF 历史计数 |
=0 | >0 判 FAIL(曾软复位过) |
工装脚本读 RTT 一行 stack <任务名> used=<n> margin=NN%,正则取值直接判,不需要上位机改代码。
第五层:云端监控
心跳里带上 min_stack_margin、stack_over_cnt、heap_corrupt_cnt 三个字段。告警规则:
- 单台
min_stack_margin < 15%连续 3 个心跳 → 生成工单; - 同一固件版本 24h 内
heap_corrupt_cnt > 0的设备 ≥3 台 → 批次告警并暂停灰度; stack_over_cnt日增量同比涨 50% → 自动回滚上一版。
📚 同类 Bug 规避清单
- 每个
xTaskCreate的栈深度必须有.su最长调用链求和作为依据,并留 ≥50% 余量 - 局部数组 >256B 一律禁止,编译期用
-Wframe-larger-than=256卡死 -
snprintf/sprintf要单独计入栈预算(newlib-nano 格式化 6 字段约 280B) -
static大缓冲必须配busy标志防重入,否则单任务也会踩自己 - 所有任务入口第一句调
stack_probe_fill_here(),把水位变成常规日志 - MSP 也要填魔术字扫描,不能只盯任务栈
- Cortex-M0+ 上分清"任务栈(PSP)"和"异常栈(MSP)",两者互不相干
- 状态机门限(如 65℃)两侧要各有测试点,64℃ 和 66℃ 都要跑
- 高温测试点至少覆盖 25/45/60/70℃,每点跑够状态切换时间(≥10 分钟)
-
configASSERT量产版不允许while(1),必须"记录 + 复位" - 堆块头
xBlockSize低位标记做运行时校验,堆损坏要能报出来 - 发布包里保留
.su与.map,否则事后无法复算栈需求
💡 工程师复盘小结
最贵的不是那 1024 字节,是"栈够不够"这个问题我们从来没有量化过。建任务时写 448 是拍脑袋,评审时看到 1024 < 1792 就点了通过------没人把调用链上四层的帧加起来 。.su 文件本来一直在编译产物里躺着,九年没人打开过。
第二个坑更隐蔽:我们把"高温 → 中断变密 → 栈溢出"当成了常识写进设计文档。这条推理在 Cortex-M4 上勉强能成立(中断也可能压 PSP),在 M0+ 上根本不成立,因为异常永远走 MSP。一个错误的架构认知,会让整个团队朝错误方向找两天。真正让我们转弯的不是灵感,是"把主栈缩到 384B 强制造溢出,看死机现象对不对得上"这个对照实验。
第三个:0xDEADBEEF 这种土办法,在没有 MPU 的 M0+ 上依然是性价比最高的栈观测手段,成本 20 行代码。它给出的不是"大概",是一条 64℃=1048B、65℃=1984B 的阶跃曲线------有了这条曲线,根因不用猜。
🎤 面试延伸(3 问 3 答)
Q1:Cortex-M0+ 上任务栈溢出和中断嵌套加深,是同一件事吗?
A:不是。FreeRTOS 在 M0+ 上用 PSP 跑任务、MSP 跑所有异常,MSPLIM/PSPLIM 这两个栈限寄存器 M0+ 根本没有(M23/M33 才有)。所以中断再密集,压的是 MSP,跟任务栈(PSP)完全不共享。本例中 ADC 中断从 200Hz 提到 1kHz,MSP 峰值只从 128B 涨到 296B,任务栈纹丝不动。反过来说,如果 ISR 里调用了大栈函数,MSP 会炸,而 MSP 通常只有 1KB 且没有任何监控------这是比任务栈更容易被忽略的坑。
Q2:为什么不用 MPU 保护栈,而用魔术字填充?
A:PY32F071 的 M0+ 核没配 MPU(可选组件,这颗没做)。魔术字方案能给出"用了多少"的连续数值,适合做趋势监控;MPU 只能给"越界了/没越界"的二值答案,而且要在每次任务切换时重配 MPU 区域,上下文切换开销 +1.8µs(24MHz 下 43 周期),对 1kHz 控制环太重。有 MPU 的 M33 平台我们会两者都用:MPU 抓瞬时越界,魔术字看长期水位。
Q3:把 frame[1024] 改成 static 有什么风险?
A:三个。① 重入:两个任务同时调 overheat_report() 会互相覆盖,必须加 busy 标志或互斥;② 递归或中断上下文调用同样危险;③ 静态缓冲不随任务结束释放,如果这个函数被复制到多个模块,1KB 会变成 3KB。所以根治版改成 64B 分段发送,峰值栈 96B,连 static 都不需要。static 只是止血------它把"栈溢出"换成了"重入风险",是拿一个已知问题换一个可控问题。
📖 参考资料
- ARM Cortex-M0+ Devices Generic User Guide (ARM DUI 0662C),异常模型、MSP/PSP 使用规则、异常入栈帧(8 字)
- ARMv6-M Architecture Reference Manual (ARM DDI 0419E),栈对齐与异常压栈章节
- FreeRTOS V10.4.3 Reference Manual,
xTaskCreate、uxTaskGetStackHighWaterMark、pxTaskGetStackStartAPI 说明 - FreeRTOS 官方文档 Memory Management 章节,heap_4 块头
BlockLink_t与相邻块合并算法 - GCC 12.2 手册
-fstack-usage、-Wframe-larger-than选项说明 - MISRA C:2012 Directive 4.1(最小化运行时故障)、Rule 18.1(指针算术边界)
- PY32F071 系列参考手册(Puya),SRAM 容量、启动文件
Stack_Size定义、NTC 采样通道配置 - IEC 60730-1 附录 H(Class B 软件自检),栈与内存自检作为诊断措施的论证思路
栈溢出最坏的地方不是死机,是它踩的东西离现场很远 ------这次踩的是堆块头,崩溃点在
pvPortMalloc,跟真正的凶手隔了三层调用。下一篇 Bug003 是另一种"现场与凶手分离":中断和任务共享一个结构体,谁都没写错,但读出来的值就是把两个时刻的数据拼在了一起。