去年我在一块STM32F407开发板上折腾MicroPython多通道ADC采集,采样率开到1Msps时,CPU占用率直接冲到80%,蓝牙一上报数据波形就断片。后来把DMA链式触发和Scatter-Gather模式用上,所有采样数据的搬运、分片、聚合全交给硬件完成,才算是把这个项目救回来。这篇就把整个方案的原理、代码和踩坑过程完整拆出来,给同样在MicroPython里被高频数据采集折磨的人一个参考。
1. 从一次ADC采集中挖出Scatter-Gather的适用场景
1.1 普通DMA不够用的真实原因
很多人在MCU上接触DMA时,第一个感觉是“太省心了”:配置一次源地址、目的地址和传输数量,DMA就把数据搬完,CPU全程不用管。但真正做高频、长时间、多通道采集时,这种单次DMA模式有几个绕不过去的坎。
第一个坎是缓冲区连续性。普通DMA要求目的地址是一个连续的内存块。你打算一次性采10万个点,就得先一次性申请一块足够大的连续RAM。MCU上的RAM本来就紧张,跑着MicroPython还有GC堆、解释器栈和脚本变量在争内存,大块连续内存很容易出现碎片。我一开始在F407上申请128KB连续缓冲,MicroPython的GC堆直接告急,采几次就MemoryError。
第二个坎是中断密度。单次DMA传完所有数据,CPU只处理一次中断,看似很轻松,但问题是缓冲区必须等到全部传完才能处理。如果改成小缓冲区多次DMA,就得在中断里反复重配DMA描述符,中断一多,CPU占用照样爆表。
第三个坎是“数据要分段处理”这种需求。拿四路ADC采样来说,每一路的波形可能希望放到独立的缓冲里,之后再按通道分别做FFT或滤波。普通DMA只能写进同一个连续大数组,到了MicroPython层还得再切片、再拷贝,额外消耗CPU和内存。
Scatter-Gather要解决的核心问题就是这三件事:把一段不连续的物理内存虚拟成一个大缓冲,让DMA自己管理多段数据的搬运顺序,同时用链式触发把多次传输串起来,减少CPU参与次数。你可以把它想象成快递驿站的分拣柜:普通DMA是一个包裹送一个地址,Scatter-Gather是先把一批包裹按收件人分到不同柜格,等全部放好后再通知你一次性去取。
1.2 链式触发到底“链”了什么
链式触发的关键不是“DMA能传数据”,而是“DMA能在传完一段后,自动从链表里找到下一段描述符,继续执行下一次传输”。这个过程在硬件层面完成,CPU只需要在整条链走到头时收到一次DMA传输完成中断。
普通的DMA传输过程是这样:CPU配置好寄存器、使能DMA、DMA搬数据、搬完触发中断、CPU进入中断回调重新配置下一轮参数。每一轮都是一来一回的等待,传输本身很快,但中断处理和重新配置的耗时占了大部分时间。
链式DMA则不同。每个描述符结构体里保存了本次传输的源地址、目的地址、传输计数、控制配置以及下一个描述符的地址指针。DMA硬件传完当前描述符后,自动从next指针指向的地址处载入下一个描述符,继续执行,直到某个描述符的next项为空或者回指到链表头。CPU要做的只是在整条链全部跑完后,统一处理一次数据。
在这套机制下,Scatter-Gather就变得很自然:你想把数据分散到N个缓冲区,就构造N个描述符,让每个描述符的dst指向各自缓冲区段;你想把四处收集到的数据聚合到同一个大数组,就让每个描述符的src指向不同外设FIFO或不同内存区域,dst则指向同一个目的地址的首地址,配合地址不变模式。这两种用法,本质上都是“用链式描述符把DMA的搬运路径规划好”,所以我习惯把Scatter-Gather和链式触发放在一起说,它们组合起来才能发挥最大作用。
1.3 为什么传统MicroPython方案会卡住
MicroPython官方提供的ADC采样接口,比如adc.read_timed(),其实内部也是用DMA搬数据的。但它有一个简化设计:只维护一个连续缓冲,采样完成后一次性把整个数组从C层返回给Python层。这种方式对于几十KHz的采样率、小数据量完全够用,但一旦上了高频、多通道、长时间连续聚合,它就会暴露出几个问题。
第一,官方接口不支持多段缓冲。你没法让ADC的DMA自动写到四个通道各自的缓冲区里,只能先把所有通道的数据混在一个大数组里,再靠Python代码去切片、去排序。第二,官方接口采样过程中不能做数据处理,必须等采样结束才返回,所以你想做“边采样边处理”的流水线基本做不到。第三,官方接口返回Python列表或bytearray时存在内存拷贝,数据量一大,GC和内存峰值都会被顶上去。
所以我当时的判断是:MicroPython在应用层很顺手,但高频数据采集这条路,必须通过C扩展把底层DMA能力开放出来,让Python只负责逻辑调度,而不是直接搬数据。这也是这篇博文后续所有操作的核心思路。
2. MicroPython+DMA架构分层:C模块与Python脚本的分工边界
2.1 MicroPython为什么不能直接操作DMA描述符
有人可能会问:MicroPython不是有machine模块吗?就算没有,能不能在Python脚本里用memoryview、struct或者寄存器地址直接操作DMA?
严格来说,MicroPython脚本层确实可以通过uctypes或machine.mem32去读写寄存器,把DMA通道寄存器、描述符链表的地址直接写进去。这在理论上可行,但实际工程中我强烈不建议这么干,原因有三条。
第一条是安全性和稳定性。Python脚本里一个单词拼错、一个地址计算错误,在解释器层面可能只是一个ValueError,但如果是DMA寄存器配置错了,可能直接导致指针乱飞、内存被DMA写到非法地址,轻则固件崩溃,重则整个MCU硬件错误。MicroPython的上下文里出现这种问题,连traceback都可能来不及打印,排查起来非常痛苦。
第二条是性能。通过uctypes逐位操作寄存器,每一行Python代码都要经过解释器字节码解释、对象模型检查,一条mem32[addr] = value背后可能隐藏着数十条机器指令的执行开销。在高频中断或实时性要求高的场景下,这种开销完全不能接受。而C扩展模块里的一行寄存器赋值,编译后就是一条STM指令,速度差距有几个数量级。
第三条是持续性和可维护性。DMA描述符结构体、寄存器的位域分布各个芯片不一样。Python代码可读性虽好,但真要对着参考手册逐位配置十几个字节,可读性优势也没了。把这些位操作封装进C模块,上层Python只需要面对init、start、read这几个清晰接口,后续换芯片,只需要改C层,Python脚本一行不动。
所以在我的方案里,MicroPython只负责事件注册、数据读取和业务处理,DMA描述符的构造、内存对齐、链表管理、中断响应和回调调度,全部放在C扩展模块里实现。这个边界画清楚之后,整个结构会非常清爽。
2.2 三层架构里每一层该放什么
我把这个项目拆成了三层,每一层职责非常独立。
第一层是C-HAL层,直接和芯片寄存器打交道。它负责定义DMA描述符结构体、分配静态缓冲区、配置DMA通道、设置链式描述符数组、启动和停止传输。这一层不关心MicroPython的任何机制,纯粹是裸机驱动。你可以把它当成一个不依赖任何库的DMA驱动。
第二层是C-Bridge层,充当底层驱动和Python之间的桥。它把Python传入的回调函数保存下来,在DMA中断里通过MicroPython的mp_sched_schedule把回调挂到解释器调度队列。它还把read_buf、current_index这些接口导出为Python函数,所有从C缓冲区到Python字节串的数据转换都在这一层完成。
第三层就是Python应用层,写业务逻辑。项目里我要做的是“触发一次事件后,连续采集四通道数据,采集完成后把所有数据聚合起来做特征提取”,所以Python层只需要调用adc_acq.start(),然后在回调函数里拿到数据就行。
这三层的关系可以用一句话概括:C-HAL层决定“数据怎么被硬件搬运”,C-Bridge层决定“硬件搬完的数据怎么交给Python”,Python层决定“拿到数据以后做什么”。这样分层最大的好处是,任何一层出了问题都能单独测试:底层可以用串口打印描述符内容,中间层可以定时器模拟中断触发回调,上层可以先喂假数据验证算法逻辑。
3. 手把手实现:DMA链式触发与Scatter-Gather数据聚合
3.1 硬件平台与固件准备
我手上最顺手的板子是STM32F407ZGT6,所以下面的实现都基于这块芯片。选它不是因为F4最强,而是因为F4的DMA2控制器原生支持链式描述符(LLI)功能,非常适合演示Scatter-Gather。如果你用的是F103等不带LLI的DMA控制器,做起来会麻烦很多——不是不能做,而是要用定时器的事件触发来模拟链式切换,代码复杂度会上一个台阶。
固件方面,我建议直接用MicroPython官方仓库的stm32端口源码来编译自定义固件,把自定义C模块一起编进去。编译流程大致是:先拉取micropython仓库,在ports/stm32目录下新建一个moddma_acq.c文件,修改mpconfigport.h和Makefile把模块注册进去,然后交叉编译生成带DMA扩展的固件。
如果你暂时不想编译固件,也可以用MicroPython的user C module机制,把C模块放在extmod/user_c_modules目录下,通过FROZEN_MANIFEST和USER_C_MODULES指定路径编译。两种方式我试过,本质一样,关键在于最后生成的固件里要能看到你的模块,否则所有Python脚本都白写。
3.2 C层DMA描述符与缓冲区定义
先定义一个通用的DMA描述符结构体。不同芯片的寄存器布局不同,但核心字段基本一致:源地址、目的地址、传输计数、控制字、下一个描述符地址。
// dma_acq_mod.c #include "py/runtime.h" #include "py/obj.h" #include "py/mphal.h" #include "stm32f4xx_hal.h" #define BUF_NUM 4 #define BUF_SIZE 4096 typedef struct { volatile uint32_t cfg; // 控制配置:位宽、地址递增、中断使能等 volatile uint32_t src; // 源地址 volatile uint32_t dst; // 目的地址 volatile uint32_t cnt; // 本次传输的数据计数 volatile uint32_t next; // 下一个描述符地址,0表示结束 } dma_lli_t; __attribute__((aligned(32))) static uint8_t sample_buf[BUF_NUM][BUF_SIZE]; __attribute__((aligned(32))) static dma_lli_t lli_desc[BUF_NUM]; static volatile uint32_t current_idx = 0;代码里有几个细节值得说明。第一,缓冲区必须用静态数组分配,不要用MicroPython的gc_malloc去申请。DMA搬运时不经过MMU和缓存管理,MicroPython堆管理器返回的地址虽然也是连续RAM,但没法保证对齐要求和内存区域属性,一旦被GC迁移或释放,DMA操作就会踩到非法地址。第二,aligned(32)是为了满足某些DMA控制器对地址对齐的要求,同时避免CPU缓存和DMA访问之间出现一致性问题。就算平台不强制,我也建议保持这个习惯。第三,描述符数组和缓冲区都要放在DMA可访问的RAM区,STM32F4的内部SRAM都没问题,但如果你用的芯片有TCM RAM,需要注意。
3.3 C层初始化函数与链式描述符配置
接下来是初始化函数。它的任务是把描述符链表的src、dst、cnt、next全部填好,然后等待启动命令。
STATIC void dma_chain_config(void) { // 以ADC1的DR寄存器作为源地址示例 uint32_t adc_dr_addr = (uint32_t)&ADC1->DR; for (int i = 0; i < BUF_NUM; i++) { lli_desc[i].src = adc_dr_addr; lli_desc[i].dst = (uint32_t)&sample_buf[i]; lli_desc[i].cnt = BUF_SIZE / 2; // 16位ADC数据 lli_desc[i].cfg = DMA_CTRL_MSIZE_16BIT | DMA_CTRL_PSIZE_16BIT | DMA_CTRL_MINC | DMA_CTRL_TC_INT_EN; lli_desc[i].next = (i < BUF_NUM - 1) ? (uint32_t)&lli_desc[i + 1] : 0; } }这里有一个关键设计:最后一个描述符的next字段设置为0。这样DMA在跑完最后一段后会停下来,触发一次最终的传输完成中断。如果你想做连续不间断采样,可以把最后一个描述符的next指回lli_desc[0],形成循环链表。两种模式对应的应用场景完全不同,前者适合“采一轮,处理一轮”的批处理,后者适合“永不停机”的流水线采集。项目一开始我选了循环链表,但后来发现配合MicroPython回调,循环模式反而容易让缓冲区被盖掉,所以最后改成了批处理模式。
中断处理函数里,我只做了两件事:清除中断标志,把当前完成段的索引通过mp_sched_schedule调度到Python层。
STATIC void dma_stream_irq_handler(void) { if (DMA2->LISR & DMA_LISR_TCIF0) { DMA2->LIFCR = DMA_LIFCR_CTCIF0; uint32_t idx = current_idx; if (idx < BUF_NUM) { current_idx++; if (ctx_callback != MP_OBJ_NULL) { mp_obj_t args[] = { mp_obj_new_int(idx) }; mp_sched_schedule(ctx_callback, mp_obj_new_tuple(1, args)); } } } }注意,DMA中断回调里绝对不能直接调用Python函数,因为此时解释器可能正忙于执行字节码,直接调用会造成对象状态错乱。mp_sched_schedule是MicroPython提供的一个安全调度接口,它会把Python回调放到解释器事件循环里,等待当前执行步骤结束后再调用,这样能避免大部分PendingKeyboardInterrupt和嵌套回调的问题。
3.4 把C模块注册进MicroPython
要让Python脚本能调用上面这些函数,需要注册一个模块。
STATIC mp_obj_t mp_dma_chain_start(void) { current_idx = 0; dma_chain_config(); // 使能DMA2 Stream0,通道0对应ADC1 DMA2_Stream0->CR &= ~DMA_SxCR_EN; DMA2_Stream0->PAR = lli_desc[0].src; DMA2_Stream0->M0AR = lli_desc[0].dst; DMA2_Stream0->NDTR = lli_desc[0].cnt; DMA2_Stream0->CR = DMA_SxCR_CHSEL_0 | DMA_SxCR_MSIZE_1 | DMA_SxCR_PSIZE_1 | DMA_SxCR_MINC_EN | DMA_SxCR_TCIE; DMA2_Stream0->CR |= DMA_SxCR_EN; return mp_const_none; } STATIC MP_DEFINE_CONST_FUN_OBJ_0(mp_dma_chain_start_obj, mp_dma_chain_start); STATIC mp_obj_t mp_dma_chain_read_buf(mp_obj_t idx_obj, mp_obj_t len_obj) { mp_int_t idx = mp_obj_get_int(idx_obj); mp_int_t len = mp_obj_get_int(len_obj); if (idx < 0 || idx >= BUF_NUM) { mp_raise_ValueError(MP_ERROR_TEXT("invalid idx")); } if (len > BUF_SIZE) len = BUF_SIZE; return mp_obj_new_bytearray_of_zeros(len, NULL); // 简化,实际需要copy } STATIC MP_DEFINE_CONST_FUN_OBJ_2(mp_dma_chain_read_buf_obj, mp_dma_chain_read_buf); STATIC const mp_rom_map_elem_t dma_acq_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR_start), MP_ROM_PTR(&mp_dma_chain_start_obj) }, { MP_ROM_QSTR(MP_QSTR_read_buf), MP_ROM_PTR(&mp_dma_chain_read_buf_obj) }, { MP_ROM_QSTR(MP_QSTR_BUF_NUM), MP_ROM_INT(BUF_NUM) }, { MP_ROM_QSTR(MP_QSTR_BUF_SIZE), MP_ROM_INT(BUF_SIZE) }, }; STATIC MP_DEFINE_CONST_DICT(mp_module_dma_acq_globals, dma_acq_module_globals_table); const mp_obj_module_t mp_module_dma_acq = { .base = { &mp_type_module }, .globals = (mp_obj_dict_t *)&mp_module_dma_acq_globals, }; MP_REGISTER_MODULE(MP_QSTR_dma_acq, mp_module_dma_acq);MP_REGISTER_MODULE是MicroPython较新的模块注册宏,编译时会把模块放进mp_builtin_module_table。如果你用的是老版本代码,可能需要手动在mpconfigport.h里声明模块引用。
3.5 MicroPython脚本侧:回调、读取与聚合
底层接口完成之后,Python层就清爽了。
import dma_acq import array BUF_NUM = dma_acq.BUF_NUM BUF_SIZE = dma_acq.BUF_SIZE store = [None] * BUF_NUM def on_segment_ready(idx): # 在回调里只做拷贝,不做复杂运算,避免阻塞下一次DMA store[idx] = dma_acq.read_buf(idx, BUF_SIZE) dma_acq.set_callback(on_segment_ready) dma_acq.start() # 主循环等待所有段采集完成 while any(s is None for s in store): pass raw = b''.join(store) data = array.array('H', raw) # 接下来做切片、滤波、FFT等聚合分析这段脚本的逻辑很简单,但有几点要提醒。第一,回调函数里不要做耗时操作,比如打印、文件写入、算法计算这些统统不要放进去。DMA是按硬件节奏跑的老牛,回调等太久,后续的DMA传输就可能覆盖掉还没被读走的缓冲区。第二,read_buf返回的是一个新的字节串,每次调用都会有内存分配,所以如果你要做实时流水线,建议用双缓冲策略:奇数段采集时处理偶数段,偶数段采集时处理奇数段。第三,array.array('H')转换要注意字节序,C层是16位ADC数据,小端模式下用array.array('H')直接解析通常没问题,但如果你在ESP32这类处理器上跑,可能需要指定字节序格式。
4. 描述符数量、缓冲区大小与中断频率的性能调优
4.1 描述符个数与缓冲区大小怎么定
描述符个数和缓冲区大小这两个参数,直接决定了整套系统的吞吐上限和实时性。选多了浪费RAM,选少了又频繁被打断。
最简单的估算方式是从数据流速出发。假设ADC采样率是1Msps,采样精度16bit,那么每秒会产生2MB数据。如果你希望CPU每10ms感知一次“有一批数据来了”,那么这10ms内的数据量就是2MB/s×10ms=20KB。保持和MicroPython调度周期相匹配,描述符总数乘以单个缓冲区大小要略大于这个值。我在项目里用了4×4096=16KB,10ms周期的实时性基本足够。
另一个需要考虑的是总内存预算。F407有192KB RAM,MicroPython的GC堆如果太小,脚本稍微写复杂一点就容易跑内存不足。我建议把DMA缓冲区控制在处理器总RAM的10%~20%以内,剩下的留给MicroPython堆和系统栈。如果你采集的数据量特别大,可以考虑把缓冲区放到外扩SRAM或SDRAM里,但要注意DMA控制器的可访问范围,以及ST的M7系列还存在D-Cache一致性问题,需要额外做cache clean/invalidate操作。
4.2 中断频率与CPU占用率之间的平衡
链式DMA可以做到“一整条链只中断一次”,但这个“一次”取决于你每个描述符的控制字里是否使能了传输完成中断。如果你在每一个描述符上都开启了TC中断,那么即使有链式结构,中断频率也等于描述符数量乘每轮次数,CPU照样会被拖垮。
更好的做法是只在最后一个描述符开启TC中断,或者利用半传输中断(HT)做双缓冲处理。半传输中断的意思是:DMA当前传输块的数据量达到一半时触发一次中断,全部完成时再触发一次。配合两块缓冲区,一块在写入时另一块可以处理,采集效率最高。这也是为什么很多音频采集方案都采用乒乓缓冲的原因。
我在项目里的参数配置是这样的:4个描述符,只在最后一个描述符开启中断,一轮完整采集完成后触发一次回调,CPU占用率从之前的78%降到了8%左右。如果你把描述符数量提到8个、16个,中断频率基本不变(还是每一轮一次),只是缓冲区换得更频繁,实时性更好。
4.3 实际性能对比数据
为了说明问题,我把同一套ADC采集任务在三种方案下的表现列在下面。测试条件:STM32F407,1Msps采样,4通道,每轮采集16384个采样点,MicroPython主循环跑一个空转计数。
| 方案 | CPU占用率 | 丢点情况 | 中断频率 | 代码复杂度 |
|---|---|---|---|---|
| 定时器中断+逐次ADC读取 | 82% | 偶尔丢点 | 1kHz | 低 |
| 单缓冲DMA+每次中断重配 | 41% | 丢点较少 | 1kHz | 中 |
| 链式Scatter-Gather DMA | 7% | 无丢点 | 每轮1次 | 较高 |
这组数据不追求绝对精确,但趋势很明确:链式描述符把中断频率降低了一到两个数量级,CPU时间被释放出来给业务逻辑。
5. DMA链式触发排查实录:串口不定长接收与高频采集的坑
5.1 Python回调执行时间过长导致缓冲溢出
这个坑是我踩得最狠的。第一次跑通Scatter-Gather采集时,我在回调里直接把数据用json.dumps格式化后通过socket上传,结果跑了几秒后发现波形出现周期性撕裂。排查半天才发现,DMA一轮传输完成,回调还没处理完,下一轮数据已经写完同一块缓冲区,把上一轮数据覆盖了。
解决方式有几种。最简单的是加大缓冲区或者减少描述符个数,让每轮数据量变小,回调处理时间不至于超过DMA写入时间。更通用的做法是把回调看成“只是一个事件通知”,在回调里只记录数据段编号,真正读取和处理放到主循环的一个任务队列里。我的最终方案是:回调里把idx放入队列,主循环空闲时从队列取编号,再调用read_buf读取数据。
5.2 串口DMA不定长接收:怎么判断一帧结束
Scatter-Gather不只用于ADC采样,做串口DMA接收不定长数据时同样好用。串口数据是不定长的,DMA无法预知要接收多少字节,所以常见做法是用串口空闲中断(IDLE)来判定一帧结束。DMA的Scatter-Gather作用在于,即使在接收过程中发生了地址切换,也不需要暂停串口接收,可以连续地把不同数据段聚合到缓冲区。
具体逻辑是:开启串口DMA接收,使能IDLE中断。每次收到一个字节,DMA就存进当前缓冲区;当串口线上出现空闲超过一个字节时间,IDLE中断触发,此时判断DMA中剩余的计数,就能算出当前帧长度。如果缓冲区收到一半被帧分割,下一个帧到来时,DMA的链式描述符可以让它继续写到下一段缓冲区。这种方案比“每字节中断”或“固定长度DMA”灵活得多,特别适合Modbus、GPS NMEA等不定长协议解析。
有个细节要注意:串口DMA的源地址是串口DR寄存器,永远固定,目的地址递增;而每段缓冲区的长度决定了当前帧的最大长度。链式缓冲区的段数就是你能缓存的最大帧数。如果数据帧特别长,或者帧与帧之间没有足够空闲间隔,DMA可能在一段缓冲区满了之后直接切到下一段,但IDLE中断可能尚未触发,这时的帧边界就取决于你如何判断DMA的NDTR寄存器和描述符索引了。
5.3 DMA发送需要等待上一轮数据发送完吗
这个问题的答案是要等。经验不足的人可能会在MicroPython脚本里连续调用两次发送函数,第二次发送时DMA还在处理第一次的数据,结果新数据不是被丢弃就是被截断。
在链式DMA场景下,这个坑会变成:修改描述符链表的内容时,必须确认DMA当前没有在读取那个描述符。如果DMA正在执行lli_desc[0],你却在Python层改写了sample_buf[1]的内容,可能还看不出问题,但如果你改了lli_desc[1].next指针,那DMA载入后就会被你带到完全错误的内存地址。我的排查习惯是,在启动传输后,用硬件断点或DMA2->LISR标志判断当前Stream是否还忙,如果不忙再允许修改描述符链表。
5.4 MicroPython固件编译失败与模块未注册
这个问题对新手最不友好,因为报错信息往往很抽象。我在第一次把C模块编进固件时,编译过了,但MicroPython里执行import dma_acq时报ModuleNotFoundError。原因是我在mpconfigport.h里没有加外部模块声明,或者Makefile没有正确指定USER_C_MODULES路径。
解决方法是:在ports/stm32/mpconfigport.h里加入#include "moddma_acq.h",并且在Makefile中添加:
USER_C_MODULES = ../../extmod/user_c_modules如果选择直接放源码目录,记得把.c文件加入SRC_USERMOD或直接修改stm32.mk。还有一个小提示:MicroPython对大小写敏感,模块名、文件路径、MP_QSTR里的字符串必须完全一致,dma_acq和DMA_ACQ是两个完全不同的模块名。
5.5 描述符链表地址配置错误的表现
链表地址配错的经典症状是:采集到的数据总是同一个缓冲区的内容,或者某些段永远为空。我遇到过一次,把所有描述符的next都填成了0,DMA跑完第一段就停住了,后来的几段缓冲区只能手动读。把next指错成某个数据缓冲区的地址,DMA会尝试把描述符当成控制结构体来解析,轻则死机,重则硬件错误。
排查这类问题没有捷径,只能通过串口打印或调试器查看每个描述符的字段值,确认next指针指向的是描述符数组的地址,而不是数据缓冲区的地址。我在调试时习惯把lli_desc[0].next、lli_desc[1].next这些值通过串口或SWD直接读出来,和预期地址做比对,一眼就能看出配置是否错位。
关于这套方案的最终建议
如果你只是偶尔采集几千个数据点,官方adc.read_timed()完全够用,没必要上C扩展。当数据量以MB为单位增长、CPU被大量中断拖累、或者你需要在DMA搬运的同时并行处理上一轮数据时,链式Scatter-Gather就是值得投入的方向。
我觉得最值钱的经验是:不要把所有逻辑都塞进一个回调函数。用DMA链式触发的本质是信任硬件,而信任硬件的前提是软件要留出足够的处理时间。把缓冲区管理、事件通知、数据处理这三个环节解耦,比优化某一个具体函数的执行速度重要得多。