Linux系统初始化基础原理笔记

一、x86架构

1. x86架构奠定了当今计算机开放平台的标准和基础,其中最经典的一款处理器是8086处理器。虽然它已经很老了,但是现在操作系统中的很多特性都和它有关,并且一直保持兼容。它的结构示意图如下所示:

Linux系统初始化基础原理笔记

8086处理器内部有8个16位的通用寄存器,也就是CPU内部的数据单元,分别是AX、BX、CX、DX、SP、BP、SI、DI,这些寄存器主要用于在计算过程中暂存数据。这些寄存器比较灵活,其中AX、BX、CX、DX可以分成两个8位的寄存器来使用,分别是AH、AL、BH、BL、CH、CL、DH、DL,其中H就是High(高位),L就是Low(低位)的意思。这样比较长的数据能暂存,比较短的数据也能暂存。

IP寄存器就是指令指针寄存器(Instruction Pointer Register),指向代码段中下一条指令的位置。CPU会根据它来不断地将指令从内存的代码段中,加载到CPU的指令队列中,然后交给运算单元去执行。如果需要切换进程,每个进程都分代码段和数据段,为了指向不同进程的地址空间,有四个16位的段寄存器,分别是CS、DS、SS、ES。

其中,CS就是代码段寄存器(Code Segment Register),通过它可以找到代码在内存中的位置;DS是数据段的寄存器,通过它可以找到数据在内存中的位置。SS是栈寄存器(Stack Register),数据的存取只能从一端进行,秉承后进先出的原则,push就是入栈,pop就是出栈,栈结构的示意图如下所示(实际计算机里栈顶在下方,栈底在上方):

Linux系统初始化基础原理笔记

凡是与函数调用相关的操作,都与栈紧密相关。例如,A调用B,B调用C。当A调用B时,要执行B函数的逻辑,因而A运行的相关信息就会被push到栈里。当B调用C时,同样B运行相关信息会被push到栈里面,然后才运行C函数的逻辑。当C运行完毕时,先pop出来的是B,B就接着调用C之后的指令运行下去。B运行完了,再pop出来的就是A,A接着运行,直到结束。

如果运算中需要加载内存中的数据,需要通过DS找到内存中的数据,加载到通用寄存器中,对于一个段,有一个起始的地址,而段内的具体位置,称为偏移量(Offset)。CS和DS中都存放着一个段的起始地址,代码段的偏移量在IP寄存器中,数据段的偏移量会放在通用寄存器中

然而,CS和DS都是16位的,也就是说起始地址都是16位的,IP寄存器和通用寄存器都是16位的,偏移量也是16位的,但是8086的地址总线地址是20。为了凑够这20位,方法就是“起始地址 * 16 + 偏移量”,也就是把CS和DS中的值左移4位,变成20位的,加上16位的偏移量,这样就可以得到最终20位的数据地址。从这个计算方式可以算出,无论真正的内存多么大,对于只有20位地址总线的8086来讲,能够区分出的地址也就 2^20=1M,超过这个空间就访问不到了。

如果想访问1M+X的地方,这个位置已经超过20位了,由于地址总线只有20位,在总线上超过20位的部分根本是发不出去的,所以发出去的还是X,最后还是会访问1M内的X的位置。因为偏移量只能是16位的,所以当时一个段最大的大小是2^16=64k。

2. 后来,计算机的发展日新月异,内存越来越大,总线也越来越宽。在32位处理器中有32根地址总线,可以访问2^32=4G的内存。使用原来的模式肯定不行,但是为了和前代生态兼容,又不能完全抛弃原来的模式。首先,通用寄存器有扩展,可以将8个16位的扩展到8个32位的,但是依然可以保留16位的和8位的使用方式。其中,指向下一条指令的指令指针寄存器IP,就会扩展成32位的,同样也兼容16位的。如下所示:

Linux系统初始化基础原理笔记

