【7】串口编程三种模式(查询/中断/DMA)韦东山老师学习笔记(课程听不懂的话试着来看看我的学习笔记吧)

<1>前置概念补充

在深入拆解三种模式前,先通过提供的 "函数对比表" 建立整体认知:这张表是串口收发的「武器库索引」,清晰标注了查询、中断、DMA 三种模式下,收发 / 回调函数的对应关系。后续会结合实际代码,讲透每个函数怎么用、何时触发,先记住这张表的核心关联👇

功能 查询模式 中断模式 DMA 模式
发送 HAL_UART_Transmit HAL_UART_Transmit_IT HAL_UART_TxCpltCallback HAL_UART_Transmit_DMA HAL_UART_TxHalfCpltCallback HAL_UART_TxCpltCallback
接收 HAL_UART_Receive HAL_UART_Receive_IT HAL_UART_RxCpltCallback HAL_UART_Receive_DMA HAL_UART_RxHalfCpltCallback HAL_UART_RxCpltCallback
错误处理 - HAL_UART_ErrorCallback HAL_UART_ErrorCallback

简单说:

  • 查询模式:用「阻塞函数」收发,CPU 全程等待
  • 中断模式:用「启动函数 + 完成回调」,收发完自动通知 CPU
  • DMA 模式:用「DMA 启动函数 + 半完成 / 完成回调」,数据自动传输,CPU 完全解放

查询模式的简单,是靠「牺牲 CPU 效率」实现的。对比另外两种模式的函数逻辑,差异一目了然:

模式 收发逻辑 CPU 参与度 典型函数
查询 函数阻塞,CPU 全程等待收发完成 100% 占用 HAL_UART_Transmit/Receive
中断 启动后立即返回,收发完成回调通知 仅中断触发时参与 HAL_UART_Transmit_IT + 回调
DMA 数据自动传输,CPU 无需参与 0 参与(纯硬件搬运) HAL_UART_Transmit_DMA + 回调

一、STM32 串口通信_查询方式

(1)开篇引言

这篇教程专为 0 基础嵌入式初学者 打造,用最通俗的语言拆解 STM32 串口通信核心函数 HAL_UART_Transmit 的用法,从硬件接线、工具使用到代码逻辑,一步步带大家实现 "STM32 发数据,电脑串口助手收数据"。后续还会扩展中断、DMA 等高级用法。

(2)硬件连接:串口接线逻辑(核心!接错没数据)

1. 接线原理

串口通信遵循 "发送端连接收端,接收端连发送端" 的规则,简单说:

  • STM32 开发板的 TX 引脚 (如 DshanMCU-F103 底板的 PA9),要连接 USB 串口模块的 RX 引脚
  • STM32 开发板的 RX 引脚 (如 DshanMCU-F103 底板的 PA10),要连接 USB 串口模块的 TX 引脚
  • 两者的 GND 引脚 必须相连(保证电平参考一致)

同时,ST-Link 要保持连接,负责给开发板供电、烧录程序和调试

2. 实物接线参考(搭配你的硬件图)

下图清晰展示了 DshanMCU-F103 底板与 USB 串口模块的接线方式:

(3)驱动安装:让电脑识别串口

如果你用的是 CH340 串口模块 ,需安装 CH340 驱动(对应你提供的 8_CH340_CH341驱动程序 文件夹),步骤如下:

  1. 解压 8_CH340_CH341驱动程序,找到并运行 CH341SER.EXE,按提示完成安装。
  2. 插入 USB 串口模块,打开 设备管理器 ,查看 "端口(COM 和 LPT)" 列表。若出现类似 USB-SERIAL CH340 (COMxx)(如 COM38),说明驱动安装成功。

(4)串口助手:收发数据的 "窗口"

我们用 sscom5.13.1收发数据,操作步骤:

  1. 解压运行 sscom5.13.1.exe,在 "通讯端口" 选择识别到的串口(如 COM38 )。
  2. 设置 波特率为 115200(必须与代码配置一致!),数据位 8、停止位 1、无校验。
  3. 点击 "打开串口",即可开始收发数据。

(5)代码解析:HAL_UART_Transmit 怎么用?

以下是核心代码逻辑:

复制代码
#include "main.h"
#include "usart.h"  // 串口相关头文件
#include "gpio.h"   // GPIO 相关头文件

/* 全局变量定义(根据需求使用,此处保留核心逻辑) */
extern UART_HandleTypeDef huart1;  
char c;  // 存储串口接收的字符

/**
  * @brief  主函数:程序入口
  * @retval int 返回值(一般无实际意义)
  */
int main(void)
{
    // 1. 初始化 HAL 库、系统时钟、串口、GPIO 等(CubeMX 自动生成,无需深究)
    HAL_Init();           
    SystemClock_Config(); 
    MX_USART1_UART_Init();
    MX_GPIO_Init();       

    // 2. 主循环:不断收发数据
    while (1)
    {
        // ① 发送提示信息:"Please enter a char: \r\n"
        HAL_UART_Transmit(&huart1, "Please enter a char: \r\n", 20, 1000);  

        // ② 接收数据:循环尝试接收 1 个字节,直到成功(超时时间 100ms )
        while(HAL_OK != HAL_UART_Receive(&huart1, &c, 1, 100));  

        // ③ 处理数据:收到的字符 +1(如输入 'a' 变成 'b' )
        c = c + 1;  

        // ④ 发送处理后的数据:把 +1 后的字符发回电脑
        HAL_UART_Transmit(&huart1, &c, 1, 1000);  

        // ⑤ 发送换行符:让串口助手显示更整洁
        HAL_UART_Transmit(&huart1, "\r\n", 2, 1000);  
    }
}

关键函数:HAL_UART_Transmit 解析

复制代码
HAL_UART_Transmit(&huart1, &c, 1, 1000);
  • &huart1:串口句柄,指定用 USART1 发数据(由 CubeMX 配置生成,直接用即可 )。
  • &c :要发送的数据地址。发送单个字符用 &c,发送字符串则用字符串数组名(如 str )。
  • 1 :发送数据的长度。发送 1 个字符填 1,发送字符串填 strlen(str)(自动计算长度 )。
  • 1000:超时时间(毫秒)。如果 1 秒内没发出去,函数返回错误。

(6)效果验证:收发数据测试

1. 正常情况:输入 1 输出 2

  • 操作:串口助手收到提示 Please enter a char: 后,输入 1 并发送。
  • 预期:开发板回发 2(因为代码里 c = c + 1 )。

2. 异常情况:输入 123 无正确响应

  • 问题:代码里是 单字节接收HAL_UART_Receive(&huart1, &c, 1, 100) ),一次只能收 1 个字符。若输入 123,实际会分 3 次接收(123 ),但代码逻辑未处理多字节连续输入,导致 "输入 123 看似没反应"。
  • 解决思路(后续优化方向 ):
    • 数组 + 长度判断 接收多字节数据。
    • 结合 中断 / DMA 实现 "数据自动缓存,无需 CPU 一直等待"。

(7)常见问题排查

1. 串口助手收不到数据?

  • 检查 接线:TX/RX 是否交叉连接,GND 是否共地。
  • 检查 波特率 :代码和串口助手的波特率是否均为 115200
  • 检查 驱动:设备管理器是否识别到串口,驱动是否安装成功。

2. 发送字符串怎么改?

若要发送字符串(如 Hello ),可修改代码:

复制代码
char str[] = "Hello";  // 定义字符串数组
// 发送字符串:长度用 strlen(str) 自动计算
HAL_UART_Transmit(&huart1, str, strlen(str), 1000);  

(8)后续优化预告(进阶方向)

当前代码用的是 查询方式(发数据要等待、收数据要循环询问 ),缺点是 "CPU 一直忙等,无法做其他事"。后续会扩展:

  1. 中断方式:数据到来自动触发,CPU 可并行处理其他任务(适合实时性高的场景 )。
  2. DMA 方式:数据直接在内存和外设间传输,完全无需 CPU 参与(适合大数据量场景 )。

二、不实用的官方中断模式

STM32 串口中断深度解析:从硬件原理到代码实战(以 HAL 库为例)

<2>中断模式完整函数链

中断模式的收发,是「启动函数 → 中断触发 → 回调函数」的完整链条,用表格串联更清晰:

阶段 发送流程(中断) 接收流程(中断)
启动 HAL_UART_Transmit_IT 启动发送 HAL_UART_Receive_IT 启动接收
中断触发 发完 1 字节 → TXE 中断;发完所有字节 → TC 中断 收到 1 字节 → RXNE 中断;收完所有字节 → 接收完成中断
回调通知 发完所有字节 → HAL_UART_TxCpltCallback 收完所有字节 → HAL_UART_RxCpltCallback
错误处理 统一走 HAL_UART_ErrorCallback 统一走 HAL_UART_ErrorCallback

(1)开篇:为什么需要串口中断?

在之前的查询方式串口通信中,CPU 需要不断 "询问" 串口是否有数据,就像一个人不停地问 "你有数据吗?有数据吗?",这会让 CPU 无法去做其他更有意义的事情。而串口中断就像给串口装了一个 "门铃",当有数据到来或者数据发送完成时,串口主动 "按门铃" 通知 CPU,这样 CPU 就可以在等待串口的空闲时间去处理其他任务,大大提高了系统效率。

(2)串口中断的硬件基础

(一)STM32 串口中断相关寄存器

  1. 状态寄存器(USART_SR)
    • TXE(Transmit Data Register Empty)位:当发送数据寄存器为空时,该位被置 1。这意味着可以往发送数据寄存器中写入新的数据了。在中断模式下,我们可以使能 TXE 中断,当 TXE 位置 1 时触发中断,去发送下一个数据。
    • TC(Transmission Complete)位:当一帧数据发送完成时,该位被置 1。可以利用 TC 中断来判断一次发送是否完全结束。
    • RXNE(Read Data Register Not Empty)位:当接收数据寄存器中有数据时,该位被置 1,可使能 RXNE 中断来触发接收操作。
  2. 控制寄存器(USART_CR1)
    • TXEIE 位:用于使能 TXE 中断。当该位被置 1,且 TXE 位置 1 时,会触发串口发送中断。
    • TCIE 位:用于使能 TC 中断。当该位被置 1,且 TC 位置 1 时,会触发串口发送完成中断。
    • RXNEIE 位:用于使能 RXNE 中断。当该位被置 1,且 RXNE 位置 1 时,会触发串口接收中断。

(二)串口中断的硬件触发流程

USART1 等外设可以通过 DMA 请求与系统进行数据交互,同时也可以通过中断的方式。当使能了串口的某个中断(如 TXE 中断)后:

  1. 当发送数据寄存器为空(TXE=1)且 TXEIE=1 时,硬件会触发中断请求,这个请求会通过总线矩阵等到达 CPU 的中断控制器。
  2. CPU 响应中断后,会跳转到对应的中断处理函数(如 USART1_IRQHandler)去执行相应的操作。
  3. 对于接收来说,当接收数据寄存器非空(RXNE=1)且 RXNEIE=1 时,同样会触发中断请求,进入接收中断处理流程。

(3)HAL 库串口中断函数解析

(一)中断处理函数入口:USART1_IRQHandler

cs 复制代码
void USART1_IRQHandler(void)
{
    HAL_UART_IRQHandler(&huart1);
}

这是串口 1 的中断处理函数入口,当串口 1 触发中断时,CPU 会首先跳转到这里。然后调用HAL_UART_IRQHandler(&huart1)函数,这个函数是 HAL 库中处理串口中断的核心函数,它会去检查中断源(是 TXE 中断、TC 中断还是 RXNE 中断等),并调用相应的处理逻辑。

(二)HAL_UART_IRQHandler 函数关键逻辑

复制代码
/* UART in mode Transmitter */
if ((((isrflags & USART_SR_TXE) != RESET) && ((cr1its & USART_CR1_TXEIE) != RESET)))
{
  UART_Transmit_IT(huart);
  return;
}
/* UART in mode Transmitter end */
if ((((isrflags & USART_SR_TC) != RESET) && ((cr1its & USART_CR1_TCIE) != RESET)))
{
  UART_EndTransmit_IT(huart);
  return;
}
  1. TXE 中断处理 :当检测到状态寄存器中的 TXE 位为 1(发送数据寄存器为空),并且控制寄存器中的 TXEIE 位为 1(使能了 TXE 中断)时,会调用UART_Transmit_IT(huart)函数,这个函数会去处理发送过程中的数据填充等操作,继续发送下一个数据。
  2. TC 中断处理 :当检测到 TC 位为 1(发送完成),并且 TCIE 位为 1(使能了 TC 中断)时,会调用UART_EndTransmit_IT(huart)函数,用于处理发送完成后的一些收尾工作,比如标记发送完成状态等。

(三)HAL_UART_Transmit_IT 函数

复制代码
HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size)
{
  /* Check that a Tx process is not already ongoing */
  if (huart->gState == HAL_UART_STATE_READY)
  {
    if ((pData == NULL) || (Size == 0U))
    {
      return HAL_ERROR;
    }

    huart->pTxBuffPtr = pData;
    huart->TxXferSize = Size;
    huart->TxXferCount = Size;

    huart->ErrorCode = HAL_UART_ERROR_NONE;
    huart->gState = HAL_UART_STATE_BUSY_TX;

    /* Enable the UART Transmit data register empty Interrupt */
    __HAL_UART_ENABLE_IT(huart, UART_IT_TXE);

    return HAL_OK;
  }
  else
  {
    return HAL_BUSY;
  }
}
  1. 函数作用 :这个函数用于以中断模式启动串口发送。首先检查串口当前状态是否为HAL_UART_STATE_READY,如果是,就对串口句柄中的发送缓冲区指针、发送数据大小、发送计数器等进行初始化,然后将串口的状态设置为HAL_UART_STATE_BUSY_TX表示正在发送,最后使能 TXE 中断(通过__HAL_UART_ENABLE_IT(huart, UART_IT_TXE)),这样当发送数据寄存器为空时就会触发中断,进入发送中断处理流程。
  2. 参数解析huart是串口句柄,pData是要发送的数据缓冲区指针,Size是要发送的数据长度。

(四)UART_Transmit_IT 函数

复制代码
static HAL_StatusTypeDef UART_Transmit_IT(UART_HandleTypeDef *huart)
{
  const uint16_t *tmp;

  /* Check that a Tx process is ongoing */
  if (huart->gState == HAL_UART_STATE_BUSY_TX)
  {
    if ((huart->Init.WordLength == UART_WORDLENGTH_9B) && (huart->Init.Parity == UART_PARITY_NONE))
    {
      tmp = (const uint16_t *)huart->pTxBuffPtr;
      huart->Instance->DR = (uint16_t)(*tmp & (uint16_t)0x01FF);
      huart->pTxBuffPtr += 2U;
    }
    else
    {
      huart->Instance->DR = (uint8_t)(*huart->pTxBuffPtr++ & (uint8_t)0x00FF);
    }

    if (--huart->TxXferCount == 0U)
    {
      /* Disable the UART Transmit Data Register Empty Interrupt */
      __HAL_UART_DISABLE_IT(huart, UART_IT_TXE);
      /* Enable the UART Transmit Complete Interrupt */
      __HAL_UART_ENABLE_IT(huart, UART_IT_TC);
    }

    return HAL_OK;
  }
  else
  {
    return HAL_BUSY;
  }
}
  1. 数据发送处理 :当进入这个函数时,首先检查串口是否处于HAL_UART_STATE_BUSY_TX状态(即正在发送过程中)。然后根据串口配置的字长和校验位情况,从发送缓冲区中取出数据写入到串口的数据寄存器(huart->Instance->DR)中。如果是 9 位数据且无校验,就按 16 位处理;否则按 8 位处理。
  2. 中断切换 :每发送一个数据,发送计数器TxXferCount减 1。当TxXferCount减到 0 时,说明当前要发送的数据已经全部放到数据寄存器了,这时候需要关闭 TXE 中断(因为不需要再触发 "发送数据寄存器为空" 的中断了),并使能 TC 中断(等待发送完成中断),这样当一帧数据发送完成后就会触发 TC 中断。

(4)中断模式完整函数链

中断模式的收发,是「启动函数 → 中断触发 → 回调函数」的完整链条,用表格串联更清晰:

阶段 发送流程(中断) 接收流程(中断)
启动 HAL_UART_Transmit_IT 启动发送 HAL_UART_Receive_IT 启动接收
中断触发 发完 1 字节 → TXE 中断;发完所有字节 → TC 中断 收到 1 字节 → RXNE 中断;收完所有字节 → 接收完成中断
回调通知 发完所有字节 → HAL_UART_TxCpltCallback 收完所有字节 → HAL_UART_RxCpltCallback
错误处理 统一走 HAL_UART_ErrorCallback 统一走 HAL_UART_ErrorCallback

(5)中断接收与发送完整代码流程

(一)全局变量定义

复制代码
extern UART_HandleTypeDef huart1;
int key_cut=0;
void key_timeout_func(void *args);
struct soft_timer key_timer ={~0,NULL,key_timeout_func};

