ARTICLE DETAIL

资讯详情

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

PYNQ自定义Overlay开发指南:从Vivado到Python驱动

PYNQ自定义Overlay开发指南:从Vivado到Python驱动 搞PYNQ的人早晚会走到定制overlay这一步。官方提供的那几个overlay比如base、pynq-z2的基础镜像跑跑例程、点个灯、调调摄像头demo没问题但一旦你要接自己的传感器、跑自己的算法、控制自己的外设就必须把整套硬件设计和Python封装这条路走一遍。整个过程本身不复杂但中间埋了不少坑尤其是第一次做的时候光overlay加载失败和bittream报错就能折腾一晚上。这篇把我自己做定制overlay的经验完整过一遍从硬件设计、文件导出、加载机制到Python驱动按实际动手顺序写你照着走基本能一遍过。1. 为什么非要自己定制overlay官方包里没有的硬件能力1.1 overlay到底是个什么概念用一句话概括overlay是PYNQ对FPGA可编程逻辑部分PL的封装一个overlay bitstream hwh文件 Python包/驱动代码。bitstream是真正烧进FPGA的二进制配置流决定PL上跑了哪些硬件逻辑hwh文件是Vivado生成的硬件描述文件用XML格式记录了这个bitstream里所有IP的实例、参数、寄存器地址、总线连接关系Python包则是你操作这些硬件的入口。PYNQ最核心的机制就是运行时把bitstream配置到PL上然后解析hwh文件把硬件IP暴露成Python对象。比如官方base overlay里你写overlay.leds背后就是PYNQ在解析hwh后找到了名为leds的GPIO IP实例然后把它映射成可以write的Python属性。这意味着你的PL上有什么IP想要怎么控制完全取决于bitstream和hwh里怎么设计。而定制overlay的本质就是亲手决定PL上跑什么逻辑、用什么总线、分配什么地址、暴露成什么样的Python接口。1.2 什么场景下必须走定制这条路我总结下来无外乎以下三类需求。第一类是接非标准的自定义硬件。基板上要挂一个官方板卡没有的I2C芯片、SDI视频输入、自定义协议传感器这些硬件接口在基础overlay的PL设计里根本不存在你只能用Verilog/VHDL写个IP核或者用Vivado IP Integrator把现成IP拖进来然后定制一个包含这些外设的overlay。第二类是算法加速。FPGA上做卷积、FFT、图像处理这类运算你要在PL上布置DSP硬核、BRAM缓存、并行流水线。这些加速器IP需要被PS端ARM处理器调用也需要在overlay里注册好地址和驱动。第三类是性能优化和功耗控制。官方base overlay为了通用性做了很多妥协接口多、资源占用大、时钟域复杂。你在具体项目里只需要某几个外设完全可以在自定义overlay里砍掉多余逻辑把资源和功耗省出来。说实话我第一次定制时以为很难实际走通以后发现真正花时间的不是写代码而是理解PYNQ的文件约定和加载机制。下面按步骤讲。2. 硬件端设计Vivado里的block design决定overlay上限2.1 创建项目与PS端基础配置定制overlay的第一步是在Vivado里创建硬件项目。目标板卡是什么型号就选什么board part比如Pynq-Z2就选tul.com.tw:pynq-z2:part0Ultra96就选对应的board part选错板子文件后续很多东西都对不上。创建完工程后建议用IP Integrator创建一个Block Design先添加Zynq PS或MPSoC。这里有个关键点PS端配置里的UART和DDR必须和你要跑的PYNQ镜像匹配。PYNQ官方镜像里无论是Pynq-Z1/Z2还是Ultra96的PS端启动用的都是固定的DDR配置和UART配置。如果你在Vivado里随手把DDR型号改了或者UART波特率改了烧进去后大概率起不来。最稳妥的办法是对照PYNQ官方项目里对应板卡的block design把PS配置一项一项抄下来。开启PS后PL端是空的要触发外部接口自动连接。比如把UART、I2C、GPIO这些PS侧的MIO接口使能然后点击Block Design里的Create Ports定义好系统复位、时钟输入、PL侧中断这些端口。这一步看似琐碎但直接决定后续你挂在PL上的IP有没有正确的时钟和复位依赖。2.2 添加并验证自定义IP这是定制overlay的核心环节。分两种情况一种是你有现成的Vivado IP库里的IP比如AXI GPIO、AXI UART16550、AXI DMA、VDMA、FIFO Generator这类直接在IP Catalog里搜索添加就行另一种是写了自己的RTL IP比如完成了某个算法模块、某个协议控制器需要打包成AXI接口的IP接入系统。如果是第二种强烈建议用Vivado自带的Create and Package New IP向导。要点是IP核接口尽量选择AXI-Lite作为控制/状态寄存器接口。AXI-Lite协议简单、地址访问方便PYNQ的MMIO操作天然适配它。如果PL上有大数据流比如DMA读了BRAM里的数据再搬运就再用AXI-Stream或AXI4-Full接口PYNQ库里的DMA封装直接支持这两种。硬件的连接顺序是固定的PS的AXI接口 - AXI Interconnect总线互联/转接 - 挂载的各IP和BRAM。总线时钟一定要选对然后给每个IP分配地址段Address Editor里分配。我自己的习惯是手动给每个IP分配固定地址比如0x80000000开始给4K、下个4K给另一个IP这样做有两个好处一是后续写Python驱动时寄存器地址是确定的不用再从hwh里解析二是多个overlay版本间保持地址不变Python驱动代码可以复用。全部连接完毕后一定要跑Validate Block DesignVivado会检查协议连接、时钟/复位完整性、地址分配是否冲突等。这个验证通过不了生成bitstream时大概率也是失败而且报错信息很难定位。所以这个步骤多花点时间仔细过比后面排错省力得多。2.3 bitstream和hwh文件的导出硬件设计验证通过后就可以走综合和实现生成bitstream。这个阶段在Vivado里点Generate Bitstream即可耗时取决于PL资源占用和电脑性能。bitstream生成后关键来了你要把硬件信息导出来给PYNQ用。PYNQ加载overlay时需要的文件有三个文件用途说明.bitFPGA配置比特流PL上跑什么逻辑就看它.hwh硬件描述XML记录IP实例、寄存器地址、连接关系.tcl可选但推荐block design重建脚本用于复用硬件工程或调试回环导出hwh的方法是在Vivado的Tcl Console里执行write_hw_platform -fixed -include_bit -force -file output_dir/design_1_wrapper.hwh这个命令会把当前block design的硬件描述写入一个.hwh文件PYNQ全靠它来构建IP对象。如果你用SDK/Vitis方式开发也可以在Export Hardware时勾选Include bitstream它会生成一个.xsa文件里面包含了bitstream和hwh。但PYNQ不直接吃.xsa你需要把.xsa解压把里面*.bit和*.hwh取出来用。还有一个经常被忽略的点PYNQ对文件名有约定。比如你的bitstream叫my_overlay.bit那么hwh文件必须命名为my_overlay.hwh放在同一个目录下这样PYNQ在加载时才能自动找到。如果不满足命名一致就只能手动传hwh参数容易踩坑。3. 加载机制的细节bitstream烧进去之后PYNQ做了什么3.1 pynq.Overlay加载过程的四个阶段在我定制完第一个overlay、第一次在Jupyter里运行overlay pynq.Overlay(my_overlay.bit)时才发现理解加载机制有多重要。这个调用背后实际经历了四个阶段第一阶段是解析bitstream并烧写PL。PYNQ通过/dev/xdevcfg设备节点Zynq或FPGA管理器MPSoC/Zynq UltraScale把bitstream写入PL配置逻辑。这个过程如果bitstream文件错误、PL供电异常或XADC温度过高都会在这里报错。第二阶段是解析hwh文件。PYNQ把hwh作为一个XML树解析提取出所有IP实例、IP的寄存器段memory map、中断连接关系、时钟频率等信息。这一步如果hwh文件与bitstream不是同一个工程导出的解析结果就会和实际硬件不一致。第三阶段是构建IP对象树。基于hwh的解析结果PYNQ会把block design里的层次结构映射成Python属性。比如你在hwh里有一个名为rgb_led的AXI GPIO实例那么overlay.rgb_led就可以访问它。这个映射关系是通过_ip_dict和HierarchyType实现的。第四阶段是初始化驱动。如果bitstream与hwh配套的Python包里有对应的driver类PYNQ会用hwh传来的参数实例化驱动。这一步就是我们后边要写的Python驱动代码的入口。3.2 地址空间和MMIO寄存器操作的本质PYNQ里操作定制IP最通用的方式是MMIO。MMIO的本质是把PS端的物理地址映射到用户空间Linux下通过用户态内存映射就可以访问硬件寄存器。PYNQ的pynq.reg_control和pynq.MMIO模块底层做的就是mmap系统调用。举个具体例子。假如你的定制IP里有三个寄存器控制寄存器偏移0x00、数据寄存器偏移0x04、状态寄存器偏移0x08。在Python里操作它from pynq import Overlay overlay Overlay(my_overlay.bit) ip overlay.my_custom_ip mmio ip.mmio # 写控制寄存器使能模块 mmio.write(0x00, 0x1) # 写数据寄存器 mmio.write(0x04, 0xDEADBEEF) # 读状态寄存器 status mmio.read(0x08)注意一个坑MMIO的read/write都以32位字为单位访问偏移量必须是4的倍数。如果你的AXI-Lite寄存器宽度是32位绝大多数是这个没问题但如果你用8位或16位寄存器就需要注意对齐了。3.3 从hwh中动态获取地址而不是硬编码这是定制overlay里非常有价值的一条经验Python驱动里不要硬编码IP的基地址而是直接通过overlay对象动态获取。ip overlay.my_custom_ip base_addr ip.mmio.base_addr ip_config ip.ip_dict[parameters]这样做的好处是当你的overlay在设计阶段修改了地址分配Python代码不需要跟着改只要保证IP名字不变即可。而且PYNQ解析hwh后IP实例自带mmio、interrupts、parameters这些属性这比一个人对着数据手册查基地址靠谱得多。4. 给自定义overlay写Python驱动别只会read和write4.1 驱动类的注册机制pynq.Overlay有一个很实用的默认行为如果Python环境里某个模块定义了一个类它的名字与hwh中的IP类型同名PYNQ在解析hwh时会自动用这个类来实例化对应的IP。这个机制叫default drivers你可以让定制的IP核在Python侧拥有封装的类而不是只暴露一个裸的mmio对象。我目前的习惯是不管项目大小都会给定制IP写一个驱动类。把寄存器操作封装成语义更明确的函数比如from pynq import DefaultIP class MyCustomIP(DefaultIP): def __init__(self, description): super().__init__(descriptiondescription) def enable(self): self.write(0x00, 0x1) def set_data(self, value): self.write(0x04, value) property def is_done(self): return bool(self.read(0x08) 0x1)然后在加载overlay之前注册这个驱动或在代码里动态绑定overlay Overlay(my_overlay.bit) overlay.my_custom_ip.add_driver(MyCustomIP) # 或者直接在驱动类名前加注册一个特别实用的小技巧驱动类的名字要和IP核的VLNVvendor/library/name/version或IP实例名匹配PYNQ的驱动匹配逻辑才会触发。如果派不上用场就在__init__里手动add_driver别死磕自动匹配。4.2 用DMA搬数据一次完整的连续读写如果你定制overlay里用了AXI DMA或者VDMA那Python驱动的写法会稍有不同。PYNQ提供了一个pynq.allocate方法用于分配连续物理内存这样DMA才能直接访问。from pynq import allocate from pynq import Overlay overlay Overlay(my_overlay.bit) dma overlay.axi_dma_0 input_buffer allocate(shape(4096,), dtypeu4, cacheable1) output_buffer allocate(shape(4096,), dtypeu4, cacheable0) # 填满输入数据 for i in range(4096): input_buffer[i] i # 启动DMA传输 dma.sendchannel.transfer(input_buffer) dma.recvchannel.transfer(output_buffer) dma.sendchannel.wait() dma.recvchannel.wait() print(output_buffer[0], output_buffer[4095])为什么用allocate而不是普通Python数组因为DMA访问的是物理地址而普通Python数组由虚拟内存管理物理地址可能不连续。allocate分配的内存在物理上是连续的才能被DMA引擎正确寻址。这点如果在定制overlay时踩过就特别好理解为什么官方库都是这个写法。4.3 中断处理让PL主动通知PS很多时候你的PL逻辑不满足于被PS轮询比如数据准备好了需要通知PS处理这时就要用中断。在Vivado block design里PL侧IP的中断输出要接到一个xlconcat中断拼接核上将多路中断拼成一路然后连到PS的中断控制器输入。在PYNQ驱动里访问中断可以用pynq.interrupt模块也可以直接在驱动里注册中断处理函数。from pynq import DefaultIP from pynq.interrupt import Interrupt class MyInterruptIP(DefaultIP): def __init__(self, description): super().__init__(description) self._interrupt Interrupt(description[interrupts][done]) def register_handler(self, handler): self._interrupt.register_handler(handler) self._interrupt.enable() def wait(self, timeout10): return self._interrupt.wait(timeout)但这里有个大坑PYNQ镜像里的Linux内核不一定给所有PL中断预分配了IRQ。很多PYNQ定制镜像里PL中断到PS的映射需要设备树或Xilinx的软核驱动支持。如果你发现中断触发不了先别急着找驱动bug用cat /proc/interrupts看看有没有对应的IRQ号。如果没有大概率是镜像的设备树没把PL中断相关节点配好。这时你有两个选择一是换用PYNQ官方内核版本它通常带完整的Xilinx中断支持二是用轮询模式先顶上。5. 排查过程实录warning l16. uncalled segment 与一堆我以为的坑5.1 l16警告到底是什么、为什么它最容易吓到第一次定制的人在定制包含MicroBlaze软核处理器、或者用SDK/Vitis编译驱动固件时经常会在编译日志里看到这样一条信息*** warning l16: uncalled segment, ignored for overlay process我第一次看到 L16 uncalled segment 时以为是overlay加载出了问题实际上这是MicroBlaze编译工具链或者老的SDK中的编译器的链接器警告。它的含义是链接器在链接可执行文件时发现代码里的某个段segment没有被任何其他段调用——通常是没有被引用——所以这个段在最终的ELF/链接结果中没有被放到overlay区域MicroBlaze把可执行代码段也叫做overlay。这个警告发生的原因和应用本身没有关系。如果你的C代码里写了一个函数但没人调用它链接器就会把它当作uncalled segment不把它纳入最终的覆盖加载区于是给你一条警告。什么情况下这个警告需要紧张一般就两种情况一种是这个未被调用的段恰好包含了你期望被运行的初始化代码或中断服务例程另一种是你用了SECTION指令想把某个函数放到特定内存区域但链接器因为不可达而丢弃了它。除此之外纯属噪声。排查时我建议的顺序是确认这个警告来自哪里。看编译日志上下文是MicroBlaze编译器还是ARM编译器还是Vivado的HLS不同工具链里这个警告语义有差异。如果不是MicroBlaze/软核处理器工程而是PYNQ的PL bitstream构建过程这个warning基本可以直接忽略。因为PL端的overlay构建不涉及C代码链接只有嵌入式软件编译里才出现这个提示。如果确实在软核工程里遇到检查是否有未实现的weak symbol或者某个段确实没被真正引用。最简单的方式是在链接脚本里显式保留它或者给对应的函数加上__attribute__((used))属性。5.2 bitstream加载时的经典错误场景与处理如果真的走到overlay加载阶段报错最常见的错误有以下几种RuntimeError: Cannot find bitstream最简单的原因文件名路径不对或者当前目录和文件不在同一个地方。PYNQ里overlay查找文件是相对于当前工作目录的Jupyter里要注意当前目录是哪个。RuntimeError: ... not enough spacePL配置时发现bitstream太大或者FPGA总容量不够定制时资源用太满。解决是削减PL逻辑或提高综合优化策略。RuntimeError: Hardware Manager error有时候板子之前加载过其他的bitstream重新加载时会因部分可重配置分区PR冲突而报错。先用没有锁定的方式重新烧录或执行一次完整的复位后重试。5.3 让调试快速有效的几个习惯定制overlay调试最有效的不是看命令行输出而是用Vivado自带的Hardware Manager做实时监测。把bitstream下载到FPGA后可以读取所有信号值、触发ILA集成逻辑分析仪一步到位定位PL逻辑问题。我记得有次定制DMA外设代码逻辑看着天衣无缝但Python里read回来的全是零。后来用ILA一抓发现DMA的AXI AW通道信号压根没发出来——原因是block design里DMA的S_AXI_LITE控制端口和P_AXI主端口时钟域没有正确连接导致控制通路正常、数据通路完全不工作。这种问题不通过硬件调试工具光看Python层基本发现不了。调试时期还建议用tcl脚本固化整个构建流程而不是每次都在GUI里点。一个demo级别的最小化tcl类似这样create_project my_overlay_prj ./my_overlay_prj -part xc7z020clg400-1 add_files -norecurse ./rtl/my_custom_ip.v create_bd_design design_1 # ... 添加IP、连接端口、分配地址 validate_bd_design generate_target all [get_files design_1.bd] launch_runs impl_1 -to_step write_bitstream -jobs 8脚本化的好处是后续每次改IP或改地址重新跑一遍全流程既省得点鼠标也不会漏步骤。6. 让你的overlay能被别人装上就用打包与分发的工程化6.1 推荐的overlay文件目录结构如果你要把定制overlay分享给团队或社区用建议按下面的目录结构组织my_overlay/ ├── bitstream/ │ ├── my_overlay.bit │ └── my_overlay.hwh ├── python/ │ ├── __init__.py │ ├── my_overlay_driver.py │ └── my_overlay.py ├── scripts/ │ ├── build.tcl │ └── export_hwh.tcl └── README.md这样别人拿到后把bitstream目录里两个文件拷贝到目标板或者修改Python包里加载路径就能直接用完全不需要装Vivado。这也是PYNQ社区的主流分发模式。6.2 README里必须写清楚的四个信息别小看README它决定了别人能不能顺利跑起来。我自己每次给别人传overlay都会在README里写明四件事第一硬件平台和PYNQ镜像版本。Pynq-Z1的overlay不能直接拿到Ultra96上用PYNQ 2.5和3.0接口也有差异。这一条不写清楚别人踩坑的第一个点就出现了。第二overlay依赖的PS配置。比如DDR型号、UART配置、MIO分配。如果默认镜像和你的配置有出入要写清楚怎么改。第三Python驱动API说明。每个方法的作用、参数、返回值最好有一个 Usage Example 的Ipython notebook示例。第四已知的限制问题。比如某个IP还不支持中断某些寄存器必须按特定顺序初始化这个功能在某个时钟频率下不可用等等。这些信息不全等于只给了硬件没给说明书。6.3 也聊聊版本管理的那点事儿overlay的bitstream是二进制文件Git仓库里直接提交会让仓库越来越大而且很难看diff。推荐的做法是把构建流程tcl脚本、RTL源码、约束文件放Git里bitstream和hwh用Git LFS或单独的Release页面发布Python驱动和示例notebook正常版本管理。这样每次更改都有迹可查别人也能基于你tcl脚本自己复现出hardware design而不是对着一个不可读的二进制文件干瞪眼。另外驱动代码里尽量带上版本号比如在__init__.py里声明__version__ 1.0.0在Overlay对象加载后打印驱动版本。这点小细节在别人问你为什么你的overlay行为不对的时候能帮你快速定位是不是版本不匹配。7. 真实项目中的一次完整定制记录从需求到运行为了让你对整个过程有更直观的把握我把一次做数据采集板卡overlay的经历放在最后复盘。需求不复杂给一块Zynq板卡做8通道模拟采集采样率1MSPS采集数据先存放在PL侧BRAM然后由PS端DMA把数据搬走处理。难点在于8通道ADC是板级外设官方overlay没有现成控制器只能自己写RTL。硬件侧我写了一个AXI-Lite接口的ADC控制器IP逻辑很简单配置寄存器0x00控制采样启动/停止状态寄存器0x04报告FIFO是否满数据寄存器0x08读出当前采样结果。控制器直接连到AXI Interconnect的一个从机端口地址分配为0x80000000到0x80001FFF。然后为了保证BRAM缓存能和DMA协同我加了AXI BRAM Controller这个IP给它分配0x80002000开始的地址把DMA的读通道接到BRAM Controller的Slave接口上这样DMA能从BRAM直接把数据搬走PS端不用逐字读寄存器。软件侧驱动类里做了一个采集函数流程是分配连续物理buffer - 把buffer物理地址写入DMA目的地址寄存器 - 通过ADC控制器启动采样 - DMA传输完成后触发中断 - Python里把buffer转为numpy数组。整条流水线稳定后实测1MSPS下连续采集没有任何数据丢失CPU占用极低。这个项目最花时间的地方反而是中断处理。原本想省事用轮询但1MSPS采样率下轮询浪费的CPU太可观最后还是老老实实接中断。中途就在设备树上卡了很久后来换到PYNQ社区维护的新内核PL中断节点才自动配好IRQ也能正常触发。如果想快速验证一组简单逻辑建议先不用DMA直接从寄存器里按时序读数据用ip.mmio.read循环读够N个点后面再升级成DMA方案。小步快跑比一上来就直接上大而全的方案容易排查问题。定制overlay这件事没有什么真正的深奥算法它考验的是对软硬接口边界的理解。硬件上搞定地址、时钟、总线软件上搞定封装、驱动再把调试工具用熟剩下的就是时间和耐心。我第一次走过整套流程大概花了两天中间趟过的坑基本都是文件命名、配置遗漏、中断映射这些烦人小事。希望这篇能帮你把这些坑提前避开让你把精力花在真正的IP逻辑设计上。
返回列表