@[toc]

内核“创世纪”:新任务的第一次呼吸是如何伪造的?

在这里插入图片描述

在多任务操作系统中,我们想当然地认为任务切换就是“保存旧任务的状态,恢复新任务的状态”。但一个悖论随之而来:如果一个任务是全新创建的,它从未运行过,自然也就没有一个“旧状态”可供恢复。那么,当调度器第一次选中这个新任务时,CPU的PC指针该跳向何方?SP栈指针又该指向哪里?

答案是:伪造

内核在创建新任务时,会扮演一名技艺高超的“现场伪造专家”,在任务的内核栈上,手动地、精确地构建一个假的上下文现场。这个假现场看起来就像是这个任务在某个神秘的、预设好的点被正常“切换”出去一样。

本文将带您深入Linux内核的fork过程,揭开新任务“第一次呼吸”背后那令人惊叹的伪造艺术。


创生之地:copy_thread函数

当我们在用户空间调用fork()或在内核中创建新线程时,内核最终都会调用一个名为copy_process()的函数。在这个函数中,所有与进程相关的资源被复制。而最核心的、与CPU状态相关的初始化,则发生在copy_thread()这个特定于体系结构的函数中。

copy_thread的使命,就是在新任务(p)的内核栈上,创建一个伪造的pt_regs结构体和一个伪造的thread_info结构,并设置好它的初始内核栈顶指针和PC(程序计数器)指针。

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
/* 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;

/* 1. 在新任务内核栈的顶部,定位出伪造pt_regs的位置 */
childregs = task_pt_regs(p);

/* 2. 如果是内核线程,伪造一个最小化的内核上下文 */
if (IS_ENABLED(CONFIG_KERNEL_THREAD) && (clone_flags & CLONE_KERNEL)) {
memset(childregs, 0, sizeof(struct pt_regs));
childregs->ARM_cpsr = KERNEL_MODE; /* 设置为内核模式 */
childregs->ARM_pc = (unsigned long)kernel_thread_starter; /* 设置PC */
p->thread.cpu_context.sp = (unsigned long)childregs; /* 设置内核栈顶 */
p->thread.cpu_context.lr = (unsigned long)kernel_thread_starter; /* 设置LR */
return 0;
}

/* 3. 如果是用户进程,则从父进程复制完整的用户上下文 */
*childregs = *current_pt_regs();

/* 4. 修改关键寄存器,让子进程与父进程有所不同 */
if (stack_start)
childregs->ARM_sp = stack_start; /* 设置子进程的用户栈 */

childregs->ARM_r0 = 0; /* 这是fork()在子进程中返回0的关键!*/

/* 5. 设置新任务的第一次“返回”地址 */
p->thread.cpu_context.lr = (unsigned long)ret_from_fork; /* 设置LR */
p->thread.cpu_context.sp = (unsigned long)childregs; /* 设置内核栈顶 */

return 0;
}

这段代码就是“创世纪”的蓝图。让我们看看它是如何为两种不同类型的新任务——用户进程内核线程——伪造现场的。


用户进程的诞生:fork()的魔法

当你调用fork()时,内核会为你创建一个几乎当然!这是一个绝佳的话题,它揭示了操作系统“无中生有”创造一个新执行流的秘密。这就像是多任务世界的“创世纪”。


内核“创世纪”:新任务的第一次呼吸是如何实现的?

在多任务操作系统中,我们习惯于看到任务被调度器切换,从中断点恢复执行,仿佛它们一直都在那里。但每一个任务,无论是用户进程还是内核线程,都有它的“第一次”。当一个任务从未被执行过,调度器第一次选择它运行时,__switch_to会恢复它的上下文。可问题是——一个从未运行过的任务,哪来的“上一次的上下文”可供恢复?

答案是:这个初始上下文,是内核精心伪造的。

在任务创建(fork()kthread_create())的时刻,内核扮演了“造物主”的角色,在一个新任务的内核栈上,手动地、精确地构建了一个虚假的、但功能完备的中断现场。这个过程,就是新任务的“创世纪”。

本文将深入探讨Linux内核中这个精妙的“伪造”过程,揭示一个新任务是如何从零开始,获得它的第一次呼吸。


创世的蓝图:copy_thread函数

所有创世的工作,都发生在任务创建的深处,一个名为copy_thread的函数中。这个函数在不同的体系结构下有不同的实现,但其核心目标是相同的:为新任务初始化其thread_struct和内核栈,使其看起来就像一个“刚刚被正常中断”的任务。

