嵌入式系统中的接口类型: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 接口隔离的层次结构
说了这么多,到底怎么组织代码?我画了一张图,展示接口隔离的典型分层:
这张图我每次做架构评审都会拿出来。你看,应用层在最上面,它只跟接口抽象层打交道。驱动实现层在中间,负责把抽象接口翻译成具体的寄存器操作。硬件层在最下面,就是那些物理外设。
这样做的好处是什么?
- 换 MCU:只需要重写驱动实现层,应用层和抽象层不动。
- 换外设:如果新外设接口类型相同(比如都是 I2C 温度传感器),只需要改设备层的初始化参数。
- 单元测试:可以 mock 掉驱动实现层,用模拟数据测试应用逻辑。
2.8 实战中的避坑清单
最后,分享几个我踩过的坑。这些教训都是用加班换来的。
我曾经犯过的错:
- 把中断处理写在抽象层里。不同 MCU 的中断优先级、嵌套机制完全不同。中断处理应该留在驱动实现层,抽象层只提供回调注册接口。
- 抽象层里用了动态内存分配。嵌入式系统很多场景不允许 malloc。抽象层的所有缓冲区应该由上层传入,或者用静态数组。
- 忽略了时序约束。I2C 的速率、SPI 的极性相位、UART 的波特率误差,这些必须在抽象层有明确的参数校验,不能等到运行时才发现通信失败。
- 把调试信息耦合进接口。不要在 gpio_set() 里面加 printf。调试日志应该通过独立的日志接口输出。
接口隔离这件事,说起来简单,做起来需要经验积累。但只要你坚持「上层不碰寄存器,底层不暴露细节」这个原则,架构就不会太差。