【嵌入式学习笔记】UART

前言

本系列学习笔记是本人跟随米醋电子工作室学习嵌入式的学习笔记,自用为主,不是教学或经验分享,若有误,大佬轻喷,同时欢迎交流学习,侵权即删。

一、UART 基础概念

1.1 串行 vs. 并行

想象一下排队过安检:

  • 串行 (Serial): 就像大家一个接一个排队通过安检门,每次只能过一个人(一个比特)。UART 就是这种方式,数据一位一位地在单根线上发送。优点: 省线,适合长距离;**缺点:**速度相对较慢。
  • 并行 (Parallel): 就像同时开了好几个安检门,大家可以同时通过。数据同时在多根线上发送。优点: 速度快;**缺点:**需要更多线,成本高,不适合长距离。

1.2 波特率 (Baud Rate)

波特率就好比两个人打电话时说话的速度。你说话快,对方也要能听得快;你说话慢,对方也要能听得慢。只有双方速度(波特率)约定一致,才能正确理解对方的信息。

常见的波特率有 9600, 115200 等,单位是 bps (bits per second),即每秒传输多少比特。

1.3 数据帧 (Data Frame)

为了确保数据正确传输,UART 通信时会把数据打包成一个个"数据帧"来发送。就像写信需要信封、地址、邮票一样,数据帧也有固定格式。

一个典型的数据帧通常包含:

  • 起始位 (Start Bit): 标志着一个新数据帧的开始,通常是低电平(逻辑 0)。
  • 数据位 (Data Bits): 这才是我们要传输的真正数据,通常是 5 到 8 位。
  • 校验位 (Parity Bit) (可选): 用于检查数据在传输过程中是否出错(奇校验、偶校验或无校验)。
  • 停止位 (Stop Bit): 标志着数据帧的结束,通常是高电平(逻辑 1),可以是 1 位、1.5 位或 2 位。

UART 数据帧结构

起始位

数据位 (7/8)

校验位 (可选)

停止位 (1/2)

空闲 (高电平)

→ 时间

**重要:**通信双方必须约定好相同的数据帧格式(数据位、校验位、停止位),否则就会"鸡同鸭讲",无法解析数据。

1.4 TX 和 RX

UART 通信至少需要两根线:

  • TX (Transmit): 发送数据线。设备的 TX 连接到另一个设备的 RX。
  • RX (Receive): 接收数据线。设备的 RX 连接到另一个设备的 TX。

记住:TX 要连接对方的 RX,RX 要连接对方的 TX,交叉连接,就像打电话一样,你的话筒对着别人的听筒。

通常还需要一根 GND (Ground) 线,即地线,用来统一双方的参考电平,确保信号稳定。

二、硬件的连接

理论讲完了,我们来看看实际怎么连接。最常见的场景是将单片机(比如 STM32)和电脑通过 USB 转 TTL 模块连接起来。

  1. 找到单片机上的 UART 引脚: 通常在开发板原理图或丝印上会标明 UART1_TX, UART1_RX 等。不同单片机可能叫 USART、SCI 等,但原理类似。
  2. 准备 USB 转 TTL 模块: 这是一个小模块,一边是 USB 口插电脑,另一边引出 TXD, RXD, GND, VCC 引脚。
  3. 连接:
    • 单片机的 TX 连接到 USB 转 TTL 模块的 RXD
    • 单片机的 RX 连接到 USB 转 TTL 模块的 TXD
    • 单片机的 GND 连接到 USB 转 TTL 模块的 GND
    • (可选)如果模块需要供电,根据情况连接 VCC。
  4. 插入电脑: 将模块的 USB 口插入电脑。电脑会识别到一个虚拟串口(COM口)。

注意: 一定要交叉连接 TX 和 RX!GND 必须连接!

连接好后,你就可以在电脑上使用串口助手软件(如 XCOM、Putty 等)来收发数据了。

三、 超时解析法:简单高效的数据接收

好了,现在硬件连上了,单片机也配置好了 UART(这部分通常由 STM32CubeMX 或类似工具生成初始化代码),数据源源不断地通过 RX 线进来了。但问题是:我怎么知道一帧完整的数据什么时候结束呢?

比如,电脑发送了 "Hello" 这个字符串,单片机是一个字节一个字节接收的('H', 'e', 'l', 'l', 'o')。单片机怎么知道收到 'o' 之后就表示 "Hello" 发送完了,而不是后面还有其他字符呢?

这就是数据解析要解决的问题。方法有很多,比如:

  • 固定长度: 双方约定好每次都发送固定长度的数据,比如每次 10 个字节。收满 10 个字节就算一帧。
  • 特定结束符: 双方约定好用一个特殊的字符或字符串作为结束标志,比如每次发送都以回车换行符 `\r\n` 结尾。收到这个标志就算一帧结束。
  • 超时解析法: 利用数据传输的间歇时间来判断。如果两个字节之间的时间间隔超过某个阈值(比如 10ms),就认为上一帧数据已经结束了。

超时解析法 是非常常用的一种方法,尤其适合那些数据长度不固定,且发送方会自然停顿的情况(比如我们通过串口助手手动发送数据)。

它的核心思想是:

  1. 设置一个接收缓冲区(就是一个数组),用来存放收到的字节。
  2. 启动 UART 接收(通常使用中断方式,每收到一个字节就触发一次中断)。
  3. 在中断服务函数里:
    • 将收到的字节存入缓冲区。
    • 记录当前收到字节的时间(或者说重置一个计时器)。
    • 再次启动下一次接收。
  4. 在主循环(或一个定时任务)里,不断检查:当前时间距离上次收到字节的时间,是否超过了预设的超时时间?
  5. 如果超过了超时时间,并且缓冲区里有数据,就说明一帧数据接收完毕!可以对缓冲区里的数据进行处理了。处理完后,清空缓冲区,准备接收下一帧。

这种方法非常灵活,不需要知道数据具体长度,也不依赖特殊结束符。接下来,我们就用代码来实现这个逻辑。

四、超时解析实战

4.1 "仓库管理员":全局变量与常量

要管理串口接收,首先得有地方存放数据、记录状态。这就像仓库需要货架、计数器和计时器一样。我们定义了以下"管理员":

cpp 复制代码
uint8_t uart_rx_buffer[UART_RX_BUFFER_SIZE];

"货架" (缓冲区): 一个 `uint8_t` 类型的数组,用于存放串口接收到的每一个字节(货物)。UART_RX_BUFFER_SIZE (值为 128) 定义了货架的大小,防止货物堆积如山导致溢出。

cpp 复制代码
uint16_t uart_rx_index;

"计数器": 记录当前货架上放了多少件货(收到了多少字节)。当新货到达,计数器加一;当一批货处理完毕,计数器清零。

cpp 复制代码
uint32_t uart_rx_ticks;

"计时器" (时间戳): 记录最后一件货物放到货架上的准确时间。我们用它来判断货物之间的时间间隔。

cpp 复制代码
extern volatile uint32_t uwTick;

"系统时钟": 这是由 STM32 HAL 库提供的一个全局变量,像一个精准的秒表,每毫秒自动递增。我们用它来获取当前时间,与 `uart_rx_ticks` 比较。

cpp 复制代码
#define UART_TIMEOUT_MS 100

