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_EXCEPTION | CPU异常 | 除零、未定义指令 |
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)
怎么分析?我个人习惯三步走:
- 看异常类型 — UsageFault通常是未对齐访问或除零;HardFault多半是野指针。
- 看PC指针 — 出错的指令地址,配合map文件或addr2line工具定位到具体代码行。
- 看调用栈 — 从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));
}
这套组合拳下来,开发阶段用断言快速定位,发布阶段用错误处理和看门狗兜底,故障转储帮你事后分析。说白了,就是让系统「死得明白,活得更久」。
总结一句话:断言抓逻辑,错误处理抓异常,故障转储抓现场,看门狗抓死锁。四者配合,嵌入式系统的稳定性才能上一个台阶。