☰
Vivado实战:从零封装AXI4-Lite自定义IP并集成到Zynq Block Design
2026/10/7 13:02:18 网站建设 项目流程

1. 为什么值得自己动手封装一个AXI自定义IP

如果你手上有一块Zynq或者纯FPGA的开发板,跑过几个官方例程之后,迟早会碰到一个绕不开的坎:官方IP核不够用,或者用起来太别扭。比如你想让PL端做一个简单的PWM输出、一个自定义的脉冲计数器、或者一个特定时序的传感器接口,用现成的AXI GPIO或者AXI Timer要么资源浪费,要么逻辑对不上。这时候最干脆的办法,就是自己封装一个带AXI总线接口的IP核,挂到Zynq的PS端总线上,像操作寄存器一样去读写它。

我刚开始接触Vivado那会儿,也觉得“创建自定义IP”这个事听起来很唬人,好像要懂AMBA协议、要手写总线握手逻辑。实际上Xilinx(现在叫AMD)早就把这件事工具化了,Vivado里有一个完整的IP封装向导,你只需要把精力放在自己的核心逻辑上,总线部分它帮你生成模板。真正需要你理解的,是AXI4-Lite的寄存器映射关系,以及IP打包时那几个关键配置项到底在干什么。

这篇内容适合三类人看:第一类是刚学完Vivado基础操作、想往Zynq软硬协同方向走的朋友;第二类是已经在用Zynq做项目,但一直靠官方IP拼凑、想自己封装模块的工程师;第三类是做FPGA逻辑验证或者接口开发,需要快速把自定义逻辑挂到处理器总线上的从业者。我会从工程创建开始,一步步走到IP被Block Design正常调用、SDK/Vitis里能读写寄存器为止,中间踩过的坑和参数选择的理由都会讲清楚。

需要提前说明的是,我用的版本是Vivado 2022.2,不同版本在菜单名称和界面布局上会有细微差别,但核心流程是一致的。2024年之后的新版本在IP打包向导里增加了一些自动化选项,但底层逻辑没变,你按这个思路走不会跑偏。

2. 动手之前的整体思路与方案选型

2.1 为什么选AXI4-Lite而不是AXI4-Full

AXI总线有好几种变体,最常见的是AXI4-Full、AXI4-Lite和AXI4-Stream。很多人一上来会纠结选哪个,我的建议很直接:如果你的自定义IP只需要CPU读写几个控制寄存器、状态寄存器,数据量不大、不需要突发传输,那就选AXI4-Lite。它的协议最简单,地址对齐到32位,每次传输一个数据,握手信号少,调试起来一目了然。

AXI4-Full适合做DMA那种大批量数据搬运的场景,比如你要把ADC采样的一整块数据搬到DDR里,那用Full才能发挥突发传输的带宽优势。但Full的握手逻辑复杂得多,自己手写容易出问题,通常配合Xilinx的DMA IP来用。AXI4-Stream则是纯数据流接口,没有地址概念,适合视频流水线、数字信号处理链路这种场景。

我这次要封装的是一个“可配置脉冲发生器”IP,功能很简单:PS端写频率控制字和使能位,PL端输出对应频率的方波。这种场景用AXI4-Lite绰绰有余,寄存器数量不超过8个,地址空间映射清晰,Vitis里用Xil_Out32和Xil_In32就能操作。

2.2 接口封装方式的选择:IP Packager还是RTL Module

Vivado里把自定义逻辑挂到Block Design上有两种路径。一种是把RTL代码直接Add Module到Block Design里,然后手动连线;另一种是用IP Packager封装成标准IP,之后可以像官方IP一样在IP Catalog里调用、配置、例化。

两种方式各有适用场景。如果你只是临时验证一个逻辑,项目里用一次就扔,那直接Add Module更快。但如果你这个模块会在多个项目里复用,或者需要团队其他人也能方便地调用,那封装成IP是更规范的做法。封装之后,IP有独立的版本号、有GUI配置界面、有文档,别人用的时候不需要看你的RTL源码,只需要知道寄存器映射就行。

我个人的习惯是:任何会用到第二次的逻辑,都封装成IP。前期多花二十分钟打包,后期省下的是反复翻代码、对接口的时间。而且IP Packager生成的AXI接口模板是经过验证的,比自己手写总线逻辑可靠得多。