"超时规则": 定义了一个常量,表示我们能容忍的货物到达的最大时间间隔(100毫秒)。如果超过这个时间没新货来,我们就认为这一批货送完了。

cpp 复制代码
extern UART_HandleTypeDef huart1;

"串口控制器": 这是 HAL 库中代表具体串口硬件(如 USART1)的结构体。我们需要通过它来操作串口,比如启动接收、发送数据等。

cpp 复制代码
#include "stdio.h"
#include "string.h"
#include "stdarg.h"
...

"工具库": 引入标准库头文件,提供诸如 `memset` (清空货架)、`vsnprintf` (打包打印信息) 等常用工具。

我们将这些变量定义在函数外部,称为全局变量,这样在中断处理函数和主任务函数中都可以访问和修改它们,共享状态信息。

4.2 "收货员":中断回调函数 HAL_UART_RxCpltCallback

当串口硬件接收到一个字节的数据时,它会向 CPU 发送一个"中断"信号,就像快递员按门铃。CPU 暂停当前工作,转而执行一个特定的函数来处理这个"门铃"事件。在 HAL 库中,这个函数就是 HAL_UART_RxCpltCallback

cpp 复制代码
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);
	}
}

这个"收货员"的工作流程很简单,但至关重要:

  1. 核对身份 (if 判断): 确认是目标串口 `USART1` 发来的中断。
  2. 更新时间戳 (uart_rx_ticks = uwTick;): 立刻记录下当前收到字节的时间。这是超时判断的基础。
  3. 更新计数 (uart_rx_index++;): 记录已接收字节数的"计数器"加一。HAL 库在调用这个回调之前,已经默默地把接收到的那个字节放到了 `uart_rx_buffer` 中 `uart_rx_index` 指向的位置。
  4. "预订下一个包裹" (HAL_UART_Receive_IT(...)): 这是精髓 所在!我们告诉 UART 硬件:"好的,这个字节我收到了,请继续监听,如果再来一个字节,请再次通知我(触发中断),并把它放到缓冲区的下一个位置 (`&uart_rx_buffer[uart_rx_index]`)"。如果不重新启动接收,UART 就只会接收这一个字节,然后就"关门谢客"了。

注意:

首次"开门": 别忘了,第一次接收需要手动启动。通常在 `main` 函数初始化代码中,调用一次 `HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[0], 1);` 来等待第一个字节的到来,相当于第一次给门口的传感器通电。

4.3 "理货员":超时处理任务 uart_task

"收货员"(中断)只负责快速接收字节并记录时间,它不关心数据是否完整。判断一批货(一帧数据)是否送完,并进行处理的任务,交给了我们的"理货员"------uart_task 函数。这个函数需要在主循环 `while(1)` 中不断地被调用。

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

"理货员"的逻辑清晰:

  1. 检查是否有货 (if (uart_rx_index == 0)): 如果缓冲区是空的 (`uart_rx_index` 是 0),说明没有待处理的数据,直接返回,不浪费时间。
  2. 判断是否超时 (if (uwTick - uart_rx_ticks > UART_TIMEOUT_MS)): 这是超时法的核心!用当前系统时间 `uwTick` 减去最后一次收到字节的时间 `uart_rx_ticks`,得到时间差。如果这个差值大于我们设定的 `UART_TIMEOUT_MS`(100毫秒),就意味着在100毫秒内没有新的字节到来。
  3. 处理数据 (超时后的代码块): 一旦超时条件满足,我们就认为 `uart_rx_buffer` 中从第 0 个元素到第 `uart_rx_index - 1` 个元素构成了一帧完整的数据。代码中简单地用 `my_printf` 将数据显示出来。这是你需要根据实际应用替换成自己数据处理逻辑的地方,比如解析 JSON、执行命令等。
  4. 清空状态 (memset(...)uart_rx_index = 0;): 数据处理完毕后,必须清理现场!使用 `memset` 将缓冲区内容清零(好习惯),并将 `uart_rx_index` 置 0,表示货架空了,可以接收下一批新货了。

关于 `uwTick` 回卷: `uwTick` 是一个 32 位无符号整数,它会一直增加,最终会溢出回零。但是, `(uint32_t)currentTime - (uint32_t)lastTime` 这种计算方式在 C 语言中能正确处理回卷(只要两次时间差不超过 `uint32_t` 最大值的一半,对于毫秒计时这几乎不可能发生),所以直接相减是安全的。

重要修正:关于注释掉的 `huart1.pRxBuffPtr = ...`: 原始代码注释中认为在简单中断场景下此行可选,但经过实践验证,在此实现中,这行代码是必需的! 如果注释掉 `huart1.pRxBuffPtr = uart_rx_buffer;` 这一行,很可能会导致接收指针混乱,引发不可预期的错误。具体原因涉及到 HAL 库内部状态管理,我们将在下节课开头 进行详细的 Debug 演示来深入剖析这个问题。请务必保留这行代码!

4.4 "广播员":非常通用的辅助函数 my_printf

为了方便我们在开发过程中查看接收到的数据或打印调试信息,代码提供了一个 `my_printf` 函数,它能像标准 `printf` 一样工作,但输出目标是 UART 串口。

cpp 复制代码
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;
}

它通过以下步骤工作:

  • 使用 C 语言的 `stdarg.h` 库处理不定数量的参数(就像 `printf` 可以接受不同数量和类型的参数一样)。
  • 调用 `vsnprintf`,这是一个安全的函数,它会将格式字符串 (`format`) 和后续参数组合成最终的字符串,并存入 `buffer` 中,同时防止缓冲区溢出。
  • 最后调用 `HAL_UART_Transmit` 函数,将 `buffer` 中的内容通过指定的 `huart` (比如 `&huart1`) 发送出去。`0xFF` 是发送超时时间(这里设为一个相对宽松的值)。

这个函数非常适合在 `uart_task` 中打印接收到的数据,或者在代码其他地方输出调试日志。

五、DMA与空闲中断解析法

超时解析法简单易懂,但在数据量较大或速率较高时,频繁进入中断处理每个字节会消耗不少 CPU 资源。为了解放 CPU,让它专注于更重要的任务,我们可以请出两位强大的帮手:DMAUART 空闲中断

这种方法的核心思想是:

  • DMA (Direct Memory Access): 像一个专业的"搬运工",可以在不需要 CPU 干预的情况下,直接将串口硬件接收到的数据搬运到内存缓冲区。
    想象一下: CPU 是个大忙人(老板),内存是仓库,串口硬件是收货口。没有 DMA 时,每来一个包裹(字节),都得老板亲自去收货口取货,再放到仓库里,老板累得够呛。有了 DMA 这个"专业搬运工",老板只需要告诉搬运工:"你去收货口守着,有货来了直接搬到仓库的这个区域,搬满/对方不送了再告诉我。" 之后老板就可以专心处理其他事务了。
  • UART 空闲中断 (Idle Line Detection): 串口硬件能检测到总线上是否出现了一段持续的高电平(空闲状态),这通常标志着一次数据传输(一个数据包或一帧)的结束。当检测到空闲状态时,触发一个中断。

结合起来,流程就变成了:CPU 告诉 DMA "开始搬运",然后就去忙别的了。DMA 默默地把串口收到的数据搬到内存。当发送方停止发送,总线出现空闲时,硬件触发空闲中断,通知 CPU:"嘿,刚才那波数据传输结束了,DMA 已经把数据都搬到内存的这个位置了,总共搬了这么多字节,你快去处理吧!"

这样,CPU 只在一次数据传输(可能包含很多字节)结束后才需要介入一次,大大提高了效率。

DMA在CubeMax中的参数解析

DMA Request(DMA 请求源)

  • 意思:你要让 DMA 帮哪个外设干活。比如这里选 "USART1_RX",就是 "帮串口 1 的接收模块搬运数据"。

  • 意义:告诉 DMA "你的服务对象是谁",不同外设(串口、ADC、SPI 等)都有对应的 DMA 请求选项。

Stream(DMA 流)

  • 意思:DMA 的 "硬件通道"。STM32 的 DMA(比如 DMA2)有多个 Stream(流),每个流可以绑定不同的外设请求。

  • 意义:相当于给 DMA 分配 "专属车道"------ 多个外设同时用 DMA 时,不同流可以并行工作,避免抢资源。

Direction(数据方向)

  • 意思:数据 "从哪搬到哪"。这里 "Peripheral To Memory" 是 "外设→内存":比如串口接收时,数据从串口(外设)传到单片机的 RAM(内存)。还有另外两种方向:

    • Memory To Peripheral:内存→外设(比如串口发送,把内存里的数据传到串口)

    • Memory To Memory:内存→内存(比如 RAM 里的数据复制)

  • 意义:确定数据的搬运路径,不能搞反。

Priority(优先级)

  • 意思:多个 DMA 任务同时来的时候,谁先执行。这里是 "Low(低)",还有 Medium(中)、High(高)、Very High(极高)等级。

  • 意义:当多个外设同时让 DMA 干活时,优先级高的任务会被优先处理,避免混乱。

Mode(传输模式)

  • 意思:当前选的是 "Normal(正常模式)"------DMA 只完成一次搬运任务 就停下来,比如只接收一段固定长度的数据。还有另一种常用模式:"Circular(循环模式)"------ 搬完一轮后自动重新开始,适合持续不断的传输(比如串口一直接收数据)。

  • 意义:决定 DMA 是 "一次性干活" 还是 "循环反复干活"。

Increment Address(地址增量)

  • 分为 "Peripheral(外设)" 和 "Memory(内存)" 两个选项:

  • Peripheral Increment Address (外设地址增量)

    • 这个选项决定了外设的地址在每次数据传输后是否需要改变。

    • 不勾选 (Disabled): 固定的 "收货窗口"

      • 含义 : 外设的地址是固定的。就像快递员每次都从同一个窗口(比如 "1 号窗口")取包裹。

      • 场景 : 这是最常见的情况。很多外设只有一个数据寄存器。例如,串口(USART)的接收 / 发送数据寄存器(DR)就只有一个地址。每次有新数据,都会出现在这个地址上,所以 DMA 只需要反复访问这一个地址即可。

    • 勾选 (Enabled): 移动的 "收货窗口"

      • 含义: 外设的地址在每次传输后会自动增加。就像快递员从 "1 号窗口" 取完一个包裹后,自动走到 "2 号窗口" 取下一个。

      • 场景: 这种情况相对少见,通常用于那些拥有内部 FIFO(先进先出队列)或数据寄存器阵列的外设。例如,某些型号的 ADC(模数转换器)在扫描多个通道时,可以将结果存放在一个内部的 FIFO 中,DMA 可以通过递增地址的方式连续读取这些结果。

    • Memory Increment Address (内存地址增量)

    • 这个选项决定了内存的地址在每次数据传输后是否需要改变。

    • 不勾选 (Disabled): 固定的 "卸货点"

      • 含义 : 内存的地址是固定的。就像快递员每次都把包裹放在同一个位置(比如 "家门口的红色箱子")。

      • 场景 : 这种模式用于需要持续刷新某个内存变量的场景。例如,一个温度传感器的数据需要被实时显示在屏幕上。我们不需要保存历史数据,只需要知道最新的温度值。DMA 可以不断地将新的温度数据写入同一个内存地址,覆盖掉旧的数据。

    • 勾选 (Enabled): 连续的 "卸货点"

      • 含义 : 内存的地址在每次传输后会自动增加。就像快递员把包裹一个接一个地排成一列放在地上(第一个在 0x20000000,第二个在 0x20000001,以此类推)。

      • 场景 : 这是最常见的情况,用于数据采集和存储。例如,通过 ADC 连续采集电压信号,并将数据存储在内存的一个数组中,以便后续进行分析或处理。

  • 意义:让数据 "按顺序存",避免乱序或覆盖。

Use Fifo & Threshold(FIFO 缓存)

  • Use Fifo:勾选后,DMA 会先把数据存到自己的 "小缓存(FIFO)" 里,攒够一定量再搬运;不勾选的话,数据 "直接传输"(一个传一个)。

  • Threshold:FIFO 的 "触发阈值"(比如 "半满""全满"),只有勾选 Use Fifo 后才会生效。

  • 意义:FIFO 可以减少频繁的内存访问,提升传输效率(类似快递员先把快递攒一筐,再一次性送,而不是拿一个送一个)。

Data Width(数据宽度)

  • 外设和内存都选了 "Byte(字节,8 位)":表示每次搬运 1 个字节的数据。还有另外两个选项:

    • Half-Word(半字,16 位):一次搬 2 字节

    • Word(字,32 位):一次搬 4 字节

  • 意义:必须和外设的传输宽度匹配(比如串口是按字节传输的,所以选 Byte;如果是 ADC 按 16 位输出,就选 Half-Word),否则会传错数据。

Burst Size(突发传输大小)

  • 意思:一次 "批量搬运" 的数量(比如 1 个、4 个、8 个数据),当前是空的表示 "单次传输"。

  • 意义:适合批量传输场景(比如一次性搬多个数据,减少 DMA 的启动次数)。

5.1 全局变量与 DMA 配置

要使用 DMA + 空闲中断,我们的"仓库"也需要升级:

cpp 复制代码
uint8_t uart_rx_dma_buffer[UART_RX_DMA_BUFFER_SIZE];

"DMA 专用卸货区": 这是 DMA 控制器直接操作的内存区域。串口硬件接收到的数据会被 DMA "搬运工"直接放到这里。其大小 `UART_RX_DMA_BUFFER_SIZE` 需要足够容纳预期中一次传输的最大数据量(或者可以设置为一个合理的值,配合空闲中断使用)。
类比: 这就像是给"搬运工"指定了一个临时的、专属的货物堆放场地。收货口的货来了,搬运工就往这个场地上堆,不需要老板(CPU)插手。场地的大小决定了一次最多能连续堆放多少货。

cpp 复制代码
uint8_t uart_dma_buffer[UART_DMA_BUFFER_SIZE];

"待处理货架": 当空闲中断发生时,我们会将 DMA 卸货区 (`uart_rx_dma_buffer`) 的数据复制到这个缓冲区进行处理。这样做是为了尽快让 DMA 可以继续接收下一批数据,避免数据丢失。其大小通常与 DMA 缓冲区相同。

cpp 复制代码
volatile uint8_t uart_flag = 0;

"到货通知旗": 一个标志位。当空闲中断发生,表示一批数据接收完成并且已经从 DMA 区复制到待处理货架后,我们会把这个旗子举起来 (`uart_flag = 1;`)。主循环中的处理任务看到旗子举起来了,就知道有新货到了,可以开始处理。

