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.
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
| Component | Requirement |
|---|---|
| OS | Linux, kernel 5.15 or newer |
| Toolchain | GCC 11+, CMake 3.22+ |
| Tools | tmux, iperf3, iproute2, python3 |
| Core network | SD-Core or OAI CN5G, reachable from this host |
| Privileges | root 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
001010100000001upward. Runs on a separate host. - OAI CN5G: subscribers from
001010000000001upward. Runs locally in containers;tools/rfsim/provision.sqlwill 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:
| Config | Core | Bandwidth | Sample rate |
|---|---|---|---|
gnb_rfsim.yaml | OAI | 40 MHz | 61.44 |
gnb_rfsim_100mhz.yaml | OAI | 100 MHz | 122.88 |
gnb_rfsim_sdcore.yaml | SD-Core | 40 MHz | 61.44 |
gnb_rfsim_sdcore_100mhz.yaml | SD-Core | 100 MHz | 122.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:
| Setting | gnb1 | gnb2 |
|---|---|---|
| gNB ID | 411 | 412 |
| PCI | 1 | 2 |
| Cell ID | 0x66C000 | 0x670000 |
| Bind address | 192.168.1.117 | 192.168.1.120 |
| Xn address | 127.0.0.1 | 127.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:
| Flag | Meaning | Default |
|---|---|---|
-n N | Number of UEs | 8 |
-b MHz | Cell bandwidth, 40 or 100 | 40 |
-s SEC | Delay between UE launches | 16 |
-t SEC | iperf3 duration per direction | 15 |
-N NAME | tmux session name | rfsim |
-c LIST | CPU list to pin the UEs to, e.g. 0-10 | none |
-w SEC | Extra time to keep waiting for attaches after the last UE launched | 240 |
-E SEC | Give up on stragglers once the attach count has been unchanged this long | 60 |
-R LIST | Stress ramp: comma-separated UE counts, headless, no tmux | unset |
-o FILE | Per-UE metrics CSV, appended | /tmp/rfsim_metrics.csv |
-O FILE | Summary text file | /tmp/rfsim_summary_<run>.txt |
-G FILE | gNB log to read scheduler metrics from | /tmp/gnb.log |
-F FILE | Diagnostics for UEs that never got a tunnel: NAS/RRC reason, output tail, gNB release lines | /tmp/rfsim_failed_<run>.log |
-D ADDR | Data 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 needed | 192.168.70.135 |
-e NAME | Data network container hosting the iperf3 servers, restarted on -k. Unset means they are expected to be running already | unset |
-a DIR | OAI source tree, holds the UE binary and multi-ue.sh. UECAP follows it unless set | /home/seven/OAI_RAN |
-k | Full reset: tmux session, all UEs, all ue* namespaces, stale ip rules, iperf3 servers, stale sweep json. The gNB is left running | n/a |
-h | Help | n/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:
| Flag | Meaning |
|---|---|
-r PRB | PRB count |
-S SSB | SSB offset |
-f Hz | Centre frequency |
-B BAND | NR band |
-P PORT | rfsim server port |
-M MTU | Tunnel MTU |
-T N | UE 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.
| Metric | OAI CN5G | SD-Core | ||||
|---|---|---|---|---|---|---|
| 8 UEs | 16 UEs | 50 UEs | 8 UEs | 16 UEs | 50 UEs | |
| Attached | 8 / 8 | 16 / 16 | 50 / 50 | 8 / 8 | 16 / 16 | 50 / 50 |
| DL aggregate | 106.25 Mbps | 54.33 Mbps | 16.40 Mbps | 97.36 Mbps | 51.13 Mbps | 15.12 Mbps |
| DL per UE | 13.28 | 3.40 | 0.328 | 12.170 | 3.196 | 0.308 |
| UL aggregate | 44.94 Mbps | 39.68 Mbps | 17.27 Mbps | 49.02 Mbps | 37.34 Mbps | 16.80 Mbps |
| Real-time factor | 1.0487x | 0.532x | 0.110x | 1.0743x | 0.5288x | 0.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
| Path | Contents |
|---|---|
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.sh | Test harness |
tools/rfsim/rfsim_report.py | Report and CSV generator |
tools/rfsim/provision.sql | Bulk subscriber provisioning for the OAI core |
configs/gnb_rfsim*.yaml | gNB configurations |
configs/gnb[12]_rfsim_ho_sdcore.yaml | Handover pair |
10. Source
- OCUDU-RAN, branch
rfsim: github.com/TOSSI-Foundation/OCUDU-RAN - OAI-RAN, branch
rfsim_ocudu: github.com/TOSSI-Foundation/OAI-RAN
Related: Multi-UE Simulation covers the ZeroMQ-based multi-UE environment. The rfsimulator built on OpenAirInterface's simulator, carried in-tree and compiled unmodified.

