学51单片机到串口这一篇,很多人的感觉是“突然从一个单纯的芯片编程,变成两个设备之间的通信”。前面点灯、数码管、按键,程序跑没跑对一眼就能看出来;但到了UART串口通信,数据是一串看不见的波形在两根线上流来流去,程序有没有按预期工作,全得靠接收端的反馈来判断。
串口是51单片机里最基础也最实用的通信方式,也是后续调试程序和对接各种模块(蓝牙、WiFi、GPS)的“生命线”。这篇内容我按自己从零自学时的理解路径来写:先搞懂硬件机制,再动手写发送,接着写接收,最后聊一些调试中的坑和进阶思路。全程用STC89C52这类经典51内核的单片机讲解,代码可直接抄,原理尽量说得像朋友聊天一样明白。
1. 为什么非学串口不可:从“靠灯猜”到“把数据直接掏出来”
1.1 学完定时器和中断后,最该补上的一课
回想一下你写复杂程序的时候是不是这样:主循环里跑一个计数值,或者一个状态标志位,你想确认它现在到底是多少,结果只能靠LED亮灭硬猜。猜得多了就烦了。
串口解决的就是这个核心痛点:让单片机把内部的变量、状态、传感器数据直接“说出来”,你在电脑上用串口调试助手就能看到。这等于给单片机开了一扇窗口,程序里的任何中间量都可以实时掏出来检查。后面学ADC采样、温湿度传感器读取、PID调参,没有串口调试,效率会低很多。
串口不只是调试工具,它还承担着设备间通信的职责。51单片机可以通过串口和电脑、蓝牙模块、无线透传模块、另一块单片机互相传数据。很多物联网小项目的基本链路就是“传感器 → 单片机 → 串口 → WiFi模块 → 云端”,这里面承担数据搬运工角色的就是UART串口通信。
1.2 UART的通信协议:两线制、异步、帧格式
UART的全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。它只需要两根信号线:TX(发送)和RX(接收),加上一根共地线就能工作。
先说“异步”是什么意思。同步通信(比如SPI、I2C)要求收发双方共用一根时钟线,但异步通信不共享时钟,而是双方各自按预定的波特率自己计时。打个比方:两个人约好“每人每秒说5个字”,发送方按这个节奏说话,接收方也按这个节奏听,不必有根线在旁边打拍子。前提是双方约定的波特率必须一致,误差不能太大,否则就像一个人念得太快另一个人跟不上一样,数据就听岔了。
串口传输一个完整的数据字节,不是直接把8个数据位“平铺”过去,而是按帧格式打包:
| 帧组成 | 默认状态 | 作用 |
|---|---|---|
| 起始位 | 1位,低电平 | 通知接收方“准备好,下面开始传数据” |
| 数据位 | 8位 | 真正要传的数据,一般低位在前 |
| 校验位(可选) | 通常不用 | 奇偶校验,51中很少用 |
| 停止位 | 1位,高电平 | 一个字节传输结束的标志 |
空闲时TX线是持续高电平;发送第一个字节前,先把TX线拉低一个波特率周期,接收方看到这个下降沿就知道“开始读数据了”,然后按波特率一个个采样数据位。传完数据后拉高一个周期作为停止位,接着又回到空闲高电平。这整个时序就是串口的最低层协议。
1.3 波特率到底怎么定义的
波特率就是一秒钟传输多少个码元。串口这里一个码元就是一个电平状态位,所以波特率单位是bps(bits per second)。常用值有4800、9600、38400、115200。
对初学者来说,理解波特率有一个关键点:通信双方必须设置完全相同的波特率。串口调试助手和单片机程序各设各的,两边不一致,收到的就是乱码,而且这种乱码特别有迷惑性,一个字符可能变成几个乱码字符。
更微妙的是,单片机这边的波特率不是芯片自动生成的,而是靠定时器配合系统晶振“算”出来的。这就引出下一个核心话题——51单片机的UART硬件到底是怎么搭出来的。
2. 51单片机UART硬件剖析:SBUF、SCON和定时器1的三角关系
2.1 SBUF:同一个地址,两个方向的双缓冲
在51单片机里,串口数据缓冲寄存器叫SBUF,地址是0x99。有意思的是,它其实是两个独立的物理寄存器共用同一个地址:一个只写,负责发送;一个只读,负责接收。
你向SBUF写一个数据,数据进入发送缓冲,硬件自动按波特率把数据一位一位从TX引脚发出去;你读SBUF,读到的是硬件从RX引脚收回的完整字节。
为什么要搞成双缓冲?因为串口逐位发送的速度相对CPU执行速度来说非常慢,9600波特率下大约每0.104毫秒发一位,而一个完整字节需要约1.04毫秒。CPU把数据丢给发送缓冲后,如果硬件没有“锁存”这个数据而是直接被新数据覆盖,就会发送混乱。双缓冲保证CPU可以快速把数据交给发送硬件,然后腾出时间去干别的,发送由硬件慢慢磨。接收也一样,字节完整收进接收缓冲后,CPU再随时取走。
2.2 SCON寄存器:串口工作的总开关
SCON(Serial Control,串口控制寄存器)直接决定串口的工作方式,几大关键位必须一个个弄清楚。
| 位名称 | 作用 | 常用设置建议 |
|---|---|---|
| SM0 / SM1 | 组合选择4种工作方式 | 01 = 方式1(8位UART,最常用) |
| SM2 | 多机通信使能位 | 点对点通信时置0 |
| REN | 接收使能 | 置1才允许接收数据 |
| TB8 / RB8 | 第9数据位 | 方式1下基本用不到,置0 |
| TI | 发送完成标志 | 硬件置1,必须软件清零 |
| RI | 接收完成标志 | 硬件置1,必须软件清零 |
工作方式1是我们这篇文章的主角:8位数据,1位起始位,1位停止位,没有校验位,波特率由定时器1(或定时器2,51增强型才有)提供。这是绝大多数串口应用的出厂配置。
TI和RI这两个标志位是编程时最容易出错的地方。比如发送数据时,硬件把数据发完会自动把这个标志置1。如果你不清除它,下次再检查时它还是1,你的程序可能认为“上次还没发完”或者“一直发完了”,逻辑就会混乱。接收标志RI同理,收完一个字节硬件置1,软件必须清0,否则下一个字节来了你也分不清是新的还是老的。很多新手写的串口程序死循环卡住,八成就是这两个标志没有在正确的位置清零。
2.3 为什么波特率必须用定时器1的模式2,以及11.0592MHz的数学魔法
51内核的串口波特率,在方式1下由定时器1的溢出率决定,经过硬件的一个分频电路(除以32)后形成实际波特率。所以定时器1的角色是“波特率发生器”。
波特率计算公式如下:
波特率 = (定时器1溢出率) / 32 定时器1溢出率 = 系统时钟频率 / (12 × (256 - TH1))合并起来就是:
波特率 = Fosc / (12 × (256 - TH1) × 32)所以想得到目标波特率,反推定时器初值即可:
TH1 = 256 - Fosc / (12 × 32 × 波特率)定时器1为什么一定要用模式2(8位自动重装)?因为模式2下,TH1存初值,TL1负责计数,TL1溢出后硬件自动把TH1的值重新装入TL1,不需要软件在中断里重新赋初值。串口波特率发生器要求定时器溢出率非常稳定,软件重装初值存在中断延迟,误差会在每个周期积累,导致波特率不稳。自动重装从硬件层面保证了每个周期的长度一致。
再说11.0592MHz晶振。这个数字看着很怪,其实是串口通信的最优解。以9600波特率为例,代入公式:
TH1 = 256 - 11059200 / (12 × 32 × 9600) = 256 - 11059200 / 3686400 = 256 - 3 = 253 = 0xFD正好整除,误差为0。这也就意味着只要初始化代码写对,发送端和接收端的时钟节奏完全一致,通信极其稳定。换一个常见的12MHz晶振试试:
TH1 = 256 - 12000000 / (12 × 32 × 9600) = 256 - 3.255 ≈ 253 或 252初值取253时实际波特率约9615.4bps,误差约0.16%;初值取252时实际波特率约10416.7bps,误差约8.5%。短数据可能勉强能跑,长数据就会乱码。所以做串口通信的板子,老老实实用11.0592MHz晶振,是过来人踩坑踩出来的经验。
3. 发送功能从零写起:初始化、字节发送、字符串发送与上位机验证
3.1 初始化代码逐行讲
先看完整的一份初始化函数:
#include <reg51.h> // 串口初始化:9600bps,方式1,允许接收 void Uart_Init(void) { SCON = 0x50; // 0101 0000:SM0=0 SM1=1(方式1),REN=1(允许接收) TMOD &= 0x0F; // 只改TMOD的高四位,不影响定时器0 TMOD |= 0x20; // 定时器1工作在模式2:8位自动重装 TH1 = 0xFD; // 波特率9600初值(晶振11.0592MHz) TL1 = 0xFD; TR1 = 1; // 启动定时器1 ES = 1; // 打开串口中断 EA = 1; // 打开总中断 }第一行SCON = 0x50是最核心的。SM0=0、SM1=1选取工作方式1;REN=1开启接收。要注意的是,如果你只想发不想收,REN完全可以置0,但实际使用场景几乎都有收发需求,所以直接置1最省事。
第二、三行是设置定时器1。这里有一个常见错误:直接写TMOD = 0x20。如果项目里定时器0已经在用了,这行代码会把定时器0的模式也改掉,导致程序莫名其妙跑飞。正确做法是先清零高四位,保留低四位,再用或运算置位。这个习惯建议从今天养成。
TH1 = 0xFD; TL1 = 0xFD,模式2下TH1和TL1都填同一个初值。实际起计数作用的是TL1,TH1只在溢出后负责自动重装。后面TR1 = 1让定时器1跑起来,波特率发生器开始工作。
最后是关键中的关键:ES = 1; EA = 1。串口中断使能和总中断使能一起打开。很多人问:“我只想查询发送,不开中断行不行?”行,但不建议。因为中断接收的方式才是串口编程的正道,而且中断是否打开,直接影响后续写发送函数时对TI标志的处理方式。下面马上就会讲到这个微妙的区别。
3.2 发送函数的写法与TI标志的正确姿势
发送一个字节的经典写法:
// 串口发送一个字节 void Uart_SendByte(unsigned char dat) { SBUF = dat; // 数据交给硬件,开始发送 while (TI == 0); // 等待发送完成标志置1 TI = 0; // 软件清零,准备下次发送 }逻辑很简单:数据写进SBUF,硬件开始逐位发送,发送完成自动把TI置1,while (TI == 0)跳出循环,然后软件把TI清零。
接着发送字符串:
// 串口发送字符串 void Uart_SendString(char *str) { while (*str != '\0') { Uart_SendByte(*str++); } }这里有个新手极其容易踩的坑:初始化里已经打开了串口中断(ES=1),发送数据时如果把SBUF写成一次,且等待过程中硬件发送完毕触发中断,而中断服务函数里又没有判断RI/TI、没有清理标志,就会造成两个后果:一是程序反复进入中断死循环;二是即使不卡死,TI标志也可能被中断里的错误逻辑提前清掉,主循环的等待检查全部失效。
我在实际调试中踩过一次:初始化时顺手开了ES=1,又在主循环里用查询方式发字符串,结果程序一执行到发送函数就“卡死”。当时一度怀疑是波特率不对,浪费了不少时间排查。后来发现是中断标志没处理导致的中断风暴。解决方式有两个,一是在中断服务函数里正确判断并处理RI和TI;二是如果确定不使用中断接收,就不开ES。更推荐前者,因为后面接收数据一定需要中断。中断服务函数的写法,下一章详说。
3.3 接线、串口调试助手设置、验证发送结果
写入程序之前,要先解决硬件连接。现在的电脑基本没有原生串口,需要用USB转串口模块把电脑USB虚拟成串口。这个模块引出的是TTL电平信号,而51单片机串口引出的也是TTL电平,两者可以直接对接,但前提是接线不能搞错:
- 单片机的TX(P3.1)接到模块的RX
- 单片机的RX(P3.0)接到模块的TX
- 模块的GND与单片机GND必须共地
注意,51单片机的串口引脚默认是P3.0和P3.1,只要使用串口功能,数据就必须从这两个引脚进出。不要试图把TX改到别的引脚,UART硬件功能是固定的。
电脑端打开串口调试助手,设置波特率9600、数据位8、停止位1、无校验,打开对应的COM口。点一下“打开串口”,然后给单片机复位或让程序运行,发送函数就会把指定字符串发到电脑上。调试助手显示区出现你发送的字符,说明发送链路彻底打通。
如果没看到数据,先检查串口有没有被别的程序占用、驱动是否装好、接线有没有接反。我用得最多的排查手段是在调试助手界面上看“接收到的字节计数”是否有变化:如果计数在涨但显示乱码,那问题在波特率或电平;如果计数完全不动,问题大概率在接线或者硬件本身。
4. 接收功能的两种实现:查询与中断,以及不定长数据的处理思路
4.1 查询方式:代码最短,但主循环会“堵车”
接收一个字节的查询写法:
// 查询方式接收一个字节(阻塞等待) unsigned char Uart_RecvByte(void) { while (RI == 0); // 没收到数据就死等 RI = 0; // 清标志 return SBUF; // 返回接收缓冲 }代码确实简单,但问题非常明显:这个函数的“死等”会阻塞主循环,单片机在等待期间什么别的事都干不了。如果主循环里还要扫描按键、刷新数码管、处理定时器任务,一次阻塞可能让整个系统卡顿几毫秒甚至几十毫秒,这在实时性要求稍高的项目里是不可接受的。
还有一种变体的查询写法是查“有没有数据”而不是“死等”,比如每轮主循环检查一次RI是否为1。这种写法不会阻塞,但问题是主循环的执行周期如果不恒定,接收数据的实时性就得不到保证。来个突发的高波特率连续数据流,主循环忙别的任务的间隙就可能丢字节。所以查询方式我只用来处理“按下回车发送一条”这种交互式场景,工程中真正的主角永远是中断接收。
4.2 中断方式:把接收变成后台服务
中断接收的核心思路:设置一个接收缓冲区,在串口中断服务函数里,硬件每收到一个字节就自动存进缓冲区,主循环随时去缓冲区取数据,两者互不干扰。
先定义缓冲区和中断函数:
#define RX_BUF_SIZE 32 unsigned char rx_buf[RX_BUF_SIZE]; // 接收缓冲区 unsigned char rx_index = 0; // 下一个数据存放位置 // 串口中断服务函数 void Uart_ISR(void) interrupt 4 { if (RI) // 接收完成 { RI = 0; // 先清标志再读数据 rx_buf[rx_index++] = SBUF; if (rx_index >= RX_BUF_SIZE) { rx_index = 0; // 防止越界,环形覆盖 } } if (TI) // 发送完成 { TI = 0; // 如果使用中断发送,在这里处理 // 平时用查询发送,TI不会进到这边来 } }这里必须强调一个错误:先清RI标志再读SBUF,还是先读SBUF再清RI?我一开始写反了,先读后清,运行偶尔会丢字节。后来查资料理解到:接收硬件完成一个字节后,如果不清除RI,下一次接收的状态会被置为“被占用”,硬件可能直接丢弃新字节。正确的顺序是先清RI,再读SBUF。这样硬件在读完当前数据的同时已经可以接收下一个字节了,不易丢数据。
主循环侧的使用方式:
void main(void) { Uart_Init(); while (1) { if (rx_index > 0) // 缓冲区里有新数据 { unsigned char i = 0; // 从缓冲区取数据,处理完毕后清零计数器 // 这个场景下不要求实时性,简单示意 } } }从原理上看,中断方式让接收动作完全由硬件事件驱动,CPU只在字节到达的瞬间被硬生生拉进中断服务函数,存好数据立刻返回。主循环的流程完全不会被阻塞,这就是“把接收变成后台服务”的含义。
4.3 收到一串不定长数据时的常用协议思路
串口每收到一个字节就中断一次,但实际的项目消息往往是“一串数据”。例如你从电脑发来一条指令“LED_ON”,单片机收到的是连续的多个字节。怎么判断这一串数据什么时候结束?
简单粗暴的做法是判断帧头和帧尾:约定指令以0xAA开头,以0xFF结尾,收到0xAA开始记录,收到0xFF则认为一条指令结束。
另一种好用的思路是超时判断:规定两个字节之间的间隔如果超过某个时间阈值(比如5毫秒),就认为一帧数据已经接收完毕。实现时借助定时器:每次进入接收中断都重新装载定时初值,定时器溢出则触发“帧超时”标志;主循环检测到超时标志,就把缓冲区里这段数据整体处理掉。
我实际在产品开发中用得最多的是“帧头+长度+数据+校验”的结构,一次中断只负责把字节顺序放进缓冲区,解析工作放在主循环里做。这里不展开协议细节,但方向很明确:中断只负责“接收字节”,解析逻辑全部放到主循环里。中断服务函数越短越好,解析和业务逻辑一旦搬进中断,系统实时性迟早被复杂的处理拖垮。
5. 我踩过的串口乱码坑与进阶玩法
5.1 乱码根源排查:先查波特率,再查接线,最后查芯片
串口通信出问题,表现形式无外乎三种:完全收不到、收到乱码、数据对但时不时多一两个字符。
完全收不到:优先检查接线和共地。尤其共地这一点,很多人把USB转串口的TX、RX接好,却忘了接GND,导致电平参考不一致,模块和单片机之间根本没有统一的电压基准,信号完全不可靠。
收到乱码:第一怀疑对象是波特率不一致。电脑调试助手设置的是9600,单片机实际跑的可能是10417(比如误用了12MHz晶振),那结果一定乱套。第二怀疑对象是晶振误差太大,前文已经算过账。第三则是USB转串口模块本身质量不行,某些劣质模块在115200这种高波特率下丢字符,降到9600又一切正常,这种直接换模块最省心。
数据对但多出字符:多半是调试助手开了“发送新行”,发送每条指令时自动附带了回车换行(0x0D 0x0A),单片机的接收逻辑把这俩字符当成有效数据了。调试阶段把“新行”选项关掉,或者写程序时自己识别并滤除回车换行。
5.2 一个容易被忽略的TI/RI中断标志问题
前面章节提过TI标志在中断和查询混用时的诡异表现,这里再展开一次排查思路。
现象:主循环里明明用查询方式发送字符串,程序却卡在发送函数里。这个问题的核心在于,打开中断后,发送完成触发的也是同一个串口中断(中断号4)。如果中断服务函数只判断了RI而没处理TI,那么TI触发的中断进入服务函数后没被清除,退出中断后硬件立刻再次触发中断,形成无限中断循环。
排查时用Keil的调试功能是最直观的:单步运行到发送函数,观察程序是不是反复跳进中断服务函数;或者先把ES=0关闭串口中断,如果发送立刻恢复正常,那就实锤是中断标志没处理干净。
这个坑看似不起眼,但几乎每个初学者都会撞上一次。我的建议是哪怕目前不打算使用中断发送,也一定要在中断服务函数里同时把RI和TI两个分支都写上。哪怕TI分支只是简单清零,也能避免无数让人抓狂的灵异问题。
5.3 进阶方向:printf重定向、蓝牙/WiFi模块联动、多机通信
串口真正发挥威力,是从“能用printf打印调试信息”开始。在Keil环境下,只需要重定向一个字符输出函数:
// 重定向printf输出到串口 char putchar(char c) { SBUF = c; while (TI == 0); TI = 0; return c; }加上头文件#include <stdio.h>,程序里就可以直接写printf("ADC值: %d\n", adc_val);,再也不用费劲封装各种格式化转换,调试临时变量的速度直接起飞。实测下来,这个功能对系统调试效率的提升是质变级别的。
再往前一步,就是串口在设备间通信中的实战。比如51单片机接一个蓝牙透传模块,把串口数据无线传给手机App;接WiFi模块,用串口AT指令控制模块联网,把传感器数据发到服务器;或者两块51板子通过串口直接对话,一个发指令一个执行,这就是最朴素的多机通信骨架。
对自学单片机的朋友来说,串口是第一个需要“用协议思维”来思考的外设:数据在线上是按帧格式一位一位走的,接收端必须按完全相同的节奏和格式去解析;一旦涉及两个设备,约定永远比技术细节更重要——先定好波特率、帧格式、指令含义,再写代码,项目会顺畅得多。
我做这个项目时最大的体会是:串口通信的开销远不止寄存器配置那几行代码,而是要从通信双方的视角去理解“数据是如何从一方传到另一方的”。从靠灯猜状态,到用串口把变量打印出来,调试体验完全是两个世界。如果你正在学51的中断和定时器,接下来把串口这关啃掉,你的“单片机脑回路”会有一个跳跃式的提升。