
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

DMA 源码分析与实战 ------ 从寄存器到状态机的完整闭环
面向初学者的通俗解读,配合逐行注释的代码,以及每个知识点后的"一句话总结"。
本文基于 STM32F103 + HAL 库,用串口 DMA 收发实战,把 DMA 的初始化、启动、中断回调、状态机设计彻底讲透。
目录
- 需求说明与需求分析
- [CubeMX 环境配置](#CubeMX 环境配置)
- 生成配置信息源码阅读:句柄与寄存器
- [初始化通道:HAL_DMA_Init 深入](#初始化通道:HAL_DMA_Init 深入)
- [DMA 通道与外设关联:__HAL_LINKDMA](#DMA 通道与外设关联:__HAL_LINKDMA)
- 实战编码:普通模式收发(demo1)
- 循环模式(demo2)
- [收发源码阅读:从 HAL_UART_Receive_DMA 说起](#收发源码阅读:从 HAL_UART_Receive_DMA 说起)
- 中断触发与回调链
- [DMA 收发状态机](#DMA 收发状态机)
- 附录:触类旁通与软件分层
1. 需求说明与需求分析
1.1 需求说明
本示例演示串口的 DMA 模式 进行数据收发:上位机给开发板发数据,开发板用串口 DMA 模式接收,收满一定数量后,再用串口 DMA 模式一次性回传给上位机。
通过该示例,模拟了系统的三个工作阶段:
- 上位机发送,开发板用串口 DMA 模式接收数据;
- 接收完成,启动串口 DMA 数据回传流程;
- 回传完成,切换到 DMA 接收状态(循环往复)。

1.2 需求分析
- 需要一个能与电脑连接的串口(开发板用 CH340K 芯片做 USB 转 UART TTL);
- 需要用纯 DMA 方式完成串口收发;
- 用 LED4 指示灯指示 DMA 通信状态。
1.3 通过示例要掌握的技能
- DMA 通道初始化的方法;
- 启动串口 DMA 接收数据的方法;
- 启动串口 DMA 发送数据的方法;
- DMA 接收完成回调函数的实现;
- DMA 发送完成回调函数的实现;
- 通过串口 DMA 过程,对其他外设 DMA 有触类旁通的认识。
📌 本知识点一句话总结
本示例要演示"串口纯 DMA 收发"的完整闭环:上位机发数据 → DMA 接收满 N 字节 → 触发回调 → 业务处理 → DMA 一次性回传 → 回传完成再切回接收。要掌握的核心技能是:DMA 通道初始化、
HAL_UART_Receive_DMA/HAL_UART_Transmit_DMA两个接口、以及收/发完成回调函数的实现,并由此形成对其他外设 DMA 模式的触类旁通。
2. CubeMX 环境配置
2.1 开启调试(SWD)
配置 Serial Wire Debug(SWD),开启调试与烧录引脚(PA13/SWDIO、PA14/SWCLK)。
⚠️ 若不勾选,芯片第一次烧录后调试引脚可能被配成普通 IO,导致芯片锁死、无法再烧录。SWD 是占用引脚最少的调试方式。

2.2 设置串口 USART1 参数
- 波特率 115200;
- 9 位数据(实际 8 位数据 + 1 位校验);
- 奇校验(Odd);
- 1 位停止位;
- 收发模式(TX + RX);
- 无硬件流控。
PA9、PA10 被自动选定为 USART1_TX、USART1_RX(这是标准手册固定的,CubeMX 自动配置)。
2.3 开启 USART1 的 DMA
- 接收通道 映射到
DMA1_Channel5(外设→内存); - 发送通道 映射到
DMA1_Channel4(内存→外设); - 均设置:外设地址不递增、内存地址递增、字节对齐、普通模式、低优先级。
当选中 USART1_RX/TX 时,CubeMX 会自动选 Channel5/Channel4,因为串口 TX/RX 与通道的对应关系是确定的(硬映射)。

2.4 使能中断
- 开启 DMA 通道 4 和 5 的全局中断(通知收发完成后的处理);
- 开启 USART1 global interrupt(串口全局中断)。
为什么还要开启 USART1 的中断? 因为 DMA 通道搬完数据,不代表串口真正发送/接收完成------它们是两个独立的设备。所以需要勾选 USART1 中断,未来确保串口正确完成工作,同时处理可能的异常。
注意:这里开启 USART1 中断只是在 NVIC 层面开启,真正让 USART1 以中断模式工作还需调用
XXX_IT函数。后续我们并不调用它,所以不用担心 DMA 中断和 USART1 中断互相干扰。

2.5 开启 LED4
LED4 和 MCU 接线为 PF8 ,GPIO 输出采用开漏模式(根据二极管朝向和外部电路)。

2.6 配置时钟树与项目
-
外部晶振 HSE 选 Crystal/Ceramic Resonator;

-
HCLK 输入 72 回车,软件自动计算倍频系数;

-
配置项目管理信息,生成代码。
📌 本知识点一句话总结
CubeMX 配置串口 DMA 的关键点:① 必须勾选 SWD 否则芯片锁死;② 串口配 115200/9位/奇校验/1停止位/无流控;③ DMA 接收映射 Channel5、发送映射 Channel4(外设地址不增、内存地址增、字节对齐、普通模式、低优先级);④ 同时使能 DMA 通道 4/5 和 USART1 的中断------因为 DMA 和 USART 是两个独立设备,DMA 搬完不等于串口真正完成,还需 USART 中断做收尾和异常处理。
3. 生成配置信息源码阅读:句柄与寄存器
生成代码后,先别急着写业务,先读源码,具体到寄存器视角,才算真正搞懂配置。
3.1 三个全局句柄
c
/* Private variables */
UART_HandleTypeDef huart1; // 串口 USART1 句柄
DMA_HandleTypeDef hdma_usart1_rx; // USART1 接收对应的 DMA 句柄
DMA_HandleTypeDef hdma_usart1_tx; // USART1 发送对应的 DMA 句柄
3.2 三个关键结构体
① DMA_HandleTypeDef(DMA 句柄结构体)
c
typedef struct __DMA_HandleTypeDef
{
DMA_Channel_TypeDef *Instance; /*!< 寄存器基地址:指向具体 DMA 通道(如 DMA1_Channel1) */
DMA_InitTypeDef Init; /*!< DMA 初始化参数:方向、地址自增、模式等 */
HAL_LockTypeDef Lock; /*!< DMA 锁对象:线程安全保护 */
__IO HAL_DMA_StateTypeDef State; /*!< DMA 传输状态:就绪/忙碌/错误 */
void *Parent; /*!< 父对象指针:关联的外设句柄(如 UART_Handle) */
void (* XferCpltCallback)( struct __DMA_HandleTypeDef * hdma); /*!< 传输完成回调 */
void (* XferHalfCpltCallback)( struct __DMA_HandleTypeDef * hdma); /*!< 传输过半回调 */
void (* XferErrorCallback)( struct __DMA_HandleTypeDef * hdma); /*!< 传输错误回调 */
void (* XferAbortCallback)( struct __DMA_HandleTypeDef * hdma); /*!< 传输终止回调 */
__IO uint32_t ErrorCode; /*!< DMA 错误码 */
DMA_TypeDef *DmaBaseAddress; /*!< DMA 控制器基地址(如 DMA1) */
uint32_t ChannelIndex; /*!< DMA 通道索引(用于快速识别通道号) */
} DMA_HandleTypeDef;
② DMA_Channel_TypeDef(通道寄存器结构体,对应 4 个寄存器)
c
typedef struct
{
__IO uint32_t CCR; /*!< 通道配置寄存器:使能、中断、方向、循环模式等 */
__IO uint32_t CNDTR; /*!< 通道数据数量寄存器:待传字节数,每搬一个自动减 1 */
__IO uint32_t CPAR; /*!< 通道外设地址寄存器:外设数据寄存器基地址 */
__IO uint32_t CMAR; /*!< 通道存储器地址寄存器:内存缓冲区基地址 */
} DMA_Channel_TypeDef;
③ DMA_TypeDef(DMA 控制器级寄存器)
c
typedef struct
{
__IO uint32_t ISR; /*!< 中断状态寄存器:查询各通道传输完成/半完成/错误标志 */
__IO uint32_t IFCR; /*!< 中断标志清除寄存器:写 1 清除 ISR 对应位 */
} DMA_TypeDef;
④ DMA_InitTypeDef(用户设置的参数结构体)
c
typedef struct
{
uint32_t Direction; /*!< 传输方向:内存→外设 / 外设→内存 / 内存→内存 */
uint32_t PeriphInc; /*!< 外设地址是否自增(通常不增) */
uint32_t MemInc; /*!< 内存地址是否自增(通常增) */
uint32_t PeriphDataAlignment; /*!< 外设数据宽度(字节/半字/字) */
uint32_t MemDataAlignment; /*!< 内存数据宽度(字节/半字/字) */
uint32_t Mode; /*!< 工作模式:普通(Normal) / 循环(Circular) */
uint32_t Priority; /*!< 软件优先级:低/中/高/最高 */
} DMA_InitTypeDef;
DMA_InitTypeDef是"用户填参数"的结构体;后续 HAL 根据它来初始化 DMA 通道对应的寄存器。
📌 本知识点一句话总结
HAL 库用三层结构体描述 DMA:
DMA_HandleTypeDef(句柄,含 Instance 指针、Init 参数、Parent 关联、四个函数指针回调、ChannelIndex 等)、DMA_Channel_TypeDef(对应每通道 4 个寄存器 CCR/CNDTR/CPAR/CMAR)、DMA_TypeDef(对应 DMA 控制器级 ISR/IFCR)。用户通过填充DMA_InitTypeDef表达配置意图,HAL 再把它翻译成寄存器操作。这套结构体是后续所有 DMA 源码分析的基础。
4. 初始化通道:HAL_DMA_Init 深入
4.1 为什么 DMA 初始化藏在 USART 初始化里
DMA 是从属技术 ------它为任意外设搬数据,没有外设就没有 DMA 需求。所以 DMA 的初始化在 CubeMX 里是在对应外设(USART1)里开启的,生成的代码中 DMA 初始化就藏在 MX_USART1_UART_Init → HAL_UART_Init → HAL_UART_MspInit 里。
4.2 使能 DMA1 时钟
配置 DMA 本质是写寄存器,属于硬件操作,必须先使能 DMA1 时钟:
c
static void MX_DMA_Init(void)
{
/* DMA 控制器时钟使能 */
__HAL_RCC_DMA1_CLK_ENABLE();
/* DMA 中断初始化(NVIC 层) */
HAL_NVIC_SetPriority(DMA1_Channel4_IRQn, 0, 0); // 设置 DMA1 通道 4 优先级
HAL_NVIC_EnableIRQ(DMA1_Channel4_IRQn); // 使能 DMA1 通道 4 中断
HAL_NVIC_SetPriority(DMA1_Channel5_IRQn, 0, 0); // 设置 DMA1 通道 5 优先级
HAL_NVIC_EnableIRQ(DMA1_Channel5_IRQn); // 使能 DMA1 通道 5 中断
}
展开时钟使能宏:
c
#define __HAL_RCC_DMA1_CLK_ENABLE() do { \
__IO uint32_t tmpreg; \
SET_BIT(RCC->AHBENR, RCC_AHBENR_DMA1EN); /* 使能 DMA1 时钟 */ \
tmpreg = READ_BIT(RCC->AHBENR, RCC_AHBENR_DMA1EN); /* 读回产生延迟 */ \
UNUSED(tmpreg); \
} while(0U)
#define SET_BIT(REG, BIT) ((REG) |= (BIT))
#define RCC_AHBENR_DMA1EN (0x1UL << 0) /* 0x00000001 */
核心就是
RCC->AHBENR |= 0x01,把 AHB 外设时钟使能寄存器的 DMA1EN 位(bit0)置 1。
4.3 通道被访问的本质
c
// 通道 5 实例
#define DMA1_Channel5 ((DMA_Channel_TypeDef *)DMA1_Channel5_BASE)
#define DMA1_Channel5_BASE (AHBPERIPH_BASE + 0x00000058UL)
#define AHBPERIPH_BASE (PERIPH_BASE + 0x00020000UL)
#define PERIPH_BASE 0x40000000UL
// 通道 4 实例
#define DMA1_Channel4 ((DMA_Channel_TypeDef *)DMA1_Channel4_BASE)
#define DMA1_Channel4_BASE (AHBPERIPH_BASE + 0x00000044UL)
结论 :访问通道的本质,就是通过指针 访问代表通道的各个寄存器。为了不用手动算偏移量,把指针强转成 DMA_Channel_TypeDef 结构体,用结构体成员访问寄存器,变相完成"语言级命名"。
4.4 HAL_DMA_Init:三段关键逻辑
c
HAL_StatusTypeDef HAL_DMA_Init(DMA_HandleTypeDef *hdma)
{
uint32_t tmp = 0U;
if(hdma == NULL) return HAL_ERROR; // 句柄空检查
/* 参数合法性检查 */
assert_param(IS_DMA_ALL_INSTANCE(hdma->Instance));
assert_param(IS_DMA_DIRECTION(hdma->Init.Direction));
assert_param(IS_DMA_PERIPHERAL_INC_STATE(hdma->Init.PeriphInc));
assert_param(IS_DMA_MEMORY_INC_STATE(hdma->Init.MemInc));
assert_param(IS_DMA_PERIPHERAL_DATA_SIZE(hdma->Init.PeriphDataAlignment));
assert_param(IS_DMA_MEMORY_DATA_SIZE(hdma->Init.MemDataAlignment));
assert_param(IS_DMA_MODE(hdma->Init.Mode));
assert_param(IS_DMA_PRIORITY(hdma->Init.Priority));
/* ---- 第一段:计算通道索引,关联所属 DMA ---- */
#if defined (DMA2)
if ((uint32_t)(hdma->Instance) < (uint32_t)(DMA2_Channel1))
{
hdma->ChannelIndex = (((uint32_t)hdma->Instance - (uint32_t)DMA1_Channel1) /
((uint32_t)DMA1_Channel2 - (uint32_t)DMA1_Channel1)) << 2;
hdma->DmaBaseAddress = DMA1; // 属于 DMA1
}
else
{
hdma->ChannelIndex = (((uint32_t)hdma->Instance - (uint32_t)DMA2_Channel1) /
((uint32_t)DMA2_Channel2 - (uint32_t)DMA2_Channel1)) << 2;
hdma->DmaBaseAddress = DMA2; // 属于 DMA2
}
#else
hdma->ChannelIndex = (((uint32_t)hdma->Instance - (uint32_t)DMA1_Channel1) /
((uint32_t)DMA1_Channel2 - (uint32_t)DMA1_Channel1)) << 2;
hdma->DmaBaseAddress = DMA1; // 属于 DMA1
#endif
hdma->State = HAL_DMA_STATE_BUSY; // 状态置忙
/* ---- 第二段:设置 CCR 寄存器 ---- */
tmp = hdma->Instance->CCR; // 读当前 CCR
tmp &= ~(DMA_CCR_PL | DMA_CCR_MSIZE | DMA_CCR_PSIZE |
DMA_CCR_MINC | DMA_CCR_PINC | DMA_CCR_CIRC | DMA_CCR_DIR); // 先清除相关位
tmp |= hdma->Init.Direction |
hdma->Init.PeriphInc | hdma->Init.MemInc |
hdma->Init.PeriphDataAlignment | hdma->Init.MemDataAlignment |
hdma->Init.Mode | hdma->Init.Priority; // 再按配置填位
hdma->Instance->CCR = tmp; // 统一写入 CCR
/* ---- 第三段:收尾 ---- */
hdma->ErrorCode = HAL_DMA_ERROR_NONE;
hdma->State = HAL_DMA_STATE_READY;
hdma->Lock = HAL_UNLOCKED;
return HAL_OK;
}
4.4.1 ChannelIndex 计算详解
DMA1 各通道基地址(AHBPERIPH_BASE = 0x40020000):
c
#define DMA1_BASE (AHBPERIPH_BASE + 0x00000000UL) // 0x40020000(ISR/IFCR)
#define DMA1_Channel1_BASE (AHBPERIPH_BASE + 0x00000008UL)
#define DMA1_Channel2_BASE (AHBPERIPH_BASE + 0x0000001CUL)
#define DMA1_Channel3_BASE (AHBPERIPH_BASE + 0x00000030UL)
#define DMA1_Channel4_BASE (AHBPERIPH_BASE + 0x00000044UL)
#define DMA1_Channel5_BASE (AHBPERIPH_BASE + 0x00000058UL)
#define DMA1_Channel6_BASE (AHBPERIPH_BASE + 0x0000006CUL)
#define DMA1_Channel7_BASE (AHBPERIPH_BASE + 0x00000080UL)
- 通道间间隔:
0x1C - 0x08 = 0x14 = 20字节(5×4 字节,中间有填充)。 - 每个通道实际用 4 个寄存器(CCR/CNDTR/CPAR/CMAR),但相邻通道起始地址差 20 字节,中间的填充已由全局定义好的起始地址体现。
ChannelIndex 计算分四步:
c
hdma->ChannelIndex = (((uint32_t)hdma->Instance - (uint32_t)DMA1_Channel1) /
((uint32_t)DMA1_Channel2 - (uint32_t)DMA1_Channel1)) << 2;
// A = Instance - DMA1_Channel1 // 相对 1 号通道的地址偏移
// B = DMA1_Channel2 - DMA1_Channel1 // 相邻通道步长(0x14 = 20 字节)
// C = A / B // 得到通道号(0 起)
// D = C << 2 // 左移 2 位 = 乘 4,得到 ISR 中的位偏移
为什么要
<< 2?因为 DMA 的 ISR 寄存器里,每个通道占 4 个 bit(GIF、TCIF、HTIF、TEIF)。通道 1 从 bit0 开始,通道 2 从 bit4,通道 3 从 bit8...... 把"通道序号"转换成"比特起始位置",所以要乘 4。
意义 :ChannelIndex 让 HAL 后续能通过 DMA_FLAG_xxx << ChannelIndex 快速定位到该通道在 ISR 里的那 4 个标志位;DmaBaseAddress 则让通道关联所属的 DMA 控制器(DMA1/DMA2)。
4.4.2 为什么只初始化 CCR,不初始化另外 3 个寄存器
- CCR 决定"怎么干活" :方向、自增、优先级、模式......这些属性初始化后通常不变,所以
HAL_DMA_Init一次性写入 CCR。 - CPAR/CMAR/CNDTR 决定"去哪干活、干多少":地址和数量每次传输都可能变(第一次发 A 数组、第二次发 B 数组,长度可能不同)。
所以 HAL 把 CPAR/CMAR/CNDTR 的赋值推迟到"启动"阶段 ------调用 HAL_UART_Transmit_DMA / HAL_DMA_Start 时才写入。配置与执行是分开的。
4.5 一个遗留问题:为什么有 HAL_UART_MspInit
初始化为何不直接放在 HAL_UART_Init 里,而要再搞一个 HAL_UART_MspInit?这涉及软件分层,附录会详细回答。
📌 本知识点一句话总结
HAL_DMA_Init做三件事:① 通过通道地址计算ChannelIndex((实例地址-通道1地址)/通道步长<<2,本质是算出该通道在 ISR 里的 4-bit 位偏移)并关联DmaBaseAddress;② 把DMA_InitTypeDef的配置"先清后置"地写入 CCR 寄存器;③ 初始化状态/错误码/锁。它只初始化 CCR ,因为 CCR 是"静态规则"(怎么干),而 CPAR/CMAR/CNDTR 是"运行时参数"(去哪干、干多少),要推迟到启动阶段(HAL_DMA_Start_IT)才设置,体现"配置与执行分离"的设计。
5. DMA 通道与外设关联:__HAL_LINKDMA
回到 HAL_UART_MspInit,初始化完 DMA 通道后,还有两行:
c
__HAL_LINKDMA(huart, hdmarx, hdma_usart1_rx);
__HAL_LINKDMA(huart, hdmatx, hdma_usart1_tx);
展开宏:
c
#define __HAL_LINKDMA(__HANDLE__, __PPP_DMA_FIELD__, __DMA_HANDLE__) \
do { \
(__HANDLE__)->__PPP_DMA_FIELD__ = &(__DMA_HANDLE__); \
(__DMA_HANDLE__).Parent = (__HANDLE__); \
} while(0U)
替换后:
c
do {
huart->hdmarx = &hdma_usart1_rx; // 串口句柄指向接收 DMA 句柄
hdma_usart1_rx.Parent = huart; // 接收 DMA 句柄指回串口句柄
} while(0U);
作用 :把串口句柄和 DMA 通道句柄双向绑定,让两个句柄结构体通过指针互相指向。
对应结构体里的字段:
UART_HandleTypeDef里有DMA_HandleTypeDef *hdmatx;和*hdmarx;(串口指向 DMA);DMA_HandleTypeDef里有void *Parent;(DMA 指回串口)。
为什么要双向绑定?因为 DMA 完成中断里,只有 DMA 句柄,需要靠
Parent找到它服务的串口,才能进一步调用HAL_UART_RxCpltCallback。这在后续回调链里会看到。
📌 本知识点一句话总结
__HAL_LINKDMA宏的作用是把外设句柄和 DMA 句柄双向绑定 :huart->hdmarx = &hdma_usart1_rx(串口指向 DMA),hdma_usart1_rx.Parent = huart(DMA 指回串口)。这样做的原因是 DMA 完成中断里只有 DMA 句柄,必须靠Parent字段反查到它所服务的串口句柄,才能进一步调用串口的完成回调,实现两个独立外设之间的软件关联。
6. 实战编码:普通模式收发(demo1)
6.1 两个接口签名
HAL_UART_Receive_DMA(接收):
c
HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
| 参数 | 说明 |
|---|---|
huart |
串口句柄指针,如 &huart1 |
pData |
接收缓冲区起始地址 |
Size |
期望接收的数据字节数 |
HAL_UART_Transmit_DMA(发送):
c
HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size)
| 参数 | 说明 |
|---|---|
huart |
串口句柄指针 |
pData |
待发送数据缓冲区(const 修饰,只读) |
Size |
待发送字节数 |
两个接口都是非阻塞的,本质是"开启一个数据搬迁计划":设置收发缓冲区、配置收发地址和个数、使能中断,然后立即返回。
6.2 demo1 完整代码
c
/* USER CODE BEGIN Includes */
#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include <ctype.h>
/* USER CODE END Includes */
/* USER CODE BEGIN PM */
#define BUFFER_SIZE 8 // 收发缓冲区大小:8 字节
/* USER CODE END PM */
/* USER CODE BEGIN PV */
uint8_t rx_buffer[BUFFER_SIZE]; // 接收缓冲区
volatile bool is_data_received = false; // 默认没收到数据(volatile:中断里改)
uint8_t tx_buffer[BUFFER_SIZE]; // 发送缓冲区
volatile bool is_transmit_ready = true; // 默认能发
/* USER CODE END PV */
/* USER CODE BEGIN PFP */
void TransformCase(uint8_t *dst, uint8_t *src, uint16_t len);
/* USER CODE END PFP */
int main(void)
{
/* ... 系统初始化略 ... */
MX_GPIO_Init();
MX_DMA_Init();
MX_USART1_UART_Init();
/* USER CODE BEGIN 2 */
HAL_Delay(1000);
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_SET); // 关闭指示灯
// 先触发 DMA 接收
HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE);
/* USER CODE END 2 */
/* Infinite loop */
while (1)
{
// 只有收到了数据 && 发送接口允许,才把数据回发
if (is_data_received && is_transmit_ready)
{
// 1. 做业务处理:大小写转换(拷到 tx_buffer,避免 tx/rx 同时工作时互相干扰)
TransformCase(tx_buffer, rx_buffer, BUFFER_SIZE);
// 2. 翻转指示灯
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
// 3. 清除接收标志
is_data_received = false;
// 4. 尽快重新开始接收(普通模式下收完 DMA 会关闭,必须手动重启)
HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE);
// 5. 设置发送忙
is_transmit_ready = false;
// 6. 开始 DMA 发送,发完中断会自动把 is_transmit_ready 置回 true
HAL_UART_Transmit_DMA(&huart1, tx_buffer, BUFFER_SIZE);
}
/* USER CODE END WHILE */
/* USER CODE BEGIN 3 */
}
}
/* USER CODE BEGIN 4 */
// 接收完成回调:只设标志位
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
is_data_received = true;
}
}
// 发送完成回调:只设标志位
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
{
is_transmit_ready = true;
}
}
// 大小写转换函数(用 <ctype.h> 里的库函数)
void TransformCase(uint8_t *dst, uint8_t *src, uint16_t len)
{
if (dst == NULL || src == NULL || len == 0)
return;
for (int i = 0; i < len; i++)
{
char c = src[i];
if (isupper(c)) dst[i] = tolower(c); // 大写转小写
else if (islower(c)) dst[i] = toupper(c); // 小写转大写
else dst[i] = c; // 其他原样
}
}
/* USER CODE END 4 */
6.3 DMA 收发数据的两个建议
- 中断内只设标志位(Flag),严禁写业务逻辑。避免出错导致高频中断(如串口溢出)阻塞主循环。
- 尽量不在回调函数内直接启动新的 DMA 传输。此时外设句柄状态可能是 BUSY,会导致启动失败,且存在寄存器未清理或死锁风险。
📌 本知识点一句话总结
串口 DMA 收发采用"中断只设标志、主循环做逻辑"的模式:回调里只置
is_data_received/is_transmit_ready标志,主循环检测到"已收到且可发送"再做大小写转换等业务并回传。普通模式下 DMA 收满即关闭,所以回传前要重新调用HAL_UART_Receive_DMA才能继续接收。两条铁律:中断内不写业务逻辑、回调里不直接重启 DMA(避免状态 BUSY 导致启动失败和死锁)。
7. 循环模式(demo2)
7.1 开启循环接收模式
只需把 USART1_RX 的 DMA 模式改成循环(Circular),其余不变:
c
hdma_usart1_rx.Instance = DMA1_Channel5;
hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_usart1_rx.Init.Mode = DMA_CIRCULAR; // <-- 改成循环模式
hdma_usart1_rx.Init.Priority = DMA_PRIORITY_LOW;
7.2 demo2 的核心改动
c
while (1)
{
if (is_data_received && is_transmit_ready)
{
is_data_received = false; // 立即清除接收标志
TransformCase(tx_buffer, rx_buffer, BUFFER_SIZE); // 业务处理
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
// 【重点】删掉了原来的 HAL_UART_Receive_DMA(...)
// 因为循环模式下,硬件正在自动填充 rx_buffer,手动重启反而会干扰
is_transmit_ready = false;
HAL_UART_Transmit_DMA(&huart1, tx_buffer, BUFFER_SIZE);
}
}
7.3 循环模式的两个细节
- 接收 :DMA 每填满一次 BUFFER_SIZE 就触发一次
HAL_UART_RxCpltCallback,然后自动回到缓冲区开头继续填,不需要频繁重启。但要注意"能搬迁数据 ≠ 正在搬迁数据"。 - 发送 :TX 一般是"按需设置",不能无脑开循环模式。发送是主动行为,通常处理完一包才发一包;若把 TX 设成 Circular,发完 8 字节会立刻自动循环重发旧数据,对方收到无数重复消息,直到关 DMA。所以发送都用普通模式。
📌 本知识点一句话总结
循环模式(Circular)下,DMA 收满预设数量后自动回到缓冲区开头继续接收,无需手动重启
HAL_UART_Receive_DMA,适合 ADC 连续采样、音频播放等持续数据流。但接收可循环、发送不循环:接收是被动持续流入,适合循环;发送是主动按需行为,若开循环会无限重发旧数据,所以 TX 一律用普通(Normal)模式按需启动。
8. 收发源码阅读:从 HAL_UART_Receive_DMA 说起
8.1 收发函数核心就 3 件事
DMA 搬迁是硬件完成的,源码里不会有 CPU 拷贝数据的逻辑。这类 DMA 函数核心就 3 件事:
- 配置后半部分硬件参数(地址、数量),设置回调;
- 启动 DMA、启动中断;
- 通信完成后自动关闭 DMA(循环模式不关闭,只重置参数),由中断处理函数完成。
8.2 HAL_UART_Receive_DMA 源码
c
HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
{
if (huart->RxState == HAL_UART_STATE_READY) // RX 就绪才允许
{
if ((pData == NULL) || (Size == 0U)) return HAL_ERROR; // 参数校验
huart->ReceptionType = HAL_UART_RECEPTION_STANDARD; // 标准接收
return (UART_Start_Receive_DMA(huart, pData, Size)); // 真正干活
}
return HAL_BUSY; // 忙
}
核心是 UART_Start_Receive_DMA:
c
HAL_StatusTypeDef UART_Start_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size)
{
uint32_t *tmp;
huart->pRxBuffPtr = pData; // ① 初始化接收缓冲指针
huart->RxXferSize = Size; // ① 设置传输长度
huart->ErrorCode = HAL_UART_ERROR_NONE; // ① 清错误码
huart->RxState = HAL_UART_STATE_BUSY_RX; // ① 状态置接收中
// ② 设置 DMA 层回调(关键:把 DMA 完成映射到 UART 逻辑)
huart->hdmarx->XferCpltCallback = UART_DMAReceiveCplt; // DMA 完成回调
huart->hdmarx->XferHalfCpltCallback = UART_DMARxHalfCplt; // DMA 半完成回调
huart->hdmarx->XferErrorCallback = UART_DMAError; // DMA 错误回调
huart->hdmarx->XferAbortCallback = NULL; // 清终止回调
// ③ 启动 DMA 中断传输:外设地址=USART DR 寄存器,内存地址=pData
tmp = (uint32_t *)&pData;
HAL_DMA_Start_IT(huart->hdmarx, (uint32_t)&huart->Instance->DR, *(uint32_t *)tmp, Size);
// ④ 开启 USART 侧的错误中断与 DMA 使能
__HAL_UART_CLEAR_OREFLAG(huart); // 清溢出标志
if (huart->Init.Parity != UART_PARITY_NONE)
ATOMIC_SET_BIT(huart->Instance->CR1, USART_CR1_PEIE); // 开校验错误中断
ATOMIC_SET_BIT(huart->Instance->CR3, USART_CR3_EIE); // 开异常中断(FE/NE/ORE)
ATOMIC_SET_BIT(huart->Instance->CR3, USART_CR3_DMAR); // 开 UART 的 DMA 接收请求
return HAL_OK;
}
8.3 为什么要开 USART 的 DMA 使能位
DMA 通信必须双方都同意:DMA 外设要同意,USART 也要同意。
ATOMIC_SET_BIT(huart->Instance->CR3, USART_CR3_DMAR)就是把 USART 的 CR3 寄存器第 6 位(DMAR)置 1,开启"DMA 接收请求"。ATOMIC_SET_BIT(huart->Instance->CR3, USART_CR3_EIE)开异常中断(错误、噪声、溢出)。
细节 :这段代码(其实整个 DMA 收发过程)并没有开启 USART 的收发中断使能(RXNEIE/TXEIE),只开了错误中断。为什么?
因为有了 DMA,就不再需要 USART 的中断了。如果同时开启 USART 收发中断和 DMA,USART 收到数据后既通知 CPU 又通知 DMA,就乱套了。所以默认不开启。那为什么要开"异常"中断?因为 DMA 搬完数据 ≠ USART 真正完成(两个独立设备),发送过程中出错需要通知 CPU 紧急处理(清标志、关/重启通道)。
8.4 HAL_DMA_Start_IT 与 DMA_SetConfig
c
HAL_StatusTypeDef HAL_DMA_Start_IT(DMA_HandleTypeDef *hdma, uint32_t SrcAddress,
uint32_t DstAddress, uint32_t DataLength)
{
HAL_StatusTypeDef status = HAL_OK;
assert_param(IS_DMA_BUFFER_SIZE(DataLength)); // 校验长度
__HAL_LOCK(hdma); // 锁句柄防冲突
if (HAL_DMA_STATE_READY == hdma->State) // 仅就绪态才启动
{
hdma->State = HAL_DMA_STATE_BUSY; // 置忙
hdma->ErrorCode = HAL_DMA_ERROR_NONE;
__HAL_DMA_DISABLE(hdma); // 先失能,便于配寄存器
DMA_SetConfig(hdma, SrcAddress, DstAddress, DataLength); // 配地址和数量
if (NULL != hdma->XferHalfCpltCallback) // 有半完成回调
{
__HAL_DMA_ENABLE_IT(hdma, (DMA_IT_TC | DMA_IT_HT | DMA_IT_TE)); // 开 TC+HT+TE
}
else // 没有半完成回调
{
__HAL_DMA_DISABLE_IT(hdma, DMA_IT_HT); // 关 HT
__HAL_DMA_ENABLE_IT(hdma, (DMA_IT_TC | DMA_IT_TE)); // 只开 TC+TE
}
__HAL_DMA_ENABLE(hdma); // 重新使能,正式开始硬件搬运
}
else
{
__HAL_UNLOCK(hdma);
status = HAL_BUSY;
}
return status;
}
核心是 DMA_SetConfig:
c
static void DMA_SetConfig(DMA_HandleTypeDef *hdma, uint32_t SrcAddress,
uint32_t DstAddress, uint32_t DataLength)
{
hdma->DmaBaseAddress->IFCR = (DMA_ISR_GIF1 << hdma->ChannelIndex); // 清该通道所有中断标志
hdma->Instance->CNDTR = DataLength; // 设置传输数据量
if ((hdma->Init.Direction) == DMA_MEMORY_TO_PERIPH) // 内存→外设
{
hdma->Instance->CPAR = DstAddress; // 外设=目的地址
hdma->Instance->CMAR = SrcAddress; // 内存=源地址
}
else // 外设→内存
{
hdma->Instance->CPAR = SrcAddress; // 外设=源地址
hdma->Instance->CMAR = DstAddress; // 内存=目的地址
}
}
这就是前面说的"启动阶段才设置 CPAR/CMAR/CNDTR"的印证。其中:
IFCR = (DMA_ISR_GIF1 << ChannelIndex):根据通道索引清空该通道所有中断标志(写 1 清零);CNDTR = DataLength:设置要搬的数量;- 根据 DIR 决定 CPAR/CMAR 谁放源、谁放目的。
📌 本知识点一句话总结
HAL_UART_Receive_DMA的完整逻辑是:UART_Start_Receive_DMA里先保存缓冲指针/长度,再设置三个 DMA 层回调(UART_DMAReceiveCplt等),然后调HAL_DMA_Start_IT启动传输。HAL_DMA_Start_IT里通过DMA_SetConfig完成之前"推迟"的工作------写 IFCR 清标志、写 CNDTR 设数量、根据方向写 CPAR/CMAR,再按需使能 TC/HT/TE 中断,最后使能通道。同时还要用CR3 的 DMAR 位打开 USART 侧 DMA 使能,体现"DMA 和 USART 双方都要同意"的原则;且不开启 USART 收发中断(避免与 DMA 冲突),只开错误中断。
9. 中断触发与回调链
9.1 硬件到软件的中断路径
DMA 数据搬迁是硬件自动完成的,软件看不到任何拷贝逻辑。中断触发路径:
硬件:USART 收到数据 → 通知 DMA1 通道5 → DMA 完成搬迁 → 触发中断给 CPU
软件:CPU 查中断向量表 → 跳转到 DMA1_Channel5_IRQHandler → HAL_DMA_IRQHandler
→ UART_DMAReceiveCplt → HAL_UART_RxCpltCallback(用户 weak 函数)
9.2 中断向量表(startup 文件)
asm
DCD DMA1_Channel1_IRQHandler ; DMA1 Channel 1
DCD DMA1_Channel2_IRQHandler ; DMA1 Channel 2
DCD DMA1_Channel3_IRQHandler ; DMA1 Channel 3
DCD DMA1_Channel4_IRQHandler ; DMA1 Channel 4
DCD DMA1_Channel5_IRQHandler ; DMA1 Channel 5
DCD DMA1_Channel6_IRQHandler ; DMA1 Channel 6
DCD DMA1_Channel7_IRQHandler ; DMA1 Channel 7
DCD 指令分配 32 位字,填入中断服务函数入口地址,实现"中断请求 → 处理函数"的地址关联。
9.3 中断服务函数
c
void DMA1_Channel5_IRQHandler(void)
{
HAL_DMA_IRQHandler(&hdma_usart1_rx); // 交给 HAL 公共处理函数
}
9.4 HAL_DMA_IRQHandler:中断分发核心
c
void HAL_DMA_IRQHandler(DMA_HandleTypeDef *hdma)
{
uint32_t flag_it = hdma->DmaBaseAddress->ISR; // 读中断状态标志
uint32_t source_it = hdma->Instance->CCR; // 读中断使能情况
/* --- 半传输完成 HT --- */
if (((flag_it & (DMA_FLAG_HT1 << hdma->ChannelIndex)) != RESET) &&
((source_it & DMA_IT_HT) != RESET))
{
if ((hdma->Instance->CCR & DMA_CCR_CIRC) == 0U) // 非循环模式
__HAL_DMA_DISABLE_IT(hdma, DMA_IT_HT); // 关 HT 防重复触发
__HAL_DMA_CLEAR_FLAG(hdma, __HAL_DMA_GET_HT_FLAG_INDEX(hdma)); // 清标志
if (hdma->XferHalfCpltCallback != NULL)
hdma->XferHalfCpltCallback(hdma); // 调半完成回调
}
/* --- 传输完成 TC --- */
else if (((flag_it & (DMA_FLAG_TC1 << hdma->ChannelIndex)) != RESET) &&
((source_it & DMA_IT_TC) != RESET))
{
if ((hdma->Instance->CCR & DMA_CCR_CIRC) == 0U) // 非循环模式
{
__HAL_DMA_DISABLE_IT(hdma, DMA_IT_TE | DMA_IT_TC); // 关 TC 和 TE
hdma->State = HAL_DMA_STATE_READY; // 状态置就绪
}
__HAL_DMA_CLEAR_FLAG(hdma, __HAL_DMA_GET_TC_FLAG_INDEX(hdma)); // 清标志
__HAL_UNLOCK(hdma);
if (hdma->XferCpltCallback != NULL)
hdma->XferCpltCallback(hdma); // 调完成回调
}
/* --- 传输错误 TE --- */
else if ((flag_it & (DMA_FLAG_TE1 << hdma->ChannelIndex)) != RESET &&
(source_it & DMA_IT_TE) != RESET)
{
__HAL_DMA_DISABLE_IT(hdma, (DMA_IT_TC | DMA_IT_HT | DMA_IT_TE)); // 关全部中断
hdma->DmaBaseAddress->IFCR = (DMA_ISR_GIF1 << hdma->ChannelIndex); // 清全部标志
hdma->ErrorCode = HAL_DMA_ERROR_TE;
hdma->State = HAL_DMA_STATE_READY;
__HAL_UNLOCK(hdma);
if (hdma->XferErrorCallback != NULL)
hdma->XferErrorCallback(hdma); // 调错误回调
}
}
关键理解:
flag_it & (DMA_FLAG_xxx << ChannelIndex)判断该通道对应类型的中断标志是否置位;source_it & DMA_IT_xxx判断该中断是否被使能;- 两者同时成立才处理------即"使能了 && 触发了",再根据类型调不同回调。
- 非循环模式下,处理完会自动关闭所有中断使能,这就是"DMA 只搬一次"的原因(中断关了,后续不再调用;硬件也不会重置计数器/指针)。
- 错误时硬件自动清 EN 位,软件再手动关全部中断、清全部标志,让上层能重新调用
HAL_UART_Receive_DMA(重置接收)。
9.5 UART_DMAReceiveCplt:从 DMA 回调到用户回调
c
static void UART_DMAReceiveCplt(DMA_HandleTypeDef *hdma)
{
// 通过 Parent 找到关联的串口句柄
UART_HandleTypeDef *huart = (UART_HandleTypeDef *)((DMA_HandleTypeDef *)hdma)->Parent;
if ((hdma->Instance->CCR & DMA_CCR_CIRC) == 0U) // 非循环模式
{
huart->RxXferCount = 0U;
ATOMIC_CLEAR_BIT(huart->Instance->CR1, USART_CR1_PEIE); // 关校验中断
ATOMIC_CLEAR_BIT(huart->Instance->CR3, USART_CR3_EIE); // 关错误中断
ATOMIC_CLEAR_BIT(huart->Instance->CR3, USART_CR3_DMAR); // 关 USART 的 DMA 接收请求
huart->RxState = HAL_UART_STATE_READY; // 状态置就绪
if (huart->ReceptionType == HAL_UART_RECEPTION_TOIDLE)
ATOMIC_CLEAR_BIT(huart->Instance->CR1, USART_CR1_IDLEIE);
}
huart->RxEventType = HAL_UART_RXEVENT_TC;
// 普通 DMA 接收 → 调用标准接收完成回调
HAL_UART_RxCpltCallback(huart);
}
而 HAL_UART_RxCpltCallback 是 __weak 修饰的函数:
c
__weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
UNUSED(huart); // 默认空实现,用户可覆盖
}
所以我们重写
HAL_UART_RxCpltCallback就能拿到"DMA 搬迁完成"的通知,进而处理数据(如大小写转换)。这里再次印证了上一课讲的 weak 机制。
9.6 发送侧:UART_DMATransmitCplt
发送侧的中断入口是 DMA1_Channel4_IRQHandler,同样走 HAL_DMA_IRQHandler(&hdma_usart1_tx),几乎和接收一模一样。区别只是启动时设置的回调是 UART_DMATransmitCplt:
c
static void UART_DMATransmitCplt(DMA_HandleTypeDef *hdma)
{
UART_HandleTypeDef *huart = (UART_HandleTypeDef *)((DMA_HandleTypeDef *)hdma)->Parent;
if ((hdma->Instance->CCR & DMA_CCR_CIRC) == 0U) // 非循环
{
huart->TxXferCount = 0x00U;
ATOMIC_CLEAR_BIT(huart->Instance->CR3, USART_CR3_DMAT); // 关 USART 发送 DMA 请求
ATOMIC_SET_BIT(huart->Instance->CR1, USART_CR1_TCIE); // 开发送完成中断(确保最后字节发完)
}
else // 循环模式
{
HAL_UART_TxCpltCallback(huart);
}
}
细节:发送侧在 DMA 搬完后,还会开启 USART 的 TCIE(发送完成中断)。原因还是那个------DMA 搬完数据 ≠ 串口真正把最后一位从移位寄存器发出去。所以靠 TCIE 中断再兜底一次,确保发送真正完成。
发送与接收的本质一致性 :HAL 库几乎不关心"收还是发",收/发是 DMA 通道配置决定的(方向不同)。中断触发逻辑几乎一样,只要回调了就说明"完成/半完成/异常"。所以 DMA 句柄的回调字段叫 XferCpltCallback(通用"传输完成"),而不是"发送/接收完成"。
📌 本知识点一句话总结
DMA 中断回调链是:
DMA1_Channelx_IRQHandler→HAL_DMA_IRQHandler(读 ISR 状态 + CCR 使能位,判断是 HT/TC/TE 哪种中断)→ 调用对应的XferCpltCallback(即UART_DMAReceiveCplt/UART_DMATransmitCplt)→ 通过Parent反查串口句柄、关闭 USART 侧 DMA 使能、置状态 READY → 最后调用用户重写的__weak函数HAL_UART_RxCpltCallback。判断条件是"中断使能了 && 中断触发了";非循环模式处理完会自动关中断(所以只搬一次);发送侧还会额外开 TCIE 中断,因为 DMA 搬完不等于串口真正发完。
10. DMA 收发状态机
10.1 为什么引入状态机
推荐"中断修改标志位、主循环逻辑处理"的开发方式,为了让代码更健壮、可扩展,引入状态机。
引入状态机的理由(FSM Logic):
- 嵌套风险规避(Stack Safety) :严禁在中断内调用耗时或依赖其他中断的函数(如
HAL_Delay),防止优先级冲突导致死锁、深层嵌套引发栈溢出。 - 逻辑归位准则(Context Separation):中断负责"事件通知",主循环负责"逻辑决策",保证 CPU 在干净上下文处理复杂任务。
10.2 状态机实现
c
/* USER CODE BEGIN PTD */
typedef enum
{
USART_STATE_IDLE, // 空闲(准备启动接收)
USART_STATE_RXING, // 正在接收
USART_STATE_DATA_READY, // 数据就绪(可处理)
USART_STATE_TXING, // 正在发送
USART_STATE_ERROR // 错误
} usart_state_t;
/* USER CODE END PTD */
volatile usart_state_t g_state = USART_STATE_IDLE; // 全局状态变量
int main(void)
{
/* ... 初始化略 ... */
const char *tips = "This Is State Machine Test\n";
HAL_UART_Transmit_DMA(&huart1, tips, strlen(tips)); // 测试发送
HAL_Delay(1000);
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_SET);
while (1)
{
switch (g_state)
{
case USART_STATE_IDLE: // 空闲:启动接收
if (HAL_UART_Receive_DMA(&huart1, rx_buffer, BUFFER_SIZE) == HAL_OK)
g_state = USART_STATE_RXING;
else
g_state = USART_STATE_ERROR;
break;
case USART_STATE_RXING: // 接收中:什么都不做
// CPU 可以在这里做其他事情
break;
case USART_STATE_DATA_READY: // 数据就绪:处理 + 回传
TransformCase(tx_buffer, rx_buffer, BUFFER_SIZE);
HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_8);
if (HAL_UART_Transmit_DMA(&huart1, tx_buffer, BUFFER_SIZE) == HAL_OK)
g_state = USART_STATE_TXING;
else
g_state = USART_STATE_ERROR;
break;
case USART_STATE_TXING: // 发送中:什么都不做
break;
case USART_STATE_ERROR: // 错误:清理并复位
HAL_UART_DMAStop(&huart1); // 停止 DMA,重置寄存器
__HAL_UART_CLEAR_OREFLAG(&huart1); // 必须手动清溢出标志,否则无法重启
HAL_Delay(300); // 给点时间让重启生效
g_state = USART_STATE_IDLE; // 回到空闲重新开始
break;
default:
break;
}
}
}
/* 三个回调只负责"改状态",业务全在主循环 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
g_state = USART_STATE_DATA_READY; // 收完 → 数据就绪
}
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
g_state = USART_STATE_IDLE; // 发完 → 回空闲
}
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1)
g_state = USART_STATE_ERROR; // 出错 → 错误态
}
10.3 如何制造并验证异常
- 方法 :在
USART_STATE_DATA_READY分支里加长延时(如HAL_Delay(2000))。 - 操作:上位机高频(如每 1ms 一包)连续发送。
- 现象:CPU 被 Delay 堵住,DMA 又是普通模式(收完即关),无法及时处理数据,串口硬件迅速溢出(ORE),进入 ERROR 状态,执行清理后恢复正常收发。
10.4 HAL_UART_DMAStop 与清溢出标志
c
HAL_StatusTypeDef HAL_UART_DMAStop(UART_HandleTypeDef *huart);
核心逻辑:
- 禁用 USART 的 DMA 使能(CR3 的 DMAR/DMAT);
- 调用
HAL_DMA_Abort强制停止关联的收发 DMA 通道; - 恢复软件状态(
RxState/gState置 READY)。
一句话:把 DMA 通道和 USART 的相关状态寄存器恢复成初始状态,方便重新启动,主要用于出错后的状态恢复。
📌 本知识点一句话总结
DMA 收发用状态机(IDLE→RXING→DATA_READY→TXING→ERROR→IDLE)管理,核心原则是"中断只做事件通知(改状态),主循环做逻辑决策(switch 处理)"。这有两个好处:一是规避嵌套风险(中断里不调用
HAL_Delay等耗时函数,防止死锁/栈溢出);二是逻辑归位(复杂任务在干净上下文处理)。错误态用HAL_UART_DMAStop重置 DMA 和 USART 状态、__HAL_UART_CLEAR_OREFLAG清溢出标志,再回到 IDLE 重启接收,实现异常自恢复。
11. 附录:触类旁通与软件分层
11.1 其他外设的 DMA 回调与中断回调同名
对 USART/SPI/I2C 来说,中断模式和 DMA 模式的用户回调函数同名 (都是 __weak):
| 外设 | 事件类型 | 中断模式用户回调 | DMA 模式用户回调 | 是否相同 |
|---|---|---|---|---|
| USART | 接收完成 | HAL_UART_RxCpltCallback |
HAL_UART_RxCpltCallback |
✅ 相同 |
| USART | 发送完成 | HAL_UART_TxCpltCallback |
HAL_UART_TxCpltCallback |
✅ 相同 |
| USART | 接收半完成 | --- | HAL_UART_RxHalfCpltCallback |
❌ DMA 独有 |
| USART | 发送半完成 | --- | HAL_UART_TxHalfCpltCallback |
❌ DMA 独有 |
| USART | 错误 | HAL_UART_ErrorCallback |
HAL_UART_ErrorCallback |
✅ 相同 |
| SPI | 收发完成 | HAL_SPI_TxRxCpltCallback |
HAL_SPI_TxRxCpltCallback |
✅ 相同 |
| SPI | 半完成 | --- | HAL_SPI_RxHalfCpltCallback 等 |
❌ DMA 独有 |
| I2C | Memory 收发完成 | HAL_I2C_MemTxCpltCallback 等 |
HAL_I2C_MemTxCpltCallback 等 |
✅ 相同 |
| I2C | 半完成 | --- | --- | I2C 协议无半完成概念 |
这种一致性的意义:
- 业务逻辑与底层传输解耦------无论底层用中断还是 DMA,上层回调名一样,业务代码不用改。
- 符合"面向接口编程"------降低学习与记忆成本。
- 结果:隐藏复杂性,暴露统一抽象。
11.2 为什么要有句柄结构体和 msp 软件层
疑问:直接写寄存器不就好了,为什么要搞句柄结构体、保存属性、再通过结构体设置寄存器?
答案:底层硬件差异很大,即便都是串口/DMA,不同厂商、不同版本都可能不同,但都有同样的属性类型(控制寄存器、状态寄存器),只是设置方式不同。
HAL 库为了屏蔽底层差异 ,用结构体句柄先把配置在软件层保存一份,再调用不同的底层初始化方式。这样即使底层硬件配置方式变化,只需改底层初始化,不用改句柄结构体及以上的应用层,本质是解耦 + 可扩展。
STM32 + HAL 的软硬件是层状结构,共 4 层:
应用层(你的 main.c 业务逻辑)
│
用户抽象层(句柄结构体:huart、hdma 等)
│
HAL 库层(HAL_UART_xxx、HAL_DMA_xxx 等)
│
低层(MSP:HAL_UART_MspInit、HAL_DMA_MspInit,实际操作寄存器/GPIO/时钟)
这就是为什么 DMA 和串口的初始化放在
XXX_MspInit函数里:MSP(MCU Support Package)层负责最底层的硬件操作,与具体芯片绑定;HAL 层和句柄层保持通用,这样换芯片只改 MSP 层即可。
📌 本知识点一句话总结
HAL 库把中断和 DMA 的用户回调设计成同名 (都是
__weak),目的是让"业务逻辑与底层传输解耦"------用户不管底层用中断还是 DMA,回调名一致,业务代码不用改,符合面向接口编程。而"句柄结构体 + MSP 层"的存在是为了屏蔽硬件差异 :不同芯片寄存器可能不同,但属性类型相同,用句柄在软件层先保存配置、再调用底层(MSP)初始化,实现软硬件解耦。因此 STM32+HAL 分为 4 层:应用层 → 句柄抽象层 → HAL 库层 → MSP 底层,换芯片只需改 MSP 层,这就是初始化放在XXX_MspInit的原因。
全文总结(速记卡)
- 配置:接收映射 Channel5、发送映射 Channel4;同时使能 DMA 和 USART 中断(两个独立设备)。
- 三层结构体 :
DMA_HandleTypeDef(句柄)/DMA_Channel_TypeDef(4 寄存器)/DMA_TypeDef(ISR/IFCR)。 - HAL_DMA_Init:算 ChannelIndex(通道号→ISR 位偏移)+ 关联 DmaBaseAddress + 只写 CCR。
- 配置与执行分离:CCR 是静态规则,CPAR/CMAR/CNDTR 是运行时参数,启动时才写。
- __HAL_LINKDMA :双向绑定串口和 DMA 句柄,靠
Parent反查。 - 启动逻辑 :
UART_Start_Receive_DMA→ 设回调 →HAL_DMA_Start_IT→DMA_SetConfig(写 CPAR/CMAR/CNDTR)→ 开 CR3 的 DMAR。 - 回调链 :
IRQHandler→HAL_DMA_IRQHandler(判断 HT/TC/TE)→UART_DMAReceiveCplt→HAL_UART_RxCpltCallback(weak)。 - 接收可循环、发送不循环:发送是主动按需,开循环会无限重发。
- 状态机:IDLE/RXING/DATA_READY/TXING/ERROR,中断改状态、主循环做逻辑。
- 分层设计:中断与 DMA 回调同名(解耦业务),句柄+MSP 分层(屏蔽硬件差异)。

