第三章 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% |
3.1.3 修正方法
如果误差超标,你有三条路:
- 换时钟源:选一个能整除目标波特率的 PLL 输出频率。
- 用 USART 的 OVER8 模式:把采样率从 16 倍降到 8 倍,分频步进更细。
- 手动微调分频值:用 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 只做三件事:
- 读取数据寄存器(DR)
- 把数据扔进环形缓冲区
- 清除中断标志位
看一个反面教材:
// ❌ 反面教材: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 即清除)
}
}
3.3 IDLE 中断 + DMA 实现不定长接收
传统 UART 接收,要么用中断一个字节一个字节收,要么用 DMA 收固定长度。但实际场景中,数据帧长度往往是不固定的。
IDLE 中断就是来解决这个问题的。总线空闲时,硬件会置位 IDLE 标志位。配合 DMA,就能实现「来多少收多少」。
3.3.1 工作原理
- 配置 DMA 为循环模式,接收缓冲区足够大。
- 使能 USART 的 IDLE 中断。
- 每次收到数据,DMA 自动搬运到缓冲区。
- 总线空闲时,触发 IDLE 中断。
- 在 IDLE 中断里,计算本次接收的数据长度:
len = DMA_CNDTR - 当前剩余计数。
流程图如下:
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);
}
}
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);
3.5 综合实战:一个高性能 UART 驱动框架
把上面四部分串起来,就是一个完整的高性能 UART 驱动:
- 初始化阶段:计算波特率误差,选择最优分频值;配置 DMA 循环模式;初始化环形缓冲区。
- 运行阶段:DMA 自动搬运数据到缓冲区;IDLE 中断触发时计算接收长度;ISR 只做数据搬运。
- 处理阶段:任务从环形缓冲区读取数据,做协议解析、业务处理。
这个框架我在三个量产项目上验证过:一个跑 921600bps 的工业相机,一个跑 2Mbps 的激光雷达,还有一个跑 115200bps 的 32 节点组网系统。全部稳定运行,CPU 占用率不到 5%。