SMARTWATCH 学习手册有 C 基础 · 嵌入式从零开始
主线 / 数据搬运

DMA:数据怎样自动搬进数组?

屏幕和麦克风章节已经让 DMA 帮忙搬数据。现在把这个“小助手”拆开:谁告诉它开始搬,从哪里搬、往哪里放、一次搬多大,以及搬完以后程序应该怎样接手。

前置:数组、中断、ADC 与串口先前用过,现在解释原理本篇结束后进入裸机收束

第 1 节 · 如果不用 DMA,每个样本由谁搬?

ADC 完成一次转换,结果先出现在外设的结果寄存器里。没有自动搬运时,CPU 执行指令读出这个数,再把它写入数组。连续接收很多样本,就要反复做“读寄存器、写数组、移动位置”这些动作;串口接收也有相似过程。

DMA 是直接存储器访问控制器。CPU 先配置一段传输,外设准备好时发出搬运请求,DMA 再通过芯片内部的总线读来源、写目标,并更新自己的位置和剩余数量。CPU 不必为每一个样本执行同样的复制指令,可以去做别的工作。

ADC请求驱动DMA把结果搬到数组CPU配置来源、目标、宽度和数量。ADC有结果时请求DMA,DMA固定读取结果寄存器并依次写入数组,完成后通知程序。CPU 先安排传输;外设请求推动重复搬运ADC 结果寄存器每次结果仍在同一地址DMA按配置读取并写入样本数组每次写下一个元素ADC 产生搬运请求 → DMA 完成一次数据传输搬完规定数量后产生通知,程序再处理这一批。
DMA 负责重复搬运,ADC 负责转换,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 可以从内存读取配置单,搬完一张配置单描述的数据,再去读取下一张。

这样的配置单在库里叫节点;节点记下请求、地址、宽度和长度等配置,还能链接到后续节点。将若干节点按“下一张在哪里”连起来,就得到链表。它保存的是搬运安排,和存放声音样本的数组用途不同。

单个DMA链表节点链接回自身节点保存搬运配置。末尾指针链接回该节点自身,搬完本轮后重新读取配置继续一轮,形成循环。一张搬运配置单,也能链接回自己节点:请求、来源、目标、宽度与长度按配置搬完一轮 → 读取“下一张配置”下一张仍是自己:继续循环填充同一块工作数组。
配置节点说明怎样搬,工作数组保存搬来的样本,两者不是同一块数据。

当前 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,后面会解释任务组织和算法接入;这一阶段先学会在没有任务调度器时安排数据流。

下一步有两种读法

沿项目继续:进入 裸机逻辑综合,把各项工作接起来,结束裸机部分。

补充其他嵌入式必学知识:展开目录的“嵌入式拓展”,依次学习 时钟、定时器、看门狗与系统运行,之后同样回到逻辑综合。它们在其他控制与测量项目中也很常用,这里单独放成拓展路线。

资料与源码