Python Drives Hardware: PYNQ Overlays

Bitstreams as Python objects: Overlay, MMIO, interrupts and DMA with contiguous buffers, all from a Jupyter notebook on the board.

1. The idea: hardware as a Python library

In Chapter 20 you produced a bitstream with a peripheral at an address on the ARM's bus. To use it you could write a Linux kernel driver in C, or a bare-metal program. PYNQ (Python productivity for Zynq) takes a different route: it runs Linux and Jupyter on the ARM cores and lets you load a bitstream and talk to its IP blocks from a Python notebook. The bitstream plus its description is called an overlay — like a library you import, except that importing it rewires the chip.

Jupyter notebookbrowser → http://pynq:9090calc.bitconfigures PLcalc.hwhdescribes IPsame base name, same folderol = Overlay('calc.bit')downloads .bit, parses .hwhol.ip_dictname → phys_addr, addr_range,type, registers, interruptsol.axil_calc_0DefaultIP or your driverol.axi_dma_0DMA driversoftware (ARM, Linux)hardware (PL)axil_calc registersAXI DMA ⇄ stream IPMMIO, GP0DMA, HP0
How PYNQ sees an overlay. The .bit configures the PL; the .hwh (hardware handoff) describes every IP block, its address and its interfaces, so PYNQ can build Python objects for them. Data moves either through MMIO (one word at a time on GP0) or by DMA through allocated buffers in DDR.

2. Getting the board running

On the board: first boot with the PYNQ image
  1. Download the PYNQ image for the PYNQ-Z2 from the PYNQ website and write it to a microSD card (8 GB or more) with a tool such as balenaEtcher or dd. The version matters later: v3.1 overlays are built with Vivado 2024.1, v3.0 with 2022.1.
  2. Set the boot jumper to SD (Part V used JTAG). Insert the card, connect Ethernet to your router or directly to your PC, connect the micro-USB cable for power, and switch on. After a minute or so the LEDs flash to signal that Linux is up.
  3. Open http://pynq:9090 in a browser (if the name does not resolve, use the board's IP address; with a direct cable the image typically uses a static 192.168.2.99, and your PC needs an address in the same subnet). Log in with the default password xilinx (the default user is also xilinx). Change it if the board is on a shared network.
  4. Browse the example notebooks that come with the image; the base overlay drives the LEDs, buttons and switches from Python.
  5. Upload your calc.bit and calc.hwh from Chapter 20 to a folder of your choice using Jupyter's Upload button.

Every code block below in this chapter runs on the board, in a notebook — not in this web page. They import the pynq package, which only exists on a Zynq running the PYNQ image. The one program that runs here, in Section 8, is a model of the timing.

3. Loading an overlay and reading its map

Creating an Overlay object does two things: it programs the PL with the bitstream, and it parses the .hwh file to learn what is inside. That second part is what makes PYNQ convenient — you never type an address by hand. The ip_dict is a dictionary with one entry per memory-mapped IP block:

notebook — load the overlay (runs on the board)
from pynq import Overlay

ol = Overlay('calc.bit')       # finds calc.hwh next to it, programs the PL
print(ol.is_loaded())          # True once the bitstream is in the PL

for name, info in ol.ip_dict.items():
    print(f"{name:20s} base=0x{info['phys_addr']:08X} "
          f"range=0x{info['addr_range']:X}  type={info['type']}")
# axil_calc_0          base=0x43C00000 range=0x10000  type=<vendor>:user:axil_calc:1.0

The base address is whatever Vivado's address editor chose in Chapter 20, and the range is the size of the window (64 KB here even though we only use 16 bytes). The type string is the IP's VLNV identifier (vendor, library, name, version) that you set when packaging it. The exact value depends on your packaging settings, so print it rather than guess it.

Common mistake: .bit and .hwh with different names
Vivado names the bitstream after the wrapper (calc_wrapper.bit) and the hardware handoff after the block design (calc.hwh). Overlay looks for a .hwh with the same base name in the same folder, so rename one of them. A stale .hwh from an older build is worse than a missing one: the addresses in Python no longer match the hardware.

