Skip to content

rfsimulator Multi-UE Testbed

Run a complete 5G network, base station, handsets, core and real IP traffic, on a single Linux machine with no radio hardware. This testbed replaces the air interface with a TCP socket, and nothing else. The gNB's protocol stack, the UE's protocol stack, the core network, the IP tunnels and the traffic that flows through them are all real software doing real work, the same code that would run against a software-defined radio.

The OCUDU-RAN rfsimulator multi-UE testbed. On the left, N instances of the OAI nr-UE: each is an nr-uesoftmodem process carrying PHY, MAC, RLC, PDCP, RRC and NAS, running in its own network namespace with an oaitun_ue1 tunnel interface at MTU 1400 and a v-ue veth on the namespace side, seen from the host root namespace as a v-eth with NAT masquerade reaching the gNB seam. Two paths cross this topology: baseband IQ from UE through veth to host to gNB on port 4043, and user traffic from iperf3 through the tunnel and GTP to the core. In the centre, the TCP port 4043 wire protocol: a 32-byte fixed header per transfer carrying SIZE (samples per antenna), NBANT (antenna count), TIMESTAMP (the simulated-time clock), RESERVED options and BEAM_MAP, followed by one sample buffer per antenna of c16_t values, 16-bit real plus 16-bit imaginary in 4 bytes. The transfer is lockstep, not free-running: a block must reach the peer before the peer can reply. On the right, the OCUDU gNB: a CU with CU-CP holding RRC, NGAP, XnAP and PDCP-C and CU-UP holding PDCP-U, SDAP and GTP-U, all production code identical to a hardware build; and a DU comprising DU-high with MAC scheduler and RLC, DU-low with encode/decode, modulation and channel estimation, and a Radio Unit with OFDM modulation and sample timing. Beneath these sits the baseband_gateway plug-in point, where four radio drivers are siblings behind one interface selected by device_driver: uhd over USB or Ethernet and free-running, zmq over sockets and blocking, rfsimulator over TCP and blocking, and sidekiq on hardware and free-running. The rfsimulator implementation splits into OCUDU-native parts (radio_session_ rfsimulator_impl, rfsim_shim.cpp for config, logging and stubs, and oai_shim with 11 headers) and vendored upstream parts (simulator.cpp at 1744 lines, a TCP server with an epoll loop, and per-peer ring buffers). A 5G Core, SD-Core, connects over N2 and N3. Below, the gNB thread model shows a sequential profile forced by a blocking driver, across RADIO_EXEC (a lockfree MPMC radio worker polling at 50 microseconds), RU_PHY_EXEC (a locking MPSC ru_phy_worker with no realtime priority), a DU-low pool that is sequential by default and parallel via environment override, and DU-high/CU carrying MAC, RLC, PDCP, NGAP and GTP-U; cores are isolated with isolcpus and assigned in three pools for UEs, the gNB and the harness. At the bottom left, the test harness rfsim_multi_ue.sh plus rfsim_report.py runs six stages: namespaces, launch UEs, await tunnels, iperf3 sweep, probe and ping, and report, producing a per-UE CSV of about 60 columns keyed by run_id, an aligned summary.txt with aggregates, and failure isolation where a stalled UE is skipped rather than averaged in. At the bottom right, scaling results: N equals 8 gives a real-time factor of 1.05 at 106 Mbps and 13.3 per UE; N equals 16 gives 0.53 at 54 Mbps and 3.4 per UE; N equals 32 gives 0.20 at 21 Mbps and 0.67 per UE; N equals 50 gives 0.14 at 15 to 16 Mbps and 0.31 per UE.
The testbed end to end. The rfsimulator joins uhd, zmq and sidekiq as one more driver behind the same baseband gateway interface, so everything above it is untouched production code. Each UE runs in its own network namespace, which is what allows many of them to coexist on one host.

That single substitution turns a lab full of hardware into one server. It makes it practical to attach 50 handsets to one base station, run traffic through every one of them, and measure the result repeatedly, from a script, in CI if you want to.

What it is good for

  • Multi-UE scale and capacity testing without a physical handset per UE.
  • Protocol development and regression testing across the full stack.
  • Mobility procedures such as Xn handover between two base stations.
  • Reproducible benchmarks: same inputs, same numbers, every run.

What it is not. There is no channel model. The propagation path is ideal, so there is no fading, no path loss and no noise. This testbed measures protocol and software behaviour, not radio performance.

How it is built

Three pieces make it work.

