📚 Part I: 第三章:内核篇(上):线程与调度 (核心)

(V5 最终版)

🔬 3.1 线程 (Thread) - 昂贵的“员工”

“线程是工作单元”,这句话太“面”了。我们从“成本”开扒。

一个线程(Thread)的“成本”是什么?

1. 内存成本:栈 (Stack)

  • 这是什么? 这是每个线程的“独立办公桌”和“私人储物柜”。它存放着:

    • 局部变量 (int my_local_var;)

    • 函数调用的返回地址

    • [重点] 线程被“抢占”时,CPU 所有寄存器(R0-R15, PC, SP...)的完整备份。

  • 代价: 每一个线程都必须有自己独立的栈空间。

  • 举例: 在 3.5 节的实战中,我们定义 K_THREAD_STACK_DEFINE(thread_a_stack, 1024)。这就是在静态内存 (RAM) 中划出了 1024 字节只给 A 线程用。

  • 专家视角(Gotcha 1): 你有 10 个线程?每个 1KB 栈?那 10KB 的 RAM 就这么没了。在一个只有 64KB RAM 的 MCU 上,这是巨大的开销。你不能(也不应该)随心所欲地创建 100 个线程。

2. 性能成本:上下文切换 (Context Switch)

  • 这是什么? 还记得“霸道经理一脚把 A 踹下灶台”吗?这个“踹”的动作不是免费的。

  • 它干了什么:

    1. 保存 A: 把 CPU 当前所有寄存器(A 的“思路”)全部复制到 A 的栈(储物柜)里。

    2. 恢复 B: 把 B 上次被踹下去时存的“思路”(B 储物柜里的寄存器)再原封不动地复制回 CPU。

  • 代价: 这个“复制”动作需要时间。虽然很快(几十到几百个时钟周期),但如果你的线程切换得过于频繁(比如 100 个线程都在等一个信号),CPU 就会把大量时间花在“换人”上,而不是在“干活”上。这就是所谓的系统抖动 (Jitter)。

至于“纤程 (Fiber)”?

忘了它。它是 Zephyr 早期的产物,为了省内存(共享栈)。但它带来的复杂度远超收益。现代 Zephyr(3.x 以后)已经不推荐使用它了。现在我们用 userspace(用户空间)和内存保护(MPU)来实现更高级的“特权分离”,这才是正道。


🚦 3.2 线程的生命周期 - “大厨”的 5 种状态

“入门者”看到的是 5 个“状态”。“专家”看到的是数据结构(队列)。

调度器(厨房经理)手里不是只有一个“就绪”名单,他手里有好几本不同的“花名册”:

  1. _wait_q (就绪队列 / Ready Queue):

    • 这是核心。 它不是一个队列,而是一个多级优先队列(一个数组,每个优先级对应一个链表)。

    • “就绪 (Ready)”状态的线程,都会根据自己的优先级,被挂在 _wait_q 对应的那条链表上。

    • 调度器永远只从这个“花名册”里优先级最高(数字最小)的链表里,挑排在第一个的线程去“运行 (Running)”。

  2. 各种“等待队列” (Wait Queues):

    • “等待/阻塞 (Pending)”不是一个状态,而是无数种状态。

    • 举例 1: 线程 A 调用 k_sem_take(&my_sem, K_FOREVER)。它会从 _wait_q 移出,被挂到 my_sem 这个信号量自己的 wait_q 上。

    • 举例 2: 线程 B 调用 k_sleep(K_MSEC(500))。它会从 _wait_q 移出,被挂到内核的**“超时队列 (Timeout Queue)”** 上,并定好一个 500ms 后的“闹钟”。

    • 专家视角: 这才是为什么 RTOS 高效。当一个线程在“等待”时,它不占用任何 CPU,调度器甚至都“懒得看”它。只有当“事件”(如 k_sem_give 或“闹钟响了”)发生时,内核才会把这个线程从“等待队列”移回“就绪队列 _wait_q”,它才有机会再次被运行。

  3. _current_thread (运行指针):

    • “运行 (Running)”状态的线程,全系统永远只有一个。它的指针存放在 _current_thread 变量里。


👨⚖️ 3.3 调度器详解 - “Tick” 还是 “Tickless”?

我们说了“霸道经理”(抢占式)。但他“检查”的频率是怎样的?

1. 传统的“Tick 滴答”内核

  • 原理: 你设置一个硬件定时器(比如 Systick),让它以固定频率(比如 100Hz,即每 10ms)产生一次中断。这个中断就是“Tick”(时钟滴答)。

  • 行为: 每 10ms,Systick 中断触发,CPU 暂停所有工作,进入调度器。调度器检查一下:

    1. 有没有“闹钟”到点了?(k_sleep 结束了?)

    2. 当前线程的“时间片”用完了吗?(如果 CONFIG_TIMESLICING 开启了)

  • 时间片 (Timeslicing): 如果你有两个同优先级的线程(A 和 B,都是 Prio 7),调度器会执行“轮转调度”:让 A 跑 10ms (一个 Tick),然后换 B 跑 10ms,再换 A... 确保“公平”。

  • 缺点: 功耗高。即使 CPU 没事干,它也必须每 10ms 醒来一次来处理这个 Tick 中断。

2. [专家重点] “Tickless 无滴答”内核 (CONFIG_TICKLESS_KERNEL)

  • 原理: 这是现代 IoT 设备的省电黑科技。

  • 行为: 当 Zephyr 发现 _wait_q (就绪队列) 空了,所有线程都在“等待”(比如,一个 BLE 传感器线程在 k_sleep(K_MINUTES(5))):

    1. 调度器会去“超时队列”里扫一眼,发现“哦,最近的闹钟是 5 分钟后”。

    2. 它不会设置 10ms 的 Systick。它会关闭 Systick!

    3. 它会去“硬件实时时钟 (RTC)”那里,设置一个5 分钟后才触发的“真闹钟”。

    4. 然后,它让 CPU 进入深度睡眠 (Deep Sleep)。

  • 结果: 整个 CPU 睡了整整 5 分钟,而不是每 10ms 就被叫醒一次。功耗天差地别。 这就是 Zephyr 这种现代 RTOS 在低功Gong耗领域吊打“裸机 while(1) { delay_ms(5000); }”的根本原因。


🔢 3.4 优先级 - 不只是数字,更是“规则”

“数字越小,优先级越高”。这只是皮毛。

专家视角(Gotcha 2):优先级翻转 (Priority Inversion)

我们再看一遍那个“灾难”场景(A、B、C 三个厨师):

  • A (Prio 5, 高)

  • B (Prio 7, 中)

  • C (Prio 10, 低)

  • 祖传菜刀 (Mutex 互斥锁)

  1. C 拿到菜刀 (k_mutex_lock)。

  2. B 准备就绪(B 想炒菜,不需要菜刀)。

  3. A 准备就绪(A 想切菜,需要菜刀)。

  4. 调度器看 _wait_q:A 和 B。A 优先级高,A 运行。

  5. A 试图拿菜刀 (k_mutex_lock),发现 C 拿着。A 阻塞,进入菜刀的 wait_q。

  6. 调度器看 _wait_q:只剩 B。B 运行。

  7. 灾难: 高优先级的 A 在等 C,C 在等 B。系统被中优先级的 B 霸占了。

[专家重点] 解决方案:优先级继承 (Priority Inheritance)

  • 这是 k_mutex (互斥锁) 内置的“魔法”。 (这也是为什么它比“信号量”更高级)

  • 原理: 当第 5 步(A 试图拿锁)发生时,内核会发现:

    • “A (Prio 5) 想要一个锁。”

    • “这个锁被 C (Prio 10) 拿着。”

    • “A 的优先级(5) > C 的优先级(10)。”

  • 魔法生效: 内核会立刻、当场把 C 厨师的“工牌”从 10 换成 5!

  • 新剧本:

    • C 的优先级被临时提升到 5。

    • B (Prio 7) 还在就绪队列里。

    • 调度器看 _wait_q:B (7) 和 C (5)。

    • C 的优先级(5) > B 的优先级(7)! C 立即抢占 B,上灶台运行!

  • 结果: C 快速用完菜刀,释放了锁 (k_mutex_unlock)。C 的优先级恢复到 10。

  • A(在菜刀的 wait_q 里)被唤醒,进入 _wait_q。A (5) 优先级最高,A 运行。

  • 问题解决。 这就是“优先级继承”。


🏃 3.5 [实战] 创建和管理多线程 (深度分析)

我们再看那段代码:

C

K_THREAD_STACK_DEFINE(thread_a_stack, 1024);...void thread_a_entry(void *p1, void *p2, void *p3) {    while (1) {        printk("Thread A (Prio 7) is running...\n");        k_sleep(K_MSEC(500));    }}...k_thread_create(..., thread_a_entry, ..., 7, ...);

专家视角(Gotcha 3):栈溢出 (Stack Overflow)

  • 问题: 1024 这个数字怎么来的?拍脑袋?

  • 分析: thread_a_entry 里面调用了 printk。printk 是个“大杀器”,它为了支持 %s, %d, %f 等格式化字符串,内部实现非常复杂,它会大量使用栈空间。

  • 如果: 你为了省内存,把 1024 改成了 256。

  • 灾难: printk 在执行时,需要的栈空间超过了 256 字节。它会“写出界”,把数据写到 thread_a_stack 之外的内存上。

  • 结果: 你可能恰好写坏了 thread_b_stack 里的数据,或者某个全局变量。你的程序会“随机”崩溃。崩溃的地方(B 线程)和出错的地方(A 线程)毫无关系。

  • 这就是“栈溢出”,嵌入式系统中最阴险、最难调的 Bug。

  • 怎么办?

    1. 用 CONFIG_THREAD_ANALYZER=y 和 CONFIG_THREAD_STACK_INFO=y,Zephyr 会帮你监控栈的使用率。

    2. 绝对不要在“高性能”的线程里(比如处理蓝牙数据包的线程)调用 printk。printk 是给“调试”用的,它很“重”。

专家视角(Gotcha 4):k_sleep vs k_busy_wait

  • k_sleep(K_MSEC(500)):好文明。线程进入 Pending 状态,CPU 让给别人用,或者深度睡眠。

  • k_busy_wait(500 * 1000) (或者 for(i=0...);):坏文明。线程在 Running 状态空转 500ms!它 100% 占着 CPU,导致所有比它优先级低的线程(如 Thread B)都无法运行。绝对禁止在线程里用“忙等”来做延迟!


章节总结:

我们从线程的“内存成本”和“性能成本”开始,聊到了调度器背后的“队列”数据结构,挖出了“Tickless”内核的省电黑科技,彻底搞懂了 k_mutex 是如何用“优先级继承”解决“优先级翻转”的,最后还分析了 printk 导致的“栈溢出”这个头号 Bug。

这,才是 Zephyr 内核的冰山之下。

PI 附录

