@[toc]

[linux][rtos]新线程创建和退出的异同点解析

在这里插入图片描述

1. 引言

在现代多任务操作系统中,线程是调度的基本单位。当一个新线程被创建后,它并不会立即执行,而是处于就绪状态,等待操作系统的调度器(Scheduler)首次将CPU的控制权交给它。这个从“被调度”到“开始执行第一行有效代码”的过渡过程,并非一次简单的函数跳转,而是由一个精心设计的、位于内核深处的“启动入口”来完成的。这个入口点,我们称之为新线程的“第一步”。

本文旨在深入剖析两个极具代表性的操作系统——通用的宏内核Linux和嵌入式实时操作系统RT-Thread——它们各自是如何实现新线程的“第一步”的。我们将重点分析Linux ARM架构下的ret_from_fork汇编例程,以及RT-Thread中的rt_thread_startupC语言函数。通过对比这两种在不同层级、不同复杂度环境下实现的机制,本文将揭示操作系统在任务启动这一核心功能上共通的设计哲学与差异化的实现策略,为内核开发者和嵌入式系统工程师提供一个深入理解操作系统底层运作的独特视角。

2. 上下文的伪造:新线程首次执行的“前夜”

无论是Linux还是RT-Thread,一个新线程在能够被调度前,内核必须为其准备一个初始的上下文环境。这个过程通常在创建线程的API(如fork()rt_thread_create)内部完成,其核心是在线程的内核栈上伪造一个上下文帧。这个帧被构建得看起来就好像该线程是在执行一次正常的上下文切换时被“换出”的一样。

2.1 Linux中的copy_thread

在Linux中,fork()系统调用链会执行到copy_process(),进而调用特定于体系结构的copy_thread()函数。对于ARM架构,copy_thread的核心工作之一是初始化新任务的thread_struct,并构建初始的内核栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* file: arch/arm/kernel/process.c */
int copy_thread(unsigned long clone_flags, unsigned long stack_start,
unsigned long stk_sz, struct task_struct *p)
{
struct thread_info *thread = task_thread_info(p);
struct pt_regs *childregs = task_pt_regs(p);

/* ... */

memset(&thread->cpu_context, 0, sizeof(struct cpu_context));
/* 关键:将ret_from_fork的地址设置到PC寄存器的保存位置 */
thread->cpu_context.pc = (unsigned long)ret_from_fork;
thread->cpu_context.sp = (unsigned long)childregs;

return 0;
}

关键在于,新任务的上下文(cpu_context)中,程序计数器(pc)被直接设置为ret_from_fork函数的地址。这意味着,当新任务首次被__switch_to切换并恢复上下文时,CPU的PC指针将直接指向ret_from_fork

2.2 RT-Thread中的rt_hw_stack_init

RT-Thread采用了相似的策略。rt_thread_create会调用rt_hw_stack_init函数来伪造初始栈帧。退出线程时将线程踢出线程队列,执行一次调度切换,在空闲线程中执行回收等任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/**
* @brief This f
* unction will initialize a thread. It's used to initialize a
* static thread object.
*
* @param thread is the static thread object.
*
* @param name is the name of thread, which shall be unique.
*
* @param entry is the entry function of thread.
*
* @param parameter is the parameter of thread enter function.
*
* @param stack_start is the start address of thread stack.
*
* @param stack_size is the size of thread stack.
*
* @param priority is the priority of thread.
*
* @param tick is the time slice if there are same priority thread.
*
* @return Return the operation status. If the return value is RT_EOK, the function is successfully executed.
* If the return value is any other values, it means this operation failed.
*/
rt_err_t rt_thread_init(struct rt_thread *thread,
const char *name,
void (*entry)(void *parameter),
void *parameter,
void *stack_start,
rt_uint32_t stack_size,
rt_uint8_t priority,
rt_uint32_t tick)
{
/* parameter check */
RT_ASSERT(thread != RT_NULL);
RT_ASSERT(stack_start != RT_NULL);
RT_ASSERT(tick != 0);

/* clean memory data of thread */
rt_memset(thread, 0x0, sizeof(struct rt_thread));

/* initialize thread object */
rt_object_init((rt_object_t)thread, RT_Object_Class_Thread, name);

return _thread_init(thread,
name,
entry,
parameter,
stack_start,
stack_size,
priority,
tick);
}
RTM_EXPORT(rt_thread_init);

