当前位置: 首页 > news >正文

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

<1>前置概念补充

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

功能查询模式中断模式DMA 模式
发送HAL_UART_TransmitHAL_UART_Transmit_IT
HAL_UART_TxCpltCallback
HAL_UART_Transmit_DMA
HAL_UART_TxHalfCpltCallback
HAL_UART_TxCpltCallback
接收HAL_UART_ReceiveHAL_UART_Receive_IT
HAL_UART_RxCpltCallback
HAL_UART_Receive_DMA
HAL_UART_RxHalfCpltCallback
HAL_UART_RxCpltCallback
错误处理-HAL_UART_ErrorCallbackHAL_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

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)代码角色分工:谁在干啥?

// 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数组存数据,大小100circld_buf_init(&g_uart1_rx_bufs, 100, g_recvbuf);  // 2. 使能接收中断:收到1个字节就触发HAL_UART_RxCpltCallbackHAL_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. 主程序取数据:从 “仓库” 拿数据处理
// 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;elsekey_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(发完触发);接收同理。

/发送
HAL_UART_Transmit_DMA
HAL_UART_TxHalfCpltCallback
HAL_UART_TxCpltCallback
//接收
HAL_UART_Receive_DMA
HAL_UART_RxHalfCpltCallback
HAL_UART_RxCpltCallback
//错误回调
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_TxCpltCallbackDMA 把数据全部发完后触发通知 CPU “发送完成”
HAL_UART_Receive_DMA主程序调用,启动 “DMA 接收”告诉 DMA 开始收数据
HAL_UART_RxCpltCallbackDMA 把数据全部收满后触发通知 CPU “收满指定长度了”
HAL_UART_RxHalfCpltCallbackDMA 收了一半数据后触发(可选)收一半时提前处理数据
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 完整代码(修复版)
/* 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(最终结果)

// 引入头文件:头文件相当于工具包,包含了各种已经写好的函数和定义,方便我们直接使用
#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;   // 按键按下,存0x1elsekey_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();     // 初始化DMAMX_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;  // 退出内层循环,回到外层循环重新等待输入}}}
}
// 发送/接收完成标志,使用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;
}

 

http://www.dtcms.com/a/306387.html

相关文章:

  • luoguP13511 [KOI P13511 [KOI 2025 #1] 等腰直角三角形
  • S3、SFTP、FTP、FTPS 协议的概念、对比与应用场景
  • vulhub ica1靶场攻略
  • AI框架工具FastRTC快速上手2——整体框架及Stream类详解
  • 浏览器pdf、image显示
  • MaxKB+MinerU:通过API实现PDF文档解析并存储至知识库
  • 虚幻基础:旋转体
  • 在java开发中,错误信息类中定义一个errMap,为什么要在static{}中,put键值对?这是为什么?好处是什么?
  • 嵌入式 C 语言入门:分支结构(if/switch)的用法与硬件控制实践
  • [ java IO ] 文件传输中的输入输出(流)
  • 算法能力提升之快速矩阵
  • PSO-TCN-BiLSTM-MATT粒子群优化算法优化时间卷积神经网络-双向长短期记忆神经网络融合多头注意力机制多特征分类预测/故障诊断Matlab实现
  • 电动车充电桩能耗实时监测解决方案
  • 【Java】批量生成Excel放入文件夹并打zip压缩包
  • LangChain 完全入门:5分钟搭建你的第一个AI智能体
  • 河南萌新联赛2025第(三)场:河南理工大学【补题】
  • 氯碱废水除钙镁金属离子
  • 无人机在复杂气流中,IMU 如何精准捕捉姿态变化以维持稳定?
  • WPFC#超市管理系统(3)商品管理
  • 今日行情明日机会——20250730
  • 【LeetCode】链表反转实现与测试
  • ansible巡检脚本
  • 2025年7月28日–7月29日 · AI 今日头条
  • 串口接收数据包(协议带帧头帧尾)的编程实现方法:1、数据包格式定义结构体2、使用队列进行数据接收、校验解包
  • centos7 aarch64上安装PostgreSQL14.3
  • 如何在生成式引擎优化(GEO)中取得成功
  • Java:高频面试知识分享1
  • 比特币挖矿的能源消耗和环保问题
  • 【Linux】重生之从零开始学习运维之备份恢复
  • CONTRASTIVE-KAN:一种用于稀缺标记数据的网络安全半监督入侵检测框架