2.3 寄存器映射的前期规划

在动手创建IP之前,有一件事必须先想清楚:你的IP需要几个寄存器,每个寄存器的地址偏移是多少,读写属性是什么。这个规划没做好,后面改起来很麻烦,因为一旦IP被例化到Block Design里,地址分配就固定了,改寄存器映射意味着要重新生成IP、重新综合。

我的做法是拿一张纸或者一个文本文件,先把寄存器表列出来。以脉冲发生器为例:

偏移地址寄存器名读写属性功能说明
0x00CTRLR/Wbit0为使能位,bit1为复位位
0x04FREQ_DIVR/W分频系数,决定输出频率
0x08STATUSRbit0为运行状态,bit1为溢出标志
0x0CDUTYR/W占空比控制字

这张表定下来之后,IP Packager里的AXI接口配置就按这个来。注意一点:AXI4-Lite的寄存器必须是32位对齐的,地址偏移以4为步进。你不能定义一个16位的寄存器放在偏移0x02的位置,那样总线访问会出问题。

3. 从零创建自定义IP的完整实操

3.1 工程创建与IP Packager入口

打开Vivado,先创建一个RTL工程,器件选你实际使用的型号,比如xc7z020clg400-2。工程创建好之后,在Flow Navigator里找到Tools菜单,点开之后选择Create and Package New IP。这时候会弹出一个向导,有几个选项:

  • Package your current project:把当前工程直接打包成IP
  • Package a specified directory:打包指定目录下的文件
  • Create a new AXI4 peripheral:创建一个新的AXI外设

我们选第三个,Create a new AXI4 peripheral。这个选项会自动生成AXI接口的模板代码,包括总线握手逻辑、寄存器读写逻辑,你只需要在用户逻辑区域填充自己的功能。

点Next之后,给IP起个名字,比如pulse_gen_v1_0。IP存放路径建议放在工程目录下的ip_repo文件夹里,这样工程迁移的时候IP跟着走,不会丢。版本号先写1.0,后面如果改了寄存器映射,版本号要往上加,避免新旧版本冲突。

3.2 AXI接口参数配置的关键决策

接下来是AXI接口的配置页面,这里有几个参数需要认真选:

Interface Type选AXI4-Lite,理由前面说过了。Interface Mode选Slave,因为我们的IP是挂在PS端总线上的从设备,PS是Master。Data Width选32位,这是AXI4-Lite的标准位宽,也是Zynq PS端GP接口的默认位宽。Memory Size这个参数容易被忽略,它表示IP占用的地址空间大小,单位是字节。如果你有4个寄存器,那就是16字节,但通常会给一个更大的范围,比如4K字节,方便后续扩展。我一般填4K,不会浪费实际资源,只是地址译码范围。

Number of Registers填4,对应我们规划的那四个寄存器。这个数字会影响生成的模板代码里寄存器的数量,填少了后面要手动加,填多了会有多余的寄存器浪费逻辑资源。

配置完成之后点Next,向导会问你要不要编辑IP,选Edit IP,它会自动打开一个临时工程,里面就是生成的IP源码。

3.3 理解生成的AXI模板代码结构

IP Packager生成的代码主要有两个文件:一个是顶层的pulse_gen_v1_0.v,里面例化了AXI接口模块和用户逻辑模块;另一个是pulse_gen_v1_0_S00_AXI.v,这是AXI4-Lite的从设备接口逻辑,也是我们主要修改的地方。

打开S00_AXI这个文件,你会看到一大段代码,但核心结构很清晰。前半部分是AXI握手信号的声明和赋值,包括awready、wready、arready、rvalid这些。中间是写地址通道、写数据通道、写响应通道、读地址通道、读数据通道的状态机逻辑。最后是寄存器读写部分,也就是我们需要改的地方。

模板里默认生成了4个寄存器,名字叫slv_reg0到slv_reg3。写操作的时候,根据axi_awaddr的低位判断写哪个寄存器;读操作的时候,根据axi_araddr的低位把对应寄存器的值送到rdata上。这部分逻辑是标准化的,一般不需要动,除非你要做地址对齐之外的地址译码。

真正要改的是用户逻辑部分。在顶层文件里,你会看到一个叫user_logic的模块例化,或者直接在顶层里有一段注释写着“Add user logic here”。我的做法是在S00_AXI模块里增加输出端口,把slv_reg0和slv_reg1引出来,然后在顶层模块里例化我的脉冲发生器逻辑。

