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

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

September 4, 2026 Andrei Gosman 9 min read

I run a small SDR research lab on a MacBook Pro M4, with a LibreSDR B220 Mini card, for passive protocol research on GSM and LTE. As of today, the lab decodes GSM BCCH from a raw wideband capture, inventories 2G and 4G cells in Bucharest across all current mobile operators, and runs a full LTE core (srsue + srsenb + srsepc) in loopback, all native on macOS ARM64. No Docker, no VM, no Linux host anywhere in the chain.

Getting there took three days of ports and chasing a bitstream problem that looked like a UHD bug and turned out to be a resource-limited FPGA image shipped as a “compatible” bitstream. This post lists the problems, in the order I hit them, with the fix I ended up using for each. It closes with what the lab does today and links to the public repositories.

The setup, in short

Hardware:

  • LibreSDR B220 Mini: a clone of the Ettus B210, with an Artix-7 XC7A200T FPGA instead of the XC7A75T of the original, a full-spec AD9361 that covers 70 MHz to 6 GHz with 56 MHz bandwidth, USB 3.0 through a Cypress FX3
  • MacBook Pro M4, macOS Tahoe (Darwin 25.5.0), Apple Silicon ARM64
  • Sirio SO 4G LTE-M3 antenna, dual-band 790-960 / 1710-2700 MHz omni, on the main RX port

Software stack, all in a local prefix activated by a single env.sh, so nothing pollutes the system Homebrew tree or the shell rc file:

  • UHD 4.10 from Homebrew, the official Apple Silicon bottle
  • GNU Radio 3.10.12, Python 3.14
  • libosmocore + gr-gsm + gr-osmosdr, native, after a large patch set on libosmocore
  • usrsctp + libsctp-compat, my own shim
  • srsRAN 4G v25.10, with 23 patches and three Darwin shims

Everything below is a problem that stood between the hardware and a running srsue on live spectrum.

Problem 1: osmocom does not build on Darwin

The whole 2G stack (libosmocore, gr-gsm, grgsm_scanner) is written for Linux. It uses glibc-specific calls (setresgid, gettid), Linux-only kernel headers (<linux/if.h>, <linux/tcp.h>, <asm/hwcap.h>), timerfd, netlink, io_uring, and the auxiliary vector. None of these exist on Darwin.