CubeMX 配置是关键: 要使用 DMA 和空闲中断,必须在 STM32CubeMX 中:

  • 为对应的 UART RX 添加 DMA 请求 (通常设置为 Circular 模式,这样 DMA 缓冲区满了会自动回到开头继续写入,但配合空闲中断,Normal 模式更常见)。
  • 使能 UART 的全局中断。
  • (部分芯片/库版本可能需要额外配置使能 IDLE Line 事件中断)。

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

5.2 启动 DMA 接收

在所有初始化完成之后(通常在 `main` 函数的初始化代码段末尾),我们需要调用一个函数来启动第一次 DMA 接收,并告诉系统我们关心空闲中断:

cpp 复制代码
HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);

这个函数就像是对 DMA 搬运工和串口硬件下达指令:"开始工作!请使用 DMA 通道(已在 CubeMX 配置好),将串口 `huart` 收到的数据,持续搬运到内存地址 `pData` (`uart_rx_dma_buffer`) 开始的地方,最多搬运 `Size` (`sizeof(uart_rx_dma_buffer)`) 个字节。同时,请留意串口总线上的空闲状态,一旦检测到空闲(表示对方可能发完了一帧),或者 DMA 缓冲区满了,就触发相应的事件通知我。"

Receive To Idle: 这个函数名明确表示了它的意图------接收数据,直到检测到总线空闲。它内部会自动处理 DMA 的配置和启动,并确保相关的 UART 中断(特别是 IDLE 中断)被使能。

有时,我们可能不关心 DMA 的"半满"中断(Half Transfer Complete),因为它对于判断一帧结束没有帮助,反而可能干扰空闲中断的处理逻辑。因此,可能会看到这样的代码紧随其后:

cpp 复制代码
__HAL_DMA_DISABLE_IT(&hdma_usart1_rx, DMA_IT_HT);

这行代码的作用是明确地关闭 DMA 的"半满中断"功能。`hdma_usart1_rx` 是 CubeMX 为 USART1 RX 通道生成的 DMA 句柄。

一旦调用了 HAL_UARTEx_ReceiveToIdle_DMA,CPU 就可以撒手不管数据的字节级接收了,直到中断发生。

5.3 DMA的中断处理函数:HAL_UARTEx_RxEventCallback

中断角色的转变: 在纯中断接收(或超时解析)方法中,UART 中断几乎是为每个字节服务的。但在 DMA + 空闲中断模式下,UART 中断的角色发生了根本性转变!它不再关心单个字节的到达(因为这由 DMA 在后台处理了),而是主要关注一个更宏观的事件:一帧数据传输的结束,这个结束的标志就是我们接下来要讲的"总线空闲"。

当 UART 检测到总线空闲(IDLE 事件),或者 DMA 传输达到预期的一半/全部(如果使能了 HT/TC 中断)时,会触发 UART 的全局中断。HAL 库的中断处理程序在识别到是这些事件后,会调用这个扩展回调函数

cpp 复制代码
/**
 * @brief UART DMA接收完成或空闲事件回调函数
 * @param huart UART句柄
 * @param Size 指示在事件发生前,DMA已经成功接收了多少字节的数据
 * @retval None
 */
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);
    }
}

这个回调函数是 DMA+空闲中断方法的核心枢纽:

  1. 确认身份 (if 判断): 检查是否是目标串口触发的事件。
  2. 停止 DMA (HAL_UART_DMAStop): 空闲事件发生时,明确停止当前的 DMA 传输。这很重要,因为 DMA 可能还在等待更多数据(如果设置的 Size 比较大)。
  3. 复制数据 (memcpy): 将 DMA 缓冲区 (`uart_rx_dma_buffer`) 中实际接收到的 `Size` 个字节 复制到我们的应用缓冲区 (`uart_dma_buffer`)。Size 参数非常关键,它告诉我们这次空闲事件发生前,DMA 到底接收了多少数据。
  4. 设置标志 (uart_flag = 1;): 通知主循环中的处理任务。
  5. 清空 DMA 缓冲区 (memset): 为下一次接收做好准备。
  6. 重新启动接收 (HAL_UARTEx_ReceiveToIdle_DMA): 极其重要! 必须重新调用此函数,才能接收下一次的数据传输。
  7. (可选) 禁用半满中断: 如果在初始化时禁用了,通常在重新启动后也需要再次禁用。

关于 Size 参数: 在空闲中断场景下,Size 表示从上次启动 ReceiveToIdle_DMA 到本次空闲中断发生之间,DMA 实际接收到的字节数。它可能小于你启动时设置的 DMA 缓冲区总大小。所以 memcpy 时必须使用 Size 作为长度,而不是 `sizeof(uart_rx_dma_buffer)`,否则会复制无效数据。

5.4 主循环任务 uart_proc

当回调函数举起 `uart_flag` 后,主循环中运行的处理任务(或定时器任务)就可以来处理这批数据了:

cpp 复制代码
/**
 * @brief  处理 DMA 接收到的 UART 数据
 * @param  None
 * @retval None
 */
void uart_proc(void)
{
    // 1. 检查"到货通知旗"
    if(uart_flag == 0) 
        return; // 旗子没举起来,说明没新货,直接返回
    
    // 2. 放下旗子,表示我们已经注意到新货了
    //    防止重复处理同一批数据
    uart_flag = 0;
	
    // 3. 处理 "待处理货架" (uart_dma_buffer) 中的数据
    //    这里简单地打印出来,实际应用中会进行解析、执行命令等
    printf("DMA data: %s\n", uart_dma_buffer);
    //    (注意:如果数据不是字符串,需要用其他方式处理,比如按字节解析)
    
    // 4. 清空"待处理货架",为下次接收做准备
    memset(uart_dma_buffer, 0, sizeof(uart_dma_buffer));
}

这个处理任务的逻辑很简单:

  1. 检查标志位 `uart_flag`。
  2. 如果标志位为 1,则将其清零,防止重复处理。
  3. 处理 `uart_dma_buffer` 中的数据(这里是打印)。
  4. 清空 `uart_dma_buffer`。

通过这种方式,数据接收(DMA + 中断回调)和数据处理(主循环任务)实现了分离,CPU 仅在每帧数据结束时被短暂占用,处理任务则可以在 CPU 空闲时执行。

数据流转枢纽:深入环形缓冲区 (Ring Buffer)

前面的例子中,我们使用了简单的线性数组作为接收缓冲区。当数据量不大或处理及时的情况下,这种方式是可行的。但如果数据产生速度快于处理速度,或者我们需要更灵活地处理数据流,线性缓冲区很快就会遇到瓶颈(比如频繁的数据搬移或溢出)。这时,环形缓冲区 (Ring Buffer) 闪亮登场,它是一种非常高效且常用的数据结构,尤其适用于处理像 UART 这样的串行数据流。

六、环形缓冲区

6.1 什么是环形缓冲区?

想象一条固定长度的传送带,货物(数据)从一端放上,从另一端取下。当传送带转到底时,又会绕回到开头。环形缓冲区就类似这个概念:

  • 它使用一段固定大小的连续内存(就像一个普通数组)。
  • 它维护两个关键的指针(或索引):一个指向下一个可读 数据的位置("读指针" read_index),另一个指向下一个可写 的位置("写指针" write_index)。
  • 当数据被写入缓冲区时,写指针向前移动;当数据被读取时,读指针向前移动。
  • 关键在于"环形":当指针移动到缓冲区的末尾时,它会自动**"回绕"**到缓冲区的开头,继续移动。这使得缓冲区看起来像一个首尾相连的环。

环形缓冲区示意图

6.2 工作原理:读写指针与状态判断

环形缓冲区的精髓在于如何管理读写指针以及判断缓冲区的状态(空、满、或部分填充)。基本操作如下:

✍️写入数据 (Producer):

当有新数据需要存入时,生产者(比如 UART 中断服务程序)检查缓冲区是否未满 。如果未满,就将数据放入写指针 write_index 指向的位置,然后将 write_index 向前移动一位(如果到达末尾则回绕到开头)。

📖读取数据 (Consumer):

当需要取出数据时,消费者(比如主循环中的处理任务)检查缓冲区是否非空 。如果不为空,就从读指针 read_index 指向的位置取出数据,然后将 read_index 向前移动一位(同样,到达末尾则回绕)。

⚪空状态判断:

通常,当 read_index 等于 write_index 时,表示缓冲区为空。(具体需结合满状态判断方法)


❓ 满状态判断的挑战与策略

棘手之处在于:如果仅用 read_index == write_index 判断空,那么当缓冲区刚好写满时,写指针回绕后也会等于读指针,导致空满状态混淆。常见解决方法有:

方法一:牺牲存储单元

有意保留一个单元不使用。当写指针的下一个位置是读指针时,即认为已满。read_index == write_index 明确表示空。

优点: 逻辑简单直观。
**缺点:**浪费一个存储单元。

方法二:额外计数器

维护一个变量记录有效数据数量。写入时+1,读取时-1。通过计数器判断空(0)或满(size)。

优点: 不浪费空间。
**缺点:**需要额外变量,并发操作计数器可能需加锁。

⭐方法三:镜像指示位 (Mirror Bit - 本代码库实现)

为读写索引各分配一个"镜像位"(`read_mirror`, `write_mirror`)。每次指针越过缓冲区末尾(回绕)时,翻转其镜像位。

  • 空: read_index == write_index read_mirror == write_mirror
  • 满: read_index == write_index read_mirror != write_mirror

优点: 不浪费空间,无需额外计数器,状态判断精确。
**缺点:**索引位数减少(本例中为15位,限制容量为32KB),逻辑稍复杂。


6.3 环形缓冲区的优势

相比简单的线性数组,环形缓冲区在处理流式数据时具有显著优势:

  • 高效的内存利用: 空间固定,重复使用,避免了动态内存分配的开销和碎片问题。
  • 无需数据搬移: 线性数组在读取数据后,如果要保持后续数据连续,通常需要将后面的数据向前移动,这非常耗时。环形缓冲区通过移动读指针即可,无需搬移数据本身。
  • 天然解耦生产者和消费者: 生产者(如中断)只管往缓冲区里写,消费者(如主任务)只管从缓冲区里读。只要缓冲区没满或没空,两者可以异步地以不同的速率工作,起到了很好的缓冲和削峰填谷的作用。这对于处理速率不匹配或有突发数据的情况(如 UART 通信)非常有益。
  • 线程安全(需注意): 环形缓冲区本身提供了解耦机制,但在多线程或中断环境中使用时,仍需对指针操作进行保护(例如,关中断、使用原子操作或锁),以防止读写指针在并发访问时出错。不过,单生产者单消费者模型通常可以设计成无锁(lock-free)的,效率很高。

七、深入 HAL 库 API:幕后英雄们

之前的代码讲解展示了超时解析的逻辑,但具体和硬件打交道的部分,我们依赖了 STM32 HAL 库提供的 API(应用程序接口)。理解这些 API 的工作方式,就像了解话剧表演中每个演员的角色和台词,能帮助我们更深入地掌握整个流程。让我们来认识一下这几位"幕后英雄"吧!

7.1 "预订下一个包裹":HAL_UART_Receive_IT

cpp 复制代码
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);

这位英雄的角色是**"快递预订员"** 。每次调用它,就好比你跟前台说:"嘿,请帮我留意一下,下一个 (而且仅仅一个 ,因为我们Size=1)送来的包裹,到了之后立刻 (中断方式)通知我(触发中断),并把它放到这个指定的位置(pData指向的内存)。"

更形象的比喻: 想象你在门口装了一个红外传感器(硬件中断标志位 RXNE - Read Data Register Not Empty),专门检测有没有人(数据字节)进来。调用 `HAL_UART_Receive_IT` 就是给这个传感器"通电"(使能 RXNE 中断),并告诉它:"一旦有人触发了你,就立刻拉响警报(触发中断),同时把这个人引导到指定的房间(pData 指向的内存地址)。"

它的"台词"(参数)解读:

  • UART_HandleTypeDef *huart: 告诉预订员是哪个"快递站"(比如 &huart1)的包裹。
  • uint8_t *pData: 指定包裹送达后要存放的"货位地址"(比如 &uart_rx_buffer[uart_rx_index])。
  • uint16_t Size: 指定这次预订接收的包裹"数量"。在我们的超时解析法中,为了能及时响应每个字节,我们每次都只预订 1 个字节。

关键点 IT (Interrupt - 中断): 函数名末尾的 _IT 是关键!它表示这是一个非阻塞的操作。你跟前台预订完就可以走了,去做别的事情。当包裹真的到达时,硬件会自动触发一个"中断信号",就像前台给你打了个紧急电话,而不是让你一直在那里傻等。这使得 CPU 可以高效地处理其他任务,只在数据到达时才介入。

调用时机:

  1. 初始化时: 在系统启动后,需要调用一次 HAL_UART_Receive_IT(&huart1, &uart_rx_buffer[0], 1); 来"预订"第一个字节的到来,相当于第一次给门口的传感器通电。
  2. 中断回调中: 当一个字节成功接收并触发了中断回调函数(HAL_UART_RxCpltCallback)后,必须 在回调函数内部再次调用 HAL_UART_Receive_IT 来"预订"下一个字节。这就像警报响过后,你需要手动再次给传感器"重新通电",否则它检测到一次之后就失效了。

简化流程:

bash 复制代码
你调用 HAL_UART_Receive_IT(huart, pData, 1);
  |
  v
HAL库: "好的, 知道了!"
  - 检查串口状态 (huart->RxState)
  - 记录下你的要求: pData地址, Size=1
  - 关键: 打开'接收不为空'中断开关 (使能 RXNEIE 位)
  - 更新串口状态为 "正在接收(中断模式)"
  |
  v
(CPU去做别的事情了...)

(某个时刻, 硬件接收到一个字节)
  |
  v
硬件: "嘿, 数据寄存器里有新东西了!" (自动设置 RXNE 标志位)
  |
  v
(如果 RXNEIE 中断开关是打开的)
  |
  v
硬件: "触发 UART 中断信号给 CPU!"
  |
  v
CPU: "收到中断! 跳转到 UART 中断服务程序..."

7.2 "快递已送达!":HAL_UART_RxCpltCallback

cpp 复制代码
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart);

这位是"收货确认员",但它不是你主动去调用的,而是 HAL 库这位"大管家"在特定事件发生时自动呼叫的。哪个特定事件呢?就是你之前通过 HAL_UART_Receive_IT 预订的那个字节,它完整接收成功了!

这个函数就像是前台给你的那个"紧急电话":"喂!你之前订的那个包裹(那 1 个字节)已经送到了!请指示下一步操作。"

它的"台词"只有一个参数:

  • UART_HandleTypeDef *huart: 表明是哪个"快递站"(比如 `USART1`)打来的电话,方便你管理多个串口。

回调 (Callback) 的含义: 这个函数是一种特殊的设计模式,叫做"回调"。你不需要显式地调用它,只需要把它实现好(就像写好接电话后的标准处理流程)。HAL 库会在合适的时机(数据接收完成时)自动"回头调用" (call back) 你写的这个函数。

重要特性:弱函数 (Weak Function)

HAL 库中的很多回调函数(包括这个)都被声明为 __weak (或者类似的编译器特定语法)。这就像一个"默认的接线员",如果你不提供自己的版本,系统会使用这个默认的、通常什么也不做的版本。但一旦你在自己的代码中定义了一个同名的、非 weak 的函数(就像我们做的那样),链接器就会忽略那个弱的默认版本,转而使用你提供的"定制接线员"。这使得用户可以方便地在自己的代码中"覆盖"或"重写" HAL 库的默认中断处理行为,而无需修改库本身的源码,大大提高了代码的可维护性和模块化。

函数内部的标准处理流程(我们代码中的实现):

  1. 确认"来电显示" (if (huart->Instance == USART1)): 检查是不是我们关心的那个串口(USART1)打来的电话。
  2. 记录"收货时间" (uart_rx_ticks = uwTick;): 立刻记下当前时间,这是超时判断的关键依据。
  3. 更新"库存数量" (uart_rx_index++;): 将表示已收字节数的"计数器"加一。(注意:HAL 库在调用这个回调之前,已经默默地把收到的那个字节放到了你上次通过 HAL_UART_Receive_IT 指定的 pData 位置了)。
  4. "预订下一个包裹" (HAL_UART_Receive_IT(...)): 这是最重要的环节!处理完这个字节后,必须立刻告诉前台:"好的,知道了。请继续帮我留意下一个包裹,到了还放老地方的下一个格子(&uart_rx_buffer[uart_rx_index]),数量还是 1 个。" 这一步重新"激活"了中断接收,确保程序能够接收后续的字节。如果忘了这一步,你的串口就变成"一次性"的了。

工作循环: HAL_UART_Receive_IT 启动监听 -> 硬件接收完成 -> 触发中断 -> HAL 库调用 HAL_UART_RxCpltCallback -> 在回调中再次调用 HAL_UART_Receive_IT 启动下一次监听... 这个循环不断进行,实现了字节流的连续接收。

中断 -> 回调 流程:

bash 复制代码
CPU: "收到 UART 中断! 跳转到 UART 中断服务程序 (ISR)..."
  |
  v
(在 ISR 内部)
HAL库的 ISR 处理代码: "让我看看是啥情况..."
  - 检查是哪个中断标志位触发了? (比如是 RXNE?)
  - 如果是 RXNE:
    - 从硬件数据寄存器读取那个字节
    - 把它存到上次 Receive_IT 指定的 pData 地址
    - 清除 RXNE 中断标志位 (告诉硬件处理完了)
    - "哦, 这次接收操作 (Size=1) 完成了!"
    - "是时候通知用户了..."
    - 调用 HAL_UART_RxCpltCallback(huart);
  |
  v
(进入我们写的 HAL_UART_RxCpltCallback 函数)
我们代码: "电话响了!"
  - if (huart->Instance == USART1) { ... }
  - uart_rx_ticks = uwTick;
  - uart_rx_index++;
  - 再次调用 HAL_UART_Receive_IT(...) 为下一次接收做准备!
  - (函数结束, 从中断返回)

7.3 "发快递啦!":HAL_UART_Transmit

cpp 复制代码
HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);

这位是"快递发货员"。当你需要通过串口向外发送数据时,就调用它。就像你把打包好的、写好地址的一批货交给发货员,告诉他:"把这些货(数据)从这个快递站(串口)发出去。"

它的"台词"(参数)解读:

  • UART_HandleTypeDef *huart: 指定从哪个"快递站"(比如 &huart1)发货。
  • uint8_t *pData: 指向你要发送的那批"货物"(数据缓冲区,比如 my_printf 函数中的 buffer)。
  • uint16_t Size: 这批货物的"总件数"(要发送的数据长度,比如 my_printf 计算出的 len)。
  • uint32_t Timeout: 发货超时时间(单位毫秒)。如果在这个时间内,硬件一直忙碌无法开始发送,函数就会放弃并返回一个超时错误。你可以设置为 HAL_MAX_DELAY 表示一直等待直到能发送为止。

阻塞 vs. 非阻塞 (中断/DMA):

阻塞模式 (HAL_UART_Transmit)

你把一箱货交给发货员,然后就站在旁边一直盯着他,直到他把箱子里所有的货一件件发出去(或者等到超时你放弃了),你才能离开。

**优点:**代码简单,逻辑直接。

**缺点:**如果货物很多或发送慢,CPU 会一直卡在这里等待,无法处理其他紧急事务。

bash 复制代码
你: 调用 Transmit(data, size)
CPU: (循环检查硬件状态...)
   - 硬件发送缓冲区空了吗? (检查 TXE 位)
   - 如果空了, 把下一个字节放入发送缓冲区
   - (重复 size 次...)
   - 所有字节都进发送缓冲区了?
   - 硬件发送完成了吗? (检查 TC 位)
   - 完成了! 函数返回。
(CPU 被阻塞了很久...)
非阻塞模式 (_IT / _DMA)

你把货交给发货员,然后告诉他:"货发完了打我电话(中断)。" 然后你就离开了,去做别的事。发货员自己处理,完成后通过中断通知你。

**优点:**CPU 不会被阻塞,可以高效处理其他任务。

**缺点:**需要配合中断回调函数来处理"发送完成"事件,逻辑稍微复杂一点。DMA 方式效率最高,连字节搬运都无需 CPU 操心。

对于少量调试信息,阻塞方式足够简单。但在需要同时处理接收和其他任务的场景下,非阻塞方式通常是更好的选择。

在我们的 `my_printf` 函数中,就是调用了它来将格式化好的字符串通过串口发送给电脑的串口助手。

7.4 "精准计时器":uwTick

cpp 复制代码
extern volatile uint32_t uwTick;

这位不是函数,而是一个全局变量,可以理解为整个系统(或大楼)的"中央时钟脉冲计数器"。

  • 来源: 它通常由一个叫做 SysTick 的硬件定时器驱动。你可以把 SysTick 配置为每 1 毫秒产生一次中断。

  • 工作方式: 在 SysTick 定时器的中断服务函数(通常是 SysTick_Handler)中,HAL 库会自动执行 HAL_IncTick() 函数,这个函数的作用就是把 uwTick 这个变量的值加 1。

  • 作用: 因为 uwTick 每毫秒加 1,所以它的值就代表了从系统启动开始经过的总毫秒数。

    bash 复制代码
    系统启动 -> uwTick = 0;
    1ms 后   -> SysTick 中断 -> HAL_IncTick() -> uwTick = 1;
    再 1ms 后 -> SysTick 中断 -> HAL_IncTick() -> uwTick = 2;
    再 1ms 后 -> SysTick 中断 -> HAL_IncTick() -> uwTick = 3;
    ... (以此类推)

