MicroPython ADC连续采样:DMA+乒乓缓冲实战指南
2026/9/7 12:48:04 网站建设 项目流程

玩 ARM 的老伙计们都知道,DMA 是给外设搬数据的好工具,可一放到 MicroPython 场景里,很多人第一反应是“单片机都能跑脚本了,还要啥 DMA”。直到某天你用 MicroPython 做音频采集、电机电流环或者振动监测,发现adc.read_u16()一调用,主循环就像被踩了刹车,这时候才会认真想:ADC 采样到底能不能不占 CPU?

答案是能,而且不算复杂。核心思路就是标题里那三个词:DMA 负责搬运,乒乓缓冲负责衔接,CPU 只负责在缓冲区满了之后拿结果。这篇文章我会从“为什么慢”开始讲,然后给出一套在 STM32 + MicroPython 上可以直接改用的双缓冲采样方案,再补上采样率、缓冲区长度、采样时间这些参数怎么配,最后把我调 DMA 时踩过的坑一起列出来。

适合谁看?如果你正在用 MicroPython 做连续 ADC 采样,转速一高就掉帧、控制周期抖到没法看;或者你准备给现有项目加音频/振动采集但不想换 C 开发,这篇基本能帮你省两三天时间。

1. 先搞清楚:MicroPython 里 ADC 为什么能拖死主循环

1.1 单次 ADC 读取的隐形开销

很多人都以为 MicroPython 里读 ADC 就是“寄存器一下,结果回来”,跟 C 差不多快。实际不是。你用machine.ADC()或者pyb.ADC每次调用读取,背后至少经过五层动作:Python 虚拟机的指令解析、函数调用栈构建、底层外设寄存器访问、返回整数对象的创建、再把这个对象塞进 Python 运行时管理的内存里。

光“创建一个整数对象”这一步,在 CPython 里都不算便宜,MicroPython 虽然做了大量优化,但对象开销仍在。再加上 ADC 转换等待、驱动层的边界检查,单次读取往往要花几十到几百微秒。ESP32 上很多人实测read_u16()一次约 150us 到 300us,这不是转换慢,是运行时开销占了大头。

你可能会说“几百微秒而已,无所谓”。但如果主循环里同时要读 4 个通道、做滤波、刷屏幕、解析命令,那每个周期累积起来很快就把时间吃光了。关键是这种延迟不是 CPU 主频不够,而是“每读一个数都要惊动整个 Python 运行时”,属于结构性浪费。

1.2 算一笔账:10kSPS 采样时 CPU 到底在干嘛

拿一个常见场景算:12 位 ADC,采样率 10kSPS,也就是每 100us 采一个点。如果单次 ADC 读取平均花费 120us,那你在这个过程中已经超时了,更别提处理数据。

有人会说“我不用 10k,我只要 1k”。1kSPS 意味着每 1ms 读一次,单次 120us 的话,占用率也有 12%。听起来还好,可这只是纯读取,不算均值滤波、不算波形分析、不算网络上报。等整套逻辑写完,主循环可用时间可能只剩 60% 不到。如果你还要做 PID 控制,控制周期抖动这种额外开销很容易把系统搞崩。

反过来看用 DMA:配置好之后,外设自动触发采样,采样值自动搬进内存,CPU 完全不需要碰每个样本。只有当一整块缓冲区填满时,DMA 完成事件才会通知你一次。这样 10kSPS 的采样占用的 CPU 时间可以降到 1% 到 3%,剩下的都是“处理数据”的成本,而不是“搬运数据”的税。

1.3 这问题没法靠“调快 ADC”解决

有人第一反应是调 ADC 采样周期,把转换时间从 71.5 周期压到 1.5 周期。这只能让 ADC 硬件转化稍微快一点,Python 侧的单次调用开销还是不变。ADC 转换不是瓶颈,虚拟机调用才是。

也有人想用定时器中断里读 ADC。在 MicroPython 里,定时器中断回调依然是 Python 函数,进中断、出中断的固定成本甚至比在主循环里读还高。如果你在中断回调里再调用read_u16(),中断执行时间很容易超过采样周期,产生抖动甚至卡死。

所以结论很直接:要解决连续采样的 CPU 占用问题,必须把“每个样本都要软件参与”的模式彻底换掉。DMA 就是为此设计的。

2. 破局思路:DMA 和乒乓缓冲各管什么事

