宠文网

深度探索Linux操作系统

宠文网 > 科普学习 > 深度探索Linux操作系统

5.3 内核初始化

书籍名:《深度探索Linux操作系统》    作者:王柏生
    《深度探索Linux操作系统》章节:5.3 内核初始化,宠文网网友提供全文无弹窗免费在线阅读。!


虽然操作系统的功能包括进程管理、内存管理、设备管理等,但是操作系统的终极目标是创造一个环境,承载进程。但是由于进程运行时,可能需要和各种外设打交道,因此,操作系统初始化时,也会将这些外设等子系统进行初始化,这也导致内核初始化过程异常复杂。虽然这些过程很重要,但是忽略它们并不妨碍理解操作系统的本质。本节我们并不关心这些子系统的初始化,比如USB系统是如何初始化的,我们只围绕进程来讨论内核相关部分的初始化。



5.3.1 初始化虚拟内存


相对于单任务来说,多任务的好处无需赘言,但是多任务也对操作系统提出了更多的要求,其中一个主要问题就是如何在多个任务间互不干扰地共享同一物理内存。正如Dennis  DeBruler说过的经典的一句话:计算机科学中所有问题都可以通过多一个间接层来解决。现代操作系统设计了虚拟内存机制支持多任务。

通过虚拟内存机制,多个进程之间就可以和平共享物理内存。每个进程都有了自己独立的虚拟地址空间,感觉就像自己独占物理内存一样。在某一个进程中访问任何地址都不可能访问到另外一个进程的数据,这使得任何一个进程由于执行错误指令或恶意代码导致的非法内存访问都不会意外改写其他进程的数据,也不会影响其他进程的运行,从而保证整个系统的稳定性。进程本身不必关心虚拟地址是如何映射到物理内存以及存储在物理内存的什么位置,完全由操作系统替其打理。

为了支持虚拟内存,操作系统不必孤军奋战。现代的处理器几乎都从硬件的角度设计了支持虚拟内存的机制,以x86架构为例,其引入了MMU单元。但是与其他CPU几乎全部采用分页机制支持实现虚拟内存不同,对于x86体系架构来说,由于历史的原因,事情有点复杂。最初,8086的寄存器是16位的,可以寻址64KB内存,为了在不改变寄存器和指令位数的情况下支持更大的寻址空间,Intel的工程师们设计了一种段式寻址机制。后来,为了向后兼容,也就是保证为更早的体系结构开发的程序依旧可以在新的体系架构上运行,在后续的x86系列处理器上,Intel保留了段式寻址机制,而且不能关闭段式机制。因此,对于x86架构来说,虚拟内存向物理内存的转换需要经过两个阶段,如图5-13所示。

图 5-13 x86架构虚拟内存向物理内存转换

1)逻辑地址转换为线形地址。CPU将逻辑地址发送给MMU。逻辑地址分为两部分:16位的段选择子和32位的段内偏移。当把这48位地址传给MMU时,MMU中的分段单元根据16位段选择子,从GDT表中获取对应段,取出段基址,再加上逻辑地址中的32位的偏移,就形成了线性地址。

2)线形地址转换为物理地址。分段单元将线性地址发送给分页单元,分页单元通过页表,将线性地址转换为物理地址。

通过虚拟内存,同一个虚拟地址可以映射到不同的物理内存。这也是多个进程共享同一个物理内存的理论基础,如图5-14所示。

图 5-14 虚拟内存映射

显然,为了支持MMU进行地址转换,操作系统需要为MMU准备GDT以及页表,下面我们就讨论这两个过程。

1.创建GDT

分段机制是x86系列处理器演变发展过程中向后兼容的产物,更重要的是,页式映射已经完全可以非常好地支持虚拟内存机制了,除了增加实现的复杂度,分段机制已经没有存在的意义了。除了IA架构,其他体系结构几乎没有使用段机制的。但是为了向后兼容,又不能关闭段机制,IA架构提出了一种特殊的内存管理模型——平坦内存模型(flat  model),如图5-15所示。

图 5-15 平坦内存模型

当使用平坦内存模型时,所有段的基址均为0,段长为线性地址空间的整个长度。读者可能心存疑问:如果段基址相同,那么同一进程的不同段之间的地址是否会发生重叠?这点大可不必担心,虽然各个段的段基址都从0开始,但是在编译时,链接器会通过段内偏移控制各个段的内容不会彼此覆盖。

