4. UART驱动移植与实战(上):UART轮询模式
UART,说白了就是串口。做嵌入式开发的人,没有谁离得开它。我刚开始用STM32那会儿,调试全靠串口打印,那时候觉得这玩意儿太简单了——不就是发数据、收数据嘛。后来踩的坑多了,才发现事情没那么简单。
这一章,咱们就好好聊聊UART轮询模式。我会带着你从HAL库源码入手,看看HAL_UART_Transmit和HAL_UART_Receive到底干了什么,再分析一下阻塞式收发带来的延时问题。
4.1 轮询模式的核心逻辑
轮询模式,也叫阻塞模式。你调用一个发送函数,CPU就在那儿死等,直到数据发完才返回。接收也是一样,没收到数据就卡住不动。
我画了一张流程图,帮你理清整体思路:
你看,整个流程就是一个线性执行链。CPU从调用函数开始,到返回结果为止,中间没有任何"分心"的机会。
4.2 HAL_UART_Transmit 源码分析
咱们直接看HAL库的源码。我挑几个关键点来说。
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart,
uint8_t *pData,
uint16_t Size,
uint32_t Timeout)
{
uint16_t *tmp;
uint32_t tickstart = 0;
// 1. 参数检查
if (huart->gState == HAL_UART_STATE_READY) {
// 2. 设置状态为BUSY
huart->gState = HAL_UART_STATE_BUSY;
} else {
return HAL_BUSY;
}
// 3. 获取起始时间
tickstart = HAL_GetTick();
// 4. 逐字节发送
while (huart->TxXferCount > 0) {
// 等待TXE标志
while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TXE) == RESET) {
// 超时判断
if ((Timeout == 0) ||
((HAL_GetTick() - tickstart) > Timeout)) {
huart->gState = HAL_UART_STATE_READY;
return HAL_TIMEOUT;
}
}
// 写入数据寄存器
huart->Instance->DR = (*pData++ & 0xFF);
huart->TxXferCount--;
}
// 5. 等待发送完成
while (__HAL_UART_GET_FLAG(huart, UART_FLAG_TC) == RESET) {
// 同样有超时判断
}
// 6. 恢复状态
huart->gState = HAL_UART_STATE_READY;
return HAL_OK;
}
这段代码,我拆成几个关键点来讲:
| 步骤 | 做了什么 | 注意点 |
|---|---|---|
| 状态检查 | 判断gState是否为READY | 如果正在发送,直接返回HAL_BUSY |
| 设置BUSY | 防止其他任务干扰 | 这是临界区保护的关键 |
| 等待TXE | 等待发送寄存器空 | 这里会死等,直到标志置位 |
| 写入DR | 把数据塞进硬件寄存器 | 一次只发一个字节 |
| 等待TC | 等待移位寄存器发送完成 | 最后一个字节必须等TC |
| 恢复状态 | 释放UART资源 | 返回HAL_OK |
关键区别:TXE vs TC
TXE(发送数据寄存器空)表示你可以往DR写下一个字节了。TC(发送完成)表示整个帧已经发完了。发送最后一个字节时,必须等TC,否则数据可能还没发出去就被覆盖了。
我在项目中遇到过一个问题:连续发送大量数据时,只等TXE不等TC,结果最后一帧数据总是丢失。查了半天才发现是这个问题。
4.3 HAL_UART_Receive 源码分析
接收函数和发送函数是对称的,但有个细节不一样。
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart,
uint8_t *pData,
uint16_t Size,
uint32_t Timeout)
{
uint32_t tickstart = 0;
if (huart->gState == HAL_UART_STATE_READY) {
huart->gState = HAL_UART_STATE_BUSY_RX;
} else {
return HAL_BUSY;
}
tickstart = HAL_GetTick();
while (huart->RxXferCount > 0) {
// 等待RXNE标志
while (__HAL_UART_GET_FLAG(huart, UART_FLAG_RXNE) == RESET) {
if ((Timeout == 0) ||
((HAL_GetTick() - tickstart) > Timeout)) {
huart->gState = HAL_UART_STATE_READY;
return HAL_TIMEOUT;
}
}
// 读取数据寄存器
*pData++ = (uint8_t)(huart->Instance->DR & 0xFF);
huart->RxXferCount--;
}
huart->gState = HAL_UART_STATE_READY;
return HAL_OK;
}
接收函数的核心逻辑:
- 等待RXNE(接收数据寄存器非空)标志
- 从DR寄存器读一个字节
- 重复直到收完指定字节数
注意:接收超时问题
如果对方发送的数据少于你期望的Size,接收函数就会一直卡在等待RXNE的循环里,直到超时。我曾经在一个项目中,因为通信协议没对齐,接收端等了500ms超时,导致整个系统响应变慢。后来我改成了不定长接收,才解决这个问题。
4.4 阻塞式发送与接收的延时问题
轮询模式最大的问题,就是延时。咱们来算一笔账。
假设你用的是115200波特率,8N1格式。那么:
- 1个字节 = 10位(1起始 + 8数据 + 1停止)
- 传输1个字节的时间 = 10 / 115200 ≈ 86.8μs
- 传输100个字节 ≈ 8.68ms
- 传输1024个字节 ≈ 88.9ms
这88.9ms里,CPU在干什么?
在死等。在空转。在浪费算力。
延时问题的本质
轮询模式下,CPU的利用率极低。发送1024字节,CPU真正干活的时间只有写入DR的那几个微秒,剩下的88.9ms全在等硬件。你想想看,如果你的系统还要处理其他任务,比如按键扫描、LED刷新、传感器采集,这88.9ms的阻塞就是灾难。
我整理了一个对比表,帮你直观感受一下:
| 数据量 | 115200波特率耗时 | CPU有效工作时间 | CPU浪费时间 |
|---|---|---|---|
| 1字节 | 86.8μs | ≈0.5μs | 86.3μs |
| 100字节 | 8.68ms | ≈50μs | 8.63ms |
| 1024字节 | 88.9ms | ≈512μs | 88.4ms |
你看,CPU的有效利用率不到1%。说白了,轮询模式只适合两种场景:
- 发送极少量的数据(比如几个字节的调试信息)
- 系统没有其他任务需要处理(比如裸机跑一个简单的循环)
我的建议
如果你要在项目里用UART做数据通信,我个人建议直接上中断模式或DMA模式。轮询模式只适合做调试打印,或者发送一些不频繁的状态信息。我曾经在一个产品里用了轮询模式接收GPS数据,结果因为阻塞时间太长,导致主循环里的传感器采集周期被拉长,最后定位精度出了问题。从那以后,我再也不敢在正式产品里用轮询模式做数据收发了。
4.5 轮询模式的使用场景
虽然轮询模式有延时问题,但它也不是一无是处。我总结了几种适合用轮询模式的场景:
- 调试信息输出:printf打印日志,数据量小,偶尔用一下没问题。
- 系统初始化阶段:MCU刚启动时,外设还没初始化完,用轮询模式最可靠。
- 低优先级通信:比如每隔几秒发一次状态心跳包,阻塞几十微秒可以接受。
- 简单裸机程序:没有RTOS,没有复杂任务调度,轮询模式反而简单直接。
避坑指南
我曾经在一个项目中,用轮询模式接收不定长数据。对方发数据的时间间隔不固定,结果接收函数经常超时返回,导致数据丢失。后来我改成了中断接收+环形缓冲区,才彻底解决这个问题。所以,如果你要接收不定长数据,千万别用轮询模式。
4.6 本章小结
这一章,咱们把UART轮询模式的源码和延时问题都过了一遍。核心就三点:
- 轮询模式是阻塞的,CPU在等待期间啥也干不了
- 发送函数等TXE和TC两个标志,接收函数等RXNE标志
- 延时问题严重,CPU利用率极低,只适合少量数据或调试场景