3. Modern C++语言基础回顾
做嵌入式开发久了,你会发现一个规律:没有分层架构的项目,最终都会变成一坨「意大利面」。代码互相纠缠,改一个驱动要重新编译整个应用,换个芯片平台几乎等于重写。这滋味,我尝过不止一次。
今天聊的三层结构——HAL、OSAL、App,就是解决这个问题的核心武器。说白了,就是把软件切成三块:跟硬件相关的、跟操作系统相关的、跟业务逻辑相关的。各管各的,互不干扰。
为什么是三层?不是两层或四层?
你可能会问:两层不行吗?应用直接调硬件?
我早年做过一个项目,MCU 从 STM32F1 换到 GD32F3,结果应用层代码改了 60%。为什么?因为应用里到处都是 GPIO_SetBits、TIM_Cmd 这些硬件寄存器操作。换个芯片,这些全得重写。
后来加了 OSAL 层,又发现一个问题:有些模块依赖 FreeRTOS 的队列,有些依赖裸机的全局变量。想切到 RT-Thread?改吧。
所以三层结构不是拍脑袋想出来的,是实践逼出来的:
- HAL:屏蔽芯片差异
- OSAL:屏蔽系统差异
- App:只关心业务逻辑
每一层解决一个维度的变化。这样,任何一个维度变了,其他两层基本不用动。
HAL:硬件抽象层
HAL 的任务很简单:把芯片的寄存器操作封装成统一的接口。不管底下是 STM32、GD32 还是 NXP,上层看到的都是 gpio_write(pin, level)、uart_send(handle, data, len) 这种函数。
我在项目中遇到过最典型的场景:客户要求把主控从 STM32F4 换成国产某芯片。因为 HAL 层封装得好,只改了 HAL 目录下的 5 个文件,应用层一行代码没动。测试通过那天,客户说「你们效率真高」。我心里想:不是效率高,是架构好。
HAL 的划分原则,我总结了几条:
| 原则 | 说明 | 反例 |
|---|---|---|
| 接口统一 | 不同芯片的同功能接口,参数和返回值保持一致 | STM32 的 GPIO 初始化用结构体,换芯片后变成传参列表 |
| 功能原子 | 每个函数只做一件事,不掺杂业务逻辑 | uart_send() 里加了个「发送完点亮 LED」 |
| 无状态依赖 | 不依赖全局变量或静态变量记录业务状态 | gpio_write() 里判断「当前是否在报警状态」 |
| 可测试 | 可以单独编译测试,不依赖其他模块 | HAL 函数里调用了 OSAL 的延时函数 |
OSAL:操作系统抽象层
OSAL 解决的是「操作系统差异」问题。你的项目可能跑 FreeRTOS、RT-Thread、uCOS,甚至裸机。OSAL 就是让上层代码不感知这些差异。
OSAL 通常封装这些功能:
- 任务/线程管理:创建、删除、挂起、恢复
- 同步机制:信号量、互斥锁、事件组
- 通信机制:消息队列、邮箱、管道
- 时间管理:延时、定时器、获取系统 tick
- 内存管理:动态分配、内存池
举个例子,裸机和 RTOS 下的延时实现完全不同:
// OSAL 统一接口
void osal_delay_ms(uint32_t ms);
// 裸机实现:使用 SysTick 忙等
void osal_delay_ms(uint32_t ms) {
uint32_t start = sys_tick_get();
while ((sys_tick_get() - start) < ms);
}
// FreeRTOS 实现:让出 CPU
void osal_delay_ms(uint32_t ms) {
vTaskDelay(pdMS_TO_TICKS(ms));
}
// RT-Thread 实现
void osal_delay_ms(uint32_t ms) {
rt_thread_mdelay(ms);
}
你看,应用层调 osal_delay_ms(100),底层怎么实现它不管。这就是抽象的价值。
App:应用层
App 层是业务逻辑的所在地。它只关心「做什么」,不关心「怎么做」。比如:
- 温度超过阈值就报警
- 收到特定指令就切换工作模式
- 每 100ms 采集一次传感器数据
App 层调用的所有接口,要么来自 OSAL,要么来自 HAL。它自己不能直接操作寄存器,也不能直接调用 RTOS 的 API。
我见过一个反面教材:App 层代码里直接写了 NVIC_EnableIRQ(TIM2_IRQn)。后来芯片换了,中断号变了,整个应用层翻了个底朝天。这就是典型的跨层调用。
App 层的划分原则:
- 模块化:每个业务功能独立成一个模块,模块间通过接口通信
- 无硬件依赖:头文件里不包含任何芯片厂商的头文件
- 无 OS 依赖:不直接调用 RTOS API,全部走 OSAL
- 可模拟测试:在 PC 上可以编译运行,用模拟的 HAL/OSAL 做单元测试
三层之间的依赖关系
用一张图来展示三层结构的关系:
这张图要表达的核心思想:依赖方向是单向的。App 调 OSAL,OSAL 调 HAL。反过来不行。HAL 不能知道 App 的存在,OSAL 也不能直接调用 App 的回调(除非通过注册机制)。
实际项目中的目录结构
理论说完了,看看实际项目里怎么组织代码。这是我个人比较推荐的结构:
project/
├── app/ # 应用层
│ ├── module_a/
│ │ ├── module_a.c
│ │ └── module_a.h
│ ├── module_b/
│ │ ├── module_b.c
│ │ └── module_b.h
│ └── main.c
├── osal/ # 操作系统抽象层
│ ├── osal_task.c
│ ├── osal_sem.c
│ ├── osal_queue.c
│ ├── osal_time.c
│ └── osal.h # 统一对外接口
├── hal/ # 硬件抽象层
│ ├── hal_gpio.c
│ ├── hal_uart.c
│ ├── hal_spi.c
│ └── hal.h # 统一对外接口
├── bsp/ # 板级支持包(芯片相关)
│ ├── stm32f4/
│ │ ├── startup.s
│ │ └── system_stm32f4.c
│ └── linker.ld
└── build/
注意看:app 目录下的文件,只包含 osal.h 和 hal.h。它不知道底下是 FreeRTOS 还是裸机,也不知道是 STM32 还是 GD32。这就是分层的目的。
#ifndef HAL_GPIO_H,防止上层不小心包含了芯片厂商的头文件。我习惯在 CI 里加一个编译检查:只编译 app 目录,看能不能通过。
什么时候可以打破分层?
说实话,我见过不少项目「严格分层」搞到走火入魔。比如为了分层,在 HAL 里封装一个「读按键」的函数,结果应用层读按键还要经过 OSAL 的消息队列转发一圈。这就过度设计了。
我的经验是:分层是手段,不是目的。以下情况可以适当打破:
- 性能敏感路径:比如中断服务函数里,直接调 HAL 的寄存器操作,不走 OSAL
- 原型验证阶段:先快速出功能,后面再重构分层
- 芯片和 OS 都固定不变:比如产品生命周期内不换平台,可以适当简化
但要注意:打破分层要有注释说明,并且标记为「待重构」。我曾经在一个项目里看到 5 处跨层调用,每处都加了 TODO 注释。后来产品迭代了 3 年,这些 TODO 一个都没改。嗯,这就是技术债。
总结一下
三层架构不是什么高深理论,它就是一套代码组织的规矩。规矩定好了,团队协作就顺畅了,换平台就轻松了,测试也方便了。
最后送你一句话:好的架构,是让「改代码」这件事变得无聊。每次换芯片、换系统、加功能,你都觉得「哦,就改这几行」,那说明分层做对了。