3. 物理层驱动开发:GPIO、UART、SPI、I2C 底层驱动实现与寄存器操作
物理层驱动,说白了就是直接跟芯片寄存器打交道的代码。很多刚入行的朋友喜欢用 HAL 库或者 SDK 封装好的 API,觉得省事。但我个人的经验是——不懂寄存器操作,你永远只能当个“调包侠”。出了问题,连日志都看不懂。
这一章,我们就把 GPIO、UART、SPI、I2C 这四个最常用的外设,从寄存器层面拆开揉碎。我会结合自己踩过的坑,告诉你每个驱动背后真正该注意什么。
3.1 GPIO:最基础也最容易翻车的外设
GPIO 看起来简单,不就是拉高拉低吗?但我在项目中遇到过好几次,因为 GPIO 配置不当导致整板功耗异常、信号反射、甚至锁死芯片的情况。
3.1.1 寄存器模型
以 STM32 为例,每个 GPIO 端口有以下几个关键寄存器:
| 寄存器 | 功能 | 位宽 |
|---|---|---|
| MODER | 模式选择(输入/输出/复用/模拟) | 2 bit / pin |
| OTYPER | 输出类型(推挽/开漏) | 1 bit / pin |
| OSPEEDR | 输出速度 | 2 bit / pin |
| PUPDR | 上下拉配置 | 2 bit / pin |
| IDR | 输入数据寄存器 | 16 bit |
| ODR | 输出数据寄存器 | 16 bit |
| BSRR | 位设置/复位寄存器 | 32 bit |
3.1.2 驱动实现要点
写 GPIO 驱动,核心就三件事:
- 时钟使能——不打开外设时钟,写寄存器等于白写。我见过有人调了半天 GPIO 没反应,结果发现 RCC 没配。
- 模式配置——输入还是输出?复用还是模拟?每个模式对应不同的电气特性。
- 输出/输入操作——读 IDR,写 BSRR 或 ODR。
// 一个极简的 GPIO 输出初始化(STM32 风格)
void GPIO_InitOutput(GPIO_TypeDef *port, uint16_t pin) {
// 1. 使能时钟(以 GPIOA 为例)
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
// 2. 配置为输出模式(01)
port->MODER &= ~(0x3 << (pin * 2));
port->MODER |= (0x1 << (pin * 2));
// 3. 推挽输出,速度 50MHz
port->OTYPER &= ~(0x1 << pin);
port->OSPEEDR |= (0x2 << (pin * 2));
// 4. 无上下拉
port->PUPDR &= ~(0x3 << (pin * 2));
}
// 写引脚
void GPIO_WritePin(GPIO_TypeDef *port, uint16_t pin, uint8_t state) {
if (state)
port->BSRR = (1 << pin); // 置位
else
port->BSRR = (1 << (pin + 16)); // 复位
}
3.2 UART:异步通信的“老黄牛”
UART 是调试利器,也是很多通信问题的源头。说白了,UART 驱动就两个核心:波特率生成和帧格式控制。
3.2.1 波特率计算
波特率寄存器 USART_BRR 的值由以下公式决定:
BRR = fCK / (16 * BaudRate)
举个例子,系统时钟 72MHz,目标波特率 115200:
BRR = 72000000 / (16 * 115200) ≈ 39.0625
整数部分 = 39 (0x27)
小数部分 = 0.0625 * 16 = 1 (0x1)
BRR 值 = (39 << 4) | 1 = 0x271
嗯,这里要注意:小数部分算出来如果不是整数,实际波特率会有偏差。偏差超过 2% 就可能丢数据。我建议你算完后用示波器实测一下波形,别光靠公式。
3.2.2 中断与 DMA 的选择
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 轮询 | 实现简单 | 阻塞 CPU | 调试打印、低速率 |
| 中断 | 不阻塞,响应及时 | 高频中断可能影响实时性 | 命令交互、不定长数据 |
| DMA | 零 CPU 开销 | 配置复杂,适合连续流 | 大量数据收发(如日志、文件) |
我的建议:调试阶段用中断接收,配合环形缓冲区。产品阶段如果数据量大,果断上 DMA。我曾经在一个 2Mbps 的 UART 项目里用中断,结果 CPU 一半时间都在处理中断,后来换成 DMA,CPU 占用率直接降到 5%。
3.3 SPI:高速同步通信的“双刃剑”
SPI 速度快,但信号完整性问题也多。我遇到过 SPI 通信偶尔错位,排查了两天才发现是时钟极性配置跟从机不匹配。
3.3.1 四种模式
SPI 有四种模式,由 CPOL(时钟极性)和 CPHA(时钟相位)决定:
| 模式 | CPOL | CPHA | 数据采样边沿 |
|---|---|---|---|
| 0 | 0 | 0 | 第一个跳变沿(上升沿) |
| 1 | 0 | 1 | 第二个跳变沿(下降沿) |
| 2 | 1 | 0 | 第一个跳变沿(下降沿) |
| 3 | 1 | 1 | 第二个跳变沿(上升沿) |
3.3.2 驱动实现骨架
// SPI 发送一个字节(阻塞模式)
uint8_t SPI_TransferByte(SPI_TypeDef *spi, uint8_t data) {
// 等待发送缓冲区空
while (!(spi->SR & SPI_SR_TXE));
spi->DR = data;
// 等待接收缓冲区非空
while (!(spi->SR & SPI_SR_RXNE));
return (uint8_t)spi->DR;
}
你想想看,这个代码看起来简单,但实际项目中往往需要加超时机制。万一从机没响应,程序就卡死在 while 循环里了。我一般会加一个计数超时,超过一定次数就返回错误。
3.4 I2C:多从机总线上的“外交官”
I2C 只有两根线(SCL、SDA),但协议比 SPI 复杂。因为它有地址、有应答、有起始条件和停止条件。
3.4.1 时序关键点
- 起始条件:SCL 高电平时,SDA 从高变低
- 停止条件:SCL 高电平时,SDA 从低变高
- 数据有效性:SCL 高电平时,SDA 必须稳定;SCL 低电平时,SDA 可以变化
- 应答:每 8 位数据后,接收方拉低 SDA 表示 ACK
我的经验:I2C 最容易出问题的地方是“总线锁死”。从机拉低 SDA 不放,主机无法产生停止条件。解决办法是:在初始化时,先软件复位 I2C 外设,然后 GPIO 模拟 9 个时钟脉冲,让从机释放总线。这个技巧我在好几个项目里都用过。
3.4.2 寄存器操作示例
// I2C 发送起始条件 + 从机地址(7位地址模式)
uint8_t I2C_Start( I2C_TypeDef *i2c, uint8_t addr, uint8_t dir ) {
// 等待总线空闲
while (i2c->SR2 & I2C_SR2_BUSY);
// 产生起始条件
i2c->CR1 |= I2C_CR1_START;
// 等待起始条件发送完成
while (!(i2c->SR1 & I2C_SR1_SB));
// 发送从机地址 + 读写位
i2c->DR = (addr << 1) | dir;
// 等待地址发送完成
while (!(i2c->SR1 & I2C_SR1_ADDR));
// 读 SR2 清除 ADDR 标志
(void)i2c->SR2;
return 0; // 成功
}
3.5 知识结构总览
下面这张图,把四种外设的核心要素串在了一起。你可以把它当作驱动开发的“速查地图”。
3.6 写在最后
物理层驱动开发,说白了就是“寄存器翻译官”。你把芯片手册上的时序图翻译成 C 代码,再把代码烧进芯片,用逻辑分析仪验证波形对不对。
我个人觉得,这四个外设里,I2C 最难调,SPI 最容易出信号问题,UART 最常用但最容易被忽视细节,GPIO 最简单但坑最多。你如果能把这四个吃透,其他外设(比如 CAN、USB)的驱动思路,基本就是换汤不换药。