The Zynq SoC & the AXI Bus

Processing system and programmable logic, the AXI4 family (AXI-Lite, AXI4, AXI-Stream), valid/ready handshakes and memory-mapped registers.

1. Two worlds on one chip

Everything you have built so far lived in the programmable logic (PL): LUTs, flip-flops, block RAMs and DSP slices that you wire together with HDL. But the chip on the PYNQ-Z2 is not just an FPGA. The Zynq XC7Z020 is a system-on-chip (SoC): next to the PL sits a complete processing system (PS) built around a dual-core ARM Cortex-A9 processor, running at 650 MHz on this board, with its own 512 MB of DDR3 memory. The PS is ordinary, fixed silicon — it runs Linux and Python just like a small computer.

The point of putting the two together is to let each do what it is good at. Software is flexible and easy to change; hardware is parallel, deterministic and cycle-exact. In Part VII you will write a peripheral in the PL, talk to it from the ARM, and end (Chapter 22) with Python feeding your own accelerator. This chapter is about the road between the two worlds: the AXI bus.

PSfixed siliconARM Cortex-A9 × 2650 MHz, L1 cachesshared L2 cache + SCUDDR controller512 MB DDR3(off-chip)peripheralsUSB, Ethernet, SD,UART, I2C, SPI, GPIOPL clocksFCLK0–3, resets,interrupts IRQ_F2Pcentral interconnect inside the PSM_AXI_GP0/1S_AXI_HP0–3S_AXI_ACPS_AXI_GP0/1PL53,200 LUTs · 220 DSPyour AXI-Lite slaveregisters (Ch. 20)AXI DMAmemory ⇄ streamyour stream IPAXI-Stream (Ch. 22)ACP: through the cache
The Zynq-7000: the processing system (top) and the programmable logic (bottom) talk through AXI ports. The ARM is master on the GP ports; your hardware is master on the HP and ACP ports.

2. What lives where, and how the chip wakes up

A useful rule of thumb: put in the PS anything that is control, irregular or changes often — the operating system, file and network access, the user interface, decisions. Put in the PL anything that is a fixed, repetitive operation on a stream of data, or that needs exact timing — filters, pixel pipelines, protocol engines, counters measured in nanoseconds. The PL has no instruction fetch and no operating system; it does the same thing on every clock edge.

Unlike a pure FPGA, the Zynq is a processor-first device: the PS always starts first, and the PL stays blank until someone configures it. At a high level, booting from the SD card goes like this:

  1. A boot ROM burnt into the PS reads the boot-mode jumpers and loads the first-stage boot loader (FSBL) from the SD card into on-chip memory.
  2. The FSBL sets up the clocks, the DDR controller and the pin multiplexing of the PS. These settings come from the Vivado configuration of the processing system. It can also load a bitstream into the PL.
  3. The FSBL hands over to a second-stage loader (U-Boot), which starts the Linux kernel.
  4. Once Linux runs, a program can (re)load the PL at any time. That is exactly what PYNQ does when you create an Overlay in Chapter 21: the bitstream becomes a file you load from Python.

The PS also gives the PL its clocks (FCLK_CLK0 to FCLK_CLK3, typically 100 MHz for FCLK_CLK0 in a PYNQ design), a reset, and interrupt lines from the PL back to the ARM. In Part VII the PL logic attached to the AXI bus runs from FCLK_CLK0, not from the 125 MHz board oscillator on H16 that Part V used.

3. AXI: one idea, three flavours

AXI (Advanced eXtensible Interface) is part of ARM's AMBA family of on-chip bus standards. Every Xilinx IP block speaks it, so learning it once lets you connect anything to anything. The side that starts a transaction is the master (also called manager); the side that answers is the slave (subordinate). Three variants matter here:

VariantAddresses?TransfersTypical use
AXI4-Liteyesone 32-bit word per transactioncontrol and status registers
AXI4 (full)yesbursts of up to 256 beats per addressmoving blocks of memory (DMA, caches)
AXI4-Streamnoan endless stream of beats, packets end with TLASTdata pipelines: samples, pixels, vectors

Memory-mapped AXI (Lite and full) works like reading and writing memory: every transfer carries an address, and the slave decides what that address means. A peripheral exposes its controls as memory-mapped registers: writing 5 to address 0x43C0_0004 is not storing a number in RAM, it is setting an input of your hardware. AXI-Stream has no addresses at all — it is a one-way pipe from a source to a sink, ideal for data that simply flows.

AXI4-Lite splits a peripheral access into five independent channels, each with its own handshake. Writes use three, reads use two:

ChannelDirectionMain signalsMeaning
AW write addressmaster → slaveAWADDR, AWVALID, AWREADYwhere to write
W write datamaster → slaveWDATA, WSTRB, WVALID, WREADYwhat to write; WSTRB = byte enables
B write responseslave → masterBRESP, BVALID, BREADYOKAY (00) or error
AR read addressmaster → slaveARADDR, ARVALID, ARREADYwhere to read
R read dataslave → masterRDATA, RRESP, RVALID, RREADYthe value read, plus OKAY or error

Because AW and W are separate channels, the address and the data of a write may arrive in either order, or in the same cycle. The slave answers on B only after it has both. Full AXI4 adds burst length and type, IDs for out-of-order transactions, and WLAST/RLAST to mark the last beat of a burst — but the channels and the handshake are the same. AXI-Stream keeps only one channel: TVALID, TREADY, TDATA, and optionally TLAST (end of packet) and TKEEP(which bytes are valid).

4. The valid/ready handshake

Every AXI channel, of every flavour, moves data with the same two wires. The source drives VALID (“the data on the bus is meaningful”); the destination drives READY (“I can take it”). The precise rules are short:

  1. A transfer happens on a rising clock edge at which VALID and READY are both high. On any other edge, nothing moves.
  2. The source must not wait for READY before asserting VALID.
  3. Once the source asserts VALID, it must keep VALID high and the data unchanged until the transfer happens.
  4. The destination may wait for VALID before asserting READY, and may raise and lower READY freely while VALID is low.
clkVALIDREADYDATA01234567D0D1D2D0 takenD1 takenD2 takenvalid, waitingready, idle
An AXI handshake. Transfers (green) happen only on edges where VALID and READY are both high: D0 appears just after edge 1 and waits at edge 2 because READY is low; it is taken at edge 3, D1 at edge 4. At edge 5 the sink is ready but nothing is valid; D2 is held through a stall at edge 6 and taken at edge 7.
Key idea: the handshake is the contract
With these rules, neither side needs to know anything about the other's speed. A fast source attached to a slow sink simply sees READY low more often. Throughput is the fraction of clock edges on which both are high; at 125 MHz with 32-bit words, one transfer per cycle is 500 MB/s.
Common mistake: VALID that depends on READY
Writing assign m_tvalid = have_data & m_tready; (or waiting for AWREADY before raising WVALID) breaks rule 2. Connected to a destination that waits for VALID before raising READY — which is legal — both sides wait forever. The related mistake is changing TDATAwhile TVALID is high and TREADY is low; the word the sink eventually takes is then not the one you meant to send.

5. Memory-mapped registers and the address map

From the ARM's point of view, the PL is a range of addresses. A load or store to an address in that range leaves the processor, travels out of a master port as an AXI-Lite (or AXI) transaction, passes through an interconnect that looks at the address and routes it to one slave, and comes back with a response. The table that says which slave owns which range is the address map, set in Vivado's address editor (Chapter 20).

Address range (Zynq-7000)What is there
0x0000_0000 – 0x3FFF_FFFFDDR memory (512 MB used on the PYNQ-Z2) and on-chip memory
0x4000_0000 – 0x7FFF_FFFFPL slaves reached through M_AXI_GP0
0x8000_0000 – 0xBFFF_FFFFPL slaves reached through M_AXI_GP1
0xE000_0000 and abovePS peripherals (UART, USB, SD, …) and system registers

Inside a peripheral, registers are 32 bits wide and AXI addresses count bytes, so register\(k\) lives at byte offset \(4k\):

