Linux 内核|初始化

在 Linux 内核启动后,完成了实模式到保护模式的切换,并做好了各种准备工作。接下来进入内核初始化,我们主要关注初始化的流程。

1.概述

内核的启动从入口函数 start_kernel() 开始。在 init/main.c 文件中,start_kernel 相当于内核的 main 函数。这个函数里,就是各种各样初始化函数 XXXX_init。其中的主要流程有以下内容:

  1. 创建0号进程:INIT_TASK(init_task)
  2. 异常处理类中断服务程序挂接:trap_init()
  3. 初始化内存:mm_init()
  4. 初始化进程调度器:sched_init()
  5. 初始化基于内存的文件系统:vfs_caches_init()
  6. 初始化其他内容: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
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
/*
* Set up the first task table, touch at your own risk!. Base=0,
* limit=0x1fffff (=2MB)
*/
struct task_struct init_task
#ifdef CONFIG_ARCH_TASK_STRUCT_ON_STACK
__init_task_data
#endif
= {
......
.state = 0,
.stack = init_stack,
.usage = REFCOUNT_INIT(2),
.flags = PF_KTHREAD,
.prio = MAX_PRIO - 20,
.static_prio = MAX_PRIO - 20,
.normal_prio = MAX_PRIO - 20,
.policy = SCHED_NORMAL,
.cpus_ptr = &init_task.cpus_mask,
.cpus_mask = CPU_MASK_ALL,
.nr_cpus_allowed = NR_CPUS,
.mm = NULL,
.active_mm = &init_mm,
......
.thread_pid = &init_struct_pid,
.thread_group = LIST_HEAD_INIT(init_task.thread_group),
.thread_node = LIST_HEAD_INIT(init_signals.thread_head),
......
};
EXPORT_SYMBOL(init_task);

set_task_stack_end_magic(&init_task) 函数如下,通过 end_of_stack() 获取栈边界地址,然后把栈底地址设置为STACK_END_MAGIC,作为栈溢出的标记。每个进程创建的时候,系统会为这个进程创建2个页大小的内核栈。

1
2
3
4
5
6
7
void set_task_stack_end_magic(struct task_struct *tsk)
{
unsigned long *stackend;

stackend = end_of_stack(tsk);
*stackend = STACK_END_MAGIC; /* for overflow detection */
}

3.初始化中断

trap_init():/arch/x86/kernel/traps.c

idt_setup_traps():/arch/x86/kernel/idt.c

IDT:Interrupt Descriptor Table,中断描述符表

trap_init() 通过 idt_setup_traps() 设置了很多的中断门(Interrupt Gate),用于处理各种中断,如系统调用的中断门set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32),系统调用也是通过发送中断的方式进行的。

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
/*
* The default IDT entries which are set up in trap_init() before
* cpu_init() is invoked. Interrupt stacks cannot be used at that point and
* the traps which use them are reinitialized with IST after cpu_init() has
* set up TSS.
*/
static const __initconst struct idt_data def_idts[] = {
// 每一个 INTG 就是设置一个 idt_data 表项(第一个参数是中断向量)
INTG(X86_TRAP_DE, divide_error),
INTG(X86_TRAP_NMI, nmi),
INTG(X86_TRAP_BR, bounds),
INTG(X86_TRAP_UD, invalid_op),
INTG(X86_TRAP_NM, device_not_available),
INTG(X86_TRAP_OLD_MF, coprocessor_segment_overrun),
INTG(X86_TRAP_TS, invalid_TSS),
INTG(X86_TRAP_NP, segment_not_present),
INTG(X86_TRAP_SS, stack_segment),
INTG(X86_TRAP_GP, general_protection),
INTG(X86_TRAP_SPURIOUS, spurious_interrupt_bug),
INTG(X86_TRAP_MF, coprocessor_error),
INTG(X86_TRAP_AC, alignment_check),
INTG(X86_TRAP_XF, simd_coprocessor_error),

#ifdef CONFIG_X86_32
TSKG(X86_TRAP_DF, GDT_ENTRY_DOUBLEFAULT_TSS),
#else
INTG(X86_TRAP_DF, double_fault),
#endif
INTG(X86_TRAP_DB, debug),

#ifdef CONFIG_X86_MCE
INTG(X86_TRAP_MC, &machine_check),
#endif

SYSG(X86_TRAP_OF, overflow),
#if defined(CONFIG_IA32_EMULATION)
SYSG(IA32_SYSCALL_VECTOR, entry_INT80_compat),
#elif defined(CONFIG_X86_32)
SYSG(IA32_SYSCALL_VECTOR, entry_INT80_32), // x86_32 的系统调用 int 0x80
#endif
};

4.初始化内存模块

mm_init():/init/main.c

