Part 1 of RF Front End, a 14-part series that walks the RF source layer behind GopherTrunk — the pure-Go USB drivers that turn a dongle into a stream of IQ samples — one component per post, and explains the software-design principle behind each piece and how that principle shaped the Go code.
TL;DR — The RF source layer is the pure-Go USB driver stack that turns RTL-SDR, Airspy, and HackRF dongles into IQ samples. GopherTrunk writes it all in Go to dodge the CGO/libusb “tax,” shipping a single static cross-compilable binary with no C dependencies.
In this post
- Why the RF source layer gets its own series — SDR Internals Part 2 only skimmed it; the drivers are deep enough to fill fourteen posts.
- The CGO/libusb “tax” GopherTrunk refuses to pay, and the single static binary you get for refusing it.
- What an RF source actually is, and the three device families GopherTrunk drives in pure Go.
- A map of the series so you can jump to the chip or layer you care about.
What an RF source is
Every signal GopherTrunk decodes starts the same way: some piece of hardware
digitizes a slice of the radio spectrum and hands the computer a torrent of
IQ samples — complex numbers that capture the amplitude and phase of the
signal. Everything after that — tuning, filtering, demodulation, FEC, the
trunking engine — is software. The “RF source” is the thing that produces those
samples: open the USB hardware, tune it, set gain and sample rate, and stream
[]complex64 chunks back without dropping any.
That job sounds small. It is not. Each dongle does it differently — different
chips (the RTL2832U demodulator, the Airspy register map, HackRF’s transceiver
command set), different tuners
(R820T, R828D, the Airspy
front ends), different USB control transfers, different sample formats (unsigned
8-bit, signed 8-bit, real 12-bit packed). Hiding all of that behind one clean
<-chan []complex64 is the entire point of the RF front end, and it is where a
surprising amount of GopherTrunk’s hardest bugs live.
New here? The reference entries on software-defined radio and IQ data cover the fundamentals this series builds on.
To make the scope concrete: an RF source has to keep up with the firehose. At
2.4 MS/s, an RTL-SDR delivers 2.4 million complex samples every second, packed
as interleaved bytes the USB endpoint streams in continuously. A chunk arrives
roughly every few milliseconds and must be unpacked from the hardware’s wire
format — unsigned 8-bit for the RTL2832U, signed 8-bit for HackRF, real 12-bit
for Airspy — into complex64, and handed off before the next chunk lands. There
is no room to fall behind: a dropped URB is a gap in the IQ, and a gap in the IQ
is a corrupted symbol in whatever the decoder was tracking. The RF front end is a
soft-real-time problem wearing the clothes of a USB driver, and that tension —
keep up, allocate nothing you can avoid, never block the bus — is the through-line
of Parts 4 through 11.
Why a separate series
SDR Internals Part 2 gave
the RF source layer a single post: here’s the Device interface, here’s the
registry, drivers register themselves, the engine stays hardware-agnostic. True,
and enough to understand the shape. But it left the inside of each driver as a
black box — “the RTL2832U register dance, the R820T PLL math, HackRF’s
transceiver state machine,” in that post’s own words, deferred to “a future
series.” This is that series.
The reason it deserves fourteen posts is that the RF front end is where pure-Go
ambition meets the metal. There is no libusb underneath catching our mistakes.
We are issuing raw USB control transfers, packing async URBs, decoding 8-bit IQ
in tight loops, and recovering dongles that fall off the bus mid-stream. There is
also no shared C library quietly normalizing four vendors’ hardware into a common
shape: where a C-linked project inherits librtlsdr, libairspy, and
libhackrf and lets each vendor’s headers define the abstraction, GopherTrunk has
to choose the abstraction and then earn it, chip by chip. That is more work, but
it is also the only way to keep the whole receive chain — antenna to audio —
inside one language you can read, test with the race detector, and cross-compile
with go build. The bugs are real, the fixes are specific, and the design
choices — one narrow interface,
self-registering drivers, a supervised pool — are what keep a pile of
chip-specific hacks from leaking into the rest of the engine.
The CGO tax we refuse to pay
The conventional way to talk to an RTL-SDR from any language is to link
librtlsdr, which links libusb. For Airspy you link libairspy; for HackRF,
libhackrf; for the HF+, libairspyhf. Each is a C library, and in Go each one
drags in CGO.
CGO is a tax with several line items:
- You lose the single static binary. A CGO build links against shared
objects that must be present on the target machine. “Install GopherTrunk” turns
into “install GopherTrunk and
librtlsdr0and the rightlibusb-1.0and hope the versions match.” - You lose trivial cross-compilation.
GOOS=windows GOARCH=amd64 go buildstops being a one-liner the moment CGO is on; you need a C cross-toolchain and the target’s C libraries for every platform you ship. - You lose a uniform debugging story. A crash in
libusbis a crash in C — no Go stack trace, no race detector coverage, a different mental model at the exact layer where the timing-sensitive USB bugs live. - You lose build hermeticity. The version of
libusbon the developer’s machine, the CI runner, and the operator’s box can all differ, and the RF-front-end behavior can quietly differ with them. A pure-Go transport pins all of that behavior in the module graph, so the bytes-on-the-bus logic is the same everywhere the binary runs.
GopherTrunk pays none of it. As the README puts it, the project “has no C
dependencies at build or runtime (no librtlsdr / libhackrf / libairspy /
libairspyhf / libusb …) and ships as a single ~10 MB static binary for
Linux, macOS, and Windows.” Every driver speaks to the kernel’s USB interface
directly — USBDEVFS on Linux, WinUSB on Windows, IOKit on macOS — instead of
linking a C USB stack. The build stays CGO_ENABLED=0, and go build keeps
cross-compiling to every target with no toolchain gymnastics.
The cost of refusing the tax is that we have to write the USB transport and the chip protocols ourselves. That cost is exactly what this series documents.
It is worth being precise about what “pure Go” buys an operator, because the
benefit is not abstract. A GopherTrunk release is one file. You download it,
chmod +x, and run it — no package manager, no apt install librtlsdr0, no
matching a runtime libusb against the one the binary was built against, no
LD_LIBRARY_PATH surprises on a stripped-down host. The Windows build talks WinUSB;
the macOS build talks IOKit; the Linux build talks USBDEVFS — and all three come
out of the same source tree with GOOS/GOARCH set, because none of them shell
out to a C toolchain. For a daemon that is meant to run unattended at the antenna —
often on a Raspberry Pi or a small ARM box — “one static binary with no shared
libraries to drift out from under it” is a reliability feature, not just a
packaging convenience.
The other half of the payoff is on our side of the build. Because every byte
from the USB endpoint to the complex64 is Go, the whole RF front end is in scope
for go test -race, for table-driven unit tests against a mock USB transport, and
for the same profiler that watches the DSP layer. When a streaming bug shows up as
GC churn (Part 9) or a tuner comes up deaf (Part 8), we debug it with a Go stack
trace and a Go profile — not by attaching gdb to libusb. The pure-Go decision
is what makes the rest of this series debuggable, and that is most of why it was
worth making.
The three device families
GopherTrunk drives three families of USB SDR in pure Go, plus a couple of network backends and a mock. The families differ enough that each gets its own pair of posts later:
- RTL-SDR — the $25 RTL2832U dongles (every osmocom tuner). The RTL2832U is a DVB-T demodulator repurposed as a raw ADC; the actual tuning happens in a separate tuner chip, usually the R820T/R828D. Cheap, ubiquitous, and full of quirks — including the RTL-SDR Blog V4 “deafness” bug that gets its own post.
- Airspy R2 / Mini and Airspy HF+ — higher-dynamic-range receivers. The R2 and Mini stream real samples that we convert to complex baseband in Go; the HF+ is a separate family tuned for HF/VHF. Different register map, different sample math.
- HackRF One (and Jawbreaker / Rad1o) — a half-duplex transceiver that streams signed 8-bit IQ. We only ever drive it in receive, but the command set is a full transceiver state machine.
All of them satisfy the same Device interface, so the engine can’t tell an
R820T from an Airspy HF+ from a recorded capture. Reference entries:
RTL-SDR,
HackRF,
Airspy.
Beyond the three USB families there are two more Device implementations worth
knowing exist, because they prove the abstraction holds. The rtltcp and
soapyremote backends mount network SDRs — a dongle on a Raspberry Pi at the
antenna, or professional gear like a USRP, LimeSDR, or bladeRF reached over the
SoapyRemote protocol — as if they were local hardware, all in pure Go with no
CGO. And the mock driver replays recorded IQ captures back into the pool as a
virtual tuner. None of these are USB at all, yet every one is a Device, which is
the clearest evidence that the contract in Part 2 is drawn at the right level: the
engine asks for IQ, and it does not care whether the IQ came from an R820T, a
network socket, or a WAV file on disk.
How GopherTrunk implements it in Go
The whole series hangs off one interface in internal/sdr/device.go. Every
backend, regardless of silicon, hides behind it:
// internal/sdr/device.go
type Device interface {
Info() Info
SetCenterFreq(hz uint32) error
SetSampleRate(hz uint32) error
SetGain(tenthDB int) error // -1 selects automatic gain control
SetPPM(ppm int) error
SetBiasTee(enable bool) error
StreamIQ(ctx context.Context) (<-chan []complex64, error)
Close() error
}
StreamIQ is the heart of it: give it a context.Context, get back a channel of
IQ chunks; cancel the context and the stream stops. The rest of the engine only
ever sees this interface — it never imports rtlsdr, hackrf, or airspy. The
concrete drivers live in sub-packages (internal/sdr/rtlsdr/purego,
internal/sdr/airspy, internal/sdr/airspyhf, internal/sdr/hackrf), and each
one registers itself with the package-global registry at init(). Part 2 takes
the contract apart method by method; Part 3 takes the registry apart.
The setters carry conventions that let one interface stand in for very different
silicon. SetGain(tenthDB int) takes gain in tenths of a dB, with -1 as a
sentinel that selects automatic gain control — one integer expresses both “manual”
and “let the tuner decide,” so the engine never needs a separate AGC method that
only some radios would honor. SetBiasTee(enable bool) toggles the 5V bias-tee
that powers an external LNA through the antenna SMA; on dongles without the
circuit it is required to silently no-op and return nil, so the engine can call
it unconditionally. These conventions are the small print of a narrow contract,
and Part 2 is largely about why they are drawn the way they are.
The design principle: one narrow contract, many implementations
The architectural bet of the RF front end is the same bet the whole engine makes, applied to hardware: depend on a small abstract contract, never on a concrete device. The engine asks for “a thing that streams IQ and can be tuned.” It does not ask for “an RTL-SDR.” Everything chip-specific lives behind the interface, and the chip-specific quirks live behind optional interfaces the caller type-asserts for (the subject of Part 2).
How that principle shaped the Go code
- One interface, four radios.
Deviceis the only type the engine knows. Adding the Airspy HF+ didn’t change a line of engine code — it added a package that satisfiesDevice. - Quirks stay optional. RTL-SDR-only behavior (Blog V4 override, tuner
diagnosis) lives in
TunerDiagnoserandBlogV4Forcer, optional extensions callers type-assert for — so the universal contract stays universal. - The binary’s import set picks the hardware.
cmd/gophertrunkblank-imports the real drivers; theirinit()functions populate the registry. Change the imports, change the supported hardware — no engine edit. - Pure Go, no CGO, end to end. USB transport, register protocols, IQ
decode — all Go, all
CGO_ENABLED=0, all cross-compilable.
The series map
| Part | Topic | Problem solved |
|---|---|---|
| 1 | Why drive radios in pure Go? (this post) | The CGO/libusb tax |
| 2 | The Device contract | One interface, four radios |
| 3 | The driver registry & enumeration | Hardware-agnostic core |
| 4 | Talking to USB without libusb | No C USB stack |
| 5 | USB on Linux: USBDEVFS & async URBs | High-throughput transfers |
| 6 | USB on macOS & Windows | Cross-platform transport |
| 7 | RTL-SDR I: bringing up the RTL2832U | The register dance |
| 8 | RTL-SDR II: the R82xx tuner & Blog V4 deafness | A mistuned LO |
| 9 | RTL-SDR III: IQ streaming & the GC-churn bug | Allocation pressure |
| 10 | Airspy R2/Mini & HF+: real samples to complex baseband | Real-to-IQ conversion |
| 11 | HackRF One: signed 8-bit IQ end to end | Transceiver state machine |
| 12 | The SDR pool & USB hotplug watchdog | Self-healing on disconnect |
| 13 | Testing radios without radios | CI with no hardware |
| 14 | Diagnostics, metrics & the pure-Go payoff | Observability + the payoff |
Each post is an overview — enough to understand the component, the Go that implements it, and the principle behind it. The pipeline below the RF front end is the subject of the sister series, SDR Internals; this series is the layer that feeds it.
Where this goes next
Part 2
opens up the Device interface itself — every method, the -1-means-AGC gain
convention, the bias-tee no-op rule, and the tension of expressing four very
different radios through one contract without letting any one radio’s quirks bleed
into the others. From there the series descends through the stack: the registry
and enumeration (Part 3), the USB transport that has to replace libusb on three
operating systems (Parts 4-6), then the chip drivers one family at a time — the
RTL2832U bring-up and its R82xx tuner, including the Blog V4 deafness bug and the
GC-churn bug in its streaming path (Parts 7-9), Airspy’s real-to-complex
conversion (Part 10), and HackRF’s signed-8-bit transceiver (Part 11). The last
three posts step back up: the pool and hotplug watchdog that supervise a fleet of
dongles (Part 12), how all of this is tested with no radios attached (Part 13),
and the diagnostics, metrics, and the pure-Go payoff that ties the series off
(Part 14).
FAQ
Why not just link librtlsdr and move on?
Linking librtlsdr reintroduces CGO and a runtime shared-library dependency,
which breaks the single-static-binary promise and turns cross-compilation into a
C-toolchain problem. Writing the USB transport and chip protocols in Go keeps the
build pure Go and the binary self-contained.
Is the RF front end just USB plumbing? The USB transport is the bottom of it (Parts 4-6), but each driver is also a small chip-protocol implementation — the RTL2832U bring-up, the R82xx tuner math, HackRF’s transceiver commands, Airspy’s real-to-complex conversion. The plumbing is necessary; the protocols are where the radios differ.
Do I have to read this in order? No. Part 1 is the map and each later part stands alone. But the layers stack — USB transport under the chip drivers under the pool — so top-to-bottom mirrors how a sample actually climbs out of the hardware.
Series navigation
Part 1 of 14 · Next → Part 2: The Device contract