\[ \text{address of register } k = \text{base} + 4k, \qquad k = \text{(address} - \text{base)} \gg 2 \]

When Vivado assigns addresses automatically, the first custom peripheral on GP0 typically lands at 0x43C0_0000. Treat that as a common default, not a law — always read the actual value from the address editor, or let PYNQ read it for you from the hardware description file.

6. The Zynq's AXI ports: GP, HP and ACP

The PS exposes several AXI ports to the PL, each with a different job. (Internally the Zynq-7000 ports speak AXI3, the previous version of the protocol; Vivado inserts the converters for you, so your IP can be AXI4.)

PortWho is masterWidthUse it for
M_AXI_GP0, M_AXI_GP1PS (ARM)32 bitARM reads/writes your registers (AXI-Lite)
S_AXI_GP0, S_AXI_GP1PL32 bitPL reaching PS peripherals; rarely needed
S_AXI_HP0 – HP3PL32 or 64 bithigh-bandwidth access to DDR (DMA engines)
S_AXI_ACPPL64 bitcache-coherent access through the ARM L2 cache

GP (general purpose) ports are for control: a handful of register accesses, each taking on the order of a hundred nanoseconds or more round trip. HP (high performance) ports have their own FIFOs and a short path to the DDR controller; a DMA engine in the PL uses them to stream large buffers while the ARM does something else. HP accesses bypass the ARM's caches, which is why Chapter 21 must worry about cache coherence. The ACP (accelerator coherency port) goes through the ARM's snoop control unit, so the PL sees the same data as the caches — convenient for small, latency-sensitive exchanges, at the cost of competing with the CPU for the cache.

On the board: looking at the processing system in Vivado
  1. Create a project for xc7z020clg400-1 (or select the PYNQ-Z2 board if its board files are installed) and create a block design.
  2. Add the ZYNQ7 Processing System IP. Run the block automation that Vivado offers; it connects DDR and the fixed I/O and applies the board preset if one is available.
  3. Double-click the block. Find the pages for PS-PL configuration (the GP/HP/ACP ports), clock configuration (the FCLK frequencies) and interrupts. Enable S_AXI_HP0 and watch a new port appear on the block.
  4. No bitstream yet — Chapter 20 adds a peripheral of your own to this design.

7. A register slice for AXI-Stream

The handshake has a hidden cost. In a long pipeline, TREADY travels backwards: if the last stage stalls, every stage before it must stall in the same cycle. If TREADY is computed combinationally through several modules, that path gets long and limits the clock frequency (Chapter 15). The standard fix is a register slice: a small block that sits on a stream and drives every one of its outputs, including the upstream TREADY, from a flip-flop.

The catch: with a registered s_tready, the slice tells the source “ready” one cycle before it knows whether the sink will stall. If the sink does stall, the word that arrives anyway must go somewhere. A second storage slot — the skid buffer — catches it, and s_tready drops for the next cycle. Our version also counts transferred words, a handy debugging register.

