AES67
A full AES67 Audio-over-IP implementation built around a single FPGA. The FPGA owns the entire data plane — Ethernet MAC, IEEE 1588 (PTPv2), a PTP-disciplined wallclock, media-clock derivation, RTP packetisation and TDM/I2S audio I/O — while a separate, swappable control plane handles the non-realtime work (network management, PTP grandmaster selection, stream announcement/discovery, configuration, web UI).
The defining feature of the design is that the control plane is not fixed to one host. The FPGA exposes its whole register set as a Wishbone-slave bridge (LiteX CSR map), and that bridge can be driven by:
- an integrated LiteX RISC-V softcore (VexRiscv) running Zephyr RTOS on the same FPGA (single-chip endpoint), or
- an external MCU (e.g. an ESP32-S3) running the same Zephyr control-plane firmware over SPI, or
- an external Linux host (e.g. a Raspberry Pi): the
aes67_eth kernel driver turns the FPGA into a normal net_device with a PTP hardware clock (so stock ptp4l disciplines the wallclock) and owns the SPI→Wishbone (spibone) bus, and the Rust config_tool stack (daemon/CLI/web/discovery) runs on top of that driver, reaching the FPGA registers through it.
Table of Contents
System Overview
flowchart TB
phy(["Ethernet PHY"])
phy <== MII ===> yol_mac
aout(["TDM OUT"])
ain --> tx_tdm_demux
rx_tdm_mux --> aout
ptp_nco --> aclk
ain(["TDM IN"])
aclk(["MCLK · BCLK · LRCK"])
wishbone@{ shape: lean-r, label: "Wishbone Bus" }
wishbone ==> tx_arbiter
rx_filter ==> wishbone
rx_config === wishbone
tx_config === wishbone
spibone === wishbone
uartbone === wishbone
subgraph FPGA["FPGA Data Plane (aes67_top)"]
direction TB
tsu["Timestamping Unit"]
tsu --> ptp_parser
yol_mac & phy --> tsu
subgraph mac["Ethernet MAC"]
tx_arbiter["TX Arbiter"]
rx_filter["RX Filter"]
yol_mac["YOL Ethernet Mac"]
yol_mac --> rx_filter
tx_arbiter --> yol_mac
end
subgraph ptp["PTP Implementation"]
direction TB
ptp_parser["Parser"]
ptp_bmca["BMCA"]
ptp_sender["Sender"]
ptp_controller["Controller"]
ptp_servo["Servo"]
ptp_nco["NCO"]
ptp_mediaclock["Mediaclock"]
wallclock["Wallclock"]
ptp_parser -- timestamp --> ptp_servo -- ppb --> wallclock -- timestamp --> ptp_sender
ptp_parser --> ptp_bmca --> ptp_controller --> ptp_sender
wallclock --> ptp_nco
wallclock --> ptp_mediaclock
end
subgraph audio_rx["Audio RX"]
direction TB
rx_config[("Config")]
rx_parser["RTP Packet Parser"]
rx_buffer["Sample Buffer"]
rx_playout["Sample Playout"]
rx_tdm_mux["TDM Multiplexer"]
rx_parallel_out["Parallel Output"]
rx_config --> rx_parser --> rx_buffer --> rx_playout --> rx_tdm_mux & rx_parallel_out
end
subgraph audio_tx["Audio TX"]
direction TB
tx_config[("Config")]
tx_tdm_demux[TDM Demux]
tx_parallel_in["Parallel Input"]
tx_buffer["Sample Buffer"]
tx_router["Router"]
tx_transmitter["Sender"]
tx_tdm_demux & tx_parallel_in --> tx_buffer
tx_buffer & tx_router --> tx_transmitter
tx_config --> tx_router
end
ptp_mediaclock --> rx_playout
ptp_mediaclock --> tx_buffer
rx_filter --> ptp_parser & rx_parser
ptp_sender & tx_transmitter --> tx_arbiter
end
subgraph control_plane["Control Plane"]
direction BT
spibone["SPI Wishbone Bridge"]
uartbone["UART Wishbone Bridge"]
zephyr["Zephyr Firmware"]
linux["Linux Kernel Driver"]
linux_daemon["aes67d"]
ptp4l["ptp4l"]
spibone & uartbone <--> linux & zephyr
ptp4l & linux_daemon <--> linux
end
The data-plane logic is identical regardless of who drives it; only the Wishbone master in front of the aes67_wb_bridge changes. On the FPGA side the master is chosen by the SOC_TYPE generic on soc_top.vhd (and by which LiteX target is generated). On the host side, the Zephyr firmware (soc_firmware/app/), the Rust config_tool, and the aes67_eth kernel driver all speak to the same register names resolved from the generated CSR map.
Control-Plane Backends
| Backend |
LiteX target / SOC_TYPE |
Host |
Transport |
Notes |
| Integrated softcore |
cyclone10 / cyc1000 / gowin (LITEX_HRAM / LITEX_SDRAM) |
On-FPGA VexRiscv + Zephyr |
LiteX CSR over Wishbone |
Self-contained single-chip system; SoC boots from SPI flash. |
| External MCU (Zephyr) |
spibone (LITEX_SPIBONE) |
ESP32-S3 (same Zephyr firmware) |
SPI → Wishbone |
Active target; the SPI driver still needs porting from the old spictrl byte protocol to spibone. |
| External Linux host |
aes67_bridge, driven over spibone (FPGA top built with PTP_IN_SOFTWARE = true) |
Linux (e.g. Raspberry Pi) |
SPI→Wishbone via aes67_eth.ko |
The aes67_eth kernel driver provides net_device + PHC (stock ptp4l disciplines the wallclock) and owns the bus; the Rust config_tool stack runs on top of it (daemon, CLI, web, discovery, TAP bridge). |
For bring-up/debug, config_tool can also open a spibone/uartbone bridge directly (aes67cfg --spi/--uart) without the kernel driver — but the full Linux control plane (with ptp4l hardware PTP) runs through aes67_eth.ko, the single Wishbone master.
The earlier external-MCU path used a hand-rolled SPI byte-command protocol in spictrl.vhd. That FPGA block and its config_ram_address_map.md have been removed in favour of the CPU-less Wishbone bridge (spibone/uartbone); the ESP32-S3 driver just needs porting onto it.
FPGA Architecture (Data Plane)
All time-critical audio and timing logic lives in FPGA/. New logic is VHDL; a few audio-clock helpers are Verilog.
- FPGA/aes67_top.vhd — the data-plane core (MAC + PTP + wallclock + audio TX/RX).
- FPGA/aes67_wb_bridge.vhd — wraps the core and exposes its register set + the Ethernet control-plane buffer (
eth_buf) as a Wishbone slave. This is the single point all control-plane hosts talk to.
- FPGA/soc_top.vhd — board/SoC wrapper: instantiates the data plane plus the chosen LiteX core (full VexRiscv SoC, or a CPU-less
spibone/uartbone master) selected by the SOC_TYPE generic.
- FPGA/wb_bridge_top.vhd — a leaner CPU-less wrapper (bridge + master only, no softcore).
Ethernet
PTP (IEEE 1588 / PTPv2)
In hardware PTP mode the FPGA runs the full PTPv2 engine on-chip and the control plane only runs the BMC (best-master-clock) decision, feeding the resulting grandmaster priorities/identity back over the bus. In software PTP mode (FPGA top built with PTP_IN_SOFTWARE = true) the FPGA provides only hardware timestamping and CSR access to the wallclock (the bridge CSR map is identical in both modes), and a host PTP stack (ptp4l via the kernel driver, or Zephyr’s CONFIG_PTP) disciplines it.
Clock & Timing
Audio
Audio framing is now TDM-centric: I2S is handled as a mode of the TDM path (TDM_I2S_MODE) rather than a separate serialiser, so a single set of modules covers I2S and TDM8.
Configurable Generics
The core is parameterised through the generics on aes67_top.vhd (mirrored by soc_top.vhd / wb_bridge_top.vhd). The most useful ones:
| Generic |
Typical default |
Purpose |
SOC_TYPE |
"LITEX_HRAM" |
Control-plane core: LITEX_HRAM, LITEX_SDRAM, LITEX_SPIBONE, LITEX_UARTBONE |
platform |
"ALTERA" |
"ALTERA" or "GOWIN" vendor glue |
ETHERNET_TYPE / MII_WIDTH |
"RMII" / 2 |
PHY interface (RMII 100 Mbit or RGMII Gigabit) and MII data width |
SYS_CLK_NS_PER_TICK / MII_CLK_NS_PER_TICK |
8 / 20 |
System (125 MHz) and MII clock periods — keep in sync with the actual clocks |
TX_MAX_STREAMS / RX_MAX_STREAMS |
8 / 8 |
Maximum concurrent TX / RX RTP streams |
TX_CHANNELS / RX_CHANNELS |
16 / 16 |
Audio channel count (×2 for I2S, ×8 for TDM8) |
TX_BYTE_DEPTH / RX_BYTE_DEPTH |
3 / 3 |
Sample width in bytes (3 = 24-bit) |
TX_SAMPLE_BUFFER_DEPTH |
64 |
TX ring depth — must be a power of two (media-clock write pointer) |
RX_SAMPLE_BUFFER_DEPTH |
256 |
RX playout buffer depth (latency vs. jitter tolerance) |
AUDIO_TX/RX_TDM_CHANNELS / ..._TDM_INPUTS/OUTPUTS |
8 / 1–2 |
TDM lane width and number of TDM data lines per direction |
TDM_I2S_MODE / TDM_BCLK_MULT / TDM_FSCLK_50DUTY |
false / 256 / false |
I2S framing mode, BCLK multiplier, 50 %-duty frame sync |
AUDIO_TX/RX_USE_PARALLEL_INTERFACE |
false |
false = integrates tdm mux/demux true exposes raw sample values |
USE_EXTERNAL_PLL |
true |
true = drive audio clocks from the external Si5351A; false = use the on-chip NCO-generated clocks directly |
ENABLE_METERING |
true |
Per-channel signal/clip metering; set false to drop it and save logic |
STATIC_PTP_CONF |
true |
true = compile-time PTP servo/parser config; false = runtime-tunable from the control plane |
PTP_MOVING_AVERAGE_DEPTH |
8 |
Depth of the PTP offset/delay moving-average filter |
PTP_IN_SOFTWARE |
false |
true = host runs PTP (timestamping only in HW); false = full HW PTP engine |
MIIM_CLOCK_DIVIDER / MIIM_PHY_ADDRESS |
50 / 0 |
MDIO clock divider and PHY management address |
Defaults give a 48 kHz / 24-bit endpoint with up to 8 TX and 8 RX streams over 16 channels each.
Data Flow
flowchart LR
wcmc(["Wallclock + media clock<br/>(PTP-disciplined)"])
subgraph TXP["TX path"]
direction LR
ti(["TDM / I2S in"]) --> tsb["tx_sample_buffer"] --> trt["tx_router"] --> ttx["tx_transmitter"] --> arb["eth_tx_arbiter"] --> tmac["MAC"] --> tw(["wire"])
tcfg[["TX stream config RAM<br/>(control plane)"]] -.-> trt
end
subgraph RXP["RX path"]
direction LR
rw(["wire"]) --> rmac["MAC"] --> udp["UDP parse"] --> rrb["rx_ringbuffer<br/>(stream demux)"] --> ro(["TDM / I2S out"])
rcfg[["RX stream config RAM<br/>(control plane)"]] -.-> rrb
end
wcmc == "write ptr / RTP timestamp" ==> tsb
wcmc == "playout timing" ==> ro
Control Interface (Wishbone / LiteX CSR)
Every control-plane host reaches the data plane through the Wishbone-slave register set in aes67_wb_bridge.vhd, exposed as a LiteX CSR map. There is no longer a bespoke byte-command protocol — the bridge is a standard memory-mapped register block, so:
- The integrated VexRiscv accesses CSRs directly over its internal Wishbone bus.
- An external Linux host reaches the same CSRs over a CPU-less bridge —
spibone (SPI→Wishbone) or uartbone (UART→Wishbone) — generated by LiteX. The host issues word peek/poke reads/writes at byte addresses; each bridge applies its own address convention internally.
- The register map is resolved at runtime from the LiteX-generated
csr.csv / csr.json (for the aes67_bridge build). Regenerate the gateware and every host tracks the new addresses automatically — nothing is hard-coded.
Logical register groups exposed by the bridge include: device info; MAC/IP; control flags and per-domain reset (aes67_csr_reset, all-held at power-on, active-high); PTP grandmaster params and servo/parser tuning; PTP status (offset, mean path delay, grandmaster identity, lock); clocking/PPB status; channel metering; the Ethernet control buffer (eth_buf, RX-drain / TX-inject); and the write-only TX/RX stream config RAMs (up to 8 streams each). The authoritative, byte-exact field conventions live in the on-target driver eth_litex.c and are mirrored by the Rust aes67-config crate.
LiteX SoC & Bus Bridges
The LiteX SoC and the CPU-less bridges are generated by litex_soc/generate.py, which drives the aes67_soc Python package (one module per concern: platform, CRG, SoC assembly, and peripherals/). It emits portable Verilog into litex_soc/build/; the FPGA top-level feeds it clocks (no SoC-internal PLL).
Targets
source soc_firmware/.venv/bin/activate
python litex_soc/generate.py --target <name>
| Target |
Kind |
Role |
aes67_bridge |
Wishbone slave |
The AES67 peripheral/register block — the only target that builds the AES67 peripherals; every other target is a master to it. Produces the canonical csr.csv. |
cyclone10 |
Full VexRiscv SoC |
Integrated softcore + HyperRAM (Cyclone 10LP eval) |
cyc1000 |
Full VexRiscv SoC |
Integrated softcore + SDRAM (CYC1000) |
gowin |
Full VexRiscv SoC |
Integrated softcore + DDR3 (Tang Primer 20K) |
spibone |
CPU-less master |
SPI→Wishbone bridge for an external host |
uartbone |
CPU-less master |
UART→Wishbone bridge for an external host |
The CPU-less targets drop everything CPU-adjacent (VexRiscv, BIOS, boot stub) and just present a Wishbone master that drives the aes67_bridge slave across the FPGA top-level. The spibone master uses a forked spi_bone.py with burst commands for throughput.
Integrated SoC resources & boot
| Resource |
Details |
| CPU |
VexRiscv RISC-V (sys clock typ. 75–80 MHz, supplied by top-level) |
| RAM |
HyperRAM (Cyclone 10LP) or SDRAM (CYC1000) / DDR3 (Gowin) @ 0x20000000 |
| Flash |
SPI flash (BIOS + firmware) @ 0x30000000 |
| CSR |
Peripheral registers @ 0xf0000000 |
| Ethernet |
MAC ↔ SoC via litex_eth_buffer_bridge.vhd (dual-port packet buffers) |
| I2C / SPI / UART |
Display + Si5351A PLL, SD card, console |
Boot flow (integrated softcore): FPGA configures from its own flash → a RISC-V boot stub at the SPI-flash reset vector (litex_soc/boot_stub/) copies the LiteX BIOS into RAM and sets the HyperRAM latency (6 CK) → the BIOS loads the Zephyr .fbi image (binary + length + CRC-32) → Zephyr boots and starts DHCP and the application threads.
Firmware merges the CPU SoC’s CSR header with the aes67_bridge CSR header via merge_litex_csr.py (run from CMake), so the AES67 register addresses match the standalone bridge build.
config_tool/ is a Rust workspace that runs the control plane on an external Linux host (typically a Raspberry Pi). In the normal Linux deployment it runs on top of the aes67_eth kernel driver (next section): the driver owns the SPI→Wishbone bus and provides the net_device (+PHC for ptp4l), and config_tool reaches FPGA registers through the driver’s /dev/aes67ctl peek/poke char device. It has no native C dependencies, so it cross-compiles cleanly to ARM. See config_tool/README.md for the full guide.
aes67d (daemon) — serves a control API over a Unix socket and persists FPGA settings to a JSON config, replaying them on restart. Drives IGMP membership on behalf of the data plane, triggers DHCP on link-up, mirrors Linux IP → FPGA IP CSR, and runs a staged reset bring-up with warm-restart and auto-recovery (the gateware powers up with all reset domains held). With the kernel driver it works through the net_device//dev/aes67ctl; in standalone bring-up mode it can instead own a spibone/uartbone link directly and bridge eth_buf into its own TAP.
aes67cfg (CLI) — read/write registers by name (get/set/dump/peek/poke), set MAC/IP/PTP grandmaster, configure RX/TX streams, list discovered streams. Talks to the daemon by default, or directly to the FPGA with --spi/--uart for bring-up.
aes67web — a standalone monitoring/config web dashboard (embedded HTML, REST API), itself just another client of the daemon.
- Discovery — SAP/SDP announcement + foreign-stream discovery, plus mDNS/DNS-SD and RAVENNA RTSP (server for local TX streams, client
subscribe for remote sessions), all in pure-Rust crates (sap, sdp, mdns, rtsp).
Crate layout: transport (HAL: UART/SPI bridges) → config (CSR map + by-name device + stream helpers) → proto/client (daemon wire protocol) → daemon/cli/web. The whole stack is documented in config_tool/docs/control-plane-plan.md.
Linux Kernel Driver & Software PTP
driver/aes67_eth/ is an out-of-tree Linux driver that turns the FPGA (gateware built with PTP_IN_SOFTWARE = true) into a first-class network device with hardware PTP timestamping, so stock ptp4l disciplines the FPGA wallclock. This is “Phase 5 — PTP offload” of the control-plane plan.
net_device carrying the FPGA eth_buf datapath in-kernel (the RX-drain / TX-inject protocol the daemon used).
- PHC (
/dev/ptpN, clock aes67_wallclock) mapping the wallclock CSRs: gettime/settime/adjtime/adjfine.
- HW timestamps — TX from the
tx_timestamp_* CSRs, RX from the 5-byte trailer the FPGA appends after the payload (payload | FCS(4) | seconds(1) | nanoseconds_LE(4)).
/dev/aes67ctl — a peek/poke char device so userspace (aes67d / aes67cfg) keeps reaching FPGA registers while the kernel owns the SPI bus (single Wishbone master).
make # build against running kernel (or KDIR=… ARCH=… CROSS_COMPILE=… to cross-compile)
insmod aes67_eth_drv.ko
ethtool -T eth0 # shows HW TX/RX + a PHC index
ptp4l -H -i eth0 -m # hardware timestamping, disciplines the FPGA wallclock
aes67_regs.h is generated from the LiteX csr.csv (make regs CSV=…) so register addresses track the gateware. The same software-PTP idea also runs on the integrated softcore: the Zephyr firmware always contains both PTP services (CONFIG_PTP + the ptp_clock_aes67.c PHC driver alongside the FPGA-BMC path) and picks one at boot from the gateware’s static build configuration (system_cfg CSRs) — PTP_IN_SOFTWARE bitstreams get the Zephyr IEEE 1588 stack, hardware-PTP bitstreams get the FPGA engine.
Firmware — Integrated Softcore (Zephyr)
Zephyr RTOS application in soc_firmware/app/ (Zephyr v4.2.0; west manifest at soc_firmware/app/west-manifest/west.yml). This is the control plane for the single-chip (integrated softcore) builds. main.c is the full-feature entry point; src/main_spi_bringup.c is a minimal SPI-only bring-up for porting to a new external-MCU board.
Application Modules
Drivers
Supported Boards
Targets are at various maturity levels — the build matrix is still being shaken out (see todo.md).
| Board |
FPGA |
Control plane |
RAM |
Status |
Cyclone 10LP eval (c10_evalkit) |
10CL025YU256I7G |
Integrated softcore / External |
HyperRAM |
Primary single-chip target |
C10 006 (c10_006) |
Cyclone 10LP |
External / spibone |
none |
Working |
CYC1000 (trenz_cyc1000) |
10CL025YU256C8G |
Integrated softcore / External |
SDRAM |
Working |
Lattice (boards/lattice) |
Lattice |
Integrated softcore / CPU-less bridge |
— |
New / experimental |
| Tang Primer 20K (Gowin) |
Gowin GW2A-18C |
Integrated softcore |
DDR3 |
Experimental (Gowin EDA Ethernet clock-tree issues) |
FPGA board projects/pinouts live under FPGA/boards/ (Altera / Lattice / Gowin), each with its own top_*.vhd wrapping soc_top; Zephyr board configs under soc_firmware/app/boards/.
Building
Pull submodules first:
git submodule update --init --recursive
LiteX SoC / bridges
source soc_firmware/.venv/bin/activate
python litex_soc/generate.py --target aes67_bridge # AES67 register block + csr.csv
python litex_soc/generate.py --target cyclone10 # integrated VexRiscv SoC
python litex_soc/generate.py --target spibone # (or uartbone) CPU-less host bridge
Outputs land in litex_soc/build/<target>/. Regenerate after editing the aes67_soc package; the generated csr.csv is what every host (firmware, config_tool, kernel driver) resolves register names against.
FPGA
Open the per-board Quartus project under FPGA/boards/ (e.g. FPGA/boards/altera/c10_evalkit/FPGA.qpf) in Intel Quartus Prime 25.1 (primary device 10CL025YU256I7G), or the Gowin/Lattice flow for those boards. Pick the control-plane core via the SOC_TYPE generic on soc_top (LITEX_HRAM / LITEX_SDRAM / LITEX_SPIBONE / LITEX_UARTBONE).
Firmware (Zephyr — integrated softcore)
cd soc_firmware/app
source ../.venv/bin/activate
west build -b litex_vexriscv_cyclone10 -p # single-chip (HyperRAM)
west build -b litex_vexriscv_cyc1000 -p # single-chip (SDRAM)
One image serves both PTP modes: the firmware reads the gateware’s system_cfg CSRs at boot and starts either the FPGA hardware PTP path or Zephyr’s IEEE 1588 stack (software PTP) accordingly.
LiteX builds produce a .fbi flash image (binary + length + CRC-32) for loading via the LiteX BIOS.
Host control plane (Rust)
cd config_tool
cargo build --release # binaries: aes67d, aes67cfg, aes67web
# cross-compile to a Pi, e.g.:
cargo build --release --target aarch64-unknown-linux-gnu
Linux kernel driver
cd driver/aes67_eth
make # against the running kernel
make regs CSV=../../litex_soc/build/aes67_bridge/csr.csv # regenerate register header
Technical Notes
Resource usage
Rough numbers measured on a Cyclone 10LP (10CL025, ~24.6k LEs); Gowin (Tang Primer 20K) lands in the same ballpark. “LE” = logic element / 4-input-LUT-equivalent.
| Block |
~LEs |
Notes |
| TX path |
1600 |
sample buffer, router, transmitter, TX arbiter |
| RX path |
600 |
UDP parse, ringbuffer/demux, output |
| PTP (controller + parser + servo + sender) |
5500 |
the dominant cost — discipline maths and timestamping |
| Data-plane core total |
~8800 |
everything in FPGA Architecture |
spibone/uartbone bridge |
small |
CPU-less Wishbone master (external-host build) |
| LiteX SoC (full config) |
~7000 |
VexRiscv + Wishbone + peripherals (integrated build) |
So a single-chip integrated build is roughly core + SoC ≈ 8800 + 7000, while an external-host build is core + bridge with the rest of the control plane living on the Linux host. Disabling ENABLE_METERING, trimming stream/channel counts or buffer depths trades features for area; PTP is where the big wins would be — building with PTP_IN_SOFTWARE and disciplining the wallclock from a host (ptp4l) removes most of the 5500-LE PTP engine.
PTP clock discipline
In hardware mode ptpv2_servo.vhd is a PI controller: it filters offset measurements (configurable moving-average depth via average.vhd), outputs a frequency correction in PPB, and has lock detection with hysteresis (defaults 500 ns lock / 5 µs unlock) and message-interval-aware gain scaling. With STATIC_PTP_CONF=false it is tunable from the control plane. In software mode the FPGA only timestamps and exposes the wallclock; the host PI loop (ptp4l) does the discipline via the PHC.
wallclock.vhd generates all audio clocks directly from an NCO. The 48-bit phase accumulator (32 integer + 16 fractional bits) is disciplined straight from the PTP frequency correction (freq_correction_ppb), so it is locked to the wallclock without any external clock chip in the generation path. Its MSB is MCLK (≈24.576 MHz = fs×512); a counter over the NCO MSB edges (mclk_cnt, 0…511) divides that down into BCLK, LRCK/fsclk and the TDM/I2S frame clocks, plus the sample_pulse at fs. The media-clock counter is a pure function of the wallclock (seconds × 48000 + sample_in_second) and drives the RTP timestamps; the NCO-derived sample phase is pulled toward it so all PTP-locked boards stay sample-coherent.
License
See LICENSE.md. Third-party cores retain their own licenses: the Ethernet MAC and RMII bridge are pulled in as submodules under their respective upstream licenses; LiteX and its spibone/uartbone cores under their upstream licenses.
评论