如何用 HAL_UART_Transmit_IT 实现真正高效的 UART 中断通信?

在嵌入式开发中,你是否曾遇到这样的问题:打印一条调试信息,整个系统却“卡”了一下?或者主循环因为等待串口发完数据而延迟响应传感器?这背后,往往就是 轮询式UART发送 的锅。

其实,从你第一次调用 HAL_UART_Transmit 开始,就已经站在了两种设计哲学的分岔路口——
是让CPU寸步不离地盯着每一个字节发完(阻塞),
还是让它发出指令后转身去处理更重要的事(非阻塞)?

今天,我们就来彻底讲清楚: 如何用中断 + HAL库,把UART发送变成一个“后台任务” ,让你的STM32真正跑出实时系统的味道。


为什么 HAL_UART_Transmit 默认不是你想要的那个?

先泼一盆冷水:很多人以为只要用了 HAL_UART_Transmit 就是非阻塞的,其实不然。

看看这个函数原型:

HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);

它长得很通用,但行为完全取决于第四个参数 Timeout
- 如果传的是具体时间(比如 100ms),它是 阻塞模式 ,会一直等到底层寄存器空了再写下一个字节;
- 只有当你使用 HAL_UART_Transmit_IT() —— 这个专门用于中断的封装函数时,才是真正意义上的 异步发送

换句话说:

HAL_UART_Transmit ≠ 非阻塞传输
HAL_UART_Transmit_IT 才是你该用的起点

别小看这一字之差,背后是两种完全不同的系统架构思维。


中断模式是怎么做到“发完就走”的?

我们来看一次典型的中断发送流程是如何启动的。假设你写了这样一段代码:

uint8_t msg[] = "Hello from IT mode!\r\n";
HAL_UART_Transmit_IT(&huart2, msg, sizeof(msg) - 1);

这时候发生了什么?

第一步:只送第一个字节,立刻返回

HAL_UART_Transmit_IT 并不会一口气把所有数据塞进硬件。它的实际动作非常克制:
1. 检查当前是否正在发送(防重入)
2. 把 msg[0] 写进 USART 的 TDR 寄存器
3. 打开 TXEIE 中断位(允许“发送寄存器为空”时触发中断)
4. 设置句柄状态为 HAL_UART_STATE_BUSY_TX
5. 函数返回 HAL_OK ,控制权交还给你的主程序

此时,CPU已经可以去做别的事了,而UART外设正默默准备发送第一个字节。

第二步:每个字节发完都会“敲门”

当第一个字节通过TX引脚发送完毕后,硬件自动置位 TXE 标志,并触发中断。这时 NVIC 会跳转到:

void USART2_IRQHandler(void)
{
    HAL_UART_IRQHandler(&huart2);  // HAL统一入口
}

HAL_UART_IRQHandler 内部会判断是哪种事件,如果是 TXE 触发,则执行:

if (__HAL_UART_GET_FLAG(huart, UART_FLAG_TXE) && ... )
{
    huart->pTxBuffPtr++;           // 指针前移
    huart->TxXferCount--;          // 剩余计数减一
    if (huart->TxXferCount > 0)
        WRITE_REG(huart->Instance->TDR, *huart->pTxBuffPtr);  // 发下一字节
    else
        // 所有数据发完了
        HAL_UART_TxCpltCallback(huart);
}

看到没? 真正的“逐字节发送”是在中断里完成的 ,主线程早已继续运行多时。


关键细节:别让缓冲区成了定时炸弹

最常被忽视的问题来了: 你传进去的 pData 缓冲区必须在整个传输过程中有效

举个反例:

void SendStatus(void)
{
    uint8_t local_buf[32];
    sprintf(local_buf, "Temp: %.2f\r\n", read_temp());
    HAL_UART_Transmit_IT(&huart2, local_buf, strlen(local_buf));  // ❌ 危险!
}

这段代码看起来没问题,但实际上 local_buf 是局部变量,函数退出后栈空间可能被覆盖。当中断尝试读取后续字节时,拿到的数据可能是垃圾值,甚至导致 HardFault。

✅ 正确做法有三种:
1. 使用静态缓冲区
2. 动态分配(需确保不会被提前释放)
3. 在回调中复制并清理

推荐写法示例:

static uint8_t tx_buffer[64];  // 全局静态

void SendStatusSafe(void)
{
    float temp = read_temp();
    int len = snprintf((char*)tx_buffer, sizeof(tx_buffer), "Temp: %.2f\r\n", temp);

    if (HAL_UART_GetState(&huart2) == HAL_UART_STATE_READY) {
        HAL_UART_Transmit_IT(&huart2, tx_buffer, len);
    }
    // 否则可选择排队或丢弃
}

