2. 嵌入式系统基础
好,咱们今天聊聊分层架构。说实话,我最早入行那会儿,写的嵌入式代码基本都是“一锅端”。一个 main.c 文件,里面 GPIO 初始化、定时器配置、业务逻辑全揉在一起。那时候项目小,芯片资源也紧,觉得这样挺直接。直到有一次,客户要求把产品从 STM32 平台换到 GD32 上……
嗯,那真是一场噩梦。所有硬件相关的代码散落在各个业务逻辑里,我花了整整两周才把移植搞定,还引入了一堆新 bug。从那以后,我彻底成了分层架构的忠实拥趸。
2.1 为什么必须分层?——从“一锅端”到“分而治之”
单体架构,说白了就是所有代码混在一起。硬件操作、系统调度、业务逻辑,全在一个大循环里。你想想看,这种代码有什么问题?
- 移植成本高:换个 MCU,所有代码都要翻一遍,找哪些地方用了寄存器操作。
- 测试困难:想单独测业务逻辑?得先把硬件搭好,还得祈祷别烧板子。
- 团队协作乱:A 工程师改了个 GPIO 定义,B 工程师的业务逻辑莫名其妙就崩了。
- 复用性差:这个项目的代码,下个项目基本没法直接用,得重写。
分层架构的核心思想,就是“关注点分离”。每一层只关心自己的事,层与层之间通过标准接口通信。这样做的好处,我在项目中体会太深了。
分层架构的三大好处:
- 可移植性:换硬件平台,只改最底层,上层纹丝不动。
- 可测试性:可以 mock 掉底层,单独验证业务逻辑。
- 可维护性:每层代码职责清晰,新人上手快,改 bug 定位准。
2.2 经典三层模型:HAL、OSAL、App
嵌入式领域最经典的分层模型,就是这三层:硬件抽象层(HAL)、操作系统抽象层(OSAL)、应用层(App)。我习惯叫它“三明治结构”。
2.2.1 硬件抽象层(HAL)
HAL 是最底层,直接跟 MCU 的寄存器打交道。它的任务就是封装所有硬件操作,向上层提供一套统一的、平台无关的接口。
我个人习惯,HAL 层的接口命名都带 HAL_ 前缀,比如 HAL_GPIO_WritePin()、HAL_UART_Transmit()。这样一看就知道是硬件操作,方便代码审查。
我的经验:HAL 层不要做任何业务判断。它就是个“翻译官”,把上层指令翻译成寄存器操作。我曾经见过有人在 HAL 层里加了个超时重试逻辑,结果换芯片后定时器频率变了,整个逻辑全乱套。
来看一个简单的 GPIO 控制接口定义:
/* hal_gpio.h - 硬件抽象层 GPIO 接口 */
#ifndef HAL_GPIO_H
#define HAL_GPIO_H
#include <stdint.h>
/* GPIO 端口枚举,平台无关 */
typedef enum {
HAL_GPIO_PORT_A,
HAL_GPIO_PORT_B,
HAL_GPIO_PORT_C,
HAL_GPIO_PORT_D
} hal_gpio_port_t;
/* GPIO 引脚号 */
typedef uint8_t hal_gpio_pin_t;
/* GPIO 电平状态 */
typedef enum {
HAL_GPIO_LOW = 0,
HAL_GPIO_HIGH = 1
} hal_gpio_state_t;
/* 初始化指定引脚为输出模式 */
void HAL_GPIO_InitOutput(hal_gpio_port_t port, hal_gpio_pin_t pin);
/* 设置引脚电平 */
void HAL_GPIO_WritePin(hal_gpio_port_t port, hal_gpio_pin_t pin, hal_gpio_state_t state);
/* 读取引脚电平 */
hal_gpio_state_t HAL_GPIO_ReadPin(hal_gpio_port_t port, hal_gpio_pin_t pin);
#endif /* HAL_GPIO_H */
你看,这个头文件里没有任何 STM32 或 GD32 的影子。换平台时,只需要重新实现这个 .c 文件就行。上层代码完全不用动。
2.2.2 操作系统抽象层(OSAL)
OSAL 这一层,很多人容易忽略。觉得“我用 FreeRTOS 就用 FreeRTOS 的 API 呗,干嘛还要包一层?”
我一开始也这么想。直到有一次,项目中期客户要求把 RTOS 从 FreeRTOS 换成 RT-Thread,因为后者有更好的网络协议栈支持。结果呢?所有任务创建、信号量、队列的调用全要改,光改这些就花了一周。
OSAL 的作用,就是封装 RTOS 的系统服务,提供统一的接口。常见的封装对象包括:
| 系统服务 | FreeRTOS API | OSAL 统一接口 |
|---|---|---|
| 任务创建 | xTaskCreate() |
OSAL_TaskCreate() |
| 信号量 | xSemaphoreCreateBinary() |
OSAL_SemCreate() |
| 消息队列 | xQueueCreate() |
OSAL_QueueCreate() |
| 延时 | vTaskDelay() |
OSAL_DelayMs() |
注意:OSAL 层不要引入任何业务逻辑。它只做“系统调用翻译”。我曾经见过有人在 OSAL 层里加了个任务优先级自动调整的算法,结果换 RTOS 后调度策略不同,系统直接跑飞了。
2.2.3 应用层(App)
应用层是咱们写业务逻辑的地方。状态机、通信协议、数据处理、用户界面,全在这层。它的核心约束是:不直接调用任何 HAL 或 RTOS 的原生 API。
应用层只通过 HAL 和 OSAL 的接口来间接操作硬件和系统服务。这样做的好处,说白了就是“业务代码一次编写,到处编译”。
举个例子,一个简单的 LED 闪烁任务:
/* app_led_task.c - 应用层 LED 控制任务 */
#include "osal.h"
#include "hal_gpio.h"
#define LED_PORT HAL_GPIO_PORT_B
#define LED_PIN 0
#define BLINK_MS 500
void App_LedTask(void *param)
{
/* 初始化 LED 引脚(通过 HAL) */
HAL_GPIO_InitOutput(LED_PORT, LED_PIN);
while (1)
{
HAL_GPIO_WritePin(LED_PORT, LED_PIN, HAL_GPIO_HIGH);
OSAL_DelayMs(BLINK_MS);
HAL_GPIO_WritePin(LED_PORT, LED_PIN, HAL_GPIO_LOW);
OSAL_DelayMs(BLINK_MS);
}
}
你看,这个文件里没有 HAL_GPIO_WriteBit(ST 风格),也没有 vTaskDelay(FreeRTOS 风格)。它只用了咱们自己定义的 HAL 和 OSAL 接口。换平台?重新编译 HAL 和 OSAL 的 .c 文件就行,App 层代码一个字都不用改。
2.3 分层架构的依赖规则
这里有个关键点,我称之为“单向依赖原则”:
- App → OSAL → HAL:上层依赖下层,下层绝不能反向依赖上层。
- 同层之间:尽量通过接口通信,不要直接调用同层其他模块的内部函数。
- 跨层调用:禁止 App 直接调 HAL,也禁止 HAL 调 App。
我曾经在一个项目里发现,有人为了“省事”,在 HAL 的 UART 中断里直接调了 App 层的协议解析函数。结果换芯片后,UART 中断频率变了,协议解析的时序全乱,查了三天才找到原因。
避坑指南:如果你发现代码里出现了 #include "app_xxx.h" 在 HAL 层的 .c 文件里,那一定是架构设计出了问题。赶紧重构,别等到移植时再后悔。
2.4 什么时候可以“打破”分层?
嗯,我知道有人会问:是不是所有场景都必须严格分层?
我的回答是:规则是死的,项目是活的。在以下场景,可以适当放宽:
- 资源极度受限的 MCU(比如 1KB RAM):分层会带来额外的函数调用开销和代码体积。这时候可以适当合并层,但要做好注释,标明“此处为架构妥协”。
- 性能敏感的中断服务函数:中断里如果走 HAL → OSAL → App 的完整路径,延迟可能不可接受。可以在 HAL 层提供“快速通道”回调注册机制,但必须用文档严格约束。
- 原型验证阶段:快速出 Demo 时,可以先写“平铺代码”。但一定要在进入正式开发前完成分层重构,否则技术债会越滚越大。
说白了,分层架构是工具,不是教条。但作为架构师,你得清楚:每一次打破规则,都是在借未来的债。我个人习惯,在项目启动时就定好分层目录结构,并在代码评审时重点检查依赖方向。养成习惯后,团队协作效率会明显提升。
好,这一章的核心内容就这些。记住:HAL 管硬件、OSAL 管系统、App 管业务。各司其职,移植无忧。