第3章 硬件抽象层(HAL)设计:接口即契约
做嵌入式这些年,我见过太多代码被硬件绑定死的案例。换一颗MCU,整个项目重写——这种痛,经历过一次就够了。硬件抽象层(HAL)就是来解决这个问题的。说白了,它是一层“翻译官”,把千奇百怪的寄存器操作,翻译成统一的接口。
你想想看,GPIO、UART、SPI、I2C、定时器、ADC,这些外设每家芯片厂商的实现都不一样。但它们的功能本质是一样的。我们要做的,就是把这些共性抽出来,定义一套稳定的接口契约。
核心原则:上层代码只依赖HAL接口,不依赖具体芯片。换芯片时,只换HAL实现层,上层逻辑纹丝不动。
3.1 GPIO抽象:最简单的接口,最深的坑
GPIO看似简单,其实最容易出问题。我在项目中遇到过,有人把推挽输出和开漏输出混用,导致I2C总线死活拉不低。嗯,这里要注意。
我建议的GPIO抽象接口如下:
/* gpio_hal.h */
typedef enum {
GPIO_MODE_INPUT,
GPIO_MODE_OUTPUT_PP, /* 推挽输出 */
GPIO_MODE_OUTPUT_OD, /* 开漏输出 */
GPIO_MODE_AF_PP, /* 复用推挽 */
GPIO_MODE_AF_OD, /* 复用开漏 */
GPIO_MODE_ANALOG
} gpio_mode_t;
typedef enum {
GPIO_PULL_NONE,
GPIO_PULL_UP,
GPIO_PULL_DOWN
} gpio_pull_t;
typedef struct {
uint8_t port; /* 端口号 */
uint8_t pin; /* 引脚号 */
gpio_mode_t mode;
gpio_pull_t pull;
uint8_t speed; /* 0:low, 1:medium, 2:high */
} gpio_cfg_t;
/* 接口函数 */
void gpio_init(const gpio_cfg_t *cfg);
void gpio_set(uint8_t port, uint8_t pin, uint8_t level);
uint8_t gpio_get(uint8_t port, uint8_t pin);
void gpio_toggle(uint8_t port, uint8_t pin);
为什么用结构体传参?因为配置项多了,函数参数会爆炸。我曾经见过一个GPIO初始化函数有8个参数,调用时根本分不清哪个是哪个。结构体传参,可读性翻倍。
个人习惯:所有HAL接口的返回值统一用int8_t,0表示成功,负数表示错误码。这样上层做错误处理时,逻辑非常统一。
3.2 UART抽象:流式接口的典范
UART是最常用的串行通信接口。它的抽象核心是“流”——数据像水流一样,收进来、发出去。我建议用回调机制来处理接收,而不是轮询。
/* uart_hal.h */
typedef void (*uart_rx_cb_t)(uint8_t data);
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_rx_cb_t rx_callback;
} uart_cfg_t;
int8_t uart_init(uint8_t ch, const uart_cfg_t *cfg);
int8_t uart_send(uint8_t ch, const uint8_t *data, uint16_t len);
int8_t uart_send_byte(uint8_t ch, uint8_t data);
int8_t uart_receive(uint8_t ch, uint8_t *buf, uint16_t len, uint32_t timeout_ms);
这里有个关键点:uart_receive是阻塞式接收,适合简单场景。而rx_callback是中断回调,适合实时响应。两种方式都要提供,让上层自己选。
我曾经踩过的坑:回调函数里做耗时操作,导致中断溢出。记住,回调里只做“标记”,不做“处理”。把数据扔进队列,让主循环去处理。
3.3 SPI与I2C抽象:总线协议的差异与统一
SPI和I2C虽然都是串行总线,但差异很大。SPI是主从全双工,I2C是半双工带地址。抽象时,要保留各自的特点,不能强行统一。
| 特性 | SPI | I2C |
|---|---|---|
| 通信方式 | 全双工 | 半双工 |
| 从机选择 | 片选线(CS) | 设备地址(7/10bit) |
| 速度 | 可达几十MHz | 标准100kHz/快速400kHz |
| 硬件复杂度 | 简单(4线) | 中等(2线+上拉) |
| 典型应用 | 显示、ADC、SD卡 | 传感器、EEPROM、RTC |
/* spi_hal.h */
typedef struct {
uint8_t mode; /* 0~3, CPOL/CPHA组合 */
uint32_t freq; /* 目标频率 */
uint8_t data_order; /* 0:MSB first, 1:LSB first */
} spi_cfg_t;
int8_t spi_init(uint8_t ch, const spi_cfg_t *cfg);
int8_t spi_transfer(uint8_t ch, const uint8_t *tx, uint8_t *rx, uint16_t len);
int8_t spi_cs_set(uint8_t ch, uint8_t level);
/* i2c_hal.h */
typedef struct {
uint32_t freq; /* 目标频率 */
uint8_t addr_mode; /* 0:7bit, 1:10bit */
} i2c_cfg_t;
int8_t i2c_init(uint8_t ch, const i2c_cfg_t *cfg);
int8_t i2c_write(uint8_t ch, uint16_t addr, const uint8_t *data, uint16_t len);
int8_t i2c_read(uint8_t ch, uint16_t addr, uint8_t *buf, uint16_t len);
int8_t i2c_write_reg(uint8_t ch, uint16_t addr, uint8_t reg, uint8_t data);
int8_t i2c_read_reg(uint8_t ch, uint16_t addr, uint8_t reg, uint8_t *data);
注意I2C接口里我加了i2c_write_reg和i2c_read_reg。为什么?因为90%的I2C设备都是寄存器寻址的。把这个高频操作封装好,上层代码能少写一半。
3.4 定时器抽象:从硬件到逻辑的映射
定时器是最灵活的外设,可以当计数器、PWM、输入捕获、输出比较用。抽象时,我倾向于按“功能角色”来分,而不是按“硬件通道”来分。
/* timer_hal.h */
typedef enum {
TIMER_MODE_ONESHOT, /* 单次 */
TIMER_MODE_PERIODIC, /* 周期 */
TIMER_MODE_PWM,
TIMER_MODE_INPUT_CAPTURE
} timer_mode_t;
typedef void (*timer_cb_t)(void);
typedef struct {
timer_mode_t mode;
uint32_t period_us; /* 周期,微秒 */
uint8_t duty_cycle; /* PWM占空比 0~100 */
timer_cb_t callback;
} timer_cfg_t;
int8_t timer_init(uint8_t ch, const timer_cfg_t *cfg);
int8_t timer_start(uint8_t ch);
int8_t timer_stop(uint8_t ch);
uint32_t timer_get_count(uint8_t ch);
这里有个设计取舍:period_us用微秒还是用时钟周期?我建议用微秒。因为上层逻辑不关心时钟频率,只关心时间。底层实现时再换算成计数值。
关键设计:定时器回调里绝对不能做malloc、printf等耗时操作。我见过有人直接在回调里打印日志,结果系统直接卡死。回调里只做“标记-处理”模式中的“标记”部分。
3.5 ADC抽象:采样与转换的分离
ADC的抽象核心是“采样-转换-读取”三个阶段的分离。不同芯片的ADC触发方式、转换时间、分辨率都不一样,但上层只关心“我要读一个通道的电压值”。
/* adc_hal.h */
typedef struct {
uint8_t channel; /* ADC通道 */
uint8_t resolution; /* 8/10/12/16 bit */
uint32_t sample_time; /* 采样时间,单位ADC时钟周期 */
} adc_cfg_t;
int8_t adc_init(uint8_t ch, const adc_cfg_t *cfg);
int8_t adc_start(uint8_t ch);
int8_t adc_wait_complete(uint8_t ch, uint32_t timeout_ms);
uint32_t adc_get_value(uint8_t ch);
float adc_get_voltage(uint8_t ch, float vref);
我特意加了adc_get_voltage这个函数。为什么?因为上层很多时候要的是“电压值”,不是“原始码值”。把转换公式封装在HAL层,上层就不用关心Vref和分辨率了。
避坑指南:ADC的采样时间不能太短,否则采样电容充不满,结果会偏小。我曾经在STM32上采样时间设了1.5个周期,结果10位ADC只跳了8位的范围。后来改成13.5个周期,问题解决。
3.6 寄存器操作与库函数封装:两种实现策略
HAL接口定义好了,接下来是实现。有两种主流方式:直接寄存器操作,或者封装厂商库函数。
| 对比项 | 寄存器操作 | 库函数封装 |
|---|---|---|
| 代码体积 | 小 | 较大 |
| 执行速度 | 快 | 稍慢(函数调用开销) |
| 可读性 | 差(需要查手册) | 好(函数名即文档) |
| 移植性 | 差(完全绑定芯片) | 中等(依赖厂商库) |
| 调试难度 | 高 | 低 |
我个人习惯:性能敏感的部分用寄存器操作,比如定时器PWM、ADC高速采样;非性能敏感的部分用库函数封装,比如初始化配置、UART收发。
举个例子,GPIO的寄存器操作实现:
/* gpio_hal_stm32.c */
void gpio_set(uint8_t port, uint8_t pin, uint8_t level) {
GPIO_TypeDef *gpio = (GPIO_TypeDef *)(GPIOA_BASE + port * 0x400);
if (level) {
gpio->BSRR = (1 << pin); /* 置位 */
} else {
gpio->BSRR = (1 << (pin + 16)); /* 复位 */
}
}
而库函数封装版本:
/* gpio_hal_stm32_ll.c */
void gpio_set(uint8_t port, uint8_t pin, uint8_t level) {
GPIO_TypeDef *gpio = (GPIO_TypeDef *)(GPIOA_BASE + port * 0x400);
LL_GPIO_SetOutputPin(gpio, (1 << pin)); /* 使用LL库 */
}
两种方式都可以,关键是接口要统一。上层代码调用gpio_set时,根本不知道底层是寄存器还是库函数。这就是抽象的力量。
重要提醒:不要在一个HAL实现里混用寄存器和库函数。要么全用寄存器,要么全用库函数。混用会导致维护噩梦——你永远不知道某个接口到底用了哪种方式。
3.7 HAL设计全景图
下面这张图展示了HAL层的整体结构。你可以看到,上层应用只和HAL接口打交道,底层实现可以随时切换。
这张图的核心思想是:接口是契约,实现是细节。上层应用只认契约,不认细节。换芯片时,只需要重新实现HAL接口层下面的部分,上面的所有代码都不用动。
3.8 小结:HAL设计的三个黄金法则
- 接口要稳定:一旦发布,尽量不改。改接口意味着改所有上层代码。
- 实现要隔离:寄存器操作和库函数封装不要混在同一个文件里。用条件编译或独立文件来区分。
- 错误要传递:所有HAL函数都要返回错误码。不要吞掉错误,上层需要知道底层发生了什么。
最后说一句:HAL层不是一次就能设计完美的。我做了十年嵌入式,每次换平台都会微调接口。但大方向不变——让上层代码永远不碰寄存器。做到这一点,你的架构就成功了一半。