平坦内存模型就像功夫中的太极,将分段这个“麻烦”化解于无形。在平坦内存模型下,MMU中的分段单元对地址的变换没有任何影响,编译好的二进制程序中的偏移地址(或者称为虚拟地址)完全等同于线性地址。平坦内存模型不仅简化了操作系统中的内存管理,而且也大大降低了编译器和链接器实现的复杂度。

本质上,平坦内存模型并不是一种什么特殊的模式,只是保护模式下的一种特例而已,其中的关键就在于段的基址和段的长度的设置。在内核初始化代码中,有两处设置并加载了GDT。第一处是函数startup_32,代码如下:



内核首先检查引导协议中的loadflags的第6位,即KEEP_SEGMENTS位。如果Bootloader没有设置这一位,那么内核需要重新装载各个段寄存器,包括GDT寄存器gdtr,第6行代码就是重新设置GDT寄存器gdtr,使其指向boot_gdt_descr,而boot_gdt_descr中又包含了符号boot_gdt。我们来看一下内核中的这两个符号的值:



这两个符号的值均以C开头,显然都是虚拟地址了。但是,问题是此时CPU尚未开启分页,所以不能使用虚拟地址寻址,而只能使用物理地址寻址。所以在第6行代码中,使用宏pa将符号boot_gdt_descr的虚拟地址转化为物理地址,稍后我们会具体讨论这个宏,其主要作用就是将符号中的3GB偏移去掉。同理,注意第12行代码,在使用符号boot_gdt时,也去除了3GB偏移。因此,此后直到下一次重新装载,寄存器gdtr中将始终记录的是符号boot_gdt_descr的物理地址。

但是在CPU开启了分页后,应该使用虚拟地址寻址了。所以,在开启分页后,内核还需要重新装载寄存器gdtr,将其中的物理地址替换为GDT描述符的虚拟地址,这就是内核两次加载gdtr寄存器的原因。因为这个boot_gdt只是临时的GDT,够用就可以了,所以我们看到boot_gdt非常简单。

内核中第二次加载寄存器gdtr的代码如下:



在开启分页机制后,虚拟内存已经初始化完成,内核不必再使用汇编语言将虚拟地址使用宏pa手工转化为物理地址了,可以直接使用符号的虚拟地址了,如第9行代码使用的符号early_gdt_descr以及第13行代码使用的符号gdt_page,使用的全部是符号的虚拟地址,MMU会完成虚拟地址到物理地址的转换。换句话说,内核不必再使用汇编语言“精确”地指挥CPU了,可以使用更容易维护的C语言了,所以内核使用C语言重新定义了更完善的GDT:>


宏GDT_ENTRY_INIT用来构建一个全局描述符,定义如下:



参照图5-16所示的段描述符的定义。

图 5-16 段描述符定义

可见,所有段的基址都是0,上限都是0xfffff。正如我们前面讨论的,Linux使用了平坦内存模型。各个段仅有属性有一些差别,如表5-2所示。

其中不同的字段是DPL和Type。

DPL表示段的特权级。内核的代码段和数据段的特权级是最高的0,而用户代码和数据段的特权级是最低的3。显然,从保护的角度,内核将段划分为内核空间和用户空间。

另外一个不同的是Type。对于代码段,包括内核和用户空间的,其类型为1010b,表示只具有可读权限。数据段的类型为0010b,表示具有读写权限。显然这也是从保护角度考虑的,试图写代码段将激发Segment  Fault类型的错误。

理论上,如果使用平坦内存模型,GDT中只定义一个段描述符就可以了,所有段都使用这一个段描述符,但是出于保护的目的,代码是不允许随意改写的,因此内核定义了代码段和数据段。同样出于保护的目的,内核是不允许用户空间的程序随意访问内核空间的,所以内核又定义了内核段和用户段。最终内核定义了内核代码段、内核数据段、用户代码段和用户数据段。

正如同在平坦内存模型下,链接器通过偏移地址控制代码和数据占据的空间,链接器也需要通过偏移地址控制内核和用户程序占据的地址空间。在Linux系统上,约定内核占用3GB~4GB的地址空间,而应用程序使用0~3GB。

那么内核是如何将自己的地址空间限制在3GB以上的呢?如同普通应用程序一样,内核符号的地址也是编译时链接器分配的,查看链接内核时链接器使用的链接器脚本:



