linux核心原理之二系统初始化

737 阅读31分钟

x86架构

x86架构及时一个开放的平台。在开始之前我们先看看计算机的工作模式。

对于一台计算机来讲,最核心的就是cpu,如图,这是这台计算机的大脑,所有的设备都围绕它展开。

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

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

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

cpu其实也不是单纯的一块,它包括三个部分,运算单元,数据单元和控制单元。

运算单元只管算,例如做加法,做位移等等,但是它不知道应该算那些数据,运算结果应该放在那里。

运算单元计算的数据如果每次都要经过总线,到内存里面现拿,这样就太慢了,所以就有了数据单元。数据单元包括cpu内部的缓存和寄存器组,空间很小,但是速度飞快,可以暂时存放数据和运算结果。

有了放数据的地方,也有了算的地方,还需要有个指挥到底做什么运算的地方,这就是控制单元。控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。

(图片来自互联网,侵权请联系删除)

每个进程都有一个程序放在硬盘上,是二进制的,在里面就是一行行的指令,会操作一些数据。

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

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

cpu的控制单元里面,有一个指令指针寄存器,它里面存放的是下一条指令在内存中的地址。控制单元会不停地将代码段的指令拿进来,先放入指令寄存器。

当前的指令分两部分,一部分是做什么操作,例如是加法还是位移;一部分是操作那些数据。

要执行这条指令,就要把第一部分交给运算单元,第二部分交给数据单元。

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

cpu里面有两个寄存器,专门保存当前处理的代码段的起始地址,以及数据段的起始地址。上面写的都是进程A,那当前执行的就是进程A的指令,等切换成进程B,就会执行B的指令了,这个过程叫做进程切换。这是一个多任务系统的必备操作。

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

总线其实有点像连接cpu和内存这两个设备的高速公路,说总线到底是多少位,就类似说高速公路有几个车道。但是这两种总线的位数意义是不同的。

地址总线的位数,决定了能访问的地址范围到底有多广。例如只有两位,那cpu就只能认00 ,01,10,11四个位置,超过四个位置,就区分不出来了。位数越多,能够访问的位置就越多,能管理的内存的范围也就越广。

而数据总线的位数,决定了一次能拿多少个数据进来。例如只有两位,那cpu一次只能从内存拿两位数。要想拿八位,就要拿四次。位数越多,一次拿的数据就越多,访问速度也就是越快。

x86的重要性

早期的 IBM 凭借大型机技术成为计算机市场的领头羊,直到后来个人计算机兴起,苹果公司诞生。但是,那个时候,无论是大型机还是个人计算机,每家的 CPU 架构都不一样。如果一直是这样,个人电脑、平板电脑、手机等等,都没办法形成统一的体系,就不会有我们现在通用的计算机了,更别提什么云计算、大数据这些统一的大平台了。

好在历史将 x86 平台推到了开放、统一、兼容的位置。IBM 开始做 IBM PC 时,一开始并没有让最牛的华生实验室去研发,而是交给另一个团队。一年时间,软硬件全部自研根本不可能完成,于是他们采用了英特尔的 8088 芯片作为 CPU,使用微软的 MS-DOS 做操作系统。谁能想到 IBM PC 卖的超级好,好到因为垄断市场而被起诉。IBM 就在被逼的情况下公开了一些技术,使得后来无数 IBM-PC 兼容机公司的出现,也就有了后来占据市场的惠普、康柏、戴尔等等。 能够开放自己的技术是一件了不起的事。从技术和发展的层面来讲,它会使得一项技术大面积铺开,形成行业标准。

就比如现在常用的 Android 手机,如果没有开放的 Android 系统,我们也没办法享受到这么多不同类型的手机。对于当年的 PC 机来说,其实也是这样。英特尔的技术因此成为了行业的开放事实标准。由于这个系列开端于 8086,因此称为 x86 架构。

后来英特尔的 CPU 数据总线和地址总线越来越宽,处理能力越来越强。但是一直不能忘记三点,一是标准,二是开放,三是兼容。

8086的重要性

说完了 x86 的历史,再来看 x86 中最经典的一款处理器,8086 处理器。虽然它已经很老了,但是咱们现在操作系统中的很多特性都和它有关,并且一直保持兼容。

把 CPU 里面的组件放大之后来看:

(图片来自网易云计算研究院刘超老师,侵权请联系删除)

先来看数据单元。为了暂存数据,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(低位)的意思。这样,比较长的数据也能暂存,比较短的数据也能暂存。你可能会说 16 位并不长啊,但是那是在计算机刚刚起步的时代

接着来看控制单元。

ip寄存器就是指令指针寄存器,指向代码段中下一条指令的位置。cpu会根据它来不断地将指令从内存的代码段中,加载到cpu的指令队列中,然后交给运算单元去执行。

如果需要切换进程,每个进程都分代码段和数据段,为了指向不同进程的地址空间,有四个16位的段寄存器,分别是CS,DS,SS,ES

其中,cs就是代码段寄存器,通过它可以找到代码在内存中的位置;DS是数据段的寄存器,通过它可以找到数据在内存中的位置。

ss是栈寄存器。栈是程序运行中一个特殊的数据结构,数据的存取只能从一端进行,秉承后进先出的原则,push就是入栈,pop就是出栈。

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

如果运算中需要加载内存中的数据,需要通过DS找到内存中的数据,加载到调用寄存器中,如何加载?对于一个段,有一个起始的地址,而段内的具体位置,我们称为偏移量。例如8号会议室的第三排,8号会议室就是起始地址,第三排就是偏移量。

在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+X的地方,这个位置已经超出20位了,由于地址总线只有20位,在总线上超过20位的部分根本是发不出去的,所以发出去的还是X,最后还是会反问1M内的X的位置。

一个段最大的大小是2的16次方等于64K,因为偏移量只能是16位的。对于8086cpu,最多只能访问1M的内存空间,还要分成多个段,每个段最多64K。在现在看来是很小,但是在当时已经够用了。

32位处理器

后来计算机的发展日新月异,内存越来越大,总线也越来越宽。在32位处理器中,有32根地址总线,可以访问2的32次方等于4G的内存。使用原来的模式肯定不行了,但是又不能完全抛弃原来的模式,因为这个架构是开放的。

"开放",意味着有大量的其他公司的软硬件是基于这个架构来实现的,不能为所欲为,想怎么改怎么改,一定要和原来的架构兼容,而且要一直兼容,这样大家才愿意跟着你这个开放平台一直玩下去。如果你朝令夕改,那其他厂商就惨了。

如果是不开放的架构,那就没有问题。硬件、操作系统,甚至上面的软件都是自己搞的,你想怎么改就可以怎么改。

在开放的架构基础上,如何保持兼容?首先,通用寄存器有拓展,可以将8个16位的扩展到8个32位的,但是依然可以保留16位的和8位的使用方式。 其中,指向下一条指令的指令指针寄存器IP,就会拓展成32位的,同样也兼容16位的。

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

如果新的段寄存器都改成 32 位的,明明 4G 的内存全部都能访问到,还左移不左移四位呢?

那索性就重新定义一把吧。CS、SS、DS、ES 仍然是 16 位的,但是不再是段的起始地址。段的起始地址放在内存的某个地方。这个地方是一个表格,表格中的一项一项是段描述符。这里面才是真正的段的起始地址。而段寄存器里面保存的是在这个表格中的哪一项,称为选择子。这样,将一个从段寄存器直接拿到的段起始地址,就变成了先间接地从段寄存器找到表格中的一项,再从表格中的一项中拿到段起始地址。

这样段起始地址就会很灵活了。当然为了快速拿到段起始地址,段寄存器会从内存中拿到 CPU 的描述符高速缓存器中。

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

文中内容参考链接:www.cs.virginia.edu/~evans/cs21…

BIOS

当按下计算机的电源按钮时,主板就加上电了,这个时候cpu应该开始执行指令了,但是这个时候没有操作系统,内存也是空的,那么cpu该怎么运行? 其实计算机系统早有计划了。在主板上,有个东西叫ROM(只读存储器)。注意了,这和我们平时说的内存RAM(随机存取存储器)不同。

我们平时买的内存条是可读可写的,这样才能保存计算结果。而ROM是只读的,上面早就固话了一些初始化的程序,也就是BIOS(基本输入输出系统)

在我们平时装操作系统的时候,刚启动的时候,按某些键,显示器会弹出一个蓝色的界面。能够调整启动顺序的系统,这个就是我们所说的BIOS。如图所示:

在执行BIOS里面的初始化程序后,现在就有了1M的内存地址空间了。

(图片侵权请联系删除)

在x86系统中,将1M空间最上面的0xF0000到0xFFFFF这64K映射给ROM,也就是说,到这部分地址访问的时候,会访问ROM

当电脑刚加电的时候,会做一些重置的工作,将cs(代码段寄存器)设置为0xFFFF,将IP(指令指针寄存器)设置为0x0000,所以第一条指令就会指向0xFFFF0,正是在ROM的范围内。在这里,有一个JMP命令会跳到ROM中做初始化工作的代码,于是,BIOS开始进行初始化的工作。

那么接下来,BIOS要检查一下系统的硬件是不是都好着,这个时候,还要建立一个中断向量表和中断服务程序,因为现在用户还要用键盘和鼠标,这些都是要通过中断进行的。这个时期也要给用户输出一些结果,做了什么工作,做到了什么程度,都要主动显示给客户,也就是在内存空间映射显存空间,在显示器上显示一些字符。

Grub2

在BIOS做完自己的事情之后,就该去加载操作系统了。

操作系统一般都会安装在硬盘上。在BIOS的界面上,会看到一个启动盘的选项。启动盘一般在第一个扇区,占512字节,而且0xAA55结束。这是一个约定,当满足这个条件的时候,就说明这是一个启动盘,在512字节以内启动相关的代码。

这些代码是Grub2放在这里的。在linux里面有一个工具,叫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
}

这里面的选项会在系统启动的时候,成为一个列表,让用户选择从哪个系统启动。最终显示出来的结果就是下面的这张图。

注意:使用grub2-install /dev/sda,可以将启动程序安装到相应的位置。

grub2第一个要安装的就是boot.img 它由boot.S编译而成,一共512字节,正式安装到启动盘的第一个扇区。这个扇区通常称为MBR(主引导记录/扇区)

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,切换到保护模式,这样就能在更大的寻址空间里面,加载更多的东西。

从实模式切换到保护模式

那么core.img就进入保护模式。切换到保护模式要干很多工作,大部分的工作都与内存的访问方式相关,

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

保护模式此时还需要做一项工作,那就是打开 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) 才开始真正地启动内核。

Gate A20的补充:

8086 地址线20根 -> 可用内存 0 ~ FFFFF

寄存器却是16位,寻址模式为 segment(16位):offset(16位), 最大范围变成 0FFFF0(左移了4位) + 0FFFF = 10FFEF 后果是多出来了 100000 ~ 10FFEF (访问这些地址时会回绕到 0 ~ FFEF)

  • 80286 开始地址线变多,寻址范围大大增大,但是又必须兼容旧程序,8086在访问 100000 ~ 10FFEF时会回绕,但是 80286 不会 ,因为有第21根线的存在,会访问到实际的 100000 ~ 10FFEF 地址的内存。 于是 Gate A20 开关就诞生了,它的作用是:

  • 实模式下 (存在的唯一理由是为了兼容8086):

    • 打开 -> 寻址100000 ~ 10FFEF会真正访问
    • 关闭-> 回绕到 0 ~ FFEF
  • 保护模式下:

    • 打开 -> 可连续访问内存
    • 关闭 -> 只能访问到奇数的1M段,即 00000-FFFFF, 200000-2FFFFF,300000-3FFFFF…

内核初始化

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

在操作系统里面,先要有个创始进程,有一行指令set_task_stack_end_magic(&init_task)。这里面有一个参数 init_task,它的定义是 struct task_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),这是系统调用的中断门。系统调用也是通过发送中断的方式进行的。

接下来要执行的是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) ,虚拟文件系统。

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

初始化1号进程

rest_init的第一个工作是,用kernel_threa(kernel_init,NULL,CLONE_FS)创建第二个进程,这个是1号进程。

1号进程对于操作来讲,具有的意义,是因为它将运行一个用户进程,就像是一个师傅带了个大徒弟,有了第一个就有第二个,后面大徒弟开枝散叶,带了很多徒弟,形成一颗进程树。

一旦有了用户进程,资源就要做划分。好在x86提供了分层的权限机制,把区域分成了四个Ring,越往里权限越高,越往外权限越低。

操作系统很好地利用了这个机制,将能够访问关键资源的代码放在Ring0,称为内核态(kernel mode) ;将普通的程序代码放在Ring3,我们称为用户态(user mode)

至此,系统已经处于保护模式了,保护模式除了可访问空间大一些,还有保护的功能,也就是说,当处于用户态的代码想要执行更高权限的指令,这种行为是被禁止的,要防止他们为所欲为。