4. MMIO: reading and writing registers

MMIO (memory-mapped I/O) is PYNQ's class for accessing a window of physical addresses. Under Linux, user programs cannot touch physical addresses directly; MMIO maps the window into the Python process (through /dev/mem) and then each read/write becomes one 32-bit load or store, i.e. one AXI-Lite transaction on M_AXI_GP0. Offsets are in bytes from the base, exactly as in the register map of Chapter 20:

notebook — talk to the Chapter 20 peripheral (runs on the board)
from pynq import MMIO

CTRL, A, B, RESULT = 0x0, 0x4, 0x8, 0xC          # byte offsets (Chapter 20)

info = ol.ip_dict['axil_calc_0']
calc = MMIO(info['phys_addr'], info['addr_range'])

calc.write(A, 1234)
calc.write(B, 5678)
calc.write(CTRL, 0)                 # OP = add
print(calc.read(RESULT))            # 6912

calc.write(A, 40000)
calc.write(B, 50000)
calc.write(CTRL, 1)                 # OP = multiply
print(calc.read(RESULT))            # 2000000000

# The overlay also gives you an object per IP with the same read/write:
ol.axil_calc_0.write(A, 7)
print(ol.axil_calc_0.read(A))       # 7
OffsetNameAccessPython
0x0CTRLR/Wcalc.write(0x0, 1) # multiply
0x4AR/Wcalc.write(0x4, a)
0x8BR/Wcalc.write(0x8, b)
0xCRESULTRcalc.read(0xC)

For anything more than a quick test, wrap the register map in a small driver class. If a class derived from DefaultIP declares a bindto list with the IP's VLNV string, PYNQ uses it automatically for every matching block when the overlay is loaded:

notebook — a driver bound to the IP type (runs on the board)
from pynq import DefaultIP, Overlay

class CalcDriver(DefaultIP):
    """Python driver for the axil_calc peripheral of Chapter 20."""
    # Copy this string from ol.ip_dict['axil_calc_0']['type'] (vendor:library:name:version)
    bindto = ['user.org:user:axil_calc:1.0']

    CTRL, A, B, RESULT = 0x0, 0x4, 0x8, 0xC

    def __init__(self, description):
        super().__init__(description=description)

    def add(self, a, b):
        self.write(self.A, a); self.write(self.B, b); self.write(self.CTRL, 0)
        return self.read(self.RESULT)

    def mul16(self, a, b):
        self.write(self.A, a & 0xFFFF); self.write(self.B, b & 0xFFFF); self.write(self.CTRL, 1)
        return self.read(self.RESULT)

ol = Overlay('calc.bit')       # define the class BEFORE loading the overlay
print(type(ol.axil_calc_0))    # <class '__main__.CalcDriver'>
print(ol.axil_calc_0.mul16(300, 400))   # 120000

Now measure what one register access costs from Python. This tells you a lot about where the time goes:

notebook — timing MMIO from Python (runs on the board)
import time

N = 10_000
t0 = time.perf_counter()
for i in range(N):
    calc.write(A, i)
t1 = time.perf_counter()
print(f"{(t1 - t0) / N * 1e6:.2f} us per MMIO write from Python")

Expect a few microseconds per call. The AXI-Lite transaction itself takes far less; most of the time is the Python interpreter and the MMIO method call. (Our model in Section 8 assumes 2 µs — replace it with your measurement.) Hundreds of thousands of register writes per second is plenty for control, and hopeless for moving data.

5. Interrupts, briefly

Polling a status register in a loop works, but it keeps the CPU busy. Hardware can instead raise an interrupt: a wire from the PL to the ARM's interrupt controller (IRQ_F2P on the processing system). In PYNQ designs the IP's interrupt output is connected through an AXI Interrupt Controller block to the PS, and the .hwh records the connection. PYNQ then gives the IP an interrupt attribute with an asyncio-style wait():

notebook — waiting for an interrupt (sketch, runs on the board)
import asyncio

