Zephyr 系统开发指南
编制日期 2026-10-05
面向 Cortex-M / RISC-V 的 Zephyr 系统级开发规范 · 覆盖 west 工作区、内核线程调度、设备模型、Devicetree 与 Kconfig、构建系统、存储子系统、低功耗、测试与 MCUboot OTA · 系统总文档,驱动开发为其配套子文档
一句话结论:Zephyr 的系统设计,本质是用「Devicetree 描述硬件 + Kconfig 裁剪功能 + 设备模型统一初始化」把「几百块板卡各自手写板级代码」变成「一份应用配置 + 一份 overlay」。 它比 FreeRTOS 多了设备树、统一的设备初始化等级与可复用的网络/蓝牙/存储子系统,比 Linux 少了MMU 与进程抽象——代价是必须接受 Kconfig 与 DT 这两层编译期抽象的学习曲线。 线上故障最集中的四处:overlay 写了但设备没使能(status 仍为 disabled)、设备就绪检查被跳过、ISR 里调用了阻塞 API、栈与堆被低估后随机踩踏。管住这四处,系统稳定性解决八成。
这份文档解决什么
本文是 Zephyr 系统级开发总文档:从 west 工作区与工具链、内核线程与同步原语、设备模型与初始化等级、Devicetree 与 Kconfig、构建系统,到工作队列、日志诊断、存储子系统、网络蓝牙、性能低功耗、可靠性与 MCUboot OTA、测试与发布,构成一条完整链路。 具体的驱动编写(外设驱动、存储器件驱动、文件系统挂载)由配套的《Zephyr 驱动开发指南》承接;本文只讲「在这一层如何把系统组装起来、如何把驱动挂进系统」。器件级电气与时序细节仍以各器件的设计指南为准。
使用约定
- API、目录与配置项以 Zephyr 3.7 LTS / 4.x 为主线,兼顾 3.5 及更早版本;差异明显处单独标注。目标平台以 Cortex-M3/M4/M33/M55、RISC-V 为主。
- 代码示例只保留系统层逻辑;具体寄存器操作统一写成
/* SoC 层提供 */注释。 - 标 红线 为强制约束,标 警惕 为高频踩坑点,标 建议 为经验推荐值。
- 文中出现的默认数值(栈大小、优先级、缓冲区大小、宏名)以官方仓库与
Kconfig为参考;不同版本宏名与默认值可能变化,落地前以工程内build/zephyr/.config与zephyr.dts为准。 - 芯片参数、时钟、外设行为一律以官方 datasheet 与 Zephyr 官方文档 / 源码为准。
1 · 文档概述与 Zephyr 架构
这一章解决三件事:这份文档覆盖到哪一层、Zephyr 由哪些层构成、它和 Linux / FreeRTOS / RT-Thread 的边界在哪。读完再决定后面该精读哪几章。
1.1 编写目的、适用范围与读者
本指南面向基于 Zephyr 做系统级开发的工程团队:把一颗裸的 MCU 变成可量产、可升级、可运维的终端设备。它要回答的不是「某个寄存器怎么写」,而是「一堆驱动、子系统与应用如何按正确顺序长成一个系统」。
| 维度 | 本指南覆盖 | 本指南不覆盖 |
|---|---|---|
| 目标读者 | 系统 / BSP 工程师、驱动工程师、应用与联网工程师、测试与发布负责人 | 只写业务逻辑、不接触系统的上层应用开发 |
| 硬件范围 | 无 MMU 或轻量 MMU 的 MCU:Cortex-M0/M3/M4/M33/M55、RISC-V、AArch64 | 带 MMU 的 Cortex-A 应用处理器(走 Linux 分支) |
| 软件范围 | west 工作区、内核线程与同步、设备模型、DT 与 Kconfig、构建、存储、电源、测试、MCUboot OTA | 单个器件的电气与命令时序(见各器件设计指南) |
| 文档定位 | 系统总文档;驱动编写由配套的驱动开发指南承接 | 云平台侧配置与业务规则 |
1.2 版本基线、支持平台与分支策略
Zephyr 有两条实际在用的版本线:3.7 LTS(长期支持,稳定性优先,适合量产)与 4.x(新特性跟进更快)。本指南以 3.7 LTS 为主、4.x 兼容,差异较大处显式标注。
| 基线项 | 说明 | 对开发的影响 |
|---|---|---|
| 内核 | 可抢占实时内核,协作式与抢占式时间片均支持;调度算法可选 EDF 与 Meta IRQ | 内核行为由 Kconfig 决定,不是改源码 |
| 硬件描述 | Devicetree(.dts / .dtsi / .overlay),编译期由 dtc 生成 C 头文件 | 改硬件配置不碰驱动代码,只碰 overlay |
| 功能裁剪 | Kconfig 声明式配置,menuconfig / guiconfig 交互 | 所有可选功能都有一枚 CONFIG_ 宏 |
| 构建 | west(多仓库管理 + 构建入口)驱动 CMake(构建规则)+ Kconfig(配置)+ Python(工具) | 一个 west build 完成从配置到链接的全过程 |
| 设备模型 | 统一 struct device + API 表,按初始化等级与优先级自动 init | 驱动只需实现 init 与 API,无需自己排顺序 |
| 调试 | 内置 Shell、日志分级、tracing、栈保护、崩溃捕获 | 多数问题不必接仿真器即可定位 |
| 官方硬件 | 1000+ 官方支持开发板,覆盖 Nordic / NXP / ST / Espressif / Renesas 等 | 新项目优先选已支持板,把精力留给业务 |
量产项目一律锁定 3.7 LTS 或明确的主线 tag,不要长期跟随 main 分支。Zephyr 的 API 与设备树绑定在主线演进中会调整,跟着 main 走意味着每次同步都可能要改应用代码。 正确做法是:应用仓库独立,通过 west.yml 固定各个依赖的 commit(见 19.1),需要升级时一次性升并回归,而不是日常被动跟随。
1.3 术语与缩写
| 术语 | 全称 / 含义 | 出现场景 |
|---|---|---|
| Zephyr | Linux 基金会下的开源实时操作系统,面向资源受限设备 | 全文 |
| west | Zephyr 的多仓库管理与构建命令行工具 | 初始化、构建、烧录、升级模块 |
| Devicetree / DT | 描述硬件拓扑的树形结构,编译期生成 C 宏 | 节点定义、overlay、驱动取址 |
| overlay | 叠加在基础设备树之上的补丁文件 | 应用级硬件变更 |
| binding | YAML 文件,声明某类节点有哪些属性及其约束 | 自定义节点必需 |
| Kconfig | 配置系统,定义全部可选功能与编译期选项 | prj.conf、menuconfig |
| prj.conf | 项目配置文件,Kconfig 片段 | 根目录,声明 CONFIG_ 宏 |
| Kconfig 合并链 | 板级 conf、芯片 conf、prj.conf、命令行 overlay 的合并顺序 | 见 7.2 |
| struct device | Zephyr 的设备对象,内核用它统一管理所有驱动 | 驱动注册、应用取用 |
| DEVICE_DT_INST_DEFINE | 声明式设备注册宏,为每个实例生成设备对象 | 驱动代码的核心宏 |
| 初始化等级 / level | 设备初始化的阶段,从 PRE_KERNEL_1 到 APPLICATION | 设备依赖排序(见 5.3) |
| device_is_ready | 检查设备是否初始化成功 | 应用取用设备前必查 |
| k_work_q | 工作队列,把工作项延迟到线程上下文执行 | 中断里只发事件,处理放工作队列 |
| NVS | Non-Volatile Storage,轻量键值存储,ID 为 16 位 | 配置与少量参数 |
| ZMS | Zephyr Memory Storage,键值存储,支持 32/64 位 ID 与大数据 | 高容量存储、外部 Flash |
| MTD / flash_map | Flash 分区抽象层,向上提供分区对象 | 分区与存储后端的基础 |
| fstab | 文件系统表节点,支持开机自动挂载 | LittleFS / FatFs 挂载 |
| settings | 配置持久化子系统,可挂 NVS / ZMS / FCB / 文件 | 子系统级配置存储 |
| MCUboot | Zephyr 生态的引导加载程序 | 安全启动、镜像升级与回滚 |
| sysbuild | 多镜像构建系统,取代早期的 west build -t boot | 应用 + bootloader 联合构建 |
| native_sim | 把 Zephyr 编译成宿主机原生程序运行 | 主机侧快速仿真验证 |
1.4 五层架构总览
Zephyr 采用分层架构 + 编译期声明双视角:分层决定「谁能调用谁」,声明决定「编译时包含什么、硬件怎么连」。两者叠加的效果是——应用只写业务与配置,硬件细节全部由设备树供给。
- ⑤ 应用层:你的业务代码与官方 samples。唯一「自己写」的一层,也是唯一不随芯片变化的层。
- ④ 子系统层:存储、网络、蓝牙、日志、追踪等可裁剪的中间件。裁剪由 Kconfig 决定,未使能的子系统链接时完全不进镜像。
- ③ 设备模型层:Zephyr 的枢纽。
struct device承载状态,API 表承载操作,初始化等级决定谁先起来。它让「换一颗芯片不改应用」成为可能。 - ② 内核层:线程、调度、同步原语、内存分配器、工作队列、定时器。多核下由 SMP 支撑。
- ① 硬件抽象层:
arch(内核移植)、SoC 层(时钟与外设驱动)、board 层(板级 DTS 与分区)、以及贯穿全局的 Kconfig 裁剪。
应用里直接写寄存器、或驱动里直接改设备树以外的东西,都会破坏「换板只改 overlay」的承诺。应用只通过设备 API 说话;寄存器操作只允许出现在 SoC 层与设备驱动内部;板级差异只允许出现在 DTS / overlay 中。
1.5 与 Linux / FreeRTOS / RT-Thread / AliOS 的差异
选型时最常问的问题是「Zephyr 比 Linux 多了什么、比 FreeRTOS 多了什么」。下表按能力维度对照,深色列为 Zephyr。
逐条说明差异的实际含义:
| 维度 | Zephyr 的做法 | 带来的收益 | 付出的代价 |
|---|---|---|---|
| 硬件描述 | 设备树强依赖,驱动不写死寄存器与引脚 | 同一驱动跨厂商复用,换料只改 overlay | 必须学 DT 语法与绑定,调试链路多一层 |
| 功能裁剪 | Kconfig 声明,未启用即不链接 | 镜像小、无死代码、可复现 | 配置项数量大,需要理解依赖关系 |
| 设备初始化 | 统一等级 + 同级 prio 排序 | 驱动作者不必手工排 init 顺序 | 等级选错会导致启动期竞态(见 5.3) |
| 设备访问 | 设备 API 表,不经文件系统 | 类型安全、错误码统一 | 无法像 Linux 那样用 open/read 统一访问 |
| 网络与蓝牙 | 内置 TCP/IP 栈与完整 BLE 协议栈 | IoT 设备无需外挂协议栈 | 内存占用高于精简方案,需精细裁剪 |
| 存储 | MTD 分区 + NVS / ZMS / LittleFS / FatFs | 数据持久化开箱即用 | 抽象层多,学习成本在「该选哪个后端」 |
| 内存保护 | MCU 上用 MPU 做线程隔离 | 比裸 RTOS 更安全 | 无进程与虚拟内存,不能跑 Linux 应用 |
| 启动与升级 | MCUboot + sysbuild 多镜像 | 安全启动与 A/B 回滚生态成熟 | 镜像布局需预先规划,升级路径设计不可省 |
需要完整网络/蓝牙生态 + 跨厂商硬件复用 + 量产级安全启动,且芯片带 MCU 级资源,选 Zephyr; 需要极简可控、几乎不用中间件,或团队对 DT/Kconfig 无从下手,选 FreeRTOS; 需要应用处理器 + 复杂文件系统 + 进程隔离,选 Linux; 需要国产 RTOS 生态与组件化 yaml 构建,选 RT-Thread 或 AliOS Things。
1.6 阅读路线图
| 你的目标 | 推荐阅读顺序 | 预计时长 |
|---|---|---|
| 移植一颗新 MCU / 新板卡 | 2 → 6 → 5 → 11 → 13 → 20 → 附录 D | 半天读完,动手 2~3 天 |
| 现有板子跑不起来 / 设备无反应 | 5.4 → 6.5 → 13.3 → 13.5 → 20.3 | 1 小时定位 |
| 做数据持久化与存储布局 | 14 全章 → 17.4 → 附录 A | 半天 |
| 做低功耗产品 | 10 全章 → 16.1 → 16.4 → 14.6 | 半天 |
| 量产前做发布准备 | 17 → 18 → 19 → 附录 D | 1 天 |
| 做 Zephyr 驱动移植 | 先读本篇第 5、6、13 章 → 转向《Zephyr 驱动开发指南》 | — |
本篇讲的是「系统怎么组装」:工作区、配置、设备模型、构建、存储布局、低功耗、OTA。 《Zephyr 驱动开发指南》讲的是「驱动怎么写:目录结构、DT 绑定、Kconfig、具体总线与器件驱动的完整范例。 两者共享同一套 Devicetree 与 Kconfig 基础,因此建议先读本篇第 5、6 章,再进入驱动篇。
2 · 开发环境与 west 工作区
这一章解决:怎么把一套能复现的 Zephyr 开发环境搭起来,以及 west 工作区、Zephyr SDK、你的应用三者的关系。环境没搭对,后面所有章节的排查都会失真。
2.1 宿主机依赖与 Zephyr SDK
Zephyr 对宿主机有明确要求。SDK 与工具链版本必须与你的 Zephyr 基线匹配,这是最常见的「明明代码没错却编译不过」的根因。
| 依赖项 | 版本要求 / 说明 | 校验方式 |
|---|---|---|
| Zephyr SDK | 与 Zephyr 版本配套的独立工具链集合(含各架构交叉编译器与调试器) | 设置 ZEPHYR_SDK_INSTALL_DIR 后执行 west build,CMake 前段会打印 SDK 路径 |
| CMake | ≥ 3.20(Zephyr 对最低版本有硬性检查) | cmake --version |
| Python | ≥ 3.10(含 pip、venv、west、pyelftools 等) | python3 --version |
| dtc | 设备树编译器;SDK 内自带时无需单独装 | 构建日志中 -- Building DT 段无报错即正常 |
| Git | west 依赖 git 拉取所有 project | git --version |
| west | pip 安装的 Zephyr 前端工具 | pip install west,west --version |
| pip 依赖 | Zephyr 的 scripts/requirements.txt 中列出的 Python 包 | pip install -r zephyr/scripts/requirements.txt |
Zephyr 会检查 SDK 版本是否与源码基线匹配,用错版本会在 CMake 配置阶段直接报版本不匹配并中止,而不是给出有意义的编译错误。
另外注意主机上若同时装了系统包与 pip 包的同名工具(如 cmake、west),PATH 顺序会决定实际调用哪一个——排查「参数不被识别」类怪问题时,先确认 which 指向哪里。
2.2 west 工作区初始化与模块管理
west 解决的是「Zephyr 由几十个仓库组成」的问题:它按 manifest 把所有 project 拉到同一层目录,并保证各仓库处于声明的 revision 上。
初始化步骤
- 装依赖:先装 Python、CMake、Git,再
pip install west,最后装匹配的 Zephyr SDK。 - 初始化工作区:
west init ~/zephyrproject(已有仓库可west init -l <已有目录>就地接管)。 - 更新全部仓库:
west update,把 manifest 声明的所有 project 拉到指定 revision。 - 装 Python 包:
pip install -r ~/zephyrproject/zephyr/scripts/requirements.txt。 - 设置环境变量:把
ZEPHYR_BASE指向~/zephyrproject/zephyr,把ZEPHYR_TOOLCHAIN_VARIANT设为zephyr(用 SDK 时)。 - 验证:构建一次官方 sample,确认工具链与串口日志都通。
# 1) 装 west 与 Python 依赖
pip install west
pip install -r ~/zephyrproject/zephyr/scripts/requirements.txt
# 2) 初始化工作区(首次)
west init ~/zephyrproject
cd ~/zephyrproject
west update
# 3) 指定 SDK 并验证
export ZEPHYR_TOOLCHAIN_VARIANT=zephyr
export ZEPHYR_SDK_INSTALL_DIR=~/zephyr-sdk-0.17.0
# 4) 跑通第一个应用(以官方 sample 验证环境)
west build -b qemu_cortex_m3 zephyr/samples/hello_world
west build -t run # native_sim / qemu 可直接跑,不需要真板
west update 会把每个 project 切到 manifest 声明的 revision,你在这些仓库里的本地未提交修改可能被覆盖或产生冲突。
因此永远不要在 Zephyr 官方仓库里直接改代码——需要改动时,走 11.5 的 Zephyr module 或 out-of-tree 驱动机制。
另外,west update 后若行为异常,先确认 west list 里各仓库的版本是否与预期一致。
2.3 三种应用形态与选型
Zephyr 官方定义了三种应用与源码的相对位置关系。选错了会导致 ZEPHYR_BASE 找不到、或应用被 Zephyr 更新冲掉。
三个要点:
- T2 是生产项目的默认选择:你的应用就是一个独立的 Git 仓库,通过
west.yml把 Zephyr、MCUboot、厂商 HAL 的 commit 钉死。构建可复现、CI 好做、升级节奏自控(详见 19.1)。 - T1 适合验证:改官方 sample 最省事,但不要用于量产——它会随 Zephyr 仓库变动。
- T3 适合跨团队:应用与 Zephyr 彻底分离,但要自己保证
ZEPHYR_BASE与 SDK 环境变量在所有机器上一致。
2.4 工程目录结构详解
一个结构良好的应用目录,能让新同事在半天内看懂「配置在哪、代码在哪、板级差异在哪」。推荐如下布局:
my-product/
├── west.yml # 清单:钉住 zephyr / mcu_boot / hal_* 的 commit
├── CMakeLists.txt # find_package(Zephyr) + target_sources
├── prj.conf # 基础 Kconfig:所有板子共用的配置
├── VERSION # 版本号(签名与上报用)
├── app.overlay # 基础 overlay:所有板子共用的硬件变更
├── boards/
│ ├── <board1>.conf # 板级配置:只放该板特有项
│ ├── <board1>.overlay # 板级 overlay:引脚 / 外设差异
│ └── custom_board/ # 自定义板:dts / defconfig / yaml / Kconfig.board
├── src/
│ ├── main.c # 应用入口
│ ├── ble/ # 按子系统分目录,各自独立编译
│ │ └── CMakeLists.txt
│ ├── sensors/
│ │ └── CMakeLists.txt
│ └── storage/ # 存储业务逻辑
│ └── CMakeLists.txt
├── include/ # 对外头文件
├── drivers/ # out-of-tree 驱动(不进 Zephyr 源码树)
│ └── my_flash/
│ ├── CMakeLists.txt
│ ├── my_flash.c
│ └── dts/bindings/vendor,my-flash.yaml
├── tests/ # ztest 用例与 testcase.yaml
│ ├── CMakeLists.txt
│ └── prj.conf
├── scripts/ # 构建、烧录、签名辅助脚本
└── child_image/ # 子镜像配置(如 MCUboot 的 Kconfig 片段)
└── mcuboot.conf
这不是洁癖:每个子目录一个 CMakeLists.txt 意味着一个子系统由一个人负责,两个工程师改不同子系统时几乎不会产生合并冲突。
把所有源文件堆在一个 target_sources 列表里,短期省事,长期会在团队规模变大后成为冲突重灾区。
2.5 west 常用命令速查
| 命令 | 作用 | 常用场景 |
|---|---|---|
west init [-l 目录] | 初始化或接管工作区 | 首次搭建 / 接管已有仓库 |
west update | 拉取并切到 manifest 声明的 revision | 初始化后;升级 Zephyr 后 |
west list | 列出各 project 及当前 revision | 确认版本是否对齐 |
west build -b <board> <app> | 构建指定板的应用 | 日常构建 |
west flash | 烧录上次构建的产物 | 上板验证 |
west debug | 启动调试器并停在入口 | 调崩溃 / 断点 |
west attach | 附加到运行中的目标 | 看活体状态 |
west build -t menuconfig | 交互式配置 | 裁剪功能、查依赖 |
west build -t run | 构建并运行(native_sim / qemu) | 主机仿真 |
west build -t pristine | 清理构建目录后重建 | 改了 Kconfig 却不生效时 |
west manifest --resolve | 更新 west.yml 锁定的 revision | 主动升级依赖 |
west compare | 对比 project 之间的差异 | 跨仓库定位改动 |
这是 Zephyr 里最高频的「诡异现象」:Kconfig 的依赖关系变了,但 CMake 缓存不知道。
解决办法是 west build -t pristine 清掉构建目录重建(不要手动去删 .config,删不干净)。
养成习惯:凡是改过 Kconfig 或新增驱动目录,验证前先 pristine 一次。
2.6 构建产物与烧录
Zephyr 不支持 in-tree 构建,产物全部落在独立的 build/ 目录。知道每个产物是什么、留哪个做现场分析,才能在出问题时不至于抓瞎。
| 产物 | 路径 | 用途 |
|---|---|---|
| 合并后设备树 | build/zephyr/zephyr.dts | 排查设备树问题的第一站;可看到 overlay 合并后的最终结果 |
| 生成头文件 | build/zephyr/include/generated/ | 含 devicetree_unfixed.h 与 autoconf.h(最终生效的 CONFIG_) |
| elf 产物 | build/zephyr/zephyr.elf | 调试与栈回溯解析;量产归档必需 |
| bin 产物 | build/zephyr/zephyr.bin | 烧录用(无符号) |
| 链接映射 | build/zephyr/zephyr.map | 查 ROM/RAM 占用、定位大符号、分析栈大小 |
| 二进制尺寸 | build/zephyr/zephyr.stat | 各段占用明细,配合 16.1 做预算 |
| 配置快照 | build/zephyr/.config | 复现构建、排查宏定义差异 |
把每次量产的 zephyr.elf、zephyr.map、.config、zephyr.dts 四件套一并归档。
线上崩溃时,只有与现场固件完全一致的 elf 才能正确解析栈回溯——版本对不上的符号表解析出来的行号全是错的,等于没有信息。
2.7 native_sim 主机仿真
Zephyr 有一个常被忽视的能力:把整个系统编译成宿主机原生程序,在 PC 上跑起来。板级代码与真实外设不可用,但内核、驱动逻辑、协议栈、存储子系统都能真实执行。
# 编译为宿主机程序并直接运行(无需任何硬件)
west build -b native_sim ~/zephyrproject/my-product
west build -t run
# 常用配套手段
# - 单元测试:把被测逻辑写成 ztest,用 native_sim 跑
# - 逻辑层:协议解析、状态机、算法、数据结构都能在此验证
# - 配合 CONFIG_ARCH_POSIX,可用 ptrace/gdb 调宿主程序
能测:内核行为、驱动状态机与错误处理、协议栈与解析逻辑、文件系统读写、并发与竞态、内存泄漏(宿主工具链友好)。 不能测:真实时序、电气特性、Flash 与 SRAM 的真实磨损、无线性能、中断延迟、功耗。 经验做法是:把「逻辑正确性」在 native_sim 上穷尽验证,把「时序与电气」留到真板压测,两边职责不重叠。
3 · 内核线程与调度
这一章解决:线程是怎么创建与切换的、优先级怎么设才算合理、栈与内核内存从哪来、多核下要注意什么。线程模型没想清楚,后面的并发缺陷与环境无关 bug 会集中爆发。
3.1 线程模型与生命周期
Zephyr 内核是可抢占的实时内核:默认按优先级抢占,支持协作式调度(优先级设为负数)与时间片(可选)。一个应用在编译期最多可用的线程数由配置决定,运行时由内核统一管理。
创建线程的基本形态如下,注意启动延迟与栈大小两个参数:
#include <zephyr/kernel.h>
/* 线程入口:返回即终止 */
static void sensor_thread(void *a, void *b, void *c)
{
while (1) {
/* 周期性工作 */
sample_and_process();
k_msleep(1000); /* 让出 CPU,进入阻塞态 */
}
}
/* 静态方式:栈与控制块由应用提供,链接期确定,无运行时分配 */
K_THREAD_STACK_DEFINE(sensor_stack, 2048);
static struct k_thread sensor_thr;
static struct k_sem sensor_sem;
K_THREAD_DEFINE(sensor_tid, 2048, sensor_thread, NULL, NULL, NULL,
5, 0, 500); /* 优先级 5、启动延迟 0、立即启动 */
/* 动态方式:栈由内核内存池分配,适合数量与栈大小运行期才确定的场景 */
int sensor_start(void)
{
k_tid_t tid = k_thread_create(&sensor_thr, sensor_stack,
K_THREAD_STACK_SIZEOF(sensor_stack),
sensor_thread, NULL, NULL, NULL,
5, 0, K_NO_WAIT);
if (tid == NULL) {
return -EAGAIN; /* 内存不足 */
}
return 0;
}
能用 K_THREAD_DEFINE 就不要用 k_thread_create。
静态方式栈在链接期确定:不会被运行时内存不足打断、启动时序可预测、map 文件里能直接查到栈位置,栈溢出也更容易用 CONFIG_THREAD_STACK_SENTINEL 定位。
动态方式只在「线程数量或栈大小由运行期输入决定」时使用(例如按不同产品型号加载不同功能)。
3.2 优先级与抢占模型
Zephyr 的优先级数值越小越优先,范围通常是 -1 到 NUM_PRIORITIES-1。其中负值表示协作式(不可被抢占,只在显式让出时切换),非负表示抢占式。
| 优先级范围 | 类型 | 抢占行为 | 典型用途 |
|---|---|---|---|
| 负数(如 -1) | 协作式 | 不会被任何线程抢占,只能主动让出或阻塞 | 延迟敏感、必须按序跑完的处理线程 |
| 0(默认) | 抢占式 | 可被优先级更高者抢占;同级按配置的时间片轮转 | 普通后台线程(数量最多的一档) |
| 1 ~ 15 | 抢占式(高) | 优先级高于 0 档,优先获得 CPU | 采样、I/O 收发、控制回路等有实时要求的线程 |
| ISR 优先级 | 中断优先级 | 与线程优先级是两套独立体系 | 见 9.2;高优先级 ISR 可打断任何线程 |
三条排优先级的实用准则
- 线程优先级数量控制在 5~7 档以内。档位越多,每档至少一个线程,栈与内存开销线性增长;档位过密则优先级反转的复杂度上升。
- 0 档留给「普通后台」,所有不需要实时性的业务线程都放这一档;实时线程只给真正有时序要求的那几条。常见错误是给每个外设都开一个高优先级线程,导致高优先级线程长期霸占 CPU。
- 用完高优先级必须主动阻塞:实时线程做完事就
k_msleep或等信号量,不要空转等待。红线 空转的高优先级线程会让所有低优先级线程饿死,是「系统偶尔卡住」的头号原因。
高优先级线程里写 while (!ready) { k_msleep(10); } 这种「轮询 + 延时」看似安全,实际问题是它把系统最低响应时间拉长到 10 ms,且掩盖了真正的同步设计缺失。
正确做法是用同步原语阻塞等待(k_sem_take / k_msgq_get / k_fifo_get),让 CPU 在等待期间真正释放。
3.3 调度器实现机制
Zephyr 的调度器有两个实现可换,行为差异明显:
| 实现 | 配置宏 | 机制 | 适用场景 |
|---|---|---|---|
| Meta IRQ(默认) | 无(默认) | 调度逻辑在内核态 IRQ 上下文中完成,内核不占用普通中断号 | 绝大多数 MCU 应用;延迟可预测,调试直观 |
| EDF(最早截止期优先) | CONFIG_SCHED_EDF | 每个线程带截止期,按最早截止期调度;支持 k_thread_deadline_set() | 需要「周期性截止期」语义的任务;TWS(时间触发)场景 |
| 时间片轮转 | CONFIG_TIMESLICING | 同级线程按固定时间片轮流执行 | 同级任务需公平推进时开;会增加切换开销 |
调度器锁存点(z_reschedule)发生在中断退出、阻塞 API 返回、显式让出三个时机。理解这一点能解释一个常见现象:为什么在中断里改优先级后,被提升的线程不是立刻运行,要等中断返回——因为调度判定在中断退出时统一做。
/* 典型正确用法:中断只做「标记 + 唤醒」,重活放工作队列 */
static void sensor_isr(const struct device *dev)
{
struct sensor_ctx *ctx = dev->data;
/* ISR 内允许:读设备状态、置标志、k_sem_give / k_work_enqueue */
k_sem_give(&ctx->data_ready);
/* ISR 内禁止:k_mutex_lock、k_msleep、打印、动态内存分配 */
}
3.4 线程栈与内核内存
Zephyr 的栈有两种来源,这与 FreeRTOS 常见做法不同,也是最易踩坑的地方。
| 栈来源 | 配置宏 | 内存归属 | 特点与注意 |
|---|---|---|---|
| 主栈(系统栈) | CONFIG_MAIN_STACK_SIZE | 由 CONFIG_MAIN_STACK_SIZE 决定,可由 CONFIG_USERSPACE 影响 | 内核自身与 main 线程使用;主栈溢出是最高频的崩溃原因 |
| 线程栈 | CONFIG_THREAD_STACK_INFO 等 | 由 k_thread_create 从系统内存池分配 | 栈大小由你指定;SMP 下每核另有内核栈 |
| 静态栈 | K_THREAD_STACK_DEFINE | 链接期分配在 BSS 段 | 栈溢出可用 sentinel 检测(见 8.4) |
CONFIG_MAIN_STACK_SIZE 的默认值通常只够跑最小示例。一旦应用里有深层调用链(协议栈解析、文件格式化、JSON 解析、printf 浮点),默认主栈很快就不够。
典型崩法是:平时好好的,一收到特定报文就 HardFault,栈回溯显示 printf 或解析函数——这类问题的根因几乎总是主栈偏小。
做法:把主栈调到 4 KB 起步,并打开栈 sentinel 观察实际水位(见 8.4、16.2)。
3.5 SMP 与多核支持
Zephyr 支持对称多核(SMP),但多核支持需要 SoC 与板级配合:SoC 层需实现 CONFIG_SMP 下的 CPU 启动与中断路由,板级 DTS 需描述各核(通常是 cpus 节点)。
| 事项 | 单核(默认) | 多核(CONFIG_SMP) | 注意 |
|---|---|---|---|
| 内核栈 | 一个主栈 | 每核一个内核栈 | 各核栈不能互相踩;SMP 下主栈归属需确认 |
| 线程亲和性 | 不需要 | k_thread_cpu_set() 绑定到指定核 | 绑定后可减少 cache 抖动与伪共享 |
| 全局变量 | 普通变量即可 | 需 atomic_t / 原子操作保护跨核共享 | 普通 int 的 ++ 在多核下不安全 |
| 中断路由 | 单一中断向量表 | 需 CPU 接口层指定目标核 | 由 SoC 层实现,不是应用层关心的事 |
| 实测建议 | 绝大多数 MCU 应用无需 SMP | 只在有明确并行收益时开启 | SMP 会显著增加调试难度与栈占用 |
SMP 带来的调试复杂度上升(竞态出现概率、性能不可复现、栈水位难评估)通常大于它带来的收益。 单核下用「多线程 + 优先级 + 工作队列」已经能榨干绝大多数 MCU 的并发能力。 确有并行收益时(例如双核分别跑协议栈与控制),再开启 SMP 并认真做亲和性规划。
4 · 同步原语与线程通信
这一章解决:用什么原语保护共享资源、怎么在线程间传数据、死锁与优先级反转怎么防。并发缺陷是 Zephyr 系统里最难复现也最难查的一类问题,核心纪律是「提前设计」而不是「事后调试」。
4.1 互斥量与信号量
Zephyr 的互斥类原语有四种,最常用的两个是 k_mutex 与 k_sem,选错会导致隐性 bug。
| 原语 | 可否在 ISR 中使用 | 是否睡眠 | 优先级继承 | 典型用途 |
|---|---|---|---|---|
k_mutex | 否 | 是 | 有(默认) | 保护共享数据结构、驱动内互斥 |
k_sem | give 可以,take 不行 | take 时睡 | 无(不持有锁) | ISR 通知线程、同步节拍 |
k_spinlock | 可以(两种上下文) | 否 | 无 | 中断与线程共享的小临界区 |
atomic_t | 可以 | 否 | 无 | 计数器、标志位、简单状态 |
k_mutex_lock() 在 ISR 中调用是确定的错误:它可能让出 CPU,而中断上下文不允许让出。
这与 FreeRTOS 中「中断里不能用 xSemaphoreTake(..., portMAX_DELAY)」是同一条纪律,只是 Zephyr 直接禁用更彻底。
中断里要发信号就用 k_sem_give()(它不阻塞),或更推荐直接把工作项丢进工作队列(见 12.1)。
另外三条容易忽略的细节:
- 一个锁只保护一个资源。不要用一把全局大锁保护所有共享数据——正确做法是「每个资源一把锁」,减少锁竞争与持锁时间。
- 解锁必须用
k_mutex_unlock且路径唯一。用goto汇到统一出口解锁,是 C 语言里最稳的写法。 k_sem没有优先级继承。所以它不能用来保护共享资源,只能用来「通知」。用信号量做互斥是常见且隐蔽的错误。
/* 推荐写法:单资源单锁 + 统一出口解锁 */
struct dev_ctx {
struct k_mutex lock; /* 只保护本驱动的状态 */
struct device_state state;
bool busy;
};
static int dev_write(struct dev_ctx *c, const void *buf, size_t len)
{
int rc = 0;
if (k_mutex_lock(&c->lock, K_FOREVER) != 0) {
return -EBUSY;
}
if (c->busy) { /* 业务条件判断也放在锁内 */
rc = -EBUSY;
goto out; /* 统一出口,避免漏解锁 */
}
c->busy = true;
/* 真正的寄存器操作放在这里,且尽量短 */
hw_write(buf, len);
out:
if (rc == 0) {
c->state = STATE_IDLE;
}
c->busy = false;
k_mutex_unlock(&c->lock);
return rc;
}
4.2 消息队列与 FIFO
需要在线程间传数据时用消息队列或 FIFO,而不是轮询共享变量 + 标志位。
| 原语 | 传递内容 | 内核是否分配 | ISR 可用 | 适用 |
|---|---|---|---|---|
k_msgq | 定长结构体,按值拷贝 | 发送时需用户先给缓冲池 | put 可以 | 控制命令、传感器采样值、状态包 |
k_fifo | 字节流 | 不分配,用户提供的缓冲 | put 可以 | 串口收发的环形缓冲、日志输出 |
k_fifo_alloc | 字节流 | 内核分配缓冲 | put 可以 | 不想自己管缓冲时的省事选择 |
两条实战要点:
- 优先用
k_msgq而非「环形缓冲 + 锁」。手写环形缓冲的锁保护与满/空判断是 bug 高发区;消息队列把这些边界处理内建了。 - 消息体不要直接传大结构体指针,除非你保证指针指向的对象在接收侧处理完之前不会被释放。传指针的风险是生命周期不受控——这是嵌入式里最难查的一类 use-after-free。
/* 推荐:定长消息按值传递,生命周期完全可控 */
struct sensor_sample {
uint32_t timestamp; /* ms */
int16_t temp_c10; /* 温度 x10,避免浮点 */
uint16_t humidity_c100;
};
K_MSGQ_DEFINE(sample_q, sizeof(struct sensor_sample), 8, 4);
/* 生产者(可以是线程或工作队列) */
static void on_sample_ready(const struct sensor_sample *s)
{
/* 非阻塞发送:队列满时返回 -ENOMSG,由调用方决定丢弃还是覆盖 */
int rc = k_msgq_put(&sample_q, s, K_NO_WAIT);
if (rc == -ENOMSG) {
/* 必须处理「满了」:丢弃最旧、覆盖新值、或降采样 */
}
}
/* 消费者线程 */
static void consumer_thread(void *a, void *b, void *c)
{
struct sensor_sample s;
while (1) {
if (k_msgq_get(&sample_q, &s, K_FOREVER) == 0) {
process(&s);
}
}
}
「队列满时丢最新」还是「丢最旧」还是「触发降采样」——这是必须由业务决定的策略,不能留空。
Zephyr 的 k_msgq_put 在队列满且传 K_NO_WAIT 时返回 -ENOMSG,不会阻塞、不会覆盖,所以这个分支一定会被走到。
把处理写进代码里,而不是靠日志事后发现数据丢了。
4.3 事件位与广播
当一个状态变化需要通知多个订阅方时,用事件位(k_event)比广播指针更安全——它只传递「发生了什么」,不传递「数据在哪」。
/* 事件位:只传状态,不传指针,从根上避免悬垂指针 */
#define EVT_SENSOR_READY BIT(0)
#define EVT_SENSOR_ERROR BIT(1)
#define EVT_LOW_BATTERY BIT(2)
/* 发送方:设置位并广播 */
k_event_set(&app_events, EVT_SENSOR_READY);
/* 接收方:等待任意位或指定位 */
uint32_t events = EVT_SENSOR_READY | EVT_SENSOR_ERROR;
k_event_wait(&app_events, events, false, K_FOREVER);
/* 非阻塞轮询版本:适合在主循环里检查 */
if (k_event_test(&app_events, EVT_LOW_BATTERY)) {
handle_low_battery();
}
k_event 的价值在于把「状态」与「数据」分离:接收者被唤醒后,自己决定去哪里读数据(例如从一个受锁保护的上下文中读)。
这比传递指针更安全,也更容易扩展(新增订阅者不改发送方)。
4.4 死锁与优先级反转
并发缺陷有两类典型形态:死锁是「双方互等」的直接卡死,优先级反转是「高优先级被低优先级间接阻塞」的隐性饥饿。两者都必须在设计阶段消除,靠调试是碰运气。
死锁的预防清单
- 全局固定加锁顺序:需要同时持多把锁时,按锁地址(或编号)升序加锁,绝不反序。
- 临界区内不做阻塞操作:锁内禁止
k_msleep、k_msgq_get(K_FOREVER)、printf、动态分配。 - 用带超时的获取:关键路径用
k_mutex_lock(&lock, K_MSEC(50)),超时后走降级路径,而不是无限等待。 - 用 K_NO_WAIT 试探:不可重入的场合先试探,失败就返回
-EBUSY交给上层重试,避免原地死等。 - 锁的数量控制在最小:能用单锁解决就不要两锁;需要两锁时优先考虑「合并成一个锁 + 临界区内处理」。
优先级反转的预防清单
- 保护共享数据一律用
k_mutex(默认开启优先级继承),不要用k_sem做互斥。 - 缩短持有时间:持锁期间只做「必须原子完成」的最短操作,耗时计算放到锁外。
- 不要在锁内做 I/O:Flash 读写、SPI 传输、串口发送都可能长时间占用总线,放在锁内会显著拉长临界区。
- 提高共享资源的持有者优先级:如果某资源被一个固定线程独占,可把该线程优先级提到使用者之上。
代码里 k_mutex_lock 出现次数的多少,直接决定并发缺陷的概率。
健康的做法是让共享状态集中在一两个上下文里(例如一个驱动线程 + 一个工作队列),其余部分通过消息传递交互——这样锁的数量自然被压到最低,也更容易验证正确性。
4.5 原语选型决策表
| 你要做的事 | 选这个 | 不要用 | 原因 |
|---|---|---|---|
| ISR 通知线程有数据了 | k_sem_give 或 k_work_enqueue | k_msgq_put(可能分配) | ISR 内应避免可能分配内存的调用 |
| 保护驱动的设备状态 | k_mutex | k_sem | 信号量无优先级继承,且语义上不表达「互斥」 |
| 中断与线程共享一个计数器 | atomic_t | k_mutex | 自旋锁在中断里也不该长时间持锁,原子操作更短 |
| 给线程传一条控制命令 | k_msgq | 共享全局变量 + 标志位 | 边界处理(满/空)内建,避免竞态 |
| 缓冲串口不定长收发的字节流 | k_fifo | k_msgq 逐字节 | 字节流场景 FIFO 更贴合,零额外分配 |
| 多个模块都要知道「状态变了」 | k_event | 传递指针回调 | 只传状态不传指针,无生命周期风险 |
| 跨模块读取当前状态 | 受锁保护的 getter | 直接暴露全局变量 | 直接暴露会让所有调用方都成为潜在竞态源 |