3. 中断模式串口通信:非阻塞收发的正确打开方式
轮询模式虽然简单,但在实际项目中基本撑不住场面。你想想看,要是主循环一直在等一个字节,那CPU啥也别干了。我刚开始用STM32做产品时,就吃过这个亏——一个简单的串口协议解析,硬是被轮询拖成了“卡顿现场”。
中断模式才是串口通信的入门姿势。说白了,就是让硬件在数据到达时主动通知CPU,而不是CPU傻等着。这一章,我们就把USART中断优先级配置、HAL库的非阻塞收发API,以及中断服务函数的执行流程彻底捋清楚。
3.1 中断优先级配置:别让串口被“饿死”
配置USART中断,第一步不是写代码,而是想清楚优先级。STM32的NVIC支持最多16级抢占优先级(视芯片型号而定),但很多人一上来就全用默认值,结果就是高频率中断把串口中断活活“踩死”。
我个人习惯的做法是:把串口中断的抢占优先级设得比系统滴答定时器低,但比普通外设中断高。为什么?因为串口数据如果不及时读取,硬件RXNE标志位会被覆盖,导致丢字节。我在一个4G模块通信的项目中就遇到过——GPS数据流一快,串口中断被定时器中断频繁打断,结果协议帧老是校验失败。
| 中断源 | 抢占优先级 | 子优先级 | 说明 |
|---|---|---|---|
| SysTick | 0 | 0 | 系统心跳,最高优先级 |
| USART1 | 1 | 0 | 串口数据收发 |
| 定时器 | 2 | 0 | PWM/计时等 |
| GPIO外部中断 | 3 | 0 | 按键等 |
配置代码其实就几行,在HAL初始化之后调用:
// 设置USART1中断优先级
HAL_NVIC_SetPriority(USART1_IRQn, 1, 0);
// 使能USART1中断
HAL_NVIC_EnableIRQ(USART1_IRQn);
3.2 HAL_UART_Transmit_IT / Receive_IT:非阻塞的“开关”
这两个API是中断模式的核心。调用它们之后,函数会立即返回,不会傻等。数据收发由硬件中断驱动,CPU可以继续干别的事。
先看发送:
uint8_t tx_buffer[] = "Hello, STM32!\r\n";
HAL_UART_Transmit_IT(&huart1, tx_buffer, sizeof(tx_buffer));
// 函数返回后,CPU可以继续执行其他任务
// 数据在后台通过中断逐字节发送
再看接收:
uint8_t rx_buffer[64];
HAL_UART_Receive_IT(&huart1, rx_buffer, 10);
// 函数返回后,等待接收10个字节
// 每收到一个字节,硬件触发中断,HAL库自动处理
这里有个关键点:HAL_UART_Receive_IT是一次性的。它只接收指定数量的字节,收完之后就不会再触发中断了。如果你想持续接收,必须在接收完成回调中再次调用它。嗯,这个“再触发”的机制,是很多新手翻车的地方。
3.3 中断服务函数执行流程:数据是怎么“流”起来的?
很多人以为中断服务函数就是HAL_UART_IRQHandler,其实它只是一个“调度中心”。真正的数据处理在回调函数里。我们来看完整流程:
- 硬件触发: USART收到一个字节,硬件置位RXNE标志位,触发中断。
- 中断向量跳转: CPU跳转到USART1_IRQHandler(在stm32f4xx_it.c中)。
- HAL库处理: HAL_UART_IRQHandler(&huart1)被调用,它检查中断标志位,判断是接收还是发送。
- 数据搬运: 如果是接收中断,HAL库把收到的字节存入你指定的rx_buffer,并更新接收计数。
- 回调通知: 当接收计数达到你设定的长度时,HAL库调用HAL_UART_RxCpltCallback。
- 用户处理: 你在回调函数里处理收到的数据,比如解析协议帧。
下面这张图把整个流程串起来了:
3.4 回调函数实现:你的代码放在哪?
回调函数HAL_UART_RxCpltCallback是弱定义,你需要在自己的源文件里重新实现它。注意:这个回调是在中断上下文中执行的,所以里面不能做耗时操作,比如打印、延时、复杂计算。
我一般这样写:
// 在main.c或其他用户文件中
uint8_t rx_buffer[64];
volatile uint8_t rx_complete_flag = 0;
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
// 设置标志位,通知主循环处理
rx_complete_flag = 1;
// 重新启动接收,准备接收下一包
HAL_UART_Receive_IT(huart, rx_buffer, 10);
}
}
// 主循环中
while (1)
{
if (rx_complete_flag)
{
rx_complete_flag = 0;
// 处理接收到的数据
ProcessReceivedData(rx_buffer, 10);
}
// 其他任务...
}
3.5 发送中断的“隐形”回调
发送也有回调:HAL_UART_TxCpltCallback。但实际项目中,发送中断用得比接收少。为什么?因为发送通常是主动的——你调用Transmit_IT,数据发完就完事了,不需要额外通知。
不过有一种情况必须用:连续发送大数据块时。比如你要发1KB的数据,如果一次调用Transmit_IT,它会占用中断较长时间。我建议分片发送,每片256字节,在发送完成回调中触发下一片。这样不会阻塞其他中断。
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
// 发送下一片数据
if (tx_offset < tx_total_len)
{
uint16_t chunk = (tx_total_len - tx_offset > 256) ? 256 : (tx_total_len - tx_offset);
HAL_UART_Transmit_IT(huart, tx_buffer + tx_offset, chunk);
tx_offset += chunk;
}
}
}
3.6 常见问题与避坑
- 中断不触发: 检查NVIC是否使能,检查USART的全局中断是否在CubeMX中勾选。
- 接收数据错位: 多半是中断优先级太低,被其他中断打断导致RXNE被覆盖。提高USART中断优先级。
- 回调不执行: 确认你重新实现了回调函数,并且没有在别的文件里用弱定义覆盖了它。
- 内存溢出: 接收缓冲区要足够大,尤其是接收不定长数据时。我习惯用环形缓冲区来解这个问题。