Linux 内核初始化流程(十):初始化收尾、内核线程诞生与第一个用户进程的执行

【免费下载链接】linux-insides-zh

Linux 内核揭秘

项目地址: https://gitcode.com/hust-open-atom-club/linux-insides-zh
点击查看 免费下载

start_kernel 是 Linux 内核中首个与体系架构无关的初始化函数,它贯穿了从内核入口点到启动 PID 为 1 的 init 进程之间的漫长旅程。本文是《Linux 内核初始化流程》章节的收尾部分(对应 Initialization/linux-initialization-10.md),完整讲解 start_kernel 末尾的 SLAB 缓存体系搭建、procfs 根目录创建、rest_init 中 init 与 kthreadd 两个内核线程的诞生,以及 kernel_init 最终如何借助 execve 系列调用运行出第一个用户进程。读完本文,你将掌握内核从"自身就绪"过渡到"运行用户空间程序"的完整调用链,并理解 init=、rdinit= 等内核命令行参数对首进程选择的影响。

背景:我们在 start_kernel 中的位置

在上一部分中,我们完成了 RCU 的初始化,并停留在 acpi_early_init 函数调用处。本篇从 acpi_early_init 返回后的代码继续前行。在此之前,start_kernel 已经完成了大量工作:初始化了内存管理器(mm_init)、调度器(sched_init)、PID 哈希表(pidhash_init)以及虚拟文件系统(VFS)的早期缓存,这部分函数位于 init/main.c 中。

接下来要做的,是为后续创建第一个用户进程做准备:建立各类对象的 SLAB 缓存、搭建 procfs 的根目录,然后通过 rest_init 正式交出控制权。

espfix:防止返回 16 位栈时的寄存器泄漏

acpi_early_init 返回后,start_kernel 中首先出现的是如下代码:

#ifdef CONFIG_X86_ESPFIX64
	init_espfix_bsp();
#endif

init_espfix_bsp 的调用受 CONFIG_X86_ESPFIX64 配置选项控制,定义在 arch/x86/kernel/espfix_64.c。从函数名可以看出它与栈有关:它的作用是防止在从内核态返回到 16 位栈时,泄漏 esp 寄存器的 31:16 位。

init_espfix_bsp 的第一步,是把 espfix 页上级目录安装进内核页目录:

pgd_p = &init_level4_pgt[pgd_index(ESPFIX_BASE_ADDR)];
pgd_populate(&init_mm, pgd_p, (pud_t *)espfix_pud_page);

其中 ESPFIX_BASE_ADDR 定义为:

#define PGDIR_SHIFT     39
#define ESPFIX_PGD_ENTRY _AC(-2, UL)
#define ESPFIX_BASE_ADDR (ESPFIX_PGD_ENTRY << PGDIR_SHIFT)

这一地址区域在 x86_64 虚拟地址布局中被称为 "%esp fixup stacks",位于 Documentation/x86/x86_64/mm.txt 中描述的空洞区间内(ffffff0000000000 起的 39 位区间),专门用于 espfix 的栈修复映射。

填充完页全局目录之后,接下来调用 init_espfix_random(为 espfix 页返回随机位置)和 init_espfix_ap(为当前 CPU 启用 espfix)。至此 init_espfix_bsp 完成,栈安全相关工作就绪。

关键对象的 SLAB 缓存初始化

espfix 处理完毕后,start_kernel 进入了一连串与 SLAB 缓存相关的初始化。这些缓存的用途是:让后续通过 fork/clone 创建进程时,可以高效地分配 task_struct、thread_info、mm_struct 等核心数据结构。

thread_info_cache_init:thread_info 缓存

thread_info_cache_init 定义在 kernel/fork.c,仅当 THREAD_SIZE 小于 PAGE_SIZE 时才分配 thread_info 缓存:

# if THREAD_SIZE >= PAGE_SIZE
...
void thread_info_cache_init(void)
{
        thread_info_cache = kmem_cache_create("thread_info", THREAD_SIZE,
                                              THREAD_SIZE, 0, NULL);
        BUG_ON(thread_info_cache == NULL);
}
...
#endif

