4. 自定义AXI4-Lite IP核封装:使用Vivado IP Packager创建自定义IP、添加用户逻辑寄存器
好,咱们进入第四章。这一章要干的事,说白了就是——把你自己写的逻辑,打包成一个标准的AXI4-Lite IP核。
为什么要干这个?因为Zynq的PS和PL通信,最常用的方式就是通过AXI总线。而AXI4-Lite,是其中最轻量、最容易上手的一种。你写一个自定义IP,挂到AXI总线上,PS端就能像访问内存一样,读写你IP里的寄存器。
我在项目里见过不少新手,直接在顶层文件里写一堆端口,然后手动连AXI接口。结果呢?时序乱套、地址对不上、仿真跑不通……折腾好几天。其实Vivado早就给你准备好了IP Packager这个工具,咱们得学会用它。
本章核心目标:
- 学会使用Vivado IP Packager创建自定义AXI4-Lite IP
- 理解AXI4-Lite接口的寄存器映射机制
- 在IP中添加用户逻辑寄存器,并实现PS端的读写控制
4.1 AXI4-Lite接口的本质
先聊点基础的。AXI4-Lite,是AXI4协议的一个子集。它去掉了突发传输、去掉了数据交织,只保留了最基本的单次读写能力。
你想想看,对于配置寄存器、读取状态这种操作,一次读写一个地址就够了,没必要搞突发。所以AXI4-Lite非常适合用来做控制平面的通信。
| 特性 | AXI4-Full | AXI4-Lite | AXI4-Stream |
|---|---|---|---|
| 突发传输 | 支持 | 不支持 | 支持 |
| 数据宽度 | 可配置 | 32位固定 | 可配置 |
| 地址总线 | 独立 | 独立 | 无地址 |
| 典型用途 | DMA、高速数据传输 | 寄存器配置、状态读取 | 数据流、视频、音频 |
| 接口复杂度 | 高 | 低 | 中 |
嗯,这张表你得记住。选型的时候,别拿AXI4-Lite去传视频流,那会把自己坑死。
4.2 Vivado IP Packager 工作流程
Vivado的IP Packager,说白了就是一个打包工具。你把RTL代码、约束文件、仿真模型准备好,它帮你生成一个标准的IP核,带AXI接口、带GUI配置界面、带文档模板。
我个人习惯的流程是这样的:
- 创建IP工程——在Vivado里选择"Create and Package New IP"
- 选择模板——选AXI4-Lite外设模板,省去手写接口的麻烦
- 配置接口参数——设置地址范围、寄存器数量、数据宽度
- 添加用户逻辑——在生成的模板里,加入你自己的功能代码
- 打包IP——生成.xcix文件,供其他工程使用
小技巧:第一次创建IP时,建议选"Edit IP"模式。这样Vivado会打开一个独立的工程,你可以直接修改RTL代码,仿真验证通过后再打包。别一上来就"Package IP",万一代码有bug,后面改起来麻烦。
4.3 理解AXI4-Lite的寄存器映射
这是本章最核心的概念。AXI4-Lite的寄存器映射,其实就是把一组32位的寄存器,映射到一段连续的地址空间上。
PS端通过AXI总线发起读写操作,地址总线告诉IP要访问哪个寄存器,数据总线传输具体的数据。IP内部有一个地址译码器,根据地址选择对应的寄存器。
你看这张图,PS通过AXI总线发送地址和数据,IP内部的地址译码器根据地址偏移,选中对应的寄存器。每个寄存器占4字节(32位),地址偏移依次是0x00、0x04、0x08、0x0C……
4.4 实战:创建自定义AXI4-Lite IP
好,理论说完了,咱们动手。我以Vivado 2023.1为例,带你走一遍完整的流程。
4.4.1 创建IP工程
- 打开Vivado,点击 Tools → Create and Package New IP
- 选择 Create a new AXI4 peripheral
- 设置IP名称:
my_axi_gpio,版本1.0 - 接口类型选 AXI4-Lite,数据宽度32位
- 寄存器数量设为 4(slv_reg0 ~ slv_reg3)
- 选择 Edit IP 模式,点击Finish
注意:寄存器数量不要贪多。每个寄存器占用4字节地址空间,4个寄存器就是16字节。如果你只需要2个寄存器,就设2个,别浪费地址空间。我曾经见过有人设了64个寄存器,结果大部分都没用,白白增加了逻辑资源消耗。
4.4.2 理解生成的模板代码
Vivado会生成一个Verilog文件,里面包含了AXI4-Lite从机接口的完整实现。核心代码是这样的:
// AXI4-Lite 从机接口模板 (简化版)
module my_axi_gpio_v1_0_S00_AXI #(
parameter integer C_S_AXI_DATA_WIDTH = 32,
parameter integer C_S_AXI_ADDR_WIDTH = 4
)(
input wire S_AXI_ACLK,
input wire S_AXI_ARESETN,
// 写地址通道
input wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_AWADDR,
input wire S_AXI_AWVALID,
output wire S_AXI_AWREADY,
// 写数据通道
input wire [C_S_AXI_DATA_WIDTH-1:0] S_AXI_WDATA,
input wire [C_S_AXI_DATA_WIDTH/8-1:0] S_AXI_WSTRB,
input wire S_AXI_WVALID,
output wire S_AXI_WREADY,
// 写响应通道
output wire [1:0] S_AXI_BRESP,
output wire S_AXI_BVALID,
input wire S_AXI_BREADY,
// 读地址通道
input wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_ARADDR,
input wire S_AXI_ARVALID,
output wire S_AXI_ARREADY,
// 读数据通道
output wire [C_S_AXI_DATA_WIDTH-1:0] S_AXI_RDATA,
output wire [1:0] S_AXI_RRESP,
output wire S_AXI_RVALID,
input wire S_AXI_BREADY
);
// 用户寄存器
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg0;
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg1;
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg2;
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg3;
// 地址译码逻辑
localparam ADDR_LSB = (C_S_AXI_DATA_WIDTH/32) + 1;
localparam OPT_MEM_ADDR_BITS = 1;
// 写操作
always @(posedge S_AXI_ACLK) begin
if (!S_AXI_ARESETN) begin
slv_reg0 <= 0;
slv_reg1 <= 0;
slv_reg2 <= 0;
slv_reg3 <= 0;
end else begin
if (S_AXI_WVALID && S_AXI_AWVALID) begin
case (S_AXI_AWADDR[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB])
2'h0 : slv_reg0 <= S_AXI_WDATA;
2'h1 : slv_reg1 <= S_AXI_WDATA;
2'h2 : slv_reg2 <= S_AXI_WDATA;
2'h3 : slv_reg3 <= S_AXI_WDATA;
endcase
end
end
end
// 读操作
always @(*) begin
case (S_AXI_ARADDR[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB])
2'h0 : reg_data_out <= slv_reg0;
2'h1 : reg_data_out <= slv_reg1;
2'h2 : reg_data_out <= slv_reg2;
2'h3 : reg_data_out <= slv_reg3;
default : reg_data_out <= 0;
endcase
end
endmodule
这段代码,你仔细看。它实现了:
- 写操作:当AWVALID和WVALID同时有效时,根据地址把WDATA写入对应的寄存器
- 读操作:根据ARADDR,把对应寄存器的值送到RDATA上
- 地址译码:用地址的低2位(因为4个寄存器)来选择目标
4.4.3 添加用户逻辑
现在,咱们在寄存器的基础上,加点实际功能。比如做一个可配置的PWM发生器:
- slv_reg0:PWM周期(PS写入)
- slv_reg1:PWM占空比(PS写入)
- slv_reg2:控制寄存器(使能位、极性位)
- slv_reg3:状态寄存器(当前计数值、溢出标志)
// 在模板中添加用户逻辑
// PWM 发生器
reg [31:0] pwm_counter;
reg pwm_out;
always @(posedge S_AXI_ACLK) begin
if (!S_AXI_ARESETN) begin
pwm_counter <= 0;
pwm_out <= 0;
end else begin
if (slv_reg2[0]) begin // 使能位
if (pwm_counter >= slv_reg0) begin
pwm_counter <= 0;
end else begin
pwm_counter <= pwm_counter + 1;
end
if (pwm_counter < slv_reg1) begin
pwm_out <= 1'b1;
end else begin
pwm_out <= 1'b0;
end
end else begin
pwm_counter <= 0;
pwm_out <= 0;
end
end
end
// 更新状态寄存器
always @(posedge S_AXI_ACLK) begin
if (!S_AXI_ARESETN) begin
slv_reg3 <= 0;
end else begin
slv_reg3[31:1] <= pwm_counter[31:1];
slv_reg3[0] <= (pwm_counter == slv_reg0) ? 1'b1 : 1'b0; // 溢出标志
end
end
// 输出PWM信号
assign pwm_output = pwm_out;
经验之谈:写用户逻辑时,一定要把寄存器读写和用户功能分开。寄存器读写用模板里的always块,用户功能自己单独写always块。这样代码清晰,调试也方便。我早期犯过一个错,把PWM逻辑直接塞进了寄存器写操作的case语句里,结果地址译码和功能逻辑搅在一起,改一个bug引出三个新bug。
4.5 打包与验证
代码写完后,在IP工程里做两步:
- 综合检查:跑一遍Synthesis,确保没有语法错误和综合警告
- 行为仿真:写一个简单的testbench,验证寄存器读写和PWM功能
确认无误后,点击 Tools → Package IP。Vivado会生成一个.xcix文件,这就是你的自定义IP核。
| 步骤 | 操作 | 预期结果 |
|---|---|---|
| 1 | 综合检查 | 无错误,无严重警告 |
| 2 | 行为仿真 | 寄存器读写正确,PWM波形符合预期 |
| 3 | 打包IP | 生成 .xcix 文件,可在IP Catalog中看到 |
| 4 | 导入工程 | 在Block Design中可添加该IP |
4.6 避坑指南
我曾经踩过的坑:
- 地址对齐问题:AXI4-Lite的地址必须是4字节对齐的。如果你在PS端写了一个非对齐地址,总线会直接返回错误。解决办法:在PS端用
Xil_Out32这类函数,它们会自动处理对齐。 - 寄存器复位值:模板里寄存器复位值是0。如果你的逻辑需要非零初始值,记得在复位逻辑里改。我见过有人忘了改,结果PWM使能位默认是0,PS端写1之前,PWM一直不工作。
- WSTRB信号:这个信号是写选通,用来控制写入哪些字节。如果你只写32位数据,WSTRB固定为4'b1111就行。但如果你做字节级操作,必须正确处理WSTRB,否则会写错数据。
4.7 小结
这一章,咱们从AXI4-Lite的接口本质讲起,到Vivado IP Packager的完整流程,再到添加用户逻辑寄存器,最后打包成可复用的IP核。
核心就三句话:
- AXI4-Lite是控制通道,不是数据通道——别拿它传大块数据
- 寄存器映射是核心——地址偏移、数据宽度、读写时序,必须搞清
- IP Packager是工具,不是魔法——它帮你省去手写接口的麻烦,但用户逻辑还得你自己写