3. 中断模式串口通信:非阻塞收发的正确打开方式

轮询模式虽然简单,但在实际项目中基本撑不住场面。你想想看,要是主循环一直在等一个字节,那CPU啥也别干了。我刚开始用STM32做产品时,就吃过这个亏——一个简单的串口协议解析,硬是被轮询拖成了“卡顿现场”。

中断模式才是串口通信的入门姿势。说白了,就是让硬件在数据到达时主动通知CPU,而不是CPU傻等着。这一章,我们就把USART中断优先级配置、HAL库的非阻塞收发API,以及中断服务函数的执行流程彻底捋清楚。

本章核心目标: 掌握中断模式串口收发的完整链路——从NVIC优先级配置,到HAL_UART_Transmit_IT/Receive_IT调用,再到中断服务函数内部的数据流转。

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);
小技巧: 如果你用的是HAL_UART_Init(),它内部会自动调用HAL_NVIC_SetPriority,但优先级值取决于你的UART_InitTypeDef配置。我建议显式调用一次,确保优先级符合预期。

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是一次性的。它只接收指定数量的字节,收完之后就不会再触发中断了。如果你想持续接收,必须在接收完成回调中再次调用它。嗯,这个“再触发”的机制,是很多新手翻车的地方。

曾经踩过的坑: 我在做一个工业数据采集器时,用了HAL_UART_Receive_IT接收不定长数据。结果发现收完第一包后,第二包就收不到了。查了半天,原来是忘记在回调里重新调用Receive_IT。记住:一次调用,一次有效。

3.3 中断服务函数执行流程:数据是怎么“流”起来的?

很多人以为中断服务函数就是HAL_UART_IRQHandler,其实它只是一个“调度中心”。真正的数据处理在回调函数里。我们来看完整流程:

  1. 硬件触发: USART收到一个字节,硬件置位RXNE标志位,触发中断。
  2. 中断向量跳转: CPU跳转到USART1_IRQHandler(在stm32f4xx_it.c中)。
  3. HAL库处理: HAL_UART_IRQHandler(&huart1)被调用,它检查中断标志位,判断是接收还是发送。
  4. 数据搬运: 如果是接收中断,HAL库把收到的字节存入你指定的rx_buffer,并更新接收计数。
  5. 回调通知: 当接收计数达到你设定的长度时,HAL库调用HAL_UART_RxCpltCallback。
  6. 用户处理: 你在回调函数里处理收到的数据,比如解析协议帧。

下面这张图把整个流程串起来了:

USART中断模式数据接收流程 ① USART硬件接收字节 ② RXNE标志置位,触发中断 ③ USART1_IRQHandler ④ HAL_UART_IRQHandler ⑤ HAL_UART_RxCpltCallback 关键说明 • 中断服务函数由硬件自动调用 • HAL库负责标志位判断和数据搬运 • 用户只需实现回调函数 • 回调中需重新调用Receive_IT

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中断优先级。
  • 回调不执行: 确认你重新实现了回调函数,并且没有在别的文件里用弱定义覆盖了它。
  • 内存溢出: 接收缓冲区要足够大,尤其是接收不定长数据时。我习惯用环形缓冲区来解这个问题。
曾经踩过的坑: 有一次我把HAL_UART_Receive_IT放在了回调函数外面,只在初始化时调用了一次。结果收完第一包后,中断就再也不触发了。排查了半天才发现——Receive_IT是一次性的,必须在回调里重新“上膛”。这个坑,我估计80%的初学者都踩过。