Software-defined wireless

OCUDUSDRFPGA

A 5G cell on an M.2 card: bringing up the LiteX M2SDR with OCUDU

What it took to run an OCUDU gNB on an open-source AD9361 SDR with a commercial phone attached: hardware-timed TX, sample-exact RX timestamps, and a TX DMA that finally behaves like a FIFO.

Dan Mihai Dumitriu · · 14 min read

OCUDU gives the community an open, carrier-grade CU/DU. The radio underneath it has mostly been a USRP. We wanted to know whether a small, fully open radio could carry a real cell: the LiteX M2SDR from Enjoy-Digital, an M.2 2280 card with an Artix-7 XC7A200T and an AD9361, open from the gateware up to the SoapySDR driver.

It can. With the changes described here, an OCUDU gNB runs on the M2SDR on band n78 (TDD, 30 kHz SCS, 20 MHz, 23.04 MSps) with OCUDU's stock lower PHY and default timing, plus one static RX timestamp correction. A commercial phone attaches and holds the link:

14 minsoak with a phone attached and pinging
0.003 %TX frames late (292 of 9.5 million)
0RX discontinuities, “PUxCH request late” or “UL processor is busy”
42samples RX-to-TX offset, identical on 9 of 9 cold starts

Getting there was almost entirely about time: when a sample leaves the antenna, what timestamp a received sample carries, and how late the host may be before something breaks. None of it was exotic, but each piece hid behind the next. This post walks through them in the order we found them. All of the work is on the hw-timed-tx branch of our fork, with the full design notes in doc/timed_tx_hw.md and doc/rx_delivery_lag.md.

What a gNB expects from a radio

We connect OCUDU to the M2SDR through a SoapySDR radio driver we added to OCUDU (upstream ships UHD and ZMQ); it lives in lib/radio/soapy/ on the soapy branch of our fork. Whatever the driver, OCUDU, like srsRAN before it, assumes UHD semantics:

  • Timed TX. The gNB writes each burst with a timestamp. The radio holds it until that instant, emits it on exactly that sample, and drops it if it arrives late. The timeline never moves.
  • Exact RX timestamps. Every received buffer is labelled with the index of its first sample, and consecutive labels are contiguous.
  • Back-pressure. A write blocks once the radio holds enough not-yet-due samples, so the gNB cannot run arbitrarily far ahead.

A UE synchronises to the downlink it actually receives, then schedules its uplink against that. If the radio emits downlink samples a few microseconds away from where the gNB thinks it did, the uplink comes back off the gNB's slot grid. PRACH tolerates this: the format we use aliases the error modulo 33 µs, so the phone still gets through random access. PUSCH does not tolerate it, and the cell looks almost-working: attach starts, uplink decodes fail.

Starting point: timing in software

The M2SDR's original driver timed TX in software. It streamed through a free-running ring of 256 DMA buffers of 2048 samples each and anchored "sample N leaves at board time T" once per start, and again after every underflow. That anchor has one-buffer granularity, a race with the DMA start, and a whole-lap ambiguity (the ring is 22.76 ms at 23.04 MSps). So every start, the real emission time differed from the stamp by a random offset, and every re-anchor moved it again.

It was possible to work around this on the OCUDU side by measuring the offset at start-up and shifting RX timestamps to match. That got a phone attached, but it is a patch over a radio that doesn't keep time. Hardware-timed radios (UHD, LimeSDR, bladeRF in metadata mode) don't need it, so we set out to give the M2SDR the same semantics.

A timed TX gate in the FPGA

Earlier attempts existed. A scheduler inside the TX header module (late 2025) never streamed in the right frame. A sys-domain hold/pass/drop arbiter (spring 2026) worked logically but sat upstream of the AD9361 core's TX FIFO, which has priming hysteresis: it starts output only when half full and stops when empty. Anything released upstream of it lands in a FIFO whose fill time depends on DMA burst timing, so the release jitter was microseconds and every gap re-primed it.

The new design, TimedTXGate, sits between the TX header extractor and the loopback mux. Each 8 KiB DMA buffer already carries a 16-byte header with a 64-bit timestamp. On the first payload word of each frame the gate decides:

condition action
no timestamp, or gate disabled pass (untimed)
due in the future hold: stall the DMA reader; the PHY outputs zeros until the frame is due
late by less than the margin pass
late by more than the margin drop the frame and count it

While the gate is enabled, the SoC forces the RFIC TX FIFO into its started state, which removes the priming hysteresis. After a hold the downstream FIFOs are empty, so release-to-air latency is a fixed pipeline depth. In a continuous stream only the first frame after a start is actually held; the rest arrive on time and pass. After a host stall, the frames that missed their time are dropped and the stream re-aligns on the first on-time frame. No re-anchor, no timeline shift.

