第六讲Uart模块
本讲总览
这一讲主要解决三个问题:
-
UART 到底是什么,为什么 TX、RX、波特率这些东西必须配对。
-
怎么把串口先用起来,最基础的是能发、能收。
-
收数据时怎么判断“一帧结束了”,以及为什么后面要学 DMA、空闲中断、环形缓冲区。
这篇笔记保留课堂里的原代码思路,不改你上课时的写法,重点补两类内容:
-
这行代码是什么意思
-
为什么课堂上要这样写
复习时先抓住 4 个结论:
-
UART 先看接线和参数是否对上,再看代码,不然程序写得再对也可能全是乱码。
-
串口发送本质上是在发一串字节;串口接收真正难的地方,往往是“怎么判断这一帧结束了”。
-
中断接收更适合先把流程吃透,
DMA + 空闲中断更适合后面把接收做得更省 CPU。 -
后面学环形缓冲区和协议解析,不是在推翻前面的 UART,而是在解决“连续字节流怎么稳定处理”的问题。
串行 vs. 并行
想象一下排队过安检:
-
串行 (Serial): 就像大家一个接一个排队通过安检门,每次只能过一个人(一个比特)。UART 就是这种方式,数据一位一位地在单根线上发送。**优点:**省线,适合长距离;**缺点:**速度相对较慢。
-
并行 (Parallel): 就像同时开了好几个安检门,大家可以同时通过。数据同时在多根线上发送。**优点:**速度快;**缺点:**需要更多线,成本高,不适合长距离。

复习结论
-
UART 是串行通信。
-
UART 不是靠线多取胜,而是靠简单、通用、接线少取胜。
波特率
波特率可以理解成双方“说话的速度”。
-
你说得快,对方也要按同样速度听。
-
你说得慢,对方也要按同样速度采样。
如果两边波特率不一致,最常见结果就是:
-
收到乱码
-
串口助手显示一堆看不懂的字符
常见波特率有:
-
9600 -
115200
复习结论
-
波特率就是串口每秒传多少位。
-
调串口第一件事先查:两端波特率是否一致。
数据帧
UART 传输不是“直接把数据扔出去”,而是按帧发送。
一个典型的数据帧一般包含:
-
起始位
-
数据位
-
校验位(可选)
-
停止位
最常见配置就是:
-
8 位数据位
-
无校验
-
1 位停止位
也就是常说的 8N1。
复习结论
-
通信双方不只波特率要一致,数据位、校验位、停止位也要一致。
-
不一致时,不是“偶尔错”,而是底层解析方式就不一样。
TX 和 RX
TX 要连接对方的 RX,RX 要连接对方的 TX,交叉连接,就像打电话一样,你的话筒对着别人的听筒。
通常还需要一根 GND (Ground) 线,即地线,用来统一双方的参考电平,确保信号稳定。
复习结论
-
TX -> 对方 RX -
RX -> 对方 TX -
GND -> 对方 GND
USB 转 TTL 是什么
电脑一般没有直接给你留单片机那种串口针脚,所以需要中间模块转换:
-
一边是电脑 USB
-
一边是串口
TXD / RXD / GND
这个模块就叫 USB 转 TTL。
它的作用是:
-
让电脑能和单片机的串口直接通信
-
方便你用串口助手看输出、发命令
复习结论
-
USB和TTL不是一回事,中间必须靠转换模块衔接。 -
你平时电脑上看到的串口助手,本质上就是通过这个转换模块在和单片机通信。

CH340 是什么芯片
CH340 是常见的 USB 转串口芯片。
你可以把它理解成:
-
电脑这边它认识 USB
-
单片机这边它输出串口信号
很多开发板和串口模块上都能见到它。
复习结论
-
CH340 最常见的身份就是:
USB 转串口芯片。 -
你调试串口时,电脑能识别出一个串口号,很多时候背后就是这类芯片在工作。



CubeMX 里最基础的 UART 配置
先把对应串口引脚配置出来,避免后面把引脚功能和复用关系看混。

设置波特率。

