第19章 硬件抽象层(HAL):设计跨平台的硬件操作接口

做嵌入式开发的朋友,一定被芯片厂商的SDK折磨过。换一个MCU,GPIO的寄存器地址变了,UART的初始化流程也变了。代码里到处都是 #ifdef STM32、#ifdef GD32、#ifdef NRF52832……维护起来简直想砸键盘。

我早年做物联网网关项目时,就吃过这个亏。产品要支持三款不同主控,代码里塞满了条件编译。后来客户要求换芯片,改了一个月,还改出了几个隐蔽的bug。从那以后,我下定决心——必须搞一套硬件抽象层(HAL),把底层差异彻底隔离掉。

说白了,HAL就是在硬件和上层应用之间,加一层“翻译官”。上层只跟翻译官说话,翻译官再去跟具体硬件打交道。这样换芯片时,只需要换翻译官,上层代码一个字都不用动。

19.1 HAL的核心设计思想

HAL的设计,本质上就是面向接口编程。我习惯用结构体+函数指针的方式,把硬件操作抽象成一组“协议”。

举个例子,GPIO的操作无非就是:初始化、设置电平、读取电平、配置中断。那我们就定义这样一个接口:

// gpio_hal.h
#ifndef GPIO_HAL_H
#define GPIO_HAL_H

#include <stdint.h>

// GPIO引脚状态
typedef enum {
    GPIO_LOW  = 0,
    GPIO_HIGH = 1
} gpio_state_t;

// GPIO方向
typedef enum {
    GPIO_INPUT  = 0,
    GPIO_OUTPUT = 1
} gpio_dir_t;

// GPIO中断触发方式
typedef enum {
    GPIO_IRQ_RISING,
    GPIO_IRQ_FALLING,
    GPIO_IRQ_BOTH
} gpio_irq_t;

// 硬件抽象接口结构体
typedef struct {
    void     (*init)(uint8_t pin, gpio_dir_t dir);
    void     (*set)(uint8_t pin, gpio_state_t state);
    gpio_state_t (*get)(uint8_t pin);
    void     (*toggle)(uint8_t pin);
    void     (*enable_irq)(uint8_t pin, gpio_irq_t mode);
    void     (*disable_irq)(uint8_t pin);
} gpio_hal_t;

// 全局接口指针
extern const gpio_hal_t gpio;

#endif

看到没?这里没有一行具体硬件的代码。上层应用只需要调用 gpio.set(5, GPIO_HIGH),至于底层是STM32还是GD32,它根本不关心。

核心原则:接口定义要“最小化”。只暴露上层真正需要的操作,不要试图把硬件的所有特性都抽象出来。比如GPIO的驱动能力、上下拉电阻,这些通常不需要暴露给上层。

19.2 UART的抽象:从轮询到中断

UART比GPIO复杂一些。因为它涉及数据收发、缓冲区管理、中断处理。我见过很多工程师把UART的HAL做成“万能接口”,结果又大又慢。

我的做法是分层抽象。底层只负责“发一个字节”和“收一个字节”,上层再封装成带缓冲区的驱动。

// uart_hal.h
#ifndef UART_HAL_H
#define UART_HAL_H

#include <stdint.h>

// UART配置参数
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
} uart_config_t;

// UART事件回调
typedef void (*uart_rx_cb_t)(uint8_t data);
typedef void (*uart_tx_cb_t)(void);

// 硬件抽象接口
typedef struct {
    void (*init)(uint8_t port, const uart_config_t *cfg);
    void (*send_byte)(uint8_t port, uint8_t data);
    uint8_t (*recv_byte)(uint8_t port);
    void (*send_buffer)(uint8_t port, const uint8_t *buf, uint32_t len);
    void (*set_rx_callback)(uint8_t port, uart_rx_cb_t cb);
    void (*set_tx_callback)(uint8_t port, uart_tx_cb_t cb);
    void (*enable_irq)(uint8_t port);
    void (*disable_irq)(uint8_t port);
} uart_hal_t;

extern const uart_hal_t uart;

#endif

这里有个细节:send_buffer 是阻塞发送还是非阻塞?我建议接口本身不规定,由底层实现决定。上层可以通过回调知道发送完成。

我的经验:UART的接收缓冲区最好在HAL层内部管理,不要暴露给上层。上层只需要注册一个“收到数据”的回调,底层在中断里把数据塞进环形缓冲区,然后触发回调。这样上层代码干净很多。

19.3 SPI的抽象:同步与异步的取舍

SPI的抽象,最头疼的是同步和异步的问题。有些场景需要高速连续传输,有些场景只是偶尔读个传感器。

我一般提供两套接口:同步的简单接口,和异步的DMA接口。上层按需选择。

// spi_hal.h
#ifndef SPI_HAL_H
#define SPI_HAL_H

#include <stdint.h>

// SPI模式
typedef enum {
    SPI_MODE_0,  // CPOL=0, CPHA=0
    SPI_MODE_1,  // CPOL=0, CPHA=1
    SPI_MODE_2,  // CPOL=1, CPHA=0
    SPI_MODE_3   // CPOL=1, CPHA=1
} spi_mode_t;

// SPI配置
typedef struct {
    uint32_t    frequency;
    spi_mode_t  mode;
    uint8_t     data_width;  // 8 or 16
} spi_config_t;

// 同步接口
typedef struct {
    void (*init)(uint8_t bus, const spi_config_t *cfg);
    void (*select)(uint8_t bus, uint8_t cs_pin);
    void (*deselect)(uint8_t bus, uint8_t cs_pin);
    uint8_t (*exchange)(uint8_t bus, uint8_t tx_data);
    void (*exchange_buf)(uint8_t bus, const uint8_t *tx, uint8_t *rx, uint32_t len);
} spi_sync_t;

