2. 嵌入式系统基础回顾
说实话,我见过太多嵌入式项目栽在同一个坑里——硬件一换,软件几乎重写。你想想看,辛辛苦苦调了三个月的驱动,结果客户说「MCU 缺货,换另一家」,那种感觉,真叫一个酸爽。
怎么破?答案就四个字:架构先行。别急着写代码,先把架构图画清楚。
为什么硬件耦合是「万恶之源」?
嵌入式软件最头疼的问题,就是跟硬件绑得太死。我早期做过一个车载项目,用的是 STM32F4,所有外设操作都直接写在业务逻辑里。后来客户要求换成 NXP 的芯片,好家伙,整个应用层代码几乎全废。
硬件耦合带来的典型问题:
- 移植成本高——换个 MCU,驱动层、中间件、甚至业务逻辑都得改
- 测试困难——没有硬件,代码根本跑不起来
- 团队协作乱——硬件没定型,软件只能干等
- 复用率低——换个项目,代码基本重写
核心观点:硬件耦合不是技术问题,是架构问题。架构设计好了,硬件变化对上层的影响可以降到最低。
分层架构:把「变」与「不变」拆开
怎么解耦?分层。把系统拆成三层:HAL(硬件抽象层)、OSAL(操作系统抽象层)、BSP(板级支持包)。每一层各司其职,上层不关心下层怎么实现。
我习惯用一张图来说明这三层的关系:
HAL:硬件抽象层
HAL 的作用,就是把 MCU 的外设操作「标准化」。比如 GPIO 的读写,不管底层是 STM32 还是 GD32,HAL 提供的接口都一样。
我一般这样定义 HAL 接口:
/* hal_gpio.h */
typedef enum {
HAL_GPIO_MODE_INPUT,
HAL_GPIO_MODE_OUTPUT,
HAL_GPIO_MODE_ALT_FUNC
} hal_gpio_mode_t;
void hal_gpio_init(uint8_t pin, hal_gpio_mode_t mode);
void hal_gpio_set(uint8_t pin, uint8_t level);
uint8_t hal_gpio_get(uint8_t pin);
你看,接口里没有任何 MCU 相关的信息。底层实现可以随便换,只要保证接口不变就行。
我的习惯:HAL 接口尽量用纯 C 结构体 + 函数指针,不要用宏定义。宏在调试时很难跟踪,函数指针反而更灵活。
OSAL:操作系统抽象层
OSAL 解决的是「RTOS 依赖」问题。FreeRTOS、RT-Thread、uC/OS 各有各的 API,如果业务代码直接调用,换个 RTOS 又得重写。
OSAL 把 RTOS 的核心功能抽象出来:
| 功能 | OSAL 接口 | 说明 |
|---|---|---|
| 任务创建 | osal_task_create() | 统一任务入口和栈管理 |
| 信号量 | osal_sem_create() / take() / give() | 支持二值和计数信号量 |
| 消息队列 | osal_mq_create() / send() / recv() | 支持超时机制 |
| 时间管理 | osal_delay_ms() / osal_get_tick() | 毫秒级延时和系统滴答 |
| 内存管理 | osal_malloc() / osal_free() | 统一堆管理接口 |
举个例子,任务创建的 OSAL 封装:
/* osal_task.c */
#if defined(OS_FREERTOS)
#include "FreeRTOS.h"
#include "task.h"
int osal_task_create(const char *name, void (*entry)(void *),
void *param, uint32_t stack_size, uint32_t priority) {
return xTaskCreate(entry, name, stack_size, param, priority, NULL);
}
#elif defined(OS_RTTHREAD)
#include "rtthread.h"
int osal_task_create(const char *name, void (*entry)(void *),
void *param, uint32_t stack_size, uint32_t priority) {
rt_thread_t tid = rt_thread_create(name, entry, param,
stack_size, priority, 10);
if (tid) rt_thread_startup(tid);
return (tid != NULL) ? 0 : -1;
}
#endif
注意:OSAL 不是「万能胶」。如果项目确定只用一种 RTOS,OSAL 可以简化甚至省略。但如果你做的是平台级产品,OSAL 能省下大量移植时间。
BSP:板级支持包
BSP 是最底层的代码,直接跟硬件打交道。它负责:
- 时钟配置——系统时钟、外设时钟的初始化
- 中断向量——中断优先级、中断服务函数注册
- 内存映射——Flash、RAM 的地址分配
- 启动代码——从复位向量到 main() 的引导过程
- 外设初始化——GPIO 复用、UART 波特率、I2C 速率等
BSP 的特点是:每个板子都不一样。但好消息是,BSP 的接口是固定的,上层 HAL 只管调用,不关心 BSP 怎么配。
我习惯把 BSP 做成一个独立的模块,每个板子一个文件夹:
bsp/
├── board_a/
│ ├── bsp_clock.c
│ ├── bsp_uart.c
│ ├── bsp_gpio.c
│ └── bsp_config.h
├── board_b/
│ ├── bsp_clock.c
│ ├── bsp_uart.c
│ ├── bsp_gpio.c
│ └── bsp_config.h
└── bsp_common.h /* 统一的 BSP 接口声明 */
关键原则:BSP 只做「配置」和「初始化」,不做「业务逻辑」。业务逻辑应该放在应用层,通过 HAL 和 OSAL 间接访问硬件。
从需求文档到架构蓝图:实战步骤
光讲理论没用,咱们看看实际怎么做。我一般按这五步走:
- 梳理硬件资源——列出 MCU 型号、外设清单、传感器类型、通信接口
- 识别变化点——哪些硬件可能换?哪些 RTOS 可能换?哪些传感器可能升级?
- 定义接口契约——HAL、OSAL、BSP 的接口头文件先写好,不写实现
- 分层实现——从 BSP 开始,逐层向上实现,每层独立测试
- 集成验证——用「冒烟测试」验证各层之间的接口是否通畅
举个例子,我之前做一个物联网网关项目,需求文档里写的是「支持 4G 模块」。我一看,4G 模块的型号还没定,供应商可能换。怎么办?
我在 HAL 层定义了一个 hal_modem.h,接口包括:
int hal_modem_init(void);
int hal_modem_send(const uint8_t *data, uint32_t len);
int hal_modem_recv(uint8_t *buf, uint32_t *len, uint32_t timeout);
底层实现先写了一个模拟版本,用串口调试助手代替。等 4G 模块型号定了,再写真正的驱动。应用层从头到尾没改过一行代码。
避坑指南:我曾经在一个项目里把 HAL 接口定义得太「薄」,几乎就是寄存器操作的简单封装。结果换 MCU 时,HAL 接口本身也得改。后来我学乖了——HAL 接口要站在「应用视角」来定义,而不是「硬件视角」。
架构蓝图的核心要素
一份好的架构蓝图,应该包含以下内容:
| 要素 | 说明 | 产出物 |
|---|---|---|
| 分层结构图 | 展示各层之间的依赖关系 | SVG / Draw.io 图 |
| 接口定义 | HAL、OSAL、BSP 的头文件 | .h 文件 |
| 模块划分 | 每个模块的职责和边界 | 模块列表 + 说明 |
| 数据流图 | 数据在各层之间的流转路径 | 流程图 |
| 依赖清单 | 每个模块依赖哪些下层接口 | 表格 / 矩阵 |
我个人习惯在架构蓝图中加一个「变化影响分析」的表格,把每个变化点的影响范围列出来:
| 变化点 | 影响范围 | 需要修改的层 | 工作量评估 |
|---|---|---|---|
| 更换 MCU | BSP + HAL 实现 | BSP、HAL | 2-3 人天 |
| 更换 RTOS | OSAL 实现 | OSAL | 1-2 人天 |
| 更换传感器 | HAL 实现 + 应用层驱动 | HAL、应用层 | 1-3 人天 |
| 增加通信协议 | 应用层 | 应用层 | 3-5 人天 |
有了这个表,项目经理和客户都能清楚知道:改什么、改多少、花多久。
写在最后
分层架构不是银弹,但它能解决 80% 的硬件耦合问题。你想想看,如果每个项目都从零开始写驱动、写业务,那跟「搬砖」有什么区别?
架构先行,说白了就是「先想清楚再动手」。把变的部分和不变的部分拆开,把接口定好,剩下的就是填代码的事。嗯,这个思路,我用了十几年,从来没翻过车。