系统是如何启动的

当按下计算机的电源键,过一会计算机应该就能启动完成,在屏幕上会有欢迎登录的界面,接着只需要输入密码,就可以成功登录了。那么从按下电源键,到显示器显示界面过程中,究竟发生了什么?

通电

按下开关之后,肯定会给整个系统通上电(通电的具体步骤就真的不再细究了,没学过也没必要),此时CPU会启动,并且会初始化所有的寄存器,因为CPU启动起来了肯定要有指令给它执行,而对应的指令的位置是由CS:IP来指定的,所以我们这里只关注CS:IP是怎么样的。CS:IP会被设置成如下:

1
2
3
IP          0xfff0
CS selector 0xf000
CS:IP = CS selector << 4 + IP = 0xffff0

所以最最开始的时候,CPU中的CS:IP会被设置成0xffff0(这里已经是1M寻址空间的最后16位了),也就是CPU需要执行的第一条指令,会在0xFFFF0这个地址,而这个地址就是ROM区(也是整个1M寻址空间)的最最顶层。

BIOS

主板上有一个硬件,叫ROM(Read Only Memory),而在ROM上有一段被固化好了的程序(现在可以进行BIOS升级,所以其实能改),就是BIOS(Basic Input and Output System),其大小一般在几兆以内,就是进入系统的时候疯狂按某个按键(不同主板不同)进入的一个界面,一般最常见的就是用来打开支持CPU虚拟化以及设置启动盘。

之间也说了CS:IP会设置成0xFFFF0,而这个地址中存放的指令是jmp,跳转到一个特定的地址开始,执行BIOS中固化好的指令。这里肯定有人会有疑问,假设jmp跳转的目的地址是A,那为什么不直接就从A开始执行呢?这个我个人感觉应该是为了节省空间考虑,也可以有更好的扩展性。

这里要稍微回溯一下历史,8086处理器有20位寻址总线,但是它的寄存器都是16位的,那怎么支持20位的地址呢?就是通过将一个寄存器乘以16(左移4位)然后加上另外一个寄存器,这样就能得到一个地址(如果溢出就舍去高位),这便是大名鼎鼎的实模式

而实模式有个问题,假设CS=0xffff,IP=0xffff,显然两者相加已经超过了1M的寻址空间了,这里实模式采用的是类似模运算的方法,直接把超过的位置丢弃即可,但是如果地址线超过了20根,就会出现问题,所以才会有之后的打开A20地址线的操作。

此时由于只是固化在ROM上的一段程序,所以其实它功能很弱,同时此时x86的寻址能力也很弱,只启用了20根地址线,所以最多支持220=1M的地址空间。而这个地址空间的映射图见下:

image-20201022151619059

对应的地址表:

1
2
3
4
5
6
7
8
9
10
11
0x00000000 - 0x000003FF - Real Mode Interrupt Vector Table		// 实模式下的中断向量表
0x00000400 - 0x000004FF - BIOS Data Area // 数据区
0x00000500 - 0x00007BFF - Unused
0x00007C00 - 0x00007DFF - Our Bootloader
0x00007E00 - 0x0009FFFF - Unused
0x000A0000 - 0x000BFFFF - Video RAM (VRAM) Memory
0x000B0000 - 0x000B7777 - Monochrome Video Memory
0x000B8000 - 0x000BFFFF - Color Video Memory
0x000C0000 - 0x000C7FFF - Video ROM BIOS
0x000C8000 - 0x000EFFFF - BIOS Shadow Area
0x000F0000 - 0x000FFFFF - System BIOS

可以看到整个系统其实就1M,然后包含了中断向量表和中断服务程序(这里的中断向量表和中断服务程序只是为BIOS所使用,之后bootloader会关闭中断,并且会重新生成中断向量表和服务程序),而在最上面的空间,也就是0xF0000-0xFFFFF这64K空间映射到ROM中。最开始的时候,CS:IP会指向0xFFFF0,然后会有相关的命令,完成初始化工作:包括硬件自检这些的。

小总结:第一条指令是jmp,执行完成之后,BIOS开始进行初始化的工作,包括检查系统硬件是否完好,并在内存中生成一个中断向量表和中断服务程序(因为你还需要在BIOS中使用键盘鼠标,也需要给你显示内容嘛!),之后把控制权交给bootloader。

bootloader

BIOS完成它的任务(自检等)之后,就会到自己的配置中找到引导盘的记录,一般而言都是硬盘,那么就会去读取第一个扇区,如果它发现这个扇区满足一定的规律,就说明这是一个MBR,那么BIOS就会把这段程序(只有512字节)从硬盘中加载到内存中(而且还是固定的0x7c00地址处)来进行运行,同时这段第一个扇区的代码也被称为boot.img。