# Requires the IP's interrupt pin to reach the PS through an AXI Interrupt
# Controller in the block design; PYNQ then exposes it as ip.interrupt.
async def wait_for_done(ip):
    await ip.interrupt.wait()          # the notebook is free while we wait
    print('done, status =', ip.read(0x4))

asyncio.get_event_loop().run_until_complete(wait_for_done(ol.my_ip_0))

Our axil_calc has no interrupt (it finishes in one cycle), and the DMA engine of the next section is usually used with a blocking wait(). Interrupts matter when hardware takes a long or unpredictable time — waiting for an external event, or a long computation — and you want the notebook to stay responsive.

6. DMA: moving whole buffers

To feed a hardware pipeline with megabytes of data, the CPU must stop handling words one at a time. A DMA (direct memory access) engine is a small hardware block that reads or writes memory on its own. Xilinx's AXI DMA IP converts between memory and AXI-Stream: its MM2S channel (memory-mapped to stream) reads a buffer from DDR through an HP port and emits it as a stream; its S2MM channel (stream to memory-mapped) takes a stream and writes it to DDR. The CPU only programs a few registers: buffer address, length, go.

ARM + NumPysrc[:] = datareads dstL1/L2 cachemay hold neweror older copiesDDR3src, dst: physicallycontiguous (allocate)flushinvalidateS_AXI_HP0PSPLAXI DMAMM2S reads · S2MM writesstream IP(Chapter 22)M_AXISS_AXISDMA control registersaddress, length, go (via GP0)
DMA data path. The ARM writes the buffer (possibly into its cache), the cache is flushed to DDR, the DMA reads DDR through HP0 and streams the data to the PL; results come back the opposite way, and the CPU invalidates its cached copy before reading them.

Two problems arise that MMIO never had. First, the DMA works with physical addresses and cannot follow the page tables of Linux's virtual memory, so a buffer must be one physically contiguous block. An ordinary NumPy array is not. PYNQ's allocate() reserves contiguous memory and returns it as a NumPy array (a PynqBuffer) that also knows its physical_address.

Second, cache coherence. If the buffer is cacheable, when Python writes into it the new values may sit in the ARM's cache and not yet be in DDR; the HP port reads DDR directly, so the DMA would read stale data. In the other direction, after the DMA writes results into DDR, the CPU may still hold old copies in its cache. The fixes are buf.flush() (write cache lines back to DDR) before the hardware reads, and buf.invalidate() (discard cached copies) before the CPU reads what hardware wrote. For buffers from allocate(), PYNQ's DMA driver takes care of this for you when you call transfer() and wait(); you only need to call them yourself when your own hardware masters the buffer.

notebook — a DMA loopback test (runs on the board)
import numpy as np
from pynq import Overlay, allocate

ol  = Overlay('loopback.bit')            # AXI DMA with MM2S -> S2MM looped through a FIFO
dma = ol.axi_dma_0

N = 4096
src = allocate(shape=(N,), dtype=np.uint32)   # physically contiguous, DMA-able
dst = allocate(shape=(N,), dtype=np.uint32)
src[:] = np.arange(N, dtype=np.uint32)        # use it like any NumPy array
print(hex(src.physical_address))              # the address the DMA engine will use

dma.recvchannel.transfer(dst)   # arm the receiver first (S2MM)
dma.sendchannel.transfer(src)   # then start sending (MM2S)
dma.sendchannel.wait()
dma.recvchannel.wait()

print(np.array_equal(src, dst))  # True
src.freebuffer(); dst.freebuffer()   # contiguous memory is a limited resource

The loopback overlay assumed here is the simplest possible DMA design: AXI DMA with both channels enabled and its MM2S stream connected straight back to S2MM through an AXI-Stream FIFO, MM2S/S2MM memory ports on HP0, control on GP0. Build it as an exercise with the flow of Chapter 20; Chapter 22 replaces the FIFO with an accelerator. Arm the receive channel before starting the send channel, so that the returning stream has somewhere to go.

