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里,动态创建并不等于动态分配栈,栈还得你提前准备好。

我的经验:产品代码里尽量用静态创建。调试阶段可以用动态创建快速验证想法,但定型后我会全部改成静态的。曾经有个同事在量产固件里用了动态创建,结果某批次芯片的RAM时序有点偏差,堆分配偶尔失败,线程没创建出来,系统直接卡死。排查了两天才找到原因。

4.2 线程属性:优先级、栈大小、入口函数

线程属性里,优先级和栈大小是最容易出问题的两个。咱们一个一个说。

4.2.1 优先级

Zephyr的优先级数值越小,优先级越高。0是最高优先级,CONFIG_NUM_PREEMPT_PRIORITIES - 1是最低。可抢占优先级和协作优先级是分开的,这个设计很有意思。

优先级范围 类型 说明
0 ~ (CONFIG_NUM_PREEMPT_PRIORITIES - 1) 可抢占 可以被更高优先级线程打断
负值 协作(非抢占) 必须主动让出CPU

你想想看,如果两个线程优先级相同,Zephyr会用时间片轮转调度。但如果你把关键任务设成协作优先级,它不主动让出CPU,其他线程就永远跑不了。我遇到过这种情况:一个传感器采集线程设成了协作优先级,结果它在一个循环里做数据处理,忘了加k_yield(),整个系统的低优先级任务全部饿死。

避坑指南:我曾经把一个中断底半处理线程设成了可抢占优先级0,结果它把其他所有线程都阻塞了。后来我改成优先级2,配合信号量同步,系统才稳定下来。记住:优先级越高,责任越大。

4.2.2 栈大小

栈大小怎么定?这是个玄学。Zephyr提供了K_THREAD_STACK_SIZEOF()宏来帮你计算,但实际项目中我一般这样估算:

  • 简单任务(只调几个API):512字节起步
  • 中等任务(有局部数组、调用多层函数):1024~2048字节
  • 复杂任务(有递归、大缓冲区):4096字节以上

但说实话,最靠谱的方法还是实测。Zephyr有栈溢出检测功能,打开CONFIG_INIT_STACKSCONFIG_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引脚号。

核心要点:入口函数不能返回!如果返回了,线程就结束了。Zephyr会调用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()可以打印线程当前状态。我在调试复杂死锁问题时,会在控制台定时打印所有线程的状态,一眼就能看出哪个线程卡在等待上了。

最后说一句:线程状态机看着简单,但实际项目中状态切换的时机非常关键。我曾经在一个项目里,两个线程互相等待对方释放资源,结果双双进入等待状态,系统死锁。排查时看状态机,两个线程都显示"等待",但等的是什么?得靠经验去分析代码了。

嗯,线程管理基础就这些。下一节咱们聊聊调度器的具体工作方式,包括时间片轮转和优先级抢占是怎么配合的。到时候我会拿一个实际的多任务场景来拆解,保证你听完就能上手用。