干嵌入式DIY这些年,我在MicroPython里最常被逼疯的场景之一就是:主循环里开了ADC采样,系统瞬间像被按了暂停键。明明只是读个电压、测个温度,代码逻辑也没几行,可整个程序就是卡顿、响应迟钝。后来我把思路从“怎么把Python代码写得更快”转到“怎么让硬件先把活干完”,用DMA配合乒乓缓冲把ADC这条路彻底打通了,主循环的CPU占用从两位数降到了几乎可以忽略。这篇文章就把这套方案的原理、代码和踩坑记录完整分享出来,适合在MicroPython里做多路模拟信号采集、实时波形显示、音频分析、传感器连续监测的朋友参考。
1. 先搞清楚:MicroPython 的 ADC 慢在哪
1.1 一次 read_u16 背后发生了什么
很多人直觉里会觉得ADC慢是硬件转换慢,其实在MicroPython场景下,硬件ADC的采样速度并不差。以ESP32系列和RP2040为例,单次ADC转换在硬件层面也就是微秒级别,SAR型ADC逐次逼近一个结果通常只需要十来个ADC时钟周期。真正拖后腿的,是Python解释层那套复杂的调用链。
你在代码里写一句adc.read_u16(),底层要经历的事情大致是:MicroPython运行时解析这条语句、找到对象的read_u16方法、把参数压栈、调用C函数、C函数再去操作寄存器读取转换结果、把结果封装成Python整数对象、最后再回到Python虚拟机继续执行下一条指令。这一整套流程下来,单次调用的时间通常在40到80微秒,甚至更慢。
这里有个关键点:read_u16()并不是说硬件只花了这么多时间,而是Python解释层做了一堆“虚功”。如果你用逻辑分析仪去对比,会发现硬件ADC转换本身只占了一个零头,剩下的时间全耗在解释器调度和方法调用上了。这就是为什么当你用MicroPython做高密度采样时,CPU占用率会直线飙升。
1.2 主循环到底被谁拖住了
主循环卡顿的本质是CPU被“串行等待”占满了。比如说你在主循环里要同时处理按键、刷新OLED屏幕、更新PID控制输出,然后中间插了一段10kHz采样代码。每次循环要读100个ADC样本,每个样本50微秒,这就是5毫秒的纯等待,而你的主循环可能总共只跑了1毫秒。结果就是一个简单的LED闪烁都会变得肉眼可见地不均匀。
更糟的是,如果你用了time.sleep_us()控制采样间隔,或者用循环逐个读取多个通道,这个时间还会成倍增长。在MicroPython里,一次多通道循环采集4路ADC、每通道100个样本,可能就需要30到50毫秒。这个量级的阻塞会让任何实时交互都变成“幻灯片”。
我实际遇到的一个项目是ESP32做掌上电池检测仪,需要同时读取电压、电流、温度三路模拟信号,再在屏幕上实时刷新曲线。第一版代码用最直接的read_u16循环,采样率别说画曲线了,连屏幕刷新都能卡住,按键响应也出现明显延迟。这就是典型的“ADC拖垮主循环”。
1.3 常见的三种硬扛方案为什么不靠谱
在换到DMA方案之前,我周围很多人会尝试几种“硬扛”的办法,但实测下来都不太理想。
第一种是降低采样率。这个最直接,通过拉大采样间隔来减少read_u16的调用次数,比如从10kHz降到500Hz。但它压缩了信号带宽,很多高频细节直接丢失,对于音频分析、振动监测、脉冲测量这类场景完全不可用。
第二种是改用C模块或内联汇编。把采样循环下沉到C层,确实能大幅减少解释器开销,但这对大多数玩MicroPython的人来说门槛太高。你得自己搭建编译环境、写MicroPython的模块接口、处理内存管理等,维护成本远大于收益。
第三种是增加一片外部ADC,通过SPI或I2C读取。这个方向本身没错,但在MicroPython下使用SPI和I2C同样存在调用开销,高速连续采样时CPU照样被占用。而且还要额外接线、校准、调试,项目复杂度瞬间上升一个等级。
这些方案都没有从根本上解决问题——它们只是把“等待ADC”的时间减少了一点,并没有让CPU从“等待别人出结果”的状态里解放出来。真正该做的,是让硬件在后台自己干活,CPU只需要在必要时去取已经准备好的结果。
2. DMA + 乒乓缓冲:原理和分工
2.1 DMA 是搬运工,CPU 是施工员
DMA的全称是Direct Memory Access,直接存储器访问。用人话说,就是给数据增加了一条不经过CPU的“高速公路”,让外设和内存之间能够直接搬运数据。一个典型的ADC采集场景里,DMA从ADC数据寄存器里取出转换结果,自动写到内存缓冲区里,整个过程不需要CPU参与。
你可以把CPU想象成一个正在砌墙的施工员,DMA是专门负责运砖的搬运工。传统模式下,施工员每砌一块砖就要自己跑去搬砖,来回跑腿累得半死;启用DMA后,搬运工负责把砖排好放在手边,施工员只需要专心砌墙,效率自然完全不同。
具体到MicroPython环境,虽然你不太可能直接在脚本层操作DMA寄存器和中断向量表,但很多固件在ADC模块内部已经启用了DMA搬运,把转换结果持续写入一块由固件管理的内存区域。你从脚本层读到的其实是“已经搬好放在那里的砖”,而不是每次亲自去ADC寄存器里催一次转换。
这里要提一个容易混淆的点:MicroPython的ADC模块不一定都开放了DMA配置,不同芯片、不同固件版本的差异很大。有的固件在底层就把连续采样和DMA做好了,你只需要调用一个read方法去取结果;有的固件则完全没有DMA支持。所以动手之前,先去确认你的板子和固件是否支持连续采样DMA模式,这一步很重要。
2.2 乒乓缓冲解决“边采边处理”的冲突
有了DMA搬运工,数据可以源源不断地送进缓冲区,但紧接着又冒出下一个问题:CPU在分析处理缓冲区数据时,DMA还在继续往同一个缓冲区里写数据。这就好比搬运工已经把砖堆在一块水泥板上,施工员正在用这些砖砌墙,搬运工又把新砖也堆到同一块板子上——要么旧砖还没用完就被盖住了,要么施工员挡住搬运工没法卸货。
乒乓缓冲(也叫双缓冲)就是为这个冲突设计的。思路是准备两个等长的缓冲区,一个让DMA写入,另一个让CPU读取和处理。当DMA把缓冲区A写满时,自动切换到缓冲区B继续写;与此同时CPU去处理缓冲区A的数据。等DMA把缓冲区B写满,再切换回A,CPU则处理缓冲区B,如此交替循环,就像打乒乓球一样。
这种机制的好处在于采样和处理时间被重叠隐藏。CPU不再需要等到整轮采样全部结束才开始干活,而是和DMA“并行流水线”式工作。在实际实现中,如果你用的是固件内部DMA,缓冲区切换可能会在驱动层完成;如果你自己做软件双缓冲,就需要通过标志位或回调来协调两边。
强调一下:乒乓缓冲解决的并不是采样速度本身,而是采样和处理之间的时序竞态。即使DMA已经很高效,如果之间没有缓冲隔离,数据错乱、采样丢点的事故迟早会发生。
2.3 这套组合对 MicroPython 特别友好
DMA加乒乓缓冲这套玩法在传统嵌入式C开发里已经很常见,而它放到MicroPython里反而更有价值。原因是MicroPython的Python解释层本身就比较“贵”,你要是让解释器一字节一字节地去等数据,那开销完全不可控。DMA把数据搬到了内存里,Python层面只需要做很少的批量读取和计算。
更进一步说,乒乓缓冲还解决了MicroPython的一个典型痛点:垃圾回收和对象分配。如果你频繁调用read_u16并生成大量Python整数对象,内存碎片化和GC停顿会严重影响实时性。使用双缓冲区后,Python脚本只需一批一批地处理整块数据,在较长时间内都不需要做小对象的频繁分配,GC压力大幅下降。
另外一个很实际的好处是代码结构变得清晰。你把“采样”这件事从主循环里彻底剥离出去,主循环只负责消费已经准备好的数据。无论是做FFT频谱分析、均值滤波、峰值检测,还是把数据串口发出,都从“等待数据”变成了“处理现成数据”,整个程序的可维护性都会上一大截。
3. 实操前的准备:硬件、固件与接口摸底
3.1 硬件平台怎么选
想用MicroPython跑DMA连续采样,第一条件是芯片和固件支持。目前我试过的几种平台里,ESP32系列和ESP32-S3的体验最好,因为MicroPython的ESP32端口里有专门针对ADC连续采样的扩展功能,底层驱动把DMA配置好了,脚本层可以比较简单地开启连续采样模式。
RP2040(树莓派Pico)的官方MycroPython固件默认没有暴露DMA接口,但有些第三方固件或者自编译固件可以做到。如果你平时主要玩Pico,建议去查阅对应固件的文档,确认是否有增加ADC相关的DMA支持,再决定走不走这条路。
STM32系列的MicroPython移植版本很多,ADC和DMA的开放程度也各不相同。通常性能不错的体验是使用带硬件过采样和DMA功能的型号,但脚本层的API支持不统一,需要更多研究。总体建议:如果你只是想快速验证这套方案,ESP32是最省心的选择,社区资料多、踩坑记录也丰富。
3.2 固件版本和 API 摸底
MicroPython的API在不同版本之间有时会有调整,所以在写代码之前,最好先在REPL里执行import esp32然后查看dir(esp32),看有没有与ADC连续模式相关的类或函数。常见的名字有ADCContinuousMode、ADCBlock、ContinuousADC等,不同固件移植来源可能不一样。
以ESP32的MicroPython为例,启用连续采样大致会有这样一个流程:先实例化ADCBlock,然后在块里创建通道,设定衰减和采样参数。注意,ESP32的ADC有两个单元,ADC1和ADC2,ADC2可能会被WiFi占用,所以连续采样尽量选ADC1的通道,避免互相干扰。
一个比较靠谱的测试办法是:先读取最近一次转换结果,连续循环读几百次,看能不能在预期时间内完成。如果固件底层确实有DMA在跑,你会发现在连续读取时CPU等待很少,采样速度快到普通read_u16方式根本追不上。
如果你发现自己的固件不支持任何连续采样DMA接口,也别急着放弃。你可以往下看第4.4节的软件替代方案,虽然性能不如硬件DMA,但配合乒乓缓冲仍然能在很多场景下优化主循环响应。
3.3 缓冲区大小怎么估算
缓冲区大小直接影响延迟和内存开销。太小了容易溢出丢数据,太大了又浪费内存,而且处理整块数据的延迟会变高。一个简单的估算公式是:缓冲区大小等于采样时长乘以采样率。比如你想以10kHz采样率连续采集100毫秒的数据,那缓冲区容量就是1000个样本。
如果样本是16位无符号整数,1000个样本用array('H')存储只需要2KB内存,ESP32完全没问题。但如果你要连续跑几分钟的波形记录,不建议一次性分配超大缓冲区,更合理的做法是采用多组双缓冲循环覆盖,定时把数据搬走到另一个长期存储区或者通过串口发出去。
我做音频分析时一般选512或1024个样本作为一帧,这样既能满足FFT的分辨率需求,又不会让延迟太明显。做传感器慢变量监测时则不同,采样率本身不高,缓冲区可以开大一些,比如4096个样本,保证一次处理的数据足够做平滑滤波。
内存分配上还有个技巧:尽量用array模块的array('H')而不是Python原生list,因为array在内存中是紧凑的C类型数组,存取效率远高于Python对象列表,也减少垃圾回收压力。
4. 完整代码:从硬件初始化到主循环改造
4.1 ADC 连续采样与 DMA 初始化
下面是ESP32上的一个参考流程,核心思路是让ADC工作在连续采样模式,利用底层DMA把结果持续送到内存。由于不同固件的API有差异,请以你的固件实际支持的接口为准。
import esp32 from machine import Pin import array, time, _thread # 假设使用 ADC1 的 CH6(GPIO34) # 先创建 ADC 块和通道,配置衰减范围 adc_block = esp32.ADCBlock(0) # ADC1 # 不同固件的 API 差异很大,这里只是示意 adc_ch = adc_block.create_channel(6, atten=esp32.ADCBlock.ATTEN_11DB) # 开启连续采样模式,sample_rate 单位是 Hz # 如果你固件里的名称不是 ADCContinuousMode,请替换为实际的类名 adc_cont = esp32.ADCContinuousMode(adc_ch, sample_rate=10000) adc_cont.init()需要注意,atten=11DB意味着可测量电压范围大约在0到3.3V左右,对应ADC读值范围是0到4095(12位)或0到65535(16位),具体看固件的配置。采样率的选择要结合信号频率和你的处理能力,超出DMA实际吞吐率固件会返回错误或者丢数据。
初始化完成后,你可以先用一个简单循环测试连续读取,确认数据确实在稳定更新,再进入双缓冲阶段。这一步看似简单,但很多人直接跳到完整工程,一旦出现问题就很难定位是采样问题还是处理问题。
4.2 软件乒乓缓冲实现
既然MicroPython脚本层不能直接操作硬件缓冲区的切换,我们就在Python层做软件双缓冲。核心是一个队列类,内部维护两个缓冲区,一个正在接收新数据,另一个等待被取走处理。
class PingPongBuffer: def __init__(self, size): self.buf_a = array.array('H', [0]) * size self.buf_b = array.array('H', [0]) * size self.fill_buf = self.buf_a # 正在填充的缓冲区 self.ready_buf = None # 已经填满、等待处理的缓冲区 self.idx = 0 def push(self, value): self.fill_buf[self.idx] = value self.idx += 1 if self.idx >= len(self.fill_buf): self.ready_buf = self.fill_buf # 切换到另一个缓冲区继续填充 self.fill_buf = self.buf_a if self.fill_buf is self.buf_b else self.buf_b self.idx = 0 return self.ready_buf return None这个双缓冲区最妙的地方在于,当push方法返回一个非空缓冲区时,你就知道有一整块样品可以拿去处理了,而新数据还在源源不断地写入另一块缓冲区。处理旧数据期间,新采样不会覆盖旧数据,两者完全隔离。
实际使用时需要注意:ready_buf被取走后,一定要立刻把引用置为None或者把这个缓冲区交给处理函数,不要让主循环保留多个引用。否则等下一轮切换回来时,之前的数据可能还没有处理完,缓冲区又会被覆盖,造成错乱。
4.3 后台采样线程与主循环消费
在MicroPython的ESP32端口上,_thread模块可以创建线程。虽然MicroPython有全局解释器锁,线程之间不是绝对并行,但线程在遇到I/O等待和时间片切换时会让出CPU,后台采样线程就可以利用主循环处理逻辑的时间片段持续调用ADC读取。这种写法和“DMA搬运”结合起来,已经足以让主循环看起来“全程解放”。
# 创建待处理缓冲区队列,用 list 简单模拟 pending = [] def sampler(sample_count): ppb = PingPongBuffer(sample_count) while True: # 从连续 ADC 模式中读取一个样本,这个操作非常轻量 val = adc_cont.read_u16() ready = ppb.push(val) if ready is not None: pending.append(ready) # 稍微让出CPU,平衡和主循环的调度 time.sleep_us(10) # 启动后台采样线程 _thread.start_new_thread(sampler, (512,)) # 主循环只负责处理“已经准备好的数据” while True: if pending: block = pending.pop(0) process_data(block) # 你的分析、滤波、显示、发送都在这里 else: # 没有数据时做其他事情,比如扫描按键、刷新屏幕 passprocess_data函数就是你的业务逻辑:求平均值、找峰值、做FFT、发送串口都可以。和以前每个样本都亲自去read相比,现在的调用频率大大降低,一次批量处理512个样本,解释器开销摊销到每帧数据上就非常小了。
这里要提醒一个细节:pending.append(ready)在主线程和采集线程之间共享,MicroPython的list在简单append和pop操作下通常是安全的,但如果你的处理逻辑很复杂,建议加上线程锁。更稳重的做法是用双队列交换,两边分别操作不同队列,避免并发风险。
4.4 不支持DMA时的软件替代方案
如果你手里的固件实在不支持连续采样DMA,仍然可以用“批量触发采样”加“双缓冲”的办法缓解主循环卡顿。思路是:不要一个样本一个样本地放在主循环里读,而是配置ADC的自动扫描功能或软件定时触发,批量转换一批样本,然后再一次性读回。
def sample_block(buf, channel_pins, count): for i in range(count): for j, pin in enumerate(channel_pins): adc = ADC(pin) buf[i * len(channel_pins) + j] = adc.read_u16()这种方式仍然绕不开单个样本的read开销,但至少把数据收集逻辑集中到了一个函数里,方便配合双缓冲进行批处理。实际效果取决于你的固件能否做到自动连续转换,如果单个样本之间的间隔很小,那整体采样率依然可以达到一个可用水平。
更进阶的做法是在固件源码层面增加一个自定义模块,用C语言写一个带DMA和中断回调的ADC驱动,再对MicroPython开放一个读取接口。这个方案门槛高,但如果你长期做MicroPython数据采集,值得投资时间。我自己就是在踩过很多坑之后,专门为项目编译了一次固件,把DMA连续采样做成了自定义模块,之后的效率提升非常明显。
5. 实测对比与踩坑记录
5.1 优化前后的性能对比
我在ESP32-S3上做了一个简单的对比实验:单通道ADC,10kHz目标采样率,每次采集512个样本。使用最原始的read_u16()循环时,采集耗时接近25毫秒,主循环在那段时间内完全阻塞,CPU占用率高到让我怀疑板子在空转。启用连续采样DMA加乒乓缓冲后,后台线程用不到5毫秒就能完成512个样本的读取和填充,主循环几乎感觉不到采样过程的存在。
更直观的对比是主循环响应时间。原始方案里,主循环平均每轮需要等待25到30毫秒才能继续跑业务逻辑,表现出来就是LED闪烁频率漂移、按键偶尔失效。改版之后,主循环每轮等待时间降到1毫秒以内,业务逻辑稳定执行,UI操作反应速度恢复到了正常水平。
从CPU占用角度看,采样相关的解释器开销从原来的大幅占用降到了5%以下。因为DMA搬运数据的动作发生在硬件层面,CPU只在每次调用read_u16时去读一个现成的值,工作量和普通变量读取差不多。
5.2 踩过的坑:数据错乱、采样率漂移与内存复制
这套方案虽然效果好,但坑也不少。第一个坑是数据错乱。我在多通道采样时发现DMA连续模式返回的通道顺序和预期不一致,后来查资料才知道ESP32的ADC连续模式通常固定按某个扫描顺序输出,需要按照实际顺序去对应硬件引脚。解决方案是先用一个已知电压分别接到不同通道,逐个验证数据的对应关系,不要假设通道顺序。
第二个坑是采样率漂移。DMA连续模式由硬件时钟驱动,理论上很稳定,但MicroPython脚本层的循环读取和线程调度会导致缓冲区的填充速度略低于采样率,从而出现“实际样本数比预期少”的情况。解决方法是不要依赖软件计数来确定时间基准,而是在数据块里记录时间戳,或者用定时器中断定期统计采样数量,再做补偿。
第三个坑是内存复制开销。如果你在process_data里对整块数据做了大量Python操作,例如逐样本进行复杂的数学运算,那么即使DMA再快,处理阶段也会成为新的性能瓶颈。一个有效办法是尽量用array的切片和memoryview操作,避免把数据转成列表;如果要做FFT或者大量数值计算,可以借用ulab这样的模块,它专门针对MicroPython做了数值优化。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查与解决办法 |
|---|---|---|
| 采样数据每隔一段就出现跳变 | DMA缓冲区被覆盖或双缓冲没有及时切换 | 检查缓冲区大小是否足够;确认处理函数耗时不超过整块缓冲区的填充时间 |
| 多通道数据顺序错乱 | 固件ADC通道扫描顺序和预期不一致 | 用已知电压逐通道测试映射关系;调整代码里的通道对应表 |
| 后台线程卡死或无数据 | 连续采样模式未正确初始化 | 检查固件API参数;先用单线程连续读测试基础功能 |
| 采样率明显低于设定值 | 固件内部DMA配置或时钟分频不匹配 | 查阅芯片参考手册;尝试降低目标采样率,找到稳定区间 |
| 主循环仍有卡顿感 | 处理函数调用太慢或阻塞了线程GIL | 精简process_data逻辑;把耗时操作分散到多帧数据中处理 |
| 使用WiFi时ADC2采样异常 | ESP32的ADC2与WiFi共用硬件资源 | 改用ADC1通道;或避免在采样时开启WiFi |
5.4 个人体会与扩展建议
这套方案做下来,我最深的感觉是:在MicroPython里做数据采集,思路要从“怎么让代码更快”转变成“怎么让代码更少干活”。与其纠结单次read_u16的微秒级开销,不如让DMA把数据准备好,然后让Python用更粗的粒度处理整块数据。
后续如果你想让这套方案更硬核,可以研究这几个方向:一是把采样数据通过串口DMA直接转发到上位机,做到“零CPU数据搬运”;二是在固件层用中断回调直接处理环形缓冲区,进一步减少脚本层的参与;三是引入实时操作系统思想,用双缓冲加队列实现生产者消费者模式,把数据采集模块独立出来复用。
最后再说一个实用的小技巧:调试时不要一上来就开最高的采样率。先把采样率设低,验证双缓冲和数据处理逻辑没问题,再逐步拉高采样率,直到出现丢点或错乱,然后往回退一档作为稳定工作点。这样定位问题会快很多,也能清楚自己这套方案的性能边界在哪里。