简介:面向STM32嵌入式开发者的串口1中断控制LED示例工程,聚焦USART1接收中断与GPIO操作,演示通过接收'1''2''3'字符命令切换LED熄灭、点亮与均匀闪烁,可学习中断机制、串口通信、定时器及GPIO的综合运用。压缩包共172个文件,含32个C源文件、31个头文件、目标文件、调试信息文件及uvprojx工程文件,并附hex烧录文件便于快速验证,整包仅4.37MB,目录清晰、编译输出完整。已有1669人学习下载,该工程展示从串口初始化、中断优先级设定到中断服务程序编写的完整流程,特别在均闪模式中调用定时器周期翻转电平,能帮助理解轮询与中断方式的差异。通过串口助手向开发板发送对应命令,即可实时观察LED状态变化,同时可在定时器中断里微调闪烁频率,为后续扩展更复杂的串口指令控制外设提供直接参考。 之前帮人调过一套课设代码,压缩包名字就叫做“利用串口1的中断方式控制LED灯(仅展示了三种状态控制:灭、亮、均闪).rar”。一开始我以为是普通的点灯实验,打开才发现这是一个特别典型的串口通信+中断系统入门项目,麻雀虽小但五脏俱全。
简单说,这个项目的核心就是:单片机上电后,通过串口1接收上位机发送的指令,每收到一个字符就触发一次串口接收中断,在中断服务函数里判断指令内容,然后切换LED的状态——灭、亮、均闪三种模式。它同时覆盖了串口通信、中断机制、GPIO输出、状态机设计、定时器配合这五个嵌入式基本功,非常适合正在学51单片机或刚开始接触STM32的读者。
这篇文章我会把这个项目的原理、接线、代码、调试过程完整走一遍,还会把我在实际调试中遇到的乱码、中断冲突、闪烁不均匀这些坑都交代清楚,希望能帮你少走弯路。
1. 项目拆解:这个“串口1中断控制LED”到底在做什么
1.1 串口与中断:单片机入门必过的两道坎
很多初学者学串口的时候,只会用轮询方式收发数据,也就是主循环不停查询接收标志位有没有被置位。这种方式简单直白,但有一个致命弱点:CPU被占死。如果主循环里恰好有一段延时函数,串口数据来了也来不及处理,轻则丢数据,重则整个系统响应不及时。
中断方式走的是另一条路。串口每收到一个完整字节,硬件会自动把RI(接收中断标志)置1,并且跳转到中断服务函数去执行。你不用在主循环里做任何轮询。主程序该干嘛干嘛,数据来了“打断”一下,处理完再回来接着跑。这个机制放在LED控制这个场景里,最大的好处就是响应快、不阻塞,为后续扩展更多功能留好了余地。
这个项目恰恰把这两个知识点拧在一起:串口负责收指令,中断负责通知CPU“来活了”。理解了这条主线,代码就很好读了。
1.2 三种状态背后的协议设计思路
再看“灭、亮、均闪”这三个状态。很多人觉得这有什么可讲的?不就是三个if分支吗?实际上这里藏着一个很基础但很关键的“通信协议”设计思想。
上位机发什么数据,单片机才能识别出“这是让我灭灯”?在这个项目里,通常约定如下:
- 字符
'0'或0x30:LED熄灭 - 字符
'1'或0x31:LED常亮 - 字符
'2'或0x32:LED均匀闪烁
为什么用ASCII字符而不是直接用0、1、2?因为串口调试助手默认是按文本方式发送的。你发送一个字符'1',实际上线路上跑的是它的ASCII码0x31。如果用十六进制发送模式,那0x01在文本模式下很难通过键盘打出来,这就要看你用的哪款调试助手了。
千万不要小看这行约定。真实工程项目里,协议设计就是解决“双方如何理解同一条消息”的问题。哪怕只是LED控制,只要串口这个通信链路存在,就必须先定义好这条规则。
2. 硬件连接与环境准备
2.1 最小系统接线:单片机、CH340串口模块、LED
这个项目用51单片机最顺手,我用的是STC89C52RC,经典中的经典。这套硬件的连接方式如下:
- 单片机串口1:P3.0(RXD)接CH340模块的TXD,P3.1(TXD)接CH340模块的RXD。注意这里一定是交叉连接,不能同名的接到一起,很多新手第一次接反,结果数据全部发不出去。
- 地线:CH340模块的GND必须和单片机系统的GND连在一起,否则电平没有参考点,通信直接失败。
- LED电路:我用的是P2.0引脚,LED正极串联一个330Ω限流电阻接到P2.0,负极接GND。这个接法叫低电平点亮,单片机引脚输出低电平时LED亮,输出高电平时灭。
- 晶振:11.0592MHz。这句话我建议你直接用笔记记下来,串口波特率误差想最小化,11.0592MHz是首选,因为它能精确分频出9600、19200这些常用波特率。如果用12MHz晶振,理论上波特率会有误差,短数据看不出问题,多字节传输就可能出现乱码。
CH340是USB转串口芯片,市面上绝大多数开发板自带的下载电路都是它。如果是自己做的最小系统板,买一个几块钱的CH340模块就行,连接方式就是上面说的三根线:TXD、RXD、GND。
2.2 工具链准备:Keil、CH340驱动、串口调试助手
这个项目需要准备三个软件工具,缺一不可:
| 工具 | 用途 | 说明 |
|---|---|---|
| Keil C51 | 编写、编译、生成hex文件 | 51单片机标准IDE,5版本即可 |
| CH340驱动 | 让电脑识别串口模块 | 去官网下载对应系统版本,Win10/11通常自动装 |
| 串口调试助手 | 发送字符指令、接收数据 | 比如常见的友善串口助手、XCOM、SSCOM都行 |
我个人的建议是:先装驱动,再插模块。CH340模块插上电脑后,打开设备管理器,看到“USB-SERIAL CH340”并且带一个COM口号,说明驱动正常。如果显示黄色感叹号,多半是驱动没装好,重装一下就好。
串口调试助手的选择没什么讲究,能设置波特率、能选文本/Hex发送、能显示接收数据就行。我调试时用的一个习惯是:上位机发送区选“字符发送”,接收区开“时间戳”,这样能精确看到每次指令发送的时刻,排查时序问题特别有用。
3. 核心代码实现:串口1中断接收与状态切换
3.1 串口初始化的寄存器配置
整个代码分三块:串口初始化、中断服务函数、主循环状态处理。先看初始化函数,这是所有功能的地基。
#include <reg52.h> sbit LED = P2^0; // LED接在P2.0,低电平点亮 unsigned char state = 0; // 0-灭 1-亮 2-均闪 void UART1_Init(void) { SCON = 0x50; // 串口1工作方式1:8位UART,允许接收(REN=1) TMOD &= 0x0F; // 只修改定时器1的部分,保留定时器0的配置 TMOD |= 0x20; // 定时器1工作方式2:8位自动重装载 TH1 = 0xFD; // 波特率9600初值(晶振11.0592MHz) TL1 = 0xFD; PCON &= 0x7F; // SMOD=0,波特率不加倍 ES = 1; // 使能串口1中断 EA = 1; // 使能总中断 TR1 = 1; // 启动定时器1,为串口提供波特率时钟 }这里逐行说一下为什么要这么写。
SCON = 0x50是SCON寄存器最常用的配置。SCON的位定义里,SM0和SM1决定串口工作方式,01对应方式1,也就是8位UART,一帧数据包括1位起始位、8位数据、1位停止位,这是和电脑串口通信最常用的方式。REN=1是允许接收,这个必须打开,不然单片机只能发不能收,整个项目直接瘫痪。
TMOD配置的是定时器1的模式,这里有个小细节我特别想提醒你:TMOD的高4位控制定时器1,低4位控制定时器0。如果直接用TMOD = 0x20,会把定时器0的配置也一起覆盖掉。我在代码里先TMOD &= 0x0F清零高4位,再TMOD |= 0x20,这样既不影响定时器0,又完成了定时器1的配置。这个写法在工程里叫“读-改-写”,比直接赋值安全得多。
TH1 = 0xFD是波特率配置的关键。方式2的定时器是8位自动重装载,计数溢出后会自动把TH1的值重新装入TL1,不需要在中断里手动赋初值。那这个0xFD是怎么算出来的?波特率公式是:
波特率 = (2^SMOD / 32) × 定时器1溢出率,其中定时器1溢出率 = 晶振频率 / (12 × (256 - TH1))。
把SMOD=0、晶振11.0592MHz、目标波特率9600代入,就可以算出TH1。用11.0592MHz晶振的好处就在这里,算出来是整数0xFD,不会产生误差。如果你用12MHz晶振,算出来是小数,取整后波特率有误差,短时间通信没问题,连续发几十个字节就容易出乱码。
3.2 中断服务函数与主循环逻辑
串口1的中断号是4,对应interrupt 4,这是51单片机规定的,不需要自己分配。中断服务函数的写法如下:
void UART1_ISR(void) interrupt 4 { unsigned char cmd; if (RI) // 接收中断标志 { RI = 0; // 必须软件清零,否则会反复进入中断 cmd = SBUF; // 读取接收缓冲区,读取后硬件自动清除SBUF if (cmd == '0') state = 0; else if (cmd == '1') state = 1; else if (cmd == '2') state = 2; } if (TI) // 发送中断标志,本工程用不到 { TI = 0; // 但还是要清标志,防止误入中断 } }这里有两个非常容易踩的坑。
第一个坑:RI标志必须软件清零。51单片机的串口接收中断标志RI不会自动清除,你必须手动写RI = 0。如果忘了清,中断服务函数执行完退出后,硬件发现RI还是1,会立刻再次触发中断,结果是主循环根本跑不起来,程序卡死在中断里,现象就是LED不受控制、系统“假死”。
第二个坑:SBUF必须读取一次。当RI=1时,接收到的数据已经在SBUF里了。虽然51单片机的SBUF在物理上是两个独立的寄存器(一个发送缓冲、一个接收缓冲,但共用同一个地址),实际操作上你只要读一次SBUF,接收缓冲区的“占用”状态就算被清除了。这个读取动作不能省,否则下一次接收的数据可能会覆盖掉还没读走的上一次数据。
主循环部分就简单多了,它只负责根据state变量的值去控制LED的状态:
void main(void) { unsigned char cnt = 0; UART1_Init(); LED = 1; // 上电初始:熄灭 while (1) { if (state == 0) { LED = 1; // 灭 } else if (state == 1) { LED = 0; // 亮 } else if (state == 2) { // 均闪逻辑,见下节 } } }注意这里的设计思路:中断服务函数只负责修改state,不直接控制LED。主循环根据state做实际的动作。这种把“事件产生”和“事件处理”分离的思想,是嵌入式开发里非常重要的结构设计。如果不这么做,直接在中断里操作LED引脚,一旦将来要增加更多功能,中断服务函数会变得越来越臃肿,优先级稍高的实时任务会被拖慢。
3.3 “均闪”的实现细节:为什么不能用延时
状态3“均闪”是整个项目里最容易写崩的地方。很多初学者第一反应是:LED亮一会儿,延时500ms,再灭一会儿,延时500ms,循环不就行了吗?
问题在于:主循环一旦进了延时函数,串口中断虽然能打断延时,但延时函数的计时并不是实时的。你可以理解为,延时函数是一个“死等”的过程,你在里面等待的时间,会因为你反复进出中断而变长。具体现象就是:如果我把串口指令改成“常亮”,LED却还要等当前的半个周期延时结束才反应过来,控制明显迟钝。
更严重的还有一个问题:延时函数消耗的是CPU时间,如果状态是“均闪”,主循环的绝大部分时间都耗在延时里,串口仍然可以中断接收,但一旦有新的指令进来,state值被改了,LED却要等当前延时走完才做出响应,这就有“卡顿感”。
正确的做法是用定时器0实现非阻塞式闪烁。思路是:让定时器0固定每50ms中断一次,在中断服务函数里累加计数,计数到10次也就是500ms后,翻转一次LED的电平状态。这样主循环完全不用等待,LED的闪烁节奏由硬件定时器精确控制。
unsigned char timer0_cnt = 0; void Timer0_ISR(void) interrupt 1 { TH0 = 0x4C; // 50ms定时初值,11.0592MHz TL0 = 0x00; if (state == 2) { timer0_cnt++; if (timer0_cnt >= 10) // 500ms翻转一次 { timer0_cnt = 0; LED = !LED; // 翻转电平 } } }定时器0的中断号是1,初值0x4C00对应11.0592MHz晶振下约50ms一次中断。这里的算法是:50ms中断一次,累计10次就是500ms,翻转一次LED。亮500ms、灭500ms,就是标准的1Hz呼吸节奏,视觉上刚好是“一眼能看出来闪烁,又不会闪得太快”,这个频率也是我实测下来最舒服的均闪效果。
4. 实测过程与问题排查实录
4.1 整套流程:编译、烧录、验证三种状态
代码写完后,完整的验证流程分成四步。
第一步:Keil里点击编译,确认0错误0警告。如果用了我的代码结构,理论上不会有问题。出现警告的话,重点检查有没有定义了但没使用的变量。
第二步:生成hex文件。Keil里需要配置一下Output选项卡,勾选“Create HEX File”,不然编译结果只有调试文件,没法烧录。
第三步:用STC-ISP软件烧录。选择正确的单片机型号STC89C52RC,选择串口,打开编译出来的hex文件,点击下载,然后给单片机上电。STC单片机是“先点下载再上电”的冷启动方式,这个顺序搞反了会一直提示“正在检测目标单片机”。
第四步:打开串口调试助手,设置波特率9600、数据位8、停止位1、无校验,打开串口,依次发送字符0、1、2,观察LED状态变化。
我实测时的完整记录如下:
| 发送内容 | 预期现象 | 实测结果 |
|---|---|---|
0 | LED熄灭 | 正常,立即熄灭 |
1 | LED常亮 | 正常,立即点亮且稳定 |
2 | LED均闪,500ms间隔翻转 | 正常,闪烁节奏均匀 |
先发2再发1 | 立即从闪烁切换为常亮,无延迟 | 正常,切换动作发生在下一个定时器周期内 |
连续发2 | 每次进入均闪状态,无累积切换异常 | 正常 |
其中“先发2再发1”这个测试我觉得有必要单独说一下,它验证的是“非阻塞”的核心优势:因为闪烁由定时器中断驱动,没有占用主循环,所以切换指令能被立即执行。
4.2 我踩过的四个坑,按出现频率排序
坑一:串口助手发送了字符,LED没任何反应。
先看串口号选没选对。CH340模块插上电脑后,设备管理器里查看当前占用的COM口号,串口助手里的串口设置必须和它一致。还有就是波特率必须和代码里的配置一致,我是9600,那串口助手就选9600。
如果这些都对了还是没反应,检查TXD和RXD有没有接反。这是最经典的错误,我帮别人排查时最常遇到的就是两根线接成了直连。记住:单片机的RXD接模块的TXD,单片机的TXD接模块的RXD,地线直接相连。
坑二:串口助手发送0却显示“灭”,发送1却是“灭”。
这大概率是串口助手的发送模式选错了。如果你用的是Hex发送模式,键盘上输入1发送的是十六进制数0x01,单字符1的ASCII码0x31根本发不出去。解决办法:切换到字符发送模式,或者打开Hex发送模式手动输入31。顺带说一句,系列代码里判断写的是cmd == '1',如果你更喜欢用十六进制值,可以写成cmd == 0x31,效果完全一样,只是一个可读性好,一个和硬件贴得更近。
坑三:闪烁不均匀,有时候亮500ms灭500ms,有时候明显感觉频率变了。
这个原因多半是中断里操作了不该操作的东西。比如我在调试时离了个大谱,把SBUF的读取放在主循环里做,还开了定时器中断,结果定时器中断频繁打断主循环读SBUF的过程,芯片就有点“手忙脚乱”。正确的做法是SBUF只在串口中断里读,其他中断或者主循环不要碰它。51单片机同时只有一个中断在执行,不会有并发问题,但代码逻辑上切忌把同一个数据读取拆到多处执行。
坑四:有时候发送指令后第一个动作是正常的,连续发几条就卡死了。
典型的RI标志没清零,或者SBUF数据被覆盖。RI清零问题我前面讲过了,这里再补充一个排查方法:如果程序卡死在中断里,LED会表现为“你发什么它都没反应”,因为CPU根本没空去主循环执行状态切换。遇到这种情况,别急着改逻辑,先在中断服务函数第一行点亮一个调试LED,重新烧录测试,看这个调试LED是否常亮,如果常亮说明中断确实被反复触发,RI清零代码肯定有问题。
4.3 关于“串口调试助手”的一些使用心得
网上串口调试助手版本很多,挑一款常用的就行。我个人的建议是:
- 不要开“自动发送”来测试状态切换。这个功能适合测连续收发稳定性,不适合看状态切换逻辑。手动发送,一次一条,观察现象,这样思路最清晰。
- 开启接收区的“时间戳”显示。如果未来你想在这个项目基础上扩展回显功能,比如单片机收到指令后回复“OK”,时间戳能帮你确认回显延迟。
- 波特率设置成9600就够用了。这个项目就传单个字符,9600波特率完全没压力。有些人盲目追求115200,反而因为晶振或者单片机内部RC振荡器精度不够,出现误码,得不偿失。
5. 这个项目还能怎么扩展
做完了本项目的三个状态,如果还有余力,我建议往这几个方向顺手扩展一下,每一个都能让你的理解加深一层。
第一个方向是协议升级。把单字符指令改成帧格式,比如帧头0xAA、帧尾0x55,中间放指令码和校验和,这样就能同时控制LED1、LED2、LED3,还可以扩展出PWM调光、呼吸灯这些效果。这就是在做一个轻量级自定义通信协议了。
第二个方向是数据回传。单片机收到指令后,回复一个状态字符串,比如收到1就回传LED:ON,收到0就回传LED:OFF。这里要用到串口发送功能,需要处理TI标志位。有了回传机制,你就完成了“上位机下发、下位机执行、下位机上报”的完整闭环,这也是绝大多数工业串口通信设备的基本工作模式。
第三个方向是增加状态数量。比如加一个“频闪”状态,也就是快速的亮灭交替,对应约10Hz频率。实现方式还是基于定时器,把翻转周期改短就行。还有一种方法是增加“三秒呼吸”的状态,频率是1Hz,但占空比逐渐变大再逐渐变小,视觉效果就像LED在呼吸,这就涉及到PWM控制,是51单片机进阶绕不开的环节。
第四个方向是把这套代码移植到STM32上。原理完全一样,无非是寄存器名不同、中断服务函数写法不同,但“串口接收中断+状态变量+定时器非阻塞闪烁”这个架构可以原封不动搬过去。如果手头有STM32F103C8T6的开发板,用HAL库重写一遍,你就能直观感受到标准库和HAL库的差异,也能理解为什么越来越多人选择HAL库开发。
最后再分享一个我自己的习惯:每完成一个功能,先在代码里保留一个“调试断点”。比如在中断服务函数入口放一个临时变量计数器,用串口回传计数值。这样一旦出问题,我可以快速确认中断有没有进来、进来了几次。等全部验证通过,再把这些调试代码删掉或注释掉。这套项目虽然小,但这种调试习惯是通用且值钱的。
本文还有配套的精品资源,点击获取