Part 8 of RF Front End. The RTL2832U from Part 7 can’t tune anything on its own — the actual frequency synthesis lives in a separate tuner chip on the I2C bridge. This post brings up the R820T/R828D, and tells the story of the bug that ate the most field debugging time in the whole RTL-SDR driver: the RTL-SDR Blog V4 that came up deaf.
TL;DR — This is the pure-Go R820T/R828D tuner driver, built around a shadow-register cache that makes read-modify-write free. The headline bug: the RTL-SDR Blog V4 came up deaf and mistuned by ~1.8×, fixed with a crystal override plus per-band input switching. A second bug (#248) — a 17-byte I2C burst stalling on NESDR v5 — is fixed by halving the chunk size until it fits.
In this post
- What the R82xx tuner does — PLL frequency synthesis, the 3.57 MHz IF, and why the RTL2832U only ever sees an intermediate frequency.
- The shadow-register cache — how mirroring the chip’s writable registers in Go makes read-modify-write free and papers over a real R820T quirk.
- Tuner auto-detection — probing I2C addresses in librtlsdr’s exact order.
- The problem we hit (issue #264): the RTL-SDR Blog V4 mistunes by ~1.8× and receives only noise, and the manual override + per-band input switching that fixed it.
- A second bug (issue #248): a multi-byte I2C burst stalls on NESDR v5 silicon, and the chunk-halving recovery that got it streaming.
What the R82xx tuner is
The R820T, R820T2, and R828D (collectively the “R82xx” family) share one I2C
register map and one PLL synthesizer. The tuner’s job is to take an RF frequency
from the antenna, mix it down to a fixed 3.57 MHz intermediate frequency, and
hand that to the RTL2832U’s ADC. So when you ask GopherTrunk to tune 154 MHz, the
tuner’s PLL doesn’t target 154 MHz — it targets 154 MHz + 3.57 MHz, and the
RTL2832U is configured (back in PrepareDemod) to expect signal at that IF
offset.
The driver is a straight port of osmocom librtlsdr’s tuner_r82xx.c — register
addresses, the init flood, the PLL math, and the frequency-range mux table are
all kept byte-identical so real-hardware captures replay-validate against the
mock USB transport. The whole driver lives in
internal/sdr/rtlsdr/tuners/r82xx.go.
How GopherTrunk implements it in Go
The shadow-register cache
The single most important design decision in the R82xx driver is the
shadow-register cache. The R82xx struct keeps a local mirror of the chip’s
registers:
// internal/sdr/rtlsdr/tuners/r82xx.go
type R82xx struct {
demod *rtl2832u.Demod
i2cAddr uint8
chipType Type
xtalHz uint32
// regs[0x05..0x1F] is the shadow for writable registers.
// regs[0x00..0x04] holds the most recently read read-only status bytes.
regs [r82xxNumRegs]byte
// ...
}
This solves a concrete hardware problem: the R820T’s writable registers can’t
be read back through the I2C bridge. Read-modify-write — “set these three bits,
leave the rest” — is impossible if you can’t read the current value. The shadow
makes it trivial, and it has a free side benefit: the chip silently drops a write
whose value matches the previous one, so eliding redundant writes saves a USB
roundtrip without changing observable behavior. Every masked write goes through
writeRegMask, which reads the shadow, applies the masked bits, and only touches
the wire if the result actually changed:
// internal/sdr/rtlsdr/tuners/r82xx.go
func (r *R82xx) writeRegMask(addr uint8, val, mask byte) error {
if addr < r82xxShadowStart {
return fmt.Errorf("r82xx writeRegMask: addr=0x%02x is read-only", addr)
}
cur := r.regs[addr]
next := (cur &^ mask) | (val & mask)
if cur == next {
return nil
}
r.regs[addr] = next
return r.writeBurstRaw(addr, []byte{next})
}
PLL tuning
setPLL is a faithful port of r82xx_set_pll: it sweeps the mixer divider to
land the VCO inside its valid range, computes an integer + sigma-delta fractional
division, and compensates with a VCO fine-tune read back from the chip. The math
is intricate but the load-bearing detail for this post is small — the divider
sweep and the nint/vcoFra split off the reference crystal:
// internal/sdr/rtlsdr/tuners/r82xx.go (shape)
vcoFreq := uint64(freqHz) * uint64(mixDiv)
effXtal := r.effectiveXtalHz()
pllRef := uint64(effXtal)
nint := uint32(vcoFreq / (2 * pllRef))
vcoFra := uint32((vcoFreq - 2*pllRef*uint64(nint)) / 1000)
Notice effectiveXtalHz(). Every PLL division is computed off the reference
crystal. If the driver believes the crystal is 16 MHz when it’s actually 28.8
MHz, every tune is wrong by the ratio 28.8/16 = 1.8×. Hold that thought.
Tuner auto-detection
Before any of this runs, we have to figure out which tuner is on the board.
detect.go probes candidate I2C addresses in librtlsdr’s exact order, reading
the chip-ID byte off each. For the R82xx family that’s a 0x69 ID at address 0x34
(R820T) or 0x74 (R828D):
// internal/sdr/rtlsdr/tuners/r82xx.go
func detectR82xx(d *rtl2832u.Demod) Tuner {
for _, c := range []struct {
addr uint8
typ Type
}{
{addr: r82xxI2CAddr, typ: TypeR820T2},
{addr: r828dI2CAddr, typ: TypeR828D},
} {
out, err := d.I2CRead(c.addr, 1)
if err != nil || len(out) == 0 {
continue
}
id := r82xxBitReverse(out[0])
if id == 0x69 || id == 0x96 { // includes some bit-reversed clones
return NewR82xx(d, c.addr, c.typ)
}
}
return nil
}
The whole detection runs under a single SetI2CRepeater(true)/(false) bracket so
the bridge doesn’t flap between candidates. That detail matters more than it
looks — it sets up the second bug below.
The problem we hit: the RTL-SDR Blog V4 deafness (issue #264)
The symptom. Plug in an RTL-SDR Blog V4 — a popular R828D-based dongle — and it would detect fine, claim its interface, accept every tune… and receive only noise. An R820T2 dongle on the exact same signal decoded cleanly. Worse, when it did seem to tune, frequencies were off by a factor of roughly 1.8.
The root cause — three of them, actually. The V4 is an R828D, and our
NewR82xx defaults every R828D to a 16 MHz crystal — which is correct for a
generic R828D. But the Blog V4 runs its R828D from the RTL2832U’s 28.8 MHz
reference crystal. There’s the 1.8× mistune, straight out of the PLL math above:
// internal/sdr/rtlsdr/tuners/r82xx.go
func NewR82xx(d *rtl2832u.Demod, i2cAddr uint8, chip Type) *R82xx {
xtal := r82xxXtalHz
if chip == TypeR828D {
xtal = r828dXtalHz // 16 MHz — wrong for the Blog V4
}
// ...
}
But fixing the crystal alone still left it deaf, because the V4 has a switched front end: an HF/VHF/UHF input bank with an on-board upconverter for HF. The stock R828D init leaves every V4 input off, so even perfectly tuned, no RF reaches the mixer. And detection can’t reliably tell a V4 from a generic R828D — the distinguishing signal is the USB iManufacturer/iProduct strings, which are sometimes blank or non-standard on real units.
The fix — three parts. First, an explicit SetBlogV4 that restores the right
crystal and arms the V4 path:
// internal/sdr/rtlsdr/tuners/r82xx.go
func (r *R82xx) SetBlogV4(lite bool) {
r.blogV4 = true
r.blogV4L = lite
r.xtalHz = r82xxXtalHz // 28.8 MHz, overriding the R828D 16 MHz default
}
Detection keys off the USB strings, but because that misses some units, the
driver also exposes a manual override all the way up at the Device level —
SetBlogV4 implements sdr.BlogV4Forcer, and the pool applies it from a config
hint before the first tune. Autodetection when it can; explicit override when it
can’t.
Second, per-band input switching. The V4 routes HF through an upconverter (so the
R828D actually sees hz + 28.8 MHz), and switches a physical input bank per band.
SetFreq adjusts the PLL target for HF and then drives the input switches:
// internal/sdr/rtlsdr/tuners/r82xx.go
target := hz
if r.blogV4 && hz <= r82xxV4HFCrossHz {
target = hz + r82xxXtalHz // HF reaches the mixer through the upconverter
}
// ...setMux(target), then:
if r.blogV4 {
if err := r.applyBlogV4Band(hz); err != nil {
return fmt.Errorf("r82xx SetFreq: v4 band: %w", err)
}
}
applyBlogV4Band drives the notch filter, the HF tracking-filter bypass, and the
HF/VHF/UHF input relays — ported verbatim from the rtlsdr-blog fork’s
r82xx_set_freq V4 block. It caches the last-selected band in a v4Band enum so
it only rewrites the input switches when the band actually changes:
// internal/sdr/rtlsdr/tuners/r82xx.go
band := v4BandFor(hz, r.blogV4L)
// HF: bypass tracking filter (re-applied every tune since setMux rewrites 0x1A/0x1B)
if band == v4BandHF { /* ...writeRegMask(0x1A,...) ... */ }
// Only rewrite the input switches on a band change.
if band == r.v4Input {
return nil
}
r.v4Input = band
// ...cable2 = HF input, GPIO5 upconverter relay, cable1 = VHF, air-in = UHF...
Third, the gain path. The V4 is a marginal-signal dongle, and there was a latent
AGC-mode bug that compounded the deafness: in AGC mode librtlsdr pins the VGA at a
fixed +16.3 dB, but SetGain is a no-op in AGC mode, so the VGA was being left at
its init default — running the front end ~17 dB low. SetGainMode now writes
the VGA in the AGC branch:
// internal/sdr/rtlsdr/tuners/r82xx.go
// AGC mode: pin the VGA at librtlsdr's fixed default (+16.3 dB).
if !manual {
if err := r.writeRegMask(0x0C, 0x0B, 0x9F); err != nil {
return err
}
}
There was even a fourth, subtler V4 issue tucked into the PLL: the rtlsdr-blog
fork lowers the VCO power reference from 2 to 1 for the R828D, and without that
the V4’s LO mistunes and receives noise while an R820T2 on the same signal
decodes cleanly. vcoPowerRef() returns 1 for TypeR828D. The V4 needed every
one of these to receive — which is exactly why it was so painful to chase.
To make the state visible, TunerDiag/IsBlogV4/XtalHz surface the effective
crystal and whether the V4 path armed, so the pool can log a boot-time line: a 16
MHz R828D means the V4 path did not arm and the LO is mistuned by 1.8×; 28.8 MHz
means SetBlogV4 ran.
The second problem: a 17-byte I2C burst that stalls (issue #248)
The symptom. Two NESDR SMArt v5 units would fail tuner init with an EPIPE
(Linux) / ERROR_GEN_FAILURE (Windows) on the very first multi-byte I2C burst —
the 27-byte R82xx init flood. Detection succeeded; the burst write that follows it
did not.
The root cause. librtlsdr assumes the chip’s I2C-bridge FIFO can swallow a
16-byte write (NMAX_WRITES = 16). On that specific firmware revision the FIFO
depth appears to be smaller, so the 17-byte first chunk (1 address byte + 16 data
bytes) NACKs. An earlier fix added a per-chunk retry; it wasn’t enough.
The fix. writeBurstRaw halves the chunk size on a stall — 16 → 8 → 4 — until
one size succeeds:
// internal/sdr/rtlsdr/tuners/r82xx.go
func (r *R82xx) writeBurstRaw(addr uint8, data []byte) error {
var lastErr error
for chunkSize := r82xxBurstMaxData; chunkSize >= r82xxBurstMinData; chunkSize /= 2 {
err := r.writeBurstAtSize(addr, data, chunkSize)
if err == nil {
return nil
}
if !isI2CBurstStall(err) {
return err
}
lastErr = err
if chunkSize > r82xxBurstMinData {
time.Sleep(r82xxBurstRetryDelayMillis * time.Millisecond)
}
}
return fmt.Errorf("tried chunk sizes 16,8,4; all stalled: %w", lastErr)
}
Two details make this robust. The stall predicate has to be
cross-platform — the same logical stall is a raw syscall.EPIPE on Linux and a
mapped usb.ErrPipeStalled on Windows, so isI2CBurstStall checks both;
checking only EPIPE meant the whole recovery silently never fired on Windows.
And there’s a detection-side dependency: Detect deliberately toggles the I2C
repeater off before returning so that Init’s leading SetI2CRepeater(true) is
a fresh wire write — that explicit “kick” is load-bearing on NESDR v5 to arm the
bridge, even though the cache from Part 7 would otherwise elide it.
The design principle: defensive hardware-quirk handling
Both bugs come from the same place: real hardware deviates from the reference, and when it does, autodetection is not enough — you need explicit, observable overrides.
The Blog V4 looks like a generic R828D until it doesn’t. The NESDR v5 looks like a standard RTL-SDR until its FIFO chokes on a standard-sized write. A driver that assumes every chip matches the datasheet — or even matches librtlsdr — comes up deaf on exactly the dongles people actually buy.
How that principle shaped the Go code
- Autodetect first, override explicitly. USB-string detection arms the V4
path automatically when it can;
SetBlogV4/sdr.BlogV4Forceris the manual escape hatch for units it misses. The default path stays correct for the common case and the override is one config hint away. - Quirk state is observable.
IsBlogV4,XtalHz, andTunerDiagexist so a failed override shows up as a boot-time diagnostic (“16 MHz R828D → mistuned 1.8×”) instead of a silent deaf dongle. - Recovery degrades gracefully. The burst write doesn’t give up at the first stall — it halves the chunk size and retries, so one firmware’s small FIFO costs a few extra round-trips instead of a hard failure. The stall predicate is written cross-platform so the recovery fires on every OS.
- Quirk reproduction stays faithful underneath. Every V4-specific write — band switching, VGA pinning, the VCO power reference — is ported verbatim from the rtlsdr-blog fork with a comment tying it to the source, so the override path is just as byte-faithful as the default path.
Where this goes next
The RTL2832U is initialized and the R82xx is tuned and locked. The only thing left is to actually move samples — to keep a 2.4 MS/s bulk-IN stream flowing without dropping IQ. Part 9 takes apart the streaming layer and the GC-churn bug that was shedding a quarter of the control channel’s live IQ.
FAQ
Why does the R828D default to 16 MHz if the V4 needs 28.8?
Because a generic R828D really does run from a separate 16 MHz crystal — that
default is correct for the common case. The Blog V4 is the special case, and
SetBlogV4 is how we mark it. Defaulting to 28.8 would mistune every non-V4
R828D instead.
Why not just always chunk I2C writes at 4 bytes to dodge issue #248? Because that triples the round-trips for every tuner init on every dongle to work around one firmware revision. The halving fallback only pays the cost when a chunk actually stalls; healthy chips still write 16 bytes at a time.
Is the shadow cache ever wrong?
Only for the read-only status registers (0x00–0x04), which we refresh by reading
the chip. The writable shadow (0x05–0x1F) is authoritative because those
registers can’t be read back anyway — the shadow is the source of truth, and
Init primes it with the init-flood values before the first burst.
Series navigation
Part 8 of 14 · ← Part 7 · Next → Part 9: RTL-SDR III — IQ streaming & the GC-churn bug