第六讲Uart模块

第六讲Uart模块

本讲总览

这一讲主要解决三个问题:

  • UART 到底是什么,为什么 TX、RX、波特率这些东西必须配对。

  • 怎么把串口先用起来,最基础的是能发、能收。

  • 收数据时怎么判断“一帧结束了”,以及为什么后面要学 DMA、空闲中断、环形缓冲区。

这篇笔记保留课堂里的原代码思路,不改你上课时的写法,重点补两类内容:

  • 这行代码是什么意思

  • 为什么课堂上要这样写

复习时先抓住 4 个结论:

  • UART 先看接线和参数是否对上,再看代码,不然程序写得再对也可能全是乱码。

  • 串口发送本质上是在发一串字节;串口接收真正难的地方,往往是“怎么判断这一帧结束了”。

  • 中断接收更适合先把流程吃透,DMA + 空闲中断 更适合后面把接收做得更省 CPU。

  • 后面学环形缓冲区和协议解析,不是在推翻前面的 UART,而是在解决“连续字节流怎么稳定处理”的问题。


串行 vs. 并行

想象一下排队过安检:

  • 串行 (Serial): 就像大家一个接一个排队通过安检门,每次只能过一个人(一个比特)。UART 就是这种方式,数据一位一位地在单根线上发送。**优点:**省线,适合长距离;**缺点:**速度相对较慢。

  • 并行 (Parallel): 就像同时开了好几个安检门,大家可以同时通过。数据同时在多根线上发送。**优点:**速度快;**缺点:**需要更多线,成本高,不适合长距离。

image-20260429193043265

复习结论

  • 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

它的作用是:

  • 让电脑能和单片机的串口直接通信

  • 方便你用串口助手看输出、发命令

复习结论

  • USBTTL 不是一回事,中间必须靠转换模块衔接。

  • 你平时电脑上看到的串口助手,本质上就是通过这个转换模块在和单片机通信。

image-20260503142812879


CH340 是什么芯片

CH340 是常见的 USB 转串口芯片。

你可以把它理解成:

  • 电脑这边它认识 USB

  • 单片机这边它输出串口信号

很多开发板和串口模块上都能见到它。

复习结论

  • CH340 最常见的身份就是:USB 转串口 芯片。

  • 你调试串口时,电脑能识别出一个串口号,很多时候背后就是这类芯片在工作。

image-20260503143047744

image-20260503143136571

image-20260503143541107


CubeMX 里最基础的 UART 配置

先把对应串口引脚配置出来,避免后面把引脚功能和复用关系看混。

image-20260503143716320

设置波特率。

image-20260503151535238

这里最少要关注这些:

  • 选的是哪个串口,比如 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;

这行的意思:

  • 返回这次最终发了多少字节。

为什么这么写:

  • 方便以后调试,或者上层如果想知道发送长度也能拿到。

这段代码整体在做什么

你可以把它理解成两步:

  1. 先把你想说的话整理成字符串

  2. 再通过串口发出去

根据不同的 usart 接口,UART_HandleTypeDef *huart 是需要替换的。

这里还要补一句很实用的话:

  • 这套 my_printf 更适合打印调试信息、字符串和简单格式化输出。

  • 如果你后面要发的是二进制协议、图片数据、定长字节流,就不要把它和“字符串打印”完全混成一件事。

image-20260503152240006


为什么要勾选微库

为什么要勾选微库(MicroLIB):

  • 单片机工程资源有限

  • 微库更轻

  • 对我们这种主要做串口打印、基础格式化输出的小工程更合适

这里和 my_printf 的关系

课堂这里讲微库,主要是因为:

  • my_printf 用到了 vsnprintf

  • 这类格式化函数背后依赖标准库的一部分支持

所以这里是在提醒你:

  • 工程配置也会影响代码是否能正常跑

  • 不是“代码写对了”就一定没问题


超时解析

超时解析法的核心思想:

如果两个字节之间停顿时间超过阈值,就认为上一帧结束。