static uint8_t g_data_buf[100];
static circle_buf g_key_bufs;

static volatile int g_tx_cplt = 0;

这里定义了串口句柄(通过extern引用)、一些用于按键处理的变量(虽然在串口中断中可能暂时没用到,但属于整个工程的变量)以及用于标记发送完成的 volatile 变量g_tx_cplt(因为在中断回调函数和主函数中都会访问,需要用 volatile 保证其可见性)。

(二)发送完成回调函数

复制代码
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
  g_tx_cplt =1;
}

当串口发送完成中断触发并处理完成后,会调用这个回调函数,将g_tx_cplt置 1,用于在主函数中判断发送是否完成。

(三)等待发送完成函数

复制代码
void Wait_Tx_Complete(void)
{
  while(g_tx_cplt ==0);
    g_tx_cplt =0;
}

这个函数会在主函数中被调用,用于等待发送完成。它会一直循环检查g_tx_cplt是否为 1,当为 1 时说明发送完成,然后将其置 0,为下一次发送做准备。

(四)主函数中的中断发送流程

复制代码
while (1)
{
    /*enable txe interrupt*/
    HAL_UART_Transmit_IT(&huart1,str2,strlen(str2));
    /*wait for tc*/
    Wait_Tx_Complete();
    while(HAL_OK !=HAL_UART_Receive(&huart1,&c,1,100));
    c=c+1;
    HAL_UART_Transmit(&huart1,&c,1,1000);
    HAL_UART_Transmit(&huart1,"\r\n",2,1000);
}
  1. 发送流程 :在主循环中,首先调用HAL_UART_Transmit_IT(&huart1,str2,strlen(str2))以中断模式发送字符串str2,然后调用Wait_Tx_Complete()等待发送完成(通过检查g_tx_cplt)。
  2. 接收流程 :发送完成后,调用HAL_UART_Receive(&huart1,&c,1,100)以查询方式接收一个字节的数据(这里也可以改为中断接收方式,后续优化方向),接收完成后对数据进行简单处理(c=c+1),然后再用查询方式发送回传数据和换行符。

(6)中断模式优缺点与适用场景

(一)优点

  1. 效率高:不需要像查询方式那样一直占用 CPU 去等待串口状态,CPU 可以在等待串口中断的时间去处理其他任务,比如进行按键扫描、传感器数据处理等,提高了系统的整体效率。
  2. 实时性好:当串口有数据到来或者发送完成时能及时触发中断进行处理,对于一些对实时性要求较高的应用场景(如工业控制中的快速指令响应)非常合适。

(二)缺点

  1. 代码复杂度高:相比查询方式,中断模式需要处理中断函数、回调函数、中断使能与禁用、中断标志判断等,代码逻辑相对复杂,对于初学者来说理解和调试难度较大。
  2. 资源占用:虽然 CPU 利用率提高了,但中断本身也会带来一定的开销,比如中断上下文切换等,如果中断过于频繁,也可能会影响系统性能。

(三)适用场景

适用于对实时性要求较高、CPU 需要同时处理多个任务的场景,比如多传感器数据实时上传、工业设备的远程控制指令接收与响应等。而对于一些简单的、对实时性要求不高的小项目,查询方式可能因为代码简单更容易实现。

(7)常见问题与调试方法

(一)中断不触发问题

  1. 可能原因
  • 1.中断使能不正确:比如在HAL_UART_Transmit_IT中没有正确使能 TXE 中断,或者在HAL_UART_IRQHandler中相关中断源的使能和标志判断有问题。
  • 2.串口配置错误:在 STM32CubeMX 中配置串口时,没有正确使能对应的中断(如在 NVIC 设置中没有使能 USART1 的中断)。
  • 3.全局中断未使能:即使串口的中断使能了,但如果 CPU 的全局中断没有使能(比如没有调用__enable_irq()函数,不过在 HAL 库中一般会自动处理,但也可能因为某些配置被关闭),也无法响应中断。
  1. 调试方法
    • 检查 CubeMX 配置:查看串口的中断是否在 NVIC 中正确使能,优先级是否设置合理。
    • 检查代码中的中断使能函数:在HAL_UART_Transmit_IT中查看__HAL_UART_ENABLE_IT是否正确调用,使能的中断类型是否正确。
    • 在中断处理函数入口加断点:在USART1_IRQHandler函数中加断点,看是否能进入中断处理函数,如果进不去,说明中断触发有问题;如果能进去,再逐步检查HAL_UART_IRQHandler中的逻辑。

(二)数据发送不完整或错误

  1. 可能原因
    • 发送缓冲区处理问题:在UART_Transmit_IT中,数据从缓冲区取出和指针移动的逻辑有错误,导致数据没有正确发送或者发送了错误的数据。
    • 中断切换逻辑问题:TXE 中断和 TC 中断的切换没有正确处理,导致数据发送到一半就停止或者发送完成后没有正确标记状态。
    • 数据长度设置错误:在调用HAL_UART_Transmit_IT时,strlen(str2)计算的长度不正确,导致发送的数据长度错误。
  2. 调试方法
    • UART_Transmit_IT函数中加断点,检查每次从缓冲区取出的数据是否正确,指针移动是否正确。
    • 检查中断切换时的计数器TxXferCount,看其递减是否正确,以及中断使能和禁用是否在正确的时机。
    • 打印发送的数据长度和实际发送的数据(可以通过串口助手配合,或者在代码中使用调试串口打印中间变量),检查数据是否正确。

(三)回调函数不执行

  1. 可能原因
    • 没有正确实现回调函数:在 HAL 库中,回调函数需要按照规定的名称和参数实现,比如HAL_UART_TxCpltCallback,如果函数名写错或者参数不匹配,就不会被正确调用。
    • 中断没有正确触发到发送完成阶段:可能在数据发送过程中出现了错误,导致没有触发 TC 中断,所以回调函数不会执行。
  2. 调试方法
    • 检查回调函数名称和参数是否正确。

三、中断改造方法

(1)核心需求:"串口收发数据不丢" 要解决啥问题?

想象你用串口给设备发消息,比如连续快速发 10 个字符。如果设备处理慢,或者中断响应不及时,数据就会 "挤在一起" 丢包。环形缓冲区 就像一个 "临时仓库",先把收到的数据存起来,主程序慢慢取;中断 负责 "一收到数据就通知仓库存数据",两者配合就能解决丢包问题。

(2)代码角色分工:谁在干啥?

cs 复制代码
// usart.c:专门处理串口中断、环形缓冲区,收数据存缓冲区、取数据逻辑
#include "main.h"        // 包含HAL库等
#include "usart.h"       // 串口相关声明
#include "circle_buffer.h"  // 环形缓冲区头文件

// 静态全局变量:仅usart.c内部用,避免全局污染
static volatile int g_tx_cplt = 0;  // 发送完成标志(0=未完成,1=完成)
static volatile int g_rx_cplt = 0;  // 接收完成标志(同理)
static uint8_t g_c;                 // 临时存"刚收到的1个字节"
static uint8_t g_recvbuf[100];      // 环形缓冲区的"物理存储数组"
static circle_buf g_uart1_rx_bufs;  // 环形缓冲区的"控制结构体"(存读写位置等)

// 串口发送完成回调函数:HAL库规定名,发送中断完成后自动调用
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    g_tx_cplt = 1;  // 标记"发送完成",告诉主程序可以继续了
}

// 【关键】串口接收完成回调函数:收到1个字节后,HAL库自动调用
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    // 1. 把刚收到的1个字节(存在g_c里)存进环形缓冲区
    cirble_buf_write(&g_uart1_rx_bufs, g_c);  

    // 2. 重新使能接收中断!否则只能收1个字节,收完就不监听了
    HAL_UART_Receive_IT(&huart1, &g_c, 1);  
}

// 等待发送完成:给主程序用,阻塞直到发送完成
void Wait_Tx_Complete(void)
{
    while (g_tx_cplt == 0) { /* 循环等,直到g_tx_cplt被回调函数设为1 */ }
    g_tx_cplt = 0;  // 清零,下次发送再用
}

// 启动串口接收中断:主程序初始化时调用,开启"收数据触发中断"
void startuart1recv(void)
{
    // 1. 初始化环形缓冲区:告诉它用g_recvbuf数组存数据,大小100
    circld_buf_init(&g_uart1_rx_bufs, 100, g_recvbuf);  

    // 2. 使能接收中断:收到1个字节就触发HAL_UART_RxCpltCallback
    HAL_UART_Receive_IT(&huart1, &g_c, 1);  
}

