Zephyr is the DOS-to-Linux moment for embedded

In May I had Zephyr filed under alternatives to watch. By August the whole OGLAS sensor fleet was running on it, including one in-tree board whose LoRa radio had never worked.

I’ve written embedded firmware for a long time, on AVR, ESP32, nRF52 and RP2040, and mostly the default was bare metal or a small superloop. It worked. It was also the DOS era of microcontrollers: you own the whole machine, you write every driver yourself, and scaling up means more #ifdef blocks. FreeRTOS if the project was bigger, 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

In May I wrote myself a note about build systems. PlatformIO was under “Current” and Zephyr was 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. By 16 August, 184 commits later, the whole fleet (sensor, repeater and hub tiers, plus a handheld and a remote control) was running on Zephyr.

Hardware goes in devicetree

This was the first thing that sold me. Hardware goes in devicetree overlays, features go in Kconfig fragments, and each product tier gets its own app directory. The three vary independently, so adding a board or a feature does not multiply the others.

In practice I collapsed a pile of per-role firmware variants into one config-driven image with a runtime node config store. There is one binary, configured per node, where before there was 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. The last two are RISC-V.

The application did not change between instruction sets because the HAL is Zephyr’s and not the vendor’s. I did not expect that to work without effort. Changing silicon mid-project has normally meant a rewrite, and here it was a board file.

CI for firmware

ztest and twister are the parts most people miss. I have twelve test suites that build and run across a board matrix on every push, which is the regression discipline web code has had for years.

It still bit me. 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.

The rest is less visible but adds up. Networking is part of the tree, so BLE, Thread, Wi-Fi and MQTT are maintained subsystems and not vendor code copied into the project. Power management has policy hooks: I gated a GNSS UART between acquisitions and stopped the GPS drawing current between fixes. The build reports enough that I noticed a board at 87.6% DRAM, moved the big arrays into PSRAM, and got it back to 73.2%.

No flag day

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

Being able to deploy the rewrite one node at a time is the reason it got finished.

The potholes

Bringing up a Heltec Wireless Tracker, nothing I wrote to the SX1262 appeared to take. The sync word read back 0x00 0x00 instead of 0x14 0x24, and every transmit timed out.

I worked 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. The status byte came back 0x52, chip mode RX. The radio had been in RX exactly as commanded, so my writes had been landing all along and only the read path was dead.

The cause was that 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 had never worked.

That took two days. I could read the driver, find the fault, fix it and write it up for upstream. On a vendor SDK I would have filed a ticket and waited.

Where the line falls

Kconfig, devicetree and west are a lot to take in on day one. For a 2KB ATtiny or a one-off blinky I still reach for PlatformIO and a superloop, and I’d tell a client the same.

Once a project has more than one bus, more than one transport, or more than one person on it, Zephyr pays back quickly.

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