这里最少要关注这些:
-
选的是哪个串口,比如
USART1 -
TX/RX 引脚有没有选对
-
波特率是多少,比如
115200 -
停止位、校验位、数据位这些参数
复习结论
-
初学阶段最常用就是
115200 + 8N1 -
能发不能收时,除了接线,也要查:
-
UART 引脚复用
-
中断是否使能
-
DMA 是否配到了正确串口
-
串口发送:my_printf
需要引用的几个头文件:
#include "usart_app.h"
课堂原代码:
int my_printf(UART_HandleTypeDef *huart, const char *format, ...)
{
char buffer[512]; // 临时存储格式化后的字符串
va_list arg; // 处理可变参数
int len; // 最终字符串长度
va_start(arg, format);
// 安全地格式化字符串到 buffer
len = vsnprintf(buffer, sizeof(buffer), format, arg);
va_end(arg);
// 通过 HAL 库发送 buffer 中的内容
HAL_UART_Transmit(huart, (uint8_t *)buffer, (uint16_t)len, 0xFF);
return len;
}
逐行理解
int my_printf(UART_HandleTypeDef *huart, const char *format, ...)
这行的意思:
-
这是一个“像
printf一样”的函数。 -
huart表示你要往哪个串口发。 -
format表示格式字符串,比如"temp=%d\n"。 -
...表示后面可以继续跟可变参数。
为什么这么写:
-
因为普通
HAL_UART_Transmit只能发一段已经准备好的数据。 -
但我们平时调试更习惯写
printf("x=%d", x)这种形式。 -
所以这里自己封装了一个“串口版 printf”。
char buffer[512];
这行的意思:
- 先准备一个字符数组,用来暂时存放格式化后的完整字符串。
为什么这么写:
-
因为
HAL_UART_Transmit发送前,必须先有一块已经组织好的连续数据。 -
所以先把内容拼到
buffer里,再统一发出去。
va_list arg;
这行的意思:
- 用来接收可变参数。
为什么这么写:
-
因为这个函数后面参数个数不固定,比如:
-
my_printf(&huart1, "hello\n"); -
my_printf(&huart1, "x=%d\n", x);
-
-
所以需要
va_list这套机制来处理。
int len;
这行的意思:
- 用来记录最终格式化出来的字符串长度。
为什么这么写:
- 串口发送时要知道到底发送多少字节。
va_start(arg, format);
这行的意思:
- 让
arg开始接管format后面的那些可变参数。
为什么这么写:
- 不调用它,后面的
vsnprintf就拿不到那些参数。
len = vsnprintf(buffer, sizeof(buffer), format, arg);
这行的意思:
-
按
format的格式,把后面的参数拼成完整字符串,写入buffer。 -
返回值存在
len里。
为什么这么写:
-
这是整段代码最核心的一步。
-
它把“格式字符串 + 参数”变成真正可发送的数据。
举例:
my_printf(&huart1, "temp = %d\n", 25);
执行到这里后,buffer 里就会变成:
temp = 25
va_end(arg);
这行的意思:
- 可变参数处理结束。
为什么这么写:
- 这是
va_list这一套的标准收尾动作。
HAL_UART_Transmit(huart, (uint8_t *)buffer, (uint16_t)len, 0xFF);
这行的意思:
- 把
buffer里的前len个字节,通过huart指定的串口发送出去。
为什么这么写:
-
前面已经把内容组织好了,这里就是正式发出去。
-
这里的
0xFF可以先理解成一个发送超时值,课堂里这样写的重点是先把发送流程跑通。
return len;
这行的意思:
- 返回这次最终发了多少字节。
为什么这么写:
- 方便以后调试,或者上层如果想知道发送长度也能拿到。
这段代码整体在做什么
你可以把它理解成两步:
-
先把你想说的话整理成字符串
-
再通过串口发出去
根据不同的 usart 接口,UART_HandleTypeDef *huart 是需要替换的。
这里还要补一句很实用的话:
-
这套
my_printf更适合打印调试信息、字符串和简单格式化输出。 -
如果你后面要发的是二进制协议、图片数据、定长字节流,就不要把它和“字符串打印”完全混成一件事。

