Cross-RTOS Support in One Platform: How Hoomanely Unifies Firmware Across Bare-Metal and FreeRTOS

Product families rarely run on a single RTOS. A hardware platform might ship as a bare-metal variant for simple, resource-constrained nodes and a FreeRTOS-based SKU for feature-rich devices, each with different constraints, scheduling guarantees, and memory layouts. Traditionally, supporting multiple RTOSes forces teams into separate repositories, linker scripts, build configurations, and debugging workflows.
At Hoomanely, where we design modular IoT pet-care devices across diverse MCU platforms, that kind of fragmentation wasn't acceptable. We needed a cross-RTOS firmware architecture that unifies application logic while accommodating each RTOS's distinct memory model, interrupt behavior, and scheduling semantics, supporting bare-metal and FreeRTOS through shared interfaces with RTOS-specific linker scripts, heap strategies, and startup pipelines.
One hardware platform, multiple RTOS requirements
Hoomanely builds modular IoT systems, sensor nodes, feeding bowls, cameras, gateways, on a universal SoM architecture. Different subsystems have different real-time requirements, power budgets, and certification constraints, which naturally pushes different RTOS choices across the product line. A cross-RTOS platform lets us reuse 70 to 90 percent of the codebase across devices, reduce integration errors between SoMs, accelerate validation on new MCU families, and maintain a consistent recovery pipeline. This isn't a nice-to-have, it's a core platform capability.
The recurring dilemma: you want code reuse, but each RTOS forces different linker scripts, memory layouts, init sequences, and heap behavior. Bare-metal offers direct MCU control, no scheduler, no dynamic memory unless you implement it yourself, and deterministic but limited scalability, good for simple low-power nodes. FreeRTOS offers a preemptive priority-based scheduler, multiple heap implementations, FromISR-safe APIs for interrupt contexts, and built-in timers, queues, semaphores, and mutexes, better for complex multi-tasking applications. You can't share the same startup code, linker script, heap model, or ISR wiring between them without turning your repo into two partial forks.
Why FreeRTOS
We chose FreeRTOS for several reasons: it's an industry standard used in billions of devices with broad hardware support across Cortex-M, RISC-V, x86, and Xtensa; it's MIT-licensed, open source, with no royalties; it has a rich feature set of preemptive scheduling, queues, semaphores, mutexes, software timers, event groups, and lightweight task notifications; it has a small footprint, roughly 9KB of code and 500 bytes of RAM at minimal config, scaling from Cortex-M0+ up to Cortex-A; and a certified variant, SAFERTOS, exists for safety-critical work. FreeRTOS also offers five heap implementations, from heap_1's allocate-only pattern up to heap_4's coalescing best-fit allocator, which we standardized on for general-purpose use, and heap_5's multi-region support for fragmented memory maps.
Our approach: a unified platform with per-RTOS components
The guiding principle: application logic must be RTOS-agnostic. Shared, RTOS-independent components live once and get used everywhere, drivers, the hardware abstraction layer, board configuration, peripheral discovery, the messaging framework, telemetry, and logging. RTOS-specific code lives under /rtos/baremetal/ and /rtos/freertos/, each containing startup and reset handlers, linker scripts, memory and heap layout, scheduler glue, ISR routing, and error and recovery handling. The common firmware layer never reaches into RTOS internals directly.
We keep the system-level API narrow and explicit:
typedef struct {
void (*init)(void);
void (*delay_ms)(uint32_t ms);
uint32_t (*uptime_ms)(void);
} system_api_t;Bare-metal implements delay_ms as a busy-wait against a systick counter; FreeRTOS implements it as vTaskDelay(pdMS_TO_TICKS(ms)). For events, we define both a normal and an ISR-safe variant, post_event and post_event_from_isr, letting the FreeRTOS backend use xQueueSendFromISR while bare-metal uses a simple ring buffer with interrupts disabled for atomicity. Compile-time selection picks the right backend:
#if defined(USE_FREERTOS)
#include "freertos_backend.h"
#else
#include "baremetal_backend.h"
#endifApplication code stays ignorant of which RTOS is actually running underneath it.
Per-RTOS linker scripts and heap models
Each RTOS needs different memory organization. The bare-metal linker script reserves a small heap (16K) and a small fixed stack, while the FreeRTOS linker script reserves a larger heap (32K, since it backs multiple task stacks) and a smaller main stack, since after the scheduler starts, ISRs use PSP rather than the main stack. Bare-metal uses a bump allocator for early boot and simple nodes, predictable and O(1) but with no free support, fine for boot-time allocations. FreeRTOS uses heap_4 for general-purpose dynamic memory, configured through FreeRTOSConfig.h with a total heap size and a malloc-failed hook that logs the error and triggers recovery.
Startup, ISRs, and build integration
Bare-metal startup copies .data from flash to SRAM, zeroes .bss, calls SystemInit(), then calls main(), which runs hardware init followed by an infinite application loop. FreeRTOS startup does the same hardware init, then creates tasks and queues, and calls vTaskStartScheduler(), which never returns under normal operation.
ISR handling differs too. A bare-metal UART handler reads data directly into a ring buffer. A FreeRTOS handler posts to a queue with xQueueSendFromISR and calls portYIELD_FROM_ISR if a higher-priority task woke up. Using our abstraction layer, the same ISR code compiles for both:
void UART_IRQHandler(void)
{
bool woken = false;
if (UART->SR & UART_SR_RXNE) {
event_t ev = { .type = EVENT_UART_RX, .data = UART->DR };
event_api.post_event_from_isr(&ev, &woken);
}
}FreeRTOS yields if woken is true; bare-metal simply ignores it. The build system selects the RTOS backend, startup file, and linker script at configure time, whether through CMake with an RTOS_IMPL cache variable or a Makefile with an RTOS variable, both defining separate source lists, startup assembly, and linker scripts per backend.
Adding a new MCU to the platform
When we bring up a new MCU family, STM32, nRF52, ESP32-S3, we follow the same sequence every time: hardware bring-up (clock tree, power rails, GPIO, UART), a minimal bare-metal boot to validate the platform with a simple printf loop, FreeRTOS integration with port files and a basic two-task scheduler test, abstraction layer validation running the same test suite on both RTOS backends, and finally platform certification, stress-testing the scheduler, validating interrupt handling, testing recovery paths, and measuring timing and determinism.
Results
Code reuse lands at 70 to 90 percent shared between bare-metal and FreeRTOS, with a single HAL and driver implementation and unchanged application logic. New MCU onboarding dropped from two to three weeks down to three to five days, since bare-metal validates hardware quickly and FreeRTOS adds multi-tasking without a rewrite. Behavior stays consistent, same event handling, unified logging and telemetry, and predictable recovery paths across RTOSes. Maintenance dropped, since there's a single HAL to maintain and bug fixes apply everywhere at once. And the product line stays flexible: simple nodes run bare-metal for low power and cost, complex nodes run FreeRTOS for richer features, all on the same firmware architecture.
Takeaways
Real product lines need multiple RTOS approaches for different constraints. FreeRTOS provides robust multi-tasking with minimal overhead. Abstraction layers enable RTOS independence at the application level. Each RTOS needs its own dedicated linker script, heap model, and startup code. Interrupt handling requires careful priority management under FreeRTOS. And the build system should select the RTOS backend at compile time, giving you a single codebase that supports bare-metal and FreeRTOS across multiple MCU families.
Cross-RTOS support isn't about supporting more operating systems for its own sake. It's about building a platform that scales from simple, power-constrained sensors to complex, feature-rich gateways without fragmenting the codebase.