// 从环形缓冲区取1个字节:给主程序用,取"存好的数据"
int UART1getchar(uint8_t *pVal)
{
    // 调用环形缓冲区的读取函数,把数据放到pVal里
    return circle_buf_read(&g_uart1_rx_bufs, pVal);  
}

先看代码里的关键模块,像 "部门分工" 一样理解:

代码部分 作用类比 核心任务
circle_buf(环形缓冲区) 快递临时仓库 存收到的数据,主程序按需取
HAL_UART_RxCpltCallback(接收中断回调) 仓库管理员 一收到串口数据,就存进仓库
startuart1recv 启动 "仓库监听" 打开串口中断,让它能触发回调
UART1getchar 取快递的人 从仓库里拿数据给主程序用

(3)流程拆解:从 "启动程序" 到 "收发数据" 全步骤

1. 初始化:给 "仓库" 和 "串口" 搭好架子
  • 环形缓冲区初始化 (对应 circld_buf_init(&g_uart1_rx_bufs,100,g_recvbuf);):

    • 就像给仓库划分区域:g_recvbuf 是实际存数据的数组(仓库货架),g_uart1_rx_bufs 是管理这个数组的 "仓库规则"(比如:数据存在哪、存了多少、从哪取)。
    • 你可以理解为:circld_buf_init 帮你 "建了一个 100 字节的临时仓库,准备存串口数据"。
  • 串口中断启动 (对应 startuart1recv();):

    • 调用 HAL_UART_Receive_IT(&huart1,&g_c,1);,意思是:"串口 1 啊,你开启'接收中断'吧!只要收到 1 个字节,就触发回调函数!"
    • 这一步是 "打开仓库管理员的监听开关",让串口一收到数据,就通知程序处理。
2. 数据接收:"仓库管理员" 怎么存数据?

当串口收到数据时(比如电脑发了一个字符 'A'),会触发 HAL_UART_RxCpltCallback 中断回调,流程像这样:

  1. 触发条件 :串口硬件收到 1 个字节(比如 'A'),自动触发这个函数。
  2. 存数据到环形缓冲区 (对应 cirble_buf_write(&g_uart1_rx_bufs,g_c);):
    • g_c 里存着刚收到的字节(比如 'A' 的 ASCII 码)。
    • 调用 cirble_buf_write,就像 "管理员把刚收到的快递(数据)放进仓库货架(数组 g_recvbuf)"。
  3. 重新开启中断HAL_UART_Receive_IT(&huart1,&g_c,1);):
    • 因为中断触发一次后会关闭,必须重新调用它,才能继续监听下一个字节。
    • 相当于 "管理员存完这个快递,赶紧打开监听,等下一个快递"。
3. 主程序取数据:从 "仓库" 拿数据处理
cs 复制代码
// main.c:主程序,负责初始化、循环收发数据
#include "main.h"        // 包含HAL库等基础头文件
#include "i2c.h"
#include "usart.h"       // 假设usart相关声明在这里(实际工程需单独.h)
#include "gpio.h"
#include "circle_buffer.h"  // 环形缓冲区头文件

// 软件定时器结构体(按键消抖用,和串口主逻辑关联弱,初学可先关注串口)
struct soft_timer
{
    uint32_t timeout;      // 超时时间戳(毫秒)
    void *args;            // 回调参数
    void (*func)(void *);  // 回调函数
};

// 全局变量声明(实际工程建议用static+访问函数,这里简化)
extern UART_HandleTypeDef huart1;  // 串口1句柄,usart.c里会用到
int key_cut = 0;
void key_timeout_func(void *args); 
struct soft_timer key_timer = {~0, NULL, key_timeout_func};

// 环形缓冲区相关(按键逻辑,初学可先跳过,关注串口部分)
static uint8_t g_data_buf[100];     
static circle_buf g_key_bufs;

// 串口收发相关函数声明(实际应放usart.h,这里简化)
void startuart1recv(void);   // 启动串口接收中断
int UART1getchar(uint8_t *pVal); // 从环形缓冲区取数据
void Wait_Tx_Complete(void);  // 等待发送完成
void Wait_Rx_Complete(void);  // 等待接收完成(实际未用到,演示用)

// 主函数:程序入口
int main(void)
{
    char *str = "Please enter a char: \r\n"; // 发送的提示字符串
    char *str2 = "www.100ask.net\r\n";       // 欢迎信息
    char c;  // 存储接收到的字符

    // 1. 初始化HAL库、系统时钟
    HAL_Init();          
    SystemClock_Config(); 

    // 2. 初始化环形缓冲区(按键逻辑,初学先记住串口也有环形缓冲区)
    circld_buf_init(&g_key_bufs, 100, g_data_buf); 

    // 3. 初始化外设:GPIO、I2C、串口
    MX_GPIO_Init();      
    MX_I2C1_Init();      
    MX_USART1_UART_Init(); 

    // 4. 初始化OLED(如果有的话,演示用,和串口核心逻辑无关)
    OLED_Init();
    OLED_Clear();
    OLED_PrintString(0, 0, "cnt     : ");
    int len = OLED_PrintString(0, 2, "key val : ");

    // 5. 先发送一条欢迎信息
    HAL_UART_Transmit(&huart1, str2, strlen(str2), 1000); 
    // 6. 启动串口接收中断:让串口一收到数据就触发中断存缓冲区
    startuart1recv();  

    // 主循环:一直运行
    while (1)
    {
        // 7. 非阻塞发送提示信息:告诉串口"后台发,发完通知我"
        HAL_UART_Transmit_IT(&huart1, str, strlen(str)); 
        // 8. 等待发送完成(阻塞等待,确保发完再干别的)
        Wait_Tx_Complete();  

        // 9. 从环形缓冲区取数据:循环取,直到取到数据(0表示成功)
        while (0 != UART1getchar(&c)) { /* 没数据就循环等 */ }

        // 10. 处理数据:收到的字符+1,再发回去
        c = c + 1; 
        HAL_UART_Transmit(&huart1, &c, 1, 1000); 
        HAL_UART_Transmit(&huart1, "\r\n", 2, 1000); 

        // 以下是按键消抖逻辑(和串口主流程关联弱,初学可暂时注释掉)
        // key_timeout_func相关逻辑...(演示用,不影响串口理解)
    }
}

// 按键消抖回调(和串口主逻辑无关,初学可跳过)
void key_timeout_func(void *args)
{
    uint8_t key_val;
    key_cut++;
    key_timer.timeout = ~0;
    if (HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_14) == GPIO_PIN_RESET)
        key_val = 0x1;
    else
        key_val = 0x81;
    cirble_buf_write(&g_key_bufs, key_val);
}

// 定时器修改函数(和串口主逻辑无关,初学可跳过)
void mod_timer(struct soft_timer *pTimer, uint32_t timeout)
{
    pTimer->timeout = HAL_GetTick() + timeout;
}

// 定时器检查函数(和串口主逻辑无关,初学可跳过)
void cherk_timer(void)
{
    if (key_timer.timeout <= HAL_GetTick())
    {
        key_timer.func(key_timer.args);
    }
}

// GPIO中断回调(和串口主逻辑无关,初学可跳过)
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_PIN_14 == GPIO_Pin)
    {
        mod_timer(&key_timer, 10);
    }
}

主程序里有个循环 while(0 != UART1getchar(&c));,它的作用是 "一直从仓库里取数据",流程:

  1. 调用 UART1getchar(&c) (对应 return circle_buf_read(&g_uart1_rx_bufs,pVal);):
    • 这是 "取快递的人" 去仓库找数据。circle_buf_read 会检查环形缓冲区里有没有数据。
    • 如果有数据,就把数据放到 c 里(比如刚才存的 'A');如果没数据,就等下次再取。
  2. 主程序处理数据 (比如 c=c+1; HAL_UART_Transmit(&huart1,&c,1,1000);):
    • 拿到 c(比如 'A')后,主程序可以修改它(比如 c+1 变成 'B'),再通过 HAL_UART_Transmit 发回去。
4. 发送数据:"非阻塞" 发送是咋回事?

代码里用了 HAL_UART_Transmit_IT(&huart1,str,strlen(str)); + Wait_Tx_Complete(); 发送数据:

  • HAL_UART_Transmit_IT:"告诉串口:你后台慢慢发数据,别阻塞主程序!发完了通知我。"
  • Wait_Tx_Complete() :"主程序在这等着,直到串口发完数据(g_tx_cplt 变成 1),再继续干别的。"
    (类比:你点了个外卖配送,不用一直盯着,配送完 App 通知你 ------ 但你可以选择 "等配送完再干别的")

(4)环形缓冲区核心逻辑(白话版)

