分层架构基础:经典三层架构
做嵌入式开发这么多年,我见过太多「一锅粥」式的代码。说白了,就是所有功能揉在一个文件里,GPIO 初始化、任务调度、业务逻辑全混在一起。这种代码,换一个芯片基本等于重写。
那怎么解决?分层。今天我们就聊聊嵌入式领域最经典的三层架构:HAL、OSAL、App。
为什么需要分层?
先问一个问题:你写的代码,换一块 MCU 还能跑吗?
如果不能,说明你的硬件依赖和业务逻辑绑得太死了。我在项目中遇到过好几次这种情况——客户说「换个更便宜的芯片」,结果底层驱动全要改,上层业务逻辑也跟着遭殃。那段时间加班加得我头皮发麻。
分层架构的核心目的就一个:隔离变化。硬件变了,只改 HAL;操作系统换了,只改 OSAL;业务需求变了,只改 App。互不干扰。
三层架构全景图
先看一张整体结构图,我习惯用这张图给团队新人做培训:
这张图我用了好多年。每次画完,我都会补一句:箭头方向就是依赖方向。App 依赖 OSAL,OSAL 依赖 HAL。反过来不行——HAL 里绝对不能出现 App 的调用。
HAL:硬件抽象层
HAL 是离硬件最近的一层。它的任务就是把芯片厂商的寄存器操作包装成统一的接口。
举个例子。同样是点亮一个 LED,STM32 用 HAL_GPIO_WritePin,GD32 用 gpio_bit_set,NXP 用 GPIO_PinWrite。如果没有 HAL,你的业务代码里就会到处是这些厂商特有的函数。
我个人的习惯是,HAL 接口只暴露最基本的功能:
/* hal_gpio.h - 硬件抽象层 GPIO 接口 */
#ifndef HAL_GPIO_H
#define HAL_GPIO_H
#include <stdint.h>
/* GPIO 方向枚举 */
typedef enum {
HAL_GPIO_DIR_IN,
HAL_GPIO_DIR_OUT
} hal_gpio_dir_t;
/* GPIO 电平枚举 */
typedef enum {
HAL_GPIO_LOW = 0,
HAL_GPIO_HIGH = 1
} hal_gpio_level_t;
/* 初始化 GPIO */
void hal_gpio_init(uint8_t pin, hal_gpio_dir_t dir);
/* 设置 GPIO 输出电平 */
void hal_gpio_write(uint8_t pin, hal_gpio_level_t level);
/* 读取 GPIO 输入电平 */
hal_gpio_level_t hal_gpio_read(uint8_t pin);
#endif /* HAL_GPIO_H */
OSAL:操作系统抽象层
OSAL 这一层,说白了就是让上层代码不依赖具体的 RTOS。
你想想看,FreeRTOS 的任务创建函数是 xTaskCreate,RT-Thread 是 rt_thread_create,uCOS 是 OSTaskCreate。如果 App 层直接调用这些,换 RTOS 就得改 App——这跟没分层有什么区别?
OSAL 要做的就是定义一套通用的接口:
/* osal_task.h - 操作系统抽象层任务管理 */
#ifndef OSAL_TASK_H
#define OSAL_TASK_H
#include <stdint.h>
/* 任务句柄类型(对具体 RTOS 句柄的封装) */
typedef void* osal_task_handle_t;
/* 任务入口函数类型 */
typedef void (*osal_task_entry_t)(void* param);
/* 创建任务 */
osal_task_handle_t osal_task_create(
const char* name,
osal_task_entry_t entry,
void* param,
uint32_t stack_size,
uint32_t priority
);
/* 删除任务 */
void osal_task_delete(osal_task_handle_t task);
/* 任务延时(毫秒) */
void osal_task_delay(uint32_t ms);
#endif /* OSAL_TASK_H */
App:应用层
App 层是写业务逻辑的地方。它只调用 OSAL 和 HAL 的接口,不直接接触任何硬件寄存器或 RTOS API。
这样做的好处是什么?我举个例子。之前做一个数据采集项目,客户要求从 FreeRTOS 换成 RT-Thread。因为 App 层只用了 OSAL 的接口,我只需要重新实现 OSAL 层的几个函数,App 层一行代码没改。整个移植花了不到两天。
App 层的典型代码长这样:
/* app_sensor.c - 应用层传感器采集任务 */
#include "osal_task.h"
#include "hal_gpio.h"
#include "hal_uart.h"
/* 传感器数据采集任务 */
static void sensor_task_entry(void* param)
{
(void)param;
uint8_t buffer[64];
while (1) {
/* 触发传感器 */
hal_gpio_write(TRIGGER_PIN, HAL_GPIO_HIGH);
osal_task_delay(10);
hal_gpio_write(TRIGGER_PIN, HAL_GPIO_LOW);
/* 读取数据 */
hal_uart_read(SENSOR_UART, buffer, sizeof(buffer));
/* 处理数据... */
/* 等待下一次采集 */
osal_task_delay(1000);
}
}
/* 应用初始化 */
void app_init(void)
{
hal_gpio_init(TRIGGER_PIN, HAL_GPIO_OUT);
hal_uart_init(SENSOR_UART, 115200);
osal_task_create("sensor", sensor_task_entry, NULL, 1024, 5);
}
层间依赖关系与接口设计原则
三层架构的依赖关系,我用一个表格总结:
| 层 | 依赖谁 | 被谁依赖 | 关键原则 |
|---|---|---|---|
| App | OSAL、HAL | 无 | 不直接调用 RTOS API 或寄存器 |
| OSAL | HAL(可选)、RTOS | App | 接口取最大公约数,不暴露 RTOS 特性 |
| HAL | MCU SDK / 寄存器 | OSAL、App | 只做原子操作,不做业务逻辑 |
接口设计上,我总结了三条铁律:
- 单向依赖:上层依赖下层,下层绝不反向依赖上层。这是分层架构的底线。
- 接口稳定:HAL 和 OSAL 的接口一旦确定,尽量不要改。改接口意味着所有上层代码都要跟着改。
- 最小暴露:只暴露必要的功能。比如 HAL 的 GPIO 接口,只给 init、write、read 就够了,不需要暴露寄存器地址。
避坑指南:我曾经在一个项目里,为了「方便」,在 HAL 层直接调用了 App 层的回调函数。结果后来 App 层重构,HAL 层也得跟着改。那叫一个痛苦。记住:下层永远不知道上层是谁。如果需要回调,用注册机制,不要硬编码。
一个完整的调用链路
最后,看一个实际例子。假设你要通过 UART 发送一条数据:
- App 层调用
osal_uart_write(data, len) - OSAL 层获取 UART 互斥锁,然后调用
hal_uart_write(port, data, len) - HAL 层操作 UART 数据寄存器,等待发送完成中断
整个过程,App 层不知道用的是哪个 MCU,也不知道有没有用 DMA。它只管发数据。这就是分层架构的魅力。