多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PYNQ实战:AXI DMA Simple DMA模式从硬件到Python全解析

PYNQ实战:AXI DMA Simple DMA模式从硬件到Python全解析 有人在PYNQ上跑了两年Jupyter还没碰过DMA这很常见。不是技术门槛有多高而是总觉得那是FPGA老手才玩的寄存器艺术。其实在Zynq的PS和PL之间做数据搬移DMA不是加分项是刚需。这篇文章我就用PYNQ上的AXI DMA把Simple DMA transfer模式从头到尾讲透它在整个AXI DMA体系里处于什么位置、Vivado里怎么搭硬件、Python侧怎么写代码、实测能跑多快以及我踩过的三个坑。目的是让你看完之后能自己把手头的PYNQ板子DMA跑起来而不是只会跑官方demo notebook。1. 为什么DMA在PYNQ上是个躲不开的话题1.1 PS与PL之间的数据交互本质Zynq的PS是双核ARM Cortex-A9PYNQ-Z1/Z2上都是Z-7020PL是Artix-7等价逻辑。PS和PL之间的数据通路从使用方式上看其实就几类AXI GPIO这类低速控制口、AXI Lite寄存器访问以及走AXI全互联的高带宽通路。如果你只是点几个LED、读几个按键用AXI GPIO完全够了几十个字节的寄存器读写CPU自己扛没问题。可一旦PL侧接了摄像头、ADC、高速传感器要往DDR里搬一帧图像、一段采样波形再用CPU逐字去搬那就完全不是一个量级的事了。这个搬的动作如果没有DMACPU就得执行一堆ldr/str指令从外设地址空间读再往内存地址写。每次搬运32位数据还要自增地址、判断边界。数据量一旦上了MB级别这部分开销能把系统的可用算力吃干净。DMA做的事就是把重复劳动从CPU手上下放给一个专门的硬件引擎CPU只需要告诉它从哪搬、搬到哪、搬多少。在PYNQ这种以Python为主的开发环境里这件事反而更容易理解Python每条语句都有解释器开销如果你在Python层面做大容量内存拷贝或者外设读写性能会非常感人。把搬移下放到DMACPU只负责组织缓冲区、等结果这才是合理的分工。打个比方CPU搬运数据像一个业务员自己拖着推车一趟趟跑仓库DMA则是一条自动传送带业务员只需要把清单交给传送带管理员。1.2 不用DMA会怎样CPU搬运的代价讲一个我自己实测过的例子。之前我在PL里做了一个32位采样器数据通过AXI4-Stream持续输出。初期图省事没用DMA直接在PS侧用MMIO寄存器读FIFO每次读一个32位Python代码大概长这样data [] for i in range(4096): data.append(mmio.read(FIFO_RD_REG))实测下来搬4096个字就要花掉几百毫秒。为什么这么慢因为每个mmio.read在底层都是一段AXI-Lite总线事务再经过Python对象包装返回这个往返延迟以微秒计。4096次微秒级往返就是几十毫秒到几百毫秒。而同样4096个32位字用DMA搬只需要几十微秒差了三个数量级。所以要不要用DMA的答案基本是只要你在PS和PL之间搬的数据量超过几十KB并且有持续搬运需求DMA就是必选项。这篇文章说的就是PYNQ上最常用、最容易理解的DMA形态——AXI DMA的Simple DMA transfer模式也就是非Scatter Gather的直接寄存器模式。2. Simple DMA transfer模式先搞清楚它在AXI DMA里排老几2.1 AXI DMA的三种模式速览Xilinx的AXI DMA IPPG021工作模式从配置层面说分两大类一类是直接寄存器模式Direct Register Mode大家习惯叫它Simple DMA另一类是带描述符的Scatter Gather模式。SG模式下还能派生Cyclic模式这是给视频、连续流应用准备的。特性Simple DMA直接寄存器模式Scatter GatherCyclic描述符环无有有一次传输的缓冲区单一连续缓冲区多个不连续BD链循环BD链是否硬件自动重装描述符不需要自动自动适合场景固定长度单次/简单连续搬运大文件分块、多缓冲视频流循环采集使用门槛低较高较高在Vivado里勾选Enable Scatter Gather Engine出来的就是SG模式的寄存器空间不勾选的默认就是Simple DMA。Simple DMA模式下你不需要在内存里维护Buffer Descriptor只需要往寄存器里写源地址、目的地址、传输长度然后启动。这个上手即用的特性让Simple DMA成为学习DMA的首选也是很多固定场景的最优解。2.2 Simple DMA的寄存器视角AXI DMA内部有两条独立通道MM2SMemory-Mapped to Stream和S2MMStream to Memory-Mapped。对应到Simple DMA模式MM2S的搬运是从DDR读数据 → 从AXI4-Stream口发出去S2MM则是从AXI4-Stream接收数据 → 写入DDR。两条通道互相独立可以只开一条也可以同时工作。PYNQ的base overlay里通常两条都开着用来做回环测试。以MM2S为例Simple DMA模式下一笔传输就三个动作把源地址写到MM2S_SA寄存器64位地址还要写MM2S_SA_MSB。把要搬的字节数写到MM2S_LENGTH寄存器。确保MM2S_DMACR里的RSRun/Stop位是1通道处于运行状态。之后DMA自己会去AXI4总线上发起突发读从源地址连续取数从M_AXIS端口以流的方式吐出去。搬完之后MM2S_DMASR的Idle位会被置1标志这一笔传输结束。有一条经验必须分享写寄存器时地址的高32位SA_MSB/DA_MSB要写在低32位前面长度寄存器放在最后。很多Xilinx驱动的实现里长度寄存器的写入动作就相当于触发信号你要是先写长度再写地址DMA很可能已经按一个还没写好的地址开始搬运了。S2MM通道的逻辑是对称的写目的地址到S2MM_DA、长度到S2MM_LENGTH、启动然后等待流数据到达。S2MM的完成条件有两个要么收到长度寄存器指定字节数的数据要么在收满之前遇到TLAST信号提前结束。这一点在接入外部IP时非常关键后面踩坑部分会专门展开。2.3 为什么Simple DMA适合入门和固定场景很多人一开始接触AXI DMA看到Scatter Gather那堆Buffer Descriptor就头大。事实也确实如此SG模式要求你在内存里手动铺好描述符表每个描述符里填地址、长度、控制位还要维护环的head/tail指针出问题排查起来非常痛苦。Simple DMA省掉了所有这些中间层一条线性地址加一个长度思维负担小得多。适合Simple DMA的典型固定场景包括把CPU算好的波形数据一次性灌给PL侧的DAC或信号源。把PL侧FIFO攒住的一组采样数据整体搬回内存。第一版FPGA原型验证时先不用SG用Simple DMA把通路协议跑通之后再按需升级。当然它也有明显边界一次只能搬一块连续内存。如果数据在内存里分散成多个段要么你手动拼接成一个大缓冲区要么就得换SG模式。好在实际工程里大部分传感器数据、图像行缓冲都是连续分配的Simple DMA足够用。3. Vivado里搭建一套最小可跑的Simple DMA硬件平台3.1 建工程、加IP、连线的完整过程PYNQ的PL侧叫Overlay本质上就是一个bitstream加tcl描述硬件层次。你可以直接用官方base overlay里面已经带AXI DMA但我建议在Vivado里从头搭一遍因为知道连线原理之后换板子、加自定义IP心里才不慌。器材准备PYNQ-Z2或PYNQ-Z1、Vivado 2018.x到2021.x都行我自己用2019.1、烧好PYNQ镜像的SD卡、能访问PYNQ的网线。先在Vivado里新建RTL工程选板子型号xc7z020clg400-1然后添加Zynq7 Processing System IP选择Board预设。PYNQ-Z2的话直接Apply Board Preset预设会把PS的UART、SD、USB、以太网都配好。添加AXI Direct Memory Access IP再添加一个AXI4-Stream Data FIFO IP。添加AXI SmartConnectVivado 2019之后的名字老工程里叫AXI Interconnect。用Run Block Automation自动连接Zynq PS的M_AXI_GP0到SmartConnect再从SmartConnect的一个口接AXI DMA的S_AXI_LITE。AXI DMA的M_AXI_MM2S和M_AXI_S2MM接回SmartConnect的另外两个口这样DMA读写DDR的路径就通了。把AXI DMA的M_AXIS_MM2S接到FIFO的S_AXISFIFO的M_AXIS接回AXI DMA的S_AXIS_S2MM。这就是一个最简单的内存→流→内存回环数据进FIFO后原样出来DMA把自己发出去的数据再收回来。时钟和复位Zynq PS的FCLK_CLK0作为axi_dma、axis_data_fifo的aclkFCLK_RESET0_N本来就是低有效复位直接接各IP的aresetn即可。PYNQ预设下FCLK_CLK0默认100MHz对这个Demo完全够用。3.2 AXI DMA的关键参数怎么填双击AXI DMA IP有几处配置要仔细看Enable Read Channel勾选对应MM2S。Enable Write Channel勾选对应S2MM。回环测试两条都要。Memory Map Data Width建议64 bit。Zynq的HP口是64位DMA走内存通道的位宽越大理论带宽越高。Stream Data Width这里也要64 bit因为FIFO的AXI4-Stream接口宽度要匹配。如果后面接的是32位自定义IP统一改成32。Max Burst Size默认16可以调到256。这个值表示一次突发读/写多少个数据节拍burst越大总线效率越高。Address WidthRead/Write Channel保持32即可Zynq-7000的32位地址空间足够。Enable Micro DMA不勾。Micro DMA是给超低延迟场景准备的简化模式跟Simple DMA不是一回事初学者容易混。参数配好后去Address Editor里分配地址。AXI DMA的S_AXI_LITE放在0x40400000之类的空闲区间其他自动就行。整个设计里没有自定义IP只靠FIFO回环就能验证DMA这是排查硬逻辑bug的最小集合。3.3 地址分配与bitstream导出地址确认没冲突之后综合、实现、生成bitstream然后Export Hardware勾选Include bitstream导出XSA。注意XSA主要是给Vitis/SDK用的PYNQ侧加载Overlay需要的是bit文件。最方便的做法是自己建一个overlay目录把bit放进去然后Python里这样加载from pynq import Overlay ol Overlay(/home/xilinx/pynq/overlays/dma_demo/dma_demo.bit)加载后可以用print(ol.ip_dict)确认axi_dma出现在硬件层次里。PYNQ的Overlay类会根据导出时的tcl信息自动构建驱动基地址这些不用你手动指定。这一点对新手很友好但前提是你的bit是用Vivado标准流程导出的。4. PYNQ侧代码实操两种调用方式和完整可跑示例4.1 方式一是pynq自带的DMA类库PYNQ封装好了pynq.lib.dma.DMA这个类它把MM2S和S2MM分别暴露成sendchannel和recvchannel你只需要给它一个缓冲区剩下的事它全包了。样例代码from pynq import Overlay, allocate import numpy as np ol Overlay(/home/xilinx/pynq/overlays/dma_demo/dma_demo.bit) dma ol.axi_dma_0 num 1024 in_buffer allocate(shape(num,), dtypenp.int32) out_buffer allocate(shape(num,), dtypenp.int32) in_buffer[:] np.arange(num, dtypenp.int32) dma.sendchannel.transfer(in_buffer) dma.recvchannel.transfer(out_buffer) dma.sendchannel.wait() dma.recvchannel.wait() print(in_buffer[:10]) print(out_buffer[:10]) print((in_buffer[:] out_buffer[:]).all())关键点有两个。第一缓冲区必须用allocate()分配不能用普通numpy数组。allocate()分配的是CMA连续物理内存只有物理连续DMA才能按线性地址搬运普通numpy数组虚拟内存连续物理页却可能分散DMA会直接被总线错误教育做人。第二transfer()只是把任务交给DMA就返回真正完成要等wait()。如果你的应用不关心完成时机也可以不等但绝不能传输还在进行时就修改甚至释放缓冲区那是典型的use-after-free。4.2 方式二是直接操作寄存器理解Simple DMA本质PYNQ的类库很香但想真正理解Simple DMA我强烈建议用mmio直写寄存器做一遍。这样你会对地址、长度、状态位有更直观的认识。以MM2S和S2MM回环为例MM2S_DMACR 0x00 MM2S_DMASR 0x04 MM2S_SA 0x18 MM2S_SA_MSB 0x1C MM2S_LENGTH 0x28 S2MM_DMACR 0x30 S2MM_DMASR 0x34 S2MM_DA 0x48 S2MM_DA_MSB 0x4C S2MM_LENGTH 0x58 RS_BIT 0x1 IDLE_BIT 0x2 blk 64 # 单位是字节 dma_mmio ol.axi_dma_0.mmio # 复位MM2S通道等待Halted位置1 dma_mmio.write(MM2S_DMACR, 0x4) while not (dma_mmio.read(MM2S_DMASR) 0x1): pass # 启动MM2SRS1之后再写地址和长度 dma_mmio.write(MM2S_DMACR, RS_BIT) # S2MM同样复位并启动 dma_mmio.write(S2MM_DMACR, 0x4) while not (dma_mmio.read(S2MM_DMASR) 0x1): pass dma_mmio.write(S2MM_DMACR, RS_BIT) # 先给S2MM配目的地址和长度 dma_mmio.write(S2MM_DA_MSB, 0) dma_mmio.write(S2MM_DA, out_buffer.device_address) dma_mmio.write(S2MM_LENGTH, blk) # 再给MM2S配源地址和长度 dma_mmio.write(MM2S_SA_MSB, 0) dma_mmio.write(MM2S_SA, in_buffer.device_address) dma_mmio.write(MM2S_LENGTH, blk) # 等两通道都回到Idle while not (dma_mmio.read(MM2S_DMASR) IDLE_BIT): pass while not (dma_mmio.read(S2MM_DMASR) IDLE_BIT): pass print(out_buffer[:16])这段代码里最值得注意的就是启动顺序复位之后先把RS位置1然后再写地址和长度最后写长度寄存器触发搬移。S2MM要先准备好MM2S后发数据。如果MM2S已经把数据发出去了S2MM还没就位FIFO虽然能缓冲一些但超出深度就会丢数据。回环场景下FIFO能兜底接外部实时IP时这个顺序就是生死线。out_buffer.device_address是PYNQ给缓冲区分配的内存物理地址直接作为DMA目标地址写入寄存器这是Simple DMA模式的核心——地址、长度、启停全部在寄存器层面解决。4.3 缓存一致性与缓冲区对齐说完两种方式必须补一个大坑DMA和CPU之间的缓存一致性。Zynq的ARM处理器有L1/L2 cacheDMA没有。CPU往in_buffer里写数据数据可能还躺在cache里没刷进DDRDMA去读DDR读到的是旧的反过来DMA往out_buffer写完了CPU去读cache读到的可能还是之前缓存的旧内容。PYNQ的allocate()默认分配的是非缓存一致性内存区类库的wait()返回时通常已经处理好了cache同步。但如果你像4.2那样直接写寄存器又用了cacheable的缓冲区就要手动处理。稳妥的做法是分配时把cacheable关掉in_buffer allocate(shape(num,), dtypenp.int32, cacheable0) out_buffer allocate(shape(num,), dtypenp.int32, cacheable0)PYNQ 2.x里cacheable默认是0所以你直接allocate就行但如果你是老版本系统或者写底层裸机代码一定要确认这个参数。缓冲区对齐方面AXI DMA对源地址、目的地址没有强制要求对齐到burst长度但建议至少4字节对齐stream数据宽度是64bit时建议8字节对齐否则某些总线组合下会报对齐错误。allocate默认返回cacheline对齐的地址通常不用额外处理。5. 实测数据与性能分析Simple DMA到底能跑多快5.1 测速方法和计时细节在Jupyter或者Python脚本里测速度最直接的方式是用time.perf_counter()import time repeat 100 blk_bytes 4 * 1024 start time.perf_counter() for _ in range(repeat): dma.sendchannel.transfer(in_buffer) dma.sendchannel.wait() elapsed time.perf_counter() - start throughput repeat * blk_bytes / elapsed print(f{throughput / 1e6:.2f} MB/s)有个工程细节单纯测MM2S吞吐时S2MM可以不开但单纯测S2MM就得有数据源源不断进来。最省事的回环测法是把MM2S和S2MM同时跑或者在PL里放一个简单的计数器生成连续数据来灌S2MM。我自己一般两种都测既验功能又看极限。另外提醒一句网上那些DMA测速软件在PC上很流行但在PYNQ上不用找现成工具Python里写个计时循环就是最好的测速手段还能量到软件层的真实开销。5.2 数据对比CPU copy、DMA单次、DMA连续我在这套100MHz、64bit的整体环境下实测过几组数据列在下面数量级是大概值不同板子和Vivado版本有差异方式搬运4KB耗时对应吞吐率Python MMIO逐字读4KB约50ms0.08 MB/sC程序里memcpy 4KB数微秒数百MB/sSimple DMA 单次4KB约10微秒约400MB/sSimple DMA 连续100次4KB约1ms约400MB/sCPU在C层用memcpy其实很高效这点必须承认但在Python里你根本拿不到C的效率——每写一行for循环中间全是解释器开销。DMA真正的优势在于搬运本身不占CPU当系统里同时有图像处理、网络传输等任务时DMA释放出来的CPU时间非常可观。5.3 影响吞吐率的真实瓶颈实测数据不能只看好看的数字。Simple DMA吞吐率的瓶颈主要来自四层AXI时钟和数据宽度。100MHz、64bit的理论上限是800MB/s这是物理天花板。想更快要么调高FCLK_CLK0要么加大数据位宽。突发长度和传输长度。Max Burst Size256比16更高效因为每次总线事务的地址握手开销被分摊到更多数据节拍上。如果传输长度太短比如小于256字节DMA优势发挥不出来。DDR控制器和HP口竞争。PS侧还有其他主设备在访问DDRCPU、以太网都会跟DMA抢带宽多个DMA通道同时跑会有明显衰减。Python侧派发和wait的软件开销。每次transfer()和wait()之间都有Python调用的毫秒级开销数据块越小软件固定开销占比越高大批量连续搬运的效率远高于碎小搬运。一句话总结Simple DMA单次搬运的启动、完成判断、状态轮询这些事务型开销是固定的你要做的就是让每次搬运尽量大、尽量连续把固定开销摊薄。6. 踩坑记录我踩过的三个DMA坑和完整排查过程6.1 现象一DMA卡住不完成一直超时症状是dma.sendchannel.transfer(in_buffer)之后wait()永远等不到返回程序挂死。第一次遇到这个问题我第一反应是代码写错了检查一遍逻辑没问题。后来把状态寄存器一个bit一个bit地读出来看才找到问题所在DMA通道的复位问题。AXI DMA IP上电后不是running状态DMACR的RS位是0。PYNQ的DMA类库初始化时会自动做复位和启动但如果你用寄存器直写又没做复位等待DMA会一直停在Halted状态此时写SA、写长度都不会触发搬运。排查步骤是读DMASR看Halted位和SGIncld位。Halted为1说明DMA没在跑先把DMACR的Reset位置1再清0等Halted恢复为0。确保RS位为1后再写长度触发。排查思路是先把DMACR、DMASR都读出来打印对照PG021的位定义逐个看不要凭猜测改代码。DMA这类硬件外设寄存器状态是最诚实的比任何printf都好用。6.2 现象二数据搬出来全是0或者花掉第二个坑是数据内容不对。回环测试里我把in_buffer填成等差数列搬回来之后out_buffer前几十个元素全是0后面数据又是乱序的。这个现象基本锁定在缓存一致性上。我当时用的是cacheable的缓冲区DMA写完后CPU去读cache里的旧数据读出来的自然不对。解决方案就是前面说的allocate(..., cacheable0)或者在做完DMA之后手动做cache invalidate。PYNQ类库的wait()返回时一般已经处理好了但寄存器直写时一定要记住DMA不看cache。还有个经验数据有一部分对、有一部分错多半是缓冲区物理地址不连续DMA跨页时出了问题这时候优先检查是不是用了普通numpy数组而不是allocate。6.3 现象三传超过某个长度就罢工第三个坑非常典型传小块数据正常一旦单次数据量超过某值DMA就报错或者不完成。原因在长度寄存器的位宽边界。AXI DMA的MM2S_LENGTH/S2MM_LENGTH是26位有效位最大能表示2^26-167108863字节也就是64MB减1字节。超过这个值高位进位被截断DMA要么搬一个错误的小长度要么直接报Slave Error。遇到这种场景解决办法是把大缓冲区拆成多个64MB以内的chunk多次Simple DMA传输或者直接上SG模式让硬件自动处理长描述符链。另外注意很多网上的老代码会把长度按字数填而AXI DMA的长度寄存器单位是字节。stream位宽64bit时一次burst处理8字节但length字段仍然按字节填不是按beat数量。我在论坛帮人排查过半天最后发现就是单位搞错了。6.4 别拿Simple DMA的习惯套别的模式最后提醒一点Simple DMA用顺了很容易形成写地址、写长度、启动、等idle的思维定势。这个思维在切换到Scatter Gather模式时会害死人。SG模式下DMA内部有一个描述符引擎CURDESC指向当前描述符TAILDESC控制环的尾巴传完一个描述符硬件自动取下一条。你跟它每次手动配一笔的Simple DMA交互方式完全不同。当时我从Simple DMA切到SG做多缓冲视频流整整花了一晚上才意识到罪魁祸首不是代码而是我还在用Simple DMA的思路去猜SG的运行机制。所以我的建议很直接先花一个下午把Simple DMA彻底吃透特别是寄存器层面理解每一笔传输的生命周期再去碰SG和Cyclic。基础牢固之后那些看起来高级的模式其实都是在这个底子上加了几层自动化。7. 顺带聊聊热搜里的那些DMA问题对PYNQ用户也是一面镜子7.1 中断接收还是DMA接收本质是延迟和吞吐的权衡经常有人问串口该用DMA还是中断、CAN总线一般中断接收还是DMA接收、STM32CubeMX怎么配置ADC多通道DMA采集。这些问题放到PYNQ的语境里核心是同一个矛盾数据到了是通知CPU来处理还是让硬件引擎自己搬到该去的地方搬完了再通知拿CAN举例如果每帧8字节都用中断收频率不高时没问题但到了几Mbit/s甚至更高中断风暴会把CPU时间吃得很厉害。此时用DMA接收让硬件把一帧帧数据搬到内存队列里CPU只在一个DMA完成中断里做批量处理这就是典型的中断处理尾部、DMA搬运主体。PYNQ上串口、GPIO、高速采集中断和DMA的关系也类似——用PL做高速采集时数据通路一定让DMA走中断只用来告诉CPU这一批到了。我在PYNQ上的实际选择是PL测出来的波形容数据全部由AXI DMA连续搬运到DDR环形缓冲区Python侧只用一个轻量轮询或者PS中断去检查DMA完成事件绝不让CPU插在数据通路中间。PL侧数据速率往往远高于Python事件循环的响应能力这点想明白之后很多架构决策就顺了。7.2 SPI、ADC多通道DMA配置的类比通道和描述符的概念还有人问SPI是需要两个DMA吗、GD32的ADC DMA数据为什么会紊乱。这些在PYNQ上能找到对应的理解框架。SPI收发各一个方向全双工时需要两个DMA通道很正常这就对应AXI DMA里的MM2S和S2MM两条独立通道——发和收从来是两条路径。ADC多通道DMA数据紊乱的问题本质是DMA引擎按固定长度把转换结果一块块搬进缓冲区但多通道数据在缓冲区里的排列规则不匹配结果就是通道0的数据跑到了通道1的位置上。映射到AXI DMA场景就是上游产生了什么格式的流S2MM目的缓冲区的布局就得严格对应否则数据搬对了但解错了。这类问题反复出现在搜索词里说明确实是新人最容易掉进去的坑。理解DMA的本质是按地址和长度做字节搬移它不关心搬的内容是什么语义——语义解析是你自己的事。这一条放之四海皆准无论STM32还是PYNQ。7.3 关于DMA连续请求和continuous requests的一点联想热词里还有dma continuous requests和dma proxy这类说法。放到AXI DMA语境里对应的是连续搬运设计和DMA对总线占有策略的思考。AXI DMA在搬运时会不断发起burstburst与burst之间总线仲裁允许其他设备插入请求这是AXI协议的设计目标。真正需要独占通路时要么用关键周期锁定要么从DMA通道优先级入手而不是在Simple DMA层面硬做流控。Simple DMA的连续搬运靠CPU在每笔传输完成后立刻配置下一笔中间必然有软件间隙。如果你需要无间隙的连续数据流就要认真考虑Cyclic模式了。这些思路听起来散但它们是同一个能力的不同侧面——你越清楚DMA这个硬件引擎能做什么、不能做什么就越能在不同平台之间做迁移。PYNQ上的AXI DMA只是DMA家族的一员成员长得不一样内核逻辑是一套。我在PYNQ上把Simple DMA这套流程跑通之后再看MCU上的DMA问题很多以前觉得玄的东西一下子就通了。
返回列表