An rfsimulator radio driver in the gNB. OCUDU-RAN already supported several radio back-ends: USRP hardware over UHD, ZeroMQ, and Sidekiq. The rfsimulator joins them as one more driver behind the same interface, selected at runtime by device_driver: rfsimulator. Everything above the driver is untouched production code.

The OAI rfsimulator, vendored. The wire protocol and sample-mixing logic come from OpenAirInterface's rfsimulator, carried in-tree at lib/radio/rfsimulator/vendor/ and compiled unmodified. A small shim layer supplies the eleven headers it expects, so no OAI source tree is needed to build the gNB.

A test harness. rfsim_multi_ue.sh creates a network namespace per UE, launches the handsets, waits for their tunnels, drives iperf3 through all of them at once, and writes a per-UE CSV alongside a readable summary. A UE that fails to attach is recorded and skipped rather than stalling the run.

Each UE is a full OAI nr-uesoftmodem process in its own network namespace with its own veth pair and subnet. That isolation is what allows many of them to coexist: without it, every UE would try to create a tunnel interface of the same name.

Requirements

ComponentRequirement
OSLinux, kernel 5.15 or newer
ToolchainGCC 11+, CMake 3.22+
Toolstmux, iperf3, iproute2, python3
Core networkSD-Core or OAI CN5G, reachable from this host
Privilegesroot for running (raw sockets, namespaces), not for building

A multi-core machine is strongly recommended. The reference host has 18 cores.

1. Clone

Two repositories: the base station and the handsets.

git clone -b rfsim       https://github.com/TOSSI-Foundation/OCUDU-RAN.git OCUDU-RAN
git clone -b rfsim_ocudu https://github.com/TOSSI-Foundation/OAI-RAN.git   OAI-RAN

Both branches matter. The rfsimulator work is on rfsim in OCUDU-RAN and on rfsim_ocudu in OAI-RAN, not on either repository's main. The OAI-RAN branch carries the rfsimulator performance patches and raises the UE limit from 16 to 64, so a clone of main or develop will cap out at 16 handsets.

Every command below assumes both live side by side in the same parent directory, and that you are working from that parent.

2. Build

2.1 The gNB

cd OCUDU-RAN
mkdir -p build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release -DENABLE_RFSIM=ON
make gnb rfsimulator_protocol_test -j$(nproc)
cd ../..

-DENABLE_RFSIM=ON is required, as the rfsimulator driver is off by default. There is no OAI_SRC variable to set: the simulator sources are vendored in-tree.

Do not build with sudo. Building as root writes root-owned object files into a user-owned tree. Every later incremental build then fails with misleading "the C++ compiler is not able to compile a simple test program" errors. sudo belongs on the gNB at run time, for raw sockets and network namespaces, never at build time. If you have already done this, clean up with sudo rm -rf build and start again as your normal user.

Confirm the driver is present:

./OCUDU-RAN/build/apps/gnb/gnb --help 2>&1 | grep -i rfsim

2.2 The UEs

cd OAI-RAN/cmake_targets
./build_oai --nrUE -w SIMU --ninja
cd ../..

This produces OAI-RAN/cmake_targets/ran_build/build/nr-uesoftmodem.

3. Core network

Provision subscribers for as many UEs as you intend to run. The harness derives each IMSI from a base value plus the UE index, so they must be contiguous.

  • SD-Core: subscribers from 001010100000001 upward. Runs on a separate host.
  • OAI CN5G: subscribers from 001010000000001 upward. Runs locally in containers; tools/rfsim/provision.sql will bulk-provision them.

4. Running a test

Four steps, in order. Steps 0 and 1 are one terminal; step 2 is another.

Step 0. Teardown

Run this before every test. It clears namespaces, stale UE processes, leftover IP rules and old results. Skipping it is the most common cause of a run that misbehaves for no visible reason.

cd OCUDU-RAN
# SD-Core, or any core outside this host
sudo ./rfsim_multi_ue.sh -k
# OAI core, also restarts the DN container
sudo ./rfsim_multi_ue.sh -k -e oai-ext-dn

The gNB is deliberately left running, so you can re-run UEs against the same base station.

Step 1. Start the gNB

Pick the configuration matching your core and bandwidth.

# OAI core, 40 MHz
sudo ./build/apps/gnb/gnb -c configs/gnb_rfsim.yaml 2>&1 | tee /tmp/gnb.log

# SD-Core, 100 MHz
sudo ./build/apps/gnb/gnb -c configs/gnb_rfsim_sdcore_100mhz.yaml 2>&1 | tee /tmp/gnb.log

tee /tmp/gnb.log is not optional. The harness reads that file to extract per-UE scheduler metrics (CQI, MCS, BLER, HARQ) and joins them with the throughput results. Without it the report has throughput but no radio quality columns. Wait for the AMF connection to come up before starting UEs.

