3. Core Dump 与堆栈回溯:让死机现场“开口说话”

嵌入式开发最怕什么?不是逻辑复杂,而是程序跑着跑着突然挂了,连个招呼都不打。更气人的是,重新上电它又好了——这种“幽灵故障”最让人头疼。

Core Dump 和堆栈回溯,就是用来对付这种问题的。说白了,它能把死机那一刻的现场完整保存下来,然后我们像侦探一样,一步步还原出到底哪行代码踩了雷。

3.1 Core Dump 文件生成与配置

Core Dump 是什么?简单讲,就是程序崩溃时,操作系统把内存、寄存器、堆栈等信息打包成一个文件。这个文件就是“案发现场的照片”。

我刚开始做嵌入式时,总觉得 Core Dump 是 Linux 桌面才用的东西。后来在一个 RTOS 项目里被 HardFault 折磨了两周,才意识到——没有 Core Dump,你就是在黑灯瞎火里找针。

3.1.1 生成条件与配置

要让系统生成 Core Dump,需要满足几个条件:

  • 操作系统支持:Linux、FreeRTOS+堆栈保存扩展、部分 RT-Thread 版本
  • 资源预留:必须有足够的存储空间(Flash 或外部存储)
  • 信号处理:程序没有屏蔽 SIGSEGV、SIGABRT 等关键信号

在 Linux 嵌入式环境中,我习惯这样配置:

# 设置 Core Dump 文件大小不限
ulimit -c unlimited

# 指定 Core Dump 文件名格式(带 PID 和时间)
echo "/tmp/core-%e-%p-%t" > /proc/sys/kernel/core_pattern

# 临时开启(重启失效)
echo 1 > /proc/sys/kernel/core_uses_pid
我的习惯:在产品开发阶段,我会把 ulimit -c unlimited 直接写到 /etc/profile 或启动脚本里。否则调试到一半发现没开,那才叫欲哭无泪。

3.1.2 裸机/RTOS 下的“轻量级 Core Dump”

没有 Linux 那么完善的内存管理怎么办?别急,我们可以自己造一个。

我记得在一个 STM32 项目上,我写了个简单的异常钩子:

void HardFault_Handler(void) {
    // 1. 读取 MSP 和 PSP
    uint32_t msp = __get_MSP();
    uint32_t psp = __get_PSP();

    // 2. 保存关键寄存器到预留的 RAM 区域
    fault_regs.r0  = __get_REG(0);
    fault_regs.r1  = __get_REG(1);
    fault_regs.r2  = __get_REG(2);
    fault_regs.r3  = __get_REG(3);
    fault_regs.r12 = __get_REG(12);
    fault_regs.lr  = __get_LR();
    fault_regs.pc  = __get_PC();
    fault_regs.psr = __get_xPSR();

    // 3. 把堆栈内容复制到 Flash 末尾的保留区
    memcpy((void*)FAULT_DUMP_ADDR, (void*)msp, 512);

    // 4. 触发看门狗复位或进入死循环等待调试器
    while(1);
}

这样,下次复位后,只要读取 FAULT_DUMP_ADDR 区域,就能拿到死机时的“快照”。

3.2 GDB 分析 Core Dump

有了 Core Dump 文件,接下来就是 GDB 登场了。GDB 分析 Core Dump 的核心命令其实不多,但每个都很有分量。

3.2.1 加载 Core Dump

# 基本用法
arm-none-eabi-gdb my_program.elf -c core.12345

# 或者进入 GDB 后手动加载
(gdb) file my_program.elf
(gdb) core-file core.12345

加载成功后,GDB 会自动停在崩溃时的位置。你会看到类似这样的输出:

Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x08001234 in my_function (ptr=0x0) at main.c:42
42          *ptr = 0xFF;

看到没?它直接告诉你:第 42 行,往空指针里写数据了。这就是 Core Dump 的价值——省去你反复猜测的时间。

3.2.2 bt 与 frame 命令详解

bt(backtrace) 是堆栈回溯的核心命令。它会把函数调用链从当前崩溃点一直追溯到 main 函数:

(gdb) bt
#0  0x08001234 in my_function (ptr=0x0) at main.c:42
#1  0x08001278 in process_data (buf=0x20000100) at main.c:58
#2  0x080012B4 in main () at main.c:72

这个输出告诉我们:main 调用了 process_data,process_data 又调用了 my_function,然后在 my_function 里崩溃了。

frame 命令 用来切换“视角”。比如我想看 process_data 当时的状态:

(gdb) frame 1
#1  0x08001278 in process_data (buf=0x20000100) at main.c:58
58          result = my_function(buf->data);

