1. USART到底是什么:一个老协议为什么到现在还没退休
做嵌入式开发这些年,几乎每天都要和USART打交道。哪怕现在各种高速接口满天飞,USB、CAN、以太网、SPI、I2C各有各的领地,但USART依然稳稳占据着调试串口、低速外设通信、模块透传这些场景的生态位。很多朋友从51单片机入门,第一行通信相关的代码往往就是往SBUF里写一个字节,然后眼巴巴地看着串口助手吐出一串“Hello World”。这玩意儿看着简单,真正把它吃透的人却不多。
USART的全称是Universal Synchronous/Asynchronous Receiver/Transmitter,翻译过来就是通用同步/异步收发器。注意这里有个“同步”的S,通常我们天天挂在嘴边的“串口”,大多数时候用的是它的异步模式,也就是UART。那USART和UART到底差在哪?简单说,UART只支持异步通信,收发双方各干各的,靠约定好的波特率来对齐节奏;USART在硬件上多了一路时钟输出能力,可以切换成同步模式,在传输数据的同时输出SCLK时钟信号,让从设备跟着时钟节拍走。我见过不少人在STM32和51单片机上把USART当纯UART用,这没什么问题,但如果你不知道它还能做同步通信,遇到需要和某些带同步串口接口的芯片对接时就会卡住。
那USART解决了什么问题?最核心的就一件事:让两个设备之间能传数据。你想想,一个单片机要跟PC通信,把传感器数据扔到电脑上显示,或者让电脑下发指令控制单片机,没有串口的话只能靠SPI/I2C这些又要接线又要考虑主从关系的方案,麻烦得多。USART只需要两根线,TX接RX、RX接TX,再共个地,就能全双工收发。硬件上极简,协议上透明,几乎不需要额外的握手逻辑,所以才成了调试阶段最好用的“后门”。电脑上用USB转串口的小板子一插,串口助手一开,想看点内部状态、寄存器值、报错信息,全都一目了然。可以说,USART是单片机的“生命线”也不为过。
这个主题适合谁?不管你是刚接触单片机的学生,还是有了一定经验但没系统梳理过串口细节的从业者,这篇都值得看一看。我会从帧结构、波特率、收发方式、异常排查几个角度,把我自己踩过的坑和沉淀下来的方法一起讲清楚。
2. 一帧数据是怎么在线上跑的:帧格式拆解与参数选择逻辑
2.1 从起始位到停止位:一帧数据的完整旅程
USART异步通信没有时钟线,接收端怎么知道对方什么时候开始发数据?靠的就是帧格式。一个标准的UART帧长这样:空闲时TX线保持高电平,要发数据时先拉低一个bit时间,这个拉低动作就是起始位,告诉接收端“注意,后面有话要说”。然后按顺序发出数据位,通常是5到8位,低位在前。接着是校验位,可选;最后是停止位,把TX线拉回高电平并保持至少1个bit时间,给双方一个缓冲,也标志这一帧结束。
拿最常用的8N1格式举例,8表示数据位8个,N表示无校验(None),1表示1位停止位。一帧总共10个bit:1个起始位 + 8个数据位 + 1个停止位。发送一个字节“A”,即0x41的二进制01000001,线上看到的波形就是:先拉低1bit,然后依次送出1、0、0、0、0、0、1、0这8个数据位(注意低位先发),最后拉高1bit。接收端在检测到下降沿后,往后数时间,在每一位的中间时刻采样,这样能最大程度避开信号跳变带来的不确定区域。
用寄快递来类比就很好懂:起始位就是快递员按门铃的“叮咚”,数据位就是包裹里的货物,校验位是运单上的重量复核,停止位是快递员签收完跟你挥手说再见。每一帧都是一个完整的小包裹,设备之间就靠这些包裹一包接一包地交流。
2.2 校验位到底要不要开:奇偶校验的工程权衡
配置串口的时候总会遇到校验位这个选项,None、Even、Odd、甚至Mark和Space也有。初学者容易纠结,我直接给结论:绝大多数场景,尤其是板级通信,用无校验就够;数据量不大、干扰不算恶劣的工业场景,偶校验也常见;如果对数据完整性要求较高,别指望靠1bit校验位兜底,改用帧尾CRC或者更高层的协议更靠谱。
为什么这么说?奇偶校验只能检测奇数个bit翻转的错误,如果两个bit同时翻转,校验照样通过,检测能力非常有限。而且开了校验位后,有效数据率还会下降。8E1格式一帧就是11bit了。我在实际项目中,长距离或强干扰环境下更倾向于关掉硬件校验,自己在协议层加CRC16,这样比硬件1bit校验可靠得多。当然,如果你在跟某些老式设备对接,对方强制要求偶校验,那就得老老实实跟着配,否则通信直接失败。
2.3 波特率与比特率的换算陷阱:115200到底能传多快
波特率(Baud Rate)说的是每秒传多少个码元,在USART这种二电平调制方式下,一个码元就是1个bit,所以波特率数值上等于比特率。115200就等于每秒传115200个bit。发一个字节按8N1算需要10个bit,所以每秒最多能发11520个字节。但注意,这是理论毛速率,实际还得扣除帧间隔、处理时间、协议开销,有效吞吐率通常只有理论值的六到八成。
这里有个非常经典的坑:很多人以为波特率9600就是每秒传9600个字节,错得离谱。9600波特率每秒只有960字节,约合0.94KB/s,连1KB/s都不到。更迷惑的是,有些显示模块或者GPRS模块标注的“波特率”也会直接用bps来表述,这时就要看手册里给的默认定义了。做产品选型时一定要算清楚实际需要的吞吐量,别等到联调时才发现带宽不够,那真叫一个难受。
下表是几个常用波特率在8N1格式下的理论字节速率,可以直接存下来备查:
| 波特率 | 每字节耗时(bit) | 理论字节速率 | 一字节耗时 |
|---|---|---|---|
| 9600 | 10 | 960 B/s | 约1.04 ms |
| 19200 | 10 | 1920 B/s | 约0.52 ms |
| 38400 | 10 | 3840 B/s | 约0.26 ms |
| 57600 | 10 | 5760 B/s | 约0.17 ms |
| 115200 | 10 | 11520 B/s | 约86.8 us |
| 230400 | 10 | 23040 B/s | 约43.4 us |
| 460800 | 10 | 46080 B/s | 约21.7 us |
| 921600 | 10 | 92160 B/s | 约10.9 us |
选波特率还有一个现实考量:USB转串口芯片和PC端驱动对某些波特率支持得好,对另一些可能偏差大。115200是一道分水岭,往上的230400、460800、921600在高负载下容易出问题,除非确有必要,我不建议盲目上高速。我见过一个项目,为了省那几毫秒把波特率拉到921600,结果换了几根USB线就有概率乱码,排查半天最后降回460800,稳如老狗。串口通信讲究“够用、稳定”,不是越快越好。
3. 波特率是怎么算出来的:分频器的原理与误差分析
3.1 分频公式里的门道:整数分频为什么会有误差
USART的波特率不是随便配的,它由外设时钟经过分频得到。以常见的外设时钟频率48MHz为例,要得到115200波特率,公式大概是:
波特率 = 外设时钟频率 / (16 × 分频系数)
反推一下:分频系数 = 48MHz / (16 × 115200)= 48000000 / 1843200 = 26.041666...。这个数字不是整数,但分频寄存器往往是整数,于是只能四舍五入取26。这时候实际波特率变成了48000000 / (16 × 26)= 115384.6,跟目标115200差了大概0.16%。这个误差在正常通信容限之内,因为收发双方的误差合计不超过±2%~3%基本都能正常收。但如果时钟频率选得不好,比如11.0592MHz和12MHz的经典对比——前者整除115200很干净,后者就总有零头,这就是老单片机开发板爱用11.0592MHz晶振的根源之一。
有趣的是,现在很多MCU的外设时钟经过PLL倍频后,可以凑出一些“看起来能整除”的组合,但实际算下来依然可能有小数分频。现代MCU通常提供小数分频器(Fractional Divider),能把误差压到0.01%以内。配置时打开计算器自己算一遍,或者直接看数据手册里的表格,别想当然。
3.2 误差到底影响什么:采样点的偏移与容限
USART接收端的采样策略通常是在起始位下降沿后,延迟半个bit时间,然后在每个bit的中央采样。如果波特率有误差,采样点会逐步偏移。假设发送端比接收端快0.5%,发完第10个bit时,采样时刻已经偏移了大约0.05个bit宽度,还在安全范围内。如果误差到2%,一个字节下来偏移0.2个bit,采样点已经从位中心滑向边缘,噪声一大就可能采错。经验值:单字节8N1格式下,累计误差控制在±2%以内基本稳;如果帧长度更长,比如9bit数据位,或者用扩展帧,容限还要收紧。
这也是为什么USB转串口线要用好一点的。劣质方案用的晶振精度不够,标称115200实际偏了3%,跟MCU这边误差叠加,轻则偶发乱码,重则完全不通。我排查过不少这类问题,最后换上带温补晶振的转换器就好了。别小看一颗晶振。
3.3 实际配置计算示例:手把手算一次48MHz下的分频
拿一个外设时钟48MHz、想跑115200波特率的场景来走一遍完整流程。假设MCU支持小数分频,寄存器里有一个整数部分和一个小数部分。先算比例,48000000 / 115200 = 416.6666,由于采用16倍过采样,再除以16得到26.04166。整数取26,小数0.04166乘以分频粒度(比如粒度是1/16的话,0.04166 × 16 = 0.666,约等于1),于是小数部分写1。最终实际波特率 = 48000000 / (16 × (26 + 1/16))= 48000000 / (16 × 26.0625)= 115107.8,跟115200偏差0.08%。这个误差完全可接受。
如果你用的MCU没有小数分频,直接取26,误差0.16%也能用。但要注意,如果你的外设时钟和波特率组合不合理,误差可能跑到1.7%以上,那就可能不稳定了。所以选外设时钟时,优先找能被波特率整除的配置,比如11.0592MHz、18.432MHz、36.864MHz这类“串口友好”频率。我做了十年开发,越来越觉得硬件时钟选型是个隐形炸弹,选对了后期少掉一半头发。
4. 从轮询到中断再到DMA:三种收发方式的分工与取舍
4.1 轮询方式:简单直接,但别拿它干重活
最基础的做法就是轮询,主循环里不断查接收标志位,有数据就处理。代码写起来最简单,逻辑也清楚,但代价是CPU被占得死死的。发数据时要循环等待上一个字节发送完,收数据时要么丢在那边不管,要么高频轮询。对于只传几个字节的低频调试场景,轮询完全没问题,我早期调模块、打印调试信息都是这么干的。可一旦涉及多路传感器、带协议解析、还要跑显示逻辑,轮询串口就会拖慢整个系统,这时候就得换玩法。
4.2 中断收发:主流做法,也是必修课
中断方式是目前的主流。发送时把数据往发送寄存器里一丢,发完产生发送完成中断,在中断里继续喂下一个字节;接收时每收到一个字节就触发接收中断,把数据搬进自己的环形缓冲区,主循环再从缓冲区取。这样CPU既不会卡死,又能及时响应通信。
配置中断时有个容易翻车的点:中断优先级要合适。如果你的USART接收中断优先级太低,在主循环处理耗时任务时可能丢中断;如果太高,又可能频繁打断重要实时计算。我在一个项目里把串口接收中断优先级设为仅次于系统时钟中断,结果主程序里一个长时间计算的浮点运算被反复打断,计算时间长了近一倍。后来把串口中断优先级降了一档,通信依然稳定,实时计算也恢复正常。优先级这个东西,不是越高越好,够用就行。
接收中断里建议做这几件事:读取数据寄存器、压入环形缓冲区、更新计数、检查缓冲区是否溢出。动作必须轻量,千万别在中断里做协议解析、字符串格式化这些耗时的活。中断里只存数据,解析放到主循环,这是铁律。
4.3 DMA收发:让数据绕过CPU直接进内存
DMA方式更高级,数据从外设寄存器直接搬到内存环形缓冲区,完全不用CPU干预,收一整包数据CPU都在睡大觉。对高频数据采集、大批量日志输出的场景非常友好。配合IDLE空闲中断,还能实现“收完一帧数据再通知CPU”的效果,效率极高。
一个常见的配置组合是:开启USART接收DMA,循环模式,把接收缓冲区设成环形,同时开启IDLE中断。一帧数据发来,从第一个字节到最后一个字节之间没有空闲超过1个bit,DMA默默把这些字节依次放进缓冲区,最后一个字节到来后总线空闲产生IDLE中断,CPU这才醒来去处理。这种方式处理不定长协议非常优雅,我在处理GPS数据、图传协议时一直用这个套路。
DMA的坑也有。最常见的是竞争条件:CPU在读缓冲区的同时DMA还在写同一个位置,数据错乱。解决办法是设一个写索引和读索引,判断读写索引差值时注意“回绕”问题,或者在协议上加起始标志和长度字段,自己处理拆包。另外,DMA通道被其他外设占用、配置了DMA但忘了使能对应中断,都是新手常踩的雷。出现“数据好像收到了但程序没反应”的情况,先查DMA中断使能和中断服务函数里有没有清标志位。
下表是三种方式的直观对比:
| 方式 | CPU占用 | 代码复杂度 | 实时性 | 适用场景 |
|---|---|---|---|---|
| 轮询 | 高 | 低 | 差 | 低频调试、数据量极小 |
| 中断 | 中 | 中 | 好 | 大多数常规通信 |
| DMA | 低 | 较高 | 好 | 高数据量、长帧传输、低功耗场景 |
4.4 环形缓冲区实现:一个稳定好用的基础组件
不管用中断还是DMA,最终数据都要落在环形缓冲区里。一个简单可靠的环形缓冲区实现长这样,因为太通用,我直接给出C语言形态的骨架:
#define RING_BUF_SIZE 256 typedef struct { uint8_t buffer[RING_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buf_t; static ring_buf_t rx_ring; uint16_t ring_data_len(ring_buf_t *rb) { return (uint16_t)((rb->head - rb->tail) & (RING_BUF_SIZE - 1)); } int ring_write(ring_buf_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) & (RING_BUF_SIZE - 1); if (next == rb->tail) { return -1; } rb->buffer[rb->head] = data; rb->head = next; return 0; } int ring_read(ring_buf_t *rb, uint8_t *data) { if (rb->head == rb->tail) { return -1; } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) & (RING_BUF_SIZE - 1); return 0; }核心思想是用head和tail两个索引实现先进先出,头部写、尾部读,容量取2的幂次方,用按位与来快速处理回绕。接在串口接收中断里,每进一个字节就调用一次ring_write。主循环里只要ring_data_len不为0,就取出来解析。这套代码我用了很久,稳定可靠,换平台基本只改字节类型。
5. 上位机联调与电平匹配:连线、电平转换及调试工具选型
5.1 同样的“串口”,可能不是同一个电平
很多人拿着单片机直接往电脑USB口怼,没反应,以为是代码问题,其实是电平不匹配。MCU的USART引脚通常是TTL电平,高电平3.3V或5V;电脑串口或USB转串口线输出的往往是RS-232电平,正负电压摆幅,逻辑跟TTL完全不同。所以单片机跟PC通信,中间必须加USB转TTL模块,或者用带电平转换的RS-232转接器。
模块选型时注意看芯片方案,常见靠谱的有CH340、CP2102、FT232这类。供电电压也要匹配,3.3V单片机就选3.3V输出的模块,5V的选5V,混用轻则读不到数据,重则烧引脚。我早期做过一次5V模块直接接3.3V单片机的事,结果USART引脚被灌坏,幸好只烧了一个引脚,从那以后每次都先确认电平再接线。
5.2 交叉接线还是直连:TX和RX永远要对调
收发接线这块,几乎每个新手都迷糊过。记住一个铁律:两个设备之间,TX接对方的RX,RX接对方的TX。用USB转TTL模块连单片机时,模块的TX要接单片机RX,模块的RX要接单片机TX。很多模块主控板上还带一个“交叉/直连”的跳线开关,直连模式适合和非交叉的设备接,交叉模式适合直接跟另一个串口设备对接,具体看模块说明书。
接错的表现很典型:发数据对方完全收不到,要么收出来全是0x00或0xFF,要么根本无响应。这时候先量一下TX脚有没有波形,用示波器或者逻辑分析仪一看就明白。另外注意共地问题——两个设备地线必须连在一起,电平才有参考基准。不共地时偶发乱码、时通时断的现象特别常见,尤其是两边都用独立电源供电的情况下。
5.3 调试工具怎么用:串口助手、逻辑分析仪和示波器各显神通
调试串口通信,手边三样东西最好都备着。第一是串口助手软件,用来跟单片机收发数据,选的时候看它能不能自动识别波特率、能不能十六进制显示、支不支持定时发送,这几个功能在实际排查里太常用了。第二是逻辑分析仪,采样率不需要太高,串口最高也就一两Mbps,普通的24MHz采样率的够用;接上TX线和RX线,就能把整个波形抓下来,数数起始位、数据位、停止位,直接判断波特率配置对不对、有没有毛刺、帧间隔多久。第三是示波器,逻辑分析仪能看协议波形,但测电压幅值、看信号完整性还是示波器靠谱,尤其排查电平不匹配、走线过长导致的波形畸变时。
我待遇过一个“串口偶发乱码”的问题,串口助手看着正常,逻辑分析仪也看不出大毛病,最后把示波器探头怼上去才发现TX线边沿很平,压摆率不够,原因是线缆太长且没加终端电阻。换短线、降波特率后问题消失。这种问题靠软件怎么改都改不出来,只能靠硬件测量定位。
6. 乱码、丢包和通信超时:一线排查记录与速查表
6.1 乱码的三张面孔:波特率、校验位和地电位
乱码是串口调试里最让人血压升高的现象之一。同样是乱码,原因可能完全不同。第一类:能收到字符但明显不对,比如发“A”收到“@”或“B”,大概率是波特率不匹配,或者收发双方对波特率的偏差超过了容限。第二类:字符对一半错一半,偶尔还会少字节,常见于校验位配置不一致,你开了偶校验对方没开,数据bit解读错乱。第三类:凭空出现一堆0x00或者乱码中带规律性杂音,先查地线有没有接好,再用示波器看TX输出波形是否规整。
排查乱码有个固定套路:先固定一端用PC串口助手发已知数据(比如0x55,它的波形是01010101,很容易看),另一端用逻辑分析仪抓MCU的RX波形。如果波形正常,问题在MCU侧的配置;如果波形本身就不对,说明源头那边就有问题。用这种方法能快速缩小范围,不用瞎猜。
6.2 丢数据与“粘包”问题:拆包策略和缓冲区调优
中断方式下丢数据,大概率是中断处理太慢,上一个字节还没取走,下一个字节已经把寄存器覆盖了。排查方法:查溢出标志位(Overrun Error),很多MCU的USART寄存器里都有这个位,一旦置1说明发生过溢出。如果经常溢出,把中断处理精简到只读寄存器、压缓冲区、清标志,其他一切挪出中断;还有一种情况是中断开了但优先级配置不当,被别的更高优先级中断长时间打断。
“粘包”是协议层面的麻烦:多个数据帧连在一起发过来,如果不能正确区分哪里是一帧的结束、哪里是下一帧的开始,数据就会错乱。常见的破解办法有三种。第一种是固定帧长,收满指定字节数就当成一帧,适合结构化的控制指令;第二种是添加帧头帧尾,比如帧头0xAA 0x55,帧尾0x0D 0x0A,接收端靠状态机拆包;第三种是在帧里放长度字段,收到长度字段后就知道后面要跟多少数据字节。根据传输内容决定用哪种。纯透传文本数据就按分隔符处理;二进制协议建议用帧头+长度+校验的组合,状态机成熟可靠。
6.3 常见异常速查表:症状、原因与对策
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 完全无通信 | 接线错误、电平不匹配 | 检查交叉接线、确认TTL电平、查共地 |
| 乱码但波形正常 | 波特率配置、校验位、数据位不一致 | 核对双方参数,用逻辑分析仪实测波特率 |
| 时通时断 | 地线虚接、电源波动、线路干扰 | 紧固接线、加粗地线、降波特率 |
| 收多收少 | 缓冲溢出、中断响应超时 | 查溢出标志、精简中断、加大缓冲区 |
| 第一个字节丢失 | MCU复位后串口未初始化完成 | 延时后再收发,或用握手信号 |
| 首发失败后恢复 | 硬件流控未同步 | 关闭RTS/CTS流控或正确配置 |
6.4 一个微型案例:某模块通信超时的完整排查过程
处理过一个具体的例子,现象是某传感器模块每隔500ms上报一条数据,但MCU偶尔一天内会有几次收不到。串口助手上看到的是数据少了一条,不是乱码。第一步查溢出标志,发现偶尔置位,说明中断没来得及搬数据;第二步查中断优先级,发现USART中断优先级跟一个定时器中断相撞,定时器中断里干了个耗时的滤波运算,正好在数据到达时把USART中断卡住了;第三步调整优先级和精简定时器中断,问题就消失了。整个过程不到半小时,但如果一开始就盲改波特率、盲目换线,可能几天都查不明白。
7. 写在最后:串口调试的经验沉淀
做了这么多年嵌入式,USART始终是我最依赖的调试工具和通信手段。它不花哨,没什么值得炫耀的黑科技,但稳定性极高,排查思路也相对清晰。很多人觉得掌握了收发函数就算会串口了,实际上还有很多细节值得琢磨,比如波特率误差、中断优先级、电平匹配、缓冲区设计、拆包策略,每一个都可能在关键时刻坑你一次。
我个人最大的体会是:遇到串口问题,先别急着改代码,先确认硬件连接和信号波形,再用逻辑分析仪看数据链路,最后才回头审软件配置。按这个顺序排查,百分之八十的问题都能在十几分钟内定位。串口通信是嵌入式的基本功,基本功扎实了,后面调更复杂的协议栈才会顺手。如果你也把这套东西跑明白了,至少能少走很多弯路。