【stm32】DMA 源码分析与实战

🔥铅笔小新z:个人主页

🎬博客专栏:stm32学习

💫滴水不绝,可穿石;步履不休,能至渊。

DMA 源码分析与实战 ------ 从寄存器到状态机的完整闭环

面向初学者的通俗解读,配合逐行注释的代码,以及每个知识点后的"一句话总结"。

本文基于 STM32F103 + HAL 库,用串口 DMA 收发实战,把 DMA 的初始化、启动、中断回调、状态机设计彻底讲透。


目录

  1. 需求说明与需求分析
  2. [CubeMX 环境配置](#CubeMX 环境配置)
  3. 生成配置信息源码阅读:句柄与寄存器
  4. [初始化通道:HAL_DMA_Init 深入](#初始化通道:HAL_DMA_Init 深入)
  5. [DMA 通道与外设关联:__HAL_LINKDMA](#DMA 通道与外设关联:__HAL_LINKDMA)
  6. 实战编码:普通模式收发(demo1)
  7. 循环模式(demo2)
  8. [收发源码阅读:从 HAL_UART_Receive_DMA 说起](#收发源码阅读:从 HAL_UART_Receive_DMA 说起)
  9. 中断触发与回调链
  10. [DMA 收发状态机](#DMA 收发状态机)
  11. 附录:触类旁通与软件分层

1. 需求说明与需求分析

1.1 需求说明

本示例演示串口的 DMA 模式 进行数据收发:上位机给开发板发数据,开发板用串口 DMA 模式接收,收满一定数量后,再用串口 DMA 模式一次性回传给上位机。

通过该示例,模拟了系统的三个工作阶段:

  1. 上位机发送,开发板用串口 DMA 模式接收数据;
  2. 接收完成,启动串口 DMA 数据回传流程;
  3. 回传完成,切换到 DMA 接收状态(循环往复)。

1.2 需求分析

  • 需要一个能与电脑连接的串口(开发板用 CH340K 芯片做 USB 转 UART TTL);
  • 需要用纯 DMA 方式完成串口收发;
  • 用 LED4 指示灯指示 DMA 通信状态。

1.3 通过示例要掌握的技能

  1. DMA 通道初始化的方法;
  2. 启动串口 DMA 接收数据的方法;
  3. 启动串口 DMA 发送数据的方法;
  4. DMA 接收完成回调函数的实现;
  5. DMA 发送完成回调函数的实现;
  6. 通过串口 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 收发数据的两个建议

  1. 中断内只设标志位(Flag),严禁写业务逻辑。避免出错导致高频中断(如串口溢出)阻塞主循环。
  2. 尽量不在回调函数内直接启动新的 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 循环模式的两个细节

  1. 接收 :DMA 每填满一次 BUFFER_SIZE 就触发一次 HAL_UART_RxCpltCallback,然后自动回到缓冲区开头继续填,不需要频繁重启。但要注意"能搬迁数据 ≠ 正在搬迁数据"。
  2. 发送 :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 件事:

  1. 配置后半部分硬件参数(地址、数量),设置回调;
  2. 启动 DMA、启动中断;
  3. 通信完成后自动关闭 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):

  1. 嵌套风险规避(Stack Safety) :严禁在中断内调用耗时或依赖其他中断的函数(如 HAL_Delay),防止优先级冲突导致死锁、深层嵌套引发栈溢出。
  2. 逻辑归位准则(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);

核心逻辑:

  1. 禁用 USART 的 DMA 使能(CR3 的 DMAR/DMAT);
  2. 调用 HAL_DMA_Abort 强制停止关联的收发 DMA 通道;
  3. 恢复软件状态(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 的原因。


全文总结(速记卡)

  1. 配置:接收映射 Channel5、发送映射 Channel4;同时使能 DMA 和 USART 中断(两个独立设备)。
  2. 三层结构体 :DMA_HandleTypeDef(句柄)/ DMA_Channel_TypeDef(4 寄存器)/ DMA_TypeDef(ISR/IFCR)。
  3. HAL_DMA_Init:算 ChannelIndex(通道号→ISR 位偏移)+ 关联 DmaBaseAddress + 只写 CCR。
  4. 配置与执行分离:CCR 是静态规则,CPAR/CMAR/CNDTR 是运行时参数,启动时才写。
  5. __HAL_LINKDMA :双向绑定串口和 DMA 句柄,靠 Parent 反查。
  6. 启动逻辑 :UART_Start_Receive_DMA → 设回调 → HAL_DMA_Start_IT → DMA_SetConfig(写 CPAR/CMAR/CNDTR)→ 开 CR3 的 DMAR。
  7. 回调链 :IRQHandler → HAL_DMA_IRQHandler(判断 HT/TC/TE)→ UART_DMAReceiveCplt → HAL_UART_RxCpltCallback(weak)。
  8. 接收可循环、发送不循环:发送是主动按需,开循环会无限重发。
  9. 状态机:IDLE/RXING/DATA_READY/TXING/ERROR,中断改状态、主循环做逻辑。
  10. 分层设计:中断与 DMA 回调同名(解耦业务),句柄+MSP 分层(屏蔽硬件差异)。

相关推荐
笨笨饿3 小时前
#141_ Codex 本地 AI 代理工具链工作流重构:Headroom、Scrapling、Archify 统一接入
开发语言·人工智能·stm32·单片机·嵌入式硬件·ui·重构
新思维软件4 小时前
非机动车管理系统设计 | STM32+RFID+MQTT | X504291项目编号 X504291
spring boot·stm32·单片机·嵌入式硬件
Zw-awa10 小时前
蜂鸣器为什么会响:有源、无源和低电平触发
stm32
木白CPP13 小时前
SELinux 介绍和基本使用
嵌入式硬件·安卓·安全架构
女神下凡14 小时前
芯参谋(30):EMMC eMCP 软件设计规范
arm开发·单片机·嵌入式硬件·设计规范
xingzhemengyou116 小时前
STM32F427将函数设定在固定位置
stm32·单片机·嵌入式硬件
SKH.16 小时前
ARM-uart
arm开发·单片机
@MMiL17 小时前
为什么-200 mod 4000 等于3800
单片机
殷忆枫20 小时前
基于ESP32 的局域网全双工对讲系统
单片机·嵌入式硬件·esp32·对讲系统