The four configurations differ only in the core they target and the cell bandwidth:

ConfigCoreBandwidthSample rate
gnb_rfsim.yamlOAI40 MHz61.44
gnb_rfsim_100mhz.yamlOAI100 MHz122.88
gnb_rfsim_sdcore.yamlSD-Core40 MHz61.44
gnb_rfsim_sdcore_100mhz.yamlSD-Core100 MHz122.88

Edit the core and gNB addresses inside the chosen configuration to match your own deployment.

Step 2. Launch the UEs

-a is effectively required. Its default is /home/seven/OAI_RAN, a developer's home directory. Omit it and the harness finds no nr-uesoftmodem and exits before doing anything. Pass -a ../OAI-RAN, or whatever path your clone sits at, on every invocation.

Against the OAI core. Subscriber defaults suit this core, so no extra environment is needed.

cd OCUDU-RAN
OAI=../OAI-RAN

# 40 MHz, 8 UEs
sudo setsid --fork ./rfsim_multi_ue.sh -a $OAI -n 8 -b 40 -s 12 -t 12 -e oai-ext-dn \
  -o /tmp/oai40_8.csv -O /tmp/oai40_8.txt -F /tmp/oai40_8_failed.log

# 100 MHz, 50 UEs
sudo setsid --fork ./rfsim_multi_ue.sh -a $OAI -n 50 -b 100 -s 10 -t 12 -E 90 -e oai-ext-dn \
  -o /tmp/oai100_50.csv -O /tmp/oai100_50.txt -F /tmp/oai100_50_failed.log

-e oai-ext-dn names the data network container, so the harness can start iperf3 servers inside it and restart it during teardown.

Before using -e, check that the grep -c fix is on your branch. In start_iperf_servers, listening=$(ss -tln | grep -cE ...) exits 1 when nothing matches, and under set -e that ends the run silently right after the tunnel list: no iperf, no report. It fires whenever the iperf3 servers live in the container rather than on the host, which is every OAI-core run. The fix is || true on that assignment.

Against SD-Core. SD-Core uses a different subscriber range and credentials, passed in the environment because they belong to the core rather than to the harness. -D points at the host running the iperf3 servers.

cd OCUDU-RAN
OAI=../OAI-RAN
SDC='IMSI_BASE=100000000 UE_KEY=5122250214c33e723a5dd523fc145fc0 UE_OPC=981d464c7c52eb6e5036234984ad0bcf NSSAI_SST=1 NSSAI_SD=0x010203 DNN=internet'

# 40 MHz, 8 UEs
sudo setsid --fork env $SDC ./rfsim_multi_ue.sh -a $OAI -n 8 -b 40 -s 12 -t 12 -D 192.168.1.117 \
  -o /tmp/sdc40_8.csv -O /tmp/sdc40_8.txt -F /tmp/sdc40_8_failed.log

If -D is an address of this host, the harness starts the iperf3 servers locally and no -e is needed. If it is not, the servers must already be listening on ports 5201 to 5200+N.

Step 3. Watch and read the results

sudo tmux attach -t rfsim          # ctrl-b then d to detach; pane N-1 is ueN

The session is named rfsim unless you passed -N NAME to the harness, in which case attach to that name instead.

Each UE gets its own pane; a final pane runs the iperf3 sweep and prints progress. The session stays open after the run so you can read it. Results survive teardown:

cat /tmp/sdc40_8.txt               # summary table, per-UE and aggregate
cat /tmp/sdc40_8.csv               # per-UE metrics, ~60 columns, appended across runs
cat /tmp/sdc40_8_failed.log        # only exists if a UE never got a tunnel

The summary gives per-UE and aggregate throughput, radio quality averaged over the traffic window, tunnel RTT, and the real-time factor.

Step 4. A single UE by hand

Useful for debugging one attach without the harness. Run from the directory holding the UE binary, OAI-RAN/cmake_targets/ran_build/build.

Two arguments are not optional even though the UE starts without them. --uecap_file must be passed, or uecap_maxMIMO_PDSCH_layers stays 0 and the UE aborts on AssertFatal(max_mimo_layers > 0) immediately after CellGroupConfig. The --uicc0.pdu_sessions.[0].* arguments must also be passed: --uicc0.dnn on its own leaves no PDU session to request, so no tunnel is created and the UE deregisters.

Against SD-Core:

sudo ./nr-uesoftmodem -r 106 --numerology 1 --band 78 -C 3489420000 --ssb 42 --rfsim \
  --rfsimulator.serveraddr 127.0.0.1 \
  --uecap_file ../../../targets/PROJECTS/GENERIC-NR-5GC/CONF/uecap_ports1.xml \
  --uicc0.imsi 001010100000001 \
  --uicc0.key 5122250214c33e723a5dd523fc145fc0 --uicc0.opc 981d464c7c52eb6e5036234984ad0bcf \
  --uicc0.nssai_sst 1 --uicc0.nssai_sd 0x010203 \
  --uicc0.pdu_sessions.[0].nssai_sst 1 --uicc0.pdu_sessions.[0].nssai_sd 0x010203 \
  --uicc0.pdu_sessions.[0].dnn internet

A successful attach brings up oaitun_ue1 with an address from the subscriber pool.

Against the OAI core. Note there is no --uicc0.nssai_sd here: upstream OAI CN5G refuses to register when an SD is offered at all, even sd 0. This is why the harness makes NSSAI_SD opt-in, and why sd: was removed from gnb_rfsim.yaml and gnb_rfsim_100mhz.yaml.

sudo ./nr-uesoftmodem -r 106 --numerology 1 --band 78 -C 3489420000 --ssb 42 \
  --rfsim --rfsimulator.serveraddr 127.0.0.1 --rfsimulator.serverport 4043 \
  --uecap_file ../../../targets/PROJECTS/GENERIC-NR-5GC/CONF/uecap_ports1.xml \
  --uicc0.imsi 001010000000002 --uicc0.nssai_sst 1 \
  --uicc0.pdu_sessions.[0].nssai_sst 1 \
  --uicc0.pdu_sessions.[0].dnn oai

For 100 MHz use -r 273 --ssb 180 instead of -r 106 --ssb 42.

5. Handover between two base stations

A second configuration runs two gNBs to exercise Xn handover. Both connect to the same AMF and pair with each other over loopback.

Handover requires SD-Core. The OAI core does not implement Path Switch, so the procedure completes on the RAN side and then fails at the core.

The socket roles invert here. In a normal run the gNB listens and the UEs dial in. For handover the UE listens and both gNBs dial into it. That is what lets a single handset be reached by two base stations.

# Terminal 1: source gNB
cd OCUDU-RAN/build/apps/gnb
sudo ./gnb -c ../../../configs/gnb1_rfsim_ho_sdcore.yaml

# Terminal 2: target gNB
sudo ./gnb -c ../../../configs/gnb2_rfsim_ho_sdcore.yaml

Terminal 3 runs the UE as the rfsim server, with --rfsimulator.serveraddr server. Trigger the handover by typing this in the gnb1 terminal once the UE has attached:

ho 1 4601 2 00101 1

Arguments are: source cell index, UE RNTI, target cell index, PLMN, target gNB index. The RNTI appears in the gnb1 log when the UE attaches.

The two configurations mirror each other. Each declares the other as its neighbour by cell ID, and their PCIs are swapped:

Settinggnb1gnb2
gNB ID411412
PCI12
Cell ID0x66C0000x670000
Bind address192.168.1.117192.168.1.120
Xn address127.0.0.1127.0.0.2
Log/tmp/gnb1.log/tmp/gnb2.log

6. Harness options

Run ./rfsim_multi_ue.sh -h for the full list. The ones you will reach for:

FlagMeaningDefault
-n NNumber of UEs8
-b MHzCell bandwidth, 40 or 10040
-s SECDelay between UE launches16
-t SECiperf3 duration per direction15
-N NAMEtmux session namerfsim
-c LISTCPU list to pin the UEs to, e.g. 0-10none
-w SECExtra time to keep waiting for attaches after the last UE launched240
-E SECGive up on stragglers once the attach count has been unchanged this long60
-R LISTStress ramp: comma-separated UE counts, headless, no tmuxunset
-o FILEPer-UE metrics CSV, appended/tmp/rfsim_metrics.csv
-O FILESummary text file/tmp/rfsim_summary_<run>.txt
-G FILEgNB log to read scheduler metrics from/tmp/gnb.log
-F FILEDiagnostics for UEs that never got a tunnel: NAS/RRC reason, output tail, gNB release lines/tmp/rfsim_failed_<run>.log
-D ADDRData network address the UEs run iperf3 and ping against. If it is an address of this host, the servers are started locally and no -e is needed192.168.70.135
-e NAMEData network container hosting the iperf3 servers, restarted on -k. Unset means they are expected to be running alreadyunset
-a DIROAI source tree, holds the UE binary and multi-ue.sh. UECAP follows it unless set/home/seven/OAI_RAN
-kFull reset: tmux session, all UEs, all ue* namespaces, stale ip rules, iperf3 servers, stale sweep json. The gNB is left runningn/a
-hHelpn/a

