Zephyr 与 FreeRTOS 迁移实战:把跑了三年的 FreeRTOS 项目搬到 Zephyr,我把两个 RTOS 的底层差异从头对了一遍

2026年10月5日 0 By admin

缘起:为什么要把一个”跑得好好的”项目迁到 Zephyr

上个月接了个活:公司一个老的表计项目,FreeRTOS 跑了三年,稳得很。新需求下来——要上 BLE、要 OTA(带签名校验)、要多平台复用(下代硬件换 MCU 厂商)、还要过团队这边”构建环境必须一键拉起”的评审。

FreeRTOS 本身没问题,问题在于围着它拼的那一整套:BLE 协议栈是芯片厂闭源库,OTA 的 bootloader 是三年前同事手写的,构建脚本是 Makefile 加一堆手写路径,换个 MCU 基本等于重写。评审会上被问了一句”你们的驱动层换芯片要改多少行?”,我答不上来——这不是 FreeRTOS 的问题,是”只有内核、没有生态”的问题。

Zephyr 在这份清单上的答案是现成的:BLE/LoRa/WiFi 栈、MCUboot、devicetree、west 工具链、Apache 2.0 许可。听起来很美,但真动手迁的时候才发现,两个 RTOS 的差异比我想的深得多——不是 API 换个名字的事,是好几处语义根本不一样。

这篇把这次迁移里我对过的账完整记下来:调度、tick、中断、同步原语、内存、设备模型、构建系统,最后给一个”值不值得迁”的决策表和迁移自查清单。上一期选型对比(《TencentOS tiny / RT-Thread / FreeRTOS 怎么选》)讲的是”新项目怎么选”,这篇讲的是”老项目怎么搬”,两篇正好互补。

先把两家定位对清楚:内核项目和全栈项目

迁移前最容易犯的错,是拿 FreeRTOS 的心智模型直接套 Zephyr。先纠正这个:

维度 FreeRTOS Zephyr
项目定位 内核 + 少量附件(+TCP/+FAT),生态靠芯片厂和第三方拼 全栈:内核 + 调度 + 驱动模型 + 协议栈 + 构建/工具链一体
许可 MIT Apache 2.0(Linux 基金会托管)
硬件抽象 移植层要自己搭(portable 目录 + 芯片厂移植包) devicetree + 统一 HAL,官方支持板卡直接用
构建系统 你自己的 Makefile/CMake CMake + Kconfig + west,工具链配置内置
协议栈 +TCP 可用,BLE 基本靠芯片厂闭源库 BLE(SoftDevice 控制器方案/自研控制器)、6LoWPAN、LwM2M、MCUboot 全内置
最小 footprint 内核 5~10KB Flash 量级 起步就要 15~30KB Flash(含 C 库与初始化),比 FreeRTOS 内核大一圈
C 库 通常 newlib,行为随工具链变 内置 minimal libc / newlib / Picolibc 可选

一句话:FreeRTOS 是给你一块发动机,Zephyr 是给你一辆整车。迁到 Zephyr 意味着你放弃的是”什么都是自己拼”的控制感,换来的是一整套不用自己维护的底盘。footprint 那行要特别当心——我们的老工程 Flash 余量只有 48KB,算上 BLE 栈和 MCUboot 之后直接爆了,最后是换了大容量型号才迁成的。迁移评估第一件事:算 Flash/RAM 预算,别到链接期才发现装不下。

调度模型对账:优先级方向、时间片与协作线程

两家的抢占式调度思路一致:同优先级可以时间片轮转、高优先级抢占低优先级。但对账时发现三处差异,每一处都踩过:

第一,优先级数值方向相反。 FreeRTOS 数值越大优先级越高(0 是空闲任务);Zephyr 抢占式线程数值越小优先级越高(0 最高),协作线程用负数优先级。我们 os_compat 封装层里第一件事就是做了档位归一,这段代码后来救了我好几次:

/* os_compat.c —— 优先级档位归一,业务代码只写 OS_PRIO_HIGH/MID/LOW */
#define OS_PRIO_HIGH    (3)   /* FreeRTOS: uxPriority=3;Zephyr: prio=3(数值越小越高) */
#define OS_PRIO_MID     (5)
#define OS_PRIO_LOW     (7)

#if defined(USE_FREERTOS)
#define OS_TASK_CREATE(name, prio, stack, fn, arg) \
    xTaskCreate((fn), (name), (stack), (arg), (prio), NULL)
#elif defined(USE_ZEPHYR)
/* 注意:Zephyr 侧传参顺序不同,且栈对象类型完全不同(见后文) */
#define OS_TASK_CREATE(name, prio, stack, fn, arg) \
    k_thread_create(&(stack##_zt), (stack), K_THREAD_STACK_SIZEOF(stack), \
                    (k_thread_entry_t)(fn), (arg), NULL, NULL, \
                    (prio), 0, K_NO_WAIT)
#endif

第二,同优先级时间片是可关的。 Zephyr 的 CONFIG_TIMESLICING(及 CONFIG_TIMESLICE_SIZE)默认配置随板子走,如果你的老代码依赖 FreeRTOS configUSE_TIME_SLICING=1 的轮转行为,Kconfig 里得显式核对,否则会出现”两个同优先级任务有一个饿死”的隐蔽问题。

第三,协作线程是个新物种。 FreeRTOS 里没有”永远不让出 CPU 的任务”这个一等公民概念(只能靠临界区硬扛);Zephyr 里把线程优先级设成负数它就是协作式的,任何抢占都打不断它。移植时把原来”关调度保护”的段落翻出来检查,有些场景在 Zephyr 里更优雅的写法是协作线程或自旋锁,而不是照搬 FreeRTOS 的 vTaskSuspendAll() 语义(Zephyr 里这个函数不存在,替代品是 k_sched_lock(),但语义细节有差别:后者嵌套计数,锁住的只是调度不是中断)。

tick 与超时语义:这是迁移里最大的语义坑

FreeRTOS 的所有阻塞 API 接收 tick 数,pdMS_TO_TICKS() 换算,portMAX_DELAY 永久等待。Zephyr 的阻塞 API 直接接收 毫秒超时,配 K_MSEC() / K_SECONDS() / K_FOREVER / K_NO_WAIT 宏。

看起来 Zephyr 更友好,但迁移时埋着一个雷:老代码里 pdMS_TO_TICKS(50) 在 configTICK_RATE_HZ=1000 下是 50 tick,语义正确;但如果有人在 Zephyr 里手滑写了 K_TICKS(50) 而系统 tick 是 32768Hz,那就不是 50ms,是 1.5ms——Zephyr 的 CONFIG_SYS_CLOCK_TICKS_PER_SEC 在很多板子上默认是 32768 或 100,不是 1000。

我们的处理办法很死板但有效:封装层禁止业务代码直接出现任何时间字面量,全部走宏:

/* os_compat.h —— 超时语义统一,业务代码只写毫秒 */
#if defined(USE_FREERTOS)
#define OS_WAIT_MS(ms)          pdMS_TO_TICKS(ms)
#define OS_WAIT_FOREVER         portMAX_DELAY
#define OS_WAIT_NOW             0
#elif defined(USE_ZEPHYR)
#define OS_WAIT_MS(ms)          K_MSEC(ms)
#define OS_WAIT_FOREVER         K_FOREVER
#define OS_WAIT_NOW             K_NO_WAIT
#endif

/* 业务代码里只允许这样写: */
/* xSemaphoreTake(s, OS_WAIT_MS(50))  →  k_sem_take(&s, OS_WAIT_MS(50)) */

另一个细节:Zephyr 的 tick 精度如果不是 1000Hz,K_MSEC(1) 会被向上取整到 1 个 tick,高分辨率延时要用 k_usleep() / k_busy_wait(),别指望毫秒 API 能给你亚毫秒精度。

中断上下文:FromISR 后缀消失,但代价是纪律

这是迁移体验最好的部分。FreeRTOS 里 ISR 上下文必须用 xxxFromISR() 后缀版本,用错了直接内核崩;Zephyr 里大部分内核 API 本身就区分上下文,k_sem_give()、k_fifo_put()、k_msgq_put() 在中断里直接调(消息队列在中断里只允许 K_NO_WAIT),不需要后缀。

迁移操作就是全局搜 FromISR,逐个删后缀,然后把 portYIELD_FROM_ISR(xHigherPriorityTaskWoken) 换成 k_yield()(Zephyr 在中断退出时自动做调度判断,多数情况不用显式让出)。

但”不需要区分”不等于”没有约束”。两件事要重新过一遍:

  • 临界区保护换了体系。 taskENTER_CRITICAL() / portENTER_CRITICAL() 在 Zephyr 里的对位物是 k_spin_lock()(自旋锁,返回 k_spinlock_key_t,必须用 k_spin_unlock(key) 归还)或 irq_lock()(返回中断锁 key)。它们返回的是必须保存的 key,不是 FreeRTOS 那种配对宏——写惯了配对宏的人很容易在这里直接编译不过,反而是好事。
  • k_timer 回调跑在中断上下文。 这是语义级差异:FreeRTOS 软件定时器回调跑在 Timer Service 任务里(可以阻塞一点点,虽然不该);Zephyr 的 k_timer expiry 回调跑在系统时钟中断里,绝对禁止阻塞、禁止拿互斥量。老工程里”定时器回调里做点 Flash 写”的代码,迁到 Zephyr 就是死路,必须改成把工作 k_work_submit() 到系统工作队列。
/* FreeRTOS 软件定时器回调 → Zephyr 正确写法:回调只提交工作 */
static struct k_timer sensor_timer;
static struct k_work   sensor_work;

static void sensor_timer_cb(struct k_timer *t)
{
    k_work_submit(&sensor_work);   /* 中断上下文,只做投递 */
}

static void sensor_work_fn(struct k_work *w)
{
    /* 线程上下文(系统工作队列),这里才能做耗时/可阻塞操作 */
    sensor_sample_and_store();
}

/* 初始化:k_timer_init(&sensor_timer, sensor_timer_cb, NULL); */

同步原语映射表:一张表过完,两个语义差异要背下来

FreeRTOS Zephyr 语义差异备注
xSemaphoreCreateBinary() k_sem_init(&s, 0, 1) Zephyr 信号量创建即计数上限指定,无”先创建后 give”两步
xSemaphoreCreateCounting(max, init) k_sem_init(&s, init, max) 同上
xSemaphoreCreateMutex() k_mutex_init() + k_mutex_lock() 两家都有优先级继承
xSemaphoreCreateRecursiveMutex() 不存在 Zephyr 设计上禁递归锁,同线程重复 lock 返回 -EDEADLK,逼你改设计
xQueueCreate() k_msgq_init() 都是定长元素按值拷贝,对位良好
xQueueSend()(队列集等用法) 无队列集 Zephyr 用 k_poll 重组多源等待
xEventGroupWaitBits() k_event_wait()(新版本)/ k_poll Zephyr 3.6 起有 k_event,老版本用 k_poll 或手写
vTaskDelayUntil() k_sleep(K_USEC(...)) + 绝对时限 周期执行用 k_timer 或手算 deadline,无直接对位 API
xTaskGetTickCount() k_uptime_get_32()(毫秒) 注意单位从 tick 变毫秒

两个必须背下来的差异:

  • 递归锁不存在。 老代码里”图省事用了递归互斥量”的地方,迁移时要挨个改成”把锁的粒度重新切”或者”把内层函数拆成带锁/不带锁两个版本”。短期看是工作量,长期看是 Zephyr 在替你排雷——递归锁大概率是设计混乱的信号。
  • 任务通知没有直接对位。 FreeRTOS xTaskNotify() 那套轻量通知,Zephyr 的对位物是 k_poll signal 或 k_event 的单 bit 用法,性能略差但语义更明确。原来把任务通知当”超级信号量+邮箱”混用的地方,迁移时是重构重灾区。

内存管理:静态优先的哲学差异

FreeRTOS 的 pvPortMalloc(heap_4)是”一把 malloc 走天下”,Zephyr 把内存按用途拆成了明确的几档:

需求 FreeRTOS 惯用 Zephyr 对位
定长块、初始化后不回收 pvPortMalloc + 手工池 K_MEM_SLAB_DEFINE(),k_mem_slab_alloc()
变长分配、有回收 heap_4 全局堆 k_heap / K_HEAP_DEFINE(),k_heap_alloc()
全局堆快捷方式 pvPortMalloc k_malloc()(从系统堆)
线程栈 xTaskCreate 里传字数 K_THREAD_STACK_DEFINE(name, size) 字节数,静态定义

迁移时的实操结论:

  1. xQueueCreate/xSemaphoreCreate 这类运行时创建,在 Zephyr 里改成静态定义。 K_MSGQ_DEFINE(name, msg_size, max_msgs, align) 编译期就把队列的内存定死,链接期就能看到占用——这是比 FreeRTOS 更好的工程实践,迁移时顺手就把”运行时创建失败没检查”这类隐患清了。
  2. Zephyr 的 C 库选项直接影响你能不能用 %f 和 malloc。 我们团队有铁律(业务路径零 malloc、禁 %f,newlib-nano 的浮点格式化内部走 malloc,Release 配置一关浮点支持就输出空串),Zephyr 里对应的动作是:Kconfig 选 CONFIG_PICOLIBC(比 minimal libc 功能全、比 full newlib 小),并且不要开 CONFIG_PICOLIBC_IO_FLOAT,这样 %f 在编译期就会给出告警而不是运行期静默吐空串——比裸 newlib-nano 的坑友好得多。
  3. 系统堆要显式配。 CONFIG_HEAP_MEM_POOL_SIZE 默认是 0(有的版本很小),k_malloc 直接返回 NULL。老代码里残留的 malloc 调用迁过来会”看起来跑了,实际全失败”,这个要全局搜一遍确认归零。

设备模型与初始化:从”手写 init 链”到 devicetree

这是工作量最大的一章,也是收益最大的一章。

老工程的初始化是这样的:main() 里依次调 i2c1_init()、sensor_init()、flash_init(),每个 init 里面硬编码引脚、时钟、中断号,换块板子改一圈。Zephyr 的做法是引脚和外设全部进 devicetree,代码里通过符号引用:

/* app.overlay —— 板级差异全在这里,代码零改动 */
&i2c1 {
    status = "okay";
    pinctrl-0 = <&i2c1_scl_pb8 &i2c1_sda_pb9>;
    clock-frequency = <I2C_BITRATE_FAST>;

    sht3x@44 {
        compatible = "sensirion,sht3xd";
        reg = <0x44>;
    };
};
/* 代码里拿设备句柄:编译期展开,设备不存在直接编译报错 */
static const struct device *const i2c_dev = DEVICE_DT_GET(DT_NODELABEL(i2c1));

if (!device_is_ready(i2c_dev)) {
    /* Zephyr 的设备是异步初始化的,is_ready 检查必须做 */
    return -ENODEV;
}

对账时记录的三个要点:

  • 初始化有五个层级:PRE_KERNEL_1 → PRE_KERNEL_2 → POST_KERNEL → APPLICATION →( SMP 场景再细分)。你的驱动挂在哪一层,决定了它能不能用内核服务(PRE_KERNEL 层不能有任何依赖内核 API 的操作)。老代码里”init 顺序有隐式依赖”的地方,迁移时要显式声明 SYS_INIT(fn, POST_KERNEL, CONFIG_APPLICATION_INIT_PRIORITY),依赖关系从”写在注释里”变成”写在优先级数字里”。
  • GPIO API 按逻辑名取引脚:GPIO_DT_SPEC_INST_GET() 系列,引脚改动只碰 overlay。老工程换板子要改 7 个驱动文件的日子结束了。
  • device_is_ready() 不是可选项。 Zephyr 设备初始化是延迟且异步语义的, boards 上时钟树晚的设备在 main() 进来时可能还没 ready,漏检的后果是”偶发读不到设备”,和真实硬件故障混在一起极难排查。

构建与工具链:从 Makefile 手工活到 west 一键流

迁移前后我们的构建动作对比:

动作 迁移前(FreeRTOS 老工程) 迁移后(Zephyr)
新同事拉环境 装工具链 + 装芯片厂库 + 改 Makefile 路径 + 祈祷 west init + west update,全自动
切配置 注释/解开 Makefile 里的宏 west build -b <board> -- -DEXTRA_CONF_FILE=prod.conf
烧录 J-Flash 手动 / 每家一个脚本 west flash / west debug,板卡定义内置
看日志 每个 IDE 各配一遍 RTT/串口 west esb / west build -t rtt 统一入口

配置系统是 Kconfig fragment:业务配置写 prj.conf,生产/调试差异写在 prod.conf / debug.conf 里做 overlay,版本库里看得见、评审得了。这一项对我们的收益超过所有内核层收益的总和——RTOS 内核差异是”换个写法”,构建体系差异是”换个时代”。

老 Makefile 迁移的实操路径:先别急着删,把老工程里的编译宏(-DPROD_VERSION 之类)逐个映射成 Kconfig 选项(CONFIG_APP_PROD_VERSION),映射完跑一次二进制 diff 确认行为一致,再切 west 构建。

迁移踩坑实录:6 个真金白银的坑

  1. 栈大小单位差出一个指针宽度。 FreeRTOS xTaskCreate 的栈参数单位是 StackType_t(32 位平台上是 4 字节),传 512 实际是 2KB;Zephyr K_THREAD_STACK_DEFINE(name, size) 参数是字节数。老代码”512 栈”直译过去变成 512 字节,跑起来必炸。我们的迁移检查项里第一条就是:所有栈定义 ×4 核对,再按高水位线重新核定。(这个坑在选型篇里提过 FreeRTOS 侧的口径,Zephyr 侧是另一个方向。)
  2. k_timer 回调里拿了互斥量,直接死机。 前面说过,k_timer 回调在中断上下文,而 FreeRTOS 软件定时器回调在守护任务上下文。老代码里 xTimerStart 的回调体不做上下文审查直接搬,就是这条死法。解法统一:回调只做 k_work_submit。
  3. 递归互斥量没有替代品,被迫重构。 一处日志模块用了递归锁(log_write 内部又调 log_write),Zephyr 直接 -EDEADLK。最后的改法是把内层改成 _unlocked 内部函数,外层管锁——迁完回头看,这处重构让日志死锁隐患彻底消失,算是 Zephyr 逼出来的良性改造。
  4. K_TICKS 和 K_MSEC 混用,周期任务快了 32 倍。 tick 是 32768Hz 的板子上,K_TICKS(1) ≈ 30μs,不是 1ms。后来我们在 CI 里加了一条 grep 规则:业务代码出现 K_TICKS 直接构建失败(驱动层白名单除外)。
  5. Picolibc 开了浮点 IO,Flash 爆了 12KB 还引入 malloc。 有人在 printf 里顺手打了浮点,Kconfig 里浮点 IO 一开,_dtoa 全家进 Flash,连带堆初始化。对照我们”零 malloc + 禁 %f”的铁律,Zephyr 的正确姿势是保持 CONFIG_PICOLIBC_IO_FLOAT 关闭 + 业务侧定点格式化(我们 Meter_FmtFloat 那套直接平移过去了)。
  6. overlay 覆盖顺序导致引脚”改了没生效”。 多个 overlay 文件按命令行顺序合并,后加载的覆盖先加载的。有一次 app.overlay 里改了引脚,但 boards/xxx.conf 侧还有一个旧 overlay 也在定义同一节点,合出来的结果是旧的赢。教训:一个板子一个 overlay 入口,其余配置进 fragment,别让两个 overlay 抢同一个节点。

值不值得迁:一张决策表

迁完复盘,我们把”该不该迁 Zephyr”整理成了这张表:

场景判断 建议
单片机 32KB Flash 以下、逻辑简单、只用裸机都够 别迁,FreeRTOS 都可能多余
已有三年稳定 FreeRTOS 工程,新需求只有”改改业务” 不迁。迁移成本(我们实测约 6 周人月当量)买不回收益
需要 BLE/OTA/MCUboot/安全启动任一项,且目前用闭源芯片厂库 值得迁,Zephyr 内置方案能省掉一大块自维护成本
多代硬件、多厂商 MCU 复用一套业务代码 强烈建议迁,devicetree + 统一驱动 API 是唯一正解
团队 ≤3 人且没人碰过 CMake/Kconfig 缓一缓,构建体系学习成本先在小项目上练
对 footprint 极致敏感(<64KB Flash 还要协议栈) 先做 footprint 实测(west build -t rom_report)再决定,Zephyr 起步体积比 FreeRTOS 大

我们项目的结论:需要 BLE + OTA + 多平台复用三条全中,迁。如果当年只有第一条业务修改需求,我会在评审会上直接说”不迁”,把这个精力花在把 FreeRTOS 工程的驱动层抽象补齐上。

迁移自查清单

迁移动手前和合入前,按这张单子逐项过:

  • ☐ Flash/RAM 预算已实测:目标板跑 Zephyr 最小系统 + rom_report / ram_report,余量 ≥30%
  • ☐ 所有任务/线程栈定义已按”FreeRTOS 字数 ×4 = Zephyr 字节数”核对,并按高水位线重估
  • ☐ 业务代码已禁止直接出现 tick 字面量与 K_TICKS,时间全部走 OS_WAIT_MS 封装
  • ☐ 全局 FromISR 已清零,ISR 中无阻塞调用(含 k_msgq_put 只用 K_NO_WAIT)
  • ☐ 所有软件定时器回调已做上下文审查,耗时操作已改 k_work_submit
  • ☐ 递归互斥量使用点已清零(或已记录重构方案与责任人)
  • ☐ 任务通知(xTaskNotify)使用点已映射到 k_event/k_poll,无语义错位
  • ☐ 全局 malloc/free/pvPortMalloc 已清零,CONFIG_HEAP_MEM_POOL_SIZE 与实际需求对账
  • ☐ C 库浮点 IO(CONFIG_PICOLIBC_IO_FLOAT / newlib float printf)确认关闭,浮点输出走定点格式化
  • ☐ 设备引用全部走 devicetree 符号(DEVICE_DT_GET),入口处 device_is_ready 检查齐全
  • ☐ overlay 唯一入口已确认,多 fragment 覆盖顺序已评审,CI 已有 overlay 冲突检查
  • ☐ 初始化依赖已用 SYS_INIT 层级显式声明,原”注释里写的顺序依赖”已清零

结语:迁的是内核,立的是规范

六周下来,我最深的感受是:Zephyr 的价值不在”内核比 FreeRTOS 好”——调度器层面两家都是成熟作品,日常写业务感知不到差别。它的价值在于把散落在工程里的隐式知识变成显式约束:引脚在 devicetree 里、配置在 Kconfig 里、初始化顺序在 SYS_INIT 优先级里、内存预算在链接报告里。这些约束平时看着啰嗦,换板子、换人、换平台的时候全是救命绳。

而迁移过程本身,是对老工程的一次强制体检:递归锁暴露了日志模块的设计混乱,栈单位差异暴露了从来没人核过高水位线,malloc 清零暴露了三处”创建失败没检查”的裸奔代码。就算最后决定不迁,把这次对账做一遍,工程质量也会上一个台阶。

有老工程要过这遍秤的,欢迎留言聊。


相关阅读: