4. 串口通信:UART轮询、中断、DMA模式,以及环形缓冲区实现

串口,说白了就是嵌入式世界的“串行电话线”。你想想看,从调试打印到跟传感器、Wi-Fi模块、蓝牙模块打交道,哪个离得开UART?我做了这么多年项目,几乎每个板子至少留一个串口,就是为了万一出问题能扒开看看里面在干什么。

但很多人把串口想简单了——以为就是调个波特率、发个数据完事。其实,UART的三种工作模式(轮询、中断、DMA)选不对,轻则CPU空转浪费,重则丢数据、死机。今天我就把这三种模式掰开揉碎了讲清楚,再送你一个实战级的环形缓冲区实现。

4.1 UART硬件基础回顾

先快速过一下STM32的UART外设结构。每个USART(通用同步异步收发器)内部有独立的发送数据寄存器(TDR)和接收数据寄存器(RDR),它们共享一个移位寄存器。

发送时,你把数据写到TDR,硬件自动移位到TX引脚上。接收时,移位寄存器把RX引脚上的串行数据拼成字节,放到RDR里等你取走。

关键标志位就两个:

  • TXE(发送数据寄存器空):TDR空了,可以写下一个字节
  • RXNE(接收数据寄存器非空):RDR有数据,赶紧读走

嗯,这里要注意:如果你在RXNE置位后没及时读走数据,下一个字节进来就会覆盖——这就是经典的“溢出错误”。我在调试一个GPS模块时就吃过这个亏,后面会细说。

4.2 轮询模式:简单但低效

轮询模式就是CPU死等。发送时循环检查TXE,接收时循环检查RXNE。代码写起来最直接:

// 轮询发送一个字节
void UART_SendByte(USART_TypeDef* USARTx, uint8_t data)
{
    while (!(USARTx->SR & USART_SR_TXE));  // 等TDR空
    USARTx->DR = data;
}

// 轮询接收一个字节
uint8_t UART_ReceiveByte(USART_TypeDef* USARTx)
{
    while (!(USARTx->SR & USART_SR_RXNE)); // 等RDR有数据
    return (uint8_t)(USARTx->DR);
}

这段代码看着简单,但有个致命问题:CPU被完全占住了。如果你用轮询方式打印调试信息,发送一个字节要等几十微秒,发100个字节就是几毫秒——这期间CPU什么都不能干。

避坑指南:我曾经在一个电机控制项目里用轮询串口打印波形数据,结果电机控制周期从1ms抖到了3ms,电机直接啸叫。后来换成DMA才解决。轮询只适合初始化阶段打印一两行信息,或者极低频率的通信。

4.3 中断模式:解放CPU的关键一步

中断模式就好多了。你配置好UART中断,数据来了硬件自动调用中断服务函数(ISR),CPU不用空等。

配置步骤:

  1. 使能UART接收中断(RXNEIE位)
  2. 编写中断服务函数,在函数里读DR
  3. 使能NVIC中的UART中断通道

中断服务函数示例:

void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_RXNE)
    {
        uint8_t data = USART1->DR;
        // 把data存入缓冲区,或者直接处理
        // 注意:ISR里不要做耗时操作!
    }
}

中断模式解决了CPU空等的问题,但带来了新挑战:中断频率。如果波特率是115200,每秒约11520个字节,也就是每87微秒触发一次中断。如果ISR处理时间超过87微秒,就会丢数据。

我的经验:ISR里只做“存数据”这件事,不要做协议解析、不要调用printf、不要做延时。把数据存到缓冲区后,在主循环里慢慢处理。这就是后面要讲的环形缓冲区派上用场的地方。

4.4 DMA模式:零CPU开销的终极方案

DMA(直接存储器访问)模式,说白了就是给CPU请了个“数据搬运工”。你告诉DMA控制器:从UART的DR寄存器搬100个字节到内存数组里,搬完了通知我。然后CPU就可以去干别的事了。

STM32的UART DMA配置要点:

  • 发送DMA:内存→外设,数据方向从内存到UART的DR
  • 接收DMA:外设→内存,数据方向从UART的DR到内存
  • 循环模式:接收DMA可以配置成循环模式,自动从头开始覆盖缓冲区

配置代码片段(以USART1接收DMA为例):

// 假设已经初始化了DMA1通道5(USART1_RX)
#define RX_BUF_SIZE  256
uint8_t rx_buffer[RX_BUF_SIZE];

void UART_DMA_Init(void)
{
    // 配置DMA
    DMA1_Channel5->CPAR = (uint32_t)&USART1->DR;  // 外设地址
    DMA1_Channel5->CMAR = (uint32_t)rx_buffer;    // 内存地址
    DMA1_Channel5->CNDTR = RX_BUF_SIZE;           // 传输数量
    DMA1_Channel5->CCR = DMA_CCR_MINC |           // 内存地址递增
                         DMA_CCR_CIRC |           // 循环模式
                         DMA_CCR_TCIE;            // 传输完成中断
    // 使能DMA
    DMA1_Channel5->CCR |= DMA_CCR_EN;
    
    // 使能USART1的DMA接收
    USART1->CR3 |= USART_CR3_DMAR;
}
核心优势:DMA模式下,CPU完全不用管数据搬运。115200波特率接收1000个字节,CPU开销几乎为0。我在一个4G模块数据采集项目里,用DMA+环形缓冲区实现了每秒50KB的稳定接收,CPU占用率不到5%。

