OpenAirInterface now builds and runs on my Mac: the gNB, its nrUE, and the RF simulator between them, against the same Open5GS core from the earlier posts. The UE registers, opens a PDU session, gets 10.45.0.6, and pings the gateway both ways. Twenty-six patches, none in protocol code.
Two days earlier the same core ran with OCUDU as its gNB. OAI is a second gNB from a different codebase.
The stack, and its Linux assumptions
OpenAirInterface is EURECOM’s research 3GPP stack, now hosted by LF Networking as the Duranta project. The tree holds both ends of the air interface, gNB and UE, so there is no separate UE project to port and no ZeroMQ bridge: the gNB’s RF simulator is a TCP server that feeds IQ samples to whatever connects. I pinned tag 2026.w39, commit 29d5fa7d, version 2.4.0, under the Collaborative Standards Software License.
The tree assumes Linux and glibc throughout without declaring it. CMake refuses to configure on anything but x86_64 and aarch64, and Darwin reports arm64. The link lines pass start-group and whole-archive to ld. Fifteen files include malloc.h. The config loader parses its command line with glibc’s allocating %m conversion in sscanf. None of that is 5G code.
What the port reused
Reusing three libraries avoided most of the work.
The PHY is written against SSE and AVX2 intrinsics and compiles on AArch64 through SIMDe, a header-only translation layer that OAI’s own CI uses for its ARM builds. On Apple clang it needed one change. Five call sites pass a run-time value to the immediate-only left shift; x86 compilers accept it, GCC accepts it after inlining, and clang rejects it at parse time. The register-count form of the intrinsic gives the same result for any count. One patch, across two files.
The ITTI inter-task layer and the timers use epoll, eventfd and timerfd, all Linux-only. For OCUDU I wrote a kqueue backend by hand, because its I/O broker is a single class. OAI calls those from too many places for that, so I used epoll-shim, a kqueue-backed implementation from the FreeBSD ports. Its header redefines close and fcntl as macros, so it has to link into every target, not only the ones that call epoll. Its close reaches the SCTP shim’s close through RTLD_NEXT in both link orders; I tested both before relying on it.
NGAP needs SCTP, and macOS has none in the kernel. libsctp-compat, a usrsctp shim over the lksctp API from the Osmocom work, was already at v0.4.0 from the OCUDU port. OAI needed one addition. It subscribes to SCTP notifications with an 8-byte sctp_event_subscribe, the old lksctp layout, and usrsctp wants the full structure; the shim now pads a short subscription instead of rejecting it. That is v0.4.1: one patch and one two-process test.
With those three in place, the rest is an ordinary Linux-to-BSD port: missing headers, functions glibc has and Darwin lacks, Mach-O link order. Twenty of the twenty-six patches are that kind.
The UE tunnel
OAI’s tuntap_if.c opens /dev/net/tun and sets the address over netlink. On Darwin the UE opens a utun through the kernel control socket, takes the name the kernel assigns (utun7, not oaitun_ue1p1), sets the address with the SIOCAIFADDR ioctl with the destination in the broadaddr field, and adds or strips the 4-byte address-family header utun puts in front of each packet. About two hundred lines. The same pattern is in Open5GS’s UPF and in my libosmocore port.
Three macOS-specific failures
A SIGBUS on the first slot, in the L1 receive thread, was a stack overflow: a secondary thread on macOS gets 512 KB where Linux gives 8 MB, and the slot buffers do not fit. The OCUDU port hit the same limit in a different function. The fix sets an 8 MB stack on every thread created through OAI’s wrapper.
“Protocol not supported” from the socket() call, with the shim on the link line, was the Mach-O two-level namespace: ld64 binds each undefined symbol to the first library on the line that exports it, and the shim sat behind the objects. The OCUDU port had the same failure. I now check nm -m before the first run.
A NULL dereference at the first Msg4 came from aligned_alloc(4, n) for a transport block. glibc returns a pointer; Darwin returns EINVAL for an alignment below the pointer size, which the C standard permits. malloc aligns to max_align_t on every platform, which covers the uint32_t reads the buffer is used for. This one is not a Darwin bug, and the patch is going upstream.
Result
NG Setup completed in 2.4 ms over the shim. The nrUE went through cell search, RACH, RRC Setup, 5G-AKA, Security Mode and Registration Complete in 47 ms from the Initial UE Message, as logged on the AMF. PDU Session Resource Setup took 7.4 ms, with the N3 tunnel configured. Ping was 4 of 4 each way at about 4 ms round trip, set by the RF simulator’s TCP loop rather than the stack. The session ran eleven minutes with no HARQ retransmissions in either direction, then deregistered cleanly.
The two ends exchange IQ over TCP on localhost. A capture of that port I left running by mistake reached 170 GB in five minutes.
Two gNBs from two codebases now attach the same core on the same machine. The two ports share the SCTP shim, the stack-size fix, the link-order fix, and the single-host routing for the utun. The result is a kit, oai-macos-arm64, with libsctp-compat v0.4.1 under it, and it applies to the upstream tag with git am.