异常处理机制设计:让单片机学会“自救”

做嵌入式开发,最怕什么?不是代码写不出来,而是程序跑飞了、死机了,你连它死在哪、怎么死的都不知道。我早年做电机驱动项目时,就吃过这个亏——设备在现场突然不转了,复位后又能跑,但过一会儿又死。查了三天,最后发现是异常向量表没配好,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 整体架构:一张图看懂

下面这张图展示了异常处理机制的整体流程。从异常触发到日志记录,再到系统恢复,每一步都环环相扣。

异常处理机制架构图 异常处理机制整体架构 异常触发 异常向量表查询 是否HardFault? 是 HardFault处理函数 否 其他异常处理 断言检查(ASSERT) 错误日志记录 日志存入环形缓冲区 带时间戳和错误类型

4.6 实战要点总结

模块 关键动作 常见坑
向量表配置 设置 VTOR 寄存器,拷贝向量表到目标地址 只拷贝部分中断,导致未定义中断触发 HardFault
HardFault 处理 保存现场、读取 CFSR、记录 PC/LR 忘记区分 MSP/PSP,读到的栈数据是错的
断言机制 检查条件、记录文件行号、触发错误处理 Release 版本忘记关闭断言,影响性能
错误日志 环形缓冲区写入、时间戳标记、中断安全 在中断里调用非重入函数,导致二次异常

最后提醒一句:异常处理机制不是写完了就完事了。你得在实际运行中验证它——故意制造一个除零错误,看看 HardFault 能不能正确捕获;故意传一个空指针,看看断言会不会触发。我每次做完这部分,都会花半天时间做“故障注入测试”,确保这套机制在关键时刻靠得住。