2. HAL基础认知

做嵌入式HAL设计,说白了就是跟「变化」打交道。芯片厂商换了一茬又一茬,外设寄存器布局千奇百怪,但上层应用不想跟着折腾。怎么让驱动层稳定,让应用层省心?

我个人习惯,先吃透三个设计原则:接口隔离、依赖倒置、单一职责。这三个东西不是学院派的纸上谈兵,我在项目里踩过的坑,十有八九都能追溯到其中某条没做到位。

2.1 接口隔离原则(ISP):别让驱动暴露它不该管的事

接口隔离原则,原话是「客户端不应该依赖它不需要的接口」。翻译成嵌入式的话:你的HAL接口,别把底层寄存器的脏活累活甩给上层。

举个例子。你写一个UART驱动,如果暴露了这样的接口:

// 糟糕的设计:上层要关心寄存器位域
void uart_set_baud_rate_divisor(uint32_t base_addr, uint16_t divisor);
void uart_set_stop_bit_config(uint32_t base_addr, uint8_t stop_bit_mode);
void uart_enable_fifo(uint32_t base_addr, uint8_t fifo_level);

上层调用者就得知道:基地址是多少?divisor怎么算?fifo_level填几?这等于把芯片手册甩给了应用工程师。换一颗芯片,这些接口全得改。

我建议的做法是:

// 好的设计:上层只关心「我要什么」,不关心「你怎么做」
hal_status_t hal_uart_init(hal_uart_t *huart, const hal_uart_config_t *config);
hal_status_t hal_uart_send(hal_uart_t *huart, const uint8_t *data, uint32_t len);
hal_status_t hal_uart_receive(hal_uart_t *huart, uint8_t *data, uint32_t len);

配置结构体把细节藏起来:

typedef struct {
    uint32_t baud_rate;      // 直接填 115200,不用算 divisor
    uint8_t  data_bits;      // 7 或 8
    uint8_t  stop_bits;      // 1 或 2
    uint8_t  parity;         // NONE, ODD, EVEN
} hal_uart_config_t;

我在项目中遇到过一件事:某款MCU的UART波特率计算需要查表,另一款需要公式算。如果接口暴露了divisor,上层代码就得写两套逻辑。用了隔离后的接口,上层永远只填115200,底层自己去换算。这就是接口隔离的价值——把变化关在底层。

核心要点:HAL接口只暴露「能力」,不暴露「实现细节」。上层调用者看到的应该是:初始化、发送、接收、控制。而不是:写寄存器A的bit3、读寄存器B的bit7。

2.2 依赖倒置原则(DIP):让应用层依赖抽象,而不是依赖芯片

依赖倒置原则,原文是「高层模块不应该依赖低层模块,两者都应该依赖抽象」。在HAL里,这个抽象就是接口定义。

很多新手写驱动,习惯这样:

// 应用层直接调用芯片厂商的驱动
#include "stm32f4xx_hal.h"

void app_task(void) {
    HAL_UART_Transmit(&huart1, "Hello", 5, 1000);
}

代码跑得挺好。但换到GD32、AT32、NXP的时候呢?全部重写。因为应用层直接依赖了具体的HAL实现。

正确的做法是:

// 定义抽象接口(头文件)
typedef struct {
    hal_status_t (*init)(void *handle, const void *config);
    hal_status_t (*send)(void *handle, const uint8_t *data, uint32_t len);
    hal_status_t (*receive)(void *handle, uint8_t *data, uint32_t len);
} hal_uart_ops_t;

// 应用层只依赖这个结构体指针
void app_task(hal_uart_ops_t *uart, void *handle) {
    uart->send(handle, "Hello", 5);
}

底层驱动只要填充这个操作结构体:

// STM32 实现
hal_uart_ops_t stm32_uart_ops = {
    .init    = stm32_uart_init,
    .send    = stm32_uart_send,
    .receive = stm32_uart_receive,
};

// GD32 实现
hal_uart_ops_t gd32_uart_ops = {
    .init    = gd32_uart_init,
    .send    = gd32_uart_send,
    .receive = gd32_uart_receive,
};

