线程管理与调度:从控制块到时间片轮转

各位同学,今天我们来聊聊 RT-Thread 里最核心的部分——线程管理。说白了,一个 RTOS 好不好用,很大程度上就看它怎么管线程。我做了这么多年嵌入式,见过太多项目因为线程管理不当而翻车。嗯,咱们今天就把这块彻底讲透。

线程控制块——每个线程的身份证

在 RT-Thread 里,每个线程都有一个对应的控制块。你可以把它想象成线程的「身份证」,里面存着线程的所有关键信息。

我习惯把控制块理解成一张表格:

字段 含义 我踩过的坑
stack_addr 栈起始地址 栈对齐没做好,直接 HardFault
stack_size 栈大小 给太小,递归一深就崩
entry 线程入口函数 入口函数不能返回,切记
init_tick 初始化时间片 时间片设太大,低优先级线程饿死
remaining_tick 剩余时间片 每次时钟中断都会减这个值
stat 当前状态 状态切换错了,调度器就乱套
我的习惯:创建线程时,我会把栈大小设为 512 字节的整数倍。为什么?因为 ARM Cortex-M 的栈对齐要求是 8 字节,512 的倍数天然满足。曾经有个同事设了 100 字节,结果压栈压到飞起,查了两天才发现是栈溢出。

线程状态与切换——状态机其实很简单

RT-Thread 的线程有 5 种状态。你想想看,一个线程从出生到消亡,无非就是在这几个状态里转悠:

  • 初始态:刚创建,还没启动
  • 就绪态:万事俱备,只差 CPU
  • 运行态:正在跑代码
  • 挂起态:等某个事件(延时、信号量、消息队列)
  • 终止态:跑完了或者被强制结束

这里有个关键点——只有就绪态和运行态的线程才能被调度器选中。挂起态的线程,调度器根本不会看它一眼。

我曾经犯过的错:在一个项目中,我让一个线程在 while(1) 里不断调用 rt_thread_delay(1)。结果发现 CPU 占用率飙到 99%。后来才意识到——每次延时 1 个 tick,线程就进入挂起态,但 1 个 tick 后马上又就绪,调度器频繁切换,效率极低。正确的做法是:如果不需要精确延时,用 rt_thread_mdelay(10) 或者更长时间。

线程切换的时机,说白了就三种情况:

  1. 当前线程主动让出 CPU(调用延时或阻塞 API)
  2. 时间片用完了
  3. 有更高优先级的线程就绪了

创建/启动/删除线程——三板斧

创建线程有两种方式:动态和静态。我个人更推荐动态创建,除非你对内存有极端要求。

// 动态创建——我常用的方式
rt_thread_t tid = rt_thread_create(
    "led_thread",      // 名字,调试时有用
    led_entry,         // 入口函数
    RT_NULL,           // 入口参数
    1024,              // 栈大小
    25,                // 优先级(数值越小优先级越高)
    10                 // 时间片(单位:tick)
);

if (tid != RT_NULL) {
    rt_thread_startup(tid);  // 启动线程
}

启动线程用 rt_thread_startup,这个函数会把线程从初始态变成就绪态。注意,不是直接变成运行态——调度器会在下一个调度点才真正让它跑起来。

删除线程要小心:

// 删除线程
rt_thread_delete(tid);  // 动态创建的线程用这个
// 或者
rt_thread_detach(tid);  // 静态创建的线程用这个
避坑指南:千万不要在中断服务函数里删除线程!我曾经这么干过,结果系统直接死机。原因是中断上下文不允许进行线程调度。正确的做法是:在中断里发一个信号量或者事件,然后在线程里处理删除操作。

时间片轮转调度原理——公平是相对的

时间片轮转,说白了就是让同优先级的线程轮流用 CPU。每个线程分到一段固定时间,用完了就换下一个。

RT-Thread 的调度策略是这样的:

  • 优先级不同:高优先级线程先跑,直到它主动让出或被更高优先级抢占
  • 优先级相同:时间片轮转,每人分一段

我画了一张图,帮你理解这个过程:

时间片轮转调度示意图 时间轴 → t0 t1 t2 t3 线程A 运行 线程B 运行 线程C 运行 时间片 = 10 ticks 就绪队列(优先级相同) 线程A 线程B 线程C

你看,三个线程优先级相同,每人分到 10 个 tick 的时间片。线程 A 跑完 10 个 tick,调度器就切换到线程 B,再切换到线程 C,然后回到线程 A。这就是轮转。

我的经验:时间片设多少合适?我一般设 5-20 个 tick。假设 tick 是 1ms,那就是 5-20ms。太短了切换开销大,太长了低优先级线程等得心焦。曾经有个项目,我把时间片设成 100 个 tick,结果三个同优先级线程里有一个是按键扫描,按下去要等 100ms 才有反应——用户体验极差。

调度器的核心逻辑——就绪优先级组

RT-Thread 的调度器为什么快?因为它用了一个叫「就绪优先级组」的位图结构。说白了,就是用 32 位整数来表示 256 个优先级里哪些线程就绪了。

// 核心数据结构
rt_uint32_t rt_thread_ready_priority_group;  // 就绪优先级组
// 每个 bit 对应一个优先级
// bit 0 = 优先级 0,bit 1 = 优先级 1,以此类推

// 查找最高优先级就绪线程
register rt_ubase_t highest_ready_priority;
highest_ready_priority = __rt_ffs(rt_thread_ready_priority_group) - 1;
// __rt_ffs 返回最低位为 1 的位置

这个 __rt_ffs 函数(find first set)是调度器的灵魂。它能在几个指令周期内找到最高优先级的就绪线程。我刚开始看源码时,觉得这玩意儿太巧妙了——用空间换时间,一个 32 位整数就搞定了优先级查找。

性能数据:在 STM32F4 上,一次线程切换(从决定切换目标到实际切换完成)大约需要 1-3 微秒。其中查找最高优先级线程只占不到 0.5 微秒。这就是位图算法的威力。

实战中的线程设计建议

说了这么多理论,咱们来点实际的。我在项目中总结了几条线程设计的原则:

  • 紧急的事用高优先级:比如电机控制、通信接收,优先级设 5-10
  • 周期性的任务用中等优先级:比如数据采集、状态刷新,优先级设 15-20
  • 不紧急的后台任务用低优先级:比如日志输出、界面刷新,优先级设 25-30
  • 空闲线程不要占 CPU:RT-Thread 自带 idle 线程,优先级最低,别往里面塞东西
我曾经踩过的坑:在一个四轴飞行器项目里,我把姿态解算线程的优先级设成了 0(最高),把通信接收线程设成了 1。结果通信线程偶尔收不到数据——因为姿态解算线程跑得太频繁,通信线程根本没机会执行。后来我把姿态解算改成 5,通信改成 4,问题解决。记住:优先级不是越高越好,够用就行。

好了,线程管理这块的核心内容就这些。你想想看,控制块是身份证,状态是当前处境,调度器是交通警察,时间片是红绿灯——把这些概念串起来,RT-Thread 的线程调度就没那么神秘了。