Split the OCUDU gNB at the O-RAN 7.2 fronthaul with no vendor radio: the OCUDU O-DU (gnb with ru_ofh) and Open5GS on one sb1 server, ProtO-RU — a software O-RU — driving the sb1 USRP N310 from a second sb1 server, eCPRI over VLAN 4 between them, and a commercial 5G modem as the UE. Both servers run the same ocudu.ndz image; one command per host renders its configuration.
The O-RAN 7.2 split moves the low PHY (FFT/IFFT, cyclic prefix, PRACH extraction) out of the gNB into an O-RU, and carries frequency-domain IQ as eCPRI over Ethernet. OCUDU (srsRAN Project) implements the O-DU side of that split; with a commercial O-RU (see OCUDU with an O-RAN 7.2 O-RU) the radio is a closed box.
ProtO-RU (NUS CIR) is the open counterpart: a Category-A O-RU written on the OCUDU/srsRAN code base (its ru_emulator application), that terminates the Open Fronthaul C-plane/U-plane and PRACH streams, runs the low PHY in software and uses an ordinary USRP (B210, N310, X410) as its RF front end. Because both halves are software you get counters and logs on both ends of the fronthaul — on-time/early/late windows, sequence errors, lower-PHY real-time failures — which a hardware O-RU never shows you.
This is OCUDU's equivalent of Duranta's nr-oru (Duranta soft O-RU). Everything runs on sb1: two servers with Intel E810 fronthaul NICs on the shared VLAN-4 fabric, the N310 sdr1-s1-lg1 as the radio, and the Quectel modem on sdr1-in3 as the UE.
After completing this tutorial you will be able to:
OFH sector metrics.| Difficulty | Advanced |
| Estimated time | 60–90 min (including one reboot per server for CPU isolation) |
| Domain / sandbox | sb1 (two E810 servers + the N310 sdr1-s1-lg1 + the sdr1-in3 modem host) |
| Topic group | Cellular (4G/5G/O-RAN) |
| Last verified | 2026-09-15 on sb1 (srv1-lg1 O-DU ↔ srv2-lg1 O-RU, N310 sdr1-s1-lg1, modem on sdr1-in3) |
| Upstream source | OCUDU gitlab.com/ocudu/ocudu dev @ 36128c56ef + one COSMOS patch (T1a/Ta4 range to 5000 µs); ProtO-RU github.com/NUS-CIR/ProtO-RU branch dev @ a3ba4b9 |
Background knowledge
/opt/proto-ru/proto-ru/ (ARCHITECTURE.md, TIME_SYNC.md, TROUBLESHOOTING.md, KNOWN_ISSUES.md).Account & access
Devices / nodes
| Resource | Role | Qty | Notes |
|---|---|---|---|
srv1-lg1.sb1 |
O-DU (gnb) + Open5GS core |
1 | 2× Xeon Gold 6226, E810 DATA1a on NUMA 1 |
srv2-lg1.sb1 |
O-RU (ru_emulator) |
1 | same hardware; reaches the N310 over DATA2a |
sdr1-s1-lg1.sb1 |
USRP N310 (radio of the O-RU) | 1 | 10.39.2.1, XG FPGA (2× 10GbE) |
sdr1-in3.sb1 |
UE | 1 | Quectel RM520N-GL, SIM IMSI 001010000000032 |
Any two COSMOS 7.2 servers work (Xeon Gold 6126/6226, AMD EPYC); nothing below is specific to these two.
Disk images
| Image | Load onto | Provides |
|---|---|---|
ocudu.ndz |
srv1-lg1, srv2-lg1 |
OCUDU gnb (T1a/Ta4 up to 5000 µs), ProtO-RU ru_emulator, Open5GS, UHD 4.9, PREEMPT_RT kernel, protoru-render, cosmos-ofh-isolation |
Software components
| Component | Version | Source |
|---|---|---|
OCUDU gnb |
dev @ 36128c56ef + T1a/Ta4 range patch, -march=skylake-avx512 |
/opt/ocudu/build/apps/gnb/gnb |
ProtO-RU ru_emulator |
dev @ a3ba4b9, -march=skylake-avx512 |
/usr/local/bin/ru_emulator → /opt/proto-ru |
| Open5GS | as baked | /root/open5gs/start.sh |
| UHD | 4.9 | image |
| Renderer / launcher | COSMOS | /root/proto-ru/protoru-render.py, protoru-run.sh, protoru-host-prep.sh |
Spectrum / RF / special
sb1 VLAN-4 fronthaul fabric (eCPRI, PTP G.8275.1 boundary clock)
┌────────────────────────────┐ DATA1a DATA1a ┌─────────────────────────────┐
│ srv1-lg1 O-DU │<========================>│ srv2-lg1 O-RU │
│ Open5GS (AMF 127.0.0.5) │ C/U-plane + PRACH │ ProtO-RU ru_emulator │
│ OCUDU gnb (ru_ofh) │ BFP-9, VLAN 4 │ low PHY in software │
│ ogstun 192.168.100.1/22 │ │ │ DATA2a (10GbE) │
└────────────────────────────┘ └────────┼────────────────────┘
│ UHD, 61.44 Msps
┌─────────▼─────────┐ n41 OTA ┌──────────────┐
│ N310 sdr1-s1-lg1 │ ~~~~~~~~~~> │ sdr1-in3 │
│ 10.39.2.1 │ <~~~~~~~~~~ │ Quectel UE │
└───────────────────┘ └──────────────┘
Both servers take their time from the fabric's G.8275.1 boundary clock on DATA1a (LLS-C3: the O-DU and O-RU each follow the switch). The O-RU's real-time cores sit on the DATA1a NUMA node, so the USRP stream is routed over DATA2a (the E810 on the same node) rather than the default DATA2.
ssh <username>@console.sb1.cosmos-lab.org
omf load -i ocudu.ndz -t srv1-lg1,srv2-lg1 -o 2400 -b 600
omf tell -a on -t srv1-lg1,srv2-lg1
omf stat -t srv1-lg1,srv2-lg1
omf load sometimes leaves a node powered off: if omf stat says POWEROFF, send omf tell -a on again.omf tell -a on -t sdr1-s1-lg1
ssh root@srv2-lg1 'uhd_find_devices --args addr=10.39.2.1'
serial: 3176DF5
fpga: XG
name: ni-n3xx-3176DF5
product: n310
isolcpus is a boot parameter and the right set differs per host, so the image carries none. This derives the set for the fronthaul NIC, writes GRUB, and says whether a reboot is needed:ssh root@srv1-lg1 /root/ofh-isolation-ensure.sh DATA1a
ssh root@srv2-lg1 /root/ofh-isolation-ensure.sh DATA1a
REBOOT-NEEDED want=7,9,11,13,15,17,19,21,23 have=none
Reboot each server that printed REBOOT-NEEDED (ssh root@srv1-lg1 systemctl reboot), wait for it, and re-run the command: it must now print ISOLATED 7,9,11,13,15,17,19,21,23.Run the same prep on both servers. It sets the performance governor, disables deep C-states, puts DATA1a in promiscuous mode with MTU 9000, moves NIC IRQs off the isolated cores, raises the socket buffers UHD needs at 61.44 Msps, and locks PTP to the fabric (step once, then slew, UTC timescale):
ssh root@srv1-lg1 'bash /root/proto-ru/protoru-host-prep.sh DATA1a'
ssh root@srv2-lg1 'bash /root/proto-ru/protoru-host-prep.sh DATA1a'
pinned 54 IRQs to 1,3,5
== PTP G.8275.1 from the switch BC (step once, then slew; UTC timescale -w, same as the O-DU)
ptp4l: ptp4l[128.920]: rms 6 max 15 freq -1134 +/- 9 delay 246 +/-
phc2sys: phc2sys[128.586]: CLOCK_REALTIME phc offset 25 s2 freq -91473 delay 4
===== HOST-PREP-DONE
rms in single-digit nanoseconds on both hosts is what you want.
Each side's configuration names the other side's DATA1a MAC:
ssh root@srv1-lg1 'protoru-render mac' # the O-DU MAC
ssh root@srv2-lg1 'protoru-render mac' # the O-RU MAC
50:7c:6f:6c:76:9c
50:7c:6f:6c:76:38
ssh root@srv2-lg1 'bash /root/proto-ru/protoru-run.sh ru 50:7c:6f:6c:76:9c'
wrote /root/proto-ru/ru.yml
RU-UP pid 2857
protoru-run.sh ru routes 10.39.2.1 over DATA2a, renders /root/proto-ru/ru.yml for this host and starts ru_emulator under a taskset covering every CPU. The rendered file carries the host-derived parts:
ru_emu:
timing_cpus: [7]
cells:
- network_interface: DATA1a
ru_mac_addr: 50:7c:6f:6c:76:38
du_mac_addr: 50:7c:6f:6c:76:9c
vlan_tag: 4
bandwidth: 40
dl_arfcn: 519000
band: 41
prach_port_id: [4]
ofh_cpus: [9, 11, 13]
sdr:
device_args: type=n3xx,addr=10.39.2.1,master_clock_rate=122.88e6,recv_frame_size=7900,send_frame_size=7900
srate: 61.44
tx_gain: 37
rx_gain: 40
ru_cpus: [15, 17, 19]
Until the O-DU starts, ProtO-RU prints its per-second RX table with all zeros.
ssh root@srv1-lg1 'bash /root/proto-ru/protoru-run.sh du 50:7c:6f:6c:76:38'
open5gs: inventory (cosmos-sim-db 2026-08-28) 73 SIMs on 00101 incl. soft pool; provisioned 73; missing 0
wrote /root/proto-ru/du.yml
DU-UP pid 3235
This starts Open5GS (if it is not running), reconciles the COSMOS SIM catalogue into it, renders /root/proto-ru/du.yml and starts gnb. The O-DU uses ProtO-RU's timing windows (t1a_max_cp_dl: 2435 and friends); stock OCUDU caps these at 1960 µs, the ocudu.ndz build accepts up to 5000 µs. The O-DU's cores come from the same isolated set: ru_cpus 7,9; OFH txrx_cpus 11,13,15,17; the main pool everywhere else.
On the O-RU, the RX_ON_TIME column should carry the traffic and RX_LATE / RX_SEQ_ERR stay 0:
ssh root@srv2-lg1 'grep -a "^| " /root/proto-ru/ru-stdout.log | tail -2 | cut -c1-110'
| 11:03:52 | 0 | 4555 | 3398 | 0 | 0 | 0 | 257 |
| 11:03:53 | 0 | 4542 | 3386 | 0 | 0 | 0 | 256 |
On the O-DU, the OFH metrics count every received message as on time:
ssh root@srv1-lg1 'grep -a "OFH sector#0" /tmp/ocudu-protoru-du.log | tail -1 | cut -c1-140'
[METRICS ] OFH sector#0 metrics: pci=3 received messages stats: rx_total=7600 rx_early=0 rx_on_time=7600 rx_late=0
On sdr1-in3, the modem needs SA-only, band n41, the COSMOS-O APN and a PLMN lock; then a QMI data call. The OCUDU 7.2 bundle's modem helper does exactly this (ocudu-72_modem.py, from the OCUDU O-RU tutorial bundle):
ssh root@sdr1-in3
systemctl stop ModemManager
python3 ocudu-72_modem.py configure /dev/quectel-at 00101 41 COSMOS-O
python3 ocudu-72_modem.py wait-reg /dev/quectel-at 240 00101
+QENG: "servingcell","NOCONN","NR5G-SA","TDD",001,01,00066C000,3,7,516030,41,1,-97,-10,18,1,42
REGISTERED
AT+QMBNCFG="AutoSel",0 # and AT+QMBNCFG="Deactivate" if a carrier MBN is active
AT+QNWPREFCFG="mode_pref",NR5G
AT+QNWPREFCFG="nr5g_disable_mode",0
AT+QNWPREFCFG="nr5g_band",41
AT+CGDCONT=1,"IP","COSMOS-O"
AT+COPS=2 ; AT+CFUN=0 ; AT+CFUN=1
AT+COPS=1,2,"00101",12
AT+C5GREG? # wait for +C5GREG: 0,1
Data call and routes (never a default route via the modem — the host's management traffic uses the default):
echo Y > /sys/class/net/wwan0/qmi/raw_ip; ip link set wwan0 up
qmicli -p -d /dev/cdc-wdm0 --wds-start-network="apn='COSMOS-O',ip-type=4" --client-no-release-cid
qmicli -p -d /dev/cdc-wdm0 --wds-get-current-settings | grep -E "IPv4 (address|gateway)"
ip addr add 192.168.100.3/22 dev wwan0; ip link set wwan0 mtu 1400
ip route replace 192.168.100.0/22 dev wwan0
ip route replace 8.8.8.8/32 via 192.168.100.4 dev wwan0
ping -I wwan0 -c 10 192.168.100.1; ping -I wwan0 -c 10 8.8.8.8
iperf3 -c 192.168.100.1 -B 192.168.100.3 -t 10 -R # DL (the O-DU host runs iperf3 -s)
iperf3 -c 192.168.100.1 -B 192.168.100.3 -t 10 # UL
10 packets transmitted, 10 received, 0% packet loss, time 2707ms
10 packets transmitted, 10 received, 0% packet loss, time 2707ms
[ 5] 0.00-10.01 sec 123 MBytes 103 Mbits/sec receiver
[ 5] 0.00-10.09 sec 46.9 MBytes 39.0 Mbits/sec receiver
Use the addresses --wds-get-current-settings prints; the ones above are from the verification run. Read the iperf3 receiver line — it is the measurement.
| Check | Where | Expected |
|---|---|---|
| PTP locked | both hosts, protoru-host-prep.sh output |
rms < 20 ns |
| Fronthaul RX | O-RU table | RX_LATE 0, RX_SEQ_ERR 0 |
| Fronthaul RX | O-DU OFH sector#0 |
rx_late=0 |
| Cell | O-DU /tmp/ocudu-protoru-du.log |
==== gNB started ===, scheduler metrics pci=3 |
| UE | modem | NR5G-SA, PCI 3, ARFCN 516030, band 41, +C5GREG: 0,1 |
| Data | UE | ping 0 % loss to 192.168.100.1 and 8.8.8.8 |
Performance (iperf3 TCP, receiver line, 10 s; n41 40 MHz 1×1, DDDSU):
| CPU platform (O-DU and O-RU hosts) | Logical CPUs | Radio | DL Mbit/s | UL Mbit/s | RSRP / SINR | Date | Image |
|---|---|---|---|---|---|---|---|
| 2× Intel Xeon Gold 6226 (24C/24T) | 24 | USRP N310, tx_gain 37 | 88–103 | 38–39 | −97 dBm / 18 dB | 2026-09-15 | ocudu-20260915 (4 runs: 1 on the build host, 3 from the loaded image) |
UL is bounded by the TDD pattern: one uplink slot (plus two symbols) in every five.
The CPU isolation from Setup step 4 stays on both servers until you remove it. Remove it before running the direct-USRP OTA tutorial on the same server: that gNB is not pinned, so on an isolated host it gets only the non-isolated CPUs and floods Real-time failure in RF: late under traffic (measured: DL 4–59 Mbit/s with isolation, 59.7 / UL 59.5 without, same image and radio).
ssh root@srv1-lg1 'sed -i -E "s/ ?(isolcpus|nohz_full|rcu_nocbs)=[^ \"]*//g" /etc/default/grub && update-grub && systemctl reboot'
Then stop the run:
ssh root@sdr1-in3 'qmicli -p -d /dev/cdc-wdm0 --wds-stop-network=disable-autoconnect; ip link set wwan0 down'
ssh root@srv1-lg1 'kill $(pgrep -x gnb); bash /root/open5gs/stop.sh'
ssh root@srv2-lg1 'kill $(pgrep -x ru_emulator)'
omf tell -a offh -t srv1-lg1,srv2-lg1
| Symptom | Cause | Fix |
|---|---|---|
gnb exits: Invalid CPU core selected '16'. Valid CPU ids: [0, …, 14] |
the process was started under a mask without the isolated cores. nproc counts only the caller's mask, so taskset -c 0-$(($(nproc)-1)) shrinks to the non-isolated CPUs |
launch under taskset -c 0-$(($(nproc --all)-1)) (the launcher does) |
gnb rejects t1a_max_cp_dl: 2435 |
stock OCUDU caps T1a/Ta4 at 1960 µs | use the ocudu.ndz build (range patched to 5000 µs) |
RU number of PRACH ports=1 must be equal or greater than the number of reception antennas=2 |
2×2 needs one PRACH eAxC per antenna | prach_port_id: [4, 5] on both sides (protoru-render --layers 2 does it) |
2×2 gives less DL than 1×1 (45.6 vs 103), Real-time failure in lower PHY hundreds of times |
two 61.44 Msps chains overrun ProtO-RU's three lower-PHY cores | stay 1×1 at 40 MHz, or 2×2 at 20 MHz |
| UE camps at RSRP ≈ −112 / SINR ≈ 4, DL ~38 Mbit/s | ProtO-RU applies a 12 dB gain_backoff, so the OTA tutorial's tx_gain 25 radiates 12 dB less |
tx_gain 37 (the renderer's default) |
UHD: The recv buffer could not be resized sufficiently |
kernel socket-buffer ceiling too low for 10GbE streaming | sysctl -w net.core.rmem_max=33554432 net.core.wmem_max=33554432 (host prep does it) |
==== gNB started === never appears on stdout although the cell is up |
srsRAN block-buffers stdout | read the file log /tmp/ocudu-protoru-du.log |
O-DU rx_total = 0 while tcpdump shows frames |
the AF_PACKET socket needs the NIC in promiscuous mode |
enable_promiscuous: true + ip link set DATA1a promisc on (both done) |
O-RU RX_LATE climbing |
PTP not locked on one side, or O-RU OFH threads sharing a core | re-run host prep; keep ofh_cpus ≥ 3 isolated cores |
dev; the default branch is older) — on the image in /opt/proto-ru/proto-ru/.Topic group: Cellular (4G/5G/O-RAN) · Last verified: 2026-09-15 on sb1 · Image: ocudu.ndz (build of 2026-09-15)