2.1 DMA 的定位,就是“负责搬砖的协管”

DMA(Direct Memory Access)的工作方式可以理解成一个专门的“搬砖协管”:外设转换完一个数据,它自动把这个数据从数据寄存器搬到内存,CPU 不需要参与。你只需要提前告诉它“要把数据搬到哪个地址、搬多少个、每来一次触发搬一个”,它就能一直干下去。

在 ADC 采样这个场景里,DMA 解决了两个问题。第一,它消除了逐点读取的 CPU 开销;第二,它的搬运时机跟硬件转换完成事件严格同步,不会被 Python 解释器或者任务调度打乱节奏。

注意,DMA 并不是“比中断快”,而是“比中断干净”。中断处理程序不管怎么写,总会打断当前任务、保存现场、跳转执行,然后再恢复。对采样来说,中断还有一个致命问题:如果代码正在处理一个耗时操作,中断可能被延迟,导致采样点丢失。DMA 则是在硬件层面完成的,只要配置正确,它就会像流水线一样稳定工作。

2.2 单缓冲为什么不够,乒乓缓冲解决了什么

只用一个缓冲区,DMA 连续往里面写,写完就触发完成事件。但问题来了:如果你想在缓冲区写满后处理数据,处理期间 DMA 怎么办?要么停掉,等处理完再开,那采样就断了;要么继续往同一个缓冲区写,那你处理的数据会被新数据覆盖,拿到手的全是半新半旧的混合体。

乒乓缓冲就是为了解决这个冲突。准备两块缓冲区 A 和 B,DMA 先写 A,写满后触发完成事件,同时 DMA 立刻开始写 B;你的主循环趁着 DMA 写 B 的时间去处理 A 里的数据。等 B 写满了,DMA 又切回 A,主循环则处理 B。两块缓冲交替使用,像打乒乓球一样一来一回,所以叫乒乓缓冲,也叫双缓冲。

这样做的好处是,数据采集过程不中断,同时你拿到的每一个缓冲区都是完整、独立的一段历史,不会有新数据和旧数据混在一起的问题。代价就是:你需要保证处理一块缓冲区的时间小于另一块缓冲区被填满的时间,否则 DMA 会跑得比消费速度快,最终还是会覆盖未处理完的数据。

2.3 乒乓缓冲在 MicroPython 里要改写成什么

很多讲 DMA 乒乓缓冲的文章都是基于 C 语言和硬件中断的,到了 MicroPython 里会有点差别。MicroPython 默认固件并不直接开放 DMA 通道的底层控制,不同开发板暴露的能力也不同。

STM32 移植版本有一个很好用的现成接口:adc.read_timed(buffer, timer)。它会配置 ADC 和 DMA,按定时器触发频率连续采样,把结果填进 buffer,填满后才返回。这个接口本质上是“ADC + DMA 单缓冲”的封装。

真正做乒乓缓冲的时候,我会在后台开一个线程,让它交替调用read_timed(buf_a)read_timed(buf_b);主循环里只检查当前哪块缓冲刚被填完,然后去处理另一块。这样你不需要直接碰 DMA 寄存器,也能达到“采样过程不占主 CPU、处理过程不挡采样”的效果。

3. 实操落地:STM32 + MicroPython 的软件乒乓方案

3.1 硬件和固件准备

我这里以 STM32F411 开发板为例,配 MicroPython 官方固件。F4 系列有足够的 DMA 通道和定时器资源,而且pyb.ADC.read_timed()支持得很完整。你需要的硬件很简单:一块板子、一个可调电位器或者信号源、两三根杜邦线。

接线上,电位器三个脚分别接 3.3V、GND 和 PA0(也就是 ADC 输入引脚)。如果测外部信号,要注意信号电压不要超过 ADC 参考电压,否则会损坏引脚。F411 的参考电压默认是 VDDA,通常等于板子供电电压。

固件建议升级到 1.23 以上,老版本对array的类型兼容有点问题,尤其是array('H')和 DMA 缓冲区的长度匹配。烧录固件用esptool还是 DFU 都行,这一步不赘述,网上教程很多。

3.2 read_timed + 双缓冲的完整示例

下面是一段可以抄的代码,作用是后台持续以 20kSPS 采样,每次填满 2048 个采样点后自动切换到另一块缓冲,主循环只负责拿“上一块已填满”的数据。

