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.

31161500x0CTRLunused (read back as written)OPR/W0x4Aa[15:0] — used by multiplya[31:16]R/W0x8Bb[15:0] — used by multiplyb[31:16]R/W0xCRESULTOP = 0: a + b (mod 2³²)OP = 1: a[15:0] × b[15:0]R onlywrites to 0xC are acknowledged with OKAY and ignored
The register map of axil_calc. Four 32-bit registers at byte offsets 0x0, 0x4, 0x8 and 0xC. Only bit 0 of CTRL is used; reads of unused CTRL bits return what was written.

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.

AW channelAWADDR, AWVALIDW channelWDATA, WSTRB,WVALIDaw_addrflag: aw_fullw_data, w_strbflag: w_fullAWREADY = !aw_fullWREADY = !w_fullANDregister bankCTRL / A / Bbyte-wise under WSTRBBVALID, BRESP=00clears both flagsB channelto master
Write path of axil_calc. AW and W are captured independently; the register write and the B response happen on the first edge where both holding registers are full.

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:

\[ \text{reg}_\text{new}[8i{+}7:8i] = \begin{cases} \text{WDATA}[8i{+}7:8i] & \text{if WSTRB}[i] = 1 \\ \text{reg}_\text{old}[8i{+}7:8i] & \text{otherwise} \end{cases} \qquad i = 0,1,2,3 \]

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.

axil_calc — a from-scratch AXI4-Lite slave with four registers
// 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
endmodule

Two 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.

Key idea: READY from your own flip-flops only
Every 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.

Testbench: write/read procedures and self-checks
`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
endmodule
Run it
iverilog -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.

Common mistake: a slave that only works with one master
Many hand-written slaves pass a testbench that always sends AW and W together, then lock up on the board because the interconnect delivers them a cycle apart. Always test the three orderings, and if possible use random delays on every channel as in Chapter 19.

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.

Python
script.py106 lines

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.

On the board: packaging axil_calc as an IP
  1. 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). Add axil_calc.v or axil_calc.vhd.
  2. 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.
  3. In the packager, open the Ports and Interfaces page. You should see one interface s_axi of type AXI4-Lite (slave), plus s_axi_aclk as a clock and s_axi_aresetn as an active-low reset. If the clock is not associated with the bus interface, associate it (right-click the interface).
  4. 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.
  5. 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

ZYNQ7 ProcessingSystemDDR, FIXED_IOM_AXI_GP0FCLK_CLK0FCLK_RESET0_NAXI interconnectAXI3 → AXI4-Lite,address decodeProcessor SystemResetaxil_calc_0s_axi @ 0x43C0_0000 (typ.)s_axi_aclk, s_axi_aresetnAXI-Liteperipheral_aresetnFCLK_CLK0 (e.g. 100 MHz) clocks every AXI port in the PL
The block design. The ARM is master on M_AXI_GP0; an interconnect inserted by connection automation converts AXI3 to AXI4-Lite and routes addresses to axil_calc. FCLK_CLK0 clocks the PL side, and a reset block synchronises the PS reset to it.
On the board: block design, address map and bitstream
  1. In a new project for xc7z020clg400-1, open the IP settings (Settings → IP → Repository) and add your ip_repo folder so Vivado finds axil_calc.
  2. 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.
  3. Add axil_calc from the IP catalogue. Run Run Connection Automation on its s_axi interface. Vivado inserts an AXI interconnect (an AXI Interconnect or SmartConnect, depending on version) and a Processor System Reset block, and connects FCLK_CLK0 to every clock.
  4. Open the Address Editor. axil_calc_0 is typically placed at 0x43C0_0000 with a 64 KB range. Write down what you actually get.
  5. 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.
  6. Generate Bitstream. No XDC is needed: this design uses no PL pins, and the clock comes from the PS.
  7. Collect two files for PYNQ: the bitstream (typically <project>.runs/impl_1/calc_wrapper.bit) and the hardware handoff file calc.hwh (typically under <project>.gen/sources_1/bd/calc/hw_handoff/). Copy them and give them the same base name, e.g. calc.bit and calc.hwh. Alternatively, File → Export → Export Hardware with the bitstream included produces an .xsa file, which is a zip archive that contains both.
Common mistake: mismatched Vivado and PYNQ versions
The .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:

calc.c (bare-metal sketch)
#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

Progress: 0/5 (0%)
1
⭐ Easy
The peripheral is at base address 0x43C0_0000. Which absolute address do you write to set operand B, and which do you read for the result?
2
⭐ Easy
Register A holds 0x1122_3344. The master writes 0xAABB_CCDD to A with WSTRB = 4'b1001. What does A contain afterwards?
3
⭐⭐ Medium
Count the clock cycles in the testbench's 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?
4
⭐⭐ Medium
A colleague suggests 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?
5
⭐⭐⭐ Hard
You want RESULT to be a full 32×32-bit product. (a) How many bits does the product need, and how would you expose it through 32-bit registers? (b) The DSP48E1 multiplier on the Zynq is 25×18 bits. Roughly how many DSP slices does a 32×32 multiplier need, and what happens to the critical path? (c) Why might you need to add a status bit?

Problem Set Statistics

Total Problems
5
Attempted
0
Completion
0%
Share:XRedditLinkedIn
Rate this chapter: