DMA从内存到内存

Tutorial: 电子电路 Category: 嵌入式 Published: 2026-10-08 01:26:37 Views: 9 Likes: 0 Comments: 0

158.11DMA从内存到内存

/* 以下仅摘录 CubeIDE/17DMA _M2M/Core/Src/main.c 中本节最终保存的业务代码;该写法的时序隐患见正文 5.4。 */
#define BUFF_SIZE 12U                                                               /* 定义源、目标缓冲区的字节数,两个数组都占 12 字节。 */
uint8_t SRC_Buff[BUFF_SIZE];                                                        /* 定义 DMA 的源缓冲区,每个元素占 1 字节。 */
uint8_t DEST_Buff[BUFF_SIZE];                                                       /* 定义 DMA 的目标缓冲区,静态存储期对象会被初始化为 0。 */

MX_GPIO_Init();                                                                     /* 执行工程生成的 GPIO 初始化。 */
MX_DMA_Init();                                                                      /* 打开 DMA1 时钟,并按内存到内存、双地址递增、32 位宽度配置通道 1。 */
MX_USART1_UART_Init();                                                              /* 初始化 USART1,供后面发送目标缓冲区进行验证。 */

for (int i = 0; i < BUFF_SIZE; i++)                                                 /* 从下标 0 遍历到下标 11,为源数组的 12 个元素赋值。 */
{                                                                                   /* 源数组初始化代码块开始。 */
    SRC_Buff[i] = (uint8_t)(i + 2);                                                 /* 依次写入 0x02~0x0D,便于在串口十六进制显示中核对。 */
}                                                                                   /* 源数组初始化代码块结束。 */

HAL_DMA_Start(&hdma_memtomem_dma1_channel1,                                        /* 使用 CubeMX 生成的 DMA1 通道 1 句柄启动传输。 */
              (uint32_t)SRC_Buff,                                                  /* 把源数组首地址转换成 HAL 接口要求的 32 位地址值。 */
              (uint32_t)DEST_Buff,                                                 /* 把目标数组首地址转换成 HAL 接口要求的 32 位地址值。 */
              BUFF_SIZE / 4U);                                                     /* 32 位宽度下一次搬 4 字节,因此 12 字节需要 3 次传输。 */

while (1)                                                                           /* 进入主循环,课程最终程序每秒重复发送一次目标缓冲区。 */
{                                                                                   /* 一轮处理开始。 */
    hdma_memtomem_dma1_channel1.Instance->CNDTR = BUFF_SIZE / 4U;                   /* 重新装入 3 个数据项;此寄存器只能在 DMA 通道禁用时写。 */
    __HAL_DMA_ENABLE(&hdma_memtomem_dma1_channel1);                                 /* 置位通道 EN;内存到内存模式会在使能后立即开始搬运。 */
    while (__HAL_DMA_GET_FLAG(&hdma_memtomem_dma1_channel1, DMA_FLAG_TC1) == 0U)    /* 轮询 DMA1 通道 1 的传输完成标志。 */
    {                                                                               /* 等待循环开始。 */
    }                                                                               /* 标志置位后结束等待;原工程没有超时和错误标志处理。 */
    HAL_UART_Transmit(&huart1, DEST_Buff, BUFF_SIZE, 100U);                         /* 经 USART1 阻塞发送目标数组的 12 个原始字节。 */
    __HAL_DMA_CLEAR_FLAG(&hdma_memtomem_dma1_channel1, DMA_FLAG_TC1);               /* 清除本轮通道 1 的传输完成标志,防止下一轮误判。 */
    __HAL_DMA_DISABLE(&hdma_memtomem_dma1_channel1);                                /* 禁用 DMA 通道,使下一轮可以合法重写 CNDTR。 */
    HAL_Delay(1000U);                                                               /* 延时 1 秒,降低串口输出频率,便于观察。 */
}                                                                                   /* 本轮结束,返回循环顶部。 */

一、实验演示

本节使用 STM32F103ZET6 做一个“存储器到存储器”的 DMA 拷贝实验:程序先建立 SRC_Buff 和 DEST_Buff 两个 12 字节数组,CPU 只负责配置 DMA,DMA 控制器把源数组搬到目标数组,最后 USART1 发送目标数组以验证内容。

这里的“内存到内存”更准确地说是存储器到存储器(Memory-to-Memory)。本例的两个缓冲区都位于 SRAM;它不是两个物理内存条之间的复制,也不是串口在执行复制。

预期数据为 02 03 04 05 06 07 08 09 0A 0B 0C 0D。其中多数是不可打印控制字符,因此串口助手应切换到十六进制显示;若按文本显示,不能期待看到字符串“2~13”。

DMA 数据流如下:

SRC_Buff(SRAM) → DMA1_Channel1 → DEST_Buff(SRAM) → USART1 → 串口助手

1.1 存储器和存储器间DMA

STM32F103ZET6 属于高密度 STM32F103 器件,包含 DMA1 和 DMA2:DMA1 有 7 个通道,DMA2 有 5 个通道,共 12 个通道。这里的“通道”是 DMA 控制器内部可独立配置的一组寄存器和传输路径,不是 12 根外部引脚。

外设 DMA 请求与通道之间是固定映射。例如某个 USART、ADC 或定时器的请求只能送到手册表格指定的通道;不能仅凭“哪个通道空闲”随意选择。内存到内存模式不等待外设请求,设置 MEM2MEM 并使能通道后就由软件立即启动,因此 CubeMX 允许本实验选择多个可用通道,课程选用 DMA1_Channel1。

二、理论知识

DMA(Direct Memory Access,直接存储器访问)的价值,是让专用硬件在外设和存储器之间搬运数据,减少 CPU 对每个数据项逐一读写的负担。CPU 仍需完成配置、启动、判断完成和处理错误;所以 DMA 不是“完全不占系统资源”,它还会与 CPU 竞争总线带宽。

一次 DMA 传输需要明确以下参数:

参数本节设置含义
控制器/通道DMA1 通道 1使用哪组 DMA 硬件资源
方向Memory to Memory两端都是存储器地址,不依赖外设请求
源地址SRC_Buff从哪里读取
目标地址DEST_Buff写到哪里
源地址递增Enable每传一个数据项后移到下一个源元素
目标地址递增Enable每传一个数据项后移到下一个目标位置
源/目标宽度Word/Word每个数据项为 32 位,即 4 字节
数据项数量12 / 4 = 3搬 3 次,共 12 字节
模式Normal数量减到 0 后停止,不自动循环装载
优先级Low与其他 DMA 请求竞争时的软件优先级

常见三种方向可这样理解:

  • 外设到存储器:例如 USART 接收寄存器 → 接收数组、ADC 数据寄存器 → 采样数组。
  • 存储器到外设:例如发送数组 → USART 发送寄存器、波形表 → DAC 数据寄存器。
  • 存储器到存储器:例如一个 SRAM 缓冲区 → 另一个 SRAM 缓冲区,本节即这种情况。

“Flash、SRAM、APB/AHB 外设都可作为源或目标”只能作为总览理解,实际还受总线可达性、读写属性和具体芯片限制。例如 Flash 可以读取但不能像 SRAM 那样被普通 DMA 直接随意写入;只读寄存器也不能当可写目标,最终必须查芯片参考手册。

正常模式(Normal)在计数归零后结束。循环模式(Circular)会在一轮结束后自动重装计数并从原地址重新开始,适合 ADC 连续采样等场景;本芯片的内存到内存模式不能与循环模式同时使用,所以 CubeMX 在本实验中只提供 Normal。

DMA 优先级并非“没用”。多个通道同时申请总线时,先比较软件设置的 Very High/High/Medium/Low;同级时,编号较小的通道优先。只有当前实验没有其他 DMA 竞争,Low 才看不出差别。

2.1 配置DMA

课程在 CubeMX/CubeIDE 中执行的配置过程是:

  1. 新建 STM32F103ZET6 工程,配置 HSE、72 MHz 系统时钟和 Serial Wire 调试接口。
  2. 启用 USART1 异步模式,波特率设为 115200,用于观察拷贝结果。
  3. 打开 DMA 页面,添加 MEMTOMEM 请求,选择 DMA1_Channel1。
  4. Direction 选择 Memory To Memory,源端和目标端地址递增都启用。
  5. 课程先用 Byte/Byte 演示,最后把两端数据宽度都改为 Word/Word。
  6. Mode 保持 Normal,Priority 保持 Low,保存 .ioc 并生成代码。

生成的 MX_DMA_Init() 会打开 DMA1 时钟,并写入方向、递增、宽度、模式和优先级。HAL 在内存到内存方向下,把源地址写到 CPAR、目标地址写到 CMAR;因此生成结构体仍使用 PeriphInc/PeriphDataAlignment 这些名字,并不代表本例真的接入了外设。

2.2 拷贝代码并配置

HAL_DMA_Start() 的参数依次是 DMA 句柄指针、源地址、目标地址和数据项数量。数组名在表达式中通常会退化为首元素地址,但 HAL 接口用 uint32_t 保存地址,因此课程把它们转换为 (uint32_t)。STM32F103 是 32 位地址空间,此转换适用于本工程;在指针宽度不是 32 位的平台上不应机械照搬。

最初配置为 Byte/Byte 时,每项 1 字节,因此 DataLength = BUFF_SIZE = 12;最终配置为 Word/Word 时,每项 4 字节,因此 DataLength = BUFF_SIZE / 4 = 3。核心关系是:

总字节数 = 数据项数量 × 每项传输宽度

本例使用 uint8_t 数组却以 32 位访问。地址必须满足 4 字节对齐,且总字节数必须是 4 的整数倍。全局数组在当前编译器和链接布局下一般会得到足够对齐,但更清晰的工程写法是使用 3 个 uint32_t 元素,或显式声明 4 字节对齐,不能把这种偶然条件推广到任意局部字节数组。

2.3 问题代码分析

DMA 是异步硬件:HAL_DMA_Start() 完成的是配置并启动,函数返回不等于整块数据已经搬完。小到只有 12 字节时,CPU 随后发送串口前 DMA 往往已经结束,所以即使不等待也可能“看起来正确”;缓冲区变大、总线繁忙或代码优化变化后就可能读到尚未完整更新的目标数组。

课程增加的:

while (__HAL_DMA_GET_FLAG(..., DMA_FLAG_TC1) == 0U) {}

是在轮询 TC(Transfer Complete)标志。它能保证后续发送发生在搬运完成后,但这是一个无超时的忙等:若配置错误或出现传输错误,CPU 会永远卡住。工程代码至少还应检测 TE(Transfer Error)并设置超时;使用 HAL 时,更合适的是调用 HAL_DMA_PollForTransfer()。

三、内存到内存DMA传输

视频中的“打印变慢”不是 DMA 搬运变慢,而是后来人为加入 HAL_Delay(1000),并且 HAL_UART_Transmit() 本身也是阻塞发送。UART 115200、8N1 条件下发送 12 字节的线上时间约为:

12 × 10 / 115200 ≈ 1.04 ms

DMA 与串口打印承担不同任务:DMA 只把一个数组复制到另一个数组;打印只是验证手段。频繁打印会占用串口时间和 CPU,但不会让已经完成的内存拷贝重新发生。

四、问题引入

TC 标志在传输完成后会保持置位,直到软件向 DMA 的中断标志清除寄存器写 1 清除它。若不清除,主循环下一次检查会立即通过,于是程序不断重复发送第一次拷贝留下的目标数组,这并不代表 DMA 每次都重新搬运了数据。

__HAL_DMA_CLEAR_FLAG(..., DMA_FLAG_TC1) 只清状态标志,不会自动恢复 CNDTR、源/目标地址,也不会启动新一轮传输。同理,清标志后只打印一次,说明新一轮 DMA 没有正确启动,不是“清标志让 DMA 只能传一次”。

五、代码分析

5.1 清除标志位的重要性

一轮 DMA 的正确状态变化可概括为:

装入地址和数量 → 清旧标志 → 使能 → CNDTR 递减 → TC 置位 → 处理数据 → 清 TC

如果复用通道,旧 TC 必须在下一轮开始前清掉,否则完成判断会把旧事件当成新事件。传输错误标志也应处理,不能只盯住 TC。

5.2 DMA传输次数与CNDTR寄存器变化

CNDTR 的 NDT 字段是 16 位,寄存器可表示 0~65535;真正启动一轮传输时应装入 1~65535,装 0 不会发生传输。它记录的是“剩余数据项数”,不是固定的字节数。

以最终配置为例:

3 → 2 → 1 → 0

每减少 1,实际已经搬完一个 32 位数据项,即 4 字节。原稿中的“每传一个字节,CNDTR 减 1”只在宽度为 Byte 时成立。

5.3 DMA传输完成后的处理

正常模式结束后,CNDTR 停在 0,TC 置位。若要再次传输,必须在通道禁用时重写计数;如源、目标起点也发生变化,还要重写 CPAR/CMAR。内存到内存模式在通道再次使能后立即运行,不需要外设请求。

5.4 代码调试与问题发现

配套最终代码存在一个不宜照搬的顺序问题:初始化区调用 HAL_DMA_Start() 后,主循环一进入就直接重写 CNDTR,然后才等待 TC。12 字节拷贝通常极快,课堂机器上可能在进入循环前已经结束,但代码逻辑没有先证明通道已禁用;根据 RM0008,CNDTR 只能在通道禁用时写,所以该写法依赖时序。

此外,HAL_DMA_Start() 会把 HAL 句柄状态置为 BUSY;直接查询并清硬件标志不会替 HAL 把状态恢复为 READY。若以后再次调用 HAL_DMA_Start(),可能得到 HAL_BUSY。对于本节这种轮询实验,更稳妥的业务代码是每轮都使用配套的 HAL 启动与轮询接口:

if (HAL_DMA_Start(&hdma_memtomem_dma1_channel1,                                  /* 配置地址和数量、清旧标志并启动本轮 DMA。 */
                  (uint32_t)SRC_Buff,                                            /* 传入源缓冲区首地址。 */
                  (uint32_t)DEST_Buff,                                           /* 传入目标缓冲区首地址。 */
                  BUFF_SIZE / 4U) != HAL_OK)                                     /* 32 位宽度下传输 3 个数据项,并判断启动结果。 */
{                                                                                /* DMA 启动失败处理开始。 */
    Error_Handler();                                                             /* 进入工程错误处理,避免继续使用无效数据。 */
}                                                                                /* DMA 启动失败处理结束。 */

if (HAL_DMA_PollForTransfer(&hdma_memtomem_dma1_channel1,                        /* 轮询本轮传输状态。 */
                            HAL_DMA_FULL_TRANSFER,                               /* 等待整块数据传输完成,而不是半传输。 */
                            100U) != HAL_OK)                                     /* 设置 100 ms 超时,并判断超时或传输错误。 */
{                                                                                /* DMA 等待失败处理开始。 */
    Error_Handler();                                                             /* 处理超时或传输错误。 */
}                                                                                /* DMA 等待失败处理结束。 */

HAL_UART_Transmit(&huart1, DEST_Buff, BUFF_SIZE, 100U);                          /* 只有确认 DMA 完成后才发送目标缓冲区。 */

这段改进代码不是配套 main.c 的原始最终内容,而是依据本工程 HAL 实现给出的安全写法。HAL_DMA_PollForTransfer() 会检查传输错误与超时、清完成标志,并把 HAL 状态恢复为 READY,所以下一轮可再次调用 HAL_DMA_Start()。

5.5 重新赋值与使能操作

如果确实要绕过 HAL 状态机直接操作寄存器,至少应严格遵循“等待完成 → 禁用通道 → 清标志 → 写 CNDTR/必要时写地址 → 使能通道”的顺序。寄存器写法和完整 HAL 接口不要随意混用,否则硬件状态与 DMA_HandleTypeDef.State 可能不一致。

六、传输宽度实验

6.1 传输宽度实验介绍

