Zynq 实战 06|自定义 IP 核开发:从 AXI4-Lite 模板到可跑的 PWM 控制器
Zynq 实战 06|自定义 IP 核开发:从 AXI4-Lite 模板到可跑的 PWM 控制器
这是《Zynq FPGA 嵌入式系统设计实战》系列的第 6 篇。 板子:Pynq-Z2(XC7Z020-1CLG400C)。工具链:Vivado / Vitis 2023.2。 上一篇:Zynq 实战 05|AXI DMA 与高速数据通路
0. 这一篇要解决什么问题
前几篇里,我们用的都是 Xilinx 官方提供的 IP 核——AXI GPIO、AXI UART、DMA。这些 IP 已经打包好了,拖进 Block Design 连线就完事。
但真实项目里,你迟早会遇到”官方 IP 里没有、但我就是需要一个能被 PS 读写寄存器的硬件模块”的场景——比如自定义的 PWM 控制器、采集接口、电机驱动时序单元。
这一篇就解决这个问题:怎么把一段自己写的 Verilog 变成一个可以像官方 IP 一样在 Block Design 里拖进来、有地址、PS 能读写的 IP 核。
具体会讲:
- Vivado 的 Create and Package New IP 向导——每一个点击的真实含义
- Vivado 自动生成的 AXI4-Lite Slave 模板代码逐行是什么意思
- 以 PWM 控制器为例,把用户逻辑嵌进模板的完整方法
- IP Packager 里怎么加端口、关联接口、设置地址段
component.xml在哪、路径怎么配进 Vivado IP repository- Block Design 里实例化、Address Editor 分配地址、Validate Design
- Vitis 软件端:
xparameters.h里找 base address、Xil_Out32/Xil_In32读写
这篇不会讲:IP 级仿真(专门一篇)、AXI4-Full/AXI-Stream(后续篇章)。
1. 先想清楚你要做什么 IP
在打开 Vivado 之前,先把寄存器地图设计出来。后期改寄存器意味着重新封装,很烦。
我们这一篇的示例是一个 PWM 控制器 IP,4 个 32 位寄存器,挂在 PS 的 M_AXI_GP0 总线上,通过 AXI4-Lite Slave 接口访问:
| 偏移地址 | 寄存器名 | 读/写 | 说明 |
|---|---|---|---|
0x00 | REG_PERIOD | R/W | PWM 周期(单位:PL 时钟周期数) |
0x04 | REG_DUTY | R/W | 占空比(高电平持续时钟周期数,必须 < PERIOD) |
0x08 | REG_CTRL | R/W | 使能位 [0]:1 = 启动 PWM,0 = 停止并输出低 |
0x0C | REG_STATUS | R only | 当前计数器值(软件只读,写入无效) |
PL 时钟 100 MHz,要输出 10 kHz 的 PWM,那 REG_PERIOD = 10000。要 30% 占空比,REG_DUTY = 3000。
🚧 避坑:很多教程把”周期”和”频率”混着说。这里我们选用周期(时钟数),因为寄存器直接存时钟周期数,软件转换最简单:
period_clks = fclk_hz / pwm_freq_hz。不要在 IP 内部做除法,这会消耗大量 DSP48E1 资源,而且逻辑延迟很难收敛。
2. Vivado Create and Package New IP 向导——每步的真实含义
打开 Vivado 2023.2,不需要专门为打包 IP 创建一个新工程——你可以在已有工程里操作,也可以创建一个专用工程。这里我们新建一个干净的工程:
菜单路径:Tools → Create and Package New IP
向导分为几步,下面逐步说清楚:
步骤 1:选择 IP 类型
○ Create a new AXI4 peripheral ← 我们选这个
○ Package a specified directory
○ Package your current project
选 “Create a new AXI4 peripheral”。另外两个选项用于把已有 Verilog 工程打包,适合更高级场景(我们后面也会介绍如何手动加端口,道理一样)。
步骤 2:设置 IP 基本信息
Name: my_pwm_ip
Version: 1.0
Display Name: My PWM IP
Description: AXI4-Lite controlled PWM generator
IP location: ~/Projects/ip_repo/my_pwm_ip_1.0
↑ 这个路径很重要,记住它,后面要加进 IP repo
关键提醒:ip_repo 这个文件夹要放在你的工程目录之外,或者一个专门的 IP 仓库路径。这样多个工程都能引用同一套 IP,不用复制来复制去。我习惯放在 ~/Projects/ip_repo/。
步骤 3:配置 AXI 接口
这是整个向导里最关键的一步:
Interface Name: S00_AXI
Interface Type: AXI4 Lite ← 寄存器控制选 Lite;流数据选 Stream;高带宽选 Full
Interface Mode: Slave ← 我们的 IP 是 PS 控制的从机
Data Width (Bits): 32 ← Zynq PS 的 GP 总线是 32-bit,保持一致
Number of Registers: 4 ← 对应我们的 4 个寄存器
“Number of Registers” 决定了地址宽度。4 个寄存器 × 4 字节 = 16 字节,需要 4-bit 地址(2^4 = 16),所以向导会把 C_S_AXI_ADDR_WIDTH 参数设为 4。
步骤 4:完成向导
○ Add IP to the repository ← 仅添加到当前工程的 IP repo
● Edit IP ← 选这个,向导完成后直接打开 IP 编辑项目
选 “Edit IP”,点 Finish。Vivado 会新开一个 IP 编辑工程,里面已经有两个生成好的 Verilog 文件,这就是我们接下来要填充用户逻辑的骨架。
3. Vivado 生成的 AXI4-Lite Slave 模板逐行解读
打开 IP 编辑工程,Sources 面板里有两个文件:
my_pwm_ip_v1_0.v ← 顶层 wrapper,负责把外部端口接到子模块
my_pwm_ip_v1_0_S00_AXI.v ← AXI4-Lite Slave 实现,含 slv_reg0~3
my_pwm_ip_v1_0_S00_AXI.v 是核心。Vivado 2023.2 生成的这个文件大约 280 行,我们把关键结构拆出来看:
3.1 参数与端口声明
module my_pwm_ip_v1_0_S00_AXI #(
parameter integer C_S_AXI_DATA_WIDTH = 32,
// 地址宽度 4:对应 4 个寄存器(2^4 = 16 字节空间)
parameter integer C_S_AXI_ADDR_WIDTH = 4
)(
// ─── 写地址通道(AW)───────────────────────────
input wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_AWADDR,
input wire [2:0] S_AXI_AWPROT, // 保护信号,对 Lite 通常忽略
input wire S_AXI_AWVALID,
output reg S_AXI_AWREADY,
// ─── 写数据通道(W)────────────────────────────
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, // 字节使能,4-bit
input wire S_AXI_WVALID,
output reg S_AXI_WREADY,
// ─── 写响应通道(B)────────────────────────────
output reg [1:0] S_AXI_BRESP, // 00=OKAY, 10=SLVERR
output reg S_AXI_BVALID,
input wire S_AXI_BREADY,
// ─── 读地址通道(AR)───────────────────────────
input wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_ARADDR,
input wire [2:0] S_AXI_ARPROT,
input wire S_AXI_ARVALID,
output reg S_AXI_ARREADY,
// ─── 读数据通道(R)────────────────────────────
output reg [C_S_AXI_DATA_WIDTH-1:0] S_AXI_RDATA,
output reg [1:0] S_AXI_RRESP,
output reg S_AXI_RVALID,
input wire S_AXI_RREADY,
// ─── 时钟与复位 ─────────────────────────────────
input wire S_AXI_ACLK,
input wire S_AXI_ARESETN // 低有效复位
);
为什么 AWPROT/ARPROT 可以忽略?
这是 AXI4-Lite 的保护属性信号,用于标识访问是否为特权/安全/数据访问。在大多数用户 IP 里直接不管它——除非你做了 TrustZone 隔离,否则接受任何值。
3.2 寄存器声明
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg0; // offset 0x00 → REG_PERIOD
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg1; // offset 0x04 → REG_DUTY
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg2; // offset 0x08 → REG_CTRL
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg3; // offset 0x0C → REG_STATUS(只读)
这 4 个 reg 就是 PS 可以通过 AXI 总线读写的寄存器。地址解码用 AWADDR[3:2] 两位来选([3:2] 正好是 0/1/2/3,对应 4 个寄存器)。
3.3 写逻辑——地址/数据两个通道的握手
AXI4-Lite 的写需要 AW(写地址)和 W(写数据)两个通道独立握手,Vivado 生成的模板用两个 always 块分别处理:
// 写地址通道:锁存地址
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN) begin
S_AXI_AWREADY <= 1'b0;
aw_en <= 1'b1;
end else begin
if (~S_AXI_AWREADY && S_AXI_AWVALID && S_AXI_WVALID && aw_en) begin
// AW 和 W 都 valid 时才接受,避免地址先到、数据还没来的竞态
S_AXI_AWREADY <= 1'b1;
aw_en <= 1'b0;
end else if (S_AXI_BREADY && S_AXI_BVALID) begin
aw_en <= 1'b1;
S_AXI_AWREADY <= 1'b0;
end else begin
S_AXI_AWREADY <= 1'b0;
end
end
end
注意 aw_en 这个辅助寄存器——它确保一次写事务(从 AW+W 开始,到 B 响应完成)期间只接受一笔写,防止流水线重叠出错。这是 Vivado 模板的一个保守但安全的设计。
写数据真正落到寄存器是在这里:
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 if (slv_reg_wren) begin
// slv_reg_wren = AWREADY & AWVALID & WREADY & WVALID 同时有效
case (axi_awaddr[ADDR_LSB+OPT_MEM_ADDR_BITS:ADDR_LSB])
2'h0: for (i=0; i<4; i=i+1)
if (S_AXI_WSTRB[i]) slv_reg0[(i*8)+:8] <= S_AXI_WDATA[(i*8)+:8];
2'h1: for (i=0; i<4; i=i+1)
if (S_AXI_WSTRB[i]) slv_reg1[(i*8)+:8] <= S_AXI_WDATA[(i*8)+:8];
2'h2: for (i=0; i<4; i=i+1)
if (S_AXI_WSTRB[i]) slv_reg2[(i*8)+:8] <= S_AXI_WDATA[(i*8)+:8];
// 注意:slv_reg3 是只读(状态寄存器),不在 case 里写
default: ;
endcase
end
end
S_AXI_WSTRB 是 4-bit 字节使能,bit 0 控制 WDATA[7:0],bit 3 控制 WDATA[31:24]。这段循环用 generate-style 的 for 处理字节使能,支持非 32-bit 对齐的写操作(虽然 ARM 大多数情况发全 4'b1111)。
3.4 读逻辑
always @(*) begin
case (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
读逻辑是纯组合逻辑(always @(*)),没有寄存器延迟,ARREADY 拉高当拍就可以返回数据。
🚧 避坑:
ADDR_LSB的值在 32-bit 模式下是 2(因为地址对齐到 4 字节,低 2 位永远是 00,ADDR_LSB = clog2(DATA_WIDTH/8) = clog2(4) = 2)。所以真正用来解码寄存器编号的是AWADDR[3:2],而不是AWADDR[1:0]。如果你不清楚这一点,试图用AWADDR[1:0]解码,4 个寄存器永远访问的是 reg0。这个坑在调试 ILA 的时候特别难发现。
4. 在模板上添加 PWM 用户逻辑——完整 Verilog
现在我们有了模板骨架,要做的事情很明确:
- 在
my_pwm_ip_v1_0_S00_AXI.v里增加pwm_out输出端口 - 增加 PWM 计数器逻辑,读取
slv_reg0/slv_reg1/slv_reg2 - 把当前计数器值写回
slv_reg3(只读状态寄存器) - 在顶层
my_pwm_ip_v1_0.v把pwm_out透传到外部端口
下面是修改后的完整两个文件:
4.1 my_pwm_ip_v1_0_S00_AXI.v(含用户 PWM 逻辑)
`timescale 1 ns / 1 ps
// ============================================================
// my_pwm_ip_v1_0_S00_AXI.v
// AXI4-Lite Slave + PWM 用户逻辑
// Vivado 2023.2 生成骨架 + 用户修改部分
//
// 寄存器映射:
// offset 0x00 (slv_reg0) : REG_PERIOD -- PWM 周期(时钟数)
// offset 0x04 (slv_reg1) : REG_DUTY -- 高电平时钟数(< PERIOD)
// offset 0x08 (slv_reg2) : REG_CTRL -- bit[0] = enable
// offset 0x0C (slv_reg3) : REG_STATUS -- 只读,当前计数器值
// ============================================================
module my_pwm_ip_v1_0_S00_AXI #(
parameter integer C_S_AXI_DATA_WIDTH = 32,
parameter integer C_S_AXI_ADDR_WIDTH = 4
)(
// ── 用户逻辑端口(新增)──────────────────────────────
output wire pwm_out,
// ── AXI4-Lite Slave 接口 ────────────────────────────
input wire S_AXI_ACLK,
input wire S_AXI_ARESETN,
input wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_AWADDR,
input wire [2:0] S_AXI_AWPROT,
input wire S_AXI_AWVALID,
output reg 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 reg S_AXI_WREADY,
output reg [1:0] S_AXI_BRESP,
output reg S_AXI_BVALID,
input wire S_AXI_BREADY,
input wire [C_S_AXI_ADDR_WIDTH-1:0] S_AXI_ARADDR,
input wire [2:0] S_AXI_ARPROT,
input wire S_AXI_ARVALID,
output reg S_AXI_ARREADY,
output reg [C_S_AXI_DATA_WIDTH-1:0] S_AXI_RDATA,
output reg [1:0] S_AXI_RRESP,
output reg S_AXI_RVALID,
input wire S_AXI_RREADY
);
// ─── 地址解码参数 ──────────────────────────────────────────
// ADDR_LSB = 2(32-bit 数据总线,地址步长 4 字节,低 2 位无效)
// OPT_MEM_ADDR_BITS = 1(用 addr[3:2] 选 4 个寄存器,需 2 位,最高位 index = 1)
localparam integer ADDR_LSB = (C_S_AXI_DATA_WIDTH/32) + 1; // = 2
localparam integer OPT_MEM_ADDR_BITS = 1; // 寄存器数 4 → 需 2-bit 索引 [1:0]
// ─── 内部信号 ─────────────────────────────────────────────
reg [C_S_AXI_ADDR_WIDTH-1:0] axi_awaddr;
reg aw_en;
reg [C_S_AXI_ADDR_WIDTH-1:0] axi_araddr;
reg [C_S_AXI_DATA_WIDTH-1:0] reg_data_out;
reg slv_reg_rden;
wire slv_reg_wren;
// ─── AXI 从机寄存器(4 个)────────────────────────────────
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg0; // REG_PERIOD
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg1; // REG_DUTY
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg2; // REG_CTRL
reg [C_S_AXI_DATA_WIDTH-1:0] slv_reg3; // REG_STATUS(只读,由 PWM 逻辑更新)
integer byte_index;
// ─────────────────────────────────────────────────────────
// AXI 写地址通道
// ─────────────────────────────────────────────────────────
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN) begin
S_AXI_AWREADY <= 1'b0;
aw_en <= 1'b1;
axi_awaddr <= 0;
end else begin
if (~S_AXI_AWREADY && S_AXI_AWVALID && S_AXI_WVALID && aw_en) begin
// AW 和 W 都有效时,锁存地址并拉高 AWREADY
S_AXI_AWREADY <= 1'b1;
aw_en <= 1'b0;
axi_awaddr <= S_AXI_AWADDR;
end else if (S_AXI_BREADY && S_AXI_BVALID) begin
// B 响应完成,允许下一笔写
aw_en <= 1'b1;
S_AXI_AWREADY <= 1'b0;
end else begin
S_AXI_AWREADY <= 1'b0;
end
end
end
// ─────────────────────────────────────────────────────────
// AXI 写数据通道
// ─────────────────────────────────────────────────────────
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN)
S_AXI_WREADY <= 1'b0;
else begin
if (~S_AXI_WREADY && S_AXI_WVALID && S_AXI_AWVALID && aw_en)
S_AXI_WREADY <= 1'b1;
else
S_AXI_WREADY <= 1'b0;
end
end
// slv_reg_wren:AW 和 W 握手都完成的标志,真正写寄存器的时刻
assign slv_reg_wren = S_AXI_WREADY && S_AXI_WVALID && S_AXI_AWREADY && S_AXI_AWVALID;
// ─────────────────────────────────────────────────────────
// 寄存器写入逻辑(带字节使能 WSTRB)
// ─────────────────────────────────────────────────────────
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN) begin
slv_reg0 <= 32'd0; // PERIOD 复位为 0
slv_reg1 <= 32'd0; // DUTY 复位为 0
slv_reg2 <= 32'd0; // CTRL 复位为 0(disabled)
// slv_reg3 是只读状态,不在写逻辑里
end else if (slv_reg_wren) begin
case (axi_awaddr[ADDR_LSB + OPT_MEM_ADDR_BITS : ADDR_LSB])
2'h0: for (byte_index = 0; byte_index <= 3; byte_index = byte_index+1)
if (S_AXI_WSTRB[byte_index])
slv_reg0[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];
2'h1: for (byte_index = 0; byte_index <= 3; byte_index = byte_index+1)
if (S_AXI_WSTRB[byte_index])
slv_reg1[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];
2'h2: for (byte_index = 0; byte_index <= 3; byte_index = byte_index+1)
if (S_AXI_WSTRB[byte_index])
slv_reg2[(byte_index*8) +: 8] <= S_AXI_WDATA[(byte_index*8) +: 8];
// 2'h3 → slv_reg3 只读,忽略写操作
default: ;
endcase
end
end
// ─────────────────────────────────────────────────────────
// AXI 写响应通道(B)
// ─────────────────────────────────────────────────────────
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN) begin
S_AXI_BVALID <= 1'b0;
S_AXI_BRESP <= 2'b00;
end else begin
if (S_AXI_AWREADY && S_AXI_AWVALID && ~S_AXI_BVALID &&
S_AXI_WREADY && S_AXI_WVALID) begin
S_AXI_BVALID <= 1'b1;
S_AXI_BRESP <= 2'b00; // OKAY
end else if (S_AXI_BREADY && S_AXI_BVALID) begin
S_AXI_BVALID <= 1'b0;
end
end
end
// ─────────────────────────────────────────────────────────
// AXI 读地址通道(AR)
// ─────────────────────────────────────────────────────────
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN) begin
S_AXI_ARREADY <= 1'b0;
axi_araddr <= 0;
end else begin
if (~S_AXI_ARREADY && S_AXI_ARVALID) begin
S_AXI_ARREADY <= 1'b1;
axi_araddr <= S_AXI_ARADDR;
end else begin
S_AXI_ARREADY <= 1'b0;
end
end
end
// ─────────────────────────────────────────────────────────
// AXI 读数据通道(R)
// ─────────────────────────────────────────────────────────
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN) begin
S_AXI_RVALID <= 1'b0;
S_AXI_RRESP <= 2'b00;
end else begin
if (S_AXI_ARREADY && S_AXI_ARVALID && ~S_AXI_RVALID) begin
S_AXI_RVALID <= 1'b1;
S_AXI_RRESP <= 2'b00; // OKAY
end else if (S_AXI_RVALID && S_AXI_RREADY) begin
S_AXI_RVALID <= 1'b0;
end
end
end
// 读数据多路选择(组合逻辑)
// slv_reg3 映射到当前计数器值,由 PWM 逻辑持续更新
always @(*) begin
case (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; // 读出当前 PWM 计数器值
default: reg_data_out = 32'hDEAD_BEEF; // 非法地址返回特征值,方便调试
endcase
end
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN)
S_AXI_RDATA <= 0;
else if (slv_reg_rden)
S_AXI_RDATA <= reg_data_out;
end
assign slv_reg_rden = S_AXI_ARREADY & S_AXI_ARVALID & ~S_AXI_RVALID;
// ═══════════════════════════════════════════════════════════
// 用户逻辑:PWM 控制器
//
// slv_reg0 = REG_PERIOD : PWM 周期(时钟数),0 表示关闭
// slv_reg1 = REG_DUTY : 高电平持续时钟数
// slv_reg2[0] = REG_CTRL enable 位
// slv_reg3 = REG_STATUS(只读,反映当前计数器)
//
// pwm_out = 1,当 (enable=1) && (counter < duty) && (period > 0)
// ═══════════════════════════════════════════════════════════
reg [31:0] pwm_counter; // 当前计数器,0 ~ (PERIOD-1) 循环
reg pwm_reg; // PWM 输出寄存器(D 触发器,避免毛刺)
wire pwm_enable = slv_reg2[0]; // 使能位
wire [31:0] period_val = slv_reg0; // 周期
wire [31:0] duty_val = slv_reg1; // 占空比
// 计数器:在 enable=1 且 period>0 时循环计数
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN) begin
pwm_counter <= 32'd0;
end else if (!pwm_enable || period_val == 32'd0) begin
// 未使能或周期为 0:复位计数器
pwm_counter <= 32'd0;
end else begin
if (pwm_counter >= period_val - 1)
pwm_counter <= 32'd0; // 计满一个周期,归零
else
pwm_counter <= pwm_counter + 32'd1;
end
end
// PWM 输出逻辑:counter < duty 时输出高
// 用寄存器输出而不是 assign wire,避免组合逻辑毛刺
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN) begin
pwm_reg <= 1'b0;
end else if (!pwm_enable || period_val == 32'd0) begin
pwm_reg <= 1'b0;
end else begin
// duty_val = 0 → 始终低电平;duty_val >= period_val → 始终高电平
if (duty_val == 32'd0)
pwm_reg <= 1'b0;
else if (duty_val >= period_val)
pwm_reg <= 1'b1;
else
pwm_reg <= (pwm_counter < duty_val) ? 1'b1 : 1'b0;
end
end
// 状态寄存器:将当前计数器值反馈给 slv_reg3(软件可读)
always @(posedge S_AXI_ACLK) begin
if (~S_AXI_ARESETN)
slv_reg3 <= 32'd0;
else
slv_reg3 <= pwm_counter; // 每拍更新
end
// 输出端口连接
assign pwm_out = pwm_reg;
endmodule
4.2 my_pwm_ip_v1_0.v(顶层 wrapper 增加 pwm_out)
`timescale 1 ns / 1 ps
// ============================================================
// my_pwm_ip_v1_0.v
// 顶层 wrapper:把 pwm_out 透传到外部端口
// ============================================================
module my_pwm_ip_v1_0 #(
parameter integer C_S00_AXI_DATA_WIDTH = 32,
parameter integer C_S00_AXI_ADDR_WIDTH = 4
)(
// ── 用户端口(新增)──────────────────────────────────
output wire pwm_out,
// ── AXI4-Lite Slave 端口(工具自动生成,透传到子模块)──
input wire S00_AXI_ACLK,
input wire S00_AXI_ARESETN,
input wire [C_S00_AXI_ADDR_WIDTH-1:0] S00_AXI_AWADDR,
input wire [2:0] S00_AXI_AWPROT,
input wire S00_AXI_AWVALID,
output wire S00_AXI_AWREADY,
input wire [C_S00_AXI_DATA_WIDTH-1:0] S00_AXI_WDATA,
input wire [(C_S00_AXI_DATA_WIDTH/8)-1:0] S00_AXI_WSTRB,
input wire S00_AXI_WVALID,
output wire S00_AXI_WREADY,
output wire [1:0] S00_AXI_BRESP,
output wire S00_AXI_BVALID,
input wire S00_AXI_BREADY,
input wire [C_S00_AXI_ADDR_WIDTH-1:0] S00_AXI_ARADDR,
input wire [2:0] S00_AXI_ARPROT,
input wire S00_AXI_ARVALID,
output wire S00_AXI_ARREADY,
output wire [C_S00_AXI_DATA_WIDTH-1:0] S00_AXI_RDATA,
output wire [1:0] S00_AXI_RRESP,
output wire S00_AXI_RVALID,
input wire S00_AXI_RREADY
);
// 实例化 AXI Slave 子模块(含 PWM 用户逻辑)
my_pwm_ip_v1_0_S00_AXI #(
.C_S_AXI_DATA_WIDTH (C_S00_AXI_DATA_WIDTH),
.C_S_AXI_ADDR_WIDTH (C_S00_AXI_ADDR_WIDTH)
) my_pwm_ip_v1_0_S00_AXI_inst (
.pwm_out (pwm_out),
.S_AXI_ACLK (S00_AXI_ACLK),
.S_AXI_ARESETN (S00_AXI_ARESETN),
.S_AXI_AWADDR (S00_AXI_AWADDR),
.S_AXI_AWPROT (S00_AXI_AWPROT),
.S_AXI_AWVALID (S00_AXI_AWVALID),
.S_AXI_AWREADY (S00_AXI_AWREADY),
.S_AXI_WDATA (S00_AXI_WDATA),
.S_AXI_WSTRB (S00_AXI_WSTRB),
.S_AXI_WVALID (S00_AXI_WVALID),
.S_AXI_WREADY (S00_AXI_WREADY),
.S_AXI_BRESP (S00_AXI_BRESP),
.S_AXI_BVALID (S00_AXI_BVALID),
.S_AXI_BREADY (S00_AXI_BREADY),
.S_AXI_ARADDR (S00_AXI_ARADDR),
.S_AXI_ARPROT (S00_AXI_ARPROT),
.S_AXI_ARVALID (S00_AXI_ARVALID),
.S_AXI_ARREADY (S00_AXI_ARREADY),
.S_AXI_RDATA (S00_AXI_RDATA),
.S_AXI_RRESP (S00_AXI_RRESP),
.S_AXI_RVALID (S00_AXI_RVALID),
.S_AXI_RREADY (S00_AXI_RREADY)
);
endmodule
5. IP Packager:端口、接口、地址段、component.xml
代码写好之后,回到 IP 编辑工程里的 “Package IP” 标签页。左侧是各个配置项。
5.1 Ports and Interfaces——注册 pwm_out 端口
点击 “Ports and Interfaces”,此时你能看到 Vivado 已经自动识别了所有 S00_AXI_* 信号并把它们归进了一个 AXI4-Lite Bus Interface。但 pwm_out 这个用户端口不属于任何标准接口,需要手动确认。
正常情况下,Vivado 会自动把 pwm_out 列为一个独立端口(类型 std_logic,方向 out)。如果它没出现,点右上角的刷新按钮(“Merge changes from Ports and Interfaces Wizard”)。
确保 pwm_out 的设置是:
- Signal Type:
data(不是clock或reset) - Direction: out
- 不属于任何 Bus Interface(独立端口)
5.2 Addressing and Memory——地址段定义
点击 “Addressing and Memory”,你会看到一个 Memory Map 已经自动创建:
Memory Map Name: S00_AXI_reg
Address Block: reg0
Base Offset: 0x0000
Range: 4K (0x1000) ← Vivado 默认最小 4KB,实际我们只用 16 字节
Width: 32
4 个寄存器只用了 16 字节(0x00~0x0F),但 Vivado 默认分配 4KB 地址段。这是正常的——AXI Interconnect 的地址对齐要求,以及 PS 侧 MMU 的页表粒度,都需要至少 4KB 对齐。
不需要修改这里,保持 4KB 就好。
5.3 Review and Package——生成 component.xml
确认所有绿勾之后,点 “Package IP”。Vivado 会在 ~/Projects/ip_repo/my_pwm_ip_1.0/ 目录下生成:
my_pwm_ip_1.0/
├── component.xml ← IP 的完整描述文件(Spirit/IPXACT 格式)
├── hdl/
│ ├── my_pwm_ip_v1_0.v
│ └── my_pwm_ip_v1_0_S00_AXI.v
└── xgui/
└── my_pwm_ip_v1_0.tcl ← Vivado GUI 定制化面板
component.xml 是 IP Packager 的核心输出。关键片段:
<!-- 总线接口定义 -->
<spirit:busInterfaces>
<spirit:busInterface>
<spirit:name>S00_AXI</spirit:name>
<spirit:busType spirit:vendor="xilinx.com"
spirit:library="interface"
spirit:name="aximm"
spirit:version="1.0"/>
<spirit:abstractionType spirit:vendor="xilinx.com"
spirit:library="interface"
spirit:name="aximm_rtl"
spirit:version="1.0"/>
<spirit:slave/>
<!-- ... 信号映射 ... -->
</spirit:busInterface>
</spirit:busInterfaces>
<!-- 地址空间声明 -->
<spirit:addressSpaces/>
<spirit:memoryMaps>
<spirit:memoryMap>
<spirit:name>S00_AXI_reg</spirit:name>
<spirit:addressBlock>
<spirit:name>reg0</spirit:name>
<spirit:baseAddress>0x0</spirit:baseAddress>
<spirit:range>4096</spirit:range> <!-- 4KB -->
<spirit:width>32</spirit:width>
</spirit:addressBlock>
</spirit:memoryMap>
</spirit:memoryMaps>
🚧 避坑:
component.xml是 XML 文件,可以手工编辑,但格式非常敏感。如果你手改错了,Vivado 加载时可能只报”IP catalog error”而不给行号。建议修改前先复制一份备份,或者通过 IP Packager GUI 改。
5.4 配置 IP Repository 路径
打包好的 IP 要让其他 Vivado 工程能找到,需要注册路径:
Vivado 菜单 → Tools → Settings → IP → Repository
点 "+" → 添加 ~/Projects/ip_repo/
添加后,Vivado 会自动扫描这个目录下的所有 component.xml,你的 my_pwm_ip 就会出现在 IP Catalog 里。
如果是 Tcl 脚本方式(方便在 CI/自动化流程中):
set_property ip_repo_paths ~/Projects/ip_repo [current_project]
update_ip_catalog
6. IP 内部架构图
7. 在 Block Design 中实例化与集成
IP 打包好、路径配置好之后,在你的主工程里操作:
7.1 添加 IP Repository
Project Settings → IP → Repository Manager
点 "+" → 选择 ~/Projects/ip_repo/
→ Vivado 自动扫描,弹出 "1 IP added" 提示
7.2 在 Block Design 中添加 IP
打开 Block Design,按 Ctrl+I(或点 ”+” 按钮),搜索 my_pwm_ip,双击添加。
此时 Block Design 上出现了你的 IP 块,有一个 AXI Slave 接口和一个 pwm_out 端口。
7.3 连接 AXI 总线
点击 Block Design 上方的 “Run Connection Automation”,勾选 my_pwm_ip_0/S00_AXI,选择 Master 为 processing_system7_0/M_AXI_GP0。
Vivado 会自动插入 ps7_0_axi_periph(AXI Interconnect)并完成连接,同时还会为 S_AXI_ACLK 和 S_AXI_ARESETN 连接上 PS 提供的时钟和复位信号。
7.4 引出 pwm_out 到外部端口
右键点击 IP 上的 pwm_out 端口 → “Make External”。Block Design 上会出现一个 pwm_out_0 外部端口。
然后在 XDC 约束文件里把它约束到 Pynq-Z2 的实际引脚(以 Arduino 扩展口 AR0 脚为例,对应 FPGA 管脚 T14):
# Pynq-Z2 Arduino IO Header AR0 → T14 (3.3V LVCMOS)
set_property PACKAGE_PIN T14 [get_ports pwm_out_0]
set_property IOSTANDARD LVCMOS33 [get_ports pwm_out_0]
🚧 避坑:
IOSTANDARD必须和你的引脚电平匹配。Pynq-Z2 的 Arduino 扩展口是 3.3V,用LVCMOS33。如果漏掉这句,Vivado 会在 Implementation 阶段报错(而不是在综合阶段),浪费你等待时间。
7.5 Address Editor 分配地址
Block Design 顶部切换到 “Address Editor” 标签页(或 Window → Address Editor)。
你会看到:
Cell Slave Interface Master Base Address Range Offset Address
my_pwm_ip_0 S00_AXI 0x43C0_0000 4K 0x43C0_0000
0x43C0_0000 是 Vivado 自动分配的,在 Zynq-7000 的 M_AXI_GP0 地址空间内(GP0 可用范围 0x4000_0000 ~ 0x7FFF_FFFF,来自 UG585 第 5 章 Table 5-1)。
如果你想手动指定地址(比如要和某个驱动里的宏定义对齐),直接双击地址格改成你想要的值,只要不和其他 IP 冲突就行。
7.6 Validate Design
按 F6 或菜单 Tools → Validate Design。若出现 Validation successful,说明所有连接和地址分配没有冲突。
然后:Generate HDL Wrapper → 综合 → 实现 → 生成 Bitstream → 导出 XSA。
8. Vitis 软件端:找 Base Address 并读写寄存器
在 Vitis 里基于导出的 XSA 创建平台和应用工程后,打开 xparameters.h(路径通常是 <platform>/hw/include/xparameters.h):
/* 搜索 MY_PWM,找到这几行 */
#define XPAR_MY_PWM_IP_0_S00_AXI_BASEADDR 0x43C00000
#define XPAR_MY_PWM_IP_0_S00_AXI_HIGHADDR 0x43C00FFF
BASEADDR 就是你在 Address Editor 里分配的地址。HIGHADDR 是 BASEADDR + 4KB - 1。
下面是完整的软件测试程序,演示配置 PWM 并读回状态:
#include <stdio.h>
#include "xil_io.h" /* Xil_Out32 / Xil_In32 */
#include "xparameters.h" /* XPAR_MY_PWM_IP_0_S00_AXI_BASEADDR */
#include "sleep.h" /* usleep() */
/* 寄存器偏移地址,和 Verilog 里的映射对应 */
#define PWM_BASE XPAR_MY_PWM_IP_0_S00_AXI_BASEADDR
#define REG_PERIOD (PWM_BASE + 0x00)
#define REG_DUTY (PWM_BASE + 0x04)
#define REG_CTRL (PWM_BASE + 0x08)
#define REG_STATUS (PWM_BASE + 0x0C)
/* 控制位 */
#define CTRL_ENABLE (1U << 0)
int main(void)
{
u32 status;
xil_printf("=== PWM IP Test ===\r\n");
/* Step 1: 先停止 PWM(确保初始状态干净)*/
Xil_Out32(REG_CTRL, 0);
/* Step 2: 配置周期
* 目标:10 kHz PWM,PL 时钟 100 MHz
* period_clks = 100_000_000 / 10_000 = 10_000
*/
Xil_Out32(REG_PERIOD, 10000);
/* Step 3: 配置占空比 30%
* duty_clks = 10_000 × 0.30 = 3_000
*/
Xil_Out32(REG_DUTY, 3000);
/* Step 4: 使能 PWM */
Xil_Out32(REG_CTRL, CTRL_ENABLE);
xil_printf("PWM started: freq=10kHz, duty=30%%\r\n");
/* Step 5: 读回验证写入是否成功 */
xil_printf("REG_PERIOD read back: %lu\r\n", (u32)Xil_In32(REG_PERIOD));
xil_printf("REG_DUTY read back: %lu\r\n", (u32)Xil_In32(REG_DUTY));
xil_printf("REG_CTRL read back: %lu\r\n", (u32)Xil_In32(REG_CTRL));
/* Step 6: 轮询状态寄存器(计数器在运行时快速变化)*/
for (int i = 0; i < 5; i++) {
usleep(100000); /* 等 100ms */
status = Xil_In32(REG_STATUS);
xil_printf("REG_STATUS (counter snapshot): %lu\r\n", status);
}
/* Step 7: 动态改占空比为 70% */
Xil_Out32(REG_DUTY, 7000);
xil_printf("Duty changed to 70%%\r\n");
/* Step 8: 停止 PWM */
Xil_Out32(REG_CTRL, 0);
xil_printf("PWM stopped\r\n");
return 0;
}
Xil_Out32 / Xil_In32 的实质:这两个函数在 xil_io.h 里定义为:
static inline void Xil_Out32(UINTPTR Addr, u32 Value) {
volatile u32 *LocalAddr = (volatile u32 *)Addr;
*LocalAddr = Value;
}
static inline u32 Xil_In32(UINTPTR Addr) {
return *(volatile u32 *)Addr;
}
就是带 volatile 的指针解引用。volatile 防止编译器把连续读写优化掉(因为每次访问都会走 AXI 总线,不只是读写寄存器)。
🚧 避坑:如果你在 Vitis 里发现
XPAR_MY_PWM_IP_0_S00_AXI_BASEADDR不存在,通常有两个原因:
- XSA 是旧的,重新 Export Hardware 覆盖一次
- IP 名字里有大小写或特殊字符——Vivado 把名字全部大写后拼接成宏名,
my_pwm_ip→MY_PWM_IP,搜索时注意
9. 本篇你应该带走的几个判断
- 寄存器地图要在动手写代码前设计好——改寄存器 = 重新封装 IP
- AXI4-Lite 地址解码用的是
AWADDR[ADDR_LSB+1 : ADDR_LSB],不是低两位 -
slv_reg3做只读状态寄存器的方法:不在写 case 里处理它,读 MUX 里用 PWM 逻辑值覆盖 -
component.xml的路径要加进工程 IP Repository,否则 Block Design 找不到你的 IP - Address Editor 分配的地址 =
xparameters.h里的BASEADDR,两者必须一致 -
Xil_Out32/Xil_In32是 volatile 指针操作,不是”驱动”——真正的生产驱动要处理中断和 cache
10. 下一篇预告
下一篇 《Zynq 实战 07|IP 级仿真与 ILA 在线调试》,我们会:
- 在 Vivado Simulator 里对
my_pwm_ip写 testbench,验证 AXI 握手时序 - 用 ILA(Integrated Logic Analyzer)在板子上抓 AXI 总线波形
- 教你怎么在不重新跑综合的情况下在已有 bitstream 里插入 ILA(Partial Reconfiguration Debug)
参考资料
| 文档号 | 名称 | 用途 |
|---|---|---|
| UG585 | Zynq-7000 SoC Technical Reference Manual | 第 5 章 Table 5-1:GP/HP/ACP 地址空间划分 |
| UG994 | Vivado Design Suite: Designing IP Subsystems Using IP Integrator | IP Packager 完整操作指南 |
| UG1118 | Creating and Packaging Custom IP using the IP Packager | component.xml 结构、Ports/Interfaces 配置 |
| PG021 | AXI4-Lite IPIF Product Guide | AXI4-Lite 从机接口参考实现 |
| UG761 | AXI Reference Guide | AXI 握手协议时序,2.2 节写通道,2.3 节读通道 |
| DS190 | Zynq-7000 SoC Data Sheet: Overview | XC7Z020-1 资源数字来源 |
这是《Zynq FPGA 嵌入式系统设计实战》系列第 6 篇。 如果你在自定义 IP 开发中踩到了其他坑,欢迎留言。