3. 轮询发送代码实现:从HAL库到底层寄存器

这一章我们直接动手写代码。很多初学者用HAL_UART_Transmit()发数据,觉得挺好用。但一旦遇到性能瓶颈或者诡异bug,就完全不知道底层发生了什么。我个人习惯是:先搞懂封装层在干什么,再决定要不要自己写寄存器。

3.1 HAL_UART_Transmit() 到底干了什么?

先看标准库函数原型:

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

四个参数:句柄、数据指针、字节数、超时时间。看起来简单,但内部逻辑其实分三步:

  1. 检查状态——判断UART是否忙、参数是否合法
  2. 轮询发送——逐字节写入数据寄存器,等待发送完成标志
  3. 超时处理——如果超过Timeout还没发完,返回超时错误

我拆过HAL库的源码,核心循环其实就几行:

while(huart->TxXferCount > 0)
{
    /* 等待TXE标志置位 */
    while(__HAL_UART_GET_FLAG(huart, UART_FLAG_TXE) == RESET)
    {
        /* 超时检查 */
        if(Timeout-- == 0) return HAL_TIMEOUT;
    }
    /* 写入数据寄存器 */
    huart->Instance->DR = (*pData++);
    huart->TxXferCount--;
}

看到了吗?说白了就是两个while循环嵌套。外层循环控制字节数,内层循环等待发送缓冲区空标志。我在项目中遇到过一个问题:用这个函数发大量数据时,CPU被死死卡住,中断都进不去。这就是轮询模式的代价。

关键点:HAL_UART_Transmit() 本质是「忙等待」——CPU在发送期间不能干别的事。适合数据量小、实时性要求不高的场景。

3.2 寄存器级发送代码编写

为什么要自己写寄存器?两个原因:一是性能,二是可控。我自己写UART驱动时,通常只保留最核心的操作。

先看STM32的UART发送相关寄存器:

寄存器 功能 关键位
USART_DR 数据寄存器 DR[8:0]:要发送的数据
USART_SR 状态寄存器 TXE(bit7):发送缓冲区空;TC(bit6):发送完成
USART_CR1 控制寄存器1 UE(bit13):UART使能;TE(bit3):发送使能

嗯,这里要注意:TXE和TC是两个不同的标志。TXE表示数据已经从DR移到了移位寄存器,可以写入下一个字节。TC表示移位寄存器里的数据已经全部从TX脚发出去了。

我写一个最简的轮询发送函数:

void UART_SendByte(USART_TypeDef *UARTx, uint8_t data)
{
    /* 等待TXE置位,表示DR为空 */
    while(!(UARTx->SR & USART_SR_TXE));
    
    /* 写入数据 */
    UARTx->DR = data;
}

void UART_SendString(USART_TypeDef *UARTx, uint8_t *str, uint16_t len)
{
    for(uint16_t i = 0; i < len; i++)
    {
        UART_SendByte(UARTx, str[i]);
    }
    
    /* 等待最后一个字节发送完成 */
    while(!(UARTx->SR & USART_SR_TC));
}

这段代码比HAL库精简多了。但有个坑——我刚开始写的时候,忘了最后那个TC等待。结果最后一个字节经常发不出去,因为函数返回时数据还在移位寄存器里。你想想看,如果紧接着关UART或者进低功耗模式,那个字节就丢了。

避坑指南:我曾经在一个批量产项目中,因为没等TC标志,导致最后几个字节偶尔丢失。排查了两天才找到原因。记住:TXE只保证DR空了,TC才保证数据真正发出去了。

3.3 HAL库 vs 寄存器:性能对比

我实际测过两种方式的性能差异。用逻辑分析仪抓波形,同样发1024字节:

方式 代码量 每字节额外开销 CPU占用率
HAL_UART_Transmit() 1行调用 约2.5μs (72MHz) 100%
寄存器直接操作 5行函数 约0.8μs (72MHz) 100%

寄存器方式快了3倍左右。为什么?因为HAL库多了很多安全检查、状态机维护、超时计算的开销。如果你的系统对发送速度有要求,或者CPU资源紧张,我建议用寄存器方式。

但话说回来,HAL库也有它的价值。它帮你处理了各种边界情况,比如UART未使能、DMA冲突等。我自己做原型验证时,还是会先用HAL库,等确定方案后再优化成寄存器。

3.4 知识结构图:轮询发送的核心逻辑

下面这张图展示了轮询发送的完整流程,从应用层到寄存器层:

轮询发送模式核心流程 应用层调用发送函数 HAL层:参数检查 + 状态判断 轮询循环:逐字节发送 等待TXE → 写DR → 字节计数减1 直到所有字节发送完毕 寄存器层:操作SR和DR寄存器 硬件层:移位寄存器 → TX引脚 注意:轮询模式下CPU全程参与,无法响应其他中断,适合短数据块发送

3.5 实际项目中的选择策略

我做了这么多年嵌入式,总结了一个简单的选择原则:

  • 调试阶段:用HAL库,快速验证功能
  • 量产代码:寄存器操作,追求性能和可靠性
  • 混合使用:初始化用HAL库,发送用寄存器

个人技巧:我习惯在寄存器发送函数里加一个超时保护,防止硬件异常导致死循环。用一个递减的循环变量做看门狗,超时后强制退出并返回错误码。这个习惯帮我避免了好几次产线死机问题。