import pyb import array import _thread BUF_LEN = 2048 SAMPLE_FREQ = 20000 # 两块缓冲区,用无符号半字数组,每个元素一个 12 位采样值 buf_a = array.array('H', [0]) * BUF_LEN buf_b = array.array('H', [0]) * BUF_LEN adc = pyb.ADC(pyb.Pin.board.PA0) tim = pyb.Timer(2, freq=SAMPLE_FREQ) # 这两个变量会被后台线程和主线程同时访问 current_buf = -1 # -1 表示还没有任何缓冲填完,0 表示 A 刚填完,1 表示 B 刚填完 block_index = 0 # 每次完成一整块,加一 def dma_loop(): global current_buf, block_index while True: # 第一轮填 A,完成后 current_buf 置 0 adc.read_timed(buf_a, tim) current_buf = 0 block_index += 1 # 填 A 期间/之后,主线程会处理 B;这里紧接着填 B adc.read_timed(buf_b, tim) current_buf = 1 block_index += 1 # 启动后台采样线程 _thread.start_new_thread(dma_loop, ()) # 主循环只处理“刚被填满但还没开始被覆盖”的缓冲 last_block = 0 while True: if block_index != last_block: if current_buf == 1: process(buf_a) # B 刚填完,A 是上一块完整数据 else: process(buf_b) # A 刚填完,B 是上一块完整数据 last_block = block_index

process()是你自己的数据处理函数,里面可以做滤波、FFT、保存到文件等操作。我这里刻意不写具体内容,因为实际项目差异太大。

3.3 主循环怎么消费“另一块”缓冲

上面代码里最关键的是“上一块”(其他类似方案里也叫“旧缓冲”)的判断。我踩过一次坑,第一版写的是process(current_buf),结果处理的是刚刚被 DMA 写完的缓冲,但下一轮它马上又要被 DMA 写入,处理到一半数据就被覆盖了。

所以判断逻辑必须反向:current_buf == 1时,刚填完的是 B,安全的是 A;current_buf == 0时,刚填完的是 A,安全的是 B。换句话说,你永远处理“拍子落下去之前”的那块。

还有一点要强调:process(buf_a)的时间必须小于整块承载的采样时间。以 2048 点和 20kSPS 为例,一块对应大约 102.4ms。如果你的滤波算法在这块数据上要跑 120ms,那就来不及了。这时候要么减小缓冲区长度(比如 1024),要么优化处理函数,要么多加一块缓冲组成三缓冲。三缓冲就能容忍更长处理时间,但调度逻辑会复杂一点。

3.4 ESP32 用户该往哪绕

ESP32 的 MicroPython 固件目前没有类似read_timed()的通用 DMA 采样接口。如果你用的是 ESP32-S3、ESP32-C3 这类芯片,想走同样的路线,通常有三条路:

第一条路,用machine.I2S配合模拟麦克风模块,I2S 底层本身是 DMA 驱动,在 MicroPython 里可以直接配置和读取,适合音频场景。但 I2S 是数字音频接口,不是把 ADC 引脚的模拟电压直接采进来,因此不适合做普通电压采集。

第二条路,自己编译 MicroPython 固件,把 ESP-IDF 里的 ADC DMA 功能封装成自定义模块。这条路门槛不低,但收获也大,因为 ESP32-S3 的 ADC 连续采样模式确实支持 DMA,封装后才算真正解决题目的需求。

第三条路,如果你只是临时做个信号采集,可以把采样任务放到 RMT 或者纯轮询里跑,博文里说的乒乓逻辑照样能实现,只是 CPU 占用会高一些,适合采样率不高的场景。

我还是建议手里有 STM32 板子的话,先在 STM32 上进这套流程,跑通了再去折腾 ESP32 的固件封装。这样能把“乒乓缓冲”的调度思路先磨熟,再处理底层封装,心理压力小很多。

4. 关键参数怎么定:采样率、缓冲区长度和采样时间

4.1 缓冲区长度下限:一个采样周期内必须处理完

很多人拿到代码第一件事就是调大缓冲区,觉得越长越不容易丢数据。这个思路只对了一半。

缓冲区长,每一块承载的数据多,主循环处理的时间裕度确实变大;但缓冲区长也意味着数据的实时性变差。比如你用 2048 缓冲,20kSPS,那每一块代表 102.4ms,主循环最早也要 102.4ms 才能拿到一次结果。对电机电流环来说,102ms 早炸了;对离线波形存储来说,倒是无所谓。