如果用户态的代码想要访问核心资源,那么就得调用系统调用这个统一的入口,用户态代码在这里请求即可。用户态代码不用管内核态做了什么,做完了返回结果就可以了。

当一个用户态的程序运行到一半,要访问一个核心资源,例如访问网卡发一个网络包,就需要暂停当前的运行,调用系统调用,接下来就轮到内核中的代码运行了。

首先,内核将从系统调用传过来的包,在网卡上排队,轮到的时候就发送。发送完了,系统调用就结束,返回用户态,让暂停运行的程序接着运行。

这个暂停的过程其实就是把程序运行到一半的情况保存下来。例如,内存我们知道是用来保存程序运行时候的中间结果的,现在要暂停下来,这些中间结果不能丢,因为再次运行的时候,还要基于这些中间结果接着来。另外就是,当前运行到代码的哪一行了,当前的栈在哪里,这些都是在寄存器里面的。

所以暂停的那一刻,要把当时cpu的寄存器的值全部暂存到一个地方,当系统调用完毕,返回的时候,再从这个地方将寄存器的值回复回去,就能接着运行了。

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

创建2号进程

rest_init第二大事情就是创建第三个进程,就是2号进程。

kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES) 又一次使用 kernel_thread 函数创建进程。这里需要指出一点,函数名 thread 可以翻译成“线程”。

从内核态来看,无论是进程还是线程,都可以统称为任务(Task),都使用相同的数据结构,平放在同一个链表中。 这里的kthreadd,负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。

glibc

什么是系统调用?

系统调用是操作系统提供给程序设计人员使用系统服务的接口。 在linux系统里面几乎每一个模块都涉及系统调用。linux提供了glibc这个 库, 它封装了系统调用接口, 对上层更友好的提供服务。

那么接下来以解析从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位系统的定义是不一样的。

32位系统调用过程

/* 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

这里面的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里面取出来,然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。

根据宏定义,#define ia32_sys_call_table sys_call_table,系统调用就是放在这个表里面。

当系统调用结束之后,在entry_INT80_32之后,紧接着调用的是INTERRUPT_RETURN,找到它的定义,也就是 iret。

#define INTERRUPT_RETURN iret

iret指令将原来用户态保存的现场恢复回来,包含代码段,指令指针寄存器等。这时候用户态进程恢复执行。

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指令还使用 了一种特殊的寄存器,称为特殊模块寄存器,简称MSR。这种寄存器是cpu为了完成某些特殊控制功能为目的的寄存器,其中就有系统调用。

在系统初始化的时候,trap_init除了初始化前面中断模式,这里面还会调用cpu_init->syscall_init。这里面有这样的代码:

wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64);

ramsr和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_pat->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里面拿出系统调用号,然后根据系统调用号,在系统调用表sys_call_table中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。

所以,无论是32位,还是64位,都会到系统调用表sys_call_table这里来。

那么在64位的系统调用返回的时候,执行的是USERGS_SYSRET64。定义如下:

#define USERGS_SYSRET64        \
  swapgs;          \
  sysretq;


这里,返回用户态的指令变成了sysretq

系统调用表

系统调用表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里面,但是你会发现样子很奇怪。

.YSYSCALL_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 是一个宏系统调用最多六个参数,根据参数的数目选择宏。具体是这样定义的:

#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__)

如果我们把宏展开之后,实现如下,和声明的是一样的。

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;

声明和实现都接好后,接下来,在编译的过程中,需要根据 syscall_32.tbl 和 syscall_64.tbl 生成自己的 unistd_32.h 和 unistd_64.h。生成方式在 arch/x86/entry/syscalls/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,定义了这样一个表,里面 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,定义了这样一个表,里面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>
};

宏是什么?

1,使用命令 #define 定义宏。该命令允许把一个名称指定成任何所需的文本,例如一个常量值或者一条语句。在定义了宏之后,无论宏名称出现在源代码的何处,预处理器都会把它用定义时指定的文本替换掉。

2,宏的名称一般使用全大写的形式。

3,宏可以定义参数,参数列表需要使用圆括号包裹,且必须紧跟名称,中间不能有空格。

4,使用#undef NAME取消宏的定义,从而可以重新定义或使用与宏重名的函数或变量。

5,出现在字符串中的宏名称不会被预编译器展开。