3. 轮询式串口驱动:阻塞发送与接收的实现,及其在RTOS中的问题
轮询式(Polling)串口驱动是最基础的实现方式。CPU 通过循环检查状态寄存器,等待硬件完成一个字节的发送或接收。这种“忙等待”机制在裸机编程中简单直接,但引入 RTOS 后,会引发严重的多任务互斥与实时性问题。
3.1 阻塞发送的实现原理
阻塞发送的核心逻辑:应用程序调用发送函数后,函数不会立即返回,而是持续轮询串口状态寄存器,直到当前字节发送完成(TX 缓冲区空/发送完成标志置位),再发送下一个字节。整个过程 CPU 被完全占用。
典型实现代码(STM32 HAL 风格):
// 轮询式阻塞发送一个字节
void UART_Polling_SendByte(UART_HandleTypeDef *huart, uint8_t data)
{
// 等待上一次发送完成(TXE 标志)
while (!(huart->Instance->SR & USART_SR_TXE))
{
// 空循环,CPU 100% 占用
}
// 写入数据寄存器
huart->Instance->DR = data;
}
// 轮询式阻塞发送多字节
void UART_Polling_SendBuffer(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size)
{
for (uint16_t i = 0; i < Size; i++)
{
UART_Polling_SendByte(huart, pData[i]);
}
// 等待最后一个字节发送完成(TC 标志)
while (!(huart->Instance->SR & USART_SR_TC))
{
// 空循环
}
}
关键点: 轮询发送的“阻塞”体现在函数返回前,调用线程无法执行任何其他任务。发送 100 字节 @ 115200bps 约耗时 8.7ms,期间 CPU 完全被该线程独占。
3.2 阻塞接收的实现原理
阻塞接收同样采用忙等待:应用程序调用接收函数后,循环检查 RXNE(接收数据寄存器非空)标志,直到收到一个字节才返回。若没有数据到达,线程将永久卡死在轮询循环中。
// 轮询式阻塞接收一个字节
uint8_t UART_Polling_ReceiveByte(UART_HandleTypeDef *huart)
{
// 等待接收数据到达
while (!(huart->Instance->SR & USART_SR_RXNE))
{
// 空循环,CPU 100% 占用
}
// 读取数据寄存器
return (uint8_t)(huart->Instance->DR & 0xFF);
}
// 轮询式阻塞接收多字节(带超时)
uint8_t UART_Polling_ReceiveBuffer(UART_HandleTypeDef *huart,
uint8_t *pData, uint16_t Size,
uint32_t Timeout)
{
uint32_t tickstart = HAL_GetTick();
for (uint16_t i = 0; i < Size; i++)
{
while (!(huart->Instance->SR & USART_SR_RXNE))
{
// 超时检查
if ((HAL_GetTick() - tickstart) > Timeout)
{
return 1; // 超时错误
}
}
pData[i] = (uint8_t)(huart->Instance->DR & 0xFF);
}
return 0; // 成功
}
注意: 轮询接收若不实现超时机制,一旦对端停止发送数据,当前线程将永久挂起,导致整个系统假死。在 RTOS 中,这种无限制的忙等待会阻塞所有低优先级任务。
3.3 轮询驱动在RTOS中的核心问题
将轮询式串口驱动直接移植到 RTOS 环境,会暴露以下四个关键问题:
| 问题 | 描述 | 影响等级 |
|---|---|---|
| CPU 资源浪费 | 轮询等待期间 CPU 100% 空转,无法执行其他任务 | 严重 |
| 任务优先级反转 | 低优先级任务持有串口锁,高优先级任务被阻塞等待 | 严重 |
| 中断响应延迟 | 长时间轮询关闭中断或屏蔽调度器,导致高优先级中断丢失 | 中等 |
| 多任务互斥困难 | 多个任务同时调用轮询发送/接收,导致数据错乱 | 严重 |
3.4 多任务互斥问题详解
当两个或以上任务同时访问串口硬件时,轮询驱动缺乏原子性保护,会导致数据交叉错乱。以下是一个典型竞态场景:
竞态场景示例:
// 任务A:发送 "Hello"
void TaskA(void *arg)
{
while (1)
{
UART_Polling_SendBuffer(&huart1, "Hello", 5);
vTaskDelay(100);
}
}
// 任务B:发送 "World"
void TaskB(void *arg)
{
while (1)
{
UART_Polling_SendBuffer(&huart1, "World", 5);
vTaskDelay(100);
}
}
由于轮询发送函数内部没有临界区保护,任务A发送到一半时可能被任务B抢占,导致串口输出变成 "HeWorllldo" 等乱序数据。这种问题在裸机中不会出现(单任务),但在 RTOS 多任务环境下是致命缺陷。
3.5 轮询驱动与RTOS调度器的冲突
轮询等待循环会阻止 RTOS 调度器运行。若轮询期间关闭了中断或调度锁,系统将完全失去响应能力。
| 场景 | 轮询行为 | 调度器状态 | 后果 |
|---|---|---|---|
| 轮询发送 100 字节 | ~8ms 空循环 | 无法调度 | 高优先级任务延迟 8ms |
| 轮询接收无超时 | 永久等待 | 永久阻塞 | 系统假死 |
| 轮询中关中断 | 空循环 | 中断被屏蔽 | 丢失串口中断及其他中断 |
经验法则: 在 RTOS 中,任何形式的忙等待(while 空循环)都应被视为设计缺陷。应使用信号量、消息队列或事件标志组来替代轮询等待。
3.6 轮询驱动的适用场景
尽管轮询驱动在 RTOS 中存在诸多问题,但在以下受限场景中仍可谨慎使用:
- 系统启动阶段: RTOS 内核尚未启动,或调度器未运行,此时只能使用轮询方式输出调试信息。
- 临界错误处理: 系统发生致命错误(HardFault),需要立即输出错误码,此时中断可能已不可用。
- 极低波特率调试: 发送单个字节耗时极短(如 1200bps 下约 8ms),且系统无其他实时任务。
- 单任务裸机过渡: 从裸机向 RTOS 迁移的初期阶段,临时保留轮询驱动用于验证硬件。
强烈建议: 在产品级 RTOS 项目中,轮询式串口驱动仅用于调试输出(printf),不应作为正式通信接口。后续章节将介绍中断驱动和 DMA 驱动的正确实现方式。