static rt_err_t _thread_init(struct rt_thread *thread,
const char *name,
void (*entry)(void *parameter),
void *parameter,
void *stack_start,
rt_uint32_t stack_size,
rt_uint8_t priority,
rt_uint32_t tick)
{
thread->sp = (void *)rt_hw_stack_init(thread->entry, thread->parameter,
(rt_uint8_t *)((char *)thread->stack_addr + thread->stack_size - sizeof(rt_ubase_t)),
(void *)_thread_exit);
}

/* file: libcpu/arm/cortex-m4/cpuport.c (示例) */
rt_uint8_t *rt_hw_stack_init(void *tentry,
void *parameter,
rt_uint8_t *stack_addr,
void *texit)
{
struct stack_frame *stack_frame;
/* ... */

stack_frame = (struct stack_frame *)stack_addr;

/* 伪造硬件自动保存的寄存器帧 */
stack_frame->exception_stack_frame.r0 = (unsigned long)parameter; /* R0 = 参数 */
stack_frame->exception_stack_frame.r1 = 0;
/* ... */
stack_frame->exception_stack_frame.lr = (unsigned long)texit; /* LR = 线程退出函数 */
/* 关键:将PC设置为统一的启动入口 rt_thread_startup */
stack_frame->exception_stack_frame.pc = (unsigned long)tentry; /* PC = 线程入口,但实际上是startup */

/* ... */

/* 返回伪造好的栈顶指针 */
return stack_addr;
}

3. “第一步”的执行:ret_from_fork vs rt_thread_startup

当调度器首次选中新线程时,上下文切换发生,CPU开始执行这个伪造的入口点。

3.1 Linux的ret_from_fork:汇编层面的精巧分流

ret_from_fork是一个汇编例程,它不仅要启动任务,还要处理复杂的任务类型分化。

在这里插入图片描述

图1:Linux ret_from_fork 逻辑流程图

ret_from_fork的核心逻辑如下:

  1. 调用schedule_tail(): 完成调度器的收尾工作,例如释放前一个任务的锁。
  2. 区分任务类型: 通过一个特殊的寄存器(r5)来判断新任务是内核线程还是用户进程的子进程。这个值是在copy_thread时精心设置的。
  3. 内核线程路径: 如果是内核线程,它会直接跳转到线程的主体函数开始执行。当该函数返回时,ret_from_fork会捕获这个返回,并引导线程进入do_exit()流程以安全地终结。
  4. 用户进程路径: 如果是用户进程,它会跳转到ret_slow_syscall,这是通用的系统调用返回路径。这条路径会准备好所有返回用户空间所需的状态,最终使得fork()系统调用在子进程中看起来就像是返回了一个0。

3.2 RT-Thread的rt_thread_startup:C语言层面的直接引导

与Linux的复杂性相比,RT-Thread的rt_thread_startup则显得非常简洁明了,因为它只处理一种线程模型。

在这里插入图片描述

图2:RT-Thread rt_thread_startup 逻辑流程图

rt_thread_startup的C代码实现逻辑清晰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

static void _thread_exit(void)
{
struct rt_thread *thread;
rt_base_t critical_level;

/* get current thread */
thread = rt_thread_self();

critical_level = rt_enter_critical();

rt_thread_close(thread);

_thread_detach_from_mutex(thread);

/* insert to defunct thread list */
rt_thread_defunct_enqueue(thread);

rt_exit_critical_safe(critical_level);

/* switch to next task */
rt_schedule();
}

它的流程非常直接:退出线程时将线程踢出线程队列,执行一次调度切换,在空闲线程中执行回收等任务

4 Linux 线程退出机制

作用与原理

schedule_tail是Linux内核调度器设计中一个至关重要的**“衔接”函数**。它并非由普通任务在调度循环中调用,而是专门服务于所有**“新生”**的任务。它的核心职责是执行那些本应在schedule()函数后半部分完成,但由于新任务的特殊启动路径(ret_from_fork)而被“跳过”的收尾工作。

