Embedded development is not a static field. Complexity grows, security constraints tighten, tools evolve — sometimes quickly. Early experiences on PIC microcontrollers in assembly feel distant: current projects mix security, connectivity, over-the-air updates and sometimes a full Linux system.
There are two main families of systems, with very different profiles: microcontrollers under an RTOS (Cortex-M, RISC-V 32-bit, AVR…) and application processors under embedded Linux (Cortex-A, x86, RISC-V 64-bit). Both have their rules, their pitfalls, their tools. And they are converging — slowly but surely.
Here is what we have taken away from field experience.
Part 1 — RTOS and microcontrollers
Working without a safety net
A Cortex-M0 microcontroller: 32 kB of Flash, maybe 8 kB of RAM, no MMU, no OS in the traditional sense. It runs at 48 MHz and consumes a few milliamps. This is not a limitation you endure — it is the constraint that defines the product. The battery of an IoT sensor that must last 10 years is sized to the bit.
A few everyday realities in this world:
- No MMU: a wild pointer overwrites the stack silently. Detection tools (MPU on Cortex-M3+) exist but do not cover everything.
- RAM counted in kB: every allocation is deliberate. Dynamic heap allocations are often banned in production — too much risk of fragmentation.
- Determinism: tasks must trigger within a guaranteed delay — for example, motor control or a wireless transmission. This is the purpose of real-time.
- Direct register access: you talk to hardware without OS abstraction. One wrong byte in a configuration register and the peripheral is in an undefined state.
- Difficult debugging:
printfover UART is still the first tool used in 80% of cases. GDB via SWD/JTAG works, but you quickly lose context the moment you touch interrupts.
RTOS — an overview
An RTOS (Real-Time Operating System) provides preemptive scheduling, task management, synchronisation primitives (mutexes, semaphores, message queues), and sometimes a network stack and filesystem. Several players share the market.
FreeRTOS
Most deployedUltra-lightweight: 4 KB RAM minimum, support on virtually anything that has an MCU. The first name that comes up when you say "RTOS".
Strengths: ubiquity, abundant documentation, vendor BSP often delivered ready to go, MIT licence.
Limits: no unified HAL, no Devicetree, configuration via FreeRTOSConfig.h that grows and becomes hard to maintain. Acquired by Amazon in 2017 (AWS FreeRTOS / coreMQTT, etc.) — the cloud focus is real.
NuttX
POSIX on MCUAn ambitious bet: implementing full POSIX on a microcontroller. open(), read(), POSIX threads, BSD sockets — all present.
Strengths: application code portability, used by SpaceX on Dragon, clean and well-thought-out architecture.
Limits: complex integration, steep learning curve, much smaller community. Hard to find someone who knows it well.
RIOT
Low-power IoTLow-power IoT focus, excellent radio protocol support: IEEE 802.15.4, LoRa, BLE, 6LoWPAN. Designed to run on constrained nodes.
Strengths: well-integrated IoT network stack, active European academic community, modular approach.
Limits: more limited industrial support, vendor BSP often absent — you do part of the work yourself.
Zephyr
Emerging standardLinux Foundation, launched in 2016. This is where the industry is heading. Architecture mirroring Linux, Devicetree, exceptional commercial support.
Strengths: Nordic, ST, TI, Espressif, NXP, Renesas all maintain upstream BSPs. Rapidly growing ecosystem, serious documentation.
Limits: heavier than FreeRTOS (20–50 KB RAM depending on config), longer build times, non-trivial learning curve. More details below.
Comparison table
| RTOS | Min RAM | Integration | Ecosystem | Real-time | Industrial support | Devicetree |
|---|---|---|---|---|---|---|
| FreeRTOS | ~4 KB | Simple | Very rich | Yes | Excellent | No |
| NuttX | ~32 KB | Complex | Average | Yes | Limited | No |
| RIOT | ~1.5 KB | Moderate | Decent | Yes | Weak | No |
| Zephyr | ~20 KB | Moderate | Excellent | Yes | Excellent | Yes |
Why Zephyr is establishing itself as the reference RTOS
Four concrete reasons, not marketing arguments.
-
1
Devicetree — hardware is declarative, not hard-coded. You describe your circuit in a
.dtsfile, the firmware adapts at compile time. Changing a board, adding a sensor — it becomes a text file edit, not a hunt through#definestatements in ten files. And engineers coming from the Linux world are immediately at home.
A typical overlay for an I2C sensor:
/* boards/my_board.overlay */
&i2c0 {
status = "okay";
clock-frequency = <I2C_BITRATE_FAST>; /* 400 kHz */
bme280: bme280@76 {
compatible = "bosch,bme280";
reg = <0x76>;
label = "BME280";
};
};
/* In C code, retrieve the device cleanly: */
/* const struct device *dev =
DEVICE_DT_GET(DT_NODELABEL(bme280)); */
-
2
CMake + Kconfig — the same system as the Linux kernel. Build options are configured in a human-readable
prj.conf. No esoteric configuration GUI, no sprawling Makefile.
# Enable Bluetooth LE
CONFIG_BT=y
CONFIG_BT_PERIPHERAL=y
CONFIG_BT_DEVICE_NAME="Codium Sensor"
# Network stack + TLS
CONFIG_NET_SOCKETS=y
CONFIG_MBEDTLS=y
# Logging
CONFIG_LOG=y
CONFIG_LOG_DEFAULT_LEVEL=3 # INFO
# MPU for invalid memory access detection
CONFIG_ARM_MPU=y
-
3
Upstream hardware support. Nordic Semiconductor, STMicroelectronics, Texas Instruments, Espressif (ESP32), Microchip, NXP, Renesas — they all maintain their BSPs directly in the main Zephyr repository. This is not a symbolic contribution: it is code tested in CI on every commit, with actual hardware runners for some vendors. When you target an nRF9160 or an STM32U5, you have a solid starting point, not a fork that is three years old.
-
4
west — the multi-repository workspace manager. A YAML manifest describes all your dependencies (Zephyr itself, third-party modules, your own code). Guaranteed reproducibility between developers and in CI.
manifest:
defaults:
remote: upstream
remotes:
- name: upstream
url-base: https://github.com/zephyrproject-rtos
- name: codium
url-base: https://github.com/codium-electronique
projects:
- name: zephyr
remote: upstream
revision: v3.7.0
import: true
- name: firmware-drivers
remote: codium
path: modules/codium-drivers
revision: main
Part 2 — Embedded Linux
Scaling up — but constraints remain
We move to an application processor: Cortex-A53, i.MX8, RK3568, or x86 Atom. MMU, L1/L2 cache, multiple cores, hundreds of MB or even GB of RAM. Linux runs on it with its full ecosystem.
But "Linux" does not mean "easy". A few realities:
- Boot < 10 s: in many industrial products, this is a hard requirement. The kernel takes time. You must optimise the initrd, disable unused subsystems, sometimes patch the bootloader.
- Real-time: the standard kernel is not real-time. PREEMPT_RT (now integrated into mainline since 6.12) drastically reduces maximum latency — you can get below 100 µs on a well-configured Cortex-A53. Sufficient for medium-term motion control, not for microsecond-level command loops.
- Certifications: IEC 62443 for industry, IEC 61508 for functional safety — mainstream Linux is not certifiable as-is. Variants like RTEMS or heavily reduced and validated Linux configurations exist for these cases.
- Kernel rules: no stable internal ABI, no memory allocation in interrupt context (
GFP_ATOMIConly), demanding code review for anything going upstream. If you write an in-house driver, you alone maintain it — that is a real cost.
Structure of a Linux driver
The Linux kernel organises drivers around the Device Model: each driver is bound to a bus (I2C, SPI, platform, PCI…), registers with a functional subsystem (IIO for sensors, input for input devices, DRM for display, V4L2 for video…), and exposes a standard interface to userspace.
This layered architecture has a concrete advantage: the subsystem handles the generic part (event management, sysfs interface, buffers) and your driver does only what is strictly necessary — reading registers, handling interrupts, translating raw data. This decoupling simplifies maintenance and enables reuse across products.
In practice, a driver always starts with a probe() (called on device detection) and a remove() (cleanup). In between, you configure the hardware, register resources (IRQ, regulator, clock…) via kernel APIs, and connect to the functional subsystem.
Custom BSP on a Debian/Fedora base
When porting Linux to a custom board, you build a BSP (Board Support Package) — the combination of kernel + bootloader + root filesystem adapted to the hardware. The main steps:
-
1
LTS kernel + SoC patches Start from an LTS kernel (6.6, 6.12…), integrate the SoC vendor patches (Rockchip, NXP, ST…) — usually a public git repository maintained by the vendor. Enable only the necessary subsystems. A production
.configtypically has 200–400 options, not 4000. -
2
Custom Device Tree Source (DTS) Start from the SoC reference DTS, extend it with the board's specific peripherals: GPIO, power regulators, display panels, sensors. The overlay is maintained in the kernel sources or project sources, alongside the rest of the BSP.
-
3
U-Boot The bootloader. It initialises RAM, loads the kernel and DTS into memory, hands over control. Configuration via Kconfig, identical to the kernel. A few environment scripts for fallback (failed OTA update → automatic rollback).
-
4
Root filesystem Start from stable Debian (debootstrap) or Fedora, strip aggressively, add business packages. Result: a 300 MB to 1.2 GB system depending on needs. Updates go through the native package manager —
.debon Debian,.rpmon Fedora — guaranteeing reproducibility, clean rollback and traceability without third-party dependencies. -
5
Proprietary packages GPU firmware (blobs), video codec, certified cryptography stack, business daemon. Integrated into the rootfs via signed Debian packages — reproducibility and traceability guaranteed.
Concrete example: MIPI DSI display driver + I2C touch controller
A concrete and recurring example in our projects: a custom board with a MIPI DSI display and an I2C touch controller. Two separate drivers, two kernel subsystems, one link in the Device Tree.
The MIPI DSI panel — DRM subsystem
A DRM panel implements a few callbacks: prepare() powers up the panel, enable() comes out of reset and sends the DCS initialisation commands. Conversely, disable() and unprepare() handle clean shutdown.
static int my_panel_prepare(struct drm_panel *panel)
{
struct my_panel *ctx = to_my_panel(panel);
/* Power-on sequence: regulator then GPIO reset */
regulator_enable(ctx->vdd);
usleep_range(5000, 6000);
gpiod_set_value_cansleep(ctx->reset_gpio, 1);
usleep_range(10000, 12000);
gpiod_set_value_cansleep(ctx->reset_gpio, 0);
msleep(120); /* Delay specified by datasheet */
return 0;
}
static int my_panel_enable(struct drm_panel *panel)
{
struct my_panel *ctx = to_my_panel(panel);
struct mipi_dsi_device *dsi = ctx->dsi;
/* DCS init sequence — exit sleep, display on */
mipi_dsi_dcs_exit_sleep_mode(dsi);
msleep(120);
mipi_dsi_dcs_set_display_on(dsi);
return 0;
}
static const struct drm_panel_funcs my_panel_funcs = {
.prepare = my_panel_prepare,
.enable = my_panel_enable,
.disable = my_panel_disable,
.unprepare = my_panel_unprepare,
.get_modes = my_panel_get_modes,
};
The touch controller — input subsystem
The touch controller sends an interrupt on each detected contact. The driver reads coordinates via I2C and reports events to the input subsystem using the multi-touch API (type B).
static irqreturn_t my_ts_irq_handler(int irq, void *data)
{
struct my_ts *ts = data;
struct touch_point points[MY_TS_MAX_FINGERS];
int count, i;
count = my_ts_read_touch_data(ts->client, points);
if (count < 0)
return IRQ_HANDLED;
for (i = 0; i < MY_TS_MAX_FINGERS; i++) {
input_mt_slot(ts->input, i);
if (i < count) {
input_mt_report_slot_state(ts->input,
MT_TOOL_FINGER, true);
input_report_abs(ts->input, ABS_MT_POSITION_X,
points[i].x);
input_report_abs(ts->input, ABS_MT_POSITION_Y,
points[i].y);
} else {
input_mt_report_slot_state(ts->input,
MT_TOOL_FINGER, false);
}
}
input_mt_report_pointer_emulation(ts->input, true);
input_sync(ts->input);
return IRQ_HANDLED;
}
The Device Tree binding
The DTS links both drivers and describes the hardware: which GPIO for the panel reset, which IRQ for the touch, which regulators.
&mipi_dsi {
status = "okay";
panel@0 {
compatible = "vendor,my-panel";
reg = <0>;
reset-gpios = <&gpio3 RK_PB6 GPIO_ACTIVE_LOW>;
vdd-supply = <&vcc_lcd>;
/* MIPI DSI timing */
dsi,lanes = <4>;
dsi,format = <MIPI_DSI_FMT_RGB888>;
dsi,flags = <MIPI_DSI_MODE_VIDEO |
MIPI_DSI_MODE_VIDEO_BURST>;
/* Reference to touch controller (for DRM link) */
touchscreen = <&my_touch>;
};
};
&i2c4 {
status = "okay";
my_touch: touchscreen@38 {
compatible = "vendor,my-touch";
reg = <0x38>;
interrupt-parent = <&gpio1>;
interrupts = <RK_PA7 IRQ_TYPE_EDGE_FALLING>;
reset-gpios = <&gpio3 RK_PB7 GPIO_ACTIVE_LOW>;
vcc-supply = <&vcc_tp>;
touchscreen-size-x = <1280>;
touchscreen-size-y = <800>;
};
};
compatible strings, and it looks for a registered driver that matches. This is the Device Model in action — complete decoupling between hardware description and driver code.
Conclusion
These two worlds remain distinct. A microcontroller specialist and an embedded Linux engineer do not share the same instincts, the same tools, the same problems. And that is normal — the fundamental constraints are different.
What is changing is the progressive convergence of tools. Zephyr deliberately copied Linux's architecture: Devicetree, Kconfig, CMake, west. An engineer writing kernel code in the morning can understand a Zephyr project in the afternoon without being completely lost. That is new, and it is useful.
The fundamental challenges remain. RAM still matters. Determinism cannot be improvised. A mishandled pointer is still a silent bug. An allocation in an IRQ handler is still incorrect code. These rules do not change because the tools become more comfortable.
But the field evolves quickly — Zephyr 3.x, PREEMPT_RT in mainline, Matter on microcontrollers, eBPF beginning to appear in embedded. That is precisely what makes it interesting.
A project with a firmware constraint?
From LTE-M PSM/eDRX configuration to a custom Linux driver, we handle the software side of your embedded systems. Zephyr, Linux kernel, BSP — we know the terrain.