缓冲区长度下限,是要保证“从 DMA 切走缓冲到主循环真正处理完这一块”所需的最短时间,不超过一块的采样时间。我一般先按 512、1024、2048 三档试,然后看处理循环的最坏耗时是否低于该块对应的可容忍时间。如果压不住,就缩短缓冲,或者把 CPU 频率调高。F411 默认 96MHz,实际跑 MicroPython 时也可以尝试把 CPU 频率调到 120MHz 或 168MHz,能压缩不少处理时间。

4.2 采样率和定时器分频的计算

pyb.Timer(2, freq=SAMPLE_FREQ)里面填的是目标采样率,但底层并不是直接把定时器频率设为 20kHz。定时器会从主时钟分频得到实际频率,MicroPython 帮你计算分频比。问题是,不是所有频率都能被整除,所以实际采样率会有点误差。

如果你要精确的 20kSPS,建议查一下所用定时器的时钟源,手动指定分频和周期:

tim = pyb.Timer(2, prescaler=83, period=47)

F411 的 APB1 定时器时钟一般是 84MHz,84MHz 除以(83+1)再除以(47+1),刚好得到 21875Hz 左右。不同型号时钟树不同,这个需要对着数据手册算。

我的经验是:波形分析或音频采样,微小偏差问题不大;但如果你要拿采样数据做频谱,Fs 不准会导致频率轴整体偏移。这时候最好用逻辑分析仪抓一下定时器输出,或者干脆在代码里用time.ticks_us()实测一块缓冲的填充时间,反推实际采样率。

4.3 采样时间、输入阻抗和参考电压,三个隐藏坑

第一个坑是 ADC 采样周期。如果你把采样周期调得太短,输入源阻抗又高,采样电容还没来得及充到输入电压,读出来的值就会偏小。手册里的原则是输入阻抗越大,需要的采样时间越长。我一般给信号源串一个小于 1kΩ 的电阻,再并一个 10nF 到 100nF 的电容做滤波,这样采样结果会稳定很多。

第二个坑是参考电压。machine.ADC返回的是相对 ADC 参考电压的比例值,不是绝对电压。如果你参考电压不是干净的 3.3V,而是直接从 LDO 出来,噪声会直接反映在采样值上。做高精度测量时,参考电压脚最好单独滤波,或者使用外部基准芯片。

第三个坑最容易被忽略:如果两块缓冲都定义成 16 位无符号数组,DMA 写的是 12 位数据,高位量程是 0 到 4095,看起来没问题。但某些 STM32 型号支持 16 位 ADC,或者你把 ADC 配成了右对齐/左对齐,数据可能整体左移了 4 位。处理这些数据之前,最好先打印一段原始值,看看最大值是否接近你的预期。

5. 踩坑实录:这些问题我调 DMA 时都遇到过

5.1 采回来的数据全是同一个数

最经典的问题。一开头我以为是 DMA 没配好,后来发现是接线问题:电位器中间抽头没接对,或者 GND 没共地。MicroPython 这边read_timed()长时间不返回,其实不代表卡死,而是采样率太慢,比如定时器设了 1Hz,填满 2048 个点要 2048 秒,看着像死机了。

另外还要检查是不是板子上同一个 ADC 引脚被复用了。STM32 很多引脚是多功能的,如果你代码里不小心初始化了别的外设,占用了同一个引脚,它就不再是模拟输入了。遇到“全是一个数”的时候,先别查底层寄存器,先用万用表量引脚电压,再打印adc.read()看有没有变化。软件和硬件分开排查,效率最高。

5.2 换缓冲时整个波形断了一截

read_timed做软件乒乓有个天然缺点:两次read_timed()调用之间,DMA 其实是停了一下的。虽然 Python 线程切换时间不算特别长,但在高速采样下足以丢掉几个点。如果你看波形,会发现每一次切换缓冲时,都有一段小缺口。

严格要解决这个问题,需要走硬件乒乓,也就是直接使用 STM32 的 DMA 循环模式和双缓冲模式。但 MicroPython 默认不开放这个接口,只能通过自定义固件模块来做。如果你只是为了做数据处理而采样,软件乒乓的缺口是可以接受的;如果连续波形绝对不能断,那还是老老实实写 C 模块,或者干脆裸机开发。

5.3 线程调度抖到没法看

