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 | 可编程 | 用法错误 |
配置向量表有两种方式:
- 链接脚本控制:在 .ld 文件里指定向量表段的位置,通常放在 Flash 起始地址。
- 运行时重映射:通过 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 知识体系总览
下面这张图把本章的核心逻辑串起来了:
3.6 实战中的避坑指南
最后,分享几个我踩过的坑:
我曾经...在一个产品中把 assert 留在了发布版本里。结果用户操作时触发了断言,设备直接死机。从那以后,我强制要求所有 assert 必须用宏包裹,并且发布脚本里自动定义 NDEBUG 来关闭断言。
另一个教训:HardFault_Handler 里不要做复杂操作。比如调用 printf 可能再次触发 HardFault,形成死循环。我现在的做法是:先把关键寄存器保存到固定内存地址,然后点亮一个 LED 指示灯,最后进入 while(1)。调试时通过调试器读取那个内存地址。
嗯,这章的内容就到这里。断言和异常捕获,说白了就是「防患于未然」和「事后诸葛亮」的结合。用好它们,能让你的嵌入式软件健壮不少。