From the Issue Tracker, Part 4: The Dongle That Heard Nothing — One Line in a Register Table

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: -4 looked 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, r82xxVCOPowerRef must be set to 1 for 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 nint cap 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.go came 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