而改动比较大,有点不兼容的就是段寄存器(Segment Register)。因为原来的模式有点不伦不类,因为它没有把16位当成一个段的起始地址,也没有按8位或者16位扩展的形式,而是根据当时的硬件,弄了一个不上不下的20位的地址。这样每次都要左移四位,也就意味着段的起始地址不能是任何一个地方,只是能整除16的地方

假如新的段寄存器都改成32位的,进行重新定义,CS、SS、DS、ES仍然是16位的,但是不再是段的起始地址。段的起始地址放在内存的某个地方。这个地方是一个表格,表格中的一项一项是段描述符(Segment Descriptor)。这里面才是真正的段的起始地址。而段寄存器里面保存的是在这个表格中的哪一项,称为选择子(Selector)。这样,将一个从段寄存器直接拿到的段起始地址,就变成了先间接地从段寄存器找到表格中的一项,再从表格中的一项中拿到段起始地址。这样段起始地址就会很灵活了。当然为了快速拿到段起始地址的值,段寄存器会从内存中拿到CPU的描述符高速缓存器中。

这样就和前代不兼容了,好在后面这种模式灵活度非常高,可以保持将来一直兼容下去。前面的模式出现的时候,没想到自己能够成为一个标准,所以设计就没这么灵活。因而到了32位的系统架构下,将前一种模式称为实模式(Real Pattern),后一种模式称为保护模式(Protected Pattern)。当系统刚刚启动时,CPU是处于实模式的,这个时候和原来的模式是兼容的。也就是说,哪怕买了32位的CPU,也支持在原来的模式下运行,只不过快了一点而已。当需要更多内存的时候,可以遵循一定的规则,进行一系列的操作,然后切换到保护模式,就能够用到32位CPU更强大的能力。这也就是说,不能无缝兼容,但是通过切换模式兼容,也是可以接受的。寄存器和段的工作关系如下图所示:

Linux系统初始化基础原理笔记

二、BIOSbootloader

3. 计算机在开机的时候,会先进入BIOS。在主板上,有一个东西叫ROM(Read Only Memory,只读存储器)。这和平常说的内存RAM(Random Access Memory,随机存取存储器)不同。内存条是可读可写的,这样才能保存计算结果。ROM是只读的,上面早就固化了一些初始化的程序,也就是BIOS(Basic Input and Output System,基本输入输出系统)。BIOS占用的内存空间区域如下所示:

Linux系统初始化基础原理笔记

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

首先,BIOS要检查一下系统的硬件是不是都好,然后要建立一个中断向量表中断服务程序,因为现代BIOS也有图形化界面,可以用键盘和鼠标,这些都是要通过中断进行的。为了在显示器上显示一些结果,就在内存空间映射显存的空间,在显示器上显示一些字符。

当BIOS干完了自己的活,就要开始查找操作系统了。操作系统一般都会在安装在硬盘上,在BIOS的界面上会看到一个启动盘的选项,它一般在第一个扇区,占512字节,而且以0xAA55结束。这是一个约定,当满足这个条件的时候,就说明这是一个启动盘,在512字节以内会有启动相关的代码。这些代码是一个叫Grub2的工具放在这的,全称Grand Unified Bootloader 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
}

这里的选项会在系统启动的时候,成为一个列表从而选择从哪个系统启动,选择界面如下所示:

Linux系统初始化基础原理笔记

使用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就相当于操作系统档案管理处,它由lzma_decompress.imgdiskboot.imgkernel.img和一系列的模块组成,功能比较丰富,grub结构图如下所示:

Linux系统初始化基础原理笔记

boot.img先加载的是core.img的第一个扇区。如果从硬盘启动的话,这个扇区里面是diskboot.img,对应的代码是diskboot.S。boot.img将控制权交给diskboot.img后,diskboot.img的任务就是将core.img的其他部分加载进来,先是解压缩程序lzma_decompress.img,再往下是kernel.img,最后是各个模块module对应的映像。需要注意这里的kernel.img不是Linux的内核,而是grub的内核。lzma_decompress.img对应的代码是startup_raw.S,本来kernel.img是压缩过的,现在执行的时候需要解压缩。

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