1. 线程创建 (Thread Creation)

  • K_THREAD_STACK_DEFINE(name, size)

    • 类型: 宏。

    • 作用: 在内存的 .bss 或 .noinit 段静态分配一块 size 字节的内存,用作栈。name 就是这块内存的名字。

    • 用法: K_THREAD_STACK_DEFINE(my_stack, 2048);

  • k_thread_create(struct k_thread *new_thread, k_thread_stack_t *stack, size_t stack_size, k_thread_entry_t entry, void *p1, void *p2, void *p3, int prio, uint32_t options, k_timeout_t delay)

    • 类型: 函数。

    • 作用: “雇佣”线程。

    • 参数详解:

      • new_thread: 指向一个 struct k_thread 结构体的指针。这是线程的“工牌”(句柄)。

      • stack: 指向 K_THREAD_STACK_DEFINE 定义的栈内存。

      • stack_size: 必须用 K_THREAD_STACK_SIZEOF(my_stack) 来获取,不要手写 2048。

      • entry: 你的“菜谱”,即线程函数名 (e.g., thread_a_entry)。

      • p1, p2, p3: 3 个 void* 参数,用来给你的线程函数传递“原料”。

      • prio: 优先级。数字越小,优先级越高。

      • options: 选项。0 就行。高级用法如 K_ESSENTIAL (标记为关键线程)。

      • delay: 延迟启动。K_NO_WAIT (立即进入就绪态) 或 K_MSEC(100) (等 100ms 再进入就绪态)。

    • 用法: k_thread_create(&my_tid, my_stack, K_THREAD_STACK_SIZEOF(my_stack), my_func, NULL, NULL, NULL, 7, 0, K_NO_WAIT);

2. 线程状态控制 (Lifecycle Control)

  • k_sleep(k_timeout_t timeout)

    • 类型: 函数。

    • 作用: [自愿] 让当前线程“睡觉”一段时间,进入 Pending 状态,让出 CPU。

    • 超时参数 (k_timeout_t):

      • K_MSEC(100): 睡 100 毫秒。

      • K_SECONDS(1): 睡 1 秒。

      • K_MINUTES(5): 睡 5 分钟。

      • K_FOREVER: 永久睡下去,直到被别人唤醒。

      • K_NO_WAIT: 不睡。

    • 用法: k_sleep(K_SECONDS(1));

  • k_busy_wait(uint32_t usec_to_wait)

    • 类型: 函数。

    • 作用: [CPU 燃烧器] 在 Running 状态空转 CPU,100% 占用率。

    • 警告: 绝对不要用它来做长延时!它只配用在“启动早期、等待时钟稳定”或“超精密的微秒级延时”的场景。

  • k_yield()

    • 类型: 函数。

    • 作用: [自愿] 主动让出 CPU。调度器会把它放到“同优先级”队列的末尾,然后看队首有没有别人。

    • 用法: 两个“同优先级”线程协作时使用。

  • k_thread_suspend(struct k_thread *thread)

    • 类型: 函数。

    • 作用: [非自愿] 由 另一个 线程调用,强行“暂停”目标线程。目标线程进入 Suspended 状态。

    • 用法: k_thread_suspend(thread_a_tid);

  • k_thread_resume(struct k_thread *thread)

    • 类型: 函数。

    • 作用: “解冻”一个被 k_thread_suspend 的线程,让它回到 Ready 队列。

    • 用法: k_thread_resume(thread_a_tid);

  • k_thread_abort(struct k_thread *thread)

    • 类型: 函数。

    • 作用: “开除”一个线程。它会从所有队列中移除,进入 Dead 状态。

3. 优先级与调度 (Scheduler Control)

  • k_thread_priority_set(struct k_thread *thread, int prio)

    • 类型: 函数。

    • 作用: 动态修改一个线程的优先级。

    • 用法: k_thread_priority_set(my_tid, 4); // 把我“提拔”到 Prio 4

  • k_current_get()

    • 类型: 函数。

    • 作用: 返回当前正在运行的线程的 struct k_thread * 句柄(工牌)。

    • 用法: k_thread_priority_set(k_current_get(), 4); // “提拔”我自己

  • k_sched_lock() / k_sched_unlock()

    • 类型: 函数。

    • 作用: [危险] k_sched_lock() 会“锁住”调度器。这会关闭“抢占”。

    • 解释: 就算一个“高优先级”线程 B 此时被唤醒了,它也不能抢占当前线程 A。A 会一直运行,直到 A 调用 k_sched_unlock()。

    • 用途: 用于极短的、必须“原子”执行、但又不想关中断的代码段。

    • 警告: 锁住期间,中断 (IRQ) 还是会正常触发的。它和 irq_lock() (关中断) 是两码事!

准备好进入 第四章 了吗?

 

 

 

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