3、STM32基础外设开发(UART):串口通信协议、HAL库串口配置、printf重定向、DMA+IDLE接收不定长数据

串口,在嵌入式开发里就像人的嘴巴和耳朵。你想想看,一个设备连上电脑,总得说点什么吧?打印个日志、接收个指令,全靠它。我做了这么多年物联网终端,可以说90%的调试工作都离不开串口。今天咱们就把STM32的串口玩透,从最基础的协议,到HAL库配置,再到printf重定向,最后搞定DMA+IDLE接收不定长数据——这一套下来,你的终端基本就“能说会听”了。

3.1 串口通信协议:别被“异步”吓到

串口通信,全称是通用异步收发传输器(UART)。说白了,就是一根线发数据,一根线收数据,没有时钟线。那两边怎么同步呢?靠的是事先约定好的波特率。

关键参数就三个:

  • 波特率:每秒传输的位数。常用9600、115200。我习惯用115200,速度快,调试不卡顿。
  • 数据位:一般是8位,一个字节。
  • 停止位:1位或2位,表示一帧结束。

嗯,这里要注意:收发双方的参数必须完全一致,否则就是乱码。我在项目中遇到过客户说“串口收不到数据”,结果一看,波特率配成了9600和115200,能通才怪。

小技巧:调试串口时,先用逻辑分析仪抓波形,看起始位、数据位、停止位对不对。比对着屏幕猜乱码原因快多了。

3.2 HAL库串口配置:三步走,不迷路

STM32的HAL库把串口封装得挺友好。配置起来就三步:

  1. 使能时钟:GPIO时钟和USART时钟都要开。
  2. 配置GPIO:TX设为复用推挽输出,RX设为浮空输入或上拉输入。
  3. 初始化USART:设置波特率、数据位、停止位等。

代码长这样:

// 使能时钟
__HAL_RCC_USART1_CLK_ENABLE();
__HAL_RCC_GPIOA_CLK_ENABLE();

// GPIO配置
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_9 | GPIO_PIN_10;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF7_USART1;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// USART初始化
UART_HandleTypeDef huart1;
huart1.Instance = USART1;
huart1.Init.BaudRate = 115200;
huart1.Init.WordLength = UART_WORDLENGTH_8B;
huart1.Init.StopBits = UART_STOPBITS_1;
huart1.Init.Parity = UART_PARITY_NONE;
huart1.Init.Mode = UART_MODE_TX_RX;
huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE;
HAL_UART_Init(&huart1);

我个人习惯把串口初始化封装成一个函数,比如 MX_USART1_UART_Init(),这样主函数里调用一下就行,清爽多了。

避坑指南:我曾经在配置GPIO时忘了设置复用功能(Alternate),结果串口死活不工作。查了半天才发现TX脚没配成AF模式。记住:串口用的是外设功能,不是普通GPIO!

3.3 printf重定向:让调试信息飞起来

裸机开发时,最痛苦的就是看串口数据。一个个字节拼起来,眼睛都花了。所以咱们得把printf搞过来,直接打印格式化字符串。

原理很简单:重写 fputc 函数,让printf输出到串口。在STM32上,我一般这么写:

#include <stdio.h>

int fputc(int ch, FILE *f)
{
    HAL_UART_Transmit(&huart1, (uint8_t *)&ch, 1, 0xFFFF);
    return ch;
}

然后在工程设置里勾选“Use MicroLIB”,或者自己实现 _sys_exit() 避免半主机模式。嗯,这里有个坑:如果不勾选MicroLIB,程序可能会卡死在printf里。我刚开始用HAL库时就踩过这个坑,折腾了一下午。

搞定之后,你就可以这样用了:

printf("系统启动,当前温度:%.2f°C\n", temperature);

是不是瞬间感觉回到了PC编程?调试效率直接翻倍。

提示:如果串口打印出现乱码,先检查波特率是否匹配。如果波特率没问题,再检查时钟配置——HSE和PLL的配置会影响USART的时钟源,导致波特率不准。

3.4 DMA+IDLE接收不定长数据:这才是实战

前面讲的都是基础。真正到了物联网终端项目里,你面对的不是固定长度的数据包,而是不定长的、随时可能到来的数据。比如NB-IoT模块发来的AT指令响应,长度从几个字节到几百个字节不等。

这时候,轮询接收就不行了——CPU会被占死。中断接收呢?每个字节都进中断,频繁进出,效率低。怎么办?

我的方案是:DMA + 空闲中断(IDLE)

  • DMA:负责把串口收到的数据自动搬运到内存缓冲区,CPU不用管。
  • IDLE中断:当串口收到一帧数据后,总线空闲了,触发中断。这时候我们再去处理缓冲区里的数据。

配置代码大致如下:

// 开启DMA接收,指定缓冲区
#define RX_BUF_SIZE  256
uint8_t rx_buffer[RX_BUF_SIZE];

HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUF_SIZE);

// 使能IDLE中断
__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

然后在中断服务函数里处理:

void USART1_IRQHandler(void)
{
    if(__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
    {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        
        // 停止DMA,计算接收到的数据长度
        HAL_UART_DMAStop(&huart1);
        uint16_t recv_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        
        // 处理数据(比如解析AT指令)
        process_data(rx_buffer, recv_len);
        
        // 重新开启DMA接收
        HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUF_SIZE);
    }
}

这里有个关键点:DMA缓冲区要开得足够大,至少能容纳一帧最大数据。我一般设256字节,对于NB-IoT模块的AT响应来说绰绰有余。

实战经验:我在做NB-IoT温湿度采集终端时,就是用DMA+IDLE接收模块的AT指令响应。一开始用中断接收,结果数据一多就丢包。换成DMA后,CPU占用率从30%降到了5%,而且再也没丢过数据。说白了,DMA就是让硬件帮你干活,CPU只管处理结果。

3.5 总结:串口是物联网终端的“生命线”

从协议到配置,从printf到DMA+IDLE,这一套下来,你的STM32串口已经具备了实战能力。记住几个要点:

  • 波特率、数据位、停止位必须一致
  • GPIO要配成复用功能
  • printf重定向用MicroLIB最省事
  • 不定长数据接收,DMA+IDLE是王道

下一章咱们要聊的是SPI和I2C,这两个总线在传感器通信里用得特别多。到时候你会发现,有了串口的基础,学起来会轻松很多。

嗯,今天就到这儿。如果你在配置过程中遇到问题,欢迎随时交流。毕竟,做嵌入式就是这样——踩坑、填坑、再踩坑,最后就成专家了。