利用数据传输的间歇时间来判断。如果两个字节之间的时间间隔超过某个阈值(比如 10ms),就认为上一帧数据已经结束了。

这套方法最适合的场景是:

  • 一帧一帧地发

  • 帧和帧之间有明显停顿

  • 现在主要目标是先把 UART 接收流程看懂

基本步骤

  1. 设置一个接收缓冲区,用来存放收到的字节。

  2. 启动 UART 接收,通常使用中断方式。

  3. 在中断服务函数里:

    • 将收到的字节存入缓冲区。

    • 记录当前收到字节的时间。

    • 再次启动下一次接收。

  4. 在主循环或定时任务里,不断检查是否超时。

  5. 如果超时并且缓冲区里有数据,就说明一帧收完了,可以处理。

数组如果不够大,数据又太长怎么办,这就是后面要引出缓冲区管理问题的原因。

image-20260503175836043


超时解析代码

第一次接收需要手动启动。通常在 main 函数初始化代码中或者 usart 的函数中,调用一次:

 HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[0], 1);

这句的作用是:

  • 先把第一次接收“挂上去”

  • 相当于第一次告诉硬件:我准备好收第 1 个字节了

代码执行流程图解

如果你以后做项目时想顺着代码查,这套超时解析可以按下面顺序去理解:

  1. main() 或初始化函数里先执行一次:
 HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[0], 1);

这一步的意义不是“已经收到数据了”,而是:

  • 告诉 HAL:开始准备接收第 1 个字节
  1. 外部设备通过串口发来第 1 个字节
  • HAL 把这个字节放进 uart_rx_buffer[0]

  • 然后进入 HAL_UART_RxCpltCallback()

  1. 在回调函数里做 3 件事
  • 更新时间 uart_rx_ticks

  • uart_rx_index++

  • 再次调用 HAL_UART_Receive_IT(...),准备接第 2 个字节

  1. 外部设备继续发第 2、3、4…个字节
  • 每来一个字节,都重复上面的流程

  • 所以缓冲区里的数据越来越多

  • uart_rx_index 也越来越大

  1. 只要发送方还在持续发数据,uart_rx_ticks 就会不断刷新
  • 所以主循环里的超时判断一直不会成立
  1. 当发送方停下来后
  • 一段时间内没有新字节到来

  • 这时 uwTick - uart_rx_ticks > UART_TIMEOUT_MS 就成立

  1. 一旦超时成立
  • uart_task() 就把当前缓冲区里的内容当成“一整帧”

  • 打印

  • 处理

  • 清空

  • 把接收起点重新拉回头部

项目里你可以怎么排查这套流程

如果你以后做项目时这套跑不起来,可以按这个顺序查:

  1. 初始化里有没有先调第一次 HAL_UART_Receive_IT

  2. 串口中断有没有开

  3. HAL_UART_RxCpltCallback() 有没有进

  4. uart_rx_index 有没有一直增长

  5. uart_rx_ticks 有没有刷新

  6. 发送停止后,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 步:

  1. 确认是目标串口

  2. 更新时间

  3. 记录当前总共收了多少字节

  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;
     }
     // 如果没超时,啥也不做,等下次再检查
 }

image-20260503175904352

逐行理解

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 先默默接,等“一帧结束”再通知你

image-20260503204643436


DMA是怎么去配置的

CubeMX 配置是关键。

要使用 DMA 和空闲中断,必须在 STM32CubeMX 中:

  • 为对应的 UART RX 添加 DMA 请求(课堂这里重点先理解这个动作本身)

  • 使能 UART 的全局中断

  • 某些情况下还需要注意空闲中断相关支持

CubeMX 会自动生成 DMA 和 UART 的初始化代码,包括关联 DMA 句柄(如 hdma_usart1_rx)。

image-20260503205629375

image-20260503205819070


DMA高频提问速记

这一节把做项目时最容易反复问的问题压缩一下,方便你后面直接翻。

1. DMA 的具体原理是什么

DMA 可以理解成一个硬件搬运工

