4. HAL进阶:UART、I2C、SPI等外设的通用接口设计
讲完了HAL层的基础架构,咱们得聊聊实际干活时最头疼的事——外设驱动。UART、I2C、SPI,这三个家伙几乎出现在每一个嵌入式项目里。你想想看,一个产品里可能挂了三四个I2C传感器,两个SPI屏幕,再加一路UART跟PC通信。如果每个外设都单独写一套初始化、收发、中断处理,那代码量简直不敢看。
我早年做一款工业采集器时,就吃过这个亏。当时项目赶,我图省事,每个I2C设备都自己写一套读写函数。结果后期要换MCU平台,那叫一个酸爽——所有驱动几乎重写。从那以后,我痛定思痛,开始琢磨怎么把外设接口抽象成通用模板。
4.1 为什么要做通用接口?
说白了,通用接口就是给同类外设定一个“标准动作”。不管你是温度传感器还是加速度计,只要挂在I2C总线上,读写操作就那几招:写寄存器、读寄存器、写一批数据、读一批数据。我们把这些操作封装成统一的API,上层应用只管调用,底层驱动只管实现。
这样做的好处很明显:
- 代码复用:换个传感器,只要实现新的底层驱动,上层逻辑基本不动。
- 平台迁移:从STM32换到GD32,甚至换到ESP32,只要HAL层接口一致,应用层代码直接搬过去。
- 调试方便:所有外设的收发流程都走同一套接口,出问题时排查路径清晰。
核心思想:定义接口时,要站在“使用者”的角度思考。使用者不关心你是用轮询还是DMA,他只关心“给我读一个字节回来”。
4.2 通用接口设计三要素
我总结了一套通用接口设计的三要素,你可以把它当成模板来用:
- 初始化/反初始化:配置外设参数,申请资源;退出时释放资源。
- 发送/接收原语:单字节、多字节、带超时的阻塞/非阻塞收发。
- 回调注册:让上层应用能挂接自己的处理函数,比如接收完成回调、错误回调。
嗯,这里要注意,回调机制是通用接口的灵魂。没有回调,你就得在中断里写死业务逻辑,那代码就耦合死了。
4.3 UART通用接口设计
UART是最常用的串行通信接口。它的通用接口设计,我习惯这样搞:
/* uart_hal.h */
typedef struct {
void (*init)(uint32_t baudrate, uint8_t data_bits, uint8_t stop_bits, uint8_t parity);
void (*deinit)(void);
int32_t (*send)(const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t (*receive)(uint8_t *buffer, uint32_t len, uint32_t timeout_ms);
int32_t (*send_it)(const uint8_t *data, uint32_t len); /* 中断发送 */
int32_t (*receive_it)(uint8_t *buffer, uint32_t len); /* 中断接收 */
void (*register_callback)(uart_event_t event, void (*cb)(void *arg), void *arg);
} uart_hal_t;
你看,这个结构体里,send和receive是阻塞的,带超时;send_it和receive_it是非阻塞的,靠中断或DMA完成。上层应用可以根据场景选:轮询模式用在简单任务里,中断模式用在实时性要求高的地方。
我的习惯:超时参数一定要加。我在项目中遇到过UART接收卡死的情况,就是因为没有超时保护。加了超时后,至少系统能恢复,不会死在那。
4.4 I2C通用接口设计
I2C比UART复杂一点,因为它有设备地址、寄存器地址、多字节读写、重复起始条件等概念。但通用接口反而更清晰:
/* i2c_hal.h */
typedef struct {
void (*init)(uint32_t speed_hz);
void (*deinit)(void);
int32_t (*write_reg)(uint8_t dev_addr, uint8_t reg_addr, const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t (*read_reg)(uint8_t dev_addr, uint8_t reg_addr, uint8_t *buffer, uint32_t len, uint32_t timeout_ms);
int32_t (*write_raw)(uint8_t dev_addr, const uint8_t *data, uint32_t len, uint32_t timeout_ms);
int32_t (*read_raw)(uint8_t dev_addr, uint8_t *buffer, uint32_t len, uint32_t timeout_ms);
void (*register_callback)(i2c_event_t event, void (*cb)(void *arg), void *arg);
} i2c_hal_t;
这里我特意把write_reg/read_reg和write_raw/read_raw分开。前者是标准I2C操作——先发寄存器地址,再读写数据;后者是裸读写,用于一些特殊设备(比如某些EEPROM不需要寄存器地址)。
曾经踩过的坑:I2C的NACK处理。有些从设备在忙时会发NACK,如果你不重试,通信就断了。我建议在通用接口里内置重试机制,比如连续失败3次才返回错误。
4.5 SPI通用接口设计
SPI的通用接口相对简单,因为它本质上是全双工通信。但要注意片选信号的管理:
/* spi_hal.h */
typedef struct {
void (*init)(uint32_t speed_hz, uint8_t mode, uint8_t bit_order);
void (*deinit)(void);
int32_t (*transmit)(const uint8_t *tx_data, uint8_t *rx_data, uint32_t len, uint32_t timeout_ms);
int32_t (*transmit_it)(const uint8_t *tx_data, uint8_t *rx_data, uint32_t len);
void (*cs_control)(uint8_t level); /* 片选控制 */
void (*register_callback)(spi_event_t event, void (*cb)(void *arg), void *arg);
} spi_hal_t;
注意transmit函数同时有发送和接收缓冲区。SPI是全双工的,你发一个字节的同时会收到一个字节。如果只发不收,把rx_data传NULL就行;如果只收不发,把tx_data传NULL,驱动内部会发0xFF占位。
片选控制我单独拎出来,是因为有些场景需要手动控制片选(比如多个SPI设备共用总线时,要在两次传输之间保持片选有效)。
4.6 通用接口的对比与选择
为了让你看得更清楚,我把三个接口的核心差异整理成一张表:
| 特性 | UART | I2C | SPI |
|---|---|---|---|
| 通信方式 | 异步,全双工 | 同步,半双工 | 同步,全双工 |
| 设备寻址 | 无(点对点) | 7位/10位地址 | 片选信号 |
| 典型速率 | 115200 bps ~ 1 Mbps | 100 kHz ~ 3.4 MHz | 1 MHz ~ 50 MHz |
| 通用接口核心 | send/receive | write_reg/read_reg | transmit |
| 超时处理 | 必须 | 必须 | 必须 |
| 回调机制 | 接收完成、错误 | 传输完成、NACK | 传输完成、错误 |
从这张表能看出来,虽然三个外设的物理层差异很大,但抽象到通用接口层面,核心要素是一样的:初始化、收发、回调、超时。这就是分层设计的力量——把差异藏在底层,把共性暴露给上层。
4.7 通用接口的知识体系
下面这张SVG图,把本章的核心逻辑串起来了。你可以看到,从硬件外设到通用接口,再到上层应用,每一层都有明确的职责。
这张图里,从上到下依次是:应用层、通用接口层、硬件驱动层、物理硬件。每一层只跟相邻层打交道,不跨层调用。这就是分层设计的精髓——你改底层驱动,上层不用动;你换上层应用,底层不用管。
4.8 实战建议:从零搭建通用接口
如果你现在要开始做通用接口,我建议你按这个步骤来:
- 先列需求:你的项目里用到了哪些外设?UART、I2C、SPI都跑在什么速率?需要中断还是DMA?
- 定义结构体:按我上面给的模板,把函数指针列出来。初期可以只实现阻塞模式,后面再慢慢加中断和DMA。
- 实现底层:针对你的MCU,写具体的寄存器操作。这一步最枯燥,但也是最关键的。
- 写测试用例:每个接口写一个简单的测试函数,比如UART回环测试、I2C扫描设备地址、SPI读写Flash ID。
- 封装回调:把中断服务函数里的业务逻辑抽出来,通过回调挂接到上层。
一个小技巧:在调试阶段,可以在通用接口里加一个“调试模式”。打开后,每次收发都会打印日志。这样排查硬件问题时,能快速定位是驱动问题还是接线问题。
好了,这一章的内容就这些。通用接口设计看起来简单,但真正做好需要反复迭代。我自己的代码库里,UART通用接口已经改过四个版本了——从最初的纯轮询,到后来加中断,再到现在的DMA+超时+回调。每一次重构,都让代码更健壮、更易用。
你动手试试看,从最简单的UART开始,把通用接口搭起来。相信我,等你做完I2C和SPI的接口后,你会爱上这种“一次设计,到处使用”的感觉。