Project

Your first custom AXI peripheral on the PYNQ-Z2 (or Zybo Z7)

Write an AXI4-Lite slave with four registers by hand, drop it into a Zynq block design, export the hardware, and drive it from Python on PYNQ or from bare-metal C in Vitis.

AdvancedAMDUpdated 13 Nov 2026
BoardTUL PYNQ-Z2
Timeabout 6 hours
Languageverilog
Extra partsTUL PYNQ-Z2 or Digilent Zybo Z7-20, microSD card with the PYNQ image (for the Python route), Micro-USB cable, Ethernet cable or Wi-Fi dongle for PYNQ, Computer with Vivado and Vitis

What you will build

On a Zynq-7000 the ARM cores and the programmable logic talk over AXI, and the fastest way to understand that bus is to write a slave for it yourself rather than clicking through the IP wizard. This project builds an AXI4-Lite peripheral with four 32-bit registers: two control registers, a status register, and a counter that increments every clock. Four bits of the first control register drive the board’s LEDs so you can see a write land. The peripheral goes into a Vivado block design next to the Zynq processing system, gets an address, and is then driven two ways: from a Python session on the PYNQ-Z2 with MMIO, and from a bare-metal C program in Vitis. The Zybo Z7-20 has the same device and works with a different LED constraint, noted where it matters.

This is an advanced project not because the Verilog is long (it is about 120 lines) but because the handshake rules have to be right, and because the flow crosses from RTL into block designs, hardware export and software. You should be comfortable with state machines and have been through From RTL to board at least once.

What you need

  • A TUL PYNQ-Z2 or a Digilent Zybo Z7-20 (both Zynq XC7Z020-1CLG400C). The cheaper Zybo Z7-10 also works, with the part changed to xc7z010clg400-1.
  • Vivado and Vitis, the free edition. At the time of writing 2025.1 is current. Install the board files for your board so the processing-system preset (DDR, clocks, MIO) is applied for you.
  • For the Python route: a microSD card with the PYNQ image, a network connection to the board, and a browser or SSH.
  • For the C route: the micro-USB cable, which carries JTAG and a UART.

Design

Registers

Offset Name Access Contents
0x00 ctrl0 read/write bit 0 counter enable, bit 1 counter clear (self-clearing), bits 7:4 LEDs
0x04 ctrl1 read/write compare value
0x08 status read only bits 31:16 identifier 0x1A2B, bit 1 counter has reached ctrl1 (sticky until clear), bit 0 enable
0x0C counter read only increments every clock while enabled

The identifier in the status register is a habit worth keeping: the first thing software does is read it, and a wrong value tells you the address map is off before you waste time on anything else.

AXI4-Lite in five channels

AXI4-Lite has five independent channels, each with a valid from the sender and a ready from the receiver. A transfer happens on the clock edge where both are high. The write side uses three channels: write address (AW), write data (W) and write response (B). The read side uses two: read address (AR) and read data (R). The two rules that catch people are that valid must not wait for ready (the master asserts valid and holds it until the transfer completes) and that a slave may assert ready before valid, or after, as it likes, so long as it does not make ready depend combinationally on valid in a way that creates a loop.

An AXI4-Lite write. The master presents address and data together, the slave accepts both on the same edge, writes the register, and answers on the B channel.clkawvalidawaddr0x00awreadywvalidwdata0xA1wreadybvalidbreadyctrl0old0xA1
An AXI4-Lite write. The master presents address and data together, the slave accepts both on the same edge, writes the register, and answers on the B channel.

The slave here accepts an address or data whenever it is not already holding one, remembers each in a register, and performs the write once both have arrived. That handles masters that send the address first, the data first, or both together. The Zynq general-purpose port sends both together, but the testbench exercises the other order as well. The response channel raises bvalid after the write and drops it when the master takes it with bready.

Reads are simpler. The slave accepts the address whenever it is not already presenting data, registers the selected value into rdata with rvalid high, and clears rvalid when rready takes it. Only address bits 3:2 are decoded. The interconnect has already checked that the address falls in this slave’s range, so the upper bits are redundant here.

The system

Processing system (PS)Cortex-A9x2, 650 MHzDDR3512 MBUART, SD,Ethernet, USBM_AXI_GP0AXI3 masterFCLK_CLK0 100 MHz, FCLK_RESET0_NProgrammable logic (PL)interconnectAXI3 to AXI4-Liteaxi_lite_regs0x4000_0000LEDsproc_sys_resetsynchronised rst_nclk, rst_n
The Zynq processing system reaches the custom slave through the general-purpose AXI master port and the interconnect that Vivado adds for you.

