用好 HAL_UART_Transmit ,让串口通信不再“卡住”你的系统

你有没有遇到过这种情况:主控在发一条日志时,整个系统像被“冻结”了一样?定时器不准了、按键没反应、传感器数据也丢了……排查半天,最后发现罪魁祸首竟是那句看似无害的:

HAL_UART_Transmit(&huart1, buffer, len, 100);

没错,就是这个 “基础中的基础”函数 —— HAL_UART_Transmit 。它简单易用,几乎每个STM32项目都会用到;但它也暗藏陷阱,稍不注意就会拖垮系统的实时性和响应能力。

今天我们就来彻底拆解 HAL_UART_Transmit ,从它的底层机制讲起,一步步带你走出“轮询阻塞”的舒适区,构建一个真正 高效、稳定、可复用 的UART驱动架构。


为什么 HAL_UART_Transmit 会让CPU“忙死”?

先来看一眼这个函数的标准原型:

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

参数很清晰:
- huart :UART句柄
- pData :要发送的数据缓冲区
- Size :数据长度
- Timeout :最大等待时间(毫秒)

表面上看一切正常。但问题就出在它的 工作方式 上。

它干了什么?

当你调用这个函数后,HAL库会做这几件事:

  1. 检查参数合法性;
  2. 设置UART状态为“BUSY”;
  3. 把第一个字节写进 USART_DR 寄存器;
  4. 然后——开始 轮询等待

它不断读取状态寄存器( SR ),检查两个标志位:
- TXE :发送数据寄存器空(可以写下一个字节)
- TC :传输完成(所有字节都已移出)

直到所有数据发完或超时为止。

🔍 关键点:这整个过程是 完全由CPU主动轮询完成的 ,期间不能做任何其他事。

这意味着:如果你要发512字节的数据,波特率是115200,理论上需要约44ms。在这44ms里,你的主循环停摆,中断虽然还能响应,但高频率任务可能已经被打乱节奏。

这不是“通信”,这是“自锁”。


那怎么办?别急,HAL库早就准备了后手

好消息是,ST并没有让我们一直困在轮询里。除了 HAL_UART_Transmit ,还有两个更高级的兄弟:

函数 类型 CPU占用 适用场景
HAL_UART_Transmit 阻塞式轮询 调试打印、极小数据
HAL_UART_Transmit_IT 中断驱动 周期性小包、中频通信
HAL_UART_Transmit_DMA DMA驱动 极低 大数据流、固件升级

我们一个个来看怎么用。


方案一:用中断解放CPU —— HAL_UART_Transmit_IT

它是怎么工作的?

调用 HAL_UART_Transmit_IT 后:
1. HAL把第一字节写入DR;
2. 开启 TXE 中断;
3. 函数立即返回,CPU继续执行其他任务;
4. 每当硬件发送完一字节,触发中断;
5. 在中断服务程序中填入下一字节;
6. 全部发完后,调用回调函数 HAL_UART_TxCpltCallback()

实战代码示例

uint8_t tx_buf[] = "Sensor: OK\r\n";

// 启动中断发送
if (HAL_UART_Transmit_IT(&huart1, tx_buf, sizeof(tx_buf)-1) != HAL_OK) {
    Error_Handler();
}

// 回调函数必须自己实现
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        // 可以在这里置标志、点亮LED、启动下一次发送等
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);
    }
}

注意事项 ⚠️

  • 中断频率不能太高 :比如921600波特率下连续发数据,中断太频繁会影响系统性能;
  • 缓冲区不能改 :发送过程中千万别去修改 tx_buf 的内容;
  • 不能并发调用 :同一UART实例不支持同时发起两次IT发送,否则会报错 HAL_BUSY

所以, 适合每秒几次到几十次的小数据包发送 ,比如传感器上报、心跳包、状态通知等。


方案二:彻底放手给硬件 —— HAL_UART_Transmit_DMA

这才是真正的“零打扰”方案。

它的核心思想是什么?

DMA(Direct Memory Access)是一个独立于CPU的控制器,专门负责内存和外设之间的数据搬运。

当我们调用 HAL_UART_Transmit_DMA 时:
1. HAL配置DMA通道:源地址 = 数据缓冲区,目标地址 = USART_DR;
2. 启动DMA传输;
3. 此后无需CPU干预 ,DMA自动将每个字节送入UART;
4. 传完一半时可选触发半完成回调;
5. 全部完成后触发 HAL_UART_TxCpltCallback()

整个过程CPU只花了几个微秒做初始化,剩下的全交给硬件。

如何配置DMA?(基于STM32CubeMX)

.ioc 文件中打开UART配置 → 找到 DMA Settings → 添加一条新通道:

参数 推荐设置
Mode Normal 或 Circular
Direction Memory to Peripheral
Data Width Byte
Increment Memory: Yes / Peripheral: No
Priority Medium

✅ 特别提醒:确保DMA请求映射正确!例如USART1_TX通常对应DMA1_Stream7_Channel4(具体查芯片手册RM0090)。

实际应用场景

  • 固件升级(FOTA)时上传大量日志或接收固件块;
  • 波形数据实时上传PC分析;
  • RS485网络广播多设备同步消息;
  • 音频调试信息流输出。

示例代码

uint8_t big_data[1024];
// ...填充数据...

// 发起DMA发送
HAL_UART_Transmit_DMA(&huart1, big_data, 1024);

// 可选进度监控
void HAL_UART_TxHalfCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        // 已发送512字节,可用于更新UI或心跳
    }
}

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        // 发送完毕,可以关闭电源域或进入低功耗模式
    }
}

多任务环境下如何避免“抢串口”?

在FreeRTOS或其他RTOS环境中,多个任务都想通过UART发数据怎么办?

直接调用 HAL_UART_Transmit_DMA 很容易导致冲突:A任务刚启动发送,B任务又来了,结果A的数据还没发完就被覆盖了。

解法:加互斥锁(Mutex)

osMutexId_t uart_mutex;

// 初始化时创建互斥量
uart_mutex = osMutexNew(NULL);

// 封装安全发送函数
HAL_StatusTypeDef SafeUartSend(uint8_t *data, uint16_t len) {
    osStatus_t status = osMutexAcquire(uart_mutex, osWaitForever);
    if (status != osOK) return HAL_ERROR;

    HAL_StatusTypeDef result = HAL_UART_Transmit_DMA(&huart1, data, len);

    // 不在这里释放!要在回调里释放
    return result;
}

// 在回调中释放锁
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        osMutexRelease(uart_mutex);
    }
}

这样就能保证: 只有当前发送完全结束,下一个任务才能拿到资源


还有哪些坑?这些经验帮你避雷

❌ 坑1:DMA发送时修改缓冲区内容

常见错误写法:

uint8_t temp[32];
sprintf(temp, "Count: %d\r\n", i++);
HAL_UART_Transmit_DMA(&huart1, temp, strlen(temp));  // 危险!

问题在哪? temp 是局部变量,函数退出后栈空间可能被覆盖;而且DMA还没发完,下次循环又改了 temp 内容。

✅ 正确做法:
- 使用静态缓冲区;
- 或动态分配并确保生命周期覆盖整个DMA过程;
- 或使用双缓冲机制轮流发送。

❌ 坑2:忘记处理错误回调

UART通信不是总成功的。可能会遇到帧错误、噪声干扰、溢出等问题。

记得实现错误回调:

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        // 记录错误类型
        uint32_t error = huart->ErrorCode;

        // 复位UART
        __HAL_UART_DISABLE(huart);
        __HAL_UART_ENABLE(huart);

        // 释放互斥锁(如果有)
        osMutexRelease(uart_mutex);
    }
}

❌ 坑3:超时不设或设得太长

即使是阻塞式发送,也别偷懒写成:

HAL_UART_Transmit(&huart1, buf, len, HAL_MAX_DELAY); // 绝对禁止!

一旦物理链路断开,系统就永远卡住了。

✅ 建议策略:
- 小数据包:50~100ms
- 大数据包:按波特率估算 + 一定余量(如 (size * 10) / baud_rate_second + 50 ms)


最佳实践总结:什么时候该用哪种方式?

场景 推荐方式 理由
调试打印、偶尔发个命令 HAL_UART_Transmit 简单直接,不怕短暂阻塞
传感器周期上报(每秒几次) HAL_UART_Transmit_IT CPU释放,响应及时
固件升级、大数据上传 HAL_UART_Transmit_DMA 零负载,高吞吐
RTOS多任务共享UART + Mutex互斥锁 防止资源竞争
高可靠性要求 + 超时 + 重试 + 错误恢复 提升鲁棒性

写在最后:理解 HAL_UART_Transmit ,其实是理解嵌入式通信的本质

很多人觉得“用了HAL库就不需要懂寄存器”,其实恰恰相反。

正是因为你用了 HAL_UART_Transmit ,才更需要明白它背后发生了什么。否则你永远只能停留在“能跑就行”的阶段,一旦出问题就束手无策。

而当你掌握了从 轮询 → 中断 → DMA 这条演进路径,你就不仅学会了UART,也为掌握SPI、I2C、ADC等其他外设打下了坚实基础。

毕竟,所有的高效嵌入式系统,都不是靠“卡住CPU”来实现的。

它们靠的是—— 让每个部件各司其职,协同运转

如果你正在做一个需要稳定串口通信的项目,不妨回头看看你的 HAL_UART_Transmit 是不是还在“霸占”主循环?也许只需一次小小的重构,就能换来整个系统流畅度的飞跃。

欢迎在评论区分享你的UART优化经验,我们一起打造更强的嵌入式系统!

Logo

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

更多推荐