异常处理机制设计:让单片机学会“自救”
做嵌入式开发,最怕什么?不是代码写不出来,而是程序跑飞了、死机了,你连它死在哪、怎么死的都不知道。我早年做电机驱动项目时,就吃过这个亏——设备在现场突然不转了,复位后又能跑,但过一会儿又死。查了三天,最后发现是异常向量表没配好,HardFault 连个影子都没抓到。
所以这一章,咱们就聊聊怎么给单片机装上“黑匣子”和“安全气囊”。说白了,就是异常处理机制。
4.1 异常向量表:CPU的“紧急通讯录”
异常向量表,你可以把它想象成一张电话表。CPU 遇到异常(比如除零、访问非法地址),就会按这张表打电话找人处理。这张表通常放在 Flash 的起始地址,也就是 0x00000000 处。
核心要点:向量表的第一项是栈顶指针,第二项才是复位向量。很多人只配了中断,忘了异常,结果 HardFault 来了直接死机。
4.1.1 向量表重定向
实际项目中,我们经常需要把向量表搬到 RAM 里。为什么?因为 Bootloader 和 App 各有一套中断处理,运行时得动态切换。Cortex-M 系列提供了 SCB->VTOR 寄存器,写一下就能重定向。
// 将向量表重定向到 0x20000000(SRAM 起始地址)
#define VECTOR_TABLE_ADDR 0x20000000
void vector_table_relocate(void)
{
// 先关全局中断,防止重定向过程中被中断打断
__disable_irq();
// 设置 VTOR 寄存器
SCB->VTOR = VECTOR_TABLE_ADDR;
// 内存屏障,确保写入完成
__DSB();
__ISB();
// 重新使能中断
__enable_irq();
}
注意:重定向之前,必须确保目标地址已经拷贝了完整的向量表。我曾经见过有人只拷贝了前几个中断,结果某个外设中断一来,PC 直接飞到未定义区域——那画面,太美不敢看。
4.2 HardFault 处理函数:抓住“真凶”
HardFault 是 Cortex-M 的“最后一道防线”。当其他异常处理不了时,它就会登场。但默认的 HardFault 处理函数就是个死循环——这等于告诉 CPU:“你死吧,我不管了。”
咱们得让它干点正事:记录现场、保存寄存器、然后复位或者输出错误信息。
4.2.1 获取故障信息
HardFault 发生时,CPU 会自动压栈一些寄存器。我们需要从栈里把它们捞出来。这里有个关键点:要区分是线程模式还是 Handler 模式,因为栈指针(MSP/PSP)不一样。
// HardFault 处理函数(汇编入口)
__asm void HardFault_Handler(void)
{
IMPORT hard_fault_handler_c
TST LR, #4 // 检查 EXC_RETURN 的 bit2
ITE EQ
MRSEQ R0, MSP // 如果为0,使用 MSP
MRSNE R0, PSP // 如果为1,使用 PSP
B hard_fault_handler_c
}
// C 语言处理函数
void hard_fault_handler_c(uint32_t *fault_stack)
{
uint32_t r0 = fault_stack[0];
uint32_t r1 = fault_stack[1];
uint32_t r2 = fault_stack[2];
uint32_t r3 = fault_stack[3];
uint32_t r12 = fault_stack[4];
uint32_t lr = fault_stack[5];
uint32_t pc = fault_stack[6];
uint32_t psr = fault_stack[7];
// 记录错误信息到日志缓冲区
error_log_write("HardFault: PC=0x%08X, LR=0x%08X, PSR=0x%08X", pc, lr, psr);
// 读取故障状态寄存器
uint32_t cfsr = SCB->CFSR;
if (cfsr & (1 << 9)) { // DIVBYZERO
error_log_write("Cause: Divide by zero");
}
if (cfsr & (1 << 8)) { // UNALIGNED
error_log_write("Cause: Unaligned access");
}
if (cfsr & (1 << 12)) { // UNDEFINSTR
error_log_write("Cause: Undefined instruction");
}
// 保存完现场后,可以选择复位
NVIC_SystemReset();
}
我的习惯:在 HardFault 处理函数里,我会把 PC 和 LR 的值通过串口打印出来,同时闪烁一个 LED 作为视觉指示。这样即使串口线没接,也能知道程序挂了。
4.3 断言机制:把 Bug 扼杀在摇篮里
断言(assert)是 C 语言里被低估的调试利器。它的原理很简单:检查一个条件,如果为假,就触发错误处理。我见过太多人只在 PC 端用 assert,到了单片机就扔了——其实嵌入式更需要它。
4.3.1 自定义断言实现
标准库的 assert 会调用 abort(),这在单片机里基本等于死机。咱们自己写一个,让它调用 HardFault 或者输出错误信息。
// 自定义断言宏
#ifdef DEBUG
#define ASSERT(expr) \
do { \
if (!(expr)) { \
assert_failed(__FILE__, __LINE__, #expr); \
} \
} while(0)
#else
#define ASSERT(expr) ((void)0)
#endif
// 断言失败处理函数
void assert_failed(const char *file, int line, const char *expr)
{
// 关闭所有中断
__disable_irq();
// 记录断言失败信息
error_log_write("ASSERT FAILED: %s at %s:%d", expr, file, line);
// 进入死循环,方便调试器连接
while(1) {
// 可以在这里闪烁 LED 指示错误代码
// 比如:长闪3次表示断言失败
}
}
使用场景举例:
- 检查指针是否为空:
ASSERT(ptr != NULL); - 检查参数范围:
ASSERT(len < BUFFER_SIZE); - 检查状态机状态:
ASSERT(state == STATE_IDLE);
4.4 错误日志记录:让 Bug 无处遁形
光有异常处理还不够,你得把错误信息记下来。现场设备不可能总连着调试器,所以日志系统得能“自存储”。
4.4.1 环形缓冲区日志
我推荐用环形缓冲区(Ring Buffer)来存日志。它固定大小、不会溢出、写入速度快。配合 RTC 时间戳,就能还原事故现场。
#define LOG_BUFFER_SIZE 256
#define LOG_ENTRY_MAX 64
typedef struct {
uint32_t timestamp; // 时间戳
uint8_t level; // 日志级别
char message[LOG_ENTRY_MAX]; // 日志内容
} log_entry_t;
typedef struct {
log_entry_t buffer[LOG_BUFFER_SIZE];
uint32_t head;
uint32_t tail;
} log_ring_buffer_t;
static log_ring_buffer_t log_rb;
void error_log_write(const char *fmt, ...)
{
va_list args;
uint32_t head_next;
// 计算下一个写入位置
head_next = (log_rb.head + 1) % LOG_BUFFER_SIZE;
// 如果缓冲区满了,覆盖最旧的数据
if (head_next == log_rb.tail) {
log_rb.tail = (log_rb.tail + 1) % LOG_BUFFER_SIZE;
}
// 写入日志
log_rb.head = head_next;
log_rb.buffer[log_rb.head].timestamp = get_rtc_tick();
log_rb.buffer[log_rb.head].level = LOG_LEVEL_ERROR;
va_start(args, fmt);
vsnprintf(log_rb.buffer[log_rb.head].message,
LOG_ENTRY_MAX, fmt, args);
va_end(args);
}
避坑指南:我曾经在日志函数里用了 malloc,结果在中断里调用时直接死机。记住:错误日志函数必须是“中断安全的”,不能使用动态内存分配,不能调用可能阻塞的函数。
4.5 整体架构:一张图看懂
下面这张图展示了异常处理机制的整体流程。从异常触发到日志记录,再到系统恢复,每一步都环环相扣。
4.6 实战要点总结
| 模块 | 关键动作 | 常见坑 |
|---|---|---|
| 向量表配置 | 设置 VTOR 寄存器,拷贝向量表到目标地址 | 只拷贝部分中断,导致未定义中断触发 HardFault |
| HardFault 处理 | 保存现场、读取 CFSR、记录 PC/LR | 忘记区分 MSP/PSP,读到的栈数据是错的 |
| 断言机制 | 检查条件、记录文件行号、触发错误处理 | Release 版本忘记关闭断言,影响性能 |
| 错误日志 | 环形缓冲区写入、时间戳标记、中断安全 | 在中断里调用非重入函数,导致二次异常 |
最后提醒一句:异常处理机制不是写完了就完事了。你得在实际运行中验证它——故意制造一个除零错误,看看 HardFault 能不能正确捕获;故意传一个空指针,看看断言会不会触发。我每次做完这部分,都会花半天时间做“故障注入测试”,确保这套机制在关键时刻靠得住。