Set -a on every invocation unless your OAI clone happens to sit at the default path. A stress ramp runs several UE counts back to back, tearing down between each and appending one line per count to /tmp/rfsim_stress.txt:

sudo ./rfsim_multi_ue.sh -a ../OAI-RAN -R 8,16,32,50 -b 40

Overrides, normally derived from -b:

FlagMeaning
-r PRBPRB count
-S SSBSSB offset
-f HzCentre frequency
-B BANDNR band
-P PORTrfsim server port
-M MTUTunnel MTU
-T NUE launch retries

7. Reading the numbers

The summary reports throughput measured by iperf3 and radio quality read from the gNB's own scheduler log. Nothing is estimated.

One figure needs explaining. The real-time factor is the ratio of simulated time to wall-clock time. At rt = 1.0 the simulation keeps pace with reality; at rt = 0.5 one simulated second takes two real seconds.

The protocol behaves correctly at any value, but measured throughput is proportional to it: aggregate DL = capacity x rt. Tunnel RTT is inflated by it. Both are properties of the simulation's speed, not of the cell's capacity.

The simulated-time capacity line divides that distortion out: aggregate throughput divided by rt. It answers "what would this cell carry if the simulation ran at real time", and it stays roughly constant as UE count rises, which is what tells you the cell itself is unchanged.

rt needs a probe binary. It is reported as n/a unless PROBE points at a built tools/rfsim/rfsim_probe, compiled from tools/rfsim/rfsim_probe.cpp. Without it the throughput figures still stand, but there is nothing to divide the distortion out with.

Attachment scales cleanly to 50. Simulation speed does not. Throughput per UE at high counts is bounded by how fast one host thread can serialise every UE's sample stream each slot, not by the cell.

8. Measured results

40 MHz, 12-second iperf3 sweep in each direction, both cores measured on the same host and the same build. These reference figures were taken on an 18-core host booted with isolcpus=0-13, the gNB started under taskset -c 11-17 and the UEs pinned with -c 0-10. An unpinned host produces roughly half the throughput and results that vary between runs, so reproduce those conditions before comparing against this table.

MetricOpenAirInterfaceOAI CN5GSD-CoreSD-Core
8 UEs16 UEs50 UEs8 UEs16 UEs50 UEs
Attached8 / 816 / 1650 / 508 / 816 / 1650 / 50
DL aggregate106.25 Mbps54.33 Mbps16.40 Mbps97.36 Mbps51.13 Mbps15.12 Mbps
DL per UE13.283.400.32812.1703.1960.308
UL aggregate44.94 Mbps39.68 Mbps17.27 Mbps49.02 Mbps37.34 Mbps16.80 Mbps
Real-time factor1.0487x0.532x0.110x1.0743x0.5288x0.1448x

Throughput per UE falls as the UE count rises, but that reflects simulation speed rather than cell capacity: dividing aggregate throughput by the real-time factor gives a figure that stays near 100 Mbps at every UE count on both cores.

Tunnel RTT at 50 UEs differs sharply between the two cores: roughly 167 ms on the OAI core against 3425 ms on SD-Core, a factor of twenty. Expect it on SD-Core at high UE counts and do not read it as a RAN fault.

The 8-UE SD-Core figures are not reproducible to the precision shown. Repeat runs on an identical configuration settled into two stable regimes, about 12.2 and about 9.9 Mbps per UE (97 to 99 against 79 to 80 Mbps aggregate), at the same real-time factor and the same MCS, with better BLER in the slower regime. The cause is not established. Treat the third decimal as noise.

9. Repository layout

PathContents
lib/radio/rfsimulator/The radio driver: session, factory, validator
lib/radio/rfsimulator/oai_shim/Headers satisfying the vendored source, 11 files
lib/radio/rfsimulator/vendor/Vendored OAI simulator, compiled unmodified
lib/radio/rfsimulator/tests/Wire-protocol conformance test
rfsim_multi_ue.shTest harness
tools/rfsim/rfsim_report.pyReport and CSV generator
tools/rfsim/provision.sqlBulk subscriber provisioning for the OAI core
configs/gnb_rfsim*.yamlgNB configurations
configs/gnb[12]_rfsim_ho_sdcore.yamlHandover pair

10. Source

Related: Multi-UE Simulation covers the ZeroMQ-based multi-UE environment. The rfsimulator built on OpenAirInterface's simulator, carried in-tree and compiled unmodified.