为什么要勾选微库
为什么要勾选微库(MicroLIB):
-
单片机工程资源有限
-
微库更轻
-
对我们这种主要做串口打印、基础格式化输出的小工程更合适
这里和 my_printf 的关系
课堂这里讲微库,主要是因为:
-
my_printf用到了vsnprintf -
这类格式化函数背后依赖标准库的一部分支持
所以这里是在提醒你:
-
工程配置也会影响代码是否能正常跑
-
不是“代码写对了”就一定没问题
超时解析
超时解析法的核心思想:
如果两个字节之间停顿时间超过阈值,就认为上一帧结束。
利用数据传输的间歇时间来判断。如果两个字节之间的时间间隔超过某个阈值(比如 10ms),就认为上一帧数据已经结束了。
这套方法最适合的场景是:
-
一帧一帧地发
-
帧和帧之间有明显停顿
-
现在主要目标是先把 UART 接收流程看懂
基本步骤
-
设置一个接收缓冲区,用来存放收到的字节。
-
启动 UART 接收,通常使用中断方式。
-
在中断服务函数里:
-
将收到的字节存入缓冲区。
-
记录当前收到字节的时间。
-
再次启动下一次接收。
-
-
在主循环或定时任务里,不断检查是否超时。
-
如果超时并且缓冲区里有数据,就说明一帧收完了,可以处理。
数组如果不够大,数据又太长怎么办,这就是后面要引出缓冲区管理问题的原因。

