No description
  • Zig 90.9%
  • C 8.2%
  • Nix 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 729b552dd3
All checks were successful
test / test (push) Successful in 7m32s
test / docs (push) Successful in 2m13s
Take librtlsdr's sources from the Zig package manager
The cross-check compiled librtlsdr from whatever nixpkgs happened to have,
handed to it through a LIBRTLSDR_SRC environment variable that the devshell
set. That tied a check of this port to a package in another distribution, and
meant the shell script running it could not work without Nix.

The sources now come from the Zig package manager, pinned by hash to
rtl-sdr-blog 1.3.5, and the whole check is build steps rather than a shell
script -- which also drops the Python that the subsequence check was written
in. `zig build xcheck` compiles ref.c, compiles the real librtlsdr against the
fake libusb, runs each alongside its Zig counterpart, and compares the captured
output with tools/xcheck/compare.zig. A divergence prints the line and both
bytes, and fails the build.

The check is its own project in tools/xcheck, with its own manifest, and that
is not incidental. A build script cannot find out which steps were asked for --
the build runner resolves those after `build` has already returned -- so
merely naming a dependency is enough to have it fetched, and anything that
depends on this library runs its `build.zig` to get the module. A dependency in
this library's manifest is one every consumer downloads whatever they asked
for. Keeping librtlsdr in the sub-project's manifest keeps it out of theirs:
`zig build` and `zig build test` here now fetch nothing at all.

librtlsdr narrates itself on stderr -- which tuner it found, every direct
sampling transition, every loop that would not lock -- and the build runner
treats a program that writes to stderr as one that went wrong, so the reference
driver sends it to /dev/null. None of it was the comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XoRLfNnmyxUKzstPG7R9ET
2026-09-07 19:32:41 -05:00
.forgejo/workflows Take librtlsdr's sources from the Zig package manager 2026-09-07 19:32:41 -05:00
LICENSES Port librtlsdr to Zig 0.16, with a usbfs backend and no libusb 2026-09-07 14:08:20 -05:00
src Rename the project to zig-rtlsdr 2026-09-07 18:39:09 -05:00
tests Fuzz the arithmetic and the driver, and fix the eight crashes it found 2026-09-07 16:03:36 -05:00
tools Take librtlsdr's sources from the Zig package manager 2026-09-07 19:32:41 -05:00
.gitignore Take librtlsdr's sources from the Zig package manager 2026-09-07 19:32:41 -05:00
build.zig Take librtlsdr's sources from the Zig package manager 2026-09-07 19:32:41 -05:00
build.zig.zon Rename the project to zig-rtlsdr 2026-09-07 18:39:09 -05:00
flake.lock Port librtlsdr to Zig 0.16, with a usbfs backend and no libusb 2026-09-07 14:08:20 -05:00
flake.nix Take librtlsdr's sources from the Zig package manager 2026-09-07 19:32:41 -05:00
README.md Take librtlsdr's sources from the Zig package manager 2026-09-07 19:32:41 -05:00
REUSE.toml Take librtlsdr's sources from the Zig package manager 2026-09-07 19:32:41 -05:00
rtl-sdr.rules Port librtlsdr to Zig 0.16, with a usbfs backend and no libusb 2026-09-07 14:08:20 -05:00

zig-rtlsdr

RTL2832U software-defined radio receivers, in Zig 0.16, with no C in the build and no libusb underneath.

This is a port of librtlsdr — the RTL-SDR Blog fork, version 1.3.5 — onto Zig, including its USB layer. Where librtlsdr calls libusb, this talks to Linux's /dev/bus/usb directly through Io.Operation.device_io_control, so the whole thing is one Zig module with nothing to link against.

The API documentation is generated from the doc comments and published at https://jeff.ocj.page/zig-rtlsdr/.

What works

  • Device enumeration, opening by index or by serial number, and taking the device back from the kernel's DVB-T driver.
  • The demodulator: sample rate, frequency correction, the decimating filter, test mode, digital automatic gain control, direct sampling, offset tuning, the bias tee, and the EEPROM.
  • The R820T and R828D tuners, including the RTL-SDR Blog V4's HF upconverter, its notch filters and its three-way antenna switch.
  • Streaming, both a simple blocking read and a ring of bulk transfers kept in flight so that the endpoint always has somewhere to put data.
  • rtl_sdr and rtl_test, taking the same options as the originals.

Unlike librtlsdr as most distributions build it, this detaches the kernel's dvb_usb_rtl28xxu driver on open and puts it back on close, so there is no module to blacklist. (Upstream only does that when built with DETACH_KERNEL_DRIVER, which nixpkgs, among others, does not define — its rtl_sdr refuses to open the device and tells you to blacklist the module.)

What does not

  • The E4000, FC0012, FC0013 and FC2580 tuners are not ported. They are detected and named, and everything that does not go through the tuner still works, but tuning one reports error.UnsupportedTuner. Between them these cover a small and shrinking fraction of the dongles in circulation; the R82xx family covers nearly everything sold since about 2013.
  • Linux only. The USB layer is written against usbfs. Porting it to another system means writing another backend, not changing the driver.
  • Only the R820T has been exercised on real hardware. The R828D and Blog V4 paths are checked against librtlsdr's own register traffic but have not met a dongle.
  • The other tools — rtl_tcp, rtl_fm, rtl_power, rtl_adsb, rtl_eeprom, rtl_biast — are not ported.

Getting the source

The repository has two homes, and they carry the same history.

The everyday one is a Forgejo instance:

git clone ssh://git@git.ocjtech.us/jeff/zig-rtlsdr.git

It is also published on Radicle, a peer-to-peer code forge built on git, where a repository is found by its Repository ID (RID) rather than by a server name:

rad:zaCxfhQ2rD1ndycPckLeSVWxZNU3

If you do not have Radicle yet, install it, create an identity, and start your node:

curl -sSf https://radicle.dev/install | sh
rad auth
rad node start

Then clone the repository:

rad clone rad:zaCxfhQ2rD1ndycPckLeSVWxZNU3

rad clone consults your node's routing table to find seeds that carry the repository, and drops the working copy into a zig-rtlsdr directory. Pass a target path as a second argument to put it somewhere else. If your node has not discovered a seed yet, name one directly with --seed <NID>.

The result is an ordinary git working copy on the main branch, with a rad remote wired up, so everyday work is just git. To pull in changes published since your clone:

rad sync --fetch
git pull rad main

Building

zig build            # the two tools, into zig-out/bin
zig build test       # the test suite
zig build xcheck     # compile librtlsdr and check both agree
zig build fuzz       # fuzz the arithmetic and the driver
zig build bench      # the benchmarks (they need a dongle)
zig build docs       # API documentation into zig-out/docs
zig build docs-serve # and read it at http://127.0.0.1:8000

There is a Nix flake; nix develop gives a shell with the right Zig and the tools the checks want.

Using it

const rtlsdr = @import("rtlsdr");

var dev = try rtlsdr.Device.open(gpa, io, 0, .{});
defer dev.close(io);

try dev.setSampleRate(io, 2_048_000);
try dev.setCenterFreq(io, 100_000_000);
try dev.setTunerGainMode(io, .auto);
try dev.resetBuffer(io);

var buffer: [16384]u8 = undefined;
const n = try dev.readSync(io, &buffer);

For a stream that does not drop samples, use readAsync, which keeps fifteen bulk transfers queued and calls back for each one as it completes.

Every call takes an Io. Nothing in the library reaches for an implementation of its own.

Permissions

A dongle's node under /dev/bus/usb belongs to root, so the tools report error.AccessDenied until udev is told otherwise. rtl-sdr.rules in this repository grants access to the plugdev group; install it as /etc/udev/rules.d/10-rtl-sdr.rules, reload with udevadm control --reload-rules, and re-plug the dongle.

The kernel's dvb_usb_rtl28xxu driver claims these devices as television receivers. The library detaches it on open and puts it back on close, so blacklisting the module is not necessary — but nothing stops a television application from grabbing the device between the two.

How it was checked

A driver is mostly arithmetic and ordering, and both go wrong quietly. A mis-shifted field yields a receiver that tunes to almost the right place; a register written in the wrong order yields one that works until it does not. Neither is something re-reading the code catches, and with no dongle in the room neither can be caught by running it.

So the port is checked against librtlsdr itself, by compiling librtlsdr and running the two side by side. zig build xcheck does both halves, and the Forgejo workflow runs it on every push.

That check is its own project, in tools/xcheck, and librtlsdr's sources are its dependency rather than this library's. A build script cannot find out which steps were asked for — the build runner resolves those after build has returned — so merely naming a dependency is enough to have every consumer fetch it, and anything that depends on this library runs its build.zig to get the module. Keeping the sources in the sub-project's manifest keeps them out of everyone else's, while still pinning the check to one version of upstream and needing nothing installed.

The arithmetic. The register calculations from librtlsdr.c and tuner_r82xx.c were transcribed into tools/xcheck/ref.c, compiled as written, and run over the same inputs as the Zig that replaced them. All 39,445 results agree: the intermediate frequency registers over four crystals and two thousand frequencies, the parts-per-million correction, the resampler ratios, the filter tap packing, the phase-locked loop's register plans over four crystals and every oscillator fine-tune reading, the channel filter, the tracking filter bands, the gain step walk, and every control transfer setup packet. A sample is checked in as src/test/librtlsdr_vectors.zig so that a later change has to keep agreeing.

The sequences. librtlsdr itself — the real librtlsdr.c and tuner_r82xx.c, unmodified — was linked against a fake libusb backed by a simulated RTL2832U, and driven through a fixed series of API calls with every control transfer logged. This port was driven through the same series against a model built to the same written specification. The two logs are identical, byte for byte, across all three tuner variants:

variant control transfers differences
R820T 1,404 0
R828D 1,087 0
RTL-SDR Blog V4 1,159 0

That covers opening the device, probing the tuner, bringing it up, calibrating the channel filter, tuning across every band, the gain modes, both directions of the automatic direct sampling switch below 24 MHz, the bias tee, the EEPROM, and shutting down.

It found two real defects. setSampleRate was missing the retune that r820t_set_bw performs, so every rate change would have left the demodulator looking for the signal at the old intermediate frequency; and the I2C repeater was being closed in three places where librtlsdr deliberately leaves it open. Neither would have shown up in any amount of reading.

The usbfs layer is checked where it can be: the ioctl numbers and the structure layouts are asserted against the values usbdevice_fs.h expands to, because an ioctl number that is merely plausible fails at run time with EINVAL and nothing else to go on. Device enumeration is exercised against whatever is plugged into the machine running the tests.

One difference is deliberate. The comparison turned up an input where the original reaches undefined behaviour: at the very bottom of the lower sample rate window, on a dongle whose crystal has been declared above nominal, the resampler ratio does not fit its 28-bit register, and the C masks the overflow away and then converts a nonsensical double to an unsigned int. Here that is error.InvalidArgument.

The streaming loop has no equivalent in librtlsdr to be diffed against — usbfs queues transfers where libusb queued them, so the ring, the cancellation and the error accounting are new code. src/test/streaming.zig drives it against a fake kernel: transfers delivered in order, a short transfer, a device that disappears mid-stream, transfers that keep failing until the driver gives up, and a submission the kernel refuses partway through filling the ring. Each one has to come back with every queued transfer accounted for and every buffer freed.

And then a dongle. A generic RTL2832U with an R820T was plugged in and both this and the distribution's rtl_sdr were pointed at the same signals. The captures agree:

this port librtlsdr
100 MHz, 2.048 MS/s, gain 29.7 dB mean 127.37, sd 0.74, 8 levels, 28.8 dB peak mean 127.37, sd 0.73, 8 levels, 28.4 dB peak
433.92 MHz, 2.4 MS/s, gain 40.2 dB mean 127.40, sd 1.85, 20 levels, 34.8 dB peak mean 127.40, sd 1.86, 20 levels, 34.3 dB peak
direct sampling, 7.1 MHz mean 127.07, sd 0.25, 23 dB peak mean 127.06, sd 0.24, 24 dB peak

The strongest carriers in the 100 MHz capture land in the same places for both — 100.800, 100.3, 99.99 MHz — which is to say both are receiving the same FM stations. rtl_test streams for 45 seconds with zero samples lost, and reports the dongle's crystal as about 85 ppm fast, steadily across measurement windows. Sync mode, async mode, 2.048 and 2.4 MS/s, and the kernel driver being detached and put back all work.

Hardware also found a defect that neither cross-check could, because both model a chip that always does as it is told. librtlsdr reports a tune whose phase-locked loop failed to lock as a successr82xx_set_pll returns zero and r82xx_set_freq only complains about negative codes — and the driver depends on that. It asks for frequency zero while the tuner is asleep, once on the way into automatic direct sampling and again on the way out, and a real loop cannot lock on either. Treating that as an error, which is the obvious thing to do when porting, means the frequency is forgotten: coming back out of direct sampling tunes to zero, decides zero is below 24 MHz, and goes straight back in. The receiver sits in direct sampling for ever, handing back a constant. That is exactly what the first capture was, and the failure is now pinned by a test in src/test/tuning.zig — where the simulated tuner is told never to lock.

And fuzzing, for the two places that take input from somewhere other than the programmer. The first is the arithmetic, which takes plain integers from a caller. The second is the device: everything the driver learns about the chip arrives as bytes over USB, and a broken or hostile dongle — anyone can plug one in — chooses every one of them. The Transport seam means the whole driver can be run against a device whose every answer comes from the fuzzer, with no hardware and no I/O at all.

zig build fuzz                                  a few seconds on each target
zig build fuzz -- --target hostile-device --seconds 600
zig build fuzz -- --input .zig-cache/fuzz-current-arithmetic.bin

Zig 0.16 cannot run a coverage-guided fuzz test — nothing populates the table of program counters, so a bounded run dies with "corrupted coverage file" and an unbounded one panics in the build runner — and it cannot even compile one without a one-word fix to its standard library, which the flake applies. So tools/fuzz.zig is a loop of its own, generating structure-aware input through std.testing.Smith. Without coverage feedback there is nothing to say whether a run went anywhere, so the targets count the places worth reaching and the driver prints the tally. That mattered: the first version reported hundreds of thousands of clean runs while never once getting past device bring-up, because Smith hands back a weighted choice's minimum once its input is spent and the harness had "the write failed" as the minimum. The counters are the only reason that was visible.

It found eight ways to bring the library down, every one of them a place where the original C quietly wraps, divides by zero, or converts a nonsensical double to an integer:

  • a crystal scaled to zero by a large frequency correction, then divided by, in both ifFreqRegisters and the phase-locked loop
  • 128 / vco_power_ref - 1 underflowing, and 2 * pll_ref_khz overflowing
  • a resampler ratio whose implied rate does not fit the integer it is put in
  • (freq + 500) and (xtal + 500) overflowing near the top of u32
  • the local oscillator frequency, an intermediate frequency above the wanted one, overflowing at the top of the tuning range
  • tuning below the offset with offset tuning enabled, wrapping the frequency to nearly four gigahertz on its way to the tuner
  • a parts-per-million correction with nothing to bound it, which rtl_sdr -p 2000000000 reached from the command line

All of them are now error.InvalidArgument or a saturating conversion, pinned by tests in src/test/robustness.zig — and reverting any one of them fails those tests. None of it changes the register traffic for anything a real dongle does: the cross-check above still reports byte-for-byte agreement.

Chasing the last of those turned up something no fuzzer could flag, because it requires knowing what the words mean: rtl_sdr printed "Tuned to 4294967295 Hz" for a frequency it had not tuned to. setCenterFreq reports success when the direct sampling state changes as a result of the call, because it is that transition's result that comes back rather than the tune's — librtlsdr does the same. centerFreq is the truth, and the tools now report that instead.

What still is not covered is throughput at the edges, long runs, the tuners that are not ported, and the R828D and Blog V4 paths, which have librtlsdr's own register traffic behind them but no dongle.

Performance

Every vendor request to an RTL2832U costs about 1.9 ms. That is the device, not the driver: the same request takes 1877.9 µs through libusb and 1875.0 µs through this, and a no-op ioctl on the same descriptor takes 0.4 µs. So the transport has nothing to give — the only thing that makes any of this faster is sending fewer requests, and librtlsdr sends a great many it does not need.

Fidelity.fast, the default, takes two shortcuts. It reads the EEPROM sixteen bytes to a request instead of one — the demodulator's I2C bridge auto-increments and will return sixteen, though it stalls at seventeen — and reads only as far as the byte that is actually used when opening, rather than all 256 to look at one. And it skips writes to tuner registers that already hold the value being written, which turns out to be 74% of them during a retune.

Measured on a generic RTL2832U with an R820T (zig build run-bench -- latency):

librtlsdr fidelity fast
open a device 970 ms 426 ms 2.3×
read the whole EEPROM 546 ms 64 ms 8.5×
retune 48.5 ms 23.4 ms 2.1×
change gain 20.6 ms 12.3 ms 1.7×

Skipping writes is only sound if the chip ends up in the same state, so that is checked rather than assumed, three ways. The cross-check verifies that the fast path's writes are a subsequence of librtlsdr's — same writes, same order, some left out — and that both end with the same reported state. A test drives forty retunes and gain changes at both fidelities against a simulated chip and compares every tuner and demodulator register afterwards. And on hardware, zig build run-bench -- ab tunes the same dongle to nine frequencies at both fidelities and compares the received passband:

worst passband deviation: 1.01 dB between runs of the same fidelity
                          1.29 dB between the two fidelities

That is, the difference between the two modes is no bigger than the difference between two runs of the same one. Setting .fidelity = .librtlsdr on Device.open turns both shortcuts off and restores byte-for-byte identical traffic.

Streaming needed no work. At the maximum 3.2 MS/s — 6.4 MB/s — the driver costs 0.29 ms of CPU per megabyte, against 0.58 ms for libusb-based librtlsdr doing the same thing, with no samples lost by either. Both are noise; the ring of bulk transfers is simply not where any time goes, which is why the zero-copy buffers librtlsdr can be built with are not implemented here.

Layout

src/usb/usbdevfs.zig  the Linux usbfs ABI, transcribed from the kernel header
src/usb.zig           enumeration, control and bulk transfers, URB queueing
src/rtlsdr.zig        the RTL2832U driver
src/tuner/r82xx.zig   the R820T/R828D tuner driver
tools/rtl_sdr.zig     an I/Q recorder
tools/rtl_test.zig    a dropped-sample and crystal-error benchmark
src/test/tuning.zig   bring-up and tuning against a simulated chip
src/test/streaming.zig  the transfer ring against a fake kernel
tests/fuzz.zig        fuzz targets, including a device that answers badly
tools/fuzz.zig        the fuzzing loop, since Zig 0.16 has none that works
tools/bench/          benchmarks that need a dongle attached
tools/xcheck/         its own project: librtlsdr, compiled and run alongside
tools/xcheck/seq/     the fake libusb the register sequences run against

Licence

librtlsdr is GPL-2.0-or-later, and a port is a derivative work, so everything ported from it keeps that licence and its original copyright holders: src/rtlsdr.zig, src/tuner/r82xx.zig, the two tools, and the cross-check harness.

The parts that are new — the usbfs USB layer, the build script and the flake — are MIT. The project as a whole is distributed as GPL-2.0-or-later, which is what a combined work of the two comes to.

Every file carries its own SPDX headers and the repository follows REUSE; reuse lint says so.