8051单片机轮询串口回环实战:波特率、TI/RI与丢字节排查
1. 方案定型:为什么这个回环实验值得认真做一遍
8051单片机做串口通信,STC89C52是绕不开的一颗芯片——学校里用它,小项目里用它,很多工业小板子上还是它。而"以轮询方式实现串口通信,51接收数据后原样发送回PC端"这个需求,看起来像是入门第一课,实际上它把串口的三个核心问题一次性摆在台面上:波特率怎么算、标志位怎么清、数据什么时候会丢。我见过不少人在中断方式下写得很溜,回头用轮询做一个简单回环,反而卡在"发第一个字节就没了""收到的全是乱码""发一次就不再响应"这类问题上。原因不复杂,中断方式帮你兜住了时序,轮询方式则把所有时序责任全部压在你自己身上。
这篇内容面向的是手里已经有一块STC89C52最小系统板、一个USB转TTL模块、装了串口调试助手的读者。基础要求不高,会点亮LED、能看懂C语言位操作就够。我会把寄存器配置、波特率计算、收发时序、实际踩到的坑,一层一层拆开讲清楚,代码全部给完整的,能直接编译烧录。
1.1 轮询与中断的取舍:先想清楚代价在哪
轮询的本质是"我自己去问"。主程序不停地查RI标志位,看有没有新数据进到SBUF里;发送的时候往SBUF写一个字节,然后死等TI置位。整条链路里没有任何中断参与,程序执行流是严格线性的,看一眼代码就知道它下一步干什么。中断的本质是"别人来通知我",CPU被打断,跳进服务函数,处理完再回来。两种方式做同一件事,差别在时序责任归谁。
轮询的优势是可控和可调试。你在发送函数里下一个断点,寄存器状态、局部变量、执行到哪一行一目了然,不像中断那样随时可能被打断,调试器一停就丢数据。对于回环这种一对一、低速、数据量小的场景,轮询完全没有性能问题。代价也很直接:等待期间CPU什么都干不了。发送一个字节在9600bps下大约要1.04毫秒,这1毫秒里主循环是空转的。如果主循环里还挂了数码管动态扫描、按键检测,这些任务就会被拖慢,反过来又可能影响接收——因为接收不等人。
这一点必须提前想明白:轮询回环丢数据的根源,往往不是串口配错了,而是主循环里有别的任务在拖时间。我建议第一次做这个实验时,主循环里除了串口收发什么都别放,先把链路跑通,确认数据稳定了,再往里塞别的逻辑。上来就把串口和数码管扫描、按键消抖揉在一起的人,出问题之后根本分不清是串口的锅还是别的任务的锅。
再说一个容易被忽略的点:轮询方案里,EA和ES应该明确关掉。不是必须,但强烈建议。如果中断使能着却没写服务函数,一旦RI或TI触发,程序就跳到默认的中断入口去跑一段莫名其妙的东西,现象是"程序跑飞了"或者"死机"。STC89C52的中断向量表是固定的,串口中断入口在0x0023,没写服务函数时它会跳到一个空地址,行为不可预期。所以初始化里显式写EA = 0; ES = 0;,把不确定性排除掉。
1.2 硬件链路与最小系统构成
回环实验的硬件部分简单到只有三根线,但接错的人不在少数。USB转TTL模块上有TXD、RXD、GND、VCC(或者5V、3V3),单片机上对应的是P3.1(TXD)和P3.0(RXD)。必须交叉连接:模块的TXD接单片机的P3.0,模块的RXD接单片机的P3.1。这是最经典的接反点,接反了现象是收发都没反应,串口助手一片空白,很多人会误以为是程序问题,去改波特率改半天。
GND一定要共地。USB转TTL模块和单片机板子如果各自独立供电,不共地的话电平参考点不一致,通信时好时坏,表现为偶尔收到几个乱码字节。这个坑很隐蔽,因为有时候它又能通,让人以为是接触不良。VCC一般不要接——如果单片机板子已经用外部电源或者USB供电,再从这个模块引5V过去,两路电源打架,轻则通信不稳,重则烧稳压芯片。我的习惯是只接TX、RX、GND三根,供电走板子自己的口。
单片机的串口引脚上不要挂别的东西。有些最小系统板把P3.0接到了按键上,或者P3.1上并了一个LED做指示灯,这些都会干扰串口。尤其是并LED的做法,发送时LED会闪,看着挺好玩,但那颗LED的限流电阻和结电容会让波形变差,高速率下直接通信失败。做串口实验前,把P3.0、P3.1上多余的器件检查一遍,能拆就拆。
另外提醒一句:STC89C52的串口和下载口是同一组引脚。烧录程序的时候,上位机通过P3.0/P3.1把代码灌进去,烧完之后程序开始运行,引脚就被你的程序占用了。这意味着如果你想重新下载程序,必须先断开电源再上电,利用上电瞬间的ISP窗口期。如果你在程序里把串口初始化得很早、波特率设得和下载波特率不一样,某些情况下会导致下载握手失败,表现为"一直显示正在连接"。遇到这种情况,拔电、点下载、再上电,顺序别乱。
1.3 数据流向与程序骨架
整个程序的骨架简单到可以画成一句话:初始化串口 → 死循环 → 等一个字节 → 把字节原样发回去。数据从PC的串口助手发出,经过USB转TTL模块电平转换,进入单片机P3.0,串口硬件自动完成起始位检测、8位数据移位、停止位校验,最后把完整字节放进SBUF并置位RI。程序查到RI后读走SBUF,清RI,再把这个字节写回SBUF发送,硬件自动加起始位和停止位,从P3.1送出去,回到PC。
这里有个容易被误解的地方:SBUF在物理上是两个寄存器,一个是发送缓冲,一个是接收缓冲,只是共用同一个地址0x99。读SBUF读到的是接收寄存器,写SBUF写的是发送寄存器,两者互不干扰,这也是为什么串口能同时收和发(全双工)。很多初学者以为读写同一个地址会冲突,实际上硬件已经分开了,你不用担心。
程序结构上我习惯拆成三个函数:UartInit()、UartSendByte()、UartRecvByte()。拆开的好处是后面要改成中断方式或者加协议,改动面小。全写main里也不是不行,但调试的时候你就知道有多难受了——尤其是想看某个字节发出前SBUF是什么值,函数封装之后加断点方便得多。
2. 串口初始化的每一个参数都要有出处
初始化是这类实验里最容易抄错、也最容易出问题的一段。网上流传的代码五花八门,有的写SCON = 0x50,有的写SCON = 0x40,有的干脆把TH1写死成0xFD但晶振用的是12MHz。抄错一个参数,现象就是乱码,而乱码的排查成本很高,因为你得把每一步都验一遍。所以这一段我不打算直接甩代码,先把每个寄存器、每个参数为什么是这个值讲清楚。
2.1 SCON、TMOD、PCON 三个寄存器的位定义
SCON是串口控制寄存器,字节地址0x98,可以位寻址。8个位从高到低是SM0、SM1、SM2、REN、TB8、RB8、TI、RI。工作模式由SM0和SM1决定,我们要用的是方式1:8位数据、可变波特率、带1个起始位和1个停止位,对应SM0=0、SM1=1。SM2是多机通信控制位,方式1下一般写0。REN是接收允许位,必须置1才能收到数据,写0的话硬件根本不采样RXD引脚,这是"程序没错但收不到"的第一嫌疑人。TB8和RB8是方式2/3下用的第9位,方式1用不到,写0。
TI和RI是两个由硬件置位、软件清零的标志。发送完一个字节,硬件把TI置1;接收完一个字节,硬件把RI置1。注意"硬件置位、软件清零"这六个字——你不可能靠写1把它们清了,只能写0。同理,你不能指望硬件帮你清零,不清就一直保持1,后面的判断全部失效。所以SCON = 0x50的意思是:方式1(SM0=0,SM1=1)、SM2=0、REN=1、TB8=0、RB8=0、TI=0、RI=0。这个值一次把工作模式、接收使能、两个标志位全定了,比逐位赋值干净。
TMOD是定时器模式寄存器,地址0x89,不可位寻址,低4位管定时器0,高4位管定时器1。我们只用定时器1,要把它设成方式2——8位自动重装。为什么必须用方式2?因为方式0和方式1是16位定时器,溢出后计数值清零,需要软件重新赋初值。而波特率要求的是稳定连续的溢出脉冲,如果每次溢出都要软件重装,中断响应延迟、指令执行时间都会带来抖动,波特率就不准了。方式2把TH1当重装值、TL1当计数器,TL1溢出时硬件自动把TH1的值重新装进TL1,全程不需要软件介入,溢出率绝对稳定。这就是波特率发生器必须用方式2的原因,不是习惯问题,是原理问题。
写TMOD的时候千万别直接TMOD = 0x20。因为这个寄存器是定时器0和定时器1共用的,直接赋值会把定时器0的配置一起清掉。如果程序里别的地方用了定时器0做延时或者扫描,你这一句就把它废了,现象是"加了串口之后延时函数不准了"。正确做法是先用&= 0x0F清高4位,再用|= 0x20置位,只动定时器1那半边。
PCON的最高位是SMOD,波特率加倍位。地址0x87,不可位寻址,所以不能写SMOD = 1,只能PCON |= 0x80。SMOD=0时波特率是正常值,SMOD=1时波特率翻倍。9600bps在11.0592MHz下用SMOD=0就够了,不需要加倍,所以这一句可以不写或者写成PCON &= 0x7F。但如果你要跑19200,就必须置SMOD=1。
2.2 波特率计算:为什么晶振非要用11.0592MHz
先给公式,方式1、定时器1方式2、SMOD=0时:
波特率 = (2^SMOD / 32) × (fosc / (12 × (256 − TH1)))
这个公式里,fosc是晶振频率,12是51单片机的机器周期分频(STC89C52RC默认12T模式,一个机器周期12个时钟),(256 − TH1)是定时器的重装计数值,32是串口方式1内部固定的16倍采样再除以2。把这些常数代进去反推TH1:
以9600bps、11.0592MHz为例:
- 256 − TH1 = 11059200 / (12 × 32 × 9600)
- 分母 = 12 × 32 × 9600 = 3686400
- 256 − TH1 = 11059200 / 3686400 = 3
- TH1 = 256 − 3 = 253 = 0xFD
正好整除,误差为零。这就是11.0592MHz这个"奇怪"频率的由来——它是9600、4800、2400、1200这些标准波特率的公倍数,能整除出一个整数重装值。换12MHz试试:12000000 / 3686400 = 3.255,取整3,实际波特率 = 12000000 / (12 × 32 × 3) = 10416.7,误差8.5%。串口通信一般要求误差控制在2%以内,8.5%必然乱码。所以用12MHz晶振跑9600,怎么调都是错,不是代码问题,是物理上就做不到。
| 波特率 | 晶振 | SMOD | 重装值(256−TH1) | TH1 | 实际波特率 | 误差 |
|---|---|---|---|---|---|---|
| 1200 | 11.0592MHz | 0 | 24 | 0xE8 | 1200 | 0% |
| 2400 | 11.0592MHz | 0 | 12 | 0xF4 | 2400 | 0% |
| 4800 | 11.0592MHz | 0 | 6 | 0xFA | 4800 | 0% |
| 9600 | 11.0592MHz | 0 | 3 | 0xFD | 9600 | 0% |
| 19200 | 11.0592MHz | 1 | 3 | 0xFD | 19200 | 0% |
| 2400 | 12MHz | 0 | 13 | 0xF5 | 2403.8 | 0.16% |
| 9600 | 12MHz | 0 | 3 | 0xFD | 10416.7 | 8.5%(不可用) |
表里最后两行是我特意放进去的对照。如果你手头的板子晶振是12MHz,别死磕9600,老老实实降到2400,误差0.16%,稳得很。或者换晶振,一颗11.0592MHz的直插晶振几毛钱,比调代码省事多了。晶振不换、波特率不改、代码反复改,是新手最容易陷进去的死循环。
再补一个STC特有的点:STC89C52RC有一部分批次的AUXR寄存器(0x8E)可以影响定时器1的时钟源,进而影响波特率。不同批次的定义有细微差异,我不建议在这个实验里去动它,默认值就是对的。真要改,先翻对应批次的手册确认位定义,别照着网上的代码抄。
2.3 初始化函数逐行拆解与常见错误
#include <reg52.h> #define FOSC 11059200UL #define BAUD 9600 void UartInit(void) { SCON = 0x50; // 方式1,8位UART,REN=1允许接收 TMOD &= 0x0F; // 清定时器1的高4位,保住定时器0的配置 TMOD |= 0x20; // 定时器1工作在方式2(8位自动重装) TH1 = 0xFD; // 9600bps @ 11.0592MHz, SMOD=0 TL1 = 0xFD; // 第一次装载,之后由硬件自动重装 TR1 = 1; // 启动定时器1,波特率发生器开始工作 PCON &= 0x7F; // SMOD = 0,波特率不加倍 EA = 0; // 轮询方案关闭总中断 ES = 0; // 关闭串口中断 }逐行说。SCON = 0x50这一句是直接赋值,不是位操作,它同时把TI和RI清成了0,这在初始化阶段是好事,避免上电后残留标志位导致第一次判断就直接通过。TMOD两行前面已经解释过,重点是别破坏定时器0。
TH1和TL1都要赋值。有些人只赋TH1,理由是方式2会自动重装,TL1不用管。理论上第一次溢出前TL1是随机值,会导致第一个字节的波特率不对。实践中因为串口初始化到真正发数据之间隔了几百个机器周期,早就溢出重装过很多次了,通常看不出问题。但这是运气,不是设计。两个都赋,一行代码的事。
TR1 = 1千万别漏。我见过有人调了一下午,最后发现是没启动定时器。现象是能发出去但全是乱码,因为定时器没跑,波特率等于0或者极低。
EA = 0; ES = 0;这两句在轮询方案里是显式声明"我不打算用中断"。如果你的工程里有别的模块需要中断(比如定时器0做数码管扫描),那就不能写EA = 0,只关ES就行。这个要看你工程整体,不能一概而论。
一个常见的错误写法是把SCON配成0x40。0x40意味着SM0=0、SM1=1、REN=0,模式对了但接收被关掉了。现象是能发不能收,串口助手里发出去的数据石沉大海。这个错很难自己发现,因为代码"看起来"没问题,很多人会先去怀疑硬件。记住:0x50能收能发,0x40只能发。
3. 轮询收发:从单字节到完整回环
初始化跑通之后,真正考验时序理解的是收发函数。这两个函数各自只有三四行,但每一行的顺序都不能乱,乱了就丢数据或者死循环。我把发送和接收分开讲,因为它们的坑不在同一个地方。
3.1 发送一个字节的时序与TI的位置
void UartSendByte(unsigned char dat) { SBUF = dat; // 写SBUF,硬件自动开始发送 while (!TI); // 等待发送完成,TI由硬件置1 TI = 0; // 软件清零,为下一次发送做准备 }顺序是死的:先写SBUF,再等TI,最后清TI。为什么不能先清TI再写SBUF?因为如果上一次发送的TI还没清,你先清TI再写SBUF,逻辑上也行,但会引入一个隐患——如果清TI和写SBUF之间恰好上一次发送完成了(虽然在这个函数里不可能,因为上次的TI一定在上次函数结束时清过了),时序会乱。规范的写法就是"写—等—清"。
while (!TI);是个死等循环,没有超时。如果波特率配错、定时器没启动、TXD引脚被短路,这个循环就永远出不来,现象是程序卡死,其他任务全部停摆。调试阶段可以在这里加个计数超时,跑通了再删掉:
void UartSendByte(unsigned char dat) { unsigned int guard = 0; SBUF = dat; while (!TI) { if (++guard > 60000) { TI = 0; return; } // 超时保护,仅调试用 } TI = 0; }9600bps下一个字节约1.04ms,11.0592MHz下大约960个机器周期,60000的计数上限足够宽松,正常情况下绝不会触发。真触发了,说明硬件或者配置有问题,这时候程序能继续跑,你能通过别的方式(比如点个LED)判断出卡在哪。
还有一个细节:TI是硬件置位、软件清零,写1是无效的。有人习惯写TI = 1想"手动置位",那是没用的,硬件不会理你。同理RI = 1也无效。51的这两个标志位只认硬件置1、软件写0。
连续发送多个字节的时候,每发一个都要走一遍"写—等—清"。不能连写两个SBUF,因为第一个还没发完就写第二个,会把发送缓冲覆盖掉,实际发出去的只有后一个。这是"发字符串只收到最后一个字符"的经典原因。
3.2 接收一个字节的时序与RI的位置
unsigned char UartRecvByte(void) { unsigned char dat; while (!RI); // 等待接收完成,RI由硬件置1 dat = SBUF; // 读走数据 RI = 0; // 软件清零 return dat; }这里的顺序是"等—读—清"。先读SBUF再清RI,这个顺序有讲究。51的串口接收是双缓冲结构:外部有一个接收移位寄存器,收满8位后整体送入SBUF,同时置RI。如果你先清RI再读SBUF,理论上在这两条指令之间如果新的一帧刚好收完,RI会被重新置1,然后你读SBUF读到的是新数据,但RI又被你之前那句清了——不过实际上这种情况概率极低,因为清RI和读SBUF之间只有一两个机器周期,而一帧数据要上千个机器周期。但规范就是规范,先读后清,不给未来留隐患。
真正会丢数据的情况是另一种:你在处理上一个字节的时候,下一个字节已经到了。轮询接收完一个字节,程序回到主循环,做别的处理,这期间如果PC端又发来一个字节,硬件收完后置RI,但SBUF里的旧数据被新数据覆盖(SBUF只有一级缓冲),旧数据就永久丢失。这就是为什么我一直强调主循环里别放耗时任务。
while (!RI);同样是死等。如果PC端不发数据,程序就停在这里。对于回环实验这没问题,因为整个程序的目的就是等数据。但如果你的程序还要做别的事,这个死等就是灾难。后面第五章会讲非阻塞的写法。
3.3 完整可编译代码与主循环
把上面几块拼起来,就是一个完整的回环程序:
#include <reg52.h> void UartInit(void) { SCON = 0x50; TMOD &= 0x0F; TMOD |= 0x20; TH1 = 0xFD; TL1 = 0xFD; TR1 = 1; PCON &= 0x7F; EA = 0; ES = 0; } void UartSendByte(unsigned char dat) { SBUF = dat; while (!TI); TI = 0; } unsigned char UartRecvByte(void) { unsigned char dat; while (!RI); dat = SBUF; RI = 0; return dat; } void main(void) { unsigned char ch; UartInit(); while (1) { ch = UartRecvByte(); UartSendByte(ch); } }烧录进去,串口助手设成9600、8位数据、1位停止、无校验、无流控,打开串口,发送"HELLO",应该原样收回来"HELLO"。注意串口助手的编码要选对,发中文的话还得考虑GBK和UTF-8的差异,测试阶段建议只发ASCII字符,排除编码干扰。
回环成功之后可以再加一点反馈,比如每收到一个字节翻转一次P1.0上的LED,或者收到特定字符(比如'A')时点亮某个引脚。这样即使串口助手没开,也能从硬件上看出来程序在跑。
3.4 实测节奏:一个字节到底占用多长时间
9600bps下,一帧数据是1个起始位 + 8个数据位 + 1个停止位 = 10位,每位时长 = 1/9600 ≈ 104.17微秒,一帧约1.04毫秒。这个数字很关键,因为它决定了你的系统能承受多快的连续数据。
回环程序的节奏是"收一个—发一个"。接收本身不占时间(硬件自动完成,程序只在RI置位后读一下),但发送要占1.04毫秒的死等。也就是说,从收到第N个字节到能接收第N+1个字节,中间至少隔了1.04毫秒的发送时间。如果PC端是以"连续发送"模式发一串数据,字节之间的间隔远小于1.04毫秒,那么在第N个字节发送期间到达的第N+1个字节,会因为RI没被及时处理、SBUF被覆盖而丢失。
实测下来,串口助手手动点一次"发送",发一个5字节的字符串,因为中间有软件处理开销,字节间隔通常在几十微秒到几百微秒,回环基本能完整收回。但如果用脚本以1毫秒间隔高速连续发,或者干脆用串口的连续发送功能,丢字节几乎必然发生。这不是程序写错了,是轮询回环这个架构本身的吞吐上限决定的——单字节回环的有效吞吐大约是波特率的一半,因为收和发各占一份时间。
这个结论很重要,它解释了为什么很多人做完这个实验会觉得"串口不稳定"。实际上链路是稳的,只是你没意识到它的带宽被吃掉了一半。解决方案有两个方向:一是PC端加发送间隔,二是程序改成中断加环形缓冲,用后台收数据、前台慢慢处理。第五章会展开。
4. 现场排查:乱码、丢字节、只收一次
串口调试的痛苦在于,出问题的时候你面对的是一个空白的接收窗口,什么线索都没有。全乱码、什么都没有、只收到第一个字节——这三种现象看起来差不多,排查方向完全不同。这一段我按现象分类,把定位路径和判断依据整理出来。
4.1 三类高频故障的定位路径
现象一:收到的是乱码,但确实有数据。这说明链路是通的,TXD、RXD、GND至少接对了两根,问题99%在波特率上。先确认晶振是不是11.0592MHz,把晶振上的丝印看一遍(常见的有11.0592、12.000、24.000)。如果是12MHz,TH1=0xFD就是错的,按第二章的表改成0xF5(2400bps),串口助手也改成2400,立刻就好。如果晶振对,检查SMOD位,PCON &= 0x7F和PCON |= 0x80的区别是波特率差一倍,误写会导致收到数据但全是垃圾。再检查定时器1是不是真的启动(TR1=1),以及TMOD有没有被别的地方改掉。
还有一种更隐蔽的乱码原因:串口助手的数据位、停止位、校验位设置不一致。单片机固定是8N1(8位数据、无校验、1位停止),助手如果设成7位数据或者偶校验,收到的必然是乱的。这个设置项在助手的界面上很显眼,但恰恰因为显眼所以容易被忽略。
现象二:一个字节都收不到,窗口空白。先看发送的时候单片机有没有反应——如果程序里配了发送指示灯,看它闪不闪。不闪说明程序卡在while(!RI)里,根本没进到发送。这时候重点查接收链路:REN有没有置1(SCON是不是0x50)、模块的TXD有没有接到单片机的P3.0、GND有没有共地。如果指示灯闪了但PC端没显示,问题在发送链路上:模块的RXD有没有接到P3.1、串口助手有没有点"打开串口"(这个听起来很蠢,但真的很常见)、串口号选对没有。
现象三:只收到第一个字节,后面全是空白或者卡死。这是TI没清的典型症状。第一次发送完成后TI被硬件置1,如果程序里忘了TI = 0,第二次进UartSendByte时写SBUF后while(!TI)立刻通过(因为TI还是1),然后返回,实际上数据可能还没发出去就被下一次覆盖。更常见的是整个流程乱了,表现为只发出第一个字节。检查发送函数里有没有清TI,以及清的位置对不对。
另一种"只收一次"是RI没清。第一次收到数据后RI置1,读走SBUF但没清RI,主循环第二次进来while(!RI)立刻通过,读到的是已经被覆盖的新数据或者旧数据,看起来像是"收到了重复的东西"。所以TI和RI的清零,是这类实验里最重要的两行代码。
4.2 常见问题速查表
| 现象 | 最可能原因 | 快速验证方法 | 解决 |
|---|---|---|---|
| 全是乱码 | 波特率不匹配、晶振频率不符 | 换2400bps试一次 | 核对晶振,按公式重算TH1 |
| 收不到任何数据 | REN=0、TX/RX未交叉、未共地 | 万用表量P3.0有无电平变化 | SCON改0x50,交叉接线,接GND |
| 只收到第一个字节 | TI未清零、SBUF被连续覆盖 | 在发送函数后加LED翻转 | 补上TI = 0; |
| 收到重复数据 | RI未清零 | 单步调试看RI状态 | 补上RI = 0; |
| 程序卡死不响应 | while(!TI)或while(!RI)死等 | 用示波器看P3.1有无波形 | 加超时保护,检查硬件 |
| 首字节丢失 | 上位机在初始化完成前就发了数据 | 延迟1秒再发 | PC端加延时或先发同步符 |
| 通信时好时坏 | 电源不稳、共地不良、线太长 | 换短杜邦线,单独供电 | 缩短连线,加滤波电容 |
| 下载程序失败 | 串口被程序占用,ISP窗口期错过 | 断电→点下载→上电 | 严格按冷启动顺序操作 |
表里"首字节丢失"这一条值得单独说。串口助手点发送的瞬间,如果单片机刚上电还在初始化,第一个字节到达时RI置位了但SBUF里的数据可能被后续初始化操作影响,或者程序还没进主循环,等进主循环时第一个字节已经被覆盖。这不是程序bug,是时序竞争。解决办法很简单:PC端等1秒再发,或者让单片机初始化完成后先主动发一个同步字符(比如0x55),PC端收到这个字符再开始发数据。
4.3 硬件侧的坑与实操心得
硬件问题在串口调试里占的比例比想象中高。我踩过最坑的一次是杜邦线接触不良,现象是发10次能成7次,失败的那3次完全是空白。因为接触不良导致电平悬空,串口硬件采样到的是噪声,有时候能被识别成帧有时候不能。换了三根线才找到问题。所以排查顺序永远是先硬件后软件,别一上来就怀疑代码。
USB转TTL模块的电平选择也要注意。有些模块上有3.3V和5V的跳线,STC89C52是5V系统,跳线要选5V。选3.3V的话,模块输出高电平只有3.3V,可能达不到单片机的高电平识别阈值(通常0.7×VCC=3.5V),表现为时通时不通。反过来,单片机输出的5V电平进3.3V模块,一般模块能容忍,但长期这么用不保险。
模块的VCC引脚我建议悬空。有些模块的VCC是输出(给目标板供电),有些是输入(从目标板取电),不同厂家的定义不一样,接错了会短路。只接TX、RX、GND三根,绝对不会错。
还有一个不起眼但影响很大的点:串口线不要和电机、继电器、大电流LED驱动走在一起。并行走线会有串扰,尤其在没有屏蔽的杜邦线上。如果板子上有这类器件,串口线尽量走远一点,或者加个磁环。
另外,STC89C52的P3.0是RXD,这个引脚同时还兼着下载口。有些开发板在这个引脚上接了下拉电阻或者按键,会改变电平特性,导致高速率下误码。如果你发现2400bps稳定但9600bps不行,除了波特率误差,也要考虑引脚上的负载。
5. 从回环走向可用:给轮询加一点工程化
回环跑通只是个开始。真实项目里,"收一个发一个"这种结构基本用不上,你得处理不定长数据、处理协议帧、处理主循环里的其他任务。轮询方式不是不能用,但要加点结构,否则它的死等特性会把整个系统拖死。
5.1 非阻塞轮询与状态机写法
把死等的接收改成"查一下,有就拿走,没有就返回",这是最基础的改造:
bit UartPollRecv(unsigned char *p) { if (RI) { *p = SBUF; RI = 0; return 1; // 收到一个字节 } return 0; // 没有数据 }主循环就变成"查一次收一次,查不到继续干别的"。这样即使PC端不发数据,程序也不会卡住。但这里有个陷阱:两次调用之间的间隔不能超过1.04毫秒,否则新字节到达时SBUF被覆盖。如果你的主循环里有数码管动态扫描(通常1毫秒刷新一位),刚好卡在边界上,会偶发丢字节。所以用这种方式,主循环的单次耗时必须做到心中有数,最好控制在几百微秒以内。
发送也有非阻塞的写法,但比较麻烦,因为51的发送缓冲只有一级。你可以用状态机:在主循环里检查TI,如果上一次发完了就装下一个字节。这就是简单的发送队列雏形:
unsigned char tx_buf[16]; unsigned char tx_head = 0, tx_tail = 0; void UartTxPoll(void) { if (TI && tx_head != tx_tail) { TI = 0; SBUF = tx_buf[tx_tail]; tx_tail = (tx_tail + 1) & 0x0F; } }这段代码要放在主循环里高频调用。注意if (TI ...)进去后先清TI再写SBUF,顺序和阻塞版正好反过来——因为这里的TI代表"上一次发完了",先清掉它,再启动下一次。这个顺序细节很多人搞混,搞混了就会多发一个字节或者少发一个。
5.2 环形缓冲与简单的帧协议
真正稳定的回环,接收端一定要有环形缓冲。中断方式下是中断里写缓冲、主循环里读缓冲;轮询方式下是主循环里按固定节奏查RI写缓冲。缓冲的作用是把"接收"和"处理"解耦,接收速度跟波特率走,处理速度跟程序逻辑走,中间用缓冲垫着。
#define RB_SIZE 32 unsigned char rb_buf[RB_SIZE]; unsigned char rb_head = 0, rb_tail = 0; void UartRxPoll(void) { unsigned char next; if (!RI) return; next = (rb_head + 1) & (RB_SIZE - 1); if (next != rb_tail) { // 缓冲未满才写入 rb_buf[rb_head] = SBUF; rb_head = next; } RI = 0; // 无论是否溢出都要清RI }这里用了位与来做环形索引,前提是RB_SIZE必须是2的幂。next != rb_tail是判满条件,留一个空位区分满和空。这个写法是嵌入式里最经典的生产者-消费者模型,值得记住。
有了缓冲,就可以做帧解析了。回环实验里最简单的协议是给数据加包头包尾,比如0xAA开头、0x55结尾,中间是数据。接收端在缓冲里找包头,凑够长度后校验包尾,对上就处理,对不上就丢。这样即使偶尔丢一个字节,下一帧还能自动同步,不会因为一字节错误导致整条链路瘫痪。
void UartFrameParse(void) { static unsigned char state = 0, len = 0; unsigned char ch; while (rb_tail != rb_head) { ch = rb_buf[rb_tail]; rb_tail = (rb_tail + 1) & (RB_SIZE - 1); if (state == 0 && ch == 0xAA) { state = 1; len = 0; } else if (state == 1) { frame[len++] = ch; if (len >= 8) state = 2; } else if (state == 2 && ch == 0x55) { frame_ready = 1; state = 0; } else { state = 0; } } }这只是一个示意,真实的协议还要加长度字段、校验和、超时重传。但思路就是这个思路:用状态机吃掉字节流,用缓冲解耦速度。这个结构做出来之后,串口就不会"时好时坏"了。
5.3 什么时候必须切换到中断
轮询不是万能的。以下几种情况,我建议直接上中断,别硬扛:
主循环里有耗时超过500微秒的任务,比如延时、液晶刷新、EEPROM写入。这些任务执行期间RI可能被置位又覆盖,轮询根本来不及查。波特率高于19200,字节间隔小于520微秒,轮询的余量太小。需要同时处理接收和发送且数据量不小,轮询的"收一个发一个"吞吐不够。系统对实时性有要求,比如收到命令要在10毫秒内响应,轮询的死等会破坏这个指标。
中断方式的代码其实和轮询差不多,只是把while(!RI)换成了中断服务函数:
void UartISR(void) interrupt 4 { if (RI) { RI = 0; rb_buf[rb_head] = SBUF; rb_head = (rb_head + 1) & (RB_SIZE - 1); } if (TI) { TI = 0; tx_busy = 0; } }注意中断号是4,对应串口。服务函数里第一件事就是判断是RI还是TI触发的,两个都要处理,否则会漏。而且进入服务函数后RI和TI不会自动清零,必须手动清,这一点和轮询完全一样。
从轮询切到中断,思路上的转变是:轮询是"我控制节奏",中断是"硬件控制节奏"。前者适合简单场景,代码直白好调试;后者适合复杂场景,但引入了并发问题——主循环和中断会同时访问缓冲区,需要保证原子性。对于单字节的读写(head和tail的更新),在51上单条指令就是原子的,问题不大;但如果是多字节结构体,就要考虑关中断保护了。
我个人在实际项目里的做法是:先用轮询把链路跑通、把协议调对、把硬件验稳,再切中断优化。一上来就写中断版,出了问题你分不清是协议逻辑错了还是中断时序错了,排查成本翻倍。轮询版本虽然效率低,但它是一面干净的镜子,能照出所有硬件和配置问题。
最后分享一个小技巧:调试串口的时候,在初始化完成后先主动发一串固定字符串,比如"UART OK\r\n"。串口助手一打开就能看到这行字,说明单片机在跑、波特率对、发送链路通。这一行字的排查效率,抵得上你盯着空白窗口猜半小时。收不到这行字,问题一定在初始化或者发送链路;收到了但回环不正常,问题在接收链路或者主循环节奏。把故障域一刀切开,剩下的就好办了。
这个实验我前后带过不少人做,真正的难点从来不是那几行代码,而是"知道自己在等什么"。while(!TI)等的是发送完成,while(!RI)等的是接收完成,清标志位的时机决定了下一次能不能正常开始。把这几件事在脑子里过一遍,再回头看那些乱码和丢字节,基本都能自己对号入座。