课程先把源、目标宽度都设为 Byte,此时一次传 1 字节、计数 12 次;随后只把两端宽度改为 Word,以观察“宽度”和“数量”的区别。

6.2 传输宽度修改为4个字节

Word 在 STM32F1 DMA 配置中表示 32 位,即 4 字节。它不是 C 语言在所有平台上的 word 类型,也不是“一次搬完整数组”。源、目标宽度都变为 32 位后,源地址和目标地址每次都增加 4。

6.3 实验现象: 数据未打印

若宽度已改为 4 字节,而数量仍写 12,DMA 会尝试传 12 × 4 = 48 字节,越过两个 12 字节数组的边界。TC 最终仍可能置位;“一直没有打印”更可能来自越界访问造成的未定义结果、传输错误或程序状态异常,不能严谨地解释为“数组不够,所以 TC 永远不触发”。DMA 硬件只认识地址和计数,并不知道 C 数组的边界,不会主动保护数组。

6.4 实验修改: 传输数目除以四

把两个装载数量的位置都改成 BUFF_SIZE / 4U,会把数量从 12 项改为 3 项,正好覆盖 12 字节。这里能够整除是因为 BUFF_SIZE = 12;通用代码还应在编译期或运行时保证长度为 4 的整数倍。

6.5 实验现象: 数据打印

修改为 3 个 32 位数据项后,DMA 只访问两个数组各自的 12 字节范围,串口再次得到 02~0D,说明宽度和数量已经匹配。

6.6 实验分析: buffer size为负12

转录中的“buffer size 为负 12”是语音识别错误。源码实际是:

#define BUFF_SIZE 12

它表示宏 BUFF_SIZE 的值为正整数 12。#define 的 # 是预处理指令标记,不是负号。

6.7 实验分析: 传输数目与数组大小

宽度配置CNDTR/DataLength实际搬运字节数是否匹配 12 字节数组
Byte/Byte1212 × 1 = 12匹配
Half-word/Half-word66 × 2 = 12匹配,但地址至少 2 字节对齐
Word/Word33 × 4 = 12匹配,地址至少 4 字节对齐
Word/Word1212 × 4 = 48越界,错误

6.8 实验结论: 数据宽度与传输数目理解

  • 数据宽度决定一次事务搬几个字节,CNDTR 决定有多少个事务。
  • 地址递增的步长跟对应一端的数据宽度一致,而不是永远加 1。
  • DMA 不知道 C 数组长度,防止越界是程序员的责任。
  • 本实验用于理解 DMA 配置;普通小块 RAM 拷贝直接调用 memcpy() 往往更简单。只有数据量、并行性、总线结构和 CPU 占用等条件合适时,DMA 内存拷贝才一定值得采用,不能只凭“DMA 理论上快”下结论。

七、容易混淆的关键点

  • BUFF_SIZE 是“缓冲区字节数”,CNDTR 是“数据项数”,两者只在宽度为 1 字节时数值相同。
  • TC 是一次传输完成的历史状态;反复看到 TC 不代表反复传输了数据。
  • 清 TC、重装 CNDTR、重新使能是三个不同动作。
  • HAL_DMA_Start() 启动后立即返回;要使用目标数据,必须轮询、等待中断回调或采用其他同步机制。
  • 无超时的 while(flag == 0) 可能永久卡死,不适合作为健壮工程模板。
  • 本节没有启用 DMA 中断,HAL_DMA_Start_IT()、NVIC 和完成回调不属于当前实现。

八、参考依据

  • ST 官方 RM0008 Rev 21 参考手册:第 13 章,核对 DMA 通道、优先级、内存到内存启动条件、数据宽度、标志位及 CNDTR 写入限制。
  • 本地配套工程 17DMA _M2M:核对 STM32F103ZET6、DMA1 通道 1、最终 32 位宽度、数组内容和主循环顺序。
  • 本地配套 HAL 驱动 stm32f1xx_hal_dma.c:核对 HAL_DMA_Start() 与 HAL_DMA_PollForTransfer() 对标志、锁和句柄状态的处理。