4. GPIO的LL库驱动开发

UART 这玩意儿,可以说是嵌入式开发里最常用的外设了。调试打印、跟传感器聊天、和蓝牙模块对接……哪哪都离不开它。我个人习惯把 UART 驱动分成四个层次来写:轮询、中断、DMA,还有一个是低功耗下的特殊玩法。今天咱们就把前三种聊透。

核心要点:LL 库的 UART 初始化流程其实比 HAL 库更直白——没有句柄、没有超时机制,你直接操作寄存器就完事了。但直白不代表可以乱来,初始化顺序错了照样翻车。

4.1 LL 库 UART 初始化流程

先说说初始化。LL 库的初始化分三步走:时钟使能、GPIO 配置、UART 参数设置。嗯,这里要注意——顺序不能乱。

// 第一步:使能时钟
LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_USART2);
LL_AHB1_GRP1_EnableClock(LL_AHB1_GRP1_PERIPH_GPIOA);

// 第二步:配置 GPIO(TX - PA2, RX - PA3)
LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_2, LL_GPIO_MODE_ALTERNATE);
LL_GPIO_SetPinSpeed(GPIOA, LL_GPIO_PIN_2, LL_GPIO_SPEED_FREQ_HIGH);
LL_GPIO_SetPinOutputType(GPIOA, LL_GPIO_PIN_2, LL_GPIO_OUTPUT_PUSHPULL);
LL_GPIO_SetAFPin_0_7(GPIOA, LL_GPIO_PIN_2, LL_GPIO_AF_7);

LL_GPIO_SetPinMode(GPIOA, LL_GPIO_PIN_3, LL_GPIO_MODE_ALTERNATE);
LL_GPIO_SetAFPin_0_7(GPIOA, LL_GPIO_PIN_3, LL_GPIO_AF_7);

// 第三步:配置 UART 参数
LL_USART_InitTypeDef USART_InitStruct = {0};
USART_InitStruct.BaudRate = 115200;
USART_InitStruct.DataWidth = LL_USART_DATAWIDTH_8B;
USART_InitStruct.StopBits = LL_USART_STOPBITS_1;
USART_InitStruct.Parity = LL_USART_PARITY_NONE;
USART_InitStruct.TransferDirection = LL_USART_DIRECTION_TX_RX;
USART_InitStruct.HardwareFlowControl = LL_USART_HWCONTROL_NONE;
USART_InitStruct.OverSampling = LL_USART_OVERSAMPLING_16;
LL_USART_Init(USART2, &USART_InitStruct);

// 最后使能
LL_USART_Enable(USART2);

我在项目里遇到过一个问题:GPIO 的复用功能(AF)配错了,结果 TX 死活不出波形。你想想看,STM32 的引脚复用功能有好几组,AF7 和 AF8 差一个数字,出来的外设就完全不一样了。所以配完 GPIO 之后,我建议你先用逻辑分析仪抓一下 TX 引脚——如果没波形,八成是 AF 配错了。

初始化步骤 LL 库函数 常见坑点
时钟使能 LL_APBx_GRP1_EnableClock() USART1 在 APB2,USART2/3 在 APB1
GPIO 模式 LL_GPIO_SetPinMode() TX 要配成推挽输出,RX 不用配输出类型
复用功能 LL_GPIO_SetAFPin_0_7() 不同芯片 AF 号不同,查数据手册
UART 参数 LL_USART_Init() 波特率误差超过 3% 会丢数据
使能外设 LL_USART_Enable() 必须在所有配置之后才使能

4.2 轮询模式收发

轮询模式最简单,说白了就是 CPU 死等。发送的时候一直检查发送寄存器是否为空,接收的时候一直等数据进来。这种模式在裸机调试阶段特别好用,但正式产品里我基本不用——太浪费 CPU 了。

// 轮询发送一个字节
void uart_send_byte_poll(USART_TypeDef *USARTx, uint8_t data)
{
    // 等待发送数据寄存器为空
    while(!LL_USART_IsActiveFlag_TXE(USARTx));
    // 写入数据
    LL_USART_TransmitData8(USARTx, data);
}

// 轮询接收一个字节
uint8_t uart_recv_byte_poll(USART_TypeDef *USARTx)
{
    // 等待接收数据寄存器非空
    while(!LL_USART_IsActiveFlag_RXNE(USARTx));
    // 读取数据
    return LL_USART_ReceiveData8(USARTx);
}

警告:轮询接收时如果对方没发数据,程序会卡死在 while 循环里。我曾经在一个产品原型里用了轮询接收,结果传感器偶尔不响应,整个系统就死机了。后来加了超时机制才解决。

轮询模式适合什么场景?调试打印、初始化阶段的设备自检、或者你知道对方一定会回复的短数据交换。说白了就是「我知道你肯定会来,我就在这等你」。

4.3 中断模式收发

中断模式就灵活多了。CPU 不用死等,数据来了会触发中断,你在中断服务函数里处理就行。我个人习惯把接收中断和发送中断分开配——接收中断常开,发送中断只在有数据要发的时候才开。

// 使能接收中断
LL_USART_EnableIT_RXNE(USART2);

