资讯详情

8051单片机轮询串口回环实战:波特率、TI/RI与丢字节排查

📅 2026/11/3 22:54:45 | 华诺云谱 👁 阅读
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.1TXD和P3.0RXD。必须交叉连接模块的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决定我们要用的是方式18位数据、可变波特率、带1个起始位和1个停止位对应SM00、SM11。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的意思是方式1SM00SM11、SM20、REN1、TB80、RB80、TI0、RI0。这个值一次把工作模式、接收使能、两个标志位全定了比逐位赋值干净。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。SMOD0时波特率是正常值SMOD1时波特率翻倍。9600bps在11.0592MHz下用SMOD0就够了不需要加倍所以这一句可以不写或者写成PCON 0x7F。但如果你要跑19200就必须置SMOD1。2.2 波特率计算为什么晶振非要用11.0592MHz先给公式方式1、定时器1方式2、SMOD0时波特率 (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 3686400256 − TH1 11059200 / 3686400 3TH1 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实际波特率误差120011.0592MHz0240xE812000%240011.0592MHz0120xF424000%480011.0592MHz060xFA48000%960011.0592MHz030xFD96000%1920011.0592MHz130xFD192000%240012MHz0130xF52403.80.16%960012MHz030xFD10416.78.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; // 方式18位UARTREN1允许接收 TMOD 0x0F; // 清定时器1的高4位保住定时器0的配置 TMOD | 0x20; // 定时器1工作在方式28位自动重装 TH1 0xFD; // 9600bps 11.0592MHz, SMOD0 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意味着SM00、SM11、REN0模式对了但接收被关掉了。现象是能发不能收串口助手里发出去的数据石沉大海。这个错很难自己发现因为代码看起来没问题很多人会先去怀疑硬件。记住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.04ms11.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个字节到能接收第N1个字节中间至少隔了1.04毫秒的发送时间。如果PC端是以连续发送模式发一串数据字节之间的间隔远小于1.04毫秒那么在第N个字节发送期间到达的第N1个字节会因为RI没被及时处理、SBUF被覆盖而丢失。实测下来串口助手手动点一次发送发一个5字节的字符串因为中间有软件处理开销字节间隔通常在几十微秒到几百微秒回环基本能完整收回。但如果用脚本以1毫秒间隔高速连续发或者干脆用串口的连续发送功能丢字节几乎必然发生。这不是程序写错了是轮询回环这个架构本身的吞吐上限决定的——单字节回环的有效吞吐大约是波特率的一半因为收和发各占一份时间。这个结论很重要它解释了为什么很多人做完这个实验会觉得串口不稳定。实际上链路是稳的只是你没意识到它的带宽被吃掉了一半。解决方案有两个方向一是PC端加发送间隔二是程序改成中断加环形缓冲用后台收数据、前台慢慢处理。第五章会展开。4. 现场排查乱码、丢字节、只收一次串口调试的痛苦在于出问题的时候你面对的是一个空白的接收窗口什么线索都没有。全乱码、什么都没有、只收到第一个字节——这三种现象看起来差不多排查方向完全不同。这一段我按现象分类把定位路径和判断依据整理出来。4.1 三类高频故障的定位路径现象一收到的是乱码但确实有数据。这说明链路是通的TXD、RXD、GND至少接对了两根问题99%在波特率上。先确认晶振是不是11.0592MHz把晶振上的丝印看一遍常见的有11.0592、12.000、24.000。如果是12MHzTH10xFD就是错的按第二章的表改成0xF52400bps串口助手也改成2400立刻就好。如果晶振对检查SMOD位PCON 0x7F和PCON | 0x80的区别是波特率差一倍误写会导致收到数据但全是垃圾。再检查定时器1是不是真的启动TR11以及TMOD有没有被别的地方改掉。还有一种更隐蔽的乱码原因串口助手的数据位、停止位、校验位设置不一致。单片机固定是8N18位数据、无校验、1位停止助手如果设成7位数据或者偶校验收到的必然是乱的。这个设置项在助手的界面上很显眼但恰恰因为显眼所以容易被忽略。现象二一个字节都收不到窗口空白。先看发送的时候单片机有没有反应——如果程序里配了发送指示灯看它闪不闪。不闪说明程序卡在while(!RI)里根本没进到发送。这时候重点查接收链路REN有没有置1SCON是不是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收不到任何数据REN0、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秒再发或者让单片机初始化完成后先主动发一个同步字符比如0x55PC端收到这个字符再开始发数据。4.3 硬件侧的坑与实操心得硬件问题在串口调试里占的比例比想象中高。我踩过最坑的一次是杜邦线接触不良现象是发10次能成7次失败的那3次完全是空白。因为接触不良导致电平悬空串口硬件采样到的是噪声有时候能被识别成帧有时候不能。换了三根线才找到问题。所以排查顺序永远是先硬件后软件别一上来就怀疑代码。USB转TTL模块的电平选择也要注意。有些模块上有3.3V和5V的跳线STC89C52是5V系统跳线要选5V。选3.3V的话模块输出高电平只有3.3V可能达不到单片机的高电平识别阈值通常0.7×VCC3.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)等的是接收完成清标志位的时机决定了下一次能不能正常开始。把这几件事在脑子里过一遍再回头看那些乱码和丢字节基本都能自己对号入座。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