Key idea: control by MMIO, data by DMA
Every PYNQ design you will meet follows one pattern: a few AXI-Lite registers to configure and start the hardware, and DMA through contiguous buffers to move bulk data. The CPU decides; the PL does the repetitive work.
Common mistake: buffer length and the DMA's length register
The AXI DMA's transfer length register has a configurable width (an IP parameter, 14 bits by default, i.e. just under 16 KB per transfer). A larger buffer then fails or hangs. Set the “width of buffer length register” to its maximum (26 bits) when you configure the IP.

7. Loading your own overlay, step by step

On the board: from Vivado to a running notebook
  1. Build the design in the Vivado version matching your PYNQ image (Chapter 20). Copy the bitstream and the .hwh, renamed to the same base name: calc.bit, calc.hwh.
  2. Upload both to one folder on the board through Jupyter, next to a new notebook.
  3. Run ol = Overlay('calc.bit') and print ol.ip_dict. Check the base address against the Address Editor.
  4. Run the MMIO test of Section 4: 1234 + 5678 must give 6912, and 40 000 × 50 000 must give 2 000 000 000 — the same numbers your testbench checked in simulation.
  5. Time the MMIO loop. Note the number; you will use it in Chapter 22.
  6. If the board hangs on the first read: the address is probably wrong, or the IP is not clocked or held in reset. An AXI access to an address where no slave answers can block the bus — reboot, then check the address editor and the clock/reset connections.

8. Python: when does DMA pay off?

Moving \(N\) bytes costs a fixed overhead \(t_0\) (Python calls, driver, cache maintenance, waiting) plus the time at the stream bandwidth \(B\):

\[ t_\text{DMA}(N) = t_0 + \frac{N}{B}, \qquad B_\text{eff}(N) = \frac{N}{t_0 + N/B} \;\xrightarrow{N\to\infty}\; B, \qquad B_\text{eff}(t_0 B) = \tfrac{1}{2}B \]

A 32-bit stream at 100 MHz carries at most \(4 \times 10^8\) B/s = 400 MB/s. The model below compares that with word-by-word MMIO and, for scale, with one NumPy pass over the same data on the ARM. All constants are assumptions in the right range for a PYNQ-Z2 driven from Python; replace them with your own measurements. Two lessons survive any reasonable choice of numbers: DMA beats MMIO for anything beyond a few hundred bytes, and a DMA call only reaches its bandwidth for buffers of tens to hundreds of kilobytes.

Python
script.py54 lines

Click Run to execute the Python code

Code will be executed with Python 3 on the server

Notice the dashed line. Even a perfect DMA only moves data at a speed comparable to NumPy processing it on the ARM for a simple operation. That is the central question of Chapter 22: an accelerator only wins if it does much more work per byte than the CPU can.

Practice Problems

Progress: 0/5 (0%)
1
⭐ Easy
You copy calc_wrapper.bit and calc.hwh to the board and run Overlay('calc_wrapper.bit'). It fails to find the hardware description. Why, and what are two ways to fix it?
2
⭐ Easy
The ip_dict says axil_calc_0 has phys_addr = 0x43C00000. Write the two Python lines that set operand B to 99 and read the result.
3
⭐⭐ Medium
Your timing loop shows 2 µs per MMIO.write from Python. How long does it take to send 1 MB to the PL word by word? With the DMA model of Section 8 (100 µs setup, 400 MB/s), how long by DMA? What is the ratio?
4
⭐⭐ Medium
A student allocates a buffer, fills it in Python, starts their own PL master (not the PYNQ DMA driver) that reads it over HP0, and gets old data. What happened, and what one call fixes it? What call would they need for a buffer the PL writes and Python then reads?
5
⭐⭐⭐ Hard
Using the model \(t(N) = t_0 + N/B\), show that the effective bandwidth \(N/t\) reaches half of \(B\) when \(N = t_0 B\). For \(t_0 = 100\,\mu\text{s}\) and \(B = 400\,\text{MB/s}\), what is that size? If you need 90 % of \(B\), how large must the buffer be? What does this tell you about calling the accelerator once per small vector?

Problem Set Statistics

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