Zephyr is the DOS-to-Linux moment for embedded

I filed Zephyr under “alternatives watched” in May. Twenty-four days and 184 commits later the whole OGLAS sensor fleet runs on it — including one in-tree board whose LoRa radio could never have worked.

I’ve written embedded firmware for a long time — AVR, ESP32, nRF52, RP2040 — and mostly the default was bare metal or a tiny superloop. It worked. It was also, honestly, the DOS era of microcontrollers: you own the whole machine, you reinvent every driver, and “scaling up” means more #ifdef soup. FreeRTOS if you were feeling fancy, but the same shape.

Zephyr is the move to Linux. It brings what Linux got right down to a 64KB MCU.

I was late to this

I have the receipts. In May I wrote myself a note about build systems: PlatformIO under “Current”, Zephyr filed under “Alternatives watched” with the line “worth it if I move heavier on nRF.” The same note listed the trigger that would change my mind — “a specific project’s complexity outgrows PlatformIO’s project model.”

Then exactly that happened. I started the second-generation OGLAS repo on 23 July. Twenty-four days and 184 commits later, the whole sensor fleet — sensor, repeater and hub tiers, plus a handheld and a remote control — runs on Zephyr.

Devicetree, not #ifdef soup

This was the first thing that sold me. Hardware goes in devicetree overlays, features in Kconfig fragments, product tier in its own app directory. Three orthogonal axes, and variation stops turning into soup.

Concretely: I collapsed a pile of per-role firmware variants into one config-driven image with a runtime node config store. One binary, configured per node, instead of a build matrix that grew every time a customer wanted something slightly different.

The same app on ARM and RISC-V

The same OGLAS sensor code runs on nRF52840 (RAK4631, XIAO BLE, T-Echo Lite), ESP32-S3 (T3-S3, Heltec Wireless Tracker), ESP32-C6 and ESP32-C3. That last pair is RISC-V, not ARM.

Same application, different instruction set, because the HAL is Zephyr’s rather than the vendor’s. I did not expect that to just work. Changing silicon mid-project is normally a rewrite; here it was a board file.

CI for firmware

ztest and twister are the parts most people miss. Twelve test suites that build and run across a board matrix on every push — firmware gets the same regression discipline web code has had for years.

It still bit me, mind you. The platform list in CI has to name every board a sample.yaml mentions, or those tests skip silently rather than failing. That is how two of my apps went unbuilt for a week without anyone noticing. Silent skips are worse than red builds.

The rest is quieter but adds up. Networking ships in the box, so BLE, Thread, Wi-Fi and MQTT are maintained subsystems rather than copy-pasted vendor blobs. Power management has real policy hooks — I PM-gated a GNSS UART between acquisitions and stopped the GPS burning current between fixes. And there’s enough visibility into the build to notice a board sitting at 87.6% DRAM, move the big arrays into PSRAM, and get it back to 73.2%.

No flag day

The part I’m most pleased with isn’t a feature.

The interop contract for OGLAS is the wire protocol, not the code: a Zephyr node has to be byte-identical over the air to a legacy Arduino one. The binary framing and radio parameters are frozen invariants, so old and new firmware coexist in the field. There was no migration weekend, no fleet-wide reflash, no moment where the whole network had to be on the new thing at once.

That matters more than any single Zephyr feature. A rewrite you can deploy one node at a time is a rewrite you can actually finish.

The potholes are real

Bringing up a Heltec Wireless Tracker, the SX1262 ignored everything I sent it. The sync word read back 0x00 0x00 instead of 0x14 0x24, and every transmit timed out.

I worked down through the obvious suspects — software chip-select, reclaiming the CS pin from its pinmux, a push-pull reset, dropping SPI from 4 MHz to 1 MHz — and got nowhere. So I bit-banged a GetStatus on the same pins with plain GPIO, bypassing the driver entirely. It answered 0x52: chip mode RX. The radio had been in RX exactly as commanded. My writes had been landing all along; only the read path was dead.

Root cause: the MISO pad’s input buffer was never enabled (FUN_IE = 0 in IO_MUX), so the GPIO matrix was reading a constant zero. Boards where MISO happens to land on a strapping pin work by accident, because the boot ROM enables the input buffer for them — which is presumably why this had gone unnoticed. Between that and a board-file defect, that in-tree board’s LoRa radio could never have worked.

Two days of my life. But here’s the thing: I could read the driver, find it, fix it, and write it up for upstream. On a vendor SDK I’d have filed a ticket and waited.

Where the line falls

Kconfig, devicetree and west are a lot to swallow on day one. It’s overkill for a 2KB ATtiny or a one-off blinky — there I still reach for PlatformIO and a superloop, and I’d tell a client the same.

But the moment a project has more than one bus, more than one transport, or more than one person on it, Zephyr pays back fast. I resisted it for years and then did it in under a month.

If you’re weighing the same jump, the Zephyr work I take on covers firmware architecture, driver work, LoRa and mesh, low-power design, and getting a team onto Zephyr without the first-month pain. Happy to talk through where the line falls for your hardware — get in touch.