硬件抽象层设计原则:接口定义、隔离变化、依赖倒置

做嵌入式移植,最怕什么?

怕换芯片。换一次芯片,驱动代码重写一遍。我见过不少团队,项目初期跑得飞快,一到换平台就崩盘。说白了,就是硬件抽象层没设计好。

这一章,咱们就聊聊HAL设计的三个核心原则。我个人习惯把它们叫做「三板斧」:接口定义要稳、隔离变化要狠、依赖倒置要准。

2.1 接口定义:给硬件画个「标准插座」

先问个问题:你写GPIO驱动时,是怎么定义接口的?

很多人上来就写 HAL_GPIO_WritePin(PORT_A, PIN_1, HIGH)。这没问题。但换个芯片,参数可能就变了。有的芯片用端口号+引脚号,有的用寄存器地址+位掩码。接口一改,上层代码全得跟着改。

我建议的做法是:接口只描述「做什么」,不描述「怎么做」。

接口定义三要素:

  • 功能语义:接口名要清晰表达功能,比如 HAL_UART_Transmit 而不是 HAL_UART_SendByte
  • 参数抽象:用枚举、结构体代替裸数字,比如 GPIO_PinState 而不是 uint8_t
  • 返回值规范:统一用 HAL_StatusTypeDef 枚举,别一会儿返回0一会儿返回-1

举个例子。我之前做的一个项目,需要支持三种不同厂家的MCU。接口定义是这样的:

// 统一的GPIO接口
typedef enum {
    GPIO_PIN_RESET = 0,
    GPIO_PIN_SET
} GPIO_PinState;

typedef enum {
    GPIO_PORT_A, GPIO_PORT_B, GPIO_PORT_C
} GPIO_Port;

HAL_StatusTypeDef HAL_GPIO_WritePin(GPIO_Port port, uint16_t pin, GPIO_PinState state);
GPIO_PinState HAL_GPIO_ReadPin(GPIO_Port port, uint16_t pin);

你看,接口里没有出现任何寄存器地址、时钟配置。上层代码只关心「往哪个端口写什么电平」。至于底层是用寄存器操作还是用库函数,那是BSP的事。

小技巧:接口参数尽量用 uint16_t 或 uint32_t,别用 int。不同编译器对 int 的长度定义不一样,移植时容易踩坑。

2.2 隔离变化:把「变」和「不变」分开

接口定义好了,接下来就是怎么隔离变化。

你想想看,一个嵌入式系统里,什么最容易变?

  • 芯片型号(STM32F4 → STM32H7)
  • 外设版本(UART v1 → UART v2)
  • 引脚分配(PA9 → PB10)
  • 时钟频率(72MHz → 168MHz)

这些变化,不能影响到上层应用代码。怎么做到?分层隔离。

我习惯把驱动分成三层:

层次 职责 变化频率 示例
应用层 业务逻辑 几乎不变 LED闪烁、数据采集
HAL层 统一接口 很少变 HAL_GPIO_WritePin
BSP层 硬件操作 经常变 寄存器读写、时钟配置

这里的关键是:HAL层只调用BSP层的接口,不直接操作寄存器。BSP层变了,HAL层的接口不变,应用层就完全不受影响。

注意:别把BSP层的头文件暴露给应用层。应用层只能包含HAL层的头文件。否则,BSP层一改,应用层也得重新编译,那就白隔离了。

我曾经在一个项目中吃过这个亏。当时图省事,直接在应用层里调用了BSP的寄存器宏。结果换芯片时,光改应用层就花了两周。后来老老实实加了HAL层,第二次移植只用了两天。

2.3 依赖倒置:让高层「命令」底层

依赖倒置原则,说白了就是:高层模块不依赖底层模块,两者都依赖抽象接口。

在嵌入式里怎么用?举个例子。

假设你要实现一个数据采集系统。传统写法是这样的:

// 传统写法:高层依赖底层
void DataCollect_Task(void) {
    uint8_t data;
    // 直接调用UART驱动
    UART1_Receive(&data, 1);
    ProcessData(data);
}

这种写法,DataCollect_Task 直接依赖了 UART1_Receive。如果以后要改成SPI接收,就得改任务代码。

用依赖倒置改一下:

// 定义抽象接口
typedef struct {
    HAL_StatusTypeDef (*Receive)(uint8_t* data, uint16_t len);
    HAL_StatusTypeDef (*Transmit)(uint8_t* data, uint16_t len);
} HAL_CommInterface;

// 高层只依赖抽象接口
void DataCollect_Task(HAL_CommInterface* comm) {
    uint8_t data;
    comm->Receive(&data, 1);
    ProcessData(data);
}

// 底层实现
HAL_CommInterface uart_comm = {
    .Receive = UART_Receive_Impl,
    .Transmit = UART_Transmit_Impl
};

你看,DataCollect_Task 不再知道底层是UART还是SPI。它只认 HAL_CommInterface 这个抽象接口。换通信方式?换个实现结构体就行。

核心思想:依赖倒置让「变化」从底层向上层传播变成了「配置」从上层向下层注入。高层只管「我要什么」,底层只管「我怎么给」。

2.4 实战案例:一个GPIO驱动的HAL设计

光说不练假把式。咱们看一个完整的例子。

假设你要设计一个LED控制模块,支持两种芯片:STM32F4和GD32F3。

第一步:定义HAL接口

// hal_gpio.h
#ifndef HAL_GPIO_H
#define HAL_GPIO_H

#include "hal_common.h"

typedef enum {
    LED_RED, LED_GREEN, LED_BLUE
} LED_ID;

typedef enum {
    LED_ON, LED_OFF, LED_TOGGLE
} LED_Action;

HAL_StatusTypeDef HAL_LED_Control(LED_ID led, LED_Action action);

#endif

第二步:实现BSP层(STM32F4版本)

// bsp_gpio_stm32f4.c
#include "bsp_gpio_stm32f4.h"

static GPIO_TypeDef* led_ports[] = {GPIOA, GPIOB, GPIOC};
static uint16_t led_pins[] = {GPIO_PIN_0, GPIO_PIN_1, GPIO_PIN_2};

HAL_StatusTypeDef BSP_LED_Write(LED_ID led, uint8_t state) {
    if (led >= LED_BLUE) return HAL_ERROR;
    HAL_GPIO_WritePin(led_ports[led], led_pins[led], 
                      state ? GPIO_PIN_SET : GPIO_PIN_RESET);
    return HAL_OK;
}

第三步:实现HAL层

// hal_gpio.c
#include "hal_gpio.h"
#include "bsp_gpio.h"  // BSP层接口

HAL_StatusTypeDef HAL_LED_Control(LED_ID led, LED_Action action) {
    uint8_t state;
    switch(action) {
        case LED_ON:    state = 1; break;
        case LED_OFF:   state = 0; break;
        case LED_TOGGLE: state = !BSP_LED_Read(led); break;
        default: return HAL_ERROR;
    }
    return BSP_LED_Write(led, state);
}

换GD32F3芯片时,只需要重新实现 bsp_gpio_gd32f3.c,HAL层和应用层代码一行都不用改。

避坑指南:我曾经在BSP层里直接用了HAL层的结构体类型,导致BSP和HAL耦合。正确的做法是:BSP层只提供最基础的读写函数,不做逻辑判断。逻辑判断全部放在HAL层。

2.5 知识结构图

下面这张图,把HAL设计的三个原则串起来了:

HAL设计三大原则 应用层 HAL层(接口定义) BSP层(隔离变化) 硬件寄存器 依赖倒置 高层不依赖底层 两者都依赖抽象接口 接口定义在HAL层 隔离变化 BSP层封装硬件差异 HAL层提供统一接口 接口定义原则 功能语义清晰 参数抽象化 返回值规范化

这张图想表达的是:应用层只认HAL接口,HAL层通过BSP隔离硬件差异,依赖倒置让整个架构可插拔。你换芯片,只需要换BSP层那一块,其他层纹丝不动。

2.6 小结

嗯,这一章的内容就到这儿。三个原则,其实就一句话:接口要稳、变化要隔、依赖要倒。

我个人觉得,HAL设计最难的还不是技术,而是「克制」。你总想给上层多暴露点功能,总想省掉一层抽象。但移植的时候,这些「省掉」的代价会加倍还回来。

下次写驱动前,先问问自己:这个接口,换芯片后还能用吗?