DMA从内存到内存
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 中执行的配置过程是:
- 新建 STM32F103ZET6 工程,配置 HSE、72 MHz 系统时钟和 Serial Wire 调试接口。
- 启用 USART1 异步模式,波特率设为 115200,用于观察拷贝结果。
- 打开 DMA 页面,添加
MEMTOMEM请求,选择DMA1_Channel1。 - Direction 选择
Memory To Memory,源端和目标端地址递增都启用。 - 课程先用 Byte/Byte 演示,最后把两端数据宽度都改为 Word/Word。
- 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/Byte | 12 | 12 × 1 = 12 | 匹配 |
| Half-word/Half-word | 6 | 6 × 2 = 12 | 匹配,但地址至少 2 字节对齐 |
| Word/Word | 3 | 3 × 4 = 12 | 匹配,地址至少 4 字节对齐 |
| Word/Word | 12 | 12 × 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()对标志、锁和句柄状态的处理。