3. 断言与异常捕获:从原理到实战

大家好,我是你们的老朋友。今天咱们聊一个看似基础、但实际项目中坑最多的话题——断言与异常捕获。说实话,我见过太多工程师把 assert 当成调试玩具,或者把 HardFault_Handler 当成「死机了重启一下」的摆设。这章咱们就把这些概念掰开揉碎了讲清楚。

3.1 断言机制原理:到底在「断」什么?

断言,说白了就是「我坚信这里不会出问题」。你写代码时假设某个条件一定成立,那就用 assert 把它写下来。如果运行时条件不成立,系统立刻停下来告诉你:喂,你的假设错了!

我在项目中遇到过最典型的场景:一个驱动函数要求传入的指针不能为 NULL,但调用方传了个 NULL 进来。如果没有断言,程序可能跑飞,或者更糟——静默地写坏内存,几个月后才发现。有了断言,当场就能定位。

核心思想:断言是开发阶段的「安全网」,不是运行时的「错误处理」。它帮你尽早发现逻辑错误,而不是掩盖问题。

3.2 assert 宏使用:别把它当 printf

标准 C 库的 assert 宏长这样:

#include <assert.h>

void set_gpio_mode(GPIO_TypeDef *gpio, uint8_t pin) {
    assert(gpio != NULL);          // 指针不能为空
    assert(pin < 16);              // 引脚号必须在 0-15
    // 实际配置代码...
}

但嵌入式环境里,标准 assert 有个大问题——它依赖 printf 和 abort()。很多 MCU 上根本没有标准输出。所以我个人习惯自己写一个:

#ifdef DEBUG_ENABLE
    #define ASSERT(cond) \
        do { \
            if (!(cond)) { \
                /* 记录文件名和行号 */ \
                assert_failed(__FILE__, __LINE__); \
                while(1); /* 或者触发断点 */ \
            } \
        } while(0)
#else
    #define ASSERT(cond) ((void)0)
#endif

注意看,生产版本里 ASSERT 被展开成空操作。这就是断言的关键原则:只在调试阶段生效,发布时完全消失。你想想看,如果断言留在产品里,用户操作时突然触发 while(1),那体验得多糟糕。

我的习惯:在 assert_failed 函数里,我会把出错的文件名和行号写入一个专用的错误寄存器,或者通过串口打印出来。这样即使板子死机了,也能通过调试器读出最后一条错误信息。

3.3 异常向量表配置:CPU 的「紧急联络人」

异常向量表,就是一张地址表。CPU 遇到异常时,会查这张表,找到对应的处理函数地址,然后跳过去执行。说白了,它就是 CPU 的紧急联络人名单。

以 Cortex-M 系列为例,向量表的前几个条目是固定的:

偏移地址 异常类型 优先级 说明
0x0000_0000 栈顶地址 - 复位后 SP 初始值
0x0000_0004 Reset_Handler -3(最高) 复位入口
0x0000_0008 NMI_Handler -2 不可屏蔽中断
0x0000_000C HardFault_Handler -1 硬错误,重点对象
0x0000_0010 MemManage_Handler 可编程 内存管理异常
0x0000_0014 BusFault_Handler 可编程 总线错误
0x0000_0018 UsageFault_Handler 可编程 用法错误

配置向量表有两种方式:

  1. 链接脚本控制:在 .ld 文件里指定向量表段的位置,通常放在 Flash 起始地址。
  2. 运行时重映射:通过 SCB->VTOR 寄存器,把向量表搬到 RAM 里,方便动态修改。

我曾经在一个项目中,因为链接脚本写错了,向量表偏移了 4 个字节。结果复位后直接跑飞,查了整整两天才找到原因。嗯,从那以后我每次改链接脚本都会用 objdump 验证一下向量表地址。

注意:如果启用了 Bootloader,向量表一定要重映射到应用程序的起始地址。否则中断来了,CPU 还是去 Bootloader 的向量表里找处理函数,那应用程序的中断就永远跑不到了。

3.4 HardFault_Handler 分析:死机后的「尸检报告」

HardFault 是 Cortex-M 的「最后一道防线」。当其他异常处理不了时,就交给它。常见的触发原因:

  • 访问非法地址(比如 NULL 指针解引用)
  • 执行未定义的指令
  • 总线错误(比如访问外设时时钟没开)
  • 除零操作(如果使能了除法异常)

默认的 HardFault_Handler 就是个死循环。但我们可以把它改造成一个「故障分析器」:

void HardFault_Handler(void) {
    /* 读取错误状态寄存器 */
    uint32_t cfsr = SCB->CFSR;   // 可配置故障状态寄存器
    uint32_t hfsr = SCB->HFSR;   // 硬错误状态寄存器
    uint32_t mmfar = SCB->MMFAR; // 内存管理故障地址
    uint32_t bfar = SCB->BFAR;   // 总线故障地址

    /* 保存现场到专用缓冲区 */
    fault_info.cfsr = cfsr;
    fault_info.hfsr = hfsr;
    fault_info.mmfar = mmfar;
    fault_info.bfar = bfar;

    /* 尝试通过串口输出错误信息 */
    debug_printf("HardFault! CFSR=0x%08X, HFSR=0x%08X\r\n", cfsr, hfsr);

    /* 进入调试陷阱 */
    __asm("BKPT #0");
    while(1);
}

为什么这么做?因为 CFSR 寄存器里藏着关键线索。比如:

  • bit 0: 未对齐访问
  • bit 8: 除零错误
  • bit 9: 未定义指令
  • bit 15: NULL 指针(MPU 区域违规)

我记得有一次,客户反馈设备偶尔死机。我通过 HardFault_Handler 抓到的 CFSR 值,发现是 bit 8 被置位——除零错误。顺着这个线索,找到了一个在特定工况下分母为 0 的算法。如果没有这个「尸检报告」,这种偶发问题可能永远复现不了。

进阶技巧:在 HardFault_Handler 里,还可以通过 LR 寄存器的值判断当前使用的是 MSP 还是 PSP,然后从栈帧里提取出错的 PC 地址。这样就能精确定位到是哪一行代码触发的故障。

3.5 知识体系总览

下面这张图把本章的核心逻辑串起来了:

断言与异常捕获知识体系 断言机制 开发阶段逻辑校验 异常向量表 CPU紧急联络人名单 HardFault 最后一道防线 assert(cond) 条件不成立时触发 链接脚本 / VTOR 静态配置或运行时重映射 CFSR / HFSR 分析 提取错误类型和地址 故障定位核心手段 断言触发 → 异常向量表跳转 → HardFault 分析 → 定位根因 实践建议 1. 断言只在 DEBUG 版本生效,生产版本必须关闭 2. HardFault_Handler 必须保留现场并输出诊断信息 3. 向量表配置后务必用调试器验证地址正确性

3.6 实战中的避坑指南

最后,分享几个我踩过的坑:

我曾经...在一个产品中把 assert 留在了发布版本里。结果用户操作时触发了断言,设备直接死机。从那以后,我强制要求所有 assert 必须用宏包裹,并且发布脚本里自动定义 NDEBUG 来关闭断言。

另一个教训:HardFault_Handler 里不要做复杂操作。比如调用 printf 可能再次触发 HardFault,形成死循环。我现在的做法是:先把关键寄存器保存到固定内存地址,然后点亮一个 LED 指示灯,最后进入 while(1)。调试时通过调试器读取那个内存地址。

嗯,这章的内容就到这里。断言和异常捕获,说白了就是「防患于未然」和「事后诸葛亮」的结合。用好它们,能让你的嵌入式软件健壮不少。