STM32 HAL 库串口 (UART) 应用详解
本文档旨在详细介绍在 STM32CubeMX 环境下,如何配置和使用 UART,重点阐述了通过重定向 printf 实现数据发送,以及三种主流的数据接收方案:超时解析、DMA+空闲中断 和 Ringbuffer。
目录
1. UART 基础配置 (CubeMX)
-
选择引脚与模式:
- 在左侧功能列表中展开
Connectivity,选择您要使用的 USART 外设(例如USART1)。 - 在
Mode下拉菜单中,选择Asynchronous(异步模式)。 - CubeMX 会自动将对应的
TX和RX引脚(如PA9,PA10)高亮配置。
- 在左侧功能列表中展开
-
配置参数:
- 在下方的
Parameter Settings标签页中,设置Baud Rate(波特率),常用值为115200或9600。 - 其他参数(数据位、停止位、校验位)通常保持默认即可。
- 在下方的
-
使能全局中断:
- 切换到
NVIC Settings标签页。 - 勾选对应 USART 的全局中断
Enabled复选框。这是实现中断接收的前提。

- 切换到
2. 串口发送:重定向 printf
为了方便地通过串口发送格式化字符串,我们可以重定向标准库函数 printf。
实现原理
通过重写一个底层函数,将 printf 的输出流从默认的调试端口“劫持”到我们指定的 UART 硬件发送接口。在 Keil MDK 中,推荐使用 fputc 或自定义一个功能完整的 my_printf 函数。
实现步骤
-
包含头文件:在
main.c或专门的usart.c文件顶部,包含必要的头文件。#include <stdarg.h> // 用于处理可变参数 #include <stdio.h> // vsnprintf 函数需要 #include <string.h> // memset 等函数需要 -
开启 MicroLIB (可选但推荐):在 Keil 的工程选项
Options for Target -> Target中,勾选Use MicroLIB。这会使用一个专为嵌入式系统优化的、更小的 C 库,能有效减小代码体积。 -
编写重定向函数:在代码中添加以下函数。
/** * @brief 自定义 printf 函数,将格式化字符串通过指定 UART 发送 * @param huart UART 句柄指针 * @param format 格式化字符串 * @param ... 可变参数 * @retval int 发送的字节数 */ int my_printf(UART_HandleTypeDef *huart, const char *format, ...) { char buffer[256]; // 临时存储格式化后的字符串 va_list args; // 定义可变参数列表 int len; va_start(args, format); // 初始化可变参数 // 安全地将格式化字符串和参数合成到 buffer 中 len = vsnprintf(buffer, sizeof(buffer), format, args); va_end(args); // 结束可变参数处理 // 通过 HAL 库以阻塞方式发送 buffer 中的内容 HAL_UART_Transmit(huart, (uint8_t *)buffer, len, HAL_MAX_DELAY); return len; } -
使用方法:在代码中像调用
printf一样调用my_printf。// 示例:通过 USART1 发送 "Temperature: 25.5 C" float temp = 25.5; my_printf(&huart1, "Temperature: %.1f C\r\n", temp);
3. 串口接收:三种高级解析方案
3.1 超时解析方案 (Interrupt + Timeout)
工作原理
这是一种经典且可靠的方法,用于接收不定长的数据帧。
- 中断接收:每当 UART 接收到一个字节,触发中断。
- 计时器复位:在中断服务函数中,将接收到的字节存入缓冲区,并刷新一个计时器(记录当前时间戳)。
- 超时判断:在主循环中,不断检查当前时间与最后一次接收到数据的时间戳之差。
- 触发解析:如果时间差超过预设的帧间超时值(如 10ms),则认为一帧数据已完整接收,可以开始处理缓冲区中的数据。
实现步骤
-
定义相关变量:
#define UART_RX_BUFFER_SIZE 128 #define UART_TIMEOUT_MS 10 uint8_t uart_rx_buffer[UART_RX_BUFFER_SIZE]; uint16_t uart_rx_index = 0; uint32_t uart_rx_ticks = 0; -
启动首次接收:在初始化代码之后,必须手动调用一次
HAL_UART_Receive_IT来“激活”中断接收,建议放在usart.c中,此过程还顺带绑定了缓冲数组。// 在 main.c 的初始化代码之后,主循环之前 HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[0], 1); -
编写中断回调函数:此函数在每次接收到一个字节后被 HAL 库自动调用。
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { // 1. 判断是否为目标串口 (例如 USART1) if (huart->Instance == USART1) { // 2. 刷新最后接收时间 uart_rx_ticks = HAL_GetTick(); // 3. 准备接收下一个字节 // (HAL 库已自动将数据存入 uart_rx_buffer[uart_rx_index]) uart_rx_index++; if (uart_rx_index >= UART_RX_BUFFER_SIZE) { // 缓冲区溢出处理 uart_rx_index = 0; } HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[uart_rx_index], 1); } } -
编写数据处理任务:此函数应在
main函数的while(1)中被周期性调用。void uart_task(void) { // 1. 检查是否有数据待处理 if (uart_rx_index == 0) { return; } // 2. 检查是否超时 if (HAL_GetTick() - uart_rx_ticks > UART_TIMEOUT_MS) { // --- 3. 超时,开始处理数据 --- // 此时,uart_rx_buffer 中存储了从第 0 到 uart_rx_index-1 的完整数据帧 my_printf(&huart1, "Received Data: %s\r\n", uart_rx_buffer); // 在此添加你自己的数据解析逻辑... // --- 数据处理结束 --- // 4. 清理现场,为下一次接收做准备 memset(uart_rx_buffer, 0, uart_rx_index); uart_rx_index = 0; } }
3.2 DMA + 空闲中断方案 (DMA + Idle-Line Interrupt)
工作原理
这是目前 STM32 平台上最高效、最推荐的串口接收方式,它完美地解决了不定长数据接收问题,且 CPU 占用率极低。
- DMA (Direct Memory Access): 像一个专业的“搬运工”,可以在不需要 CPU 干预的情况下,自动将串口硬件接收到的数据搬运到内存缓冲区。
- UART 空闲中断 (Idle Line Detection): 串口硬件能够检测到总线上是否出现了一段持续的高电平(空闲状态),这通常标志着一次数据传输(一个数据包)的结束。当检测到空闲时,它会触发一个中断。
协同流程:CPU 启动 DMA 传输后便可“撒手不管”。DMA 在后台默默地将数据从串口搬运到内存。当对方停止发送数据,总线出现空闲时,触发空闲中断,通知 CPU:“一批货已经全部送达,请处理!”
配置步骤 (CubeMX)
- 开启 DMA:在
USART的DMA Settings标签页中,点击Add添加USARTx_RX通道。 - 配置 DMA 参数:
- Mode:
Normal。配合空闲中断,每次接收一帧数据,因此使用普通模式。 - Increment Address:
Memory勾选,Peripheral不勾选。表示数据从固定的外设地址搬运到连续递增的内存地址。 - Data Width:
Byte。

- Mode:
- 使能中断:确保在
NVIC Settings中已使能USART的全局中断。
应用实现
-
定义相关变量:
#define UART_DMA_RX_BUFFER_SIZE 128 uint8_t uart_rx_dma_buffer[UART_DMA_RX_BUFFER_SIZE]; // DMA 接收缓冲区 uint8_t uart_proc_buffer[UART_DMA_RX_BUFFER_SIZE]; // 用于数据处理的缓冲区 volatile uint8_t uart_flag = 0; // 新数据标志位 -
启动首次接收:在初始化代码之后,调用
HAL_UARTEx_ReceiveToIdle_DMA函数启动接收,并关闭半满中断。HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_dma_buffer, sizeof(uart_rx_dma_buffer)); __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); // 关闭 DMA 半传输中断 -
编写空闲中断回调函数:这是此方案的核心。
/** * @brief UART 接收空闲事件回调函数 * @param huart UART 句柄 * @param Size 本次接收到的数据长度 */ void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { // 1. 停止 DMA 传输,防止新数据覆盖 HAL_UART_DMAStop(huart); // 2. 将接收到的有效数据复制到处理缓冲区 memcpy(uart_proc_buffer, uart_rx_dma_buffer, Size); // 3. 设置新数据标志位,通知主循环处理 uart_flag = 1; // 4. 清空 DMA 接收缓冲区 memset(uart_rx_dma_buffer, 0, sizeof(uart_rx_dma_buffer)); // 5. 重新启动 DMA 空闲接收 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_dma_buffer, sizeof(uart_rx_dma_buffer)); __HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT); } } -
编写数据处理任务:在
main函数的while(1)中调用。/** * @brief 处理 DMA 接收到的 UART 数据 */ void uart_dma_proc(void) { if (uart_flag == 0) return; // 检查标志位 uart_flag = 0; // 清除标志位,防止重复处理 // 处理 uart_proc_buffer 中的数据 my_printf(&huart1, "DMA Data: %s\r\n", uart_proc_buffer); // 在此添加你的数据解析逻辑... // 清空处理缓冲区 memset(uart_proc_buffer, 0, sizeof(uart_proc_buffer)); }
3.3 Ringbuffer (环形缓冲区) 方案
引入背景
方案二虽然高效,但它存在一个问题:如果主循环处理数据的速度跟不上数据帧到来的速度,新的数据可能会覆盖掉还未处理的旧数据。环形缓冲区 (Ring Buffer) 正是解决这一“产销不平衡”问题的利器。它在中断(生产者)和主循环(消费者)之间提供了一个先进先出的数据缓冲池,极大地提高了系统的鲁棒性。
工作原理
此方案是方案二的升级版。数据接收的触发机制仍然是 DMA + 空闲中断,但数据的暂存方式从简单的线性数组升级为环形缓冲区。
协同流程:
- DMA 将串口数据搬运到一个临时的 DMA 缓冲区。
- 空闲中断触发,在中断回调函数中,将 DMA 缓冲区里的整帧数据快速“投入”到环形缓冲区中。这个过程极快,几乎不耗时。
- 主循环可以按照自己的节奏,从容地从环形缓冲区中“取出”数据进行解析和处理,无需担心数据被覆盖。
Ringbuffer API 参考
我们移植的 ringbuffer.c/h (源自 RT-Thread) 提供了一套标准 API 来操作环形缓冲区。以下是核心 API 介绍。
核心结构:struct rt_ringbuffer
struct rt_ringbuffer
{
rt_uint8_t *buffer_ptr; // 指向实际存储数据的内存区域
rt_uint16_t read_mirror : 1; // 读指针的镜像位 (0 或 1)
rt_uint16_t read_index : 15; // 读指针索引 (0 ~ 32767)
rt_uint16_t write_mirror : 1;// 写指针的镜像位 (0 或 1)
rt_uint16_t write_index : 15;// 写指针索引 (0 ~ 32767)
rt_int16_t buffer_size; // 缓冲区总大小 (字节)
};
镜像位与位域:该库通过巧妙的“镜像位”来区分缓冲区是“空”还是“满”,避免了传统方法中浪费一个存储单元的问题。同时,使用C语言的“位域”技术将索引和镜像位压缩到同一个16位整型中,节约了内存。
初始化与重置
void rt_ringbuffer_init(struct rt_ringbuffer *rb, rt_uint8_t *pool, rt_int16_t size);
void rt_ringbuffer_reset(struct rt_ringbuffer *rb);
数据写入 (生产者操作)
rt_size_t rt_ringbuffer_put(struct rt_ringbuffer *rb, const rt_uint8_t *ptr, rt_uint16_t length);
rt_size_t rt_ringbuffer_put_force(struct rt_ringbuffer *rb, const rt_uint8_t *ptr, rt_uint16_t length);
rt_size_t rt_ringbuffer_putchar(struct rt_ringbuffer *rb, const rt_uint8_t ch);
rt_size_t rt_ringbuffer_putchar_force(struct rt_ringbuffer *rb, const rt_uint8_t ch);
数据读取 (消费者操作)
rt_size_t rt_ringbuffer_get(struct rt_ringbuffer *rb, rt_uint8_t *ptr, rt_uint16_t length);
rt_size_t rt_ringbuffer_getchar(struct rt_ringbuffer *rb, rt_uint8_t *ch);
rt_size_t rt_ringbuffer_peek(struct rt_ringbuffer *rb, rt_uint8_t **ptr);
实现步骤
1. 移植 Ringbuffer 库
-
将
ringbuffer.c和ringbuffer.h添加到工程中。 -
重要修复:为解决
rt_ringbuffer_status函数可能出现的链接器 “Undefined symbol” 错误,需修改其在ringbuffer.c中的定义,为其添加static关键字,使其具有内部链接属性。// 在 ringbuffer.c 中找到此函数定义 static rt_inline enum rt_ringbuffer_state rt_ringbuffer_status(struct rt_ringbuffer *rb) { // ... 函数体 ... }原因解析:
static将函数的作用域限制在当前文件,inline建议编译器内联展开。对于未被外部文件调用的static inline函数,编译器通常不会为其生成独立的、可供外部链接的符号,从而避免了链接器找不到定义的错误。
2. 定义相关变量
#define UART_DMA_RX_BUFFER_SIZE 128
#define RING_BUFFER_POOL_SIZE 256
// DMA 直接操作的临时缓冲区
uint8_t uart_rx_dma_buffer[UART_DMA_RX_BUFFER_SIZE];
// Ringbuffer 实例及其内存池
struct rt_ringbuffer uart_ringbuffer;
uint8_t ringbuffer_pool[RING_BUFFER_POOL_SIZE];
// 用于主循环数据处理的临时缓冲区
uint8_t uart_dma_buffer[RING_BUFFER_POOL_SIZE];
3. 初始化
在 main 函数的初始化阶段,除了启动 DMA 接收,还需初始化环形缓冲区。
// 初始化 Ringbuffer,将其与内存池绑定
rt_ringbuffer_init(&uart_ringbuffer, ringbuffer_pool, sizeof(ringbuffer_pool));
// 启动 DMA+空闲中断接收
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_dma_buffer, sizeof(uart_rx_dma_buffer));
__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);
4. 修改空闲中断回调函数 (生产者)
/**
* @brief UART 接收空闲事件回调函数
* @param huart UART 句柄
* @param Size 本次接收到的数据长度
*/
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart->Instance == USART1)
{
// 停止 DMA 传输,锁定已接收的数据
HAL_UART_DMAStop(huart);
// 将 DMA 缓冲区中的有效数据快速放入 Ringbuffer
rt_ringbuffer_put(&uart_ringbuffer, uart_rx_dma_buffer, Size);
// 清空 DMA 缓冲区,为下次接收做准备
memset(uart_rx_dma_buffer, 0, sizeof(uart_rx_dma_buffer));
// 重新启动 DMA 空闲接收,等待下一帧数据
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_dma_buffer, sizeof(uart_rx_dma_buffer));
__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);
}
}
5. 编写数据处理任务 (消费者)
此函数应在 main 函数的 while(1) 中被周期性调用。
void uart_ringbuffer_proc(void)
{
uint16_t length = 0;
// 检查 Ringbuffer 中是否有数据
length = rt_ringbuffer_data_len(&uart_ringbuffer);
if (length == 0) return;
// 从 Ringbuffer 中取出数据到处理缓冲区
rt_ringbuffer_get(&uart_ringbuffer, uart_dma_buffer, length);
// --- 在此处理 proc_buffer 中的数据 ---
my_printf(&huart1, "Ringbuffer Data: %s\r\n", uart_dma_buffer);
// ... 在此添加你自己的数据解析逻辑 ...
// --- 数据处理结束 ---
// 清空处理缓冲区,为下次使用做准备
memset(proc_buffer, 0, sizeof(proc_buffer));
}
总结:
在串口的应用中 无论采用什么方案 无非就是分为以下几个步骤
- 根据方案 定义相应缓存区
- 首次启动接收 或者初始化绑定缓冲区
- 根据不同方案 选择不同回调函数 并完善其中逻辑
- 根据不同方案 选择不同的 uart_task()流程