I got a complete 5G standalone network running on an Apple Silicon Mac: Open5GS as the core, srsRAN as the gNB and the UE, connected over ZeroMQ instead of radios. The UE registers, opens a PDU session, gets an IP, and pings the user-plane gateway both ways. No SDR, no Linux VM, no containers.
Open5GS needed no code changes at all. srsRAN needed two, none specific to macOS.
The setup
The loopback has three parts and a virtual radio. srsUE runs in 5G SA mode as the handset. srsENB runs in its 5G SA gNB mode as the base station. Open5GS provides the 5G core, eleven network functions from AMF and SMF down to UPF and NRF. ZeroMQ carries IQ samples between the UE and the gNB in place of an over-the-air link, so the whole chain runs on one machine with no hardware.
srsRAN 4G has carried a 5G SA prototype in both srsUE and srsENB since version 22.04, so the radio side needs no separate 5G stack. The subscriber is a test IMSI on PLMN 001/01 with slice SST 1. Everything below is on srsRAN 4G 25.10 and Open5GS 2.8.0.
Open5GS builds unchanged
Open5GS needed no source changes for macOS. The core already carries Darwin support upstream: the event loop picks kqueue, the SCTP layer selects usrsctp automatically, and the user-plane TUN device has a native backend in lib/tun/mac-setup.c that sets up a utun interface. Open5GS ships an Apple Silicon guide.
The only problem was the build environment. freeDiameter did not find gcrypt, idn, and gnutls because Apple clang does not search /opt/homebrew/lib; LIBRARY_PATH and CPATH fix it. The bison in the base system is too old to parse freeDiameter’s grammar, so the Homebrew bison has to be on PATH. mongod refuses –fork on macOS and has to run in the foreground. All eleven NFs start, register with the NRF over HTTP/2, and hold.
The loopback traps
Most of the lost time was in how usrsctp and the loopback interface behave on macOS.
usrsctp binds a hardcoded UDP port, 9899. srsRAN’s own srsepc uses the same port, so the two cannot run at once, and when they collide the bind fails silently. netstat shows the conflict; lsof without root does not.
usrsctp also does not answer an INIT when the socket listens on a lo0 alias such as 127.0.0.2 and the association never forms. Bind the SCTP endpoints to 127.0.0.1 and they connect in under a second. This appeared three times across 4G and 5G before the problem was clear. In the gNB case the cause is one line in srsRAN: enb_cfg_parser.cc sets the NGAP bind address equal to the GTP-U bind address, so pointing GTP-U at an alias drags NGAP onto the alias with it.
The last problem is the data-plane route. Open5GS sets the UPF’s utun peer address equal to its own address, which makes the kernel bind the host route back to the interface as local and return “no route to host”. Setting the peer to the UE address fixes it. This only matters for a single-machine test; a UE on another host needs does not need it.
Bug 1: a write through a map’s end iterator
With the transport up, srsUE died at Msg3, right after the random-access response, before RRC setup. SIGBUS on the PHY worker thread, inside a timer callback, with a this pointer of 0x0000000a00000000. A timer’s parent reference was corrupt.
The timer was not the cause. I built srsUE with AddressSanitizer, put a hardware watchpoint on the corrupted field in lldb, and caught two writes to it: one legitimate write from the object’s own init, and one foreign write from proc_bsr_nr::setup_lcid, reached from rrc_nr::init.
The bug is a write through the end iterator of a std::map. setup_lcid does a find on the priority map and then assigns through the returned iterator without checking whether the key was found. When the key is new, the iterator is end(). On libc++ the end node lives inside the map object, and its value sits at offset +32, so the four-byte write lands in the low half of a neighboring timer handle. On libstdc++ the same write lands in the map’s node count, zero over zero, harmless. The code is wrong on both, fatal only on libc++. That is why it surfaces on macOS and not on a typical Linux build. The fix is one line: assign with the subscript operator instead of the raw iterator.
Bug 2: uplink packets with no QFI
With srsUE fixed, registration and the PDU session completed, but ping was 0 for 4. The UPF logged an Error Indication for every uplink packet.
TS 38.415 requires the gNB to put a PDU Session Container carrying the QoS Flow Identifier in the GTP-U extension header on the N3 uplink. srsENB’s gNB mode sent bare G-PDUs with no extension, so the UPF rejected them. The fix carries the QoS flow ID from NGAP through the bearer manager into the GTP-U tunnel and writes the container on the uplink. Like the first, it is a generic conformance bug, not a macOS one.
Result
After both fixes the chain closes. The UE registers, opens the PDU session, gets 10.45.0.4, and pings the gateway 4 for 4 in both directions. Round-trip time is 41 to 64 ms uplink and 29 to 69 ms downlink, which is ZeroMQ scheduling latency, the same range as the 4G loopback on the same setup.
The result is a full 5G SA network, core and radio and handset, on a laptop with no SDR and no Linux. The Open5GS side is a config-and-environment exercise, and I published the reproducible setup. The two srsRAN fixes are in my macOS patch kit and submitted upstream (PR #1551, PR #1552); both are real bugs that also affect Linux, one of them silently.