第三章 UART驱动深度调优:从能用到好用

UART 这个外设,看起来简单吧?

我见过太多工程师,调通收发就以为完事了。结果产品一量产,高温下丢数据、长距离通信乱码、高负载下 CPU 被中断淹死……问题全冒出来。

这一章,咱们就把 UART 驱动从「能用」做到「好用」。核心就四件事:波特率算准、ISR 减负、不定长接收用 DMA、环形缓冲区兜底。


3.1 波特率误差计算与修正

波特率不准,通信就不可靠。这不是玄学,是数学。

3.1.1 误差来源

STM32 的 USART 时钟来自 APB 总线(通常是 8MHz、16MHz、36MHz、45MHz 或 84MHz)。波特率发生器是一个 16 位分频器,公式如下:

USARTDIV = fCK / (16 × BaudRate)

分频结果取整后,实际波特率就和目标值有了偏差。我见过一个项目,用 72MHz 主频跑 115200bps,算出来 USARTDIV = 39.0625,取整后误差 0.16%,看起来不大。但换成 250000bps 试试?

USARTDIV = 72000000 / (16 × 250000) = 18.0

刚好整除,误差 0%。但如果你用 8MHz 时钟跑 115200:

USARTDIV = 8000000 / (16 × 115200) ≈ 4.3403

取整后误差约 0.2%,还在 ±2% 的容忍范围内。但要是再高一点,比如 921600,误差就奔着 3% 去了。

3.1.2 误差容忍度

不同帧格式的容忍度不一样。我整理了一张表,你直接对照:

帧格式 理论最大误差 工程建议
8N1(8位数据,无校验,1停止位) ±4.5% ≤ ±2%
8N2(8位数据,无校验,2停止位) ±5.0% ≤ ±2%
8E1/8O1(带校验) ±3.5% ≤ ±1.5%
9位数据模式 ±3.0% ≤ ±1.5%
我曾经踩过的坑: 用内部 HSI 振荡器跑 UART,温度一上来频率漂了 2%,加上分频误差,直接超了 4%。从那以后,只要用 UART,我一定用外部晶振或者校准过的 HSI。

3.1.3 修正方法

如果误差超标,你有三条路:

  1. 换时钟源:选一个能整除目标波特率的 PLL 输出频率。
  2. 用 USART 的 OVER8 模式:把采样率从 16 倍降到 8 倍,分频步进更细。
  3. 手动微调分频值:用 USART_BRR 的 DIV_Mantissa 和 DIV_Fraction 做精细调整。

我个人习惯用 OVER8 模式。它把分频公式变成:

USARTDIV = fCK / (8 × BaudRate)

分频步进从 1/16 变成 1/8,精度翻倍。代价是抗干扰能力稍微下降,但大多数场景下没问题。


3.2 中断服务程序(ISR)瘦身

UART 中断服务程序,是 CPU 性能的隐形杀手。

你想想看,115200bps 每秒产生约 11520 个中断(8N1 格式)。每个 ISR 如果跑 10μs,CPU 就被吃掉 11.5% 的算力。要是 921600bps 呢?直接 92%!

3.2.1 ISR 里不该做的事

  • 不要在 ISR 里做协议解析:Modbus、JSON、自定义协议,统统放到任务级处理。
  • 不要在 ISR 里调用 printf:printf 内部有锁、有缓冲、有系统调用,一个 printf 能吃掉几百微秒。
  • 不要在 ISR 里做动态内存分配:malloc 不可重入,而且耗时不确定。
  • 不要在 ISR 里操作临界区过久:关中断时间太长,会影响其他中断响应。

3.2.2 ISR 应该做的事

ISR 的黄金法则是:能少做就少做,能不做就不做。

一个合格的 UART ISR 只做三件事:

  1. 读取数据寄存器(DR)
  2. 把数据扔进环形缓冲区
  3. 清除中断标志位

看一个反面教材:

// ❌ 反面教材:ISR 里做太多事
void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1, USART_IT_RXNE)) {
        uint8_t data = USART_ReceiveData(USART1);
        // 在 ISR 里解析协议
        if (data == 0xAA) {
            // 开始解析一帧数据...
            // 还调用了 printf 打印调试信息
            printf("RX: 0x%02X\n", data);
        }
        // 还做了 CRC 校验
        crc = crc16_update(crc, data);
    }
}

再看一个正确的做法:

// ✅ 正确做法:ISR 只做数据搬运
void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1, USART_IT_RXNE)) {
        uint8_t data = USART_ReceiveData(USART1);
        ringbuf_put(&uart1_rx_buf, data);
        // 清除标志位由硬件自动完成(读 DR 即清除)
    }
}
我的经验: 把 ISR 的执行时间控制在 1μs 以内。用 GPIO 翻转 + 示波器量一下,如果 ISR 执行时间超过 5μs,就该瘦身了。

3.3 IDLE 中断 + DMA 实现不定长接收

传统 UART 接收,要么用中断一个字节一个字节收,要么用 DMA 收固定长度。但实际场景中,数据帧长度往往是不固定的。

