Hardware-accelerated FMCW (Frequency-Modulated Continuous-Wave) radar signal processing on the PYNQ-Z2 (Xilinx Zynq XC7Z020) FPGA platform. The full range-Doppler processing chain is implemented as a streaming AXI4-Stream pipeline in programmable logic, driven from Python via the PYNQ framework.
AXI DMA ──► Windowing ──► Range FFT ──► 2D Corner-Turn Buffer ──► Windowing ──► Doppler FFT ──► AXI DMA
(MM2S) (Hanning) (256-pt) (256×256 BRAM) (Hanning) (256-pt) (S2MM)
fast-time Radix-2 DIT Row→Column transpose slow-time Radix-2 DIT
All IPs are connected via 32-bit AXI4-Stream and controlled through AXI4-Lite registers. Data flows through the chain without processor intervention—only the DMA transfers at the endpoints touch DDR memory.
| Parameter | Value |
|---|---|
| Carrier frequency | 77 GHz |
| Chirp bandwidth | 150 MHz |
| Sampling rate | 100 MHz |
| Fast-time samples per chirp | 256 |
| Slow-time chirps per frame | 256 |
| Chirp duration | 2.56 µs |
| Max unambiguous range | ~100 m |
| Range resolution | 1.0 m |
| Max unambiguous velocity | ~±128 m/s |
All IPs use a unified 32-bit packed complex format:
Bit [31:16] → 16-bit signed real (Q8.8 fixed-point, ap_fixed<16,8>)
Bit [15: 0] → 16-bit signed imag (Q8.8 fixed-point, ap_fixed<16,8>)
TLAST is asserted on the last sample of each 256-sample vector.
PYNQ_Z2_FMCW_Radar/
├── Windowing/ # 256-point Hanning Window HLS IP
│ ├── src/
│ │ ├── windowing.h
│ │ ├── windowing.cpp
│ │ └── windowing_tb.cpp
│ ├── run_hls.tcl
│ └── Windowing/ # Vitis HLS project outputs (solution1/)
│
├── FFT/ # 256-point Radix-2 DIT FFT HLS IP
│ ├── src/
│ │ ├── fft.h
│ │ ├── fft.cpp
│ │ └── fft_tb.cpp
│ ├── run_hls.tcl
│ └── FFT/ # Vitis HLS project outputs (solution1/)
│
├── Buffer/ # 256×256 2D Corner-Turn Buffer HLS IP
│ ├── src/
│ │ ├── buffer.h
│ │ ├── buffer.cpp
│ │ └── buffer_tb.cpp
│ ├── run_hls.tcl
│ └── Buffer/ # Vitis HLS project outputs (solution1/)
│
├── python/ # Beat-signal generation & Python-only processing
│ ├── fmcw_generate.py # Simulates 3-target FMCW beat signal
│ ├── fmcw_process.py # Range-Doppler processing + CA-CFAR detection
│ ├── beat_signal_complex.npy
│ ├── beat_signal_ddr.npy
│ ├── beat_signal_ddr_q8.npy
│ └── radar_params.npy
│
├── notebooks/ # PYNQ Jupyter notebooks & overlay driver
│ ├── fmcw_radar.ipynb # End-to-end demo on PYNQ-Z2
│ └── fmcw_pipeline.py # Overlay driver (DMA, IP control, post-processing)
│
└── README.md
All three IPs target the xc7z020clg400-1 at 100 MHz and are built with Vitis HLS 2025.1/2025.2. Each exposes:
- AXI4-Stream slave (
in_stream) and master (out_stream) — 32-bit TDATA - AXI4-Lite (
s_axi_control) — AP_CTRL, interrupt registers
| Top function | windowing |
| Operation | Multiplies each complex sample by a precomputed 256-point Hanning coefficient |
| Coefficients | ap_fixed<16,2> stored in ROM (14 fractional bits for [0, 1]) |
| Optimization | DATAFLOW (read → apply_window → write) |
| Latency | 778 cycles (7.78 µs) per 256-sample vector |
| Resources | 5 BRAM (1%), 2 DSP (<1%), 335 LUT (<1%), 229 FF (<1%) |
| Top function | fft |
| Operation | Bit-reversal permutation → 8 butterfly stages with precomputed twiddle factors |
| Twiddle factors | ap_fixed<16,2> ROM tables (cos/sin), 128 entries each |
| Internal precision | ap_fixed<24,12> accumulator (4-bit headroom over Q8.8) |
| Scaling | 1/2 per stage → 1/256 (1/N) total |
| Optimization | DATAFLOW (read → bit_reverse → fft_stages → write) |
| Latency | 3,355 cycles (33.55 µs) per 256-sample vector |
| Resources | 12 BRAM (4%), 4 DSP (1%), 1,525 LUT (2%), 839 FF (<1%) |
| Top function | corner_turn |
| Operation | Fills a 256×256 frame row-by-row, then drains column-by-column |
| Storage | Dual-port BRAM, 256 × 256 × 32-bit = 256 KB |
| Latency | 131,082 cycles (1.31 ms) per frame — 65,536 fill + 65,536 drain |
| Resources | 128 BRAM (45%), 0 DSP, 561 LUT (1%), 164 FF (<1%) |
Simulates a complex baseband FMCW beat signal for three targets with configurable range, velocity, and RCS. Outputs:
| File | Format | Description |
|---|---|---|
beat_signal_complex.npy |
complex128 (256×256) |
Full-precision floating-point beat signal |
beat_signal_ddr.npy |
uint64 (256×256) |
Q16.16 packed (32-bit real + 32-bit imag) |
beat_signal_ddr_q8.npy |
uint32 (256×256) |
Q8.8 packed (16-bit real + 16-bit imag) — pipeline input |
radar_params.npy |
dict | All radar/signal parameters for reconstruction |
Default simulated targets:
| # | Range | Velocity | RCS |
|---|---|---|---|
| 1 | 30 m | +10 m/s (approaching) | 20 dB |
| 2 | 75 m | −25 m/s (receding) | 15 dB |
| 3 | 120 m | +5 m/s (approaching) | 10 dB |
Reference Python implementation of the full processing chain:
- Hanning windowing (2D: fast-time × slow-time)
- Range FFT (fast-time axis)
- Doppler FFT (slow-time axis) → Range-Doppler map
- 2D CA-CFAR detection with clustering and peak extraction
- Visualization — beat signal, range profile, range-Doppler map, CFAR detections (saved to PNG)
Controls the hardware pipeline on the PYNQ-Z2 board:
- Loads the bitstream overlay
- Starts all five IPs in auto-restart mode (
AP_START | AUTO_RESTART = 0x81) - Sends a full 256×256 frame via AXI DMA (
MM2S) - Receives 256 × 256-sample chunks via
S2MM(Doppler FFT asserts TLAST every 256 samples) - Post-processes output into a range-Doppler map with correct axis scaling
IP address map:
| IP | Base Address |
|---|---|
windowing_0 (fast-time) |
0x4000_0000 |
fft_0 (range FFT) |
0x4001_0000 |
corner_turn_0 (2D buffer) |
0x4002_0000 |
windowing_1 (slow-time) |
0x4003_0000 |
fft_1 (Doppler FFT) |
0x4004_0000 |
axi_dma_0 |
0x4040_0000 |
- PYNQ-Z2 board with PYNQ image
- Vitis HLS 2025.1+ (for rebuilding IPs)
- Vivado 2025.1+ (for block design integration)
- Python 3 with
numpy,matplotlib
Each IP can be synthesized independently:
cd Windowing && vitis-run --tcl run_hls.tcl && cd ..
cd FFT && vitis-run --tcl run_hls.tcl && cd ..
cd Buffer && vitis-run --tcl run_hls.tcl && cd ..The TCL scripts run the full flow: C simulation → C synthesis → C/RTL co-simulation → IP packaging.
Packaged IPs are exported under <Component>/<Component>/solution1/impl/ip/.
cd python
python fmcw_generate.py
python fmcw_process.py # optional: verify processing in software- Import the three packaged IPs into a Vivado project targeting xc7z020clg400-1
- Instantiate the Zynq PS with an HP AXI port
- Connect the five processing IPs in a streaming chain via AXI4-Stream (Windowing → FFT → Buffer → Windowing → FFT)
- Attach an AXI DMA block (MM2S → first Windowing input, last FFT output → S2MM)
- Connect AXI-Lite interfaces to the GP AXI port
- Generate bitstream and export
.bit/.hwhfiles
- Copy the bitstream (
.bit,.hwh) andbeat_signal_ddr_q8.npyto the board - Open the Jupyter notebook
notebooks/fmcw_radar.ipynbor run:
from fmcw_pipeline import load_overlay, start_pipeline, process_frame, postprocess_range_doppler
import numpy as np
# Load overlay and start IPs
ol, ips = load_overlay("./overlays/fmcw_pipeline.bit")
start_pipeline(ips)
# Load Q8.8 beat signal and process
ddr_data = np.load("beat_signal_ddr_q8.npy")
output, elapsed = process_frame(ips, ddr_data.flatten().astype(np.uint32))
# Post-process into range-Doppler map
params = np.load("radar_params.npy", allow_pickle=True).item()
rd_map, rd_db, range_axis, vel_axis = postprocess_range_doppler(output, params)Estimated total resource usage for all five streaming IPs (2× Windowing, 2× FFT, 1× Buffer) on XC7Z020:
| Resource | Used | Available | Utilization |
|---|---|---|---|
| BRAM_18K | ~162 | 280 | ~58% |
| DSP48E1 | ~12 | 220 | ~5% |
| LUT | ~4,281 | 53,200 | ~8% |
| FF | ~2,210 | 106,400 | ~2% |
This project is provided for educational and research purposes.