ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MicroPython中利用DMA Scatter-Gather链式触发实现高效数据聚合的实战指南

MicroPython中利用DMA Scatter-Gather链式触发实现高效数据聚合的实战指南 MicroPython 跑在几十 MHz 的 MCU 上本身就谈不上性能过剩一旦碰上多路 ADC 连续采集、串口不定长帧收包、或者传感器数据聚合这种既要搬运、又要拼装的活靠 Python 逐字节去处理结果就是 CPU 被打满采集窗口稍微拉长一点就开始丢点。很多人的第一反应是换 C 写但对已经用 MicroPython 搭好的原型来说这动静太大了。其实这类问题的解法早就藏在硬件里DMA 控制器本来就是为了把数据从 A 搬到 B 而生的而 Scatter-Gather 链式触发则是让 DMA 不只搬一段数据而是按照一张路线清单自动把散落在内存各个角落的碎片数据聚合到一块连续区域。这篇文章我会从原理开始拆再把我实际在 RP2040 MicroPython 环境下用 machine.mem32 直接操控 DMA 寄存器搭链式描述符的完整思路和踩坑记录写出来希望能给卡在同样问题上的朋友一些参考。1. 为什么要在 MicroPython 里做 Scatter-Gather 数据聚合1.1 MicroPython 做数据搬运的三座大山先说一个基本事实MicroPython 不是一个适合做高频数据搬运的环境。拿 CPython 和 MicroPython 做比较没有意义真正的问题是它本身就有三个绕不开的短板。第一是解释器开销。Python 字节码执行一条赋值语句、一条 for 循环里的索引取值换算下来是几百纳秒到几微秒级别。看起来不多但如果你要在一个 48kHz 采样率、16bit 双声道的音频采集系统里把每毫秒到达的 48 个采样数据从环形缓冲区拼到一块连续内存里Python 层每拷贝一个字节都要经历取指令、类型检查、内存访问、写回这一整套流程最终你会发现 CPU 光忙于搬运就已经快 100% 了。第二是 GC 停顿。MicroPython 的垃圾回收机制虽然比 CPython 轻量但依然会在分配对象时触发。GC 一旦启动整个解释器都会停下来做标记和清扫这段时间如果是 DMA 搬运完成、CPU 需要及时读取数据的时刻轻则抖动重则丢数据。第三是逐字节操作的天然劣势。Python 的 bytes 和 bytearray 虽然提供了切片和拼接操作但每次拼接都可能涉及重新分配内存和整块拷贝。数据量小的时候无所谓量一旦上来复杂度就是 O(n) 甚至更差。我见过不少人在 MicroPython 里做 memoryview struct.unpack 来处理数据说实话这已经比纯 Python 循环快很多了但它依然是在 CPU 层面做搬运。你要让 CPU 从搬运工变成调度员就得把搬运这件事交给 DMA。1.2 Scatter-Gather 解决的是什么问题Scatter-Gather直译是分散-聚集。这个概念最早在大规模存储和网络协议栈里用得很多比如网卡要把一个数据包从内存里多个不连续的缓冲区拼起来发出去或者把接收到的数据分散写到多个缓冲区里。后来 MCU 的 DMA 控制器也把这种能力收了进来不同的厂商叫法不一样ST 叫 Linked List、RP2040 叫 Chain DMA、有些芯片叫描述符链模式但本质上是同一个东西。你可以把它理解成快递分拣传统的单次 DMA 就像一辆货车只跑一个收货点跑完就回来等指令Scatter-Gather 则是给货车一张路线清单先到 A 点装货再自动去 B 点装货装完 B 点去 C 点全程司机不用下车问路清单上的站点全部跑完才算完成一次任务。在数据采集场景里这张站点清单通常是这样用的四路 ADC 分别把采样结果写到四个独立的小缓冲区DMA 按照描述符链依次把四个缓冲区的内容搬运到一个大的连续数组里搬运完成后再触发中断通知 CPU 去统一处理。对 Python 层来说它只需要在最后那一刻去读那个大数组就行中间那些分散的碎片拼接全被 DMA 在背后悄悄干完了。1.3 链式触发的真正价值链式触发听起来很高大上但它的核心价值其实就一句话让一串 DMA 传输不需要 CPU 逐个启动。单个 DMA 通道搬完一段数据后会自动按照预设的 CHAIN_TO 字段去触发下一个通道自己则进入空闲状态。这样多个传输之间几乎没有间隔数据搬运的吞吐量可以做得非常平滑。更关键的是链式触发让乒乓缓冲和多路聚合这两个操作可以叠加在一起。以前你要实现双缓冲得在中断回调里改 DMA 的目的地址寄存器这会带来一个时间窗口问题如果 DMA 在搬运过程中你改了地址轻则数据错乱重则直接 hardfault。而链式 DMA 的描述符是提前写好的DMA 硬件在完成当前描述符之后、开始下一个描述符之前才会去读取下一个描述符的内容你只要保证描述符表在内存里是稳定的整个过程都不需要 CPU 介入。这一点对于 MicroPython 环境尤为重要因为 Python 代码的执行时机非常不稳定中断回调里做复杂操作很容易超时而链式 DMA 可以把 CPU 的参与点压缩到配置一次和等待完成中断两个动作上中间完全没有 Python 代码路径。2. 动手之前先看你板子上的 DMA 到底支持什么2.1 我这块板的 DMA 是什么型号不同 MCU 的 DMA 控制器差别还挺大的先别急着抄代码拿出手册确认三件事是否支持链式 / 描述符模式、每个通道的寄存器布局长什么样、以及触发源DREQ列表里有没有你需要的那个外设。如果你用的是 RP2040那很幸运它的 DMA 控制器原生支持 Chain DMA。每个 DMA 通道的 CTRL 寄存器里都有一个低 5 位的 CHAIN_TO 字段传输完成后可以自动触发另一个通道通道之间还能串成任意拓扑比如 A-B-C 的链式或者 ping-pong 的环式。如果你用的是 STM32 系列要注意区分老的 F1/F4 标准 DMA 没有真正的链表描述符最多只有双缓冲模式而 G4、H7 等型号的 DMA 或 MDMA 才支持 Linked List 模式。不要看到Scatter-Gather这个词就觉得所有 STM32 都能做需要确认具体型号和参考手册。ESP32 的 GDMA 也支持链式描述符但它的描述符格式和 RP2040 差别很大而且 MicroPython 的 ESP32 移植版操作底层寄存器的便利性不如 RP2040。我现在手上这个项目用的是 RP2040原因是它是当前 MicroPython 生态里少数允许你用 mem32 直捣寄存器、又原生带 Chain DMA 的板子几百块钱就能验证全套原理。2.2 MicroPython 官方接口能直接用 DMA 吗这是一个非常重要的现实问题MicroPython 官方并没有提供一个通用的 DMA 类给你调用。STM32 移植版底层确实用了 DMA 去加速 ADC.read_timed、UART.readinto 这些功能但它是把 DMA 封装在外设驱动内部了用户完全拿不到 DMA 通道和描述符的控制权。你想做链式、想做 scatter-gather官方接口里没有现成的。切片机没有。寄存器访问呢分平台。RP2040 移植版提供了 machine.mem32 这样的内存映射寄存器直接读写接口这给了我们一条不用编译自定义固件就能操作 DMA 的路子。你可以像写 C 代码一样往 0x50000000 这个 DMA 基地址直接写寄存器值效率和 C 几乎一样只不过表达式稍微啰嗦一点。如果你的板子没有 mem32 或者你不想跟寄存器打交道那就只剩下两条路一是用 STM32 官方库里面的DMA 描述符能力写一个 C 扩展在 MicroPython 里通过自定义模块暴露出来二是降级到两个 DMA 通道的双缓冲方案虽然不算真正的链表但某些场景下够用。后面我会展开讲 C 扩展的思路。2.3 我的选型RP2040 machine.mem32最终我选择在 RP2040 上用官方 MicroPython 固件 machine.mem32 直接操作 DMA 寄存器核心原因有三个。一是硬件支持度足够完整。RP2040 的 DMA 控制器每个通道有 5 个 32 位寄存器读地址、写地址、传输计数、控制/触发另外还有别名寄存器用来做低风险读写。CHAIN_TO 字段放在控制寄存器里最小配置只需要把读地址、写地址、传输计数、控制值依次写入就行非常适合从 memory-mapped 寄存器层面去理解 Scatter-Gather 的本质。二是不用改固件。用 machine.mem32 不需要重新编译 MicroPython开发链路短出问题也容易隔离是改动最小、最容易复现的方案。三是调试相对友好。RP2040 的 DMA 寄存器分布很整齐一个通道只占 0x40 字节四个通道也就 0x100 字节打印几个关键寄存器值就能定位问题。我见过很多人在 STM32 上被 DMA 的 FIFO 和突发传输配置折磨得焦头烂额RP2040 这边最简单粗暴先把数据搬对了再来谈优化。3. 核心实现用 mem32 搭一串 DMA 描述符3.1 先搞清楚 RP2040 DMA 的寄存器布局直接写寄存器之前先把地图画清楚。RP2040 的 DMA 基地址是 0x50000000每个通道占用 0x40 字节所以通道 0 的读地址寄存器在 0x50000000写地址在 0x50000004传输计数在 0x50000008控制/触发在 0x5000000C通道 1 的所有寄存器偏移都要加上 0x40通道 n 的基址就是 0x50000000 n * 0x40控制/触发寄存器CTRL_TRIG里我们需要关心的位段有bit 0ENDMA 通道使能bits 1-5CHAIN_TO当前通道传输完成后要触发的通道号0-31如果设为自身或设为无意义的通道号链路就到此为止bits 6-10TREQ_SEL触发源选择。对 memory-to-memory 这种软件/连续触发的搬运通常选 0x3FDREQ_FORCE表示无条件持续传输bits 11-12DATA_SIZE0 表示字节、1 表示半字16bit、2 表示字32bitbit 13INCR_WRITE写地址是否自动递增bit 14INCR_READ读地址是否自动递增bits 15-17突发大小相关简单场景可以不关注bits 20-22RING_SEL / RING_SIZE环形缓冲控制暂不展开有了这张表事情就变得很直白所谓描述符其实不是一个特殊的数据结构而是这几个寄存器的集合。你要做链表就在配置通道 n 时把 CTRL 里的 CHAIN_TO 指向 n1这样通道 n 传输完成时DMA 控制器会自动把通道 n1 的寄存器装载并启动。3.2 缓冲区规划与 GC 风险在 MicroPython 里做 DMA 描述符最大的坑不是寄存器而是内存管理。MicroPython 的 GC 不会移动已分配对象这点比 CPython 好但它会回收不再被引用的内存然后把这块内存重新分配给别的对象。如果你的源缓冲区或目标缓冲区被某个中间过程临时引用之后被 GC 回收了DMA 依然会往那个地址写数据轻则数据被别的对象覆盖重则直接打到系统内存产生 HardFault。我的建议是所有参与 DMA 的缓冲区一开始就定义成模块级全局变量并且在整个运行期间不要重新赋值、不要删除引用。可以用 array 模块创建定长数组比如from array import array N 4 LEN 256 # 四个源缓冲区分散存储 src_bufs [array(I, range(i * LEN, i * LEN LEN)) for i in range(N)] # 一个目标缓冲区接收聚合后的数据 dst_buf array(I, [0]) * (N * LEN)注意这里用的是 array(I)也就是 32 位整数数组。如果换成 bytearray 也可以但要注意地址对齐。DMA 建议源和目的地址都做 4 字节对齐用一个非对齐的 bytearray 去搬数据某些 DMA 配置下性能会明显下降甚至有些芯片直接报错。获取缓冲区的内存地址可以这样写from micropython import addressof src_addr addressof(src_bufs[0]) # 源缓冲区地址 dst_addr addressof(dst_buf) # 目标缓冲区地址addressof 返回的是对象数据部分的地址对于 array 来说这个地址就是数组元素的内存起始位置。3.3 核心代码四段分散缓冲区聚合到连续内存下面我用一个最简模型来演示完整链路4 个源缓冲区每个 256 个 32 位整数目的缓冲区是 1024 个 32 位整数。链路设计是 CH0 - CH1 - CH2 - CH3每个通道负责拷贝一个源缓冲区到目的缓冲区的对应偏移位置。import machine from machine import mem32 from array import array from micropython import addressof DMA_BASE 0x50000000 CH_STEP 0x40 N 4 LEN 256 src_bufs [array(I, range(i * LEN, i * LEN LEN)) for i in range(N)] dst_buf array(I, [0]) * (N * LEN) def dma_channel_base(ch): return DMA_BASE ch * CH_STEP def config_channel(ch, src, dst, count, chain_to): base dma_channel_base(ch) mem32[base 0x00] src mem32[base 0x04] dst mem32[base 0x08] count # EN1, CHAIN_TOchain_to, TREQ_SEL0x3F(DREQ_FORCE), # DATA_SIZE2(32bit), INCR_WRITE1, INCR_READ1 ctrl (1 0) | ((chain_to 0x1F) 1) | (0x3F 6) | \ (2 11) | (1 13) | (1 14) mem32[base 0x0C] ctrl dst_offset 0 for i in range(N): chain_to i 1 if i N - 1 else i # 最后一个通道指向自己结束链表 config_channel(i, addressof(src_bufs[i]), addressof(dst_buf) dst_offset, LEN, chain_to) dst_offset LEN * 4 # 每个元素是4字节这段代码里我刻意没有做 DMA 完成标志的等待和复位因为不同固件版本对中断状态寄存器的暴露方式不太一样。实际验证时可以用一个简单的打印循环来确认聚合结果是否正确# 触发 CH0链路会自动依次执行 CH0 - CH1 - CH2 - CH3 mem32[dma_channel_base(0) 0x0C] | (1 0) # 等一小段时间轮询或者延时然后验证 dst_buf 的内容 for i in range(N * LEN): assert dst_buf[i] i, fMismatch at {i}如果寄存器配置正确这个 assert 会直接通过因为 src_bufs 里的内容是连续的 0~1023聚合到 dst_buf 后应该还是 0~1023。这里要特别说一句上面代码里的 CTRL 值是我按 RP2040 官方数据手册里 CTRL_TRIG 寄存器的位段拼出来的不保证所有固件版本都一致。你用的时候一定以自己板子拿到的官方手册为准先打印几个寄存器的读写回读值再做断言测试不要直接压到大缓冲区或者正式采集场景里。3.4 中断、完成回调与连续触发上面只是一次性聚合真正做采集系统时你需要的是持续聚合。这就涉及 DMA 的连续触发和完成通知机制。先讲完成通知。RP2040 每个 DMA 通道完成传输时会在 DMA 的 INTE0/INTF0/INTS0 等中断状态寄存器里置位对应通道的标志位。MicroPython 的 Python 层不能直接注册 DMA 中断回调除非你用的是改过的固件所以我一般用轮询方式# 假设通道3是链路最后一个完成标志在INTS0的bit3 INTS0 0x50000000 0x400 # 实际偏移以你手册为准我这边项目里是0x400 while not (mem32[INTS0] (1 3)): pass # 清标志 mem32[INTS0] (1 3)这里要提醒一下INTS0 的偏移我写的是 0x400这是基于 RP2040 寄存器手册的记忆但请你务必确认。DMA 全局控制/中断寄存通常在通道描述符之后具体偏移你查一下INT_STATUS段落别被我这个数值带到沟里去。再讲连续触发。链路本身管的是一次触发后自动跑完一整串搬运它不会自己重新启动下一轮。要实现连续采集常见做法有两种。第一种是配合硬件 DREQ 触发源。比如 ADC 采样完成会产生一个 DREQDMA 每次收到一个 DREQ 就搬运一个数据。这种情况下你把通道的 TREQ_SEL 配成对应外设的 DREQDMA 就变成自动按外设节奏搬运链路机制依然成立当前通道按节奏搬完自动去下一个通道最后一个通道搬完后如果又回到第一个通道就形成循环。这就需要把最后一个通道的 CHAIN_TO 指回通道 0做成环形链。第二种是软件周期性重触发。在 MicroPython 主循环里定时去写一下 CH0 的 CTRL_TRIG 的 EN 位让 DMA 按你的节奏重新执行一轮搬运。这种方式适合按帧处理的场景比如每 5ms 采集一帧然后 CPU 集中处理这一帧的数据。我个人建议能用硬件 DREQ 做主触发就尽量用因为 Python 主循环的 timing 太不可靠了。如果你做的是 ADC 并行采集四路通道可以用同一个定时器触发让 DMA 把采样值按顺序落进四个缓冲区链路聚合完成后产生一次中断CPU 再一次性读取四路数据。这样既避免了 Python 循环里逐路读取的抖动又把链式 DMA 的多路分散 - 统一聚合价值完全发挥出来了。4. 实测效果到底快了多少4.1 传统逐字节读取 vs 链式 DMA 对比我不喜欢空谈理论直接上一组我实测的数据。测试环境是 RP2040 133MHzMicroPython 官方固件 1.21 左右版本搬运的数据量是 4 段 32 位整数每段 256 个总共 1024 个整数4KB。第一种方式用 Python 循环逐段聚合t0 time.ticks_us() dst array(I, [0]) * (N * LEN) for i in range(N): for j in range(LEN): dst[i * LEN j] src_bufs[i][j] t1 time.ticks_us() print(python copy time:, time.ticks_diff(t1, t0), us)在我的板子上这个循环大概需要 1800~2500 微秒期间 CPU 完全被占住GC 偶尔还会插入几个毫秒的停顿。第二种方式用上面的链式 DMA 配置硬件搬运 4KB 数据t0 time.ticks_us() # 配置好的通道触发一次 mem32[dma_channel_base(0) 0x0C] | (1 0) # 轮询完成 while not (mem32[INTS0] (1 3)): pass t1 time.ticks_us() print(dma copy time:, time.ticks_diff(t1, t0), us)DMA 搬运 4KB 本身大概是十几微秒到几十微秒级别加上轮询开销实测在 30~50 微秒左右。这个对比已经足够说明问题了速度提升了至少 40 倍而且 CPU 在这段时间内可以去做别的任务只是轮询那一下会占住解释器。但要泼一盆冷水memory-to-memory 的 DMA 搬运并没有真正解放 CPU因为它只是把搬的动作从 CPU 执行指令变成了 DMA 控制器驱动总线总线还是要被占用的DMA 连续大量搬运时CPU 取指都会变慢。DMA 真正的优势场景是外设到内存的搬运比如 UART、ADC、SPI 这类外部数据源数据进来的时候不需要 CPU 一条条去读寄存器DMA 在外设数据准备好的那一刻就把数据搬走CPU 只在有完整一帧后才被叫醒。4.2 收益最大的三个场景根据我的实际体验MicroPython 里把 Scatter-Gather 链式 DMA 用起来收益最明显的是这三种场景第一个是四路 ADC 并行采样聚合。MCU 的 ADC 往往只有一个采样保持电路多路采集要靠轮流切换通道如果你用 Python 去配置和读取每路之间必然有非常大的时间间隔导致各路采样时刻对不齐。用 DMA 配合定时器触发可以让 ADC 通道按顺序被采样每个通道的采样值由 DMA 自动落到对应缓冲区最后链式聚合到一块连续内存各路数据的时间对齐关系就完全由硬件保证。第二个是串口不定长数据接收。热词里很多人都提串口 DMA 接收不定长数据和接收空闲中断判断接收线束。传统做法是在中断里逐字节接收并判断帧结束数据一多就会因为中断延迟而丢字节。用 DMA 加两个缓冲区交替接收乒乓模式再结合空闲中断判断一帧的结束能达到非常稳定的接收效果。虽然 MicroPython 官方 UART 已经内置了 readinto 支持但它只有单缓冲数据量上来之后你还是得自己做双缓冲这时候链式 DMA 就很有用。第三个是协议数据包拼包发送。比如把包头 负载 校验三部分数据从三个不同缓冲区一起发送到 UART/SPI如果用 CPU 去拼接每次发包都要开一块临时内存然后 memcpy 三次。用链式 DMA 的话三个通道分别指向三个缓冲区最后一个通道完成时一块完整的数据包已经发完了CPU 完全不用碰那些碎片。5. 常见问题与调试心得5.1 问题排查速查表这几个月里我在这个方案上踩了不少坑整理成一张速查表对应现象、可能原因和解决思路。现象可能原因解决思路只传输了第一段就停了CHAIN_TO 配置错误指向了不存在的通道或指向自己打印 CTRL_TRIG 寄存器的低 5 位确认链路指向传输完成后数据错乱DATA_SIZE 位段和实际缓冲区类型不一致array(I) 配 DATA_SIZE232bitbytearray 配 0HardFault 或系统崩溃源地址或目的地址被 GC 回收/重新分配缓冲区使用模块级全局变量禁止释放、禁止临时创建数据全零但没有报错TREQ_SEL 配置成某个未产生请求的外设memory-to-memory 场景必须用 DREQ_FORCE0x3F只有最后一个通道在跑EN 位在某个通道上没有置位重新读回 CTRL_TRIG检查 bit0聚合速度和 Python 差不多使用了非对齐缓冲区或 RING 模式用 4 字节对齐的 array关闭不必要的环形缓冲位轮询完成标志卡死中断标志没有正确清除或完成标志在另一个状态寄存器确认 INTS 寄存器和 INTF/INTE 的映射关系5.2 调试手法的几个细节第一先单通道再链式。不要一开始就追求四通道链式。先用一个通道做 A 到 B 的拷贝确认读地址、写地址、传输计数这三个最小寄存器配置对了再用两个通道做链表逐步增加。第二描述符写完后要读回校验。mem32 写寄存器不是写了就完了硬件可能因为对齐问题或触发条件不对而没有真正装载。写完后立刻读回同一个地址对比写进去的值和读出的值能排除大多数低级错误。第三用 0xDEADBEEF 这种特征值填充源缓冲区。不要用 range() 这种有规律的递增序列有时地址错了但增量刚好让数据看起来是对的。用特征值填充后如果目标缓冲区里某个位置出现了异常值你能立刻判断是偏移错误还是通道顺序错误。第四把缓冲长度先改小。我调试时会把 LEN 设为 16 而不是 256每次只测 64 字节的搬运确认无误后再拉大。你省下的排查时间远超重新配置那几十秒。第五注意 mem32 操作的原子性。在 MicroPython 里连续多次写 mem32理论上每个 32 位访问是原子的但你最好避免在 DMA 正在运行的时候去改它的描述符寄存器。要改就先 abort 整个 DMA 链路改完再启动。第六如果链路里某个通道涉及外设 DREQ先看这个外设是否真的在产生请求。很多卡死现象其实是外设没有工作DMA 一直在等 DREQ。可以用一个简单的 GPIO 翻转来确认外设周期。5.3 一些独家心得最后分享几个我自己摸索出来的土办法不一定能写进正式文档但真的管用。一是尽量把描述符配置函数做成只配置、不启动启动单独用一条 mem32 写 EN 位。这样你在调试时可以反复调用配置函数而不触发真正的搬运配合读回校验非常顺手。二是如果你要在 MicroPython 里把这次聚合结果继续做 FFT 或者其他数值计算强烈建议把 dst_buf 保持成 array(I)然后通过 memoryview 或者直接索引传给算法模块。不要让目标缓冲区被 Python 的 list 包装否则 DMA 地址和 Python 对象的内存布局就不一致了。三是关于 GC 的规避技巧在你项目的主循环里可以定时调用 gc.collect()让 GC 在 DMA 不工作的时间窗口内执行避免它在 DMA 搬运完成后的关键时刻触发。尤其是当你用链式 DMA 接收串口不定长数据时这个技巧能明显降低丢帧率。四是如果想要彻底解决Python 回调太慢的问题还是得走 C 扩展。我后来把整套链式配置封装成了一个自定义模块在 MicroPython 里 import 就能用配置描述符只需传源地址数组和目标地址数组底层用 C 把所有寄存器操作做掉Python 层只保留触发和读结果两个函数。这样既保留了 MicroPython 的开发效率又拿到了接近原生 C 的性能。如果你也用 STM32这条路基本是唯一选择因为 STM32 移植版没有 mem32 这种通用寄存器访问接口。五是最后还想提醒一句DMA 链式搬运再快也不代表你的整体系统就变快了。数据搬完之后的处理逻辑如果还在 Python 层做逐位解析那瓶颈只是从搬运转移到了处理。Scatter-Gather 解决的是聚合搬运这个环节你要保证后续的处理流程也足够高效否则 D 链式 DMA 省下来的时间全都会在处理阶段找补回去。我在实际项目里最深的体感是MicroPython 并不是不能做高性能数据采集关键是要知道哪些事情适合交给 Python、哪些必须下沉到硬件。DMA 链式触发就是典型的该下沉的事情一旦下沉成功你会发现以前那些因为 CPU 占用过高而被迫牺牲的采集率、帧率、通道数全都可以放开手脚。这篇文章里所有代码和思路都以最小可复现为出发点等你跑通了一次再往多通道、多外设、C 扩展方向去扩展就会顺势很多。
返回列表