3.4 添加自定义逻辑与端口

具体操作是这样的:在S00_AXI模块的端口列表里增加几个output,比如o_pulse_out、o_status。然后在模块内部,把slv_reg0的bit0连接到o_pulse_out的使能,slv_reg1的值作为分频系数送出去。状态信号o_status则通过组合逻辑或者时序逻辑反馈回slv_reg2的输入。

这里有一个细节要注意:slv_reg是时序逻辑生成的寄存器,它们的值在时钟上升沿更新。如果你直接把slv_reg1当作分频系数送到用户逻辑,那分频系数的更新会晚一个时钟周期,这在大多数场景下没问题,但如果你的逻辑对时序敏感,就要考虑用组合逻辑旁路或者加一级同步。

端口添加完之后,回到顶层模块,把S00_AXI的例化里对应的端口连出来,再例化你自己的脉冲发生器模块。脉冲发生器的逻辑很简单:一个计数器,计数到FREQ_DIV的一半时翻转输出,就能得到50%占空比的方波。如果要调占空比,就用两个比较值分别控制高电平和低电平的翻转点。

3.5 IP打包时的文件管理与版本控制

逻辑改完、仿真验证过之后,就可以打包了。在Package IP的界面里,有几个标签页需要检查:

Identification:确认IP名字、版本号、描述信息。描述信息建议写清楚寄存器的功能,方便别人调用时查看。Compatibility:确认支持的器件系列,一般默认就行。File Groups:检查所有源文件是否都包含进来了,特别是如果你引用了其他目录下的文件,要手动添加。Customization Parameters:如果你想让IP在例化时可以配置一些参数,比如默认分频系数,可以在这里定义User Parameters。

全部检查完之后,点Review and Package,再点Package IP。Vivado会重新打包IP,生成一个压缩包和对应的IP描述文件。打包完成后,在IP Catalog里就能搜到你的IP了。

注意:每次修改IP源码之后,都要重新打包,并且版本号要递增。如果版本号不变,Vivado可能会缓存旧的IP,导致Block Design里例化的还是旧逻辑。我踩过这个坑,改了代码综合出来结果不对,查了半天才发现是IP没更新。

4. 在Block Design中集成与Vitis端验证

4.1 在Block Design中例化自定义IP

新建一个Block Design,添加Zynq Processing System,然后Run Block Automation,把DDR和时钟配置好。接着在IP Catalog里找到你刚才打包的IP,双击添加到Block Design里。Vivado会自动识别AXI接口,点Run Connection Automation,它会自动把IP的AXI从接口连接到Zynq的M_AXI_GP0上,同时连接时钟和复位。

连接完成之后,打开Address Editor,确认你的IP被分配了地址。默认情况下,Vivado会给每个AXI从设备分配一段地址空间,比如0x43C00000到0x43C00FFF。这个基地址后面在Vitis里要用到,记下来。

然后Validate Design,如果没有报错,就可以Generate Output Products,再Create HDL Wrapper,最后Generate Bitstream。综合实现的过程可能会比较慢,取决于你的逻辑复杂度和器件规模。

4.2 Vitis端寄存器读写测试

比特流生成之后,Export Hardware,包括比特流文件。打开Vitis,新建一个Platform Project,导入刚才的XSA文件。然后新建一个Application Project,选Hello World模板就行。

在main函数里,我们要做的是通过Xil_Out32和Xil_In32读写寄存器。假设基地址是0x43C00000,那么:

#include "xil_io.h" #include "xparameters.h" #define PULSE_GEN_BASE 0x43C00000 #define CTRL_REG 0x00 #define FREQ_REG 0x04 #define STATUS_REG 0x08 int main(void) { // 设置分频系数为1000 Xil_Out32(PULSE_GEN_BASE + FREQ_REG, 1000); // 使能输出 Xil_Out32(PULSE_GEN_BASE + CTRL_REG, 0x01); // 读取状态 u32 status = Xil_In32(PULSE_GEN_BASE + STATUS_REG); // 打印状态 xil_printf("Status: %x\r\n", status); return 0; }

