先提醒一句:这篇文章里的代码和思路全部基于我自己的实际摸索,不同固件版本的 API 会有差异,贴代码之前最好先跑一下help(模块名)确认。开始正题。
你如果在 ESP32-S3 上用 MicroPython 做过几路模拟量加一路音频的同步采集,多半会遇到同一个尴尬:传感器数据明明不多,但主循环一边要轮询 ADC,一边要读 I2S,还要做简单滤波和拼包,最后要么采样率上不去,要么某一路数据断断续续。我一开始也以为是 MicroPython 太慢,后来把问题拆开才发现,瓶颈根本不在计算,而在“搬运”。CPU 花在等数据、搬数据上的时间,比真正处理数据的时间多得多。
后来我把思路换成了用 DMA 做搬运,用链式触发做调度,最后一层用 Scatter-Gather 的思路做数据聚合,才把整个采集链路理顺。简单说就是:让硬件 DMA 按预先排好的描述符,自动把分散在不同缓冲区的数据搬运并拼接成连续的数据块,CPU 只在整块数据就绪后做一次处理。这个方案对 MicroPython 开发者尤其有价值,因为解释型语言本身不擅长高频中断和逐字节搬运,把这些活交给 DMA 才是最合理的选择。这篇文章我会完整展开这套方案的设计思路、MicroPython 里两种落地方式、以及我踩过的一堆坑。
1. 先搞清楚:DMA 的 Scatter-Gather 到底解决什么问题
1.1 普通 DMA 为什么在连续采集中不够用
理解 Scatter-Gather 之前,先看普通 DMA 的局限。普通 DMA 的工作方式是一次搬运一块物理连续的内存区域,比如从外设 FIFO 把 1KB 数据搬到内存的某个数组里。搬运完成,DMA 产生一个中断,CPU 进入中断处理函数,重新配置 DMA 的源地址、目的地址、长度,然后再次启动。这个过程本身没问题,问题在于“每次搬完都要 CPU 重新配置一次”。
拿音频采集举例,16kHz 采样率、16bit 单声道,每秒就是 32KB 数据。如果 DMA 缓冲区只有 1KB,那每秒要产生 32 次中断。对普通单片机来说,这不算什么,但 MicroPython 的解释器执行中断回调时,上下文切换、脚本解释、对象操作的耗时比原生 C 大得多。我实际测过,同等条件下 MicroPython 里 I2S 中断回调的响应延迟比 C 项目高一个数量级,数据一密就容易出 overrun。
普通 DMA 还有一个问题:它只负责“一块一块地搬”,不负责“把多块数据拼起来”。实际采集系统里,数据源往往是分散的——两路 ADC 各有自己的缓冲,一路 I2S 有循环缓冲,还有一个温度传感器通过串口不定长地送数据。如果每一路都用普通 DMA 独立搬运,CPU 还是要频繁参与。这时候就需要 Scatter-Gather。
1.2 Scatter-Gather 的三板斧:描述符、缓冲表、链接
Scatter-Gather 的核心是一个叫“描述符”的数据结构。描述符里记录了一次 DMA 传输所需的全部信息:源地址、目的地址、传输长度、下一跳描述符的地址。多个描述符通过 next 指针串起来,就是一个描述符链表。
DMA 控制器的行为也很直接:它从链表头取出第一个描述符,按描述符里的参数执行搬运,搬完以后自动跳转到 next 指针指向的下一个描述符继续执行,直到链表尾部。如果最后一个描述符的 next 指回首地址,就形成了环形链,DMA 会一直循环搬运,不需要 CPU 干预。
我把这理解成快递小哥的两种工作方式。普通 DMA 相当于小哥手里只有一张快递单,送完一件必须跑回站点领下一单,遇到收件人不在还要等很久。Scatter-Gather 相当于站长给了他一个路线本,上面写着每一单的收货地址、包裹位置、下一单去哪里取,小哥按着路线本一路送完所有包裹再回来汇报。
1.3 链式触发比普通回调聪明在哪
普通 DMA 是“搬完一块,中断通知 CPU,CPU 安排下一块”。链式触发是“搬完一块,DMA 自己读下一个描述符,继续搬下一块”。CPU 不需要在每个数据块之间插一脚,只需要在整条链跑完(或跑到某个特定位置)时收到一个通知。
这个差异在 MicroPython 里很重要。MicroPython 的回调执行成本比 C 高得多,中断里哪怕只是设置一个标志位,也会引入微秒到几十微秒的抖动。如果数据块之间的间隔很小,这种抖动就会变成丢数据的直接原因。把 CPU 从“频繁中断 + 逐次配置”中解放出来,是这套方案最大的收益。
2. MicroPython 环境下做这件事,先认清两条路
2.1 官方 API 对 DMA 的封装边界
MicroPython 官方固件没有直接暴露 DMA 描述符的配置接口,这是很多人的第一印象,也确实属实。官方 API 把 DMA 封装在了外设驱动里:I2S 内部用 DMA 做音频收发,ADC 在支持连续采样的固件里有底层 DMA 队列,SDMMC、SPI 等外设也各有 DMA 加持。
这种封装的好处是简单稳定,你不会碰到描述符写错、地址没对齐这些底层问题。坏处也很明显:你没法自定义描述符链表,没法真正做到完全自定义的 Scatter-Gather。比如 I2S 虽然有循环缓冲,但缓冲数量、每个缓冲的大小只能通过参数控制,DMA 链的具体组织方式是固件作者定好的。
对于大多数项目,官方 API 其实是够用的。你只要理解了底层 DMA 的工作方式,就能通过合理配置 I2S 的 buffer 数量和 ADC 连续采样的队列深度,达到“不等 CPU,自动连续搬运”的效果。我在后面的实战一里会演示这条路。
2.2 想要真正操作 GDMA 描述符,有三条技术路线
如果你的需求超出了官方 API 的能力范围,比如需要把两路完全独立的外设数据用一条 DMA 链穿起来,或者需要在 DMA 搬运过程中动态插入描述符,那有三条路可以走。
路线一:改固件,写自定义 C 模块。这是最正统的做法。你用 ESP-IDF 的驱动接口创建 DMA 通道,手动构建描述符链,然后把这个功能封装成 MicroPython 模块。优点是安全可控,内存分配可以用heap_caps_malloc指定 DMA 能力的内存,完全绕开 MicroPython 层的内存不可控问题。缺点是你得会配 ESP-IDF 环境,编译固件也需要一点时间。
路线二:在 MicroPython 里用machine.mem32和uctypes直接操作寄存器。这条路看起来很酷,实际上极其容易翻车。MicroPython 层没有内存屏障,没有原子操作,寄存器写入时序全靠你手控;更麻烦的是,Python 侧拿到的对象地址不一定是 DMA 可访问的内存,对齐也不一定满足要求。我在实战二里会演示它的思路和坑,但结论先行:只适合研究学习,不适合上生产。
路线三:用内联汇编或esp32.native之类的机制。MicroPython 存在 native code emitter,但主要优化计算,不解决 DMA 描述符管理的问题,适用面很窄,我基本不用。
我的最终选择是混合方案:项目主体逻辑用官方 API 实现,稳定性优先;同时用一个简单的自定义 C 模块封装“真正的链式描述符采集”,在 Python 层只负责触发和读结果。这篇文章会把官方 API 的路线讲透,也会把 C 模块封装思路分享出来。
2.3 平台选型:为什么用 ESP32-S3
选 ESP32-S3 不是因为它最强,而是因为它最适合做 MicroPython 下的 DMA 实验。首先是 GDMA 控制器本身支持链式描述符,通用性很强,不绑定特定外设。其次是外设覆盖面够广,I2S、ADC 连续采样、SPI、串口都能找到对应的 DMA 路径。最重要的是,ESP32-S3 的 MicroPython 固件比较成熟,I2S 的 IRQ 事件、ADCContinuous 这些接口都是现成的。
内存方面,ESP32-S3 有 512KB SRAM,给 MicroPython 用的堆也够大。我做 16kHz 音频 + 双路低速 ADC 聚合时,预分配了几十个 KB 的缓冲,完全没压力。如果是 ESP32-C3,内存小一半,做同样的实验会更局促,但不影响思路。
3. 实战一:用官方 API 实现逻辑链式触发与数据聚合
3.1 系统设计:一路 I2S 音频 + 两路慢速传感器
这个实验我搭了一个比较通用的场景:一路 I2S 数字麦克风以 16kHz、16bit 单声道持续采集音频;两路 ADC 分别接电位器之类的慢变传感器,采样率只要 50Hz;主控需要把 1 秒的数据合成一个数据帧,方便统一上传或保存。
传统写法的毛病很明显:主循环里while True: read_adc(); read_i2s()会导致两边互相拖累。I2S 读慢了缓冲会 overrun,ADC 读快了白白占用 CPU。用 DMA 链式思想改造后,I2S 数据由底层 DMA 不断填入 I2S 内部缓冲,IRQ 事件通知主循环“有数据可读”;ADC 用连续采样模式自动定时填充队列,主循环按需取。各走各的 DMA 通道,互不干扰。
3.2 I2S 数据流的 DMA 缓冲配置
MicroPython 的 I2S 初始化需要关注两个与 DMA 缓冲直接相关的参数:ibuf和buffer_count。ibuf是 I2S 内部 DMA 缓冲区的总字节数,buffer_count是把这个总缓冲区切分成几块。切分后,DMA 从外设接收数据时轮流填充这几块,填满一块就触发一次 IRQ 事件。
这里有一个经验值:缓冲块的大小要略大于你每次准备读取的数据量,否则事件频繁,主循环来不及消费。比如我每次想读 1024 个采样(2048 字节),ibuf就设 32768,buffer_count设 4,每块约 8192 字节。这样一次 IRQ 事件后,主循环有相当长的时间窗口去消费数据,大大降低了 overrun 概率。
from machine import I2S, Pin SAMPLE_RATE = 16000 CHUNK_BYTES = 2048 # 每次读取 1024 个 16bit 采样 audio_in = I2S( 0, sck=Pin(16), ws=Pin(17), sd=Pin(18), mode=I2S.RX, bits=16, format=I2S.MONO, rate=SAMPLE_RATE, ibuf=32768, buffer_count=4, )3.3 IRQ 事件标志 + 主循环消费的模式
MicroPython 的 I2S 支持注册 IRQ 回调,触发条件是数据从 DMA 缓冲就绪。这里我强烈建议不要在 IRQ 回调里直接做复杂处理,只设置一个标志位,让主循环去消费数据。原因有两个:一是 IRQ 回调运行在中断上下文,执行时间长会阻塞其他中断;二是 MicroPython 的回调里如果触发了内存分配,可能给 GC 带来额外压力。
下面的代码用预分配的缓冲池实现“事件触发 + 主循环读取 + 简单聚合”。缓冲池的数量要足够多,保证即使主循环短暂卡顿,也不会因为没有空闲缓冲而丢数据。
import time from machine import I2S, Pin CHUNK_BYTES = 2048 POOL_SIZE = 4 buf_pool = [bytearray(CHUNK_BYTES) for _ in range(POOL_SIZE)] buf_state = [0] * POOL_SIZE # 0: 空闲 1: 已填满等待消费 rx_flag = False def on_rx(_): global rx_flag rx_flag = True audio_in.irq(on_rx, trigger=I2S.RX) def read_one_chunk(): for i in range(POOL_SIZE): if buf_state[i] == 0: n = audio_in.readinto(buf_pool[i]) if n > 0: buf_state[i] = 1 return i return -1 # 预分配聚合缓冲,避免运行期反复申请内存 AGG_BYTES = SAMPLE_RATE * 2 # 1 秒音频数据 agg_buf = bytearray(AGG_BYTES) agg_pos = 0 last_ts = time.ticks_ms() while True: if rx_flag: rx_flag = False slot = read_one_chunk() if slot >= 0: data = buf_pool[slot] n = len(data) if agg_pos + n <= AGG_BYTES: agg_buf[agg_pos:agg_pos + n] = data agg_pos += n buf_state[slot] = 0 if agg_pos >= AGG_BYTES: now = time.ticks_ms() print("one frame ready:", agg_pos, "bytes, cost(ms):", now - last_ts) last_ts = now agg_pos = 03.4 ADC 连续采样与数据融合
ADC 部分我用了 ESP32-S3 新固件上的连续采样接口。如果你的固件没有这个接口,可以用传统轮询方式降采样替代,但连续采样才能真正体现 DMA 的省心之处。ADC 连续采样模式内部有 DMA 支持的环形队列,配置好采样频率后,它自己会定时把采样值填入队列,主循环按需读取。
from machine import Pin import esp32 adccont = esp32.ADCContinuous( adc=esp32.ADC, sample_freq=100, # 每秒 100 个采样,对慢变传感器足够 channel_num=2, channel_width=3, # 12bit 分辨率时的宽度参数 channel_attn=3, channel_bitpack=12, ) adccont.init(adc_pins=(Pin(1), Pin(2)))主循环里每隔一段时间统一取 ADC 数据,并在聚合帧里打上时间戳。我习惯把音频数据和 ADC 数据分块组织,帧头放时间戳,帧体先放 ADC 样本再放音频块。这样接收端解析起来方便,也更容易定位同步问题。如果你需要更严格的通道间时间对齐,可以把 ADC 数据也按 DMA 采样周期封装成带时标的结构,但这里可以演示简单的分块混拼。
frame_header = bytearray(8) frame_header[0:4] = time.ticks_ms().to_bytes(4, "little") frame_header[4:8] = (2 * 4).to_bytes(4, "little") # adc 数据长度这段逻辑里其实已经体现了 Scatter-Gather 的思想:ADC 样本在队列里,音频数据在 I2S 缓冲里,它们在内存中是分散的,但最终被“聚集”到一个连续帧里。硬件 Scatter-Gather 用描述符自动完成这件工作,软件层面则靠主循环按顺序拼接。两者的本质是一致的。
4. 实战二:直操 GDMA 描述符链,实现硬件级 Scatter-Gather
4.1 ESP32-S3 GDMA 描述符长什么样
如果你不满足于官方 API 的“隐形链式触发”,想自己构建描述符链表,就需要了解 GDMA 描述符的数据结构。ESP32-S3 的 GDMA 描述符通常用 8 个 32 位字描述一次传输:第一个字是控制信息,包括缓冲区大小、已传输长度、owner 位、结束标志;第二个字保留;第三个字是下一个描述符地址;第四五六七八个字保存缓冲区地址等参数。
owner 位是个特别关键的细节,它表示当前缓冲区是归软件管还是归 DMA 管。DMA 只会处理 owner 位指示“硬件可写”的描述符,传输完成后把它改成“软件可用”,并触发相应中断。如果你手工操作描述符,必须遵循这个握手规则,否则 DMA 会一直等待、停止,或者覆盖你还没处理的数据。
我在 MicroPython 里用 uctypes 定义描述符结构体,核对寄存器手册后大致长这样:
import uctypes DMA_DESC_LAYOUT = { "ctrl": 0 | uctypes.UINT32, # 包含 size/length/owner/eof 等位域 "reserved1": 4 | uctypes.UINT32, "next": 8 | uctypes.UINT32, # 下一个描述符地址 "reserved2": 12 | uctypes.UINT32, "buf_addr0": 16 | uctypes.UINT32, # 缓冲区0地址 "buf_addr1": 20 | uctypes.UINT32, # 缓冲区1地址 } class GDMA_DESC: def __init__(self, buf, next_ptr=0): self.raw = bytearray(24) self.desc = uctypes.struct(uctypes.addressof(self.raw), DMA_DESC_LAYOUT) self.desc.buf_addr0 = id(buf) self.desc.ctrl = (len(buf) << 0) | (1 << 24) # owner 置1,表示 DMA 可用 self.desc.next = next_ptr这段代码能跑通思路,但直接在生产环境用风险不小。因为id(buf)拿到的地址在 ESP32 的 MicroPython 里通常就是对象的堆地址,等同于物理地址,但 MicroPython 不保证所有堆内存都是 DMA 可访问的,也不保证地址满足 DMA 对齐要求。正确做法是在 C 扩展里用heap_caps_malloc(MALLOC_CAP_DMA)分配。
4.2 手动构建链式描述符的操作流程
假设你用两段缓冲区做音频采集的乒乓缓冲,链式描述符的构建流程大致是:分配两段 DMA 安全的缓冲区,分别创建描述符,把第一个描述符的 next 指向第二个描述符,第二个的 next 指回第一个,形成一个环,然后把首描述符地址写入 GDMA 控制器的 DMA 描述符起始地址寄存器,启动 DMA。
# 伪代码:只演示流程,寄存器地址需要用实测值替换 from machine import mem32 desc0 = GDMA_DESC(buf0, next_ptr=id(desc1_raw)) desc1 = GDMA_DESC(buf1, next_ptr=id(desc0_raw)) GDMA_BASE = 0x3FCE0000 # 示意地址,必须用你真机实测基址 DMA_DESC_ADDR_REG = GDMA_BASE + 0x00 DMA_START_REG = GDMA_BASE + 0x04 mem32[DMA_DESC_ADDR_REG] = id(desc0_raw) mem32[DMA_START_REG] = 1 # 启动这是最简化的流程,真实项目中你还得处理外设和 DMA 通道的绑定、中断使能、忙标志轮询这些步骤。如果描述符里某个字节写错,最常见的现象不是报错,而是 DMA 停转或者系统崩溃,调试起来相当痛苦。我在第一次手搓描述符时,因为 next 指针写成了 Python 对象地址而不是原始字节地址,结果 DMA 直接跳飞,整个系统反复重启,最后用二分排查才发现是地址类型混了。
4.3 更稳妥的做法:写一个 C 扩展模块
如果你想长期稳定地用硬件 Scatter-Gather,我的建议是先放弃在 Python 层直操寄存器,转而在 ESP-IDF 里写一个 MicroPython 自定义模块。C 层用heap_caps_malloc(MALLOC_CAP_DMA)分配描述符和缓冲区,用 GDMA 驱动接口初始化通道,然后为 MicroPython 层暴露两个函数:一个启动链式采集,一个获取聚合结果。
// dma_chain.c 的模块注册片段,接口示意 STATIC mp_obj_t dma_chain_start(mp_obj_t buf_count) { // 构建描述符链,启动 DMA return mp_const_none; } STATIC MP_DEFINE_CONST_FUN_OBJ_1(dma_chain_start_obj, dma_chain_start); STATIC const mp_rom_map_elem_t dma_chain_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_dma_chain) }, { MP_ROM_QSTR(MP_QSTR_start), MP_ROM_PTR(&dma_chain_start_obj) }, };C 模块的好处是描述符字段的赋值完全可控,内存分配的失败能直接捕获,不会因为 Python 层的内存分配时机问题导致 DMA 访问非法地址。这也是为什么很多跑到极限的 MicroPython 采集项目,最终都把热路径下沉到 C 层的原因。Python 负责业务逻辑,C 负责硬件时序,各干各擅长的活。
4.4 寄存器级操作在 Python 层的适用边界
我并不是说 Python 层直操寄存器完全不可行。如果你只是做一个一次性的验证实验,比如让一串 LED 靠 DMA 描述符自动点亮,那 Python 层的 mem32 方案完全够用,代码短、调试直观。但涉及多路高速外设协同、数据吞吐量大的生产场景,Python 层的不确定因素太多。
有一个很容易忽略的坑是 MicroPython 的 GC。GC 执行时,整个 Python 解释器会短暂停顿,如果你的 DMA 描述符链表在 GC 期间被某个对象引用关系影响(比如缓冲对象被回收),后果不可预测。预分配好所有缓冲并在初始化时持有引用,能大大降低这类风险。
5. 性能对比与瓶颈分析
5.1 三种方式的实际表现
我拿同一块 ESP32-S3 开发板,分别用纯轮询、官方 API DMA、C 模块直操链式描述符跑 16kHz 音频 + 双路 ADC 采集,记录一秒钟内主循环的忙闲比和数据完整性。
| 采集方式 | CPU 每秒钟被占用的估计时间 | 缓冲区溢出情况 | 实现复杂度 |
|---|---|---|---|
| 纯轮询读取 | 约占 80% 以上 | 高,主循环稍慢就丢数据 | 低 |
| 官方 API DMA + IRQ 标志 | 约占 30% 到 40% | 低,缓冲池够大时可避免 | 中 |
| C 模块直操链式描述符 | 约占 10% 到 15% | 极低,DMA 自动续跑 | 高 |
这里的数据是我自己环境下的实测感受,不同固件版本、不同任务负载会有差异,但数量级值得参考。特别说明,轮询方式之所以 CPU 占用那么高,不是因为 MicroPython 计算慢,而是因为轮询循环里反复执行外设读操作,每次读都会经过解释器的多层封装,开销非常大。
5.2 真正的瓶颈在哪
跑完对比以后,我意识到一个问题:即使 DMA 把所有搬运工作都接管了,MicroPython 的数据消费端仍然是限制吞吐的短板。I2S 回调只是告诉你“数据准备好了”,真正把数据从缓冲池拷贝到聚合帧,再用 Python 字节码处理,这部分耗时可不能忽视。
解决思路是把数据处理尽量做成“批量”,而不是逐样本处理。比如音频滤波,不要在 DMA 回调里对每个样本做乘加运算,而是攒够一整个数据块后,用切片、数组运算或 C 扩展一次性处理。MicroPython 的字节码循环每次迭代开销不低,积少成多就能压垮采集进程。
另外,如果聚合帧要经过 WiFi 发送,发送本身也可能阻塞主循环。建议把 WiFi 发送放到独立的线程或使用异步任务,避免发送慢时 DMA 缓冲溢出。
6. 常见问题与排查技巧实录
6.1 I2S 回调触发了,readinto 返回的数据全是 0
遇到这种情况,先看是不是在 IRQ 回调里直接调用了 readinto。如果回调里读到的数据是 0,极有可能是因为某个缓冲的owner位还没被 DMA 置回“软件可用”,你只是读到了一个还没来得及被填充的缓冲。
排查顺序:先打印audio_in内部队列的可用字节数,确认 events 触发时确实有数据;再检查是否因为 buffer 太小,数据还在 DMA 转移途中;最后检查引脚接线,I2S 的数据线没接好时,DMA 一样会搬数据,但搬的是无效电平采出来的 0。
6.2 ADCContinuous 配置失败,提示参数错误
这个接口在不同的 MicroPython 固件版本里参数名可能有变化,我就在固件升级后因为channel_width字段变了而踩坑。遇到报错,先跑help(esp32.ADCContinuous)看当前固件的签名,不要照抄网上的旧代码。另外,ESP32 的 ADC2 通道和 WiFi 有硬件复用冲突,如果 ADC 配置后读数不稳定或直接失败,优先检查你是不是用了 ADC2 引脚,返回 -1 或异常时换成 ADC1 通道基本能解决。
6.3 直操 GDMA 后系统反复重启
这个是寄存器级方案最容易踩的坑,原因通常是描述符地址没有对齐,或者 next 指针写错。GDMA 对描述符的对齐要求一般至少 4 字节,保险起见按 8 字节对齐。另一个原因是缓冲区内存不可被 DMA 访问,比如你把缓冲定义在 PSRAM 而 DMA 不支持访问时,系统会在搬运瞬间访问非法地址。
调试时先写一个最小配置,只挂一个描述符,不做链,确认单段搬运正常后再扩展成链式。每次改完寄存器配置,建议用看门狗或者单步打印确认寄存器状态,不要直接上完整采集逻辑。
6.4 常见问题速查表
| 现象 | 大概率原因 | 排查方向 |
|---|---|---|
| I2S 数据偶尔断流 | 主循环消费速度慢于 DMA 填充 | 增加 buffer_count,减小每次读取量 |
| ADC 读数全部为 0 | 用了 ADC2 + WiFi,或引脚初始化不对 | 换用 ADC1,检查 pin 定义 |
| DMA 搬运不启动 | 描述符 next 或 owner 位没写对 | 核对描述符内存内容,检查 owner 位 |
| mem32 直操寄存器后死机 | 地址写错或对齐不满足 | 减小实验范围,先跑单描述符 |
| 聚合帧时间戳跳动大 | 主循环被 GC 或 WiFi 任务打断 | 预分配内存,把发送移到异步任务 |
6.5 几个独家避坑技巧
MicroPython 高性能采集的“秘密”其实不在某一个神奇 API,而在内存和中断的使用策略上。这里分享几个我实际项目中总结出来的习惯。
第一,所有缓冲区全局预分配,不要在采集循环里创建bytearray或list。原因是 MicroPython 对象分配在堆上,分配多了会触发 GC,GC 停顿几十毫秒就可能丢一整块 DMA 数据。
第二,IRQ 回调里只做最少的操作。我目前的做法是只翻转一个 GPIO 引脚,或设置一个整型标志位。主循环检测到标志位后,再批量读取和处理数据。有人喜欢直接在线程里消费 I2S 数据,可行但要注意线程切换开销,实测下来不如事件标志 + 主循环。
第三,调试 DMA 问题时,准备好逻辑分析仪或示波器观察中断引脚的波形。你可以在 IRQ 里让某个引脚翻转,主循环消费后让另一个引脚翻转,用波形对比事件产生和处理的频率,能快速定位是“没数据”还是“来不及处理”。
串口接收不定长数据、Modbus 这类场景,思路也可以照搬:DMA 收到数据后置标志,主循环判断空闲或超时再解析一帧。这样比在中断里逐字节解析稳定得多,这也是网上一堆“DMA 接收不定长”讨论里的常见做法。
最后分享一个小技巧
我后来把这套采集方案稳定下来之后,最大的体会是:MicroPython 里玩 DMA,思路比代码更重要。你先别急着写寄存器,先用官方 API 把“DMA 自动搬运 + 事件通知”这套逻辑跑通,确认瓶颈,再决定要不要下沉到 C 扩展。很多项目其实根本不需要直操描述符,官方 I2S 和 ADCContinuous 的 DMA 能力已经能覆盖八成需求。
另外,调试链式描述符时,记得在固件里开看门狗或加心跳打印。因为描述符一旦写错,系统可能既不报错也不继续跑,很容易让人以为是采集数据没来,实际上是 DMA 早就停在某个错误节点了。我在第一次手搓 GDMA 描述符时就因为 next 指针写错,整整排查了两个小时,最后发现只是把一个 Python 整数的低 32 位当成了内存地址,这种低级错误有个看门狗帮忙定位能省太多时间。