串口通信这个模块,在蓝桥杯单片机组的赛题里,属于那种“不考反而奇怪”的考点。不管是省赛还是国赛,串口几乎都会出现:要么让单片机把采集到的温度、电压发给电脑,要么让电脑通过串口助手发一条指令来控制板上的LED、数码管、蜂鸣器。CT107D是官方使用的竞赛平台,串口相关的硬件和代码套路都很固定,一旦把波特率、寄存器、中断这三个点吃透,这类题基本就是送分。
这篇内容写给自己动手备赛的同学,尤其是那种“抄官方例程能点亮,但换个波特率就懵、一进中断就乱”的初学者。我会从板子上的物理连线开始讲,一直讲到能直接上赛场的完整代码框架,最后把比赛现场常见的翻车点也帮你理一遍。只要跟着走一遍,串口对你来说就不再是玄学。
1. 赛题视角:串口通信为什么绕不开
1.1 高频考点背后的逻辑
蓝桥杯的单片机赛题,表面上考的是某个具体外设,实际上考的是“单片机系统与外界的交互能力”。LED、数码管、按键这些东西是单向的,要么输入要么输出,但串口是双向的,既能往外发数据,也能收外部指令,天然适合做综合题的“枢纽”。所以你在赛题里会看到串口和DS18B20温度采集、PCF8591电压采集、继电器控制、PWM调速等模块组合在一起,串口负责把数据上报,或者接收上位机的控制命令。
从出题角度想,串口题有个天然优势:判分容易。评卷时不需要额外的仪器,上位机发一条指令,看板上现象对不对;或者单片机主动发一串数据,看内容正不正确。这就决定了串口题在省赛里经常出现在“第二道大题”里的某个小问,而在国赛里往往作为整套系统的通信主干。
1.2 串口的“一头多能”
串口在赛题里通常承担三类任务。第一类是状态上报,比如定时发送“TEMP=25.6”,或者按键按下时上报键值。第二类是远程控制,比如电脑发0x01亮灯、0x02关灯,单片机收到后执行。第三类是参数配置,比如上位机下发一个数字作为PWM占空比,或者设置定时器的初值。这三类都建立在同一个基础上:可靠地收发单个字节,并且能解析出完整的命令帧。
很多选手备考时喜欢死记官方驱动里的初始化函数,这没问题,但换个芯片型号、换个晶振频率、换个波特率就容易“失灵”。真正需要理解的就三件事:串口硬件怎么接的、波特率怎么算出来的、中断收发怎么配合。这三个点通透了,串口题拿分是必然的。
2. 硬件基础:CT107D上的串口到底连在哪
2.1 核心器件与信号链路
CT107D的主控是STC15F2K60S2,这是一颗增强型8051内核的单片机,指令集兼容传统51,但速度更快、资源更多。板载的串口转换芯片是CH340,它负责把电脑USB口发出的信号转换成单片机认识的TTL电平信号。USB线插到开发板上,电脑端会识别出一个虚拟COM口,单片机端则通过P3.0(RXD)和P3.1(TXD)完成数据收发。
信号流向很简单:电脑发送数据时,USB信号经过CH340变成TTL电平,从CH340的TXD脚输出,送到单片机的RXD引脚(P3.0);单片机往外发数据时,P3.1(TXD)输出,送到CH340的RXD脚,再转成USB信号给电脑。注意这里是交叉连接,这是串口通信的老规矩:发送接接收,接收接发送。
2.2 板载跳线与连接细节
CT107D板上有几个跳线帽需要留意。比如J13跳线控制LED的电源,如果拔掉了,LED操作会失效;串口相关的跳线如果松动,也会出现“电脑发了但单片机收不到”的诡异现象。上电之前先把所有跳线按默认位置插好,尤其是涉及板载供电和数据通路的跳线,别小看这一步,比赛现场因为跳线松脱浪费半小时的人不在少数。
另外一个认知需要纠正:CT107D的串口通信走的是USB线,不需要外接USB转TTL模块,也不需要接RS232电平转换芯片。很多同学第一次看到串口题,下意识去找“串口线”和DB9接口,其实这块板子一根USB线全搞定。下载程序用的是这条线,串口调试用的还是这条线,比老式开发板方便很多。
2.3 电脑端和单片机的握手
电脑端使用串口助手时,需要选择正确的COM口号。CH340的驱动装好之后,在设备管理器里能看到“USB-SERIAL CH340”字样,后面跟着COM编号。如果插上板子后没反应,先检查驱动,再换一根数据线——这里有个坑:有些USB线只能充电不能传数据,下载程序失败、串口无响应的原因可能就是线不对。
串口助手的参数设置要和单片机端完全一致。默认情况下,CT107D官方例程用的是9600波特率、8位数据、无校验、1位停止位,也就是常说的8N1。不要开流控,RTS和CTS都不要勾。只要波特率不一致,发出来的数据必然乱码,这个现象也是最常见的串口故障。
3. 原理拆解:波特率、寄存器与数据帧
3.1 异步通信的数据帧长什么样
串口通信是异步串行通信,异步的意思是没有独立的时钟线,收发双方靠约定好的波特率对齐节奏。每个字节发送时,先发一个起始位(低电平),然后从低位到高位依次发8个数据位,最后发一个停止位(高电平)。接收方在起始位的下降沿开始采样,按波特率定时读取每个bit的电平,拼出一个完整的字节。
这就好比两个人约好“每秒说一个字”,发话方按节奏吐字,听话方按同样的节奏记字。只要节奏不一致,听到的内容就会错位。所以波特率的精度直接决定通信是否可靠。这也是为什么很多工程板子喜欢用11.0592MHz晶振,而不是12MHz——后者虽然便宜,但算出来的波特率会带误差。
3.2 波特率的本质与计算方法
STC15F2K60S2的串口1工作在方式1时,波特率由定时器1的溢出率决定。经典公式是:
波特率 = (2^SMOD / 32) × 定时器1溢出率
SMOD是PCON寄存器最高位,通常设成0。定时器1工作在方式2,也就是8位自动重装,溢出率的计算公式为:
定时器1溢出率 = 定时器计数频率 / (256 - TH1)
这里要分清楚STC15和传统51的区别。传统51的定时器固定12分频,而STC15可以通过AUXR寄存器的bit6控制定时器1是否12分频。官方例程默认把AUXR的bit6清0,让定时器1按12分频工作,也即计数频率 = 晶振频率 / 12。
以CT107D常见的11.0592MHz晶振、9600波特率为例。
计数频率 = 11059200 / 12 = 921600 Hz
TH1取0xFD,也就是253,256 - 253 = 3,所以溢出率 = 921600 / 3 = 307200 Hz
波特率 = 307200 / 32 = 9600
整个过程刚好整除,误差为0。这就是官方例程里TH1和TL1都写0xFD的原因。如果换用12MHz晶振算9600,结果会是 12000000 / 12 / 3 / 32 = 10416.67,和9600差了大约8.5%,这种误差在连续字节传输时必然乱码。
如果需要115200波特率,可以把AUXR的bit6置1,让定时器1工作在1T模式。这时计数频率等于晶振频率,同样取TH1=0xFD,则:
115200 = 11059200 / (32 × (256 - 253)) = 11059200 / 96 = 115200
依然整除。所以在STC15上,11.0592MHz晶振配TH1=0xFD,12T模式是9600,1T模式是115200,这个规律很好记。
3.3 关键寄存器逐个说清楚
串口1涉及的核心寄存器主要有SCON、SBUF、PCON、TMOD、TH1/TL1、AUXR、IE。逐个过一遍:
SCON是串口控制寄存器,方式1的配置值是0x50。二进制0101 0000,SM0=0、SM1=1表示方式1(8位UART,波特率可变),REN=1表示允许接收。低两位在方式1里不用,保持0即可。
SBUF是串口缓冲寄存器,物理上是两个:写SBUF就是发送数据,读SBUF就是接收数据。这个“读写地址相同但物理独立”的机制是51内核的特色,理解成邮箱就行:你投信和收信用的是同一个窗口,但信件不会混。
PCON寄存器里的SMOD位控制波特率加倍,默认0不用改。TMOD控制定时器模式,方式2对应高四位为0010,也就是TMOD |= 0x20,同时注意不要影响定时器0的配置,所以先TMOD &= 0x0F清掉高四位再设置。
AUXR是STC15扩展的特殊功能寄存器。官方初始化代码里的AUXR &= 0xBF是把bit6清零,让定时器1按12分频工作;AUXR &= 0xFE是保证串口1的波特率发生器选择定时器1而不是定时器2。这个寄存器的存在,是STC15和传统51配置串口时最大的区别,很多人移植代码失败就是漏了这一步。
IE寄存器控制总中断和串口中断。EA总开关,ES是串口1中断允许位。初始化时还要记得ET1=0,关闭定时器1的中断,因为这里只需要定时器1提供波特率时钟,不需要它产生中断。如果ET1忘记关,而程序里又没有定时器1的中断服务函数,单片机就会在溢出后跳到一个空的中断入口,程序跑飞,现象极其诡异。
4. 代码落地:初始化、收发与中断框架
4.1 初始化函数模板
下面这段代码是CT107D官方风格的串口1初始化,适用于9600波特率、11.0592MHz晶振:
void uart_init(void) { SCON = 0x50; // 串口1方式1,允许接收 AUXR &= 0xBF; // 定时器1按12分频工作 AUXR &= 0xFE; // 串口1选择定时器1作为波特率发生器 TMOD &= 0x0F; // 清空定时器1的模式位 TMOD |= 0x20; // 定时器1设为模式2(8位自动重装) TL1 = 0xFD; // 波特率初值 TH1 = 0xFD; // 自动重装值 ET1 = 0; // 禁止定时器1中断 TR1 = 1; // 启动定时器1 }如果想要115200波特率,只需要把AUXR &= 0xBF改成AUXR |= 0x40,让定时器1工作在1T模式,其余不变。这组参数我已经在板子上实测过,配合串口助手收发数字、字符串都没问题。
4.2 单字节发送与字符串发送
发送一个字节很简单,把数据放进SBUF,然后等待TI标志置1即可。但这里有一个非常经典的坑:如果开启了串口中断ES,而且中断服务函数里对TI做了清0处理,那么发送函数里while(!TI)会永远等不到TI置1,因为TI刚置1就被中断服务函数清掉了。
解决办法是发送期间临时关闭串口中断,发送完再恢复:
void uart_send_byte(unsigned char dat) { ES = 0; // 临时关闭串口中断 SBUF = dat; // 写入发送缓冲 while (!TI); // 等待发送完成 TI = 0; // 清发送完成标志 ES = 1; // 恢复串口中断 } void uart_send_string(unsigned char *s) { while (*s) { uart_send_byte(*s++); } }有些同学的代码里发送函数没有临时关闭ES,结果主循环跑到发送字符串时卡死,还以为是波特率错了。实际上就是中断把TI抢先清掉的问题。如果你没有开启串口中断,或者中断里只清RI不清TI,那发送函数可以不关ES,但为了稳妥,我建议始终保留这个临时关ES的操作。
4.3 中断接收与环形缓冲区
接收数据推荐用中断方式,不要在while循环里轮询RI标志,因为主循环往往还要处理数码管刷新、按键扫描这些任务,轮询很容易丢字节。基本原理是:串口收到一个字节后,硬件自动把数据放进SBUF,并触发中断,在中断服务函数里把SBUF读出来存到缓冲区。
缓冲区可以做成最简单的环形队列:
#define RX_BUF_SIZE 16 unsigned char rx_buf[RX_BUF_SIZE]; unsigned char rx_head = 0; unsigned char rx_tail = 0; void uart_isr() interrupt 4 { if (RI) { RI = 0; rx_buf[rx_tail++] = SBUF; if (rx_tail >= RX_BUF_SIZE) { rx_tail = 0; } } if (TI) { TI = 0; } }rx_head表示主循环读到哪了,rx_tail表示中断写到哪了。两者相等时缓冲区为空;rx_tail追上rx_head时说明缓冲区满了,但赛题里的命令帧一般只有几个字节,16字节足够用。如果真想写严谨一点,可以在满了之后丢弃旧数据或停止接收,但对竞赛来说没必要。
主循环里只需要做一件事:只要缓冲区不为空,就取出一个字节交给解析函数。
void main(void) { uart_init(); EA = 1; ES = 1; uart_send_string("Uart OK\r\n"); while (1) { while (rx_head != rx_tail) { unsigned char d = rx_buf[rx_head++]; if (rx_head >= RX_BUF_SIZE) { rx_head = 0; } parse_byte(d); } // 这里继续做数码管刷新、按键检测等任务 } }这个结构的好处是:中断只负责快速收数据,耗时较长的命令解析放在主循环里,不会因为中断服务函数执行时间过长而丢下一帧数据。
5. 赛题实战:一条串口指令控制板载外设
5.1 一个典型的综合任务怎么拆
赛题里最常见的串口综合任务是:电脑通过串口发送命令帧,单片机解析后控制LED、蜂鸣器、数码管,或者回传采集到的数据。我拆一个简化版示例:
任务要求:上位机发送5字节帧,第一字节0xAA作为帧头,第二字节0x55作为同步字,第三字节命令字,第四字节参数,第五字节为校验和。命令字0x01表示点亮第N个LED,0x02表示全部熄灭,0x03表示上报“TEMP=25.6”字符串。
拆题的时候先把两部分分开:一部分是串口底层,负责把字节收进来并还原出完整的帧;另一部分是业务层,根据命令字去操作外设。千万不要在中断里直接写业务逻辑,否则命令解析、数据回传、LED操作全都挤在中断里,代码混乱而且容易丢字节。
5.2 帧协议与状态机解析
帧协议就是收发双方约定好的数据格式。0xAA、0x55起定位作用,校验和用来确认数据传输过程中没有出错。校验和用简单的异或就行:校验和 = 0xAA ^ 0x55 ^ 命令字 ^ 参数。这种校验方式防不了复杂干扰,但对竞赛场景完全够用。
解析用状态机,比用缓冲区攒一整包再判断要稳得多。状态机的好处是帧与帧之间的间隔时间随意,哪怕命令一个字节一个字节地来也能正确拼出完整帧:
#define FRAME_HEAD1 0xAA #define FRAME_HEAD2 0x55 unsigned char rx_step = 0; unsigned char rx_cmd = 0; unsigned char rx_param = 0; unsigned char rx_check = 0; void parse_byte(unsigned char d) { switch (rx_step) { case 0: if (d == FRAME_HEAD1) { rx_step = 1; } break; case 1: if (d == FRAME_HEAD2) { rx_step = 2; } else if (d != FRAME_HEAD1) { rx_step = 0; } break; case 2: rx_cmd = d; rx_step = 3; break; case 3: rx_param = d; rx_step = 4; break; case 4: rx_check = d; if ((FRAME_HEAD1 ^ FRAME_HEAD2 ^ rx_cmd ^ rx_param) == rx_check) { process_cmd(rx_cmd, rx_param); } rx_step = 0; break; default: rx_step = 0; break; } }这个状态机里有一个细节值得注意:在等待第二个帧头时,如果收到的不是0x55,要再判断一下是不是0xAA。这是因为数据流中间可能有残缺的帧,如果收到一个0xAA之后紧跟着另一个0xAA,而你直接把状态归零,就会漏掉一组可能的帧头。
5.3 主循环里的事件分发
process_cmd实现具体的业务逻辑,代码很直观:
void process_cmd(unsigned char cmd, unsigned char param) { switch (cmd) { case 0x01: if (param >= 1 && param <= 8) { P2 = (P2 & 0x1F) | 0x80; // 选通LED锁存器 P0 = ~(0x01 << (param - 1)); // 低电平点亮 P2 &= 0x1F; // 关闭锁存器 } break; case 0x02: P2 = (P2 & 0x1F) | 0x80; P0 = 0xFF; // 全灭 P2 &= 0x1F; break; case 0x03: if (param == 0) { uart_send_string("TEMP=25.6\r\n"); } break; default: uart_send_string("ERR\r\n"); break; } }这里操作LED时打开锁存器、操作完立刻关闭,是一个好习惯。CT107D的P0口同时连接多个外设的锁存器,如果操作完不关闭,P0上的残留数据可能会串到其他外设,导致蜂鸣器乱响、数码管乱跳。
整个流程跑起来之后的效果是:串口助手发送AA 55 01 03 57(校验和可以自己算),板上第3个LED点亮;发送AA 55 02 00 58,所有LED熄灭;发送AA 55 03 00 59,串口助手收到TEMP=25.6。这个示例基本覆盖了串口题的所有核心要素:帧格式设计、状态机解析、外设联动、数据回传。
6. 调试技巧与避坑经验
6.1 把串口当成调试利器
串口不光是赛题要求,更是调试神器。程序跑飞了、变量值不对、外设没反应,最直接的办法就是在关键位置加一句uart_send_string,把状态打出来。比如初始化之后发一个“Uart OK”,接收中断里收到字节后转发回串口,就可以立刻判断底层收发是否正常。
我在备赛时习惯把串口发送函数定义成一条“调试总线”,在按键扫描、ADC采集、温度读取这些关键节点都埋上报点。比赛时如果某一项功能没实现,先不急着查硬件,看串口有没有输出,能快速缩小问题范围。
6.2 高频故障的排查顺序
串口出问题时,按下面的顺序排查,能省很多时间:
| 现象 | 大概率原因 | 检查方法 |
|---|---|---|
| 完全收不到 | COM口号不对、驱动没装、USB线只供电不传数据 | 检查设备管理器,换线,重插 |
| 收得到但乱码 | 波特率不一致、晶振频率和代码不匹配 | 确认9600 vs 115200,确认11.0592MHz |
| 发不出去 | TXD/RXD接反、P3.1被占用 | 用官方例程回发测试 |
| 能发不能收 | SCON里REN没置1,或ES没开,或中断里没清RI | 检查SCON=0x50,检查IE寄存器 |
| 发送卡死 | 发送函数和TI中断冲突 | 发送函数里临时关ES |
| 程序跑飞 | ET1没关,定时器1中断没服务函数 | 检查ET1=0 |
| 帧解析不稳定 | 帧头检测状态机没处理“连续帧头” | 按5.2的状态机写法 |
这里面“乱码”还有一个容易被忽略的来源:串口助手的数据位、停止位设置不对。如果上位机那边配成了7位数据,而单片机是8位,收到的内容就会错位。每次连接串口助手的参数都确认一遍,不要想当然。
6.3 赛前准备与现场节奏
赛前一定要把自己的串口初始化函数、发送函数、接收缓冲区、状态机解析函数整理成一套独立模板,放在工程里随时调用。不要到了赛场上才开始“回忆”官方例程怎么写。模板准备好之后,找几道历年的串口真题,把命令帧、回传数据、外设联动全部验证一遍,重点是验证各种波特率下的收发稳定性。
现场还有一个很小的细节:每次重新下载程序后,单片机是自动复位的,串口助手不用重新打开,但要注意发送数据前先确认串口助手的“打开串口”按钮是开启状态。另外,比赛时如果题目要求串口上报数据,记得在字符串末尾加上\r\n换行,评卷系统或者串口助手会自动换行,也方便人工观察输出。
我在实际备赛过程中踩过最深的坑,是中断里直接写命令解析导致的高速收帧丢字节。后来把中断改成只往环形缓冲区塞数据,解析挪到主循环,问题立刻消失。这个结构看似多了一道工序,但对赛题这种连续多发几帧的场景非常有效。串口题真正难的地方从来不是背代码,而是理解数据从电脑到引脚、从引脚到寄存器、从寄存器到中断、从中断到缓冲区、再从缓冲区到业务逻辑的完整链路。这串链路捋顺了,串口就是整张卷子里最稳定拿分的那道题。