嵌入式系统中的接口类型:GPIO、I2C、SPI、UART、CAN、ADC、PWM 等外设接口的抽象与隔离

做嵌入式架构设计,说白了就是在和各种外设接口打交道。你想想看,一个典型的嵌入式系统,CPU 要跟传感器说话、要控制电机、要收发数据、要采集模拟信号……这些全靠接口来打通。

我个人习惯,在设计初期就把接口层给理清楚。为什么?因为接口是硬件和软件的分界线。分界线划得好,后面换芯片、换外设,代码改动量能控制在 10% 以内。划不好?嗯,那可能就是重写。

2.1 接口的本质:寄存器操作 vs. 功能抽象

很多刚入行的朋友,写驱动就是对着数据手册翻寄存器。比如点亮一个 GPIO:

// 直接寄存器操作
*(volatile uint32_t*)0x40020C00 |= (1 << 12);  // 设置输出
*(volatile uint32_t*)0x40020C14 |= (1 << 12);  // 输出高电平

这段代码能跑,但问题很大。哪天换了 STM32 的某个系列,GPIO 基地址变了,你得全文搜索 0x40020C00 去改。更别说换到 NXP 或者 GD32 了。

我建议的做法是:先定义接口,再实现接口。把「我要让这个引脚输出高电平」这个意图,和「具体往哪个寄存器写什么值」这个实现,彻底分开。

核心原则:上层代码只依赖接口抽象,不依赖具体寄存器。接口隔离的第一步,就是把「做什么」和「怎么做」拆开。

2.2 七大接口的抽象模型

嵌入式系统里常见的接口,我按功能分了三大类。每一类的抽象方式都不一样。

接口类型 功能本质 抽象核心 典型应用
GPIO 数字信号输入/输出 引脚号 + 方向 + 电平状态 按键、LED、片选信号
UART 异步串行通信 波特率 + 数据收发回调 调试日志、蓝牙模块
I2C 同步串行总线(多从机) 设备地址 + 寄存器读写 传感器、EEPROM
SPI 同步串行总线(高速) 片选 + 全双工收发 显示屏、Flash、ADC
CAN 差分串行总线(多主) 报文 ID + 数据帧收发 汽车电子、工业控制
ADC 模拟量转数字量 通道号 + 采样值(mV) 电位器、温度传感器
PWM 脉冲宽度调制 频率 + 占空比 电机调速、呼吸灯

这张表我建议你存一下。每次设计新模块,先问自己:这个外设属于哪类接口?它的抽象核心是什么?

2.3 GPIO 抽象:从引脚到逻辑信号

GPIO 看起来最简单,但最容易写死。我见过太多代码把「LED 亮」直接写成 GPIO_SetBits(GPIOB, GPIO_Pin_12)。这有什么问题?

问题大了。哪天硬件改版,LED 从 PB12 换到了 PA0,你得改所有调用点。更糟糕的是,如果这个 LED 表示「系统运行状态」,那上层逻辑根本不该关心它接在哪个引脚上。

我习惯的做法是:

// 接口定义(头文件)
typedef struct {
    void (*set)(void *obj, uint8_t level);
    uint8_t (*get)(void *obj);
} gpio_ops_t;

// 使用示例
void system_indicator_set_on(void) {
    // 上层只调用接口,不关心具体引脚
    led_ops.set(&led_dev, 1);
}

这样,硬件改引脚,只需要改底层实现文件。上层代码一行不动。

小技巧:GPIO 抽象时,把「高电平有效」还是「低电平有效」也封装在底层。上层永远传「亮/灭」「开/关」这种逻辑值,底层自己处理取反。

2.4 I2C 与 SPI:总线级抽象

I2C 和 SPI 都是总线协议,但抽象思路不太一样。

I2C 的核心是「设备地址 + 寄存器读写」。我建议把 I2C 抽象成两层:

  • 总线层:负责起始条件、停止条件、ACK 检测。这一层跟具体外设无关。
  • 设备层:基于总线层,封装具体传感器的读写序列。比如读温度传感器的温度寄存器。

SPI 呢?它的特点是全双工,而且每个从设备有自己的片选。抽象时要注意:

  • 把片选控制独立出来,因为不同 MCU 的片选实现方式不同(硬件 NSS 或软件 GPIO)。
  • 收发接口统一为 spi_transfer(tx_data, rx_data, len),上层不用关心是同时收发还是分时收发。