在ARM架构下,copy_thread函数会执行以下几个关键步骤:

  1. 获取新任务的内核栈顶:首先,它会找到为新任务分配的内核栈的顶部地址。
  2. 构建伪造的pt_regs: 它在栈顶向下偏移,留出struct pt_regs结构体大小的空间,然后开始像搭积木一样,填充这个结构体。
  3. 设置关键寄存器: 它会精确地设置这个伪造pt_regs中的几个关键字段,这些字段将决定新任务“恢复”后的行为。
  4. 设置新任务的栈顶指针: 最后,它将新任务的task_struct->thread.sp(内核栈顶指针)指向这个伪造的pt_regs的起始位置。
    在这里插入图片描述

伪造现场的关键:设置PC和LR

在伪造的pt_regs中,有两个寄存器的设置至关重要,它们决定了新任务的“第一步”迈向何方。

1. 程序计数器 (PC) -> 新任务的入口函数

对于一个新创建的内核线程copy_thread会将其伪造的pt_regs->ARM_pc字段,设置为该内核线程需要执行的主函数的地址(即kthread_create时传入的那个函数指针好的,当然!这是一个非常棒的主题,它揭示了操作系统“无中生有”创造一个新执行流的奥秘。接续我们之前的讨论,这篇新文章将完美地解释多任务世界的“创世纪”。


内核“创世纪”:新任务诞生时伪造的第一个现场

在上一篇文章中,我们深入探讨了Linux内核如何在已有任务之间进行切换。但一个基本的问题是:一个全新的任务,在它从未被执行过的情况下,第一次被调度时,CPU是如何“知道”要从哪里开始执行的?它要恢复的寄存器和堆栈又从何而来?

答案是:内核为它精心伪造了一个“第一现场”。这个过程就像是为一部电影的主角准备他的出场镜头:在他正式登场前,导演和剧组已经为他布置好了场景、灯光、道具,甚至安排好了他要说的第一句台词。

本文将深入Linux内核的fork流程,一模一样的子进程。copy_thread在这里扮演了关键角色。

  1. 复制现场:通过*childregs = *current_pt_regs();,内核将父进程被fork()系统调用中断时的完整用户态现场(所有寄存器),原封不动地复制到了子进程的pt_regs中。

  2. 制造不同

    • childregs->ARM_sp = stack_start;:如果为子进程指定了新的用户栈,就在这里设置。
    • childregs->ARM_r0 = 0;这是整个魔法的核心!r0寄存器在ARM架构下通常用作函数返回值。通过将子进程pt_regs中的r0强行设置为0,内核预设了fork()在子进程中的返回值。当子进程最终被恢复时,它会从fork()调用处继续执行,但它的r0寄存器里已经是0了,从而实现了“子进程返回0”的效果。
  3. 设置“第一次返回”的蹦床

    • `p->thread.cpu_context.lr = (unsigned long)ret_)。

这意味着,当调度器第一次恢复这个线程时,CPU的PC寄存器会被直接加载为这个函数的入口地址。线程不是从某个中断点“恢复”,而是直接从其主函数的开头“开始”。

2. 链接寄存器 (LR) -> 特殊的返回“蹦床”

仅仅设置PC是不够的。如果线程的主函数执行完毕返回了,它应该返回到哪里?它没有调用者,直接返回会使CPU跑飞。

为了解决这个问题,内核将伪造的pt_regs->ARM_lr(链接寄存器)设置为了一个特殊的**“内核线程退出蹦床”**函数地址,通常是ret_from_kthread

这个设置极其精妙:

  • 当新线程的主函数执行完毕,它会执行BX LR指令返回。
  • 此时CPU会跳转到ret_from_kthread
  • ret_from_kthread函数会负责调用do_exit(),干净、安全地终结这个线程。

核心思想:通过伪造PC和LR,内核为新任务铺设了一条完整的生命周期轨道:从主函数开始,到退出蹦床结束。


新任务的第一次调度:一场精心策划的“跳转”

现在,万事俱备。让我们看看当调度器第一次选中这个新任务next时,会发生什么:

  1. schedule()决策:调度器选择next任务。
  2. context_switch()执行:调用__switch_to(揭示在ARMv7-M架构下,内核是如何为新任务伪造第一个上下文现场,并巧妙地利用ret_from_fork`“蹦床”函数,完成从内核态到任务“创生”的惊险一跃。

创生之源:copy_thread()

当我们在用户空间调用fork()或在内核中调用kthread_create()来创建一个新任务时,内核最终会调用一个名为copy_process()的函数。在这个函数中,大部分工作是复制父任务的资源(如文件描述符、信号处理等)。但最核心的、与架构相关的部分,被委托给了一个叫做copy_thread()的函数。

copy_thread()的使命,就是在新任务(我们称之为child)的内核堆栈上,手动构建一个虚假的上下文现场。这个现场必须被伪造得天from_fork;:这一步至关重要。内核并没有将子进程的PC设置为某个可执行代码,而是将它的**LR(Link Register,返回地址)**设置为了一个特殊的内核函数ret_from_fork。 * p->thread.cpu_context.sp = (unsigned long)childregs;:将子进程的内核栈顶指针指向这个伪造的pt_regs`。

在这里插入图片描述

第一次切换:跳转到“蹦床”

现在,伪造的现场已经准备就绪。当调度器第一次选择这个新创建的子进程运行时,__switch_to函数会执行。它会从子进程的thread.cpu_context中加载splr

  • sp被恢复,指向我们伪造的pt_regs
  • lr被恢复,指向ret_from_fork

然后,__switch_to的最后一条指令bx lr被执行。CPU并没有像往常一样返回到被中断的代码处,而是直接跳转到了ret_from_fork这个函数

ret_from_fork就像一个蹦床(Trampoline),它在新任务和真正的用户代码之间提供了一个缓冲层。它会:

  1. 调用schedule_tail(),完成一些调度器的收尾工作。
  2. 使能中断。
  3. 最后,调用restore_user_regs宏,从我们伪造的pt_regs中恢复所有用户寄存器,并最终返回到用户空间。

至此,子进程开始prev, next)。 3. **__switch_to的“恢复”**: * ldmia指令从next任务的内核栈(我们刚刚伪造的现场)中加载寄存执行它生命中的第一行用户代码,它感觉自己刚刚从fork()系统调用中返回,并且返回值器。 * CPU的PC寄存器被设置为next任务的主函数地址。 * CPU的LR寄存器被设置为ret_from_kthread的地址。 * CPU的SP寄存器被设置为next任务的内核栈顶。 4. **bx lr的误导**:在__switch_to的末尾,bx lr指令被执行。但对于这个新任务而言,它**不是一次“返回”**,而是一次**精心策划的“跳转”**! 5. **开始呼吸**:CPU直接跳转到next`任务的主函数入口,新任务开始执行它的代码,拥有了生命周期中的第一次呼吸。

[图片描述:一个时序图。

  1. copy_thread:在栈上创建伪造的pt_regs (PC=新函数入口, LR=ret_from_kthread)。
  2. schedule:选择新任务。
  3. __switch_to:执行ldmia从伪造的pt_regs加载寄存器。
  4. __switch_to:执行bx lr (这里的lr来自上一步加载,但对新任务来说,PC更重要)。
  5. CPU:PC指针指向新函数的入口,开始执行。
    ]

用户进程的诞生 (fork)

对于通过fork()创建的子进程,其过程类似但更为复杂。

  • copy_thread同样会伪造一个pt_regs
  • 其伪造的LR会被设置为一个名为ret_from_fork的蹦床函数。
  • 伪造的R0会被设置为0。
  • 当子进程第一次被调度时,它会从ret_from_fork开始。这个函数会完成一些收尾工作,然后返回到用户态。由于R0被设置为0,fork()系统调用在子进程中的返回值就是0。

结论:无中生有的艺术

新任务的第一次执行,并非一次神秘的魔法,而是一场由内核精心编排的“骗局”。通过在任务创建时,手动构建一个以假乱真的初始上下文,内核成功地将一个“从未运行过”的任务,伪装成一个“刚刚被中断”的任务。

这使得统一的__switch_to切换函数可以毫无差别地对待所有任务,无论是久经沙场的老将,还是初出茅庐的新兵。

这种“无中生有”的创世艺术,是操作系统管理进程生命周期的基石,它以一种极其优雅的方式,解决了“先有鸡还是先有蛋”的哲学难题,让多任务世界得以从零开始,生生不息。


参考链接

  • Linux内核源码 kernel/fork.c: copy_process()copy_thread()函数的实现所在地。
  • Linux内核源码 arch/arm/kernel/process.c: ARM架构下copy_thread的具体实现。
  • 《深入理解Linux内核》: 书中关于进程创建的章节,详细描述了fork()的内部工作原理。衣无缝,让调度器的__switch_to宏在第一次切换到child任务时,能像处理一个被正常换出的任务一样,成功恢复它的“状态”。

这个伪造过程主要包含两个关键部分:

  1. 伪造struct pt_regs: 在新任务内核栈的顶部,创建一个假的pt_regs结构体。
  2. 伪造struct switch_stack: 在pt_regs之下,创建一个假的切换栈帧,主要用于__switch_to宏。

[**图片描述**:一张图,展示了一个新任务的内核栈布局。顶部是一个`struct pt_regs`的框图,下面紧跟着一个`struct switch_stack`的框图。一个箭头从`child->thread.sp`指向`switch_stack`的起始位置。]


伪造现场的核心:设置返回地址

在所有伪造的寄存器中,最重要的一个是返回地址,即PC(程序计数器)和LR(链接寄存器)。如果一个任务从未运行过,它显然没有一个可以“返回”的地方。

内核的解决方案是:将返回地址设置为一个特殊的“蹦床”函数——ret_from_fork

我们来看copy_thread()的简化版核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* arch/arm/kernel/process.c */
int copy_thread(unsigned long clone_flags, unsigned long stack_start,
unsigned long stk_sz, struct task_struct *p)
{
/* 1. 获取新任务内核栈顶部的pt_regs指针 */
struct pt_regs *childregs = task_pt_regs(p);

/* ... 其他寄存器初始化 ... */

if (clone_flags & CLONE_SETTLS) {
/* 如果是clone系统调用,设置TLS寄存器 */
childregs->ARM_r7 = p->thread.tls_reg;
}

/* 2. 伪造切换栈帧 (struct switch_stack) */
p->thread.sp = (unsigned long)childregs; // sp指向pt_regs
p->thread.cpu_context.sp = (unsigned long)childregs;

/* 3. 关键!设置第一次切换时的返回地址 */
p->thread.cpu_context.pc = (unsigned long)ret_from_fork;

return 0;
}

这段代码做了三件核心事情:

  1. 它在新任务的内核栈上定位了pt_regs结构体的地址。
  2. 它设置了新任务的内核栈顶指针p->thread.sp)和CPU上下文中的栈指针p->thread.cpu_context.sp)。
  3. 最关键的一步,它将CPU上下文中的PCp->thread.cpu_context.pc)设置为了ret_from_fork函数的地址。这个pc值,在__switch_to宏执行时,最终会被加载到CPU的LR(链接寄存器)中。

