Zephyr 与 FreeRTOS 迁移实战:把跑了三年的 FreeRTOS 项目搬到 Zephyr,我把两个 RTOS 的底层差异从头对了一遍
缘起:为什么要把一个”跑得好好的”项目迁到 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_timerexpiry 回调跑在系统时钟中断里,绝对禁止阻塞、禁止拿互斥量。老工程里”定时器回调里做点 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) 字节数,静态定义 |
迁移时的实操结论:
xQueueCreate/xSemaphoreCreate这类运行时创建,在 Zephyr 里改成静态定义。K_MSGQ_DEFINE(name, msg_size, max_msgs, align)编译期就把队列的内存定死,链接期就能看到占用——这是比 FreeRTOS 更好的工程实践,迁移时顺手就把”运行时创建失败没检查”这类隐患清了。- 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 的坑友好得多。 - 系统堆要显式配。
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 个真金白银的坑
- 栈大小单位差出一个指针宽度。 FreeRTOS
xTaskCreate的栈参数单位是StackType_t(32 位平台上是 4 字节),传 512 实际是 2KB;ZephyrK_THREAD_STACK_DEFINE(name, size)参数是字节数。老代码”512 栈”直译过去变成 512 字节,跑起来必炸。我们的迁移检查项里第一条就是:所有栈定义 ×4 核对,再按高水位线重新核定。(这个坑在选型篇里提过 FreeRTOS 侧的口径,Zephyr 侧是另一个方向。) - k_timer 回调里拿了互斥量,直接死机。 前面说过,
k_timer回调在中断上下文,而 FreeRTOS 软件定时器回调在守护任务上下文。老代码里xTimerStart的回调体不做上下文审查直接搬,就是这条死法。解法统一:回调只做k_work_submit。 - 递归互斥量没有替代品,被迫重构。 一处日志模块用了递归锁(log_write 内部又调 log_write),Zephyr 直接
-EDEADLK。最后的改法是把内层改成_unlocked内部函数,外层管锁——迁完回头看,这处重构让日志死锁隐患彻底消失,算是 Zephyr 逼出来的良性改造。 K_TICKS和K_MSEC混用,周期任务快了 32 倍。 tick 是 32768Hz 的板子上,K_TICKS(1)≈ 30μs,不是 1ms。后来我们在 CI 里加了一条 grep 规则:业务代码出现K_TICKS直接构建失败(驱动层白名单除外)。- Picolibc 开了浮点 IO,Flash 爆了 12KB 还引入 malloc。 有人在 printf 里顺手打了浮点,Kconfig 里浮点 IO 一开,
_dtoa全家进 Flash,连带堆初始化。对照我们”零 malloc + 禁 %f”的铁律,Zephyr 的正确姿势是保持CONFIG_PICOLIBC_IO_FLOAT关闭 + 业务侧定点格式化(我们Meter_FmtFloat那套直接平移过去了)。 - 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 清零暴露了三处”创建失败没检查”的裸奔代码。就算最后决定不迁,把这次对账做一遍,工程质量也会上一个台阶。
有老工程要过这遍秤的,欢迎留言聊。
相关阅读: