4. UART串口驱动:轮询模式、中断模式、DMA模式实现,环形缓冲区设计与波特率误差分析

UART串口,做嵌入式的人几乎天天打交道。调试打印、与传感器通信、升级固件,都离不开它。但说实话,很多人只是调个库函数就完事了,一旦遇到丢数据、波特率对不上、高并发收发卡死,就抓瞎了。

这一章,我带你把这层窗户纸捅破。三种模式怎么选?环形缓冲区怎么设计才不踩坑?波特率误差到底多少算安全?咱们一个一个来。

4.1 三种驱动模式:轮询、中断、DMA

这三种模式,说白了就是CPU参与收发的程度不同。我画了张图,你看一眼就明白了。

UART三种驱动模式对比 轮询模式 CPU不断查询标志位 占用100% CPU 代码最简单 中断模式 硬件触发中断 CPU只在收发时介入 适合中低速场景 DMA模式 硬件直接搬运数据 CPU零负担 适合高速大数据量 CPU占用率对比 100% ~20% <1% 适用场景 调试打印、按键扫描 低速率、非实时 传感器数据采集 命令响应、人机交互 文件传输、固件升级 高速数据流、音频

4.2 轮询模式实现

轮询模式最简单,但代价也最大。CPU一直在那死等,啥也干不了。我一般只在系统刚启动、RTOS还没跑起来的时候用一下。

// 轮询发送一个字节
void uart_poll_send(uint8_t data) {
    // 等待发送缓冲区空
    while (!(UART->SR & UART_SR_TXE));
    UART->DR = data;
}

// 轮询接收一个字节
uint8_t uart_poll_recv(void) {
    // 等待接收到数据
    while (!(UART->SR & UART_SR_RXNE));
    return (uint8_t)(UART->DR & 0xFF);
}
注意:轮询接收时,如果对方没发数据,程序就卡死在while循环里了。这在产品代码里是绝对不能出现的。我曾经见过一个同事,调试时用轮询收数据,结果传感器没接,整个系统就挂在那了,看门狗都救不回来。

4.3 中断模式实现

中断模式是实际项目中最常用的。CPU不用死等,来了数据才处理。但这里有个关键点:中断服务函数里千万别干重活。

// 中断接收服务函数
void UART_IRQHandler(void) {
    uint8_t data;
    
    if (UART->SR & UART_SR_RXNE) {
        data = (uint8_t)(UART->DR & 0xFF);
        // 把数据扔进环形缓冲区,立刻退出中断
        ringbuf_push(&rx_ring, data);
    }
    
    // 发送中断同理
    if (UART->SR & UART_SR_TXE) {
        if (ringbuf_pop(&tx_ring, &data)) {
            UART->DR = data;
        } else {
            // 没数据要发了,关掉发送中断
            UART->CR1 &= ~UART_CR1_TXEIE;
        }
    }
}
我的习惯:中断里只做两件事——从硬件拿数据塞进缓冲区,或者从缓冲区拿数据写给硬件。解析协议、处理逻辑这些,统统放到任务线程里去做。这样中断响应快,系统也稳定。

4.4 DMA模式实现

DMA模式是性能王者。CPU只需要配置好DMA通道,剩下的搬运工作硬件全包了。我做过一个项目,用115200波特率连续收发大数据包,中断模式CPU占用率大概15%,换成DMA后直接降到0.5%以下。

// DMA发送配置示例
void uart_dma_send(uint8_t *buf, uint32_t len) {
    // 等待上一次DMA传输完成
    while (DMA->ISR & DMA_ISR_TCIF1);
    
    // 配置DMA源地址、目的地址、长度
    DMA->CPAR1 = (uint32_t)&UART->DR;   // 外设地址
    DMA->CMAR1 = (uint32_t)buf;          // 内存地址
    DMA->CNDTR1 = len;                   // 传输长度
    
    // 使能DMA通道
    DMA->CCR1 |= DMA_CCR1_EN;
    
    // 使能UART的DMA发送请求
    UART->CR3 |= UART_CR3_DMAT;
}
关键点:DMA传输完成后,一定要记得关掉DMA使能位,否则下次配置时会出问题。另外,DMA缓冲区建议用全局数组或者动态分配的内存,别用局部变量——函数退出后栈空间就释放了,DMA还在那读,读出来的全是垃圾。