下载比特流、运行程序,用示波器或者逻辑分析仪接在PL端的输出引脚上,应该能看到对应频率的方波。如果频率不对,检查分频系数的计算:输出频率 = 输入时钟频率 / (2 * FREQ_DIV)。比如输入时钟100MHz,FREQ_DIV设为1000,输出就是50kHz。

4.3 常见连接错误与地址映射排查

第一次做的时候,最容易出的问题是地址映射对不上。Vitis里用的基地址必须和Address Editor里分配的一致,不能想当然。另外,Xil_Out32的偏移地址是字节偏移,不是寄存器编号。比如第二个寄存器是偏移0x04,不是0x01。

还有一个坑是时钟域。Zynq的GP接口时钟通常是FCLK_CLK0,默认100MHz或者50MHz。如果你的IP逻辑对时钟频率有要求,要在Block Design里确认FCLK_CLK0的频率设置。我遇到过有人把FCLK_CLK0设成50MHz,但代码里按100MHz算分频系数,结果输出频率差一倍。

5. 实操中踩过的坑与排查技巧实录

5.1 IP打包后Block Design里找不到

这个问题通常是因为IP Catalog没有刷新。解决办法是在IP Catalog里右键,选择Refresh IP Catalog,或者重启Vivado。如果还是找不到,检查IP的存放路径是否在工程的ip_repo目录下,以及IP的Component.xml文件是否存在。有时候打包过程中断了,Component.xml没生成完整,也会导致IP无法识别。

5.2 综合时AXI接口报时序违例

AXI4-Lite的时序通常不紧张,但如果你的IP逻辑里有时钟分频或者跨时钟域处理,可能会引入时序问题。我遇到过一次,是因为在AXI接口逻辑里直接用了分频后的时钟去驱动寄存器,导致建立时间不够。正确的做法是所有AXI接口逻辑都用总线时钟,分频逻辑放在用户逻辑区域,并且做好时钟域 crossing。

5.3 Vitis里读写寄存器没反应

先确认比特流是否下载成功,PL端是否配置完成。然后检查Xil_Out32的地址是否真的映射到了你的IP。可以在Vitis里读一个已知的寄存器,比如Zynq的GPIO寄存器,看能不能读到预期值。如果读不到,说明地址映射有问题,回到Address Editor检查。

还有一个可能是IP的时钟或复位没连接。在Block Design里,AXI接口的aclk和aresetn必须连接到Zynq的FCLK_CLK0和peripheral_aresetn,否则IP不工作。

5.4 修改IP后重新打包版本冲突

前面提过,改IP一定要升版本号。但有时候升了版本号,Block Design里例化的还是旧版本。这时候需要在Block Design里删掉旧的IP例化,重新从IP Catalog里添加新版本。Vivado不会自动替换已例化的IP版本,需要手动操作。

另外,如果IP的端口发生了变化,比如增加了新的输出端口,重新添加IP后需要重新Run Connection Automation,否则新端口会悬空。

5.5 常见问题速查表

现象可能原因排查方法
IP Catalog里搜不到自定义IPIP未刷新或路径不对刷新IP Catalog,检查ip_repo目录
Block Design里AXI连接失败接口模式或位宽不匹配检查IP的AXI配置与Zynq GP接口是否一致
Vitis读写寄存器无响应地址映射错误或时钟未连接核对Address Editor,检查aclk/aresetn
输出频率不对分频系数计算错误确认输入时钟频率,重新计算FREQ_DIV
综合报时序违例跨时钟域或逻辑太复杂检查时钟域,插入寄存器打拍
重新打包后逻辑没更新版本号未递增升版本号,删除旧例化重新添加

6. 进阶扩展与个人经验补充

6.1 从AXI4-Lite扩展到AXI4-Full的思路

如果你后续需要做大批量数据传输,比如把PL端采集的ADC数据搬到DDR,那AXI4-Lite就不够用了。扩展的思路是:保留Lite接口用于寄存器配置,另外增加一个Full接口用于数据搬运。Xilinx的DMA IP就是这种架构,一个Lite接口配寄存器,一个Full接口搬数据。

自己封装Full接口的难度在于突发传输的地址计算和握手逻辑。我的建议是不要从头写,而是基于Xilinx的AXI DMA IP或者AXI DataMover IP来搭,把自定义逻辑挂在Stream接口上。这样你只需要处理Stream的握手,Full总线的复杂性由官方IP承担。

