← ブログ一覧へ
FPGAZynqXilinxAXI自定义IPVerilogIP PackagerVivado嵌入式系统

Zynq 实战 06|自定义 IP 核开发:从 AXI4-Lite 模板到可跑的 PWM 控制器

この記事は中国語で書かれ、Google 翻訳で自動翻訳されています。
中国語の原文を見る →

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 接口访问:

偏移地址寄存器名读/写说明
0x00REG_PERIODR/WPWM 周期(单位:PL 时钟周期数)
0x04REG_DUTYR/W占空比(高电平持续时钟周期数,必须 < PERIOD)
0x08REG_CTRLR/W使能位 [0]:1 = 启动 PWM,0 = 停止并输出低
0x0CREG_STATUSR 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

现在我们有了模板骨架,要做的事情很明确:

  1. my_pwm_ip_v1_0_S00_AXI.v 里增加 pwm_out 输出端口
  2. 增加 PWM 计数器逻辑,读取 slv_reg0/slv_reg1/slv_reg2
  3. 把当前计数器值写回 slv_reg3(只读状态寄存器)
  4. 在顶层 my_pwm_ip_v1_0.vpwm_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(不是 clockreset
  • 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 内部架构图

my_pwm_ip 内部模块架构 PS M_AXI_GP0 Master AW: AWADDR AWVALID/READY W: WDATA[31:0] WSTRB[3:0] B: BRESP BVALID/READY AR: ARADDR ARVALID/READY R: RDATA[31:0] RVALID/READY AXI4-Lite Slave my_pwm_ip_v1_0_S00_AXI AW/W 握手状态机 地址锁存 + WSTRB 写使能 B 响应状态机 BRESP = OKAY (2'b00) AR/R 握手状态机 地址解码 + 读数据 MUX 寄存器文件 slv_reg0 [31:0] REG_PERIOD slv_reg1 [31:0] REG_DUTY slv_reg2 [31:0] REG_CTRL slv_reg3 [31:0] REG_STATUS ← (只读,由 PWM 逻辑写入) R/B ch AW/W ch PWM 用户逻辑 (内嵌在 S00_AXI 模块中) pwm_counter [31:0] 0 → PERIOD-1 循环计数 enable=0 时归零 比较器 counter < duty → high counter ≥ duty → low pwm_reg (D-FF) 寄存器输出,消除毛刺 → assign pwm_out 状态回写 counter → slv_reg3 reg0/1/2 counter pwm_out FPGA 引脚 S_AXI_ACLK (100 MHz) · S_AXI_ARESETN (低有效) 所有模块共用同一时钟域,无跨时钟域问题
图 1. my_pwm_ip 内部模块架构:AXI4-Lite Slave 握手状态机 + 寄存器文件 + PWM 用户逻辑

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_ACLKS_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 里分配的地址。HIGHADDRBASEADDR + 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 不存在,通常有两个原因:

  1. XSA 是旧的,重新 Export Hardware 覆盖一次
  2. IP 名字里有大小写或特殊字符——Vivado 把名字全部大写后拼接成宏名,my_pwm_ipMY_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)

参考资料

文档号名称用途
UG585Zynq-7000 SoC Technical Reference Manual第 5 章 Table 5-1:GP/HP/ACP 地址空间划分
UG994Vivado Design Suite: Designing IP Subsystems Using IP IntegratorIP Packager 完整操作指南
UG1118Creating and Packaging Custom IP using the IP Packagercomponent.xml 结构、Ports/Interfaces 配置
PG021AXI4-Lite IPIF Product GuideAXI4-Lite 从机接口参考实现
UG761AXI Reference GuideAXI 握手协议时序,2.2 节写通道,2.3 节读通道
DS190Zynq-7000 SoC Data Sheet: OverviewXC7Z020-1 资源数字来源

这是《Zynq FPGA 嵌入式系统设计实战》系列第 6 篇。 如果你在自定义 IP 开发中踩到了其他坑,欢迎留言。