很多同学不懂 "环形缓冲区" 咋循环存数据,用 "快递货架" 比喻 讲清楚:

  1. 存数据(cirble_buf_write

    • 货架有 100 个格子(g_recvbuf[100]),管理员存数据时,按顺序往后放。
    • 存满了怎么办?环形 的关键:从最后一个格子跳回第一个格子继续存(像操场跑圈),这样不用清空数组,反复利用空间。
  2. 取数据(circle_buf_read

    • 取数据的人按 "存数据的顺序" 拿,存的时候从格子 0→1→2...,取的时候也 0→1→2...,保证数据顺序不乱。
    • 如果存的比取的快,仓库会暂时存着;如果取的比存的快,就等新数据进来。

(5)"不丢数据" 的核心秘密:中断 + 环形缓冲区配合

  • 中断 保证 "一收到数据就存":不管主程序在干啥,只要串口有数据,立刻触发回调存进仓库,不会因为主程序忙别的就丢数据。
  • 环形缓冲区 保证 "数据有地方存":即使主程序处理慢,数据先存在仓库里,不会因为 "没及时取" 就丢失,主程序啥时候有空啥时候取。

(6)新手常见疑问解答

Q1:HAL_UART_RxCpltCallback 里为啥要重新调用 HAL_UART_Receive_IT

A:因为串口中断触发一次后,会自动关闭。重新调用才能继续监听下一个字节,保证 "收到一个存一个",不断触发中断。

Q2:环形缓冲区和普通数组有啥区别?

A:普通数组存满了必须清空才能继续存;环形缓冲区像 "循环跑道",存满了从开头继续存,不用清空,效率更高。

Q3:主程序里 while(0 != UART1getchar(&c)); 是死循环吗?

A:不是死循环!UART1getchar 里,没数据时会返回非 0(比如 -1),主程序会一直循环尝试取;一旦取到数据(返回 0),就跳出循环继续处理。

(7)总结:完整流程串起来

  1. 初始化:建环形缓冲区仓库,打开串口中断监听。
  2. 收数据:串口收到数据 → 触发中断回调 → 数据存进环形缓冲区 → 重新开启中断,等下一个数据。
  3. 取数据:主程序循环从环形缓冲区取数据,处理后可以再发回去。
  4. 发数据:用中断方式后台发送,主程序不用阻塞等待,发完再继续干活。

这样配合,就能实现 "串口收发数据不丢失",不管数据来多快、主程序多忙,都能稳稳接住~

四、STM32 DMA 串口收发教程(结合中断,从 0 讲透)

<3>DMA 模式的回调函数(此次核心代码)

DMA 模式比中断模式多了「半完成回调」,适合大数据量的 "边传边处理",用表格对比差异:

回调类型 触发时机 典型用途
完成回调 数据全部收发完成后触发 最终数据处理(如校验、存储完整数据)
半完成回调 数据收发到一半时触发 实时处理(如显示传输进度、临时缓存)
错误回调 收发过程中出现错误时触发 错误恢复(如重发、报错提示)

DMA 发送有 HAL_UART_TxHalfCpltCallback(发一半触发)、HAL_UART_TxCpltCallback(发完触发);接收同理。

cs 复制代码
/发送
HAL_UART_Transmit_DMA
HAL_UART_TxHalfCpltCallback
HAL_UART_TxCpltCallback
cs 复制代码
//接收
HAL_UART_Receive_DMA
HAL_UART_RxHalfCpltCallback
HAL_UART_RxCpltCallback
cs 复制代码
//错误回调
HAL_UART_ErrorCallback

(1)DMA 是啥?解决啥问题?

1. 白话理解 DMA

  • DMA 全称:直接存储器访问(Direct Memory Access)
  • 作用 :让数据 "自己搬家",不用 CPU 盯着!
    比如串口发 1000 个字符:
    • 不用 DMA:CPU 得逐个把字符 "抱" 到串口寄存器,期间不能干别的(像被 "拴在串口旁当苦力")。
    • 用 DMA:CPU 说 "我要发这 1000 个字符,地址是 xx",然后就去干别的。DMA 控制器自己把数据逐个搬到串口,搬完告诉 CPU(通过中断)。

2. 解决的核心问题

  • 解放 CPU:让 CPU 能同时处理其他任务(比如按键扫描、LED 控制),不用卡在 "收发数据" 上。
  • 适合大数据:发 1000 个字符、收 1000 个字节时,DMA 效率碾压 "CPU 逐个搬"。

(2)和之前 "中断收发" 的区别(对比理解)

方式 核心逻辑 适合场景 缺点
普通中断 收 / 发 1 个字节就触发中断,CPU 处理 数据量小、需要实时响应 数据量大时,CPU 被中断 "累死"
DMA + 中断 DMA 自动搬数据,搬完(或搬一半)触发中断告诉 CPU 大数据收发(比如发 1000 字符) 接收时需配合 IDLE 中断才好用(下文讲)

(3)代码改造思路(把 "中断收发" 改成 "DMA 收发")

1. 发送改造:把 HAL_UART_Transmit_IT 换成 HAL_UART_Transmit_DMA

  • 原来的中断发送:发 1 个字节触发一次中断,CPU 得管 "发完一个,下一个咋发"。
  • DMA 发送
    1. CPU 告诉 DMA:"我要发数组 str,长度 strlen(str),目标是串口 TDR 寄存器"。
    2. DMA 自动循环搬数据,全部搬完后 触发 HAL_UART_TxCpltCallback 中断,告诉 CPU "发完了"。

2. 接收改造:HAL_UART_Receive_DMA + (可选 IDLE 中断)

  • DMA 接收
    1. CPU 告诉 DMA:"我要收数据,存到数组 RxBuf,最多收 len 个字节"。
    2. DMA 自动把串口收到的数据搬到 RxBuf收满 len 触发 HAL_UART_RxCpltCallback收一半 触发 HAL_UART_RxHalfCpltCallback
  • 为啥需要 IDLE 中断
    串口收数据时,可能 "断断续续"(比如对方一次发 5 个,又发 3 个)。DMA 只会在 "收满指定长度" 才触发中断,没法知道 "对方已经发完一批"。这时候需要 IDLE 中断:串口 "空闲" 时(没数据来)触发中断,告诉 CPU"对方暂时不发了,你可以处理收到的数据了"。

四、保姆级代码流程拆解(结合你发的代码改造)

1. 关键函数对应(看第四张图 DMA 模式 函数)

函数名 触发时机 作用
HAL_UART_Transmit_DMA 主程序调用,启动 "DMA 发送" 告诉 DMA 开始发数据
HAL_UART_TxCpltCallback DMA 把数据全部发完后触发 通知 CPU "发送完成"
HAL_UART_Receive_DMA 主程序调用,启动 "DMA 接收" 告诉 DMA 开始收数据
HAL_UART_RxCpltCallback DMA 把数据全部收满后触发 通知 CPU "收满指定长度了"
HAL_UART_RxHalfCpltCallback DMA 收了一半数据后触发 (可选)收一半时提前处理数据
HAL_UART_ErrorCallback 收发出错时触发(比如总线错误) 处理错误

2. 改造后的 main.c 核心代码(发送部分)

复制代码
// main.c 主程序

#include "main.h"
#include "usart.h"  // 包含串口、DMA 相关声明

// 全局变量
UART_HandleTypeDef huart1;  // 串口1句柄(CubeMX 配置生成)
char *str = "Hello DMA! 这是用 DMA 发的数据\r\n";  // 要发的字符串

// 主函数
int main(void)
{
    HAL_Init();             // 初始化 HAL 库
    SystemClock_Config();   // 配置系统时钟
    MX_USART1_UART_Init();  // 初始化串口(CubeMX 生成,包含 DMA 配置)
    MX_DMA_Init();          // 初始化 DMA(CubeMX 生成)

    // 1. 启动 DMA 发送:把 str 的内容,通过 DMA 发给串口
    HAL_UART_Transmit_DMA(&huart1, (uint8_t *)str, strlen(str));  

    // 2. 主循环:发完后,DMA 会触发 HAL_UART_TxCpltCallback 中断
    while (1)
    {
        // 发完后,这里可以干别的(比如扫按键、闪 LED)
        // 不用卡在"等发送完成",因为 DMA 自己在后台发
    }
}

// 【关键】DMA 发送完成回调函数:HAL 库规定名称,发完自动调用
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart1)  // 确认是串口1的回调
    {
        // 可以在这里做"发送完成后的操作":
        // 比如再发一次数据、切换 LED 状态
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);  // 假设 LED 宏定义好了
    }
}

(5)结合"DMA 模式图" 详细讲流程