其中LOAD_PHYSICAL_ADDR是假定的内核在物理内存中的实际加载位置,而LOAD_OFFSET就是人为让内核在线形地址空间中的偏移,其定义如下:



可见对于IA32来说,这个偏移默认是3GB。

我们看到,如果不增加偏移LOAD_OFFSET,内核中指令的起始地址就是LOAD_PHYSICAL_ADDR。如果内核在内存中也是实际加载到了LOAD_PHYSICAL_ADDR,那么指令或者数据的地址就是物理地址。

而在平坦内存模型下,在未开启分页时,CPU送给MMU的逻辑地址经过MMU转换后,偏移地址将原封不动的作为物理地址送到总线上:

物理地址=偏移地址+段基址(0)=偏移地址

显然,这要求CPU送出的偏移地址就是物理地址。但事实上,内核中指令和数据的地址在链接时都增加了偏移LOAD_OFFSET。因此,在没有开启分页机制前,如果使用内核中的符号,必须要减去偏移LOAD_OFFSET。因此,内核中定义了宏pa,其目的就是将逻辑地址去除人为安排的3GB偏移。宏pa的定义如下:



如在head_32.S中,寻址boot_gdt_descr、initial_page_table等符号时,因为尚未开启分页,所以均使用了宏pa。

而在CPU开启分页机制后,通过页表的映射,这个3GB的偏移将被消除。因此,在第二次加载gdtr,引用符号early_gdt_descr时,就不再需要进行任何手动的转换了。

2.创建内核页表

前面我们讨论了内核为地址转换过程中MMU的分段单元准备GDT的过程。这里我们来讨论内核为MMU中负责第二阶段的地址转换的分页单元准备的页表。

如果操作系统永远在内核空间运行,那么页表只需要覆盖内核空间就可以了。但是,最终操作系统一定是要运行进程的,对于一个进程来说,它大部分时间运行在用户空间,但是有时也要在内核空间运行,因此进程访问的空间是整个线形地址空间,包括用户空间和内核空间。

虽然进程的用户空间“岁岁年年人不同”,但是内核空间却是“年年岁岁花相似”。操作系统只有一个内核,所以理论上,进程的页表只映射用户空间就可以了,进程运行在用户空间时,使用这个页表,而当进程切入内核空间时,CR3寄存器指向内核页表。但是,看似只是一个寄存器的装载动作,其背后的代价却是非常高昂的。因为地址空间的切换,会导致TLB被清空,TLB中缓存的是虚拟地址到物理地址的映射,它可以大大提高虚拟地址到物理地址的转换速度。而且,进程在用户空间和内核空间的切换还是比较频繁的,比如,一个系统调用就会导致进程切换到内核空间。因此,Linux操作系统采用了这样一个冗余策略,在每个进程的页表中都包含了相同的内核空间映射部分。这样,当进程在用户空间和内核空间切换时,不必重新装载CR3寄存器。

在内核刚刚开始初始化时,内存尚未进行完全的初始化,所以内核将页表的初始化分成两个阶段进行。在开启页式映射前,内核还只能使用汇编语言。在准备基本的运行环境中,你愿意使用汇编语言考虑各种负责的情况吗?当然不愿意了,我们当然希望尽早地准备好可以使用C语言的环境。因此,这时内核仅建立一个小的够用的临时页目录和页表。在页式映射开启后,内核就可以毫无顾忌地使用C语言编写的代码了,于是内核进行内存的初始化。在内存子系统初始化完全完成后,内核再调用C语言函数建立完整的页表。

通常,内核创建的这个页目录和页表也被称为主内核页目录和页表,它们也作为进程的页目录和页表的模板。每当进程创建页表时,其将从主内核创建的这部分页目录和页表复制页目录项和页表项。当内核的内存映射发生变化时,内核将更新主内核页目录和页表,同时,也同步进程的页目录和页表中映射内核的页目录项和页表项。而对于页表的用户空间部分,因为与具体进程密切相关,因此由具体进程运行时按需创建。

在本小节中,我们讨论了内核第一阶段手工创建页表的过程,后面第二阶段使用C语言构建页表的过程与此原理完全相同,只不过高度自动化了,我们不再重复。

(1)页目录和页表的存储位置

最初,页目录的位置存放在BSS段中的变量swapper_pg_dir处。在建立页目录表时,除了需要将页目录中映射内核空间的部分映射到内核占据的物理内存外,还要把页目录中映射用户空间的最初部分也映射到内核占据的物理内存。内核为什么要这么做呢?这是x86架构明确要求的,在Intel的手册中明确规定了CPU切换到保护模式,并开启页式映射时的一个要求,具体如下[1]:


r  />

准确的原因需要问Intel  CPU的设计者了,但是原因之一是:在CPU设置了寄存器CR0开启分页机制后,显然所有地址都应该使用虚拟地址,而不再使用物理地址,因此内核将使用一条长跳转指令,使EIP重新装载下一条指令的虚拟地址。但是,在寻址这条长跳转指令本身时,依然使用的是物理地址。显然,要确保经过页面映射后,这条指令依然可以正确映射到其所在的物理内存,因此页目录中映射用户空间的最初部分,即没有3GB偏移的部分,也映射到内核占据的物理内存。也就是说,物理地址经过页面映射,依然可以映射到正确的物理地址。这就是Intel手册中表达的所谓的恒等映射(identity  map)。

曾经一段时间,这种机制工作得很好,也没有出现过什么问题。但是后来,在某些32位的x86处理器上将swapper_pg_dir作为页表激活其他CPU(secondary  CPU)时出现了一些bug。引起这个bug的原因就是恒等映射。

为了解决这个问题,内核引入了变量initial_page_table,其与swapper_pg_dir一样,也定义在内核的BSS段:



按照x86架构的要求,在initial_page_table中进行了恒等映射。但是initial_page_table只是在最初引导时使用,一旦引导完成,内核将initial_page_table处的内核页表复制到swapper_pg_dir,但是只复制非恒等映射部分,然后将swapper_pg_dir装载到寄存器CR3。也就是说,内核使用位置swapper_pg_dir处的页目录作为最终的页目录,代码如下:



后续激活其他CPU时,内核也使用这个去除了恒等映射的swapper_pg_dir作为页目录,代码如下:



除了要保存页目录外,内核中也要分配存储页表的地方。内核会将页表存储在BSS后面的brk段中从标号__brk_base开始的地方。__brk_base在内核链接脚本vmlinux.lds.S中定义,代码如下:



内核中的brk概念与普通程序中的brk的概念基本相同,都表示动态内存分配的内存区域。

(2)建立页目录和页表

在创建页目录和页表时,第一步需要找到页目录和页表所在的位置,然后按照IA32架构的页目录项和页表项的格式约定,逐项填充各个表项。IA32架构的页目录项和页表项的格式如图5-17所示。

图 5-17 页目录项和页表项的格式

内核初始化时创建页目录项和页表项的代码片段如下:



上述代码中包含了一个二重循环:代码第6~19行是第一层循环,这层循环的目的是填充页目录项,直到建立的页表映射的地址可以覆盖_end+MAPPING_BEYOND_END。其中符号_end在链接脚本vmlinux.lds.S中定义,标识内核映像的末尾。这里多映射了MAPPING_BEYOND_END目的是为后面第二阶段要建立完整的页表准备空间。代码第12~15行是第二层循环,这层循环每次循环1024次,目的是填充一个页表中的1024个页表项。

填充页目录项

先来看创建页目录项的第一层循环。第4行代码将页目录所在的位置initial_page_table存入寄存器edx。第3行和第7行代码共同创建了第一个页目录项的内容,将其保存到寄存器ecx。根据第3行代码可见,第一个页表位于符号__brk_base处,这个符号也在链接脚本vmlinux.lds.S中定义,基本上相当于普通进程的Program  break处,也就是堆开始的地方。

在确定了页目录项所在的位置,并且也准备好了页目录项的内容后,第8行代码将准备好的页目录项的内容填充到页目录项所在的位置。

在建立初始引导使用的页目录表initial_page_table时,除了需要将页目录中映射内核空间的部分映射到内核占据的物理内存外,还要把页目中映射用户空间的最初部分也映射到内核占据的物理内存,也就是前面谈到的恒等映射,这里第9行代码就是做这件事的。

填充页表项

第二层循环完成页表项的填充。先来看第13行处的汇编指令stosl,该指令将寄存器eax中的值存储到寄存器edi指示的内存处,然后将寄存器edi中的值增加4字节。显然,寄存器edi中保存的是页表项所在的位置,eax中保存的是页表项的内容。

根据第3行代码可见,寄存器edi的初值被设置为__brk_base,也就是说,第1个页表在符号__brk_base处。寄存器eax的初值在第5行代码中设置为PTE_IDENT_ATTR:



因为PTE_IDENT_ATTR的高20位为0,所以寄存器eax的高20位也为0。也就是说,第一个页表项映射的内存页面是从内存0开始的一个页面。

因此,第二层循环从__brk_base处填充页表项,第一个页表项覆盖了从内存0开始的一个页面,以后每次循环后将eax指向的物理地址增加4KB,见第14行代码,即指向下一个物理内存页面,依次类推。第11行代码设置循环的次数为1024,所以第二层循环循环1024次,将第一个页表全部填充。最终,第一个页表映射了从0开始的4MB内存空间。

第二层循环结束后,代码将再次进入第一层循环,重新开始新一轮大循环。我们看一下新一轮循环页目录项和页表分别所在的位置。第10行代码将寄存器edx增加了4字节,即指向下一个页目录项。而在第二层循环结束后,寄存器edi恰好指向第一个页表后的末尾,这里也就是即将开始的第二个页表的起始位置。然后,内核开启填充第二个页目录项,然后进行第二个页表的过程……以此类推,当建立的页表已经可以映射到物理地址_end+MAPPING_BEYOND_END时,即完成了初始页表的建立。

最终,内核建立的页目录以及页表如图5-18所示。

图 5-18 内核初始页表示意图

(3)启动分页机制

页目录和页表准备好后,内核设置寄存器CR3指向页目录,设置寄存器CR0中的PG位,开启页式映射,代码片段如下:



[1]来源:Intel  64  and  IA-32  Architectures  Software  Developer's  Manual,Volume  3A:System  Programming  Guide,Part  1.January  2011。



5.3.2 初始化进程0

POSIX标准规定,符合POSIX标准的操作系统采用复制的方式创建进程,但是内核总得想办法创建第一个原始的进程,否则其他进程复制谁呢?因此,内核静态的创建了一个原始进程,因为这个进程是内核的第一个进程,Linux为其分配的进程号为0,所以也被称为进程0。进程0不仅作为一个模板,在没有其他就绪任务时进程0将投入运行,所以其又称为idle进程。下面我们就看看内核是如何为进程0分配任务结构和内核栈这两个关键数据结构的。

1.创建任务结构

进程0的任务结构的定义如下:



其中变量init_task所在的位置(在内核的数据段中)就是进程0的任务结构。

当前进程的任务结构是一个频繁使用的变量,为了方便获取它,内核中专门定义了一个宏current。这个获取方法几经修改,现在的方式是定义了一个变量current_task指向当前进程的任务结构。在内核初始化时,内核将这个变量设置为指向init_task,换句话说,当前进程是进程0,代码如下:



读者不必关心所谓的PER_CPU,这是内核为了优化而定义的。为了在SMP情况下减少锁的使用,内核中为每颗CPU都定义了一个current_task。

宏current就是通过读取current_task来获取当前进程的任务结构,定义如下:



接下来在内核创建第一个真正意义上的进程(进程1)时,内核将从current指向的进程进行复制,而此时这个current恰恰指向进程0的任务结构。

2.进程0的内核栈

进程0不会切换到用户空间,所以无需用户空间的栈,只需为其安排好内核空间的栈即可。进程内核栈的数据结构抽象如下:



这个抽象中的数组stack就是内核栈,对于IA32,宏THREAD_SIZE定义为8KB,可见内核为进程内核栈分配的大小为两个页面。那么为什么进程的内核栈与另外一个结构体thread_info定义在一起呢?我们后面再讨论这个问题,下面先来具体看一下进程0的内核栈:



其中,变量init_thread_union就是进程0的内核栈所在的位置,这个变量也是在内核的数据段中,当然其栈底是在init_thread_union+THREAD_SIZE处了,如图5-19所示。

图 5-19 进程0的内核栈

在内核初始化时,设定了栈指针esp指向init_thread_union+THREAD_SIZE,代码如下:



因为此时尚未开启分页机制,而符号stack_start以及init_thread_union均使用的是加了偏移(0xc0000000)的虚拟地址,所以这里都要去掉这个偏移。而在开启页式映射后,把这个偏移又加了回来,如下面代码中使用黑体标识的部分:



最后,为了在进程切换时可以找到进程0的内核栈,还要将其保存在进程0的任务结构的结构体thread_struct的对象thread中,代码如下:



结构体thread_struct中的sp0就是记录进程内核栈的栈指针。

3.宏current与进程内核栈

