4. UART串口驱动:从框图到printf重定向

串口,嵌入式开发者的老朋友了。说实话,我入行这些年,调试用的最多的就是UART。不管是打印日志、跟传感器通信,还是跟Wi-Fi模块打交道,UART几乎无处不在。今天咱们就把STM32的USART模块彻底聊透。

4.1 USART框图与核心特性

先看框图。USART内部其实不复杂,核心就三块:

  • 发送器:数据从TX引脚发出去
  • 接收器:数据从RX引脚收进来
  • 波特率发生器:决定通信速度

我个人习惯把USART理解成一个「快递站」。你要发的数据先放进发送数据寄存器(TDR),然后移位寄存器按波特率一位一位搬出去。接收反过来,移位寄存器收齐一个字节,再放到接收数据寄存器(RDR)等你来取。

关键特性速览:

  • 支持全双工通信——收发可以同时进行
  • 可编程的数据字长度(8位或9位)
  • 可配置的停止位(0.5、1、1.5、2位)
  • 硬件流控制(RTS/CTS)——我很少用,但某些模块需要
  • DMA传输支持——大数据量时的救星

嗯,这里要注意:STM32的USART和UART有区别。USART支持同步模式,多了个SCLK时钟引脚。但咱们99%的场景只用异步模式,也就是UART。

4.2 HAL_UART_Init() 配置详解

HAL库把初始化封装成了HAL_UART_Init()。你想想看,这个函数背后到底干了什么?

我拆开给你看:

UART_HandleTypeDef huart1;

void MX_USART1_UART_Init(void)
{
    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;
    huart1.Init.OverSampling = UART_OVERSAMPLING_16;
    HAL_UART_Init(&huart1);
}

每个参数我都踩过坑:

  • BaudRate:115200是黄金速率。我曾经图快设成921600,结果线稍微长一点就丢数据。
  • WordLength:8位数据位是标准配置。除非你传特殊协议,否则别动它。
  • Parity:奇偶校验。我建议平时关掉,除非通信环境特别恶劣。
  • OverSampling:16倍过采样更稳定,8倍可以跑更高波特率。

避坑指南:

我曾经在F103上遇到一个诡异问题——串口偶尔收不到数据。查了两天才发现是GPIO复用功能没配置对。记住:HAL_UART_Init()只配USART外设,GPIO的AF模式要单独配!

4.3 中断接收与发送

轮询方式太浪费CPU了。实际项目中,我几乎只用中断方式。

中断接收的流程很简单:

  1. 使能USART接收中断
  2. 数据到达时,硬件自动触发中断
  3. 在中断回调函数里取数据
// 启动中断接收
HAL_UART_Receive_IT(&huart1, &rx_byte, 1);

// 中断回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 处理收到的数据
        process_byte(rx_byte);
        // 重新开启下一次接收
        HAL_UART_Receive_IT(&huart1, &rx_byte, 1);
    }
}

为什么每次接收完要重新调用HAL_UART_Receive_IT()?因为HAL库的设计是一次性的。你调用一次,它只等一个字节。收完了就停,得手动再开。

发送中断类似:

HAL_UART_Transmit_IT(&huart1, tx_buffer, len);

发送完成后会触发HAL_UART_TxCpltCallback()。我一般在这里设置一个标志位,告诉主循环数据发完了。

个人经验:

中断接收里别做复杂处理。我见过有人直接在回调里做协议解析、写Flash……结果中断嵌套把自己搞死了。正确的做法是:中断里只放环形缓冲区,主循环再慢慢处理。

4.4 DMA传输模式

如果你要发大量数据,比如一次发几K字节,用中断方式会频繁进中断,CPU都被占满了。这时候DMA就派上用场了。

DMA传输说白了就是:CPU告诉DMA控制器「帮我把这堆数据搬到USART的发送寄存器」,然后CPU就可以去干别的事了。

// DMA发送
HAL_UART_Transmit_DMA(&huart1, tx_buffer, len);

// DMA接收
HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE);

DMA传输完成会触发回调:

void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    // 发送完成,可以准备下一批数据了
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    // 接收完成,缓冲区里有BUFFER_SIZE个字节
}

我记得有个项目需要连续接收GPS数据,每秒输出10条NMEA语句。用中断方式CPU占用率飙到30%,换成DMA后直接降到2%。

注意:

DMA接收有个坑——它是固定长度的。如果你不知道数据什么时候结束,需要用空闲中断(IDLE)配合。具体做法是:DMA一直收着,空闲中断来了就说明一帧数据结束了,这时候去DMA缓冲区里取数据。

4.5 printf重定向实现

调试时最常用的就是printf。但STM32上默认没有printf,得自己重定向。

原理很简单:printf最终会调用fputc(),我们只要把fputc()改成通过USART发送就行。

#include <stdio.h>

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

然后在工程设置里勾选「Use MicroLIB」。为什么?因为标准C库的printf太大了,MicroLIB是精简版,专门给嵌入式用的。

用起来就简单了:

printf("Hello, STM32!\r\n");
printf("Baud rate: %d\r\n", 115200);

避坑指南:

我曾经忘了加\r\n,结果串口助手上一行接一行地显示,根本看不清。记住:\r是回车,\n是换行,两个都要加。

另外,HAL_UART_Transmit的最后一个参数是超时时间。我习惯设HAL_MAX_DELAY,但如果你在中断里调用printf,千万别用这个——会死锁。中断里用HAL_UART_Transmit_IT()或者直接操作寄存器。

4.6 本章小结

UART驱动说难不难,说简单也不简单。核心就几点:

  • 初始化时注意GPIO复用配置
  • 中断方式适合小数据量,DMA适合大数据量
  • printf重定向是调试利器,但注意使用场景
  • 环形缓冲区是串口接收的标配

嗯,这些够你应付大部分项目了。下一章咱们聊聊I2C,那个协议比UART稍微复杂点,但套路是一样的。


公众号:蓝海资料掘金营,微信deep3321