1. 发送流程(对应第二张图 RAM → DMA → USART

  1. CPU 下达指令

    执行 HAL_UART_Transmit_DMA(&huart1, str, len),CPU 告诉 DMA:

    • 源地址str(RAM 里的字符串数组)。
    • 目标地址 :串口的 TDR 寄存器(Transmit Data Register,发送数据寄存器)。
    • 长度len(要发多少个字节)。
  2. DMA 自动搬数据

    DMA 控制器开始工作,逐个把 str 里的字节,从 RAM 搬到 USART 的 TDR

    • 这一步 不需要 CPU 参与 !CPU 可以去干别的(比如 while(1) 里的按键扫描)。
  3. 发送完成触发中断

    DMA 把 len 个字节全部搬完后,触发 HAL_UART_TxCpltCallback 中断。

    • CPU 暂停当前任务,执行回调函数里的逻辑(比如 "再发一次数据""翻转 LED")。

2. 接收流程( USART → DMA → RAM

假设要收数据到数组 RxBuf[100],流程:

  1. CPU 下达指令

    执行 HAL_UART_Receive_DMA(&huart1, (uint8_t *)RxBuf, 100),CPU 告诉 DMA:

    • 源地址 :串口的 RDR 寄存器(Receive Data Register,接收数据寄存器)。
    • 目标地址RxBuf(RAM 里的数组)。
    • 长度100(最多收 100 个字节)。
  2. DMA 自动搬数据

    串口收到数据,存到 RDR 寄存器 → DMA 自动把 RDR 的数据搬到 RxBuf

  3. 触发中断的两种情况

    • 收满 100 个字节 :触发 HAL_UART_RxCpltCallback,告诉 CPU"收满了,来处理"。
    • 收了 50 个字节(一半) :触发 HAL_UART_RxHalfCpltCallback,告诉 CPU"收了一半,可以提前处理"(可选)。
  4. 为啥需要 IDLE 中断

    如果对方发的数据 不足 100 个 (比如只发 30 个),DMA 不会触发 RxCpltCallback(因为没收满 100)。这时候需要 IDLE 中断

    • 串口 "空闲" 时(没数据来),触发 IDLE 中断,告诉 CPU"对方暂时不发了,你可以处理 RxBuf 里的 30 个数据"。

(6)关键函数详解(保姆级逐行讲)

1. HAL_UART_Transmit_DMA:启动 DMA 发送

复制代码
HAL_UART_Transmit_DMA(&huart1, (uint8_t *)str, strlen(str));
  • 参数 1&huart1 → 操作的串口(串口 1)。

  • 参数 2(uint8_t *)str → 要发的数据在 RAM 里的地址。

  • 参数 3strlen(str) → 要发的字节数(比如字符串长度)。

  • 底层干了啥(结合第二张图):

    1. 配置 DMA 的 源地址str目标地址huart1->Instance->TDR(串口 TDR 寄存器)。
    2. 配置 DMA 传输长度为 strlen(str)
    3. 启动 DMA 传输,开始自动搬数据。

2. HAL_UART_TxCpltCallback:发送完成回调

复制代码
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart1)
    {
        // 发送完成!可以在这里做后续操作
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);  // 比如翻转 LED
    }
}
  • 触发时机:DMA 把所有数据搬完(TDR 发完)后,HAL 库自动调用。
  • 作用:告诉 CPU "发送结束",可以执行 "发完后的逻辑"(比如再发一批、记录日志)。

3. HAL_UART_Receive_DMA:启动 DMA 接收

复制代码
uint8_t RxBuf[100];  // 存接收的数据
HAL_UART_Receive_DMA(&huart1, RxBuf, 100);
  • 参数 1&huart1 → 操作的串口(串口 1)。

  • 参数 2RxBuf → 数据接收后,存到 RAM 里的数组。

  • 参数 3100 → 最多收 100 个字节。

  • 底层干了啥(结合第三张图):

    1. 配置 DMA 的 源地址huart1->Instance->RDR(串口 RDR 寄存器)。
    2. 配置 DMA 的 目标地址RxBuf
    3. 配置 DMA 传输长度为 100
    4. 启动 DMA 传输,串口收到数据后,DMA 自动把 RDR 的数据搬到 RxBuf

4. HAL_UART_RxCpltCallback:接收完成回调(收满长度触发)

复制代码
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart1)
    {
        // 收满 100 个字节了!可以处理 RxBuf 里的数据
        // 比如打印、解析
        HAL_UART_Transmit(&huart1, RxBuf, 100, 1000);  // 把收到的发回去
    }
}
  • 触发时机 :DMA 把 RxBuf 收满 100 个字节后,自动调用。
  • 注意 :如果对方发的数据不足 100,不会触发这个回调!这时候需要配合 IDLE 中断(下文补讲)。

(7)接收的 "坑":DMA 接收需配合 IDLE 中断(初学者必看)

1. 问题:DMA 接收 "收不满长度,不触发回调"

比如你让 DMA 收 100 个字节,但对方只发 30 个。DMA 没收满 100,不会触发 HAL_UART_RxCpltCallback,主程序就 "不知道啥时候该处理这 30 个数据"。

五、STM32 USART 进阶:DMA + IDLE 中断收发保姆级教程(含代码修复)

(1)前言:为什么需要 DMA + IDLE 中断?

在 STM32 串口通信中,普通中断收发适合小数据量,但面对连续不定长数据时:

  • 纯中断:频繁触发中断,CPU 负担重
  • 纯 DMA:无法判断 "数据何时接收完成"(比如对方发 50 字节后突然停止)

DMA + IDLE 中断 完美解决这两个痛点:

  • DMA 自动搬运数据,解放 CPU
  • IDLE 中断精准识别 "数据传输暂停",让我们知道 "该处理已收数据了"

(2)核心问题拆解(老师代码的坑)

1. 问题 1:忘记使能接收通道

  • 现象:DMA 能发数据,但收不到任何内容
  • 原因:串口接收的 DMA 通道未正确使能,数据无法从串口寄存器搬运到内存

2. 问题 2:IDLE 中断后未重启 DMA

  • 现象:只能接收一次数据,之后无响应
  • 原因:IDLE 中断触发后,未重新启动 DMA 接收,导致后续数据无法被捕获

(3)DMA + IDLE 中断完整流程(从硬件到代码)

1. 硬件配置(CubeMX 关键步骤)

(1)串口配置
  • 模式:Asynchronous(异步模式)
  • 波特率:根据需求设置(如 115200)
  • 使能 DMA:
    • TX:DMA1 Channel 4Memory To Peripheral
    • RX:DMA1 Channel 5Peripheral To Memory
(2)NVIC 配置
  • 使能 USART1 global interrupt
  • 使能 DMA1 Channel 4/5 interrupt(可选,部分场景需 DMA 半传输 / 传输完成中断)

2. 代码流程拆解

(1)文件结构
  • usart.c:核心逻辑(DMA 收发、中断回调)
(2)usart.c 完整代码(修复版)
cs 复制代码
/* USER CODE BEGIN 1 */
// 发送完成标志(用于非阻塞发送时等待发送结束)
static volatile int  g_tx_cplt = 0;
// 接收完成标志(用于普通中断接收模式,此处DMA模式较少用)
static volatile int  g_rx_cplt = 0;
// 临时接收缓冲区(普通中断模式下存储单个字节)
static uint8_t g_c;
// DMA接收缓冲区(一次接收10个字节)
static uint8_t g_buf[10];
// 环形缓冲区的物理存储空间(用于存储所有接收到的数据)
static uint8_t g_recvbuf[100];
// 环形缓冲区控制结构(管理读写位置等信息)
static circle_buf g_uart1_rx_bufs;

/**
 * 发送完成回调函数(DMA模式)
 * 当DMA将所有数据从内存发送到串口后,由HAL库自动调用
 */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    // 标记发送完成(用于Wait_Tx_Complete函数判断)
    g_tx_cplt = 1;
    // 注意:此处注释提到"放入环形缓冲区",但发送完成无需操作缓冲区
    // 发送完成回调主要用于唤醒等待的任务或处理发送后逻辑
}

/**
 * 等待发送完成(阻塞函数)
 * 调用后会一直等待,直到DMA发送完成(g_tx_cplt被置1)
 */
void Wait_Tx_Complete(void)
{
    // 循环等待发送完成标志
    while (g_tx_cplt == 0);
    // 清除标志,为下一次发送做准备
    g_tx_cplt = 0;
}

/**
 * 接收完成回调函数(普通中断模式)
 * 当使用HAL_UART_Receive_IT时,每收到1个字节触发一次
 * 注意:在DMA+IDLE模式下,主要使用HAL_UARTEx_RxEventCallback
 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    // 标记接收完成(普通中断模式下使用)
    g_rx_cplt = 1;
    // 将收到的单个字节存入环形缓冲区
    for(int i = 0; i < 10; i++)
    {
        // 此处逻辑有误!普通中断每次只收1字节,应直接存g_c
        // 正确写法:cirble_buf_write(&g_uart1_rx_bufs, g_c);
        // 但代码中错误地循环写入g_buf(DMA缓冲区),可能导致数据异常
        cirble_buf_write(&g_uart1_rx_bufs, g_buf[i]);
    }
    
    // 重新启动接收中断(很重要!否则只能接收一次)
    // 注意:此处使用了错误的函数!在DMA模式下应使用HAL_UARTEx_ReceiveToIdle_DMA
    // 正确写法:HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);
    HAL_UART_Receive_IT(&huart1, &g_c, 1);
}

/**
 * 启动串口接收(初始化函数)
 * 配置环形缓冲区并开启接收中断
 */
void startuart1recv(void)
{
    // 初始化环形缓冲区(指定缓冲区大小和物理存储数组)
    circld_buf_init(&g_uart1_rx_bufs, 100, g_recvbuf);
    
    // 启动接收中断(注意:此处使用了普通中断模式!)
    // 正确写法:HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);
    HAL_UART_Receive_IT(&huart1, &g_c, 1);
}

/**
 * 从环形缓冲区读取一个字节
 * 返回0表示成功读取,非0表示缓冲区为空
 */
int UART1getchar(uint8_t *pVal)
{
    return circle_buf_read(&g_uart1_rx_bufs, pVal);
}

/**
 * IDLE事件+DMA接收回调函数(关键!)
 * 当DMA接收完成或串口空闲时触发
 */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    // 将DMA缓冲区中的数据存入环形缓冲区
    for(int i = 0; i < Size; i++)
    {
        cirble_buf_write(&g_uart1_rx_bufs, g_buf[i]);
    }
    
    // 重新启动DMA接收(关键!否则只能接收一次)
    // 注意:老师忘记在IDLE中断中调用此函数,导致只能接收一次数据
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);
}
/* USER CODE END 1 */

(4)核心逻辑分步详解(从 "启动" 到 "收数据")

1. 启动流程(startuart1recv 做了什么?

  • 环形缓冲区g_recvbuf 作为 "临时仓库",存零散收到的数据
  • DMA 配置 :告诉硬件 "把串口收到的数据,自动搬到 g_buf,一次搬 10 字节"

2. 接收流程(DMA + IDLE 如何配合?)

(1)正常接收(数据连续)

(2)触发 IDLE 事件(数据暂停

​​​​

  • 为什么要重启 DMA
    DMA 传输一旦完成(或触发 IDLE),会自动停止。必须重新调用 HAL_UARTEx_ReceiveToIdle_DMA,才能继续接收后续数据。

3. 发送流程(DMA 发送如何工作?)

(5)常见问题与解决方案(初学者必看)

1. 问题:DMA 接收后,环形缓冲区无数据

  • 原因
    • DMA 通道未正确使能(CubeMX 配置问题)
    • HAL_UARTEx_RxEventCallback 中未正确重启 DMA
  • 解决
    • 检查 CubeMX 的 DMA 配置(确保 USART1_RX 通道使能)
    • 确认 HAL_UARTEx_RxEventCallback 中调用了 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);

2. 问题:只能接收一次数据,之后无响应

  • 原因:IDLE 事件触发后,未重启 DMA 接收

  • 解决 :在 HAL_UARTEx_RxEventCallback 中添加重启代码

    复制代码
    HAL_UARTEx_ReceiveToIdle_DMA(&huart1, g_buf, 10);

3. 问题:IDLE 中断频繁触发

  • 原因:串口线干扰或波特率配置错误,导致误判 "空闲"
  • 解决
    • 检查硬件接线(确保 GND 共地,串口线无松动)
    • 重新校准波特率(确保收发双方波特率一致)

(6)总结:DMA + IDLE 中断的价值

  • 效率:DMA 自动搬运数据,CPU 可同时处理其他任务
  • 灵活性 :IDLE 事件精准识别 "数据暂停",完美支持不定长数据收发
  • 可靠性:环形缓冲区缓冲零散数据,避免丢包

这套方案是 STM32 串口通信的 "进阶标配",掌握后可轻松应对串口调试助手连续发数据、上位机批量传文件等场景!

六、完善UART程序与stdio(最终结果)

cs 复制代码
// 引入头文件:头文件相当于工具包,包含了各种已经写好的函数和定义,方便我们直接使用
#include "main.h"         // 主头文件,包含系统初始化、核心函数的定义
#include "dma.h"          // DMA相关工具:用于高速数据传输(不用CPU参与)
#include "i2c.h"          // I2C通信工具:用于和OLED等I2C设备通信
#include "usart.h"        // UART串口工具:用于串口收发数据(比如和电脑通信)
#include "gpio.h"         // GPIO工具:用于控制引脚高低电平(比如按键、LED)
#include "circle_buffer.h"// 环形缓冲区工具:一种特殊的存储结构,适合临时存数据
#include <stdio.h>        // 标准输入输出工具:包含printf、scanf等函数


// 定义一个"软定时器"结构体:用软件实现定时功能(类似闹钟,到时间了就做指定的事)
struct soft_timer
{
  uint32_t timeout;       // 超时时间点(单位:毫秒):记录"什么时候响铃"
	void * args;            // 回调参数:给定时任务传的数据(这里暂时不用)
	void (*func)(void *);   // 回调函数:超时后要执行的任务("响铃后要做的事")
};


// 声明外部变量:huart1是UART1的"句柄"(可以理解为UART1的身份证,操作它必须用这个句柄)
extern UART_HandleTypeDef huart1;

int key_cut = 0;  // 按键计数:记录按键被有效按下的次数

// 声明按键超时处理函数(后面会具体实现)
void key_timeout_func(void *args);

// 创建一个按键专用的软定时器:初始状态为"未激活"(timeout=~0表示无穷大)
struct soft_timer key_timer = {~0, NULL, key_timeout_func};

// 声明几个函数(先告诉编译器有这些函数,后面再实现)
void Wait_Tx_Complete(void);    // 等待串口发送完成
void Wait_Rx_Complete(void);    // 等待串口接收完成
void startuart1recv(void);      // 启动UART1接收功能
int UART1getchar(uint8_t *pVal);// 从UART1读取一个字符


// 环形缓冲区相关变量:用来临时存储按键数据(防止按键触发太快处理不过来)
static uint8_t g_data_buf[100];  // 实际存储数据的空间(可以存100个字节)
static circle_buf g_key_bufs;    // 环形缓冲区的管理结构(负责读写数据)


/**
 * 按键超时处理函数:软定时器到时间后执行(用于按键消抖后确认状态)
 * 为什么需要消抖?按键按下时金属触点会抖动(10ms内可能通断多次),10ms后再读才准确
 */
void key_timeout_func(void *args)
{
  uint8_t key_val;  // 存储按键的状态值(0x1表示按下,0x81表示松开)
  
  key_cut++;        // 按键计数+1(每有效触发一次,计数加1)
  key_timer.timeout = ~0;  // 重置定时器:处理完后暂时关闭,下次按键再激活
  
  // 读取GPIO_PIN_14引脚的电平(这个引脚接了按键)
  // GPIO_PIN_RESET表示低电平(按键按下,因为按键通常接下拉电阻)
  // GPIO_PIN_SET表示高电平(按键松开)
  if(HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_14) == GPIO_PIN_RESET)
    key_val = 0x1;   // 按键按下,存0x1
  else
    key_val = 0x81;  // 按键松开,存0x81(用不同值区分两种状态)
  
  // 把按键状态存入环形缓冲区(先存起来,后面慢慢处理)
  circle_buf_write(&g_key_bufs, key_val);
}


/**
 * 设置定时器超时时间(激活定时器)
 * @param pTimer:要设置的定时器
 * @param timeout:要等待的时间(单位:毫秒)
 */
void mod_timer(struct soft_timer *pTimer, uint32_t timeout)
{
  // HAL_GetTick():获取系统从启动到现在的毫秒数(比如启动后1秒,返回1000)
  // 超时时间点 = 当前时间 + 等待时间(比如现在1000ms,等10ms,超时点就是1010ms)
  pTimer->timeout = HAL_GetTick() + timeout;
}


/**
 * 检查定时器是否超时(软定时器的核心逻辑)
 * 相当于"看闹钟有没有响",需要在主循环里反复调用
 */
