librtlsdr.so for fun and profit
Paul Tagliamonte 2026-03-27 projecthz.tools will be tagged
#hztools.It’s well known and universally agreed that radios are cool. Among the
contested field of coolest radios, Software Defined Radios (SDRs) are
definitely the most interesting to me. Out of all of my (entirely too many)
SDRs I own, the rtlsdr is still my #1. It’s just good. It’s a great price,
extremely capable, reliable, well-supported, and compact. Why bother with
anything else? Sure, it can’t transmit, uses a (fairly weird) 8 bit unsigned
integer IQ representation,
limited sampling rate, limited frequency range – but even with all that, it’s
still the radio I will pack first. Don’t get me wrong, I love my Ettus radios,
PlutoSDRs, HackRFs, my AirspyHF+ - they’re great! I just always find myself
falling back to an rtl-sdr, every time.
Perhaps the best reason to use an rtlsdr is the absolutely mind-boggling
amount of cool stuff people have written for it. The rtlsdr API is super easy
to use, widely supported if you’re building on top of existing radio processing
frameworks – it’s still a shock to me when something omits rtlsdr support.
sparky
Over the last 7 years, I’ve been learning about radios – I got my ham radio
license (de K3XEC), hacked
on some
cool stuff where I’ve
learned how radios work by “doing”, and even was lucky enough to give my first
rf-centric talk at districtcon.
Embarrassingly, I still haven’t gotten around to learning how the fancy stuff
like GNU Radio works. I’m sure I’m going to love
it when I do.
As part of this, I’ve also cooked up some very unprofessional formats and
protocols I use for convenience. Locally, all my on-disk captures are stored in
rfcap or more recently arf,
while direct SDR access at my house is almost entirely a mix of
the widely used rtl-tcp protocol, and my
“riq” protocol (post on this coming soon). Both rtl-tcp and riq operate
over the network, so I don’t have to bother with plugging things into USB ports,
and I can share my radios with my friends.
All of that work sits in my current generation of radio processing code,
“sparky” (a reference to
spark-gap transmitters),
which is a heap of Rust, supporting everything from no_std for embedded
experiments, conditional support for interfacing with all the radios I
own, and tokio-based async support in addition to blocking i/o
for highly concurrent daemons. This quickly advanced beyond my old Go-based
code (hz.tools/go-sdr), which I archived
so I can focus on learning. I still think Go is a great language to write RF
code in – but I can’t focus on that tech tree anymore.
Of course, this now poses a new problem – no one supports my format(s) or radio protocol(s), since, well, I’m the only one using them. I’ve committed a fair amount of my hardware to this setup, and yanking it from the rack to try something out does pose a bit of a pickle. This isn’t a huge deal for learning, but it does make it tedious to try out something from the internets.
librtlsdr.so
Thankfully, Rust has robust support for
wrap[ping itself] in a grotesque simulacra of C’s skin and mak[ing its] flesh undulate,
which is an attractive nuisance if i’ve ever seen one. Naturally, my ability
to restrain myself from engaging in ill-advised rf adventures is basically
zero, so it’s time to do the thing any similarly situated person would do –
reimplement the API and ABI of librtlsdr.so, backed with sparky instead.
Since enumeration of devices is going to be annoying (specifically, they’re over the network), I decided early-on to rely on an explicit list of devices via a configuration file. I’d rather only load that once so programs don’t get confused, so I opted to use a CTOR to run a stub when the ELF is linked at runtime.
// lightly edited for clarity
#[used]
#[expect(unused)]
#[unsafe(link_section = ".init_array")]
pub static INITIALIZE: extern "C" fn() = sparky_rtlsdr_ctor;
#[unsafe(no_mangle)]
pub extern "C" fn sparky_rtlsdr_ctor() {
let config: Config = {
if let Ok(config_bytes) = std::fs::read("/etc/sparky-rtlsdr.toml") {
toml::from_slice(&config_bytes).unwrap()
} else {
Config { device: vec![] }
}
};
CONFIG.set(config);
}
Next, it’s time to start with the basics. Opening and closing a handle using
rtlsdr_open and rtlsdr_close. Given we don’t control the runtime, and the
rtl-sdr device handle is opaque (for good reason!), I opted to smuggle a rust
Box<Device> non-FFI safe heap-allocated struct through the device handle
pointer, and let C take ownership of the Box. No one should be looking in
there anyway.
// lightly edited for clarity
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rtlsdr_open(dev: *mut *mut Handle, index: u32) -> int {
let config = &CONFIG.device[index as usize];
let sdr = match config.load() {
Ok(v) => v,
Err(err) => {
return -1;
}
};
let handle = Box::new(Handle { config, sdr });
unsafe { *dev = Box::into_raw(handle) };
0
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rtlsdr_close(dev: *mut Handle) -> int {
let dev = unsafe { Box::from_raw(dev) };
drop(dev);
0
}
With that in place, we can chip away at the API surface, translating calls
as best as we can. I won’t bother listing it all, since it’s not very
interesting – but here’s an example implementation of rtlsdr_set_sample_rate
and rtlsdr_get_sample_rate. These calls are translating from an rtl-sdr
frequency (which is a u32 containing the value as Hz) into a sparky Frequency
type, and invoking get_sample_rate or set_sample_rate on the device’s
rust handle. Since each device implements the sparky Sdr trait, the actual
underlying device doesn’t matter much here.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rtlsdr_set_sample_rate(dev: *mut Handle, rate: u32) -> int {
let dev = unsafe { &mut *dev };
let rate = Frequency::from_hz(rate as i64);
if let Err(err) = dev.sdr.set_sample_rate(dev.channel, rate) {
return -1;
}
0
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn rtlsdr_get_sample_rate(dev: *mut Handle) -> u32 {
let dev = unsafe { &mut *dev };
let freq = match dev.sdr.get_sample_rate(dev.channel) {
Ok(freq) => freq,
Err(err) => {
return 0;
}
};
freq.as_hz() as u32
}
After repeating this process for the rest of the stubs I could (and otherwise
setting error conditions if the functionality is not supported), I was ready to
try it out. Within sparky, I patched my “MockSDR” (basically a Sdr traited
Mock type) to implement the same testmode IQ protocol that the RTL-SDR has, and
decided to see if rtl_test from apt without any changes could be fooled.
$ rtl_test
No supported devices found.
Great, cool. No devices plugged in. Looks great. Let’s try it with my
librtlsdr.so LD_PRELOAD-ed into the binary first:
$ LD_PRELOAD=target/release/librtlsdr.so rtl_test
Found 1 device(s):
0: hz.tools, mock sdr, SN: totally legit no tricks
Using device 0: sparky mock sdr
Supported gain values (0):
Sampling at 2048000 S/s.
Info: This tool will continuously read from the device, and report if
samples get lost. If you observe no further output, everything is fine.
Reading samples in async mode...
^CSignal caught, exiting!
User cancel, exiting...
Samples per million lost (minimum): 0
$
Outstanding. Even more outstandingly, if I change my testmode implementation to
skip samples, rtl_test correctly reports the errors – I think it’s showing
promise! On to try the real endgame here – let’s have our new librtlsdr.so
connect to an rtl-tcp endpoint and see if rtl_fm works:
LD_PRELOAD=target/release/librtlsdr.so \
rtl_fm -d 1 -s 120k -E deemp -M fm -f 90.9M | \
ffplay -f s16le -ar 120k -i -
Found 2 device(s):
0: hz.tools, mock sdr, SN: totally legit no tricks
1: hz.tools, rtl-tcp, SN: node2.rf.lan:1202
Using device 1: sparky rtltcp node2
Tuner gain set to automatic.
Tuned to 91170000 Hz.
Oversampling input by: 9x.
Oversampling output by: 1x.
Buffer size: 7.59ms
Sampling at 1080000 S/s.
Output at 120000 Hz.
And there it was! Not the best audio quality (mostly due to my inability to
correctly read the rtl_fm manpage to tune the filter and
downsample/oversampling rates to audio), but it’s definitely passable.
I figured I’d try something that was a bit more interesting next – gqrx,
since it’s super handy, I use it a ton, and will definitely amuse me to no
end. To my surprise and delight, LD_PRELOAD=target/release/librtlsdr.so gqrx
wound up running, and I saw my devices pop right up in the setting menu:

Huge. Huge. Amazing. It did crash as soon as I tried to actually use the
radio, but after fixing a few dangling bugs in the API surface (and some
assumptions I think some underlying gnuradio driver may be making that I need
to double check in the code), I was able to get a super solid stream of
broadcast fm radio, with gqrx being none the wiser. It thought it was
“just” talking to the device it knows as rtl=1.

Nice. I can’t wait to try this with the rest of the rtl-sdr based tools I like
having around using my riq protocol next. I don’t think that’ll be worth a
post, but hopefully I’ll get around to publishing details on that stack next.
epilogue
Well. That’s it. End of story. A bit anti-climatic, sure. While this new shim
will provide me endless minutes of mild amusement, I could see using this to
expose my sparky testing utilities via librtlsdr.so – my “mock sdr” driver
allows for replaying captures off disk, which could be interesting to make sure
that signals are still properly decoded after changes, or instrument
performance changes (via SNR, BER, packets observed, etc) on reference samples
I have on my NAS. Maybe that’ll come in handy one day!
Truth be told, I’m not sure I actually want to encourage anyone to do this for
real (although I think I’ll definitely be using it on my LAN to see what
happens). I also don’t have a repo to share – I don’t particularly feel with
dealing with the secondary effects of publishing sparky (and sparky-rtlsdr)
yet, since i’m still getting my feet under me on the radio aspect of all this.
I’ll be sure to post updates if anything changes with this here (tagged sparky) and at @paul@soylent.green. I can’t wait to post more about some of the odd sidequests (like this one!) i’ve completed over the last few years – I’ve been waiting to feel confident that my work has matured and was withstood the new problems i’ve thrown at it, and it largely has.
It’s my hope that these projects (and this project in particular) has provided a glimpse into the world of software defined radio for my systems friends, and a bit about systems for my radio friends. It’s not all magic, and I hope someone out there feels inclined to have some fun with radios themselves!