3. 分层架构设计:应用层、服务层、驱动层、硬件抽象层(HAL)的职责划分
做单片机软件,最怕什么?
我最怕的是代码写到最后成了一团乱麻。中断里调延时,驱动里写业务逻辑,应用层直接操作寄存器……这种项目,三个月后连自己都看不懂。
分层架构就是为了解决这个问题。说白了,就是把不同职责的代码放到不同的盒子里,让它们各司其职。今天我们就聊聊这四层——应用层、服务层、驱动层、硬件抽象层——到底该怎么分。
3.1 为什么需要分层?
先讲个我自己的经历。早年做一款智能家居网关,MCU 用的是 STM32F103。刚开始图省事,所有代码都揉在 main.c 里。按键检测、LCD 刷新、Wi-Fi 通信、传感器读取……全在一个 while 循环里。
结果呢?
- 换了一个 LCD 屏,要改 17 个文件
- Wi-Fi 模块从 ESP8266 换成 ESP32,几乎重写了整个通信逻辑
- 新同事接手,看了三天代码,跟我说想辞职
这就是不分层的代价。你想想看,如果当时我把硬件操作和业务逻辑分开,换屏只需要改驱动层,换 Wi-Fi 模块只需要改 HAL 层,应用层根本不用动。
核心原则:每一层只关心自己的事,不越界。上层调用下层,下层不依赖上层。
3.2 四层架构总览
这张图是我做项目时常用的分层结构,你可以把它当作模板来用。
3.3 各层职责详解
3.3.1 硬件抽象层(HAL)
HAL 是最底层,直接跟 MCU 寄存器打交道。它的任务只有一个:把硬件细节藏起来。
举个例子。你用 STM32 的 GPIO 点灯,直接写寄存器是这样的:
// 直接操作寄存器
GPIOA->BSRR = (1 << 5); // PA5 置高
GPIOA->BRR = (1 << 5); // PA5 置低
但如果你换了 MCU,比如换成 GD32,寄存器地址和位定义可能完全不同。怎么办?
HAL 层就是用来解决这个问题的。我一般会封装成这样:
// HAL 层接口
void HAL_GPIO_WritePin(GPIO_TypeDef* port, uint16_t pin, uint8_t state);
uint8_t HAL_GPIO_ReadPin(GPIO_TypeDef* port, uint16_t pin);
这样,上层代码永远只调用 HAL_GPIO_WritePin,不管底层是 STM32 还是 GD32,甚至换成 NXP 的芯片,只要重新实现 HAL 层,上层代码一行都不用改。
我的习惯:HAL 层只做三件事——寄存器封装、引脚映射、外设初始化。不要在这里写任何业务逻辑,哪怕是一个 if 判断都要慎重。
3.3.2 驱动层(Driver Layer)
驱动层在 HAL 之上,负责管理具体的外设。比如 I2C、SPI、UART、定时器、ADC 这些。
驱动层的核心是:提供完整的外设操作接口,并处理中断和 DMA。
拿 UART 驱动来说,我通常会提供这些接口:
// 驱动层接口
void DRV_UART_Init(uint32_t baudrate);
void DRV_UART_Send(uint8_t* data, uint16_t len);
uint8_t DRV_UART_Receive(uint8_t* data, uint16_t timeout);
void DRV_UART_IRQHandler(void); // 中断处理函数
注意看,驱动层已经不再暴露寄存器了。上层调用者只需要知道「初始化、发送、接收」这三个操作,完全不用关心 UART 的波特率寄存器怎么配、中断标志位怎么清。
我曾经踩过的坑:在驱动层里直接处理协议。比如在 UART 驱动里解析 Modbus 报文。结果换了一个协议,驱动层要重写。记住,驱动层只负责「怎么收发」,不负责「收发的数据是什么意思」。
3.3.3 服务层(Service Layer)
服务层是承上启下的关键一层。它把驱动层的原始数据加工成应用层能直接用的信息。
举个例子。驱动层从传感器读回来的是原始字节:
// 驱动层返回
uint8_t raw_data[4] = {0x01, 0xAB, 0xCD, 0xEF};
服务层负责解析这些字节,转换成有意义的物理量:
// 服务层处理
float temperature = SVC_Sensor_GetTemperature(); // 返回 25.6°C
float humidity = SVC_Sensor_GetHumidity(); // 返回 68.3%
服务层还负责:
- 协议解析:Modbus、CANOpen、自定义协议
- 数据缓存:环形缓冲区、FIFO、状态缓存
- 事件管理:按键事件、超时事件、错误事件
- 超时处理:通信超时、任务超时、看门狗喂狗
关键点:服务层是唯一可以包含「状态机」的地方。应用层的状态机调用服务层,服务层内部也可以有自己的状态机(比如协议解析状态机)。但驱动层和 HAL 层绝对不要出现状态机。
3.3.4 应用层(Application Layer)
应用层在最上面,是产品的灵魂。它只关心「做什么」,不关心「怎么做」。
比如一个智能温控器,应用层的逻辑可能是:
// 应用层伪代码
void APP_TemperatureControl(void) {
float temp = SVC_Sensor_GetTemperature();
float target = SVC_Config_GetTargetTemp();
if (temp < target - 1.0) {
SVC_Heater_TurnOn();
} else if (temp > target + 1.0) {
SVC_Heater_TurnOff();
}
}
你看,应用层不知道传感器是 DS18B20 还是 NTC,不知道加热器是继电器还是可控硅,不知道通信用的是 I2C 还是 SPI。它只管业务逻辑。
应用层通常包含:
- 主状态机:系统状态切换(初始化、运行、休眠、故障)
- 任务调度:周期性任务、延时任务、事件触发任务
- 用户交互:按键处理、菜单逻辑、显示刷新
- 业务算法:PID 控制、滤波算法、数据统计
3.4 层间调用关系
层与层之间的调用必须遵守一个铁律:只能上层调用下层,不能反向调用。
| 调用方向 | 是否允许 | 说明 |
|---|---|---|
| 应用层 → 服务层 | ✅ 允许 | 最常见的调用方式 |
| 应用层 → 驱动层 | ⚠️ 尽量避免 | 紧急情况可以,但会破坏分层 |
| 应用层 → HAL 层 | ❌ 禁止 | 绝对不要,这是架构红线 |
| 服务层 → 驱动层 | ✅ 允许 | 标准调用路径 |
| 驱动层 → HAL 层 | ✅ 允许 | 驱动层必须调用 HAL |
| 下层 → 上层 | ❌ 禁止 | 通过回调或事件机制间接实现 |
特别注意:「下层调用上层」是分层架构的大忌。如果驱动层需要通知应用层数据到了,正确的做法是注册回调函数或者发送事件,而不是直接调用应用层的函数。我在一个项目中见过驱动层直接调用应用层的显示刷新函数,结果换了一个显示屏,驱动层和应用层都要改——这就是典型的耦合灾难。
3.5 实际项目中的文件组织
说了这么多理论,我们看看实际工程中怎么组织文件。这是我常用的目录结构:
project/
├── app/ # 应用层
│ ├── app_main.c # 主状态机
│ ├── app_menu.c # 菜单逻辑
│ └── app_control.c # 控制算法
├── service/ # 服务层
│ ├── svc_sensor.c # 传感器数据解析
│ ├── svc_comm.c # 通信协议
│ └── svc_storage.c # 数据存储
├── driver/ # 驱动层
│ ├── drv_uart.c # UART 驱动
│ ├── drv_i2c.c # I2C 驱动
│ └── drv_timer.c # 定时器驱动
├── hal/ # 硬件抽象层
│ ├── hal_gpio.c # GPIO 封装
│ ├── hal_spi.c # SPI 封装
│ └── hal_flash.c # Flash 操作
└── main.c # 入口,只做初始化
每个文件都有明确归属。新同事接手,看目录就知道代码在哪。换芯片,只需要改 hal 目录。换外设,只需要改 driver 目录。改业务逻辑,只需要改 app 目录。
我的建议:从项目第一天就按这个结构建文件夹。哪怕刚开始只有几个文件,也要把架子搭好。等代码量上来再重构,成本就高了。
3.6 分层架构的收益
最后总结一下,分层架构到底能带来什么好处:
- 可移植性:换 MCU 只改 HAL 层,换外设只改驱动层
- 可维护性:每层代码独立,修改不影响其他层
- 可测试性:可以单独测试每一层,不用搭完整硬件
- 团队协作:不同工程师负责不同层,接口定好就能并行开发
- 代码复用:驱动层和服务层可以在多个项目中复用
嗯,分层架构不是银弹,但它能解决 80% 的嵌入式软件混乱问题。从下一个项目开始,试试这个四层结构。你会回来感谢我的。