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.
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:
- 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.
- 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.
- The FSBL hands over to a second-stage loader (U-Boot), which starts the Linux kernel.
- Once Linux runs, a program can (re)load the PL at any time. That is exactly what PYNQ does when you create an
Overlayin 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:
| Variant | Addresses? | Transfers | Typical use |
|---|---|---|---|
| AXI4-Lite | yes | one 32-bit word per transaction | control and status registers |
| AXI4 (full) | yes | bursts of up to 256 beats per address | moving blocks of memory (DMA, caches) |
| AXI4-Stream | no | an endless stream of beats, packets end with TLAST | data 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:
| Channel | Direction | Main signals | Meaning |
|---|---|---|---|
| AW write address | master → slave | AWADDR, AWVALID, AWREADY | where to write |
| W write data | master → slave | WDATA, WSTRB, WVALID, WREADY | what to write; WSTRB = byte enables |
| B write response | slave → master | BRESP, BVALID, BREADY | OKAY (00) or error |
| AR read address | master → slave | ARADDR, ARVALID, ARREADY | where to read |
| R read data | slave → master | RDATA, RRESP, RVALID, RREADY | the 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:
- A transfer happens on a rising clock edge at which
VALIDandREADYare both high. On any other edge, nothing moves. - The source must not wait for
READYbefore assertingVALID. - Once the source asserts
VALID, it must keepVALIDhigh and the data unchanged until the transfer happens. - The destination may wait for
VALIDbefore assertingREADY, and may raise and lowerREADYfreely whileVALIDis low.
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.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_FFFF | DDR memory (512 MB used on the PYNQ-Z2) and on-chip memory |
| 0x4000_0000 – 0x7FFF_FFFF | PL slaves reached through M_AXI_GP0 |
| 0x8000_0000 – 0xBFFF_FFFF | PL slaves reached through M_AXI_GP1 |
| 0xE000_0000 and above | PS 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\):
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.)
| Port | Who is master | Width | Use it for |
|---|---|---|---|
| M_AXI_GP0, M_AXI_GP1 | PS (ARM) | 32 bit | ARM reads/writes your registers (AXI-Lite) |
| S_AXI_GP0, S_AXI_GP1 | PL | 32 bit | PL reaching PS peripherals; rarely needed |
| S_AXI_HP0 – HP3 | PL | 32 or 64 bit | high-bandwidth access to DDR (DMA engines) |
| S_AXI_ACP | PL | 64 bit | cache-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.
- Create a project for
xc7z020clg400-1(or select the PYNQ-Z2 board if its board files are installed) and create a block design. - 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.
- 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_HP0and watch a new port appear on the block. - 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.
// 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
endmoduleRead 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.
`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
endmoduleiverilog -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).
Click Run to execute the Python code
Code will be executed with Python 3 on the server
Practice Problems
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?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?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?