其基本原理如下:

  1. 上下文切换的分离:Linux内核将一次完整的任务切换(schedule())在逻辑上分为了两个部分:
    • 切换前部分:在schedule()函数中,选择下一个任务next,然后调用context_switchcontext_switch的核心是switch_to,它只负责切换CPU的寄存器状态和栈指针。在switch_to执行前,当前CPU持有运行队列锁(rq->lock),并且抢占是禁用的。
    • 切换后部分:当switch_to完成后,CPU已经在新任务的上下文中运行了。但是,前一个任务的锁还未释放,抢占也仍然是禁用的。这些收尾工作必须由新任务来完成。
  2. schedule_tail的角色:对于一个已经存在的任务,这些收尾工作由schedule()函数中紧跟在context_switch之后的部分来完成。但对于一个新创建的任务,它通过ret_from_fork直接开始执行,从未真正“调用”过schedule(),因此它必须有一个地方来执行这些必要的收尾工作。schedule_tail就是这个地方。它扮演了**“调度后半场”**的角色,确保新任务在开始执行其核心逻辑前,系统处于一个一致和安全的状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/**
* schedule_tail - 一个刚被fork出来的线程必须调用的第一个函数。
* @prev: 我们刚刚从其切换开的那个线程(即父进程或创建者)。
*/
/* asmlinkage告诉编译器从栈上获取参数,__visible确保函数不被优化掉。
* __releases(rq->lock)是一个锁注解,告诉静态分析工具,这个函数
* 的执行路径中会释放掉运行队列锁。*/
asmlinkage __visible void schedule_tail(struct task_struct *prev)
__releases(rq->lock)
{
/*
* 注释:新任务以FORK_PREEMPT_COUNT的抢占计数值开始,
* 详见该宏和finish_task_switch()的说明。
*
* 注释:finish_task_switch()会释放rq->lock(),并降低抢占计数值,
* 而preempt_enable()最终会(在支持抢占的内核上)使能抢占。
*/

/*
* 调用finish_task_switch,这是完成任务切换最核心的收尾函数。
* 它会做几件重要的事情:
* 1. 释放前一个任务所在运行队列的锁(rq->lock)。
* 2. 如果前一个任务已经死亡(变为僵尸进程),在这里进行清理。
* 3. 降低当前任务的抢占计数值(preempt_count)。
*/
finish_task_switch(prev);
/*
* 注释:这是一个特殊情况:新创建的任务刚刚完成了第一次上下文切换。
* 它在这条路径上是第一次从调度器返回。
*/
/*
* 这是一个追踪点(tracepoint),用于内核的性能分析和调试工具(如ftrace)。
* 它记录下“调度器退出”这一事件,`true`表示这是一个特殊的、非典型的返回路径。
*/
trace_sched_exit_tp(true, CALLER_ADDR0);

/*
* 使能抢占。这个函数会检查抢占计数值,如果计数值降为0,
* 并且有更高优先级的任务在等待,就会立刻触发一次新的调度。
* 对于新任务来说,这是它第一次变得“可被抢占”的时刻。
*/
preempt_enable();

/*
* 如果当前任务(即子进程)被要求在创建后,将其TID(线程ID)
* 写入父进程指定的一个用户空间地址(通常由clone系统调用的
* CLONE_CHILD_SETTID标志指定)。
*/
if (current->set_child_tid)
/* put_user是一个安全的函数,用于将内核空间的数据(当前任务的TID)
* 写入到用户空间指定的地址(current->set_child_tid)。*/
put_user(task_pid_vnr(current), current->set_child_tid);

/*
* 检查并计算是否有挂起的信号需要处理。
* 尽管任务是新创建的,但在它能够运行之前,可能已经有信号被发送给它。
* 在任务正式开始执行其用户代码或内核逻辑前,必须检查这一点。
*/
calculate_sigpending();
}

僵尸线程清理

