2. 轮询发送模式原理:CPU忙等待、发送寄存器与移位寄存器

大家好,我是你们的讲师。今天咱们来啃一块硬骨头——轮询发送模式。很多初学者觉得UART发送嘛,不就是往寄存器里丢数据吗?其实没那么简单。我见过不少工程师,包括我自己刚入行时,都在这上面栽过跟头。

说白了,轮询发送就是CPU一直在“盯着”发送完成标志位,直到它说“我准备好了”,才把下一个字节塞进去。这个过程里,CPU几乎啥也干不了,全耗在等待上了。

2.1 CPU忙等待机制:为什么CPU会“卡死”?

先看一个典型场景。你写了一个UART发送函数,循环发送100个字节。结果发现,整个系统在发送期间像死机了一样,按键没反应,LED也不闪。嗯,这就是忙等待的典型症状。

忙等待的本质:CPU不断读取状态寄存器,检查某个标志位是否置位。在标志位置位之前,CPU不执行任何其他任务,全部算力都消耗在“读-判断-再读”这个死循环里。

我给大家画个流程图,看看CPU到底在忙什么:

轮询发送模式:CPU忙等待流程图 开始发送 加载数据到TX寄存器 (写DR寄存器) 数据移入移位寄存器 (硬件自动完成) 逐位发送数据 (TX引脚输出) 读取TC标志位 (发送完成?) 否 继续等待 是 发送下一个字节 红色箭头:CPU忙等待路径 绿色箭头:发送完成路径

看到红色箭头那条循环了吗?这就是CPU“空转”的路径。我曾经在一个项目里,用115200波特率发一帧512字节的数据,结果整个系统卡了将近50毫秒。对于人来说50ms不算啥,但对于一个实时控制系统,这足以让电机丢步、传感器数据超时。

注意:忙等待模式下,CPU的利用率几乎为0%。你想想看,一个几百兆赫兹的处理器,就为了等一个串口发送完成,啥也不干——这简直是暴殄天物。但在某些简单场景下(比如裸机系统、初始化阶段),这种模式反而最可靠。

2.2 发送寄存器(TX)与移位寄存器的关系

很多新手搞不清这两个寄存器的区别。我打个比方:发送寄存器(TX)就像是一个“缓冲区”,而移位寄存器才是真正干活的那个“工人”。

寄存器 别名 作用 CPU访问方式
发送数据寄存器 TX / DR 暂存待发送的数据字节 CPU可读写
发送移位寄存器 TSR 将并行数据逐位移出到TX引脚 CPU不可直接访问

工作流程是这样的:

  1. CPU把数据写入TX寄存器——这一步很快,一个存储指令就搞定。
  2. 硬件自动将TX内容复制到TSR——前提是TSR空闲(上一字节已发送完毕)。
  3. TSR开始逐位发送——每个bit按波特率时钟从TX引脚输出。
  4. 发送完成后,硬件置位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的晶振才解决。