做BMC固件的人都有体会,带外管理里最磨人的不是功能流程,而是错误信息在代码里走不了几步就被吞掉。最近我在一个BMC重构项目里折腾了一套“BMC PSL-errno”的改造,核心就是让平台服务层(Platform Service Layer,简称PSL)用errno这一套机制统一所有内部错误码,解决传感器数据采集、电源控制等业务中“底层明明是I2C通信失败,上层日志却只显示sensor read fail”的问题。这套思路对做嵌入式Linux固件、OpenBMC或自研BMC的同行都算实用参考,尤其适合那些错误处理靠口头约定、日志查询靠人肉翻代码的团队。下面把背景、设计、落地过程和踩过的坑一次性讲清楚。
1. 项目背景与方案选型
1.1 问题现状:错误信息在各层之间走丢
BMC这块的代码链路普遍很长:驱动层读I2C、读GPIO、访问外设,中间有监控任务做轮询和数据缓存,上层还有IPMI命令处理器、Redfish服务对外提供接口。以前各模块的错误处理风格完全不一样,有些驱动返回-1代表“失败”,有些直接在函数里return一个魔法数字,比如0x3C表示超时、0x51表示传感器不存在,还有些模块干脆把错误吞掉,只在日志里打一句“read fail”。结果就是现场出了问题时,日志显示传感器读数失败,但你完全不知道是I2C总线被占用、设备掉线、访问超时还是数据格式非法。
这种状态在模拟项目X上爆发过一次,当时风扇控制模块报转速异常,排查了一整天才发现是底层I2C驱动在busy重试后返回了EAGAIN,而中间的任务把EAGAIN当成了“传感器不存在”,直接做了屏蔽处理。数据的根因其实是上一次读取操作没释放总线,跟传感器本身没关系。当时如果没有在总线级别抓到原始日志,这个问题几乎不可能定位。
后来我们在PSL层做了一次错误处理体系的重建,把目标定得很明确:所有底层错误必须带着原因走到日志里,中间层不能擅自吞错误;所有错误码必须能在代码里一眼看出属于哪类问题;对外服务的回包也要能反查到内部的完整错误链。
1.2 方案对比:为什么挑中errno而不是自造枚举
当时我列了几个候选方案,也认真权衡过。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 沿用现有自定义枚举 | 改动小,只需要规定每个模块的错误值 | 模块间容易重复编号,扩展麻烦,调试时还得翻头文件 |
| 仿照Linux内核错误码,统一用负数errno | 语义成熟,大家熟悉,驱动层大多是POSIX风格 | 公共错误码不够用,需要扩展业务错误码 |
| 定义结构体错误对象 | 信息最丰富,能带模块、行号、时间戳 | C语言下内存开销大,代码侵入性强,日志和协议映射都要跟着改 |
最后选了以负数errno为主线的方案。原因很简单:BMC里的多数驱动都跑在嵌入式Linux内核态或基于Linux的用户态,底层天然使用errno这一套表达方式。与其在中间层翻译成另一套数字,不如直接接着用,中间层和上层只做映射而不是转换。业务侧特有的错误可以在负数范围里划分独立区间,和系统错误码共享符号风格,比如用PSL_SENSOR_NOT_PRESENT这种宏,但值依然落在负数区间,方便统一判断。
这不是说枚举方案一无是处。如果项目只是单个模块内部用,自定义枚举问题不大。可BMC往往是一个团队维护多条业务线,错误码一多就必然出现“同一个数字在传感器模块表示超时,在电源模块表示电压越限”的情况。errno方案可以借助Linux已有语义减少沟通成本,更重要的是很方便对接perror、strerror这些成熟工具链,日志里打一个数字,开发人员就能立刻知道它的大概含义。
1.3 体系结构分层与错误码边界
改造后的错误处理链路分四层:
- 驱动层:直接返回内核错误码,比如-ETIMEDOUT、-EBUSY。这一层不做包装,只保证错误能带出来。
- PSL中间层:拿到驱动层错误后,结合业务上下文生成PSL错误码。PSL错误码由“系统公共错误码 + 扩展业务错误码”组成,同时保留原始驱动错误码。
- 服务层:传感器管理、电源管理、风扇控制等业务模块调用PSL接口时,统一处理错误,决定是重试、降级还是上报。
- 对外协议层:IPMI完成码、Redfish错误响应根据PSL错误码做最后映射,确保外部能看到通用语义,内部能定位细节。
每一层的错误码边界必须清晰。PSL层只负责“归类”和“增强”,不负责“改写根因”。比如底层返回-ENODEV,PSL层不能把它改成-PSL_READ_FAIL就算完事,而是要记录成“设备访问失败,底层原因为ENODEV”。我把它总结成一句话:错误码可以逐层翻译,但底层原因必须一路带着走。
2. 错误码统一与errno数据链设计
2.1 错误码编号规则与预留段设计
既然选择了errno风格,就必须先定编号规则。系统公共错误码,例如ENOMEM、EIO、ETIMEDOUT,直接沿用Linux的数值,不在我们项目里重新定义。扩展业务错误码统一从固定负数段开始,避免和系统码重叠。设计时考虑了向下兼容,如果未来要接入新的协议层,这些数字还有扩展空间。
公开头文件里集中管理宏定义。所有业务模块只允许引用这些宏,不允许自己发明数字。为了避免不同模块抢占同一个数字,我们还在编译期加了一道检查,包括重复值告警和静态断言。
#define PSL_OK 0 #define PSL_EINVAL (-EINVAL) /* 参数非法 */ #define PSL_ENOMEM (-ENOMEM) /* 内存不足 */ #define PSL_ETIMEDOUT (-ETIMEDOUT) /* 通信超时 */ #define PSL_EBUSY (-EBUSY) /* 资源忙 */ #define PSL_ENODEV (-ENODEV) /* 设备不存在 */ #define PSL_EIO (-EIO) /* 底层IO错误 */ #define PSL_SENSOR_NOT_PRESENT (-1801) /* 传感器不存在 */ #define PSL_SENSOR_RANGE_FAULT (-1802) /* 读数超量程 */ #define PSL_POWER_VOLT_FAULT (-1901) /* 电压异常 */ #define PSL_FAN_CTRL_DISABLED (-2001) /* 风扇控制被禁用 */ #define PSL_PROTO_UNSUPPORTED (-2101) /* 协议不支持 */负数的选择不是拍脑袋。Linux内核里错误码就是负值,errno全局变量在实际使用中也常被赋成负数或者正数,规范化统一成负数可以少踩很多坑。另外,上层协议回包中有些是纯业务语义的正数状态码,例如IPMI的标准完成码很多是0x00、0xC0这一类。负数错误码和正数协议码天然分隔开,写映射时不容易误判。
2.2 PSL层的错误上下文结构体
只传一个整数错误码还不够解决“日志信息不够”的问题。我们加了一个错误上下文结构体,专门用来把错误发生的位置和底层原因串联起来。
typedef struct { int err_no; /* PSL统一错误码,负数 */ int drv_errno; /* 底层驱动原始错误码,负数或0 */ uint32_t src_id; /* 来源模块ID */ uint32_t line; /* 断言行号 */ const char *func; /* 函数名 */ uint64_t ts_ms; /* 毫秒时间戳 */ } bmc_psl_err_ctx_t;err_no是PSL层最终对外暴露的错误码;drv_errno保留驱动层给到的原始错误;src_id用于识别是传感器模块、电源模块还是风扇模块;line和func对应最早打出错误的位置。加了时间戳是为了做日志排序和抖动分析,有些错误是偶发的,需要确认两次上报之间到底隔了多久。
这里要强调一点:结构体里的func用const char指针就行,不要copy完整字符串。在栈空间受限的MCU侧或者线程栈较小的用户态任务里,大结构体反复copy会让栈很快见底。我们初期贪图方便,直接用固定大小char数组,结果某些任务栈直接溢出,后面改成指针存储才消停。
2.3 错误码到日志字符串的映射
日志阶段最怕错误码只有数字没有文本。我们的做法是维护一张静态映射表,表驱动输出字符串,不在代码里堆一堆if-else。这样新增错误码时只要改一张表,打印逻辑完全不用动。
typedef struct { int code; const char *text; } err_map_t; static const err_map_t g_err_text_map[] = { { PSL_EINVAL, "Invalid parameter" }, { PSL_ENOMEM, "Out of memory" }, { PSL_ETIMEDOUT, "Transaction timeout" }, { PSL_EBUSY, "Bus busy or locked" }, { PSL_SENSOR_NOT_PRESENT, "Sensor not present" }, { PSL_SENSOR_RANGE_FAULT, "Sensor reading out of range" }, { PSL_POWER_VOLT_FAULT, "Power voltage fault" }, };日志输出时统一走一个宏,自动把err_no、drv_errno、行号都打印出来。实际输出效果像这样:
psl[src=sensor line=321 func=read_sensor] err=-110(Transaction timeout) drv=-110 ts=1718500000123这种格式有个好处,日志系统或脚本可以直接按src、err纯数字字段做过滤,现场排查时不用再去翻代码确认-110到底什么意思。drv和err如果出现相同数值,说明PSL层没有改写根因,链路是干净的。
2.4 与上层IPMI、Redfish的映射处理
PSL错误码是内部语言,不能直接暴露给外部协议。IPMI侧要用标准完成码,Redfish侧要按DMTF规范给错误类型。映射工作其实不复杂,但要防止两层映射表各搞一套导致漂移。
我们做了一张统一的“协议映射表”。比如PSL_ETIMEDOUT对应IPMI完成码0xC1(响应超时),对应Redfish错误类型为“错误响应超时”;PSL_ENODEV对应IPMI完成码0x0F(设备未就绪),对应Redfish“资源不存在”。关键点是映射逻辑只做一次,IPMI和Redfish服务都调同一个函数,不许各自写副本。
3. 实操过程:传感器链路错误处理改造全流程
3.1 试点范围选择与准备
体系设计好之后,不能一次把整个BMC代码全推倒重来。我们选了最典型的传感器采集链路做试点。选择它的理由很现实:传感器链路涉及I2C驱动、轮询任务、数据缓存、IPMI命令读取、Redfish图形化展示,几乎覆盖了所有层的错误传递场景,改造后能立刻验证日志是否贯通。
准备工作就三件:
- 完整梳理传感器相关函数的错误返回点和每个函数在哪种情况下会返回错误。
- 写一份“错误码-根因-现象”对照表,方便后续做故障注入验证。
- 准备一个故障注入框架。因为真实设备不会按需出现总线错误,我们在驱动层包了一层可替换的接口,测试时直接注入预先定义好的错误。
3.2 驱动层原始错误捕获
驱动层改动最小,但最考验耐心。以I2C读取为例,原来很多驱动在失败后只返回-1,或者把错误细节打印一下就返回0,这会让上层彻底失去依据。改造后驱动层必须把原始错误透传上来,不能用布尔值强行压缩信息。
int i2c_read_sensor(int fd, uint16_t addr, uint8_t reg, uint8_t *buf, size_t len) { struct i2c_msg msgs[2]; struct i2c_rdwr_ioctl_data data = { .msgs = msgs, .nmsgs = 2 }; int ret; msgs[0].addr = addr; msgs[0].flags = 0; msgs[0].buf = ® msgs[0].len = 1; msgs[1].addr = addr; msgs[1].flags = I2C_M_RD; msgs[1].buf = buf; msgs[1].len = len; ret = ioctl(fd, I2C_RDWR, &data); if (ret < 0) return -errno; /* 透传原始错误 */ return 0; }这里有个特别容易犯的错:ioctl失败后,别急着打印,别急着调用其它带errno副作用的函数,先把errno保存到局部变量再处理。因为printf、syslog这一类的函数内部也可能改errno,一旦先在日志调用里把值弄丢,再return时传上来的就是假错误。
3.3 PSL中间层统一错误码实现
PSL中间层要把驱动返回的原始错误和业务上下文结合。比如同样是-ENODEV,当它出现在“传感器列表扫描”和“传感器单点读取”里,业务含义其实不同。我们用一个轻量包装函数来完成这个工作。
void psl_sensor_read_error(const bmc_psl_err_ctx_t *ctx, int drv_errno) { int psl_err; switch (drv_errno) { case -ENODEV: psl_err = PSL_SENSOR_NOT_PRESENT; break; case -ETIMEDOUT: psl_err = PSL_ETIMEDOUT; break; case -EBUSY: psl_err = PSL_EBUSY; break; default: psl_err = PSL_EIO; break; } bmc_psl_set_error(ctx, psl_err, drv_errno); }这个函数的核心思想不是简单地return错误码,而是把原始错误码保存在上下文结构体里。这样上层如果只关心是否读取失败,可以只看err_no;如果要深挖具体总线状态,再看drv_errno。从日志角度,drv_errno就是现场排查的钥匙,绝对不能丢。
3.4 上层IPMI处理与回包
IPMI命令处理器拿到PSL错误码后,按照映射表翻译成IPMI完成码,同时把内部日志打出来。回包给外部管理软件时只显示IPMI完成码,日志里却完整保留内部链路。这样既不影响协议兼容性,又保证了可调试性。
uint8_t ipmi_sensor_read_response(int rc) { switch (rc) { case PSL_OK: return IPMI_CC_OK; case PSL_SENSOR_NOT_PRESENT: return IPMI_CC_INVALID_RESERVED; case PSL_ETIMEDOUT: return IPMI_CC_TIMEOUT; case PSL_EBUSY: return IPMI_CC_BUSY; default: return IPMI_CC_UNSPECIFIED; } }注意IPMI侧不要试图把内部负错误码直接塞进完成码字段。完成码是单字节无符号值,负数塞进去会截断成完全无法理解的数字,给现场值班的同事添麻烦。
3.5 验证:用故障注入模拟多种错误链路
改造后必须做故障注入验证,不能只在正常路径下测试。我在驱动接口上包了一个故障注入开关,用环境变量控制每次调用返回的错误类型。模拟情况包括:
| 注入错误 | 期望的PSL错误码 | 期望的IPMI回包 | 日志中drv_errno值 |
|---|---|---|---|
| 设备不存在 -ENODEV | PSL_SENSOR_NOT_PRESENT | 0x0F | 2 |
| 总线忙 -EBUSY | PSL_EBUSY | 0xC3 | 16 |
| 通信超时 -ETIMEDOUT | PSL_ETIMEDOUT | 0xC1 | 110 |
| 普通IO错误 -EIO | PSL_EIO | 0xFF | 5 |
验证结果基本符合预期,只有一类问题反复出现:有些任务在调用PSL层之前已经打印了错误信息,并且早把err_no给覆盖了。这个问题在下一节细说。
4. 常见问题与排查技巧实录
4.1 errno被无意识覆盖
这是整个改造过程中出现频率最高的问题。典型错误写法如下:
ret = ioctl(fd, I2C_RDWR, &data); if (ret < 0) { printf("ioctl fail: %d\n", errno); /* 此时errno可能已被printf内部改写 */ return -errno; }printf确实有几率不改变errno,但这只是一个巧合而不是可靠行为。更典型的是在错误路径上调用syslog、关闭文件描述符或者释放缓冲区,这些操作都可能改写errno。规范做法是:
int saved_errno = errno; /* 此时可以安全打印、清理资源 */ return -saved_errno;我们把这个规则写进了团队的代码规范,并且要求所有驱动层的错误返回点都必须先保存errno,再执行清理动作。虽然只改一个值,但现场定位效率一下上去了,因为日志里再也不会出现-11、-110这种“随机漂移”的错误码。
4.2 多线程与RTOS环境下的errno理解问题
很多BMC主体跑的是嵌入式Linux,用户态每个线程有自己的errno,这个没问题。但如果某块板子采样裸机任务或RTOS内核,情况就完全不同:errno很可能是一个全局变量或任务私有存储,一旦任务切换,错误码就可能被别的任务覆盖。在这种环境下,不能指望返回到调用方时errno还是原来那个值。
更稳妥的方案是:在出错现场立刻把errno复制到psl_err_ctx_t结构体,之后所有路径都只使用结构体里的值,不要再碰errno。这也是为什么我们把drv_errno放进结构体而不是继续沿用errno全局值。结构体随调用栈传参,天然适合裸机和RTOS环境。如果任务栈特别紧张,可以考虑用线程局部存储或直接在出错点同步打印日志,避免带结构体跨越太多层级。
4.3 错误码映射表与代码漂移问题
头文件里加了新错误码,但字符串表、协议映射表常常忘记同步,这几乎是必然发生的。我们不是靠自觉解决的,而是用了构建期检查脚本:从统一头文件里自动提取所有PSL_开头的宏,对比字符串映射表和协议映射表,漏一个就编译失败。同时在单元测试里遍历所有错误码,断言每个错误码至少被一个映射项覆盖。
建议小团队也把这一步加上,成本低收益大。别看都是静态表,等到线上报错发现日志里打出的全是“Unknown error -xxxx”的时候,你就会怀念构建期的十秒钟检查。
4.4 从日志反查问题的现场排查步骤
日志格式统一后,排查链路就清晰很多。遇到一条psl日志,按三步走:
第一,先看err字段,确认是系统通用错误还是PSL业务扩展错误。如果落在系统错误段,直接对照规定的错误码含义清单。第二,再看drv字段,确认底层驱动给的原始错误是什么。如果drv和err都是-110,说明链路很透明;如果drv是-16而err是-1801,说明I2C层面是“设备或资源忙”,但PSL层归类成了“传感器不存在”,这种归类就要重点核查。第三,根据src字段定位模块,例如传感器、电源还是风扇,再去对应源码模块里看错误点,结合line和func字段直接定位到函数。
这套方法让新同学也能独立排查,不用每次叫老师傅上手。实际上我见过最顺利的一次,值班工程师只看日志就把问题锁在了某条I2C总线编号上,根本没动调试器。
5. 影响范围与可移植性建议
5.1 改造涉及的功能模块
这次改造虽然试点在传感器链路,但错误码体系本身是通用的,后续可以平移到电源控制、风扇调速、硬件状态监控、Redfish服务、固件更新等模块。影响最直观的是三个地方:
- 日志可读性:统一的“err/drv/src”格式让日志平台能自动建索引,不用再接一堆自定义解析正则。
- 协议映射稳定性:IPMI和Redfish服务不再各写一套错误映射,协议行为一致,测试用例也可以复用。
- 模块间接口质量:所有跨界函数都改成返回PSL错误码,不再出现“返回0表示普通成功,返回1也表示成功”之类的模糊约定。
5.2 对调试效率和交付质量的提升
以模拟项目X的实测数据来说,改造前传感器异常类问题平均定位时间在两到三个小时,改造后多数问题能压缩到半小时以内,而且不少问题只需要看日志就能处理,不用复现现场。关键是错误上下文结构体保留了原始驱动错误,很多以前需要加日志重新编译才能看到的信息,现在一次就带全了。
交付质量上也有隐蔽好处:因为错误码被统一,自动化测试更容易断言“这里返回了PSL_ETIMEDOUT而不是任何负值”,测试用例可以写得比原来严格,不用再猜到底哪个数字代表超时。
5.3 移植到其他项目的实战建议
如果你也想在自己的BMC或类似嵌入式项目里做这套改造,我的建议是不要一上来就全局重构。先选一条最核心的链路试点,把整套错误码体系、日志格式、协议映射都跑通,再逐步扩展到其它模块。
具体步骤可以这样:先盘点上两个最乱的模块,把每个错误返回点列成清单,对照驱动原始错误码和业务层期望语义,建立映射表;然后给每个跨界函数加一个统一出口,只允许通过PSL接口返回错误;接着给错误码和映射表加编译期检查;最后补上故障注入环境,确保每种错误路径都有用例覆盖。
有一点必须清醒认识:errno这套方案解决的是“错误信息的完整性”和“排查效率”,它不会自动解决功能逻辑问题。如果底层代码本身就把错误吞在没有返回值的void函数里,再好的体系也救不回来。所以改造的边界一定要画清楚,函数必须能向上返回错误,才谈得上错误码统一。
我个人做下来的最大体会是,错误码从来不只是给机器看的,它主要是给“六个月后回来改代码的自己”看的。PSL层把错误留下来,后面真的能少加很多班。