The Zynq’s GP port speaks AXI3. Vivado’s connection automation inserts an interconnect (or a protocol converter) between it and the AXI4-Lite slave, plus a proc_sys_reset block that turns the PS reset into a clean synchronous active-low reset in the FCLK_CLK0 domain. The attributes on the clk and rst_n ports in the Verilog tell Vivado which bus they belong to, so the automation wires them correctly.

Code

The slave

// Hand-written AXI4-Lite slave with four 32-bit registers.
//   0x00  ctrl0   RW  bit 0 = counter enable, bit 1 = counter clear (self-clearing), bits 7:4 = LEDs
//   0x04  ctrl1   RW  compare value
//   0x08  status  RO  bits 31:16 = 0x1A2B id, bit 1 = counter reached ctrl1 (sticky), bit 0 = enable
//   0x0C  counter RO  increments every clock while enabled
module axi_lite_regs (
    (* X_INTERFACE_INFO = "xilinx.com:signal:clock:1.0 clk CLK" *)
    (* X_INTERFACE_PARAMETER = "ASSOCIATED_BUSIF s_axi, ASSOCIATED_RESET rst_n" *)
    input  wire        clk,
    (* X_INTERFACE_INFO = "xilinx.com:signal:reset:1.0 rst_n RST" *)
    (* X_INTERFACE_PARAMETER = "POLARITY ACTIVE_LOW" *)
    input  wire        rst_n,

    // Write address channel. Only bits 3:2 are decoded here; the interconnect handles the rest.
    /* verilator lint_off UNUSEDSIGNAL */
    input  wire [11:0] s_axi_awaddr,
    /* verilator lint_on UNUSEDSIGNAL */
    input  wire        s_axi_awvalid,
    output wire        s_axi_awready,
    // Write data channel
    input  wire [31:0] s_axi_wdata,
    input  wire [3:0]  s_axi_wstrb,
    input  wire        s_axi_wvalid,
    output wire        s_axi_wready,
    // Write response channel
    output wire [1:0]  s_axi_bresp,
    output reg         s_axi_bvalid,
    input  wire        s_axi_bready,
    // Read address channel
    /* verilator lint_off UNUSEDSIGNAL */
    input  wire [11:0] s_axi_araddr,
    /* verilator lint_on UNUSEDSIGNAL */
    input  wire        s_axi_arvalid,
    output wire        s_axi_arready,
    // Read data channel
    output reg  [31:0] s_axi_rdata,
    output wire [1:0]  s_axi_rresp,
    output reg         s_axi_rvalid,
    input  wire        s_axi_rready,

    output wire [3:0]  led
);
    wire [1:0] waddr_sel = s_axi_awaddr[3:2];
    wire [1:0] raddr_sel = s_axi_araddr[3:2];

    reg [31:0] ctrl0, ctrl1, counter;
    reg        reached;

    wire enable = ctrl0[0];
    wire clear  = ctrl0[1];
    assign led  = ctrl0[7:4];

    wire [31:0] status = {16'h1A2B, 14'd0, reached, enable};

    // ---------------- Write path ----------------
    // Address and data may arrive in either order. Hold each until both are present.
    reg        aw_held, w_held;
    reg [1:0]  waddr_q;
    reg [31:0] wdata_q;
    reg [3:0]  wstrb_q;

    assign s_axi_awready = ~aw_held;
    assign s_axi_wready  = ~w_held;
    assign s_axi_bresp   = 2'b00;   // OKAY

    wire do_write = aw_held & w_held & (~s_axi_bvalid | s_axi_bready);

    // Merge new bytes into an existing word according to the strobes.
    function [31:0] merge(input [31:0] old, input [31:0] new_data, input [3:0] strb);
        integer b;
        begin
            merge = old;
            for (b = 0; b < 4; b = b + 1)
                if (strb[b]) merge[8*b +: 8] = new_data[8*b +: 8];
        end
    endfunction

    always @(posedge clk) begin
        if (!rst_n) begin
            aw_held      <= 1'b0;
            w_held       <= 1'b0;
            waddr_q      <= 2'd0;
            wdata_q      <= 32'd0;
            wstrb_q      <= 4'd0;
            s_axi_bvalid <= 1'b0;
            ctrl0        <= 32'd0;
            ctrl1        <= 32'd0;
        end else begin
            if (s_axi_awvalid & s_axi_awready) begin
                aw_held <= 1'b1;
                waddr_q <= waddr_sel;
            end
            if (s_axi_wvalid & s_axi_wready) begin
                w_held  <= 1'b1;
                wdata_q <= s_axi_wdata;
                wstrb_q <= s_axi_wstrb;
            end
            if (s_axi_bvalid & s_axi_bready) s_axi_bvalid <= 1'b0;

            // The clear bit lasts one clock.
            ctrl0[1] <= 1'b0;

            if (do_write) begin
                case (waddr_q)
                    2'd0: ctrl0 <= merge(ctrl0, wdata_q, wstrb_q);
                    2'd1: ctrl1 <= merge(ctrl1, wdata_q, wstrb_q);
                    default: ;   // status and counter are read-only, writes are accepted and ignored
                endcase
                aw_held      <= 1'b0;
                w_held       <= 1'b0;
                s_axi_bvalid <= 1'b1;
            end
        end
    end

    // ---------------- Counter and status ----------------
    always @(posedge clk) begin
        if (!rst_n || clear) begin
            counter <= 32'd0;
            reached <= 1'b0;
        end else begin
            if (enable) begin
                counter <= counter + 32'd1;
                if (counter == ctrl1) reached <= 1'b1;
            end
        end
    end

    // ---------------- Read path ----------------
    assign s_axi_arready = ~s_axi_rvalid;
    assign s_axi_rresp   = 2'b00;

    always @(posedge clk) begin
        if (!rst_n) begin
            s_axi_rvalid <= 1'b0;
            s_axi_rdata  <= 32'd0;
        end else begin
            if (s_axi_rvalid & s_axi_rready) s_axi_rvalid <= 1'b0;
            if (s_axi_arvalid & s_axi_arready) begin
                s_axi_rvalid <= 1'b1;
                case (raddr_sel)
                    2'd0: s_axi_rdata <= ctrl0;
                    2'd1: s_axi_rdata <= ctrl1;
                    2'd2: s_axi_rdata <= status;
                    default: s_axi_rdata <= counter;
                endcase
            end
        end
    end
