4、UART串口驱动移植:串口协议回顾、FIFO与DMA模式、波特率配置的陷阱、环形缓冲区的实现、我曾经踩过的串口丢数据坑

串口驱动移植,几乎是每个BSP工程师的入门课,也是翻车最多的地方。你想想看,一个从8051时代就存在的接口,到了Cortex-A上照样能让你调一整天。为什么?因为协议简单,但工程实现里的坑一点都不少。

这一节,咱们不聊那些教科书上的电平标准。直接切入移植时真正要命的几个点:FIFO怎么用、DMA要不要开、波特率为什么对不上、环形缓冲区怎么写才不丢数据。最后,我会分享几个我亲手踩过的坑——嗯,都是真金白银换来的教训。

核心观点:串口移植的难点不在协议本身,而在中断处理、数据流控和时钟精度这三个维度。任何一个环节出问题,都会表现为“丢数据”或“乱码”。

4.1 串口协议快速回顾

咱们快速过一遍。UART是异步串行通信,一条TX、一条RX,外加地线就能跑。数据帧格式:起始位(1bit) + 数据位(5~8bit) + 校验位(可选) + 停止位(1/1.5/2bit)。

移植时最常配的参数就三个:波特率、数据位、停止位。但有个细节很多人忽略——空闲电平。总线空闲时,TX/RX都保持高电平。如果硬件设计上拉电阻没加或者加错了,空闲电平不稳,第一帧数据大概率会丢。

参数 常见值 移植注意事项
波特率 9600 / 115200 / 921600 时钟源分频精度必须<2%误差
数据位 8 7位数据位+校验位时,注意校验位是否计入
停止位 1 高波特率下建议用1.5或2个停止位
流控 无 / RTS/CTS 硬件流控需要GPIO复用配置正确

4.2 FIFO与DMA模式:什么时候用,什么时候别用

现在的MCU和MPU,串口基本都带硬件FIFO。深度从16字节到64字节不等。FIFO的作用说白了就是给CPU减负——数据先攒在FIFO里,满了再触发中断。

但FIFO不是万能的。我见过有人把FIFO触发阈值设成1,结果中断频率比不用FIFO还高,CPU全花在进出栈上了。我个人习惯:低波特率(≤115200)用FIFO,触发阈值设成FIFO深度的一半。高波特率(≥921600)直接上DMA。

DMA模式呢?适合大数据量、连续收发。但有个前提——你的DMA描述符链要提前准备好,而且DMA中断和串口中断要协同好。否则DMA传输完成中断来了,数据还在FIFO里没搬到内存,你一读就是空。

经验之谈:DMA+串口的组合,建议开启FIFO的DMA模式(很多芯片有独立的DMA使能位),同时把FIFO阈值设成FIFO深度减1。这样DMA请求不会太频繁,也不会溢出。

4.3 波特率配置的陷阱

波特率配置,看起来就是往寄存器里写个分频系数。但这里有个大坑——时钟源选择。

很多SoC的UART时钟源可以来自PLL、外部晶振或者内部RC振荡器。如果你用的内部RC,精度通常在±1%到±3%之间。115200波特率下,1%的误差还能忍,3%的误差基本就乱码了。

我踩过最惨的一次:某平台UART1用PLL0时钟,UART2用PLL1时钟。两个PLL频率不同,分频系数算出来都是整数,但实际波特率差了2.3%。两边对发数据,10个字节里错3个。查了两天才发现是时钟源没统一。

计算公式:波特率 = 时钟频率 / (16 × 分频系数)。注意,分频系数必须是整数,否则误差会累积。我的建议:先确认时钟源频率,再反算分频系数,最后用示波器实测TX脚波形。别信寄存器里的理论值。

警告:有些芯片的UART支持小数分频(比如STM32的USART)。小数分频能提高精度,但需要额外配置FRActional寄存器。如果没配,默认就是整数分频,误差可能超标。

4.4 环形缓冲区的实现

串口驱动里,环形缓冲区是标配。为什么?因为中断服务程序要快进快出,不能在里面做复杂处理。数据先扔进环形缓冲区,主循环或者任务里再慢慢解析。

一个标准的环形缓冲区,核心就四个要素:缓冲区指针、读指针、写指针、缓冲区大小。我习惯用“写指针追赶读指针”的方式判断满和空:

  • 空:读指针 == 写指针
  • 满:(写指针 + 1) % 缓冲区大小 == 读指针

注意,这里故意浪费了一个元素来区分满和空。否则满和空都是读指针等于写指针,你分不清。

// 环形缓冲区结构体
typedef struct {
    uint8_t *buffer;
    volatile uint32_t head;  // 写指针
    volatile uint32_t tail;  // 读指针
    uint32_t size;
} ring_buf_t;

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

// 读取一个字节
int ring_buf_get(ring_buf_t *rb, uint8_t *data) {
    if (rb->head == rb->tail) {
        return -1;  // 缓冲区空
    }
    *data = rb->buffer[rb->tail];
    rb->tail = (rb->tail + 1) % rb->size;
    return 0;
}

这段代码看起来简单,但有个关键点——volatile关键字。head和tail会被中断和主循环同时访问,不加volatile,编译器优化后可能读到缓存值,导致逻辑错乱。

4.5 我曾经踩过的串口丢数据坑

这部分我多说几句,都是真金白银换来的教训。

坑一:中断优先级没配好。 某次项目,串口中断优先级设得比定时器低。定时器中断频率一高,串口中断被长时间屏蔽,FIFO溢出。表现就是:偶尔丢一个字节,抓都抓不到。解决方案:串口中断优先级设成最高(除了NMI)。

坑二:DMA传输完成中断和串口空闲中断打架。 我做过一个项目,用DMA接收不定长数据。DMA传输完成中断里处理数据,串口空闲中断里也处理数据。两个中断同时触发,数据被处理了两次,环形缓冲区乱掉。后来改成:只用空闲中断判断帧结束,DMA中断只做计数。

坑三:波特率误差累积。 前面提过,时钟源没统一导致2.3%误差。但更隐蔽的是——收发双方的误差方向要一致。如果发送方偏快、接收方偏慢,累积到第N个字节就会采样错位。我后来养成的习惯:移植完先发一长串0x55(交替位),用示波器看每个位的宽度。

坑四:环形缓冲区大小没对齐。 缓冲区大小设成2的幂,可以用位运算代替取模,效率高很多。但有人设成100、200这种非2的幂,取模操作在中断里多花几十个周期,高波特率下就丢数据。我的建议:缓冲区大小固定为256、512、1024,用&(size-1)代替%size。

总结一下:串口驱动移植,FIFO和DMA是性能手段,波特率是基础精度,环形缓冲区是数据安全。这三样都对了,丢数据的概率就降到最低。但别忘了——示波器才是最终裁判。
UART串口驱动移植核心逻辑 配置层 波特率计算 | 数据位/停止位/校验位 | 时钟源选择 传输模式层 轮询模式 | 中断模式(FIFO) | DMA模式 数据管理层 环形缓冲区 | 中断服务程序 | 数据解析 常见陷阱与避坑 中断优先级 | DMA与空闲中断冲突 | 波特率误差 | 缓冲区大小对齐 先确认时钟源 再算分频系数 低波特率用FIFO 高波特率用DMA volatile不能少 大小用2的幂