超时解析代码
第一次接收需要手动启动。通常在 main 函数初始化代码中或者 usart 的函数中,调用一次:
HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[0], 1);
这句的作用是:
-
先把第一次接收“挂上去”
-
相当于第一次告诉硬件:我准备好收第 1 个字节了
代码执行流程图解
如果你以后做项目时想顺着代码查,这套超时解析可以按下面顺序去理解:
main()或初始化函数里先执行一次:
HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[0], 1);
这一步的意义不是“已经收到数据了”,而是:
- 告诉 HAL:开始准备接收第 1 个字节
- 外部设备通过串口发来第 1 个字节
-
HAL 把这个字节放进
uart_rx_buffer[0] -
然后进入
HAL_UART_RxCpltCallback()
- 在回调函数里做 3 件事
-
更新时间
uart_rx_ticks -
让
uart_rx_index++ -
再次调用
HAL_UART_Receive_IT(...),准备接第 2 个字节
- 外部设备继续发第 2、3、4…个字节
-
每来一个字节,都重复上面的流程
-
所以缓冲区里的数据越来越多
-
uart_rx_index也越来越大
- 只要发送方还在持续发数据,
uart_rx_ticks就会不断刷新
- 所以主循环里的超时判断一直不会成立
- 当发送方停下来后
-
一段时间内没有新字节到来
-
这时
uwTick - uart_rx_ticks > UART_TIMEOUT_MS就成立
- 一旦超时成立
-
uart_task()就把当前缓冲区里的内容当成“一整帧” -
打印
-
处理
-
清空
-
把接收起点重新拉回头部
项目里你可以怎么排查这套流程
如果你以后做项目时这套跑不起来,可以按这个顺序查:
-
初始化里有没有先调第一次
HAL_UART_Receive_IT -
串口中断有没有开
-
HAL_UART_RxCpltCallback()有没有进 -
uart_rx_index有没有一直增长 -
uart_rx_ticks有没有刷新 -
发送停止后,
uart_task()里的超时判断有没有成立
中断回调函数
课堂原代码:
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
// 1. 核对身份:是 USART1 的快递员吗?
if (huart->Instance == USART1)
{
// 2. 更新收货时间:记录下当前时间
uart_rx_ticks = uwTick;
// 3. 货物入库:将收到的字节放入缓冲区(HAL库已自动完成)
// 并增加计数器
// (注意:实际入库由 HAL_UART_Receive_IT 触发,这里只更新计数)
uart_rx_index++;
// 4. 准备下次收货:再次告诉硬件,我还想收一个字节
HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[uart_rx_index], 1);
}
}
逐行理解
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
这行的意思:
-
这是 HAL 库定义好的串口接收完成回调函数。
-
当一次
HAL_UART_Receive_IT(..., 1)收到 1 个字节后,就会进到这里。
为什么这么写:
-
因为课堂这里用的是中断接收模式。
-
所以每收到 1 个字节,系统就会“通知你一次”。
if (huart->Instance == USART1)
这行的意思:
- 先确认这次进回调的是不是
USART1。
为什么这么写:
-
因为工程里可能不止一个串口。
-
这样写是为了防止别的串口也进到这里时误处理。
uart_rx_ticks = uwTick;
这行的意思:
- 记录“最后一次收到字节的时间”。
为什么这么写:
-
后面
uart_task()要靠这个时间去判断有没有超时。 -
所以每来一个新字节,都要刷新一次时间。
uart_rx_index++;
这行的意思:
- 表示当前已经收到的数据长度加 1。
为什么这么写:
- 因为这套课堂代码里,真正把字节放进缓冲区这件事,已经由:
HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[uart_rx_index], 1);
提前安排好了。
-
也就是说,HAL 收到这个字节时,已经把它放到
uart_rx_buffer[uart_rx_index]指向的位置。 -
所以这里不需要再手动赋值,只需要把索引往后挪一位。
这是这段代码最关键、也最容易卡住理解的一点。
HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[uart_rx_index], 1);
这行的意思:
-
再次启动下一次接收。
-
并且告诉 HAL:下一个字节请放到
uart_rx_buffer[uart_rx_index]这个位置。
为什么这么写:
-
因为中断接收不是“自动一直收”。
-
这次收完,如果你不再调用一次,它后面就不会继续收了。
这句的本质是:
- 把下一次接收预约好
这段回调整体在做什么
可以概括成 4 步:
-
确认是目标串口
-
更新时间
-
记录当前总共收了多少字节
-
立刻准备下一次接收
主循环处理函数
课堂原代码:
void uart_task(void)
{
// 1. 检查货架:如果计数器为0,说明没货或刚处理完,休息。
if (uart_rx_index == 0)
return;
// 2. 检查手表:当前时间 - 最后收货时间 > 规定的超时时间?
if (uwTick - uart_rx_ticks > UART_TIMEOUT_MS) // 核心判断
{
// --- 3. 超时!开始理货 ---
// "uart_rx_buffer" 里从第0个到第 "uart_rx_index - 1" 个
// 就是我们等到的一整批货(一帧数据)
my_printf(&huart1, "uart data: %s\n", uart_rx_buffer);
// (在这里加入你自己的处理逻辑,比如解析命令控制LED)
// --- 理货结束 ---
// 4. 清理现场:把处理完的货从货架上拿走,计数器归零
memset(uart_rx_buffer, 0, uart_rx_index);
uart_rx_index = 0;
// 5. 将UART接收缓冲区指针重置为接收缓冲区的起始位置
huart1.pRxBuffPtr = uart_rx_buffer;
}
// 如果没超时,啥也不做,等下次再检查
}

