4. 线程管理基础:线程的创建、线程属性与状态机
好,咱们今天聊聊线程管理。这是Zephyr RTOS里最基础、也最核心的一块。说白了,你写嵌入式应用,就是在跟线程打交道。我刚开始接触Zephyr时,觉得线程创建不就是调个API嘛,后来踩了不少坑才明白,线程属性配不好,系统跑起来就是各种诡异。
4.1 线程的创建:两种方式,各有千秋
Zephyr里创建线程有两种方式:静态创建和动态创建。我个人习惯用静态创建,尤其是在资源受限的MCU上。为什么?因为静态创建在编译时就分配好了所有资源,运行时不会因为内存不足而失败。
先看静态创建的代码:
#define MY_STACK_SIZE 1024
#define MY_PRIORITY 5
K_THREAD_DEFINE(my_tid, MY_STACK_SIZE,
my_entry_function, NULL, NULL, NULL,
MY_PRIORITY, 0, 0);
这个宏干了三件事:分配栈空间、创建线程控制块、注册线程到调度器。你看,一行代码就搞定了。我有个项目用STM32F103,RAM只有20KB,用静态创建能精确控制每个线程占多少栈,心里踏实。
动态创建呢?用k_thread_create():
struct k_thread my_thread;
k_tid_t my_tid;
my_tid = k_thread_create(&my_thread, stack_buffer, stack_size,
my_entry_function, NULL, NULL, NULL,
priority, 0, K_NO_WAIT);
动态创建的好处是灵活——你可以在运行时决定要不要创建线程。但要注意,动态创建需要你手动管理栈内存,而且如果堆碎片化了,创建可能失败。嗯,这里要注意:在Zephyr里,动态创建并不等于动态分配栈,栈还得你提前准备好。
4.2 线程属性:优先级、栈大小、入口函数
线程属性里,优先级和栈大小是最容易出问题的两个。咱们一个一个说。
4.2.1 优先级
Zephyr的优先级数值越小,优先级越高。0是最高优先级,CONFIG_NUM_PREEMPT_PRIORITIES - 1是最低。可抢占优先级和协作优先级是分开的,这个设计很有意思。
| 优先级范围 | 类型 | 说明 |
|---|---|---|
| 0 ~ (CONFIG_NUM_PREEMPT_PRIORITIES - 1) | 可抢占 | 可以被更高优先级线程打断 |
| 负值 | 协作(非抢占) | 必须主动让出CPU |
你想想看,如果两个线程优先级相同,Zephyr会用时间片轮转调度。但如果你把关键任务设成协作优先级,它不主动让出CPU,其他线程就永远跑不了。我遇到过这种情况:一个传感器采集线程设成了协作优先级,结果它在一个循环里做数据处理,忘了加k_yield(),整个系统的低优先级任务全部饿死。
4.2.2 栈大小
栈大小怎么定?这是个玄学。Zephyr提供了K_THREAD_STACK_SIZEOF()宏来帮你计算,但实际项目中我一般这样估算:
- 简单任务(只调几个API):512字节起步
- 中等任务(有局部数组、调用多层函数):1024~2048字节
- 复杂任务(有递归、大缓冲区):4096字节以上
但说实话,最靠谱的方法还是实测。Zephyr有栈溢出检测功能,打开CONFIG_INIT_STACKS和CONFIG_STACK_SENTINEL,系统会在栈溢出时触发异常。我每个项目都会在调试阶段开着这个功能,等稳定了再关掉省资源。
/* 在prj.conf里打开栈检测 */
CONFIG_INIT_STACKS=y
CONFIG_STACK_SENTINEL=y
CONFIG_DEBUG=y
嗯,这里要注意:栈检测会占用额外CPU时间,生产环境建议关掉。但调试阶段千万别省这一步。
4.2.3 入口函数
入口函数就是线程启动后执行的第一个函数。它的原型是:
void my_entry_function(void *arg1, void *arg2, void *arg3);
三个参数可以传任意指针。我常用这个机制给多个相同功能的线程传不同的参数。比如控制三个LED,可以创建三个线程,入口函数相同,但参数传不同的GPIO引脚号。
k_thread_abort()终止这个线程。所以入口函数里通常是个死循环。
4.3 线程状态机:从生到死
线程的状态切换,是理解调度机制的关键。Zephyr的线程有这几个状态:
- 就绪(Ready):线程可以运行,正在等待调度器分配CPU
- 运行(Running):正在占用CPU执行
- 挂起(Suspended):线程被主动暂停,需要别人唤醒
- 等待(Waiting):在等某个事件(信号量、消息队列等)
- 终止(Terminated):线程执行完毕或被强制结束
状态切换的典型路径是这样的:
创建 → 就绪 → 运行 → 等待(等事件)→ 就绪 → 运行 → 终止
你想想看,为什么要有挂起状态?我刚开始学RTOS时觉得挂起和等待差不多。后来做项目才明白:挂起是主动行为,比如你有一个调试线程,平时不需要跑,就把它挂起。等需要调试信息时,再唤醒它。而等待是被动的,线程在等某个条件满足。
看个实际例子:
void sensor_thread(void *arg1, void *arg2, void *arg3)
{
while (1) {
/* 等待传感器数据就绪 */
k_sem_take(&sensor_sem, K_FOREVER);
/* 此时线程状态:就绪 → 运行 */
read_sensor_data();
process_data();
/* 循环结束,再次等待 */
/* 线程状态:运行 → 等待 */
}
}
这个线程大部分时间都在等待状态,不占CPU。只有传感器中断触发了信号量,它才被唤醒。这就是事件驱动的好处——省电、高效。
k_thread_state_str()可以打印线程当前状态。我在调试复杂死锁问题时,会在控制台定时打印所有线程的状态,一眼就能看出哪个线程卡在等待上了。
最后说一句:线程状态机看着简单,但实际项目中状态切换的时机非常关键。我曾经在一个项目里,两个线程互相等待对方释放资源,结果双双进入等待状态,系统死锁。排查时看状态机,两个线程都显示"等待",但等的是什么?得靠经验去分析代码了。
嗯,线程管理基础就这些。下一节咱们聊聊调度器的具体工作方式,包括时间片轮转和优先级抢占是怎么配合的。到时候我会拿一个实际的多任务场景来拆解,保证你听完就能上手用。