分层架构:嵌入式软件的三层结构划分原则

做嵌入式开发久了,你会发现一个规律:没有分层架构的项目,最终都会变成一坨「意大利面」。代码互相纠缠,改一个驱动要重新编译整个应用,换个芯片平台几乎等于重写。这滋味,我尝过不止一次。

今天聊的三层结构——HAL、OSAL、App,就是解决这个问题的核心武器。说白了,就是把软件切成三块:跟硬件相关的、跟操作系统相关的、跟业务逻辑相关的。各管各的,互不干扰。

为什么是三层?不是两层或四层?

你可能会问:两层不行吗?应用直接调硬件?

我早年做过一个项目,MCU 从 STM32F1 换到 GD32F3,结果应用层代码改了 60%。为什么?因为应用里到处都是 GPIO_SetBits、TIM_Cmd 这些硬件寄存器操作。换个芯片,这些全得重写。

后来加了 OSAL 层,又发现一个问题:有些模块依赖 FreeRTOS 的队列,有些依赖裸机的全局变量。想切到 RT-Thread?改吧。

所以三层结构不是拍脑袋想出来的,是实践逼出来的:

  • HAL:屏蔽芯片差异
  • OSAL:屏蔽系统差异
  • App:只关心业务逻辑

每一层解决一个维度的变化。这样,任何一个维度变了,其他两层基本不用动。

核心原则:上层只能调用相邻下层,不能跨层。App 不能直接调 HAL,OSAL 不能直接操作寄存器。这是铁律。

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 的延时函数
我的习惯:HAL 层头文件里只放纯 C 标准类型(uint8_t、uint32_t 等),不放结构体指针、不放回调函数。这样上层包含头文件时,不会引入任何硬件依赖。

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),底层怎么实现它不管。这就是抽象的价值。

我曾经踩过的坑:OSAL 层里不要封装「创建任务」的接口时,把任务栈大小、优先级这些参数写死。不同系统对栈对齐、优先级数值范围的要求不一样。写死了,换系统就崩。正确的做法是:把参数全部暴露出来,让上层配置。

App:应用层

App 层是业务逻辑的所在地。它只关心「做什么」,不关心「怎么做」。比如:

  • 温度超过阈值就报警
  • 收到特定指令就切换工作模式
  • 每 100ms 采集一次传感器数据

App 层调用的所有接口,要么来自 OSAL,要么来自 HAL。它自己不能直接操作寄存器,也不能直接调用 RTOS 的 API。

我见过一个反面教材:App 层代码里直接写了 NVIC_EnableIRQ(TIM2_IRQn)。后来芯片换了,中断号变了,整个应用层翻了个底朝天。这就是典型的跨层调用。

App 层的划分原则:

  1. 模块化:每个业务功能独立成一个模块,模块间通过接口通信
  2. 无硬件依赖:头文件里不包含任何芯片厂商的头文件
  3. 无 OS 依赖:不直接调用 RTOS API,全部走 OSAL
  4. 可模拟测试:在 PC 上可以编译运行,用模拟的 HAL/OSAL 做单元测试
判断 App 层是否「干净」的方法:把 HAL 和 OSAL 的实现文件全部删掉,只留头文件。如果 App 层还能编译通过(虽然链接会报错),说明分层是成功的。如果编译都过不了,说明有跨层依赖。

三层之间的依赖关系

用一张图来展示三层结构的关系:

嵌入式软件三层架构依赖关系 App 应用层 业务逻辑 · 状态机 · 协议处理 · 算法 OSAL 操作系统抽象层 任务管理 · 同步通信 · 时间管理 · 内存管理 HAL 硬件抽象层 GPIO · UART · SPI · I2C · Timer · ADC 箭头方向表示「调用关系」:上层调用下层,下层不反向依赖上层

这张图要表达的核心思想:依赖方向是单向的。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。这就是分层的目的。

一个小技巧:在 osal.h 和 hal.h 里,用条件编译做接口声明检查。比如在 hal.h 里加一句 #ifndef HAL_GPIO_H,防止上层不小心包含了芯片厂商的头文件。我习惯在 CI 里加一个编译检查:只编译 app 目录,看能不能通过。

什么时候可以打破分层?

说实话,我见过不少项目「严格分层」搞到走火入魔。比如为了分层,在 HAL 里封装一个「读按键」的函数,结果应用层读按键还要经过 OSAL 的消息队列转发一圈。这就过度设计了。

我的经验是:分层是手段,不是目的。以下情况可以适当打破:

  • 性能敏感路径:比如中断服务函数里,直接调 HAL 的寄存器操作,不走 OSAL
  • 原型验证阶段:先快速出功能,后面再重构分层
  • 芯片和 OS 都固定不变:比如产品生命周期内不换平台,可以适当简化

但要注意:打破分层要有注释说明,并且标记为「待重构」。我曾经在一个项目里看到 5 处跨层调用,每处都加了 TODO 注释。后来产品迭代了 3 年,这些 TODO 一个都没改。嗯,这就是技术债。

避坑指南:不要在 HAL 层里加延时函数。我见过有人为了「消抖」,在 HAL 的 GPIO 读取函数里加了 10ms 延时。结果上层一个循环里读 10 个按键,直接卡死 100ms。HAL 层应该是「无等待」的,延时和等待是 OSAL 或 App 层的事。

总结一下

三层架构不是什么高深理论,它就是一套代码组织的规矩。规矩定好了,团队协作就顺畅了,换平台就轻松了,测试也方便了。

最后送你一句话:好的架构,是让「改代码」这件事变得无聊。每次换芯片、换系统、加功能,你都觉得「哦,就改这几行」,那说明分层做对了。