逐行理解
if (uart_rx_index == 0) return;
这行的意思:
- 如果当前一个字节都没收到,就没必要继续往下判断。
为什么这么写:
- 这样可以减少无意义处理。
if (uwTick - uart_rx_ticks > UART_TIMEOUT_MS)
这行的意思:
- 当前时间减去最后一次收到字节的时间,如果大于超时时间,就认为已经停顿够久了。
为什么这么写:
-
这就是超时解析法的核心判断。
-
它不是看“收满多少字节”,而是看“停了多久没新字节”。
my_printf(&huart1, "uart data: %s\n", uart_rx_buffer);
这行的意思:
- 把这一帧收到的数据当字符串打印出来。
为什么这么写:
-
课堂这里主要是做演示和调试。
-
用串口把收到的数据再打印回去,最直观。
-
这里默认收到的是字符串型调试数据,所以直接用
%s打印更容易看效果。
memset(uart_rx_buffer, 0, uart_rx_index);
这行的意思:
- 把刚才收到并处理过的那部分数据清零。
为什么这么写:
- 避免旧数据残留,影响下一次观察和调试。
uart_rx_index = 0;
这行的意思:
- 告诉程序:这一帧已经处理完了,下一帧从头开始记。
为什么这么写:
- 计数器归零后,下一次接收时又会从缓冲区开头重新累计。
huart1.pRxBuffPtr = uart_rx_buffer;
这行的意思:
- 把串口接收缓冲区指针重新指回
uart_rx_buffer起始位置。
为什么这么写:
-
因为前面每收一个字节,HAL 接收指针都在往后走。
-
当前这一帧处理完后,要把“下一轮接收的起点”重新拉回数组开头。
这段 uart_task() 整体在做什么
可以概括成:
-
没数据就不处理
-
有数据就检查是否超时
-
一旦超时,就把当前缓存当成一帧处理掉
-
然后清空现场,准备收下一帧
用一句话串起来
这套代码的运行顺序其实就是:
先预约接收 → 每收 1 个字节进一次回调 → 发送方停顿一段时间 → 主循环判定一帧结束 → 处理这一帧
一定要使能中断
-
如果 UART 中断没开
-
HAL_UART_RxCpltCallback()就不会进 -
这套超时解析逻辑就跑不起来
DMA+空闲中断解析法
DMA (Direct Memory Access): 像一个专业的“搬运工”,可以在不需要 CPU 干预的情况下,直接将串口硬件接收到的数据搬运到内存缓冲区。
UART 空闲中断 (Idle Line Detection): 串口硬件能检测到总线上是否出现了一段持续的高电平(空闲状态),这通常标志着一次数据传输(一个数据包或一帧)的结束。当检测到空闲状态时,触发一个中断。
这套方法和前面的区别是:
-
前面:每来一个字节,都要中断一次
-
这里:DMA 先默默接,等“一帧结束”再通知你

DMA是怎么去配置的
CubeMX 配置是关键。
要使用 DMA 和空闲中断,必须在 STM32CubeMX 中:
-
为对应的 UART RX 添加 DMA 请求(课堂这里重点先理解这个动作本身)
-
使能 UART 的全局中断
-
某些情况下还需要注意空闲中断相关支持
CubeMX 会自动生成 DMA 和 UART 的初始化代码,包括关联 DMA 句柄(如 hdma_usart1_rx)。


