学51单片机,打卡第七天,前六天基本都在跟LED、按键、数码管、外部中断打交道,到了今天终于轮到串口了。串口(UART)大概是整个51学习过程中最实用的一块内容,不管是调试打印、连接各种无线模块,还是跟电脑通信,几乎都绕不开它。围绕80C51串行口,今天要弄清楚四件事:内部结构、SCON和PCON寄存器、四种工作方式、波特率计算,以及串口使用之前的初始化流程。
串口看起来就是一根TX一根RX,但真正用起来,寄存器配置、波特率匹配、中断处理,每一个细节都可能让人折腾半天。我见过不少同学卡在乱码和收不到数据上,到最后发现都是些很小的配置问题。这篇就把这些内容掰开揉碎讲一遍。
1. 先把串口内部的框架捋清楚
1.1 核心就是两个SBUF,但地址只有一个
80C51的串行口从结构上看,是一个全双工UART,也就是能同时收发。芯片内部有接收缓冲器和发送缓冲器,名字都叫SBUF,物理上是两个完全独立的寄存器,但地址只有一个,0x99。往这个地址写,进的是发送缓冲器;从这地址读,出来的是接收缓冲器。很多初学者第一次接触会被这个设计绕晕,其实记住一句话就行:写SBUF就是发,读SBUF就是收。
除了SBUF,串口模块还包括移位寄存器、波特率发生器(通常由定时器1承担)、中断控制逻辑以及控制寄存器。RXD对应P3.0,TXD对应P3.1。接收数据时,外部信号从RXD引脚进来,经过移位寄存器逐位移入,组装成一帧放到接收SBUF;发送时反过来,从发送SBUF取数据,逐位移出,从TXD引脚送出去。整个过程CPU不需要一位一位干预,你只要往SBUF写一个字节,硬件自动帮你发完。
1.2 结构里几个容易忽略的细节
看教材上的串口结构框图,一眼扫过去全是方框,但有三个细节很容易被忽略,却直接影响使用。
第一,发送控制器和接收控制器是分开的,各自有各自的中断标志TI和RI。这意味着发送完成和接收完成是两个独立事件,处理时要分开判断。
第二,接收部分有双重缓冲机制。接收SBUF前面还有一个移位寄存器做缓冲,这样你正在处理当前这帧数据的时候,下一帧还能继续进入移位寄存器,不至于因为处理不及时丢数据。这是串口能在中断方式下稳定工作的硬件基础。
第三,波特率并非全部来自定时器1。方式0和方式2的波特率是固定的,由晶振频率直接分频得到,只有方式1和方式3才用定时器1的溢出率。这一点在算波特率的时候特别容易搞混。
这一节不用死记结构图,有个整体框架感就行,真正要对齐细节的是下一节的寄存器配置。
2. 两个决定一切的寄存器:SCON和PCON
2.1 SCON逐位拆解
SCON是串行口控制寄存器,地址0x98,属于可位寻址的SFR,既能整体赋值,也能用sbit或者位操作单独设置。复位值是0x00,代表串口默认什么都不干。
它的8个位的作用如下表:
| 位 | 名称 | 含义 |
|---|---|---|
| SCON.7 | SM0 | 工作方式选择位高位 |
| SCON.6 | SM1 | 工作方式选择位低位 |
| SCON.5 | SM2 | 多机通信控制位 |
| SCON.4 | REN | 允许接收位,1使能接收 |
| SCON.3 | TB8 | 方式2/3中发送的第9位数据 |
| SCON.2 | RB8 | 方式2/3中接收的第9位数据 |
| SCON.1 | TI | 发送中断标志,发送完一帧硬件置1,需软件清零 |
| SCON.0 | RI | 接收中断标志,接收完一帧硬件置1,需软件清零 |
SM0和SM1组合决定四种工作方式,后面专门展开。SM2在方式2和方式3中和第九位数据配合,用于多机通信的地址帧和数据帧区分,单机通信时保持0就行。REN是接收总开关,很多同学发数据后单片机收不到,十有八九就是REN没置1,引脚根本没在监听。TB8和RB8在不用第九位的场合保持0,不用多管。TI和RI是硬件置位、软件清零,尤其是做中断接收时,服务程序里忘了清RI,程序就会反复进入中断,要么数据错乱,要么一次性把缓冲读空。
2.2 PCON寄存器:SMOD才是主角
PCON是电源控制寄存器,地址0x87,不可位寻址,只能整体读写或者用位运算修改,复位值0x00。它的最高位SMOD是串口波特率加倍位,和串口直接相关。SMOD=1时,方式1、2、3的波特率都会翻倍,方式0不受影响。其余位里,GF1、GF0是两个通用标志位,PD是掉电模式控制位,IDL是空闲模式控制位,低功耗设计才会用到,普通串口调试不用关心。
有个很典型的坑:别人给的代码里写PCON |= 0x80,意思就是把SMOD置1,波特率翻倍。如果你上位机软件配的还是原来的波特率,两边速率不一致,结果必然是乱码。所以看例程的时候,SMOD这个位必须跟波特率计算一起看,单独看哪一行都是对的,组合起来可能就错了。
另外提醒一句,PCON操作尽量用|=和&=,不要直接整体赋值。PCON里还有掉电控制位,如果对整体字节赋值不看其他位的当前状态,容易把不该改的位改了,在低功耗场景下会出莫名其妙的问题。
2.3 配置SCON和PCON的快速思路
实际写初始化代码,SCON用一句整体赋值置好。比如最常用的方式1加允许接收,就是SCON = 0x50,对应SM0=0、SM1=1、REN=1。PCON一般只动SMOD,想加倍就PCON |= 0x80,不想加倍就PCON &= 0x7F。这一步其实没什么玄学,关键是先确定自己的工作方式,再确定波特率,寄存器自然就出来了。
3. 四种工作方式,按需选择
3.1 方式0:不是UART,是同步移位寄存器
方式0下,串口并不是标准UART,而是一个同步移位寄存器接口。数据从RXD引脚输入或者输出,TXD引脚输出移位时钟,波特率固定为晶振频率的1/12。这种方式不用于两个设备之间通信,而是用来扩展并行IO口。典型接法就是外接74HC595做输出扩展,或者74LS165做输入扩展。
用起来很有意思,往SBUF写一个字节,硬件按低位在前自动移出8位。做数码管动态扫描的时候,经常用这种方式省IO口。初次接触可能会疑惑,串口怎么还能接数码管?其实就是把串口当移位寄存器用,把串行数据转成并行输出去驱动外设。方式0的收发都在RXD上,TXD只是时钟信号,所以在这种方式下,RXD被占用,TXD只是辅助线。
3.2 方式1:8位UART,出场率最高
方式1是标准8位异步通信格式,一帧包含1位起始位、8位数据位、1位停止位,波特率由定时器1的溢出率决定。这就是日常用串口助手调试时默认的格式:8数据位、无校验、1停止位。发送时往SBUF写一个字节,硬件自动加上起始位和停止位发出去;接收时REN置1后,硬件持续监听RXD引脚,收到完整帧后把数据放到接收SBUF,同时RI置1。
方式1下的RB8存放的是停止位,这个细节有印象就行,正常用不到。绝大多数上位机通信、模块调试,方式1基本就够了,配置最简单,兼容性也最好。
3.3 方式2:9位UART,波特率固定
方式2的帧格式是1位起始位、8位数据位、1位可编程的第9位、1位停止位。这个第9位就是TB8和RB8了。发送时,TB8的值会作为第9位发出去;接收时,收到的第9位会存入RB8。方式2的波特率是固定的,等于2的SMOD次方除以64再乘以晶振频率,不受定时器控制。
听起来有个第9位可以做校验或者多机通信,但正因为波特率固定、不能随便调,实际项目里用得并不多。如果晶振频率不合适,它生成的波特率可能不是标准值,反而添乱。所以方式2更多是教学意义,告诉你UART还可以有第9位这种玩法。
3.4 方式3:9位UART,波特率可调
方式3的帧格式和方式2完全一样,区别只有一点:波特率由定时器1的溢出率决定,可以灵活设置。想要9位数据帧格式,又想用自己计算的波特率,那就选方式3。
方式2和方式3搭配SM2可以实现多机通信,这也是第九位数据的主要意义。主机发送地址帧时TB8置1,发送数据帧时TB8置0,从机通过判断第9位来确定这一帧是否跟自己相关。初学阶段先记住方式3和方式2帧格式一致、波特率机制不同,等以后真做多机通信了再深入不迟。
3.5 四种方式怎么选
整理一张表,对着看更直观:
| 方式 | 帧格式 | 波特率来源 | 典型用途 |
|---|---|---|---|
| 方式0 | 同步移位数据 | fosc/12固定 | 扩展并行IO口 |
| 方式1 | 8位数据+启停位 | 定时器1溢出率 | 普通双机通信 |
| 方式2 | 9位数据+启停位 | fosc固定分频 | 多机通信、校验 |
| 方式3 | 9位数据+启停位 | 定时器1溢出率 | 多机通信、校验 |
日常调试和绝大多数应用,方式1就够,简单可靠。多机通信或者需要奇偶校验的场景,方式3更实用。方式0另类,只在扩展IO时用。方式2基本是教学和特定场景。
4. 波特率计算:别硬背公式,要学会推导
4.1 波特率的来源分两条线
波特率就是每秒传输的位数,单位bps。计算之前先分清楚,当前工作方式的波特率来自哪里。
方式0固定是fosc/12,方式2固定是2的SMOD次方除以64,再乘fosc。方式1和方式3的波特率则由定时器1溢出率决定,公式是:
波特率 = (2^SMOD / 32) × 定时器1溢出率
定时器1本身可以工作在多种方式下,溢出率不一样。最常用的做法是把定时器1配置成方式2,也就是8位自动重装。这种模式下,定时器每计数256减TH1个机器周期就溢出一次,溢出率就是晶振频率除以12,再除以计数值:
溢出率 = fosc / (12 × (256 - TH1))
把两个公式合并起来,就是:
波特率 = (2^SMOD × fosc) / (384 × (256 - TH1))
反过来,要算TH1的初值:
TH1 = 256 - (2^SMOD × fosc) / (384 × 波特率)
这个推导过程比死记公式有用,因为实际项目里晶振变了、波特率变了,套公式才知道每个数字是从哪来的。
4.2 为什么11.0592MHz晶振成了行业标准
用最常见的9600波特率、SMOD=0来算一笔账。如果晶振是11.0592MHz:
TH1 = 256 - 11059200 / (384 × 9600)
分母384 × 9600 = 3686400,11059200除以3686400正好等于3。于是TH1 = 256 - 3 = 253,也就是0xFD。初值是整数,波特率分毫不差。
如果晶振换成12MHz:
TH1 = 256 - 12000000 / (384 × 9600) ≈ 256 - 3.255 ≈ 253
初值只能取整数,TH1取253后,实际波特率就变成12000000 / (384 × 3) = 10416.7bps,跟9600差了大约8.5%。这个误差在通信上是致命的,收发双方一位一位地比对,时间长了必然错位,表现出来就是偶尔收到一两个对的字节,紧接着全是乱码。所以很多开发板坚持用11.0592MHz晶振,不是因为看起来专业,而是它恰好能被标准波特率整除干净。
4.3 常用波特率初值速查
假设晶振11.0592MHz、定时器1工作在方式2,常用配置如下:
| 波特率 | SMOD | TH1/TL1 | 误差情况 |
|---|---|---|---|
| 4800 | 0 | 0xFA | 无误差 |
| 9600 | 0 | 0xFD | 无误差 |
| 19200 | 1 | 0xFD | 无误差 |
| 57600 | 1 | 0xFE | 有微小误差 |
注意19200和9600的初值都是0xFD,区别就在SMOD。SMOD=1时,波特率在同一个初值下翻倍。57600这种高速率,初值取0xFE附近,实际值和理论值会有偏差,对晶振精度和线路质量更敏感,实际项目里如果跑这个速率还不稳定,优先降低速率排查。
4.4 误差容忍度到底在哪
个人经验是,波特率误差在2%以内通常还能凑合通信,超过3%基本就是乱码。但这个容忍度跟通信距离、帧格式、双方容错都有关,不能一概而论。实验中出现乱码,第一步永远先核对波特率,不要急着怀疑硬件电路。12MHz晶振也不是不能用,只要双方约定一个12MHz能整除的速率就行,但很多上位机工具不认非标准波特率,所以最省心的方案还是换11.0592MHz晶振,或者选带独立波特率发生器的增强型单片机。
5. 串口初始化实操,从寄存器到代码
5.1 初始化要回答五个问题
初始化串口的本质,就是回答几个问题:用哪种工作方式、波特率多少、要不要接收、要不要中断。落实到寄存器上,一共五步。
第一步,配置定时器1为方式2。设置TMOD高四位,TMOD对低四位对应定时器0,操作时不要影响定时器0的配置,所以用TMOD &= 0x0F先清高四位,再TMOD |= 0x20把定时器1设为方式2。
第二步,装初值。TH1和TL1都写入计算好的值,因为是自动重装模式,TH1是重装值,TL1是当前计数初值,两者通常写一样的。
第三步,启动定时器1,TR1 = 1。这一步特别容易忘,定时器不跑,波特率就为0,串口自然不工作。
第四步,设置SCON。方式1加允许接收就是SCON = 0x50,对应SM0=0、SM1=1、REN=1,SM2、TB8、RB8、TI、RI都为0。
第五步,设置PCON的SMOD位,然后根据需求开中断,ES置1、EA置1。
5.2 参考代码
下面给出一份完整的基础初始化代码,晶振11.0592MHz,波特率9600:
#include <reg52.h> void UART_Init(void) { TMOD &= 0x0F; // 只动高四位,不影响定时器0 TMOD |= 0x20; // 定时器1工作方式2,8位自动重装 TH1 = 0xFD; // 9600bps @11.0592MHz,SMOD=0 TL1 = 0xFD; TR1 = 1; // 启动定时器1 SCON = 0x50; // 方式1,REN=1,允许接收 PCON &= 0x7F; // SMOD=0,波特率不加倍 ES = 1; // 开串口中断 EA = 1; // 开总中断 } void UART_SendByte(unsigned char dat) { SBUF = dat; // 写入发送缓冲,启动发送 while (!TI); // 等待发送完成 TI = 0; // 清零发送中断标志 } void UART_ISR(void) interrupt 4 { if (RI) // 接收中断 { unsigned char dat = SBUF; RI = 0; // 软件清零,否则反复进中断 // 在这里处理收到的数据 } if (TI) // 发送中断 { TI = 0; // 软件清零 } }这段代码里有个细节容易踩坑:发送函数用的是查询方式while(!TI),但ES又开着,所以TI置1时一样会进中断,中断服务程序里如果只处理RI不处理TI,发送完一帧后TI没清,程序就会反复跳进串口中断。所以ISR里RI和TI分支都要写完整。
5.3 查询方式怎么用
如果暂时不想用中断,最简配置是SCON = 0x50,ES = 0。发送还是上面的查询方式。收数据时,主循环里轮询RI:
if (RI) { unsigned char dat = SBUF; RI = 0; UART_SendByte(dat); // 回显,验证链路 }调试阶段我强烈建议先用查询方式把收发调通,再改成中断方式。查询方式逻辑清楚,出问题好定位;中断方式效率高,但新手刚上手容易被各种标志位绕晕。
6. 常见问题与排查思路实录
6.1 收不到数据
收不到数据,按这个顺序查:REN是不是1,ES和EA有没有开,TR1有没有启动,SCON的工作方式是不是自己以为的那个方式,最后用示波器或者逻辑分析仪看RXD引脚上有没有实际波形。
还有一个很隐蔽的坑:不少开发板上串口引脚可能跟其他外设共用,比如某个LED或者按键占了P3.0、P3.1,或者USB转TTL模块的跳线帽没接对。芯片本身是好的,但信号根本进不去。这种情况在硬件学习板上特别常见。
6.2 收到乱码
乱码优先怀疑波特率。对比晶振频率和TH1初值是否匹配,对比上位机波特率设置是否一致,对比PCON里的SMOD有没有被无意置1。这三个地方任何一处不一致,结果都是乱码,但具体是不是波特率问题,需要逐项排除。
第二个常见原因是没共地。USB转TTL模块和单片机开发板之间如果没有把GND连在一起,信号电平没有共同参考基准,数据都是乱的。这个问题在接外部模块时发生率极高。第三个原因是接反了线,USB转TTL的TXD要接单片机的RXD,RXD接单片机的TXD,交叉连接,很多同学这里会接成直连,自然收不到。
6.3 数据错位或者丢字节
如果两边波特率有微小误差,短时间通信看不出问题,数据量一大就错位。解决方法就是选择能整除的晶振,或者降低波特率。另一个原因是接收处理太慢,中断服务程序里干了太多耗时的事情,比如在中断里做数码管动态扫描,导致下一帧到来时上一帧还没处理完,数据就被新数据覆盖了。串口中断里尽量只做收数据和置标志位,具体业务逻辑放到主循环里去跑。
6.4 调试心得
调试串口,我的个人做法是分两层:第一层先用查询方式做回环测试,也就是收到什么发什么,上位机能看到自己发出去的内容,说明收发链路通了;第二层再逐步加入业务逻辑,比如解析固定的协议帧。回环测试通过,后面的问题基本都是业务逻辑问题,跟串口本身无关。
另外提一个建议:能打印调试信息是串口最大的价值。平时写代码养成往串口丢状态的习惯,比如进入某个函数打印一个标志,程序跑到哪里、状态对不对,一眼就能看出来。第七天学到串口,后面做综合项目时你才会发现,这东西是调试利器,比那几颗LED好用太多了。