On the host side, the SoapySDR plugin gets a timed_tx=hardware device argument: it enables the DMA header, stamps every buffer with the board time of its first sample, and reports frames the gate dropped as SOAPY_SDR_TIME_ERROR.

What the air taught us

The gate passed simulation on the first day. Over the air, with OCUDU on top, it took a series of fixes before it was visible at all. Every one of these was in the driver or the plugin, not the gate:

  1. The DMA synchronizer waited for PPS. The kernel armed the TX DMA to start on the next PPS edge, up to a second later. Every frame stamped less than a second ahead was already late. Hardware mode now bypasses the synchronizer; the stamps carry the timing.
  2. The DMA reader prefetches, and the ring goes stale. The reader free-runs over the ring and prefetches two or three buffers ahead of its index. Slots not rewritten since the previous lap get re-read with their old stamps. The fix: zero the ring before starting (a zero header means untimed silence), and keep the write pointer a few slots ahead of the reader's live index rather than the kernel's count, which only refreshes every 8 buffers.
  3. One part per million of rounding. OCUDU stamps only the start of a burst; later writes are stamped from the plugin's own timeline, which advanced by llround(n × 1e9 / rate) nanoseconds per chunk. That 0.3 ns per chunk is about 1 ppm, so emission drifted ~20 samples per second until the burst restarted. The timeline is now anchor plus an exact sample count.
  4. The late margin becomes pipeline fill. A frame that arrives late but within the margin is emitted immediately, and its lateness stays in the TX pipeline as a fill level that never drains, because every later frame is then held until its stamp. With a one-frame margin the offset settled anywhere within a frame (1866 vs 168 samples on two starts). We cut the margin to 1 µs, so every frame goes through the hold path; the fine gate described below later let it grow again.
  5. Stale re-reads are not late frames. OCUDU ends its TX burst on every TIME_ERROR. The resulting gap made the reader sweep stale slots, whose drops were reported as more TIME_ERRORs: a self-sustaining loop after any host stall. Stale drops now go to their own counter and are not reported to the application.
  6. Loopback can't measure what matters. In the AD9361's PHY loopback, RX only produces samples while TX flows, so RX time freezes during a hold. Loopback proves hold, release, continuity and drops; the release offset has to be measured over the air.

The bug was on the receive side

With TX fixed, the cell still had a start-dependent failure: on about half of the starts the phone attached and then the uplink died, with OCUDU logging a permanent storm of "PUxCH request late" and "UL processor is busy". It looked like a TX timing problem. It wasn't.

RX frames were labelled with each frame's FPGA nanosecond timestamp. Those stamps carry quantisation noise, so converted back to samples the labels were not always contiguous: off by ±1 sample, 2,000 to 3,000 times per second, depending on the sub-sample phase at start. OCUDU's lower PHY treats any such mismatch as lost alignment and discards RX up to the next subframe. Across 8 starts the correlation was exact: zero label jumps meant zero late requests; 42k to 66k jumps meant the storm.

The fix was to stop using nanoseconds as the source of truth.

Labels by counting. The plugin labels RX buffers by counting samples from an anchor, using the FPGA stamp only to detect real gaps.

A sample counter in the gateware. Then we moved the counting into the FPGA. A 64-bit counter in the RFIC clock domain advances on every PHY word by the number of samples it carries. Each RX word travels through the clock-domain FIFO and pipeline together with its tick, and the header inserter stamps each frame with the tick of its first sample. On TX, the sys-domain gate still decides hold, pass or drop per frame, but now passes the frame's stamp through a small clock-domain FIFO to a new TXFineGate right in front of the PHY. That gives every word its own tick and emits it in the PHY slot carrying that tick. A word whose slot has passed is discarded; an early word waits. The stream can lose samples but is never shifted.

Hardware-timed TX path: host DMA, header extractor, TimedTXGate in the sys clock domain, clock-domain FIFO, TXFineGate in the RFIC clock domain, AD9361 PHY sys clock domain RFIC domain · 245.76 MHz Host PCIe DMA Header strip stamp TimedTXGate hold/pass/drop TXFineGate word = tick AD9361 PHY frame + stamp per frame, on ticks per word, exact slot sample counter (64-bit, RFIC domain) drives both gates and every RX stamp
The TX path on hw-timed-tx. The coarse gate decides per frame; the fine gate places every word on its exact sample tick in front of the PHY.

