I'm writing this from a mesh repeater that has no microcontroller in it. It's a Linux box running MeshCore firmware natively, the same C++ that would normally live on an nRF52 or ESP32, compiled and running as an ordinary Linux process, talking to a real SPI radio. The thing that makes that possible is a small library called ArduLinux, and this post is the story of why it exists.
The idea: the Arduino API, as a Linux library
Embedded firmware is written against the Arduino API, digitalWrite(), SPI.transfer(), Serial.printf(), Wire.begin(), and friends. ArduLinux implements that API as a Linux user-space library, so firmware written for microcontrollers builds and runs on a PC or a Raspberry Pi unmodified. It wires the Arduino calls to real hardware, GPIO via libgpiod, SPI via spidev, I2C via i2c-dev, Serial via POSIX file descriptors, or to fully simulated devices when you just want to run in CI.
Using it is one block of platformio.ini:
[env:ardulinux]
platform = git+https://github.com/l5yth/ardulinux.git
framework = arduino
board = ardulinux
That's it. Your setup() and loop() run on Linux.
Standing on portduino's shoulders
None of this is a new idea. ArduLinux is a clean-room continuation of portduino, the project (from the Meshtastic world, Kevin Hester, Jonathan Bennett, and many others) that pioneered running Arduino firmware on Linux. Portduino did the genuinely hard part: proving the approach works and carrying it for years. I want to be clear up front, this is a continuation written in gratitude, not a complaint.
But when I went to use portduino as a dependency for a fresh project, I kept tripping over the same thing: it was held together with patches.
The monkey-patch problem
Here's the concrete example that started it. The upstream ArduinoCore-API Print class gives you print() and println(), but no printf(). Tons of firmware (MeshCore included) calls Serial.printf(...). Upstream hasn't added it. So you have to shim it in somewhere.
The way that shim had grown was, roughly:
# Copy the ENTIRE upstream API tree, minus Print.h...
shutil.copytree(upstream_api, "patched_api", ignore=ignore_patterns("Print.h"))
# ...then drop in our own hand-edited Print.h that adds printf().
write_patched_print_h("patched_api/Print.h")
Look at what that actually does: to add one method, you make a full, divergent copy of the upstream library at build time. And it wasn't only Print.h, the dependencies themselves (ArduinoCore-API, the WiFi library) were vendored as copied-and-patched source trees rather than tracked upstream.
That pattern has three nasty consequences:
- You can't follow upstream. The pinned ArduinoCore-API had fallen ~180 commits behind, and bumping it meant re-doing the patches by hand. So nobody bumped it.
- The copy silently goes stale. The cached copy keyed on a path, not a version, so after a bump it could happily keep serving the old copy, and you'd never know.
- Bugs hide in the machinery. When the build is this baroque, latent bugs go unnoticed. (More on the two I found below.)
The general principle, and it applies to every monkey-patched library, not just this one: the moment you copy-and-edit a dependency, you've silently forked it, and given up the one thing a dependency is supposed to give you, which is the ability to pull upstream's fixes.
Why not just fix it inside portduino?
Fair question, and I tried to convince myself it was the right move. It wasn't, for two reasons.
It's structural, not cosmetic. The vendoring and the copytree shim weren't a bad function you could rewrite over a weekend; they were baked into the architecture, the package layout, the builder, the dependency model. Undoing them properly means re-laying the foundation. That's not a patch, it's a different building.
portduino is load-bearing for other people. It's a live dependency of Meshtastic. A sweeping "rip out the vendoring and rewrite the build" change against a tree that thousands of devices depend on is exactly the kind of disruptive churn you shouldn't push onto a working project to suit your own narrower goals.
So I did the open-source thing: fork, clean up, and present. A continuation lets you re-establish the invariant, depend on upstream, unmodified, from a clean slate, without destabilizing anyone who's happy with what they have. If it's useful to others, wonderful. If not, portduino is right where it was.
What changed in ArduLinux
The whole renovation is in service of one rule: clean upstream dependencies, and isolate the single unavoidable shim.
- Dependencies are real git submodules, tracked at their upstream commits, not copied, not patched. Bumping ArduinoCore-API from 1.1.0 to 1.5.2 became a one-line gitlink change instead of a manual re-patching session.
-
The
printfshim is now a symlink overlay. Instead of copying the whole API and stripping a file, every upstream header is symlinked as-is, and we shadow exactly one file,Print.h, re-synced verbatim from upstream with theprintf()method added. No copy, nothing to drift.
# Symlink every upstream header as-is, the real files, never copied...
for name in upstream_headers:
os.symlink(upstream / name, overlay / name)
# ...and shadow ONLY Print.h: the one minimal, honest shim.
- Dead code and clutter removed, out-of-line duplicates, unused board variants, IDE project files.
And the payoff that justifies the whole exercise: with the build no longer hiding things, two real bugs surfaced and got fixed.
-
#71 An I2C handle defaulted to file descriptor
0, stdin, so a read beforebegin()would silently block on the terminal forever. (It now defaults to-1and fails fast, like the serial port already did.) -
#72 The
printfshim trustedvsnprintf's return value, which is the length it would have written, a classic buffer over-read on long strings. (Now clamped; verified under AddressSanitizer.)
Neither was introduced by the cleanup; both were lurking. Simpler machinery just made them visible.
Room to add new things
Tidying the dependency story is only half of why the fork was worth it. The other half: once you own the runtime outright, you can make it behave like a proper Linux program.
-
Binary customization. An app sets its own name, version, description, and bug-report address through a few optional weak symbols, no header required. It then introduces itself in its startup banner and
--version, names its data directory after itself, and labels its libgpiod lines with it.meshcoredshows up asmeshcored, not a genericprogram. -
XDG-compliant data directories. The virtual filesystem lives where a well-behaved Linux app keeps state,
$XDG_DATA_HOME/<app>/default(so~/.local/share/meshcored/...), instead of scattering dotfiles across$HOME. -
A real command line.
--fsdirto relocate that state,--eraseto wipe it on startup, plus the usual--version,--help, and--usage.
And the list keeps growing, because now there's somewhere natural to put it. So I'd gently resist the idea that this was only about chasing upstream versions: tracking upstream cleanly is the discipline that made the rest possible, but the visible, day-to-day payoff is a binary that starts up, stores data, and takes its flags like every other tool on the box.
The proof
The real test wasn't the unit suite, it was whether a downstream project could actually consume this. So I pointed my MeshCore-Linux branch at the first release tag v0.2.0 as a pinned dependency and rebuilt.
It worked out of the box. I'm running a Linux-native MeshCore repeater on it right now, no complaints. That's the moment the whole "clean dependencies" thesis stopped being a principle and became a working radio on my desk.
Try it
ArduLinux is on the PlatformIO registry and GitHub, LGPL-2.1, built on the work of the Arduino project, the portduino authors, and everyone who came before this split. If you've got embedded C++ you'd like to run, test, or debug on a real Linux machine, with gdb, with sanitizers, in CI, on actual GPIO, give it a spin:
git clone --recurse-submodules https://github.com/l5yth/ardulinux.git
cmake -B build && cmake --build build
./build/ArduLinux --help
That's the spirit of open source: you fork, you clean up, you present, and you see if anyone else finds it useful. Maybe you will.
Top comments (0)