- Zig 90.9%
- C 8.2%
- Nix 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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 |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| tests | ||
| tools | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
| rtl-sdr.rules | ||
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_sdrandrtl_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 success — r82xx_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
ifFreqRegistersand the phase-locked loop 128 / vco_power_ref - 1underflowing, and2 * pll_ref_khzoverflowing- a resampler ratio whose implied rate does not fit the integer it is put in
(freq + 500)and(xtal + 500)overflowing near the top ofu32- 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 2000000000reached 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.