15 | Linux初始化(下):从_start到第一个进程
下载APP
关闭
渠道合作
推荐作者
15 | Linux初始化(下):从_start到第一个进程
2021-06-11 LMOS 来自北京
《操作系统实战45讲》
课程介绍
讲述:陈晨
时长15:27大小14.16M
你好,我是 LMOS。
今天我们继续来研究 Linux 的初始化流程,为你讲解如何解压内核,然后讲解 Linux 内核第一个 C 函数。最后,我们会用 Linux 的第一个用户进程的建立来收尾。
如果用你上手去玩一款新游戏做类比的话,那么上节课只是新手教程,而这节课就是更深入的实战了。后面你会看到很多熟悉的“面孔”,像是我们前面讲过的 CPU 工作模式、MMU 页表等等基础知识,这节课都会得到运用。
解压后内核初始化
下面,我们先从 setup.bin 文件的入口 _start 开始,了解启动信息结构,接着由 16 位 main 函数切换 CPU 到保护模式,然后跳入 vmlinux.bin 文件中的 startup_32 函数重新加载段描述符。
如果是 64 位的系统,就要进入 startup_64 函数,切换到 CPU 到长模式,最后调用 extract_kernel 函数解压 Linux 内核,并进入内核的 startup_64 函数,由此 Linux 内核开始运行。
为何要从 _start 开始
通过上节课对 vmlinuz 文件结构的研究,我们已经搞清楚了其中的 vmlinux.bin 是如何产生的,它是由 linux/arch/x86/boot/compressed 目录下的一些目标文件,以及 piggy.S 包含的一个 vmlinux.bin.gz 的压缩文件一起生成的。
vmlinux.bin.gz 文件则是由编译的 Linux 内核所生成的 elf 格式的 vmlinux 文件,去掉了文件的符号信息和重定位信息后,压缩得到的。
CPU 是无法识别压缩文件中的指令直接运行的,必须先进行解压后,然后解析 elf 格式的文件,把其中的指令段和数据段加载到指定的内存空间中,才能由 CPU 执行。
这就需要用到前面的 setup.bin 文件了,_start 正是 setup.bin 文件的入口,在 head.S 文件中定义,代码如下。
setup_header 结构
下面我们重点研究一下 setup_header 结构,这对我们后面的流程很关键。它定义在 linux/arch/x86/include/uapi/asm/bootparam.h 文件中,如下所示。
前面提到过,硬盘中 MBR 是由 GRUB 写入的 boot.img,因此这里的 linux/arch/x86/boot/head.S 中的 bootsector 对于硬盘启动是无用的。
GRUB 将 vmlinuz 的 setup.bin 部分读到内存地址 0x90000 处,然后跳转到 0x90200 开始执行,恰好跳过了前面 512 字节的 bootsector,从 _start 开始。
16 位的 main 函数
我们通常用 C 编译器编译的代码,是 32 位保护模式下的或者是 64 位长模式的,却很少编译成 16 位实模式下的,其实 setup.bin 大部分代码都是 16 位实模式下的。
从前面的代码里,我们能够看到在 linux/arch/x86/boot/head.S 中调用了 main 函数,该函数在 linux/arch/x86/boot/main.c 文件中,代码如下 。
上面这些函数都在 linux/arch/x86/boot/ 目录对应的文件中,都是调用 BIOS 中断完成的,具体细节,你可以自行查看。
我这里列出的代码只是帮助你理清流程,我们继续看看 go_to_protected_mode() 函数,在 linux/arch/x86/boot/pm.c 中,代码如下。
protected_mode_jump 是个汇编函数,在 linux/arch/x86/boot/pmjump.S 文件中。代码逻辑和我们前面(第 5 节课)学到的保护模式切换是一样的。只是多了处理参数的逻辑,即跳转到 boot_params.hdr.code32_start 中的地址。
这个地址在 linux/arch/x86/boot/head.S 文件中设为 0x100000,如下所示。
需要注意的是,GRUB 会把 vmlinuz 中的 vmlinux.bin 部分,放在 1MB 开始的内存空间中。通过这一跳转,正式进入 vmlinux.bin 中。
startup_32 函数
startup_32 中需要重新加载段描述符,之后计算 vmlinux.bin 文件的编译生成的地址和实际加载地址的偏移,然后重新设置内核栈,检测 CPU 是否支持长模式,接着再次计算 vmlinux.bin 加载地址的偏移,来确定对其中 vmlinux.bin.gz 解压缩的地址。
如果 CPU 支持长模式的话,就要设置 64 位的全局描述表,开启 CPU 的 PAE 物理地址扩展特性。再设置最初的 MMU 页表,最后开启分页并进入长模式,跳转到 startup_64,代码如下。
startup_64 函数
现在,我们终于开启了 CPU 长模式,从 startup_64 开始真正进入了 64 位的时代,可喜可贺。
startup_64 函数同样也是在 linux/arch/x86/boot/compressed/head64.S 文件中定义的。
startup_64 函数中,初始化长模式下数据段寄存器,确定最终解压缩地址,然后拷贝压缩 vmlinux.bin 到该地址,跳转到 decompress_kernel 地址处,开始解压 vmlinux.bin.gz,代码如下。
上述代码中最后到了 extract_kernel 函数,它就是解压内核的函数,下面我们就来研究它。
extract_kernel 函数
从 startup_32 函数到 startup_64 函数,其间经过了保护模式、长模式,最终到达了 extract_kernel 函数,extract_kernel 函数根据 piggy.o 中的信息从 vmlinux.bin.gz 中解压出 vmlinux。
根据前面的知识点,我们知道 vmlinux 正是编译出 Linux 内核 elf 格式的文件,只不过它被去掉了符号信息。所以,extract_kernel 函数不仅仅是解压,还需要解析 elf 格式。
extract_kernel 函数是在 linux/arch/x86/boot/compressed/misc.c 文件中定义的。
正如上面代码所示,extract_kernel 函数调用 __decompress 函数,对 vmlinux.bin.gz 使用特定的解压算法进行解压。解压算法是编译内核的配置选项决定的。
但是,__decompress 函数解压出来的是 vmlinux 文件是 elf 格式的,所以还要调用 parse_elf 函数进一步解析 elf 格式,把 vmlinux 中的指令段、数据段、BSS 段,根据 elf 中信息和要求放入特定的内存空间,返回指令段的入口地址。
请你注意,在 Lrelocated 函数的最后一条指令:jmp *rax,其中的 rax 中就是保存的 extract_kernel 函数返回的入口点,就是从这里开始进入了 Linux 内核。
Linux 内核的 startup_64
这里我提醒你留意,此时的 startup_64 函数并不是之前的 startup_64 函数,也不参与前面的链接工作。
这个 startup_64 函数定义在 linux/arch/x86/kernel/head_64.S 文件中,它是内核的入口函数,如下所示。
上述代码中省略了和流程无关的代码,对于 SMP 系统加电之后,总线仲裁机制会选出多个 CPU 中的一个 CPU,称为 BSP,也叫第一个 CPU。它负责让 BSP CPU 先启动,其它 CPU 则等待 BSP CPU 的唤醒。
这里我来分情况给你说说。对于第一个启动的 CPU,会跳转 secondary_startup_64 函数中 1 标号处,对于其它被唤醒的 CPU 则会直接执行 secondary_startup_64 函数。
接下来,我给你快速过一遍 secondary_startup_64 函数,后面的代码我省略了这个函数对更多 CPU 特性(设置 GDT、IDT,处理了 MMU 页表等)的检查,因为这些工作我们早已很熟悉了,代码如下所示。
在 secondary_startup_64 函数一切准备就绪之后,最后就会调用 x86_64_start_kernel 函数,看它的名字好像是内核的开始函数,但真的是这样吗,我们一起看看才知道。
Linux 内核的第一个 C 函数
若不是经历了前面的分析讲解。要是我问你 Linux 内核的第一个 C 函数是什么,你可能无从说起,就算一通百度之后,仍然无法确定。
但是,只要我们跟着代码的执行流程,就会发现在 secondary_startup_64 函数的最后,调用的 x86_64_start_kernel 函数是用 C 语言写的,那么它一定就是 Linux 内核的第一个 C 函数。它在 linux/arch/x86/kernel/head64.c 文件中被定义,这个文件名你甚至都能猜出来,如下所示。
x86_64_start_kernel 函数中又一次处理了页表,处理页表就是处理 Linux 内核虚拟地址空间,Linux 虚拟地址空间是一步步完善的。
然后,x86_64_start_kernel 函数复制了引导信息,即 struct boot_params 结构体。最后调用了 x86_64_start_reservations 函数,其中处理了平台固件相关的东西,就是调用了大名鼎鼎的 start_kernel 函数。
有名的 start_kernel 函数
start_kernel 函数之所以有名,这是因为在互联网上,在各大 Linux 名著之中,都会大量宣传它 Linux 内核中的地位和作用,正如其名字表达的含意,这是内核的开始。
但是问题来了。我们一路走来,发现 start_kernel 函数之前有大量的代码执行,那这些代码算不算内核的开始呢?当然也可以说那就是内核的开始,也可以说是前期工作。
其实,start_kernel 函数中调用了大量 Linux 内核功能的初始化函数,它定义在 /linux/init/main.c 文件中。
start_kernel 函数我如果不做精简,会有 200 多行,全部都是初始化函数,我只留下几个主要的初始化函数,这些函数的实现细节我们无需关心。
可以看到,Linux 内核所有功能的初始化函数都是在 start_kernel 函数中调用的,这也是它如此出名,如此重要的原因。
一旦 start_kernel 函数执行完成,Linux 内核就具备了向应用程序提供一系列功能服务的能力。这里对我们而言,我们只关注一个 arch_call_rest_init 函数。下面我们就来研究它。 如下所示。
这个函数其实非常简单,它是一个包装函数,其中只是直接调用了 rest_init 函数。
rest_init 函数的重要功能就是建立了两个 Linux 内核线程,我们看看精简后的 rest_init 函数:
Linux 内核线程可以执行一个内核函数, 只不过这个函数有独立的线程上下文,可以被 Linux 的进程调度器调度,对于 kernel_init 线程来说,执行的就是 kernel_init 函数。
Linux 的第一个用户进程
当我们可以建立第一个用户进程的时候,就代表 Linux 内核的初始流程已经基本完成。
经历了“长途跋涉”,我们也终于走到了这里。Linux 内核的第一个用户态进程是在 kernel_init 线程建立的,而 kernel_init 线程执行的就是 kernel_init 函数。那 kernel_init 函数到底做了什么呢?
结合上述代码,可以发现 ramdisk_execute_command 和 execute_command 都是内核启动时传递的参数,它们可以在 GRUB 启动选项中设置。
比方说,通常引导内核时向 command line 传递的参数都是 init=xxx ,而对于 initrd 则是传递 rdinit=xxx 。
但是,通常我们不会传递参数,所以这个函数会执行到上述代码的 15 行,依次尝试以 /sbin/init、/etc/init、/bin/init、/bin/sh 这些文件为可执行文件建立进程,但是只要其中之一成功就行了。
try_to_run_init_process 和 run_init_process 函数的核心都是调用 sys_fork 函数建立进程的,这里我们不用关注它的实现细节。
到这里,Linux 内核已经建立了第一个进程,Linux 内核的初始化流程也到此为止了。
重点回顾
又到了课程尾声,Linux 初始化流程的学习我们就告一段落了,我来给你做个总结。
今天我们讲得内容有点多,我们从 _start 开始到 startup32、startup64 函数 ,到 extract_kernel 函数解压出真正的 Linux 内核文件 vmlinux 开始,然后从 Linux 内核的入口函数 startup_64 到 Linux 内核第一个 C 函数,最后接着从 Linux 内核 start_kernel 函数的建立 ,说到了第一个用户进程。
一起来回顾一下这节课的重点:
1.GRUB 加载 vmlinuz 文件之后,会把控制权交给 vmlinuz 文件的 setup.bin 的部分中 _start,它会设置好栈,清空 bss,设置好 setup_header 结构,调用 16 位 main 切换到保护模式,最后跳转到 1MB 处的 vmlinux.bin 文件中。
2. 从 vmlinux.bin 文件中 startup32、startup64 函数开始建立新的全局段描述符表和 MMU 页表,切换到长模式下解压 vmlinux.bin.gz。释放出 vmlinux 文件之后,由解析 elf 格式的函数进行解析,释放 vmlinux 中的代码段和数据段到指定的内存。然后调用其中的 startup_64 函数,在这个函数的最后调用 Linux 内核的第一个 C 函数。
3.Linux 内核第一个 C 函数重新设置 MMU 页表,随后便调用了最有名的 start_kernel 函数, start_kernel 函数中调用了大多数 Linux 内核功能性初始化函数,在最后调用 rest_init 函数建立了两个内核线程,在其中的 kernel_init 线程建立了第一个用户态进程。
Linux 初始化要点示意图
不知道你感觉到没有,Linux 的启动流程相比于我们的 Cosmos 启动流程复杂得多。
Linux 之所以如此复杂,是因为它把完成各种功能的模块组装了一起,而我们 Cosmos 则把内核之前的初始化工作,分离出来,形成二级引导器,二级引导器也是由多文件模块组成的,最后用我们的映像工具把它们封装在一起。
对比之下,你就可以明白,软件工程模块化是多么重要了。
思考题
你能指出上文中 Linux 初始化流程里,主要函数都被链接到哪些对应的二进制文件中了?
欢迎你在留言区跟我交流互动,也欢迎你把这节课分享给同事、朋友。
我是 LMOS,我们下节课见!
分享给需要的人,Ta购买本课程,你将得20元
生成海报并分享
赞 12
提建议
© 版权归极客邦科技所有,未经许可不得传播售卖。 页面已增加防盗追踪,如有侵权极客邦将依法追究其法律责任。
上一篇
14 | Linux初始化(上):GRUB与vmlinuz的结构
下一篇
16 | 划分土地(上):如何划分与组织内存?
精选留言(14)
- neohope2021-06-12大体上整理了一下,有问题欢迎帮忙指正【上】: Grub在/boot目录下找到的linux内核,是bzImage格式 1、bzImage格式生成: 1.1、head_64.S+其他源文件->编译-> vmlinux【A】 1.2、objcopy工具拷贝【 拷贝时,删除了文件中“.comment”段,符号表和重定位表】->vmlinux.bin【A】 1.3、gzib压缩->vmlinux.bin.gz 1.4、piggy打包,附加解压信息->piggy.o->其他.o文件一起链接->vmlinux【B】 1.5、objcopy工具拷贝【 拷贝时,删除了文件中“.comment”段,符号表和重定位表】->vmlinux【B】 1.6、head.S +main.c+其他->setup.bin 1.7、setup.bin+vmlinux.bin【B】->bzImage合并->bzImage 2、GRUB加载bzImage文件 2.1、会将bzImage的setup.bin加载到内存地址0x90000 处 2.2、把vmlinuz中的vmlinux.bin部分,加载到1MB 开始的内存地址 3、GRUB会继续执行setup.bin代码,入口在header.S【arch/x86/boot/header.S】 GRUB会填充linux内核的一个setup_header结构,将内核启动需要的信息,写入到内核中对应位置,而且GRUB自身也维护了一个相似的结构。 Header.S文件中从start_of_setup开始,其实就是这个setup_header的结构。 此外, bootparam.h有这个结构的C语言定义,会从Header.S中把数据拷贝到结构体中,方便后续使用。 4、GRUB然后会跳转到 0x90200开始执行【恰好跳过了最开始512 字节的 bootsector】,正好是head.S的_start这个位置; 5、在head.S最后,调用main函数继续执行 6、main函数【 arch/x86/boot/main.c】【16 位实模式】 6.1、拷贝header.S中setup_header结构,到boot_params【arch\x86\include\uapi\asm\bootparam.h】 6.2、调用BIOS中断,进行初始化设置,包括console、堆、CPU模式、内存、键盘、APM、显卡模式等 6.3、调用go_to_protected_mode进入保护模式 7、 go_to_protected_mode函数【 arch/x86/boot/pm.c】 7.1、安装实模式切换钩子 7.2、启用1M以上内存 7.3、设置中断描述符表IDT 7.4、设置全局描述符表GDT 7.4、protected_mode_jump,跳转到boot_params.hdr.code32_start【保护模式下,长跳转,地址为 0x100000】 8、恰好是vmlinux.bin在内存中的位置,通过这一跳转,正式进入vmlinux.bin 9、startup_32【 arch/x86/boot/compressed/head64.S】 全局描述符GDT 加载段描述符 设置栈 检查CPU是否支持长模式 开启PAE 建立MMU【4级,4G】 开启长模式 段描述符和startup_64地址入栈 开启分页和保护模式 弹出段描述符和startup_64地址到CS:RIP中,进入长模式 10、 startup_64【 arch/x86/boot/compressed/head64.S】 初始化寄存器 初始化栈 调准给MMU级别 压缩内核移动到Buffer最后 调用.Lrelocated 11、.Lrelocated 申请内存 被解压数据开始地址 被解压数据长度 解压数据开始地址 解压后数据长度 调用 extract_kernel解压内核 12、extract_kernel解压内核【 arch/x86/boot/compressed/misc.c】 保存boot_params 解压内核 解析ELF,处理重定向, 把 vmlinux 中的指令段、数据段、BSS 段,根据 elf 中信息和要求放入特定的内存空间 返回了解压后内核地址,保存到%rax 13、返回到.Lrelocated继续执行 跳转到%rax【解压后内核地址】,继续执行 解压后的内核文件,入口函数为【arch/x86/kernel/head_64.S】展开
作者回复: 学习非常认真啊
共 2 条评论27 - neohope2021-06-12大体上整理了一下,有问题欢迎帮忙指正【下】: 14、SYM_CODE_START_NOALIGN(startup_64)【arch/x86/kernel/head_64.S】 SMP 系统加电之后,总线仲裁机制会选出多个 CPU 中的一个 CPU,称为 BSP,也叫第一个 CPU。它负责让 BSP CPU 先启动,其它 CPU 则等待 BSP CPU 的唤醒。 第一个启动的 CPU,会跳转 secondary_startup_64 函数中 1 标号处,对于其它被唤醒的 CPU 则会直接执行 secondary_startup_64 函数。 15、secondary_startup_64 函数【arch/x86/kernel/head_64.S】 各类初始化工作,gdt、描述符等 跳转到initial_code,也就是x86_64_start_kernel 16、 x86_64_start_kernel【 arch/x86/kernel/head64.c】 各类初始化工作,清理bss段,清理页目录,复制引导信息等 调用x86_64_start_reservations 17、x86_64_start_reservations【 arch/x86/kernel/head64.c】 调用start_kernel(); 18、start_kernel【 init/main.c】 各类初始化:ARCH、日志、陷阱门、内存、调度器、工作队列、RCU锁、Trace事件、IRQ中断、定时器、软中断、ACPI、fork、缓存、安全、pagecache、信号量、cpuset、cgroup等等 调用 arch_call_rest_init,调用到rest_init 19、rest_init【 init/main.c】 kernel_thread,调用_do_fork,创建了kernel_init进程,pid=1 . 是系统中所有其它用户进程的祖先 kernel_thread,调用_do_fork,创建了 kernel_thread进程,pid=2, 负责所有内核线程的调度和管理 【最后当前的进程, 会变成idle进程,pid=0】 20、kernel_init 根据内核启动参数,调用run_init_process,创建对应进程 调用try_to_run_init_process函数,尝试以 /sbin/init、/etc/init、/bin/init、/bin/sh 这些文件为可执行文件建立init进程,只要其中之一成功就可以 调用链如下: try_to_run_init_process run_init_process kernel_execve bprm_execve exec_binprm search_binary_handler-》依次尝试按各种可执行文件格式进行加载,而ELF的处理函数为 load_elf_binary load_elf_binary start_thread start_thread_common,会将寄存器地址,设置为ELF启动地址 当从系统调用返回用户态时,init进程【1号进程】,就从ELF执行了 到此为止,系统的启动过程结束。展开
作者回复: 是的 大写6666
共 2 条评论14 - pedro2021-06-11_start在setup.bin的开头, x86_64_start_kernel在vmlinux.bin的开头,然后start_kernel初始化,然后rest_init初始化第一个用户进程和第一个内核进程,开始操作系统罪恶的一生。
作者回复: 哈哈
5 - Geek_59a6f92021-06-24老师,你这里说的grub是grub legacy还是grub2啊?grub2应该首先会进入保护模式,那grub2还会跳转到inux/arch/x86/boot/head.S 里的main函数 再去执行一次切换保护模式吗?这个时候应该早就是保护模式了吧
作者回复: 是的 ,但是Linux并不用GRUB初始化的保护模式
共 2 条评论2 - 青玉白露2021-07-06课程已经快进入正题了,下一步就是内存了吧
编辑回复: 是的,下个模块是内存管理,更加有挑战性,敬请期待!
1 - blentle2021-06-11收获盛大,终于看到了稍微能消化的一篇了
作者回复: 你好,你平常接触Linux吧
1 - springXu2021-06-11这个问题是考对Linux熟悉程度了。哈
作者回复: 这很难吗
1 - Qfeng2022-06-19内核启动最后创建了两个进程:kernel_init和kernel_thread,前面是第一个用户进程,后续用户进程都是从它fork而来,后面是内核进程,用来管理后续内核线程调度。这两个进程令我印象深刻。
作者回复: 666666
- 艾恩凝2022-04-10这节终于结束了,计划俩月完成,感觉进度有点慢了,到现在快20天了,应该去年来参与这门课的
编辑回复: 相逢即有缘,现在跟进也不迟,有什么收获或疑问欢迎多多分享哦(ง •̀_•́)ง
- ifelse2022-02-13果然厉害了
作者回复: 什么厉害了
- kocgockohgoh王裒2021-12-12请问为什么有两个重名的startup_64啊 名字不回冲突么
作者回复: 编译时会处理的
- 日就月将2021-11-02老师,自动编译配置文件里有修改grub menuentry选项吗 要是想修改在哪里改啊
作者回复: 没有
- zlig2021-09-19还是不理解为啥linux要把vmlinux.bin压缩这么多次。
- Geek_59a6f92021-06-19老师,对于非boot cpu的启动不是很清楚。是BSP启动以后发一个中断唤醒其他cpu,然后执行到secondary_startup_64吗?接着还是会去执行start_kernel? 可是我们主cpu已经初始化内核里内存部分了,其他cpu再去执行的话就会有问题吧?
作者回复: 只有BSP会执行 start_kernel
共 2 条评论