回调函数不只是“通知”,更是控制枢纽

很多人把 HAL_UART_TxCpltCallback 当成一个简单的“完成提示”,其实它可以成为你通信调度的核心节点。

比如你想实现连续发送一组日志:

extern uint8_t log_packets[][32];
extern int total_logs;
int current_log = 0;

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart2) {
        current_log++;
        if (current_log < total_logs) {
            // 自动发起下一轮发送
            HAL_UART_Transmit_IT(huart, log_packets[current_log], 32);
        } else {
            // 全部发完,复位索引
            current_log = 0;
        }
    }
}

这样一来,你就构建了一个 自动推进的日志推送机 ,全程无需主循环干预。

更进一步,结合环形缓冲区(Ring Buffer),你甚至能实现类似 Linux tty 的后台静默输出机制。


中断 vs DMA:什么时候该升级?

虽然中断模式已经比轮询强太多,但在某些场景下仍显吃力:

  • 每秒要发几千条日志?
  • 输出音频 PCM 数据流?
  • 固件升级时连续发送 64KB 包?

这些情况下,频繁的中断上下文切换反而成了负担。这时就该请出终极武器: DMA

切换到 DMA 只需两步

  1. 启用 DMA 时钟并配置通道(CubeMX 可自动生成)
  2. 改用函数:
HAL_UART_Transmit_DMA(&huart2, data_ptr, size);

之后全程由DMA控制器接管,CPU仅在开始和结束时参与。传输期间哪怕进低功耗模式都没问题。

不过要注意:
- DMA 不适合短小、高频的数据包(建立开销大)
- 必须保证内存地址连续且对齐
- 多任务环境下需注意缓存一致性(尤其在Cortex-M7/M33上)

场景 推荐模式
调试输出、命令交互 ✅ 中断模式
传感器周期上报 ✅ 中断模式
文件/固件批量传输 ✅ DMA 模式
实时音频流 ✅ DMA + 双缓冲

工程实战技巧:打造健壮的串口子系统

1. 状态检查不能少

永远不要假设上次传输已完成。正确的调用姿势是:

if (HAL_UART_GetState(&huart2) == HAL_UART_STATE_READY) {
    HAL_UART_Transmit_IT(&huart2, buffer, len);
} else {
    // 处理忙状态:排队 / 丢弃 / 错误上报
}

2. 错误回调一定要实现

默认的 __weak 版本啥也不干,出了错你就懵了。务必重写:

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart2) {
        uint32_t error = HAL_UART_GetError(huart);
        // 记录错误类型:帧错误?溢出?噪声?
        // 可执行软重启、清除标志、重传等操作
        __HAL_UART_CLEAR_FLAG(huart, UART_CLEAR_OREF | UART_CLEAR_NEF | UART_CLEAR_FEF);

        // 可选:重新初始化UART
        HAL_UART_DeInit(huart);
        MX_USART2_UART_Init();
    }
}

3. 和 RTOS 配合更强大

如果你用了 FreeRTOS,可以用信号量或队列来做发送同步:

SemaphoreHandle_t uart_tx_sem;

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart2) {
        xSemaphoreGiveFromISR(uart_tx_sem, NULL);
    }
}

// 在任务中:
xSemaphoreTake(uart_tx_sem, portMAX_DELAY);
HAL_UART_Transmit_IT(&huart2, data, len);
// 自动等待完成

当然,更高级的做法是创建一个“串口发送任务”,所有打印请求都通过队列投递给它,实现集中管理。


总结:从“能用”到“好用”的跨越

我们回顾一下这场通信模式的进化之路:

方式 CPU占用 实时性 适用场景
HAL_UART_Transmit (轮询) 初学者实验
HAL_UART_Transmit_IT (中断) 绝大多数项目
HAL_UART_Transmit_DMA (DMA) 极低 极好 大数据流

掌握 HAL_UART_Transmit_IT 的本质,意味着你不再只是“会调API”,而是真正理解了嵌入式系统中 资源解耦 事件驱动 的精髓。

下次当你想加一句 printf 时,不妨停下来想想:
我是不是又在制造一个潜在的阻塞点?
能不能让它悄悄地在后台完成?

这才是高手和码农的区别所在。

如果你在实际项目中遇到 UART 发送卡顿、数据错乱或中断丢失的问题,欢迎留言讨论。我们可以一起分析日志、排查优先级冲突,甚至拆解汇编代码来找根源。

Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