6.2 IP封装中的参数化设计技巧

如果你希望IP在不同项目里可以配置不同的默认值,比如默认分频系数、默认使能状态,可以在IP Packager的Customization Parameters里定义User Parameters。这些参数会出现在IP例化时的GUI里,用户可以直接修改,不需要改RTL代码。

定义参数的时候要注意类型和范围。比如分频系数是整数,范围可以设1到65535。Vivado会根据你设的范围生成对应的GUI控件,超出范围会报错。这个功能在团队协作时特别有用,别人用你的IP不需要问你要默认值,直接看GUI就知道怎么配。

6.3 仿真验证的加速方法

自定义IP的仿真,如果每次都跑完整的Block Design,速度会很慢。我的做法是单独给IP写一个Testbench,只例化IP本身,用模拟的AXI激励去驱动它。这样仿真速度快,而且容易定位问题。

AXI激励可以用Vivado自带的AXI VIP(Verification IP),也可以自己写简单的task来模拟读写时序。如果只是验证寄存器读写,自己写task就够了,没必要上VIP。但如果要验证突发传输或者乱序响应,那还是用VIP更可靠。

6.4 关于Vivado版本兼容性的提醒

Vivado的IP打包格式在不同版本之间基本兼容,但也不是完全没有问题。我遇到过在2020.2里打包的IP,在2022.2里打开时提示版本不兼容,需要重新打包。所以如果你的项目需要跨版本协作,最好统一Vivado版本,或者在升级版本后重新打包所有自定义IP。

另外,IP的版本号管理要规范。我通常用主版本号表示寄存器映射的变化,次版本号表示逻辑优化但不影响寄存器映射。这样别人升级IP的时候,看到主版本号变了就知道要改软件代码。

6.5 一个容易被忽略的细节:复位极性

AXI4-Lite的复位信号aresetn是低电平有效,但很多自定义逻辑习惯用高电平复位。在连接的时候要注意转换,否则IP可能一直处于复位状态,或者永远不复位。我一般会在用户逻辑区域加一个复位同步器,把低电平有效的aresetn转换成高电平有效的内部复位,并且做同步处理,避免亚稳态。

这个细节在官方模板里没有体现,因为模板只处理AXI接口部分,用户逻辑的复位需要自己管理。如果你发现IP下载后不工作,先检查复位信号是不是接反了。

6.6 关于Zynq烧写与固化的补充

IP验证通过之后,最终要固化到Flash里。Zynq的固化流程是:生成BOOT.bin,包含FSBL、比特流和应用程序,然后通过JTAG或者SD卡烧写到QSPI Flash。这里有一个常见问题:如果PL端的逻辑依赖PS端的配置,比如时钟或者DDR初始化,那FSBL必须先配置PS,再加载比特流。Vitis里生成BOOT.bin的时候,默认的顺序就是FSBL、比特流、应用,一般不需要改。

但如果你的IP在PS配置完成之前就需要工作,比如某些电源管理逻辑,那就要考虑把比特流放在FSBL之前加载。这种情况比较少见,大多数应用按默认顺序就行。

6.7 个人实操体会

封装自定义IP这件事,第一次做会觉得步骤多、容易出错,但做过两三次之后就会发现流程很固定。真正花时间的不是打包本身,而是前期的寄存器规划和后期的调试。我的经验是:寄存器映射宁多勿少,预留几个备用寄存器,后面加功能不用重新打包;IP版本号一定要管好,改一次升一次,不要偷懒;仿真验证要做在打包之前,不要等到Block Design里才发现逻辑有问题。

还有一个很实用的技巧:把常用的自定义IP集中放在一个ip_repo目录里,所有工程都指向这个目录。这样IP改一次,所有工程都能用最新版,不需要每个工程单独维护一份。当然,前提是IP的接口保持兼容,如果接口变了,旧工程还是要手动更新。

最后说一个调试手段:如果Vitis里读写寄存器不对,但又查不出原因,可以在Block Design里加一个ILA(Integrated Logic Analyzer),抓AXI接口的握手信号。看awvalid、awready、wvalid、wready这些信号有没有正常翻转,就能判断是总线没通还是寄存器逻辑有问题。ILA是FPGA调试的万能工具,花十分钟学会,省下的是几小时的猜谜时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询