MicroPython ADC采样不再阻塞主循环:DMA与乒乓缓冲实战
在 ESP32-S3 上跑 MicroPython接一颗模拟气压传感器只需要 1kHz 采样按理说这点负载对双核 240MHz 的芯片来说不算什么。可真把代码跑起来就会发现主循环里的 OLED 刷新掉到每秒几帧WiFi 偶尔直接断连。问题不在传感器也不在屏幕而是machine.ADC的阻塞式接口。每次read_u16()都是一个“启动转换 - 等结果 - 返回”的串行流程采样期间 CPU 被死死摁住别的任务全部让路。想在高采样率下继续干别的事就得把 ADC 从 CPU 手里摘出来让 DMA 去当搬运工再用乒乓缓冲把“采集”和“处理”这两件事重叠起来。这篇就围绕这套方案展开适合正在被 MicroPython ADC 拖慢主循环卡脖子的开发者也适合想在嵌入式里把 DMA 和乒乓缓冲真正用明白的朋友。1. 先看问题阻塞式 ADC 到底在耗什么1.1 一段典型代码的耗时拆解很多同学写 ADC 读取都是这种风格from machine import ADC, Pin import time adc ADC(Pin(35), attenADC.ATTN_11DB) samples [] while True: t0 time.ticks_us() for _ in range(100): samples.append(adc.read_u16()) dt time.ticks_diff(time.ticks_us(), t0) print(100 次采样耗时:, dt, us) time.sleep_ms(500)这段代码里read_u16()每一次返回一个 16 位整数看起来人畜无害。但如果在循环里把时间打出来100 次采样在常见的 ESP32 MicroPython 固件上大约要 10~20ms。换算一下等效采样率只有 5~10kHz而且在这 10~20ms 内主循环几乎什么都干不了不能刷屏、不能处理网络、不能扫描按键。只要采样率稍微提上去一点主循环就被 ADC 彻底绑架了。有人会说“我又不采音频10Hz 慢采总行吧” 确实如果你只是测个电池电压、环境温度阻塞式采样绰绰有余。但很多项目不是这样的振动分析、电流波形、光电传感器脉冲、音频幅值检测都需要几 kHz 甚至几十 kHz 的采样率。这时候阻塞式 ADC 就成了整个系统最明显的瓶颈而且这个瓶颈不是换一块更快的 MCU 就能解决的因为问题出在软件模型上。1.2 read_u16 背后到底发生了什么阻塞式 ADC 慢不能全赖 MicroPython底层硬件流程本身就不便宜。一次完整的read_u16()在硬件层面大致要经历这几步配置 ADC 通道选择要采的引脚、衰减系数、采样时间这些寄存器每次都要重新写。启动单次转换向 ADC 控制寄存器写启动位。等待转换完成ADC 内部逐次逼近寄存器SAR工作时CPU 要么轮询状态位要么干等中断。读取结果寄存器把 12 位或 16 位的转换结果读出来。返回给 MicroPython 虚拟机把 C 层的整数包装成 Python 的int对象。中间还有 MicroPython 解释器本身的开销。每次调用read_u16()都要经过 Python 虚拟机的函数调用分发、参数检查、返回值封装这些虽然只有几微秒但累积起来相当可观。更隐蔽的是某些固件为了读数稳定会对同一通道做多次采样再取平均一次 API 调用可能内部做了 8 次甚至 16 次转换时间自然成倍上涨。所以阻塞式 ADC 的慢是“硬件转换等待 寄存器操作 解释器开销”三者的叠加。CPU 在这个过程中全程在线等待没有任何计算是有效的。1.3 实测多少采样率会把主循环压垮我手头有一块 ESP32-S3-DevKitC跑的是官方 MicroPython 固件测出来的典型数据大致如下单次采样耗时参考采样次数总耗时等效采样率主循环状态100 ~ 200 us101~2 ms5~10 kHz勉强能跑 UI100 ~ 200 us10010~20 ms5~10 kHz明显卡顿100 ~ 200 us1000100~200 ms5~10 kHz几乎死机不同固件、不同芯片差异很大但量级就在这。注意等效采样率看起来不低但代价是 CPU 在这段时间内完全不能做别的事。换句话说阻塞式 ADC 把“采样”和“处理”强制变成了串行任务采一会、处理一会或者干脆把处理时间全部让给采样。这种设计在低频慢采时没问题一旦进入连续采集场景就必须换思路。2. 为什么 DMA 和乒乓缓冲能解这道题2.1 DMA 的定位只搬运不思考DMADirect Memory Access直译过来就是“直接内存访问”。它的作用很简单让外设和内存之间能直接传数据不需要 CPU 一条一条地搬。ADC 连续采样时每完成一次转换结果可以自动写入内存里事先指定的地址。CPU 只需要在开始的时候配置好起始地址、要采多少个点、触发源是什么然后就可以去干别的了。等 DMA 搬运完一整批数据再通过中断通知 CPU 一声。这个机制用生活里的事类比就是以前你去餐厅点了几十道菜每上一道菜服务员都要跑到后厨催一遍你本人也一直盯着出菜口DMA 相当于在厨房和你的桌子之间装了一条传送带所有菜做好之后自动送到指定位置全部到齐后按一次铃你过去端菜就行。后厨忙着炒菜你忙着聊天互不干扰。需要澄清一点DMA 解放的是“搬运样本”这个过程但样本到了缓冲区之后后续的滤波、统计、显示仍然要 CPU 处理。所谓“解放 CPU”不是说整个系统完全不需要 CPU而是 CPU 从“等待每个样本”变成“批量处理一帧数据”从被采样过程占死变成按块接收任务。这个块与块之间的大段时间主循环可以自由支配。2.2 乒乓缓冲为什么必须给数据准备两个家有了 DMA还要解决缓冲区竞争问题。假设只有一块缓冲区DMA 往里面写数据CPU 在另一个任务里读这块数据做处理会发生什么大概率是 CPU 刚读到一半DMA 已经把缓冲区后半段覆盖了数据一塌糊涂。更麻烦的是CPU 处理完缓冲区后DMA 可能还在继续写你根本不知道它写到哪了。乒乓缓冲Ping-Pong Buffer就是为这个问题设计的。它准备两块缓冲区比如 A 和 B规则如下DMA 当前只往 A 里写当 A 写满触发中断程序把 DMA 的目标地址立刻切换到 BCPU 在此时开始处理 A 里的完整数据当 B 写满再次触发中断程序把 DMA 目标切回 ACPU 则处理 B。整个过程DMA 永远有一块“安全”的缓冲区可以写CPU 也永远有一块“完整”的数据可以读两者通过角色互换避免了竞争。乒乓缓冲本质上就是嵌入式里的“生产者-消费者”模型DMA 是生产者主循环是消费者两块缓冲区就是两个交替流转的仓库。单一缓冲区等于只有一个箱子消费者搬货时生产者必须停下乒乓缓冲给了两个箱子你搬这箱我装那箱谁也不用等谁。2.3 半传输中断一块缓冲也能模仿乒乓如果内存非常紧张还有一个更省内存的技巧利用 DMA 控制器的半传输中断Half Transfer Interrupt。一块缓冲区被 DMA 填充到一半时触发一次中断此时 CPU 处理前半段DMA 继续往后写等整个缓冲区写满再触发一次完成中断CPU 处理后半段。CPU 处理前半段时DMA 正在写后半段两者天然错开。效果和乒乓缓冲几乎一样但只需要一半的内存。代价是中断频率翻倍而且缓冲区长度必须能被“半满”事件准确一分为二。实际项目中如果内存允许我更推荐真正的乒乓双缓冲逻辑更清晰不容易出错只有你在内存极其紧张的 MCU 上跑才考虑用半传输中断省那几 KB。3. MicroPython 没有现成的 DMA API怎么把方案落地3.1 先接受一个事实内置 machine.ADC 不开放 DMA这里要泼一盆冷水官方主线 MicroPython 的machine.ADC并没有开放 DMA 相关参数你没法在一个普通固件里直接写adc ADC(...); adc.read_dma()这样的代码。这是 MicroPython 解释器层面的设计取舍为了保持 API 简洁牺牲了底层外设能力的暴露。但这不代表标题里的方案是空谈。实际落地有几种现实可行的路线核心思路都是既然 MicroPython 的 Python 层碰不到 DMA那就把 DMA 相关的底层逻辑用 C 扩展封装起来给 Python 层留一个干净接口。如果你完全不想碰 C也有绕开内置 ADC 的外设替代方案。下面三条路线我都实测过或拆解过按推荐程度依次讲。3.2 路线 ARP2040 上用 PIO 做采样时钟DMA 交给 C 扩展树莓派 Pico 的 RP2040 有个很大的优势MicroPython 的rp2.PIO模块可以直接编程序控制状态机。可以用 PIO 产生稳定的采样触发脉冲让 ADC 按照精确的时间间隔采样然后配置 DMA 从 ADC 结果寄存器把数据搬到内存。PIO 部分可以完全用 Python 写from rp2 import PIO, StateMachine, asm_pio from machine import Pin asm_pio(sideset_initPIO.OUT_LOW) def adc_clk(): # 每两个时钟周期输出一个正脉冲触发 ADC 采样 nop().side(1) nop().side(0) sm StateMachine(0, adc_clk, freq200_000, sideset_basePin(10)) sm.active(1)这个 PIO 状态机只是产生采样时钟真正的 DMA 搬移还是需要 C 扩展来完成。RP2040 的 SDK 提供了dma_channel_configure、dma_channel_start这些函数在 C 扩展里配置 DMA 从 ADC FIFO 读数据写到乒乓缓冲区满了以后切换目标并触发 Python 层可以轮询的标志。整体思路不复杂但前提是你得接受写一点 C 代码。3.3 路线 BESP32 上写 C 扩展把 ADC 连续模式封装成模块ESP32 系列最舒服的做法是走 ESP-IDF 的adc_continuous驱动。这个驱动底层就是 ADC DMA 连续采集支持多通道、可配置频率、可配置帧大小。在 MicroPython 里通过 usermodule 机制写一个 C 扩展把adc_continuous包装成 Python 可调用的接口就能拿到完整的 DMA 采集能力。MicroPython 官方仓库的examples/usercmodule目录就是现成的工程模板。C 扩展的核心结构大致如下// 伪代码骨架ESP32-S3 ADC continuous MicroPython usermodule #include esp_adc/adc_continuous.h #include py/runtime.h static adc_continuous_handle_t adc_handle; static uint8_t pingpong_buf[2][4096]; static volatile int current_write 0; static volatile int ready_buf -1; static bool IRAM_ATTR adc_cb(adc_continuous_handle_t handle, const adc_continuous_data_t *data, size_t len, void *user_data) { // 在中断回调里只标记数据就绪不做 Python 回调 ready_buf current_write; current_write ^ 1; return true; } STATIC mp_obj_t myadc_start(mp_obj_t channel_in, mp_obj_t freq_in) { // 配置 adc_continuous_handle设置采样频率和帧大小 return mp_const_none; } STATIC mp_obj_t myadc_read(mp_obj_t self_in) { // 把 ready_buf 指向的数据以 bytes/bytearray 形式返回 // 注意这里要用 memcpy 拷出去避免 DMA 下一次写入覆盖 return mp_obj_new_bytes((const char *)pingpong_buf[ready_buf], 4096); }需要提醒的是ESP-IDF 版本不同adc_continuous的 API 略有差异。5.x 版本用adc_continuous_handle_cfg_t和adc_continuous_start老一点的 4.x 版本接口不一样复制代码前先确认自己的工具链版本。这个路线实现难度相对高但效果最直接采样率上限也最高适合需要高频连续采集的硬核项目。3.4 路线 C外接 I2S ADC用现成 machine.I2S 绕开内置 ADC 瓶颈如果纯粹不想碰 C还有一个“曲线救国”的方案外接一颗 I2S 接口的 ADC 芯片。很多音频 ADC 芯片直接输出 I2S 格式的数字信号而 MicroPython 自带machine.I2S模块底层驱动本身就用了 DMA。你只需要配置好引脚然后周期性调用readinto()把数据块搬到字节数组里from machine import I2S, Pin import audio i2s I2S(0, sckPin(4), wsPin(5), sdPin(6), modeI2S.RX, bits16, formatI2S.MONO, rate16000, ibuf8192) buf bytearray(2048) while True: n i2s.readinto(buf) # 在这里处理 buf 里的采样数据这个方案的优点是非常省心不需要写任何 C采样数据由 I2S 外设的 DMA 自动搬运主循环只在一整块数据准备好之后处理。缺点是必须加一颗外接芯片而且 I2S 采样率通常面向音频域做低速物理量采样时要注意前端抗混叠滤波。如果项目本身就要采集音频或者振动信号这是性价比最高的路线。3.5 三条路线怎么选一张表讲清楚路线平台实现难度采样率上限典型场景是否需写 CPIO DMA C 扩展RP2040中高中等连续块采集、波形记录需要C 扩展 adc_continuousESP32 系列高较高高频多通道、FFT、时域分析需要machine.I2S 外接 ADC任意带 I2S 的 MCU低中高音频、振动、语音不需要我的个人选型习惯是信号带宽在几百 Hz 以内用 ESP32 的 C 扩展方案最顺手手上正好有 Pico又想顺便研究 PIO就走路线 A一旦涉及到音频或语音直接上 I2S别犹豫。三条路线没有绝对优劣只看你手里有什么板子、愿不愿意写 C、系统对实时性的要求有多高。4. 乒乓缓冲工程落地的完整细节4.1 参数设计采样率、缓冲长度、中断频率怎么配乒乓缓冲不是简单申请两块内存就完事参数设计直接决定系统能不能跑稳。核心公式有三个每块缓冲的数据量 缓冲长度 × 每个样本的字节数缓冲填满一次的时间 缓冲长度 / 采样率中断频率 采样率 / 缓冲长度举个例子采样率 10kHz缓冲长度 256 个样本那么每块缓冲填满需要 25.6ms对应大约 39 次中断每秒。这个中断频率对主循环的压力很小CPU 每 25.6ms 收到一块 256 点的数据处理完再去忙别的。缓冲长度不能拍脑袋定要考虑三点。第一如果要做 FFT缓冲长度最好是 2 的幂比如 256、512、1024否则频谱点数尴尬第二缓冲长度越小数据延迟越低但中断越频繁第三缓冲长度越大CPU 每次处理的数据块越大单次处理时间也越长如果超过两个缓冲周期就会丢数据。一般我会预留 2 倍余量把“单块处理时间”压在缓冲周期的 40% 以内。4.2 DMA 中断里的切换顺序一步都不能错乒乓切换最关键的代码在 DMA 中断里。很多第一次写的人容易踩一个顺序错误直接在 DMA 还在传输的时候改目标地址结果寄存器写入失败或者半截数据落到错误位置。正确的顺序应该是static void IRAM_ATTR dma_done_isr(void *arg) { // 1. 停掉 DMA 通道 dma_channel_abort(ch); // 2. 标记当前 buffer 已经就绪 current_ready current_dma_target; // 3. 切换 DMA 目标地址到另一块 buffer current_dma_target ^ 1; dma_channel_set_trans_addr(ch, buffers[current_dma_target], BUFFER_SIZE); // 4. 重新启动 DMA dma_channel_start(ch); }顺序是先停、再标记、再切换、最后启动。停掉 DMA 是为了保证目标地址切换时不会有正在进行的传输漏了这一步轻则丢一个样本重则出现数据错位。还有一种做法是配置 DMA 的自动重载功能让每次传输结束自动重新加载同一个目标缓冲区但这只能实现单缓冲循环做不了乒乓切换。4.3 Python 层安全取数据就绪标志和内存视图C 扩展里 DMA 中断只管标记就绪Python 层通过一个poll()函数取数据。关键原则是中断回调里不能执行 Python 回调否则 MicroPython 虚拟机还在处理别的事情时被硬件中断插入轻则卡顿重则直接崩溃。正确的做法是中断里只改一个 volatile 变量Python 层主动查询。取数据时尽量用memoryview或者直接拷贝到预先分配的bytearray避免每次新建大对象。一个实用的接口设计是myadc MyADC() myadc.start(channel0, freq10000, block_size256) while True: if myadc.ready(): buf myadc.get_block() # 返回一个 memoryview process(buf)这里get_block()内部把 C 层 buffer 的数据拷贝到 Python 层预先分配的内存里虽然多一次 memcpy但内存所有权清晰不会出现 Python 对象被 DMA 后台改掉的诡异问题。如果你对性能极其敏感也可以直接返回指向 C buffer 的memoryview但此时必须保证在处理完之前不调用poll()去切换缓冲区。4.4 数据拼接块与块之间真的没有缝隙吗很多做波形分析的人会问DMA 乒乓切换的瞬间会不会丢几个样本答案是只要 DMA 切换顺序正确数据流是连续无缝隙的。ADC 在后台持续转换DMA 目标地址哪怕在切换期间也能保证下一批数据写到另一块 buffer中间不会漏点。但要注意另一个问题块与块之间的处理时间间隔。CPU 正在处理 A 块时DMA 在填 B 块如果 CPU 处理 A 块耗时超过两个缓冲周期DMA 填满 B 块后会再次切回 A 块而此时 CPU 可能还在处理 A于是数据就被覆盖了。这本质上是处理速度跟不上采样速度不是乒乓机制本身的问题。解决办法有三个加大缓冲深度从 2 块增加到 4 块、8 块变成环形缓冲、降低采样率、或者把部分计算下沉到 C 层。做 FFT 频谱分析时还经常需要重叠帧每次保留上一帧末尾的一部分拼接到当前帧前面这在 Python 层做会有一些拷贝开销建议直接在 C 层完成。5. 实测效果与我踩过的五个坑5.1 对比数据主循环终于“活”过来了在同样的 ESP32-S3 板子上同样的 10kHz 采样需求我用两种方式做了对比。阻塞式 ADC 的主循环里只有两个任务刷一块小 OLED 和做 10Hz 的软按键扫描结果刷屏帧率掉到个位数换成 C 扩展 DMA 乒乓缓冲后采样任务变成每 25.6ms 处理一块 256 点数据单块处理耗时大约 2ms简单的峰值检测和均值计算主循环剩余时间几乎全部释放出来OLED 刷新恢复满速WiFi 也不断了。指标阻塞式 ADCDMA 乒乓缓冲10kHz 采样对主循环占用几乎 100%约 8%24ms 中 2ms 处理OLED 刷新率数帧/秒满速WiFi 稳定性时断时续稳定代码复杂度低中高数据本身不意外但第一次看到主循环时间线从“全红”变成“偶尔一个短脉冲”时还是会觉得这套方案值回成本。5.2 坑一带 Cache 的 MCU数据会“隐身”很多带高速缓存Cache的 MCU比如某些基于 Cortex-M7 或带 L1 Cache 的芯片DMA 写入的内存区域对 CPU 是不可见的。原因是 DMA 直接写物理内存但 CPU 读的是 Cache 里的旧副本两边看到的不是同一份数据表现就是 pingpong buffer 里的数据时而正常时而乱码调试时非常诡异。解决思路有两个一是把 DMA buffer 定义在不可缓存non-cacheable的内存区域很多 MCU 的 linker script 里可以直接指定 section二是在 DMA 完成中断里对 buffer 地址执行 Cache 无效化操作例如cache_invalidate_addr强制 CPU 下次读取时从物理内存拉新数据。这个问题只在带 Cache 的平台上出现Cortex-M0/M4 这类不带 Cache 的入门内核一般不用操心。5.3 坑二ADC 上电稳定时间第一批数据不能信ADC 模块上电或切换通道后输入采样电容需要时间充放电转换结果在一段时间内是不稳定的。DMA 连续采样的第一批数据往往整体偏大或偏小如果直接拿去做校准或者显示会出现一个明显的“台阶”。最省事的办法是在启动 DMA 后先丢弃前 16~64 个样本等 ADC 稳定了再开始真正的数据采集。在 C 扩展里可以在启动时先让 DMA 空转一段或者在前处理逻辑里加一个丢弃计数别把这段不稳定数据当成真实现象去排查半天。5.4 坑三MicroPython 的 GC 会让主循环周期性“僵住”MicroPython 的垃圾回收器GC在主线程运行触发时可能暂停几毫秒。这段暂停本身不算长但如果恰好发生在 pingpong buffer 就绪需要被处理的时候就会延迟取数据累积下来可能导致缓冲超时。尤其是在 Python 层频繁创建临时字节数组、列表时GC 会更频繁。对策有三个一是把所有可能反复用到的对象预先分配不要在采集循环里频繁创建二是在主循环空闲时主动调用gc.collect()把 GC 时间挪到不重要的时段三是最彻底的办法把逐样本处理下沉到 C 层Python 层只接收 C 层算好的统计结果。经历过一次被 GC 坑掉整帧数据之后我现在写采集代码都会习惯性检查有没有在循环里产生临时对象。5.5 坑四乒乓缓冲本身不丢数据但处理超时会丢乒乓缓冲能保证 DMA 写入不中断但它并不能保证数据在“处理”侧不丢。如果 Python 层处理一块数据耗时超过两个缓冲周期DMA 填满当前块后切回前一块而此时前一块还没处理完新数据就会覆盖旧数据。处理超时的本质是消费者速度跟不上生产者。我的处理思路是这样的先用一个计数器统计丢块次数如果丢块率持续不为零说明得优化处理算法而不是单纯加大缓冲。算法优化不动的时候就把缓冲从 2 块加到 4 块相当于给消费者更多缓冲时间。注意乒乓缓冲的“两倍安全时间”是指从 DMA 完成 A 块到 DMA 再次写完 A 块之间你有多长时间去处理 A4 块环形缓冲则把这个时间放大到约三倍缓冲周期。加深度是最后手段不是第一手段。5.6 坑五Python 层的浮点计算会吃光 DMA 省下来的时间DMA 把 CPU 从“采样等待”里解放出来但如果 Python 层用浮点去逐点处理那么省下来的时间会被加倍吃回去。MicroPython 的浮点运算比 C 慢一到两个数量级256 点数据做一次浮点 FIR 滤波可能在 Python 层要几十毫秒比阻塞式 ADC 还糟。一个典型的反面例子是每个样本乘一个浮点系数再累加# 慢浮点乘加 out sum(buf[i] * kernel[j] for i in range(256) for j in range(8))如果滤波器系数恰好可以设计成整数近似比如 8 点移动平均直接用移位操作就能完成# 快整数移位平均 avg (buf[0] buf[1] buf[2] buf[3] buf[4] buf[5] buf[6] buf[7]) 3后者在 MicroPython 里几乎是瞬时完成。原则很简单Python 层只做轻量判断、显示、统计滤波、FFT 这一类重计算要么用定点整数要么下沉到 C 扩展。6. 什么情况下别让 MicroPython 硬扛6.1 高采样率与硬实时需求的分界线DMA 乒乓缓冲解决了“采样拖慢主循环”的问题但 MicroPython 作为解释型语言本身的执行时间是不确定的。如果项目要求采样率超过 100kHz或者对样本间隔的抖动有严格规定比如音频合成、电机 FOC 控制那就不适合在 Python 层做核心实时逻辑。这时候更合理的做法是底层 C 负责全部采样和实时处理MicroPython 只负责配置参数、显示结果、联网上传。把实时性要求高的部分放到硬件层把业务灵活性放到解释层两边各取所长。6.2 混合架构是我现在最推荐的形态我最初也想在纯 Python 层模拟 DMA 的效果折腾了几天最终放弃了老老实实写 C 扩展。第一次把adc_continuous封装成 usermodule 跑通时我才意识到MicroPython 的强项从来不是高实时性而是开发效率。它在采集任务里最适合扮演“上层大脑”而不是“底层肌肉”。现在我的项目凡是涉及连续采集默认架构都是C 扩展负责 ADC DMA 乒乓缓冲 必要的信号处理MicroPython 负责界面、网络、参数调节和业务逻辑。这套组合既有开发速度又有实时性也正好回答了标题里那个问题——ADC 采样确实不用拖慢主循环关键是你得把 DMA 这颗棋子放到 MicroPython 够得着的位置。最后再说一个我自己的小习惯新项目上 MicroPython 做采集第一版我总会把 DMA 缓冲和乒乓参数写死预留二倍余量跑通功能和数据流之后再回过头来优化内存和延迟。很多看起来复杂的高频采集问题其实不是芯片不够快而是最初的设计没有给 CPU 留出喘息的空间。乒乓缓冲听起来高大上拆开就是两块内存加一个切换动作把这一步做好了后面的路就顺了。