MicroPython ADC连续采样:DMA+乒乓缓冲实战指南
玩 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 的定位就是“负责搬砖的协管”DMADirect Memory Access的工作方式可以理解成一个专门的“搬砖协管”外设转换完一个数据它自动把这个数据从数据寄存器搬到内存CPU 不需要参与。你只需要提前告诉它“要把数据搬到哪个地址、搬多少个、每来一次触发搬一个”它就能一直干下去。在 ADC 采样这个场景里DMA 解决了两个问题。第一它消除了逐点读取的 CPU 开销第二它的搬运时机跟硬件转换完成事件严格同步不会被 Python 解释器或者任务调度打乱节奏。注意DMA 并不是“比中断快”而是“比中断干净”。中断处理程序不管怎么写总会打断当前任务、保存现场、跳转执行然后再恢复。对采样来说中断还有一个致命问题如果代码正在处理一个耗时操作中断可能被延迟导致采样点丢失。DMA 则是在硬件层面完成的只要配置正确它就会像流水线一样稳定工作。2.2 单缓冲为什么不够乒乓缓冲解决了什么只用一个缓冲区DMA 连续往里面写写完就触发完成事件。但问题来了如果你想在缓冲区写满后处理数据处理期间 DMA 怎么办要么停掉等处理完再开那采样就断了要么继续往同一个缓冲区写那你处理的数据会被新数据覆盖拿到手的全是半新半旧的混合体。乒乓缓冲就是为了解决这个冲突。准备两块缓冲区 A 和 BDMA 先写 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, freqSAMPLE_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_indexprocess()是你自己的数据处理函数里面可以做滤波、FFT、保存到文件等操作。我这里刻意不写具体内容因为实际项目差异太大。3.3 主循环怎么消费“另一块”缓冲上面代码里最关键的是“上一块”其他类似方案里也叫“旧缓冲”的判断。我踩过一次坑第一版写的是process(current_buf)结果处理的是刚刚被 DMA 写完的缓冲但下一轮它马上又要被 DMA 写入处理到一半数据就被覆盖了。所以判断逻辑必须反向current_buf 1时刚填完的是 B安全的是 Acurrent_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, freqSAMPLE_FREQ)里面填的是目标采样率但底层并不是直接把定时器频率设为 20kHz。定时器会从主时钟分频得到实际频率MicroPython 帮你计算分频比。问题是不是所有频率都能被整除所以实际采样率会有点误差。如果你要精确的 20kSPS建议查一下所用定时器的时钟源手动指定分频和周期tim pyb.Timer(2, prescaler83, period47)F411 的 APB1 定时器时钟一般是 84MHz84MHz 除以831再除以471刚好得到 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_LEN和array.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稳定增长你的乒乓骨架基本就算立住了剩下的都是优化问题。

相关新闻

最新新闻

日新闻

周新闻

月新闻