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 通用接口设计三要素

我总结了一套通用接口设计的三要素,你可以把它当成模板来用:

  1. 初始化/反初始化:配置外设参数,申请资源;退出时释放资源。
  2. 发送/接收原语:单字节、多字节、带超时的阻塞/非阻塞收发。
  3. 回调注册:让上层应用能挂接自己的处理函数,比如接收完成回调、错误回调。

嗯,这里要注意,回调机制是通用接口的灵魂。没有回调,你就得在中断里写死业务逻辑,那代码就耦合死了。

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图,把本章的核心逻辑串起来了。你可以看到,从硬件外设到通用接口,再到上层应用,每一层都有明确的职责。

外设通用接口设计知识体系 上层应用(传感器、协议栈、用户逻辑) 通用接口层(HAL抽象) UART: send/receive I2C: write_reg/read_reg SPI: transmit 硬件驱动层(MCU寄存器操作) 中断配置 · DMA设置 · 时钟使能 · GPIO复用 物理硬件(UART/I2C/SPI外设) 核心设计原则 接口统一 · 回调驱动 · 超时保护

这张图里,从上到下依次是:应用层、通用接口层、硬件驱动层、物理硬件。每一层只跟相邻层打交道,不跨层调用。这就是分层设计的精髓——你改底层驱动,上层不用动;你换上层应用,底层不用管。

4.8 实战建议:从零搭建通用接口

如果你现在要开始做通用接口,我建议你按这个步骤来:

  1. 先列需求:你的项目里用到了哪些外设?UART、I2C、SPI都跑在什么速率?需要中断还是DMA?
  2. 定义结构体:按我上面给的模板,把函数指针列出来。初期可以只实现阻塞模式,后面再慢慢加中断和DMA。
  3. 实现底层:针对你的MCU,写具体的寄存器操作。这一步最枯燥,但也是最关键的。
  4. 写测试用例:每个接口写一个简单的测试函数,比如UART回环测试、I2C扫描设备地址、SPI读写Flash ID。
  5. 封装回调:把中断服务函数里的业务逻辑抽出来,通过回调挂接到上层。

一个小技巧:在调试阶段,可以在通用接口里加一个“调试模式”。打开后,每次收发都会打印日志。这样排查硬件问题时,能快速定位是驱动问题还是接线问题。

好了,这一章的内容就这些。通用接口设计看起来简单,但真正做好需要反复迭代。我自己的代码库里,UART通用接口已经改过四个版本了——从最初的纯轮询,到后来加中断,再到现在的DMA+超时+回调。每一次重构,都让代码更健壮、更易用。

你动手试试看,从最简单的UART开始,把通用接口搭起来。相信我,等你做完I2C和SPI的接口后,你会爱上这种“一次设计,到处使用”的感觉。