Home / Lab / A production 5G gNB on macOS ARM64
Lab

A production 5G gNB on macOS ARM64

October 14, 2026 Andrei Gosman 4 min read

My 5G SA loopback now runs OCUDU as the gNB instead of srsENB. Same Mac, same Open5GS core, same srsUE, same ZeroMQ link in place of radios. The UE registers, opens a PDU session, takes 10.45.0.3, and pings the gateway both ways. Forty-nine patches to get there, none of them touching protocol code.

srsENB’s 5G SA mode already attached in the earlier post. But that gNB is a prototype. OCUDU is the production CU/DU, the code srsRAN deploys for real, and I wanted the production one running on the laptop.

What is OCUDU ?

srsRAN Project was archived on 1 June 2026 and became OCUDU: same team, same code, relicensed to BSD-3-Clause in the Open MPI variant. It is the CU and the DU, no UE. The UE stays srsUE from srsRAN 4G, already ported.

49 patches for the run

git am of the series onto a clean checkout touches 84 files, 1772 insertions, 129 deletions. All of it portability. None of it changes what the gNB puts on the air or the wire. The uplink QFI bug I had to patch in srsENB, the one that gave me ping 0 of 4 last time, never showed up. OCUDU writes the PDU Session Container on the N3 uplink per TS 38.415 on its own. The production code implements the uplink correctly.

Most of the patches repeat my srsRAN 4G port: pthread naming and affinity, getauxval, perf counters, mmap flags, the glibc macros libc++ leaves out. Two groups were new.

The I/O loop

This was the main work, and the srsRAN 4G port did not cover it. OCUDU runs the entire gNB on one I/O broker over epoll, eventfd, and timerfd. 735 lines, no second backend, every gateway sitting under it. macOS has none of the three primitives. So I wrote a kqueue backend: descriptors on EVFILT_READ with EV_DISPATCH, a self-pipe doing the job eventfd did for the wakeup channel, and io_timer_source’s timerfd emulated over kqueue and a pipe, which reuses the timerfd shim from my Osmocom port.

If the broker could not hold a stable loop, nothing above it would, so I built it first and tested it on its own before touching anything else. Unit tests plus a two-process harness, green on the first run: descriptor dispatch, timer ticks, teardown with the loop parked in kevent.

SCTP with no kernel SCTP

NGAP needs SCTP. macOS has no kernel SCTP, and OCUDU calls the lksctp API directly, so on Darwin it links libsctp-compat, the usrsctp shim left over from the Osmocom and srsRAN work. OCUDU reached into corners of that API the shim had never served. Getting it to v0.4.0 took the RFC 6458 per-event subscription and six places where usrsctp and lksctp disagree, each one reproduced in a two-process C test before I changed a line of the shim.

Peeloff took the most time. OCUDU’s server peels every association onto its own socket. usrsctp supports that; the shim had simply never exposed it. The rest were smaller: a zero-length EOF send usrsctp rejected, a shutdown direction that left the client hanging forever, and three gaps in address and option handling.

One rule. A usrsctp endpoint binds 127.0.0.1, never a lo0 alias, or the association never forms and nothing tells you why. The gNB encapsulates on UDP 9900 and points at the AMF on 9899, because Open5GS already holds 9899 with its own usrsctp and the two cannot share the port.

Two macOS-specific failures

First, a SIGBUS on the very first slot, inside send_dl_tti_request. Stack overflow. macOS gives a secondary thread a 512 KB stack where Linux gives 8 MB, and that function drops a 74 KB struct on the stack partway down a call chain. Create the worker threads with an 8 MB stack on Darwin and it is solved.

Second, “Failed to create SCTP socket” with the shim already on the link line. This is Mach-O two-level namespace: a call to socket or sendmsg resolves to libSystem when the interposing library comes after the object files, so none of the shim’s symbols were ever reached. target_link_options moves it ahead of libSystem, and nm -m confirmed the bind. ELF is far looser about link order, which is why this one appears only on macOS.

Result

NG Setup between the gNB and the AMF in 4.5 ms over the shim. srsUE through cell search, PRACH, RRC setup, 5G-AKA, and Registration Complete in 375 ms, logged on the AMF. PDU session up on 10.45.0.3. Ping 4 of 4 each way, 27 to 43 ms up, 21 to 36 ms down, which is ZeroMQ scheduling and the same range as the earlier runs.

A production gNB on a laptop, no SDR and no Linux. The port is a reproducible kit, ocudu-macos-arm64, built on libsctp-compat v0.4.0. It builds from the upstream tag with git am.