第一次接触Zephyr的人十有八九会被它的工程结构绕晕。裸机开发的时候一个main.c加上启动文件就能跑到了Zephyr这里光一个CMakeLists.txt就能劝退一批人。我最初从裸机转过来时最不适应的就是“应用Application”这个概念——它不是一个文件不是一个函数而是一整套目录加构建规则。这篇是系列第2篇假设你已经把west环境搭好、跑通过Hello World那我们就来解决一个更实际的问题怎么在Zephyr工程里添加一个真正属于你自己的用户应用程序并让它稳定跑起来。Zephyr官方文档里“用户”其实有两层含义一层是指使用者自己写的应用工程另一层指用户模式User Mode——非特权线程的运行方式。这两个概念经常混在一起把新手绕晕。我会把“添加应用”的完整流程拆开讲透顺手把用户模式这个进阶话题也一起理清楚。不论你是想给自己的板子写一个应用还是想把部分逻辑放到非特权级别下运行这篇都能给你一个可以照着做的答案。1. 在Zephyr里“添加一个应用”的真实含义是什么先纠正一个思维定式。STM32裸机开发时一个.ioc文件加一个Keil工程就是全部。Zephyr不是这么玩的它更像Linux内核与BusyBox那套思路——一个工作区workspace里可以存在多个应用程序彼此独立通过west统一管理。1.1 一个容易被忽视的前提Zephyr是多应用工作区以官方默认的west init拉下来的目录为例你的工作区里会同时存在zephyr、bootloader、modules、tools等目录。zephyr目录是RTOS内核源码而你的应用可以放在工作区里的任意一个独立目录不一定非得塞进zephyr内部。很多新手会犯一个错把自己写的main.c直接丢进zephyr/samples/下面去改。这也能编译但它污染了内核源码树一旦west update或切换分支你的代码可能就没了。正确做法是创建一个独立的应用目录让它和zephyr目录平级比如my_workspace/ ├── .west/ ├── zephyr/ ├── bootloader/ ├── modules/ └── tools/ └── my_user_app/ ├── CMakeLists.txt ├── prj.conf └── src/ └── main.c只要CMakeLists.txt写得对west build -b board my_user_app就能直接找到并编译它。因为west在构建时会根据ZEPHYR_BASE环境变量或find_package(Zephyr)指令自动定位内核源码你只需要告诉它应用目录在哪。1.2 编译一个应用背后的west到底做了什么west build并不是简单的gcc main.c。它内部干了三件事先调用CMake配置构建系统再调用Ninja执行实际编译最后生成可烧录的镜像文件。west build -b qemu_cortex_m3 my_user_app这条命令等价于CMake配置时指定了板级目标board target。为什么需要-b参数指定开发板因为Zephyr是高度可移植的RTOS同一个应用你想让它跑在QEMU模拟器上还是跑在一颗真实的STM32芯片上硬件相关的引脚定义、时钟配置、外设驱动都不同。这些差异通过设备树Device Tree和Kconfig配置来隔离而-b参数就是告诉构建系统“你现在要为哪个目标硬件生成镜像”。我建议你从QEMU目标开始验证应用逻辑再烧到真实板子上。理由很简单QEMU上跑挂了不会烧坏硬件而且west build -t run可以直接启动模拟器看到串口输出调试循环效率高得多。提示west build -t run在QEMU目标上会自动运行模拟器。真实板子请用west build -t flash。这两条命令搞混了轻则程序没跑起来重则在本地启动一个莫名其妙的模拟器进程。2. 动手前先决定应用放在工作区里还是工作区外上一节说了推荐放在工作区根目录但Zephyr实际上支持更灵活的布局。你完全可以把应用放在/home/me/projects/my_app这种完全独立于Zephyr工作区的位置编译时指定路径即可。2.1 west workspace的标准布局与app的位置west本身是一个Python写的多仓库管理工具。west init之后工作区根目录下会生成一个.west/config文件里面记录了manifest仓库位置。默认的manifest仓库是zephyrproject-rtos/zephyr它维护着内核代码和所有模块的版本组合。应用放哪里都可以但有一个约束如果你想使用Zephyr自带的模块比如cmsis、hal_stm32这些模块由west update下载到工作区里你的应用CMakeLists.txt里引用的头文件路径就得能穿透到这些模块目录。放在工作区外也完全没问题find_package(Zephyr)会处理路径。实际项目中如果是公司级别的代码仓库普遍做法是应用仓库单独托管然后用west的manifest-app机制把应用仓库挂到工作区里west init -m https://your-manifest-repo --manifest-app https://your-app-repo--manifest-app会让west update自动把应用仓库克隆到工作区根目录同时保证应用目录里生成的构建元数据不会污染其他仓库。这是多团队协作的标准姿势。2.2 standalone应用与manifest应用的关系如果把应用放在工作区外每次编译都要cd到目录里执行west build吗不用。west build -d build_dir可以指定构建目录west build --pristine可以强制全量重建。这些技巧在调试难缠的增量编译问题时很有用。我自己建议个人学习和原型验证阶段就用最简单的方式——在工作区根目录建一个app文件夹。别急着上manifest-app那套复杂流程先把“应用目录CMakeListsprj.conf”这个铁三角搞清楚后面无论怎么挪窝都不会慌。3. 应用的最小文件集合与每个文件的真实作用一个能独立编译的Zephyr应用最少只需要三个文件CMakeLists.txt、prj.conf、src/main.c。别小看这三个文件每一个背后都有讲究。3.1 CMakeLists.txt不是随便抄要理解find_package做了什么我见过不少人从sample里照抄一份CMakeLists.txt路径改都不改编译出稀奇古怪的错误。最简写法是这样的cmake_minimum_required(VERSION 3.20.0) find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE}) project(my_user_app) target_sources(app PRIVATE src/main.c)find_package(Zephyr REQUIRED HINTS $ENV{ZEPHYR_BASE})这一行的作用是让CMake找到Zephyr内核的构建定义它会自动引入内核源码目录、设备树解析规则、工具链配置等一大堆东西。如果ZEPHYR_BASE没有设置west build会在构建前自动设置它这也是“必须用west而不是裸敲cmake”的原因之一。project(my_user_app)这条要放在find_package(Zephyr)之后。因为Zephyr在find_package阶段会定义它自己的一套变量和编译器选项如果你提前project()部分配置可能被覆盖编译出来的内核和你预期的不一致。target_sources(app PRIVATE src/main.c)注意这里的app不是随便起的名字它是Zephyr预先定义好的一个CMake target所有应用源码都要挂到这个target下面。如果你有其它源文件继续往这里加就行。另外还需要注意include目录target_include_directories(app PRIVATE include)如果你应用里带了自己的头文件一定要像上面这样显式声明否则编译器会报找不到头文件。3.2 prj.conf和Kconfig决定内核行为的面板prj.conf是Kconfig的配置文件它控制内核和子系统编译时被裁剪成什么样。Zephyr的裁剪能力非常强一个最小内核可以只有十几KB这全靠Kconfig把用不到的功能全部排除掉。一个空文件也能编译。但真实应用里你至少会加几项CONFIG_PRINTKy CONFIG_LOGy CONFIG_LOG_DEFAULT_LEVEL3CONFIG_PRINTKy开启串口打印接口。CONFIG_LOG则是Zephyr的日志子系统比printk更灵活可以按模块设置日志级别。我最开始在QEMU上调网络协议栈时日志系统帮了大忙——没有它你真的只能靠猜。注意prj.conf是通用配置如果你需要针对某块板子做特殊配置可以新建prj_board.conf。比如你的应用默认想用CONFIG_MAIN_STACK_SIZE1024但某块RAM很小的板子需要缩小那就建一个prj_msp430fr5994.conf填上更小的值。构建系统会自动加载板级配置且板级配置优先级更高。3.3 main.c的写法随版本变迁Zephyr 2.x时代应用入口是void main(void)裸机开发的人很熟。从3.x开始Zephyr引入并推荐int main(void)返回0表示正常退出。不过嵌入式里main返回后系统状态是未定义的所以实际代码里最常见的结构还是死循环#include zephyr/kernel.h #include zephyr/sys/printk.h int main(void) { printk(My user app started!\n); while (1) { printk(tick %u\n, (unsigned int)k_uptime_get()); k_sleep(K_SECONDS(1)); } }#include zephyr/kernel.h是3.x的写法如果你用的是老版本可能是#include kernel.h。看版本报错再改头文件是常见操作。这里我还用到了k_uptime_get()获取系统上电后的毫秒数以及k_sleep让线程进入挂起状态。线程这块Zephyr和裸机最大的不同是main本身也是一个线程调度器会在多个线程之间切换。4. 把应用和开发板“绑定”起来board、overlay和配置同步同一个应用想跑在不同板子上Zephyr如何处理答案是-b参数加设备树overlay。4.1 同一份代码适配多块开发板的技巧假设你写了一个点灯应用引脚定义因板而异。你可以用设备树overlay文件来覆盖默认引脚配置。Zephyr的设备树机制继承了Linux设备树的那套逻辑用chosen节点、aliases节点定义“逻辑设备”的位置应用代码只跟逻辑设备打交道。一个常见做法是在应用目录下建boards/文件夹里面放board.overlay文件。构建系统编译特定板子时会自动搜索这个目录并应用对应overlay。举个点灯的例子。STM32F4系列某开发板上的LED接在PB0引脚应用目录里新建boards/nucleo_f401re.overlay/ { leds { compatible gpio-leds; user_led: led_0 { gpios gpiob 0 GPIO_ACTIVE_HIGH; label User LED; }; }; }; gpiob { status okay; };应用代码里用DEVICE_DT_GET(DT_NODELABEL(led_0))拿到LED设备句柄再用GPIO API控制。这样应用本身不需要知道具体板子引脚同一份代码换一个overlay就能适配别的板子。4.2 设备树overlay最少实例LED点灯prj.conf里还要打开GPIO子系统CONFIG_GPIOymain.c里完整驱动LED闪烁#include zephyr/kernel.h #include zephyr/drivers/gpio.h #define LED0_NODE DT_NODELABEL(led_0) int main(void) { const struct device *dev DEVICE_DT_GET(LED0_NODE); if (!device_is_ready(dev)) { return -1; } gpio_pin_configure(dev, 0, GPIO_OUTPUT_ACTIVE); while (1) { gpio_pin_set(dev, 0, 1); k_sleep(K_SECONDS(1)); gpio_pin_set(dev, 0, 0); k_sleep(K_SECONDS(1)); } }gpio_pin_configure(dev, 0, GPIO_OUTPUT_ACTIVE)这里的0是引脚序号对应overlay里gpios gpiob 0 ...的第二个字段。如果overlay里把引脚改成PC13这里的0依然不用动——因为应用代码只通过DT_NODELABEL定位逻辑设备具体引脚映射全在设备树层。这整套“逻辑抽象设备树映射”的设计是Zephyr和传统裸机开发最明显的分水岭。一开始会觉得很绕但当你需要在三四种硬件平台上维护同一套业务代码时就会感谢这种设计了。5. 从source到flash的完整实测流程说了这么多理论现在完整跑一遍。我以QEMU环境为例命令亲测可用。5.1 QEMU上跑通第一个用户应用的完整演示先创建工作区如果已经有环境就跳过west init my_workspace cd my_workspace west update然后创建应用目录mkdir -p my_user_app/src用上一节给的最小main.c加上CMakeLists.txt和prj.conf放到对应位置。然后执行west build -b qemu_cortex_m3 my_user_app -d my_user_app/build-d参数指定构建目录好处是不污染应用目录。第一次构建会比较久因为要生成很多东西看到Generating files...和Ninja: no work to do这种输出就说明构建成功了。接下来直接跑west build -d my_user_app/build -t runQEMU窗口会加载你的应用串口输出*** Booting Zephyr OS build zephyr-v3.x-xxxx *** My user app started! tick 0 tick 1000 ...看到这个说明你在Zephyr里添加的第一个用户应用已经成功跑起来了。5.2 连接真实板子时的注意事项与常见报错换真实开发板需要把-b参数换成你的板型。比如west build -b nucleo_f401re my_user_app -d my_user_app/build_nucleo west build -d my_user_app/build_nucleo -t flash烧录时遇到Error: open failed多半是调试器选择问题。Zephyr默认用pyocd或openocd烧录有时候你需要手动指定调试器west flash --runner pyocd如果系统里没装对应的烧录工具west flash会报错提示安装依赖。这里有一个我踩过的坑STM32板子用stm32cubeprogrammer烧录时如果板子被其他串口工具占用会烧录失败。先把占用串口的终端关掉再试。注意west build默认复用上次的构建目录。如果你换了一块板子却不换构建目录可能一直编译的是旧板配置。稳妥做法是用-d指定不同目录或者在换板前执行west build -t clean。6. 进阶让应用跑在“用户模式”这才是Zephyr的“用户”精髓标题里的“用户”如果是指User Mode那前面的内容只是铺垫。Zephyr的用户模式是它区别于FreeRTOS、RT-Thread的一项核心安全能力。这种机制借鉴了操作系统的用户态/内核态分离思想在MCU级别用MPU内存保护单元实现权限隔离。6.1 用户模式与内核模式的对比在Zephyr中默认所有线程跑在内核模式Kernel Mode可以访问所有内存空间、所有外设寄存器、调用全部内核API。这跟裸机编程没有本质区别。用户模式线程则运行在非特权级别MPU会限制它访问的内存地址范围。它不能随便访问外设寄存器不能直接修改内核数据结构只能通过系统调用System Call接口请求内核服务。如果有恶意或有bug的代码跑到不该访问的内存区域MPU会触发内存访问错误系统能及时制止而不是静默破坏数据。对比维度内核模式线程用户模式线程内存访问全部内存仅分配的栈和内存域外设访问直接访问需通过设备驱动或syscall内核API直接调用仅特定的syscall接口崩溃影响可能拉挂整个系统仅该线程被杀死资源开销低略高需要系统调用栈在航空电子、医疗设备、汽车电子这类对安全有要求的产品里把非关键逻辑放在用户模式把关键通信放在内核模式能显著降低单点故障导致系统崩溃的风险。6.2 一个能正常工作的用户模式Demo拆解要在应用里创建用户模式线程需要在prj.conf里打开CONFIG_USERSPACEy CONFIG_APPLICATION_MEMORYy然后主代码里这样写#include zephyr/kernel.h #include zephyr/sys/printk.h K_THREAD_STACK_DEFINE(user_stack, 2048); struct k_thread user_thread; void user_entry(void *p1, void *p2, void *p3) { printk(Hello from user mode thread\n); } int main(void) { k_thread_create(user_thread, user_stack, K_THREAD_STACK_SIZEOF(user_stack), user_entry, NULL, NULL, NULL, 5, K_USER, K_NO_WAIT); k_thread_join(user_thread, K_FOREVER); return 0; }关键在k_thread_create的倒数第二个参数K_USER。它告诉内核这个线程要放到非特权级别运行。如果软件配置不支持用户模式创建后会直接失败或崩溃。K_THREAD_STACK_DEFINE(user_stack, 2048)这里的栈大小为什么是2048用户模式线程的栈需要满足MPU的对齐和大小要求并且要预留系统调用栈的空间。我实测512字节跑用户模式Thread很容易崩2048是相对稳妥的下限具体取决于你的应用逻辑。你可能会问用户线程里能直接调用printk吗答案是可以但它背后走的不是简单的printk函数而是通过Zephyr的z_user_机制进入了系统调用路径。如果控制台设备没有被显式授权给该用户线程它会触发权限异常。在QEMU的qemu_cortex_m3目标上实测直接调用printk是能工作的因为控制台设备被设置成了对所有用户线程开放。真正容易出问题的是访问自定义的全局变量。内核模式下全局变量随便读写用户模式线程只能访问自己栈所在的内存域以及通过K_APP_BMEM应用内存分区定义的数据。区分K_APP_BMEM和K_APP_DMEM这两个宏也是用户模式编程的必修课——前者是应用内存区的BSS变量后者是已初始化数据段变量。忘了加这些宏用户线程读写一个普通全局变量分分钟MPU fault。7. 我踩过的坑与最终经验清单最后这部分我把自己在“添加用户应用”这件事上踩过的坑集中整理一遍。这些坑在官方sample里基本不会遇到但自己动手时几乎一定会踩到。7.1 最容易被忽略的构建缓存问题west build是增量构建它不会每次从头编译全部代码。好处是迭代快坏处是有些配置修改后不会完全生效。我遇到过最典型的场景修改了prj.conf里一个Kconfig选项但west build -t run跑出来的二进制还是旧行为。原因是Kconfig生成的autoconf.h没有重新生成导致编译用的配置还是旧的。解决办法很简单修改Kconfig配置后执行一次全量干净构建west build -d build --pristine或者干脆手动删掉整个build目录再重建。这招比任何“温和刷新”都管用。另一种情况是换了板子但没换-d目录构建系统会警告Board changed但仍会尝试复用旧的生成文件导致莫名其妙的链接错误。我的做法是一块板子一个目录名字里带板型build_nucleo_f401re/ build_qemu_cortex_m3/7.2 能直接抄走的经验清单问题现象产生原因解决动作fatal error: zephyr/kernel.h: No such file没有用west build或ZEPHYR_BASE未设置用west build不要裸敲cmake改未生效的Kconfig增量构建复用旧配置west build --pristine换板后链接报错构建目录残留旧配置换-d目录或全量clean编译时报CMake版本错误系统CMake过旧升级CMake到3.20用户线程MPU fault未指定K_USER选项或栈过小用K_THREAD_STACK_DEFINE定义栈传K_USER用户线程访问全局变量崩溃变量不在应用内存分区用K_APP_BMEM/K_APP_DMEM声明该变量west flash找不到调试器缺烧录工具或工具链版本不符用--runner指定烧录器或安装对应runner工具QEMU能跑但板子不跑设备树overlay未生效或硬件配置缺失核对boards/下overlay命名是否匹配板型另外还有两个小体会写在这供你参考。第一printk调试够用但别依赖它日志系统才是排查复杂问题的正路——尤其当你的应用涉及多个线程时日志模块的时间戳和分级过滤能大幅提升排查效率。第二不要在一开始就追求“完美目录结构”像include/、boards/这些目录都是有需要再建空目录反而会让CMake产生一些奇怪的空target警告。Zephyr的工程组织方式一开始确实繁琐但当你把“应用目录CMakeListsprj.confoverlay”这四个概念串起来之后多应用并行开发和跨板迁移都会顺畅很多。下一步建议你动手做一个小实验把这个my_user_app复制一份改名成my_user_app2在QEMU里同时跑两个应用看看工作区是否真的能做到多应用互不干扰。这是理解Zephyr应用架构最好的方式没有之一。
阅读完成 · 觉得有帮助?