第2章:测试固件架构设计:模块化固件设计原则、分层架构与测试框架解耦
做嵌入式可靠性测试这么多年,我见过太多测试固件写得像一锅粥。一个函数上千行,全局变量满天飞,换个测试用例就得改底层代码。这种固件,说白了就是给自己挖坑。今天咱们聊聊怎么把测试固件架构搭好,让它既好写又好维护。
2.1 模块化固件设计原则
模块化不是什么新鲜概念,但在测试固件里,它的意义更特殊。测试固件本身也是固件,但它要服务于测试,而不是产品功能。我个人的习惯是,把测试固件当成一个独立的嵌入式项目来设计,而不是产品代码的附属品。
2.1.1 单一职责原则
每个模块只做一件事。比如,一个模块只负责GPIO读写,另一个只负责串口通信,第三个只负责测试流程控制。别把什么都揉在一起。
核心要点:模块的职责边界要清晰。一个模块如果同时处理硬件操作和测试逻辑,那它既不好测也不好改。
我在项目中遇到过这样的案例:一个测试固件里,同一个函数既操作I2C总线,又解析测试数据,还负责打印日志。后来要换一个I2C器件,结果改了三天,还引入了新bug。这就是典型的职责不清。
2.1.2 接口隔离原则
模块之间的交互通过接口完成,而不是直接访问内部数据。接口要小而精,不要搞一个大而全的万能接口。
/* 好的接口设计:小而专 */
typedef struct {
int32_t (*init)(void);
int32_t (*read)(uint8_t reg, uint8_t *buf, uint16_t len);
int32_t (*write)(uint8_t reg, const uint8_t *buf, uint16_t len);
} i2c_bus_t;
/* 不好的接口设计:大而全 */
typedef struct {
int32_t (*do_everything)(uint32_t cmd, void *param1, void *param2);
} universal_driver_t;
你想想看,do_everything这种接口,调用方得传什么参数?文档写不清楚,代码也看不懂。我建议每个接口只做一件事,参数明确,返回值清晰。
2.1.3 依赖倒置原则
高层模块不应该依赖低层模块,两者都应该依赖抽象。在测试固件里,这意味着测试逻辑不应该直接调用硬件寄存器,而是通过抽象层来操作。
个人经验:我曾经在一个项目中,测试用例直接操作GPIO寄存器。后来换了MCU,所有测试用例都得重写。如果当时用了HAL抽象层,只需要改底层驱动,测试用例完全不用动。
2.2 分层架构:HAL/驱动/应用层
分层架构是嵌入式固件的经典做法,测试固件也不例外。我习惯把测试固件分成三层:硬件抽象层(HAL)、驱动层、应用层。每一层各司其职,层与层之间通过接口通信。
2.2.1 硬件抽象层(HAL)
HAL层是最底层,直接跟MCU寄存器打交道。它的职责是屏蔽不同MCU之间的差异。比如,STM32的GPIO配置和GD32的GPIO配置不一样,但通过HAL层,上层看到的接口是一样的。
注意:HAL层不要包含任何测试逻辑。它只负责把硬件操作封装成函数。我曾经见过有人在HAL层里加测试计数和日志打印,结果导致时序偏差,测试结果不准。
2.2.2 驱动层
驱动层在HAL层之上,封装具体外设的操作。比如,一个I2C驱动,它调用HAL层的I2C读写函数,但增加了超时处理、错误重试、数据校验等逻辑。
/* 驱动层示例:I2C驱动 */
typedef struct {
hal_i2c_t *hal; /* 依赖HAL层接口 */
uint32_t timeout_ms; /* 超时时间 */
uint8_t retry_cnt; /* 重试次数 */
} i2c_driver_t;
int32_t i2c_driver_read(i2c_driver_t *drv, uint8_t dev_addr,
uint8_t reg, uint8_t *data, uint16_t len)
{
int32_t ret;
for (uint8_t i = 0; i < drv->retry_cnt; i++) {
ret = drv->hal->read(dev_addr, reg, data, len, drv->timeout_ms);
if (ret == 0) return 0; /* 成功 */
}
return -1; /* 重试失败 */
}
驱动层的接口要稳定。一旦确定,尽量不要改。因为应用层和测试用例都依赖它。
2.2.3 应用层
应用层是测试固件的核心。它负责测试用例的调度、测试流程的控制、结果的判定和日志的输出。应用层不直接操作硬件,它只调用驱动层的接口。
我习惯把应用层再细分成几个子模块:
- 测试调度器:管理测试用例的执行顺序、超时、异常处理
- 测试用例库:存放具体的测试用例函数
- 结果管理器:收集测试结果,生成报告
- 日志系统:记录测试过程中的关键信息
2.3 固件与测试框架解耦
这是很多团队容易忽略的点。测试固件和测试框架(比如PC端的测试管理软件)之间,应该有清晰的接口,而不是耦合在一起。
2.3.1 为什么需要解耦?
我举个例子。你写了一个测试固件,它通过串口跟PC端的测试软件通信。测试软件发命令,固件执行并返回结果。如果固件和测试软件耦合太紧,比如固件里直接解析测试软件的特定协议格式,那换一个测试软件,固件就得重写。
解耦的核心思路:固件只提供标准化的命令接口,不关心上层是谁在调用。测试框架只负责发送命令和接收结果,不关心固件内部怎么实现。
2.3.2 接口设计原则
固件与测试框架之间的接口,我建议采用以下设计:
| 接口类型 | 说明 | 示例 |
|---|---|---|
| 命令接口 | 测试框架发送命令,固件执行 | CMD_READ_ADC CH1 |
| 数据接口 | 固件返回测试数据 | DATA 1234 mV |
| 状态接口 | 固件报告当前状态 | STATUS IDLE |
| 错误接口 | 固件报告错误信息 | ERR TIMEOUT |
接口协议要简单、可扩展。我推荐用文本协议,而不是二进制协议。文本协议调试方便,人眼可读,而且容易扩展。
/* 文本协议示例 */
/* 测试框架发送: */
TEST START
TEST ADC_CH1
TEST ADC_CH2
TEST STOP
/* 固件返回: */
OK
DATA ADC_CH1: 1234 mV
DATA ADC_CH2: 1256 mV
DONE
2.3.3 解耦的具体做法
在固件内部,我建议把通信模块和测试逻辑模块分开。通信模块只负责收发数据,不解析测试命令。测试逻辑模块只负责执行测试,不关心数据怎么来的。
避坑指南:我曾经在一个项目里,把命令解析和测试执行写在同一个函数里。结果测试框架升级了协议,我不得不改固件里的解析逻辑,还差点引入新bug。后来我把通信模块独立出来,用回调函数把命令传给测试模块,再也没出过类似问题。
/* 解耦后的通信模块接口 */
typedef int32_t (*cmd_handler_t)(const char *cmd, char *resp, uint16_t resp_len);
int32_t comm_init(uart_t *uart, cmd_handler_t handler);
int32_t comm_process(void); /* 在主循环中调用 */
/* 测试模块注册命令处理函数 */
int32_t test_cmd_handler(const char *cmd, char *resp, uint16_t resp_len)
{
if (strcmp(cmd, "TEST START") == 0) {
return test_start(resp, resp_len);
} else if (strcmp(cmd, "TEST STOP") == 0) {
return test_stop(resp, resp_len);
}
return -1; /* 未知命令 */
}
这样设计之后,通信模块和测试模块完全解耦。换通信方式(比如从串口换成USB),只需要改通信模块,测试模块不用动。换测试逻辑,也只需要改测试模块,通信模块不受影响。
2.4 架构设计的避坑总结
做测试固件架构设计,有几个坑我踩过,分享给大家:
- 别过早优化:先把架构搭起来,跑通基本流程,再考虑性能优化。我见过有人花了两周设计一个完美的架构,结果发现根本跑不通。
- 接口文档要写:模块之间的接口一定要文档化。别指望别人看代码就能理解你的设计意图。
- 预留测试点:在关键模块的接口处,预留测试点(比如hook函数),方便后续调试和验证。
- 版本管理:接口变更要通知所有相关方。我曾经因为改了HAL层接口,没通知驱动层同事,结果联调时出了大问题。
最后提醒:架构设计不是一蹴而就的。随着测试需求的变化,架构也需要迭代。关键是保持模块化、分层、解耦这三个原则,这样后续改起来才不会太痛苦。