4.5 环形缓冲区设计

环形缓冲区,说白了就是一个固定大小的数组,加上读写指针。写指针追着读指针转圈圈。设计得好,中断和任务之间可以无缝配合。

typedef struct {
    uint8_t *buffer;    // 缓冲区基地址
    uint32_t size;      // 缓冲区大小(必须是2的幂)
    volatile uint32_t head;  // 写指针(生产者)
    volatile uint32_t tail;  // 读指针(消费者)
} ringbuf_t;

// 初始化
void ringbuf_init(ringbuf_t *rb, uint8_t *buf, uint32_t size) {
    rb->buffer = buf;
    rb->size = size;
    rb->head = 0;
    rb->tail = 0;
}

// 写入一个字节
int ringbuf_push(ringbuf_t *rb, uint8_t data) {
    uint32_t next = (rb->head + 1) & (rb->size - 1);
    if (next == rb->tail) {
        return -1;  // 缓冲区满了
    }
    rb->buffer[rb->head] = data;
    rb->head = next;
    return 0;
}

// 读取一个字节
int ringbuf_pop(ringbuf_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空了
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) & (rb->size - 1);
    return 0;
}
避坑指南:我曾经在一个项目里,环形缓冲区大小设成了100,结果发现取模运算特别慢。后来改成128(2的幂),用位与运算代替取模,速度提升了好几倍。记住:n % size 换成 n & (size - 1),前提是size必须是2的幂。

4.6 波特率误差分析

波特率误差,是串口通信里最容易忽略但又最致命的问题。两个设备波特率对不上,数据就全乱了。

波特率误差的来源主要有三个:

  • 时钟源精度:内部RC振荡器精度一般在±1%~±3%,外部晶振可以做到±50ppm甚至更高
  • 分频器量化误差:分频系数必须是整数,导致实际波特率与目标值有偏差
  • 温度漂移:温度变化会影响时钟频率

我整理了一个常见波特率的误差表,你看看就明白了:

目标波特率 时钟频率 分频系数 实际波特率 误差 是否可用
9600 16MHz 104.17 9615 +0.16% ✅ 安全
115200 16MHz 8.68 111111 -3.55% ❌ 危险
115200 72MHz 39.06 115385 +0.16% ✅ 安全
921600 72MHz 4.88 922000 +0.04% ✅ 安全
经验值:串口通信的波特率误差,建议控制在±2%以内。超过这个范围,尤其是在高速率下,误码率会急剧上升。我个人的安全线是±1.5%,留点余量给温度漂移。

为什么会这样?因为串口是在每个bit的中间采样,误差累积到最后一个bit时,如果偏差超过半个bit周期,采样点就跑到下一个bit上去了。你想想看,8个数据位加1个停止位,总共9个bit,误差累积9倍,能不乱吗?

// 波特率计算示例(以STM32为例)
// 目标:115200bps,时钟72MHz
// 公式:baud = fck / (16 * USARTDIV)
// USARTDIV = 72000000 / (16 * 115200) = 39.0625

// 整数部分:39
// 小数部分:0.0625 * 16 = 1(4位小数精度)
// 所以 USARTDIV = 0x271

// 实际波特率 = 72000000 / (16 * 39.0625) = 115200
// 误差 = 0%  (完美匹配!)
选时钟的小技巧:如果你发现某个波特率误差太大,可以试试微调系统时钟。比如16MHz下115200误差很大,但18.432MHz就能完美匹配。很多串口芯片用18.432MHz晶振,就是这个道理。

4.7 三种模式的选择策略

说了这么多,到底怎么选?我一般按这个原则来:

  • 轮询模式:只在系统初始化阶段、或者调试时临时用一下。产品代码里尽量别用。
  • 中断模式:最通用的方案。波特率不超过460800、数据量不大的场景,首选中断。
  • DMA模式:高速率(921600以上)、大数据量、或者CPU负载已经很紧张的项目,上DMA。

嗯,这一章的内容就到这。环形缓冲区的代码你可以直接拿去用,波特率误差表也建议收藏一下。下次调串口遇到问题,先查查是不是波特率对不上——我敢说,一半的串口问题都是这个原因。