Linux 内核|初始化
在 Linux 内核启动后,完成了实模式到保护模式的切换,并做好了各种准备工作。接下来进入内核初始化,我们主要关注初始化的流程。
1.概述
内核的启动从入口函数 start_kernel() 开始。在 init/main.c 文件中,start_kernel 相当于内核的 main 函数。这个函数里,就是各种各样初始化函数 XXXX_init。其中的主要流程有以下内容:
- 创建0号进程:
INIT_TASK(init_task)
- 异常处理类中断服务程序挂接:
trap_init()
- 初始化内存:
mm_init()
- 初始化进程调度器:
sched_init()
- 初始化基于内存的文件系统:
vfs_caches_init()
- 初始化其他内容:
rest_init()
2.创建 0 号进程
start_kernel()
:/init/mian.c
init_task
:/init/init_task.c
start_kernel() 函数体一开始就会运行 set_task_stack_end_magic(&init_task)
来创建初始进程,其中参数 init_task
,它的定义是 struct task_struct init_task = INIT_TASK(init_task)。
0 号进程:系统创建的第一个进程,也是唯一一个没有通过 fork 或者 kernel_thread 产生的进程,是进程列表(Procese List)的第一个。
- 只在内核中:
init_task
是静态定义的进程,当内核被放入内存时,它就已经存在,它没有自己的用户空间,一直处于内核空间中运行,并且也只处于内核空间运行。 - **作用:**0 号进程用于包括内存、页表、必要数据结构、信号、调度器、硬件设备等的初始化。
- **创建 1 号进程和 2 号进程:**当它执行到最后(初始化剩余内容)时,将
start_kernel
中所有的初始化执行完成后,会在内核中启动一个kernel_init
内核线程和一个kthreadd
内核线程。kernel_init
内核线程执行到最后会通过execve
系统调用执行转变为我们所熟悉的init
进程(1 号进程)。kthreadd
内核线程是内核用于管理调度其他的内核线程的守护线程(2 号进程)。
- **idle 进程:**在最后
init_task
将变成一个 idle 进程,用于在 CPU 没有进程运行时运行它,它在此时仅仅用于空转。
补充:为什么需要 idle 进程?
就绪队列结构中含 idle 指针,指向 idle 进程,当调度器发现就绪队列为空时,调用 idle 进程执行,此时 cpu 设置为低功耗省电模式。
init_task 的定义,这里只节选了部分,采用了 gcc 的结构体初始化方式为其进行了直接赋值生成。
1 |
|
set_task_stack_end_magic(&init_task)
函数如下,通过 end_of_stack()
获取栈边界地址,然后把栈底地址设置为STACK_END_MAGIC,作为栈溢出的标记。每个进程创建的时候,系统会为这个进程创建2个页大小的内核栈。
1 |
|
3.初始化中断
trap_init()
:/arch/x86/kernel/traps.c
idt_setup_traps()
:/arch/x86/kernel/idt.cIDT:Interrupt Descriptor Table,中断描述符表
trap_init() 通过 idt_setup_traps() 设置了很多的中断门(Interrupt Gate),用于处理各种中断,如系统调用的中断门set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32),系统调用也是通过发送中断的方式进行的。
1 |
|
4.初始化内存模块
mm_init()
:/init/main.c
mm_init() 就是用来初始化内存管理模块。
1 |
|
调用的函数功能基本如名字所示,主要进行了以下初始化设置:
page_ext_init_flatmem()
和 cgroup 的初始化相关,该部分是 docker 技术的核心部分;mem_init()
初始化内存管理的伙伴系统;kmem_cache_init()
完成内核 slub 内存分配体系的初始化;pgtable_init()
完成页表初始化;vmalloc_init()
完成 vmalloc 的初始化;ioremap_huge_init()
ioremap 实现 I/O 内存资源由物理地址映射到虚拟地址空间,此处为其功能的初始化;init_espfix_bsp()
和pti_init()
完成 PTI(page table isolation)的初始化。
5.初始化进程调度器
sched_init()
:/kernel/sched/core.c
set_load_weight(struct task_struct *p, bool update_load)
:/kernel/sched/core.c
sched_init() 就是用于初始化调度模块。
对相关数据结构分配内存;
初始化
root_task_group
:- 内核维护了一个全局链表
task_groups
,创建的task_group
会添加到这个链表中; - 内核定义了
root_task_group
全局结构,充当task_group
的根节点,以它为根构建树状结构; - 将
kzalloc
分配的空间用于其初始化,主要结构task_group
包含以下几个重要组成部分:se
,rt_se
,cfs_rq
以及rt_rq
。 - 补充:
- task_struct:进程
- task_group:进程组
- cfs_rq 和 rt_rq 表示
run queue
,就绪队列。
- 内核维护了一个全局链表
调用
for_each_possible_cpu()
初始化每个possible
CPU 的runqueue
队列;调用
set_load_weight(&init_task)
,将init_task
进程转变为idle进程。- 实际上是调整进程的优先级权重;
- 只有到最后
init_task
进程开启了kernel_init
和kthreadd
进程之后,才转变为真正意义上的idle
进程。
6.初始化基于内存的文件系统
vfs_caches_init()
:/fs/dcache.c
mnt_init()
:/fs/namespace.c
init_rootfs()
:/init/do_mounts.c
vfs_caches_init() -> mnt_init() -> init_rootfs() 用于初始化基于内存的文件系统 rootfs。
为了兼容各种各样的文件系统,我们需要将文件的相关数据结构和操作抽象出来,形成一个抽象层对上提供统一的接口,这个抽象层就是 VFS(Virtual File System),虚拟文件系统。
7.初始化剩余内容
rest_init()
:/init/main.c
rest_init() 进行了两件重要的事:
- 创建 1 号进程 kernel_init:内核态是kernel_init,到用户态是 init 进程,是用户态所有进程的祖先;
- 创建 2 号进程 kthreadd:负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。
7.1 权限分层与保护模式
1 号进程对于操作系统来讲,有“划时代”的意义,因为它将运行一个用户进程。有了用户进程,系统运行模式就要发生一定的变化。
- 没有用户进程:不会有进程抢占资源,也不会有进程恶意破坏、恶意使用硬件资源。
- 有用户进程:需要做一定的区分。
x86 提供了分层的权限机制,把区域分成了四个 Ring,越往里权限越高,越往外权限越低。操作系统很好地利用了这个机制,将能够访问关键资源的代码放在 Ring0,我们称为内核态(Kernel Mode);将普通的程序代码放在 Ring3,我们称为用户态(User Mode)。
在 内核启动 的过程中,从实模式切换到保护模式,除了可访问空间大一些,还有另一个重要功能,就是“保护”。用户态的代码无法执行更高权限的指令,想要访问核心资源,需要通过 系统调用 来访问。
**例:**当一个用户态的程序运行到一半,要访问一个核心资源,例如访问网卡发一个网络包的过程:
把程序运行到一半的情况保存下来,主要是 CPU 寄存器的数据。
内存的数据:内存是用来保存程序运行时候的中间结果的, 现在要暂时停下来,这些中间结果不能丢,因为再次运行的时候,还要基于这些中间结果接着来。
寄存器的数据:当前运行到代码的哪一行了,当前的栈在哪里,这些都是在寄存器里面的。
暂停的那一刻,要把当时 CPU 的寄存器的值全部暂存到一个地方,这个地方可以放在进程管理系统很容易获取的地方。(后面讨论进程管理数据结构的时候,再详细说。)
内核将从系统调用传过来的包,在网卡上排队,轮到的时候就发送。
发送完后,系统调用就结束了,返回用户态,让暂停运行的程序接着运行。
系统调用过程:用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态,接着运行。
7.2 创建 1 号进程
kernel_init()
:/init/main.c
kernel_init_freeable()
:/init/main.c
run_init_process()
:/init/main.c
kernel_thread 的参数是一个函数 kernel_init,也就是这个进程会运行这个函数。
1 |
|
在 kernel_init 里面,会调用 kernel_init_freeable(),里面有这样的代码。
1 |
|
先忽略 ramdisk,ramdisk 是基于内存的文件系统,后面会说;kernel_init 里面有类似的代码块:
1 |
|
打开 run_init_process 函数,会发现它调用的是 do_execve。
1 |
|
- execve 是一个系统调用,它的作用是运行一个执行文件。加一个 do_ 的往往是内核系统调用的实现。
- 系统调用会先尝试运行 ramdisk 的“/init”,再尝试运行普通文件系统上的“/sbin/init”“/etc/init”“/bin/init”“/bin/sh”。
- 不同版本的 Linux 会选择不同的文件启动,但是只要有一个起来了就可以。
结论:1 号进程运行的是一个文件。
7.3 从内核态到用户态
do_execve、do_execveat_common、exec_binprm、search_binary_handler:/fs/exec.c
elf_format、load_elf_binary、start_thread:/fs/binfmt_elf.c
在创建 1 号进程,之前程序一直运行在内核态,如何从内核态到用户态呢?
答案:系统利用执行 init 文件的机会,从内核态回到用户态。
- 系统调用过程:用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态。
- 运行 init 文件,是调用 do_execve,正是系统调用过程的后半部分,从内核态执行系统调用开始。
do_execve -> do_execveat_common -> exec_binprm -> search_binary_handler,这里面会调用这段内容:
1 |
|
- 运行一个程序,需要加载这个二进制文件;
- 二进制文件是有一定格式的;
- Linux 下常用的格式是ELF(Executable and Linkable Format,可执行与可链接格式)。
ELF 格式是:
1 |
|
如代码所写的,会先调用 load_elf_binary,在 load_elf_binary 函数的最后调用 start_thread。
来看看这个 start_thread:
1 |
|
- struct pt_regs,看名字里的 register,就是寄存器;
- pt_regs 这个结构是在系统调用一开始,内核中保存用户态运行上下文的,里面将用户态的代码段 CS 设置为
__USER_CS
,将用户态的数据段 DS 设置为__USER_DS
,以及指令指针寄存器 IP、栈指针寄存器 SP;(这里就是系统调用过程中,保存寄存器的步骤); - iret 用于从系统调用中返回,并将寄存器恢复至用户态。
至此,下一条指令,就从用户态开始运行了。
7.4 ramdisk 的作用
(1)什么是 ramdisk?
- 先到用户态的是 ramdisk 的 init,它会再启动真正根文件系统上的 init,真正根文件系统上的 init 成为所有用户态进程的祖先;
- 内核启动的时候,配置过这个参数
initrd16 /boot/initramfs-3.10.0-862.el7.x86_64.img
,这就是 ramdisk,一个基于内存的文件系统。
(2)为什么需要基于内存的文件系统 ramdisk?
- init 程序是在文件系统上的,文件系统一定是在一个存储设备上的,例如硬盘;
- Linux 访问存储设备,要有驱动才能访问,程序现在在内核态,所以驱动只能放在内核里;
- 但所有市面上的存储系统的驱动不能默认都放进内核。
- 只好先弄一个基于内存的文件系统。内存访问是不需要驱动的,这就是 ramdisk。
(3)ramdisk 初始化文件系统的流程
- 在内核态时,ramdisk 是根文件系统。
- 运行 ramdisk 上的 /init 之后,就已经在用户态了。
- ramdisk 上的 /init 会先根据存储系统的类型加载驱动,有了驱动就可以设置真正的根文件系统了。
- 有了真正的根文件系统,ramdisk 上的 /init 会启动文件系统上的 init。
接下来就是各种系统的初始化;启动系统的服务,启动控制台,用户就可以登录进来了。
7.5 创建 2 号进程
rest_init 第二件事就是创建第三个进程,2 号进程 kthreadd。
kthreadd 负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。
1 |
|
这里函数名 thread 可以翻译成“线程”,为什么这里创建的是进程,函数名却是线程呢?
- 从用户态来看,创建进程就是申请一份资源,多个线程,共用一份资源,并行执行不同的部分,这叫多线程(Multithreading)。如果只有一个线程,那它就是这个进程的主线程。
- 但从内核态来看,无论是进程,还是线程,都统称为任务(Task),都使用相同的数据结构,平放在同一个链表中。