分层架构基础:经典三层架构回顾
分层架构,说白了就是把代码按职责切几层。我刚开始做嵌入式那会儿,代码全写在一个文件里,一个函数管LED闪烁,另一个函数管I2C读写,中间还夹着延时逻辑。后来项目一复杂,改个定时器参数能引发三个模块的bug。嗯,从那以后我就老老实实分层了。
今天咱们聊的经典三层架构,就是嵌入式领域最常用的那套:硬件抽象层(HAL)、操作系统抽象层(OSAL)、应用层(App)。这三层怎么分?为什么分完就好测了?咱们一个个看。
1. 三层架构长什么样
先画个图,把结构看清楚。
这个图我建议你存下来。每次写代码前看一眼,问问自己:这行代码该放哪一层?
2. 硬件抽象层(HAL)——把硬件藏起来
HAL层干的事很简单:把芯片寄存器操作封装成函数接口。比如你要点亮一个LED,HAL层给你一个HAL_GPIO_WritePin(),你传个引脚号和电平值就行,不用管那个引脚对应的ODR寄存器地址是多少。
核心原则:HAL层只做一件事——把硬件差异挡在下面。上层代码永远不直接操作寄存器。
举个例子,STM32和GD32的GPIO寄存器布局不一样,但如果你在HAL层统一了接口,上层代码一个字都不用改。我在项目中遇到过两次芯片换型,一次从STM32F103换到GD32F103,另一次从NXP LPC换到STM32L4。第一次没做HAL,改代码改到怀疑人生;第二次提前搭好了HAL,三天搞定移植。
HAL层的典型接口长这样:
// hal_gpio.h
typedef enum {
HAL_GPIO_MODE_INPUT,
HAL_GPIO_MODE_OUTPUT,
HAL_GPIO_MODE_ALT
} HAL_GPIO_Mode_t;
void HAL_GPIO_Init(uint8_t pin, HAL_GPIO_Mode_t mode);
void HAL_GPIO_WritePin(uint8_t pin, uint8_t level);
uint8_t HAL_GPIO_ReadPin(uint8_t pin);
你看,接口里没有寄存器地址,没有位操作,没有volatile关键字。上层代码看到的就是干净的、可测试的函数。
3. 操作系统抽象层(OSAL)——把RTOS藏起来
OSAL层解决的是另一个痛点:不同RTOS的API千差万别。FreeRTOS的任务创建函数是xTaskCreate(),RT-Thread是rt_thread_create(),uC/OS是OSTaskCreate()。如果你在应用层直接调用这些函数,换个RTOS就得重写所有任务代码。
OSAL层提供一套统一接口:
// osal_task.h
typedef void* OSAL_TaskHandle_t;
OSAL_TaskHandle_t OSAL_TaskCreate(
void (*func)(void*),
const char* name,
uint32_t stack_size,
void* param,
uint32_t priority
);
void OSAL_TaskDelay(uint32_t ms);
void OSAL_TaskDelete(OSAL_TaskHandle_t task);
我个人习惯把OSAL层做得尽量薄。它不负责业务逻辑,只做一件事:把RTOS的API翻译成统一的接口。翻译工作用宏或者内联函数完成,零开销最好。
一个小技巧:OSAL层的头文件里不要包含任何RTOS的头文件。应用层只包含OSAL的头文件,这样换RTOS时只需要换OSAL层的实现文件,应用层连重新编译都不需要。
4. 应用层(App)——只关心业务
应用层是代码量最大的一层,也是最容易出bug的一层。但好消息是,有了HAL和OSAL垫底,应用层的代码可以写得非常干净。
应用层里应该只有:
- 业务逻辑:比如温度超过阈值就报警
- 状态机:比如设备初始化→待机→运行→故障
- 协议处理:比如解析Modbus报文、组包发出去
- 任务调度:比如每100ms采集一次传感器数据
应用层绝对不应该出现:
- 直接操作GPIO寄存器
- 调用FreeRTOS的
xQueueSend()(应该用OSAL封装后的接口) - 写死某个引脚的编号(应该用宏定义或配置表)
你想想看,如果应用层代码里到处都是GPIOA->ODR |= (1<<5),那这个代码基本没法测——你得接上真实硬件才能验证它对不对。
5. 分层如何天然促进可测试性?
这是今天的核心问题。我直接说结论:分层架构把“测什么”和“怎么测”分开了。
咱们用一张表对比一下:
| 测试对象 | 没分层时 | 分层后 |
|---|---|---|
| 应用层逻辑 | 必须跑在真实硬件上,依赖外设和RTOS | 可以编译成PC程序,用桩函数替换HAL/OSAL,纯软件测试 |
| HAL层驱动 | 与应用层代码混在一起,改驱动可能影响业务 | 独立测试,用回环测试或硬件在环测试验证 |
| OSAL层封装 | 应用层直接调用RTOS API,换RTOS要重测所有任务 | 只测OSAL层接口,应用层代码不受影响 |
| 集成测试 | 所有代码一起编译,出问题不知道是哪层的锅 | 逐层集成,先测HAL,再测OSAL,最后测App,定位快 |
说白了,分层架构让依赖关系变得单向且清晰。上层依赖下层,下层不依赖上层。这意味着你可以把下层替换成测试桩,单独测上层逻辑。
注意:分层不是万能的。如果层与层之间的接口设计得又大又乱,测试照样痛苦。接口要小、要稳定、要语义清晰。我见过一个HAL层,一个函数传8个参数,其中3个是枚举、2个是结构体指针、还有1个回调函数——这种接口测起来比不分层还累。
6. 一个实战案例:温度采集模块
咱们看个具体例子。假设你要做一个温度采集模块,每100ms读一次温度传感器,超过阈值就报警。
没分层时,代码可能长这样:
void TemperatureTask(void* param) {
while(1) {
// 直接操作I2C寄存器读传感器
I2C1->CR1 |= (1<<0);
while(!(I2C1->SR1 & (1<<0)));
uint16_t raw = I2C1->DR;
float temp = raw * 0.0625f;
// 直接调用FreeRTOS API发消息
if(temp > 50.0f) {
xQueueSend(alarm_queue, &temp, 0);
}
vTaskDelay(100);
}
}
这段代码怎么测?你得接上真实的I2C温度传感器,还得让FreeRTOS跑起来。想模拟一个高温场景?你得拿热风枪吹传感器。想测边界条件?你得把传感器放到冰水里。嗯,想想就头疼。
分层后,代码变成这样:
void TemperatureTask(void* param) {
while(1) {
float temp = HAL_I2C_ReadTemperature(SENSOR_ADDR);
if(temp > TEMP_THRESHOLD_HIGH) {
OSAL_QueueSend(alarm_queue, &temp, 0);
}
OSAL_TaskDelay(100);
}
}
现在测试就简单了:
- 写一个
HAL_I2C_ReadTemperature()的桩函数,让它返回你想要的温度值 - 写一个
OSAL_QueueSend()的桩函数,检查它是否被正确调用 - 然后你就可以在PC上跑这个任务,测试所有温度阈值逻辑
关键点:分层之后,应用层的测试不再需要硬件。你可以在PC上编译、运行、调试,用单元测试框架(比如Unity、Ceedling)跑几百个测试用例,几分钟搞定。这在传统嵌入式开发里想都不敢想。
7. 避坑指南
分层架构虽然好,但用不好也会翻车。我总结几个常见的坑:
- 层间耦合过紧:HAL层返回结构体指针,应用层直接修改结构体成员。这等于没分层。接口应该只传基本类型或简单结构体。
- 层数太多:有人喜欢分五层、六层,结果每层都在做转发,性能损耗大,测试也麻烦。三层够用就别加第四层。
- 接口不稳定:今天HAL_GPIO_WritePin有三个参数,明天改成四个,后天又改成两个。应用层跟着改来改去,测试用例也得重写。接口设计要一次想好。
- 忽略OSAL层:有人觉得“我就用FreeRTOS,不用抽象”。结果项目后期要换RTOS,或者要在Windows上模拟测试,才发现所有任务代码都绑死在FreeRTOS上,改不动。
我曾经在一个项目里,因为没做OSAL层,导致无法在PC上做集成测试。最后只能把整个固件烧到板子上,用串口打日志来调试。一个bug定位三天,其中两天在等编译和下载。从那以后,我再也不敢省OSAL层了。
8. 小结
经典三层架构不是新东西,但很多人用错了。记住三个要点:
- HAL层藏硬件——让应用层不依赖具体芯片
- OSAL层藏RTOS——让应用层不依赖具体操作系统
- App层只写业务——让测试可以脱离硬件运行