【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驱动程序
文件夹),步骤如下:
- 解压
8_CH340_CH341驱动程序
,找到并运行CH341SER.EXE
,按提示完成安装。 - 插入 USB 串口模块,打开 设备管理器,查看 “端口(COM 和 LPT)” 列表。若出现类似
USB-SERIAL CH340 (COMxx)
(如 COM38),说明驱动安装成功。
(4)串口助手:收发数据的 “窗口”
我们用 sscom5.13.1
收发数据,操作步骤:
- 解压运行
sscom5.13.1.exe
,在 “通讯端口” 选择识别到的串口(如 COM38 )。 - 设置 波特率为 115200(必须与代码配置一致!),数据位 8、停止位 1、无校验。
- 点击 “打开串口”,即可开始收发数据。
(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 次接收(1
、2
、3
),但代码逻辑未处理多字节连续输入,导致 “输入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 一直忙等,无法做其他事”。后续会扩展:
- 中断方式:数据到来自动触发,CPU 可并行处理其他任务(适合实时性高的场景 )。
- 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 串口中断相关寄存器
- 状态寄存器(USART_SR)
- TXE(Transmit Data Register Empty)位:当发送数据寄存器为空时,该位被置 1。这意味着可以往发送数据寄存器中写入新的数据了。在中断模式下,我们可以使能 TXE 中断,当 TXE 位置 1 时触发中断,去发送下一个数据。
- TC(Transmission Complete)位:当一帧数据发送完成时,该位被置 1。可以利用 TC 中断来判断一次发送是否完全结束。
- RXNE(Read Data Register Not Empty)位:当接收数据寄存器中有数据时,该位被置 1,可使能 RXNE 中断来触发接收操作。
- 控制寄存器(USART_CR1)
- TXEIE 位:用于使能 TXE 中断。当该位被置 1,且 TXE 位置 1 时,会触发串口发送中断。
- TCIE 位:用于使能 TC 中断。当该位被置 1,且 TC 位置 1 时,会触发串口发送完成中断。
- RXNEIE 位:用于使能 RXNE 中断。当该位被置 1,且 RXNE 位置 1 时,会触发串口接收中断。
(二)串口中断的硬件触发流程
USART1 等外设可以通过 DMA 请求与系统进行数据交互,同时也可以通过中断的方式。当使能了串口的某个中断(如 TXE 中断)后:
- 当发送数据寄存器为空(TXE=1)且 TXEIE=1 时,硬件会触发中断请求,这个请求会通过总线矩阵等到达 CPU 的中断控制器。
- CPU 响应中断后,会跳转到对应的中断处理函数(如 USART1_IRQHandler)去执行相应的操作。
- 对于接收来说,当接收数据寄存器非空(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;
}
- TXE 中断处理:当检测到状态寄存器中的 TXE 位为 1(发送数据寄存器为空),并且控制寄存器中的 TXEIE 位为 1(使能了 TXE 中断)时,会调用
UART_Transmit_IT(huart)
函数,这个函数会去处理发送过程中的数据填充等操作,继续发送下一个数据。 - 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;}
}
- 函数作用:这个函数用于以中断模式启动串口发送。首先检查串口当前状态是否为
HAL_UART_STATE_READY
,如果是,就对串口句柄中的发送缓冲区指针、发送数据大小、发送计数器等进行初始化,然后将串口的状态设置为HAL_UART_STATE_BUSY_TX
表示正在发送,最后使能 TXE 中断(通过__HAL_UART_ENABLE_IT(huart, UART_IT_TXE)
),这样当发送数据寄存器为空时就会触发中断,进入发送中断处理流程。 - 参数解析:
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;}
}
- 数据发送处理:当进入这个函数时,首先检查串口是否处于
HAL_UART_STATE_BUSY_TX
状态(即正在发送过程中)。然后根据串口配置的字长和校验位情况,从发送缓冲区中取出数据写入到串口的数据寄存器(huart->Instance->DR
)中。如果是 9 位数据且无校验,就按 16 位处理;否则按 8 位处理。 - 中断切换:每发送一个数据,发送计数器
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);
}
- 发送流程:在主循环中,首先调用
HAL_UART_Transmit_IT(&huart1,str2,strlen(str2))
以中断模式发送字符串str2
,然后调用Wait_Tx_Complete()
等待发送完成(通过检查g_tx_cplt
)。 - 接收流程:发送完成后,调用
HAL_UART_Receive(&huart1,&c,1,100)
以查询方式接收一个字节的数据(这里也可以改为中断接收方式,后续优化方向),接收完成后对数据进行简单处理(c=c+1
),然后再用查询方式发送回传数据和换行符。
(6)中断模式优缺点与适用场景
(一)优点
- 效率高:不需要像查询方式那样一直占用 CPU 去等待串口状态,CPU 可以在等待串口中断的时间去处理其他任务,比如进行按键扫描、传感器数据处理等,提高了系统的整体效率。
- 实时性好:当串口有数据到来或者发送完成时能及时触发中断进行处理,对于一些对实时性要求较高的应用场景(如工业控制中的快速指令响应)非常合适。
(二)缺点
- 代码复杂度高:相比查询方式,中断模式需要处理中断函数、回调函数、中断使能与禁用、中断标志判断等,代码逻辑相对复杂,对于初学者来说理解和调试难度较大。
- 资源占用:虽然 CPU 利用率提高了,但中断本身也会带来一定的开销,比如中断上下文切换等,如果中断过于频繁,也可能会影响系统性能。
(三)适用场景
适用于对实时性要求较高、CPU 需要同时处理多个任务的场景,比如多传感器数据实时上传、工业设备的远程控制指令接收与响应等。而对于一些简单的、对实时性要求不高的小项目,查询方式可能因为代码简单更容易实现。
(7)常见问题与调试方法
(一)中断不触发问题
- 可能原因:
- 1.中断使能不正确:比如在
HAL_UART_Transmit_IT
中没有正确使能 TXE 中断,或者在HAL_UART_IRQHandler
中相关中断源的使能和标志判断有问题。 - 2.串口配置错误:在 STM32CubeMX 中配置串口时,没有正确使能对应的中断(如在 NVIC 设置中没有使能 USART1 的中断)。
- 3.全局中断未使能:即使串口的中断使能了,但如果 CPU 的全局中断没有使能(比如没有调用
__enable_irq()
函数,不过在 HAL 库中一般会自动处理,但也可能因为某些配置被关闭),也无法响应中断。
- 调试方法:
- 检查 CubeMX 配置:查看串口的中断是否在 NVIC 中正确使能,优先级是否设置合理。
- 检查代码中的中断使能函数:在
HAL_UART_Transmit_IT
中查看__HAL_UART_ENABLE_IT
是否正确调用,使能的中断类型是否正确。 - 在中断处理函数入口加断点:在
USART1_IRQHandler
函数中加断点,看是否能进入中断处理函数,如果进不去,说明中断触发有问题;如果能进去,再逐步检查HAL_UART_IRQHandler
中的逻辑。
(二)数据发送不完整或错误
- 可能原因:
- 发送缓冲区处理问题:在
UART_Transmit_IT
中,数据从缓冲区取出和指针移动的逻辑有错误,导致数据没有正确发送或者发送了错误的数据。 - 中断切换逻辑问题:TXE 中断和 TC 中断的切换没有正确处理,导致数据发送到一半就停止或者发送完成后没有正确标记状态。
- 数据长度设置错误:在调用
HAL_UART_Transmit_IT
时,strlen(str2)
计算的长度不正确,导致发送的数据长度错误。
- 发送缓冲区处理问题:在
- 调试方法:
- 在
UART_Transmit_IT
函数中加断点,检查每次从缓冲区取出的数据是否正确,指针移动是否正确。 - 检查中断切换时的计数器
TxXferCount
,看其递减是否正确,以及中断使能和禁用是否在正确的时机。 - 打印发送的数据长度和实际发送的数据(可以通过串口助手配合,或者在代码中使用调试串口打印中间变量),检查数据是否正确。
- 在
(三)回调函数不执行
- 可能原因:
- 没有正确实现回调函数:在 HAL 库中,回调函数需要按照规定的名称和参数实现,比如
HAL_UART_TxCpltCallback
,如果函数名写错或者参数不匹配,就不会被正确调用。 - 中断没有正确触发到发送完成阶段:可能在数据发送过程中出现了错误,导致没有触发 TC 中断,所以回调函数不会执行。
- 没有正确实现回调函数:在 HAL 库中,回调函数需要按照规定的名称和参数实现,比如
- 调试方法:
- 检查回调函数名称和参数是否正确。
三、中断改造方法
(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 个字节(比如
'A'
),自动触发这个函数。 - 存数据到环形缓冲区(对应
cirble_buf_write(&g_uart1_rx_bufs,g_c);
):g_c
里存着刚收到的字节(比如'A'
的 ASCII 码)。- 调用
cirble_buf_write
,就像 “管理员把刚收到的快递(数据)放进仓库货架(数组g_recvbuf
)”。
- 重新开启中断(
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));
,它的作用是 “一直从仓库里取数据”,流程:
- 调用
UART1getchar(&c)
(对应return circle_buf_read(&g_uart1_rx_bufs,pVal);
):- 这是 “取快递的人” 去仓库找数据。
circle_buf_read
会检查环形缓冲区里有没有数据。 - 如果有数据,就把数据放到
c
里(比如刚才存的'A'
);如果没数据,就等下次再取。
- 这是 “取快递的人” 去仓库找数据。
- 主程序处理数据(比如
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)环形缓冲区核心逻辑(白话版)
很多同学不懂 “环形缓冲区” 咋循环存数据,用 “快递货架” 比喻 讲清楚:
存数据(
cirble_buf_write
):- 货架有 100 个格子(
g_recvbuf[100]
),管理员存数据时,按顺序往后放。 - 存满了怎么办?环形 的关键:从最后一个格子跳回第一个格子继续存(像操场跑圈),这样不用清空数组,反复利用空间。
- 货架有 100 个格子(
取数据(
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)总结:完整流程串起来
- 初始化:建环形缓冲区仓库,打开串口中断监听。
- 收数据:串口收到数据 → 触发中断回调 → 数据存进环形缓冲区 → 重新开启中断,等下一个数据。
- 取数据:主程序循环从环形缓冲区取数据,处理后可以再发回去。
- 发数据:用中断方式后台发送,主程序不用阻塞等待,发完再继续干活。
这样配合,就能实现 “串口收发数据不丢失”,不管数据来多快、主程序多忙,都能稳稳接住~
四、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 发送:
- CPU 告诉 DMA:“我要发数组
str
,长度strlen(str)
,目标是串口 TDR 寄存器”。 - DMA 自动循环搬数据,全部搬完后触发
HAL_UART_TxCpltCallback
中断,告诉 CPU “发完了”。
- CPU 告诉 DMA:“我要发数组
2. 接收改造:HAL_UART_Receive_DMA
+ (可选 IDLE 中断)
- DMA 接收:
- CPU 告诉 DMA:“我要收数据,存到数组
RxBuf
,最多收len
个字节”。 - DMA 自动把串口收到的数据搬到
RxBuf
,收满len
个 触发HAL_UART_RxCpltCallback
;收一半 触发HAL_UART_RxHalfCpltCallback
。
- CPU 告诉 DMA:“我要收数据,存到数组
- 为啥需要 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
)
CPU 下达指令:
执行HAL_UART_Transmit_DMA(&huart1, str, len)
,CPU 告诉 DMA:- 源地址:
str
(RAM 里的字符串数组)。 - 目标地址:串口的 TDR 寄存器(Transmit Data Register,发送数据寄存器)。
- 长度:
len
(要发多少个字节)。
- 源地址:
DMA 自动搬数据:
DMA 控制器开始工作,逐个把str
里的字节,从 RAM 搬到 USART 的 TDR。- 这一步 不需要 CPU 参与!CPU 可以去干别的(比如
while(1)
里的按键扫描)。
- 这一步 不需要 CPU 参与!CPU 可以去干别的(比如
发送完成触发中断:
DMA 把len
个字节全部搬完后,触发HAL_UART_TxCpltCallback
中断。- CPU 暂停当前任务,执行回调函数里的逻辑(比如 “再发一次数据”“翻转 LED”)。
2. 接收流程( USART → DMA → RAM
)
假设要收数据到数组 RxBuf[100]
,流程:
CPU 下达指令:
执行HAL_UART_Receive_DMA(&huart1, (uint8_t *)RxBuf, 100)
,CPU 告诉 DMA:- 源地址:串口的 RDR 寄存器(Receive Data Register,接收数据寄存器)。
- 目标地址:
RxBuf
(RAM 里的数组)。 - 长度:
100
(最多收 100 个字节)。
DMA 自动搬数据:
串口收到数据,存到 RDR 寄存器 → DMA 自动把 RDR 的数据搬到RxBuf
。触发中断的两种情况:
- 收满 100 个字节:触发
HAL_UART_RxCpltCallback
,告诉 CPU“收满了,来处理”。 - 收了 50 个字节(一半):触发
HAL_UART_RxHalfCpltCallback
,告诉 CPU“收了一半,可以提前处理”(可选)。
- 收满 100 个字节:触发
为啥需要 IDLE 中断?
如果对方发的数据 不足 100 个(比如只发 30 个),DMA 不会触发RxCpltCallback
(因为没收满 100)。这时候需要 IDLE 中断:- 串口 “空闲” 时(没数据来),触发 IDLE 中断,告诉 CPU“对方暂时不发了,你可以处理
RxBuf
里的 30 个数据”。
- 串口 “空闲” 时(没数据来),触发 IDLE 中断,告诉 CPU“对方暂时不发了,你可以处理
(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 里的地址。参数 3:
strlen(str)
→ 要发的字节数(比如字符串长度)。底层干了啥(结合第二张图):
- 配置 DMA 的 源地址 为
str
,目标地址 为huart1->Instance->TDR
(串口 TDR 寄存器)。 - 配置 DMA 传输长度为
strlen(str)
。 - 启动 DMA 传输,开始自动搬数据。
- 配置 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)。参数 2:
RxBuf
→ 数据接收后,存到 RAM 里的数组。参数 3:
100
→ 最多收 100 个字节。底层干了啥(结合第三张图):
- 配置 DMA 的 源地址 为
huart1->Instance->RDR
(串口 RDR 寄存器)。 - 配置 DMA 的 目标地址 为
RxBuf
。 - 配置 DMA 传输长度为
100
。 - 启动 DMA 传输,串口收到数据后,DMA 自动把 RDR 的数据搬到
RxBuf
。
- 配置 DMA 的 源地址 为
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 4
(Memory To Peripheral
) - RX:
DMA1 Channel 5
(Peripheral To Memory
)
- TX:
(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);
- 检查 CubeMX 的 DMA 配置(确保
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;
}