void check_timer(void)
{
  // 如果当前时间 >= 定时器的超时时间点,说明"闹钟响了"
  if(key_timer.timeout <= HAL_GetTick())
  {
    // 执行定时器绑定的任务(调用回调函数)
    key_timer.func(key_timer.args);
  }
}


/**
 * GPIO中断回调函数:当引脚电平变化时自动调用(比如按键按下时)
 * @param GPIO_Pin:触发中断的引脚编号
 */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
  // 判断是不是GPIO_PIN_14引脚触发的中断(我们的按键接在这个引脚)
  if(GPIO_Pin == GPIO_PIN_14)
  {
    // 激活按键定时器,10ms后执行超时处理(用于消抖)
    mod_timer(&key_timer, 10);
  }
}


/**
 * 声明系统时钟配置函数(由STM32CubeMX自动生成,负责设置CPU等的工作频率)
 */
void SystemClock_Config(void);


/**
 * 主函数:程序的入口,所有代码从这里开始执行
 * @retval int:返回值(嵌入式程序通常不返回,这里只是标准格式)
 */
int main(void)
{
  int len;  // 临时变量,用来存字符串长度
  // 定义要发送的字符串:\r\n是换行符(串口通信中换行需要这两个字符)
  char *str = "Please enter a char: \r\n";
  char *str2 = "www.100ask.net\r\n";
  char c;   // 用来存从串口接收的字符


  // 初始化HAL库:重置所有外设,初始化Flash和系统滴答定时器(用于延时)
  HAL_Init();

  // 配置系统时钟:设置CPU、外设的工作频率(比如72MHz)
  SystemClock_Config();

  // 初始化按键专用的环形缓冲区:指定大小100,用g_data_buf作为存储区
  circle_buf_init(&g_key_bufs, 100, g_data_buf);

  // 初始化各个外设(由STM32CubeMX自动生成)
  MX_GPIO_Init();    // 初始化GPIO(配置引脚功能)
  MX_DMA_Init();     // 初始化DMA
  MX_I2C1_Init();    // 初始化I2C1(用于OLED通信)
  MX_USART1_UART_Init();  // 初始化UART1(配置波特率等参数)

  // 初始化OLED屏幕并清屏
  OLED_Init();
  OLED_Clear();

  // 在OLED上显示固定文本:第一行显示"cnt     : "(用于显示按键计数)
  // 第三行显示"key val : "(用于显示按键状态值)
  OLED_PrintString(0, 0, "cnt     : ");
  len = OLED_PrintString(0, 2, "key val : ");

  // 通过UART1发送str2字符串到电脑:
  // 参数:UART句柄、要发的字符串、长度、超时时间(1000ms发不出去就放弃)
  HAL_UART_Transmit(&huart1, str2, strlen(str2), 1000);

  // 启动UART1的接收功能:让UART1准备好接收数据,收到后会触发中断
  startuart1recv();


  // 主循环:程序启动后会一直在这里循环执行(无限循环)
  while (1)
  {
    // 通过printf发送str2到串口(printf已被设置为通过UART1发送)
    printf("%s", str2);

    // 内层循环:等待用户输入一个有效字符(不是'r'也不是换行符)
    while(1)
    {
      // 从串口接收一个字符(scanf已被设置为通过UART1接收)
      scanf("%c", &c);

      // 如果接收的字符不是'r'也不是换行符('\n'),就处理并退出内层循环
      if(c != 'r' && c != '\n')
      {
        c = c + 1;  // 字符加1(比如输入'a'变成'b',输入'1'变成'2')
        printf("%c\r\n", c);  // 把处理后的字符发回串口
        break;  // 退出内层循环,回到外层循环重新等待输入
      }
    }
  }
}
cs 复制代码
// 发送/接收完成标志,使用volatile确保编译器不优化
static volatile int  g_tx_cplt=0;
static volatile int  g_rx_cplt=0;
// 临时存储接收数据的变量和缓冲区
static uint8_t g_c;
static uint8_t g_buf[10];
static uint8_t g_recvbuf[100];
// UART1接收缓冲区(环形缓冲区)
static circle_buf g_uart1_rx_bufs;

/**
 * UART发送完成回调函数
 * 当UART发送完成时,此函数会被自动调用
 */
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    if(huart == &huart1)
    {
        g_tx_cplt=1; // 设置发送完成标志
        // 注释中提到"放入环形缓冲区",但此处未实现
    }
}

/**
 * 等待UART发送完成
 * 阻塞函数,会一直等待直到发送完成
 */
void Wait_Tx_Complete(void)
{
    while (g_tx_cplt == 0 ); // 等待发送完成标志
    g_tx_cplt=0; // 清除标志,准备下一次发送
}

/**
 * UART接收完成回调函数
 * 当使用HAL_UART_Receive_IT()接收到指定数量的数据后,此函数会被调用
 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if(huart == &huart1)
    {
        g_rx_cplt=1; // 设置接收完成标志
        
        // 将接收到的数据(10字节)存入环形缓冲区
        for(int i=0;i<10;i++)
        {
            cirble_buf_write(&g_uart1_rx_bufs,g_buf[i]);
        }
        
        // 重新启动接收,准备接收下一组数据
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1,g_buf,10);
    }
}

/**
 * 启动UART1接收
 * 初始化环形缓冲区并开启接收中断
 */
void startuart1recv(void)
{
    // 初始化环形缓冲区,大小为100字节
    circld_buf_init(&g_uart1_rx_bufs,100,g_recvbuf);
    
    // 启动中断接收,每次接收1字节
    HAL_UART_Receive_IT(&huart1,&g_c,1);
}

/**
 * 从UART1接收缓冲区获取一个字符
 * 返回0表示成功获取,非0表示缓冲区为空
 */
int UART1getchar(uint8_t *pVal)
{
    return circle_buf_read(&g_uart1_rx_bufs,pVal);
}

/**
 * UART接收空闲回调函数
 * 当检测到UART接收线路空闲时,此函数会被调用
 */
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
    if(huart == &huart1)
    {
        // 将接收到的数据存入环形缓冲区
        for(int i=0;i<Size;i++)
        {
            cirble_buf_write(&g_uart1_rx_bufs,g_buf[i]);
        }
        
        // 重新启动接收,准备接收下一组数据
        HAL_UARTEx_ReceiveToIdle_DMA(&huart1,g_buf,10);
    }
}

// 回退处理相关变量
static int g_last_char;
static int g_backspace=0;

/**
 * 重定向fputc函数
 * 实现printf通过UART1发送数据
 */
int fputc(int ch,FILE* stream)
{
    HAL_UART_Transmit(&huart1,(const uint8_t *)&ch,1,10);
    return ch;
}

/**
 * 重定向fgetc函数
 * 实现scanf通过UART1接收数据
 */
int fgetc(FILE *f)
{
    int ch;
    if (g_backspace)
    {
        g_backspace=0;
        return g_last_char; // 返回上一个字符(回退功能)
    }
    
    // 阻塞等待,直到从环形缓冲区读取到数据
    while(0 != UART1getchar((uint8_t *)&ch));
    
    g_last_char = ch; // 保存当前字符,用于回退功能
    return ch;
}

/**
 * 实现回退功能
 * 允许程序"撤销"上一次读取的字符
 */
int __backspace(FILE *stream)
{
    g_backspace = 1;
    return 0;
}
相关推荐
自激振荡器1 小时前
8,FreeRTOS时间片调度
stm32·单片机·嵌入式硬件·freertos
is08152 小时前
STM32 USB 设备中间件 tinyusb
stm32·嵌入式硬件·中间件
爱煲汤的夏二2 小时前
扩展卡尔曼滤波器 (EKF) 与无人机三维姿态估计:从理论到实践
单片机·嵌入式硬件·算法·无人机
bubiyoushang8882 小时前
基于C#的CAN通讯接口程序
stm32·单片机·c#
sakabu2 小时前
ESP32 外设驱动开发指南 (ESP-IDF框架)——GPIO篇:基础配置、外部中断与PWM(LEDC模块)应用
笔记·单片机·学习·esp32
是孑然呀4 小时前
【笔记】重学单片机(51)
笔记·单片机·嵌入式硬件
武晓兵6 小时前
51单片机和 STM32 有何区别
单片机
CC呢6 小时前
基于单片机胎压检测/锅炉蒸汽压力/气压检测系统
单片机·嵌入式硬件·胎压检测·空气压力
MingYue_SSS7 小时前
【未解决】STM32无刷电机驱动电路问题记录
笔记·嵌入式硬件·学习
安庆平.Я9 小时前
STM32——HAL 库MDK工程创建
stm32·单片机·嵌入式硬件