4. 切换到保护模式要干很多工作,大部分工作都与内存的访问方式有关。第一项是启用分段,就是在内存里面建立段描述符表,将寄存器里面的段寄存器变成段选择子,指向某个段描述符,这样就能实现不同进程的切换了。第二项是启动分页(page),能够管理的内存变大了,就需要将内存分成各个相等大小的块(4KB)。

保护模式需要做一项工作,那就是打开Gate A20,也就是第21根地址线的控制线。在8086处理器实模式下面,一共就20个地址线,只可访问1M的地址空间。如果超过了这个限度就要进入保护模式,在保护模式下第21根就要起作用了。切换保护模式的函数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)才开始真正地启动内核。BIOS引导系统启动的过程比喻如下所示:

Linux系统初始化基础原理笔记

三、内核初始化

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

Linux系统初始化基础原理笔记

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

trap_init()里设置了很多中断门(Interrupt Gate),用于处理各种中断。其中有个set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32),这是系统调用的中断门。系统调用也是通过发送中断的方式进行的

mm_init()用来初始化内存管理模块,sched_init() 就是用于初始化调度模块,vfs_caches_init()会用来初始化基于内存的文件系统rootfs。在这个函数里会调用mnt_init()->init_rootfs(),这里有一行代码register_filesystem(&rootfs_fs_type),在VFS虚拟文件系统里面注册了一种类型,定义为struct file_system_type rootfs_fs_type。文件系统相当于项目资料库,为了兼容各种各样的文件系统,需要将文件的相关数据结构和操作抽象出来,形成一个抽象层对上提供统一的接口,这个抽象层就是VFS(Virtual File System,虚拟文件系统)。内核初始化的比喻示意图如下所示:

Linux系统初始化基础原理笔记

6. start_kernel()最后调用的是rest_init(),用来做其他方面的初始化。rest_init的第一大工作是用kernel_thread(kernel_init, NULL, CLONE_FS)创建第二个进程,这个是1号进程。1号进程对于操作系统来讲有“划时代”的意义,因为它将运行一个用户进程。这就像公司把一个老板独立完成的制度,变成了可以交付他人完成的制度。1号进程就相当于老板带了一个大徒弟,后面大徒弟带了很多徒弟,形成一棵进程树。

一旦有了用户进程,为了防止恶意的用户进程乱搞系统里的所有东西,需要将内存等系统资源和权限分割为用户可使用的普通区域和机密区域。x86提供了分层的权限机制,把区域分成了四个Ring,越往里权限越高,越往外权限越低。如下所示:

Linux系统初始化基础原理笔记

操作系统很好地利用了这个机制,将能够访问关键资源的代码放在Ring0,称为内核态(Kernel Mode);将普通的程序代码放在Ring3,称为用户态(User Mode)。保护模式除了可访问空间大一些,还有另一个重要功能,就是“保护”,也就是说,当处于用户态的代码想要执行更高权限的指令,这种行为是被禁止的

当一个用户态的程序运行到一半,要访问一个核心资源,例如访问网卡发一个网络包,就需要暂停当前用户态程序的运行,调用系统调用,接下来就轮到内核中的代码运行了。首先,内核将从系统调用传过来的包,在网卡上排队,轮到的时候就发送。发送完了系统调用就结束了,返回用户态,让暂停运行的程序接着运行

这个用户态暂停的实现,其实就是把程序运行到一半的情况保存下来。例如,内存是用来保存程序运行时候的中间结果的,现在要暂时停下来,这些中间结果不能丢。另外,当前运行到代码的哪一行了,当前的栈在哪里,这些都是在寄存器里的。所以暂停的那一刻,要把当时CPU的寄存器的值全部暂存到一个地方,这个地方可以放在进程管理系统很容易获取的地方。当系统调用完毕,返回的时候,再从这个地方将寄存器的值恢复回去,就能接着运行了。用户态与内核态的切换如下所示:

Linux系统初始化基础原理笔记

这个过程就是这样切换的:用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态,然后接着运行。如下所示:

Linux系统初始化基础原理笔记

7. 再回到1号进程启动的过程。当前执行kernel_thread这个函数的时候,还在内核态,现在就来跨越这道屏障,到用户态去运行一个程序。kernel_thread的参数是一个函数kernel_init,也就是这个进程会运行这个函数。在kernel_init里面,会调用kernel_init_freeable(),里面有这样的代码:

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

回到 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会选择不同的文件启动,但是只要有一个起来了就可以。run_init_process函数的源码如下所示:

static int run_init_process(const char *init_filename)
{
  argv_init[0] = init_filename;
  return do_execve(getname_kernel(init_filename),
    (const char __user *const __user *)argv_init,
    (const char __user *const __user *)envp_init);
}

如何利用执行init文件的机会,从内核态回到用户态呢?从系统调用的过程可以得到启发,“用户态 - 系统调用 - 保存寄存器 - 内核态执行系统调用 - 恢复寄存器 - 返回用户态”,然后接着运行。而刚才运行init,是调用do_execve,正是上面过程的后半部分,从内核态执行系统调用开始。do_execve->do_execveat_common->exec_binprm->search_binary_handler,这里会调用如下内容:

int search_binary_handler(struct linux_binprm *bprm)
{
  ......
  struct linux_binfmt *fmt;
  ......
  retval = fmt->load_binary(bprm);
  ......
}

也就是说,要运行一个程序,需要加载这个二进制文件。它是有一定格式的。Linux下一个常用的格式是ELF(Executable and Linkable Format,可执行与可链接格式)。于是就有了下面这个定义:

static struct linux_binfmt elf_format = {
.module  = THIS_MODULE,
.load_binary  = load_elf_binary,
.load_shlib  = load_elf_library,
.core_dump  = elf_core_dump,
.min_coredump  = ELF_EXEC_PAGESIZE,
};

这其实就是先调用load_elf_binary,最后调用start_thread:

void
start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp)
{
set_user_gs(regs, 0);
regs->fs  = 0;
regs->ds  = __USER_DS;
regs->es  = __USER_DS;
regs->ss  = __USER_DS;
regs->cs  = __USER_CS;
regs->ip  = new_ip;
regs->sp  = new_sp;
regs->flags  = X86_EFLAGS_IF;
force_iret();
}
EXPORT_SYMBOL_GPL(start_thread);

struct pt_regs,看名字里的register,就是寄存器。这个结构就是在系统调用的时候,内核中保存用户态运行上下文的,里面将用户态的代码段CS设置为__USER_CS,将用户态的数据段DS设置为__USER_DS,以及指令指针寄存器IP、栈指针寄存器SP。这里相当于补上了原来系统调用里,保存寄存器的一个步骤。

最后的iret是用于从系统调用中返回。这个时候会恢复寄存器。CS和指令指针寄存器IP恢复了,指向用户态下一个要执行的语句。DS和函数栈指针SP也被恢复了,指向用户态函数栈的栈顶。所以,下一条指令,就从用户态开始运行了。

8. init从内核到用户态,一开始到用户态的是ramdisk的init,后来会启动真正根文件系统上的init,成为所有用户态进程的祖先。为什么会有ramdisk这个东西呢?内核启动的时候,配置过这个参数:

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

这是一个基于内存的文件系统,刚才那个init程序是在文件系统上的,文件系统一定是在一个存储设备如硬盘上。Linux访问存储设备要有驱动才能访问,如果存储系统数目很有限,那驱动可以直接放到内核里面,反正前面加载过内核到内存里了,现在可以直接对存储系统进行访问。但是存储系统越来越多了,如果所有市面上的存储系统的驱动都默认放进内核,内核就太大了。

这样只好先弄一个基于内存的文件系统。内存访问是不需要驱动的,这个就是ramdisk。这时ramdisk是根文件系统。然后开始运行ramdisk上的/init。等它运行完了就已经在用户态了。/init这个程序会先根据存储系统的类型加载驱动,有了驱动就可以设置真正的根文件系统了。有了真正的根文件系统,ramdisk上的/init会启动文件系统上的init。接下来就是各种系统的初始化。启动系统的服务,启动控制台,用户就可以登录进来了。

9. 经过上面的步骤,rest_init的第一个大事情才完成,仅仅形成了用户态所有进程的祖先即1号进程。rest_init第二大事情就是建立第三个进程,就是2号进程建立内核态的进程祖先。kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES) 又一次使用kernel_thread函数创建进程。函数名thread可以翻译成“线程”,但这里创建的是进程,与线程有很大区别。

从用户态来看,创建进程其实就像项目立项。但是这个项目需要人去执行,有多个人并行执行不同的部分,这就叫多线程(Multithreading)。如果只有一个人,那它就是这个项目的主线程。但是从内核态来看,无论是进程还是线程,都可以统称为任务(Task),都使用相同的数据结构,平放在同一个链表中。这里的函数kthreadd,负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。子系统初始化和1号进程与2号进程的创建过程可以用下图表示:

Linux系统初始化基础原理笔记

四、系统调用

10. 有时用户自己手动使用系统调用不太方便,Linux还提供了glibc这个中介,它更熟悉系统调用的细节,并且可以封装成更加友好的接口。以最常用的系统调用open即打开一个文件为例,看看系统调用是怎么实现的。在用户态进程里面调用open函数,为了方便大部分用户会选择使用glibc里面的open函数,这个函数的定义如下所示:

int open(const char *pathname, int flags, mode_t mode)

在glibc的源代码中,有个文件syscalls.list,里面列着所有glibc的函数对应的系统调用,如下所示:

# File name Caller  Syscall name    Args    Strong name Weak names
open    -  open    Ci:siv  __libc_open __open open

另外,glibc还有一个脚本make-syscall.sh,可以根据上面的配置文件,对于每一个封装好的系统调用,生成一个文件。这个文件里面定义了一些宏,例如#define SYSCALL_NAME open。glibc还有一个文件syscall-template.S,使用上面这个宏,定义了这个系统调用的调用方式。如下所示:

T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS)
    ret
T_PSEUDO_END (SYSCALL_SYMBOL)

#define T_PSEUDO(SYMBOL, NAME, N)    PSEUDO (SYMBOL, NAME, N)

这里的 PSEUDO 也是一个宏,它的定义如下:

#define PSEUDO(name, syscall_name, args)                      \
  .text;                                      \
  ENTRY (name)                                    \
    DO_CALL (syscall_name, args);                         \
    cmpl $-4095, %eax;                               \
    jae SYSCALL_ERROR_LABEL

里面对于任何一个系统调用,会调用DO_CALL。这也是一个宏,这个宏32位和64位的定义是不一样的。

11. 先来看32位的情况(i386 目录下的 sysdep.h 文件):

/* Linux takes system call arguments in registers:
  syscall number  %eax       call-clobbered
  arg 1    %ebx       call-saved
  arg 2    %ecx       call-clobbered
  arg 3    %edx       call-clobbered
  arg 4    %esi       call-saved
  arg 5    %edi       call-saved
  arg 6    %ebp       call-saved
......
*/
#define DO_CALL(syscall_name, args)                           \
    PUSHARGS_##args                               \
    DOARGS_##args                                 \
    movl $SYS_ify (syscall_name), %eax;                          \
    ENTER_KERNEL                                  \
    POPARGS_##args

