2. 物理层与数据链路层实战:UART/SPI/I2C 驱动封装与DMA集成
说实话,很多嵌入式工程师写了三五年代码,还在跟寄存器较劲。每次换个MCU,就要把UART、SPI、I2C的驱动重写一遍。这不光是体力活,更是个坑——你永远不知道下一个芯片的勘误表里藏着什么古怪。
我个人习惯是,把物理层和数据链路层彻底剥离开。物理层只负责收发字节流,数据链路层管协议帧、校验和重传。这样换芯片时,只需要重写最底下那层,上面的逻辑纹丝不动。
2.1 三层驱动架构:从寄存器到业务逻辑
我一般把通信驱动拆成三层。你想想看,这样分层后,调试定位问题会快很多。
| 层级 | 职责 | 典型接口 | 依赖 |
|---|---|---|---|
| 硬件抽象层 (HAL) | 直接操作寄存器,完成最基础的收发 | hal_uart_send_byte() |
MCU寄存器手册 |
| 驱动层 (Driver) | 封装成帧、FIFO管理、中断/DMA调度 | drv_uart_send_frame() |
HAL层 |
| 接口层 (Interface) | 提供统一的收发API,屏蔽底层差异 | if_uart_write() |
驱动层 |
我在一个车载项目里吃过亏。当时直接把业务逻辑和寄存器操作混在一起写,结果换了个型号的MCU,整整花了两周改驱动。从那以后,我坚持用这种三层结构。哪怕是个简单的LED闪烁,我也给它套个抽象层——习惯成自然。
2.2 UART驱动封装:从轮询到中断
UART是最常用的串行接口。但很多人只会在主循环里轮询——说白了就是浪费CPU。
先看一个最基本的轮询发送:
// 硬件抽象层 - 轮询发送
void hal_uart_send_byte(UART_TypeDef *uart, uint8_t data)
{
// 等待发送缓冲区空
while (!(uart->SR & UART_SR_TXE));
// 写入数据寄存器
uart->DR = data;
}
// 驱动层 - 带超时的帧发送
int drv_uart_send_frame(UART_TypeDef *uart, uint8_t *buf, uint32_t len, uint32_t timeout_ms)
{
uint32_t tick_start = get_tick();
for (uint32_t i = 0; i < len; i++) {
// 带超时保护的轮询
while (!(uart->SR & UART_SR_TXE)) {
if (get_tick() - tick_start > timeout_ms) {
return -1; // 超时
}
}
uart->DR = buf[i];
}
return 0;
}
中断方式就好多了。发送时把数据丢进环形缓冲区,让中断慢慢吐。接收也一样,中断把字节塞进缓冲区,主循环来取。
// 环形缓冲区结构体
typedef struct {
uint8_t buffer[UART_BUF_SIZE];
volatile uint32_t head;
volatile uint32_t tail;
} ring_buffer_t;
// 中断服务函数 - 接收
void UART_IRQHandler(void)
{
uint8_t data;
if (uart->SR & UART_SR_RXNE) {
data = uart->DR;
// 写入环形缓冲区
rx_buf.buffer[rx_buf.head] = data;
rx_buf.head = (rx_buf.head + 1) % UART_BUF_SIZE;
}
}
嗯,这里要注意:环形缓冲区的头尾指针一定要加 volatile。中断和主循环会同时访问它们,不加的话,编译器优化可能让你读到脏数据。
2.3 SPI驱动封装:全双工与DMA流水线
SPI和UART不一样,它是全双工的。发一个字节的同时,一定会收到一个字节。很多人写SPI驱动时,只发了数据,把接收到的垃圾数据直接扔了——这在某些场景下会出问题。
我习惯把SPI的收发封装成一个原子操作:
// 硬件抽象层 - SPI全双工收发
void hal_spi_transfer(SPI_TypeDef *spi, uint8_t tx_data, uint8_t *rx_data)
{
// 等待发送就绪
while (!(spi->SR & SPI_SR_TXE));
// 发送数据
spi->DR = tx_data;
// 等待接收就绪
while (!(spi->SR & SPI_SR_RXNE));
// 读取接收数据
*rx_data = spi->DR;
}
但轮询方式在传输大量数据时效率很低。DMA才是正解。
- 发送DMA:把要发的数据从内存搬到SPI的DR寄存器
- 接收DMA:把SPI的DR寄存器数据搬到内存
- 使用硬件握手信号,让DMA控制器自动完成搬运
// 驱动层 - DMA方式SPI传输
void drv_spi_dma_transfer(SPI_TypeDef *spi, uint8_t *tx_buf, uint8_t *rx_buf, uint32_t len)
{
// 配置发送DMA
dma_config(DMA1_CH3, (uint32_t)tx_buf, (uint32_t)&spi->DR, len, DMA_DIR_MEM_TO_PERIPH);
// 配置接收DMA
dma_config(DMA1_CH2, (uint32_t)&spi->DR, (uint32_t)rx_buf, len, DMA_DIR_PERIPH_TO_MEM);
// 使能DMA
dma_enable(DMA1_CH2); // 先开接收
dma_enable(DMA1_CH3); // 再开发送
// 等待传输完成(或使用中断回调)
while (!dma_is_complete(DMA1_CH3));
}
2.4 I2C驱动封装:多主机与时钟拉伸处理
I2C比前两个都麻烦。它有地址、有ACK/NACK、有起始条件和停止条件。而且从设备可能拉低时钟线(时钟拉伸),主设备必须能处理这种情况。
我见过不少工程师在I2C驱动里用硬延时——说白了就是死等。这在单主机场景下勉强能用,一旦遇到时钟拉伸,系统性能直接崩掉。
正确的做法是用状态机+超时:
// I2C主机发送状态机
typedef enum {
I2C_STATE_IDLE,
I2C_STATE_START,
I2C_STATE_ADDR,
I2C_STATE_DATA,
I2C_STATE_STOP,
I2C_STATE_ERROR
} i2c_state_t;
int drv_i2c_master_write(I2C_TypeDef *i2c, uint8_t addr, uint8_t *data, uint32_t len)
{
i2c_state_t state = I2C_STATE_START;
uint32_t timeout = 1000;
while (timeout--) {
switch (state) {
case I2C_STATE_START:
i2c->CR1 |= I2C_CR1_START;
state = I2C_STATE_ADDR;
break;
case I2C_STATE_ADDR:
if (i2c->SR1 & I2C_SR1_SB) {
i2c->DR = addr << 1; // 写地址+写标志
state = I2C_STATE_DATA;
}
break;
case I2C_STATE_DATA:
if (i2c->SR1 & I2C_SR1_TXE) {
if (len > 0) {
i2c->DR = *data++;
len--;
} else {
state = I2C_STATE_STOP;
}
}
break;
case I2C_STATE_STOP:
i2c->CR1 |= I2C_CR1_STOP;
return 0;
default:
return -1;
}
}
return -1; // 超时
}
2.5 DMA集成:从单通道到多通道调度
DMA不是万能的。多个外设同时申请DMA通道时,优先级和仲裁策略就变得很关键。
我一般这样分配DMA通道:
| 外设 | DMA通道 | 优先级 | 数据宽度 |
|---|---|---|---|
| UART1 TX | DMA1_CH4 | 高 | 8位 |
| UART1 RX | DMA1_CH5 | 高 | 8位 |
| SPI1 TX | DMA1_CH3 | 中 | 16位 |
| SPI1 RX | DMA1_CH2 | 中 | 16位 |
| I2C1 TX | DMA1_CH6 | 低 | 8位 |
| I2C1 RX | DMA1_CH7 | 低 | 8位 |
UART的优先级最高,因为它的数据是流式的,不能断。SPI次之,I2C最低——I2C本身有协议开销,慢一点没关系。
下面这张图展示了DMA集成的整体流程:
2.6 调试技巧:逻辑分析仪与环形缓冲区快照
通信驱动调试,最怕的就是「数据好像对了,又好像不对」。我总结了几条实用经验:
- 先看波形,再看代码。逻辑分析仪是通信调试的第一工具。把UART的TX/RX、SPI的CLK/MOSI/MISO、I2C的SCL/SDA都抓下来,一眼就能看出问题。
- 环形缓冲区快照。在驱动里加一个调试接口,可以随时dump环形缓冲区的状态(head、tail、已用空间)。我曾经靠这个发现了一个中断丢失的问题——head和tail的差值一直在增长,说明接收中断没及时处理。
- DMA传输完成回调里加断言。检查CNDTR是否为0,检查传输状态寄存器是否有错误标志。这些断言在开发阶段能帮你抓住90%的DMA配置错误。