第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_ports、gd32_gpio_ports,避免链接时冲突。
19.7 知识体系总览
下面这张图,概括了HAL层的整体结构和数据流:
从这张图可以看得很清楚:上层应用只依赖HAL接口,不依赖任何具体平台。换芯片时,只需要替换中间那一层“平台实现”,其他两层纹丝不动。
19.8 写在最后
HAL的设计,说白了就是“面向接口编程”在嵌入式领域的最佳实践。它不复杂,但需要你有意识地去抽象、去隔离。我见过太多项目,一开始图省事直接操作寄存器,结果后期维护成本爆炸。
记住一句话:接口是契约,实现是细节。把契约定好,细节随时可以换。这就是HAL的精髓。
公众号:蓝海资料掘金营,微信deep3321