Arduino, Mbed, Zephyr, and IOsonata compared — and why a simple bus abstraction turns new-board work from O(N) into something closer to O(1).
You are spinning a new PCB on the same MCU family. A couple of pins move; one sensor hops from I²C to SPI. The application logic is unchanged. How hard should this be — and why does it sometimes turn into a half-day ordeal?
It depends far more on your firmware architecture than on your IDE or team size. A lot of embedded developers look at Zephyr’s huge board list and assume portability has been solved. It has not. What Zephyr has solved is something different: it has built a large, framework-managed ecosystem around boards, configuration metadata, and generated build logic. That is why it can support a lot of hardware — and also why so many users experience so much friction.
But before going further into Zephyr, it is worth understanding what board-centric actually means, because not all board-centric designs are the same.
Board-Centric Is Not Automatically Bad
Arduino is also board-centric. And Arduino shows that board-centric does not have to mean high-friction.
Arduino’s platform model is simple. Its official platform specification describes each architecture through a handful of files: platform.txt, boards.txt, and programmers.txt. The board itself is described by key-value properties. For the Uno, that is essentially uno.build.mcu=atmega328p, uno.build.f_cpu=16000000L, uno.build.core=arduino, and a variant. Arduino even documents that the minimum files needed to define new hardware can, in some cases, be reduced to just boards.txt.
That is still board-centric — but it is thin, explicit, and easy to reason about. The metadata surface is small. The board selection changes a few build properties, and that is it. There is no separate devicetree binding layer, no generated macro API for device discovery, no west workspace model, and no sysbuild hierarchy sitting on top of the base build.
Arduino is simple board-centric. Zephyr is not.
What Board-Centric Means in Zephyr
Board-centric and MCU-centric are not just two ways to organize files. They are two different ideas about where variability should live.
In a board-centric design, the build knows boards as first-class objects. Board files decide what hardware exists, what drivers are enabled, how flashing works, and how buses are wired into the system model. In an MCU-centric design, the stable unit is the silicon. Once the MCU family is supported, a new board is mostly pin mapping, clocks, and external device hookup. The build does not need to “learn” every board as a new entity. That sounds subtle, but it changes everything when you start scaling across product variants.
Zephyr’s user-facing contract is the board. You build with west build -b <board>. West is Zephyr’s own meta-tool for workspace and command orchestration. Sysbuild is a higher-level build layer that can combine multiple Zephyr and non-Zephyr builds. From the user’s point of view, hardware description, software enablement, repository management, and multi-image orchestration are all tied together.
The board porting guide makes the design explicit. A Zephyr board directory is not just a pin map. The documented structure includes board.yml, board.cmake, CMakeLists.txt, Kconfig.plank, Kconfig.defconfig, board defconfig fragments, DTS files, YAML, and documentation assets. Zephyr also requires board.cmake to configure flash and debug runner integration for west flash and west debug. A board in Zephyr is a build object, not just a hardware description.
Difficulty Scorecard: A “Pin Moved + I²C↔SPI” Change
Experience-based scores for a typical pin-move plus bus-swap on nRF/STM32. Lower = easier. Effort is typical time for an experienced developer.
| Framework | Artifacts | Locality | Cognitive | Generators | Feedback | App impact | Effort |
|---|---|---|---|---|---|---|---|
| Arduino (variants) | 3 | 3 | 2 | 1 | 3 | 4 | 30–60 min |
| Mbed OS (targets) | 4 | 3 | 3 | 3 | 3 | 3 | 1–2 h |
| Zephyr (DT/Kconfig) | 5 | 4 | 5 | 5 | 4 | 3–4 | 2.5–5 h |
| MCU-Centric (IOsonata) | 1 | 1 | 1–2 | 1 | 1–2 | 1 | 15–20 min |
Criteria — Artifacts: files/kinds to edit. Locality: one place vs many layers. Cognitive: schemas/bindings/macros to understand. Generators: code/config generation steps. Feedback: do errors point to the edit site? App impact: does a bus swap change driver or application code?
Why DTS and CMake Are the Main Sources of Friction
Zephyr’s documentation states that Devicetree “mainly deals with hardware” while Kconfig deals with software. That sounds clean on paper. In practice it means the developer must understand where hardware description ends, where software selection begins, and how those two layers interact through generated code and build rules. Zephyr’s C/C++ Devicetree API is macro-based, and the docs explicitly warn that Zephyr does not use runtime DT parsing the way Linux does — instead it generates a C header and exposes everything through a macro API at build time.
That design is not just a syntax choice. It is why so much bring-up work turns into “find the right node, binding, alias, chosen entry, Kconfig symbol, overlay, generated header output, and CMake path.” The build owns too much of the topology.
This is not just outsider criticism. The friction shows up inside Zephyr’s own ecosystem. A Zephyr collaborator wrote in a 2024 discussion that “there is unnecessary complexity put to the user” in the dt_spec usage model. A long-running CMake issue complains that Zephyr wraps standard CMake commands in its own API, forcing users to learn Zephyr-specific interfaces on top of normal CMake.
Independent engineers report the same experience. Memfault’s Practical Zephyr series exists largely because the author found the learning curve overwhelming and had to chase link after link to understand west, Kconfig, and devicetree well enough to build simple applications. The series notes explicitly that devicetree syntax and usage are more intricate than Kconfig and require their own deep dive.
The pattern on Reddit is even more direct. Users describe .dts files as a “maze of dependencies,” Zephyr as “80% configuration and 20% coding,” and its abstraction layers as “extreme” compared with doing the same job through simpler headers, linker files, and conventional build tools.
And at Open Source Summit Europe 2025, a Zephyr-related talk on devicetree complexity asked the audience whether Zephyr’s devicetree was too complicated — roughly half the room raised their hand. That is a strong signal that the problem is widely recognized even inside the community.
The Missing Piece: Bus-Agnostic Drivers via DeviceIntrf
The reason MCU-centric scales is not just “board.h vs DTS.” It is that drivers do not know or care about the bus — or the MCU.
IOsonata’s core is a C vtable (DevIntrf_t) with a C++ wrapper class DeviceIntrf. Driver code uses Read/Write only. The bus implementation (I²C/SPI/UART) is injected at initialization — the driver never sees it directly.
Core abstraction
class DeviceIntrf {
public:
// High-level helpers that drivers use:
virtual int Read(uint32_t DevSel,
const uint8_t* pAdCmd, int AdCmdLen,
uint8_t* pBuff, int BuffLen) = 0;
virtual int Write(uint32_t DevSel,
const uint8_t* pAdCmd, int AdCmdLen,
const uint8_t* pData, int DataLen) = 0;
virtual int Rx(uint32_t DevSel, uint8_t* pBuff, int BuffLen) = 0;
virtual int Tx(uint32_t DevSel, const uint8_t* pData, int DataLen) = 0;
virtual ~DeviceIntrf() = default;
};
BME280 driver — written once, bus-agnostic
class TphBme280 {
DeviceIntrf* intrf = nullptr;
uint32_t devSel = 0; // I2C 7-bit address or SPI CS index
public:
bool Init(DeviceIntrf& bus, uint32_t addr) {
intrf = &bus;
devSel = addr;
uint8_t whoamiReg = 0xD0;
uint8_t id = 0;
if (intrf->Read(devSel, &whoamiReg, 1, &id, 1) != 1)
return false;
return id == 0x60; // BME280 chip ID
}
};
Application chooses the bus — driver unchanged
I2C g_I2c; // implements DeviceIntrf
SPI g_Spi; // implements DeviceIntrf
TphBme280 sensor;
void BringUp(bool useI2C) {
DeviceIntrf& bus = useI2C
? static_cast<DeviceIntrf&>(g_I2c)
: static_cast<DeviceIntrf&>(g_Spi);
sensor.Init(bus, 0x76); // same call, same driver, any bus
}
The same driver works with I²C or SPI on any MCU that has a backend (nRF52, STM32, ESP32, RISC-V…). The bus complexity lives in two backends, not duplicated across every driver.
Side-by-Side: What You Actually Touch
| Change | Arduino | Mbed | Zephyr | MCU-Centric |
|---|---|---|---|---|
| Pins moved (same MCU) | Edit pins_arduino.h (+ boards.txt if new board) |
Edit PinNames.h + PeripheralPins.* (+ sometimes custom_targets.json) |
Edit .overlay (+ board/Kconfig/bindings as needed), regenerate |
Edit numbers in board.h |
| I²C ↔ SPI swap | App swaps library (Wire ↔ SPI) |
App swaps class (I2C ↔ SPI) + update pin maps |
Update DT nodes/bindings; app impact varies by binding model | Swap injected backend at composition root; driver unchanged |
| Build “knows” board? | ✔ | ✔ | ✔ | ✗ |
| Generation step? | ✗ | ✔ (mbed_config.h) |
✔ (DT/Kconfig → headers) | ✗ |
Code Examples: How Board-Centric Frameworks Wire Drivers
Zephyr — compile-time bus wiring via Devicetree
I²C variant (bme280_i2c.overlay):
&i2c1 {
status = "okay";
bme280@76 {
compatible = "bosch,bme280";
reg = <0x76>;
status = "okay";
};
};
SPI variant (bme280_spi.overlay):
&spi3 {
status = "okay";
bme280@0 {
compatible = "bosch,bme280";
reg = <0>;
spi-max-frequency = <8000000>;
cs-gpios = <&gpio0 15 GPIO_ACTIVE_LOW>;
status = "okay";
};
};
Build by swapping the overlay:
# I2C build
west build -b your_board app -- -DDTC_OVERLAY_FILE=bme280_i2c.overlay
# SPI build
west build -b your_board app -- -DDTC_OVERLAY_FILE=bme280_spi.overlay
The driver/device code must handle both paths via #if DT_ANY_INST_ON_BUS_STATUS_OKAY(i2c) and #elif DT_ANY_INST_ON_BUS_STATUS_OKAY(spi) blocks, each with different i2c_dt_spec / spi_dt_spec structs and different transfer functions. The bus is chosen at build time. Switching I²C↔SPI means updating DT and rebuilding.
Mbed — bus-specific classes or compile-time selection
I2C i2c(PB_9, PB_8);
SPI spi(PA_7, PA_6, PA_5);
DigitalOut cs(D10, 1);
int main() {
#if defined(USE_I2C)
BME280_I2C sensor(i2c, 0x76 << 1);
printf("T=%.2f\n", sensor.temperature());
#else
BME280_SPI sensor(spi, cs);
printf("T=%.2f\n", sensor.temperature());
#endif
}
With Mbed you typically instantiate a different class or code path for I²C vs SPI, and you keep pin/peripheral maps in PinNames.h / PeripheralPins.c (and often custom_targets.json) for new boards.
The Real Design Problem
The usual defense of board-centric complexity is that it buys portability. But portability to what?
If portability means “the framework can model many boards,” then yes, Zephyr is portable. If portability means “my application and drivers survive board changes with minimal rewiring,” that is a different test — and under that test, the architecture works against you. The more the build system owns board identity, bus topology, and hardware relationships, the more a board change becomes a build and configuration exercise instead of a firmware composition exercise.
That is why Devicetree, in this context, is a bad design choice for many MCU firmware projects. Not because hardware description is useless. Not because Linux concepts are always wrong. But because Zephyr’s DTS is tied into a larger configuration machine — bindings, Kconfig, overlays, generated macros, CMake, west, modules, and sometimes sysbuild. Ordinary firmware changes get routed through metadata plumbing instead of staying in code.
What MCU-Centric Changes
An MCU-centric architecture reverses that burden.
The MCU is the stable base. The board becomes a thin adaptation layer. Drivers talk to abstract device interfaces instead of being tightly coupled to bus-specific board metadata. Bus choice becomes a composition decision, not a build-system identity.
In practice, adding a new board in IOsonata does not require a framework object with its own DTS, Kconfig fragments, runner logic, YAML metadata, and CMake wiring. It requires a pin mapping header and initialization choices. Swapping I²C for SPI is a one-line change at initialization, not a devicetree overlay exercise. The application and driver code do not change at all, because they were written to abstract interfaces from the start.
The scaling math makes this concrete. With 100 device drivers and I²C + SPI support across 5 MCU families:
- Board-centric: up to 100 devices × 2 buses × 5 families = many integration paths. Each driver is bus-aware.
- MCU-centric: 100 devices × 1 driver. Bus complexity lives in 10 backends (I²C and SPI per family). Drivers stay unchanged.
Because drivers call Read/Write on an abstract interface, one firmware image can even probe at runtime and select the backend without rebuilding:
void InitSensorRuntime() {
uint8_t whoamiReg = 0xD0, id = 0;
if (g_I2c.Read(0x76, &whoamiReg, 1, &id, 1) == 1 && id == 0x60)
sensor.Init(static_cast<DeviceIntrf&>(g_I2c), 0x76);
else
sensor.Init(static_cast<DeviceIntrf&>(g_Spi), 0);
}
This enables board revision detection, population options, field-swappable sensors, and one binary across multiple hardware variants — without rebuilding. In board-centric systems, this is not the default pattern; it typically requires building and maintaining board/DT metadata for each variant separately.
Where Board-Centric Works — and Where It Breaks
The Arduino example shows that board-centric can work when the metadata surface stays small. A handful of key-value properties, one file, no generation step — that is a model a developer can hold in their head. The moment you add Devicetree, Kconfig, west, and sysbuild on top, the model stops being board-centric and becomes an ecosystem dependency. Every new board inherits the full weight of that stack, whether the board needs it or not. That is not a configuration problem or a documentation problem. It is a design problem, and it does not get better as the product line grows.
Zephyr’s friction is not accidental. It is a predictable result of a board-centric architecture that keeps adding layers in the name of generality.
Arduino proves board-centric can be simple.
Zephyr proves board-centric can become an ecosystem tax.
MCU-centric design exists for one reason: to keep the build from owning your product.
Your build system knows your board. Does it know it better than you do — and are you paying for that every time you add a variant?


