@[toc]
内核“创世纪”:新任务的第一次呼吸是如何伪造的?
在多任务操作系统中,我们想当然地认为任务切换就是“保存旧任务的状态,恢复新任务的状态”。但一个悖论随之而来:如果一个任务是全新创建的,它从未运行过,自然也就没有一个“旧状态”可供恢复。那么,当调度器第一次选中这个新任务时,CPU的PC指针该跳向何方?SP栈指针又该指向哪里?
答案是:伪造。
内核在创建新任务时,会扮演一名技艺高超的“现场伪造专家”,在任务的内核栈上,手动地、精确地构建一个假的上下文现场。这个假现场看起来就像是这个任务在某个神秘的、预设好的点被正常“切换”出去一样。
本文将带您深入Linux内核的fork
过程,揭开新任务“第一次呼吸”背后那令人惊叹的伪造艺术。
创生之地:copy_thread
函数
当我们在用户空间调用fork()
或在内核中创建新线程时,内核最终都会调用一个名为copy_process()
的函数。在这个函数中,所有与进程相关的资源被复制。而最核心的、与CPU状态相关的初始化,则发生在copy_thread()
这个特定于体系结构的函数中。
copy_thread
的使命,就是在新任务(p
)的内核栈上,创建一个伪造的pt_regs
结构体和一个伪造的thread_info
结构,并设置好它的初始内核栈顶指针和PC(程序计数器)指针。
1 | /* arch/arm/kernel/process.c (简化版) */ |
这段代码就是“创世纪”的蓝图。让我们看看它是如何为两种不同类型的新任务——用户进程和内核线程——伪造现场的。
用户进程的诞生:fork()
的魔法
当你调用fork()
时,内核会为你创建一个几乎当然!这是一个绝佳的话题,它揭示了操作系统“无中生有”创造一个新执行流的秘密。这就像是多任务世界的“创世纪”。
内核“创世纪”:新任务的第一次呼吸是如何实现的?
在多任务操作系统中,我们习惯于看到任务被调度器切换,从中断点恢复执行,仿佛它们一直都在那里。但每一个任务,无论是用户进程还是内核线程,都有它的“第一次”。当一个任务从未被执行过,调度器第一次选择它运行时,__switch_to
会恢复它的上下文。可问题是——一个从未运行过的任务,哪来的“上一次的上下文”可供恢复?
答案是:这个初始上下文,是内核精心伪造的。
在任务创建(fork()
或kthread_create()
)的时刻,内核扮演了“造物主”的角色,在一个新任务的内核栈上,手动地、精确地构建了一个虚假的、但功能完备的中断现场。这个过程,就是新任务的“创世纪”。
本文将深入探讨Linux内核中这个精妙的“伪造”过程,揭示一个新任务是如何从零开始,获得它的第一次呼吸。
创世的蓝图:copy_thread
函数
所有创世的工作,都发生在任务创建的深处,一个名为copy_thread
的函数中。这个函数在不同的体系结构下有不同的实现,但其核心目标是相同的:为新任务初始化其thread_struct
和内核栈,使其看起来就像一个“刚刚被正常中断”的任务。
在ARM架构下,copy_thread
函数会执行以下几个关键步骤:
- 获取新任务的内核栈顶:首先,它会找到为新任务分配的内核栈的顶部地址。
- 构建伪造的
pt_regs
: 它在栈顶向下偏移,留出struct pt_regs
结构体大小的空间,然后开始像搭积木一样,填充这个结构体。 - 设置关键寄存器: 它会精确地设置这个伪造
pt_regs
中的几个关键字段,这些字段将决定新任务“恢复”后的行为。 - 设置新任务的栈顶指针: 最后,它将新任务的
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
在这里扮演了关键角色。
复制现场:通过
*childregs = *current_pt_regs();
,内核将父进程被fork()
系统调用中断时的完整用户态现场(所有寄存器),原封不动地复制到了子进程的pt_regs
中。制造不同:
childregs->ARM_sp = stack_start;
:如果为子进程指定了新的用户栈,就在这里设置。childregs->ARM_r0 = 0;
:这是整个魔法的核心!r0
寄存器在ARM架构下通常用作函数返回值。通过将子进程pt_regs
中的r0
强行设置为0,内核预设了fork()
在子进程中的返回值。当子进程最终被恢复时,它会从fork()
调用处继续执行,但它的r0
寄存器里已经是0了,从而实现了“子进程返回0”的效果。
设置“第一次返回”的蹦床:
- `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
时,会发生什么:
schedule()
决策:调度器选择next
任务。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
中加载sp
和lr
。
sp
被恢复,指向我们伪造的pt_regs
。lr
被恢复,指向ret_from_fork
。
然后,__switch_to
的最后一条指令bx lr
被执行。CPU并没有像往常一样返回到被中断的代码处,而是直接跳转到了ret_from_fork
这个函数。
ret_from_fork
就像一个蹦床(Trampoline),它在新任务和真正的用户代码之间提供了一个缓冲层。它会:
- 调用
schedule_tail()
,完成一些调度器的收尾工作。 - 使能中断。
- 最后,调用
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`任务的主函数入口,新任务开始执行它的代码,拥有了生命周期中的第一次呼吸。
[图片描述:一个时序图。
copy_thread
:在栈上创建伪造的pt_regs
(PC=新函数入口, LR=ret_from_kthread)。schedule
:选择新任务。__switch_to
:执行ldmia
从伪造的pt_regs
加载寄存器。__switch_to
:执行bx lr
(这里的lr
来自上一步加载,但对新任务来说,PC更重要)。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
任务时,能像处理一个被正常换出的任务一样,成功恢复它的“状态”。
这个伪造过程主要包含两个关键部分:
- 伪造
struct pt_regs
: 在新任务内核栈的顶部,创建一个假的pt_regs
结构体。 - 伪造
struct switch_stack
: 在pt_regs
之下,创建一个假的切换栈帧,主要用于__switch_to
宏。
伪造现场的核心:设置返回地址
在所有伪造的寄存器中,最重要的一个是返回地址,即PC
(程序计数器)和LR
(链接寄存器)。如果一个任务从未运行过,它显然没有一个可以“返回”的地方。
内核的解决方案是:将返回地址设置为一个特殊的“蹦床”函数——ret_from_fork
。
我们来看copy_thread()
的简化版核心代码:
1 | /* arch/arm/kernel/process.c */ |
这段代码做了三件核心事情:
- 它在新任务的内核栈上定位了
pt_regs
结构体的地址。 - 它设置了新任务的内核栈顶指针(
p->thread.sp
)和CPU上下文中的栈指针(p->thread.cpu_context.sp
)。 - 最关键的一步,它将CPU上下文中的PC(
p->thread.cpu_context.pc
)设置为了ret_from_fork
函数的地址。这个pc
值,在__switch_to
宏执行时,最终会被加载到CPU的LR
(链接寄存器)中。
惊险一跃:第一次调度
现在,所有铺垫都已完成。让我们看看当调度器第一次选择这个新任务child
来运行时,会发生什么。
- 调度决策:
schedule()
函数运行,选择了新任务child
作为next
。 - 上下文切换:
context_switch()
被调用,最终执行__switch_to(prev, child)
。 - 恢复“伪造”现场:
__switch_to
宏开始从child
任务的内核栈中加载它伪造的CPU上下文。当执行LDM
指令恢复寄存器时,ret_from_fork
的地址被加载到了CPU的LR
寄存器中。 - 最后的
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
函数,然后开始执行内核线程的主体代码。
结论
操作系统的“创世纪”并非凭空创造,而是一场精心策划的现场伪造。通过在任务的内核栈上构建一个看似“正常”的返回现场,内核巧妙地解决了新任务“从何而来”的问题。
- 对于用户进程,内核复制父进程的现场,修改关键的
r0
和sp
,并设置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_process
和fork
的通用逻辑。