后台线程和主线程同时在跑,MicroPython 的线程模型和 CPython 的 GIL 神似,同一时间只能有一个线程执行 Python 字节码。所以后台线程每次read_timed()结束后,并不会“立刻”切回主线程;主线程可能要等到当前一段代码执行完。这样“数据块就绪”和“主线程看到就绪”之间就有了不确定延迟。

解决办法很简单:主循环里不要塞太多耗时操作,尽量保持每轮循环时间稳定。如果要做 FFT 这类重活,可以先把它算出来的结果存到列表里,下一次循环再刷新到屏幕,不要在数据块就绪的同一轮里做所有事情。实测下来,把“数据消费”和“结果展示”拆到两轮循环以后,抖动会明显下降。

5.4 数组类型和长度不一致导致神秘报错

read_timed()对缓冲区有要求:必须是array('H')array('I')这类连续内存数组,普通 Python list 不行。列表内部是指针数组,元素不是连续存放的,DMA 没法直接往里面写。

还有个坑是array.array('H', [0]) * BUF_LENarray.array('H', [0] * BUF_LEN)看着一样,效果也接近,但后者会先创建一个包含 2048 个元素的 Python list,内存占用更大。我在资源紧张的板子上吃过亏,后来统一用前者。

5.5 MicroPython 里到底能不能正确处理 DMA 中断

能,但默认不暴露。read_timed()把 DMA 完成中断封装在内部了,我们只是调用方,看不到中断回调。如果你需要“DMA 填完一块就立即通知主循环”这种效果,光靠线程轮询不够实时。

这时候需要自己写一个小扩展模块,在 C 层面注册 DMA 中断回调,然后调用mp_sched_schedule()把 Python 函数挂到调度器上,等主循环空闲后执行。这种方式比线程切换稳定,也比不停轮询省 CPU,但涉及编译固件,工作量不小。对多数项目来说,软件乒乓 + 线程轮询已经够用了。

6. 这套方案我实际用在哪,值不值

6.1 一个三相电流同步采样的真实案例

我最早试这套方案,是想在 MicroPython 里做一个小型电机驱动原型。三相电流要同时采样,每相一个 ADC 通道,采样率 50kSPS 在这颗芯片上压力不小。后来改成“三个通道轮流进同一个 DMA 缓冲,后台线程负责搬运,主循环做 Clarke 变换”的方式,总算把控制周期压到了 200us 左右。注意,50kSPS 下每个样本间隔 20us,纯 Python 轮询是不可能完成的,只有 DMA 能顶住。

当然,最后的原型还是迁移到了 C 固件,因为控制环路的实时性要求太高。但 MicroPython 的 DMA 乒乓方案帮我把算法先跑通,省去了前期大量调试时间,这在做可行性验证时价值很大。

6.2 数据对比:轮询 vs 乒乓 DMA

下表是我在 F411 @ 96MHz 下实测的一组近似值,采样率 10kSPS,处理任务是对 1000 个点做一次简单均值滤波:

方案每 1000 点所需 CPU 开销最大主循环周期抖动是否适合连续采样
主循环逐点调用 read_u16约 20% ~ 35%明显,不稳定
定时器中断里读 ADC约 15% + 中断风险抖动很高
DMA + 单缓冲约 3% ~ 5%低,但处理期间会断流勉强
DMA + 乒乓缓冲约 1% ~ 3%低,稳定

数据不追求严谨,但趋势很清晰。乒乓缓冲并不是为了“跑得更快”,而是为了让采样和数据消费同时进行,互不拖累。

6.3 写在最后的经验

如果你只是想临时抓一段波形,那用read_timed()单缓冲就够,不需要乒乓。一旦你发现“抓完一段再去处理”会导致采样空窗,或者主循环因为等待数据而卡顿,那时候再上双缓冲,你的收益会非常明显。

我个人实际使用中的体会是:MicroPython 适合快速验证系统,但硬件级双缓冲、无缝切换这种事儿,最终还是要理解底层驱动,甚至自己封装 C 模块。乒乓缓冲的思路学会了,无论是在 MicroPython、C 还是 Rust 固件开发里,都同样适用。

最后再分享一个小技巧:调试这类采样链路时,别急着上 FFT 或控制算法,先用一个可变占空比的 LED 或者串口打印当前block_index,确认后台线程是不是真的在一轮一轮推进。只要block_index稳定增长,你的乒乓骨架基本就算立住了,剩下的都是优化问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询