IDLE 中断就是来解决这个问题的。总线空闲时,硬件会置位 IDLE 标志位。配合 DMA,就能实现「来多少收多少」。

3.3.1 工作原理

  1. 配置 DMA 为循环模式,接收缓冲区足够大。
  2. 使能 USART 的 IDLE 中断。
  3. 每次收到数据,DMA 自动搬运到缓冲区。
  4. 总线空闲时,触发 IDLE 中断。
  5. 在 IDLE 中断里,计算本次接收的数据长度:len = DMA_CNDTR - 当前剩余计数。

流程图如下:

IDLE 中断 + DMA 不定长接收流程 初始化 DMA + USART 等待数据接收 DMA 自动搬运数据 总线空闲? 否 是 触发 IDLE 中断 关键点 • DMA 循环模式 • 使能 IDLE 中断 • 计算剩余计数

3.3.2 代码实现要点

// IDLE 中断服务程序
void USART1_IRQHandler(void) {
    if (USART_GetITStatus(USART1, USART_IT_IDLE)) {
        // 读 SR 再读 DR 清除 IDLE 标志
        (void)USART_GetFlagStatus(USART1, USART_FLAG_IDLE);
        (void)USART_ReceiveData(USART1);

        // 计算接收长度
        uint16_t remaining = DMA_GetCurrDataCounter(DMA1_Channel5);
        uint16_t received = RX_BUF_SIZE - remaining;

        // 通知应用层处理
        uart_frame_ready(received);

        // 重新配置 DMA 计数器
        DMA_SetCurrDataCounter(DMA1_Channel5, RX_BUF_SIZE);
    }
}
注意: IDLE 标志位清除需要先读 SR 再读 DR,顺序不能错。我见过有人只读 SR 不清除,结果 IDLE 中断一直触发,CPU 直接卡死。

3.4 环形缓冲区设计

环形缓冲区是 ISR 和任务之间的桥梁。它解决了一个核心矛盾:中断要快,任务要稳。

3.4.1 为什么不用数组 + 指针?

简单数组的问题是:数据满了怎么办?覆盖旧数据还是丢弃新数据?用环形缓冲区,头尾指针循环移动,空间利用率 100%。

3.4.2 核心数据结构

typedef struct {
    uint8_t *buffer;    // 缓冲区指针
    uint32_t size;      // 缓冲区大小(必须是 2 的幂)
    volatile uint32_t head;  // 写指针(ISR 中更新)
    volatile uint32_t tail;  // 读指针(任务中更新)
} ringbuf_t;

为什么 size 必须是 2 的幂?因为可以用位运算代替取模:

// ❌ 取模运算(慢)
head = (head + 1) % size;

// ✅ 位运算(快)
head = (head + 1) & (size - 1);

3.4.3 关键操作

操作 实现 说明
写入 buf->buffer[buf->head] = data; buf->head = (buf->head + 1) & (buf->size - 1); ISR 中调用,需检查是否满
读取 data = buf->buffer[buf->tail]; buf->tail = (buf->tail + 1) & (buf->size - 1); 任务中调用,需检查是否空
判满 ((buf->head + 1) & (buf->size - 1)) == buf->tail 留一个空位区分满和空
判空 buf->head == buf->tail 头尾相等即为空

3.4.4 多生产者/单消费者场景

UART 接收是典型的多生产者(多个中断源)单消费者(一个任务)。这种情况下,写操作必须保证原子性。

我的做法是:在写入前关中断,写入后开中断。因为 head 指针是 volatile 的,编译器不会优化掉。

// 原子写入
uint32_t save = __get_PRIMASK();
__disable_irq();
buf->buffer[buf->head] = data;
buf->head = (buf->head + 1) & (buf->size - 1);
__set_PRIMASK(save);
性能小技巧: 如果缓冲区大小是 2 的幂,位运算比取模快 10 倍以上。我习惯用 256、512、1024 这些值,既省内存又高效。

3.5 综合实战:一个高性能 UART 驱动框架

把上面四部分串起来,就是一个完整的高性能 UART 驱动:

  1. 初始化阶段:计算波特率误差,选择最优分频值;配置 DMA 循环模式;初始化环形缓冲区。
  2. 运行阶段:DMA 自动搬运数据到缓冲区;IDLE 中断触发时计算接收长度;ISR 只做数据搬运。
  3. 处理阶段:任务从环形缓冲区读取数据,做协议解析、业务处理。

这个框架我在三个量产项目上验证过:一个跑 921600bps 的工业相机,一个跑 2Mbps 的激光雷达,还有一个跑 115200bps 的 32 节点组网系统。全部稳定运行,CPU 占用率不到 5%。

最后提醒一句: 别迷信「调通就行」。UART 驱动是嵌入式系统的毛细血管,任何一个环节出问题,整个系统都会跟着遭殃。把波特率算准、ISR 做瘦、DMA 用好、缓冲区设计合理——这四件事做好了,你的 UART 驱动才算真正「毕业」。