Fix: 21 patches on libosmocore upstream. Some are surgical (setresgid to setregid, two arguments in place of three, semantically equivalent for this use). Some are broad (whole-file wraps in #ifdef __linux__ for the six files that pull in Linux-only headers). One is a header for symbol dummies with values that do not collide with Darwin’s own enums (SO_PRIORITY at 999, CLOCK_REALTIME_COARSE at 100, and so on). One is a stubs file for public symbols that the wrapped files used to export: osmo_stats_tcp_*, osmo_timerfd_*, osmo_tundev_*.

One patch is a linker trick, not a code change. -Wl,-undefined,dynamic_lookup must apply only at make time, never at configure time. Applying it at configure fools autoconf into concluding that gettid, setns, and unshare exist on Darwin (they do not: the linker was just permissive). I lost half an hour to this before spotting the pattern in the config cache.

The port is public at github.com/AndreiGosman/libosmocore-macos-arm64, GPL-2.0+, tag v0.1.0. It ships with an install script that clones libosmocore upstream, applies the patches, and builds into a prefix of your choice. I sent an RFC to openbsc@lists.osmocom.org asking whether a refactored version has any chance of landing upstream. The current patch set is pragmatic, not upstream-quality, and I want to know whether it is worth investing in a rewrite.

Problem 2: srsRAN wants SCTP; macOS has none

XNU has no SCTP in the kernel. There is no IPPROTO_SCTP, no <netinet/sctp.h>, no libsctp. srsRAN 4G links srsran_common against ${SCTP_LIBRARIES}, and srsran_common is used by srsue. Even a passive cell search does not link without SCTP present, because the linker wants the symbols at link time even if the runtime call graph never touches them.

Fix: a compatibility shim, roughly 1150 lines of C, that exposes the Linux libsctp API (sctp_bindx, sctp_connectx, sctp_sendmsg, sctp_recvmsg, sctp_send, sctp_opt_info, sctp_subscribe_to_events, sctp_getpaddrs, sctp_freepaddrs, and the associated structs) over usrsctp, the userspace SCTP fork of the BSD kernel implementation. Interposition through link order and pkg-config, not DYLD_INSERT_LIBRARIES. A pump fd per socket, built with socketpair(AF_UNIX, SOCK_DGRAM), so poll works on a real file descriptor. The usrsctp callback frames each message with the flags, address, and sctp_sndrcvinfo that Linux callers expect.

Three lksctp vs. usrsctp behavior differences surfaced during validation: freepaddrs(NULL) (usrsctp inherits BSD libc, lksctp accepts NULL through POSIX free), one-to-many connect() peer retention (RFC 6458 §3.1.3 makes usrsctp compliant and lksctp accidental), and HAVE_SIN_LEN normalization. Neither side is a bug. The shim documents each divergence and handles it.

The shim is public at github.com/AndreiGosman/libsctp-compat-macos-arm64, LGPL-2.1-or-later, tag v0.1.0. Validated end-to-end with a real S1AP flow between srsue and srsepc.

Problem 3: timerfd and TUN, two more Linux-only kernel APIs

srsRAN also uses timerfd_create/timerfd_settime (Linux only) and Linux TUN/TAP through ioctl(TUNSETIFF) (Linux only). Neither exists on Darwin, and neither can be stubbed to no-op without breaking the runtime.

Fix, timerfd: a wrapper over kqueue EVFILT_TIMER that returns a real file descriptor, so existing poll loops in srsRAN keep working. The read side is served by a pipe that a kqueue thread signals on each timer expiry. Behavior matches Linux timerfd for the flags srsRAN uses.

Fix, TUN: a wrapper over macOS utun. utun uses a PF_SYSTEM control socket rather than an ioctl on /dev/net/tun, and IP address assignment goes through SIOCAIFADDR rather than SIOCSIFADDR. The shim maps the srsRAN calls onto both.

Both shims live inside the srsRAN fork, public at github.com/AndreiGosman/srsRAN-4G-macos-arm64. The fork holds 23 patches plus the three Darwin shims (SCTP, timerfd, TUN). Six of the patches are platform-independent bugs I plan to submit upstream as separate pull requests, so the fork does not become the recommended path for fixes that belong in srsran/srsRAN_4G proper.

Problem 4: the bitstream trap

After srsRAN was built and the loopback worked, I moved to live LTE cell search on band 3 (1800 MHz) with the LibreSDR on the roof-line antenna. Small cells (5 MHz, 25 PRB, master clock 7.68 MHz) decoded fine. 10 MHz cells decoded fine. 15 MHz cells crashed with wait_for_ack on the VITA control channel. 20 MHz cells crashed immediately with unknown SID 655360.

The empirical table:

MCR (MHz)Result
11.52Stable, PSS + MIB decode
15.36wait_for_ack on VITA ctrl
16.00Opens, sample rate reported as 2.0 instead of 1.92
23.04unknown SID 655360
30.72Same crash

Consequence for the survey: on B3, the densest band in city, where the big operators run 20 MHz carriers, decode rate dropped to 38%. PSS peak visible, MIB parsed the bandwidth field, but cell.id came back as zero because SSS never completed.

First hypothesis was UHD 4.10 too new. Patching the srsRAN default MCR to 11.52 worked as a temporary walk-around but was clearly wrong: unknown SID 655360 is not an out-of-range error. It is a packet routing failure on the VITA control channel between CPU and FPGA. The bitstream’s control plane cannot handle the configuration traffic at higher rates.

Second hypothesis, the one that turned out to be right: the bitstream shipped by SDRstore with the card does not use the XC7A200T resources of the real silicon. It is a wrapper around the stock Ettus B210 layout, which is synthesized for the smaller XC7A75T (half the LUTs, one fifth of the DSP slices). The board behaves as a B210 with better USB, but the internal VITA is dimensioned for the smaller FPGA. Circumstantial evidence: the shipped bitstream is 4337612 bytes, only 113 KB larger than stock Ettus B210 (4224356 bytes). A real port that used double the LUT budget would be closer to 6-7 MB.

Fix: a community bitstream, prebuilt and synthesized explicitly for the XC7A200T, from github.com/bkerler/LibreSDR_UHD_B220_Mini_FPGA. MIT license, usrp_b210_fpga.bin of about 4.66 MB, hash verifiable. Installation is non-invasive through ~/.config/uhd.conf, one line per serial number pointing to the bitstream path, so the file in the UHD images directory stays as backup:

[serial=<your-serial>]
fpga=<path-to-bitstream>/usrp_b210_fpga.bin

uhd_usrp_probe picks it up on the next open, reports name: LibreSDR_B220mini (instead of the stock B210), and register loopback passes. MCR 23.04 MHz then runs stable, with zero unknown SID and zero wait_for_ack. The first 20 MHz cell decoded via SIB1 was Orange on EARFCN 1600, PSS PSR 7.7, PBCH SNR above 12 dB.

Credit for the bitstream goes to bkerler. Anyone starting on a LibreSDR B220 Mini today should deploy this bitstream on day one and treat the SDRstore-supplied one as a fallback.

Problem 5: the gain paradox

Small, but worth naming because it is counterintuitive. On the LibreSDR without an FM notch filter, the AD9361 goes into compression at gains above 55 dB, from FM broadcast intermodulation and strong adjacent LTE cells. PBCH SNR drops below the decode threshold. Lowering rx_gain from 70 to 45 dB brings SNR from 1-4 dB back up to 14+ dB on the same cell.

Fix, for now: rx_gain 40-50 dB on band 3, adjust per band. Fix, in the pipeline: an FM notch filter (RTL-SDR Blog, roughly 15-20 EUR) plus a SAW filter on B3, ordered but not yet delivered. Expected SNR improvement 3-8 dB after both are in line.

What the lab does today

The three-day port stack pays off in what the lab can actually see:

On 2G: grgsm_scanner runs live on the LibreSDR and identifies 35 active cells in Bucharest across all four historical mobile operators (Vodafone MCC/MNC 226/01, Telekom Mobile 226/03, Digi 226/05, Orange 226/10). BCCH decode with grgsm_decode on a wideband capture pulls out full System Information messages (SI3, SI9, SI13, SI17), including MCC/MNC/LAC/CID, cell selection parameters, and cipher mode capabilities. GSMTAP output to Wireshark works end-to-end.

On 4G: 34 LTE cells inventoried on bands B1, B3, and B7 with the srsRAN 4G cell_search tool. After the bitstream fix, srsue decodes SIB1 live on 20 MHz cells and confirms the operator at the PLMN level. All three current mobile operators inventoried at eNodeB-ID, PLMN, TAC, and Cell-ID level.

On the core network side, srsepc + srsenb + srsue attach end-to-end in loopback over ZeroMQ. Full LTE control plane (Attach Request, Authentication with milenage, Security Mode, ESM/EMM Session, Bearer setup, IP address assignment) and user plane (GTPU with ping response through the utun interface). All three components run native on macOS ARM64 with the S1AP flow going through the SCTP shim.

Closing notes

The lab is passive. Nothing transmits. Any transmit setup requires a Faraday cage, a programmable SIM (sysmoISIM-SJA2 is on the shopping list).

The three ports are public on their own merit, each with a README, an install script, and a tag:

Six upstream pull requests on the platform-independent bugs are the next step: arm64 detection substring match, bundled fmt priority, __GLIBC_PREREQ guard, portable ifreq/in6_addr spellings, C++ standard as a CMake cache variable, mbedtls priority.