错误处理架构:错误码设计、异常模拟、断言与日志
错误处理这事儿,说实话,是嵌入式开发里最容易被低估的环节。我见过太多项目,功能跑得飞起,一遇到异常就崩得稀里哗啦。你想想看,一个系统里如果错误处理没做好,那它本质上就是个定时炸弹。
我个人习惯,在项目启动的第一天就把错误处理框架搭好。别等到代码写了两万行再回头补,那时候你连哪个函数该返回什么错误码都理不清了。
错误码设计:别用魔法数字
很多新手喜欢直接返回 -1、0、1 这种数字。嗯,这其实是个坑。你想想,三个月后你回头看代码,看到 if (ret == -3),你还能记得 -3 是什么意思吗?
我建议用枚举来定义错误码。这样代码自解释,也方便扩展。
typedef enum {
ERR_OK = 0,
ERR_PARAM = -1,
ERR_TIMEOUT = -2,
ERR_BUSY = -3,
ERR_NOMEM = -4,
ERR_HW_FAULT = -5,
ERR_UNKNOWN = -99
} err_code_t;
我在项目中遇到过一个问题:不同模块的错误码冲突了。A模块的 ERR_TIMEOUT 是 -2,B模块的 ERR_TIMEOUT 也是 -2,但含义完全不同。后来我强制要求所有错误码统一管理,放在一个头文件里,谁要加新的都得过评审。
核心原则:错误码要全局唯一、含义清晰、可扩展。别用正数表示错误,正数留给数据长度用。
错误码的层级设计
一个成熟的系统,错误码往往分三层:
| 层级 | 范围 | 说明 |
|---|---|---|
| 系统级 | -1 ~ -99 | 通用错误,如参数错误、内存不足 |
| 模块级 | -100 ~ -999 | 各模块特有错误,如驱动超时、协议解析失败 |
| 用户级 | -1000 ~ -9999 | 应用层自定义错误,留给业务逻辑 |
这样做的好处是,看到错误码就能快速定位到哪个模块出了问题。我曾经接手过一个项目,错误码从 -1 到 -5000 乱用,查一个 bug 花了两天时间——说白了就是设计时没规划好。
异常模拟:让错误可复现
嵌入式系统里,很多错误是偶发的。比如 SPI 通信偶尔超时、ADC 偶尔采到异常值。这种问题最难查,因为你不知道它什么时候出现。
我的做法是:在调试阶段,主动注入错误。说白了就是故意让某些函数返回错误码,看看系统能不能扛住。
#ifdef DEBUG_MODE
static int inject_error = ERR_OK;
void set_inject_error(err_code_t err) {
inject_error = err;
}
err_code_t read_sensor(void) {
if (inject_error != ERR_OK) {
return inject_error;
}
// 正常读取传感器
}
#endif
你想想看,有了这个机制,你就可以在测试脚本里写:先注入一个超时错误,看看系统会不会死锁;再注入一个硬件故障,看看看门狗能不能复位。我在一个电机控制项目里,就是用这个办法找到了三个隐藏的 bug——都是正常跑永远不会触发,但一旦触发就炸的那种。
小技巧:异常注入的开关一定要用宏控制,发布版本里必须关掉。我曾经见过有人忘了关,结果产品在现场跑着跑着就报错——其实是调试代码在捣乱。
断言:早发现早治疗
断言这东西,很多人觉得可有可无。但我告诉你,在嵌入式开发里,断言是保命用的。
断言的核心思想是:如果某个条件不成立,那程序继续跑下去也没有意义,不如直接停下来让你知道。
#define ASSERT(cond) do { \
if (!(cond)) { \
log_error("Assertion failed: %s, file %s, line %d", \
#cond, __FILE__, __LINE__); \
while(1); \
} \
} while(0)
我个人习惯在以下场景必须加断言:
- 函数入口参数检查(比如指针不能为 NULL)
- 关键状态机状态检查(比如不能在 INIT 状态执行 RUN 操作)
- 内存分配结果检查(malloc 返回 NULL 时直接断言)
我曾经在一个项目中,因为没加断言,一个野指针把整个内存区写乱了,查了整整一周。后来我养成了习惯:所有可能出问题的地方,先断言再说。 你可能会觉得这样代码里全是断言,不好看。但说实话,难看总比出 bug 强。
注意:断言只在调试版本中生效。发布版本里要关掉断言,或者把断言改成恢复操作。别让产品在现场因为断言而死循环。
日志系统:你的黑匣子
日志是错误处理里最后一道防线。当所有错误都发生了,日志就是你还原现场的唯一线索。
我设计的日志系统一般分五个级别:
| 级别 | 标签 | 使用场景 |
|---|---|---|
| 0 | FATAL | 系统无法继续运行,必须复位 |
| 1 | ERROR | 功能异常,但系统还能跑 |
| 2 | WARN | 潜在问题,需要注意 |
| 3 | INFO | 关键流程信息,如启动完成 |
| 4 | DEBUG | 调试用,发布版本关掉 |
日志输出要考虑资源限制。在 RAM 小的 MCU 上,我一般用环形缓冲区,把最近的日志存下来。出问题后通过串口或者保存到 Flash 里读取。
void log_write(int level, const char *fmt, ...) {
if (level > g_log_level) return; // 级别过滤
va_list args;
va_start(args, fmt);
vsnprintf(g_log_buf, LOG_BUF_SIZE, fmt, args);
va_end(args);
// 写入环形缓冲区
ringbuf_push(g_log_ring, g_log_buf);
}
你想想看,当系统死机后,你通过调试器把环形缓冲区里的日志读出来,看到最后一条是 "ERROR: sensor timeout at line 234",那问题定位就快多了。
整体架构图
下面这张图展示了错误处理架构的核心流程。从错误发生到最终处理,每一步都有对应的机制。
避坑指南
最后,分享几个我踩过的坑:
- 错误码别用 0 表示成功以外的含义。 我曾经见过有人用 0 表示 "数据为空",结果和 ERR_OK 冲突了,排查了半天。
- 日志别打太多。 在低端 MCU 上,printf 是很慢的。我建议关键路径上只打 ERROR 和 FATAL,INFO 级别的日志只在调试时开。
- 断言别滥用。 断言是用来检查"绝对不应该发生"的情况。如果某个错误是预期可能发生的(比如通信超时),那就用错误码处理,别用断言。
- 异常注入代码记得删干净。 我曾经有一次忘了关 DEBUG_MODE,结果产品在现场跑着跑着就注入了一个错误——那场面,别提多尴尬了。
错误处理架构说白了就是给系统买保险。你花 20% 的精力把错误处理做好,能省下 80% 的调试时间。这笔账,怎么算都划算。