30、综合实战:从崩溃到修复的完整案例

前面讲了那么多理论、工具和技巧,今天咱们来点真格的。我带大家走一遍完整的故障排查流程——从一个程序崩溃开始,到最终定位并修复问题。这个案例是我去年在项目中真实遇到的,当时折腾了我整整一个下午。

嗯,咱们先看看“案发现场”。

30.1 崩溃现场:程序跑着跑着就挂了

那是一个嵌入式数据采集系统,主控芯片是STM32F407。功能很简单:通过SPI读取传感器数据,处理后通过串口打印。程序跑了大概3分钟后,突然卡死。看门狗复位后,又跑3分钟,又卡死。典型的“周期性崩溃”。

我拿到日志,最后几行是这样的:

Sensor ID: 0x5A, Temp: 25.3°C, Humidity: 68.2%
Sensor ID: 0x5A, Temp: 25.4°C, Humidity: 68.1%
Sensor ID: 0x5A, Temp: 25.3°C, Humidity: 68.3%
// 这里突然没了,没有下一行

没有异常打印,没有错误码。就是突然断了。我第一反应是:内存问题。因为周期性崩溃,很可能是内存泄漏或者堆栈溢出。

核心思路:崩溃类问题,先看是不是内存问题。内存泄漏、栈溢出、野指针,这三兄弟占了嵌入式崩溃的80%以上。

30.2 第一步:检查堆栈使用情况

我习惯先查栈。因为栈溢出是最容易忽略的。STM32的默认栈大小通常是0x400(1024字节),如果你的函数嵌套深、局部变量大,很容易爆栈。

我在代码里加了一段栈水位检测:

// 栈底标记法:在任务启动时,把栈区域全部填充为0xDEAD
// 然后定期扫描,看还有多少0xDEAD没被覆盖
uint32_t GetStackFree(void)
{
    extern uint32_t _estack;  // 栈顶地址(由链接脚本定义)
    extern uint32_t _sstack;  // 栈底地址
    uint32_t *p = (uint32_t *)_sstack;
    uint32_t count = 0;
    while (*p == 0xDEADDEAD)
    {
        count++;
        p++;
        if ((uint32_t)p >= (uint32_t)&_estack)
            break;
    }
    return count * 4;  // 返回剩余字节数
}

跑起来一看,崩溃前栈剩余只有48字节。栈总共1024字节,用了976字节。这太危险了。一旦某个中断嵌套深一点,直接爆栈。

注意:栈溢出不会立刻报错,它可能先踩坏局部变量,导致逻辑异常,然后才崩溃。所以排查时,栈水位检测要提前加进去。

30.3 第二步:定位栈消耗大户

栈用了976字节,谁吃的?我打开.map文件,看每个函数的栈使用情况。发现一个函数特别扎眼:

ProcessSensorData    0x08001234    0x00000320    (800字节局部变量)

800字节!一个函数就占了栈的80%。我点开这个函数一看:

void ProcessSensorData(uint8_t *raw, uint16_t len)
{
    uint8_t buffer[512];   // 大数组
    uint8_t temp[256];     // 又一个
    // ... 处理逻辑
}

两个局部数组加起来768字节,再加上其他变量和函数调用开销,800字节没跑了。我当时就笑了——这代码是实习生写的吧?

30.4 第三步:修复方案——从栈搬到堆

修复很简单:把大数组从栈上搬到堆上,或者改成静态全局变量。我选了堆,因为全局变量在多任务环境下容易出问题。

void ProcessSensorData(uint8_t *raw, uint16_t len)
{
    uint8_t *buffer = (uint8_t *)malloc(512);
    uint8_t *temp = (uint8_t *)malloc(256);
    if (!buffer || !temp)
    {
        // 内存分配失败处理
        free(buffer);
        free(temp);
        return;
    }

    // ... 处理逻辑 ...

    free(buffer);
    free(temp);
}

改完之后,栈剩余从48字节飙升到720字节。再跑3分钟,没崩。跑30分钟,也没崩。问题解决了?

等等,别高兴太早。

30.5 隐藏的坑:malloc/free的碎片化问题

程序跑了2小时后,又崩了。这次不是栈问题,而是堆内存碎片化。malloc返回了NULL,但我的错误处理只是return,没有正确处理后续逻辑,导致系统进入异常状态。

为什么会这样?因为我在ProcessSensorData里频繁malloc/free,每次512+256字节。跑2小时,分配了几千次。堆被切成碎片,再也找不到连续512字节的空闲块。

我后来换了个方案:使用静态池。预先分配好固定大小的内存块,用位图管理。

// 静态内存池
#define POOL_SIZE  10
#define BLOCK_SIZE 512

static uint8_t pool[POOL_SIZE][BLOCK_SIZE];
static uint32_t pool_bitmap = 0;  // 位图,0表示空闲

void *PoolAlloc(void)
{
    for (int i = 0; i < POOL_SIZE; i++)
    {
        if (!(pool_bitmap & (1 << i)))
        {
            pool_bitmap |= (1 << i);
            return pool[i];
        }
    }
    return NULL;  // 池满
}

void PoolFree(void *ptr)
{
    for (int i = 0; i < POOL_SIZE; i++)
    {
        if (ptr == pool[i])
        {
            pool_bitmap &= ~(1 << i);
            return;
        }
    }
}

用静态池之后,分配和释放都是O(1)复杂度,没有碎片问题。程序跑了72小时,稳如老狗。

我的经验:在嵌入式系统里,能不动态分配就别动态分配。静态池、环形缓冲区、预分配数组,这些才是嵌入式的好朋友。malloc/free在PC上没问题,但在资源受限的MCU上,碎片化会让你痛不欲生。

30.6 整个排查流程总结

咱们把这次排查的路径画成一张图,方便你以后遇到类似问题直接套用:

崩溃排查流程图 程序崩溃 检查栈使用情况 定位栈消耗大户 大数组搬到堆/静态区 堆碎片化导致二次崩溃 改用静态内存池

你看,整个排查过程其实就三步:

  1. 先看栈——加水位检测,确认是不是栈溢出
  2. 再看堆——如果用了动态分配,检查碎片化
  3. 最后改设计——用静态方案替代动态方案

30.7 这次实战给我的教训

说实话,这个案例让我重新审视了自己的编码习惯。我以前也喜欢在函数里定义大数组,觉得方便。但这次之后,我给自己定了个规矩:

  • 局部数组超过128字节,一律用静态或堆分配
  • 嵌入式项目尽量不用malloc/free,改用内存池
  • 每个任务至少留30%的栈余量

我曾经以为“跑起来没问题”就是没问题。但嵌入式系统讲究的是长期稳定性。跑3分钟没问题,不代表跑3天没问题。内存问题就像慢性病,早期没症状,等发现时已经晚了。

记住:崩溃不可怕,可怕的是你不知道为什么崩溃。工具链(map文件、栈检测、内存池)是你的眼睛,用好它们,你就能看到代码背后的真相。

好了,这个案例就讲到这里。下次你遇到程序周期性崩溃,别急着怀疑硬件。先查栈,再查堆,大概率能找到问题。


公众号:蓝海资料掘金营,微信deep3321