让我们来梳理一下一个线程T1死亡到被清理的完整生命周期:

  1. 线程T1死亡:

    • 线程T1执行完毕,或者调用exit()系统调用。
    • 内核执行do_exit()函数。
    • do_exit()中,T1的大部分资源(如内存mm_struct、文件描述符等)被释放。
    • 关键T1task_struct结构体和内核栈不会被立即释放T1的状态被设置为EXIT_ZOMBIE
    • T1变成了一个僵尸线程,它不再参与调度,但它的task_struct仍然保留,主要是为了让其父进程能够通过wait()系统调用来获取它的退出状态。
    • do_exit()的最后一步是调用schedule(),主动放弃CPU。
  2. 调度器选择新线程T2:

    • schedule()函数被调用,它看到T1已经是僵尸,不会再选择它。
    • 调度器选择了另一个可运行的线程,我们称之为T2
    • schedule()调用context_switch(rq, T1, T2)
    • context_switch的核心switch_to执行,CPU的控制权从T1的(垂死)上下文切换到了T2的上下文。
  3. T2执行清理工作(关键点):

    • 现在CPU已经在执行T2的代码了。
    • 对于一个已经存在的T2,它会从context_switch返回,继续执行schedule函数中剩下的代码。
    • 对于一个新创建的T2,它会通过ret_from_fork调用schedule_tail
    • 无论是哪条路径,最终都会调用到finish_task_switch(prev)函数,此时prev参数就是指向刚刚被换下的T1task_struct的指针。
  4. finish_task_switch(T1)的内部逻辑:

    • finish_task_switch函数会检查prev(即T1)的状态。
    • 它发现T1的状态是EXIT_ZOMBIE
    • 如果T1的父进程已经通过wait()回收了它,或者它是一个被init进程接管的孤儿进程,那么finish_task_switch就会在这里执行最后的清理
    • 这个清理工作由release_task()函数完成,它会释放T1task_struct结构体和内核栈,将T1从系统中彻底抹去。
  • 对僵尸线程清理时机,它不是在线程死亡时立即发生的,而是被推迟(deferred)到下一次调度事件中,由获得CPU的新任务,在其执行的初始阶段,通过调用finish_task_switch来完成对前一个僵尸线程的最终资源回收。这是一个确保内核数据结构一致性和安全性的经典设计模式。

总结

通过对ret_from_forkrt_thread_startup的深入分析,我们可以看到两者在实现新线程“第一步”这一共同目标时,所采用的不同策略。

对比维度 Linux (ret_from_fork) RT-Thread (rt_thread_startup)
实现语言 汇编 C语言
核心功能 引导新任务进入其执行体 引导新线程进入其执行体
调度收尾 显式调用 schedule_tail 逻辑内含在调度器中,此处无显式调用
任务分化 ,必须区分内核线程和用户进程 ,只有一种统一的线程模型
调用主体 通过汇编跳转(ret指令) 通过C函数指针调用
退出处理 路径复杂,分别处理 统一由rt_thread_startup引导至rt_thread_delete
设计复杂度 ,与系统调用、内存管理紧密耦合 ,逻辑单一,高度内聚

总结

ret_from_forkrt_thread_startup虽然在实现细节、语言层级和复杂度上存在巨大差异,但它们共同揭示了操作系统设计中的一个普适性原则:为所有新创建的执行单元提供一个统一的、受控的启动入口点

  • Linux的ret_from_fork 展示了通用宏内核在处理复杂、异构的任务模型(内核线程 vs 用户进程)时,如何在底层的汇编层面通过精巧的流程控制,实现对不同启动路径的精确分发。它的设计是灵活性和功能完备性的体现。
  • RT-Thread的rt_thread_startup 则代表了实时操作系统对简洁、高效和确定性的追求。通过统一的线程模型和高层语言的实现,它以一种更直接、更易于理解的方式完成了启动引导任务。

对这两者的比较分析,不仅有助于我们理解各自操作系统的内部工作机制,更能让我们深刻体会到,软件设计是如何根据其目标环境(通用服务器 vs 嵌入式设备)和核心诉求(功能 vs 实时)而演化出截然不同但同样优雅的解决方案。

6. 参考文献与链接

  1. Linux内核源码 arch/arm/kernel/entry-armv.S: ret_from_fork汇编例程的定义来源。
  2. Linux内核源码 arch/arm/kernel/process.c: copy_thread函数的实现,展示了如何设置新任务的初始上下文。
  3. RT-Thread源码 src/thread.c: rt_thread_startuprt_thread_delete等线程管理核心函数的实现。
  4. RT-Thread源码 libcpu/arm/cortex-m7/cpuport.c: ARM Cortex-M架构下的rt_hw_stack_init函数实现,展示了如何伪造初始栈帧。
  5. 《深入Linux内核架构》 (Professional Linux Kernel Architecture). Wolfgang Mauerer. Wiley Publishing, 2008. - 本书对Linux进程创建和上下文切换有深入的章节进行讲解。