当按下计算机的电源键,过一会计算机应该就能启动完成,在屏幕上会有欢迎登录的界面,接着只需要输入密码,就可以成功登录了。那么从按下电源键,到显示器显示界面过程中,究竟发生了什么?
通电
按下开关之后,肯定会给整个系统通上电(通电的具体步骤就真的不再细究了,没学过也没必要),此时CPU会启动,并且会初始化所有的寄存器,因为CPU启动起来了肯定要有指令给它执行,而对应的指令的位置是由CS:IP来指定的,所以我们这里只关注CS:IP是怎么样的。CS:IP会被设置成如下:
1 | IP 0xfff0 |
所以最最开始的时候,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的地址空间。而这个地址空间的映射图见下:
对应的地址表:
1 | 0x00000000 - 0x000003FF - Real Mode Interrupt Vector Table // 实模式下的中断向量表 |
可以看到整个系统其实就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 | asmlinkage __visible void __init __no_sanitize_address start_kernel(void) |
上面仅仅列出了比较重要的初始化函数,接着再把目光聚焦到最后的那个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中都是用这个数据结构来表示的,而这个结构可以用一张图来表示: