4. 内核基础概念:线程、优先级、时间片与状态机

好,咱们今天聊点实在的。线程、优先级、时间片、状态机、线程栈、TCB——这几个词,说白了就是 RTOS 的骨架。你把这套东西吃透了,Zephyr 的内核调度在你眼里就跟透明的一样。

我记得刚接触 RTOS 那会儿,总觉得“线程”不就是个函数嘛,跑起来就行了。结果第一次在产品里遇到优先级反转,系统直接卡死,我才意识到——嗯,这些东西真不能想当然。

4.1 线程(Thread)到底是什么?

在 Zephyr 里,线程就是一条独立的执行流。每个线程有自己的栈、自己的优先级、自己的状态。你可以把它理解成一个“轻量级任务”——它和别的线程共享 CPU,但各自保存现场。

我个人习惯把线程分成三类:

  • 主线程:系统启动后自动创建的,跑你的 main() 函数
  • 用户线程:你通过 k_thread_create() 或 K_THREAD_DEFINE 创建的
  • 系统线程:内核内部用的,比如空闲线程(idle thread)

关键点:Zephyr 的线程在创建时就已经分配好了栈空间和 TCB,不是在运行时动态分配的。这一点和 Linux 的 pthread 完全不同,别搞混了。

4.2 优先级——谁先跑谁后跑

优先级决定了调度器该先让谁上 CPU。Zephyr 用的是数值越小优先级越高的规则——0 是最高优先级。

优先级范围 说明 典型用途
0 ~ (CONFIG_NUM_COOP_PRIORITIES - 1) 协作式优先级(Cooperative) 关键控制逻辑,不能被抢占
CONFIG_NUM_COOP_PRIORITIES ~ (CONFIG_NUM_PRIORITIES - 1) 抢占式优先级(Preemptive) 普通任务、通信协议栈

这里有个坑——我曾经在一个项目中把两个线程设成相同优先级,结果发现它们轮流跑,但其中一个总是“饿死”。为什么?因为时间片没开。后面咱们会聊到。

避坑指南:我曾经在协作式线程里写了一个死循环,结果整个系统都停了。协作式线程不会主动让出 CPU,你必须手动调用 k_yield() 或者让它阻塞。否则,低优先级线程永远没机会跑。

4.3 时间片——公平轮转的秘诀

时间片(Time Slice)是给同优先级线程用的。当多个线程优先级相同时,调度器让每个线程跑一个固定时长,然后切换到下一个。这个固定时长就是时间片。

Zephyr 里时间片是全局配置的,单位是系统 tick。你可以在 prj.conf 里设置:

CONFIG_TIMESLICING=y
CONFIG_TIMESLICE_SIZE=5    # 每个线程跑 5 个 tick
CONFIG_TIMESLICE_PRIORITY=0  # 只对优先级大于等于 0 的线程生效

我个人建议:时间片别设太小,否则上下文切换开销会吃掉你的 CPU。5~10 个 tick 是个不错的起点。

4.4 线程状态机——线程的“人生”

每个线程在它的生命周期里会经历几个状态。Zephyr 的线程状态机比一些 RTOS 要简洁,但核心逻辑是一样的。

Zephyr 线程状态机 就绪(Ready) 运行(Running) 阻塞(Blocked) 挂起(Suspended) 终止(Terminated) 调度器选中 时间片用完/被抢占 等待资源 资源就绪 k_thread_suspend() k_thread_resume() 线程退出 说明: • 就绪态:线程已准备好运行,等待调度器分配 CPU • 阻塞态:线程在等待某个事件(信号量、队列、超时等) • 挂起态:被其他线程或自己主动暂停,不参与调度

你看这张图,线程的状态流转其实很直观。我刚开始学的时候,总以为“阻塞”和“挂起”是一回事——其实不是。阻塞是因为资源没到位,被动等待;挂起是主动暂停,谁叫醒谁恢复。

4.5 线程栈——每个线程的“私人空间”

每个线程都有自己的栈,用来存局部变量、函数调用链、中断上下文。Zephyr 的线程栈是在编译时就分配好的,不是 malloc 出来的。

