Part 11 of RF Front End. After the Airspy’s host-side DSP, the HackRF One is a palate cleanser: the simplest wire format of the three. The interesting part isn’t the samples — it’s identity. The driver guesses the model from a USB PID, then asks the firmware who it really is, and that open-time handshake is where a probe-gains bug lived.
TL;DR — The HackRF One driver decodes the simplest of the three wire formats: signed 8-bit interleaved IQ scaled to
complex64. Identity is the tricky part — the driver guesses the model from a USB PID, then lets the firmware’s board-ID readback override it. A probe-gains bug (sdr list --probeshowed empty gains) is fixed by stamping the full identity, ladder included, onto the device at open time.
In this post
- Decoding the HackRF’s signed 8-bit interleaved IQ into
complex64in[-1, 1]. - Enumeration across the One / Jawbreaker / Rad1o PIDs on VID
0x1d50, and why the board-ID read at open takes precedence over the PID guess. - The SET_TRANSCEIVER_MODE state machine (0 off / 1 receive) and the default gain ladder (LNA 16 dB, VGA 20 dB).
- The bug:
sdr list --probeshowed empty gains because we read them before the device was fully open — and the open-time fix the tests pin down.
What the HackRF driver does
The HackRF One is a half-duplex
transceiver from Great Scott Gadgets covering 1 MHz–6 GHz. GopherTrunk only
receives, so the driver’s surface is small: enumerate, claim, tune, set rate and
gain, flip into receive mode, and reap bulk-IN URBs into complex64. Like every
other backend it speaks the vendor protocol — here libhackrf’s — directly over
the shared pure-Go USB transport, so no CGO and no libhackrf land in the build.
Its sample format is the friendliest of the bunch. The firmware delivers
signed 8-bit interleaved IQ — I, Q, I, Q, … — on bulk endpoint 0x81 once
the transceiver is in receive mode. There’s no real-to-complex synthesis (that
was the Airspy R2/Mini in
Part 10)
and no 16-bit unpacking; just two signed bytes per complex sample.
How GopherTrunk implements it in Go
Decoding the samples
The decode is a tight loop over internal/sdr/hackrf/hackrf.go: read a signed
byte for I, one for Q, normalize each to roughly [-1, 1] by dividing by 128:
// internal/sdr/hackrf/hackrf.go
// decodeInt8IQ converts a HackRF bulk-IN payload (signed 8-bit
// interleaved IQ) into normalised complex64 samples in [-1, 1].
func decodeInt8IQ(buf []byte) []complex64 {
n := len(buf) / 2
out := make([]complex64, n)
for i := 0; i < n; i++ {
iv := float32(int8(buf[2*i])) / 128
qv := float32(int8(buf[2*i+1])) / 128
out[i] = complex(iv, qv)
}
return out
}
The int8 conversion is load-bearing: the bytes arrive as uint8 in the slice,
and reinterpreting them as int8 is what makes 0x80 read as -128 rather than
+128. The in-package test pins that down with a (-128, +64) sample expected
near (-1, +0.5).
The transceiver state machine
The HackRF won’t stream until you tell it to receive, and you must put it back to
off when you stop. That’s a two-value state machine over SET_TRANSCEIVER_MODE
(reqSetTransceiverMode):
// internal/sdr/hackrf/hackrf.go
const (
transceiverModeOff uint16 = 0
transceiverModeReceive uint16 = 1
)
func (d *Device) setMode(mode uint16) error {
return d.t.ControlOut(reqSetTransceiverMode, mode, 0, nil, controlTimeoutMs)
}
StreamIQ flips to receive, starts the bulk-IN reaper, and a detached goroutine
flips back to off on either context cancellation or an unrecoverable URB death —
the same teardown shape every driver in this series shares.
Tuning and the tracking baseband filter
SetCenterFreq is the one place the HackRF’s wire encoding is slightly unusual:
libhackrf splits the frequency into an MHz part and an Hz remainder, two
little-endian uint32s in the data stage:
// internal/sdr/hackrf/hackrf.go
payload := make([]byte, 8)
binary.LittleEndian.PutUint32(payload[0:4], hz/1_000_000)
binary.LittleEndian.PutUint32(payload[4:8], hz%1_000_000)
return d.t.ControlOut(reqSetFreq, 0, 0, payload, controlTimeoutMs)
SetSampleRate does a little extra work: the rate goes out as a
numerator/divider pair (the driver always uses divider 1, so the host sees
exactly the requested rate), and then the baseband filter cutoff tracks the
rate — set to ~75 % of it to keep the passband flat while rejecting alias
energy near the band edge. The filter program is fire-and-forget; the rate
program is the one that must land:
// internal/sdr/hackrf/hackrf.go (shape)
// rate = (hz, divider 1); then baseband filter ≈ 0.75 × rate
bw := uint32(float64(hz) * 0.75)
_ = d.t.ControlOut(reqBasebandFilterBwSet,
uint16(bw&0xFFFF), uint16(bw>>16), nil, controlTimeoutMs)
The TestSetSampleRateProgramsFilter test scripts both transfers, asserting the
filter program follows the rate program with the 0.75 cutoff.
The gain ladder
The HackRF has no true AGC. Its gain is three independent stages: an RF amp (on/off), an LNA (0–40 dB in 8 dB steps), and a VGA (0–62 dB in 2 dB steps). The driver maps a single tenth-dB target onto that triple, and maps “auto” (a negative value) onto a sane fixed preset rather than a non-existent AGC:
// internal/sdr/hackrf/hackrf.go
// splitGain maps an SDR-interface tenth-dB target to the HackRF's
// (LNA, VGA, AMP-on) triple. LNA must be a multiple of 8; VGA a multiple of 2.
func splitGain(tenthDB int) (lna, vga int, amp bool) {
if tenthDB < 0 {
return defaultLNAGainDB, defaultVGAGainDB, false // 16 dB LNA, 20 dB VGA
}
target := tenthDB / 10
lna = (target / 8) * 8
if lna > 40 {
lna = 40
}
rem := target - lna
vga = (rem / 2) * 2
if vga > 62 {
vga = 62
}
return lna, vga, false
}
The default preset — LNA 16 dB, VGA 20 dB, amp off — is the
hardware-friendly mid-band split the test ladder asserts on (splitGain(-1) ==
(16, 20, false), and rungs like 300 → (24, 6)).
Identity: PID guess, then ask the board
Three HackRF variants share VID 0x1d50 under different PIDs — One (0x6089),
the Jawbreaker prototype (0x604b), and the Rad1o badge (0xcc15). Unlike
the Airspy (one PID) or RTL-SDR, enumeration here has to scan every known PID
and concatenate the results into one descriptor list:
// internal/sdr/hackrf/hackrf.go (shape)
descs := make([]usb.Descriptor, 0)
for _, pid := range []uint16{pidHackRFOne, pidHackRFJawbrk, pidHackRFRad1o} {
found, err := d.enum.List(vidHackRF, pid)
// ...append found to descs...
}
Enumeration then maps each PID to a canonical product name, since USB descriptor strings vary by vendor and firmware while the PID is stable:
// internal/sdr/hackrf/hackrf.go
var pidProductNames = map[uint16]string{
pidHackRFOne: "HackRF One",
pidHackRFJawbrk: "HackRF Jawbreaker",
pidHackRFRad1o: "Rad1o",
}
But the PID is only a guess about what the box is — a unit flashed with the
wrong firmware will enumerate under one PID while actually running another
board’s image. So at open time the driver asks the firmware directly, via
BOARD_ID_READ, and that answer takes precedence over the PID:
// internal/sdr/hackrf/hackrf.go
product := productForPID(desc.PID, desc.Product)
if bid, err := readBoardID(t); err == nil {
if name, ok := boardIDNames[bid]; ok && name != "" {
product = name // firmware self-report wins over the PID guess
}
}
boardIDNames mirrors libhackrf’s hackrf_board_id enum (2 → “HackRF One”,
3 → “Rad1o”, …). The same open also reads VERSION_STRING_READ to suffix the
tuner name with firmware (and to tag PortaPack/Mayhem builds). Both readbacks are
best-effort: older firmware that doesn’t implement them just falls through to the
PID-derived name and a plain tuner string — pinned by
TestReadVersionStringIgnoredOnError in hackrf_test.go.
The problem we hit: --probe reported empty gains
Symptom. sdr list --probe — which actually opens each device and reports
dev.Info() — listed HackRFs (and Airspys) with an empty gain list. The
non-probing sdr list, which only enumerates, showed the ladder fine. Same
hardware, two different answers, depending on whether the device had been opened.
Root cause. The gain ladder was attached to the sdr.Info produced by
Enumerate, but the Info carried by an opened Device was built
separately at open time and never got the Gains field. So the moment a probe
opened the device and read dev.Info().Gains, it got back nil. The information
existed; it just wasn’t established on the device-side Info that --probe
reads. (The same gap bit the Airspy driver, and is called out in #454’s
probe-gains fix.)
The Go fix. Establish the full identity — board-ID, version string, and
the gain ladder — at open time, on the same Info the opened device hands back.
The ladder is shared between Enumerate and Open so a probed device reports
exactly what an enumerated one does:
// internal/sdr/hackrf/hackrf.go
// gainPresetsTenthDB ... Shared by Enumerate and Open so a probed
// (opened) device reports the same ladder an enumerated one does —
// otherwise `--probe` showed an empty list.
var gainPresetsTenthDB = []int{0, 80, 160, 240, 320, 400, 480, 560}
return &Device{
t: t,
info: sdr.Info{
// ...board-ID-resolved Product, fw-suffixed TunerName...
// Carry the gain ladder onto the opened device so dev.Info()
// (used by `sdr list --probe`) reports it, not an empty list.
Gains: gainPresetsTenthDB,
},
}, nil
The fix is pinned by a test in hackrf_test.go that opens a device through a
scripted mock and asserts the opened Info carries the ladder:
// internal/sdr/hackrf/hackrf_test.go
// The opened device must carry the gain ladder so `sdr list --probe`
// (which reads dev.Info() post-open) doesn't report an empty list.
if got := dev.Info().Gains; len(got) == 0 {
t.Errorf("opened device Info().Gains is empty; want the gain ladder")
}
That same test (TestDriverEnumerateAndOpen) scripts the BOARD_ID_READ and
VERSION_STRING_READ exchanges the open now issues — proof that the board-id and
version readbacks, the firmware suffix, and the gain ladder all get established as
one atomic open-time step rather than lazily on first use.
It’s worth dwelling on why this class of bug is easy to introduce. Enumerate
and Open each build an sdr.Info independently — they have to, because an
opened device knows things an enumerated one can’t (the board-ID, the firmware
string). The trap is that the two Info constructions drift: a field added to
one is forgotten on the other, and nothing in the type system flags it. The fix
isn’t just “stamp the ladder on at open” — it’s making the shared facts (the
gainPresetsTenthDB ladder, the canonical product name via productForPID) come
from one source that both paths read, so they can’t disagree. The open-only facts
(board-ID override, firmware suffix) layer on top.
The design principle: identity by capability, established at open
The thread tying the decode loop to the probe-gains fix is one principle: a device’s identity and capabilities are established when it’s opened, by asking the hardware — not guessed lazily and not deferred until first use.
How that principle shaped the Go code
- The board reports itself; the PID is a fallback.
BOARD_ID_READat open overrides the PID-derived name, so a mis-flashed unit reports what’s actually running. The PID guess only survives when the firmware can’t answer. - Open is the single point of truth for
Info. Product, tuner/firmware string, and the gain ladder are all stamped onto the device’sInfoinOpen. Nothing downstream has to re-derive or re-probe them —dev.Info()is complete the instantOpenreturns, which is exactly what--probedepends on. - Best-effort readbacks degrade, they don’t fail. A board-ID or version-string NAK on old firmware is swallowed; the device still opens with a sensible PID-derived identity. Capability discovery never blocks bring-up.
- The mock scripts the open handshake. Because identity is established through
ordinary control transfers, the tests drive the whole open — board-ID, version,
gain ladder — against a
MockTransportwith no hardware, so the identity-by-capability contract is regression-tested.
Where this goes next
That’s all four wire formats and all four open-time identity strategies covered: RTL-SDR, Airspy R2/Mini, Airspy HF+, and HackRF. The remaining question is what sits above the drivers — the pool that owns opened devices, dispatches retunes, and survives a USB unplug-and-replug mid-stream. That’s Part 12: the SDR pool and the USB hotplug watchdog.
FAQ
Why divide the 8-bit samples by 128 and not 127?
Dividing by 128 maps the full signed range cleanly: -128 → -1.0 exactly, and
+127 → ~0.992. It keeps the scale a power of two and guarantees the result
never exceeds [-1, 1), which downstream DSP assumes.
Why does board-ID override the PID if both are available? The PID is what the device enumerated as; the board-ID is what the firmware reports it is. A unit flashed with another board’s image enumerates under one PID but runs another board — the firmware’s self-report is the ground truth, so it wins.
Why did probe show empty gains but plain list didn’t?
sdr list only enumerates, reading the Info from Enumerate (which had the
ladder). sdr list --probe opens each device and reads dev.Info(), which was
built separately at open and lacked the Gains field until we stamped the ladder
on at open time.
Does the HackRF have AGC? No. It has three manual stages (amp, LNA, VGA). The driver maps a negative (“auto”) target to a fixed hardware-friendly preset — amp off, LNA 16 dB, VGA 20 dB — rather than pretending an AGC exists.
Why scan multiple PIDs at enumerate instead of one?
Because the HackRF family ships under three PIDs on the same VID. A single
List(vid, pid) would miss Jawbreakers and Rad1os, so enumeration loops over all
three known PIDs and concatenates the descriptors into one ordered list that
Open later indexes into.
What’s the bias-tee for, and is it on by default?
SetBiasTee toggles the +3.3 V antenna-port bias (ANTENNA_ENABLE) for powering
an external LNA up the coax. It’s off unless explicitly enabled — the test
round-trips it on then off — so you never accidentally feed DC into a passive
antenna.
Series navigation
Part 11 of 14 · ← Part 10 · Next → Part 12: The SDR pool & USB hotplug watchdog