DMA高频提问速记
这一节把做项目时最容易反复问的问题压缩一下,方便你后面直接翻。
1. DMA 的具体原理是什么
DMA 可以理解成一个硬件搬运工。
不用 DMA 时,串口每收到 1 个字节,CPU 都要亲自做这些事:
-
发现串口来了数据
-
去串口寄存器里把这个字节读出来
-
再把它写进内存缓冲区
用了 DMA 之后,流程就变成:
-
先提前告诉 DMA:
-
数据从哪里来
-
数据搬到哪里去
-
一次搬多少
-
-
串口每来一个字节
-
DMA 直接把这个字节从 UART 数据寄存器搬到内存
-
CPU 只在“这一批数据该处理了”时再出面
一句话记住:
-
不用 DMA:CPU 一个字节一个字节搬
-
用了 DMA:CPU 先下命令,DMA 自动搬,CPU 最后处理结果
2. 为什么 DMA + 空闲中断要一起看
因为:
-
DMA 只负责搬数据
-
它不知道“这一帧结束了没有”
所以还需要 UART 的空闲中断来做这件事:
-
当 RX 线安静了一段时间
-
UART 硬件认为这一帧大概率结束
-
然后进回调函数通知 CPU
所以这套组合的分工是:
-
DMA:搬
-
空闲中断:判定一帧结束
-
主循环/任务:处理数据
3. DMA 有循环模式,为什么不直接当环形缓存区
这是一个很重要的问题。
DMA Circular 只解决了一件事:
- 写指针走到末尾后,自动回到开头继续写
但真正的环形缓冲区除了“会循环写”,还要解决这些问题:
-
从哪里开始读
-
读到哪里为止
-
哪些数据已经处理过
-
哪些数据还没处理
-
当前还剩多少未处理数据
-
如果写得太快把旧数据覆盖了怎么办
所以可以这样记:
-
DMA Circular= 硬件层面的循环写入 -
软件环形缓冲区= 软件层面的读写管理
DMA 知道“我写到哪了”,但它不会替你管理:
-
你读到哪了
-
你还有多少数据没处理
所以很多项目最终会变成:
-
DMA Circular负责持续接收 -
软件环形缓冲区负责管理“未处理数据”
-
上层协议解析按自己的节奏去读
4. 什么时候用 ReceiveToIdle DMA,什么时候考虑 Circular
-
一帧一帧收,帧和帧之间有停顿
- 更适合
ReceiveToIdle DMA
- 更适合
-
连续数据流、高速流、停顿不明显
- 更适合
DMA Circular + 软件环形缓冲区
- 更适合
5. 做项目时怎么先别把自己绕进去
你现在做项目时,先按这条线理解就够:
-
先会普通中断接收 + 超时解析
-
再会
DMA + 空闲中断 -
最后再去碰
DMA Circular + 环形缓冲区 + 状态机
这样不容易把硬件接收、缓冲区管理、协议解析三件事混在一起。
DMA+空闲中断代码
代码执行流程图解
如果你以后做项目时想顺着这套 DMA 代码查,最重要的是把它看成 3 个角色:
-
HAL_UARTEx_ReceiveToIdle_DMA(...)- 负责启动整套接收流程
-
HAL_UARTEx_RxEventCallback(...)- 负责在一帧结束时接管数据
-
uart_task()- 负责真正处理这一帧
按时间顺序看,整套流程是这样的:
- 初始化阶段先调用一次:
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_dma_buffer, sizeof(uart_rx_dma_buffer));
这一步的作用是:
-
告诉 DMA:开始把 UART 收到的数据搬到
uart_rx_dma_buffer -
告诉 UART:如果线路出现空闲,记得通知我
- 外部设备开始发送一串数据
-
UART 每收到一个字节
-
DMA 就自动把它搬进
uart_rx_dma_buffer -
这期间 CPU 不需要每个字节都进中断
- 只要发送方还在持续发送
-
DMA 就持续搬运
-
程序不会立刻去处理
- 当发送方这一帧发完,线路进入空闲状态
-
UART 检测到“空闲”
-
然后进入
HAL_UARTEx_RxEventCallback()
- 在回调里做 5 件关键事
-
确认是不是
USART1 -
停掉当前 DMA
-
用
Size把这次有效数据复制出来 -
立一个
uart_flag -
重新启动下一轮 DMA 接收
- 回调退出后,主循环继续跑
-
当
uart_task()发现uart_flag == 1 -
就说明有一帧数据已经准备好了
-
这时再打印、处理、清空应用层缓冲区
这套流程最关键的“断点”在哪里
以后你做项目时,最该盯住的是这几个节点:
-
HAL_UARTEx_ReceiveToIdle_DMA(...)有没有真正执行 -
外设发数据后,
HAL_UARTEx_RxEventCallback(...)有没有进 -
回调里的
Size是不是你预期的长度 -
uart_flag有没有被置成1 -
主循环里的
uart_task()有没有看到这个标志并处理
用一句话串起来
这套 DMA 代码的运行顺序其实就是:
先启动 DMA 接收 → DMA 在后台持续搬数据 → 线路空闲后进回调 → 回调把这一帧交给主循环 → 主循环再处理
课堂原代码:
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
// 1. 确认是目标串口 (USART1)
if (huart->Instance == USART1)
{
// 2. 紧急停止当前的 DMA 传输 (如果还在进行中)
// 因为空闲中断意味着发送方已经停止,防止 DMA 继续等待或出错
HAL_UART_DMAStop(huart);
// 3. 将 DMA 缓冲区中有效的数据 (Size 个字节) 复制到待处理缓冲区
memcpy(uart_dma_buffer, uart_rx_dma_buffer, Size);
// 注意:这里使用了 Size,只复制实际接收到的数据
// 4. 举起"到货通知旗",告诉主循环有数据待处理
uart_flag = 1;
// 5. 清空 DMA 接收缓冲区,为下次接收做准备
// 虽然 memcpy 只复制了 Size 个,但清空整个缓冲区更保险
memset(uart_rx_dma_buffer, 0, sizeof(uart_rx_dma_buffer));
// 6. **关键:重新启动下一次 DMA 空闲接收**
// 必须再次调用,否则只会接收这一次
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_dma_buffer, sizeof(uart_rx_dma_buffer));
// 7. 如果之前关闭了半满中断,可能需要在这里再次关闭 (根据需要)
__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);
}
}
逐行理解
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
这行的意思:
-
这是 HAL 库在 DMA + 空闲中断模式下用到的回调函数。
-
Size表示这次实际收到的数据长度。
为什么这么写:
-
因为这里不再是“一个字节一个字节通知你”。
-
现在通知你的时机是:一帧结束了,或者这批 DMA 接收有结果了。
if (huart->Instance == USART1)
这行的意思:
- 确认回调来源是不是
USART1。
为什么这么写:
- 和前面的中断回调一样,是为了防止多个串口混在一起。
HAL_UART_DMAStop(huart);
这行的意思:
- 先把当前 DMA 接收停下来。
为什么这么写:
-
因为空闲事件已经出现了,这一批数据要开始交给应用层处理。
-
课堂这里先停下再复制,逻辑上更好理解。
-
你先把“停下这一轮,取走这一帧,再重开下一轮”这条线吃透,后面再看更复杂的工程写法会轻松很多。
memcpy(uart_dma_buffer, uart_rx_dma_buffer, Size);
这行的意思:
- 把 DMA 缓冲区里真正收到的那部分数据复制到应用层处理缓冲区。
为什么这么写:
-
DMA 缓冲区是底层接收区。
-
应用层最好用自己的缓冲区来处理,避免和下一次 DMA 接收互相干扰。
为什么这里一定是 Size
这是这段代码最关键的点之一。
-
uart_rx_dma_buffer的总大小,可能是 128 字节。 -
但这次实际收到的,也许只有 12 字节、25 字节。
所以:
-
Size= 这次真实收到多少 -
sizeof(uart_rx_dma_buffer)= 缓冲区总容量
这里必须用 Size,不能图省事直接复制整个数组。
uart_flag = 1;
这行的意思:
- 告诉主循环:有一帧新数据到了,轮到你处理了。
为什么这么写:
-
中断里不适合做太重的事情。
-
所以这里先只立个标志,把真正处理留给主循环。
memset(uart_rx_dma_buffer, 0, sizeof(uart_rx_dma_buffer));
这行的意思:
- 清空 DMA 接收缓冲区。
为什么这么写:
-
主要是为了让下一次观察和调试更清晰。
-
课堂这里这样写,思路直观。
HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_dma_buffer, sizeof(uart_rx_dma_buffer));
这行的意思:
- 重新启动下一次 DMA + 空闲中断接收。
为什么这么写:
-
和前面的
HAL_UART_Receive_IT一样,核心都是:- 这一轮收完了,要把下一轮再挂上去
如果不重新调用,它只会工作这一次。
__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);
这行的意思:
- 关闭 DMA 半传输中断。
为什么这么写:
-
课堂这里主要关注“收完一帧再处理”。
-
半传输中断不是这节课的重点,关掉后逻辑更干净。
这段 DMA 回调整体在做什么
可以概括成:
-
确认是目标串口
-
停止本轮 DMA
-
复制这次实际收到的数据
-
通知主循环处理
-
重新启动下一轮接收
主循环处理函数
课堂原代码:
void uart_task(void)
{
// 如果接收索引为0,说明没有数据需要处理,直接返回
if (uart_flag == 0)
return;
uart_flag = 0;
my_printf(&huart1, "uart data: %s\n", uart_dma_buffer);
// 清空接收缓冲区,将接收索引置零
memset(uart_dma_buffer, 0, sizeof(uart_dma_buffer));
}
逐行理解
if (uart_flag == 0) return;
这行的意思:
- 如果回调还没告诉你“数据到了”,那就先不处理。
为什么这么写:
-
DMA 方案里,主循环不再自己算超时。
-
它只看标志位。
uart_flag = 0;
这行的意思:
- 表示这次“有新数据”的通知已经接收到了。
为什么这么写:
- 防止下一次循环重复处理同一帧。
my_printf(&huart1, "uart data: %s\n", uart_dma_buffer);
这行的意思:
- 把这一帧收到的数据再打印出来。
为什么这么写:
-
和超时解析那部分一样,课堂这里主要是为了演示“收到了什么”。
-
同样默认这里收到的是可以按字符串看的数据;如果后面收的是纯二进制数据,处理方式就不能只靠
%s。
memset(uart_dma_buffer, 0, sizeof(uart_dma_buffer));
这行的意思:
- 清空应用层处理缓冲区。
为什么这么写:
- 方便下一次继续接收和观察。
“空闲”的定义
在 UART 通信中,当没有数据传输时,通信线路(RX 线)会保持在高电平状态(逻辑 1),这被称为“空闲状态”或“标记状态”。
所谓“检测到空闲”,是指硬件检测到 RX 线上连续保持高电平的时间,超过了一个完整数据帧所需要的时间。
这通常说明:
-
发送方这一帧发完了
-
后面暂时没有新的起始位跟上来
复习结论
-
空闲中断不是“收到一个字节就中断”
-
它更像“这一帧结束了,来处理吧”
环形缓冲区
这一节是为后面做铺垫,也是对上面“为什么 DMA Circular 不等于环形缓冲区”的继续展开。
前面的数组方案能跑,但一旦出现这些情况,就会吃力:
-
数据来得快
-
处理来得慢
-
一次来了多帧
-
有粘包、半包问题
这时就要引入环形缓冲区。
你可以先把它理解成一句话:
- 不是为了把 UART 变复杂,而是为了让“连续到来的数据”和“稍后再处理的数据”能和平共处。
你先抓住它的本质
-
它本身不是协议
-
它本身也不是中断
-
它只是一个更适合处理“连续字节流”的数据结构
它的价值在于:
-
接收和处理解耦
-
不用频繁搬移数组
-
更适合后面做解包、状态机
封包、解包、粘包、半包
这一部分是 UART 从“能收发”进入“能做协议”的关键。
封包
就是把原始数据包装成一帧,比如:
帧头 + 长度 + 数据 + 校验 + 帧尾
解包
就是接收方按协议把一帧一帧拆出来。
粘包
就是两帧甚至多帧连在一起到达。
半包
就是一帧只到了一部分,剩下部分后面才到。
为什么这会引出环形缓冲区
因为 UART 底层往往看到的是:
- 一串连续字节流
而不是:
- 天然分好的整帧
超时解析 vs DMA+空闲中断,怎么选
超时解析法
适合:
-
刚入门
-
串口助手调试
-
数据量不大
-
人工发送、停顿明显
优点:
-
好理解
-
好调试
缺点:
-
每来一个字节都要中断一次
-
超时时间要靠经验
DMA+空闲中断法
适合:
-
数据量更大
-
希望 CPU 更轻松
-
希望按“一帧结束”再处理
优点:
-
更高效
-
CPU 压力更小
缺点:
-
配置更复杂
-
理解门槛更高
我的复习结论
-
刚学 UART,先吃透课堂里的超时解析
-
再理解为什么要升级到DMA+空闲中断
-
再往后才是环形缓冲区 + 协议解析
本讲常见坑
硬件类
-
TX 和 RX 接反
-
没有共地
-
USB 转 TTL 电平不匹配
配置类
-
波特率不一致
-
串口参数不一致
-
中断没开
-
DMA 没配对
代码理解类
-
不明白
HAL_UART_Receive_IT()为什么每次回调都要再调一次 -
不明白
uart_rx_index++为什么没手动赋值也能成立 -
不明白
Size和sizeof(buffer)的区别 -
不明白空闲中断为什么不是“每个字节都进一次”
-
把接收到的数据默认都当成 C 字符串处理,结果二进制数据一打印就乱
本讲最后速记
-
UART 本质上是串行通信。
-
TX 接 RX,RX 接 TX,而且要共地。
-
串口参数必须一致,尤其是波特率和
8N1。 -
中断接收模式下,每次回调都要重新启动下一次接收。
-
DMA+空闲中断模式下,每次一帧结束后通常也要重新启动接收。
-
UART 底层看到的是连续字节流,帧边界需要靠超时、空闲或者协议来确定。