嵌入式故障诊断-Bug002-局部大数组压栈高温必死机

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 分钟出结果):

  1. 整机上电,进入手动模式,推杆挂 80kg 配重连续伸 90s;
  2. 高温箱设定 70℃,等 NTC 读数稳定过 65℃(约 4 分钟);
  3. 再按一次"平躺",观察:约 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(bit15 BFARVALID + bit9 PRECISERR),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

  1. 为什么高温死机?→ ctrl_task 栈溢出,踩坏堆块头,后续 pvPortMalloc 返回重叠内存。
  2. 为什么栈溢出?→ 过温分支的调用链需要 1984B,任务只给了 1792B。
  3. 为什么只给了 1792B?→ 建任务时按"看起来够"估的,没算调用链,也没看 .su。
  4. 为什么过温分支这么吃栈?→ 为了"一次发整帧",在栈上开了 uint8_t frame[1024] 并调 snprintf。
  5. 为什么没早发现?→ 测试温度上限 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、pxTaskGetStackStart API 说明
  • 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 是另一种"现场与凶手分离":中断和任务共享一个结构体,谁都没写错,但读出来的值就是把两个时刻的数据拼在了一起。

相关推荐
Zwawa3 小时前
嵌入式OLED表驱动菜单:用一张表、一个栈和一个窗口重写表驱动菜单
c语言·单片机·嵌入式
Dev_XH4 小时前
【成品设计】基于沁恒单片机的电动汽车充电站智慧调度系统
stm32·单片机·嵌入式硬件·物联网
shimly1234564 小时前
(done) 嵌入式硬件电路图 (5) 重要的元器件特性之二极管三极管
嵌入式硬件
Zw-awa6 小时前
嵌入式OLED表驱动菜单:用一张表、一个栈和一个窗口重写表驱动菜单
stm32
minglie16 小时前
esp32c3+atshell测试ov7670
单片机·嵌入式硬件
橘为己有4417 小时前
51单片机:流水灯
单片机·51单片机
蜀黍@猿7 小时前
ARM 汇编入门(十):标量浮点与 VFP 指令全解析
汇编·arm开发·stm32·mcu
乐讯通物联网服务商7 小时前
可视烟感器是怎么组网报警的?联网型烟感与物联网卡的数据链路解析
物联网·物联网卡·烟感器
不脱发的程序猿7 小时前
STM32和VS1053制作电子琴
stm32·单片机·嵌入式硬件·midi·vs1053