Building an AXI-Lite Peripheral
A register-mapped peripheral written from scratch, verified in simulation, packaged as IP and wired into a Vivado block design.
1. The plan: a tiny calculator on the bus
In Chapter 19 you met AXI4-Lite in theory. Now you build a slave from scratch: a peripheral that the ARM can program like any other piece of memory-mapped hardware. To keep the focus on the bus, the “hardware” behind the registers is deliberately simple — it adds or multiplies two operands. Swap that one line for a CRC, a PWM generator or a filter and the bus side stays exactly the same.
Vivado can generate an AXI-Lite template for you, and it works. But it is long, uses its own naming, and hides the ideas behind generated code. Writing your own once is the best way to understand what every AXI peripheral does — and it turns out to be about 120 lines.
2. Designing the write path
The hard part of an AXI-Lite slave is the write. The address arrives on the AW channel and the data on the W channel, and the standard allows them to come in any order: address first, data first, or both in the same cycle. A slave that assumes they arrive together will work with one master and hang with another.
The clean solution is to give each channel its own one-word holding register with a “full” flag. Each channel's READY simply says “my holding register is empty”. When both flags are set, the slave performs the write, clears both flags, and raises BVALID with BRESP = OKAY. While a response is waiting, it accepts nothing new — one write at a time is plenty for a register bank.
Byte enables. WSTRB has one bit per byte of WDATA. Bit \(i\) set means “update bits \(8i+7\) down to \(8i\)”. The ARM uses this when software stores a single byte or a 16-bit half-word to a 32-bit register. A slave that ignores WSTRB will corrupt the other bytes on such writes:
The read path is simpler: when ARVALID and ARREADY are both high, the slave latches the selected register into RDATA and raises RVALID. ARREADY is low while a read response is waiting, so the master cannot overrun it. Both responses are always OKAY (00); a slave may return SLVERR (10) for illegal accesses, but for four registers that is not worth the logic.
3. The complete peripheral
The port names follow the convention s_axi_<channel><signal>, plus s_axi_aclk and the active-low reset s_axi_aresetn. AXI resets are active-low by tradition; inside, it is still a synchronous reset like every other one in this course. Using these names is not just tidy: Vivado's IP packager recognises them and groups them into one AXI4-Lite interface automatically.
// AXI4-Lite slave with four 32-bit registers, written from scratch.
// 0x0 CTRL R/W bit 0: OP (0 = add, 1 = multiply)
// 0x4 A R/W operand a
// 0x8 B R/W operand b
// 0xC RESULT R a + b, or a[15:0] * b[15:0] (exact 32-bit product)
// Port names follow the AXI convention so Vivado's IP packager recognises
// the s_axi interface, its clock and its active-low reset automatically.
module axil_calc #(
parameter ADDR_W = 4 // 16 bytes = 4 registers
) (
input wire s_axi_aclk,
input wire s_axi_aresetn, // active-low, synchronous
// write address channel
input wire [ADDR_W-1:0] s_axi_awaddr,
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 = 1'b0,
input wire s_axi_bready,
// read address channel
input wire [ADDR_W-1:0] s_axi_araddr,
input wire s_axi_arvalid,
output wire s_axi_arready,
// read data channel
output reg [31:0] s_axi_rdata = 32'd0,
output wire [1:0] s_axi_rresp,
output reg s_axi_rvalid = 1'b0,
input wire s_axi_rready
);
reg [31:0] ctrl = 32'd0, a = 32'd0, b = 32'd0, result = 32'd0;
// A write's address and data may arrive in either order: catch each one
// in its own holding register, and act once both are present.
reg aw_full = 1'b0, w_full = 1'b0;
reg [ADDR_W-1:0] aw_addr = {ADDR_W{1'b0}};
reg [31:0] w_data = 32'd0;
reg [3:0] w_strb = 4'd0;
// READY depends only on our own flip-flops, never on the master's VALID
assign s_axi_awready = !aw_full && !s_axi_bvalid;
assign s_axi_wready = !w_full && !s_axi_bvalid;
assign s_axi_arready = !s_axi_rvalid;
assign s_axi_bresp = 2'b00; // OKAY
assign s_axi_rresp = 2'b00; // OKAY
// Byte-wise update of a register under WSTRB
function [31:0] merge(input [31:0] old, input [31:0] data, input [3:0] strb);
integer i;
begin
for (i = 0; i < 4; i = i + 1)
merge[8*i +: 8] = strb[i] ? data[8*i +: 8] : old[8*i +: 8];
end
endfunction
always @(posedge s_axi_aclk) begin
if (!s_axi_aresetn) begin
aw_full <= 1'b0;
w_full <= 1'b0;
s_axi_bvalid <= 1'b0;
s_axi_rvalid <= 1'b0;
ctrl <= 32'd0; a <= 32'd0; b <= 32'd0;
end else begin
// ---- write path
if (s_axi_awvalid && s_axi_awready) begin
aw_full <= 1'b1;
aw_addr <= s_axi_awaddr;
end
if (s_axi_wvalid && s_axi_wready) begin
w_full <= 1'b1;
w_data <= s_axi_wdata;
w_strb <= s_axi_wstrb;
end
if (aw_full && w_full && !s_axi_bvalid) begin
case (aw_addr[3:2])
2'd0: ctrl <= merge(ctrl, w_data, w_strb);
2'd1: a <= merge(a, w_data, w_strb);
2'd2: b <= merge(b, w_data, w_strb);
default: ; // RESULT is read-only
endcase
aw_full <= 1'b0;
w_full <= 1'b0;
s_axi_bvalid <= 1'b1; // answer OKAY
end
if (s_axi_bvalid && s_axi_bready)
s_axi_bvalid <= 1'b0;
// ---- read path
if (s_axi_arvalid && s_axi_arready) begin
case (s_axi_araddr[3:2])
2'd0: s_axi_rdata <= ctrl;
2'd1: s_axi_rdata <= a;
2'd2: s_axi_rdata <= b;
default: s_axi_rdata <= result;
endcase
s_axi_rvalid <= 1'b1;
end else if (s_axi_rvalid && s_axi_rready)
s_axi_rvalid <= 1'b0;
end
// ---- the "hardware" behind the registers: one registered ALU
if (ctrl[0])
result <= a[15:0] * b[15:0]; // 16x16 -> 32 bits, one DSP48
else
result <= a + b;
end
endmoduleTwo details worth noticing. The register index is bits [3:2] of the address: bits [1:0] select a byte inside a 32-bit word and are ignored, as is conventional. And the ALU is registered: RESULT updates one clock after CTRL, A or B change. Software that waits for each write response before reading, as the testbench does, therefore never sees a stale value; from the ARM, a read issued after the write to the same peripheral normally arrives several cycles later, which is also enough. If you replace the ALU by something slower, see Problem 5.
In VHDL the register index is first copied into a variable sel before the case: a case on a slice of a generic-width vector is not allowed in VHDL-93, because the choices must have a locally static type. Verilog has no such restriction.
READY output in this design is a function of the slave's own state (aw_full, w_full, bvalid, rvalid), never of the master's inputs. That guarantees there is no combinational loop through the interconnect, keeps timing paths short, and makes the “either order” behaviour automatic.4. An AXI-Lite master in the testbench
To test a slave you need a master. The testbench wraps the protocol in two procedures, axi_write(addr, data, strb, aw_dly, w_dly) and axi_read(addr, data), so the test itself reads like software. The two delay arguments let the same task send the address first, the data first, or both together — the three cases the slave must survive. Inputs change on the falling edge and handshakes are sampled on the rising edge, so there are no races between testbench and design.
`timescale 1ns/1ps
// Testbench with a small AXI-Lite master: write and read tasks.
// Writes are issued with address first, data first, or both together.
module axil_calc_tb;
reg clk = 0, aresetn = 0;
always #5 clk = ~clk; // 100 MHz, like FCLK_CLK0
reg [3:0] awaddr = 0, araddr = 0;
reg awvalid = 0, wvalid = 0, bready = 0, arvalid = 0, rready = 0;
reg [31:0] wdata = 0;
reg [3:0] wstrb = 0;
wire awready, wready, bvalid, arready, rvalid;
wire [1:0] bresp, rresp;
wire [31:0] rdata;
integer errors = 0;
axil_calc dut (
.s_axi_aclk(clk), .s_axi_aresetn(aresetn),
.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));
// AW is offered after aw_dly cycles, W after w_dly cycles. Signals change
// on the falling edge and handshakes are sampled on the rising edge.
task axi_write(input [3:0] addr, input [31:0] data, input [3:0] strb,
input integer aw_dly, input integer w_dly);
integer t;
reg aw_done, w_done;
begin
t = 0; aw_done = 0; w_done = 0;
while (!(aw_done && w_done)) begin
@(negedge clk);
awaddr = addr; awvalid = !aw_done && t >= aw_dly;
wdata = data; wstrb = strb; wvalid = !w_done && t >= w_dly;
@(posedge clk);
if (awvalid && awready) aw_done = 1;
if (wvalid && wready) w_done = 1;
t = t + 1;
end
@(negedge clk);
awvalid = 0; wvalid = 0; bready = 1;
@(posedge clk);
while (!bvalid) @(posedge clk);
if (bresp !== 2'b00) begin
$display("FAIL: BRESP=%b on write to 0x%h", bresp, addr);
errors = errors + 1;
end
@(negedge clk) bready = 0;
end
endtask
task axi_read(input [3:0] addr, output [31:0] data);
begin
@(negedge clk);
araddr = addr; arvalid = 1;
@(posedge clk);
while (!arready) @(posedge clk);
@(negedge clk);
arvalid = 0; rready = 1;
@(posedge clk);
while (!rvalid) @(posedge clk);
data = rdata;
if (rresp !== 2'b00) begin
$display("FAIL: RRESP=%b on read from 0x%h", rresp, addr);
errors = errors + 1;
end
@(negedge clk) rready = 0;
end
endtask
task check_reg(input [3:0] addr, input [31:0] want);
reg [31:0] got;
begin
axi_read(addr, got);
if (got !== want) begin
$display("FAIL: reg 0x%h = 0x%h, expected 0x%h", addr, got, want);
errors = errors + 1;
end else
$display(" read 0x%h -> 0x%h", addr, got);
end
endtask
initial begin
repeat (4) @(posedge clk);
aresetn <= 1;
axi_write(4'h4, 32'd1234, 4'hF, 0, 0); // A: address and data together
axi_write(4'h8, 32'd5678, 4'hF, 0, 3); // B: address 3 cycles before data
axi_write(4'h0, 32'd0, 4'hF, 3, 0); // CTRL = add: data first
check_reg(4'hC, 32'd6912); // 1234 + 5678
axi_write(4'h4, 32'd40000, 4'hF, 1, 2);
axi_write(4'h8, 32'd50000, 4'hF, 2, 1);
axi_write(4'h0, 32'd1, 4'hF, 0, 0); // CTRL = multiply
check_reg(4'hC, 32'd2000000000); // 40000 * 50000
axi_write(4'h8, 32'hAABBCCDD, 4'b0011, 0, 0); // only the two low bytes
check_reg(4'h8, 32'h0000CCDD); // 50000 = 0x0000C350
axi_write(4'hC, 32'hDEADBEEF, 4'hF, 0, 0); // RESULT is read-only
check_reg(4'hC, 32'd40000 * 32'h0000CCDD);
check_reg(4'h0, 32'd1);
check_reg(4'h4, 32'd40000);
if (errors == 0) $display("PASS: all AXI-Lite reads and writes correct");
else $display("FAIL: %0d errors", errors);
$finish;
end
endmoduleiverilog -g2012 -o tb axil_calc.v axil_calc_tb.v && vvp tb # read 0xc -> 0x00001b00 # read 0xc -> 0x77359400 # read 0x8 -> 0x0000ccdd # read 0xc -> 0x7d09e340 # read 0x0 -> 0x00000001 # read 0x4 -> 0x00009c40 # PASS: all AXI-Lite reads and writes correct (at 630 ns) ghdl -a --std=08 axil_calc.vhd axil_calc_tb.vhd ghdl -e --std=08 axil_calc_tb && ghdl -r --std=08 axil_calc_tb # same six reads, PASS at 630 ns
The test covers addition (1234 + 5678 = 6912 = 0x1B00), multiplication (40 000 × 50 000 = 2 000 000 000), a partial write with WSTRB = 0011 that replaces only the low half of B, and a write to the read-only RESULT that must be ignored. Note the Verilog task is called check_reg, not expect: expect is a reserved word in SystemVerilog, and -g2012 enforces it.
5. Python: a cycle-accurate model of the slave
This model implements the same rules as the HDL in Python, one rising edge per call, and drives it with the same sequence of writes and reads. It prints the values read back and plots the five channels for the first block of transactions. Compare the three writes: together (3 edges), address first, data first. In each case BVALID rises exactly one edge after the second of AW/W has been accepted.
Click Run to execute the Python code
Code will be executed with Python 3 on the server
6. Packaging the peripheral as IP
A block design in Vivado is built from IP blocks: packaged modules with named interfaces that the tools can connect automatically. To use axil_calc there, you package it once. The IP packager reads your HDL, infers the interfaces from the port names, and writes an IP description (an XML file called component.xml) next to your sources.
- Create a new Vivado project for
xc7z020clg400-1(use the Vivado version that matches your PYNQ image — 2024.1 for PYNQ v3.1, 2022.1 for v3.0). Addaxil_calc.voraxil_calc.vhd. - Start the IP packager (in recent versions: Tools → Create and Package New IP, then Package your current project). Choose a folder outside the project, for example
ip_repo/axil_calc. - In the packager, open the Ports and Interfaces page. You should see one interface
s_axiof type AXI4-Lite (slave), pluss_axi_aclkas a clock ands_axi_aresetnas an active-low reset. If the clock is not associated with the bus interface, associate it (right-click the interface). - Open Addressing and Memory: Vivado should have created an address block for
s_axi. A 16-byte peripheral will still get a minimum range (typically 4 KB or 64 KB) in the system address map — that is fine. - Give it a name and version on the identification page, then Review and Package → Package IP.
7. The block design: ZYNQ, interconnect and your IP
- In a new project for
xc7z020clg400-1, open the IP settings (Settings → IP → Repository) and add yourip_repofolder so Vivado findsaxil_calc. - Create Block Design (name it, say,
calc— remember the name). Add ZYNQ7 Processing System and run Run Block Automation; this connects DDR and FIXED_IO and applies the PYNQ-Z2 board preset if the board files are installed. Without board files, you must set the DDR and MIO configuration yourself — use the board files. - Add
axil_calcfrom the IP catalogue. Run Run Connection Automation on itss_axiinterface. Vivado inserts an AXI interconnect (an AXI Interconnect or SmartConnect, depending on version) and a Processor System Reset block, and connectsFCLK_CLK0to every clock. - Open the Address Editor.
axil_calc_0is typically placed at0x43C0_0000with a 64 KB range. Write down what you actually get. - Validate Design, then right-click the block design in the Sources panel and choose Create HDL Wrapper (let Vivado manage it). Set the wrapper as top.
- Generate Bitstream. No XDC is needed: this design uses no PL pins, and the clock comes from the PS.
- Collect two files for PYNQ: the bitstream (typically
<project>.runs/impl_1/calc_wrapper.bit) and the hardware handoff filecalc.hwh(typically under<project>.gen/sources_1/bd/calc/hw_handoff/). Copy them and give them the same base name, e.g.calc.bitandcalc.hwh. Alternatively, File → Export → Export Hardware with the bitstream included produces an.xsafile, which is a zip archive that contains both.
.hwh format and the PS configuration evolve between Vivado releases. Build overlays with the Vivado version that matches your PYNQ image. A mismatch can produce an overlay that loads but whose IP dictionary is wrong or empty.8. What the ARM will see
After all this, the peripheral is just four words of memory at the base address. In C on the ARM (bare-metal, or Linux with /dev/mem mapped), using it would look like this:
#include <stdint.h>
#define CALC_BASE 0x43C00000u /* from the Address Editor */
#define REG(k) (*(volatile uint32_t *)(CALC_BASE + 4u * (k)))
uint32_t hw_mul16(uint16_t a, uint16_t b) {
REG(1) = a; /* A @ 0x4 */
REG(2) = b; /* B @ 0x8 */
REG(0) = 1; /* CTRL.OP = multiply */
return REG(3); /* RESULT @ 0xC */
}The volatile keyword tells the compiler that every access matters — it must not cache the value in a CPU register or reorder the stores. Each of those four lines becomes one AXI-Lite transaction on GP0. Chapter 21 does the same from Python, without writing a line of C.
A word of honesty: calling this peripheral to multiply two numbers is slower than letting the ARM do it. A single AXI-Lite access across the PS–PL boundary costs on the order of a hundred nanoseconds or more, during which the ARM could have executed dozens of multiplications. Register-mapped peripherals are for control — configuring and starting hardware that then does a lot of work on its own. Chapter 22 builds exactly that.
Practice Problems
0x43C0_0000. Which absolute address do you write to set operand B, and which do you read for the result?0x1122_3344. The master writes 0xAABB_CCDD to A with WSTRB = 4'b1001. What does A contain afterwards?axi_write when AW and W are offered together on the first cycle and BREADY is raised right after both are accepted. On which edge is the register written, and on which edge is BVALID first seen high?assign s_axi_awready = s_axi_awvalid && s_axi_wvalid; to make sure address and data are taken together and remove the holding registers. Is this legal AXI? What does it cost?