2. 嵌入式软件架构分层与移植边界

说到嵌入式软件架构,硬件抽象层(HAL)是我最想跟你聊透的一个话题。很多工程师写驱动,上来就对着寄存器一顿操作,代码跑得挺欢。但换个芯片,全得重写。这就是没有HAL的后果。

说白了,HAL就是给上层软件一个“假象”——让应用层以为所有硬件都一样。你想想看,如果每次换MCU都要改应用代码,那还谈什么移植?

HAL的作用与边界

先搞清楚HAL到底该干什么,不该干什么。我见过不少项目,HAL越做越胖,最后变成了一个“四不像”。

职责 HAL该做的 HAL不该做的
硬件封装 屏蔽寄存器差异、外设差异 实现业务逻辑、协议栈
接口定义 提供统一的初始化、读写、控制接口 管理任务调度、内存分配策略
平台适配 隔离编译器、芯片架构差异 处理应用层状态机、数据流
资源管理 管理外设时钟、中断向量绑定 实现超时重传、错误恢复策略

核心原则:HAL只做“硬件是什么”的翻译,不做“硬件怎么用”的决策。边界划清楚了,后续移植才轻松。

我在项目中遇到过一件事:有个同事把TCP/IP的重传逻辑塞进了以太网HAL里。结果换了个PHY芯片,重传参数全乱了。嗯,这就是典型的边界模糊。

设备驱动接口标准化

接口标准化是HAL的命根子。没有标准接口,HAL就是个空壳。我个人习惯把驱动接口分成三类:

  1. 初始化/去初始化:xxx_init(), xxx_deinit()
  2. 数据收发:xxx_read(), xxx_write(), xxx_ioctl()
  3. 控制/状态:xxx_control(), xxx_status()

举个例子,一个标准的UART HAL接口长这样:

/* hal_uart.h - 硬件抽象层UART接口 */
#ifndef HAL_UART_H
#define HAL_UART_H

#include <stdint.h>
#include <stddef.h>

/* 配置结构体 - 平台无关 */
typedef struct {
    uint32_t baudrate;
    uint8_t  data_bits;    /* 5, 6, 7, 8 */
    uint8_t  stop_bits;    /* 1, 2 */
    uint8_t  parity;       /* 0: none, 1: odd, 2: even */
    uint8_t  flow_ctrl;    /* 0: none, 1: rts/cts */
} hal_uart_config_t;

/* 句柄类型 - 具体实现隐藏 */
typedef struct hal_uart_dev hal_uart_dev_t;

/* 标准接口 */
hal_uart_dev_t* hal_uart_init(uint8_t port_id, const hal_uart_config_t *cfg);
int32_t         hal_uart_write(hal_uart_dev_t *dev, const uint8_t *data, size_t len);
int32_t         hal_uart_read(hal_uart_dev_t *dev, uint8_t *buf, size_t max_len);
int32_t         hal_uart_ioctl(hal_uart_dev_t *dev, uint32_t cmd, void *arg);
void            hal_uart_deinit(hal_uart_dev_t *dev);

#endif /* HAL_UART_H */

你看,这个接口里没有出现任何寄存器地址、中断号、时钟频率。上层代码只需要知道“我要用UART0,波特率115200”,剩下的交给HAL去跟硬件打交道。

我的经验:接口参数尽量用枚举和结构体,别用裸int。枚举能自描述,结构体能扩展。我曾经见过一个接口用int传波特率,结果有人传了96000(多了一个0),查了两天才发现。

平台无关性设计模式

平台无关性不是靠“不用平台相关代码”实现的,而是靠设计模式把变化隔离掉。我常用的模式有这几个:

1. 句柄模式(Handle Pattern)

上层不直接操作硬件指针,只操作一个不透明句柄。具体实现藏在.c文件里。这样换平台时,只要重新实现.c,接口不变。

/* hal_uart_stm32.c - STM32平台实现 */
struct hal_uart_dev {
    USART_TypeDef *instance;
    IRQn_Type      irq_num;
    uint8_t        port_id;
    /* 其他STM32相关字段 */
};

hal_uart_dev_t* hal_uart_init(uint8_t port_id, const hal_uart_config_t *cfg) {
    hal_uart_dev_t *dev = malloc(sizeof(hal_uart_dev_t));
    if (!dev) return NULL;
    /* 根据port_id映射到具体USART外设 */
    switch (port_id) {
        case 0: dev->instance = USART1; dev->irq_num = USART1_IRQn; break;
        case 1: dev->instance = USART2; dev->irq_num = USART2_IRQn; break;
        default: free(dev); return NULL;
    }
    /* 配置寄存器... */
    return dev;
}

2. 回调注册模式(Callback Registration)

中断处理不写死在HAL里,而是让上层注册回调。这样HAL只管“中断来了”,不管“中断来了干什么”。

/* 回调类型定义 */
typedef void (*hal_uart_rx_cb_t)(hal_uart_dev_t *dev, uint8_t byte);

/* 注册接口 */
int32_t hal_uart_set_rx_callback(hal_uart_dev_t *dev, hal_uart_rx_cb_t cb);

/* 中断处理(在HAL内部) */
void hal_uart_isr_handler(hal_uart_dev_t *dev) {
    uint8_t byte = dev->instance->DR;  /* 读数据寄存器 */
    if (dev->rx_cb) {
        dev->rx_cb(dev, byte);  /* 回调上层 */
    }
}

3. 配置表驱动(Configuration Table Driven)

把平台相关的参数(寄存器地址、时钟门控、中断号)放到一个配置表里。换平台时只改表,不改逻辑。

/* 平台配置表 */
typedef struct {
    uint8_t      port_id;
    uint32_t     reg_base;
    uint32_t     clock_gate;
    IRQn_Type    irq_num;
    uint32_t     default_baudrate;
} hal_uart_platform_cfg_t;

/* STM32F4配置 */
static const hal_uart_platform_cfg_t uart_cfg[] = {
    {0, USART1_BASE, RCC_APB2ENR_USART1EN, USART1_IRQn, 115200},
    {1, USART2_BASE, RCC_APB1ENR_USART2EN, USART2_IRQn, 115200},
    /* 换GD32时,只改这个表 */
};

避坑指南:我曾经在配置表里漏了时钟门控的位掩码,结果UART初始化成功但死活不发数据。查了两天发现时钟没开。配置表一定要包含所有“使能类”参数,别想当然认为“默认就是开的”。

HAL架构全景图

下面这张图展示了HAL在整个嵌入式软件架构中的位置和内部结构。你可以看到,HAL像一层“绝缘层”,把上层的应用、协议栈、中间件和下层的芯片、外设完全隔开。

HAL硬件抽象层架构全景图 应用层 / 协议栈 / 中间件 不感知硬件细节,只调用HAL标准接口 硬件抽象层(HAL) 标准接口层 init/read/write/ioctl 配置表驱动 平台参数映射 回调管理 中断/事件分发 不透明句柄管理(Handle Pool) 实例化、生命周期管理 芯片抽象层(MCU Specific) 寄存器操作 / 中断向量 / 时钟门控 / DMA配置 硬件外设(UART / SPI / I2C / GPIO / Timer ...) 隔离边界 变化隔离

从这张图你能看到,HAL不是简单的一层函数封装,而是一个完整的设计体系。标准接口层负责“长什么样”,配置表驱动负责“怎么映射”,回调管理负责“怎么通知”,句柄管理负责“怎么存活”。

移植实战要点

最后聊几个移植时的实操要点,都是我用真金白银换来的教训:

  • 先定接口,再写实现。 我习惯先在hal_xxx.h里把接口签注定死,然后才去写具体芯片的.c。接口定了就不要改,改接口等于打自己的脸。
  • 每个外设一个文件。 hal_uart.c、hal_spi.c、hal_i2c.c,别混在一起。换平台时,我只需要替换对应的.c文件,头文件不动。
  • 编译期检查平台。 用宏做平台选择,但别滥用。我一般只在hal_platform.h里放一个平台宏,其他文件通过这个宏来条件编译。
  • 留好调试钩子。 在HAL接口里加可选的调试打印宏,出问题时能快速定位是HAL的问题还是上层的问题。

一句话总结:HAL设计得好,移植就是改配置表和重新编译;HAL设计得烂,移植就是重构。你选哪个?