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里没搬到内存,你一读就是空。
4.3 波特率配置的陷阱
波特率配置,看起来就是往寄存器里写个分频系数。但这里有个大坑——时钟源选择。
很多SoC的UART时钟源可以来自PLL、外部晶振或者内部RC振荡器。如果你用的内部RC,精度通常在±1%到±3%之间。115200波特率下,1%的误差还能忍,3%的误差基本就乱码了。
我踩过最惨的一次:某平台UART1用PLL0时钟,UART2用PLL1时钟。两个PLL频率不同,分频系数算出来都是整数,但实际波特率差了2.3%。两边对发数据,10个字节里错3个。查了两天才发现是时钟源没统一。
计算公式:波特率 = 时钟频率 / (16 × 分频系数)。注意,分频系数必须是整数,否则误差会累积。我的建议:先确认时钟源频率,再反算分频系数,最后用示波器实测TX脚波形。别信寄存器里的理论值。
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。