西门子笔记(2) 串口篇

STM32 HAL 库串口 (UART) 应用详解

本文档旨在详细介绍在 STM32CubeMX 环境下,如何配置和使用 UART,重点阐述了通过重定向 printf 实现数据发送,以及三种主流的数据接收方案:超时解析DMA+空闲中断Ringbuffer

目录

  1. UART 基础配置 (CubeMX)
  2. 串口发送:重定向 printf
  3. 串口接收:三种高级解析方案

1. UART 基础配置 (CubeMX)

  1. 选择引脚与模式

    • 在左侧功能列表中展开 Connectivity,选择您要使用的 USART 外设(例如 USART1)。
    • Mode 下拉菜单中,选择 Asynchronous (异步模式)。
    • CubeMX 会自动将对应的 TXRX 引脚(如 PA9, PA10)高亮配置。
  2. 配置参数

    • 在下方的 Parameter Settings 标签页中,设置 Baud Rate (波特率),常用值为 1152009600
    • 其他参数(数据位、停止位、校验位)通常保持默认即可。
  3. 使能全局中断

    • 切换到 NVIC Settings 标签页。
    • 勾选对应 USART 的全局中断 Enabled 复选框。这是实现中断接收的前提。
      开启串口全局中断

2. 串口发送:重定向 printf

为了方便地通过串口发送格式化字符串,我们可以重定向标准库函数 printf

实现原理

通过重写一个底层函数,将 printf 的输出流从默认的调试端口“劫持”到我们指定的 UART 硬件发送接口。在 Keil MDK 中,推荐使用 fputc 或自定义一个功能完整的 my_printf 函数。

实现步骤

  1. 包含头文件:在 main.c 或专门的 usart.c 文件顶部,包含必要的头文件。

    #include <stdarg.h> // 用于处理可变参数
    #include <stdio.h>  // vsnprintf 函数需要
    #include <string.h> // memset 等函数需要
    
  2. 开启 MicroLIB (可选但推荐):在 Keil 的工程选项 Options for Target -> Target 中,勾选 Use MicroLIB。这会使用一个专为嵌入式系统优化的、更小的 C 库,能有效减小代码体积。

  3. 编写重定向函数:在代码中添加以下函数。

    /**
      * @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;
    }
    
  4. 使用方法:在代码中像调用 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)

工作原理

这是一种经典且可靠的方法,用于接收不定长的数据帧。

  1. 中断接收:每当 UART 接收到一个字节,触发中断。
  2. 计时器复位:在中断服务函数中,将接收到的字节存入缓冲区,并刷新一个计时器(记录当前时间戳)。
  3. 超时判断:在主循环中,不断检查当前时间与最后一次接收到数据的时间戳之差。
  4. 触发解析:如果时间差超过预设的帧间超时值(如 10ms),则认为一帧数据已完整接收,可以开始处理缓冲区中的数据。

实现步骤

  1. 定义相关变量

    #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;
    
  2. 启动首次接收:在初始化代码之后,必须手动调用一次 HAL_UART_Receive_IT 来“激活”中断接收,建议放在usart.c中,此过程还顺带绑定了缓冲数组。

    // 在 main.c 的初始化代码之后,主循环之前
    HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[0], 1);
    
  3. 编写中断回调函数:此函数在每次接收到一个字节后被 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);
        }
    }
    
  4. 编写数据处理任务:此函数应在 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)

  1. 开启 DMA:在 USARTDMA Settings 标签页中,点击 Add 添加 USARTx_RX 通道。
  2. 配置 DMA 参数
    • Mode: Normal。配合空闲中断,每次接收一帧数据,因此使用普通模式。
    • Increment Address: Memory 勾选,Peripheral 不勾选。表示数据从固定的外设地址搬运到连续递增的内存地址。
    • Data Width: Byte
      DMA 配置
  3. 使能中断:确保在 NVIC Settings 中已使能 USART 的全局中断。

应用实现

  1. 定义相关变量

    #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; // 新数据标志位
    
  2. 启动首次接收:在初始化代码之后,调用 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 半传输中断
    
  3. 编写空闲中断回调函数:这是此方案的核心。

    /**
      * @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);
        }
    }
    
  4. 编写数据处理任务:在 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 + 空闲中断,但数据的暂存方式从简单的线性数组升级为环形缓冲区

协同流程

  1. DMA 将串口数据搬运到一个临时的 DMA 缓冲区。
  2. 空闲中断触发,在中断回调函数中,将 DMA 缓冲区里的整帧数据快速“投入”到环形缓冲区中。这个过程极快,几乎不耗时。
  3. 主循环可以按照自己的节奏,从容地从环形缓冲区中“取出”数据进行解析和处理,无需担心数据被覆盖。

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;     // 缓冲区总大小 (字节)
};

:light_bulb: 镜像位与位域:该库通过巧妙的“镜像位”来区分缓冲区是“空”还是“满”,避免了传统方法中浪费一个存储单元的问题。同时,使用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.cringbuffer.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));
}

总结:

在串口的应用中 无论采用什么方案 无非就是分为以下几个步骤

  1. 根据方案 定义相应缓存区
  2. 首次启动接收 或者初始化绑定缓冲区
  3. 根据不同方案 选择不同回调函数 并完善其中逻辑
  4. 根据不同方案 选择不同的 uart_task()流程
1 个赞