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;
}
注意:轮询发送时一定要加超时保护。我曾经见过一个产品,因为对端设备掉电,发送线程卡死在while循环里,整个系统看门狗复位。加个超时,几行代码就能避免这种低级故障。

中断方式就好多了。发送时把数据丢进环形缓冲区,让中断慢慢吐。接收也一样,中断把字节塞进缓冲区,主循环来取。

// 环形缓冲区结构体
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集成的核心思路:
  • 发送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));
}
我的经验:DMA传输完成后,一定要检查剩余数据量寄存器(CNDTR)。有一次我发现SPI读出来的数据总是错位,查了半天,原来是DMA配置时长度参数写错了,导致最后一个字节没传完。加个断言检查CNDTR是否为0,这种问题一秒就能定位。

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; // 超时
}
避坑指南:我曾经在一个项目里遇到I2C总线锁死的问题。原因是某个从设备异常,一直拉低SCL线。主设备发完起始条件后,检测到SCL被拉低,就卡在等待标志位里。解决方案是在每个等待循环里加超时,超时后主动产生9个时钟脉冲来复位总线。这个技巧救了我好几次。

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集成的整体流程:

DMA集成架构图 UART外设 TX/RX 中断请求 SPI外设 TX/RX 中断请求 I2C外设 TX/RX 中断请求 DMA控制器 通道优先级仲裁 源地址 / 目的地址 传输长度 (CNDTR) 数据宽度 / 增量模式 传输完成中断 内存 UART TX Buf UART RX Buf SPI TX/RX Buf I2C TX/RX Buf DMA描述符 DMA集成关键点 1. 外设通过硬件请求线触发DMA传输,无需CPU干预 2. DMA控制器根据优先级仲裁多个外设的并发请求 3. 传输完成后产生中断,通知CPU处理数据

2.6 调试技巧:逻辑分析仪与环形缓冲区快照

通信驱动调试,最怕的就是「数据好像对了,又好像不对」。我总结了几条实用经验:

  • 先看波形,再看代码。逻辑分析仪是通信调试的第一工具。把UART的TX/RX、SPI的CLK/MOSI/MISO、I2C的SCL/SDA都抓下来,一眼就能看出问题。
  • 环形缓冲区快照。在驱动里加一个调试接口,可以随时dump环形缓冲区的状态(head、tail、已用空间)。我曾经靠这个发现了一个中断丢失的问题——head和tail的差值一直在增长,说明接收中断没及时处理。
  • DMA传输完成回调里加断言。检查CNDTR是否为0,检查传输状态寄存器是否有错误标志。这些断言在开发阶段能帮你抓住90%的DMA配置错误。
小技巧:在调试UART通信时,我习惯在驱动层加一个「回环测试」模式。把TX和RX短接,发一串数据,看收回来的是不是一模一样。这个测试能快速验证驱动层和DMA配置是否正确,排除硬件连接问题。