模块化编程中的错误处理:错误码设计、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 关闭,错误码返回给上层,日志记录关键事件
下面这张图展示了它们之间的关系:
嗯,这里要注意:不要为了日志而日志。我见过有人每个函数入口都打印日志,结果日志量巨大,反而淹没了真正有用的信息。日志要“有选择地记录”,ERROR 级别必须记,DEBUG 级别只在调试时开。
我的经验:
日志模块最好提供一个“动态级别调整”接口。设备在现场出问题时,可以通过远程命令把日志级别从 INFO 调到 DEBUG,这样就能在不重启设备的情况下获取详细调试信息。这个功能救过我很多次。
最后说一句:错误处理不是“锦上添花”,而是“雪中送炭”。一个没有错误处理的模块,就像没有刹车的汽车——也许能跑,但你绝对不敢开快。
公众号:蓝海资料掘金营,微信deep3321