{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreifopr5y3yep3w6zzipm6xw4tbs6e4y7vl3xcstoqe7drtg5gzoage",
"uri": "at://did:plc:46ti67tc37qcmwp2vaynk6fq/app.bsky.feed.post/3mi2orp7tjap2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreickut43vkhzvthkapd4sbfbvghp43itvcedmgk7j3gzcatoik6nl4"
},
"mimeType": "image/png",
"size": 1079134
},
"path": "/sparky-rtlsdr/",
"publishedAt": "2026-03-27T17:53:55.989Z",
"site": "https://k3xec.com",
"tags": [
"@paul@soylent.green",
"hztools",
"8 bit unsigned\ninteger IQ representation",
"hacked",
"on",
"some",
"cool",
"stuff",
"talk at districtcon",
"GNU Radio",
"rfcap",
"rtl-tcp",
"my friends",
"spark-gap transmitters",
"hz.tools/go-sdr",
"wrap[ping itself] in a grotesque simulacra of C’s skin and mak[ing its] flesh undulate",
"CTOR",
"sparky"
],
"textContent": "Interested in future updates? Follow me on mastodon at @paul@soylent.green. Posts about `hz.tools` will be tagged #hztools.\n\n\nIt’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\ninteger 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.\n\nPerhaps 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.\n\n# sparky\n\nOver 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.\n\nAs 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` (post on this coming soon), while direct SDR access at my house is almost entirely a mix of the GOAT and 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.\n\nAll 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, up to conditional support for interfacing with all the radios I own all the way to `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.\n\nOf 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.\n\n# librtlsdr.so\n\nThankfully, 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.\n\nSince 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.\n\n\n // lightly edited for clarity\n\n #[used]\n #[expect(unused)]\n #[unsafe(link_section = \".init_array\")]\n pub static INITIALIZE: extern \"C\" fn() = sparky_rtlsdr_ctor;\n\n #[unsafe(no_mangle)]\n pub extern \"C\" fn sparky_rtlsdr_ctor() {\n let config: Config = {\n if let Ok(config_bytes) = std::fs::read(\"/etc/sparky-rtlsdr.toml\") {\n toml::from_slice(&config_bytes).unwrap()\n } else {\n Config { device: vec![] }\n }\n };\n CONFIG.set(config);\n }\n\n\nNext, 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.\n\n\n // lightly edited for clarity\n\n #[unsafe(no_mangle)]\n pub unsafe extern \"C\" fn rtlsdr_open(dev: *mut *mut Handle, index: u32) -> int {\n let config = &CONFIG.device[index as usize];\n let sdr = match config.load() {\n Ok(v) => v,\n Err(err) => {\n return -1;\n }\n };\n let handle = Box::new(Handle { config, sdr });\n unsafe { *dev = Box::into_raw(handle) };\n 0\n }\n\n #[unsafe(no_mangle)]\n pub unsafe extern \"C\" fn rtlsdr_close(dev: *mut Handle) -> int {\n let dev = unsafe { Box::from_raw(dev) };\n drop(dev);\n 0\n }\n\n\nWith 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.\n\n\n #[unsafe(no_mangle)]\n pub unsafe extern \"C\" fn rtlsdr_set_sample_rate(dev: *mut Handle, rate: u32) -> int {\n let dev = unsafe { &mut *dev };\n let rate = Frequency::from_hz(rate as i64);\n if let Err(err) = dev.sdr.set_sample_rate(dev.channel, rate) {\n return -1;\n }\n 0\n }\n\n #[unsafe(no_mangle)]\n pub unsafe extern \"C\" fn rtlsdr_get_sample_rate(dev: *mut Handle) -> u32 {\n let dev = unsafe { &mut *dev };\n let freq = match dev.sdr.get_sample_rate(dev.channel) {\n Ok(freq) => freq,\n Err(err) => {\n return 0;\n }\n };\n freq.as_hz() as u32\n }\n\n\nAfter 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.\n\n\n $ rtl_test\n No supported devices found.\n\n\nGreat, cool. No devices plugged in. Looks great. Let’s try it with my `librtlsdr.so` `LD_PRELOAD`-ed into the binary first:\n\n\n $ LD_PRELOAD=target/release/librtlsdr.so rtl_test\n Found 1 device(s):\n 0: hz.tools, mock sdr, SN: totally legit no tricks\n\n Using device 0: sparky mock sdr\n Supported gain values (0):\n Sampling at 2048000 S/s.\n\n Info: This tool will continuously read from the device, and report if\n samples get lost. If you observe no further output, everything is fine.\n\n Reading samples in async mode...\n ^CSignal caught, exiting!\n\n User cancel, exiting...\n Samples per million lost (minimum): 0\n $\n\n\nOutstanding. 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:\n\n\n LD_PRELOAD=target/release/librtlsdr.so \\\n rtl_fm -d 1 -s 120k -E deemp -M fm -f 90.9M | \\\n ffplay -f s16le -ar 120k -i -\n Found 2 device(s):\n 0: hz.tools, mock sdr, SN: totally legit no tricks\n 1: hz.tools, rtl-tcp, SN: node2.rf.lan:1202\n\n Using device 1: sparky rtltcp node2\n Tuner gain set to automatic.\n Tuned to 91170000 Hz.\n Oversampling input by: 9x.\n Oversampling output by: 1x.\n Buffer size: 7.59ms\n Sampling at 1080000 S/s.\n Output at 120000 Hz.\n\n\nAnd 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:\n\nHuge. 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`.\n\nNice. 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.\n\n# epilogue\n\nWell. 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!\n\nTruth 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.\n\nI’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.\n\nIt’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!",
"title": "Paul Tagliamonte: librtlsdr.so for fun and profit",
"updatedAt": "2026-03-27T17:30:00.000Z"
}