mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2025-11-17 14:43:42 +08:00
mod
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
<audio id="audio" title="06 | x86架构:有了开放的架构,才能打造开放的营商环境" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/85/3d/85835deac48171c277b1ef9e9026923d.mp3"></audio>
|
||||
|
||||
做生意的人最喜欢开放的营商环境,也就是说,我的这家公司,只要符合国家的法律,到哪里做生意,都能受到公平的对待,这样就不用为了适配各个地方的规则煞费苦心,只要集中精力优化自己的服务就可以了。
|
||||
|
||||
作为Linux操作系统,何尝不是这样。如果下面的硬件环境千差万别,就会很难集中精力做出让用户易用的产品。毕竟天天适配不同的平台,就已经够头大了。x86架构就是这样一个开放的平台。今天我们就来解析一下它。
|
||||
|
||||
## 计算机的工作模式是什么样的?
|
||||
|
||||
还记得咱们攒电脑时买的那堆硬件吗?虽然你可以根据经验,把那些复杂的设备和连接线安装起来,但是你真的了解它们为什么要这么连接吗?
|
||||
|
||||
现在我就把硬件图和计算机的逻辑图对应起来,带你看看计算机的工作模式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fa/9b/fa6c2b6166d02ac37637d7da4e4b579b.jpeg" alt="">
|
||||
|
||||
对于一个计算机来讲,最核心的就是**CPU**(Central Processing Unit,中央处理器)。这是这台计算机的大脑,所有的设备都围绕它展开。
|
||||
|
||||
对于公司来说,CPU是真正干活的,将来执行项目都要靠它。
|
||||
|
||||
CPU就相当于咱们公司的程序员,我们常说,二十一世最缺的是什么?是人才!所以,大量水平高、干活快的程序员,才是营商环境中最重要的部分。
|
||||
|
||||
CPU和其他设备连接,要靠一种叫作**总线**(Bus)的东西,其实就是主板上密密麻麻的集成电路,这些东西组成了CPU和其他设备的高速通道。
|
||||
|
||||
在这些设备中,最重要的是**内存**(Memory)。因为单靠CPU是没办法完成计算任务的,很多复杂的计算任务都需要将中间结果保存下来,然后基于中间结果进行进一步的计算。CPU本身没办法保存这么多中间结果,这就要依赖内存了。
|
||||
|
||||
内存就相当于办公室,我们要看看方不方便租到办公室,有没有什么创新科技园之类的。有了共享的、便宜的办公位,公司就有注册地了。
|
||||
|
||||
当然总线上还有一些其他设备,例如显卡会连接显示器、磁盘控制器会连接硬盘、USB控制器会连接键盘和鼠标等等。
|
||||
|
||||
CPU和内存是完成计算任务的核心组件,所以这里我们重点介绍一下**CPU和内存是如何配合工作的**。
|
||||
|
||||
CPU其实也不是单纯的一块,它包括三个部分,运算单元、数据单元和控制单元。
|
||||
|
||||
**运算单元**只管算,例如做加法、做位移等等。但是,它不知道应该算哪些数据,运算结果应该放在哪里。
|
||||
|
||||
运算单元计算的数据如果每次都要经过总线,到内存里面现拿,这样就太慢了,所以就有了**数据单元**。数据单元包括CPU内部的缓存和寄存器组,空间很小,但是速度飞快,可以暂时存放数据和运算结果。
|
||||
|
||||
有了放数据的地方,也有了算的地方,还需要有个指挥到底做什么运算的地方,这就是**控制单元**。控制单元是一个统一的指挥中心,它可以获得下一条指令,然后执行这条指令。这个指令会指导运算单元取出数据单元中的某几个数据,计算出个结果,然后放在数据单元的某个地方。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3a/23/3afda18fc38e7e53604e9ebf9cb42023.jpeg" alt="">
|
||||
|
||||
每个项目都有一个项目执行计划书,里面是一行行项目执行的指令,这些都是放在档案库里面的。每个进程都有一个程序放在硬盘上,是二进制的,再里面就是一行行的指令,会操作一些数据。
|
||||
|
||||
进程一旦运行,比如图中两个进程A和B,会有独立的内存空间,互相隔离,程序会分别加载到进程A和进程B的内存空间里面,形成各自的代码段。当然真实情况肯定比我说的要复杂的多,进程的内存虽然隔离但不连续,除了简单的区分代码段和数据段,还会分得更细。
|
||||
|
||||
程序运行的过程中要操作的数据和产生的计算结果,都会放在数据段里面。**那CPU怎么执行这些程序,操作这些数据,产生一些结果,<strong><strong>并**</strong>写入回内存呢?</strong>
|
||||
|
||||
CPU的控制单元里面,有一个**指令指针寄存器**,它里面存放的是下一条指令在内存中的地址。控制单元会不停地将代码段的指令拿进来,先放入指令寄存器。
|
||||
|
||||
当前的指令分两部分,一部分是做什么操作,例如是加法还是位移;一部分是操作哪些数据。
|
||||
|
||||
要执行这条指令,就要把第一部分交给运算单元,第二部分交给数据单元。
|
||||
|
||||
数据单元根据数据的地址,从数据段里读到数据寄存器里,就可以参与运算了。运算单元做完运算,产生的结果会暂存在数据单元的数据寄存器里。最终,会有指令将数据写回内存中的数据段。
|
||||
|
||||
你可能会问,上面算来算去执行的都是进程A里的指令,那进程B呢?CPU里有两个寄存器,专门保存当前处理进程的代码段的起始地址,以及数据段的起始地址。这里面写的都是进程A,那当前执行的就是进程A的指令,等切换成进程B,就会执行B的指令了,这个过程叫作**进程切换**(Process Switch)。这是一个多任务系统的必备操作,我们后面有专门的章节讲这个内容,这里你先有个印象。
|
||||
|
||||
到这里,你会发现,CPU和内存来来回回传数据,靠的都是总线。其实总线上主要有两类数据,一个是地址数据,也就是我想拿内存中哪个位置的数据,这类总线叫**地址总线**(Address Bus);另一类是真正的数据,这类总线叫**数据总线**(Data Bus)。
|
||||
|
||||
所以说,总线其实有点像连接CPU和内存这两个设备的高速公路,说总线到底是多少位,就类似说高速公路有几个车道。但是这两种总线的位数意义是不同的。
|
||||
|
||||
地址总线的位数,决定了能访问的地址范围到底有多广。例如只有两位,那CPU就只能认00,01,10,11四个位置,超过四个位置,就区分不出来了。位数越多,能够访问的位置就越多,能管理的内存的范围也就越广。
|
||||
|
||||
而数据总线的位数,决定了一次能拿多少个数据进来。例如只有两位,那CPU一次只能从内存拿两位数。要想拿八位,就要拿四次。位数越多,一次拿的数据就越多,访问速度也就越快。
|
||||
|
||||
## x86成为开放平台历史中的重要一笔
|
||||
|
||||
那CPU中总线的位数有没有个标准呢?如果没有标准,那操作系统作为软件就很难办了,因为软件层没办法实现通用的运算逻辑。这就像很多非标准的元器件一样,你烧你的电路板,我烧我的电路板,谁都不能用彼此的。
|
||||
|
||||
早期的IBM凭借大型机技术成为计算机市场的领头羊,直到后来个人计算机兴起,苹果公司诞生。但是,那个时候,无论是大型机还是个人计算机,每家的CPU架构都不一样。如果一直是这样,个人电脑、平板电脑、手机等等,都没办法形成统一的体系,就不会有我们现在通用的计算机了,更别提什么云计算、大数据这些统一的大平台了。
|
||||
|
||||
好在历史将x86平台推到了**开放、统一、兼容**的位置。我们继续来看IBM和x86的故事。
|
||||
|
||||
IBM开始做IBM PC时,一开始并没有让最牛的华生实验室去研发,而是交给另一个团队。一年时间,软硬件全部自研根本不可能完成,于是他们采用了英特尔的8088芯片作为CPU,使用微软的MS-DOS做操作系统。
|
||||
|
||||
谁能想到IBM PC卖得超级好,好到因为垄断市场而被起诉。IBM就在被逼的情况下公开了一些技术,使得后来无数IBM-PC兼容机公司的出现,也就有了后来占据市场的惠普、康柏、戴尔等等。
|
||||
|
||||
能够开放自己的技术是一件了不起的事。从技术和发展的层面来讲,它会使得一项技术大面积铺开,形成行业标准。就比如现在常用的Android手机,如果没有开放的Android系统,我们也没办法享受到这么多不同类型的手机。
|
||||
|
||||
对于当年的PC机来说,其实也是这样。英特尔的技术因此成为了行业的开放事实标准。由于这个系列开端于8086,因此称为x86架构。
|
||||
|
||||
后来英特尔的CPU数据总线和地址总线越来越宽,处理能力越来越强。但是一直不能忘记三点,一是标准,二是开放,三是兼容。因为要想如此大的一个软硬件生态都基于这个架构,符合它的标准,如果是封闭或者不兼容的,那谁都不答应。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/8a/548dfd163066d061d1e882c73e7c2b8a.jpg" alt="">
|
||||
|
||||
## 从8086的原理说起
|
||||
|
||||
说完了x86的历史,我们再来看x86中最经典的一款处理器,8086处理器。虽然它已经很老了,但是咱们现在操作系统中的很多特性都和它有关,并且一直保持兼容。
|
||||
|
||||
我们把CPU里面的组件放大之后来看。你可以看我画的这幅图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/1c/2dc8237e996e699a0361a6b5ffd4871c.jpeg" alt="">
|
||||
|
||||
我们先来看数据单元。
|
||||
|
||||
为了暂存数据,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寄存器就是指令指针寄存器(Instruction Pointer Register),指向代码段中下一条指令的位置。CPU会根据它来不断地将指令从内存的代码段中,加载到CPU的指令队列中,然后交给运算单元去执行。
|
||||
|
||||
如果需要切换进程呢?每个进程都分代码段和数据段,为了指向不同进程的地址空间,有四个16位的段寄存器,分别是CS、DS、SS、ES。
|
||||
|
||||
其中,CS就是代码段寄存器(Code Segment Register),通过它可以找到代码在内存中的位置;DS是数据段的寄存器,通过它可以找到数据在内存中的位置。
|
||||
|
||||
SS是栈寄存器(Stack Register)。栈是程序运行中一个特殊的数据结构,数据的存取只能从一端进行,秉承后进先出的原则,push就是入栈,pop就是出栈。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/47/08ea4adb633f114d788d5c6a9dae0f47.jpeg" alt="">
|
||||
|
||||
凡是与函数调用相关的操作,都与栈紧密相关。例如,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)。例如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,超过这个空间就访问不到了。这又是为啥呢?如果你想访问1M+X的地方,这个位置已经超过20位了,由于地址总线只有20位,在总线上超过20位的部分根本是发不出去的,所以发出去的还是X,最后还是会访问1M内的X的位置。
|
||||
|
||||
那一个段最大能有多大呢?因为偏移量只能是16位的,所以一个段最大的大小是2^16=64k。
|
||||
|
||||
是不是好可怜?对于8086CPU,最多只能访问1M的内存空间,还要分成多个段,每个段最多64K。尽管我们现在看来这不可想象的小,根本没法儿用,但是在当时其实够用了。
|
||||
|
||||
## 再来说32位处理器
|
||||
|
||||
当然,后来计算机的发展日新月异,内存越来越大,总线也越来越宽。在32位处理器中,有32根地址总线,可以访问2^32=4G的内存。使用原来的模式肯定不行了,但是又不能完全抛弃原来的模式,因为这个架构是开放的。
|
||||
|
||||
“开放”,意味着有大量其他公司的软硬件是基于这个架构来实现的,不能为所欲为,想怎么改怎么改,一定要和原来的架构兼容,而且要一直兼容,这样大家才愿意跟着你这个开放平台一直玩下去。如果你朝令夕改,那其他厂商就惨了。
|
||||
|
||||
如果是不开放的架构,那就没有问题。硬件、操作系统,甚至上面的软件都是自己搞的,你想怎么改就可以怎么改。
|
||||
|
||||
我们下面来说说,在开放架构的基础上,如何保持兼容呢?
|
||||
|
||||
首先,通用寄存器有扩展,可以将8个16位的扩展到8个32位的,但是依然可以保留16位的和8位的使用方式。你可能会问,为什么高16位不分成两个8位使用呢?因为这样就不兼容了呀!
|
||||
|
||||
其中,指向下一条指令的指令指针寄存器IP,就会扩展成32位的,同样也兼容16位的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/84/e3f4f64e6dfe5591b7d8ef346e8e8884.jpeg" alt="">
|
||||
|
||||
而改动比较大,有点不兼容的就是**段寄存器**(Segment Register)。
|
||||
|
||||
因为原来的模式其实有点不伦不类,因为它没有把16位当成一个段的起始地址,也没有按8位或者16位扩展的形式,而是根据当时的硬件,弄了一个不上不下的20位的地址。这样每次都要左移四位,也就意味着段的起始地址不能是任何一个地方,只是能整除16的地方。
|
||||
|
||||
如果新的段寄存器都改成32位的,明明4G的内存全部都能访问到,还左移不左移四位呢?
|
||||
|
||||
那我们索性就重新定义一把吧。CS、SS、DS、ES仍然是16位的,但是不再是段的起始地址。段的起始地址放在内存的某个地方。这个地方是一个表格,表格中的一项一项是**段描述符**(Segment Descriptor)。这里面才是真正的段的起始地址。而段寄存器里面保存的是在这个表格中的哪一项,称为**选择子**(Selector)。
|
||||
|
||||
这样,将一个从段寄存器直接拿到的段起始地址,就变成了先间接地从段寄存器找到表格中的一项,再从表格中的一项中拿到段起始地址。
|
||||
|
||||
这样段起始地址就会很灵活了。当然为了快速拿到段起始地址,段寄存器会从内存中拿到CPU的描述符高速缓存器中。
|
||||
|
||||
这样就不兼容了,咋办呢?好在后面这种模式灵活度非常高,可以保持将来一直兼容下去。前面的模式出现的时候,没想到自己能够成为一个标准,所以设计就没这么灵活。
|
||||
|
||||
因而到了32位的系统架构下,我们将前一种模式称为**实模式**(Real Pattern),后一种模式称为**保护模式**(Protected Pattern)。
|
||||
|
||||
当系统刚刚启动的时候,CPU是处于实模式的,这个时候和原来的模式是兼容的。也就是说,哪怕你买了32位的CPU,也支持在原来的模式下运行,只不过快了一点而已。
|
||||
|
||||
当需要更多内存的时候,你可以遵循一定的规则,进行一系列的操作,然后切换到保护模式,就能够用到32位CPU更强大的能力。
|
||||
|
||||
这也就是说,不能无缝兼容,但是通过切换模式兼容,也是可以接受的。
|
||||
|
||||
在接下来的几节,我们就来看一下,CPU如何从启动开始,逐渐从实模式变为保护模式的。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们讲了x86架构。在以后的操作系统讲解中,我们也是主要基于x86架构进行讲解,只有了解了底层硬件的基本工作原理,将来才能理解操作系统的工作模式。
|
||||
|
||||
x86架构总体来说还是很复杂的,其中和操作系统交互比较密切的部分,我画了个图。在这个图中,建议你重点牢记这些寄存器的作用,以及段的工作模式,后面我们马上就能够用到了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/76/e2e92f2239fe9b4c024d300046536d76.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
操作这些底层的寄存器往往需要使用汇编语言,操作系统的一些底层的模块也是用汇编语言写的,因而你需要简单回顾一些汇编语言中的一些简单的命令的作用。所以,今天给你留个练习题,简单了解一下这些命令。
|
||||
|
||||
mov, call, jmp, int, ret, add, or, xor, shl, shr, push, pop, inc, dec, sub, cmp。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
@@ -0,0 +1,152 @@
|
||||
<audio id="audio" title="07 | 从BIOS到bootloader:创业伊始,有活儿老板自己上" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/75/1c/759af1740f3fa587eab1cf0211182c1c.mp3"></audio>
|
||||
|
||||
有了开放的营商环境,咱们外包公司的创业之旅就要开始了。
|
||||
|
||||
上一节我们说,x86作为一个开放的营商环境,有两种模式,一种模式是实模式,只能寻址1M,每个段最多64K。这个太小了,相当于咱们创业的个体户模式。有了项目只能老板自己上,本小利微,万事开头难。另一种是保护模式,对于32位系统,能够寻址4G。这就是大买卖了,老板要雇佣很多人接项目。
|
||||
|
||||
几乎所有成功的公司,都是从个体户模式发展壮大的,因此,这一节咱们就从系统刚刚启动的个体户模式开始说起。
|
||||
|
||||
## BIOS时期
|
||||
|
||||
当你轻轻按下计算机的启动按钮时,你的主板就加上电了。
|
||||
|
||||
按照我们之前说的,这时候你的CPU应该开始执行指令了。你作为老板,同时也作为员工,要开始干活了。可是你发现,这个时候还没有项目执行计划书,所以你没啥可干的。
|
||||
|
||||
也就是说,这个时候没有操作系统,内存也是空的,一穷二白。CPU该怎么办呢?
|
||||
|
||||
你作为这个创业公司的老板,由于原来没开过公司,对于公司的运营当然是一脸懵的。但是我们有一个良好的营商环境,其中的创业指导中心早就考虑到这种情况了。于是,创业指导中心就给了你一套创业公司启动指导手册。你只要按着指导手册来干就行了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/6a/a4009d3de2dbae10340256af2737c26a.jpeg" alt="">
|
||||
|
||||
计算机系统也早有计划。在主板上,有一个东西叫**ROM**(Read Only Memory,只读存储器)。这和咱们平常说的内存**RAM**(Random Access Memory,随机存取存储器)不同。
|
||||
|
||||
咱们平时买的内存条是可读可写的,这样才能保存计算结果。而ROM是只读的,上面早就固化了一些初始化的程序,也就是**BIOS**(Basic Input and Output System,基本输入输出系统)。
|
||||
|
||||
如果你自己安装过操作系统,刚启动的时候,按某个组合键,显示器会弹出一个蓝色的界面。能够调整启动顺序的系统,就是我说的BIOS,然后我们就可以先执行它。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/13/b7/13187b1ffe878bc406da53967e8cddb7.png" alt="">
|
||||
|
||||
创业初期,你的办公室肯定很小。假如现在你有1M的内存地址空间。这个空间非常有限,你需要好好利用才行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5f/fc/5f364ef5c9d1a3b1d9bb7153bd166bfc.jpeg" alt="">
|
||||
|
||||
在x86系统中,将1M空间最上面的0xF0000到0xFFFFF这64K映射给ROM,也就是说,到这部分地址访问的时候,会访问ROM。
|
||||
|
||||
当电脑刚加电的时候,会做一些重置的工作,将CS设置为0xFFFF,将IP设置为0x0000,所以第一条指令就会指向0xFFFF0,正是在ROM的范围内。在这里,有一个JMP命令会跳到ROM中做初始化工作的代码,于是,BIOS开始进行初始化的工作。
|
||||
|
||||
创业指导手册第一条,BIOS要检查一下系统的硬件是不是都好着呢。
|
||||
|
||||
创业指导手册第二条,要有个办事大厅,只不过自己就是办事员。这个时期你能提供的服务很简单,但也会有零星的客户来提要求。
|
||||
|
||||
这个时候,要建立一个中断向量表和中断服务程序,因为现在你还要用键盘和鼠标,这些都要通过中断进行的。
|
||||
|
||||
这个时期也要给客户输出一些结果,因为需要你自己来,所以你还要充当客户对接人。你做了什么工作,做到了什么程度,都要主动显示给客户,也就是在内存空间映射显存的空间,在显示器上显示一些字符。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/63/2900bed28c7345e6c90437da8a5cd563.jpeg" alt="">
|
||||
|
||||
最后,政府领进门,创业靠个人。接下来就是你发挥聪明才智的时候了。
|
||||
|
||||
## bootloader时期
|
||||
|
||||
政府给的创业指导手册只能保证你把公司成立起来,但是公司如何做大做强,需要你自己有一套经营方法。你可以试着从档案库里面翻翻,看哪里能够找到《企业经营宝典》。通过这个宝典,可以帮你建立一套完整的档案库管理体系,使得任何项目的档案查询都十分方便。
|
||||
|
||||
现在,什么线索都没有的BIOS,做完自己的事情,只能从档案库门卫开始,慢慢打听操作系统的下落。
|
||||
|
||||
操作系统在哪儿呢?一般都会在安装在硬盘上,在BIOS的界面上。你会看到一个启动盘的选项。启动盘有什么特点呢?它一般在第一个扇区,占512字节,而且以0xAA55结束。这是一个约定,当满足这个条件的时候,就说明这是一个启动盘,在512字节以内会启动相关的代码。
|
||||
|
||||
这些代码是谁放在这里的呢?在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
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这里面的选项会在系统启动的时候,成为一个列表,让你选择从哪个系统启动。最终显示出来的结果就是下面这张图。至于上面选项的具体意思,我们后面再说。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/88/97/883f3f5d4227a593228e1bcb93f67297.png" alt="">
|
||||
|
||||
使用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和一系列的模块组成,功能比较丰富,能做很多事情。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/6a/2b8573bbbf31fc0cb0420e32d07b196a.jpeg" alt="">
|
||||
|
||||
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,切换到保护模式,这样就能在更大的寻址空间里面,加载更多的东西。
|
||||
|
||||
## 从实模式切换到保护模式
|
||||
|
||||
好了,管理处听说你要找宝典,知道你将来是要做老板的人。既然是老板,早晚都要雇人干活的。这不是个体户小打小闹,所以,你需要切换到老板角色,进入保护模式了,把哪些是你的权限,哪些是你可以授权给别人的,都分得清清楚楚。
|
||||
|
||||
切换到保护模式要干很多工作,大部分工作都与内存的访问方式有关。
|
||||
|
||||
第一项是**启用分段**,就是在内存里面建立段描述符表,将寄存器里面的段寄存器变成段选择子,指向某个段描述符,这样就能实现不同进程的切换了。第二项是**启动分页**。能够管理的内存变大了,就需要将内存分成相等大小的块,这些我们放到内存那一节详细再讲。
|
||||
|
||||
切换到了老板角色,也是为了招聘很多人,同时接多个项目,这时候就需要划清界限,懂得集权与授权。
|
||||
|
||||
当了老板,眼界要宽多了,同理保护模式需要做一项工作,那就是打开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)才开始真正地启动内核。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
启动的过程比较复杂,我这里画一个图,让你比较形象地理解这个过程。你可以根据我讲的,自己来梳理一遍这个过程,做到不管是从流程还是细节上,都能心中有数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0a/6b/0a29c1d3e1a53b2523d2dcab3a59886b.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
grub2是一个非常牛的Linux启动管理器,请你研究一下grub2的命令和配置,并试试通过它启动Ubuntu和centOS两个操作系统。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
236
极客时间专栏/趣谈Linux操作系统/核心原理篇:第二部分 系统初始化/08 | 内核初始化:生意做大了就得成立公司.md
Normal file
236
极客时间专栏/趣谈Linux操作系统/核心原理篇:第二部分 系统初始化/08 | 内核初始化:生意做大了就得成立公司.md
Normal file
@@ -0,0 +1,236 @@
|
||||
<audio id="audio" title="08 | 内核初始化:生意做大了就得成立公司" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/01/4a/01ca95e908afd2725d6b8a79db48424a.mp3"></audio>
|
||||
|
||||
上一节,你获得了一本《企业经营宝典》,完成了一件大事,切换到了老板角色,从实模式切换到了保护模式。有了更强的寻址能力,接下来,我们就要按照宝典里面的指引,开始经营企业了。
|
||||
|
||||
内核的启动从入口函数start_kernel()开始。在init/main.c文件中,start_kernel相当于内核的main函数。打开这个函数,你会发现,里面是各种各样初始化函数XXXX_init。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/01/cdfc33db2fe1e07b6acf8faa3959cb01.jpeg" alt="">
|
||||
|
||||
## 初始化公司职能部门
|
||||
|
||||
于是,公司要开始建立各种职能部门了。
|
||||
|
||||
首先是**项目管理部门**。咱们将来肯定要接各种各样的项目,因此,项目管理体系和项目管理流程首先要建立起来。之前讲的创建项目都是复制老项目,现在咱们需要有第一个全新的项目。这个项目需要你这个老板来打个样。
|
||||
|
||||
在操作系统里面,先要有个创始进程,有一行指令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),这是系统调用的中断门。系统调用也是通过发送中断的方式进行的。当然,64位的有另外的系统调用方法,这一点我们放到后面的系统调用章节详细谈。
|
||||
|
||||
接下来要初始化的是咱们的**会议室管理系统**。对应的,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),虚拟文件系统。
|
||||
|
||||
这里的rootfs还有其他用处,下面我们会用到。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/f5/d85b24af560f288847ea9f3e8776adf5.jpeg" alt="">
|
||||
|
||||
最后,start_kernel()调用的是rest_init(),用来做其他方面的初始化,这里面做了好多的工作。
|
||||
|
||||
## 初始化1号进程
|
||||
|
||||
rest_init的第一大工作是,用kernel_thread(kernel_init, NULL, CLONE_FS)创建第二个进程,这个是**1号进程**。
|
||||
|
||||
1号进程对于操作系统来讲,有“划时代”的意义。因为它将运行一个用户进程,这意味着这个公司把一个老板独立完成的制度,变成了可以交付他人完成的制度。这个1号进程就相当于老板带了一个大徒弟,有了第一个,就有第二个,后面大徒弟开枝散叶,带了很多徒弟,形成一棵进程树。
|
||||
|
||||
一旦有了用户进程,公司的运行模式就要发生一定的变化。因为原来你是老板,没有雇佣其他人,所有东西都是你的,无论多么关键的资源,第一,不会有人给你抢,第二,不会有人恶意破坏、恶意使用。
|
||||
|
||||
但是现在有了其他人,你就要开始做一定的区分,哪些是核心资源,哪些是非核心资源;办公区也要分开,有普通的项目人员都能访问的项目工作区,还有职业核心人员能够访问的核心保密区。
|
||||
|
||||
好在x86提供了分层的权限机制,把区域分成了四个Ring,越往里权限越高,越往外权限越低。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/42/2b53b470673cde8f9d8e2573f7d07242.jpg" alt="">
|
||||
|
||||
操作系统很好地利用了这个机制,将能够访问关键资源的代码放在Ring0,我们称为**内核态**(Kernel Mode);将普通的程序代码放在Ring3,我们称为**用户态**(User Mode)。
|
||||
|
||||
你别忘了,现在咱们的系统已经处于保护模式了,保护模式除了可访问空间大一些,还有另一个重要功能,就是“保护”,也就是说,当处于用户态的代码想要执行更高权限的指令,这种行为是被禁止的,要防止他们为所欲为。
|
||||
|
||||
如果用户态的代码想要访问核心资源,怎么办呢?咱们不是有提供系统调用的办事大厅吗?这里是统一的入口,用户态代码在这里请求就是了。办事大厅后面就是内核态,用户态代码不用管后面发生了什么,做完了返回结果就可以了。
|
||||
|
||||
当一个用户态的程序运行到一半,要访问一个核心资源,例如访问网卡发一个网络包,就需要暂停当前的运行,调用系统调用,接下来就轮到内核中的代码运行了。
|
||||
|
||||
首先,内核将从系统调用传过来的包,在网卡上排队,轮到的时候就发送。发送完了,系统调用就结束了,返回用户态,让暂停运行的程序接着运行。
|
||||
|
||||
这个暂停怎么实现呢?其实就是把程序运行到一半的情况保存下来。例如,我们知道,内存是用来保存程序运行时候的中间结果的,现在要暂时停下来,这些中间结果不能丢,因为再次运行的时候,还要基于这些中间结果接着来。另外就是,当前运行到代码的哪一行了,当前的栈在哪里,这些都是在寄存器里面的。
|
||||
|
||||
所以,暂停的那一刻,要把当时CPU的寄存器的值全部暂存到一个地方,这个地方可以放在进程管理系统很容易获取的地方。在后面讨论进程管理数据结构的时候,我们还会详细讲。当系统调用完毕,返回的时候,再从这个地方将寄存器的值恢复回去,就能接着运行了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/71/e6/71b04097edb2d47f01ab5585fd2ea4e6.jpeg" alt="">
|
||||
|
||||
这个过程就是这样的:用户态-系统调用-保存寄存器-内核态执行系统调用-恢复寄存器-返回用户态,然后接着运行。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/14/d2fce8af88dd278670395ce1ca6d4d14.jpg" alt="">
|
||||
|
||||
### 从内核态到用户态
|
||||
|
||||
我们再回到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会选择不同的文件启动,但是只要有一个起来了就可以。
|
||||
|
||||
```
|
||||
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也被恢复了,指向用户态函数栈的栈顶。所以,下一条指令,就从用户态开始运行了。
|
||||
|
||||
### ramdisk的作用
|
||||
|
||||
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。
|
||||
|
||||
接下来就是各种系统的初始化。启动系统的服务,启动控制台,用户就可以登录进来了。
|
||||
|
||||
先别忙着高兴,rest_init的第一个大事情才完成。我们仅仅形成了用户态所有进程的祖先。
|
||||
|
||||
## 创建2号进程
|
||||
|
||||
用户态的所有进程都有大师兄了,那内核态的进程有没有一个人统一管起来呢?有的,rest_init第二大事情就是第三个进程,就是2号进程。
|
||||
|
||||
kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES)又一次使用kernel_thread函数创建进程。这里需要指出一点,函数名thread可以翻译成“线程”,这也是操作系统很重要的一个概念。它和进程有什么区别呢?为什么这里创建的是进程,函数名却是线程呢?
|
||||
|
||||
从用户态来看,创建进程其实就是立项,也就是启动一个项目。这个项目包含很多资源,例如会议室、资料库等。这些东西都属于这个项目,但是这个项目需要人去执行。有多个人并行执行不同的部分,这就叫**多线程**(Multithreading)。如果只有一个人,那它就是这个项目的主线程。
|
||||
|
||||
但是从内核态来看,无论是进程,还是线程,我们都可以统称为任务(Task),都使用相同的数据结构,平放在同一个链表中。这些在进程的那一章节,我会更加详细地讲。
|
||||
|
||||
这里的函数kthreadd,负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先。
|
||||
|
||||
这下好了,用户态和内核态都有人管了,可以开始接项目了。
|
||||
|
||||
## 总结时刻
|
||||
|
||||
这一节,我们讲了内核的初始化过程,主要做了以下几件事情:
|
||||
|
||||
<li>
|
||||
各个职能部门的创建;
|
||||
</li>
|
||||
<li>
|
||||
用户态祖先进程的创建;
|
||||
</li>
|
||||
<li>
|
||||
内核态祖先进程的创建。
|
||||
</li>
|
||||
|
||||
咱们还是用一个图来总结一下这个过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/75/cd/758c283cf7633465d24ab3ef778328cd.jpeg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
这一节,我们看到内核创建了一些进程,这些进程都是放在一个列表中的,请你研读内核代码,看看这个列表是如何实现的。
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
|
||||
410
极客时间专栏/趣谈Linux操作系统/核心原理篇:第二部分 系统初始化/09 | 系统调用:公司成立好了就要开始接项目.md
Normal file
410
极客时间专栏/趣谈Linux操作系统/核心原理篇:第二部分 系统初始化/09 | 系统调用:公司成立好了就要开始接项目.md
Normal file
@@ -0,0 +1,410 @@
|
||||
<audio id="audio" title="09 | 系统调用:公司成立好了就要开始接项目" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/27/7c/27751b4781f548fd8fcf7553b6dd187c.mp3"></audio>
|
||||
|
||||
上一节,系统终于进入了用户态,公司由一个“皮包公司”进入正轨,可以开始接项目了。
|
||||
|
||||
这一节,我们来解析Linux接项目的办事大厅是如何实现的,这是因为后面介绍的每一个模块,都涉及系统调用。站在系统调用的角度,层层深入下去,就能从某个系统调用的场景出发,了解内核中各个模块的实现机制。
|
||||
|
||||
有的时候,我们的客户觉得,直接去办事大厅还是不够方便。没问题,Linux还提供了glibc这个中介。它更熟悉系统调用的细节,并且可以封装成更加友好的接口。你可以直接用。
|
||||
|
||||
## glibc对系统调用的封装
|
||||
|
||||
我们以最常用的系统调用open,打开一个文件为线索,看看系统调用是怎么实现的。这一节我们仅仅会解析到从glibc如何调用到内核的open,至于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位系统调用过程
|
||||
|
||||
我们先来看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位的系统调用是如何执行的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/06/566299fe7411161bae25b62e7fe20506.jpg" alt="">
|
||||
|
||||
## 64位系统调用过程
|
||||
|
||||
我们再来看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
|
||||
|
||||
```
|
||||
|
||||
和之前一样,还是将系统调用名称转换为系统调用号,放到寄存器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_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中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数。如果仔细比对,你就能发现,这些参数所对应的寄存器,和Linux的注释又是一样的。
|
||||
|
||||
所以,无论是32位,还是64位,都会到系统调用表sys_call_table这里来。
|
||||
|
||||
在研究系统调用表之前,我们看64位的系统调用返回的时候,执行的是USERGS_SYSRET64。定义如下:
|
||||
|
||||
```
|
||||
#define USERGS_SYSRET64 \
|
||||
swapgs; \
|
||||
sysretq;
|
||||
|
||||
```
|
||||
|
||||
这里,返回用户态的指令变成了sysretq。
|
||||
|
||||
我们这里总结一下64位的系统调用是如何执行的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1f/d7/1fc62ab8406c218de6e0b8c7e01fdbd7.jpg" alt="">
|
||||
|
||||
## 系统调用表
|
||||
|
||||
前面我们重点关注了系统调用的方式,都是最终到了系统调用表,但是到底调用内核的什么函数,还没有解读。
|
||||
|
||||
现在我们再来看,系统调用表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是一个宏系统调用最多六个参数,根据参数的数目选择宏。具体是这样定义的:
|
||||
|
||||
```
|
||||
#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>
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## 总结时刻
|
||||
|
||||
系统调用的过程还是挺复杂的吧?如果加上上一节的内核态和用户态的模式切换,就更复杂了。这里我们重点分析64位的系统调用,我将整个完整的过程画了一张图,帮你总结、梳理一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/86/a5/868db3f559ad08659ddc74db07a9a0a5.jpg" alt="">
|
||||
|
||||
## 课堂练习
|
||||
|
||||
请你根据这一节的分析,看一下与open这个系统调用相关的文件都有哪些,在每个文件里面都做了什么?如果你要自己实现一个系统调用,能不能照着open来一个呢?
|
||||
|
||||
欢迎留言和我分享你的疑惑和见解,也欢迎你收藏本节内容,反复研读。你也可以把今天的内容分享给你的朋友,和他一起学习、进步。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
|
||||
Reference in New Issue
Block a user