Linux系统笔记(二)系统初始化

387 阅读20分钟

一、x86架构

计算机的工作模式是什么样的?

CPU 和其他设备连接,要靠一种叫作总线(Bus)的东西,其实就是主板上密密麻麻的集成电路,这些东西组成了 CPU 和其他设备的高速通道。

在这些设备中,最重要的是内存(Memory)。因为单靠 CPU 是没办法完成计算任务的,很多复杂的计算任务都需要将中间结果保存下来,然后基于中间结果进行进一步的计算。CPU 本身没办法保存这么多中间结果,这就要依赖内存了。

总线上还有一些其他设备,例如显卡会连接显示器、磁盘控制器会连接硬盘、USB 控制器会连接键盘和鼠标等等。

CPU 其实也不是单纯的一块,它包括三个部分,运算单元、数据单元和控制单元。
运算单元只管算,例如做加法、做位移等等。但是,它不知道应该算哪些数据,运算结果应该放在哪里。运算单元计算的数据如果每次都要经过总线,到内存里面现拿,这样就太慢了,所以就有了数据单元。
数据单元包括 CPU 内部的缓存和寄存器组,空间很小,但是速度飞快,可以暂时存放数据和运算结果。有了放数据的地方,也有了算的地方,还需要有个指挥到底做什么运算的地方,这就是控制单元。
控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。

进程一旦运行,比如图中两个进程 A 和 B,会有独立的内存空间,互相隔离,程序会分别加载到进程 A 和进程 B 的内存空间里面,形成各自的代码段。当然真实情况肯定比我说的要复杂的多,进程的内存虽然隔离但不连续,除了简单的区分代码段和数据段,还会分的更细。

程序运行的过程中要操作的数据和产生的计算结果,都会放在数据段里面。那 CPU 怎么执行这些程序,操作这些数据,产生一些结果,并写入回内存呢?

CPU 的控制单元里面,有一个指令指针寄存器,它里面存放的是下一条指令在内存中的地址。控制单元会不停地将代码段的指令拿进来,先放入指令寄存器。当前的指令分两部分,一部分是做什么操作,例如是加法还是位移;一部分是操作哪些数据。要执行这条指令,就要把第一部分交给运算单元,第二部分交给数据单元。

数据单元根据数据的地址,从数据段里读到数据寄存器里,就可以参与运算了。运算单元做完运算,产生的结果会暂存在数据单元的数据寄存器里。最终,会有指令将数据写回内存中的数据段。

CPU 里有两个寄存器,专门保存当前处理进程的代码段的起始地址,以及数据段的起始地址。这里面写的都是进程 A,那当前执行的就是进程 A 的指令,等切换成进程 B,就会执行 B 的指令了,这个过程叫作进程切换(Process Switch)。

CPU 和内存来来回回传数据,靠的都是总线。其实总线上主要有两类数据,一个是地址数据,也就是我想拿内存中哪个位置的数据,这类总线叫地址总线(Address Bus);另一类是真正的数据,这类总线叫数据总线(Data Bus)

地址总线的位数,决定了能访问的地址范围到底有多广。例如只有两位,那 CPU 就只能认00,01,10,11 四个位置,超过四个位置,就区分不出来了。位数越多,能够访问的位置就越多,能管理的内存的范围也就越广。而数据总线的位数,决定了一次能拿多少个数据进来。例如只有两位,那 CPU 一次只能从内存拿两位数。要想拿八位,就要拿四次。位数越多,一次拿的数据就越多,访问速度也就越快。

x86 是什么?

那 CPU 中总线的位数有没有个标准呢?如果没有标准,那操作系统作为软件就很难办了,因为软件层没办法实现通用的运算逻辑。英特尔的技术成为了行业的开放事实标准。由于这个系列开端于 8086,因此称为 x86 架构。

32 位处理器

后来计算机的发展日新月异,内存越来越大,总线也越来越宽。在 32 位处理器中,有 32 根地址总线,可以访问 2^32=4G 的内存。使用原来的模式肯定不行了,但是又不能完全抛弃原来的模式,因为这个架构是开放的。开放,意味着有大量其他公司的软硬件是基于这个架构来实现的,不能为所欲为,想怎么改怎么改,一定要和原来的架构兼容,而且要一直兼容,这样大家才愿意跟着你这个开放平台一直玩下去。如果你朝令夕改,那其他厂商就惨了。

因而到了 32 位的系统架构下,我们将前一种模式称为实模式(Real Pattern),只能寻址1M,每个段最多 64K。后一种模式称为保护模式(Protected Pattern),能够寻址 4G。当系统刚刚启动的时候,CPU 是处于实模式的,这个时候和原来的模式是兼容的。也就是说,哪怕你买了 32 位的 CPU,也支持在原来的模式下运行,只不过快了一点而已。当需要更多内存的时候,你可以遵循一定的规则,进行一系列的操作,然后切换到保护模式,就能够用到 32 位 CPU 更强大的能力。

二、从BIOS到bootloader

BIOS 时期

当按下计算机的启动按钮时,主板就加上电了。这个时候没有操作系统,内存也是空的,一穷二白。CPU 该怎么办呢?

在主板上,有一个东西叫ROM(Read Only Memory,只读存储器)。这和咱们平常说的内存RAM(Random Access Memory,随机存取存储器) 不同。咱们平时买的内存条是可读可写的,这样才能保存计算结果。而 ROM 是只读的,上面早就固化了一些初始化的程序,也就是BIOS(Basic Input and Output System,基本输入输出系统) 现在只有 1M 的内存地址空间。这个空间非常有限,需要好好利用才行。

1K = 1Kb = 1024b = 8*1024 Bit 地址范围0x00~0x3FF

1M = 1Mb = 1024K = 1024Kb = 1024*1024B 地址范围0x00000~0xFFFFF

1G = 1Gb = 1024M = 1024Mb = 1024*1024K B= 10243B 地址范围0x00000~0x3FFFFFFF

1TB = 1024GB = 10242MB = 10243KB = 10244B = 8*10244

在 x86 系统中,将 1M 空间最上面的 0xF0000 到 0xFFFFF 这 64K 映射给 ROM,也就是说,到这部分地址访问的时候,会访问 ROM。
当电脑刚加电的时候,会做一些重置的工作,将 CS 设置为 0xFFFF,将 IP 设置为0x0000,所以第一条指令就会指向 0xFFFF0,正是在 ROM 的范围内。在这里,有一个JMP 命令会跳到 ROM 中做初始化工作的代码,于是,BIOS 开始进行初始化的工作。

BIOS要做的事:

  1. BIOS 要检查一下系统的硬件是不是都好着呢。
  2. 要建立一个中断向量表和中断服务程序,因为现在你还要用键盘和鼠标,这些都要通过中断进行的。

bootloader 时期

操作系统在哪儿呢?一般都会在安装在硬盘上,在 BIOS 的界面上。你会看到一个启动盘的选项。启动盘有什么特点呢?它一般在第一个扇区,占 512 字节,而且以 0xAA55 结束。这是一个约定,当满足这个条件的时候,就说明这是一个启动盘,在 512 字节以内会启动相关的代码。
这些代码是谁放在这里的呢?在 Linux 里面有一个工具,叫Grub2,全称 Grand UnifiedBootloader Version 2。顾名思义,就是搞系统启动的。

你可以通过 grub2-mkconfig -o /boot/grub2/grub.cfg 来配置系统启动的选项。你可以看到里面有类似这样的配置。

menuentry 'CentOS Linux (3.10.0-862.el7.x86_64) 7 (Core)' --class centos --class gnu-linux --class gnu --class os --unrestricted $menuentry_id_option 'gnulinux-3.10.0-862.el7.x86_64-advanced-b1aceb95-6b9e-464a-a589-bed66220ebee' {
	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'  b1aceb95-6b9e-464a-a589-bed66220ebee
	else
	  search --no-floppy --fs-uuid --set=root b1aceb95-6b9e-464a-a589-bed66220ebee
	fi
	linux16 /boot/vmlinuz-3.10.0-862.el7.x86_64 root=UUID=b1aceb95-6b9e-464a-a589-bed66220ebee ro console=tty0 console=ttyS0,115200 crashkernel=auto net.ifnames=0 biosdevname=0 rhgb quiet 
	initrd16 /boot/initramfs-3.10.0-862.el7.x86_64.img
}

这里面的选项会在系统启动的时候,成为一个列表,让你选择从哪个系统启动。最终显示出来的结果就是下面这张图。 使用 grub2-install /dev/sda,可以将启动程序安装到相应的位置。

grub2 第一个要安装的就是 boot.img。它由 boot.S 编译而成,一共 512 字节,正式安装到启动盘的第一个扇区。这个扇区通常称为MBR(Master Boot Record,主引导记录 / 扇区)。BIOS 完成任务后,会将 boot.img 从硬盘加载到内存中的 0x7c00 来运行。

由于 512 个字节实在有限,boot.img 做不了太多的事情。它能做的最重要的一个事情就是加载 grub2 的另一个镜像 core.img。引导扇区就是你找到的门卫,虽然他看着档案库的大门,但是知道的事情很少。他不知道你的宝典在哪里,但是,他知道应该问谁。门卫说,档案库入口处有个管理处,然后把你领到门口。core.img 就是管理处,它们知道的和能做的事情就多了一些。core.img 由lzma_decompress.img、diskboot.img、kernel.img 和一系列的模块组成,功能比较丰富,能做很多事情。

boot.img 先加载的是 core.img 的第一个扇区。如果从硬盘启动的话,这个扇区里面是diskboot.img,对应的代码是 diskboot.S。

boot.img 将控制权交给 diskboot.img 后,diskboot.img 的任务就是将 core.img 的其他部分加载进来,先是解压缩程序 lzma_decompress.img,再往下是 kernel.img,最后是各个模块 module 对应的映像。这里需要注意,它不是 Linux 的内核,而是 grub 的内核。

lzma_decompress.img 对应的代码是 startup_raw.S,本来 kernel.img 是压缩过的,现在执行的时候,需要解压缩。

在这之前,我们所有遇到过的程序都非常非常小,完全可以在实模式下运行,但是随着我们加载的东西越来越大,实模式这 1M 的地址空间实在放不下了,所以在真正的解压缩之前,lzma_decompress.img 做了一个重要的决定,就是调用 real_to_prot,切换到保护模式,这样就能在更大的寻址空间里面,加载更多的东西。

从实模式切换到保护模式

切换到保护模式要干很多工作,大部分工作都与内存的访问方式有关。进入保护模式了,把哪些是你的权限,哪些是你可以授权给别人的,都分的清清楚楚。

  1. 启用分段,就是在内存里面建立段描述符表,将寄存器里面的段寄存器变成段选择子,指向某个段描述符,这样就能实现不同进程的切换了。
  2. 启动分页。能够管理的内存变大了,就需要将内存分成相等大小的块。

保护模式需要做一项工作,那就是打开 Gate A20,也就是第 21 根地址线的控制线。在实模式 8086 下面,一共就 20 个地址线,可访问 1M 的地址空间。如果超过了这个限度怎么办呢?当然是绕回来了。在保护模式下,第 21 根要起作用了,于是我们就需要打开 Gate A20。切换保护模式的函数 DATA32 call real_to_prot 会打开 Gate A20,也就是第 21 根地址线的控制线。

现在好了,有的是空间了。接下来我们要对压缩过的 kernel.img 进行解压缩,然后跳转到kernel.img 开始运行。kernel.img 对应的代码是 startup.S 以及一堆 c 文件,在 startup.S 中会调用grub_main,这是 grub kernel 的主函数。在这个函数里面,grub_load_config() 开始解析,我们上面写的那个 grub.conf 文件里的配置信息。如果是正常启动,grub_main 最后会调用 grub_command_execute (“normal”, 0, 0),最终会调用 grub_normal_execute() 函数。在这个函数里面,grub_show_menu() 会显示出让你选择的那个操作系统的列表。

一旦启动某个操作系统,就要开始调用grub_menu_execute_entry() ,开始解析并执行你选择的那一项。例如里面的 linux16 命令,表示装载指定的内核文件,并传递内核启动参数。于是grub_cmd_linux() 函数会被调用,它会首先读取 Linux 内核镜像头部的一些数据结构,放到内存中的数据结构来,进行检查。如果检查通过,则会读取整个 Linux 内核镜像到内存。如果配置文件里面还有 initrd 命令,用于为即将启动的内核传递 init ramdisk 路径。于是grub_cmd_initrd() 函数会被调用,将 initramfs 加载到内存中来。当这些事情做完之后,grub_command_execute (“boot”, 0, 0) 才开始真正地启动内核。

三、内核初始化

内核的启动从入口函数 start_kernel() 开始。在 init/main.c 文件中,start_kernel 相当于内核的 main 函数。打开这个函数,你会发现,里面是各种各样初始化函数 XXXX_init。

在操作系统里面,先要有个创始进程,有一行指令set_task_stack_end_magic(&init_task)。这里面有一个参数 init_task,它的定义是 structtask_struct init_task = INIT_TASK(init_task)。它是系统创建的第一个进程,我们称为0号进程。这是唯一一个没有通过 fork 或者 kernel_thread 产生的进程,是进程列表的第一个。

第二个要初始化中断门,这里面对应的函数是 trap_init(),里面设置了很多中断门(Interrupt Gate),用于处理各种中断。其中有一个 set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32),这是系统调用的中断门。系统调用也是通过发送中断的方式进行的。当然,64 位的有另外的系统调用方法。

接下来要初始化的是内存管理模块。mm_init() 就是用来初始化的函数。vfs_caches_init() 会用来初始化基于内存的文件系统 rootfs。在这个函数里面,会调用mnt_init()->init_rootfs()。这里面有一行代码,register_filesystem(&rootfs_fs_type)。在 VFS 虚拟文件系统里面注册了一种类型,我们定义为 struct file_system_typerootfs_fs_type

为了兼容各种各样的文件系统,我们需要将文件的相关数据结构和操作抽象出来,形成一个抽象层对上提供统一的接口,这个抽象层就是VFS(Virtual File System),虚拟文件系统

最后,start_kernel() 调用的是 rest_init(),用来做其他方面的初始化,这里面做了好多的工作。

初始化 1 号进程

rest_init 的第一大工作是,用kernel_thread(kernel_init, NULL, CLONE_FS) 创建第二个进程,这个是1 号进程。1 号进程对于操作系统来讲,有“划时代”的意义。因为它将运行一个用户进程,这意味着这个公司把一个老板独立完成的制度,变成了可以交付他人完成的制度。一旦有了用户进程,公司的运行模式就要发生一定的变化,就要注意权限管理。

x86 提供了分层的权限机制,把区域分成了四个 Ring,越往里权限越高,越往外权限越低。 将能够访问关键资源的代码放在 Ring0,我们称为内核态(Kernel Mode);将普通的程序代码放在 Ring3,我们称为用户态(User Mode)。现在系统已经处于保护模式了,保护模式除了可访问空间大一些,还有另一个重要功能,就是“保护”,也就是说,当处于用户态的代码想要执行更高权限的指令,这种行为是被禁止的,要防止他们为所欲为。

当一个用户态的程序运行到一半,要访问一个核心资源,例如访问网卡发一个网络包,就需要暂停当前的运行,调用系统调用,接下来就轮到内核中的代码运行了。首先,内核将从系统调用传过来的包,在网卡上排队,轮到的时候就发送。发送完了,系统调用就结束了,返回用户态,让暂停运行的程序接着运行。这个暂停怎么实现呢?其实就是把程序运行到一半的情况保存下来。例如,我们知道,内存是用来保存程序运行时候的中间结果的,现在要暂时停下来,这些中间结果不能丢,因为再次运行的时候,还要基于这些中间结果接着来。另外就是,当前运行到代码的哪一行了,当前的栈在哪里,这些都是在寄存器里面的。所以,暂停的那一刻,要把当时 CPU 的寄存器的值全部暂存到一个地方,这个地方可以放在进程管理系统很容易获取的地方。在后面讨论进程管理数据结构的时候,我们还会详细讲。当系统调用完毕,返回的时候,再从这个地方将寄存器的值恢复回去,就能接着运行了。 这个过程就是这样的:用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态,然后接着运行。

从内核态到用户态

再回到 1 号进程启动的过程。当前执行 kernel_thread 这个函数的时候,我们还在内核态,现在我们就来跨越这道屏障,到用户态去运行一个程序。这该怎么办呢?很少听说“先内核态再用户态”的。
kernel_thread 的参数是一个函数 kernel_init,也就是这个进程会运行这个函数。在kernel_init 里面,会调用 kernel_init_freeable(),里面有这样的代码:

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

先不管 ramdisk 是啥,我们回到 kernel_init 里面。这里面有这样的代码块:

if (ramdisk_execute_command) {
	ret = run_init_process(ramdisk_execute_command);
    ......
}
......
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;

这就说明,1 号进程运行的是一个文件。如果我们打开 run_init_process 函数,会发现它调用的是 do_execve。这个名字是不是看起来很熟悉?前面讲系统调用的时候,execve 是一个系统调用,它的作用是运行一个执行文件。加一个 do_ 的往往是内核系统调用的实现。没错,这就是一个系统调用,它会尝试运行 ramdisk 的“/init”,或者普通文件系统上的“/sbin/init”“/etc/init”“/bin/init”“/bin/sh”。不同版本的 Linux 会选择不同的文件启动,但是只要有一个起来了就可以。

ramdisk 的作用

为什么会有 ramdisk 这个东西呢?还记得上一节咱们内核启动的时候,配置过这个参数:

initrd16 /boot/initramfs-3.10.0-862.el7.x86_64.img

就是这个东西,这是一个基于内存的文件系统。为啥会有这个呢?是因为刚才那个 init 程序是在文件系统上的,文件系统一定是在一个存储设备上的,例如硬盘。Linux 访问存储设备,要有驱动才能访问。如果存储系统数目很有限,那驱动可以直接放到内核里面,反正前面我们加载过内核到内存里了,现在可以直接对存储系统进行访问。

但是存储系统越来越多了,如果所有市面上的存储系统的驱动都默认放进内核,内核就太大了。这该怎么办呢?我们只好先弄一个基于内存的文件系统。内存访问是不需要驱动的,这个就是 ramdisk。这个时候,ramdisk 是根文件系统。

然后,我们开始运行 ramdisk 上的 /init。等它运行完了就已经在用户态了。/init 这个程序会先根据存储系统的类型加载驱动,有了驱动就可以设置真正的根文件系统了。有了真正的根文件系统,ramdisk 上的 /init 会启动文件系统上的 init。接下来就是各种系统的初始化。启动系统的服务,启动控制台,用户就可以登录进来了。先别忙着高兴,rest_init 的第一个大事情才完成。我们仅仅形成了用户态所有进程的祖先。

创建 2 号进程

用户态的所有进程都有大师兄了,那内核态的进程有没有一个人统一管起来呢?有的,rest_init 第二大事情就是第三个进程,就是 2 号进程。

kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES) 又一次使用 kernel_thread函数创建进程。这里需要指出一点,函数名 thread 可以翻译成“线程”,这也是操作系统很重要的一个概念。它和进程有什么区别呢?为什么这里创建的是进程,函数名却是线程呢?

从用户态来看,创建进程其实就是立项,也就是启动一个项目。这个项目包含很多资源,例如会议室、资料库等。这些东西都属于这个项目,但是这个项目需要人去执行。有多个人并行执行不同的部分,这就叫多线程(Multithreading)。如果只有一个人,那它就是这个项目的主线程。

但是从内核态来看,无论是进程,还是线程,我们都可以统称为任务(Task),都使用相同的数据结构,平放在同一个链表中。

这里的函数 kthreadd,负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。

0号进程,唯一一个没有通过fork或kernel_thread产生的进程,是进程列表的第一个。
1号进程是用户态祖先进程。
2号进程是内核态所有线程运行的祖先。

四、系统调用

glibc 对系统调用的封装

系统调用表

系统调用,都是最终到了系统调用表。 32 位的系统调用表定义在面 arch/x86/entry/syscalls/syscall_32.tbl 文件里。例如 open是这样定义的:

5 i386 open sys_open  compat_sys_open

64 位的系统调用定义在另一个文件 arch/x86/entry/syscalls/syscall_64.tbl 里。例如open 是这样定义的:

2 common open sys_open

第一列的数字是系统调用号。可以看出,32 位和 64 位的系统调用号是不一样的。第三列是系统调用的名字,第四列是系统调用在内核的实现函数。不过,它们都是以 sys_ 开头。