3. 硬件抽象层(HAL)核心概念
硬件抽象层(HAL)的核心,说白了就是给上层应用一套统一的“遥控器”。不管底层是STM32、NXP还是国产MCU,上层调用的接口长得都一样。这一章,我们就来定义这套遥控器上最关键的几个按键:GPIO、UART、I2C、SPI和定时器。
我个人习惯,在设计HAL接口时,会先画一张“接口关系图”,把每个外设的“能力”和“约束”列清楚。这样写代码时心里才有底。
核心原则:接口要“够用”,但不要“过度设计”。只抽象共性的操作,把芯片特有的配置留给底层。
3.1 接口设计总纲
先定几个规矩,所有外设接口都得遵守:
- 初始化/反初始化:每个外设都有
_init()和_deinit(),成对出现。 - 句柄(Handle):用不透明指针或结构体封装外设状态,上层不关心内部细节。
- 回调机制:异步操作(如中断、DMA完成)通过回调函数通知上层。
- 错误码:统一返回
HAL_OK、HAL_ERROR、HAL_BUSY、HAL_TIMEOUT。
下面这张图,是我每次做新平台移植时都会先画出来的“接口全景图”。它帮你一眼看清每个外设的“职责边界”。
3.2 GPIO 接口定义
GPIO 是最基础的外设,但坑也最多。我记得有一次,一个同事把引脚上下拉配置漏了,结果按键中断死活不触发。排查了一下午才发现。
标准接口如下:
// GPIO 初始化参数结构体
typedef struct {
uint32_t pin; // 引脚号(支持按位或,一次配置多个)
uint8_t mode; // 输入/输出/复用/模拟
uint8_t pull; // 上拉/下拉/浮空
uint8_t speed; // 低速/中速/高速
uint8_t alternate; // 复用功能编号
} gpio_init_t;
// 核心接口
hal_err_t gpio_init(gpio_t *gpio, gpio_init_t *init);
hal_err_t gpio_deinit(gpio_t *gpio);
void gpio_write(gpio_t *gpio, uint8_t level);
uint8_t gpio_read(gpio_t *gpio);
void gpio_toggle(gpio_t *gpio);
// 中断相关
hal_err_t gpio_irq_enable(gpio_t *gpio, uint8_t trigger); // trigger: 上升沿/下降沿/双边沿
hal_err_t gpio_irq_disable(gpio_t *gpio);
void gpio_irq_callback(gpio_t *gpio, void (*cb)(void *arg), void *arg);
我的习惯:GPIO 的
pin 字段支持位掩码,这样一次调用就能配置多个引脚。比如 gpio_init(&led, &(gpio_init_t){.pin=GPIO_PIN_0 | GPIO_PIN_1, .mode=GPIO_MODE_OUTPUT});
3.3 UART 接口定义
UART 是调试和通信的“生命线”。我见过太多人把 UART 的 DMA 收发和中断收发混在一起用,结果数据错乱。接口设计上,一定要把同步、中断、DMA 三种模式分开。
// UART 初始化参数
typedef struct {
uint32_t baudrate; // 波特率
uint8_t word_length; // 7/8/9 位
uint8_t stop_bits; // 1/1.5/2
uint8_t parity; // 无/奇/偶
uint8_t mode; // 只发/只收/收发
uint8_t flow_ctrl; // 无/RTS/CTS/RTS+CTS
} uart_init_t;
// 核心接口
hal_err_t uart_init(uart_t *uart, uart_init_t *init);
hal_err_t uart_deinit(uart_t *uart);
// 同步收发(阻塞)
hal_err_t uart_transmit(uart_t *uart, uint8_t *data, uint16_t size, uint32_t timeout);
hal_err_t uart_receive(uart_t *uart, uint8_t *data, uint16_t size, uint32_t timeout);
// 中断收发(非阻塞)
hal_err_t uart_transmit_it(uart_t *uart, uint8_t *data, uint16_t size);
hal_err_t uart_receive_it(uart_t *uart, uint8_t *data, uint16_t size);
// DMA 收发(非阻塞)
hal_err_t uart_transmit_dma(uart_t *uart, uint8_t *data, uint16_t size);
hal_err_t uart_receive_dma(uart_t *uart, uint8_t *data, uint16_t size);
// 中断回调
void uart_irq_callback(uart_t *uart, void (*tx_cb)(void *arg), void (*rx_cb)(void *arg), void *arg);
避坑指南:我曾经在一个项目中,UART 的 DMA 接收缓冲区没做“乒乓缓冲”,结果数据量一大就丢包。建议 DMA 模式至少准备两个缓冲区,交替使用。
3.4 I2C 接口定义
I2C 的难点在于“多主冲突”和“时钟拉伸”。接口设计上,我建议把“主模式”和“从模式”的接口分开,避免混淆。
// I2C 初始化参数
typedef struct {
uint32_t clock_speed; // 标准模式 100k / 快速模式 400k
uint8_t own_address; // 本机地址(从模式用)
uint8_t addressing_mode; // 7位/10位
} i2c_init_t;
// 主模式接口
hal_err_t i2c_master_init(i2c_t *i2c, i2c_init_t *init);
hal_err_t i2c_master_write(i2c_t *i2c, uint8_t dev_addr, uint8_t *data, uint16_t size, uint32_t timeout);
hal_err_t i2c_master_read(i2c_t *i2c, uint8_t dev_addr, uint8_t *data, uint16_t size, uint32_t timeout);
hal_err_t i2c_master_write_read(i2c_t *i2c, uint8_t dev_addr, uint8_t *wdata, uint16_t wsize, uint8_t *rdata, uint16_t rsize, uint32_t timeout);
// 从模式接口
hal_err_t i2c_slave_init(i2c_t *i2c, i2c_init_t *init);
hal_err_t i2c_slave_write(i2c_t *i2c, uint8_t *data, uint16_t size);
hal_err_t i2c_slave_read(i2c_t *i2c, uint8_t *data, uint16_t size);
// 中断回调
void i2c_irq_callback(i2c_t *i2c, void (*addr_cb)(void *arg), void (*rx_cb)(void *arg), void (*tx_cb)(void *arg), void (*err_cb)(void *arg), void *arg);
关键点:
i2c_master_write_read 这个接口很重要。很多传感器(如 MPU6050)都是“先写寄存器地址,再读数据”的协议。一个接口搞定,上层不用关心底层细节。
3.5 SPI 接口定义
SPI 比 I2C 简单,但“时钟极性(CPOL)”和“时钟相位(CPHA)”的组合容易搞错。我建议在接口里用枚举,别用数字,可读性更好。
// SPI 初始化参数
typedef struct {
uint8_t mode; // 主/从
uint8_t cpol; // 时钟极性:0/1
uint8_t cpha; // 时钟相位:0/1
uint8_t data_size; // 8位/16位
uint8_t bit_order; // MSB 先 / LSB 先
uint32_t clock_speed; // 时钟频率
} spi_init_t;
// 核心接口
hal_err_t spi_init(spi_t *spi, spi_init_t *init);
hal_err_t spi_deinit(spi_t *spi);
// 全双工收发
hal_err_t spi_transmit_receive(spi_t *spi, uint8_t *tx_data, uint8_t *rx_data, uint16_t size, uint32_t timeout);
// 半双工
hal_err_t spi_transmit(spi_t *spi, uint8_t *data, uint16_t size, uint32_t timeout);
hal_err_t spi_receive(spi_t *spi, uint8_t *data, uint16_t size, uint32_t timeout);
// DMA 版本
hal_err_t spi_transmit_receive_dma(spi_t *spi, uint8_t *tx_data, uint8_t *rx_data, uint16_t size);
// 片选控制(硬件或软件)
void spi_chip_select(spi_t *spi, uint8_t level);
小技巧:SPI 的片选(CS)我建议用软件控制,而不是硬件自动控制。为什么?因为有些从设备在两次传输之间需要 CS 拉高一段时间,硬件 CS 可能做不到这么灵活。
3.6 定时器接口定义
定时器是最灵活的外设,可以当“秒表”、“闹钟”、“PWM 发生器”、“脉冲计数器”……接口设计上,我倾向于按“功能模式”来分组,而不是把所有功能塞到一个结构体里。
// 基本定时器参数
typedef struct {
uint32_t prescaler; // 预分频系数
uint32_t period; // 自动重装载值
uint8_t counter_mode; // 递增/递减/中心对齐
} timer_base_init_t;
// PWM 输出参数
typedef struct {
uint32_t channel; // 通道号
uint32_t pulse; // 占空比(比较值)
uint8_t polarity; // 高电平有效/低电平有效
} timer_pwm_init_t;
// 输入捕获参数
typedef struct {
uint32_t channel; // 通道号
uint8_t polarity; // 上升沿/下降沿/双边沿
uint8_t filter; // 数字滤波
} timer_capture_init_t;
// 核心接口
hal_err_t timer_base_init(timer_t *tim, timer_base_init_t *init);
hal_err_t timer_base_deinit(timer_t *tim);
hal_err_t timer_start(timer_t *tim);
hal_err_t timer_stop(timer_t *tim);
uint32_t timer_get_counter(timer_t *tim);
// PWM
hal_err_t timer_pwm_init(timer_t *tim, timer_pwm_init_t *init);
hal_err_t timer_pwm_start(timer_t *tim, uint32_t channel);
hal_err_t timer_pwm_stop(timer_t *tim, uint32_t channel);
hal_err_t timer_pwm_set_duty(timer_t *tim, uint32_t channel, uint32_t pulse);
// 输入捕获
hal_err_t timer_capture_init(timer_t *tim, timer_capture_init_t *init);
hal_err_t timer_capture_start(timer_t *tim, uint32_t channel);
hal_err_t timer_capture_stop(timer_t *tim, uint32_t channel);
uint32_t timer_capture_get_value(timer_t *tim, uint32_t channel);
// 中断回调
void timer_irq_callback(timer_t *tim, void (*cb)(void *arg), void *arg);
避坑指南:我曾经在做一个电机控制项目时,定时器的预分频系数算错了,导致 PWM 频率不对,电机嗡嗡响。记住公式:
定时器频率 = 时钟源频率 / (prescaler + 1) / (period + 1)。别把 +1 忘了!
3.7 接口设计对比总结
下面这个表格,是我做接口评审时常用的“检查清单”。每个外设的接口设计,都要过一遍这几项:
| 外设 | 初始化参数数量 | 核心接口数 | 异步支持 | 常见坑点 |
|---|---|---|---|---|
| GPIO | 5 个字段 | 6 个 | 中断回调 | 上下拉配置遗漏、中断触发方式错误 |
| UART | 6 个字段 | 10 个 | 中断 + DMA | DMA 缓冲区溢出、波特率误差 |
| I2C | 3 个字段 | 8 个 | 中断回调 | 时钟拉伸超时、从机地址错误 |
| SPI | 6 个字段 | 7 个 | DMA | CPOL/CPHA 组合错误、片选时序 |
| 定时器 | 3 个基础 + 通道参数 | 12 个 | 中断回调 | 预分频计算错误、PWM 极性反 |
嗯,这一章的内容就到这里。接口定义是 HAL 的“骨架”,骨架搭好了,后面的移植和优化才能站得稳。记住:接口是契约,一旦定下来,就不要轻易改。改接口的成本,比你想象的高得多。