这一小节我们来回答为什么进程的内核栈与另外一个结构体thread_info定义在一起的问题。

在2.4版本以前,内核直接将任务结构嵌入在堆栈的最下方。但是鉴于任务结构也占据不小的空间,而且要把任务结构放在栈底,还需要把任务结构复制到栈中。因此,在2.6版本时,内核设计了结构体thread_info,取而代之的是thread_info放在了栈底。通过对寄存器esp进行对齐运算,即可方便地找到当前进程的thread_info:



而thread_info中有一个指针指向进程的任务结构,因此获取当前进程的任务结构的方法如下:



但是,内核开发者还是认为计算thread_info位置时间过长,于是采用了以空间换时间的办法,从2.6.22版本开始,内核在内存中定义了一个变量current_task记录当前进程的任务结构。内核不再通过计算,而是直接通过一条访存指令来设置或者读取当前进程的任务结构。

我们在前面看到,在内核初始化时,current_task指向进程0的任务结构init_task。以后每次切换进程时,调度函数设置current_task指向下一个投入运行的进程的任务结构:



5.3.3 创建进程1

在内核初始化的最后,将调用kernel_thread创建进程1,代码如下:



根据kernel_thread代码可见,进程1是通过复制进程0而来的。在复制了进程后,将执行kernel_init,相关代码如下:



根据代码可见,我们已经看清楚了,第一个进程的创建过程与我们在用户空间创建一个进程并无本质区别,就是我们惯用的套路:fork+exec。

创建进程1后,内核调用函数sechedule让进程1投入运行。在讨论进程1的投入运行前,我们先来了解一下内核的基本调度原理,如图5-20所示。

图 5-20 内核调度机制示意图

内核采用模块化的方法,将任务分成四类,优先级从高到低分别是停止类(stop_sched_class)、实时类(rt_sched_class)、公平类(fair_sched_class)和空闲类(idle_sched_class)。从名字我们就可以判断出了这几个类中归属的任务类型了。实时类中记录的是实时任务,一般的任务都归类在公平类中,而停止类和空闲类中记录的是两个特殊的任务。

在没有其他任务就绪时,CPU将运行空闲类中的任务,该任务将CPU置于停机状态,直到有中断将其唤醒。而停止类中的任务是用于负载均衡或者进行CPU热插拔时使用的任务,顾名思义,其目的是为了停止正在运行的CPU,以进行任务迁移或者插拔CPU。每个CPU分别只有一个停止任务和空闲任务。

实时类和公平类分别有一个就绪队列rt_rq和cfs_rq,维护着可以投入运行的任务。每个就绪队列有自己的排队算法,比如公平类采用红黑树对就绪的任务进行排队。

这几个类组成了一个链表,其中最高优先级的停止类作为表头。每个CPU有一个就绪队列(run  queue),通过该队列,可以访问实时队列、公平队列以及停止任务和空闲任务。

每当调度发生时,调度函数schedule调用函数pick_next_task按照优先级依次遍历各个类,找出下一个投入运行的任务,代码如下:



先看函数pick_next_task中后面的for循环,显然这是在遍历调度类。pick_next_task从优先级最高的停止类开始查找,每个类提供了各自的函数pick_next_task,从就绪队列中选择需要投入运行的任务。

除非用在特定的领域,否则大部分任务应该属于公平类,所以内核开发人员对调度算法进行了一个小小的优化:如果目前系统就绪的任务都属于公平类,则直接从公平类中挑选下一个任务。这就是for循环前面的代码片段的作用。

那么进程0和进程1分别都是属于哪个调度类呢?看下面的代码:



在内核初始化时,在调度相关的初始化函数sched_init中,进程0的调度类被设置为公平类,因此,在从进程0复制后,进程1也是公平类。而在复制完成进程1后,内核将进程0的调度类设置为空闲类,代码如下:



在调用init_idle_bootup_task设置了进程0的调度类后,内核调用函数schedule_preempt_disabled进行调度,代码如下:



作为公平类中的进程1显然要排在属于空闲类的进程0的前面,因此,在这次调度后,进程1将被调度函数选中,作为下一个投入运行的任务。而当系统没有其他就绪任务时,将返回到函数schedule_preempt_disabled中,继续执行进入函数cpu_idle。cpu_idle就是一个无限的循环,循环主体就是CPU停机,等待下一次被唤醒执行任务。可见,进程0的主体最后就退化为一个无限的while循环。