axis_skid — AXI-Stream register slice with a transfer counter
// AXI4-Stream register slice ("skid buffer") with a transfer counter.
// Every output is driven straight from a flip-flop, so the slice cuts
// long combinational paths in BOTH directions (data forward, tready back)
// while still moving one word per clock when nobody stalls.
module axis_skid #(
    parameter W = 32                  // TDATA width in bits
) (
    input  wire         clk,
    input  wire         rst,
    // slave side (upstream source -> us)
    input  wire         s_tvalid,
    output reg          s_tready = 1'b0,
    input  wire [W-1:0] s_tdata,
    input  wire         s_tlast,
    // master side (us -> downstream sink)
    output reg          m_tvalid = 1'b0,
    input  wire         m_tready,
    output reg  [W-1:0] m_tdata  = {W{1'b0}},
    output reg          m_tlast  = 1'b0,
    // number of words delivered on the master side
    output reg  [31:0]  count    = 32'd0
);
    reg         skid_valid = 1'b0;          // second storage slot
    reg [W-1:0] skid_data  = {W{1'b0}};
    reg         skid_last  = 1'b0;

    wire in_xfer  = s_tvalid & s_tready;    // a word enters this cycle
    wire out_xfer = m_tvalid & m_tready;    // a word leaves this cycle

    always @(posedge clk) begin
        if (rst) begin
            m_tvalid   <= 1'b0;
            skid_valid <= 1'b0;
            s_tready   <= 1'b0;
            count      <= 32'd0;
        end else begin
            if (out_xfer)
                count <= count + 1;

            if (m_tready || !m_tvalid) begin
                // the output register is free (or being emptied) this cycle
                if (skid_valid) begin
                    m_tvalid   <= 1'b1;            // drain the skid slot first
                    m_tdata    <= skid_data;
                    m_tlast    <= skid_last;
                    skid_valid <= 1'b0;
                end else begin
                    m_tvalid   <= in_xfer;         // pass the input straight on
                    m_tdata    <= s_tdata;
                    m_tlast    <= s_tlast;
                end
                s_tready <= 1'b1;                  // both slots free next cycle
            end else if (in_xfer) begin
                // output is stalled but we already promised to take this word
                skid_valid <= 1'b1;
                skid_data  <= s_tdata;
                skid_last  <= s_tlast;
                s_tready   <= 1'b0;                // full: stop the source
            end
        end
    end
endmodule

Read the main branch carefully. If the output register is empty or being emptied this cycle (m_tready || !m_tvalid), it is refilled — from the skid slot if that holds a word (to keep the order), otherwise straight from the input. Otherwise the output is stalled, and a word accepted this cycle is parked in the skid slot. The two slots are enough because s_tready drops exactly one cycle after the stall begins.

8. Testing under random back-pressure

A stream block that works when everybody is always ready proves very little. The testbench below makes the source go idle about 25 % of the time and the sink refuse about 50 % of the time, using two 16-bit LFSRs (linear-feedback shift registers, cheap pseudo-random generators) so both languages see exactly the same “random” pattern. It checks four things: the words arrive in order, TLAST marks every eighth word, the slice never changes m_tvalid or m_tdata while stalled (rule 3), and the counter equals the number of words received. The source in the testbench obeys the rules too: once it raises s_tvalid, it holds the word until it is accepted.

Self-checking testbench with random valid/ready
`timescale 1ns/1ps
// Random back-pressure on both sides; checks order, TLAST, the AXI rule
// "valid and data stay put until accepted", and the transfer counter.
module axis_skid_tb;
    localparam N = 200;                       // words to send
    reg clk = 0, rst = 1;
    always #4 clk = ~clk;                     // 125 MHz

    // source (drives the slave side)
    reg         s_tvalid = 0;
    reg  [31:0] s_tdata  = 0;
    reg         s_tlast  = 0;
    wire        s_tready;
    // sink (drives tready on the master side)
    reg         m_tready = 0;
    wire        m_tvalid, m_tlast;
    wire [31:0] m_tdata, count;

    axis_skid #(.W(32)) dut (
        .clk(clk), .rst(rst),
        .s_tvalid(s_tvalid), .s_tready(s_tready), .s_tdata(s_tdata), .s_tlast(s_tlast),
        .m_tvalid(m_tvalid), .m_tready(m_tready), .m_tdata(m_tdata), .m_tlast(m_tlast),
        .count(count));

    // two 16-bit Galois LFSRs: reproducible "random" bits, identical in VHDL
    reg [15:0] lfsr_s = 16'hACE1, lfsr_m = 16'h1D2B;
    function [15:0] step(input [15:0] x);
        step = {1'b0, x[15:1]} ^ (x[0] ? 16'hB400 : 16'h0000);
    endfunction

    integer sent = 0, got = 0, errors = 0, cycles = 0;
    reg        was_stalled = 0;
    reg [31:0] held_data   = 0;

    always @(posedge clk) if (!rst) begin
        cycles <= cycles + 1;
        lfsr_s <= step(lfsr_s);
        lfsr_m <= step(lfsr_m);

        // ---- source: obey the rule "once valid, hold until accepted"
        if (s_tvalid && s_tready) sent = sent + 1;
        if (s_tvalid && !s_tready) begin
            // keep everything as it is
        end else if (sent < N && lfsr_s[1:0] != 2'b00) begin   // valid ~75 %
            s_tvalid <= 1;
            s_tdata  <= 32'hC0DE_0000 + sent;
            s_tlast  <= (sent % 8 == 7);
        end else
            s_tvalid <= 0;

        // ---- sink: random ready (~50 %), check every accepted word
        m_tready <= lfsr_m[0];
        if (was_stalled && (!m_tvalid || m_tdata !== held_data)) begin
            $display("FAIL: master changed valid/data while stalled at cycle %0d", cycles);
            errors = errors + 1;
        end
        was_stalled <= m_tvalid && !m_tready;
        held_data   <= m_tdata;
        if (m_tvalid && m_tready) begin
            if (m_tdata !== 32'hC0DE_0000 + got || m_tlast !== (got % 8 == 7)) begin
                $display("FAIL: word %0d = %h last=%b", got, m_tdata, m_tlast);
                errors = errors + 1;
            end
            got = got + 1;
        end
    end

    initial begin
        repeat (3) @(posedge clk);
        rst <= 0;
        wait (got == N);
        repeat (4) @(posedge clk);
        if (errors == 0 && count == N)
            $display("PASS: %0d words in order, count=%0d, %0d cycles", got, count, cycles);
        else
            $display("FAIL: errors=%0d count=%0d", errors, count);
        $finish;
    end
endmodule
Run it
iverilog -g2012 -o tb axis_skid.v axis_skid_tb.v && vvp tb
# PASS: 200 words in order, count=200, 402 cycles

ghdl -a --std=08 axis_skid.vhd axis_skid_tb.vhd
ghdl -e --std=08 axis_skid_tb && ghdl -r --std=08 axis_skid_tb
# PASS: 200 words in order, count=200, 402 cycles

200 words took 402 cycles: about one word every two cycles, which is what you expect when the sink is ready half the time. The slice itself did not slow anything down — the next section shows that in general.

9. Python: throughput versus back-pressure

This model runs a stream link for 20 000 cycles with a source that always has data and a sink that is ready with probability \(p\). It compares a direct wire, our skid buffer (same update rules as the HDL), and a naive one-slot slice whose s_tready simply means “my register is empty”. The skid buffer matches the wire: throughput \(\approx p\). The naive slice never exceeds half a word per cycle, because it cannot accept a new word in the same cycle it hands one on (Problem 5).

Python
script.py65 lines

Click Run to execute the Python code

Code will be executed with Python 3 on the server

Practice Problems

Progress: 0/5 (0%)
1
⭐ Easy
On a clock edge, which of these AXI-Stream situations transfers a word? (a) TVALID=1, TREADY=0; (b) TVALID=0, TREADY=1; (c) TVALID=1, TREADY=1. In case (a), what must the source do on the next cycle?
2
⭐ Easy
A 32-bit AXI-Stream link runs at 125 MHz and the sink is ready 60 % of the time, at random. The source always has data. What throughput do you expect in words per cycle and in MB/s?
3
⭐⭐ Medium
A student designs an AXI-Lite slave that waits for WVALID before asserting AWREADY, and a master that waits for AWREADY before asserting WVALID. What happens when you connect them? Which rule did each break?
4
⭐⭐ Medium
Your custom peripheral sits on M_AXI_GP0 and Vivado puts it at 0x43C0_0000 with a 64 KB range. What are the byte addresses of its 32-bit registers 0 to 3? What address would you use for register 3 from the ARM, and what offset from Python's MMIO object in Chapter 21?
5
⭐⭐⭐ Hard
The naive one-register slice in the Python model sets s_tready = "register is empty". Explain cycle by cycle why its throughput can never exceed 0.5 words per cycle, even when the sink is always ready. Why does the skid buffer reach 1.0 while still driving s_tready from a flip-flop?

Problem Set Statistics

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