Part 4 of From the Issue Tracker, postmortems of GopherTrunk bugs that fought back. Part 3 peeled four software layers off a single symptom. This one is a hardware bring-up story with the same shape and a better ending: after four confident, merged, wrong diagnoses, the person who fixed the RTL-SDR Blog V4 was the reporter — by reading a register table and finding the one constant the port had inherited wrong.
TL;DR: An RTL-SDR Blog V4 (R828D tuner) went through every stage of not working: I²C errors at init, a PLL overflow on tune, then — worst of all — apparent operation while receiving nothing but noise (#264). Four plausible root causes shipped and failed: PPM correction not reaching the tuner LO, spectrum-inverted IQ, a mixer-AGC gain polarity bug, and input-bank misrouting. Raw IQ captures kept the investigation honest (pure white noise means the LO is elsewhere, not attenuated), but the fix came from the reporter reading
r82xx_tables.go: the VCO power reference must be 1 on the R828D, not the 2 the osmocom-derived port hardcoded. Along the way: a crystal constant reversed twice, a missing V4 input-routing block, and a latent PLL cap bug that only a correct crystal could expose.
Cheat sheet
| Fact | Detail |
|---|---|
| Issue | #264 |
| Symptom | Blog V4 enumerated, tuned without errors, produced samples — and received only noise; a NooElec R820T2 on the same antenna decoded fine |
| Wrong theories (all shipped) | PPM not reaching the tuner LO; spectrum-inverted IQ; mixer-AGC gain polarity; input-bank misrouting via the Lite flag |
| The diagnostic | Raw IQ captures — pure complex white noise means the LO is elsewhere, not attenuated |
| Real cause | r82xxVCOPowerRef must be 1 on the R828D; the osmocom-derived port hardcoded 2 |
| Found by | The reporter, reading line 56 of r82xx_tables.go |
| Also fixed | The V4’s 28.8 MHz crystal (reversed twice), V4 input routing, the setPLL nint cap (76 → 268), and the pool’s device allowlist |
In this post
- The symptom ladder — from I²C EPIPE at init to “works, but hears nothing”.
- The pool that claimed everything — why
ppm: -4looked ignored: it was applied to the wrong dongle. - Four confident wrong diagnoses — each shipped, each explained the symptom, none fixed it.
- The captures that kept everyone honest — white noise as a hard diagnosis, and the crystal that reversed twice.
- The reporter reads the table — the one constant the port inherited wrong.
- The latent bug the correct crystal exposed — a wrong guard waiting for its first exercise.
- What we keep — the durable rules and their Field Guide entries.
The symptom ladder
The report opened simply: GopherTrunk 0.1.5 doesn’t detect the Blog V4; every other program does.
rtlsdr: tuner init: r82xx init: burst write: rtl2832u: I2CWrite addr=0x74: broken pipe
That first rung was already understood — it’s the same init-burst EPIPE as
#248, at the R828D’s
I²C address (0x74) instead of the R820T2’s (0x34), and the layered defenses
shipped for that issue (chunked bursts, chip-settle delay, EPIPE retry) covered
it. Each fix revealed the next rung:
| Rung | Symptom | What it turned out to be |
|---|---|---|
| 1 | I2CWrite addr=0x74: broken pipe at init |
The #248 init-burst timing family, fixed by existing defenses |
| 2 | r82xx setPLL: nint=78 overflows on tune |
A latent PLL cap bug, exposed by a crystal change (below) |
| 3 | All three of the reporter’s dongles claimed by the daemon; ppm: -4 “not adopted” |
Pool.Open ignored the config allowlist — a NooElec auto-claimed the control role, so the PPM landed on the wrong stick |
| 4 | V4 decodes nothing on DMR; a NooElec R820T2 on the same antenna decodes fine; “color code changes constantly” | The long tail — everything below |
Rung 4 is the interesting one, because from here the V4 looked alive. It enumerated, tuned without errors, and produced samples. It just never produced signal.
The pool that claimed everything
Rung 3 deserves its own telling, because it’s the kind of bug that poisons every
experiment run while it exists. The reporter had three dongles on the bus — the
V4 under test plus two working sticks — and configured only the V4 in
sdr.devices, with ppm: -4 measured for it. Two confusions followed. The
daemon appeared to grab all three dongles, and the PPM correction appeared to
have no effect at all.
Both were one bug: Pool.Open enumerated and opened every USB SDR it could
see, regardless of the sdr.devices allowlist. Role assignment then worked on a
first-come basis — the first opened device without an explicit role hint claimed
RoleControl. In this topology that was the NooElec, so the control-channel
hunt silently ran on a dongle the config never mentioned, while the configured
ppm: -4 landed on the V4 — which wasn’t doing the tuning. Every “did the fix
work?” test until this was fixed (PR #417) was potentially measuring the wrong
radio.
The transferable lesson: when a config value “isn’t being adopted,” first prove which device it was applied to. A device-identity bug wears a tuning bug’s clothes.
Four confident wrong diagnoses
Each of these shipped as a fix, with a mechanism that genuinely explained the symptom. None of them fixed it.
1. PPM correction never reached the tuner LO. True bug: SetPPM only retimed
the RTL2832U’s resampler clock and never re-tuned the LO, where librtlsdr does
both. Merged. The V4 stayed deaf — a few ppm was never going to matter against
what was actually wrong.
2. Spectrum-inverted IQ. The reporter asked the right-shaped question — “Are
I and Q reversed?” — and the theory had teeth: a conjugated stream flips the FM
discriminator’s sign, mapping every C4FM symbol to its negative, which is a
(dibit+2) mod 4 flip. The genuinely interesting fact unearthed here: DMR’s
nine sync words are closed under that polarity flip — an inverted data sync is
byte-identical to a clean voice sync, so the sync correlator alone structurally
cannot detect spectrum inversion; only the FEC-protected payload can. The decoder
gained dual-polarity burst decoding gated by the FEC chain. Merged. Still deaf.
3. Mixer-AGC gain polarity. SetGainMode programmed the mixer’s AGC-enable
bit with the same polarity as the LNA’s, where librtlsdr uses opposite polarities,
and never wrote the VGA in AGC mode — an under-gained front end, with a matching
fingerprint (low level, intermod forest). Merged, with an honest caveat attached
that if the V4 stayed deaf the theory was wrong. The reporter’s reply: “The gain
fix also keep my v4 silent.”
4. Input-bank misrouting via the Lite variant flag. Forcing blog_v4_lite
routed a VHF tune through the V4’s UHF input bank — this guess actively made the
capture worse.
The captures that kept everyone honest
What kept this from being an endless guessing game was ground truth: the reporter supplied raw IQ captures at every stage, and the analysis of each one constrained the next theory.
The first V4 capture was pure complex white noise — I/Q correlation ≈ 0.0001, flat power spectral density, no carrier anywhere in the ±1.1 MHz passband. That single measurement carries a hard conclusion: a blocked or under-gained front end still shows a weak copy of a strong carrier. Total absence means the LO is tuned somewhere else entirely — not attenuation, mistuning.
That insight drove the crystal work, which reversed itself twice. The first
change set every R828D to the generic 16 MHz reference crystal, per librtlsdr’s
R828D_XTAL_FREQ. But the rtlsdr-blog fork — the V4’s actual driver — keeps the
V4 on 28.8 MHz and applies 16 MHz only to non-V4 R828D dongles. A wrong
crystal is fatal arithmetic: every LO lands at 28.8/16 = 1.8× the requested
frequency, listening near 276 MHz when asked for 153. The follow-up (PR
#506) restored 28.8 MHz
for the V4 and added the second missing piece: the V4’s switched HF/VHF/UHF
input bank. Stock R828D init writes reg 0x05 = 0x83, which leaves both the
Cable-1 (0x40) and Air-In (0x20) input switches off — the V4 routes no RF
at all without per-band switching on registers 0x05/0x06 (a VHF tune sets
0x05 → 0xE3), the notch filter on 0x17, the GPIO5 relay that engages the HF
upconverter, and the HF tracking-filter bypass on 0x1A/0x1B.
After #506 the captures changed character: real signal energy, a 28 dB peak-to-floor. Progress — the front end was finally connected to the antenna. But side-by-side captures against the NooElec ground truth still showed the one carrier that mattered missing:
| NooElec R820T2 (works) | Blog V4 (fails) | |
|---|---|---|
| DMR carrier at 153.139 MHz | +41.6 dB over floor, decodes | absent — at the noise floor |
| Overall level | +1.1 dB | ~17 dB lower, intermod forest |
replay -protocol dmr |
BS-Voice sync, valid FEC frames | 0 sync, 0 frames |
The “color code changes constantly” symptom, meanwhile, was never a signal property at all — it was the decoder false-locking on noise, the slot-type Hamming code dutifully “correcting” garbage to an ever-changing color code.
The reporter reads the table
With detection confirmed working (blog_v4=true ref_xtal_hz=28800000 in the boot
log) and the gain theory dead, the reporter went into the source:
I found the solution. In line 56 of
internal/sdr/rtlsdr/tuners/r82xx_tables.go,r82xxVCOPowerRefmust be set to1for the Blog V4; that makes it work.
GopherTrunk’s setPLL was a port of osmocom librtlsdr, which hardcodes
VCO_POWER_REF = 2 for every chip. The rtlsdr-blog fork overrides it:
uint8_t vco_power_ref = 2;
...
if (priv->cfg->rafael_chip == CHIP_R828D ||
rtlsdr_check_dongle_model(priv->rtl_dev, "RTLSDRBlog", "Blog V4L"))
vco_power_ref = 1;
With the wrong reference, the VCO fine-tune step nudged the mixer divider the
wrong way and mistuned the LO — the wanted carrier landed out of band, and the V4
heard only noise. Exactly what every capture had been saying. The fix is a
per-chip vcoPowerRef() — 1 for R828D, 2 for R820T/R820T2 — leaving the working
NooElec path byte-for-byte unchanged, plus a regression test pinning the per-chip
threshold. The reporter rebuilt: “After rebuild with latest main the V4 works
with gophertrunk.”
The latent bug the correct crystal exposed
One rung deserves its own note, because it’s a pattern worth naming. When the
crystal first moved to 16 MHz, tuning 153.5875 MHz started failing with
setPLL: nint=78 overflows. The overflow guard was:
if nint > 0x3F+13 { // = 76 — derived from ni's 6-bit field alone
But the real register encoding is nint = 13 + 4*ni + si, with si living in
two more bits of the same register — the true cap is 268. With the 28.8 MHz
crystal the VCO range keeps nint near 67, so the wrong guard was latent for
every dongle ever tested. Halving the reference roughly doubled nint, and 78 —
perfectly encodable — tripped a limit that had never been exercised. A correct
change exposed a wrong guard: the bug was always there, waiting for the first
configuration that reached it.
What we keep
- Pure white noise is a diagnosis, not an absence of one. Uncorrelated I/Q and a flat PSD mean the LO is elsewhere — mistuned, not attenuated. The distinction between “weak” and “absent” signatures is cataloged in signal signatures.
- Port against the fork the hardware actually uses. Three of the V4’s failures (crystal, input routing, VCO power reference) were divergences between osmocom librtlsdr and the rtlsdr-blog fork. A port inherits its upstream’s blind spots.
- A confident mechanism is not a confirmed root cause. Four theories shipped with explanations that fit the symptom; the disciplined side-by-side capture against a known-good dongle is what falsified each one. That workflow is in the diagnostic playbook.
- Correct fixes uncover latent guards. The
nintcap was wrong for years and harmless until the crystal fix doubled the operating range. Derive limits from the register encoding, not from one field of it. - Some inversions are structurally invisible. DMR’s sync words are closed under the polarity flip — detection has to come from the FEC-protected payload. Bring-up ladders like this one, from EPIPE to working RF, are traced in RTL-SDR USB recovery.
- Reporters fix bugs. The decisive read of
r82xx_tables.gocame from the person with the hardware on their desk. Make the source approachable; it pays for itself.
FAQ
How can a dongle tune “successfully” and hear nothing? Because tuner registers accept any write. With the wrong VCO power reference, the fine-tune step nudged the mixer divider the wrong way and parked the LO out of band — no error is returned, samples keep flowing, and the passband contains only the front end’s own noise. “No error” from a register write proves the bus works, not that the radio is where you asked.
What does “pure complex white noise” actually prove? The first V4 capture measured I/Q correlation ≈ 0.0001 with a flat power spectral density — no carrier anywhere in the ±1.1 MHz passband. A blocked or under-gained front end still shows a weak copy of a strong carrier; total absence means the LO is tuned somewhere else entirely. That single measurement converts “the signal is weak” theories into “the tuning is wrong” theories.
Why couldn’t the decoder detect the inverted spectrum on its own?
DMR’s nine sync words are closed under the (dibit+2) mod 4 polarity flip — an
inverted data sync is byte-identical to a clean voice sync, so the sync
correlator structurally cannot tell inversion from truth. Only the FEC-protected
payload can, which is why the dual-polarity decode is gated by the FEC chain.
Why did fixing the crystal cause setPLL: nint=78 overflows?
The overflow guard capped nint at 76, derived from the ni field’s 6 bits
alone — but the register encoding is nint = 13 + 4·ni + si, true cap 268. At
28.8 MHz nint stays near 67, so the wrong guard was latent on every dongle
ever tested; halving the reference to 16 MHz roughly doubled nint and tripped
a limit that had never been exercised. The guard was always wrong; the crystal
change was merely the first configuration to reach it.
Which upstream should an RTL-SDR driver port follow? The fork the hardware actually ships against. Three of the V4’s failures — crystal frequency, input routing, VCO power reference — were divergences between osmocom librtlsdr and the rtlsdr-blog fork. A port inherits its upstream’s blind spots, and the V4’s upstream was never osmocom.
Series navigation
Part 4 of 22 · ← Part 3: Encrypted, Says Who — Four Layers Between a Flag and Its Metadata · Next → Part 5: Ten Megasamples — When the Bug Is in the Samples Themselves