4. 硬件抽象层(HAL)接口定义:GPIO、UART、SPI、I2C、Timer、ADC的通用API设计
好,咱们进入正题。硬件抽象层,说白了就是给MCU外设穿上一件“通用外衣”。不管底下是STM32、GD32还是NXP,上层应用看到的接口都一样。我做了这么多年嵌入式,见过太多项目因为驱动耦合太紧,换芯片时几乎重写所有代码。嗯,今天咱们就把这件事彻底解决掉。
4.1 接口设计核心原则
设计HAL接口前,先定几条规矩。我个人习惯遵循以下三点:
- 最小完备性:接口数量要少,但必须覆盖所有常用场景。少一个不行,多一个冗余。
- 无状态优先:函数不依赖内部全局变量,所有上下文通过句柄传递。这样才支持多实例。
- 错误返回统一:所有API返回标准错误码,0表示成功,负数表示错误。
核心思想:接口只描述“做什么”,不描述“怎么做”。具体实现交给芯片相关的底层驱动。
4.2 GPIO通用API设计
GPIO是最基础的外设。我见过最糟糕的设计是把每个引脚的操作都写死成宏定义,换板子就得改几百行。咱们应该这样设计:
// gpio_hal.h
typedef struct {
uint8_t port; // 端口号,如0表示PORTA
uint8_t pin; // 引脚号,0-15
} gpio_pin_t;
typedef enum {
GPIO_MODE_INPUT,
GPIO_MODE_OUTPUT_PP, // 推挽输出
GPIO_MODE_OUTPUT_OD, // 开漏输出
GPIO_MODE_AF_PP, // 复用推挽
GPIO_MODE_AF_OD // 复用开漏
} gpio_mode_t;
typedef enum {
GPIO_PULL_NONE,
GPIO_PULL_UP,
GPIO_PULL_DOWN
} gpio_pull_t;
// 核心API
int32_t gpio_init(gpio_pin_t pin, gpio_mode_t mode, gpio_pull_t pull);
int32_t gpio_set(gpio_pin_t pin, uint8_t level);
int32_t gpio_get(gpio_pin_t pin, uint8_t *level);
int32_t gpio_toggle(gpio_pin_t pin);
int32_t gpio_deinit(gpio_pin_t pin);
我的经验:gpio_pin_t用结构体而不是分开传两个参数,这样扩展性更好。比如以后要加中断功能,直接在结构体里加字段就行,不用改函数签名。
4.3 UART通用API设计
UART的坑主要在缓冲管理和流控。我曾经在一个项目里因为没处理好接收缓冲溢出,导致数据丢包查了三天。来看标准接口:
// uart_hal.h
typedef struct {
uint32_t baudrate;
uint8_t data_bits; // 5, 6, 7, 8
uint8_t stop_bits; // 1, 2
uint8_t parity; // 0:无, 1:奇, 2:偶
uint32_t rx_buf_size; // 接收缓冲大小
uint32_t tx_buf_size; // 发送缓冲大小
} uart_config_t;
typedef void* uart_handle_t;
// 核心API
uart_handle_t uart_init(uint8_t id, uart_config_t *config);
int32_t uart_send(uart_handle_t handle, const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t uart_receive(uart_handle_t handle, uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t uart_send_it(uart_handle_t handle, const uint8_t *data, uint32_t len);
int32_t uart_set_callback(uart_handle_t handle, uart_event_t event, void (*cb)(void *arg));
int32_t uart_deinit(uart_handle_t handle);
| 函数 | 阻塞/非阻塞 | 适用场景 |
|---|---|---|
uart_send |
阻塞 | 调试打印、小数据量发送 |
uart_receive |
阻塞 | 命令响应、固定长度接收 |
uart_send_it |
非阻塞 | 大数据量、实时性要求高 |
注意:uart_handle_t用void*而不是具体结构体指针,这是为了隐藏底层实现。用户不需要知道里面是什么,只需要拿着句柄用就行。
4.4 SPI与I2C通用API设计
SPI和I2C都是总线协议,但特性差异很大。我建议把它们分开设计,不要强行统一成一个接口。先看SPI:
// spi_hal.h
typedef struct {
uint32_t frequency;
uint8_t mode; // 0-3, 对应CPOL/CPHA组合
uint8_t data_width; // 8或16
uint8_t bit_order; // 0:MSB, 1:LSB
} spi_config_t;
typedef void* spi_handle_t;
spi_handle_t spi_init(uint8_t id, spi_config_t *config);
int32_t spi_transfer(spi_handle_t handle, const uint8_t *tx, uint8_t *rx, uint32_t len);
int32_t spi_transfer_dma(spi_handle_t handle, const uint8_t *tx, uint8_t *rx, uint32_t len);
int32_t spi_cs_control(spi_handle_t handle, uint8_t level); // 片选控制
int32_t spi_deinit(spi_handle_t handle);
再看I2C。I2C有设备地址的概念,所以接口要带上地址参数:
// i2c_hal.h
typedef struct {
uint32_t frequency; // 标准100k, 快速400k, 高速3.4M
uint8_t addr_width; // 7位或10位地址
} i2c_config_t;
typedef void* i2c_handle_t;
i2c_handle_t i2c_init(uint8_t id, i2c_config_t *config);
int32_t i2c_master_write(i2c_handle_t handle, uint16_t addr, const uint8_t *data, uint32_t len);
int32_t i2c_master_read(i2c_handle_t handle, uint16_t addr, uint8_t *data, uint32_t len);
int32_t i2c_mem_write(i2c_handle_t handle, uint16_t addr, uint16_t mem_addr,
uint8_t mem_addr_size, const uint8_t *data, uint32_t len);
int32_t i2c_mem_read(i2c_handle_t handle, uint16_t addr, uint16_t mem_addr,
uint8_t mem_addr_size, uint8_t *data, uint32_t len);
int32_t i2c_deinit(i2c_handle_t handle);
关键区别:SPI用片选线选设备,I2C用地址选设备。所以SPI的spi_cs_control是独立函数,而I2C的地址直接传进读写函数里。
4.5 Timer通用API设计
定时器是最灵活的外设,可以当计数器、PWM发生器、输入捕获用。接口设计要兼顾通用性和性能:
// timer_hal.h
typedef enum {
TIMER_MODE_ONESHOT, // 单次
TIMER_MODE_PERIODIC, // 周期
TIMER_MODE_PWM,
TIMER_MODE_INPUT_CAPTURE
} timer_mode_t;
typedef struct {
timer_mode_t mode;
uint32_t period_us; // 周期,微秒
uint32_t duty_us; // PWM占空比,微秒
uint8_t auto_reload; // 是否自动重装载
} timer_config_t;
typedef void* timer_handle_t;
timer_handle_t timer_init(uint8_t id, timer_config_t *config);
int32_t timer_start(timer_handle_t handle);
int32_t timer_stop(timer_handle_t handle);
int32_t timer_set_period(timer_handle_t handle, uint32_t period_us);
int32_t timer_set_duty(timer_handle_t handle, uint32_t duty_us);
int32_t timer_register_callback(timer_handle_t handle, void (*cb)(void *arg));
int32_t timer_deinit(timer_handle_t handle);
我的习惯:时间单位统一用微秒,这样上层不用关心时钟分频系数。底层驱动自己算预分频值和重装载值,上层只管说“我要1ms中断一次”。
4.6 ADC通用API设计
ADC的难点在于触发方式和转换结果读取。我建议提供同步和异步两种模式:
// adc_hal.h
typedef struct {
uint8_t channel; // ADC通道
uint8_t resolution; // 8, 10, 12, 16位
uint32_t sampling_time_ns; // 采样时间,纳秒
uint8_t continuous_mode; // 是否连续转换
} adc_config_t;
typedef void* adc_handle_t;
adc_handle_t adc_init(uint8_t id, adc_config_t *config);
int32_t adc_read(adc_handle_t handle, uint32_t *value); // 同步读取
int32_t adc_start_it(adc_handle_t handle); // 启动中断转换
int32_t adc_set_callback(adc_handle_t handle, void (*cb)(uint32_t value));
int32_t adc_calibrate(adc_handle_t handle); // 校准
int32_t adc_deinit(adc_handle_t handle);
4.7 接口设计总览图
下面这张图展示了各外设HAL接口的层级关系。你想想看,从应用层到底层驱动,中间就隔了这一层薄薄的抽象接口:
4.8 接口设计避坑指南
最后分享几个我踩过的坑:
- 别把配置和操作混在一起:初始化函数只做配置,不要在里面启动外设。启动是独立的行为。
- 回调函数要带参数指针:
void (*cb)(void *arg)比void (*cb)(void)灵活得多。用户可以把任意结构体传进去。 - 超时机制不能省:所有阻塞操作都要有超时参数。我曾经见过一个UART接收没有超时,结果硬件故障后程序永远卡死在等待状态。
- 句柄要检查有效性:每个API入口都要判断句柄是否为NULL。别问我为什么知道这个很重要。
曾经有一次,我在一个项目里把SPI的片选控制放在spi_transfer内部自动完成。结果遇到一个外设需要在传输前后单独控制片选时序,不得不临时加了一个spi_cs_control接口。从那以后,我所有SPI接口都把片选控制独立出来。
嗯,以上就是六个核心外设的HAL接口定义。这些接口我在多个项目里验证过,从Cortex-M0到M7都能用。你拿过去直接当模板,根据具体芯片调整底层实现就行。