超时判断的基石: 在我们的超时解析逻辑 if (uwTick - uart_rx_ticks > UART_TIMEOUT_MS) 中,uwTick 提供了当前的精确时间,uart_rx_ticks 保存了上次收到字节的时间戳,两者相减就得到了时间间隔,从而判断是否超时。uwTick 是实现各种基于时间的逻辑(延时、超时等)的基础。

volatile 关键字: 这个关键字告诉编译器,uwTick 的值随时可能在程序当前流程之外被改变(被 SysTick 中断改变)。因此,每次读取 uwTick 时,编译器都必须老老实实地从内存中重新获取它的最新值,而不能使用可能存在于寄存器中的旧的缓存值,从而保证了计时逻辑的准确性。

回卷问题: 作为一个 32 位无符号整数,uwTick 大约每 49.7 天会溢出回零。但正如前面提到,使用 (uint32_t)currentTime - (uint32_t)lastTime 的方式计算时间差,只要时间差没有超过约 24.8 天,就能正确处理回卷,所以在绝大多数应用中无需担心。

7.5 HAL 库的设计哲学与工作机制

了解了具体的 API 后,我们不妨退后一步,看看 STMicroelectronics 设计 HAL (Hardware Abstraction Layer - 硬件抽象层) 库的初衷和一些核心思想。这有助于我们理解为什么 API 是这样设计的,以及如何更有效地使用它。

🎯 核心目标:抽象与可移植性

想象一下,如果没有 HAL 库,你需要直接操作底层的硬件寄存器。不同的 STM32 型号(比如 F1, F4, L4, H7 系列)它们的寄存器地址、位定义可能千差万别。为 F1 写的外设驱动代码,几乎无法直接用在 F4 上。

HAL 库的核心价值就在于抽象 。它提供了一套统一的、功能导向的 API ,屏蔽了底层硬件寄存器的差异。你只需要调用像 HAL_UART_Transmit 这样的函数,而不需要关心具体是往哪个地址的哪个寄存器的哪个位写入了什么值。这使得你的应用程序代码具有更好的可移植性,更换不同的 STM32 芯片时,上层代码的修改量大大减少。

抽象层级示意:

bash 复制代码
+-----------------------+
|     你的应用程序代码    |
| (调用 HAL_UART_... ) |
+-----------+-----------+
            |
            v
+-----------+-----------+
|      HAL 库 API       |
| (HAL_UART_Transmit 等) |
+-----------+-----------+
            |  (HAL 内部根据不同芯片型号,执行不同的底层代码)
            v
+-----------+-----------+   +-----------+-----------+
| STM32F1xx 底层驱动    |   | STM32F4xx 底层驱动    | ...
| (操作 F1 寄存器)    |   | (操作 F4 寄存器)    |
+-----------------------+   +-----------------------+
🔧 核心机制:句柄 (Handle) 与状态管理

你会发现几乎所有的 HAL 函数都需要一个 UART_HandleTypeDef *huart 这样的参数。这个被称为"句柄" (Handle) 的结构体非常关键。

它就像是这个外设(比如 USART1)的"身份证"和"状态记录本"。里面包含了:

  • **配置信息:**初始化时设定的波特率、数据位、停止位等参数。
  • 状态变量: 记录当前外设是空闲、正在发送还是正在接收(比如 huart->gState, huart->RxState)。HAL 函数会根据这些状态来判断能否执行某个操作。
  • 数据指针和计数器: 在中断或 DMA 模式下,记录用户数据缓冲区地址(pRxBuffPtr)、还需要接收/发送多少数据(RxXferSize, RxXferCount)等。
  • 底层硬件寄存器地址: 指向具体的硬件实例(比如 huart->Instance = USART1;)。

通过传递这个句柄,HAL 函数就能知道要操作哪个具体的硬件实例,以及它当前的状态和相关数据信息,从而实现正确的操作和管理。

🎣 核心机制:回调 (Callback) 与弱函数 (Weak Functions)

正如在 HAL_UART_RxCpltCallback 中提到的,HAL 库广泛使用回调机制来处理异步事件(如中断)。

它定义了一系列标准的事件回调函数(如接收完成、发送完成、错误发生等),并将它们声明为弱函数。这意味着:

  1. HAL 库提供了一个(通常为空的)默认实现。
  2. 用户可以在自己的代码中提供一个同名的、非弱的实现来"覆盖"默认行为。
  3. 当对应的硬件事件发生时(例如,在中断服务程序中检测到接收完成),HAL 的中断处理代码会调用这个(可能是用户覆盖后的)回调函数。

这种设计使得用户代码和库代码得以解耦。用户无需修改库源码,就能方便地将自己的处理逻辑"挂载"到特定的硬件事件上。

🚦 核心机制:多模式支持 (阻塞/中断/DMA)

HAL 库通常为数据传输操作(如 UART 收发、SPI 收发等)提供多种模式:

  • 阻塞模式 (Polling): 函数会一直等待操作完成才返回。简单直接,但会阻塞 CPU。
  • 中断模式 (Interrupt): 函数启动操作后立刻返回,操作完成后通过中断和回调函数通知用户。CPU 不被阻塞,但中断处理本身会消耗 CPU 时间。
  • DMA 模式 (Direct Memory Access): 配置 DMA 控制器来处理内存和外设之间的数据传输,传输过程几乎不占用 CPU 时间,完成后通过中断通知用户。效率最高,尤其适合大数据量传输。

提供这些不同的模式,让开发者可以根据应用的具体需求(实时性要求、数据量大小、CPU 负载等)选择最合适的工作方式。

理解了这些设计思路,能帮助我们更好地利用 HAL 库的优势,并更容易地排查问题。例如,当中断没有按预期触发时,我们知道要去检查是否正确调用了 _IT 版本的函数、是否在回调中重新启动了接收、中断是否被正确使能等。

7.6 "委托全能搬运工":HAL_UARTEx_ReceiveToIdle_DMA

cpp 复制代码
HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);

这位英雄是 HAL_UART_Receive_IT 的升级版,专门用于配合 DMA 和空闲中断。它不仅启动接收,还同时配置和启动了 DMA 控制器,并设置了空闲线路检测。

它的指令更复杂:"嘿,DMA 搬运工 (已通过 `huart` 句柄关联好),你现在去串口收货口 (`huart->Instance`) 守着。一旦有货 (`数据字节`) 进来,你就用你专属的通道,直接把它搬到仓库的这个临时区域 (`pData`)。这个区域总共能放 `Size` 件货。你一直搬,同时注意听着收货口的动静,如果突然安静了超过一个字节的时间(总线空闲,IDLE 事件),或者你把这个临时区域 (`pData` 指向的内存,大小为 `Size`) 给堆满了 (DMA TC 事件),都要立刻通过内部专线(中断)向我(CPU)汇报!"

关键参数和 `HAL_UART_Receive_IT` 类似:

  • huart: 指定哪个串口及其关联的 DMA 配置。
  • pData: DMA 搬运的目标内存地址(通常是 DMA 专用缓冲区)。
  • Size: 本次 DMA 传输期望的最大字节数(DMA 缓冲区的总大小)。DMA 会持续接收,直到接收满 `Size` 个字节,或者中途检测到总线空闲。

