2. 轮询发送模式原理:CPU忙等待、发送寄存器与移位寄存器
大家好,我是你们的讲师。今天咱们来啃一块硬骨头——轮询发送模式。很多初学者觉得UART发送嘛,不就是往寄存器里丢数据吗?其实没那么简单。我见过不少工程师,包括我自己刚入行时,都在这上面栽过跟头。
说白了,轮询发送就是CPU一直在“盯着”发送完成标志位,直到它说“我准备好了”,才把下一个字节塞进去。这个过程里,CPU几乎啥也干不了,全耗在等待上了。
2.1 CPU忙等待机制:为什么CPU会“卡死”?
先看一个典型场景。你写了一个UART发送函数,循环发送100个字节。结果发现,整个系统在发送期间像死机了一样,按键没反应,LED也不闪。嗯,这就是忙等待的典型症状。
忙等待的本质:CPU不断读取状态寄存器,检查某个标志位是否置位。在标志位置位之前,CPU不执行任何其他任务,全部算力都消耗在“读-判断-再读”这个死循环里。
我给大家画个流程图,看看CPU到底在忙什么:
看到红色箭头那条循环了吗?这就是CPU“空转”的路径。我曾经在一个项目里,用115200波特率发一帧512字节的数据,结果整个系统卡了将近50毫秒。对于人来说50ms不算啥,但对于一个实时控制系统,这足以让电机丢步、传感器数据超时。
注意:忙等待模式下,CPU的利用率几乎为0%。你想想看,一个几百兆赫兹的处理器,就为了等一个串口发送完成,啥也不干——这简直是暴殄天物。但在某些简单场景下(比如裸机系统、初始化阶段),这种模式反而最可靠。
2.2 发送寄存器(TX)与移位寄存器的关系
很多新手搞不清这两个寄存器的区别。我打个比方:发送寄存器(TX)就像是一个“缓冲区”,而移位寄存器才是真正干活的那个“工人”。
| 寄存器 | 别名 | 作用 | CPU访问方式 |
|---|---|---|---|
| 发送数据寄存器 | TX / DR | 暂存待发送的数据字节 | CPU可读写 |
| 发送移位寄存器 | TSR | 将并行数据逐位移出到TX引脚 | CPU不可直接访问 |
工作流程是这样的:
- CPU把数据写入TX寄存器——这一步很快,一个存储指令就搞定。
- 硬件自动将TX内容复制到TSR——前提是TSR空闲(上一字节已发送完毕)。
- TSR开始逐位发送——每个bit按波特率时钟从TX引脚输出。
- 发送完成后,硬件置位TC标志——通知CPU可以发下一个字节了。
我的经验:有些芯片(比如STM32)还有一个TXE标志(发送寄存器空),它表示TX寄存器已经空了,可以写入新数据。而TC标志(发送完成)表示整个字节包括停止位都已经发出去了。我建议在轮询发送时优先检查TC标志,因为这样能确保前一帧数据完整发出,不会出现“截尾”现象。
2.3 发送完成标志位轮询:代码怎么写才靠谱?
好,理论说完了,咱们上代码。这是最基础的轮询发送实现:
// 最基础的轮询发送函数
void UART_SendByte(uint8_t data)
{
// 1. 等待发送完成标志置位
while (!(UART->SR & UART_SR_TC));
// 2. 写入数据到发送寄存器
UART->DR = data;
}
这段代码看起来没问题,对吧?但我告诉你,这里有个坑。我曾经在一个项目里用这个函数连续发送数据,结果发现第一个字节总是发不出去。排查了半天,原来是复位后TC标志默认就是1,第一次进入函数时while直接跳过,但此时TSR还没初始化完成,数据写进去就丢了。
避坑指南:我曾经在STM32F103上遇到过这个问题。复位后TC标志位是置位的,所以第一次发送前需要先写一个哑数据来触发发送,或者手动清除TC标志。不同芯片的行为可能不同,一定要看数据手册!
改进后的代码:
// 带初始化的轮询发送函数
void UART_SendByte_Safe(uint8_t data)
{
// 等待上一次发送完成
// 注意:第一次调用时,TC可能为1,但TSR可能未就绪
// 所以先写一个字节触发发送,再等待完成
while (!(UART->SR & UART_SR_TC));
// 写入数据
UART->DR = data;
// 对于某些芯片,需要再次等待TC置位
// 以确保数据真正从移位寄存器发出
while (!(UART->SR & UART_SR_TC));
}
你可能会问:“为什么要等两次?”嗯,第一次等待是为了确保前一个字节发完,第二次等待是为了确保当前字节发完。对于单字节发送,第二次等待其实可以省略,但如果你要连续发送多字节,第二次等待就是必须的。
2.4 轮询发送的性能瓶颈与优化思路
咱们来算一笔账。假设波特率是115200,数据位8位,1位停止位,无校验。那么发送一个字节需要的时间是:
(1 + 8 + 1) / 115200 ≈ 86.8 微秒
也就是说,每发送一个字节,CPU要空等大约87微秒。如果发1000个字节,就是87毫秒。这87毫秒里,CPU啥也干不了。
| 波特率 | 每字节耗时 | 1000字节耗时 | CPU利用率 |
|---|---|---|---|
| 9600 | 1.04 ms | 1.04 s | ≈0% |
| 115200 | 86.8 μs | 86.8 ms | ≈0% |
| 921600 | 10.9 μs | 10.9 ms | ≈0% |
看到没?不管波特率多高,CPU利用率都是0%。因为CPU一直在“空转”。
优化思路:如果系统允许,可以考虑以下方案:
- 使用DMA发送——让DMA控制器代替CPU搬运数据,CPU可以去做其他事。
- 使用中断发送——每次发送完成触发中断,在中断里填充下一个字节。
- 使用环形缓冲区——配合中断或DMA,实现后台发送。
但这些是后面章节的内容了。本章咱们先把轮询模式吃透。
2.5 实战中的几个“坑”
最后,我分享几个我在项目中踩过的坑,希望能帮大家少走弯路:
坑1:忘记清除标志位
有些芯片的TC标志位需要软件清除(写0清除),有些是读状态寄存器后自动清除。我曾在LPC1768上吃过亏,没手动清除TC标志,结果第二次发送时while直接跳过,数据还没发完就被覆盖了。
坑2:中断和轮询打架
如果你同时开启了发送完成中断,又在主循环里轮询TC标志,可能会造成竞争条件。我的建议是:要么全用中断,要么全用轮询,不要混用。
坑3:波特率误差导致丢帧
轮询发送模式下,如果系统时钟频率不是波特率的整数倍,会产生累积误差。我曾经用8MHz的晶振跑115200波特率,误差高达3.5%,结果连续发几百个字节后,接收端就开始出现帧错误。后来换了11.0592MHz的晶振才解决。