我在项目中遇到过一个问题:某款 MCU 的 SPI 硬件 FIFO 深度只有 4 字节,另一款有 16 字节。如果抽象层不处理好,上层代码在高速传输时就会出 BUG。后来我在抽象层加了一个 spi_set_fifo_threshold() 的钩子,问题就解决了。

2.5 UART 与 CAN:面向消息的抽象

UART 和 CAN 都是通信接口,但抽象方式完全不同。

UART 是面向字节流的。我建议抽象成:

// UART 接口抽象
typedef struct {
    int32_t (*send)(void *obj, const uint8_t *data, uint32_t len);
    int32_t (*receive)(void *obj, uint8_t *buf, uint32_t len);
    void    (*register_callback)(void *obj, uart_event_t event, void (*cb)(void*));
} uart_ops_t;

注意这里用了回调注册。为什么?因为 UART 经常要处理不定长数据,用轮询太浪费 CPU。回调机制让上层在数据到达时被通知,不用自己死等。

CAN 就不一样了。CAN 是面向报文的,每个报文有 ID 和优先级。抽象时要把 ID 过滤、报文打包解包都考虑进去。我见过最糟糕的 CAN 抽象,直接把底层寄存器暴露给应用层,结果应用工程师天天在调位时序。

避坑指南:CAN 的波特率计算各家 MCU 差异极大。千万不要在抽象层暴露「分频系数」「同步跳转宽度」这类寄存器级参数。应该只暴露「波特率数值」,让底层自己去算。

2.6 ADC 与 PWM:模拟世界的数字抽象

ADC 和 PWM 是连接数字世界和模拟世界的桥梁。抽象时有一个共同点:用物理单位,不要用寄存器值。

比如 ADC:

  • ❌ 返回 0~4095(12位 ADC 的原始值)
  • ✅ 返回 0~3300(单位 mV)

为什么?因为不同 MCU 的 ADC 分辨率不一样。有的 10 位,有的 12 位,有的 16 位。上层逻辑关心的是「电压是多少毫伏」,不是「寄存器里存了什么数」。

PWM 也一样:

  • ❌ 设置占空比寄存器值为 500
  • ✅ 设置占空比为 50.0%(浮点数或千分数)

频率和占空比分开设置。频率由底层根据定时器时钟自动计算分频系数,上层只管说「我要 1kHz 的 PWM」。

2.7 接口隔离的层次结构

说了这么多,到底怎么组织代码?我画了一张图,展示接口隔离的典型分层:

嵌入式接口隔离层次结构 应用层(业务逻辑) 接口抽象层(API) gpio_set() | i2c_write() | spi_transfer() | uart_send() | can_tx() | adc_read() | pwm_set() 驱动实现层(MCU 相关) STM32_HAL | NXP_SDK | GD32_StdPeriph | 裸机寄存器 硬件层(外设寄存器) 接口隔离的关键:应用层只依赖抽象层,不依赖驱动实现层

这张图我每次做架构评审都会拿出来。你看,应用层在最上面,它只跟接口抽象层打交道。驱动实现层在中间,负责把抽象接口翻译成具体的寄存器操作。硬件层在最下面,就是那些物理外设。

这样做的好处是什么?

  • 换 MCU:只需要重写驱动实现层,应用层和抽象层不动。
  • 换外设:如果新外设接口类型相同(比如都是 I2C 温度传感器),只需要改设备层的初始化参数。
  • 单元测试:可以 mock 掉驱动实现层,用模拟数据测试应用逻辑。

2.8 实战中的避坑清单

最后,分享几个我踩过的坑。这些教训都是用加班换来的。

我曾经犯过的错:

  1. 把中断处理写在抽象层里。不同 MCU 的中断优先级、嵌套机制完全不同。中断处理应该留在驱动实现层,抽象层只提供回调注册接口。
  2. 抽象层里用了动态内存分配。嵌入式系统很多场景不允许 malloc。抽象层的所有缓冲区应该由上层传入,或者用静态数组。
  3. 忽略了时序约束。I2C 的速率、SPI 的极性相位、UART 的波特率误差,这些必须在抽象层有明确的参数校验,不能等到运行时才发现通信失败。
  4. 把调试信息耦合进接口。不要在 gpio_set() 里面加 printf。调试日志应该通过独立的日志接口输出。

接口隔离这件事,说起来简单,做起来需要经验积累。但只要你坚持「上层不碰寄存器,底层不暴露细节」这个原则,架构就不会太差。