endmodule

Testbench

The testbench is a minimal AXI master written as tasks. It checks reset values, read-back, byte strobes, the data-before-address ordering, that the counter runs and freezes, that the clear bit self-clears, that the compare flag sets, and that a write to a read-only register is accepted and ignored.

`timescale 1ns / 1ps
// Drives the register block the way a processor would and checks every register.
module tb_axi_lite;
    reg clk = 1'b0;
    reg rst_n = 1'b0;
    always #5 clk = ~clk;   // 100 MHz, like FCLK_CLK0 on the Zynq

    reg  [11:0] awaddr = 0;  reg awvalid = 0;  wire awready;
    reg  [31:0] wdata = 0;   reg [3:0] wstrb = 0; reg wvalid = 0; wire wready;
    wire [1:0]  bresp;       wire bvalid;      reg  bready = 0;
    reg  [11:0] araddr = 0;  reg arvalid = 0;  wire arready;
    wire [31:0] rdata;       wire [1:0] rresp; wire rvalid; reg rready = 0;
    wire [3:0]  led;

    axi_lite_regs dut (
        .clk(clk), .rst_n(rst_n),
        .s_axi_awaddr(awaddr), .s_axi_awvalid(awvalid), .s_axi_awready(awready),
        .s_axi_wdata(wdata), .s_axi_wstrb(wstrb), .s_axi_wvalid(wvalid), .s_axi_wready(wready),
        .s_axi_bresp(bresp), .s_axi_bvalid(bvalid), .s_axi_bready(bready),
        .s_axi_araddr(araddr), .s_axi_arvalid(arvalid), .s_axi_arready(arready),
        .s_axi_rdata(rdata), .s_axi_rresp(rresp), .s_axi_rvalid(rvalid), .s_axi_rready(rready),
        .led(led)
    );

    integer errors = 0;

    // Write with address and data presented together, as the Zynq GP port does.
    task axi_write(input [11:0] addr, input [31:0] data, input [3:0] strb);
        begin
            @(posedge clk);
            awaddr <= addr; awvalid <= 1'b1;
            wdata  <= data; wstrb <= strb; wvalid <= 1'b1;
            bready <= 1'b1;
            fork
                begin @(posedge clk); while (!awready) @(posedge clk); awvalid <= 1'b0; end
                begin @(posedge clk); while (!wready)  @(posedge clk); wvalid  <= 1'b0; end
            join
            while (!bvalid) @(posedge clk);
            @(posedge clk);
            bready <= 1'b0;
            if (bresp !== 2'b00) begin errors = errors + 1; $display("FAIL: bresp %b", bresp); end
        end
    endtask

    // Write with the data channel first and the address a few clocks later.
    task axi_write_data_first(input [11:0] addr, input [31:0] data);
        begin
            @(posedge clk);
            wdata <= data; wstrb <= 4'hF; wvalid <= 1'b1; bready <= 1'b1;
            @(posedge clk); while (!wready) @(posedge clk); wvalid <= 1'b0;
            repeat (3) @(posedge clk);
            awaddr <= addr; awvalid <= 1'b1;
            @(posedge clk); while (!awready) @(posedge clk); awvalid <= 1'b0;
            while (!bvalid) @(posedge clk);
            @(posedge clk);
            bready <= 1'b0;
        end
    endtask

    task axi_read(input [11:0] addr, output [31:0] data);
        begin
            @(posedge clk);
            araddr <= addr; arvalid <= 1'b1; rready <= 1'b1;
            @(posedge clk); while (!arready) @(posedge clk); arvalid <= 1'b0;
            while (!rvalid) @(posedge clk);
            data = rdata;
            @(posedge clk);
            rready <= 1'b0;
        end
    endtask

    task check(input [255:0] name, input [31:0] got, input [31:0] want);
        begin
            if (got !== want) begin
                errors = errors + 1;
                $display("FAIL: %0s = 0x%08h, expected 0x%08h", name, got, want);
            end else begin
                $display("ok:   %0s = 0x%08h", name, got);
            end
        end
    endtask

    reg [31:0] v, v2;

    initial begin
        $dumpfile("tb_axi_lite.vcd");
        $dumpvars(0, tb_axi_lite);
        repeat (4) @(posedge clk);
        rst_n = 1'b1;
        repeat (2) @(posedge clk);

        axi_read(12'h008, v);
        check("status after reset", v, 32'h1A2B_0000);

        axi_write(12'h004, 32'hDEAD_BEEF, 4'hF);
        axi_read(12'h004, v);
        check("ctrl1 readback", v, 32'hDEAD_BEEF);

        axi_write(12'h004, 32'h0000_0042, 4'b0001);   // low byte only
        axi_read(12'h004, v);
        check("ctrl1 byte strobe", v, 32'hDEAD_BE42);

        axi_write_data_first(12'h000, 32'h0000_00A1);  // LEDs = 1010, enable
        axi_read(12'h000, v);
        check("ctrl0 readback", v, 32'h0000_00A1);
        check("led pins", {28'd0, led}, 32'h0000_000A);

        axi_read(12'h00C, v);
        axi_read(12'h00C, v2);
        if (v2 > v) $display("ok:   counter advanced from %0d to %0d", v, v2);
        else begin errors = errors + 1; $display("FAIL: counter did not advance (%0d, %0d)", v, v2); end

        axi_write(12'h000, 32'h0000_00A0, 4'hF);       // disable
        axi_read(12'h00C, v);
        axi_read(12'h00C, v2);
        check("counter frozen", v2, v);

        axi_write(12'h004, 32'h0000_0010, 4'hF);       // compare = 16
        axi_write(12'h000, 32'h0000_0003, 4'hF);       // clear then enable
        repeat (40) @(posedge clk);
        axi_read(12'h008, v);
        check("status reached", v, 32'h1A2B_0003);
        axi_read(12'h000, v);
        check("clear bit self-cleared", v, 32'h0000_0001);

        axi_write(12'h00C, 32'hFFFF_FFFF, 4'hF);       // write to read-only register is ignored
        axi_read(12'h008, v);
        check("status unchanged by RO write", v, 32'h1A2B_0003);

        if (errors == 0) $display("PASS: all AXI-Lite checks passed");
        else $display("FAIL: %0d errors", errors);
        $finish;
    end
endmodule

LED constraints

## PYNQ-Z2 user LEDs LD0..LD3 (LVCMOS33)
set_property -dict { PACKAGE_PIN R14 IOSTANDARD LVCMOS33 } [get_ports {led[0]}]
set_property -dict { PACKAGE_PIN P14 IOSTANDARD LVCMOS33 } [get_ports {led[1]}]
set_property -dict { PACKAGE_PIN N16 IOSTANDARD LVCMOS33 } [get_ports {led[2]}]
set_property -dict { PACKAGE_PIN M14 IOSTANDARD LVCMOS33 } [get_ports {led[3]}]

## Zybo Z7 alternative: LD0..LD3 are M14, M15, G14, D18

Build and program

Simulate

iverilog -g2012 -o axi.vvp axi_lite_regs.v tb_axi_lite.v
vvp axi.vvp
verilator --lint-only -Wall axi_lite_regs.v

Expect ten ok: lines, PASS: all AXI-Lite checks passed, and a clean Verilator report.

Vivado block design in Tcl

The script creates the project, instantiates the Zynq PS with the board preset, adds the Verilog as a module reference, runs connection automation, assigns the address, builds the bitstream and exports the hardware platform. Change board and the part for the Zybo Z7, and swap the LED constraints. Run it with vivado -mode batch -source build_bd.tcl.

# Build the Zynq block design around the hand-written slave.
# Run: vivado -mode batch -source build_bd.tcl
set proj_dir ./regs_proj
set board    "tul.com.tw:pynq-z2:part0:1.0"      ;# Zybo Z7-20: digilentinc.com:zybo-z7-20:part0:1.1
set part     xc7z020clg400-1

create_project regs $proj_dir -part $part -force
set_property board_part $board [current_project]
add_files {axi_lite_regs.v}
add_files -fileset constrs_1 {pynq_z2.xdc}

create_bd_design design_1
create_bd_cell -type ip -vlnv xilinx.com:ip:processing_system7:5.5 ps
apply_bd_automation -rule xilinx.com:bd_rule:processing_system7 \
    -config {make_external "FIXED_IO, DDR" apply_board_preset "1"} [get_bd_cells ps]
set_property -dict [list CONFIG.PCW_USE_M_AXI_GP0 {1} CONFIG.PCW_FCLK0_PERIPHERAL_CLKSRC {IO PLL} \
    CONFIG.PCW_FPGA0_PERIPHERAL_FREQMHZ {100}] [get_bd_cells ps]

# Our RTL becomes a block design cell. Vivado infers the s_axi interface from the port names.
create_bd_cell -type module -reference axi_lite_regs regs
make_bd_pins_external [get_bd_pins regs/led]
set_property name led [get_bd_ports led_0]

apply_bd_automation -rule xilinx.com:bd_rule:axi4 \
    -config {Master "/ps/M_AXI_GP0" Clk "Auto"} [get_bd_intf_pins regs/s_axi]

# Give the slave a fixed address so the software side can hard-code it.
assign_bd_address -offset 0x40000000 -range 64K [get_bd_addr_segs {regs/s_axi/reg0}]

validate_bd_design
save_bd_design
make_wrapper -files [get_files design_1.bd] -top -import
set_property top design_1_wrapper [current_fileset]

launch_runs impl_1 -to_step write_bitstream -jobs 4
wait_on_run impl_1
write_hw_platform -fixed -include_bit -force regs.xsa

Vivado block design in the GUI

  1. Create Project, RTL project, select your board under Boards.
  2. Add Sources and add axi_lite_regs.v. Add Constraints and add the XDC.
  3. Create Block Design. Add IP, ZYNQ7 Processing System. Click Run Block Automation and accept the defaults, which applies the board preset.
  4. Double-click the PS, PS-PL Configuration, and confirm M AXI GP0 interface is enabled. Under Clock Configuration confirm FCLK_CLK0 is 100 MHz.
  5. Right-click the canvas, Add Module, choose axi_lite_regs. Vivado shows a single s_axi bus port because the port names follow the AXI naming pattern.
  6. Run Connection Automation, tick s_axi, accept. The interconnect and reset block appear.
  7. Right-click the led pin, Make External. Rename the port to led to match the XDC.
  8. Address Editor: set the slave’s offset to 0x4000_0000 and range 64 K.
  9. Validate Design, then in Sources right-click the block design, Create HDL Wrapper, let Vivado manage it.
  10. Generate Bitstream. Then File, Export, Export Hardware, include bitstream, which writes the XSA.

Where the files are

PYNQ needs the bitstream and the hardware handoff file with matching names. After the build:

cp regs_proj/regs.runs/impl_1/design_1_wrapper.bit regs.bit
cp regs_proj/regs.gen/sources_1/bd/design_1/hw_handoff/design_1.hwh regs.hwh
scp regs.bit regs.hwh xilinx@pynq:/home/xilinx/regs/

Vitis needs only regs.xsa.

Test

From Python on PYNQ

Boot the board from the PYNQ card, open a Jupyter notebook or SSH in (user xilinx, password xilinx), and run:

from pynq import Overlay, MMIO
import time

ol = Overlay('/home/xilinx/regs/regs.bit')   # regs.hwh must sit beside it
print(ol.ip_dict.keys())                       # expect something like 'regs'

BASE = 0x4000_0000
CTRL0, CTRL1, STATUS, COUNTER = 0x00, 0x04, 0x08, 0x0C
mmio = MMIO(BASE, 0x1_0000)

print(hex(mmio.read(STATUS)))                  # 0x1a2b0000 after reset

mmio.write(CTRL0, 0x0000_00A1)                 # LEDs 1010, counter enabled
a = mmio.read(COUNTER)
time.sleep(0.5)
b = mmio.read(COUNTER)
print(f'counted {(b - a) & 0xFFFF_FFFF} clocks in about half a second')

mmio.write(CTRL1, 1_000_000)                   # compare value
mmio.write(CTRL0, 0x0000_0003)                 # clear, then run
time.sleep(0.01)
print('reached' if mmio.read(STATUS) & 0x2 else 'not yet')

Loading the overlay programs the PL from the bitstream. Expect 0x1a2b0000 for the first read, LEDs 1 and 3 lit after the first write, a count close to 50,000,000 for the half-second gap (the Python sleep is not precise, so a few percent either way), and reached. If the first read returns 0x0, the address does not match the block design. Check ol.ip_dict['regs']['phys_addr'].

From C in Vitis

In Vitis, create a platform from regs.xsa with the standalone operating system on ps7_cortexa9_0, then an application from the Hello World template on that platform. Replace helloworld.c with:

#include <stdio.h>
#include "xil_io.h"
#include "xil_printf.h"
#include "sleep.h"

#define REGS_BASE   0x40000000u
#define REG_CTRL0   (REGS_BASE + 0x00u)
#define REG_CTRL1   (REGS_BASE + 0x04u)
#define REG_STATUS  (REGS_BASE + 0x08u)
#define REG_COUNTER (REGS_BASE + 0x0Cu)

int main(void)
{
    xil_printf("status 0x%08x\r\n", Xil_In32(REG_STATUS));

    Xil_Out32(REG_CTRL0, 0x000000A1u);          /* LEDs 1010, counter enabled */
    u32 a = Xil_In32(REG_COUNTER);
    usleep(100000);                              /* 100 ms */
    u32 b = Xil_In32(REG_COUNTER);
    xil_printf("counted %u clocks in 100 ms\r\n", b - a);

    Xil_Out32(REG_CTRL1, 1000000u);
    Xil_Out32(REG_CTRL0, 0x00000003u);          /* clear, then run */
    usleep(10000);
    xil_printf("status 0x%08x\r\n", Xil_In32(REG_STATUS));

    for (;;) {                                   /* walk the LEDs */
        for (u32 i = 0; i < 4; i++) {
            Xil_Out32(REG_CTRL0, (1u << (4 + i)) | 1u);
            usleep(250000);
        }
    }
    return 0;
}

Build, connect a terminal to the board’s UART at 115,200 baud, and Run on Hardware. Vitis programs the bitstream over JTAG and loads the program. The terminal shows the status word, the number of clocks counted in 100 ms (close to 10,000,000), the status word again with bit 1 set, and the four LEDs walk from LD0 to LD3.

Things to try

  • Add an interrupt: a one-cycle pulse when the counter reaches ctrl1, connected to the PS through the IRQ_F2P port, and handle it in C.
  • Return SLVERR (2'b10) on writes to the read-only registers instead of ignoring them, and see how PYNQ and Vitis report it.
  • Grow the block to 16 registers and generate the register map table from a YAML description with a short Python script.
  • Replace the hand-written slave with one from the Vivado Create and Package IP wizard and compare the generated Verilog with yours.
  • Add an AXI4-Stream output that emits the counter value and connect it to a DMA engine, which is the path to the SoC FPGA topic.

Summary

  • AXI4-Lite is five valid/ready channels, and a slave that holds the write address and data until both arrive handles every master.
  • A module reference lets you drop plain Verilog into a block design, and port-name conventions plus two attributes let Vivado infer the bus.
  • The address editor decides where the registers appear. Software hard-codes that address or reads it from the hardware handoff.
  • PYNQ’s MMIO and Vitis’s Xil_Out32 are the same thing: a pointer into the AXI address space.
  • An identifier register is the cheapest debugging aid a peripheral can have.