这里将请求参数放在寄存器里面,根据系统调用的名称,得到系统调用号,放在寄存器eax里面,然后执行 ENTER_KERNEL。在Linux的源代码注释里可以清晰地看到,这些寄存器是如何传递系统调用号和参数的。这里面ENTER_KERNEL的定义如下所示:

# define ENTER_KERNEL int $0x80

int就是interrupt,也就是“中断”的意思。int $0x80就是触发一个软中断,通过它就可以陷入(trap)内核。在内核启动的时候,还记得有一个trap_init(),其中有这样的代码:

set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32);

这是一个软中断的陷入门。当接收到一个系统调用的时候,entry_INT80_32就被调用了。如下所示:

ENTRY(entry_INT80_32)
        ASM_CLAC
        pushl   %eax                    /* pt_regs->orig_ax */
        SAVE_ALL pt_regs_ax=$-ENOSYS    /* save rest */
        movl    %esp, %eax
        call    do_syscall_32_irqs_on
.Lsyscall_32_done:
......
.Lirq_return:
  INTERRUPT_RETURN

通过push和SAVE_ALL将当前用户态的寄存器,保存在pt_regs结构里面。进入内核之前,保存所有的寄存器,然后调用do_syscall_32_irqs_on。它的实现如下:

static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs)
{
  struct thread_info *ti = current_thread_info();
  unsigned int nr = (unsigned int)regs->orig_ax;
......
  if (likely(nr < IA32_NR_syscalls)) {
    regs->ax = ia32_sys_call_table[nr](
      (unsigned int)regs->bx, (unsigned int)regs->cx,
      (unsigned int)regs->dx, (unsigned int)regs->si,
      (unsigned int)regs->di, (unsigned int)regs->bp);
  }
  syscall_return_slowpath(regs);
}

在这里可以看到,将系统调用号从eax里面取出来,然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。如果仔细比对,就能发现,这些参数所对应的寄存器,和Linux的注释是一样的。根据宏定义,#define ia32_sys_call_table sys_call_table,系统调用就是放在这个表里面。当系统调用结束之后,在entry_INT80_32之后,紧接着调用的是INTERRUPT_RETURN,能够找到它的定义也就是iret,如下所示:

#define INTERRUPT_RETURN                iret

iret指令将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等。这时候用户态进程恢复执行。32位系统的系统调用执行过程如下所示:

Linux系统初始化基础原理笔记

12. 64位系统的系统调用执行情况略有不同。可以看x86_64下的sysdep.h文件:

/* The Linux/x86-64 kernel expects the system call parameters in
   registers according to the following table:
    syscall number  rax
    arg 1    rdi
    arg 2    rsi
    arg 3    rdx
    arg 4    r10
    arg 5    r8
    arg 6    r9
......
*/
#define DO_CALL(syscall_name, args)                \
  lea SYS_ify (syscall_name), %rax;                \
  syscall

和32位系统一样,还是将系统调用名称转换为系统调用号,放到寄存器rax这里是真正进行调用,不是用中断了,而是改用syscall指令了。并且通过注释也可以知道,传递参数的寄存器也变了。

syscall指令还使用了一种特殊的寄存器,叫特殊模块寄存器(Model Specific Registers,简称 MSR)。这种寄存器是CPU为了完成某些特殊控制功能为目的的寄存器,其中就有系统调用。在系统初始化的时候,trap_init除了初始化上面的中断模式,这里面还会调用cpu_init->syscall_init。这里面有这样的代码:

wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

rdmsr和wrmsr是用来读写特殊模块寄存器的。MSR_LSTAR就是这样一个特殊的寄存器,当syscall指令调用的时候,会从这个寄存器里面拿出函数地址来调用,也就是调用entry_SYSCALL_64。在arch/x86/entry/entry_64.S中定义了entry_SYSCALL_64,如下所示:

ENTRY(entry_SYSCALL_64)
        /* Construct struct pt_regs on stack */
        pushq   $__USER_DS                      /* pt_regs->ss */
        pushq   PER_CPU_VAR(rsp_scratch)        /* pt_regs->sp */
        pushq   %r11                            /* pt_regs->flags */
        pushq   $__USER_CS                      /* pt_regs->cs */
        pushq   %rcx                            /* pt_regs->ip */
        pushq   %rax                            /* pt_regs->orig_ax */
        pushq   %rdi                            /* pt_regs->di */
        pushq   %rsi                            /* pt_regs->si */
        pushq   %rdx                            /* pt_regs->dx */
        pushq   %rcx                            /* pt_regs->cx */
        pushq   $-ENOSYS                        /* pt_regs->ax */
        pushq   %r8                             /* pt_regs->r8 */
        pushq   %r9                             /* pt_regs->r9 */
        pushq   %r10                            /* pt_regs->r10 */
        pushq   %r11                            /* pt_regs->r11 */
        sub     $(6*8), %rsp                    /* pt_regs->bp, bx, r12-15 not saved */
        movq    PER_CPU_VAR(current_task), %r11
        testl   $_TIF_WORK_SYSCALL_ENTRY|_TIF_ALLWORK_MASK, TASK_TI_flags(%r11)
        jnz     entry_SYSCALL64_slow_path
......
entry_SYSCALL64_slow_path:
        /* IRQs are off. */
        SAVE_EXTRA_REGS
        movq    %rsp, %rdi
        call    do_syscall_64           /* returns with IRQs disabled */
return_from_SYSCALL_64:
  RESTORE_EXTRA_REGS
  TRACE_IRQS_IRETQ
  movq  RCX(%rsp), %rcx
  movq  RIP(%rsp), %r11
    movq  R11(%rsp), %r11
......
syscall_return_via_sysret:
  /* rcx and r11 are already restored (see code above) */
  RESTORE_C_REGS_EXCEPT_RCX_R11
  movq  RSP(%rsp), %rsp
  USERGS_SYSRET64

这里先保存了很多寄存器到pt_regs结构里面,例如用户态的代码段、数据段、保存参数的寄存器,然后调用entry_SYSCALL64_slow_path->do_syscall_64,如下所示:

__visible void do_syscall_64(struct pt_regs *regs)
{
        struct thread_info *ti = current_thread_info();
        unsigned long nr = regs->orig_ax;
......
        if (likely((nr & __SYSCALL_MASK) < NR_syscalls)) {
                regs->ax = sys_call_table[nr & __SYSCALL_MASK](
                        regs->di, regs->si, regs->dx,
                        regs->r10, regs->r8, regs->r9);
        }
        syscall_return_slowpath(regs);
}

在do_syscall_64里面,从rax(即代码中orig_ax)里面拿出系统调用号,然后根据系统调用号,在系统调用表sys_call_table中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。如果仔细比对就能发现,这些参数所对应的寄存器,和Linux的注释又是一样的。所以,无论是32位还是64位系统,都会到系统调用表sys_call_table这里来。在研究系统调用表之前,看64位的系统调用返回的时候,执行的是USERGS_SYSRET64。定义如下:

#define USERGS_SYSRET64        \
  swapgs;          \
  sysretq;

这里与32位系统不同,返回用户态的指令变成了sysretq。64位系统的系统调用执行的流程如下所示:

Linux系统初始化基础原理笔记

13. 系统调用表sys_call_table是怎么形成的呢?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_ 开头。系统调用在内核中的实现函数要有一个声明。声明往往在include/linux/syscalls.h文件中。例如sys_open是这样声明的:

asmlinkage long sys_open(const char __user *filename,
                                int flags, umode_t mode);

真正的实现这个系统调用,一般在一个.c文件里面,例如sys_open的实现在fs/open.c里面,如下所示:

SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode)
{
        if (force_o_largefile())
                flags |= O_LARGEFILE;
        return do_sys_open(AT_FDCWD, filename, flags, mode);
}