// 中断服务函数
void USART2_IRQHandler(void)
{
    uint8_t data;
    
    // 检查是否是接收中断
    if(LL_USART_IsActiveFlag_RXNE(USART2) && LL_USART_IsEnabledIT_RXNE(USART2))
    {
        data = LL_USART_ReceiveData8(USART2);
        // 把数据放入环形缓冲区
        ring_buffer_push(&rx_buffer, data);
    }
    
    // 检查是否是发送完成中断
    if(LL_USART_IsActiveFlag_TC(USART2) && LL_USART_IsEnabledIT_TC(USART2))
    {
        // 如果还有数据要发,继续发;否则关闭发送中断
        if(!ring_buffer_is_empty(&tx_buffer))
        {
            LL_USART_TransmitData8(USART2, ring_buffer_pop(&tx_buffer));
        }
        else
        {
            LL_USART_DisableIT_TC(USART2);
        }
    }
}

这里有个细节——接收中断里一定要先判断中断标志位是否置位,再判断中断是否使能。为什么?因为有时候硬件标志位已经置位了,但你还没来得及使能中断,这时候进去处理就会出问题。嗯,这是我在一个工控项目里踩过的坑。

小技巧:中断里尽量少做事,把数据扔进环形缓冲区就赶紧退出。数据处理放在主循环里做,这样不会阻塞其他中断。我一般用 256 字节的环形缓冲区,够大多数场景用了。

4.4 DMA 模式收发

DMA 模式是最高效的——CPU 只需要告诉 DMA 控制器「把这段数据发出去」或者「把收到的数据放到这里」,然后就撒手不管了。DMA 自己搬运数据,搬完了给你一个中断通知。

// 配置 DMA 发送
void uart_dma_send(USART_TypeDef *USARTx, uint8_t *data, uint32_t len)
{
    // 等待上一次 DMA 传输完成
    while(LL_DMA_IsEnabledChannel(DMA1, LL_DMA_CHANNEL_4));
    
    // 配置 DMA
    LL_DMA_ConfigTransfer(DMA1, LL_DMA_CHANNEL_4,
        LL_DMA_DIRECTION_MEMORY_TO_PERIPH |
        LL_DMA_MODE_NORMAL |
        LL_DMA_PERIPH_NOINCREMENT |
        LL_DMA_MEMORY_INCREMENT |
        LL_DMA_PDATAALIGN_BYTE |
        LL_DMA_MDATAALIGN_BYTE |
        LL_DMA_PRIORITY_HIGH);
    
    LL_DMA_SetDataLength(DMA1, LL_DMA_CHANNEL_4, len);
    LL_DMA_SetMemoryAddress(DMA1, LL_DMA_CHANNEL_4, (uint32_t)data);
    LL_DMA_SetPeriphAddress(DMA1, LL_DMA_CHANNEL_4, (uint32_t)&USARTx->DR);
    
    // 使能 DMA 发送完成中断
    LL_DMA_EnableIT_TC(DMA1, LL_DMA_CHANNEL_4);
    
    // 使能 DMA 通道
    LL_DMA_EnableChannel(DMA1, LL_DMA_CHANNEL_4);
    
    // 使能 UART 的 DMA 发送
    LL_USART_EnableDMAReq_TX(USARTx);
}

DMA 模式有个关键点——DMA 通道和 UART 的对应关系。不同的 STM32 型号,UART 的 TX 和 RX 对应的 DMA 通道可能不一样。我建议你直接查参考手册的 DMA 请求映射表,别靠猜。

模式 CPU 占用率 实时性 适用场景
轮询 高(100% 占用) 差(会阻塞) 调试、短数据、确定时序
中断 中(进出中断有开销) 好(不阻塞主循环) 不定长数据、交互式通信
DMA 低(几乎不占 CPU) 最好(硬件搬运) 大数据量、高速通信、低功耗

4.5 三种模式的选择策略

说实话,没有哪种模式是万能的。我一般这样选:

  • 调试阶段:轮询模式,简单粗暴,出问题好排查
  • 产品原型:中断模式,灵活够用,代码量适中
  • 正式产品:DMA 模式,尤其是低功耗场景,CPU 能多睡一会儿就多睡一会儿

你想想看,如果你的设备用电池供电,CPU 每多跑一毫秒,电池就少活几分钟。DMA 模式让 CPU 从数据搬运的苦力活里解放出来,该睡就睡,这才是低功耗设计的精髓。

UART 驱动模式选择流程图 开始选择模式 数据量大吗?(>100字节/次) 是 否 DMA 模式 需要实时响应吗? 是 否 中断模式 轮询模式 选择建议 大数据量 → DMA | 小数据量 + 实时性要求 → 中断 | 小数据量 + 调试阶段 → 轮询

我的经验:别一上来就上 DMA。先拿轮询把通信协议调通,再换成中断,最后才上 DMA。这样出问题的时候你知道是协议的问题还是模式切换的问题。我曾经跳过这个步骤,结果 DMA 配置错了,数据全乱套,排查了两天才发现是 DMA 地址没对齐——嗯,血的教训。