第3章:HAL核心接口定义——GPIO、UART、I2C、SPI、定时器

硬件抽象层(HAL)的核心,说白了就是给上层应用一套统一的“遥控器”。不管底层是STM32、NXP还是国产MCU,上层调用的接口长得都一样。这一章,我们就来定义这套遥控器上最关键的几个按键:GPIO、UART、I2C、SPI和定时器。

我个人习惯,在设计HAL接口时,会先画一张“接口关系图”,把每个外设的“能力”和“约束”列清楚。这样写代码时心里才有底。

核心原则:接口要“够用”,但不要“过度设计”。只抽象共性的操作,把芯片特有的配置留给底层。

3.1 接口设计总纲

先定几个规矩,所有外设接口都得遵守:

  • 初始化/反初始化:每个外设都有 _init() 和 _deinit(),成对出现。
  • 句柄(Handle):用不透明指针或结构体封装外设状态,上层不关心内部细节。
  • 回调机制:异步操作(如中断、DMA完成)通过回调函数通知上层。
  • 错误码:统一返回 HAL_OK、HAL_ERROR、HAL_BUSY、HAL_TIMEOUT。

下面这张图,是我每次做新平台移植时都会先画出来的“接口全景图”。它帮你一眼看清每个外设的“职责边界”。

HAL 核心外设接口全景图 GPIO • 引脚读写 • 中断配置 • 上下拉控制 • 输出类型 • 复用功能 • 电平触发 UART • 发送/接收 • 中断收发 • DMA 收发 • 波特率配置 • 数据位/停止位 • 流控控制 I2C • 主/从模式 • 读写操作 • 多字节传输 • 时钟频率 • 从机地址 • 超时控制 SPI • 主/从模式 • 全双工收发 • 半双工收发 • 时钟极性/相位 • 片选控制 • 数据位宽 定时器 • 基本定时 • PWM 输出 • 输入捕获 • 单次/周期 • 中断回调 • 预分频配置 设计要点 • 每个外设接口独立,不互相依赖 • 初始化参数用结构体传入,方便扩展 • 异步操作统一使用回调机制

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 的“骨架”,骨架搭好了,后面的移植和优化才能站得稳。记住:接口是契约,一旦定下来,就不要轻易改。改接口的成本,比你想象的高得多。