(gdb) info locals
buf = 0x20000100
result = 0

这样就能一层层往上查,看看到底是哪个参数传错了。

我曾经踩过的坑:有一次 bt 显示 #0 是 0xFFFFFFFF,明显是堆栈被踩烂了。这时候别慌,先检查 SP 指针是否在合法范围内,然后手动查看内存区域,往往能找到线索。

3.3 栈回溯原理与栈帧结构

要真正用好 bt 和 frame,得理解栈帧是怎么组织的。不然你看到的只是一堆地址,心里没底。

3.3.1 ARM Cortex-M 的栈帧结构

ARM Cortex-M 在进入异常时,硬件会自动压栈 8 个寄存器:

偏移 寄存器 说明
+0x00 R0 参数/返回值
+0x04 R1 参数
+0x08 R2 参数
+0x0C R3 参数
+0x10 R12 临时寄存器
+0x14 LR 返回地址
+0x18 PC 崩溃时的指令地址
+0x1C xPSR 程序状态寄存器

这个结构是硬件自动完成的,不需要软件干预。你想想看,硬件在死机前还帮你把现场保存得这么完整,是不是很贴心?

3.3.2 栈回溯的手动推导

如果 GDB 的 bt 因为堆栈损坏而失效,我们可以手动推导。我记得有一次调试,bt 输出全是乱码,我就是靠手动推导找到问题的:

// 假设 MSP = 0x20001000
// 查看堆栈内容
(gdb) x/16xw 0x20001000
0x20001000: 0x00000000  0x00000001  0x20000100  0x08001234
0x20001010: 0x0000000A  0x08001278  0x080012B4  0x21000000

// 根据栈帧结构解析:
// R0  = 0x00000000  → 空指针!
// PC  = 0x08001234  → 崩溃位置
// LR  = 0x08001278  → 返回地址

这样即使 GDB 的 bt 失灵,你也能靠“手工”还原出调用链。

3.4 实战:定位 HardFault

HardFault 是嵌入式开发者的“老朋友”了。它通常由以下原因引起:

  • 访问空指针或非法地址
  • 堆栈溢出(栈顶踩到了未映射区域)
  • 指令异常(比如在 ARM 模式下执行 Thumb 指令)
  • 总线错误(访问了不存在的内存)

3.4.1 实战步骤

假设我们有一个 Core Dump 文件,bt 输出如下:

(gdb) bt
#0  HardFault_Handler () at startup.c:45
#1  <signal handler called>
#2  0x080015A0 in memset (dst=0x20001000, c=0, n=65536) at memset.c:12
#3  0x08001234 in buffer_init (size=65536) at main.c:28
#4  0x08001278 in main () at main.c:52

看到 #2 的 memset 了吗?n=65536,明显是缓冲区大小溢出了。我检查一下 buffer_init 的代码:

(gdb) frame 3
(gdb) info locals
size = 65536
buffer = 0x20001000

(gdb) list main.c:28
28          memset(buffer, 0, size);

问题找到了:buffer 只有 32KB,但 memset 试图清零 64KB,直接踩到了栈保护区,触发了 HardFault。

定位 HardFault 的黄金法则:先看 PC 指针指向哪里,再看 LR 是谁调过来的,最后检查参数是否合理。三步走,90% 的 HardFault 都能快速定位。

3.4.2 避坑指南

我曾经在一个项目里遇到 HardFault,bt 显示崩溃点在 memset,但 memset 本身没问题。后来发现是堆栈溢出导致 LR 被覆盖,memset 返回时跳到了非法地址。所以,不要只看崩溃点,要看整个调用链是否合理。

另一个常见坑:优化选项开太高(比如 -O3),GDB 的 bt 可能不准。这时候我会把优化降到 -O1 重新编译一次,保留调试信息,等定位完问题再开回高优化。

3.5 知识结构图

下面这张图总结了 Core Dump 与堆栈回溯的核心流程:

Core Dump 与堆栈回溯知识结构 Core Dump 与堆栈回溯核心流程 程序崩溃 / HardFault 生成 Core Dump / 保存现场 GDB 加载 Core Dump bt 命令查看调用链 frame 切换栈帧 定位根因 / 修复代码 核心思路:保存现场 → 回溯调用链 → 分析参数 → 定位根因

嗯,这张图把整个流程串起来了。从程序崩溃开始,到生成 Core Dump,再到 GDB 加载,最后用 bt 和 frame 定位问题。每一步都有对应的工具和方法。

掌握了这些,下次遇到 HardFault,你就不会慌了。打开 Core Dump,敲几下 bt 和 frame,问题往往就浮出水面了。