51单片机串口通信实战:从基础配置到双向数据交互
1. 串口通信:51单片机与外界对话的“电话线”
如果你玩过51单片机,肯定遇到过这样的场景:单片机自己闷头干活,我们却不知道它到底算出了什么结果,或者我们想让它根据我们的指令做点事情,却不知道怎么告诉它。这时候,你就需要一条“电话线”——串口通信。它就像是单片机和电脑(或者其他设备)之间的一根数据管道,让它们可以互相“说说话”。
我刚开始学单片机那会儿,最兴奋的时刻就是第一次让单片机通过串口在电脑屏幕上打出“Hello World”。那一刻,感觉这个小小的芯片真的“活”了过来。串口通信可以说是单片机开发中最基础、最实用,也是新手必须跨过的一道坎。它不仅仅是发送几个字符那么简单,更是你理解单片机如何与外部世界交互的钥匙。无论是调试程序时打印变量值,还是做智能小车时接收遥控指令,都离不开它。
很多人觉得配置寄存器、计算波特率很枯燥,其实你完全可以把它想象成设置一部老式电话:波特率就是你们约定的说话语速,数据位、停止位就是你们约定的暗号格式,而寄存器就是电话机上的各种功能按键。一旦拨通,信息就能自由传递。这篇文章,我就把我这些年调试51单片机串口攒下的实战经验,从最基础的寄存器配置,到能收能发的双向对话,掰开揉碎了讲给你听,保证你跟着做一遍就能搞定。
2. 核心原理:串口是如何“一位一位”聊天的
2.1 串行 vs. 并行:单车道与多车道的抉择
要理解串口,得先搞明白单片机是怎么传输数据的。数据通信主要有两种方式:并行通信和串行通信。
你可以把并行通信想象成一条宽阔的八车道高速公路。51单片机的P0、P1这些端口,如果用来传数据,就是典型的并行通信。比如P1口有8个引脚(P1.0到P1.7),一次就可以同时发送8位数据(一个字节)。这种方式速度飞快,就像八辆车并排同时出发。但缺点也很明显:需要占用的“车道”(物理引脚)太多,线路复杂,成本高,而且信号在长距离传输时容易互相干扰,所以一般只用在芯片内部或者板卡上距离很近的器件之间。
而串行通信,就像是一条单车道乡村公路。所有数据(无论是8位、16位还是32位)都必须排成一队,一个接一个地通过这一根数据线发送出去。显然,它的速度比不上并行通信。但是,它的优势巨大:只需要两根线(一根发送TX,一根接收RX,再加上一根地线GND)就能完成双向通信,线路简单,成本极低,抗干扰能力相对更强,非常适合像单片机和电脑之间这种需要跨越“山河大海”的远距离通信。我们常说的UART、USB、I2C、SPI,本质上都属于串行通信的范畴。51单片机内置的,就是UART(通用异步收发传输器)。
2.2 51单片机串口内部结构大揭秘
知道了串行通信是“单车道”,我们再来看看51单片机内部,是谁在管理这条车道。理解了这个结构,后面配置寄存器就不会晕了。
51单片机的串口核心部件其实不多,但环环相扣:
- 两个SBUF(串行数据缓冲器):这是最容易搞混的地方。实际上,单片机内部有两个名字都叫SBUF的寄存器,一个专门管发送,一个专门管接收。但对我们编程来说,它们共用同一个地址(99H)。当我们执行
SBUF = data;这条语句时,数据就被写入了发送SBUF;当我们执行data = SBUF;时,数据是从接收SBUF中读取出来的。硬件会自动区分,我们不用操心。 - 波特率发生器(通常是定时器T1):这是串口的“心跳”或“节拍器”。通信双方必须用同样的速度发送/接收每一位数据,这个速度就是波特率(比如9600 bps,表示每秒传输9600个二进制位)。51单片机常用定时器1(T1)工作在模式2(8位自动重装模式)来产生这个精确的频率信号。注意,这里我们只是把T1当作一个精准的时钟源来用,并不需要它产生中断。
- 串行控制寄存器SCON:这是串口的“大脑”或“控制中心”。串口用什么模式工作(模式1、2、3)、允不允许接收数据、数据发完了没有、数据收完了没有,所有这些状态和控制,都由SCON这个寄存器的各个位来决定。我们编程时一大半工作就是在和它打交道。
- 移位寄存器:这是一个幕后英雄。当数据从接收引脚(RXD)一位一位进来时,移位寄存器会默默地先把它们按顺序拼装好,凑满一个完整的字节后,再一次性转存到接收SBUF中,并通知CPU“数据到货了”。
把这几个部件连起来看,发送流程就是:你写数据到发送SBUF -> 在波特率发生器节拍下,数据通过移位寄存器一位一位从TXD引脚挪出去。接收流程则相反:数据从RXD引脚一位一位进来 -> 移位寄存器组装 -> 存到接收SBUF -> 通知你来取。
3. 关键寄存器配置:手把手设置你的通信协议
理论懂了,接下来就是实战配置。配置串口,主要就是设置三个地方:SCON、PCON和定时器T1。别怕,我们一步步来。
3.1 SCON寄存器:模式与状态的总开关
SCON寄存器(地址98H,可以位寻址)的每一位都至关重要。我们常用的51单片机串口工作模式是模式1,这是一个8位数据位、波特率可变的模式,也是最常用的模式。我们就以它为例来讲解。
SCON寄存器位结构 (模式1下我们关心的位):
| 位 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
|----|-----|-----|-----|-----|-----|-----|-----|-----|
| 名 | SM0 | SM1 | SM2 | REN | TB8 | RB8 | TI | RI |
- SM0和SM1(工作模式选择位):这两位组合决定串口的工作模式。对于模式1,需要设置
SM0=0, SM1=1。 - REN(接收使能位):这是串口接收功能的“总闸”。
REN=1,单片机才愿意竖起耳朵听RXD引脚上传来的数据;REN=0,则关闭接收功能。如果你想做双向通信(既能发也能收),这个位必须置1。 - TI(发送中断标志位):这是硬件自动管理的。当你把数据写入发送SBUF后,硬件开始发送。当一帧数据(比如8位)全部发送完毕时,硬件会自动把TI置1。这个“1”是一个信号,告诉CPU:“活儿干完了!”如果你开启了串口中断,这个信号就会触发中断。特别注意:TI不会自动清零! 必须在中断服务程序里(或者用软件查询的方式)手动把它清零,否则它会一直认为是发送完成状态。
- RI(接收中断标志位):和TI类似,当单片机从RXD引脚完整接收完一帧数据,并把它存到接收SBUF后,硬件会自动把RI置1。这个信号告诉CPU:“有快递,快来取!”同样,RI也必须由软件手动清零。
所以,对于一个典型的双向通信初始化,SCON可以这样配置:SCON = 0x50;。我们来拆解一下:0x50的二进制是0101 0000。从高位到低位:SM0=0, SM1=1(模式1),SM2=0(先不用管),REN=1(允许接收),后面四位(TB8, RB8, TI, RI)通常初始化为0。
3.2 PCON寄存器与波特率加倍
PCON(电源控制寄存器,地址87H,不可位寻址)里我们只关心最高位:SMOD。
- SMOD(波特率倍增位):当
SMOD=1时,串口模式1、2、3的波特率会在定时器产生的原始波特率基础上加倍;SMOD=0时则不变。默认是0。 这个位有什么用呢?有时候我们想要一个比较高的波特率(比如115200),但受限于单片机时钟频率和定时器重装值的限制,算不出来。这时就可以通过置位SMOD,让波特率翻倍,相当于多了一个选择。配置很简单:PCON |= 0x80;就是把最高位置1,实现加倍。
3.3 定时器T1配置:精准产生通信“心跳”
前面说了,波特率发生器通常由定时器T1担当。我们让它工作在模式2(8位自动重装模式)。这个模式的好处是,你只需要设置一次重装值(TH1),每次定时器溢出后,硬件会自动把TH1的值重新装入TL1,开始新一轮计时,从而产生非常稳定、连续的溢出脉冲,这正是我们需要的精准波特率时钟源。
波特率计算公式(模式1,使用T1模式2): 波特率 = (2^SMOD / 32) * (定时器T1的溢出率) 而 定时器T1溢出率 = 单片机晶振频率 / (12 * [256 - TH1])
这里有个经典组合:当使用11.0592MHz的晶振时,为了得到9600的标准波特率,并且设置SMOD=0(不加倍),我们可以倒推出TH1的重装值: TH1 = 256 - (11059200 / (12 * 32 * 9600)) = 253 = 0xFD 如果波特率要加倍(SMOD=1),计算值会不同。
配置代码看起来是这样的:
// 配置定时器1为模式2,8位自动重装
TMOD &= 0x0F; // 清零T1的控制位(高4位)
TMOD |= 0x20; // 设置T1为工作模式2 (M1=1, M0=0)
TH1 = 0xFD; // 设置重装值,对应9600波特率@11.0592MHz
TL1 = 0xFD; // 初始化计数初值
TR1 = 1; // 启动定时器1
ET1 = 0; // **关键!禁止定时器1中断,我们只用它做波特率发生器**
记住最后一点:ET1=0。很多新手会在这里犯错,打开了定时器中断,然后程序莫名其妙地跳转。我们不需要T1中断,它安静地当个时钟源就好。
4. 实战演练一:单片机主动向电脑“汇报”
光说不练假把式。我们先来一个最简单的场景:让单片机每隔一段时间,主动向电脑的串口助手发送一个递增的数字。这个场景里,单片机只发不收,所以不需要开启接收功能,甚至可以不打开串口中断,用“查询”的方式等待发送完成。
第一步:初始化串口(只发不收)
void UART_Init_SendOnly(void) // 4800bps@11.0592MHz, 仅发送初始化
{
SCON = 0x40; // 模式1 (SM0=0, SM1=1), REN=0(禁止接收)
PCON |= 0x80; // 波特率加倍,可根据需要调整
// 配置定时器1为波特率发生器
TMOD &= 0x0F;
TMOD |= 0x20; // 定时器1,模式2
TH1 = 0xF4; // 波特率4800加倍后约等于9600的装载值
TL1 = 0xF4;
ET1 = 0; // 关闭定时器1中断
TR1 = 1; // 启动定时器1
// 注意:这里没有开总中断EA和串口中断ES,因为我们用查询法
}
这里SCON配置为0x40(0100 0000),REN=0,单片机不会理会接收线上的数据。
第二步:编写发送一个字节的函数(查询法)
void UART_SendByte(unsigned char dat)
{
SBUF = dat; // 将数据写入发送缓冲器,启动发送
while(TI == 0); // 等待发送完成。硬件发送完一帧数据后,会将TI置1
TI = 0; // **软件清除发送完成标志**
}
这个函数是核心。while(TI == 0); 这行代码就是在“查询”或“轮询”TI标志位。只要TI还是0,就说明数据还没发完,程序就在这里死等。一旦TI变成1,就跳出循环,然后立刻手动把TI清零,为下一次发送做准备。
第三步:主函数循环发送
#include <REGX52.H>
#include "Delay.h" // 假设你有延时函数
unsigned char counter = 0;
void main()
{
UART_Init_SendOnly(); // 初始化串口(只发)
while(1)
{
UART_SendByte(counter); // 发送当前计数值
counter++; // 计数值加1
DelayMs(500); // 延时500毫秒,避免发送太快看不清
}
}
把程序烧录进单片机,用USB转TTL模块连接单片机的TXD(P3.1)到电脑,打开串口助手(如XCOM、SSCOM),选择对应的COM口,设置波特率(这里因为PCON加倍了,实际波特率是9600),数据位8,停止位1,无校验。你就能看到串口助手接收区里,数字从0开始不断累加。恭喜你,完成了单向数据发送!
5. 实战演练二:电脑发指令,单片机控制LED
单向发送只是小试牛刀。更酷的是让电脑控制单片机。我们来实现:电脑通过串口发送一个字节数据(比如0xF0),单片机收到后,用这个数据直接控制P2口连接的8个LED灯,同时再把收到的数据原样发回给电脑,作为应答。
第一步:初始化串口(能收能发,开启中断)
void UART_Init_Full(void) // 9600bps@11.0592MHz, 全双工初始化
{
SCON = 0x50; // 模式1 (SM0=0, SM1=1), REN=1(允许接收)!
PCON |= 0x80; // 波特率加倍
// 配置定时器1
TMOD &= 0x0F;
TMOD |= 0x20;
TH1 = 0xFA; // 调整TH1值以获得加倍后准确的9600波特率(计算值)
TL1 = 0xFA;
ET1 = 0;
TR1 = 1;
// **开启中断**
ES = 1; // 允许串口中断
EA = 1; // 开启CPU总中断开关
}
关键变化:SCON=0x50,打开了接收使能REN。同时,我们打开了串口中断(ES=1)和总中断(EA=1)。这意味着,当数据接收完成(RI置1)或发送完成(TI置1)时,CPU会暂时跳转到中断服务函数去处理。
第二步:编写中断服务函数 中断服务函数是处理收发事件的核心。它有一个固定的格式和中断号(串口中断是interrupt 4)。
void UART_ISR(void) interrupt 4 // 串口中断服务函数
{
unsigned char recv_data;
if(RI == 1) // 首先判断是否是接收中断
{
RI = 0; // 必须软件清除接收中断标志
recv_data = SBUF; // 从接收SBUF读取数据
// 应用层处理:用接收到的数据控制LED
P2 = recv_data;
// 作为应答,将收到的数据再发回去(回显)
SBUF = recv_data; // 写入发送SBUF,启动发送
// 注意:这里发送完成后会触发发送中断(TI=1)
}
if(TI == 1) // 再判断是否是发送中断
{
TI = 0; // 必须软件清除发送中断标志
// 如果需要,可以在这里处理发送完成后的工作,比如设置一个标志位
// 本例中发送是回显,无需额外处理
}
}
这个函数逻辑很清晰:
- 进入中断后,先检查是不是因为收到了数据(RI==1)。如果是,就清除RI,然后赶紧把数据从SBUF里读出来存到变量里(否则可能被新数据覆盖)。
- 立刻用这个数据控制P2口的LED(
P2 = recv_data;)。 - 接着,把收到的数据原封不动地再塞回发送SBUF,发还给电脑。这步操作会启动发送过程。
- 发送完成后,硬件会置位TI,再次进入中断。在中断里判断是TI,就把它清零。
第三步:主函数
void main()
{
UART_Init_Full(); // 初始化全双工串口
while(1)
{
// 主循环可以空着,或者处理其他任务
// 所有串口收发事件都由中断服务函数自动处理,不占用主循环时间
}
}
主函数异常简单,初始化完就进入一个空循环。这就是中断的魅力——异步处理。单片机不用傻等着数据来,它可以去执行其他任务(比如扫描按键、刷新显示),当数据到来时,中断会暂时打断主程序,处理完数据后又立刻返回。这样大大提高了CPU的效率。
实验现象:连接好电路(P2口接8个LED,串口连接电脑)。上电后,在串口助手的发送框里输入十六进制数,比如F0(二进制11110000),点击发送。你会看到单片机上的LED灯(假设低电平点亮)高4位亮,低4位灭。同时,在串口助手的接收区,你会看到单片机回传的相同数据F0。这就完成了一次完整的“接收-处理-应答”双向交互。
6. 避坑指南与高级技巧
掌握了基本收发,你已经能应付大部分场景。但实际项目中,还有一些坑和技巧需要注意。
坑1:波特率误差与晶振选择 波特率计算对晶振频率非常敏感。为什么教程里总推荐11.0592MHz这个奇怪的晶振?因为它能被11059200 / (12 * 32 * 波特率) 整除,计算出的TH1是整数,没有误差。如果你用12MHz晶振去算9600波特率,TH1 = 256 - 12000000/(12329600) ≈ 253.xxx,取整后实际波特率会有误差,当通信数据量大、速度快时,就可能出现乱码。所以,做串口通信,强烈建议使用11.0592MHz晶振。
坑2:中断服务函数里的“快进快出” 中断函数应该像消防员一样,接到警报后迅速处理火情,然后立刻撤离。不要在中断服务函数里做复杂的运算、长时间的延时(如DelayMs(1000))或者调用可能阻塞的函数。这会导致其他中断无法及时响应,甚至影响主程序的正常运行。我们的例子中,只是读取数据、控制IO、回发数据,都是简单操作,符合要求。
坑3:数据丢失与缓冲区 我们的例子一次只处理一个字节。如果电脑发送速度很快,单片机还没处理完上一个中断,下一个数据又来了,就可能丢失数据。对于高速或大数据量通信,需要在中断里建立一个环形缓冲区。接收中断只负责把数据从SBUF快速搬到缓冲区数组里,并移动写指针;主循环则从缓冲区读取数据并处理。发送亦然。这是提升串口通信可靠性的关键一步。
技巧:字符串发送函数 实际应用中,我们更常发送字符串(比如调试信息"Temperature: 25C\r\n")。可以基于字节发送函数封装一个字符串发送函数:
void UART_SendString(unsigned char *str)
{
while(*str != '\0') // 遍历字符串,直到遇到结束符\0
{
UART_SendByte(*str); // 发送当前字符
str++; // 指针指向下一个字符
}
}
这样,在主程序里就可以直接 UART_SendString("Hello, PC!\r\n");,非常方便。
技巧:使用printf重定向进行格式化输出 如果你觉得上面那种方式还不够方便,可以借助标准库的printf函数。通过重写putchar函数,可以将printf的输出重定向到串口。这样你就可以像在电脑上编程一样,使用printf("Value = %d, Hex = 0x%02X\r\n", var, var);这样的格式化字符串了,对于调试复杂程序简直是神器。具体实现需要了解编译器的底层支持,这里不展开,但绝对是值得深入学习的进阶技能。
从最基础的寄存器配置,到查询式发送,再到中断驱动的全双工收发,最后到缓冲区和格式化输出的高级应用,串口通信的玩法是层层递进的。我建议你先把我上面两个实战例子在开发板上跑通,看到灯随指令亮灭,看到数据在电脑和单片机间来回穿梭,那种亲手打通“任督二脉”的成就感,是看多少遍教程都换不来的。遇到问题,多检查接线(TX接RX,RX接TX,共地)、波特率、中断标志位清除,这几个是新手最容易出错的地方。串口玩熟了,你就为51单片机打开了一扇通往更广阔世界的大门,后面玩蓝牙模块、GPS模块、Wi-Fi模块,底层都是这套串口通信的逻辑。
更多推荐


所有评论(0)