不用 DMA 时,串口每收到 1 个字节,CPU 都要亲自做这些事:

  1. 发现串口来了数据

  2. 去串口寄存器里把这个字节读出来

  3. 再把它写进内存缓冲区

用了 DMA 之后,流程就变成:

  1. 先提前告诉 DMA:

    • 数据从哪里来

    • 数据搬到哪里去

    • 一次搬多少

  2. 串口每来一个字节

  3. DMA 直接把这个字节从 UART 数据寄存器搬到内存

  4. 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. 做项目时怎么先别把自己绕进去

你现在做项目时,先按这条线理解就够:

  1. 先会普通中断接收 + 超时解析

  2. 再会 DMA + 空闲中断

  3. 最后再去碰 DMA Circular + 环形缓冲区 + 状态机

这样不容易把硬件接收、缓冲区管理、协议解析三件事混在一起。


DMA+空闲中断代码

代码执行流程图解

如果你以后做项目时想顺着这套 DMA 代码查,最重要的是把它看成 3 个角色:

  • HAL_UARTEx_ReceiveToIdle_DMA(...)

    • 负责启动整套接收流程
  • HAL_UARTEx_RxEventCallback(...)

    • 负责在一帧结束时接管数据
  • uart_task()

    • 负责真正处理这一帧

按时间顺序看,整套流程是这样的:

  1. 初始化阶段先调用一次:
 HAL_UARTEx_ReceiveToIdle_DMA(&huart1, uart_rx_dma_buffer, sizeof(uart_rx_dma_buffer));

这一步的作用是:

  • 告诉 DMA:开始把 UART 收到的数据搬到 uart_rx_dma_buffer

  • 告诉 UART:如果线路出现空闲,记得通知我

  1. 外部设备开始发送一串数据
  • UART 每收到一个字节

  • DMA 就自动把它搬进 uart_rx_dma_buffer

  • 这期间 CPU 不需要每个字节都进中断

  1. 只要发送方还在持续发送
  • DMA 就持续搬运

  • 程序不会立刻去处理

  1. 当发送方这一帧发完,线路进入空闲状态
  • UART 检测到“空闲”

  • 然后进入 HAL_UARTEx_RxEventCallback()

  1. 在回调里做 5 件关键事
  • 确认是不是 USART1

  • 停掉当前 DMA

  • Size 把这次有效数据复制出来

  • 立一个 uart_flag

  • 重新启动下一轮 DMA 接收

  1. 回调退出后,主循环继续跑
  • uart_task() 发现 uart_flag == 1

  • 就说明有一帧数据已经准备好了

  • 这时再打印、处理、清空应用层缓冲区

这套流程最关键的“断点”在哪里

以后你做项目时,最该盯住的是这几个节点:

  1. HAL_UARTEx_ReceiveToIdle_DMA(...) 有没有真正执行

  2. 外设发数据后,HAL_UARTEx_RxEventCallback(...) 有没有进

  3. 回调里的 Size 是不是你预期的长度

  4. uart_flag 有没有被置成 1

  5. 主循环里的 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 回调整体在做什么

可以概括成:

  1. 确认是目标串口

  2. 停止本轮 DMA

  3. 复制这次实际收到的数据

  4. 通知主循环处理

  5. 重新启动下一轮接收


主循环处理函数

课堂原代码:

 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++ 为什么没手动赋值也能成立

  • 不明白 Sizesizeof(buffer) 的区别

  • 不明白空闲中断为什么不是“每个字节都进一次”

  • 把接收到的数据默认都当成 C 字符串处理,结果二进制数据一打印就乱


本讲最后速记

  1. UART 本质上是串行通信。

  2. TX 接 RX,RX 接 TX,而且要共地。

  3. 串口参数必须一致,尤其是波特率和 8N1

  4. 中断接收模式下,每次回调都要重新启动下一次接收。

  5. DMA+空闲中断模式下,每次一帧结束后通常也要重新启动接收。

  6. UART 底层看到的是连续字节流,帧边界需要靠超时、空闲或者协议来确定。