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.
| Board | TUL PYNQ-Z2 |
|---|---|
| Time | about 6 hours |
| Language | verilog |
| Extra parts | TUL 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.
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
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
- Create Project, RTL project, select your board under Boards.
- Add Sources and add
axi_lite_regs.v. Add Constraints and add the XDC. - Create Block Design. Add IP, ZYNQ7 Processing System. Click Run Block Automation and accept the defaults, which applies the board preset.
- Double-click the PS, PS-PL Configuration, and confirm
M AXI GP0 interfaceis enabled. Under Clock Configuration confirm FCLK_CLK0 is 100 MHz. - Right-click the canvas, Add Module, choose
axi_lite_regs. Vivado shows a singles_axibus port because the port names follow the AXI naming pattern. - Run Connection Automation, tick
s_axi, accept. The interconnect and reset block appear. - Right-click the
ledpin, Make External. Rename the port toledto match the XDC. - Address Editor: set the slave’s offset to
0x4000_0000and range 64 K. - Validate Design, then in Sources right-click the block design, Create HDL Wrapper, let Vivado manage it.
- 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 theIRQ_F2Pport, 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
MMIOand Vitis’sXil_Out32are the same thing: a pointer into the AXI address space. - An identifier register is the cheapest debugging aid a peripheral can have.