毕竟boot.img也就512字节大,所以它其实也做不了太多事情,基本上就是把core.img给加载到内存中。这个core.img(注意!此时和操作系统还没关系呢)有很多的img组成,其中最重要的就是lzma_decompress.img,这个看名字也知道是和压缩相关的。这个img比较重要,因为它完成了实模式到保护模式的切换,现在终于可以从20位的寻址空间解放了。与此同时也建立了分段和分页。

接着把kernel.img进行解压(此kernel指的是grub的内核,而不是操作系统的内核),这个里面会解析一些配置文件,最终让用户选择对应的操作系统,一旦用户选择完毕,就会读取真正的内核镜像到内存中,此时bootloader的使命完毕。

自己看的xv6源码的bootloader

相关源码有两份,分别是boot.S和main.c,简单看下源码里面的逻辑。

boot.S:

  • 关闭中断(虽然中断是boot.S关闭的,但是打开却是由内核来完成的)
  • 把ax、ds、es和ss清零
  • 打开A20地址线
  • 把控制权交给main.c这个文件

main.c:

  • 从磁盘中读取文件,判断它是不是elf文件,是的话就全部加载进来。
  • 把控制权交给elf里面的一个e_entry的入口,开始执行内核的代码。

内核

内核一开始会执行start_kernel方法(在init/main.c),我把它稍微简化了一下,大致流程就是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
asmlinkage __visible void __init __no_sanitize_address start_kernel(void)
{
set_task_stack_end_magic(&init_task); // 参数中的init_task已经初始化完成,它是0号进程
trap_init(); // 初始化中断
mm_init(); // 初始化内存
sched_init(); // 初始化调度
arch_call_rest_init(); // 剩余其他部分初始化,实际这个函数用的是rest_init();
}

void rest_init(void)
{
pid = kernel_thread(kernel_init, NULL, CLONE_FS);
}

上面仅仅列出了比较重要的初始化函数,接着再把目光聚焦到最后的那个rest_init()函数中,因为在这个函数里面,会创建init(现在应该叫systemd),然后从内核态变成用户态。

其中最重要的函数就是kernel_thread,这个函数的参数本身也是一个指针,指向了一个函数,所以其实就是调用了kernel_init,而其实运行的(通过execve执行)就是一个文件,而这个文件就是/sbin/init(其实是一个软链接,真实的文件是/lib/systemd/systemd)。通过执行这个文件,并且把寄存器设置成对应的值,这样之后返回之后,就可以成功从内核态转到用户态了。

也就是,可以这样理解,系统先自己搞出了一个init_task的零号进程,然后通过这个零号进程,执行了一个文件,并且在执行的时候手动把寄存器的值修改成对应的用户态的值,就完成了init进程的创建,并且成功回到了用户态。

接着就是pid为2的进程,也就是kthreadd的创建了,而调用的方法一模一样,只是标志位有所区别而已。

系统调用

对于系统调用,其实本质上用的是DO_CALL

而在DO_CALL里面,会把请求参数保存在寄存器里面,然后根据系统调用的名称(open),找到系统调用对应的号码,然后放入eax里面,并且执行ENTER_KERNEL,而这个其实是int $0x80的宏。

然后就是通过中断进入内核了嘛,然后就需要保存所有的寄存器内容。然后调用do_syscall_32_irqs_on,把系统调用号从eax取出来,然后根据这个号码找到对应的中断处理程序,并且把请求参数从寄存器中取出来进行处理。处理完成之后,把保存的寄存器内容都恢复就行了。

简而言之:保存好请求参数和系统调用号码,通过int80中断到内核态,保存所有的寄存器的值,执行对应的函数,完成之后恢复寄存器的值。

64位稍有区别,它不使用int80了,改用了syscall,其余就不深究了。

在内核中,所有的系统调用的实现函数都是sys_xxx()这种格式声明的,而最终实现函数,是叫SYSCALL_DIFINEx,其中的x是一个数字,也就是理解为所有的系统调用的实现都叫这个名字,但是由于文件名字不同,所以可以区分。

ELF

Executable and Linking Format,最重要的就是执行链接这两个功能。

常见的ELF有三种,分别是可重定向类型(relocatable),也就是.o文件;可执行类型(executable),静态编译的可执行文件以及共享对象(shared object),这个包括动态编译的可执行文件和动态链接库。

而运行一个程序,其实就是exec及其家族来实现的,这个是系统调用,然后按照上面的系统调用,来执行程序。

tast_struct

进程和线程在linux中都是用这个数据结构来表示的,而这个结构可以用一张图来表示:

image-20201022192046549