错误处理架构:错误码设计、异常模拟、断言与日志

错误处理这事儿,说实话,是嵌入式开发里最容易被低估的环节。我见过太多项目,功能跑得飞起,一遇到异常就崩得稀里哗啦。你想想看,一个系统里如果错误处理没做好,那它本质上就是个定时炸弹。

我个人习惯,在项目启动的第一天就把错误处理框架搭好。别等到代码写了两万行再回头补,那时候你连哪个函数该返回什么错误码都理不清了。

错误码设计:别用魔法数字

很多新手喜欢直接返回 -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% 的调试时间。这笔账,怎么算都划算。

公众号:蓝海资料掘金营,微信 deep3321