1. 项目概述:从轮询到中断的通信跃迁
搞STM32串口通信,很多朋友都是从HAL库的
HAL_UART_Transmit
和
HAL_UART_Receive
这两个轮询函数入门的。简单是简单,但用过的都知道,主程序
while(1)
里一旦调用了
HAL_UART_Receive
去等数据,整个CPU就被“挂”在那儿了,啥也干不了,直到收到指定长度的数据或者超时。这对于一个需要同时处理按键、刷新屏幕、执行算法的系统来说,简直是灾难。这时候,
串口中断
就成了解放CPU、实现高效异步通信的必由之路。今天,我们就深入STM32的HAL库,把串口中断通信的里里外外、坑坑洼洼都捋一遍,让你不仅能实现功能,更能理解背后的机制,写出稳定可靠的代码。
简单说,串口中断通信的核心思想就是“触发-响应”。串口硬件在收到数据、发送完成或发生错误时,会自动产生一个中断信号,CPU收到这个信号后,会暂时放下手头的工作,跳转到我们预先写好的中断服务函数里,去处理数据搬运、状态清除等紧急事务,处理完再返回原任务继续执行。这样,主程序就不用傻等,可以自由地处理其他任务,整个系统的实时性和效率大幅提升。无论是接收不定长的传感器数据,还是与上位机进行高速命令交互,中断模式都是更优的选择。
2. 核心思路与方案选型:HAL库中断驱动模型解析
在HAL库的框架下使用串口中断,我们实际上是在使用一套经过封装的“中断驱动模型”。理解这套模型,是避免后续各种诡异问题的关键。
2.1 HAL库中断处理的三层架构
HAL库对中断的处理可以粗略分为三层:
-
硬件中断服务程序
:这是最底层,名字通常是
USARTx_IRQHandler(x为串口号)。这个函数是中断触发后第一个进入的入口,由CubeMX自动生成并关联到中断向量表。 我们通常不直接修改这个函数。 -
HAL库中断处理枢纽
:
HAL_UART_IRQHandler(&huartx)。上面的硬件ISR会调用这个函数。它是HAL库的中枢,负责判断本次中断是由什么事件触发的(接收完成、发送完成、空闲、错误等),然后根据状态调用对应的回调函数。 -
用户回调函数
:这是我们主要操作的战场。HAL库定义了一系列以
__weak修饰的弱回调函数,如HAL_UART_RxCpltCallback、HAL_UART_TxCpltCallback等。我们需要在自己的代码中重新实现(重写)这些函数,把我们自己的数据处理逻辑放在里面。当中断枢纽判断事件发生后,就会跳转到我们重写的这个函数里执行。
这种架构的好处是隔离了硬件底层和用户应用,让我们的代码更清晰。但坏处是,如果不清楚流程,一旦出现问题,调试起来会觉得像在“黑盒”里操作。
2.2 接收方案选型:单字节中断 vs. 空闲中断+DMA
对于接收,常见有两种中断方案,选择取决于你的数据格式和系统负载。
-
方案一:单字节接收中断
- 工作原理 :每收到一个字节,就触发一次接收完成中断,在回调函数中读取该字节并存入缓冲区,然后重新启动下一次单字节接收中断。
- 优点 :实现简单,对数据包格式无要求,每个字节都能实时处理。
- 缺点 :频繁中断。在115200波特率下,每秒可能产生上万次中断,大量消耗CPU资源,用于进出中断、处理上下文。在高波特率或主频较低的MCU上,可能导致系统响应变慢。
- 适用场景 :低波特率(如9600),或数据量极小、间隔很长的场合。
-
方案二:空闲中断(Idle Interrupt) + DMA
- 工作原理 :这是处理 不定长数据 的黄金搭档。DMA(直接存储器访问)负责在后台自动将串口接收到的数据搬运到我们指定的内存缓冲区中,完全不需要CPU参与。同时,我们开启串口的“空闲中断”。当一帧数据发送完毕后,串口总线会保持高电平(空闲状态)超过一个字节的时间,此时硬件会产生“空闲中断”。
- 优点 :CPU占用率极低。DMA负责搬运,CPU只在整包数据接收完毕(空闲中断触发)时被中断一次,去处理缓冲区里的完整数据包。高效且稳定。
- 缺点 :配置稍复杂,需要理解DMA和空闲中断的配合。需要额外的逻辑来判断一帧数据的结束。
- 适用场景 : 绝大多数需要接收不定长、高速率串口数据的场景 ,如Modbus、自定义文本协议、GPS模块数据等。
注意 :对于本文,我们将重点讲解最常用、也最具代表性的“空闲中断+DMA”方案。掌握了这个,单字节中断方案理解起来就轻而易举了。
3. 硬件与软件环境准备
在开始敲代码之前,我们需要把舞台搭好。
3.1 硬件连接与引脚确认
以常见的STM32F103C8T6(蓝色药丸)和USB转TTL模块为例:
- MCU USART1_TX (PA9) 接 USB-TTL的RX 。
- MCU USART1_RX (PA10) 接 USB-TTL的TX 。
- MCU GND 接 USB-TTL的GND 。
- MCU 3.3V 可为部分模块供电(注意模块电压)。
务必确认你的开发板上的串口引脚是否正确,有些板子USART1可能被映射到了PB6/PB7(重映射功能)。
3.2 软件配置:CubeMX工程生成
这是最关键的一步,配置错了,代码怎么写都白搭。
- 新建工程 ,选择你的MCU型号。
- 配置RCC :HSE(外部高速时钟)选择Crystal/Ceramic Resonator,保证系统有时钟源。
- 配置SYS :Debug根据你的调试器选择(如Serial Wire),Timebase Source可以选一个不是SysTick的定时器(如TIM1),避免和HAL库延时以及可能的操作系统冲突。
-
配置USART1
:
- Mode: 选择 Asynchronous (异步通信)。
- Basic Parameters: 设置波特率(如115200)、字长(8位)、停止位(1位)、校验位(None)、硬件流控制(None)。
-
NVIC Settings
:
这是中断配置的核心!
必须勾选
USART1 global interrupt使能全局中断。如果使用空闲中断, 暂时不在这里勾选空闲中断 ,我们后续在代码中动态开启,这样更灵活。
-
配置DMA(用于接收)
:
-
在DMA Settings标签页,点击
Add。 -
Select: 选择
USART1_RX。 - Stream: 根据数据手册选择可用的Stream(如DMA1_Channel5)。
-
Direction:
Peripheral To Memory(外设到内存)。 -
Priority: 默认
Low即可。 -
Mode:
-
Normal:传输一次指定长度后停止,需要手动重启。适合定长包。 -
Circular:循环模式。DMA在填满缓冲区后,会自动回到开头覆盖旧数据。这是配合空闲中断处理不定长数据的常用模式! 我们选择Circular。
-
-
Increment Address:
Memory要勾选(内存地址递增),Peripheral不勾选(外设地址固定)。 -
Data Width: 都选择
Byte(字节)。 - 注意 :DMA发送(USART1_TX)通常用Normal模式,在需要发送时手动启动一次。
-
在DMA Settings标签页,点击
- 生成代码 :Project Manager里设置好工程名、路径、IDE(MDK-ARM等),在Code Generator里选择“Copy only necessary library files”,然后生成代码。
4. 代码实现详解:从初始化到数据处理
生成了代码,我们进入
main.c
和
stm32f1xx_it.c
,开始添加我们的逻辑。
4.1 变量定义与初始化
在
/* USER CODE BEGIN PV */
私有变量区,定义我们需要的缓冲区和管理变量。
/* 定义接收缓冲区 */
#define RX_BUFFER_SIZE 256
uint8_t uart_rx_buffer[RX_BUFFER_SIZE]; // DMA循环接收缓冲区
uint16_t uart_rx_len = 0; // 记录一次接收到的数据长度
uint8_t uart_rx_flag = 0; // 接收完成标志位
/* 定义发送缓冲区(示例) */
uint8_t tx_data[] = "Hello from STM32!\r\n";
在
main
函数的初始化部分(
/* USER CODE BEGIN 2 */
),我们需要启动DMA接收,并开启空闲中断。
/* 启动DMA循环接收,让DMA一直在后台等待数据存入uart_rx_buffer */
if (HAL_UART_Receive_DMA(&huart1, uart_rx_buffer, RX_BUFFER_SIZE) != HAL_OK)
{
Error_Handler(); // 启动失败,进入错误处理
}
/* 手动开启串口空闲中断 */
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
关键点解析 :
-
HAL_UART_Receive_DMA:这个函数启动了DMA接收,参数分别是串口句柄、目标缓冲区、缓冲区长度。因为之前DMA配置为Circular模式,所以这个接收过程是永久的、循环的。 -
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE):这是一个宏,用于使能USART1的空闲中断。CubeMX生成的代码默认不会开启这个中断,需要我们自己开启。 务必在启动DMA接收之后再开启空闲中断 ,逻辑顺序很重要。
4.2 中断服务函数与回调函数
首先,我们需要在中断服务函数中处理空闲中断。找到
stm32f1xx_it.c
文件中的
USART1_IRQHandler
函数。
void USART1_IRQHandler(void)
{
/* USER CODE BEGIN USART1_IRQn 0 */
/* 检测是否是空闲中断 */
if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET)
{
/* 清除空闲中断标志位 */
/* 注意:读取SR寄存器后,需要再读取DR寄存器才能清除IDLE标志 */
__HAL_UART_CLEAR_IDLEFLAG(&huart1);
/* 调用我们自定义的空闲中断处理函数 */
UART_IDLE_Handler(&huart1);
}
/* USER CODE END USART1_IRQn 0 */
HAL_UART_IRQHandler(&huart1); // HAL库中断枢纽
/* USER CODE BEGIN USART1_IRQn 1 */
/* USER CODE END USART1_IRQn 1 */
}
关键点解析 :
-
我们在调用
HAL_UART_IRQHandler之前,先自己判断并处理了空闲中断。这是因为标准的HAL库中断处理函数HAL_UART_IRQHandler并没有处理空闲中断的逻辑。 -
__HAL_UART_GET_FLAG和__HAL_UART_CLEAR_IDLEFLAG是用于操作状态标志位的宏。 清除IDLE标志位的方法比较特殊,需要先读SR(状态寄存器),再读DR(数据寄存器) ,这个宏帮我们封装了正确的操作序列。 -
我们定义了一个自定义函数
UART_IDLE_Handler来处理核心逻辑,这样让中断服务函数保持简洁。
接下来,在
main.c
中实现这个自定义的空闲中断处理函数。
/**
* @brief 串口空闲中断处理函数
* @param huart: 串口句柄指针
* @retval None
*/
void UART_IDLE_Handler(UART_HandleTypeDef *huart)
{
if(huart->Instance == USART1) // 判断是哪个串口
{
/* 暂时关闭DMA,防止处理过程中数据被修改 */
HAL_UART_DMAStop(huart);
/* 计算本次接收到的数据长度
* 公式:缓冲区总长度 - DMA未传输的数据长度
*/
uart_rx_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx);
if(uart_rx_len > 0)
{
/* 设置接收完成标志,主循环中处理数据 */
uart_rx_flag = 1;
/* 可以在这里直接处理数据,但建议在标志位触发的主循环中处理,
因为中断服务函数应尽可能短小精悍 */
// process_rx_data(uart_rx_buffer, uart_rx_len);
}
/* 重新设置DMA传输数据长度,并启动DMA接收,为下一次接收做准备 */
/* 注意:必须先重新设置长度,再启动接收 */
huart->hdmarx->Instance->CNDTR = RX_BUFFER_SIZE; // 重新装载传输数量
__HAL_DMA_ENABLE(huart->hdmarx); // 使能DMA
__HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); // 重新使能空闲中断(如果被HAL库禁用)
}
}
这是整个流程的灵魂,每一步都至关重要:
- 停止DMA :在处理数据前先停止DMA,是为了“冻结”当前的DMA计数器(CNDTR寄存器),从而能准确计算出本次接收了多少数据。如果不停止,DMA可能在计算过程中还在后台修改数据,导致长度计算不准或数据错乱。
-
计算数据长度
:
__HAL_DMA_GET_COUNTER这个宏获取的是DMA通道中 剩余待传输的数据单元数 。我们用总缓冲区大小减去这个剩余数,就得到了 已经传输完成 的数据量,也就是本次从空闲中断开始到触发时接收到的数据长度。 - 设置标志位 :在中断函数里,我们只做最紧急的事——计算长度和设置标志。把耗时的数据处理(如解析协议、存储到其他队列)放到主循环中,这是保证系统实时性的基本原则。
-
重启DMA
:处理完当前数据包后,必须让DMA准备好接收下一个包。这里有两个关键操作:
-
huart->hdmarx->Instance->CNDTR = RX_BUFFER_SIZE;:手动重置DMA通道的传输数据量寄存器(CNDTR)为缓冲区大小。 这是循环模式下重启DMA的关键,很多教程遗漏这一步,导致DMA只工作一次! -
__HAL_DMA_ENABLE(huart->hdmarx);:重新使能DMA通道。 - 最后,再次使能空闲中断,确保下一次空闲事件还能被捕获。
-
4.3 主循环中的数据与发送处理
最后,我们在
main
函数的
while(1)
循环中,检查标志位并处理数据。
while (1)
{
/* 用户代码区域 - 3 */
/* 检查串口接收完成标志 */
if(uart_rx_flag == 1)
{
uart_rx_flag = 0; // 清除标志
/* 示例:将接收到的数据回环发送出去 */
HAL_UART_Transmit(&huart1, uart_rx_buffer, uart_rx_len, 100); // 阻塞式发送,超时100ms
/* 或者,进行协议解析 */
// protocol_parse(uart_rx_buffer, uart_rx_len);
/* 处理完成后,可以清空接收缓冲区(可选,循环模式会被新数据覆盖) */
// memset(uart_rx_buffer, 0, uart_rx_len);
}
/* 其他任务,如LED闪烁、传感器读取等 */
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
HAL_Delay(500);
}
发送操作
:示例中使用了
HAL_UART_Transmit
,这是一个
阻塞式
发送函数,会占用CPU直到发送完成或超时。对于需要高效率的系统,发送也应使用中断或DMA模式。
-
中断发送
:调用
HAL_UART_Transmit_IT,它启动发送后立即返回,发送完成后会触发HAL_UART_TxCpltCallback回调函数。 -
DMA发送
:调用
HAL_UART_Transmit_DMA,CPU占用率最低,发送完成后触发HAL_UART_TxHalfCpltCallback(发送一半)和HAL_UART_TxCpltCallback(发送完成)回调。
5. 常见问题与深度排查指南
即使代码看起来没问题,在实际调试中你仍可能会遇到各种情况。下面是一些“坑”和解决方案。
5.1 数据接收不完整或错位
- 症状 :只能收到第一个字节,或者收到的数据是乱的,和发送的不一样。
-
排查
:
-
检查DMA配置
:确认DMA的
Data Width(数据宽度)是否与串口配置一致(通常都是Byte)。确认Direction是Peripheral To Memory。 -
检查缓冲区大小
:
RX_BUFFER_SIZE是否足够大?如果一包数据超过缓冲区大小,在循环模式下,旧数据会被新数据覆盖,导致“丢包”或数据拼接错乱。 -
检查空闲中断判断逻辑
:确保
__HAL_UART_CLEAR_IDLEFLAG被正确调用。可以用调试器在中断里设断点,看是否每次数据帧结束都能进来。 -
检查DMA重启逻辑
:这是最常见的问题。务必确认在
UART_IDLE_Handler中, 先重置CNDTR,再__HAL_DMA_ENABLE。顺序反了或者漏了重置CNDTR,DMA都不会继续工作。 - 检查波特率 :确保MCU和上位机(如串口助手)的波特率、数据位、停止位、校验位完全一致。一个比特的误差都会导致乱码。
-
检查DMA配置
:确认DMA的
5.2 只能接收一次数据
- 症状 :上电后第一次接收正常,之后再也收不到数据。
-
原因
:几乎可以断定是DMA没有正确重启。在
UART_IDLE_Handler中,DMA被HAL_UART_DMAStop停止后,没有按照正确的步骤重新配置和使能。 -
解决
:严格按照4.2节中的步骤,特别是
CNDTR = RX_BUFFER_SIZE;和__HAL_DMA_ENABLE这两步,缺一不可。也可以单步调试,对比第一次接收和第二次接收前,DMA通道相关寄存器(尤其是CNDTR和CCR中的ENABLE位)的值是否恢复。
5.3 中断服务函数进不去
-
症状
:发送数据,但程序完全没反应,断点打在
USART1_IRQHandler或UART_IDLE_Handler里打不住。 -
排查
:
-
NVIC配置
:在CubeMX里确认
USART1 global interrupt已勾选,并且优先级已配置(默认即可)。生成的代码会在main初始化时调用HAL_NVIC_SetPriority和HAL_NVIC_EnableIRQ。 -
中断使能
:确认
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);确实被执行了。可以在调用后加个打印(如果还有其他串口)或者调试点查看huart1.Instance->CR1寄存器的IDLEIE位是否被置1。 -
系统中断总开关
:确保没有其他地方使用
__disable_irq()关闭了全局中断。
-
NVIC配置
:在CubeMX里确认
5.4 数据覆盖与缓冲区管理策略
在
Circular
模式下,DMA会循环覆盖缓冲区。如果主循环处理数据的速度跟不上接收的速度,新数据就会覆盖还未处理的老数据。
-
双缓冲区/乒乓缓冲区
:这是更高级的策略。定义两个缓冲区A和B。DMA始终向其中一个(如A)写数据。当空闲中断触发时,我们:
- 停止DMA。
- 计算长度, 将数据从当前DMA缓冲区(A)快速拷贝到另一个处理缓冲区(B) 。
-
重启DMA,并
将DMA的目标地址指向另一个缓冲区(B)
(这需要修改
huart->hdmarx->Instance->CMAR寄存器)。 - 主循环处理缓冲区A的数据。
- 如此往复,实现接收和处理的完全并行,无数据丢失风险。这是工业级应用的常见做法。
5.5 发送与接收中断的冲突
如果同时使能了发送完成中断和接收中断(包括空闲中断),在它们的中断服务函数里都要调用
HAL_UART_IRQHandler
。HAL库的中断枢纽函数内部会判断事件源。通常不会有问题,但要注意中断优先级。串口收发中断的优先级一般设置为相同,且不要设置得太高(避免阻塞其他重要中断)。
6. 进阶优化与稳定性考量
掌握了基础功能后,我们可以从工程角度进一步优化代码的健壮性和可维护性。
6.1 封装与模块化
不要把所有代码都堆在
main.c
里。建议创建一个独立的
uart_comm.c
和
uart_comm.h
文件。
-
在
.h文件中声明外部可调用的函数,如UART_Init(void),UART_Send_Data(uint8_t *data, uint16_t len),uint8_t UART_Get_RxFlag(void),uint16_t UART_Get_RxData(uint8_t *buf)。 -
在
.c文件里定义全局缓冲区、标志位,并实现所有函数,包括中断处理函数。将UART_IDLE_Handler声明为静态函数(static),只在本文件内使用,通过公共接口与主程序交互。 -
这样,主程序只需要调用
UART_Init(),然后检查UART_Get_RxFlag()并获取数据即可,实现了高内聚、低耦合。
6.2 超时与错误处理
HAL库函数都有返回值(
HAL_StatusTypeDef
),务必检查。
-
在
HAL_UART_Receive_DMA、HAL_UART_Transmit等函数调用后,判断返回值是否为HAL_OK。 -
在串口中断服务函数中,
HAL_UART_IRQHandler也会处理一些错误标志(如溢出ORE、噪声NE、帧错误FE)。我们可以重写错误回调函数HAL_UART_ErrorCallback,在里面记录错误类型(通过huart->ErrorCode),并做相应的恢复处理,比如清空缓冲区、重新初始化DMA等。
6.3 使用RTOS时的注意事项
如果在FreeRTOS等实时操作系统中使用串口中断+DMA,需要注意:
-
中断与任务通信
:在
UART_IDLE_Handler中,不要进行复杂处理或调用可能引起阻塞的API(如printf)。最佳实践是设置标志位,然后 释放一个信号量(Semaphore) 或 发送一个消息到队列(Queue) 。一个专用的数据处理任务会等待这个信号量或队列,被唤醒后去处理缓冲区中的数据。 -
DMA内存访问
:确保DMA使用的缓冲区位于
DMA可以访问的内存区域(通常都是)。如果使用了内存管理(如堆),需注意内存对齐和一致性。 -
中断优先级
:在RTOS中,如果中断服务程序里调用了
FromISR版本的RTOS API(如xSemaphoreGiveFromISR),需要合理配置串口中断的优先级,通常它应高于RTOS可管理的最高中断优先级(configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY),以避免在中断中产生任务切换时出现不可预料的问题。
从轮询到中断,不仅仅是换几个API调用,更是一种编程思维的转变——从同步等待到异步事件驱动。理解HAL库的中断封装机制、掌握DMA与空闲中断的配合、妥善处理缓冲区与数据流,是写出高效、稳定串口通信代码的基石。希望这篇长文能帮你填平那些常见的“坑”,让你在STM32的世界里更游刃有余。

3685

被折叠的 条评论
为什么被折叠?



