3、分层架构设计:应用层、HAL层、驱动层的职责划分与依赖方向
做嵌入式开发久了,你会发现一个规律:代码好不好移植,关键看分层。我见过太多项目,所有代码揉在一个文件里,换个MCU恨不得重写80%。说白了,这就是架构没想清楚。
这一章,我们聊聊三层架构——应用层、HAL层、驱动层。它们各自该干什么,不该干什么,依赖关系怎么走。嗯,这是整个课程的地基。
3.1 三层架构长什么样?
先看一张图,这是我习惯用的分层模型:
看到没?箭头是单向的。应用层只能调用HAL层,HAL层只能调用驱动层,驱动层直接操作硬件。反过来不行。这个规则,我称之为「单向依赖铁律」。
核心原则:上层依赖下层,下层绝不依赖上层。谁依赖谁,谁就要为谁提供接口。
3.2 各层职责详解
3.2.1 驱动层(Driver)—— 离硬件最近的人
驱动层是唯一知道寄存器地址的层。它负责:
- 寄存器读写:直接操作GPIO、UART、SPI等外设寄存器
- 中断服务:处理硬件中断,做最轻量的响应
- 时序控制:满足芯片手册要求的延时、等待、握手
- DMA配置:设置传输通道、源地址、目的地址
举个例子,一个UART驱动的发送函数:
// driver层:直接操作寄存器
void drv_uart_send_byte(uint32_t base_addr, uint8_t data)
{
// 等待发送缓冲区空
while (!(REG_READ(base_addr + UART_SR) & UART_SR_TXE));
// 写入数据寄存器
REG_WRITE(base_addr + UART_DR, data);
}
注意,这个函数只关心「怎么把字节发出去」,不关心「发的是什么数据」。我在项目中遇到过有人把协议解析也塞进驱动层,结果换个MCU,协议代码也得跟着重写——这就是典型的职责不清。
避坑指南:驱动层不要出现任何业务逻辑。我曾经接手一个项目,驱动函数里居然有if (temperature > 50) 这样的判断——温度阈值是业务,不该出现在寄存器操作代码里。
3.2.2 HAL层(Hardware Abstraction Layer)—— 万能适配器
HAL层是三层架构的灵魂。它把不同MCU的驱动差异「抹平」,向上提供统一的API。
HAL层负责:
- 接口统一:定义uart_init()、uart_send()、uart_recv()等标准函数
- 平台适配:根据编译宏选择调用哪个MCU的驱动
- 资源管理:维护外设句柄、缓冲区、状态机
- 错误处理:把驱动层的硬件错误转成上层能理解的错误码
看一个HAL层的典型实现:
// hal层:统一接口,屏蔽底层差异
typedef struct {
uint32_t baudrate;
uint8_t data_bits;
uint8_t stop_bits;
uint8_t parity;
} hal_uart_config_t;
int32_t hal_uart_init(hal_uart_config_t *cfg)
{
// 根据平台宏调用不同驱动
#if defined(MCU_STM32)
return drv_stm32_uart_init(cfg);
#elif defined(MCU_GD32)
return drv_gd32_uart_init(cfg);
#elif defined(MCU_AT32)
return drv_at32_uart_init(cfg);
#else
return -1; // 不支持的平台
#endif
}
你想想看,应用层调用hal_uart_init()时,根本不需要知道底下是STM32还是GD32。这就是抽象的价值。
个人习惯:HAL层的头文件里,我只暴露结构体和函数声明,绝不暴露寄存器地址和宏定义。这样应用层开发者想「抄近路」直接操作寄存器都找不到门。
3.2.3 应用层(Application)—— 只管业务,不问硬件
应用层是老板,只关心「做什么」,不关心「怎么做」。它负责:
- 业务逻辑:温度采集、数据上报、报警判断
- 任务调度:什么时候采样、什么时候发送
- 协议处理:Modbus、MQTT、私有协议解析
- 状态管理:系统运行状态、错误恢复策略
一个干净的应用层代码长这样:
// 应用层:只调用HAL接口,不碰任何寄存器
void app_temperature_monitor(void)
{
uint16_t raw_value;
float temperature;
// 调用HAL接口读取ADC
if (hal_adc_read(ADC_CHANNEL_TEMP, &raw_value) == 0) {
temperature = (float)raw_value * 3.3f / 4096.0f * 100.0f;
// 业务判断
if (temperature > 85.0f) {
hal_uart_send_string("ALARM: Over temperature!\r\n");
hal_gpio_set_pin(LED_ALARM_PIN, 1);
}
}
}
看到没?应用层调的是hal_adc_read()、hal_uart_send_string(),不是drv_stm32_adc_read()。这就是分层带来的好处——换MCU时,应用层一行代码都不用改。
3.3 依赖方向与数据流向
搞清楚了职责,我们再看看依赖方向。这是很多人容易搞混的地方。
| 方向 | 谁调用谁 | 典型方式 | 是否允许 |
|---|---|---|---|
| 正向 | 应用层 → HAL层 | 函数调用 | ✅ 必须 |
| 正向 | HAL层 → 驱动层 | 函数调用 | ✅ 必须 |
| 反向 | 驱动层 → HAL层 | 回调函数 | ⚠️ 有限允许(通过注册回调) |
| 反向 | HAL层 → 应用层 | 直接调用 | ❌ 禁止 |
| 跨层 | 应用层 → 驱动层 | 直接调用 | ❌ 禁止 |
这里有个例外:驱动层的中断回调。比如UART接收中断,驱动层收到一个字节后,通过注册的函数指针通知HAL层。这是自下而上的事件通知,不是依赖。HAL层在初始化时把回调函数地址传给驱动层,驱动层只保存指针,不依赖HAL层的任何头文件。
关键区别:「调用」是主动的,「回调」是被动的。回调不破坏单向依赖原则,因为驱动层不知道回调函数属于哪一层——它只是执行一个函数指针。
3.4 实际项目中的分层案例
拿一个温控器项目举例。我几年前做过一个产品,最初代码全在main.c里,后来要换MCU,差点没把我逼疯。重构后用了三层架构:
项目结构:
├── app/
│ ├── app_temp_control.c // 温度控制逻辑
│ ├── app_display.c // 屏幕显示逻辑
│ └── app_key_scan.c // 按键扫描逻辑
├── hal/
│ ├── hal_timer.c // 定时器抽象接口
│ ├── hal_uart.c // 串口抽象接口
│ ├── hal_adc.c // ADC抽象接口
│ └── hal_gpio.c // GPIO抽象接口
├── driver/
│ ├── drv_stm32_timer.c // STM32定时器驱动
│ ├── drv_stm32_uart.c // STM32串口驱动
│ ├── drv_stm32_adc.c // STM32 ADC驱动
│ └── drv_stm32_gpio.c // STM32 GPIO驱动
└── main.c // 初始化 + 主循环
后来客户要求换成GD32,我只改了driver目录下的文件,hal层微调了几个时序参数,app层一个字没动。整个移植花了3天,其中2天在等芯片到货。
我的建议:写代码前先画好分层图。哪层该有什么文件,依赖箭头怎么画,想清楚了再动手。磨刀不误砍柴工。
3.5 常见分层错误与修正
这些年做代码评审,我见过最多的分层错误:
- 应用层直接操作寄存器——换MCU时欲哭无泪
- 驱动层包含业务逻辑——if else满天飞,驱动代码比应用还复杂
- HAL层形同虚设——直接透传驱动层接口,没做任何抽象
- 跨层调用——应用层跳过HAL直接调驱动,美其名曰「性能优化」
怎么修正?记住一句话:每一层只做自己该做的事,只调用下一层的接口。如果发现某段代码既操作寄存器又做业务判断,拆!
我曾经见过一个项目,应用层里直接写了REG_WRITE(GPIOA->ODR, 0x01)。问为什么这么写,答曰「这样快」。结果换MCU时,GPIOA都不存在了,整个应用层代码全部报废。分层不是束缚,是保护。
3.6 本章小结
三层架构的核心就三句话:
- 驱动层:知道硬件细节,提供最基础的寄存器操作
- HAL层:抹平硬件差异,提供统一API
- 应用层:只关心业务,不关心硬件
依赖方向永远是从上到下。记住这个铁律,你的代码复用率至少提升50%。下一层的变化,不应该影响上一层——这就是低耦合的真谛。