SYSCALL_DEFINE3是一个宏(传入3个参数),系统调用最多六个参数,根据参数的数目选择宏。具体是这样定义的:

#define SYSCALL_DEFINE1(name, ...) SYSCALL_DEFINEx(1, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE2(name, ...) SYSCALL_DEFINEx(2, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE3(name, ...) SYSCALL_DEFINEx(3, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE4(name, ...) SYSCALL_DEFINEx(4, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE5(name, ...) SYSCALL_DEFINEx(5, _##name, __VA_ARGS__)
#define SYSCALL_DEFINE6(name, ...) SYSCALL_DEFINEx(6, _##name, __VA_ARGS__)


#define SYSCALL_DEFINEx(x, sname, ...)                          \
        SYSCALL_METADATA(sname, x, __VA_ARGS__)                 \
        __SYSCALL_DEFINEx(x, sname, __VA_ARGS__)


#define __PROTECT(...) asmlinkage_protect(__VA_ARGS__)
#define __SYSCALL_DEFINEx(x, name, ...)                                 \
        asmlinkage long sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))       \
                __attribute__((alias(__stringify(SyS##name))));         \
        static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__));  \
        asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__));      \
        asmlinkage long SyS##name(__MAP(x,__SC_LONG,__VA_ARGS__))       \
        {                                                               \
                long ret = SYSC##name(__MAP(x,__SC_CAST,__VA_ARGS__));  \
                __MAP(x,__SC_TEST,__VA_ARGS__);                         \
                __PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__));       \
                return ret;                                             \
        }                                                               \
        static inline long SYSC##name(__MAP(x,__SC_DECL,__VA_ARGS__)

如果把宏展开之后,实现如下,和声明的是一样的,SYSCALL_DEFINE3指代的就是传入3个参数的sys_open

asmlinkage long sys_open(const char __user * filename, int flags, int mode)
{
 long ret;


 if (force_o_largefile())
  flags |= O_LARGEFILE;


 ret = do_sys_open(AT_FDCWD, filename, flags, mode);
 asmlinkage_protect(3, ret, filename, flags, mode);
 return ret;

在Makefile里会使用两个脚本,其中第一个脚本arch/x86/entry/syscalls/syscallhdr.sh,会在文件中生成#define __NR_open;第二个脚本arch/x86/entry/syscalls/syscalltbl.sh,会在文件中生成__SYSCALL(__NR_open, sys_open)。这样,unistd_32.h和unistd_64.h是用于对应系统调用号和系统调用实现函数之间的对应关系。在文件arch/x86/entry/syscall_32.c,定义了这样一个表sys_call_table,里面include了这个头文件,从而所有的sys_系统调用名和系统调用号的对应关系都放在这个表里面了,如下所示:

__visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = {
        /*
         * Smells like a compiler bug -- it doesn't work
         * when the & below is removed.
         */
        [0 ... __NR_syscall_compat_max] = &sys_ni_syscall,
#include <asm/syscalls_32.h>
};

同理,在文件arch/x86/entry/syscall_64.c,定义了这样一个表sys_call_table,里面include了这个头文件,这样所有的sys_系统调用名和系统调用号也都放在这个表里面了。如下所示:

/* System call table for x86-64. */
asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = {
  /*
   * Smells like a compiler bug -- it doesn't work
   * when the & below is removed.
   */
  [0 ... __NR_syscall_max] = &sys_ni_syscall,
#include <asm/syscalls_64.h>
};

综上,64位系统的系统调用与内核态和用户态切换的完整总体流程如下所示:

Linux系统初始化基础原理笔记

 

Linux系统初始化基础原理笔记Linux系统初始化基础原理笔记 书忆江南 发布了35 篇原创文章 · 获赞 104 · 访问量 8万+ 私信 关注
上一篇:深入理解系统调用


下一篇:Phoenix使用