UART中断驱动通信:从零开始实现高效数据接收
你有没有遇到过这种情况?写好了一个STM32程序,主循环里不断轮询UART状态寄存器,就为了等一个字节的数据。结果CPU 90%的时间都在“空转”,干不了别的事,功耗还高——这显然不是现代嵌入式系统该有的样子。
今天我们就来解决这个痛点:用中断驱动的方式,让UART自己“喊”你来收数据。整个过程不依赖操作系统、不需要DMA、也不需要复杂的框架,适合零基础入门者一步步上手。
我们将以STM32F1系列为例(但思路适用于几乎所有Cortex-M芯片),带你从硬件配置到代码实现,完整走通一次中断接收的全流程。准备好了吗?我们开始。
为什么非要用中断?轮询真的不行吗?
先别急着敲代码,咱们先搞清楚一个问题:轮询到底“错”在哪?
想象一下你在等快递。轮询的做法是——你每分钟打开门看一眼有没有人来,连续看8小时。这种方式当然能等到包裹,但代价是你啥也干不了。
而中断就像门铃机制:你安心工作,快递员一按铃,你就去开门取件。这才是聪明人的做法。
在嵌入式系统中:
- 轮询 = CPU持续检查USART_SR & RXNE
- 中断 = 硬件自动通知CPU:“有数据来了!”
尤其是在以下场景中,轮询会直接拖垮系统性能:
- 需要同时处理多个任务(比如采集传感器 + 显示 + 通信)
- 对实时性要求高的工业控制
- 使用低功耗模式的物联网节点
所以结论很明确:要做靠谱的串口通信,必须掌握中断驱动。
UART和中断是怎么配合工作的?
先看UART本身干了啥
UART模块本质上是个“翻译官”:它把CPU给的并行数据变成一串比特流发出去,也能把收到的一串比特还原成字节交给CPU。
它的基本工作流程是这样的:
- 外部设备通过RX引脚发送高低电平信号;
- UART检测到起始位(低电平)后,按照预设波特率定时采样每一位;
- 收完一帧(通常8位数据+起始/停止位),将数据放入接收数据寄存器(RDR);
- 同时置位状态寄存器中的RXNE 标志位(Receive Data Register Not Empty)。
⚠️ 注意:这时候数据已经在RDR里了,但CPU还不知道!
这时候,中断登场了
如果你提前打开了RXNE中断使能,那么当RXNE被硬件置位时,就会触发一个中断请求(IRQ)。这个信号传给NVIC(嵌套向量中断控制器),然后MCU暂停当前任务,跳转到你写的中断服务程序(ISR)去处理数据。
整个链条如下:
[数据到达] → [UART填入RDR] → [置位RXNE] → [触发中断] → [跳转ISR] → [读取数据]整个过程从数据到位到进入ISR,通常只要几个微秒,响应极快。
关键参数设置:让双方“说同一种语言”
UART是异步通信,没有时钟线同步,全靠双方事先约定好规则。这些规则统称为“串口参数”,最常见的组合是:
| 参数 | 常见值 | 说明 |
|---|---|---|
| 波特率 | 115200 bps | 每秒传输115200个bit,收发双方必须一致 |
| 数据位 | 8 bits | 实际传输的有效数据长度 |
| 停止位 | 1 bit | 标志一帧结束 |
| 校验位 | None | 不做奇偶校验,简化通信 |
如果两边配得不一样,比如一边115200、一边9600,那就像两个人用不同语速说话——听不懂,全是乱码。
所以在初始化时,一定要确保MCU和对方设备的串口参数完全匹配。
手把手配置UART+中断(基于STM32标准库)
下面我们一步步写出完整的中断接收代码。即使你是第一次接触外设配置,也能跟着做出来。
第一步:定义缓冲区与变量
我们要把收到的数据暂存起来,供主程序后续处理。
#define RX_BUFFER_SIZE 64 uint8_t rx_buffer[RX_BUFFER_SIZE]; // 接收缓冲区 volatile uint16_t rx_index = 0; // 当前写入位置这里的关键是volatile——告诉编译器:“这个变量会被中断修改,请别优化掉!”
否则编译器可能认为rx_index永远不会变,导致主循环里读不到最新值。
第二步:UART初始化函数
#include "stm32f10x.h" void UART_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; USART_InitTypeDef USART_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 开启时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); // 2. 配置GPIO:PA9(TX)复用推挽输出,PA10(RX)浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; // 复用功能 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // 浮空输入 GPIO_Init(GPIOA, &GPIO_InitStructure); // 3. 配置USART1参数 USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); // 4. 使能接收中断 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); // 5. 配置NVIC优先级 NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // 6. 启动USART1 USART_Cmd(USART1, ENABLE); }逐条解释几个关键点:
- RCC时钟使能:不打开时钟,外设就是“死”的。
- GPIO模式选择:TX要推挽输出,RX要浮空输入(外部电平决定状态)。
- USART_ITConfig:这是开启中断的核心!只有开了这个,RXNE才会触发中断。
- NVIC配置:告诉系统哪个中断对应哪个ISR,并设置优先级。
第三步:编写中断服务程序(ISR)
这是最关键的一步。每当收到一个字节,这个函数就会被自动调用。
void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); // 读数据清标志 if (rx_index < RX_BUFFER_SIZE) { rx_buffer[rx_index++] = data; } // 可选:回显收到的数据 // USART_SendData(USART1, data); } }几点注意事项:
- 必须调用
USART_GetITStatus()判断中断源,避免误触发; - 调用
USART_ReceiveData()会自动清除RXNE标志,防止重复进入中断; - 缓冲区要做边界检查,防止溢出;
- 回显功能可用于测试连通性,调试时非常有用。
第四步:主函数启动
int main(void) { UART_Init(); while (1) { // 主循环可以自由执行其他任务 // 例如:LED闪烁、ADC采样、按键扫描... } }看到没?main函数现在彻底解放了。你可以在这里加任何你想做的逻辑,UART会在后台默默收数据。
常见坑点与应对秘籍
刚学中断的同学最容易踩这几个坑,我帮你提前避雷:
❌ 坑1:中断进不去?检查NVIC配置!
常见原因:
- 忘了调NVIC_Init()
- IRQ通道写错(比如把USART2写成USART1)
- 优先级数值超出范围
✅ 秘籍:打开core_cm3.h查宏定义,确认USART1_IRQn值是否正确。
❌ 坑2:收到的数据是乱码?
多半是波特率不匹配!检查:
- MCU设置的是115200吗?
- 串口工具(如Putty、XCOM)是不是也设成了115200?
- 晶振频率配置是否准确?(影响波特率计算)
✅ 秘籍:先用9600测试通路,成功后再升到高速率。
❌ 坑3:数据丢失或只收到一半?
高速通信下容易发生溢出错误(ORE),原因是:
- ISR处理太慢
- 主程序长时间关中断
- 缓冲区太小
✅ 秘籍:
- ISR尽量轻量化,只做“读数据+存缓冲”
- 尽早使用环形缓冲(ring buffer)
- 后续可升级为DMA接收,进一步降低CPU负担
如何验证你的代码跑通了?
最简单的办法:电脑串口助手发数据 → 单片机接收 → 回显回来。
操作步骤:
1. 用USB转TTL模块连接PC与STM32的PA10(RX)、PA9(TX)
2. 打开XCOM或Tera Term,设置波特率115200
3. 发送任意字符(如”hello”)
4. 观察是否原样返回
如果能看到回显,恭喜你!中断接收已经成功运行。
更进一步:如何判断一包数据收完了?
目前我们是一个字节一个字节收,但如果要解析命令怎么办?比如收到"AT+SEND=1\r\n"才算完整指令。
两种常用方法:
方法1:以特定字符结尾(如\n)
if (data == '\n') { // 表示一帧结束,通知主程序处理 rx_buffer[rx_index] = 0; // 加字符串结束符 parse_command(rx_buffer); rx_index = 0; // 清空缓冲区 }方法2:超时判断法(推荐)
设定一个时间窗口(如10ms),如果连续这么久没新数据到来,就认为帧结束。这种方法更稳定,适合不定长协议。
实现方式可以用定时器+标志位,留作课后练习。
实际应用场景举例
掌握了这项技能,你能做什么?
✅ 场景1:蓝牙遥控小车
HC-05蓝牙模块通过UART发送指令:
F // 前进 B // 后退 L // 左转单片机用中断接收,解析后控制电机动作,主循环还能监测电池电压。
✅ 场景2:GPS定位数据解析
GPS模块持续输出NMEA语句:
$GPGGA,123519,4807.038,N,01131.000,E,1,08,0.9,545.4,M,46.9,M,,*47中断接收每一行,主程序提取经纬度信息显示在OLED上。
✅ 场景3:远程固件升级
上位机通过串口下发新的bin文件,MCU边收边写入Flash,完成后跳转执行新程序。
写在最后:这条路还能怎么走更远?
今天我们实现了最基本的中断接收,但这只是起点。下一步你可以探索:
- 环形缓冲区设计:避免缓冲区满后丢数据
- DMA+空闲中断:实现零CPU干预的海量数据接收
- 多串口并发管理:监听多个设备(调试口+传感器口)
- RTOS集成:将接收到的数据通过队列传递给任务处理
每一步都建立在今天的基础之上。理解中断的本质,比会抄代码重要一百倍。
如果你动手实现了这个例子,不妨在评论区分享你的成果。遇到了问题?欢迎留言讨论,我们一起解决。
嵌入式的世界很大,而你刚刚推开了第一扇门。