"Ex" 与 "ToIdle": 函数名中的 "Ex" (Extended) 表明这是 HAL 库的扩展功能。 "ReceiveToIdle" 则清晰地指出了它的核心机制:接收数据,直到检测到总线空闲。它内部封装了启动 DMA、使能 UART 空闲中断 (IDLEIE) 和可能的 DMA 相关中断的复杂逻辑。

调用这个函数后,CPU 基本就可以高枕无忧了,数据的字节级搬运完全交给了 DMA,CPU 只需等待最终的"收工报告"(中断)。

7.7 "紧急暂停!":HAL_UART_DMAStop

cpp 复制代码
HAL_StatusTypeDef HAL_UART_DMAStop(UART_HandleTypeDef *huart);

这位的作用是**"叫停搬运工"**。当你因为某种原因(比如检测到了空闲中断,明确知道对方已经停止发送了)需要强制停止当前正在进行的 UART DMA 传输时,就调用它。

**类比:**老板(CPU)通过内部电话(中断)得知收货口没货了(空闲事件),但搬运工(DMA)可能还在那里傻等(因为它被告知要搬满 `Size` 个才算完)。老板立刻通知搬运工:"好了好了,别等了,活儿先停下!"

它的参数只有一个:

  • huart: 指明要停止哪个串口的 DMA 操作。

为何在 RxEventCallback 中调用? 在 DMA + 空闲中断模式下,当空闲中断触发 `RxEventCallback` 时,我们明确知道发送方已经停止发送。但 DMA 控制器本身可能还在运行(因为它设置的目标是接收 `Size` 个字节,可能还没满)。此时调用 `HAL_UART_DMAStop` 可以利落地终止当前的 DMA 传输,清理相关状态,防止 DMA 出错或继续无效等待,为后续重新启动 DMA 接收做好准备。

7.8 "收工汇报!":HAL_UARTEx_RxEventCallback

cpp 复制代码
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size);

这位是 DMA + 空闲中断模式下的**"核心事件处理员"**。它也是一个弱函数回调,但触发它的事件比较特殊,通常是以下两者之一(或两者都配置了):

  1. UART 空闲中断 (IDLE): 检测到串口总线进入空闲状态,表明一次数据传输很可能已经结束。
  2. DMA 传输完成/半完成 (TC/HT): DMA 控制器完成了预设数量(Size 或 Size/2)的数据搬运。

在我们的应用中,主要关心的是 IDLE 空闲事件。当这个事件发生时,HAL 库的 `HAL_UART_IRQHandler` 会捕捉到,并最终调用这个回调函数。

它的"汇报内容"(参数):

  • huart: 哪个串口的事件。
  • Size: 至关重要! 这个参数告诉你,从上次启动 HAL_UARTEx_ReceiveToIdle_DMA 到本次事件(特别是空闲事件)发生时,DMA 搬运工实际搬运了多少个字节的数据到内存缓冲区。这个值可能小于、等于你启动 DMA 时设定的缓冲区总大小。

函数内部的标准处理流程(DMA+空闲中断模式):

  1. 确认串口 (if 判断)。
  2. 停止 DMA (HAL_UART_DMAStop(huart);): 如前所述,确保停止当前的 DMA 操作。
  3. 处理数据 (memcpy + 设置标志): 使用回调函数提供的 Size 参数,将 DMA 缓冲区中实际接收到的有效数据,拷贝到应用程序的处理缓冲区(例如 `uart_dma_buffer`)。然后设置一个标志(如 `uart_flag = 1;`)通知主循环。
  4. 清空 DMA 缓冲区 (memset): 为下一次接收做准备。
  5. 重新启动接收 (HAL_UARTEx_ReceiveToIdle_DMA(...)): 必须再次调用它,才能接收下一帧数据。

🔑

关键在于 Size: 正确使用 `Size` 参数是从 DMA 缓冲区提取有效数据的关键。如果忽略 `Size` 而总是处理整个 DMA 缓冲区,可能会处理到上次残留的数据或者无效数据。

7.9 "屏蔽干扰信号":__HAL_DMA_DISABLE_IT (宏)

cpp 复制代码
__HAL_DMA_DISABLE_IT(HANDLE, INTERRUPT)

严格来说,这位不是一个函数,而是一个宏 (Macro)。宏在编译预处理阶段就会被替换成实际的寄存器操作代码。它的作用是关闭指定的 DMA 通道上的特定中断。

**类比:**DMA 搬运工除了能在"完成任务"或"检测到空闲"时给老板打电话(触发中断)外,还可能在"任务完成一半"(Half Transfer)时也打个电话。如果我们不关心这个"中途汇报",觉得它是个干扰,就可以用这个宏告诉系统:"别让搬运工因为'完成一半'这种小事就给我打电话!"

参数解读:

  • HANDLE: DMA 的句柄(例如 `&hdma_usart1_rx`),指明是哪个 DMA 通道。

  • INTERRUPT: 要禁用的具体中断类型,HAL 库定义了一些常量,例如:

    • DMA_IT_TC: 传输完成中断 (Transfer Complete)
    • DMA_IT_HT: 半传输中断 (Half Transfer)
    • DMA_IT_TE: 传输错误中断 (Transfer Error)

    在我们的例子中,使用 DMA_IT_HT 来禁用半传输中断。

为何禁用 HT 中断? 在空闲中断接收模式下,我们通常只关心数据帧的结束(由 IDLE 中断指示),而不关心 DMA 缓冲区是否刚好填满了一半。HT 中断如果触发,可能会调用与 TC/IDLE 相同的 `RxEventCallback`,干扰我们判断数据帧结束的逻辑。因此,显式禁用 HT 中断可以简化处理流程,避免不必要的干扰。

通常,如果在初始化后调用了一次 __HAL_DMA_DISABLE_IT(..., DMA_IT_HT),那么在 `RxEventCallback` 中重新启动 DMA (HAL_UARTEx_ReceiveToIdle_DMA) 之后,可能也需要再次调用这个宏来确保 HT 中断保持禁用状态,因为启动函数可能会重新使能它。

相关推荐
被遗忘的旋律.18 小时前
Linux驱动开发笔记(二十四)——(上)IIO + icm20608驱动
linux·驱动开发·笔记
zhangrelay18 小时前
thinkpad等笔记本电脑在ubuntu等linux系统下电池校准如何做?
笔记·学习
阿华hhh18 小时前
day4(IMX6ULL)<定时器>
c语言·开发语言·单片机·嵌入式硬件
钰珠AIOT18 小时前
在电源的滤波电路中10uf 和100nF 的电容滤波的频率大概是多少?如何计算?
单片机·物联网
_Kayo_18 小时前
Node.JS 学习笔记8
笔记·学习·node.js
weixin_4624462318 小时前
使用 Docker / Docker Compose 部署 PdfDing —— 个人 PDF笔记
笔记·docker·pdf
知识分享小能手18 小时前
Oracle 19c入门学习教程,从入门到精通,Oracle 数据表对象 —— 语法知识点详解与案例实践(10)
数据库·学习·oracle
CQ_YM18 小时前
ARM中断
arm开发·嵌入式硬件·arm
炽烈小老头18 小时前
【每天学习一点算法 2026/01/22】杨辉三角
学习·算法
羽获飞18 小时前
51单片机UART-串口通讯的配置方法
stm32·单片机·嵌入式硬件