3、断言与错误处理:__ASSERT宏、系统错误处理、故障转储分析、看门狗集成

调试嵌入式系统,说白了就是跟各种「意外」打交道。指针飞了、栈溢出了、外设没响应了……这些坑我几乎都踩过。今天聊聊Zephyr里怎么用断言、错误处理、故障转储和看门狗,把这些「意外」管起来。

3.1 __ASSERT宏:开发阶段的「哨兵」

先说说断言。我个人习惯在开发阶段把断言开得足足的。Zephyr的__ASSERT宏,本质上就是个条件检查——条件不成立就触发系统halt。

// 典型用法
__ASSERT(ptr != NULL, "ptr should not be NULL");
__ASSERT(len < MAX_BUF_SIZE, "buffer overflow, len=%d", len);

嗯,这里要注意:__ASSERT只在CONFIG_ASSERT=y时生效。发布版本里一般关掉,避免性能开销。

关键配置项:

  • CONFIG_ASSERT=y — 启用断言
  • CONFIG_ASSERT_LEVEL=2 — 断言级别(2最严格)
  • CONFIG_ASSERT_NO_MSG_INFO=n — 显示详细信息

我在项目中遇到过一件事:一个同事在中断上下文里调用了k_sleep(),结果系统随机死机。加了__ASSERT(!k_is_in_isr(), "don't sleep in ISR")后,问题立刻暴露。你看,断言就是帮你把「不可能」变成「可捕获」。

我的习惯:每个函数的入口参数都加断言。尤其是指针参数,空指针是嵌入式世界的第一杀手。

3.2 系统错误处理:从panic到fatal error

断言没抓住的错误怎么办?Zephyr有一套系统级错误处理机制。核心是k_sys_fatal_error_handler这个钩子函数。

// 自定义错误处理
void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf)
{
    printk("Fatal error! reason=%d\n", reason);
    // 记录错误信息到Flash
    save_error_to_flash(reason, esf);
    // 然后复位
    sys_reboot(SYS_REBOOT_COLD);
}

错误原因(reason)包括:

宏定义含义常见场景
K_ERR_CPU_EXCEPTIONCPU异常除零、未定义指令
K_ERR_STACK_CHK_FAIL栈溢出递归太深、局部变量太大
K_ERR_KERNEL_OOPS内核Oops非法系统调用
K_ERR_KERNEL_PANIC内核恐慌严重内部错误

为什么会区分这么细?因为不同错误需要不同处理策略。比如栈溢出,我建议记录现场后立即复位;而内核Oops,也许可以尝试恢复。

避坑指南:我曾经在错误处理函数里调用printk,结果printk本身依赖的信号量被破坏了,直接死锁。所以错误处理代码要尽量简单——别用复杂的内核API,别申请内存,别加锁。

3.3 故障转储分析:从死机现场找线索

系统挂了,怎么知道为什么?故障转储(crash dump)就是你的「黑匣子」。Zephyr默认会打印一些信息,但说实话,默认输出太简略了。

我建议开启这些配置:

CONFIG_DEBUG=y
CONFIG_THREAD_STACK_INFO=y
CONFIG_PRINTK_SYNC=y
CONFIG_ESF_DUMP=y          // 打印异常栈帧
CONFIG_ARM_EXCEPTION_STACK_TRACE=y  // ARM平台栈回溯

开启后,故障时你会看到类似这样的输出:

***** CPU Exception 3 (UsageFault) *****
Current thread ID = 0x20001b40
Faulting instruction address: 0x08001234
r0: 0x00000000  r1: 0x20001c00  r2: 0x00000005
...
Call stack:
  0x08001234 (function_a + 0x10)
  0x08001100 (function_b + 0x24)
  0x08001050 (main + 0x18)

怎么分析?我个人习惯三步走:

  1. 看异常类型 — UsageFault通常是未对齐访问或除零;HardFault多半是野指针。
  2. 看PC指针 — 出错的指令地址,配合map文件或addr2line工具定位到具体代码行。
  3. 看调用栈 — 从main到出错函数的调用路径,帮你理解「怎么走到这一步的」。

小技巧:arm-none-eabi-addr2line -e build/zephyr/zephyr.elf 0x08001234,直接翻译成文件名和行号。省得自己翻map文件。

3.4 看门狗集成:最后的防线

断言和错误处理都是「事后诸葛亮」。看门狗(Watchdog)才是真正的「最后防线」——系统卡死了,它帮你复位。

Zephyr的看门狗API很简洁:

#include <drivers/watchdog.h>

const struct device *wdt = DEVICE_DT_GET(DT_ALIAS(watchdog0));
if (!device_is_ready(wdt)) {
    printk("Watchdog not ready\n");
    return;
}

// 配置超时时间(单位:毫秒)
struct wdt_timeout_cfg cfg = {
    .window.min = 0,
    .window.max = 5000,  // 5秒内必须喂狗
    .callback = NULL,    // 超时回调(可选)
};
int ret = wdt_install_timeout(wdt, &cfg);
wdt_setup(wdt, WDT_OPT_PAUSE_HALTED_BY_DBG);

喂狗:

while (1) {
    // 做业务逻辑
    wdt_feed(wdt, 0);  // 通道0
    k_sleep(K_MSEC(1000));
}

嗯,这里有几个要点:

  • 超时时间别太短 — 我见过有人设100ms,结果正常流程稍微慢一点就复位了。建议设为主循环周期的3-5倍。
  • 喂狗位置要合理 — 别在中断里喂狗,否则主循环卡死了看门狗也不复位。
  • 调试时暂停看门狗WDT_OPT_PAUSE_HALTED_BY_DBG这个选项,调试器暂停时看门狗也暂停,不然你单步调试到一半系统复位了。

我曾经踩过的坑:在一个多线程项目里,我在每个线程里都喂狗,结果线程A卡死了,线程B还在正常喂狗,看门狗永远不触发。后来改成只在主循环喂狗,并且主循环会检查所有线程的心跳。这才是正确的「系统级」喂狗策略。

3.5 把它们串起来:一个完整的错误处理框架

最后,分享一个我实际项目里用的框架思路:

// 1. 断言 — 开发阶段抓逻辑错误
__ASSERT(valid_state, "invalid state %d", state);

// 2. 运行时错误 — 记录并尝试恢复
if (ret != 0) {
    log_error("operation failed: %d", ret);
    error_handler(ret);
}

// 3. 致命错误 — 保存现场并复位
void k_sys_fatal_error_handler(unsigned int reason, const z_arch_esf_t *esf)
{
    save_crash_dump(reason, esf);  // 保存到Flash
    sys_reboot(SYS_REBOOT_COLD);
}

// 4. 看门狗 — 兜底
while (1) {
    wdt_feed(wdt, 0);
    check_all_threads_heartbeat();
    do_main_work();
    k_sleep(K_MSEC(500));
}

这套组合拳下来,开发阶段用断言快速定位,发布阶段用错误处理和看门狗兜底,故障转储帮你事后分析。说白了,就是让系统「死得明白,活得更久」。

总结一句话:断言抓逻辑,错误处理抓异常,故障转储抓现场,看门狗抓死锁。四者配合,嵌入式系统的稳定性才能上一个台阶。