简介:基于51单片机的双机通信项目,演示甲机按键控制两机LED按全灭、LED1亮、LED2亮、全亮顺序循环点亮,乙机按键则向甲机依次发送0-9数字并由甲机数码管实时显示,覆盖了串口通信、按键扫描和数码管驱动等常见单片机应用场景。压缩包共51个文件,包含Proteus仿真工程DSN/DBK、Keil源代码工程uvproj/C/hex、原理图SchDoc/PDF、流程图bmp、元件清单xlsx及功能介绍文档,大小仅1.05MB,目录结构清晰,仿真、源码、图纸分层存放,方便按需查阅。目前已有241人学习下载。借助仿真文件可无硬件直接运行验证双机交互效果,结合原理图与流程图能快速理解通信协议和电路设计,源代码附带详细注释和构建配置,适合51单片机初学者进阶,也可直接作为课程设计、毕业设计的参考方案。 解压这个工程后,先别急着点开仿真.DSN。表面上是数码管、LED和按键在动,实际上考验的是两片51之间的串口链路:甲机按键切换LED状态,乙机LED要同步变化;乙机按键发数字,甲机数码管要立刻显示。这两件事合起来,就是一次完整的一主一从双机通信。
很多51课设卡在“发过去的字节收不到”或者“收到的是乱码”,本质是TX/RX交叉、波特率初值、中断处理三个环节没串起来。这个包里的Proteus仿真、原理图、流程图和两份Keil工程正好补上了这条链路。
如果你是准备课程设计答辩,或正从单机程序往通信程序过渡,这份资源能当可复现的起点。下面按硬件连接、协议约定、代码实现和联调验证的顺序拆开讲。
2. 51双机通信的硬件链路:TX/RX交叉与LED、数码管的端口分配
2.1 为什么两端必须TX/RX交叉并共地
51单片机自带的UART工作在TTL电平,发送端TXD输出逻辑0时约为0V,逻辑1时接近电源电压。接收端RXD同样按这个电平判断。所以点对点数据传输只需要三条线:甲机TXD接到乙机RXD,甲机RXD接到乙机TXD,两边的GND接在一起。这里最容易出错的是把TX/RX直连,导致两边都在同一根线上发送,接收端什么也收不到。
从硬件拓扑看,这就是一个双机串行网络的最简形态。协议层上,通信是异步的:双方各自以约定的波特率对电平变化采样,不需要额外时钟线。也正因如此,波特率一旦不一致,接收端拿到的就是乱码。Proteus仿真里GND不接也能仿真成功,因为仿真模型把地当作公共参考点;做成实物时共地必须检查,否则电平参考不一致,数据就会漂移。
包内的原理图Sheet1.SchDoc对应Altium Designer工程,如果不想装AD,直接看工程里导出的PDF版原理图也可以。画图时除了串口两条线,还要注意电源引脚和复位引脚,Proteus里的单片机模型默认不上电也能工作,但实物按此接线则完全不行。
2.2 端口映射与LED驱动方式
包内源码里,甲机和乙机的端口分配并不是随意来的。甲机P1接4个LED,P2接共阳数码管的段码,P3.2接按键,P3.0和P3.1分别作为RXD和TXD。乙机P1也接4个LED,P3.3接按键,P3.0和P3.1与甲机交叉连接。这样分配的原因是P1口内部有上拉电阻,做输出时拉低能力比拉高能力强,LED适合做成低电平点亮。
我按低电平点亮的思路定义状态映射:0x0F是全灭,0x0E是LED1亮,0x0D是LED2亮,0x03是全亮。四个状态对应两个LED的组合,和题目描述完全一致。数码管如果选共阳,段码表就是0xC0、0xF9、0xA4这些反码形式;如果选共阴,需要把段码取反。Proteus原工程里用的是共阳数码管,打开原理图PDF能看到公共端接VCC、段码经电阻到P2。
| 模块 | 端口/接法 | 关键参数 |
|---|---|---|
| 4个LED | P1.0-P1.3,接限流电阻到VCC | 330Ω~470Ω |
| 数码管段码 | P2.0-P2.7,公共端接VCC | 段电阻220Ω |
| 甲机按键K1 | P3.2到GND | 加10nF电容或软件消抖 |
| 乙机按键K2 | P3.3到GND | 同上 |
| 晶振 | XTAL1/XTAL2 | 11.0592MHz,两个30pF电容 |
2.3 物料清单里容易被忽略的参数
打开包里的元件清单.xlsx,元件数量并不多,但有几个参数会直接影响实物调试。晶振频率一定要选11.0592MHz,不是为了玄学,而是为了9600波特率下定时器初值TH1=0xFD没有累积误差。如果换成12MHz,串口波特率误差约2.1%,短帧可能侥幸能通,但做双机通信建议直接用11.0592MHz。
第二个容易被忽略的是复位电路。仿真里复位往往是软件初始状态,但实物上电若不复位,单片机会从随机PC地址开始跑。常见做法是10μF电解电容接RST到VCC,10kΩ电阻接RST到GND。另外,双机共地后,如果甲机用独立电源供电,乙机也用独立电源供电,两个电源的地必须接在一起,否则UART电平参考不同,数据完全不可靠。
3. 指令协议设计:一个字节同时承载LED状态和数字数据
3.1 帧格式约定
这个工程的通信内容只有两种:LED状态命令(0xF0~0xF3)和数字数据(0x00~0x09)。如果为每个状态定义一个独立命令,接收端代码会变成一长串if else;如果直接把数字0-9发过去,接收端又分不清这个字节是数据还是命令。最简单的方法是保留字节的最高位作为类型标识。
| 发送值 | 含义 | 接收方动作 |
|---|---|---|
| 0x00-0x09 | 乙机发送的数字 | 甲机数码管显示对应数字 |
| 0xF0 | LED全灭 | 甲乙机LED全灭 |
| 0xF1 | LED1亮 | 甲乙机LED1点亮 |
| 0xF2 | LED2亮 | 甲乙机LED2点亮 |
| 0xF3 | LED全亮 | 甲乙机LED全亮 |
甲机按下按钮时,向乙机发送对应的0xF0~0xF3,同时更新本机LED。乙机收到后根据“高4位是否为0xF”区分命令,再取低两位设置LED状态。乙机按下按钮时,把0-9循环发送,甲机在串口中断里判断“小于10”就刷新数码管。这样双机复用同一条串口链路,协议上却互不干扰。
这里特意没有用ASCII码,比如发送'0'(0x30)而不是0x00。如果发送ASCII,接收端要先减0x30才能还原数字,还浪费了帧空间。课程设计时直接传二进制数字更直观,Proteus虚拟终端里切到Hex模式也能看得很清楚。
3.2 甲机代码:按键状态机与串口发送
甲机需要完成两件事:按下K1时切换LED状态并发送命令;串口中断接收乙机发来的数字并刷新数码管。main.c里可以这样组织:
#include <reg51.h> #define LED_PORT P1 #define SEG_PORT P2 sbit KEY1 = P3^2; typedef unsigned char u8; typedef unsigned int u16; u8 code seg_code[10] = { 0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90 }; u8 code led_state_map[4] = { 0x0F, // 全灭 0x0E, // LED1亮 0x0D, // LED2亮 0x03 // 全亮 }; void delay_ms(u16 t) { u16 i, j; for (i = 0; i < t; i++) for (j = 0; j < 123; j++); } void UART_Init(void) { SCON = 0x50; // 串口模式1,8位数据,允许接收 TMOD &= 0x0F; // 清空定时器1控制位 TMOD |= 0x20; // 定时器1工作于8位自动重装模式 TH1 = 0xFD; // 11.0592MHz下获得9600波特率 TL1 = 0xFD; TR1 = 1; ES = 1; EA = 1; } void UART_SendByte(u8 dat) { SBUF = dat; while (TI == 0); TI = 0; } void main(void) { u8 state = 0; LED_PORT = led_state_map[0]; SEG_PORT = seg_code[0]; UART_Init(); while (1) { if (KEY1 == 0) { delay_ms(10); if (KEY1 == 0) { while (KEY1 == 0); // 等待释放,避免一次按压多次触发 state = (state + 1) % 4; LED_PORT = led_state_map[state]; UART_SendByte(0xF0 | state); // 0xF0~0xF3 } } } } void UART_ISR(void) interrupt 4 { u8 rx; if (RI) { RI = 0; rx = SBUF; if (rx < 10) { SEG_PORT = seg_code[rx]; } } if (TI) { TI = 0; // 发送完成中断,必须清标志 } }这段代码里,led_state_map数组的顺序直接对应“全灭、LED1亮、LED2亮、全亮”,初始状态显示全灭。按键检测先延迟10ms消抖,再等待按键释放,这样每按一次只会切换一个状态。UART_SendByte是阻塞发送,先写SBUF,等TI置1后清零,不会和中断接收互相干扰,因为发送和接收使用不同的硬件缓冲路径。
3.3 乙机代码:循环发送数字与LED命令接收
乙机的逻辑相反:按键发送0-9,串口中断接收LED命令并修改P1。因为乙机不显示数码管,所以不需要段码表,只需要LED映射表和发送函数:
#include <reg51.h> #define LED_PORT P1 sbit KEY2 = P3^3; typedef unsigned char u8; typedef unsigned int u16; u8 code led_state_map[4] = { 0x0F, 0x0E, 0x0D, 0x03 }; u8 send_num = 0; void delay_ms(u16 t) { u16 i, j; for (i = 0; i < t; i++) for (j = 0; j < 123; j++); } void UART_Init(void) { SCON = 0x50; TMOD &= 0x0F; TMOD |= 0x20; TH1 = 0xFD; TL1 = 0xFD; TR1 = 1; ES = 1; EA = 1; } void UART_SendByte(u8 dat) { SBUF = dat; while (TI == 0); TI = 0; } void main(void) { LED_PORT = led_state_map[0]; UART_Init(); while (1) { if (KEY2 == 0) { delay_ms(10); if (KEY2 == 0) { while (KEY2 == 0); UART_SendByte(send_num); // 发送0~9 send_num = (send_num + 1) % 10; } } } } void UART_ISR(void) interrupt 4 { u8 rx; if (RI) { RI = 0; rx = SBUF; if ((rx >> 4) == 0x0F) { LED_PORT = led_state_map[rx & 0x03]; } } if (TI) { TI = 0; } }注意两个单片机的中断服务函数都同时清RI和TI。UART_ISR是串口中断向量4,RI和TI共用这个入口;如果只清RI不改TI,发送完成标志一直置1,中断会被反复触发,程序就会卡在空转里。这是初学者最容易忽略的问题。
3.4 为什么接收放在中断函数里而不是主循环
如果主循环里直接查询RI,代码会写成while (RI == 0); rx = SBUF;,这在单机运行里没问题,但双机通信时CPU在等待接收期间就无法扫描按键。比如乙机主循环正在等数字发送,此时甲机发来LED命令,乙机要等到当前循环空闲才处理,响应就会变慢。
把接收放到中断里,等于让串口硬件具备异步通知能力。乙机按键发送数字和接收LED命令是两个独立事件,一个在主循环里处理,一个在中断里处理,互不阻塞。51的串口接收缓冲只有1字节,如果主循环长时间不做别的事,下一帧到来时上一帧会被覆盖,所以中断处理能最大程度降低丢帧概率。
4. Proteus仿真与Keil联调:加载hex、波特率初值、串口波形排查
4.1 打开双机仿真工程并加载两份hex
用Proteus打开仿真.DSN后,能看到两个AT89C51芯片,甲机旁边是数码管和LED,乙机旁边是LED和按键。运行前要确认每颗芯片都加载了正确的固件:双击甲机芯片,在Program File里选择甲程序目录下的template.hex;双击乙机芯片,选择乙程序目录下的template.hex。如果改了源码,需要先在Keil里重新编译,再确认Output选项里的Create HEX File已勾选。
这里有个细节:包内两份Keil工程名都叫template,但所在目录不同,分别对应甲机和乙机。改代码时不要改错工程。仿真运行时,甲机按下K1,两边的LED应该按“全灭→LED1亮→LED2亮→全亮”循环;乙机每按一次K2,甲机数码管从0跳到1,一直到9后再回到0。
仿真不受烧录器限制,这是Proteus最大的优势。但仿真不能替代实物验证,因为仿真模型不会反映输出引脚驱动能力不足、按键抖动毛刺、电源纹波这些问题。
4.2 波特率初值的计算方式
串口模式1的波特率由定时器1提供,计算公式为:波特率 = (2^SMOD / 32) × (fosc / (12 × (256 - TH1)))。SMOD默认为0,fosc取11.0592MHz,代入TH1=0xFD即253,算出来为(1/32) × (11059200 / (12 × 3)) = 9600。这也是为什么代码里TH1和TL1都写0xFD。
| 晶振频率 | 目标波特率 | TH1初值 | 实际波特率误差 |
|---|---|---|---|
| 11.0592MHz | 9600 | 0xFD | 0% |
| 12MHz | 9600 | 0xFD | 约2.1% |
| 12MHz | 4800 | 0xF9 | 约0.16% |
如果手头只有12MHz晶振,建议把波特率降到4800,误差更小。双机通信时两端必须用一样的晶振频率和TH1初值,否则接收端采到的位序会偏移。
4.3 仿真中的常见故障排查
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 甲机按键后LED不动 | 端口接反或LED驱动方向不对 | 检查P1是否高电平灭、低电平亮 |
| 乙机LED不跟随甲机 | TX/RX没有交叉,或hex加载错芯片 | 核对TXD→RXD、RXD→TXD |
| 数码管显示乱码 | 段码表与共阳/共阴不匹配 | 共阳用0xC0系列,共阴取反 |
| 按键按一下跳多个状态 | 没有消抖或没有等待释放 | 增加delay_ms和while释放检测 |
| 通信完全无响应 | 波特率不一致或GND未共地 | 统一晶振为11.0592MHz,检查仿真GND |
有一个很隐蔽的坑是:Proteus中如果晶振频率设置成12MHz,而代码按11.0592MHz计算TH1,仿真里显示几乎正常,但用虚拟终端观察会发现字符错位。原因在于Proteus的串口模型对波特率误差比真实芯片更敏感,所以仿真工程和Keil工程里都要把晶振频率改成11.0592MHz。
4.4 用虚拟终端和示波器观察串口波形
Proteus左侧工具栏里放一个Virtual Terminal,把TXD引脚接上去,运行后能看到甲机发送的数据。虚拟终端默认按ASCII显示,所以0xF0这类十六进制字节会显示成乱码,需要右键虚拟终端选择Hex Display模式。示波器接在TXD引脚上,按下K1时会看到一串低电平起始位加8个数据位加停止位的波形,每个位宽约104μs。
观察波形时要确认起始位是下降沿,因为UART空闲时是高电平,发送字节先拉低一个位时间,接收端靠这个下降沿对齐采样。如果示波器上看到两个连续下降沿之间没有明显高电平恢复,说明数据位全为0或波特率不对,需要回到波特率计算检查。
5. 逻辑分析仪验证时序,并把协议升级成带帧头的格式
5.1 用逻辑分析仪抓一帧9600波特率的数据
手头有逻辑分析仪时,比示波器更好用。把通道接在甲机TXD,采样率设为1MHz以上,触发方式选下降沿,波特率设为9600,抓一次K1按下的事件。一帧数据包含1位起始位(低电平)、8位数据位(最低位在前)、1位停止位(高电平),总耗时约10个位时间,约1.04ms。
以发送0xF1为例,二进制是11110001,数据位从低位到高位依次是1、0、0、0、1、1、1、1。逻辑分析软件里应该能看到:起始位低电平后,第1个数据位是高,第2到第4个数据位是低,剩余是高。如果看到的波形相反,说明发送端代码或电平极性有问题,检查是否误用了取反后的段码表。
5.2 把单字节协议升级为带帧头和校验的版本
原工程一字节通信在课程设计范围内够用,但批量传输时无法防止干扰。简单升级是先发0xAA帧头,再发命令字节,最后发校验和,接收端只在帧头匹配后才解析数据。发送函数可以写成:
void UART_SendFrame(u8 cmd) { u8 sum = cmd; SBUF = 0xAA; while (!TI); TI = 0; SBUF = cmd; while (!TI); TI = 0; SBUF = sum; while (!TI); TI = 0; }接收端判断第一个字节是0xAA后才进入帧解析状态,第二个字节写入缓存,第三个字节用于验算。这样的好处是数据里出现任意值都不会引起误动作,因为接收方只在帧头对齐后才会更新状态。如果需要传输两字节以上的数据,可以把校验和改为对整帧求和取低8位,或者直接用CRC8,代码复杂度增加不多,但实用性更强。
本文还有配套的精品资源,点击获取