应用层换芯片?换个ops指针就行。我曾经在一个项目里,用这套模式三天内把驱动从STM32移植到了AT32,上层代码一行没改。嗯,这就是依赖倒置的威力。

个人经验:操作结构体(ops)里不要放太多函数指针。我见过有人把几十个函数塞进去,结果每个芯片实现都要写一堆空函数。接口要精,不要全。

2.3 单一职责原则(SRP):一个接口只做一件事

单一职责原则,说的是一个模块或接口应该只有一个引起它变化的原因。在HAL里,我把它翻译成:别把不同外设的逻辑混在一起。

看看这个反面教材:

// 糟糕:一个接口管了UART、SPI、I2C
hal_status_t hal_comm_init(uint32_t periph_type, void *config);
hal_status_t hal_comm_send(uint32_t periph_type, const uint8_t *data, uint32_t len);

为什么糟糕?因为UART、SPI、I2C的初始化参数完全不同,配置结构体没法统一。而且,如果UART要加个流控功能,这个接口就得改,但SPI和I2C根本不需要流控。一个变化,影响了三个外设。

我建议的做法:

// 每个外设独立接口
// UART
hal_status_t hal_uart_init(hal_uart_t *huart, const hal_uart_config_t *config);
hal_status_t hal_uart_send(hal_uart_t *huart, const uint8_t *data, uint32_t len);

// SPI
hal_status_t hal_spi_init(hal_spi_t *hspi, const hal_spi_config_t *config);
hal_status_t hal_spi_transmit(hal_spi_t *hspi, const uint8_t *data, uint32_t len);

// I2C
hal_status_t hal_i2c_init(hal_i2c_t *hi2c, const hal_i2c_config_t *config);
hal_status_t hal_i2c_write(hal_i2c_t *hi2c, uint8_t dev_addr, const uint8_t *data, uint32_t len);

每个接口只负责一种外设的行为。UART的变化不会影响SPI,SPI的变化不会影响I2C。维护起来,脑子不用同时装三份手册。

避坑指南:我曾经在一个项目里,把GPIO和定时器的配置塞到了同一个结构体里。结果定时器要加PWM模式,GPIO要加中断触发,两个需求同时提过来,改一个结构体就得考虑另一个会不会受影响。后来拆开,世界清净了。

2.4 三个原则的协同关系

这三个原则不是孤立的。它们一起构成了HAL接口设计的骨架:

  • 接口隔离决定了「接口长什么样」——小而专,不暴露细节
  • 依赖倒置决定了「接口怎么用」——面向抽象编程,不面向芯片编程
  • 单一职责决定了「接口管多宽」——一个接口只负责一种外设

下面这张图,是我做HAL架构时常用的分层思路:

HAL接口设计三原则协同架构 应用层(Application) HAL抽象接口层(接口隔离 + 单一职责) hal_uart_init / hal_spi_transmit / hal_i2c_write ... 依赖抽象,不依赖实现 STM32驱动 stm32_uart_ops GD32驱动 gd32_uart_ops NXP驱动 nxp_uart_ops 硬件层(寄存器 / 外设) UART外设 / SPI外设 / I2C外设 接口隔离:上层看不到寄存器细节 | 依赖倒置:应用层只认ops指针 | 单一职责:UART/SPI/I2C各管各的

2.5 实践中的权衡

原则是死的,项目是活的。我在实际做HAL设计时,会做几个取舍:

原则 理想情况 实际妥协
接口隔离 每个外设一套独立接口 相似外设(如UART和LPUART)可共用接口,用参数区分
依赖倒置 全部通过ops指针调用 性能敏感路径(如中断处理)可允许直接调用底层函数
单一职责 一个文件只放一个外设驱动 资源紧张时,可把同类型外设(如TIM1~TIM8)放一个文件

我的建议:先按原则做到位,等性能分析出来再优化。不要一开始就想着「这样会不会多一次函数调用开销」——现代MCU跑这点开销,比你后期重构省时间得多。

好了,这三个原则讲完了。你想想看,你现在的驱动代码里,有没有哪个接口既管初始化又管配置还管状态查询?有没有上层代码直接调了寄存器操作?如果有,那就是需要重构的信号。