对于 x86_64 而言,PAGE_SIZE 为 4096 字节((_AC(1,UL) << PAGE_SHIFT)),而 THREAD_SIZE 为 (PAGE_SIZE << THREAD_SIZE_ORDER),即 16384 字节,因此 THREAD_SIZE >= PAGE_SIZE 成立,上述分支会被编译执行。

cred_init:凭证缓存

cred_init 定义在 kernel/cred.c,它为进程凭证(credentials,如 uid、gid 等)分配缓存:

void __init cred_init(void)
{
         cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred),
                                     0, SLAB_HWCACHE_ALIGN|SLAB_PANIC, NULL);
}

其中 SLAB_HWCACHE_ALIGN 要求按硬件缓存行对齐,SLAB_PANIC 表示分配失败时直接触发内核 panic(初始化阶段的缓存分配不容失败)。关于凭证机制的详细设计,可参考 Documentation/security/credentials.txt。

fork_init:task_struct 缓存与最大线程数

fork_init 同样位于 kernel/fork.c,负责为 task_struct 分配缓存。其开头首先定义 ARCH_MIN_TASKALIGN 宏并创建 slab:

#ifndef CONFIG_ARCH_TASK_STRUCT_ALLOCATOR
#ifndef ARCH_MIN_TASKALIGN
#define ARCH_MIN_TASKALIGN      L1_CACHE_BYTES
#endif
        task_struct_cachep =
                kmem_cache_create("task_struct", sizeof(struct task_struct),
                        ARCH_MIN_TASKALIGN, SLAB_PANIC | SLAB_NOTRACK, NULL);
#endif

这段代码依赖 CONFIG_ARCH_TASK_STRUCT_ALLOCATOR 配置选项,它标识给定架构是否提供了 alloc_task_struct 函数。x86_64 并没有这个函数,因此该分支在 x86_64 上既不会运行也不会被编译。

架构特定缓存:arch_task_cache_init

随后 fork_init 调用 arch_task_cache_init:

void arch_task_cache_init(void)
{
        task_xstate_cachep =
                kmem_cache_create("task_xstate", xstate_size,
                                  __alignof__(union thread_xstate),
                                  SLAB_PANIC | SLAB_NOTRACK, NULL);
        setup_xstate_comp();
}

该函数完成架构特定缓存的初始化。在 x86_64 上,它为 task_xstate(表示 FPU 状态)分配缓存,并通过 setup_xstate_comp 设置 xsave 区域中所有扩展状态的偏移与大小。

最大线程数与信号资源限制

之后 fork_init 计算默认的最大线程数:

set_max_threads(MAX_THREADS);

其中:

#define FUTEX_TID_MASK  0x3fffffff
#define MAX_THREADS     FUTEX_TID_MASK

fork_init 的最后,是初始化 init_task 的信号处理器资源限制:

init_task.signal->rlim[RLIMIT_NPROC].rlim_cur = max_threads/2;
init_task.signal->rlim[RLIMIT_NPROC].rlim_max = max_threads/2;
init_task.signal->rlim[RLIMIT_SIGPENDING] =
		init_task.signal->rlim[RLIMIT_NPROC];

init_task 是 task_struct 的一个实例,其 signal 字段类型为 struct signal_struct,表示进程的信号状态。每个进程都关联一组资源限制(resource limits),用于指定该进程可以使用的资源上限。rlim 是资源控制上限,由 include/uapi/linux/resource.h 中的结构描述:

struct rlimit {
        __kernel_ulong_t        rlim_cur;
        __kernel_ulong_t        rlim_max;
};

这里涉及的两个资源是:

  • RLIMIT_NPROC —— 用户可拥有的最大进程数;
  • RLIMIT_SIGPENDING —— 最大挂起信号数。

可以在运行时用以下命令验证:

cat /proc/self/limits
Limit                     Soft Limit           Hard Limit           Units
...
Max processes             63815                63815                processes
Max pending signals       63815                63815                signals
...

proc_caches_init:内存描述符相关缓存

fork_init 之后是 proc_caches_init(kernel/fork.c),它负责为内存描述符(mm_struct 结构)分配缓存。函数开头通过 kmem_cache_create 分配了多个 SLAB 缓存:

  • sighand_cachep —— 管理已安装信号处理器信息;
  • signal_cachep —— 管理进程信号描述符信息;
  • files_cachep —— 管理已打开文件信息;
  • fs_cachep —— 管理文件系统信息。

随后为 mm_struct 分配缓存:

mm_cachep = kmem_cache_create("mm_struct",
                         sizeof(struct mm_struct), ARCH_MIN_MMSTRUCT_ALIGN,
                         SLAB_HWCACHE_ALIGN|SLAB_PANIC|SLAB_NOTRACK, NULL);

再为内核管理虚拟内存空间的重要结构 vm_area_struct 分配缓存:

vm_area_cachep = KMEM_CACHE(vm_area_struct, SLAB_PANIC);

注意这里用的是 KMEM_CACHE 宏而非 kmem_cache_create。该宏定义在 include/linux/slab.h 中,展开后等价于:

#define KMEM_CACHE(__struct, __flags) kmem_cache_create(#__struct,\
                sizeof(struct __struct), __alignof__(struct __struct),\
                (__flags), NULL)

KMEM_CACHE 与 kmem_cache_create 的区别在于:宏借助 __alignof__ 运算符自动按给定结构的对齐要求来对齐 SLAB,而 kmem_cache_create 使用调用者传入的数值作为对齐空间。

之后调用 mmap_init(初始化虚拟内存区域 SLAB)和 nsproxy_cache_init(为命名空间初始化 SLAB)。

buffer_init:缓冲区管理缓存

buffer_init 定义在 fs/buffer.c,为 buffer_head 分配缓存。buffer_head 定义在 include/linux/buffer_head.h,是用于管理缓冲区(buffer)的特殊结构。函数开头同样调用 kmem_cache_create 分配 struct buffer_head 缓存,然后计算内存中缓冲区的最大规模:

nrpages = (nr_free_buffer_pages() * 10) / 100;
max_buffer_heads = nrpages * (PAGE_SIZE / sizeof(struct buffer_head));

该上限相当于 ZONE_NORMAL 的 10%(对 x86_64 而言,即 4GB 以上的所有 RAM 中的 10%)。

vfs_caches_init:VFS 缓存的后期初始化

vfs_caches_init 为不同的 VFS 缓存分配 SLAB 缓存与哈希表。在前面的初始化流程第八部分中,我们已经见过 vfs_caches_init_early 函数,它初始化了 dcache(目录缓存)和 inode 缓存。而 vfs_caches_init 则完成 dcache、inode 缓存的后期初始化,以及私有数据缓存、挂载点哈希表等。VFS 的细节将在专门的章节中展开。

signals_init 与 page_writeback_init

signals_init 定义在 kernel/signal.c,为 sigqueue 结构分配缓存——sigqueue 表示实时信号队列。紧随其后的 page_writeback_init 则初始化脏页(dirty pages)的比率。每个底层页条目都包含一个 dirty 位,用于指示该页装入内存后是否已被写入。

创建 procfs 的根目录

在上述缓存准备工作完成后,内核需要为 proc 文件系统创建根目录,这一步通过 fs/proc/root.c 中的 proc_root_init 完成。函数开头为 inode 分配缓存并注册新文件系统:

err = register_filesystem(&proc_fs_type);
      if (err)
                return;

注册完成后调用 fs/proc/self.c 中的 proc_self_init,为 /proc/self(指向访问 /proc 文件系统的当前进程)分配 inode 编号。接着是 proc_setup_thread_self,用于设置包含当前线程信息的 /proc/thread-self 目录。

随后创建 /proc/self/mounts 符号链接,指向包含挂载点的目录:

proc_symlink("mounts", NULL, "self/mounts");

并依据不同配置选项创建一系列目录:

#ifdef CONFIG_SYSVIPC
        proc_mkdir("sysvipc", NULL);
#endif
        proc_mkdir("fs", NULL);
        proc_mkdir("driver", NULL);
        proc_mkdir("fs/nfsd", NULL);
#if defined(CONFIG_SUN_OPENPROMFS) || defined(CONFIG_SUN_OPENPROMFS_MODULE)
        proc_mkdir("openprom", NULL);
#endif
        proc_mkdir("bus", NULL);
        ...
        if (!proc_mkdir("tty", NULL))
                 return;
        proc_mkdir("tty/ldisc", NULL);
        ...

proc_root_init 末尾调用 proc_sys_init,创建 /proc/sys 目录并初始化 Sysctl 机制。

start_kernel 中略过的其他函数

start_kernel 到此已接近尾声。它内部还调用了许多对通用内核初始化不重要、仅依赖具体内核配置的函数,包括:

  • taskstats_init_early —— 向用户空间导出每个任务的统计信息;
  • delayacct_init —— 初始化每任务延迟记账(delay accounting);
  • key_init 与 security_init —— 初始化各类安全机制;
  • check_bugs —— 修复一些与架构相关的缺陷;
  • ftrace_init —— 执行 ftrace 的初始化;
  • cgroup_init —— 完成 cgroup 子系统的其余初始化。

这些子系统大多会在后续章节中详细介绍。start_kernel 的最后一次调用是 rest_init,它宣告体系无关初始化收尾,并正式启动"运行第一个进程"的流程。

rest_init:两个内核线程的诞生

rest_init 与 start_kernel 定义在同一个源文件 init/main.c 中。函数开头是两个调用:

	rcu_scheduler_starting();
	smpboot_thread_init();

rcu_scheduler_starting 使 RCU 调度器变为活动状态;smpboot_thread_init 注册 smpboot_thread_notifier CPU 通知器(详见内核 CPU 热插拔文档 Documentation/cpu-hotplug.txt)。

接下来是 rest_init 的核心动作——创建两个内核线程:

kernel_thread(kernel_init, NULL, CLONE_FS);
pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);

kernel_thread(定义在 kernel/fork.c)创建新的内核线程,它接受三个参数:

  1. 新线程中要执行的函数;
  2. 传给该函数的参数;
  3. 标志位。

kernel_thread 内部会调用 clone 系统调用。这里传入 CLONE_FS 表示父子线程共享文件系统信息。两次调用分别创建:

  • PID = 1 的 init 进程,执行 kernel_init 函数;
  • PID = 2 的 kthreadd 内核线程。

内核线程与用户线程不同,它以内核模式运行。kthreadd 是一个特殊的内核线程,负责帮助内核各个部分创建其他内核线程。可以通过 ps 命令观察它:

$ ps -ef | grep kthreadd
root         2     0  0 Jan11 ?        00:00:00 [kthreadd]

通过 RCU 保护下的 PID 查找与 completion 同步

创建完两个内核线程后,rest_init 继续:

	rcu_read_lock();
	kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns);
	rcu_read_unlock();

rcu_read_lock 标记 RCU 读侧临界区的开始,rcu_read_unlock 标记其结束——它们用来保护 find_task_by_pid_ns 的并发访问。find_task_by_pid_ns 根据给定 PID 返回 task_struct 指针。这里获取的是 PID = 2(kthreadd)对应的 task_struct。

随后调用 complete 唤醒等待者:

complete(&kthreadd_done);

kthreadd_done 定义为:

static __initdata DECLARE_COMPLETION(kthreadd_done);

其中 DECLARE_COMPLETION 宏展开为 completion 结构的定义:

#define DECLARE_COMPLETION(work) \
         struct completion work = COMPLETION_INITIALIZER(work)

completion 结构定义在 include/linux/completion.h,实现了"完成量"(completions)同步机制。该机制为必须等待某个进程到达特定状态的线程提供了无竞态的解决方案,由三部分组成:

  1. 定义 completion 结构(这里用 DECLARE_COMPLETION 完成);
  2. 调用 wait_for_completion,使调用线程阻塞,直到其他线程调用 complete;
  3. 调用 complete 唤醒等待者。

注意:kernel_init_freeable 开头正是用 wait_for_completion(&kthreadd_done) 等待 kthreadd 就绪,因此 kernel_init_freeable 会一直阻塞,直到上面 rest_init 中的 complete(&kthreadd_done) 执行完毕。

当前线程变成 idle 进程

设置完 kthreadd 后,rest_init 中还有三个调用:

	init_idle_bootup_task(current);
	schedule_preempt_disabled();
    cpu_startup_entry(CPUHP_ONLINE);

init_idle_bootup_task(kernel/sched/core.c)为当前进程设置调度类——这里是 idle 类:

void init_idle_bootup_task(struct task_struct *idle)
{
         idle->sched_class = &idle_sched_class;
}

idle 类拥有最低的任务优先级,只有当处理器没有其他任何任务可运行时才会调度该类任务。schedule_preempt_disabled 在 idle 任务中禁用抢占。第三个函数 cpu_startup_entry(kernel/sched/idle.c)调用 cpu_idle_loop——它作为 PID = 0 的进程在后台运行,主要目的是消耗空闲的 CPU 周期。当没有进程可运行时,该进程开始工作。由于当前只有一个 idle 调度类进程(通过 init_idle_bootup_task 设置),idle 线程不做有用工作,只是不断检查是否有活动任务需要切换:

static void cpu_idle_loop(void)
{
        ...
        while (1) {
                while (!need_resched()) {
                ...
                }
        ...
        }
}

至此,start_kernel 调用了 rest_init,后者创建了 init 进程(kernel_init 函数)并让自身成为 idle 进程。

kernel_init:从内核线程到第一个用户进程

kernel_init_freeable:等待 kthreadd 并完成基本设置

kernel_init 的执行从 kernel_init_freeable 开始。该函数首先等待 kthreadd 完成设置:

wait_for_completion(&kthreadd_done);

随后进行一系列设置:

  • 将 gfp_allowed_mask 设为 __GFP_BITS_MASK,表示系统已在运行;
  • 用 set_mems_allowed 把允许的 cpus/mems 设置为全部 CPU 与全部 NUMA 节点;
  • 用 set_cpus_allowed_ptr 允许 init 进程在任何 CPU 上运行;
  • 为 cad(Ctrl-Alt-Delete 进程)设置 PID;
  • 用 smp_prepare_cpus 为其他 CPU 的启动做准备;
  • 用 do_pre_smp_initcalls 调用早期 initcalls;
  • 用 smp_init 初始化 SMP;
  • 用 lockup_detector_init 初始化 lockup 检测器(详见 Documentation/lockup-watchdogs.txt);
  • 用 sched_init_smp 初始化调度器的 SMP 部分。

关于 initcall 机制的层级(early、core、postcore、arch、subsys、fs、device、late),可以参考仓库中的 Concepts/linux-cpu-3.md,该文详细介绍了 initcall 机制在内核中的实现与调用顺序。

之后调用 do_basic_setup。正如源码注释所说:

Now we can finally start doing some real work..

do_basic_setup 会:将 cpuset 重新初始化为活动 CPU 集合、初始化 khelper(内核中用于向用户空间发起调用的内核线程)、初始化 tmpfs、初始化驱动子系统、启用用户模式辅助 workqueue,并执行 initcalls 的后期调用。

打开初始控制台

do_basic_setup 之后,内核打开初始控制台:

if (sys_open((const char __user *) "/dev/console", O_RDWR, 0) < 0)
	pr_err("Warning: unable to open an initial console.\n");

(void) sys_dup(0);
(void) sys_dup(0);

这里用到了两个系统调用:sys_open 与 sys_dup。sys_dup 连续复制两次文件描述符 0,使 /dev/console 同时成为标准输入、标准输出与标准错误(fd 0、1、2)。

确定 initrd 与命名空间准备

打开初始控制台后,检查 rdinit= 选项是否传入,否则使用默认 ramdisk 路径:

if (!ramdisk_execute_command)
	ramdisk_execute_command = "/init";

随后检查用户对 ramdisk 的权限,并调用 init/do_mounts.c 中的 prepare_namespace 检查并挂载 initrd:

if (sys_access((const char __user *) ramdisk_execute_command, 0) != 0) {
	ramdisk_execute_command = NULL;
	prepare_namespace();
}

至此 kernel_init_freeable 结束,控制权返回 kernel_init。

释放初始化内存并进入运行态

kernel_init_freeable 返回后,kernel_init 调用 async_synchronize_full 等待所有异步函数调用完成,然后调用 free_initmem 释放位于 __init_begin 与 __init_end 之间的、由初始化代码占据的所有内存。接着用 mark_rodata_ro 保护 .rodata 段,并把系统状态从 SYSTEM_BOOTING 更新为:

system_state = SYSTEM_RUNNING;

运行 init 进程:内核命令行参数的选择顺序

系统进入运行态后,内核开始尝试运行 init 进程:

if (ramdisk_execute_command) {
	ret = run_init_process(ramdisk_execute_command);
	if (!ret)
		return 0;
	pr_err("Failed to execute %s (error %d)\n",
	       ramdisk_execute_command, ret);
}

首先检查 ramdisk_execute_command——它在 kernel_init_freeable 中设置,值为 rdinit= 内核命令行参数的值,或默认的 /init。run_init_process 填充 argv_init 数组的首个元素:

static const char *argv_init[MAX_INIT_ARGS+2] = { "init", NULL, };

然后调用 do_execve:

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);

do_execve(声明于 include/linux/sched.h)使用给定的文件名与参数运行程序。

如果没有传入 rdinit= 选项,内核转而检查 execute_command——它等于 init= 内核命令行参数的值:

	if (execute_command) {
		ret = run_init_process(execute_command);
		if (!ret)
			return 0;
		panic("Requested init %s failed (error %d).",
		      execute_command, ret);
	}

如果 init= 参数也没有传入,内核按顺序尝试以下可执行文件:

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;

若以上全部失败,则以内核 panic 收场:

panic("No working init found.  Try passing init= option to kernel. "
      "See Linux Documentation/init.txt for guidance.");

至此,Linux 内核的初始化过程宣告结束:start_kernel 完成了体系无关的初始化,rest_init 创建了 init 与 kthreadd 两个内核线程并让当前执行流化身 idle 进程,而 kernel_init 则在释放初始化内存、保护只读数据段后,依据 rdinit=、init= 内核命令行参数或默认路径列表,最终通过 do_execve 运行出系统中的第一个用户进程。

小结

本文(Initialization/linux-initialization-10.md)作为内核初始化流程章节的收官篇,串起了整条主线:

  • 缓存体系的收尾:thread_info_cache_init、cred_init、fork_init、proc_caches_init、buffer_init、vfs_caches_init、signals_init 等函数,为 task_struct、thread_info、凭证、mm_struct、vm_area_struct、buffer_head、sigqueue 等核心对象建好 SLAB 缓存,并通过 KMEM_CACHE 宏简化了按结构对齐的缓存创建;
  • procfs 根目录:proc_root_init 注册 proc 文件系统,创建 /proc/self、/proc/thread-self、/proc/self/mounts 以及各配置相关的子目录,并初始化 Sysctl;
  • 内核线程诞生:rest_init 借助 kernel_thread 创建 PID=1 的 kernel_init 与 PID=2 的 kthreadd,通过 completion 机制同步二者,最终把当前执行流切换为 idle 进程进入 cpu_idle_loop;
  • 用户空间首进程:kernel_init → kernel_init_freeable 完成基本设置与 do_basic_setup,打开 /dev/console 并 dup 为 fd 0/1/2,随后 free_initmem 释放初始化内存、mark_rodata_ro 保护只读段,最后按 rdinit= → init= → /sbin/init、/etc/init、/bin/init、/bin/sh 的顺序执行第一个用户进程。

整个初始化章节从内核解压后的首要步骤一路走到这里,覆盖了从 start_kernel 到第一个 init 进程启动的完整周期。调度器、中断、异常处理等子系统细节,将在此后的各个专题章节中逐一展开。

【免费下载链接】linux-insides-zh

Linux 内核揭秘

项目地址: https://gitcode.com/hust-open-atom-club/linux-insides-zh
点击查看 免费下载

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值