DMA:数据怎样自动搬进数组?
屏幕和麦克风章节已经让 DMA 帮忙搬数据。现在把这个“小助手”拆开:谁告诉它开始搬,从哪里搬、往哪里放、一次搬多大,以及搬完以后程序应该怎样接手。
第 1 节 · 如果不用 DMA,每个样本由谁搬?
ADC 完成一次转换,结果先出现在外设的结果寄存器里。没有自动搬运时,CPU 执行指令读出这个数,再把它写入数组。连续接收很多样本,就要反复做“读寄存器、写数组、移动位置”这些动作;串口接收也有相似过程。
DMA 是直接存储器访问控制器。CPU 先配置一段传输,外设准备好时发出搬运请求,DMA 再通过芯片内部的总线读来源、写目标,并更新自己的位置和剩余数量。CPU 不必为每一个样本执行同样的复制指令,可以去做别的工作。
这里的“请求”是芯片内部连接,不需要外面再接一根线。一次请求可以推动多少数据,取决于外设和 DMA 的配置。搬运也需要总线时间,会与 CPU、其他外设协调使用总线,并不是数据瞬间消失又出现在数组里。
它替代了搬运,谁还在负责别的事情?
CPU 仍需初始化、准备缓冲、检查启动结果,在搬完后处理数据和错误。ADC 负责把电压变数字,串口负责把字节变成引脚时序;DMA 负责在内存和外设之间搬运。前面的协议解析、心率计算也仍由程序完成。
反方向也成立:刷新屏幕时,数据可以从像素数组搬到串行外设;串行外设再按时钟把它们送上引脚。先看方向,再看谁产生请求,才能选择合适配置。
第 2 节 · 给 DMA 的配置单上,必须写清什么?
继续使用麦克风,目标是把 256 个 ADC 结果写入 uint16_t 数组。这里每个元素占两个字节,整个数组占 512 字节。配置必须让来源、目标和解释方式一致:
| 配置项 | 麦克风里怎样选 | 为什么 |
|---|---|---|
| 请求来自哪里 | ADC1 | 有新的 ADC 结果时才需要搬 |
| 方向 | 外设 → 内存 | 从结果寄存器取出,写进数组 |
| 来源地址是否变化 | 固定 | 每次都读同一个 ADC 结果寄存器 |
| 目标地址是否变化 | 逐次增加 | 依次写入数组第 0、1、2……个元素 |
| 来源与目标宽度 | 16 位,即半字 | 对应 uint16_t 元素 |
| 长度 | 这一轮 256 个结果 | 写完一轮后报告完成,再按循环规则继续 |
如果目标地址也固定,后来的结果会反复覆盖同一个位置;如果实际搬运宽度与数组元素不一致,数据排列和占用范围就可能出错。数组名称只提供地址,不会自动替 DMA 推断宽度和容量。
在 adc.c 中,先找到这几行,对应上表:
NodeConfig.Init.Request = GPDMA1_REQUEST_ADC1;
NodeConfig.Init.Direction = DMA_PERIPH_TO_MEMORY;
NodeConfig.Init.SrcInc = DMA_SINC_FIXED;
NodeConfig.Init.DestInc = DMA_DINC_INCREMENTED;
NodeConfig.Init.SrcDataWidth = DMA_SRC_DATAWIDTH_HALFWORD;
NodeConfig.Init.DestDataWidth = DMA_DEST_DATAWIDTH_HALFWORD;
为什么启动时传 256,而不是 512?
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)mic_buf, 256) 的长度单位是转换结果数量。当前库根据来源宽度,在内部把 256 个半字换成 512 字节,再交给 DMA。直接调用别的 DMA 接口时,长度单位可能就是字节,因此要检查具体函数,不能把不同层的参数机械照搬。
mic_buf 是 uint16_t 数组;这里转换指针类型只是满足函数声明,不改变已配置的 16 位搬运宽度。转换成 uint32_t 指针也不会把数组扩容两倍。
第 3 节 · 写完一轮,为什么还能自动回到开头?
一次性模式把指定数量搬完就停止。持续记录声音时,不能每轮都等主循环重新配置,因而需要循环使用缓冲区。在本项目的 STM32U5 上,GPDMA 可以从内存读取配置单,搬完一张配置单描述的数据,再去读取下一张。
这样的配置单在库里叫节点;节点记下请求、地址、宽度和长度等配置,还能链接到后续节点。将若干节点按“下一张在哪里”连起来,就得到链表。它保存的是搬运安排,和存放声音样本的数组用途不同。
当前 ADC 只需重复相同传输,因此队列里只有一个节点,再把最后链接回开头。源码用以下三个接口建立安排;它们返回 HAL_OK 才表示该步成功:
| 接口 | 这一步的作用 |
|---|---|
HAL_DMAEx_List_BuildNode(&NodeConfig, &Node_GPDMA1_Channel0) | 根据已经填写的配置,生成一个节点 |
HAL_DMAEx_List_InsertNode(&List_GPDMA1_Channel0, NULL, &Node_GPDMA1_Channel0) | 把节点放进当前为空的链表 |
HAL_DMAEx_List_SetCircularMode(&List_GPDMA1_Channel0) | 把末尾连接回起点,形成循环 |
随后配置通道为循环链表模式,关联这条链表,再用 __HAL_LINKDMA() 将 ADC 对象和 DMA 对象相连。最后 HAL_ADC_Start_DMA 填入实际来源地址、目标数组地址和传输长度,并启动传输。这样,先前看起来很长的初始化就能分成“写配置 → 建节点 → 接成循环 → 关联外设 → 启动”几件事。
单个节点设置中出现 DMA_NORMAL,不代表整条链没有循环;应同时检查链表末尾如何链接和通道的 LinkedListMode。节点和工作数组都要在硬件使用期间一直有效,不能在一个返回后就失效的局部变量里临时创建。
第 4 节 · 完成通知到了,这块数据还能保持多久?
循环传输会再次写回同一数组。半满和全满通知只告诉程序“刚完成到这里”,不会替程序永久保留这段历史。麦克风篇已经画过 前后半区的复制时机,现在进一步算清处理期限。
假设输出采样率为每秒 16000 个结果,数组共 256 个元素,每半区 128 个元素,那么写一半需要 128÷16000=8 毫秒。前半完成后,DMA 再花约 8 毫秒写后半,然后回到前半;程序必须在这段时间内取走前半。这里 16000 是算例,当前项目要测量真实输出率。
如果先花 20 毫秒打印日志,再去读取刚刚完成的前半区,它可能已经被下一轮覆盖。把通知标志声明为 volatile,只能让编译器按相应要求读写标志,不能冻结 DMA,也不能自动保存被覆盖的数据。
先复制成稳定的一帧,再慢慢计算
仓库在半区完成回调里及时复制,拼成另外一份完整帧,再供声音计算使用。后来增加关键词识别,才为两个使用者分别保留帧和状态;原因已在麦克风篇说明。这里要记住:处理对象什么时候可以写、什么时候可以读,必须有明确安排。
完整通知路径是 DMA 通道中断 → HAL_DMA_IRQHandler → HAL 内部 ADC 搬运回调 → HAL_ADC_ConvHalfCpltCallback 或 HAL_ADC_ConvCpltCallback。回调短小地完成复制或通知,长时间算法由后续主循环或任务处理。
出问题时,依次观察启动是否返回 HAL_OK、计数是否前进、中断是否进入、数据是否按宽度排列、处理是否赶在覆盖前完成。缓存区扩大能增加一定余量,但若平均处理速度长期赶不上产生速度,仍然要丢弃部分数据、减少工作量或调整采集安排。
第 5 节 · 到这里,项目裸机阶段需要的知识已经齐了
现在已经讲完项目裸机阶段需要的外设与数据路径:GPIO 读按钮,串口连接无线芯片,SPI 驱动屏幕,IIC 读取传感器,ADC 采电池和声音,应用协议组织消息,DMA 搬运连续数据。接下来可以把这些部分放进一个主循环,完成项目的裸机逻辑综合。
这里“已经齐了”指完成这条教学项目路线所需的驱动和通信知识。仓库当前的完整固件已经使用 FreeRTOS,后面会解释任务组织和算法接入;这一阶段先学会在没有任务调度器时安排数据流。
资料与源码
- ST AN5593:GPDMA 使用说明,查请求、传输宽度和链表。
- adc.c、gpdma.c、spu0410.c;长度换算与回调可对照仓库 ADC HAL 源码。