3. 内核启动与调度:tx_kernel_enter()启动流程,线程调度策略

好,咱们今天聊点实在的。ThreadX 怎么跑起来的?又是怎么决定先跑哪个任务的?这两个问题搞清楚了,后面写多线程代码心里就有底了。

3.1 内核启动:tx_kernel_enter() 到底干了什么

很多初学者拿到 ThreadX 的 demo,上来就找 main 函数。结果发现 main 里就干了两件事:初始化硬件、调用 tx_kernel_enter()。然后呢?然后就没有然后了——main 函数再也没回来过。

我刚开始接触 ThreadX 时也觉得奇怪:这函数进去就不出来了?后来扒了一下源码才明白,tx_kernel_enter() 本质上是一个「有去无回」的入口。它把 CPU 的控制权彻底交给了内核调度器。

3.1.1 启动流程拆解

咱们把 tx_kernel_enter() 的启动流程拆成几步来看:

  1. 关中断 —— 内核启动期间不允许任何中断打扰,这是规矩。
  2. 初始化系统时钟 —— 配置定时器,为时间片轮转和延时服务做准备。
  3. 初始化就绪队列 —— 把所有线程的优先级位图清空,准备迎接线程加入。
  4. 调用 tx_application_define() —— 这是用户自己写的函数,在里面创建线程、队列、信号量等资源。
  5. 启动第一个线程 —— 从就绪队列中挑出优先级最高的线程,恢复它的上下文,然后跳转执行。

关键点:tx_application_define() 是在内核启动过程中被调用的。这意味着你在里面创建的所有对象,都会在调度开始前就绪。我曾经见过有人在这个函数里做延时操作,结果系统直接卡死——因为调度器还没开始工作呢。

下面这张图把整个流程串起来了:

tx_kernel_enter() 启动流程 关中断 初始化系统时钟 初始化就绪队列 调用 tx_application_define() (用户在此创建线程、队列等资源) 启动第一个线程 调度器开始运行 注意:tx_kernel_enter() 不会返回。一旦调度器启动,main 函数就「回不去了」。

3.1.2 启动代码示例

一个典型的 ThreadX 工程入口长这样:

/* 硬件初始化 */
void system_setup(void) {
    /* 配置时钟、GPIO、UART 等 */
}

/* 用户定义函数:创建所有线程和资源 */
void tx_application_define(void *first_unused_memory) {
    /* 创建线程 */
    tx_thread_create(&thread_led, "LED Thread",
                     thread_led_entry, 0,
                     stack_led, STACK_SIZE,
                     5, 5, 1, TX_AUTO_START);

    tx_thread_create(&thread_uart, "UART Thread",
                     thread_uart_entry, 0,
                     stack_uart, STACK_SIZE,
                     8, 8, 1, TX_AUTO_START);
}

/* 主函数 */
int main(void) {
    system_setup();      /* 硬件初始化 */
    tx_kernel_enter();   /* 进入内核,永不返回 */
    return 0;            /* 这行永远不会执行到 */
}

个人习惯:我一般会在 tx_application_define() 里先创建调试用的串口线程,优先级设得最低。这样万一系统跑飞了,还能通过串口打印点信息出来。你想想看,要是连调试通道都没开,出了问题只能干瞪眼。

3.2 线程调度策略:谁先跑?跑多久?

调度器启动后,就面临一个核心问题:这么多线程,先跑谁? ThreadX 的调度策略可以概括为两句话:优先级高的先跑,同优先级的轮流跑。

3.2.1 抢占式调度

抢占式调度,说白了就是「后来者可以抢跑」。当一个高优先级线程就绪时,它会立刻打断当前正在运行的低优先级线程。

举个例子:

  • 线程 A 优先级 10,正在运行
  • 线程 B 优先级 5(数值越小优先级越高),因为某个事件就绪了
  • 调度器检测到 B 的优先级更高,立即保存 A 的上下文,切换到 B
  • 等 B 运行完毕或阻塞,再恢复 A 继续跑

我在项目中遇到过一个问题:一个低优先级的线程正在操作共享缓冲区,结果被高优先级线程抢占了。高优先级线程也去操作同一个缓冲区,数据就乱套了。嗯,这就是典型的「优先级反转 + 临界区保护不当」问题。后面讲互斥量时会细说。

3.2.2 时间片轮转

同优先级的线程怎么处理?ThreadX 用的是时间片轮转。每个线程可以指定一个时间片(以系统时钟 tick 为单位),到期后自动切换到下一个同优先级的就绪线程。

概念 说明 配置方式
时间片 线程一次连续运行的最大 tick 数 tx_thread_create() 的 time_slice 参数
默认值 0 表示不使用时间片(一直运行直到被抢占或阻塞) 由用户指定
轮转触发 同优先级队列中,当前线程时间片耗尽,切换到下一个 自动触发

注意:时间片轮转只对同优先级线程有效。如果系统里所有线程优先级都不同,时间片设置就没意义了——高优先级线程会一直占着 CPU,低优先级的根本轮不到。

3.2.3 调度策略对比

调度策略 触发条件 适用场景 潜在问题
抢占式 高优先级线程就绪 实时性要求高的任务(如中断响应) 优先级反转、临界区竞争
时间片轮转 同优先级线程时间片耗尽 多个同等重要的任务轮流执行 时间片设置不合理导致频繁切换
协作式(ThreadX 也支持) 线程主动放弃 CPU 简单系统、避免竞态 线程「霸占」CPU 导致其他线程饿死

3.3 调度器内部机制:就绪队列与位图

ThreadX 的调度器为什么这么快?因为它用了一个很巧妙的数据结构——优先级位图。

每个优先级对应一个 bit,32 个优先级就用 32 个 bit。哪个优先级上有就绪线程,对应的 bit 就置 1。调度器找最高优先级线程时,只需要做一次位运算就能找到第一个置 1 的 bit。

我当年第一次看到这个设计时,忍不住拍了一下大腿——这比遍历链表快太多了!在资源受限的 MCU 上,这种 O(1) 复杂度的调度算法简直是救命稻草。

3.3.1 调度决策流程

  1. 系统时钟 tick 中断触发
  2. 中断服务程序调用调度器入口
  3. 调度器扫描优先级位图,找到最高优先级的就绪线程
  4. 如果当前线程优先级不是最高,保存当前线程上下文
  5. 恢复目标线程的上下文
  6. 跳转到目标线程继续执行

避坑指南:我曾经在一个项目中把线程优先级设得过于密集(比如 1、2、3、4 各一个线程),结果高优先级的线程稍微一忙,低优先级的就饿死了。后来我调整了策略:优先级跨度拉大,关键任务用高优先级,普通任务用中优先级,后台任务用低优先级。这样调度器的负担也轻了。

3.4 实际项目中的调度策略选择

说了这么多理论,咱们聊聊实际项目中怎么选。

  • 硬实时任务(比如电机控制、PWM 输出):用最高优先级,抢占式调度,时间片设为 0(一直运行直到阻塞)。
  • 通信任务(比如 UART 接收、CAN 报文处理):中高优先级,时间片可以设小一点,保证多个通信通道公平。
  • 人机交互(比如按键扫描、LCD 刷新):中低优先级,时间片适中,别让界面卡顿就行。
  • 后台任务(比如日志记录、状态统计):最低优先级,时间片设大一点,有空就跑跑。

嗯,说白了就是一句话:紧急的优先级高,不紧急的优先级低,同等重要的用时间片轮转。