STM32中UART中断通信实战:从原理到稳定收发的完整实现
你有没有遇到过这种情况?单片机通过串口接收传感器数据,主循环里用轮询方式不断检查是否收到字节——结果CPU几乎90%的时间都在“空转”,稍微来点复杂任务系统就卡顿,更别提高速通信时还丢包。
这正是我早年做Modbus项目时踩过的坑。后来我才明白,真正的嵌入式通信不是靠“盯着看”,而是学会“听通知”。今天我们就以STM32为例,彻底讲清楚如何用中断+环形缓冲区构建一个高效、稳定、不丢数据的UART通信框架。
为什么必须放弃轮询?一个真实案例的启示
先来看一段典型的轮询代码:
while (1) { if (USART2->SR & USART_SR_RXNE) { uint8_t ch = USART2->DR; process_byte(ch); } // 其他任务... }表面上看没问题,但当你加入PID控制、LCD刷新或网络协议栈后,问题就来了:
- 如果process_byte处理慢了,下一帧数据可能已经覆盖上一帧;
- CPU始终无法进入低功耗模式;
- 系统响应延迟不可预测。
解决之道只有一个:把数据接收这件事交给硬件和中断去完成,主程序只负责消费数据。
UART通信的核心机制:异步是怎么做到的?
在深入代码前,我们得搞懂STM32里的USART模块到底是怎么工作的。
数据是如何一帧一帧传输的?
UART是异步通信,意味着没有时钟线同步发送与接收双方。它依靠的是双方事先约定好的波特率(比如115200bps)来协调每一位的持续时间。
当一个字节(如'A',即0x41)发送时,实际在线路上看到的是这样的波形:
起始位 LSB MSB 停止位 ↓ ↓ ↑ ↑ TX: [0] [1] [0] [0] [0] [0] [0] [1] [0] [1] [1] D0 D1 D2 D3 D4 D5 D6 D7 P (停止)注意:
- 起始位为低电平;
- 数据位低位在前;
- 无校验位时P可忽略;
- 停止位为高电平,通常1位或2位。
接收端检测到下降沿后,会以波特率对应的时间间隔进行多次采样(通常是16倍频),确保准确读取每一位。
STM32的USART外设内部结构简析
STM32的每个USART都有几个关键寄存器:
| 寄存器 | 功能 |
|---|---|
| DR (Data Register) | 实际包含TDR(发送)和RDR(接收)两个物理寄存器 |
| SR (Status Register) | 指示当前状态:RXNE、TXE、ORE等 |
| BRR (Baud Rate Register) | 设置波特率分频系数 |
| CR1/CR2/CR3 (Control Registers) | 控制使能、中断、模式等 |
举个例子:当你向USART2->DR写入一个字节时,硬件自动开始移位输出;而每当收到完整字节,SR中的RXNE标志就会被置起。
📌关键点:读写DR寄存器的同时也会清除某些标志位(如读DR清RXNE),这是设计中断处理函数的基础。
中断才是正道:NVIC如何帮你“省心又省电”
轮询的本质是“主动查岗”,而中断则是“有人敲门才起床”。STM32基于ARM Cortex-M内核的NVIC(嵌套向量中断控制器),让这种事件驱动成为可能。
如何配置UART中断?
以USART2为例,基本流程如下:
// 1. 开启时钟 __HAL_RCC_USART2_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); // 2. GPIO复用配置(PA2=TX, PA3=RX) GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_2 | GPIO_PIN_3; gpio.Mode = GPIO_MODE_AF_PP; gpio.Alternate = GPIO_AF7_USART2; HAL_GPIO_Init(GPIOA, &gpio); // 3. 配置USART huart2.Instance = USART2; huart2.Init.BaudRate = 115200; huart2.Init.WordLength = UART_WORDLENGTH_8B; huart2.Init.StopBits = UART_STOPBITS_1; huart2.Init.Parity = UART_PARITY_NONE; huart2.Init.Mode = UART_MODE_TX_RX; HAL_UART_Init(&huart2); // 4. 使能中断 __HAL_UART_ENABLE_IT(&huart2, UART_IT_RXNE); // 接收中断 __HAL_UART_ENABLE_IT(&huart2, UART_IT_TXE); // 发送空中断(可选) // 5. NVIC设置 HAL_NVIC_SetPriority(USART2_IRQn, 1, 0); HAL_NVIC_EnableIRQ(USART2_IRQn);中断服务函数该怎么写?
这才是核心!不能在里面做太多事,否则会影响其他中断响应。
void USART2_IRQHandler(void) { uint32_t isr_reg = USART2->SR; // 接收中断? if (isr_reg & USART_SR_RXNE) { uint8_t ch = USART2->DR; // 读DR自动清RXNE ring_buffer_write(&rx_buf, ch); } // 发送空中断? if (isr_reg & USART_SR_TXE) { uint8_t ch; if (ring_buffer_read(&tx_buf, &ch)) { USART2->DR = ch; // 触发下一次发送 } else { __HAL_UART_DISABLE_IT(&huart2, UART_IT_TXE); // 缓冲区空了,关中断 } } }✅最佳实践:ISR中只做最轻量的操作——读数据、写缓冲区、更新状态。解析协议、打印日志这些重活统统留给主循环。
数据不丢的关键:环形缓冲区(Ring Buffer)详解
如果你只用一个全局变量接收字符,那迟早会出问题。真正可靠的方案是引入环形缓冲区,实现生产者(中断)与消费者(主程序)的解耦。
它是怎么工作的?
想象一个长度为128的数组,有两个指针:
head:下一个要写的位置;tail:下一个要读的位置。
它们像两个人绕圈跑步,只要不追尾就不会冲突。
#define RB_SIZE 128 typedef struct { uint8_t buffer[RB_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; ring_buffer_t rx_buf, tx_buf; // 接收与发送各一个为什么要加volatile?因为这个变量会被中断和主程序同时访问,防止编译器优化导致缓存不一致。
完整实现(带边界保护)
bool ring_buffer_write(ring_buffer_t *rb, uint8_t data) { uint16_t next_head = (rb->head + 1) % RB_SIZE; if (next_head == rb->tail) return false; // 已满 rb->buffer[rb->head] = data; __DMB(); // 内存屏障,多核安全 rb->head = next_head; return true; } bool ring_buffer_read(ring_buffer_t *rb, uint8_t *data) { if (rb->head == rb->tail) return false; // 空 *data = rb->buffer[rb->tail]; __DMB(); rb->tail = (rb->tail + 1) % RB_SIZE; return true; } uint16_t ring_buffer_data_size(ring_buffer_t *rb) { return (rb->head - rb->tail + RB_SIZE) % RB_SIZE; }现在你可以放心地在中断里调用write,在主循环里调用read,再也不怕数据覆盖!
不定长数据怎么收?教你一招“超时判定法”
很多协议(如AT指令、NMEA语句)都是不定长的,结尾可能是\n或\r\n。如果只是逐字节存进缓冲区,你怎么知道什么时候算“一帧结束”?
经典解决方案:1.5字符时间超时法
思路很简单:连续收到数据时不停刷新定时器,一旦超过一定时间没新数据,就认为这一帧结束了。
计算一下:波特率115200,每位时间 ≈ 8.7μs,一个字节(10位)约87μs。1.5个字节就是约130μs。
我们可以用SysTick或TIM定时器来做计数,但为了简单演示,这里用HAL库提供的滴答延时:
#define CHAR_TIMEOUT_MS 2 // 实际应用建议用硬件定时器 uint32_t last_byte_time = 0; bool frame_ready = false; // 在主循环中定期检查 if (ring_buffer_data_size(&rx_buf) > 0) { if (HAL_GetTick() - last_byte_time > CHAR_TIMEOUT_MS) { if (!frame_ready) { parse_frame(); // 处理完整帧 frame_ready = true; } } } // 在每次从中断读取数据后更新时间戳 last_byte_time = HAL_GetTick();这样即使数据包长达上百字节,也能完整接收到。
提升可靠性:这些坑你一定要避开
我在实际项目中总结出几个高频陷阱,新手极易中招。
❌ 坑点1:忘记清错误标志,导致中断反复触发
除了RXNE,还要关注以下错误标志:
ORE(Overrun Error):新数据到来时旧数据未读;FE(Framing Error):停止位异常;NE(Noise Error):线路干扰。
正确做法是在ISR中统一处理:
if (isr_reg & (USART_SR_ORE | USART_SR_FE | USART_SR_NE)) { // 必须先读SR再读DR才能清错 volatile uint8_t tmp = USART2->SR; tmp = USART2->DR; // 清除错误状态 error_count++; }❌ 坑点2:中断里调用printf或malloc,引发崩溃
千万不要在ISR中使用动态内存分配、浮点运算、阻塞函数!所有处理都应尽快移交主循环。
✅ 正确做法:ISR只负责收数据,主循环再调用vsnprintf等格式化输出。
✅ 秘籍:合理设置中断优先级
如果有多个UART,记得区分优先级:
// 高优先级:紧急报警通道 HAL_NVIC_SetPriority(USART1_IRQn, 0, 0); // 抢占优先级最高 // 普通优先级:调试输出 HAL_NVIC_SetPriority(USART2_IRQn, 2, 0);避免低优先级中断长时间阻塞高优先级任务。
高级技巧:结合RTOS打造工业级通信系统
如果你正在使用FreeRTOS,可以用队列替代环形缓冲区,获得更好的线程安全性。
QueueHandle_t uart_rx_queue; // ISR中发送到队列 void USART2_IRQHandler(void) { if (USART2->SR & USART_SR_RXNE) { uint8_t ch = USART2->DR; BaseType_t xHigherPriorityTaskWoken = pdFALSE; xQueueSendFromISR(uart_rx_queue, &ch, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } } // 任务中接收 void uart_task(void *pvParameters) { uint8_t ch; while (1) { if (xQueueReceive(uart_rx_queue, &ch, portMAX_DELAY)) { process_char(ch); } } }这种方式天然支持多任务共享资源,且无需手动加锁。
总结与延伸
我们一步步搭建了一个完整的UART中断通信体系:
- 用中断替代轮询,解放CPU;
- 用环形缓冲区实现中断与主程序解耦;
- 用超时判定法可靠接收不定长帧;
- 加入错误处理机制提升鲁棒性;
- 最终可无缝接入RTOS构建复杂系统。
这套方法不仅适用于STM32,也适用于绝大多数Cortex-M系列MCU(如GD32、CH32、nRF52等)。无论是实现Modbus、MQTT-SN、自定义私有协议,还是对接ESP8266/W5500模块,都是坚实基础。
如果你正在做一个需要稳定串口通信的项目,不妨试试把这个框架集成进去。你会发现,原来“永不丢包”的串口通信,并没有那么难。
💬互动时间:你在串口通信中还遇到过哪些奇葩问题?欢迎留言分享你的调试经历!