4. HAL接口规范定义:GPIO、UART、SPI、I2C、Timer等通用API设计
好,咱们进入正题。这一章要聊的是HAL接口规范——说白了,就是给硬件抽象层定一套“通用API”的规矩。你想想看,如果每个芯片的GPIO、UART、SPI都各叫各的名字,那上层应用代码得写成什么样?换个芯片就得重写一遍,那还谈什么可移植性。
我个人习惯,在设计HAL接口时,先想清楚一件事:这个接口要抽象到什么程度?太细了,跟寄存器操作没区别;太粗了,又没法发挥硬件特性。这个度,得靠经验来把握。
核心原则:HAL接口是“硬件能力的通用化描述”,不是“某个芯片的寄存器映射”。它要能覆盖80%以上的使用场景,剩下的20%留给BSP层做扩展。
4.1 GPIO接口设计
GPIO是最基础的,但也是最容易出问题的。我记得有一次,团队里有人把GPIO的初始化参数定义成了“输入/输出/上拉/下拉”四个独立字段,结果上层代码每次配置都要写一堆结构体赋值,看着就头疼。
我建议这样设计GPIO的API:
/* GPIO配置结构体 */
typedef struct {
uint32_t pin; /* 引脚号,如 GPIO_PIN_0 ~ GPIO_PIN_15 */
uint8_t mode; /* 模式:输入/输出/复用/模拟 */
uint8_t pull; /* 上下拉:无/上拉/下拉 */
uint8_t speed; /* 速度:低/中/高/超高 */
uint8_t output_type;/* 输出类型:推挽/开漏 */
} gpio_config_t;
/* HAL GPIO API */
void hal_gpio_init(gpio_config_t *config);
void hal_gpio_deinit(uint32_t pin);
void hal_gpio_write(uint32_t pin, uint8_t state);
uint8_t hal_gpio_read(uint32_t pin);
void hal_gpio_toggle(uint32_t pin);
void hal_gpio_set_irq(uint32_t pin, uint8_t trigger, void (*callback)(void));
void hal_gpio_clear_irq(uint32_t pin);
这里有个细节:pin参数用uint32_t而不是uint8_t,为什么?因为有些芯片引脚编号超过255(比如STM32F4有144个引脚),用8位存不下。这种坑,我在项目里踩过不止一次。
经验之谈:GPIO的回调函数指针,建议统一用void (*callback)(void *arg),带上一个void *参数。这样上层可以传上下文,比如传一个结构体指针进去,回调里就能拿到具体是哪个设备触发了中断。
4.2 UART接口设计
UART的接口设计,核心在于“收发模式”的抽象。是轮询?中断?还是DMA?不同模式下的API行为差别很大。
我见过一种设计,把三种模式分别做成三套API,结果上层代码里全是#ifdef,看着就乱。更好的做法是:用配置结构体来区分模式。
/* UART配置结构体 */
typedef struct {
uint32_t baudrate; /* 波特率 */
uint8_t data_bits; /* 数据位:5/6/7/8 */
uint8_t stop_bits; /* 停止位:1/1.5/2 */
uint8_t parity; /* 校验:无/奇/偶 */
uint8_t mode; /* 模式:轮询/中断/DMA */
uint8_t tx_buf_size;/* 发送缓冲区大小(中断/DMA模式) */
uint8_t rx_buf_size;/* 接收缓冲区大小(中断/DMA模式) */
} uart_config_t;
/* HAL UART API */
void hal_uart_init(uart_config_t *config);
void hal_uart_deinit(void);
int32_t hal_uart_send(uint8_t *data, uint32_t len, uint32_t timeout);
int32_t hal_uart_receive(uint8_t *data, uint32_t len, uint32_t timeout);
int32_t hal_uart_send_it(uint8_t *data, uint32_t len);
int32_t hal_uart_receive_it(uint8_t *data, uint32_t len);
int32_t hal_uart_send_dma(uint8_t *data, uint32_t len);
int32_t hal_uart_receive_dma(uint8_t *data, uint32_t len);
void hal_uart_set_callback(uint8_t event, void (*callback)(void *arg));
注意返回值:我统一用int32_t,负数表示错误码,0表示成功,正数表示实际传输的字节数。这样上层可以判断“到底发了多少”。
避坑指南:我曾经在一个项目里,把UART的timeout参数单位定义成了“毫秒”,结果底层实现里用的是“系统滴答数”,两个单位差了10倍。调试了整整两天才发现。建议:所有超时参数统一用毫秒,底层自己去换算。
4.3 SPI接口设计
SPI的难点在于“主从模式”和“数据帧格式”。不同的芯片,CPOL和CPHA的组合方式可能不一样,有的叫“模式0/1/2/3”,有的直接给寄存器位。
我建议这样抽象:
/* SPI配置结构体 */
typedef struct {
uint32_t frequency; /* 时钟频率,单位Hz */
uint8_t mode; /* SPI模式:0/1/2/3 */
uint8_t role; /* 角色:主机/从机 */
uint8_t data_size; /* 数据位宽:8/16/32 */
uint8_t bit_order; /* 位序:MSB先/LSB先 */
uint8_t cs_pin; /* 片选引脚号(主机模式) */
} spi_config_t;
/* HAL SPI API */
void hal_spi_init(spi_config_t *config);
void hal_spi_deinit(void);
int32_t hal_spi_transmit(uint8_t *tx_data, uint32_t len, uint32_t timeout);
int32_t hal_spi_receive(uint8_t *rx_data, uint32_t len, uint32_t timeout);
int32_t hal_spi_transmit_receive(uint8_t *tx_data, uint8_t *rx_data, uint32_t len, uint32_t timeout);
void hal_spi_cs_control(uint8_t pin, uint8_t state);
这里有个关键点:hal_spi_transmit_receive这个函数。SPI是全双工的,发送和接收是同时进行的。很多新手会分开调用transmit和receive,结果数据对不上。我建议把“同时收发”作为标准API,单发和单收只是它的特例。
4.4 I2C接口设计
I2C的抽象,核心在于“从机地址”和“读写操作”。I2C协议里,地址有7位和10位两种,读写操作还分“写后读”这种组合操作。
/* I2C配置结构体 */
typedef struct {
uint32_t frequency; /* 时钟频率,标准100k/快速400k/高速1M */
uint8_t own_addr; /* 本机地址(从机模式) */
uint8_t addr_mode; /* 地址模式:7位/10位 */
} i2c_config_t;
/* HAL I2C API */
void hal_i2c_init(i2c_config_t *config);
void hal_i2c_deinit(void);
int32_t hal_i2c_master_write(uint16_t slave_addr, uint8_t *data, uint32_t len, uint32_t timeout);
int32_t hal_i2c_master_read(uint16_t slave_addr, uint8_t *data, uint32_t len, uint32_t timeout);
int32_t hal_i2c_master_write_read(uint16_t slave_addr, uint8_t *w_data, uint32_t w_len, uint8_t *r_data, uint32_t r_len, uint32_t timeout);
int32_t hal_i2c_slave_write(uint8_t *data, uint32_t len, uint32_t timeout);
int32_t hal_i2c_slave_read(uint8_t *data, uint32_t len, uint32_t timeout);
void hal_i2c_set_callback(uint8_t event, void (*callback)(void *arg));
注意slave_addr的类型是uint16_t,因为10位地址需要16位来存。我见过有人用uint8_t,结果10位地址传不进去,只能改底层代码——这种设计缺陷,一开始就要避免。
小技巧:I2C的write_read组合操作,在传感器驱动里特别常见。比如读一个寄存器,先写寄存器地址,再读数据。把这个操作封装成一个API,上层代码会简洁很多。
4.5 Timer接口设计
Timer的抽象,我觉得是最难的。因为Timer的用途太多了:定时中断、PWM输出、输入捕获、编码器接口……每种用途需要的配置都不一样。
我的做法是:按用途分类,每类一套API,但共用同一个初始化入口。
/* Timer配置结构体 */
typedef struct {
uint32_t frequency; /* 定时器频率,单位Hz */
uint8_t mode; /* 模式:定时/PWM/捕获/编码器 */
uint8_t channel; /* 通道号(PWM/捕获模式) */
uint8_t polarity; /* 极性(PWM/捕获模式) */
uint32_t period; /* 周期值(自动重装载值) */
uint32_t pulse; /* 脉宽值(PWM模式) */
} timer_config_t;
/* HAL Timer API */
void hal_timer_init(timer_config_t *config);
void hal_timer_deinit(void);
void hal_timer_start(void);
void hal_timer_stop(void);
void hal_timer_set_period(uint32_t period);
void hal_timer_set_pulse(uint32_t pulse);
uint32_t hal_timer_get_counter(void);
void hal_timer_set_callback(uint8_t event, void (*callback)(void *arg));
/* PWM专用API */
void hal_pwm_set_duty(uint8_t channel, uint8_t duty_percent);
void hal_pwm_set_frequency(uint32_t frequency);
/* 输入捕获专用API */
uint32_t hal_capture_get_value(uint8_t channel);
void hal_capture_set_callback(void (*callback)(uint32_t value));
这里有个设计取舍:hal_pwm_set_duty的参数用百分比(0~100),而不是直接写脉宽值。为什么?因为不同Timer的时钟频率不一样,同样的脉宽值在不同芯片上效果不同。用百分比,上层代码就不用关心底层时钟了。
避坑指南:我曾经在一个项目里,把Timer的回调函数设计成了“中断服务函数”的形式,结果上层代码里直接操作了硬件寄存器。后来换芯片,中断向量表变了,回调函数全得改。教训是:回调函数里不要出现任何硬件相关操作,只做纯软件逻辑。
4.6 接口设计总结
好了,上面把GPIO、UART、SPI、I2C、Timer的API都过了一遍。你可能会问:这些接口看起来差不多,有没有什么通用的设计原则?
我总结了几条:
| 原则 | 说明 | 举例 |
|---|---|---|
| 参数类型统一 | 所有API的参数类型尽量用标准类型,避免平台相关类型 | 用uint32_t而不是int或unsigned int |
| 返回值统一 | 所有API的返回值用int32_t,负数表示错误 |
0成功,-1超时,-2参数错误 |
| 超时参数统一 | 所有超时参数用毫秒,单位一致 | timeout参数都是毫秒 |
| 回调函数统一 | 回调函数签名统一,带void *arg参数 |
void (*callback)(void *arg) |
| 初始化/反初始化成对 | 每个init都有对应的deinit |
hal_uart_init / hal_uart_deinit |
这些原则,说白了就是“约定大于配置”。你想想看,如果每个外设的API风格都不一样,上层代码写起来得多痛苦?
下面这张图,展示了HAL接口在整个软件架构中的位置:
嗯,这一章的内容就到这里。接口设计看起来是“定规矩”,但其实每一条规矩背后,都有血泪教训。你按照上面的原则去设计,至少能避免80%的移植问题。剩下的20%,咱们在后面的章节里慢慢聊。