Home / Lab / Setting up an SDR lab on macOS Apple Silicon: Part...
Lab

Setting up an SDR lab on macOS Apple Silicon: Part 2

September 22, 2026 Andrei Gosman 11 min read

Continuation from Part 1

Part 1 stopped at srsRAN 4G downlink working on the LibreSDR B220 Mini. cell_search decoded MIB and SIB1/2/3 on live data.

Status two weeks later:

  • 20 repositories published at github.com/AndreiGosman/*-macos-arm64.
  • 9-daemon Osmocom 2G+3G core stack runs on B220 at ARFCN 871 DCS1800.
  • Iu-CS Location Update Accept and Iu-PS GMM Attach complete against a patched hnb-test.
  • GTP-C Create PDP Context accepted, utun tunnel up, address handed to the UE.

Problem 6: SCTP shim scope insufficient for Osmocom

Part 1 shipped libsctp-compat v0.1.0 with 8 symbols forwarded to usrsctp (sctp_bindx, sctp_connectx, sctp_sendmsg, relatives). Enough for srsRAN 4G. Not enough for Osmocom.

osmo-stp (M3UA signalling gateway) expects accept(2) to return a new socket per SCTP association. On Linux this reaches the kernel SCTP module. On Darwin there is no kernel SCTP module. usrsctp runs in userspace and does not know libc accept exists. osmo-stp called libc accept, got the internal socketpair endpoint the shim uses for poll integration, spun at 100% CPU on an fd that reported readable with no data.

Two design options:

  • A: DYLD_INSERT_LIBRARIES (Darwin equivalent of LD_PRELOAD). Rejected: insertion runs in front of everything including usrsctp‘s own sendmsg calls for UDP encapsulation, so the shim would recurse. SIP restricts insertion on system binaries.
  • B: export sendmsg, recvmsg, accept as strong symbols from libsctp.dylib. Consumers linked against -lsctp pick them up. Consumers reaching libc directly do not.

Picked B. Single dylib, no env vars, no process-start interposition.

Implementation traps:

1. Shim internally calls sendmsg and recvmsg to drive the socketpair. Those calls resolve to the shim’s own symbols and recurse. Route through private lsc_real_* wrappers. 2. usrsctp_set_ulpinfo returns 1 on success, not POSIX 0. The check != 0 reads success as failure.

Accept path redesign in v0.3.0. Upcall accepts the association, adopts the socket, queues under mutex, one token per queued entry. accept() pops from the queue, never blocks. v0.3.1: usrsctp notifications on the listening socket no longer route onto the accept pump. v0.3.2: SO_NOSIGPIPE becomes no-op to stop log flood.

Relink rule for consumers, non-obvious. libtool default on Apple ld-1267 places auto-added -lsctp after the object files. ld-1267 then binds interposed symbols to libSystem regardless of what libsctp offers. Shim bypassed. Verified with -Wl,-t.

Fix: pass -Wl,-lsctp in LDFLAGS at position. libtool keeps it ahead of the objects.

make LDFLAGS="-Wl,-undefined,dynamic_lookup \
             -Wl,-L$HOME/sdr-lab/local/lib \
             -Wl,-lsctp"

Applies to every consumer daemon rebuild after a shim symbol addition. make clean && make && make install alone does not rebind.

Problem 7: no AF_UNIX SOCK_SEQPACKET on Darwin

osmo-bts listens on /tmp/pcu_bts with SOCK_SEQPACKET. Preserves message boundaries. Linux implements it. Darwin returns EPROTONOSUPPORT (errno 43) from socket(2). Reproduced with a plain C program, no Osmocom code involved. Limit is in libc, not in libsctp-compat.

osmo-bts opens the listener unconditionally at startup. Daemon dies before BSC or TRX connect. Same call in osmo-pcu pcuif_sock.c (connect side), osmo-msc behind --pcu-socket, osmo-bsc behind external MNCC.

Fix: on EPROTONOSUPPORT, reopen as SOCK_STREAM. Install osmo_stream_srv_set_segmentation_cb returning 1007 (sizeof(struct gsm_pcu_if) in 1.12.0). All PCUIF messages are 1007 bytes on the wire; pcu_msgb_alloc does msgb_put on the union. libosmo-netif reassembly stitches partial reads.

Cross-process test with a Python client:

  • INFO_IND 1007 bytes at connect: delivered as one.
  • One message written in two chunks: delivered as one.
  • Two messages written together: delivered as two.

Linux path unchanged. Live on B220: OML up, RSL connected, POWERON, SETSLOT 0..7, PDCH TS6 and TS7 enabled after osmo-pcu connects. 95 s traffic, zero framing errors.

Problem 8: -Wl,--wrap is GNU ld and lld only

Osmocom tests use -Wl,--wrap=symbol to substitute a function with a test double at link time. Apple ld does not implement --wrap.

Affected test groups, 12 total across 4 daemons:

  • osmo-msc: 12 msc_vlr_test_* groups plus sms_queue.
  • osmo-bsc: handover_test, paging.
  • osmo-pcu: AllocTest, TbfTest, AppInfoTest.
  • osmo-sgsn: sgsn_test (5 wraps), gtphub_test (3 wraps).

Fix: same pattern upstream uses for the opt-in smpp group. AC_LINK_IFELSE probe in configure.ac (void foo() {} int main() { return __wrap_foo != 0; }), AM_CONDITIONAL(HAVE_LD_WRAP) around the test SUBDIRS, exit 77 in testsuite.at. 77 is the automake skip code.

macOS builds complete clean. Wrapped groups reported skipped. VLR regression coverage on macOS deferred to Linux CI.

Problem 9: no SCTP_INFO, plus sin_len trap

libosmo-sigtran reports SCTP association tuning via getsockopt(SCTP_INFO), returning struct tcp_info (upstream naming, TCP struct on an SCTP getsockopt). Darwin has neither.

Darwin has TCP_CONNECTION_INFO and struct tcp_connection_info, for TCP proper.

Fix: single accessor in ss7_asp_get_tcp_info(). On Darwin, getsockopt(TCP_CONNECTION_INFO) on the underlying socket the shim exposes. Field mapping:

  • Congestion window bytes to segments (divide by MSS).
  • RTT, RTO: ms to us.
  • tcpi_pmtu, tcpi_unacked: zero. Darwin does not expose them. Deriving from tcpi_snd_sbbytes is plausible and wrong.

Separate sin_len bug in osmo-sgsn integration. osmo_sockaddr_cmp() in libosmocore does memcmp on struct sockaddr_in. Darwin fills sin_len on recvfrom(), leaves it zero on configuration copies. osmo-pcu sends NS RESET, osmo-sgsn replies RESET_ACK, osmo-pcu logs Ignoring NS RESET ACK from newconnection for non-existing NS-VC. memcmp returned -16 for two addresses logically equal.

Fix: compare by field (port, address; for IPv6 also flowinfo, scope_id). Affects all BSDs. Upstream candidate patch 011 in libosmocore-macos-arm64.

Problem 10: utun creation, addressing, root

osmo-ggsn allocates addresses to UEs through a Linux TUN interface. Darwin has utun. Three distinct issues.

utun creation

Linux: open("/dev/net/tun") plus ioctl.

Darwin sequence:

1. socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL). 2. ioctl(CTLIOCGINFO) on com.apple.net.utun_control. 3. connect(2) with the returned ctl_id. 4. Kernel picks the name (utun6 here). getsockopt(UTUN_OPT_IFNAME) reads it back.

Every packet on the wire carries a 4-byte AF prefix. Strip on read, prepend on write. readv/writev with two-element iovec avoids an extra copy. Consumer sees plain IP.

Addressing

SIOCAIFADDR on IFF_POINTOPOINT requires a destination. Darwin’s struct ifaliasreq has no ifra_dstaddr; destination goes in ifra_broadaddr. On a P2P interface, destination equals the address itself. Matches ifconfig utun6 inet 10.45.0.1 10.45.0.1 netmask 255.255.0.0.

Darwin installs only the host route. Linux derives the prefix route from the address. Extra step: PF_ROUTE RTM_ADD with the prefix mask after the address is set. Patch 012 in the libosmocore kit.

Root

utun creation requires root. First end-to-end run used a passwordless sudoers drop-in scoped to the daemon binary at /etc/sudoers.d/osmo-ggsn.

Log output on first successful run:

Blacklist tun IP 10.45.0.1/16
utun6: inet 10.45.0.1 --> 10.45.0.1 netmask 0xffff0000
Create PDP Context Response, cause 128 (Request accepted)
PDP Address: IPv4 10.45.0.2

show pdp-context all active on both osmo-sgsn and osmo-ggsn. GTP echo running.

Bug found in the same run

osmo-ggsn pool blacklist reads the interface name from config (tun-device tun4) and looks up addresses under that name. Kernel named the interface utun6. Lookup returned empty. Pool allocator handed the tun’s own address to the first UE.

Fix in patch 007 of osmo-ggsn-macos-arm64: read the name from osmo_tundev_get_dev_name() (kernel-assigned). Same for the IPv6 link-local lookup that made inet6 and inet46 APNs refuse to start.

Problem 11: GNU-isms checklist

Each Osmocom port turned up two or three GNU-isms Linux hides. After the sixth port, the pattern is a checklist. Most are not Osmocom bugs, they are portability defects any BSD user hits.

Recurring:

  • -lrt on link lines. Darwin has no librt. Osmocom code uses osmo_clock_gettime(). Remove, or gate with AC_SEARCH_LIBS([clock_gettime], [rt]).
  • sched_setscheduler(2). Replace with pthread_setschedparam on the current thread.
  • signalfd(2). Async handler writes signal number to a pipe, read end in the select loop.
  • <malloc.h> and memalign. Use <stdlib.h> and posix_memalign.
  • <values.h>. glibc-only, usually unused. Remove.
  • pthread_setname_np: one argument on Darwin (current thread only), not two.
  • GNU sed \| alternation in BRE, -i without backup suffix. Both broken on BSD sed.
  • GNU coreutils install -D -t. Use $(MKDIR_P) + $(INSTALL_SCRIPT).
  • echo -e in /bin/sh. Use printf '%s\n'.

Subtler:

  • _POSIX_C_SOURCE without _DARWIN_C_SOURCE puts the Darwin SDK in strict POSIX mode. Symptom: #error "Unknown endian" from libosmocore endian.h, then structs with bit-fields under gsm_04_08.h lose their members. Found in osmo-hlr db_hlr.c.
  • AF_UNIX SOCK_SEQPACKET, see Problem 7.
  • AF_X25 unknown on Darwin. X.25 is AF_CCITT value 10. #define AF_X25 AF_CCITT.
  • AF_INET6 numeric value: 10 on Linux, 30 on Darwin. Tests must print names, not numbers.
  • Linux-only errno values: ENOKEY, ENAVAIL, EBADFD, ENOMEDIUM. Alias with attention to caller semantics. ENOKEY is distinct from ENOENT in osmo-hlr db.c (four switches); alias to ENOATTR (93), not ENOENT.

Two Osmocom-specific:

  • AC_ARG_ENABLE(sysmocom-bts, ...) in osmo-bts sets enable_sysmocom_bts="yes" also for --disable-sysmocom-bts. Third and fourth macro arguments are reversed. Same pattern for --enable-trx, --enable-octphy, --enable-litecell15, --enable-oc2g. Verify by reading the configure summary.
  • .tarball-version with git-version-gen --dirty reports -dirty after make clean because the git index has stale stat data. Fix: git update-index -q --refresh before autoreconf -fi.

Full grep list in the consolidated handover.

Iu-CS and Iu-PS control plane

Requirements: osmo-msc --enable-iu needs libasn1c and libosmo-ranap.

  • libasn1c: 1 patch. -no-undefined becomes empty on Darwin. libtool then links with -undefined dynamic_lookup. Library exports talloc_asn1_ctx as an extern the application defines. Apple ld64 refuses undefined externs in a shared object under -no-undefined.
  • libosmo-ranap: 3 patches. AF_X25/AF_INET6 numeric family in tests (Problem 11), BSD sed -i (src/Makefile.am), missing Requires: libosmo-ranap in .pc for hnbap/rua/sabp.
  • osmo-hnbgw: 0 patches. Mirror trap: GitHub mirror HEAD points at a working branch from 2022, not master. Tag 1.9.0 is on origin/master. git checkout -b main 1.9.0 before push.

5-daemon 3G core up:

  • osmo-hnbgw show cnlink: IuCS: msc-0 PC=0.23.5 <-> PC=0.23.1, RANAP state: CONNECTED (RESET/RESET ACK through STP).
  • osmo-msc show fsm-instances all: ran_peer(UTRAN-Iu:...) READY.

hnb-test (demo peer in osmo-iuh) needed 7 patches on branch option-b:

  • 0001 gen_nas_auth_resp() hardcodes N(SD)=2 on Authentication Response (comment: simulate sequence nr 2). osmo-msc TS 24.007 duplicate detection drops it: Duplicate DTAP: bin=0, expected n_sd == 1, got 2. LU Reject after 5 s. Wrong since 2018 when duplicate detection landed on osmo-msc; hnb-test is not in CI. One-byte fix: 0x80 | AUTH_RESP0x40 | AUTH_RESP.
  • 0002 Full Milenage RES (8 bytes) on UTRAN. osmo-msc refuses 4-byte SRES: AUTH via UTRAN, cannot allow GSM AKA. Route NAS replies by CN domain so a PS connection can answer.
  • 0003 LU Accept TLV parse after mandatory LAI. Upstream tlv_parse started on LAI, read wrong identity IE.
  • 0004 N(SD) counter across the whole uplink MM connection. TMSI Reallocation Complete was also dropped as duplicate before this. Squash 0001+0004 for upstream.
  • 0005 hnb-test-gmm.c new file. channel ps attach imsi IMSI, Identity Response, Auth+Ciph Response with RES and IMEISV, Attach Complete.
  • 0006 channel ps pdp-activate imsi IMSI apn APN. GMM Service Request with P-TMSI, SM Activate PDP Context Request on Service Accept, RAB Assignment Response stub with cause user-plane-versions-not-supported.
  • 0007 CO RANAP in RUA DirectTransfer decoded with ranap_ran_rx_co() (RAN-side) instead of ranap_cn_rx_co() (CN-side). RAN-side has cases for RAB Assignment, Common ID, Security Mode Control, Iu Release, Direct Transfer. Matches what an HNB receives from the CN.

Signalling verified 8 September:

  • Iu-CS: TMSI Reallocation Complete, LU Accept, show subscriber attached in osmo-msc, osmo-hlr last LU seen on CS.
  • Iu-PS: Auth+Ciph, InsertSubscriberData over GSUP, Attach Complete with P-TMSI f36a8828, osmo-hlr last LU seen on PS.
  • GTP-C: Create PDP Context Response cause 128, show pdp-context all active on both GSNs, GTP Echo running.

Not verified: user plane from a real UE. hnb-test has no Iu-UP handling.

LTE measurement kit

Independent thread on the srsRAN 4G side, 22 September.

Landon Messer (SigInt-OS) maintains a fork of srsRAN_4G with lte_cell_measure. Self-contained utility. Measures RSRP, RSRQ, SNR, CFO from a target cell over a chosen bandwidth. One JSON line per measurement. Downlink-only. Not in upstream srsRAN.

Port scope: 2 files, +1227 lines. lib/examples/CMakeLists.txt modified. lib/examples/lte_cell_measure.c new. No external dependencies. No touch on srsue, srsenb, srsepc, srsgNB, lib/src/phy, lib/src/common. Cherry-pick both files, drop the unbuilt cell_monitor target the branch also declares, build. 3 s wall-clock.

Empirical run on LibreSDR B220, SIRIO SO 4G LTE-M3 antenna on RX A, indoors, worked just fine.

Upstream defect found. srsran_band_get_fd_band computes end - start. For a single EARFCN, -s N -e N answers No EARFCNs for band. Workaround at runtime: -e N+1.

Published as patch 026 in AndreiGosman/srsRAN-4G-macos-arm64.

Current lab state

20 repositories at github.com/AndreiGosman/*-macos-arm64.

9-daemon Osmocom stack on B220, ARFCN 871 DCS1800:

  • Daemons: osmo-hlr, osmo-stp, osmo-mgw, osmo-hnbgw, osmo-sgsn, osmo-ggsn, osmo-bsc, osmo-bts-trx, osmo-pcu.
  • 2G: PDCH TS6 and TS7 enabled, NS UNBLOCKED both ends.
  • 3G control: IuCS state CONNECTED, IuPS state CONNECTED. GSUP up. GTP-C between SGSN and GGSN verified.

hnb-test Option B:

  • Iu-CS LU Accept complete.
  • Iu-PS GMM Attach complete.
  • GTP-C Create PDP Context accepted. utun up. Address handed to UE. GTP Echo running.
  • RAB Assignment Response returns cause user-plane-versions-not-supported. osmo-sgsn receives it and does not act: upstream iu_client.c ranap_handle_co_rab_ass_resp() returns -1 on RAB failure, no Delete PDP Context to GGSN, no Activate PDP Context Reject to UE. Separate upstream defect.

LTE downlink: lte_cell_measure on the same hardware. 5 cells measured.

Not verified:

  • Real UE attach over the air.
  • Kernel SCTP peer interop. Current cascade runs usrsctp on both sides with UDP encapsulation.
  • srsRAN Project (5G) on macOS.