Two more details closed the loop. The PHY's TX word counter now follows the RX word counter instead of free-running, so the RX-sample-to-TX-slot relationship is the same after every initialisation. And with time_base=samples, every timestamp crossing the SoapySDR API is the sample tick itself rather than nanoseconds, so nothing depends on rounding.

Timed RX start and back-pressure. OCUDU seeds its timeline from "RX begins at init_time", but the plugin started RX about 95 ms earlier. A small gateware block now holds the RX header inserter until the requested tick, so the first frame delivered is that sample. And where a USRP blocks send() once its FIFO is full, the M2SDR accepted 22.7 ms of samples; OCUDU stamps its first frames 100 ms ahead, so its downlink ran 177 to 194 slots ahead of RX at start. The plugin now bounds the number of submitted-but-not-due frames (default 96, about 8.5 ms) and writeStream times out beyond that.

Result: on every start, the first RX frame is exactly OCUDU's init_time; zero non-contiguous labels and zero late uplink requests on 8 of 8 starts; the RX-minus-TX offset measured on the SSB is 42 samples on 9 of 9 cold starts, each including an AD9361 re-initialisation. A constant offset is just a calibration value: OCUDU applies it once as a static RX timestamp shift, and the residual offset measures 0 samples.

Latency: from 472 µs to 8 µs

With timing deterministic, the next limit was the latency budget. OCUDU's lower PHY releases a downlink slot when the newest RX timestamp reaches the slot start minus rx_to_tx_delay (1 ms by default). Every microsecond the RX path spends delivering a buffer comes out of the time TX has to reach the FPGA.

Measured against the gateware sample counter, the age of the newest sample in each buffer when the receiving thread got it had a median of 472 µs. Two causes:

  • One interrupt per 8 buffers. Buffers arrived in batches of 8, the first of each batch already 621 µs old.
  • The completed-buffer count was one short. The DMA table's status is the index of the descriptor that completed last, not a count. The driver used the bare index, so the newest complete buffer was withheld until the next interrupt.

Fixing both (one interrupt per RX buffer by default, count = index + 1, and POLLIN as soon as one buffer is ready) brought the median to 8 µs:

mode p50 p99 p99.9 IRQ/s
before: 8 buffers/IRQ, count one short 472 µs 724 µs 764 µs 2.8 k
1 buffer/IRQ, count fixed (new default) 8 µs 38–46 µs 204–320 µs 12.7 k
busy-poll (rx_poll=busy) 4 µs 8–22 µs 130–208 µs 1.5 k

As seen by OCUDU's lower PHY, one thread hop later, that's 355 µs average before and 20 µs now.

Do we need a DPDK-style poll-mode driver?

For RX delivery, no. Busy-polling halves the median (8 → 4 µs, against an 88.7 µs frame and a 1 ms budget) and costs a full core. The p99.9 and the maximum don't move at all: on our test laptop they are set by firmware stalls of up to ~0.2 ms at least once a second, measured with the hwlat tracer. A poll-mode driver only improves the tail on a host prepared for it, with isolated cores and quiet firmware. The stream itself is 11,272 frames/s, nowhere near the packet rates DPDK exists for.

The last piece: a TX DMA that stops

With RX fast, OCUDU was handing each downlink slot to the driver about 940 µs ahead of its time. That should be plenty. It wasn't, because of what happened when the host was late once.

The TX DMA ran in LitePCIe's loop mode: the reader free-runs around a 256-slot ring, paced only by the gate holding the frame at its head. When a host stall let the reader catch up with the write pointer, nothing held it any more. Every slot ahead was a frame from the previous lap, so the gate dropped them as stale at PCIe speed; the reader lapped the ring every ~2 ms, and the host, writing 11 frames per millisecond, could never get ahead of a reader moving 125 slots per millisecond. One stall of a millisecond turned into an 80 ms episode costing ~500 late and ~4000 stale frames. With the default 1 ms delay, episodes were continuous; we had been running OCUDU with a 4–5 ms delay to stay clear of them.

What TX needed was FIFO semantics: the reader should stop when there's nothing new, like a USRP's flow-controlled TX FIFO. LitePCIe already supports this through its descriptor table's prog mode, which executes each descriptor once and stops when the table is empty. No gateware change was needed: a new ioctl selects FIFO mode, and each submit queues one descriptor per buffer. A frame is now fetched as soon as it's submitted, nothing is ever read twice, and a frame submitted too late is simply dropped by the gate.

rx_to_tx_delay loop mode FIFO mode
5 ms clean clean
2 ms episode when the phone attached 0 late, 0 stale
1.5 ms 8–12 episodes per 45 s 0 late, 0 stale
1 ms (OCUDU default) continuous episodes, unusable 0 late, 0 stale