mm_init() 就是用来初始化内存管理模块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
static void __init mm_init(void)
{
/*
* page_ext requires contiguous pages,
* bigger than MAX_ORDER unless SPARSEMEM.
*/
page_ext_init_flatmem();
init_debug_pagealloc();
report_meminit();
mem_init();
kmem_cache_init();
kmemleak_init();
pgtable_init();
debug_objects_mem_init();
vmalloc_init();
ioremap_huge_init();
/* Should be run before the first non-init thread is created */
init_espfix_bsp();
/* Should be run after espfix64 is set up. */
pti_init();
}

调用的函数功能基本如名字所示,主要进行了以下初始化设置:

  • 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()初始化每个possibleCPU 的runqueue队列;

  • 调用set_load_weight(&init_task),将init_task进程转变为idle进程。

    • 实际上是调整进程的优先级权重;
    • 只有到最后init_task进程开启了kernel_initkthreadd进程之后,才转变为真正意义上的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)。

内核启动 的过程中,从实模式切换到保护模式,除了可访问空间大一些,还有另一个重要功能,就是“保护”。用户态的代码无法执行更高权限的指令,想要访问核心资源,需要通过 系统调用 来访问。

例:当一个用户态的程序运行到一半,要访问一个核心资源,例如访问网卡发一个网络包的过程:

  1. 把程序运行到一半的情况保存下来,主要是 CPU 寄存器的数据。

    • 内存的数据:内存是用来保存程序运行时候的中间结果的, 现在要暂时停下来,这些中间结果不能丢,因为再次运行的时候,还要基于这些中间结果接着来。

    • 寄存器的数据:当前运行到代码的哪一行了,当前的栈在哪里,这些都是在寄存器里面的。

    • 暂停的那一刻,要把当时 CPU 的寄存器的值全部暂存到一个地方,这个地方可以放在进程管理系统很容易获取的地方。(后面讨论进程管理数据结构的时候,再详细说。)

  2. 内核将从系统调用传过来的包,在网卡上排队,轮到的时候就发送。

  3. 发送完后,系统调用就结束了,返回用户态,让暂停运行的程序接着运行。

系统调用过程:用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态,接着运行。

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_thread(kernel_init, NULL, CLONE_FS);

在 kernel_init 里面,会调用 kernel_init_freeable(),里面有这样的代码。

1
2
if (!ramdisk_execute_command)
ramdisk_execute_command = "/init"; // ramdisk 的 init

先忽略 ramdisk,ramdisk 是基于内存的文件系统,后面会说;kernel_init 里面有类似的代码块:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (ramdisk_execute_command) {
ret = run_init_process(ramdisk_execute_command); // 先尝试运行 ramdisk 的“/init”
if (!ret)
return 0;
......
}
......
// 再尝试运行普通文件系统上的“/sbin/init”“/etc/init”“/bin/init”“/bin/sh”
if (!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;

打开 run_init_process 函数,会发现它调用的是 do_execve

1
2
3
4
5
6
7
static int run_init_process(const char *init_filename)
{
argv_init[0] = init_filename;
return do_execve(getname_kernel(init_filename), // 系统调用
(const char __user *const __user *)argv_init,
(const char __user *const __user *)envp_init);
}
  • 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
2
3
4
5
6
7
8
int search_binary_handler(struct linux_binprm *bprm)
{
......
struct linux_binfmt *fmt; // 二进制文件的格式
......
retval = fmt->load_binary(bprm);
......
}
  • 运行一个程序,需要加载这个二进制文件;
  • 二进制文件是有一定格式的;
  • Linux 下常用的格式是ELF(Executable and Linkable Format,可执行与可链接格式)。

ELF 格式是:

1
2
3
4
5
6
7
8
static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};

如代码所写的,会先调用 load_elf_binary,在 load_elf_binary 函数的最后调用 start_thread

来看看这个 start_thread:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
set_user_gs(regs, 0);
regs->fs = 0;
regs->ds = __USER_DS; // 接下来就是保存用户态时寄存器数据的步骤
regs->es = __USER_DS;
regs->ss = __USER_DS;
regs->cs = __USER_CS;
regs->ip = new_ip;
regs->sp = new_sp;
regs->flags = X86_EFLAGS_IF;
force_iret(); // 返回用户态
}
EXPORT_SYMBOL_GPL(start_thread);
  • 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
kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)

这里函数名 thread 可以翻译成“线程”,为什么这里创建的是进程,函数名却是线程呢?

  • 从用户态来看,创建进程就是申请一份资源,多个线程,共用一份资源,并行执行不同的部分,这叫多线程(Multithreading)。如果只有一个线程,那它就是这个进程的主线程。
  • 但从内核态来看,无论是进程,还是线程,都统称为任务(Task),都使用相同的数据结构,平放在同一个链表中。

参考


Linux 内核|初始化
https://www.aimtao.net/init/
Posted on
2022-01-19
Licensed under