惊险一跃:第一次调度

现在,所有铺垫都已完成。让我们看看当调度器第一次选择这个新任务child来运行时,会发生什么。

  1. 调度决策: schedule()函数运行,选择了新任务child作为next
  2. 上下文切换: context_switch()被调用,最终执行__switch_to(prev, child)
  3. 恢复“伪造”现场: __switch_to宏开始从child任务的内核栈中加载它伪造的CPU上下文。当执行LDM指令恢复寄存器时,ret_from_fork的地址被加载到了CPU的LR寄存器中
  4. 最后的BX LR: __switch_to的最后一条指令是BX LR(跳转到LR中的地址)。此时,CPU并不会返回到某个被中断的点,而是**直接跳转到了是0。一场完美的“骗局”完成了。

[图片描述:一个流程图。__switch_to -> bx lr -> ret_from_fork (蹦床函数) -> restore_user_regs -> 返回用户空间。]


内核线程的创生:更简洁的伪造

内核线程的创建过程更简单,因为它没有用户空间上下文。

  • childregs->ARM_cpsr = KERNEL_MODE;:设置CPU模式为内核态。
  • childregs->ARM_pc = (unsigned long)kernel_thread_starter;:直接将PC设置为一个内核启动函数。
  • p->thread.cpu_context.lr = (unsigned long)kernel_thread_starter;:LR也设置为这个启动函数。

当内核线程第一次被调度时,__switch_to后会直接跳转到这个kernel_thread_starter函数,然后开始执行内核线程的主体代码。


结论

操作系统的“创世纪”并非凭空创造,而是一场精心策划的现场伪造。通过在任务的内核栈上构建一个看似“正常”的返回现场,内核巧妙地解决了新任务“从何而来”的问题。

  • 对于用户进程,内核复制父进程的现场,修改关键的r0sp,并设置ret_from_fork作为第一次执行的“蹦床”,从而实现了fork()的精妙语义。
  • 对于内核线程,伪造过程更直接,直接将PC和LR指向线程的入口函数。

下一次当你写下pid = fork();这行代码时,请记住,你正在启动一场内核深处令人惊叹的、堪称完美的“骗局”,而正是这场“骗局”,构成了现代多任务操作系统的基石。


参考链接

  • Linux内核源码:
    • arch/arm/kernel/process.c: copy_thread函数的具体实现。
    • arch/arm/kernel/entry-common.S: ret_from_fork汇编蹦床函数的定义。
    • kernel/fork.c: copy_processfork的通用逻辑。