That is the configuration behind the numbers at the top: stock OCUDU timing, 14 minutes with a phone attached and pinging, 292 late frames out of 9.5 million, ping 2769/2800. Each host stall now costs exactly the frames that were late and nothing more. HARQ error rates dropped too, from DL 3–19 % and UL 10–28 % with the 4 ms workaround to DL ~4 % and UL 4–9 %.

Along the way, every OCUDU-side workaround came back out. Earlier in the bring-up we had a configurable RX-to-TX delay in the lower PHY, a larger UL request pool, a wider RA response window and a raised max_proc_delay. All of them are gone. OCUDU's lib/phy is identical to upstream; the radio driver is the only OCUDU change the M2SDR needs, which is how it should be.

Try it

Two branches, both public:

  • Radio: hw-timed-tx in our M2SDR fork — gateware, kernel module, libm2sdr and the SoapySDR plugin.
  • gNB: soapy in our OCUDU fork — the SoapySDR radio driver (lib/radio/soapy/), the cell config and the operational scripts. The full bring-up notes are in docs/m2sdr_soapy_bringup.md.

Build and load the radio side:

# Gateware (Vivado 2026.1)
python3 litex_m2sdr.py --variant m2 --with-pcie --pcie-gen 2 --pcie-lanes 4 --build

# Flash, reload, then rescan the PCIe bus
m2sdr_util flash-write <bitstream>_operational.bin
m2sdr_util flash-reload

# Rebuild and reload the kernel module (litex_m2sdr/software/kernel),
# rebuild user/ and the SoapySDR module, install them, and check:
SoapySDRUtil --find

Build OCUDU from the soapy branch as usual, then start from configs/gnb_soapy_m2sdr_hwtimed.yml. The radio section is the interesting part:

ru_sdr:
  device_driver: soapy
  device_args: driver=LiteXM2SDR,ad9361_fir_profile=bypass,rx_worker_packets=2048,freq_corr_ppm=1.327,timed_tx=hardware,time_base=samples
  srate: 23.04
  tx_gain: -10      # the driver maps TX gain to AD9361 attenuation: 10 dB here
  rx_gain: 40
  expert_cfg:
    tx_mode: continuous

freq_corr_ppm is the crystal error of our board; measure yours. timed_tx=hardware also selects the FIFO TX queue and the gateware sample counter.

The one runtime setting is the static RX timestamp correction, passed as an environment variable:

OCUDU_SOAPY_RX_TS_SHIFT=-42 ./apps/gnb/gnb -c configs/gnb_soapy_m2sdr_hwtimed.yml

42 samples is the over-the-air value for our board at 23.04 MSps; scripts/m2sdr/align.py measures it from TX and RX captures (keep the phone in airplane mode while you measure, or its uplink swamps the SSB the tool correlates on). The phone registers even without the shift, since 2 µs is well inside the PRACH detection window. For long runs, scripts/m2sdr/guardian_hw.sh starts the gNB with the shift and restarts it on real-time trouble.

While the cell runs, the gate's counters should show late and stale flat and passed tracking the frame rate (11,272 frames/s at 23.04 MSps). Without RF, software/soapysdr/test_hw_timed_tx.py --lead-ms 300 --reps 4 --late-test in the M2SDR repo exercises hold, release, sample continuity and late drops through the AD9361 PHY loopback.

What's next

  • Upstream. This all started from Enjoy-Digital's excellent base design, and the goal is to get the timed TX gate, the sample counter and the driver fixes back into enjoy-digital/litex_m2sdr, and the SoapySDR radio driver into OCUDU.
  • Higher rates. At 61.44 MSps 2R2T a DMA frame is 16.6 µs, which would mean 60k interrupts per second at one interrupt per buffer. A larger interrupt period or busy-polling is the answer there.
  • Better hosts. Everything above was measured on a six-core laptop with SMI-heavy firmware and Wi-Fi that re-associates every half hour. The remaining tail is the host, not the radio.
  • Lower user-plane latency. With deterministic timing, the margins can shrink. On a PREEMPT_RT kernel with max_proc_delay at its minimum of 1, ping RTT through the cell went from 28 ms with the default configuration to 12 ms (one-way UL 21.8 → 7.75 ms, DL 4.6 ms), with no loss. Experiments with an event-driven downlink go further and are on the low-latency-phy branch; that's a post of its own.

We'll be at the OCUDU developer summit and happy to talk through any of this, or to see an M2SDR running your cell.