4.5 三种模式对比

特性 轮询模式 中断模式 DMA模式
CPU占用率 高(100%占用) 中(每次中断占用) 低(几乎为0)
实时性 差(阻塞等待) 好(立即响应) 好(硬件自动处理)
代码复杂度 低 中 高
适用场景 初始化打印、低频通信 中低速数据、命令交互 高速数据流、大块数据传输
数据丢失风险 低(但CPU被占) 中(ISR处理不及时) 低(硬件保证)

4.6 环形缓冲区:解决数据粘包和丢失的利器

不管用中断还是DMA,你都会面临一个问题:数据是源源不断进来的,但CPU不可能每收到一个字节就处理一次。你需要一个缓冲区,把数据先存起来,等CPU有空了再处理。

环形缓冲区(Ring Buffer)就是为此而生。它用一块固定大小的内存,通过头指针(读指针)和尾指针(写指针)实现循环存取。

// 环形缓冲区结构体
typedef struct {
    uint8_t buffer[RING_BUF_SIZE];
    volatile uint16_t head;  // 写指针,ISR中修改
    volatile uint16_t tail;  // 读指针,主循环中修改
} RingBuffer_t;

// 写入一个字节(通常在ISR中调用)
uint8_t RingBuf_Write(RingBuffer_t* rb, uint8_t data)
{
    uint16_t next_head = (rb->head + 1) % RING_BUF_SIZE;
    if (next_head == rb->tail) {
        return 0;  // 缓冲区满,丢数据
    }
    rb->buffer[rb->head] = data;
    rb->head = next_head;
    return 1;
}

// 读取一个字节(在主循环中调用)
uint8_t RingBuf_Read(RingBuffer_t* rb, uint8_t* data)
{
    if (rb->head == rb->tail) {
        return 0;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % RING_BUF_SIZE;
    return 1;
}
我曾经踩过的坑:一开始我没加volatile关键字,结果编译器优化后,ISR里修改了head,主循环里读到的还是旧值。数据时丢时不丢,查了两天才找到原因。记住:ISR和主循环共享的变量,一定要加volatile。

4.7 实战组合:DMA + 环形缓冲区

最推荐的方案是:DMA负责数据搬运,环形缓冲区负责数据缓存,主循环负责数据处理。这样CPU几乎零开销,数据也不会丢。

实现思路:

  1. DMA接收配置为循环模式,缓冲区大小设为512字节
  2. DMA传输完成中断里,记录当前DMA的CNDTR值,计算出新数据的位置
  3. 把新数据从DMA缓冲区拷贝到环形缓冲区
  4. 主循环从环形缓冲区读取数据并解析
// DMA传输完成中断
void DMA1_Channel5_IRQHandler(void)
{
    if (DMA1->ISR & DMA_ISR_TCIF5)
    {
        uint16_t current_cnt = DMA1_Channel5->CNDTR;
        // 计算本次接收到的数据量
        uint16_t received = (last_cnt - current_cnt + RX_BUF_SIZE) % RX_BUF_SIZE;
        // 从DMA缓冲区拷贝到环形缓冲区
        for (uint16_t i = 0; i < received; i++)
        {
            uint16_t idx = (last_pos + i) % RX_BUF_SIZE;
            RingBuf_Write(&uart_rb, rx_buffer[idx]);
        }
        last_cnt = current_cnt;
        last_pos = (last_pos + received) % RX_BUF_SIZE;
        
        DMA1->IFCR |= DMA_IFCR_CTCIF5;  // 清除中断标志
    }
}
我的建议:DMA缓冲区大小要大于你一次最大数据包的两倍。比如你的数据包最大256字节,DMA缓冲区至少512字节。这样即使CPU被高优先级任务卡住一段时间,DMA也能继续接收,不会丢数据。

4.8 知识体系总览

下面这张图把UART三种模式的选择逻辑和环形缓冲区的关系梳理清楚了:

UART通信模式选择与数据流架构 应用需求分析 低速 / 调试打印 中速 / 命令交互 高速 / 数据流 轮询模式 中断模式 DMA模式 环形缓冲区(Ring Buffer) 主循环 / 任务处理 核心原则:ISR只存数据,主循环处理数据,DMA负责搬运

4.9 避坑总结

  • 波特率误差:STM32的UART时钟源要算准,误差超过2%就可能丢数据。我习惯用8倍过采样(OVER8=0),精度更高
  • 中断优先级:UART中断优先级不要设太高,否则会阻塞其他关键中断。一般设到中等优先级就够了
  • DMA缓冲区对齐:DMA缓冲区地址要4字节对齐,否则在某些STM32型号上会触发硬件错误
  • 环形缓冲区大小:必须是2的幂次方,这样取模运算可以用位与(&)代替%,效率高很多

好了,UART的三种模式和环形缓冲区就讲到这里。这些内容是我多年实战中反复验证过的,你照着做基本不会出大问题。记住一句话:轮询保底、中断通用、DMA为王,环形缓冲区是最后的保险。