// 异步接口(DMA)
typedef struct {
    void (*init)(uint8_t bus, const spi_config_t *cfg);
    void (*start_dma)(uint8_t bus, const uint8_t *tx, uint8_t *rx, uint32_t len);
    void (*stop_dma)(uint8_t bus);
    void (*set_done_callback)(uint8_t bus, void (*cb)(void));
} spi_async_t;

extern const spi_sync_t  spi_sync;
extern const spi_async_t spi_async;

#endif

你可能会问:为什么把同步和异步分开?因为有些低端MCU没有DMA,异步接口根本用不上。分开定义,上层可以只依赖它需要的部分。

注意:SPI的片选信号(CS)管理,我建议放在HAL层外部。因为不同的外设对CS的时序要求不同,有的需要拉低后等几微秒再传数据。HAL层只提供select/deselect,时序由上层控制。

19.4 跨平台实现的套路

接口定义好了,接下来就是针对不同平台实现。我习惯用“平台文件”的方式组织代码:

hal/
├── include/
│   ├── gpio_hal.h
│   ├── uart_hal.h
│   └── spi_hal.h
├── src/
│   ├── stm32/
│   │   ├── gpio_stm32.c
│   │   ├── uart_stm32.c
│   │   └── spi_stm32.c
│   ├── gd32/
│   │   ├── gpio_gd32.c
│   │   ├── uart_gd32.c
│   │   └── spi_gd32.c
│   └── nrf52/
│       ├── gpio_nrf52.c
│       ├── uart_nrf52.c
│       └── spi_nrf52.c
└── platform.h  // 根据宏定义选择平台

platform.h 里,用一个宏决定链接哪个平台的实现:

// platform.h
#if defined(MCU_STM32F4)
    #include "stm32/gpio_stm32.c"
    #include "stm32/uart_stm32.c"
    #include "stm32/spi_stm32.c"
#elif defined(MCU_GD32F3)
    #include "gd32/gpio_gd32.c"
    #include "gd32/uart_gd32.c"
    #include "gd32/spi_gd32.c"
#elif defined(MCU_NRF52832)
    #include "nrf52/gpio_nrf52.c"
    #include "nrf52/uart_nrf52.c"
    #include "nrf52/spi_nrf52.c"
#else
    #error "Unsupported MCU platform!"
#endif

这样,上层应用只需要包含 gpio_hal.h,然后在编译时定义 MCU_STM32F4 之类的宏,就能自动切换到对应平台的实现。

19.5 一个完整的例子:用HAL点亮LED

来看看实际用起来是什么感觉。假设我们要点灯:

// main.c
#include "gpio_hal.h"

void delay(volatile uint32_t count) {
    while (count--);
}

int main(void) {
    // 初始化GPIO引脚5为输出
    gpio.init(5, GPIO_OUTPUT);

    while (1) {
        gpio.set(5, GPIO_HIGH);
        delay(500000);
        gpio.set(5, GPIO_LOW);
        delay(500000);
    }
}

这段代码,不管底层是STM32还是GD32,编译出来都能跑。换平台时,只需要改编译选项,代码一个字都不用动。

这就是HAL的价值:一次编写,到处编译。上层应用完全不知道底层是什么芯片,它只跟抽象的接口打交道。

19.6 避坑指南

我做了这么多年HAL,踩过不少坑。分享几个最典型的:

  • 接口粒度太细:我曾经把GPIO的上下拉电阻、驱动强度都抽象出来了。结果上层代码变得极其啰嗦,而且不同芯片对这些特性的支持不一样,接口根本没法统一。后来我砍掉了这些,只保留最核心的操作。
  • 回调函数上下文:UART的中断回调是在中断上下文执行的。如果回调里调用了 printf 或者 malloc,很容易死锁。我建议回调里只做最轻量的事情,比如把数据塞进队列,然后通知任务处理。
  • 初始化顺序:有些芯片的GPIO时钟需要先使能,才能配置寄存器。HAL的 init 函数里必须处理好这个顺序,否则一上来就写寄存器,芯片直接死给你看。
  • 全局变量冲突:不同平台的实现文件里,可能会有同名的全局变量。我习惯在每个平台的实现文件里,给变量加平台前缀,比如 stm32_gpio_portsgd32_gpio_ports,避免链接时冲突。

19.7 知识体系总览

下面这张图,概括了HAL层的整体结构和数据流:

硬件抽象层(HAL)知识体系 应用层(Application) HAL抽象接口(gpio_hal.h / uart_hal.h / spi_hal.h) 平台实现(stm32/ gpio_stm32.c | gd32/ gpio_gd32.c | nrf52/ gpio_nrf52.c) 硬件寄存器(GPIOx_ODR / USART_DR / SPI_DR) 数据流方向:应用层 → HAL接口 → 平台实现 → 硬件寄存器 接口定义 平台适配 硬件操作

从这张图可以看得很清楚:上层应用只依赖HAL接口,不依赖任何具体平台。换芯片时,只需要替换中间那一层“平台实现”,其他两层纹丝不动。

19.8 写在最后

HAL的设计,说白了就是“面向接口编程”在嵌入式领域的最佳实践。它不复杂,但需要你有意识地去抽象、去隔离。我见过太多项目,一开始图省事直接操作寄存器,结果后期维护成本爆炸。

记住一句话:接口是契约,实现是细节。把契约定好,细节随时可以换。这就是HAL的精髓。


公众号:蓝海资料掘金营,微信deep3321