摘要:本文是 Zephyr RTOS 多线程编程的入门实战课。你将理解线程(Thread)与裸机顺序执行的区别,掌握
K_THREAD_DEFINE()创建静态线程、k_sleep()/k_msleep()主动让出 CPU、线程优先级(数值越小优先级越高)以及线程生命周期(Ready → Running → Sleeping → Ready)等核心概念,并通过三个线程交替打印的完整实验,在 ESP32-S3 或 NUCLEO-F303RE 上观察多线程并发调度的实际效果。
10 - Thread(线程)
学习目标:
理解 Zephyr RTOS 的多线程机制,学会创建线程、设置优先级、线程休眠以及线程调度。
这是从“写程序”迈向“RTOS”最重要的一课。
一、Thread 是什么
在裸机程序中,一般都是
while(1){task1();task2();task3();}CPU 只能按顺序执行。
例如
采集ADC ↓ 发送UART ↓ 刷新LED ↓ 再次采集ADC所有事情都是串行。
在 RTOS 中则变成
Thread A 采集ADC Thread B UART发送 Thread C LED闪烁CPU 负责不断切换。
例如
CPU ↓ Thread A ↓ Thread B ↓ Thread C ↓ Thread A ↓ Thread B这就是 Thread。
二、Zephyr 线程模型
在 Zephyr 中,每个线程都有:
入口函数 Stack Priority 状态例如
Thread1voidthread1(void){}Thread2voidthread2(void){}每个线程都有自己的 Stack。
+----------------+|Thread1 Stack|+----------------+ +----------------+|Thread2 Stack|+----------------+ +----------------+|Main Stack|+----------------+三、创建线程
最常用API
k_thread_create()但是官方更推荐
K_THREAD_DEFINE()因为简单。
例如
K_THREAD_DEFINE(blink_tid,1024,blink_thread,NULL,NULL,NULL,5,0,0);参数分别表示:
线程ID Stack大小 入口函数 参数1 参数2 参数3 Priority Options Delay 线程入口voidblink_thread(void){while(1){}}四、第一个 Thread 程序
例如:
#include<zephyr/kernel.h>voidthread1(void){while(1){printk("Thread1\\n");k_sleep(K_MSEC(1000));}}K_THREAD_DEFINE(thread1_id,1024,thread1,NULL,NULL,NULL,5,0,0);intmain(void){while(1){printk("Main\\n");k_sleep(K_MSEC(500));}}输出类似:
Main Main Thread1 Main Main Thread1 Main Thread1说明:
Main线程和Thread1同时运行。
五、两个线程
增加第二个线程:
voidthread2(void){while(1){printk("Thread2\\n");k_sleep(K_MSEC(700));}}再定义
K_THREAD_DEFINE(thread2_id,1024,thread2,NULL,NULL,NULL,5,0,0);运行后可能看到:
Main Thread1 Thread2 Main Thread2 Main Thread1 Main Thread2三个线程共同运行。
六、线程优先级
Zephyr中:
数字越小 优先级越高例如:
Priority0最高Priority1↓ Priority2↓ Priority5↓ Priority10↓ 最低例如:
Thread1 Priority=2Thread2 Priority=8为了更直观地理解多线程调度的价值,下面从五个维度对比裸机顺序执行与 RTOS 多线程调度的差异:
| 对比维度 | 裸机顺序执行 | RTOS 多线程调度 |
|---|---|---|
| 执行方式 | 单任务串行,while (1)循环内按顺序轮询各任务 | 多线程并发,由调度器按优先级分配 CPU |
| CPU 利用率 | 较低,某任务阻塞时 CPU 空转等待 | 较高,某线程休眠/等待时 CPU 立即切换给其他线程 |
| 实时性 | 较差,高优先级任务可能被前面的任务阻塞 | 较好,高优先级线程可抢占低优先级线程 |
| 代码复杂度 | 简单,逻辑线性直观 | 较高,需考虑线程同步、资源共享与优先级设计 |
| 适用场景 | 功能简单、任务少、实时性要求低的场景 | 任务多、实时性要求高、需要并发处理的场景 |
说明:裸机程序结构简单、易于调试,但任务之间相互阻塞,CPU 利用率低;RTOS 通过线程调度让 CPU 在多个任务间高效切换,显著提升实时性与资源利用率,代价是代码结构和调试难度更高。实际项目中应根据需求权衡选择。
那么Thread1会更容易获得CPU。
为了更直观地理解,下面列出不同优先级数值对应的调度优先级和典型使用场景:
| 优先级数值 | 调度优先级 | 典型使用场景 |
|---|---|---|
| 0 | 最高 | 实时性要求极高的任务,如电机控制、紧急中断处理 |
| 1 | 很高 | 关键控制任务,如传感器数据采集、通信协议处理 |
| 5 | 中等 | 普通业务逻辑,如状态机、数据处理 |
| 10 | 较低 | 后台任务,如日志输出、统计信息汇总 |
提示:数值越小,优先级越高。优先级 0 的线程会优先于优先级 5 的线程获得 CPU;实际项目中建议为不同实时性需求的任务分配合理的优先级,避免高优先级线程长期占用 CPU 导致低优先级线程饿死。
七、线程休眠
最常用:
k_sleep(K_SECONDS(1));或者
k_sleep(K_MSEC(500));也可以
k_msleep(500);线程休眠期间:
CPU可以运行其它线程。如果线程一直运行而不休眠:
while(1){}它可能持续占用CPU,导致其他同优先级线程得不到执行机会(除非启用了时间片轮转)。
八、线程状态
线程一般有:
Ready Running Sleeping Pending Dead流程可以用下面的状态图更直观地表示:
图中展示了线程的主要状态流转:
- Ready(就绪):线程已准备好运行,等待调度器分配 CPU。
- Running(运行):线程正在占用 CPU 执行代码。
- Sleeping(休眠):线程调用
k_sleep()主动让出 CPU,休眠结束后回到 Ready。 - Pending(挂起):线程等待某个事件(如信号量、消息队列),条件满足后回到 Ready。
- Dead(终止):线程执行完毕退出。
九、实验
创建三个线程。
Main 每500ms打印一次Thread A 每1000ms打印一次Thread B 每1500ms打印一次预期输出:
Main ThreadA Main ThreadB Main ThreadA Main ThreadB注意:由于线程调度和 printk() 的执行时机不同,每次输出顺序可能略有变化,这是正常现象。
十、推荐目录结构
10-Thread/│ ├── CMakeLists.txt ├── prj.conf ├── src/│ └── main.c └── README.mdprj.conf
CONFIG_PRINTK=y CONFIG_SERIAL=y CONFIG_CONSOLE=ysrc/main.c
#include<zephyr/kernel.h>#include<zephyr/sys/printk.h>voidthread_a(void){while(1){printk("Thread A\\n");k_sleep(K_SECONDS(1));}}voidthread_b(void){while(1){printk("Thread B\\n");k_sleep(K_MSEC(1500));}}K_THREAD_DEFINE(thread_a_id,1024,thread_a,NULL,NULL,NULL,5,0,0);K_THREAD_DEFINE(thread_b_id,1024,thread_b,NULL,NULL,NULL,5,0,0);intmain(void){while(1){printk("Main\\n");k_sleep(K_MSEC(500));}}编译并下载到你的ESP32-S3或NUCLEO-F303RE后,就能在串口终端观察三个线程交替输出。编译并下载到你的ESP32-S3或NUCLEO-F303RE后,就能在串口终端观察三个线程交替输出。
十一、常见问题与排查
在实际运行中,你可能会遇到下面几种典型问题。这里给出原因分析和解决步骤,帮助你快速定位。
问题 1:线程不打印
现象:程序编译下载后,串口终端只有 Main 输出,看不到 Thread1 / Thread2 的输出。
可能原因:
- 线程入口函数没有被正确调用(
K_THREAD_DEFINE()参数顺序写错)。 - 线程栈大小设置过小,导致线程启动时栈溢出而崩溃。
- 线程优先级设置过高或过低,导致调度器一直不选中它。
printk()输出被缓冲,串口终端刷新不及时。
解决步骤:
- 检查
K_THREAD_DEFINE()的参数顺序,确认入口函数名与定义一致:
K_THREAD_DEFINE(thread1_id,/* 线程 ID */1024,/* 栈大小 */thread1,/* 入口函数 */NULL,/* 参数 1 */NULL,/* 参数 2 */NULL,/* 参数 3 */5,/* 优先级 */0,/* 选项 */0/* 延时 */);- 确认入口函数内部有循环和休眠,否则线程执行一次就退出了:
voidthread1(void){while(1){printk("Thread1\n");k_sleep(K_MSEC(1000));}}- 在
prj.conf中确认已开启串口打印:
CONFIG_PRINTK=y CONFIG_SERIAL=y CONFIG_CONSOLE=y- 如果仍然没有输出,尝试把栈大小从
1024增大到2048,排除栈溢出导致的崩溃。
问题 2:优先级设置无效
现象:明明给 Thread1 设置了更高的优先级(数值更小),但两个线程的输出频率看起来差不多,甚至低优先级线程反而更频繁。
可能原因:
- 两个线程都在
k_sleep()后进入 Sleeping,休眠时间不同导致输出频率不同,与优先级无关。 - 高优先级线程休眠时间较长,低优先级线程休眠时间较短,低优先级线程反而更频繁获得 CPU。
- 没有启用时间片轮转,同优先级线程之间不会自动切换。
解决步骤:
先确认两个线程的休眠时间是否一致。如果 Thread1 休眠 1000ms、Thread2 休眠 100ms,那么 Thread2 输出更频繁是正常的,与优先级无关。
验证优先级是否生效,可以让高优先级线程不休眠地持续打印,观察它是否独占 CPU:
voidhigh_prio_thread(void){while(1){printk("High\n");}}K_THREAD_DEFINE(high_prio_id,1024,high_prio_thread,NULL,NULL,NULL,0,0,0);/* 优先级 0,最高 */- 如果希望同优先级线程之间也能轮流执行,需要在
prj.conf中启用时间片轮转:
CONFIG_TIMESLICING=y问题 3:栈溢出导致系统崩溃
现象:程序运行一段时间后突然停止输出,或者系统复位重启,串口出现异常。
可能原因:
- 线程栈大小设置过小,局部变量、函数调用栈把栈空间耗尽。
- 在中断或线程中使用了较大的局部数组。
- 递归调用或深层函数调用导致栈增长过快。
解决步骤:
- 增大栈大小,例如从
1024改为2048或4096:
K_THREAD_DEFINE(thread1_id,2048,thread1,NULL,NULL,NULL,5,0,0);- 避免在线程内定义过大的局部数组,改用静态或全局变量:
/* 不推荐:局部大数组占用栈空间 */voidthread1(void){charbuffer[1024];/* 占用 1KB 栈 */while(1){/* ... */}}/* 推荐:使用静态数组,不占栈空间 */staticcharbuffer[1024];voidthread1(void){while(1){/* ... */}}- 在
prj.conf中开启栈溢出检测,方便定位问题:
CONFIG_THREAD_STACK_INFO=y CONFIG_DEBUG_THREAD_INFO=y开启后,如果发生栈溢出,系统会打印相关错误信息,帮助你定位是哪个线程出了问题。
问题 4:线程只运行一次就退出
现象:线程打印一次后就不再输出,主线程仍在运行。
可能原因:
- 线程入口函数没有
while (1)循环,执行完就返回了。 - 线程入口函数返回后,线程进入 Dead 状态,不会再被调度。
解决步骤:
- 确保线程入口函数内部有无限循环:
voidthread1(void){while(1){printk("Thread1\n");k_sleep(K_MSEC(1000));}}- 如果确实需要线程只执行一次后退出,这是正常行为,无需修复;但要注意线程退出后其栈空间仍然被占用。
本课重点总结
完成本课后,你应该掌握:
K_THREAD_DEFINE() 创建静态线程
k_sleep() / k_msleep() 让线程主动让出 CPU
Zephyr 线程优先级规则(数值越小,优先级越高)
线程的基本生命周期(Ready → Running → Sleeping → Ready)
多线程并发执行与调度的基本概念