模块化编程中的错误处理:错误码设计、errno 机制、断言 assert 的使用、日志模块设计

错误处理,说白了就是给代码装上“安全气囊”。

我见过太多项目,代码跑着跑着就崩了,连个提示都没有。你想想看,一个嵌入式设备在产线上突然死机,排查起来有多痛苦?

今天我们就聊聊模块化编程里怎么把错误处理做扎实。我会从错误码设计讲起,再到 errno 机制、断言 assert,最后是日志模块。这些都是我这些年踩坑踩出来的经验。

一、错误码设计:给每个错误一个“身份证”

错误码是模块间沟通的“语言”。设计得好,调试时一眼就能看出问题;设计得烂,你得像侦探一样翻代码。

我个人习惯用枚举类型来定义错误码,这样可读性强,也方便扩展。

// 错误码定义示例
typedef enum {
    ERR_OK          = 0,    // 成功
    ERR_PARAM       = -1,   // 参数错误
    ERR_MEMORY      = -2,   // 内存不足
    ERR_TIMEOUT     = -3,   // 超时
    ERR_BUSY        = -4,   // 资源忙
    ERR_NOT_FOUND   = -5,   // 未找到
    ERR_IO          = -6,   // IO错误
    ERR_UNKNOWN     = -99   // 未知错误
} err_code_t;

这里有个要点:0 永远表示成功,负数表示错误。为什么?因为很多底层函数返回 0 表示成功,非零表示失败,保持一致可以减少心智负担。

核心原则:

  • 错误码要有层次感:模块级 + 错误类型级
  • 每个模块预留 100 个错误码空间(比如 MOD1_ERR_BASE = -100)
  • 错误码要可追溯:能定位到具体模块和具体错误

我在项目中遇到过一个问题:两个模块用了相同的错误码值,结果调试时根本分不清是哪个模块报的错。后来我强制要求每个模块定义自己的错误码基址,才彻底解决。

二、errno 机制:全局错误状态的“晴雨表”

errno 是 C 标准库提供的一个全局变量,用来记录最近一次系统调用的错误状态。在模块化编程中,我们可以借鉴这个思路,为每个模块维护自己的“errno”。

// 模块级 errno 实现
static int module_errno = 0;

int module_get_errno(void) {
    return module_errno;
}

void module_set_errno(int err) {
    module_errno = err;
}

// 使用示例
int module_open(const char *path) {
    if (path == NULL) {
        module_set_errno(ERR_PARAM);
        return -1;
    }
    // ... 实际打开操作
    module_set_errno(ERR_OK);
    return 0;
}

为什么要用 errno 机制? 因为很多函数通过返回值只能传递“成功/失败”两种状态,但具体失败原因需要额外信息。errno 就是干这个的。

避坑指南:

我曾经在中断服务函数里调用了设置 errno 的函数,结果导致全局 errno 被意外修改,主循环里的错误判断全乱了。记住:中断里不要修改 errno,或者用线程局部存储(TLS)来隔离。

多任务环境下,每个任务都应该有自己的 errno 副本。RTOS 通常提供任务局部存储机制,你可以把 errno 挂到任务控制块上。

三、断言 assert:开发阶段的“哨兵”

assert 是 C 标准库提供的调试利器。它在 <assert.h> 中定义,当条件为假时,会打印错误信息并终止程序。

#include <assert.h>

void *buffer_alloc(size_t size) {
    // 断言:size 必须大于 0
    assert(size > 0);
    
    void *ptr = malloc(size);
    // 断言:分配不能失败(开发阶段)
    assert(ptr != NULL);
    
    return ptr;
}

assert 的使用原则:

  • 只用于“不应该发生”的情况(比如逻辑错误、前置条件不满足)
  • 不要用于“可能发生”的错误(比如文件不存在、网络断开)
  • 发布版本中通过 #define NDEBUG 禁用 assert

注意:

assert 在发布版本中会被完全移除。所以不要在 assert 里写有副作用的表达式,比如:

// 错误写法:release 版本中 func() 不会被执行
assert(func() == 0);

// 正确写法
int ret = func();
assert(ret == 0);

我个人习惯在模块入口处加 assert 检查参数,这样能快速定位调用方的问题。比如:

void uart_send(uint8_t *data, uint32_t len) {
    assert(data != NULL);   // 数据指针不能为空
    assert(len > 0);        // 长度必须大于 0
    assert(len <= UART_BUF_SIZE);  // 不能超过缓冲区大小
    
    // ... 实际发送逻辑
}

四、日志模块设计:系统的“黑匣子”

日志是嵌入式系统调试的“最后一道防线”。当设备在现场出问题时,日志往往是唯一的线索。

一个实用的日志模块应该具备:

  • 分级输出:ERROR、WARN、INFO、DEBUG
  • 时间戳:精确到毫秒或微秒
  • 模块标签:快速定位日志来源
  • 可配置的输出目标:串口、文件、内存缓冲区
// 日志级别定义
typedef enum {
    LOG_LEVEL_ERROR = 0,
    LOG_LEVEL_WARN  = 1,
    LOG_LEVEL_INFO  = 2,
    LOG_LEVEL_DEBUG = 3
} log_level_t;

// 日志宏定义
#define LOG_ERROR(mod, fmt, ...) \
    log_output(LOG_LEVEL_ERROR, mod, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_WARN(mod, fmt, ...)  \
    log_output(LOG_LEVEL_WARN, mod, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_INFO(mod, fmt, ...)  \
    log_output(LOG_LEVEL_INFO, mod, __LINE__, fmt, ##__VA_ARGS__)
#define LOG_DEBUG(mod, fmt, ...) \
    log_output(LOG_LEVEL_DEBUG, mod, __LINE__, fmt, ##__VA_ARGS__)

// 日志输出函数
void log_output(log_level_t level, const char *module, 
                int line, const char *fmt, ...) {
    // 检查日志级别
    if (level > g_log_threshold) return;
    
    // 获取时间戳
    uint32_t timestamp = get_system_ms();
    
    // 格式化输出
    va_list args;
    va_start(args, fmt);
    printf("[%u][%s][%s:%d] ", 
           timestamp, 
           log_level_str(level),
           module, 
           line);
    vprintf(fmt, args);
    printf("\n");
    va_end(args);
}

日志模块设计要点:

  • 日志级别可动态调整(运行时通过命令修改)
  • 日志缓冲区要环形设计,防止溢出
  • 关键日志(ERROR 级别)要持久化存储
  • 日志输出不能阻塞主流程(用 DMA 或后台任务)

我曾经在一个项目中,日志模块用了同步输出,结果系统在高负载下日志打印占用了 30% 的 CPU 时间。后来改成异步缓冲区 + DMA 输出,CPU 占用降到 1% 以下。

五、错误处理与日志的协同

错误码、errno、assert、日志,这四个东西不是孤立的。它们应该协同工作:

  • 开发阶段:assert 捕获逻辑错误,日志记录详细上下文
  • 测试阶段:错误码 + errno 定位问题模块,日志辅助分析
  • 生产阶段:assert 关闭,错误码返回给上层,日志记录关键事件

下面这张图展示了它们之间的关系:

错误处理体系架构 错误码设计 errno 机制 断言 assert 日志模块 开发阶段 测试阶段 生产阶段 全生命周期 assert 捕获逻辑错误 日志记录详细上下文 错误码定位问题模块 errno 辅助分析 assert 关闭 错误码返回上层 日志记录关键事件 持久化存储 协同工作:错误码定义问题 → errno 传递状态 → assert 拦截异常 → 日志记录现场 四者缺一不可,共同构建健壮的嵌入式系统

嗯,这里要注意:不要为了日志而日志。我见过有人每个函数入口都打印日志,结果日志量巨大,反而淹没了真正有用的信息。日志要“有选择地记录”,ERROR 级别必须记,DEBUG 级别只在调试时开。

我的经验:

日志模块最好提供一个“动态级别调整”接口。设备在现场出问题时,可以通过远程命令把日志级别从 INFO 调到 DEBUG,这样就能在不重启设备的情况下获取详细调试信息。这个功能救过我很多次。

最后说一句:错误处理不是“锦上添花”,而是“雪中送炭”。一个没有错误处理的模块,就像没有刹车的汽车——也许能跑,但你绝对不敢开快。


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