关于Liunx启动,你想知道的全都在这!


Liunx是我们日常学习工作中,打交道最多的一个系统,似乎我们很少关注它的启动初始化,那么本文就带你捋一捋Liunx启动初始化那些事。

全文结构大致如下:首先会讲解一些基础知识,这为你阅读本文减小一些压力,然后会从整体启动流程上带你简要梳理一遍Liunx启动,然后会分开深入到各个阶段去彻底理解每个阶段在干什么,之后为了检验自己是否大致掌握Liunx启动,我会以问答的形式留下几个问题,最后会对全文启动流程做一个简单的总结。

话不多说,先来几个问题热热身

那些你会迷惑的问题

  • 为什么开机的时候是从BIOS里的代码开始执行而不是内存或外存里的?
  • 0xFFFF0这个地址存在于哪里?
  • BIOS到底又做了些什么
  • 为什么一开始只有1M的寻址空间?
  • 为什么需要有GRUB引导程序,不要它行不行?
  • 我们能调用fork并创建出系统的第一个进程吗?
  • ps -ef 里面有很多父进程是1,这个1号进程是啥?
  • …..

基础知识

下面是两点基础知识,阅读前,回忆一下,话不多说,上图。

  • 计算机是由CPU、内存、磁盘、IO设备等硬件组成,其中在运行前,操作系统会将我们的二进制程序加载到内存中,这个时候才能被CPU运行。CPU就会去内存中读取相关的指令。需要注意一点的是:CPU只能读取内存中的指令,它不能和磁盘等这类外存设备交互。
  • CPU分为三个单元:计算、控制、数据单元,其中数据单元主要由一些寄存器组成,主要存储运行过程中产生的值,控制单元就是控制从哪里读取指令并控制计算单元去算,计算单元很好理解了,就是做苦力,给我算!!!,而CPU取代码指令是由CS(代码寄存器)和IP(指令寄存器)共同完成的,CPU会改变这两个寄存器的值,然后控制单元通过地址总线来到相应的内存地址取值。

好了两点基础知识回忆完了,进入正题!

启动整体流程

老规矩,先上图,一目了然,这张图描述了启动的几个阶段,其中前面三个红色阶段是重点。

上图大致解读:当机器通电后,整个机器就开始运行了,最先来到的是BIOS时期,这个时候CPU控制权在BIOS手里,会进行开机自检,自检完了之后会 会查找可启动设备,加载主引导目录(MBR),加载完MBR之后,将CPU的控制权转交给了GRUB引导,经过GRUB的几个引导步骤之后,GRUB完成就加载了内核代码,接下来,内核就会接管CPU控制权并完成接下来的内核初始化启动任务。内核初始化完成最后会运行sbin/init进行系统的初始化,等系统初始化完了之后会启动终端,输入密码,就完成了一次启动,整个流程大致就是这样了。

下面我们深入各个阶段来看看,每个阶段具体干了些什么。

深入各阶段分析

BIOS 时期

首先是 BIOS 时期阶段,BIOS(Basic Input and Output System,基本输入输出系统)系统程序在厂家出厂的时候被固化在了一个叫 ROM(Read Only Memory)的东西上,它是一个只读的存储器,显然这是一个只能读取,不能写入的存储器。了解完 BIOS 是被固化的程序之后,你肯定好奇,BIOS 程序是如何被找到的?

其实这得多亏了 CPU 设计的硬件程序员们在设计 CPU 时已经将特定的值写好了。

通电之后,CS 寄存器和 IP 寄存器会被强制赋值,其中 CS 被设成:0xFFFF,IP 设成 0x0000。在前缀知识,我们了解到了 CS 寄存器是代码段寄存器,全称是 Code Segment,它存放的是内存代码段区域的段基址。IP 寄存器是指令寄存器,全称是 Instruction Pointer,他存放的是下一条要执行指令的段内偏移地址。

有了段基址和段内偏移地址,我们就能得到下一条程序的地址。在 8086 16 位处理器中为了兼容 20 根地址总线的寻址能力,就将 cs 寄存器左移四位 + ip 寄存器 = 下一条程序地址。例如:cs = 0xFFFF, IP = 0x0000,计算出来的地址 address = CS <<<< 4 + IP = 0xFFFF0。

这里 CPU 本身只能处理 16 位数据,如果超过了 16 位,比如 18 位 0000 0000 0000 0001 00 那么就超出了 cpu 的处理范围,但是地址总线有 20 跟, 也就是可以传输 20 位的数据 例如 0000 0000 0000 0001 0001,那么 16 位和 20 位怎么兼容呢?CPU 工程师们就想到了 讲 cs 寄存器左移四位在加上 ip 寄存器的值不就是 20 位了吗?这个计算方法就是这么来的。看图,一目了然。

有框相加 就是最终值 20 位了。

通过 CS 寄存器和 IP 地址得到的内存地址 0xFFFF0,属于连接了主板的 ROM 芯片上,这类芯片的寻址方式和内存 RAM 是一样的,也就是上面提到的 BIOS 程序固化在上面了。

CPU 拿到 0xFFFF0 这个地址,就跳转到这里来执行 BIOS 代码,BIOS 就开始执行起来了。这个 0xFFFF0 地址是在 ROM 上,这和 X86 架构下 CPU 实模式工作原理有关,X86 系统会将 0xF0000 到 0xFFFFF 这 64K 空间映射给 ROM 上,而 0x00000 ~ 0xDFFFF 这段空间映射给 RAM。所以其实最开始内存没有初始化的时候,是从 ROM 读取的 BIOS 程序,具体内存布局看下图:

你仔细一看就知道了 0xFFFF0 落在了 倒数第二上面的 ROM 区域。

那么 BIOS 系统做了什么呢?

首先 BIOS 会进行 CPU,内存的初始化工作,这个时候我们说的 8G 16G 内存就真正初始化完了,可以用了。然后会把自己的一部分复制到内存中去,这一步就是为了以后建立中断向量表和服务程序做准备以及把相关的启动区程序加载进去,最后跳转告诉 CPU 跳转到内存去执行代码,这个跳转地址是:

jmp far f000:e05b

可以看到会跳转到 0xfe05b(cs=0xf000 + ip=0xe05b => cs <<<< 4 + ip = 0xfe05b) 执行内存中的代码,这也是最早内存中的代码。
跳转到内存之后,会进行本地设备的初始化工作,并进行检查,看看硬件是否损坏等,这个时候 BIOS 就会开始调用显卡、网卡等烧写好的固件程序了。

以上都完成好之后,还会建立一个中断向量表和中断服务程序,我们用到的鼠标和键盘都是要通过中断实现的,这也是启动 Liunx 非常重要的工作。

做完中断的初始化工作,下一步就是准备加载引导程序了。

为了能够启动外部存储器中的程序,BIOS 会搜索可引导的设备,至于按照什么顺序搜索,这些都是在出厂芯片中设定好了的,可以是从硬盘中、U 盘中、网络中等等。

当然,一般都是从硬盘中启动的。硬盘上的第一个扇区,512 个字节的大小,这也是每个扇区的标准大小,被称为 MBR(Master Boot Record,主启动记录),这个里面包含三个重要的信息,如图:

  • 引导加载程序(Boot loader) 446 字节

存储操作系统相关的信息,比如名称、内核位置等等。

  • 分区表 64 字节

也就是磁盘分区了,每个主分区占 16 个字节,

  • 分区表有效性标记 2 字节

BIOS 会把 MBR 加载到内存中执行,这个执行地址是 0x7c00,也就是说把主启动记录程序 MBR 全部加载到从 0x7c00 这个开始地址中了,为什么是 0x7c00 这么一个奇怪的地址呢?这是由于 IMB 公司最早的个人电脑 IBM PC6160 上市的时候搭载操作系统是 86-DOS,用到的芯片是 Intel 的第一代个人电脑芯片 8088,86-DOS 操作系统运行时需要的内存至少是 32KB,32KB 的范围就是 0x0000 ~ 0x7FFF,而 8088 本身需要占 0x0000 ~ 0x03FF 来保存中断向量表和中断服务程序,所以还剩 0x0400 ~ 0x7FFF 给操作系统用,为了尽可能的将多的连续内存空间留给 86-DOS,MBR 被放到了内存地址的末尾,而 MBR 本身也会产生数据,所以最后计算方式就变成了 最高地址 0x7FFF + 1 - 512 -512 = 0x7c00 这里 +1 是因为取值是从 0 开始。

到这里 BIOS 的任务完成了,把控制器交给 MBR,Liunx 中是 GRUB 了。

GRUB 引导

GRUB 全称叫全称 Grand Unified Bootloader Version。顾明思议就是搞系统启动的,当前都是用 GRUB2 了。这里为了简便就交 GRUB 吧

GRUB 的配置,我们可以在 /boot/grub2/ 目录下查看 grub.cfg,例如:

### BEGIN /etc/grub.d/10_Linux ###
menuentry 'CentOS Linux (3.10.0-1160.45.1.el7.x86_64) 7 (Core)' --class centos --class gnu-linux --class gnu --class os --unrestricted $menuentry_id_option 'gnulinux-3.10.0-514.26.2.el7.x86_64-advanced-59d9ca7b-4f39-4c0c-9334-c56c182076b5' {
	load_video
	set gfxpayload=keep
	insmod gzio
	insmod part_msdos
	insmod ext2
	set root='hd0,msdos1'
	if [ x$feature_platform_search_hint = xy ]; then
	  search --no-floppy --fs-uuid --set=root --hint='hd0,msdos1'  59d9ca7b-4f39-4c0c-9334-c56c182076b5
	else
	  search --no-floppy --fs-uuid --set=root 59d9ca7b-4f39-4c0c-9334-c56c182076b5
	fi
	linux16 /boot/vmlinuz-3.10.0-1160.45.1.el7.x86_64 root=UUID=59d9ca7b-4f39-4c0c-9334-c56c182076b5 ro crashkernel=auto   net.ifnames=0  idle=halt console=tty0 console=ttyS0,115200n8 LANG=en_US.UTF-8
	initrd16 /boot/initramfs-3.10.0-1160.45.1.el7.x86_64.img
}

这就是一个启动项,如果 grub 引导加载完成后,会让你选择操作系统,在那个选择菜单的界面就是这里配置而来的。
GRUB 引导启动阶段主要包含三个阶段,同时也分成了多个文件,两个重要的文件:boot.img 和 core.img,

话不多说,上图。

下面来具体看看这两个文件是怎么样被加载的。

boot.img 主要在 1 阶段被加载。

Stage1 阶段

这个阶段 BIOS 时期最后跳转的入口地址程序——BootLoader。

由于 512 个字节实在太有限了,它做不了太多的事情。仅仅就加载了 boot.img。

而 boot.img 的一个位置写入了 core.img 的文件位置,类似于你去问路,某大爷告诉你应该怎么怎么走,这个时候 boot.img 就相当于这位大爷了。

boot.img 之后就跳转 core.img 入口地址,开始进入 Stage1.5 阶段了

Stage1.5 阶段

这个阶段是 Stage1 和 Stage2 之间的过渡步骤,主要是加载 Stage2 阶段用到的 core.img,core.img 包含 diskboot.img、lzma_decompress.img、kernel.img 和一些列模块。

这个阶段首先会加载 diskboot.img,这个文件中以文件块列表的方式保存着 core.img 这个文件内容,所以 diskboot 能够找到 core.img 剩下的内容并将其他的内容加载进来,在这阶段主要是做让系统具有识别文件的能力,也是一个从 Stage1 到 Stage2 的过渡期。

Stage2 阶段

这个过程主要是执行上面 Stage1.5 阶段存放的加载指令,主要是 core.img 的解压文件和内核文件。在解压加载 kernel.img 之前,lzma_decomprees.img 会调用 real_to_prot,从实模式切换到保护模式。为什么要做这一步骤呢,因为实模式下的 1M 空间实在太小了,寻址不到超过 1M 的地址,而内核加载到内存中大于 1M 了,所以得寻求更大的空间。就会切到保护模式下,这样就能有更大的寻址空间,加载更多的东西了,保护模式下是 32 位寻址空间,也就是 2^32B = 4G,这也就足够寻址到内核在内存中的地址了。

所以 CPU 工作模式从实模式到保护模式的切换就是在 GRUB 引导阶段进行的。

有了更大的寻找空间之后,就会对 Kernel.img 进行解压缩,然后跳转到 Kernel.img 开始执行,这个时候会开始读取 grub.cfg 文件里的配置信息。如果启动正常,最后就会显示出让你选择操作系统的列表画面。

当你选择完操作系统之后,grub 的任务也就要完成了,这个时候就开始加载内核代码,也就开始进入到下一阶段了。

内核初始化阶段就真正来到了操作系统层面了,这里就是操作系统程序启动的入口!!!

内核初始化

内核初始化主要分为三个部分,也就是 0、1、2 三个进程的建立三部分,首先 grub 引导 Liunx 内核加载完之后,从 start_kernel() 方法开始进行内核初始化,这个方法非常有名,因为这里是内核的开始,Liunx 的入口。

著名的 start_kernel()

来看 start_kernel() 中的代码,这里删除了很多代码,这个方法有 200 多行,主要是一个包装方法,里面调用了各个部分的初始化方法,这里选取了几个比较重要的方法,稍作说明。

void start_kernel(void){    
    //零号进程初始化
    set_task_stack_end_magic(&init_task)
    //中断门初始化
    trap_init();
    //内存初始化    
    mm_init();
    ftrace_init();
    //调度器初始化
    sched_init();
    //fork初始化建立进程的 
    fork_init();    
    //VFS数据结构内存池初始化  
    vfs_caches_init();
    //运行第一个进程 
    arch_call_rest_init();
}

万物之源——0 号进程

首先我们来看看万物之源——0 号进程

0 号进程的初始化代码

set_task_stack_end_magic(&init_task)

可以看到这句初始化需要传入一个 init_task 参数,来看看这个 init_task 怎么来的

/* Initial task structure */
struct task_struct init_task = INIT_TASK(init_task);
EXPORT_SYMBOL(init_task);

通过了一个叫做 EXPORT_SYMBOL 的宏进行的初始化。
这个宏里面主要是对 init_task 做了写死赋值,所以我们的 0 号进程的信息,都来源是硬编码!!!这就是道生一呀。

可以看到这个结构是 task_struct,它是个什么东西呢?

顾名思义,task 是任务,也就是任务结构,任务? 好像和我们 CPU 执行任务有点联系。

没错! 它就是 CPU 执行任务的结构,也是表示进程和线程的数据结构。具体的解读请期待下一期。这里我们紧扣主题——Liunx 启动。

好了 0 号进程有了,也就是 万物之源的 一 出来了。接下来内核会做更多的初始化,例如:

  • 中断模块初始化 trap_init()

这个方法里面设置了很多中断门,用于处理各类中断,BIOS 时期也建立了中断向量表和服务程序,没错这里会根据之前建立的,为操作系统重新建立一次。

  • 内存模块初始化 mm_init()

这个方法里面会初始化内存管理模块,内存是这个时候才开始被操作系统初始化的,在操作系统层面要使用内存,就必须在这个之后了。

  • 调度模块初始化 sched_init()

这个方法会初始化调度模块,主要是用于 CPU 调度任务。

  • fork 创建初始化 fork_init()

fork 在这里才被初始化,未被初始化之前都无法使用 fork,1 号进程的初始化也是在 fork 之后进行的。所以我们 0 号进程是无法通过 fork 进行初始化的。

  • 文件系统初始化 vfs_caches_init()

这里会初始化基于内存的文件系统 rootfs,文件系统是存储信息的,要兼容各类文件系统,系统会抽象出一层虚拟文件系统(VFS Virtual File System).

  • 其他初始化 rest_init()

这里会做很多其他方面的初始化工作,比如 1 号、2 号进程的初始化。

1 号进程建立

1 号进程是在 rest_init() 方法里调用 kernel_thread(kernel_init, NULL, CLONE_FS) 进行的初始化,它对于操作系统来讲有着很重要的意义,可以说是“标志性划时代的”,在 1 号进程没有初始化的时候,所有资源都是操作系统内核本身的,而 1 号进程初始化完之后,要讲一些资源分配给它进行使用。这也就从改变了系统的运行方式。这里就设计到了,有哪些资源是内核所可以调用的,哪些资源是给类似 1 号用户进程调用的,x86 架构给我们提供了权限机制,就很好的解决了这个问题,这里简单提一点:内核拥有 Ring0 最高权限,我们把关键的资源和代码放在这里,也叫做内核态,而普通用户程序放在 Ring3,也叫做用户态。他们之间是通过系统调用进行沟通的,话不多说,看图,从里往外权限依次降低,可以看到内核态具有最高权限。

1 号进程的建立,CPU 的执行权限,将会从内核态转到用户态。在 1 号进程建立时,是调用下面的

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

也就是这个 ramdisk,进行了用户空间的初始化,从虚拟根文件系统切换到真正的根文件系统。
这一部分主要是通过在内存中建立虚拟根文件系统实现相关设备的驱动并建立和切换到真正的根文件系统。

如上图,我们当前在 / 目录下,也就是根文件目录, 它的功能就是讲内核与真正的根建立联系,内核通过它加载根文件系统的驱动程序,然后以读写方式挂载根文件系统,在 initrd 程序建立 ramdisk 内存虚拟根文件系统后 内核开始驱动基本硬件,例如:CPU,I/O 等等,在驱动加载完后,会根据 grub.cfg 文件中的 root=”xxxxx”指定的内容创建一个根设备,可以往上面看 grubp 配置文件里有下面这段代码。

set root='hd0,msdos1'

然后将跟文件系统以只读的方式挂载,这就切换到真正的根文件系统上了。这也就是 ramdisk 的作用。
1 号用户进程初始化完了,用户进程的祖先有了,那么内核进程的祖先还没,你可能会有疑惑,我们的 0 号进程不就是内核进程吗?对没错,但是 0 号进程后续会变成 CPU 的 idle 进程,要另作他用。idle 进程,你可以理解为 CPU 不工作时,他就开始运行的一个进程,他的主要作用是:节能和低退出延迟。所以得创建一个 2 号进程来作为所有内核态的祖先。

2 号进程建立

2 号进程是调用 kernel_thread 方法创建的,其具体方法如下

kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)

2 号进程是用来管理内核态的线程调度,是所有内核线程的祖先
2 号进程初始化完,到目前用户态和内核态都有祖先进程管理了,系统初始化也完成了。

2 号进程完成后会调用/sbin/init 程序,开始进入系统初始化阶段。

到这里, 内核就加载完成,接下来就是对整个系统进行初始化。

系统初始化

经过了内核的初始化,系统其实已经跑起来了,已经可以开始运行程序了,但是为了更好的提供给用户使用,需要对一些常用的东西进行封装成服务,以进程的形式加载进来。

系统初始化阶段首先会通过/etc/inittab 进行运行级别的确认,运行级别是为了系统的允许而定的机制,下面例举了 7 种级别:

0、关机,shutdown

1、单用户模式,这个就是 root 用户,最高权限;

2、多用户维护模式,会启动网络功能,但不会启动 NFS

3、多用户完全功能模式,文本界面,这个是默认的运行级别

4、预留级别:目前无特别使用目的,但习惯以同 3 级别功能使用;

5、多用户完全功能模式,图形界面,与 3 不同的是这是图形化界面模式;

6、重启,reboot

然后会去执行系统初始化脚本/erc/rc.sysinit,为用户初始化用户空间,完成之后,根据运行级别,系统开启对应的服务,关闭那些不要的服务。

系统初始化脚本主要做的一些事:设置 host 名和欢迎信息、挂在/etc/fstab 定义的文件系统、设置系统时钟、加载额外设备的驱动程序等等。

系统的初始化工作主要执行这个脚本程序,到此系统就进行了正常的初始化。

启动终端

默认运行级别情况下,会打开 6 个纯文本终端,这是 Liunx 设定的。

系统初始化完成后,会给出相应的用户登录提示,用户输入密码后,系统调用 login 程序,核验密码,如果正确,会分配 uid 和 gid,这两个 id 用于检测用户的身份信息,之后就从文件 /etc/passwd/读取登录用户指定的 shell 并启动。

到这里,整个系统就启动好了!!

接下里看看最上面提到的几个问题

几惑问题

  • 为什么开机的时候是从BIOS里的代码开始执行而不是内存或外存里的?

内存是需要进程初始化之后才能使用,而内存初始化是在 start_kernel 方法才进行,所以开机通电就读内存的代码是不行的。而文章中提到了 CPU 是不能和外存打交道的,所以开机读外存的代码也是不可行的。由于 BIOS 的代码是存放在 ROM 中的,在 CPU 运行的时候与内存 RAM 相似,

所以 CPU 开机的时候可以在这里读取代码。

  • 0xFFFF0这个地址存在于哪里?

回答完上面那个问题,相信,你也知道了,0xFFFF0 这个 BIOS 入口地址是在 ROM 中了。

  • BIOS到底又做了些什么?

在上面 BIOS 时期中已经提到了它做的一些事,可以参考上面。

  • 为什么一开始只有1M的寻址空间?

当前 CPU 有三种工作模式, 实模式,保护模式,长模式,最开始的时候 CPU 处理实模式下,也就对应在最早的 8086CPU 工作模式,而这个模式下虽然处理数据的位数只有 16 位,但是地址总线有 20 位,20 位的地址总线的寻址空间有 2^20B = 1M 这么大,这也就是最开始的寻址空间大小了,后面随着 CPU 的工作模式切换,这个寻址空间也变大了。

  • 为什么需要有GRUB引导程序,不要它行不行?

这个问题和上个问题也有牵连,由于最开始的寻址空间只有 1M,所以无法直接一次加载操作系统内核那么大的程序,如果将超过 1M 的空间地址加载到内存中,而这是能够获取到内存的最大地址是 1M, 1M 以后的数据都获取不到,所以得一步步引导并扩大,最后全部加载进来。

整个 Liunx 的启动,就像重定向一样,BIOS 告诉 CPU 你去 加载 GRUB,而 GRUB 告诉 CPU 我是启动引导程序,用户选择完操作系统后,你就可以进行加载了,而且我还要切换你的工作模式。

就这样一步步引导,Liunx 就初始化完成了。

  • 我们能调用fork并创建出系统的第一个进程吗?

通过上面的讲解,想必你也能回答这个问题了,fork 是系统调用,需要在操作系统内核初始化之后才能调用,比如 1 号进程就是 fork 出来的。

  • ps -ef 里面有很多父进程是1,这个1号进程是啥?

阅读完全文,想必你也知道了这个 1 就是 1 生万物 的 1,由于有了 1 号进程,用户态下的所有进程的鼻祖,它的作用不言而喻。

简要总结

本文从 Liunx 系统的启动初始化,与你一起了解了计算机启动原理,一起深入理解了 BIOS 时期,它干的一些事情,以及 GRUB 引导程序的相关知识,还有根据 Liunx 源码解读了内核初始化过程,最后回答了文章开头提出的相关问题。

更多内容你可以关注公众号: 「ConeZhang


文章作者: Cone
版权声明: 本博客所有文章除特別声明外,均采用 CC BY-NC-ND 4.0 许可协议。转载请注明来源 Cone !
  目录