Part 4 of RF Front End. We open the box on how GopherTrunk reaches a USB
dongle without libusb: one tiny Go interface, three per-OS implementations
chosen at compile time, and a mock that lets the whole RTL2832U stack run in CI
with no hardware attached.
TL;DR — The USB transport layer is one tiny Go
Transportinterface that exposes only the slice of USB the RTL2832U needs, with a per-OS adapter chosen at compile time. The headline problem: the first cut leaked Linux USBDEVFS assumptions into the contract, so it was rewritten around what the driver needs, not what any one OS provides.
In this post
- Why GopherTrunk talks to the kernel’s USB interface directly instead of
linking
libusb— and what that buys us (one static binary, no CGO). - The
Transportinterface inusb.go— the minimal slice of USB the RTL2832U actually needs, and nothing more. - The build-tag platform split: one interface, per-OS files selected by
//go:buildtags, plus aMockTransportfor tests. - The problem of keeping one contract that fits USBDEVFS, WinUSB, and IOKit at once.
What the USB transport layer does
An RTL-SDR dongle is, electrically, a USB 2.0 device with a startlingly narrow control surface. The RTL2832U demodulator firmware only ever wants three things from the host: vendor control transfers out (write a chip register), vendor control transfers in (read one back), and a bulk-IN endpoint that fire-hoses 8-bit IQ samples at 2.4 MS/s. There are no class requests, no isochronous streams, no multiple-configuration gymnastics. The tuner register dance, the PLL math, the gain tables — all of it rides on top of those few primitives.
So the job of GopherTrunk’s USB layer is to expose exactly that narrow slice and hide everything else. The package doc says it plainly:
// internal/sdr/rtlsdr/usb/usb.go
// Package usb is the platform-abstraction layer that the pure-Go RTL-SDR
// driver speaks to. It exposes the minimal slice of USB the RTL2832U
// demodulator needs — vendor control transfers in both directions and an
// async bulk-IN endpoint — and nothing else.
The conventional way to do this in Go is to link libusb via CGO. We
deliberately don’t. Linking libusb would reintroduce a C toolchain at build
time, a shared-library dependency at run time, and the cross-compilation
headaches that come with both — breaking the single-static-binary promise the
rest of GopherTrunk is built around. Instead, each OS already exposes a way to
drive raw USB from userspace: USBDEVFS ioctls on Linux, the WinUSB driver
on Windows, IOKit on macOS. GopherTrunk speaks to those directly. The cost is
that we write three backends by hand; the payoff is a pure-Go, CGO-free,
go build-and-ship binary on every target.
How GopherTrunk implements it in Go
Everything funnels through two interfaces. The first, Enumerator, discovers and
opens devices; the second, Transport, is a claimed handle you do I/O on. Here’s
the Transport contract, trimmed to its shape:
// internal/sdr/rtlsdr/usb/usb.go
type Transport interface {
ControlIn(bRequest uint8, wValue, wIndex uint16, n int, timeoutMs int) ([]byte, error)
ControlOut(bRequest uint8, wValue, wIndex uint16, data []byte, timeoutMs int) error
ClaimInterface(num int) error
ReleaseInterface(num int) error
StartBulkIn(epAddr byte, ringBufs, bufLen int, onPacket func([]byte), onStreamDead func(error)) error
StopBulkIn() error
Reset() error
Close() error
}
That’s the whole vocabulary. Two control methods, two claim methods, a bulk-IN
pair, plus Reset and Close. Notice what isn’t here: no endpoint
descriptors, no configuration selection, no transfer-type enums, no
buffer-pool API. The RTL2832U only exposes interface 0 with a single bulk-IN
endpoint (0x81), so the interface bakes in those assumptions rather than
modelling the full USB spec. The narrowness is the point — it’s what makes three
implementations tractable.
The control transfers carry libusb-style request-type bytes, which the package exports as the only two values the firmware understands:
// internal/sdr/rtlsdr/usb/usb.go
const (
VendorOut uint8 = 0x40 // bmRequestType: host → device, vendor, device recipient
VendorIn uint8 = 0xC0 // bmRequestType: device → host, vendor, device recipient
)
StartBulkIn is the only method with real machinery behind it. It submits a ring
of ringBufs buffers of bufLen bytes each on endpoint epAddr, and invokes
onPacket from a dedicated reaper goroutine each time a buffer completes. The
slice handed to onPacket is owned by the transport and reused once the
callback returns, so the driver copies anything it wants to keep. The second
callback, onStreamDead, fires exactly once if every buffer dies of an
unrecoverable USB error without StopBulkIn being called — a hook we added
after a hang we’ll get to below.
Discovery rides on the sibling interface:
// internal/sdr/rtlsdr/usb/usb.go
type Enumerator interface {
Name() string // "usbdevfs", "winusb", "iokit", "mock"
List(vid, pid uint16) ([]Descriptor, error) // 0 = wildcard
Open(d Descriptor) (Transport, error)
}
func DefaultEnumerator() Enumerator { return platformEnumerator() }
DefaultEnumerator is the only public entry point. It calls platformEnumerator,
and that function is defined once per OS — every backend file provides its own.
On Linux it returns &linuxEnumerator{}, on Windows &winEnumerator{}, on macOS
the IOKit-backed enumerator. The driver above never names a concrete type; it
asks for DefaultEnumerator() and gets whatever the build produced.
The selection is pure build tags. Each backend file opens with a //go:build
header so the Go toolchain compiles in exactly one:
// internal/sdr/rtlsdr/usb/usb_linux.go
//go:build linux && (amd64 || arm64 || 386 || arm || riscv64 || loong64)
// internal/sdr/rtlsdr/usb/usb_windows.go
//go:build windows && (amd64 || arm64)
// internal/sdr/rtlsdr/usb/usb_darwin.go
//go:build darwin
// internal/sdr/rtlsdr/usb/usb_other.go
//go:build !(linux && (...)) && !(windows && (amd64 || arm64)) && !darwin
The usb_other.go catch-all matters more than it looks: it returns an
unsupportedEnumerator whose every method yields ErrUnsupportedPlatform. That
keeps the package compilable on freebsd, plan9, or any target without a real
backend — the higher layers build and link everywhere, and the failure surfaces
at runtime from List/Open instead of breaking go build on an exotic OS.
The mock that needs no hardware
The same Transport shape is what makes the driver testable. MockTransport
implements the interface by replaying a scripted sequence of control exchanges:
// internal/sdr/rtlsdr/usb/usb_mock.go
type CtrlExchange struct {
In bool
BRequest uint8
WValue uint16
WIndex uint16
Data []byte // expected for OUT
Reply []byte // returned for IN
Err error
// ...
}
type MockTransport struct {
Script []CtrlExchange
Step int
Err error
// ...
BulkPackets [][]byte
}
A test builds a Script of the exact register reads and writes the RTL2832U
bring-up should emit, runs the real driver against the mock, and then asserts
MockTransport.Err is nil and Remaining() is zero. Bulk streaming is mocked by
shoving pre-built byte slices into BulkPackets, which the mock’s goroutine feeds
through onPacket on the same (shape) contract the real backends honor. Because
the mock satisfies Transport, the rtl2832u register layer and every tuner
driver above it can be unit-tested without a dongle plugged in — which is the only
way CI on a headless Linux runner can exercise Windows-and-macOS-bound code paths
at all.
The problem we hit: one contract, three alien USB stacks
The first cut of this package was Linux-only. USBDEVFS gave us submit-a-URB,
reap-a-URB, claim-an-interface, and the Transport interface was essentially a
thin shadow of those ioctls. That worked beautifully until we started PR-02
(Windows) and PR-10 (macOS) and discovered the three OS USB stacks barely agree
on anything.
The symptom. Every method we’d sketched leaked a Linux assumption. Our first
StartBulkIn returned URB handles for the caller to manage — fine on USBDEVFS,
meaningless on WinUSB (which thinks in overlapped I/O and event handles) and on
IOKit (which has no URB concept at all, just synchronous ReadPipe). Our claim
semantics assumed a kernel driver you detach; WinUSB has already taken exclusive
ownership at initialize time and “claim” is a no-op. Cancellation was modelled on
USBDEVFS_DISCARDURB; macOS cancels with AbortPipe, Windows with AbortPipe
plus a manual event drain. Any interface that exposed those mechanics couldn’t be
satisfied by all three.
The root cause. We had let the implementation leak into the contract. The interface was a description of USBDEVFS, not a description of “what the RTL2832U needs.” Every Linux-shaped detail in the signature became a constraint the other two backends had to either fake or violate.
The Go fix. We rewrote the interface around what the driver asks for, not what any OS provides — and pushed every OS-specific mechanism strictly inside the implementations. Concretely:
StartBulkInstopped returning handles. The caller supplies geometry (ringBufs,bufLen,epAddr) and two callbacks, and never sees a URB, anOVERLAPPED, or apipeRef. Whether the bytes arrive via a single reaper goroutine (Linux), one OS thread per slot (macOS), orWaitForMultipleObjects(Windows) is invisible above the contract.ClaimInterface/ReleaseInterfacebecame intent, not mechanism. Linux turns a claim intoUSBDEVFS_CLAIMINTERFACEplus auto-detach of the kernel DVB driver; Windows makes it a no-op because initialize already claimed; macOS turns it into the IOCFPlugIn interface-iterator dance. The driver just says “I own interface 0.”- Buffer ownership was nailed down in the doc comment so all three could honor
it identically: the slice passed to
onPacketis transport-owned and reused. That one sentence let each backend reuse its ring buffers without copying, and let the driver copy exactly once.
The discipline that made it work was a kind of subtraction: every time a method
needed an OS-specific type to be expressible, that was the signal the method was
wrong. The final Transport has eight methods and zero platform types in its
signature — and that’s precisely why USBDEVFS, WinUSB, and IOKit can each be one
file behind it.
The design principle: ports & adapters at the OS boundary
This is the hexagonal / ports-and-adapters pattern applied at the operating
system boundary. Transport and Enumerator are the ports — abstract contracts
owned by the application’s needs. linuxTransport, winTransport, and
darwinTransport are the adapters — concrete implementations that translate
those needs into one specific OS’s USB ABI. The RTL2832U driver lives entirely on
the port side and is, by construction, ignorant of which adapter it’s talking to.
How that principle shaped the Go code
- The port is defined by the consumer, not the provider.
Transportmodels what the RTL2832U firmware requires — vendor control + one bulk-IN endpoint — not what USBDEVFS happens to offer. When an adapter couldn’t express a method without leaking its own types, we changed the port, not the adapter. - Adapters are selected at compile time, not runtime.
//go:buildtags mean the Windows binary contains zero IOKit code and the macOS binary contains zero WinUSB code. There’s noswitch runtime.GOOSand no dead platform branches linked into the wrong binary. - The unsupported case is an adapter too.
usb_other.gois the null adapter: it satisfies the port and fails politely. The port stays total — every platform has an implementation — so nothing above has to special-case “no USB here.” - The mock is just another adapter.
MockTransportplugs into the same port as the real backends, which is what lets hardware-free tests drive the full driver. The driver genuinely cannot tell a scripted mock from a real R820T.
You’ll recognize this as the same instinct behind the driver registry from the SDR Internals series: define the contract by what the core needs, then let interchangeable implementations satisfy it from below.
Where this goes next
We’ve drawn the port; the next three posts build the adapters. Part 5 dives into the Linux backend — hand-rolled USBDEVFS ioctl encoding, submitting async URBs, and reaping them in a single goroutine for sustained throughput. Part 6 takes on macOS (IOKit via purego, OS-thread-pinned reader goroutines) and Windows (WinUSB function pointers, overlapped I/O), and shows how both fold back onto the exact same eight-method contract. With the transport solid, Part 7 finally brings up the RTL2832U itself on top of it.
FAQ
Why not just link libusb and be done with it?
libusb would reintroduce CGO and a shared-library run-time dependency, breaking
GopherTrunk’s single-static-binary, cross-compilable build. Writing three thin
backends in pure Go is more work up front but keeps go build honest on every
target.
Doesn’t writing three USB backends triple the bug surface?
It spreads it, but the narrow contract contains it. Each adapter is one file
implementing eight methods, and MockTransport lets the entire driver above the
port be tested identically regardless of which adapter runs underneath — so most
logic is exercised in CI with no hardware at all.
Why does the interface assume interface 0 and endpoint 0x81? Because the RTL2832U does. The port is shaped by what the device needs, not by the full generality of USB. If GopherTrunk ever drove a multi-interface device, that would be a reason to widen the port — deliberately, with all three adapters in view.
Series navigation
Part 4 of 14 · ← Part 3 · Next → Part 5: USB on Linux — USBDEVFS & async URBs