定义线程栈有两种方式:

// 方式一:静态定义(推荐)
K_THREAD_STACK_DEFINE(my_stack, 1024);

// 方式二:动态分配(不常用)
k_thread_stack_t *my_stack = k_thread_stack_alloc(1024, 0);

栈大小怎么定?我有个经验法则:先给个 2048 字节,跑起来后用 k_thread_stack_analyze() 看实际用了多少,再调整。别一开始就给 64 字节——我曾经这么干过,结果栈溢出,数据被悄悄覆盖,查了两天才找到原因。

小技巧:Zephyr 支持栈溢出检测(CONFIG_STACK_SENTINEL=y)。开启后,内核会在栈底放一个“哨兵值”,一旦被覆盖就触发异常。调试阶段一定要开这个。

4.6 线程控制块(TCB)——线程的“身份证”

每个线程在内核里都有一个对应的 TCB,Zephyr 里叫 struct k_thread。它记录了线程的所有信息:

字段 说明 我常用的场景
base 线程基础属性(优先级、状态标志) 调试时查看线程状态
callee_saved 上下文切换时保存的寄存器 理解切换开销
stack_info 栈起始地址和大小 检查栈使用情况
thread_group 线程组归属 批量操作线程
join_queue 等待该线程结束的其他线程 实现线程同步

TCB 是内核调度器的“操作手册”。调度器切换线程时,就是先保存当前线程的上下文到它的 TCB,再从目标线程的 TCB 里恢复上下文。说白了,TCB 就是线程的“快照”。

深入理解:Zephyr 的 TCB 是静态分配的,放在全局数据区。这意味着你创建的线程数量受限于编译时的配置(CONFIG_MAX_THREAD_BYTES)。别想着动态创建几千个线程——嵌入式系统不是服务器。

4.7 把它们串起来——一个完整的线程示例

说了这么多,咱们看个实际例子。下面这段代码创建了两个线程,一个高优先级跑控制逻辑,一个低优先级做日志输出:

#include <zephyr/kernel.h>

/* 定义线程栈 */
K_THREAD_STACK_DEFINE(ctrl_stack, 2048);
K_THREAD_STACK_DEFINE(log_stack, 1024);

/* 定义 TCB */
struct k_thread ctrl_thread;
struct k_thread log_thread;

/* 线程函数 */
void ctrl_entry(void *arg1, void *arg2, void *arg3)
{
    while (1) {
        /* 控制逻辑 */
        k_sleep(K_MSEC(10));  /* 主动让出 CPU */
    }
}

void log_entry(void *arg1, void *arg2, void *arg3)
{
    while (1) {
        /* 日志输出 */
        k_sleep(K_MSEC(100));
    }
}

void main(void)
{
    /* 创建线程 */
    k_thread_create(&ctrl_thread, ctrl_stack,
                    K_THREAD_STACK_SIZEOF(ctrl_stack),
                    ctrl_entry, NULL, NULL, NULL,
                    5, 0, K_NO_WAIT);

    k_thread_create(&log_thread, log_stack,
                    K_THREAD_STACK_SIZEOF(log_stack),
                    log_entry, NULL, NULL, NULL,
                    10, 0, K_NO_WAIT);

    /* 主线程也做点事 */
    while (1) {
        k_sleep(K_SECONDS(1));
    }
}

你看,控制线程优先级是 5,日志线程是 10。数值越小优先级越高,所以控制线程会优先跑。日志线程只有在控制线程阻塞时才有机会上 CPU。

嗯,这里要注意:k_thread_create() 的最后一个参数 K_NO_WAIT 表示创建后立即加入就绪队列。如果你传 K_FOREVER,线程会先挂起,等你手动 k_thread_start() 才跑。

好了,这一章的内容就到这儿。线程、优先级、时间片、状态机、栈、TCB——这六个概念是 Zephyr 内核调度的基石。你把这些搞清楚了,后面讲调度算法、同步机制的时候,就会觉得顺理成章。