1. 从一次串口丢包排查说起:为什么UART值得单独开一期
做嵌入式的人,几乎都绕不开UART。它可能是你点亮第一块板子时用的调试口,也可能是量产设备上跑Modbus、对接传感器、连WiFi模组的通信命脉。但恰恰因为它太基础、太常见,很多人对它的理解停留在“配个波特率、写个发送函数”的层面,直到某天现场设备开始随机丢包、偶发死机、数据错位,才发现这个看似简单的外设里藏着一堆坑。
我印象很深的一次经历,是帮一个做工业采集的朋友排查问题。他们的设备用UART接了一路称重传感器,波特率115200,平时跑得好好的,但一到现场就出现“每隔几小时丢一帧”的现象。代码翻来覆去看不出毛病,最后用逻辑分析仪抓波形才发现,是接收中断里做了一次耗时较长的浮点运算,导致下一个字节到来时硬件接收寄存器还没被读走,溢出标志置位后数据直接丢了。这个问题在实验室里几乎复现不出来,因为实验室的数据量小、干扰少,而现场是7×24小时连续跑。
这就是UART的典型特征:协议简单,但工程细节极多。它不像SPI、I2C那样有明确的时钟线来同步,而是靠双方约定好的波特率各自计时,一旦时钟偏差、中断延迟、缓冲区管理任何一环出问题,都会以“丢包”“乱码”“卡死”的形式暴露出来。而且这些问题往往不是必现的,排查起来非常折磨人。
这一期我就围绕UART,把从硬件电气特性、寄存器配置、中断与DMA收发、环形缓冲区设计,到实际项目中常见的丢包、乱码、死锁问题,系统地聊一遍。内容会偏向“工程实战”而不是“教科书复述”,适合已经能跑通串口收发、但想进一步把驱动做稳的嵌入式开发者。如果你还在纠结“为什么我的串口打印出来是乱码”,那这篇也能帮你把基础打牢。
2. UART的电气层与帧结构:先把物理世界的事搞清楚
2.1 异步串行的本质:没有时钟线,靠什么对齐
UART全称是通用异步收发器,关键词是“异步”。SPI和I2C都有独立的时钟线,主机发一个时钟沿,从机就采一个bit,双方天然同步。UART没有这根线,发送方和接收方各自用自己的时钟来计时,靠“约定的波特率”来对齐每一个bit。
这就带来一个核心问题:双方时钟必须足够接近。假设发送方波特率是115200,接收方实际是115200的±2%,那么在一个字节(10个bit,含起始位和停止位)的传输过程中,采样点会逐渐偏移。如果偏移累积到半个bit周期,采样就会出错。工程上一般要求双方波特率误差控制在2%以内,超过这个值,长帧传输就容易出错。
实际项目中,晶振精度直接决定波特率误差。用内部RC振荡器做时钟源的MCU,波特率误差可能到3%甚至更高,短帧还能凑合,长帧就危险了。所以只要条件允许,串口通信尽量用外部晶振,这是很多老工程师不会明说但默认遵守的规则。
2.2 帧结构拆解:起始位、数据位、校验位、停止位
一个标准UART帧由以下几部分组成:
| 组成部分 | 典型位数 | 作用 |
|---|---|---|
| 起始位 | 1 bit | 拉低电平,告诉接收方“数据来了” |
| 数据位 | 5~9 bit | 实际传输的数据,常用8位 |
| 校验位 | 0或1 bit | 奇偶校验,用于简单检错 |
| 停止位 | 1或2 bit | 拉高电平,表示帧结束 |
起始位的检测是UART接收的关键。接收方平时检测到高电平(空闲态),一旦发现电平被拉低,就认为检测到起始位,然后按照波特率在每bit的中间位置采样。这里有个细节:起始位检测通常有噪声过滤机制,比如连续采样三次,两次以上为低才确认是起始位,这样可以滤掉毛刺。
校验位在实际项目中用得不多,因为它的检错能力很弱——只能检测奇数个bit错误,偶数个bit同时翻转就检测不出来。如果通信环境干扰大,更可靠的做法是在应用层加CRC校验,而不是依赖UART自带的奇偶校验。
停止位设置成1位还是2位,取决于对端设备。有些老设备或特定协议要求2位停止位,如果配错了,接收方会在停止位期间检测到异常,可能触发帧错误。这个参数在对接第三方设备时一定要查手册确认。
2.3 电平标准:TTL、RS232、RS485不是一回事
很多人把“串口”和“RS232”混为一谈,其实它们是不同层面的概念。UART是协议层,TTL、RS232、RS485是电气层。
TTL电平就是MCU引脚直接输出的0V/3.3V或0V/5V,适合板内短距离通信。RS232用负逻辑,逻辑1是-3V到-15V,逻辑0是+3V到+15V,抗干扰能力强一些,传输距离能到十几米。RS485用差分信号,A、B两根线之间的电压差表示逻辑,抗共模干扰能力最强,适合工业现场几百米甚至上千米的传输。
实际项目中,MCU的UART引脚出来是TTL电平,要接RS232设备就得加电平转换芯片,要接RS485就得加收发器芯片。千万不要把TTL引脚直接接到RS232设备上,电压不匹配轻则通信失败,重则烧引脚。这个坑我见过不止一个新手踩过。
3. 寄存器级配置:波特率到底是怎么算出来的
3.1 波特率发生器的分频逻辑
UART的波特率不是凭空产生的,它来自系统时钟经过分频得到。以常见的STM32为例,波特率计算公式是:
波特率 = fCK / (16 * USARTDIV)其中fCK是给UART外设的时钟,USARTDIV是一个分频系数,包含整数部分和小数部分。比如fCK是72MHz,要得到115200的波特率:
USARTDIV = 72000000 / (16 * 115200) = 39.0625整数部分是39,小数部分是0.0625。小数部分再乘以16得到1,所以寄存器里写的是39和1。实际波特率就是72000000 / (16 * 39.0625) = 115200,误差为0。
但如果你用的时钟不是72MHz,比如用内部RC振荡器跑在8MHz,算出来的分频系数可能不是整数,实际波特率就会有偏差。这时候要反过来算误差:
误差 = |实际波特率 - 目标波特率| / 目标波特率一般要求误差小于2%,最好小于1%。如果算出来误差太大,要么换时钟源,要么换波特率。
3.2 过采样模式:16倍和8倍的区别
很多MCU的UART支持16倍过采样和8倍过采样。16倍过采样就是每个bit周期采样16次,取中间几次做多数表决,抗噪声能力更强。8倍过采样速度快一倍,适合高波特率场景,但抗干扰能力弱一些。
选择哪种模式,取决于波特率和时钟频率的匹配关系。如果时钟频率不够高,跑高波特率时16倍过采样可能分频系数太小甚至小于1,这时候就得切到8倍过采样。这个细节在配置高波特率(比如1M以上)时经常遇到,值得注意。
3.3 配置顺序:为什么先配波特率再使能
UART的配置有个顺序问题。一般建议:
- 先使能UART时钟
- 配置引脚复用功能
- 设置波特率、数据位、停止位、校验位
- 配置中断或DMA
- 最后使能UART
为什么最后才使能?因为如果在配置过程中UART已经使能,可能会因为参数不完整而进入异常状态,或者误触发中断。虽然大多数情况下不会出问题,但养成“配置完再使能”的习惯,能避免很多莫名其妙的初始化故障。
另外,修改波特率时也要先关闭UART,改完再重新使能。有些MCU允许运行时改波特率,但需要等待当前传输完成,否则会破坏正在进行的帧。
4. 中断、DMA与环形缓冲区:收发架构怎么搭才稳
4.1 中断收发:简单但容易埋雷
最基础的收发方式是中断。发送时把数据写入发送寄存器,等发送完成中断来了再写下一个字节。接收时每来一个字节触发一次接收中断,在中断里读走数据。
这种方式实现简单,但问题也很明显:
- 中断频率高:波特率115200时,每个字节约87微秒,如果连续收发,中断会非常频繁,CPU开销大。
- 中断里不能做耗时操作:前面提到的丢包案例,就是在接收中断里做了浮点运算,导致下一个字节到来时没及时读走。
- 容易丢数据:如果中断被更高优先级中断打断,或者中断服务程序执行时间过长,硬件接收寄存器就会溢出。
所以中断收发适合低速、数据量小的场景。一旦数据量大或者波特率高,就必须考虑DMA。
4.2 DMA收发:把CPU解放出来
DMA的思路是让数据在UART和外设之间直接搬运,不需要CPU逐个字节干预。发送时,CPU把数据放进缓冲区,配置DMA通道,DMA自动把数据逐个送到UART发送寄存器。接收时,DMA自动把接收寄存器的数据搬到内存缓冲区,攒够一定数量再通知CPU。
DMA收发的优势很明显:
- CPU占用率极低,可以去做其他事
- 不容易丢数据,因为DMA搬运速度远快于UART波特率
- 适合高速、大数据量场景
但DMA也有自己的坑。比如接收方向,DMA通常需要知道要接收多少字节才触发中断。如果对端发来的数据长度不固定,就不好处理。常见的解决方案是结合“空闲中断”:DMA一直往缓冲区搬数据,当总线空闲(超过一个字节时间没有新数据)时触发空闲中断,在中断里计算收到了多少字节。这个方案在STM32上很常用,配合DMA可以实现不定长数据的稳定接收。
4.3 环形缓冲区:解决生产者和消费者速度不匹配
不管用中断还是DMA,接收到的数据都需要一个缓冲区来暂存,等待应用层处理。如果应用层处理速度跟不上接收速度,数据就会丢。环形缓冲区就是解决这个问题的经典数据结构。
环形缓冲区的核心是两个指针:写指针和读指针。中断或DMA往写指针位置写数据,应用层从读指针位置读数据。写指针追上读指针表示缓冲区满,读指针追上写指针表示缓冲区空。
实现环形缓冲区有几个关键点:
- 读写指针的原子性:如果读写在不同的上下文(中断和主循环),指针操作要考虑原子性。单字节读写通常不需要加锁,但多字节操作可能需要关中断保护。
- 缓冲区大小:太小容易满,太大浪费内存。一般根据最大突发数据量和应用层处理周期来估算。比如应用层每10ms处理一次,波特率115200,10ms最多收到约115字节,缓冲区至少要有256字节才比较安全。
- 溢出处理:缓冲区满时是丢弃新数据还是覆盖旧数据,取决于业务需求。调试日志可以丢弃,控制指令则不能丢,需要设计重传或流控机制。
我个人的习惯是,只要用UART接收不定长数据,就上一个环形缓冲区,哪怕当前数据量不大。因为现场环境不可预测,多一层缓冲就多一层保障。
5. 那些年踩过的UART坑:丢包、乱码、死锁的排查链路
5.1 乱码问题:从波特率到时钟源的逐项排查
串口乱码是最常见的问题,排查思路可以按以下顺序:
- 确认双方波特率一致:这是最基础的,但也是最容易出错的。有时候代码里写的是115200,但实际时钟配置不对,导致实际波特率偏离。
- 检查时钟源精度:如果用的是内部RC振荡器,先换外部晶振试试。RC振荡器受温度影响大,波特率误差可能超标。
- 检查数据位、停止位、校验位:这些参数不匹配也会导致乱码。比如一方是8位数据位无校验,另一方是7位数据位偶校验,收到的数据就会错位。
- 检查电平匹配:TTL接TTL,RS232接RS232,不要混接。如果用了电平转换芯片,确认芯片方向和使能引脚配置正确。
- 检查地线:串口通信必须共地,如果两块板子没有共地,电平参考点不同,通信必然出错。
有一次我遇到一个很隐蔽的乱码问题:板子和电脑通信正常,但板子和另一块板子通信就乱码。最后发现是两块板子的地线之间有一根线接触不良,时通时断,导致电平参考点漂移。这种问题用示波器看波形很明显,但光看代码是看不出来的。
5.2 丢包问题:中断延迟与缓冲区溢出的量化分析
丢包比乱码更难排查,因为数据本身是对的,只是偶尔少了一部分。常见原因有:
- 接收中断被延迟:高优先级中断抢占,或者中断服务程序太长,导致接收寄存器溢出。
- 缓冲区溢出:应用层处理太慢,环形缓冲区满了之后新数据被丢弃。
- DMA配置错误:DMA传输完成中断没及时处理,或者DMA缓冲区大小设置不当。
- 硬件流控未启用:如果对端发送速度超过本端处理速度,又没有RTS/CTS流控,就会丢数据。
排查丢包时,我习惯先加计数器:在接收中断里统计接收到的字节数,在应用层统计处理的字节数,两者对比就能定位是接收端丢了还是处理端丢了。如果是接收端丢,再看溢出标志位是否置位,确认是中断延迟还是DMA问题。
5.3 死锁问题:中断与主循环共享资源的陷阱
UART驱动里的死锁,通常发生在中断和主循环共享资源的时候。比如:
- 主循环正在读环形缓冲区,中断来了也去操作读写指针,导致指针状态不一致。
- 主循环在等待发送完成标志,但发送中断被关闭了,永远等不到。
- 在中断里调用了可能阻塞的函数,比如带互斥锁的打印函数。
避免死锁的原则很简单:中断里只做最必要的事,不调用可能阻塞的函数,不长时间关中断。如果必须在中断和主循环之间共享数据,用环形缓冲区这种无锁或短临界区保护的结构,而不是直接共享复杂状态。
6. 让UART驱动更可靠的几个工程习惯
6.1 超时机制:给每次收发都加上时间边界
不管是发送还是接收,都应该有超时机制。发送时,如果等待发送完成标志超过一定时间还没置位,就认为硬件异常,返回错误而不是死等。接收时,如果等待一帧数据超过预期时间,就认为对端离线或线路故障,触发重连或报警。
超时时间怎么定?一般按理论传输时间的2到3倍来设。比如115200波特率下,一个字节约87微秒,一帧10个字节约870微秒,超时可以设3毫秒左右。当然具体要看协议和业务需求。
6.2 错误计数与日志:让问题可追溯
在驱动里加错误计数器,比如帧错误次数、溢出次数、校验错误次数。这些计数器可以通过调试接口读出来,现场出问题时就能快速判断是哪种错误为主。如果溢出次数多,说明中断延迟或缓冲区太小;如果帧错误多,说明波特率或电平有问题。
日志也很重要,但要注意日志本身不能影响实时性。我一般会在RAM里开一块日志缓冲区,记录关键事件的时间戳和错误码,需要时再导出来分析。直接往串口打印日志要小心,因为打印本身也占用串口,可能干扰正常通信。
6.3 压力测试:上线前必须做的验证
UART驱动写完,不能只测“发几个字节收几个字节”。上线前至少要做这几项压力测试:
- 连续收发测试:让设备连续收发几小时甚至几天,观察是否有丢包、死机。
- 高低温测试:如果产品有温度要求,要在高低温环境下测波特率误差和通信稳定性。
- 电源波动测试:电源不稳时,UART电平可能受影响,要验证在电源波动范围内通信是否正常。
- 对端异常测试:模拟对端突然断电、拔线、发错误数据,看本端是否能正确处理而不死锁。
这些测试看起来很笨,但能提前暴露大部分现场问题。我见过太多项目因为省了压力测试,到现场才发现问题,那时候排查成本就高得多了。
7. 写在最后:UART是基本功,也是照妖镜
UART这个东西,入门只要半天,但真正做稳需要几年经验积累。它像一面照妖镜,能把时钟配置、中断管理、缓冲区设计、错误处理这些基本功的薄弱环节全部照出来。很多人在面试时能背出UART的帧结构,但一问“接收中断里能不能做浮点运算”“环形缓冲区满了怎么办”“DMA空闲中断怎么用”就卡壳了。
我的建议是,不要满足于“能通就行”。每次用UART,都按今天聊的这些点过一遍:波特率误差算了吗?中断里有没有耗时操作?缓冲区够不够?超时加了吗?错误计数有吗?压力测试做了吗?把这些养成习惯,你的驱动稳定性会有一个明显的提升。
后面如果大家感兴趣,我可以再聊聊UART转其他协议(比如Modbus、自定义帧协议)时怎么设计状态机,以及多路UART同时工作时的资源分配问题。这些在实际项目里都是绕不开的。