How to Migrate from FreeRTOS to Zephyr Without Rewriting Your Application

Most teams that attempt a FreeRTOS to Zephyr migration start in the worst possible place: they open their main application file, see xTaskCreate, and begin manually replacing every API call with its Zephyr equivalent. Two weeks later, they have 400 compiler errors, a broken build, zero working tests, and a growing suspicion that they should have stayed put. The migration gets shelved. The Jira ticket grows cobwebs.
Here’s what those teams missed: Zephyr ships compatibility shims that can compile your existing FreeRTOS application code with minimal changes. You don’t need a rewrite. You need a phased approach. Get it building under Zephyr first, validate behavior second, then refactor toward native APIs incrementally, module by module, on your own timeline.
This guide covers the execution. It assumes you’ve already chosen Zephyr (for its device tree model, networking stack, hardware support, or because someone above you made the call). It assumes you can set up a Zephyr west workspace. We’re going straight to the how.
Pick Your Compatibility Bridge
Zephyr gives you two shim options, and your existing codebase determines which one to use.
FreeRTOS Compatibility Layer. Zephyr’s in-tree module (under modules/lib/freertos) provides a FreeRTOS.h shim that maps common FreeRTOS API calls (xTaskCreate, xQueueSend, xSemaphoreTake, xEventGroupSetBits, and friends) to Zephyr kernel primitives under the hood. Coverage is solid for tasks, queues, semaphores, mutexes, and event groups. It does not cover stream buffers, message buffers, or some software timer edge cases.
CMSIS-RTOS v2 Layer. If your codebase already wraps FreeRTOS behind the CMSIS-RTOS v2 abstraction (common on STM32 projects using CubeMX-generated code), enable CONFIG_CMSIS_RTOS_V2 and you’re working with an even thinner bridge. Most osThreadNew, osMessageQueuePut, and osSemaphoreAcquire calls translate directly.
Decision heuristic:
+---------------------------+-------------------+--------------------+
| Your Current Codebase | Recommended Shim | Effort Estimate |
+---------------------------+-------------------+--------------------+
| Direct FreeRTOS API calls | FreeRTOS compat | Medium |
| CMSIS-RTOS v2 over Free.. | CMSIS-RTOS v2 | Low |
| Custom OSAL over FreeRTOS | Reimplement OSAL | Low (cleanest) |
+---------------------------+-------------------+--------------------+If you already have a custom OS abstraction layer, you’re in the best position. Just reimplement the OSAL against Zephyr’s native API and skip the shims entirely. For everyone else, the shim is your fast path to a working build.
Phase 1: Shim — Get It Building
The goal of Phase 1 is narrow: a compiling, linking, booting image. Not feature-complete. Not optimized. Just alive.
Step 1: Set up your Zephyr workspace. Add your application as an out-of-tree app or a module within a west workspace. Your CMakeLists.txt will need to target Zephyr’s build system. If you’re carrying FreeRTOS as a source dependency, remove it. Zephyr’s kernel replaces it.
Step 2: Enable compatibility Kconfig options. In your prj.conf:
CONFIG_FREERTOS=y
CONFIG_HEAP_MEM_POOL_SIZE=4096Or for CMSIS: CONFIG_CMSIS_RTOS_V2=y.
Step 3: Fix your include paths. Replace direct paths to your old FreeRTOS headers with the shim-provided equivalents. The shim provides FreeRTOS.h, task.h, queue.h, semphr.h, and event_groups.h. In most cases, your #include statements don’t change; the build system just resolves them to different files.
Step 4: Map configXXX macros to Zephyr Kconfig. This is where most first attempts stall. FreeRTOS configuration lives in FreeRTOSConfig.h; Zephyr configuration lives in Kconfig. You need to translate:
FreeRTOS configXXX → Zephyr Kconfig
─────────────────────────────────────────────────────
configTICK_RATE_HZ → CONFIG_SYS_CLOCK_TICKS_PER_SEC
configMINIMAL_STACK_SIZE → CONFIG_MAIN_STACK_SIZE (+ per-thread)
configMAX_PRIORITIES → CONFIG_NUM_PREEMPT_PRIORITIES
configUSE_PREEMPTION → CONFIG_PREEMPT_ENABLED
configUSE_TICKLESS_IDLE → CONFIG_TICKLESS_KERNEL
configTOTAL_HEAP_SIZE → CONFIG_HEAP_MEM_POOL_SIZE
configUSE_MUTEXES → (always available)
configUSE_COUNTING_SEMAPHORES → (always available)Note that configMINIMAL_STACK_SIZE in FreeRTOS is specified in words, while Zephyr stack sizes are in bytes. On a 32-bit target, multiply by 4. Get this wrong and you’ll spend a day chasing stack overflows that don’t show up until Phase 2.
Step 5: Stub hardware-direct code. Any code that bypasses FreeRTOS to touch hardware registers directly (custom interrupt handlers, low-power entry/exit hooks, DMA setup) needs to be stubbed or repointed at Zephyr’s driver model. Don’t try to make these work yet. Stub them, mark them // TODO: MIGRATE, and move on.
After Phase 1, you should be able to west build and west flash an image that boots to your main task. Some features will be broken. That’s fine.
Phase 2: Validate — Where Silent Bugs Hide
A clean compile means nothing if the kernel underneath behaves differently. These are the runtime behavioral differences that the shim layer mostly handles but that you must verify.
Scheduling and priority inversion. FreeRTOS uses higher number = higher priority. Zephyr inverts this: priority 0 is the highest preemptible priority. The compatibility shim remaps priorities, but if you’ve hardcoded priority values in configuration tables, debug prints, or assertions, those comparisons will be wrong.
FreeRTOS Priority Zephyr Priority
(higher = more) (lower = more)
─────────────── ───────────────
31 (highest) → 0 (highest)
30 → 1
... ...
1 → -1 (if cooperative)
0 (idle) → lowest preemptStack sizing (again). FreeRTOS counts in words. Zephyr counts in bytes. Even if you got this right in prj.conf, check every xTaskCreate call where stack size is passed as a parameter. An off-by-4x error produces intermittent crashes that are brutal to debug. Enable CONFIG_THREAD_ANALYZER and CONFIG_THREAD_ANALYZER_AUTO in your prj.conf. Zephyr will periodically print stack usage for every thread, showing you exactly which ones are undersized.
Idle task behavior. If you implemented vApplicationIdleHook for background housekeeping or power management, that pattern doesn’t carry over. Zephyr’s idle thread integrates with its own power management subsystem. Move idle-hook logic into a low-priority thread or into Zephyr’s power management hooks.
ISR context constraints. FreeRTOS requires the FromISR variants for calls made from interrupt context. Zephyr’s kernel API is largely ISR-aware by design (k_sem_give is safe from ISR context, for example), but k_mutex_lock is not. If your FromISR calls touch mutexes through the shim, you’ll hit runtime assertions. Audit every ISR path.
Validation approach: Run whatever integration and unit tests you have against the shimmed build. If you don’t have tests (no judgment), exercise every task path manually and watch the console for stack overflow warnings and failed assertions. This phase typically takes days to a week depending on codebase complexity.
Phase 3: Refactor — Native APIs, One Module at a Time
This phase is optional for getting to production, but strongly recommended for long-term maintainability. The shim layer adds indirection, increases binary size slightly, and prevents you from using Zephyr-native features that have no FreeRTOS equivalent.
Strategy: refactor module-by-module, not API-by-API. Pick your lowest-risk module, the one with the best test coverage or the fewest external dependencies. Replace xTaskCreate with k_thread_create, xQueueSend with k_msgq_put, xSemaphoreTake with k_sem_take. Get that module running on native APIs, validate, then move to the next.
Features that justify the refactor cost: Zephyr’s k_work and k_work_delayable provide a deferred-execution model that’s cleaner than FreeRTOS’s software timer daemon. k_event (since Zephyr 3.0) gives you a proper event flag mechanism. The logging subsystem, power management integration, and shell subsystem are all native Zephyr capabilities that you can’t access through the shim.
Introduce an OSAL if you might move again. If your organization supports multiple hardware targets across RTOSes, or if there’s any chance of another RTOS transition in the future, wrap your OS calls behind a thin abstraction layer during this refactor. The OSAL pattern costs a few hundred lines of code and makes the next migration nearly free.
┌─────────────────────────────────────────────────────────┐
│ MIGRATION PHASES │
├───────────────┬───────────────────┬─────────────────────┤
│ PHASE 1 │ PHASE 2 │ PHASE 3 │
│ SHIM │ VALIDATE │ REFACTOR │
│ │ │ │
│ • Enable │ • Run tests │ • Module-by-module │
│ compat layer│ • Check scheduling│ native API swap │
│ • Fix includes│ • Verify stacks │ • Adopt k_work, │
│ • Map Kconfig │ • ISR behavior │ k_event, etc. │
│ • Get it │ • Thread analyzer │ • Introduce OSAL │
│ building │ │ if needed │
│ │ │ │
│ [Days] │ [Days–1 Week] │ [Weeks, ongoing] │
└───────────────┴───────────────────┴─────────────────────┘
▲ │
│ Iterate as needed │
└─────────────────────────────────────┘The Gotchas That Will Waste Your Afternoon
Rapid-fire list of the patterns that trip up every migration:
xTaskNotify/xTaskNotifyWait: No direct shim. The closest Zephyr equivalent isk_eventfor flag-based signaling ork_pollfor more complex wait conditions. This is often the first API that forces a manual rewrite.Software timers: FreeRTOS runs timer callbacks in a dedicated daemon task (thread context). Zephyr’s
k_timerexpiry functions run in ISR context by default. If your timer callback does anything that blocks, it will crash. Wrap the work in ak_worksubmission instead.pvPortMalloc/vPortFree: Map tok_malloc/k_free, but better yet, use this migration as the opportunity to move to static allocation withK_THREAD_STACK_DEFINEandK_MSGQ_DEFINE. Zephyr’s ecosystem strongly favors static allocation.Event groups: Map to
k_event. The API is different but the semantics are close. Watch for the “clear on exit” behavior inxEventGroupWaitBits. You’ll need to handle clearing manually withk_event_clear.vTaskDelayvsvTaskDelayUntil:k_sleepreplacesvTaskDelay. For periodic timing withvTaskDelayUntilsemantics, usek_timerwith an absolute period rather than chainingk_sleepcalls (which accumulate drift).taskENTER_CRITICAL/taskEXIT_CRITICAL: This needs care. If the intent is to prevent context switches, usek_sched_lock/k_sched_unlock. If the intent is to mask interrupts, useirq_lock/irq_unlock. Using the wrong one creates either unnecessary interrupt latency or insufficient protection.
Build Your Migration Plan This Week
The FreeRTOS to Zephyr migration is not a rewrite project. It’s a three-phase process: shim to get building, validate to catch behavioral differences, then refactor incrementally toward native APIs.
Phase 1 can be done in days for a typical codebase. Phase 2 adds a few more days to a week. Phase 3 is ongoing work that you schedule alongside feature development. There’s no rush because the shim layer is production-viable.
The risk is lower than your team thinks. The compatibility layers exist precisely because the Zephyr project recognized that nobody migrates to a new RTOS by starting from scratch. Use them. Get a working build by Friday, and you’ll have something concrete to show your team instead of another slide deck about why you should migrate.
Start with Phase 1. Enable CONFIG_FREERTOS=y, fix your includes, map your Kconfig, and see it boot. Everything after that is incremental.
Hubble Network enables Bluetooth-connected devices to transmit data directly to satellites—no gateways, no infrastructure changes. See how it works →