mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-11 04:04:34 +08:00
mod
This commit is contained in:
410
极客时间专栏/操作系统实战45讲/程序的基石:硬件/05 | CPU工作模式:执行程序的三种模式.md
Normal file
410
极客时间专栏/操作系统实战45讲/程序的基石:硬件/05 | CPU工作模式:执行程序的三种模式.md
Normal file
@@ -0,0 +1,410 @@
|
||||
<audio id="audio" title="05 | CPU工作模式:执行程序的三种模式" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a9/a9/a91a3070b28b2a8b5e73bf5d34c446a9.mp3"></audio>
|
||||
|
||||
你好,我是LMOS。
|
||||
|
||||
我们在前面已经设计了我们的OS架构,你也许正在考虑怎么写代码实现它。恕我直言,现在我们还有很多东西没搞清楚。
|
||||
|
||||
由于OS内核直接运行在硬件之上,所以我们要对运行我们代码的硬件平台有一定的了解。接下来,我会通过三节课,带你搞懂硬件平台的关键内容。
|
||||
|
||||
今天我们先来学习CPU的工作模式,硬件中最重要的就是CPU,它就是执行程序的核心部件。而我们常用的电脑就是x86平台,所以我们要对x86 CPU有一些基本的了解。
|
||||
|
||||
按照CPU功能升级迭代的顺序,CPU的工作模式有**实模式**、**保护模式**、**长模式**,这几种工作模式下CPU执行程序的方式截然不同,下面我们一起来探讨这几种工作模式。
|
||||
|
||||
## 从一段死循环的代码说起
|
||||
|
||||
请思考一下,如果下面这段应用程序代码能够成功运行,会有什么后果?
|
||||
|
||||
```
|
||||
int main()
|
||||
{
|
||||
int* addr = (int*)0;
|
||||
cli(); //关中断
|
||||
while(1)
|
||||
{
|
||||
*addr = 0;
|
||||
addr++;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
上述代码首先关掉了CPU中断,让CPU停止响应中断信号,然后进入死循环,最后从内存0地址开始写入0。你马上就会想到,这段代码只做了两件事:一是锁住了CPU,二是清空了内存,你也许会觉得如果这样的代码能正常运行,那简直太可怕了。
|
||||
|
||||
不过如果是在实模式下,这样的代码确实是能正常运行。因为在很久以前,计算机资源太少,内存太小,都是单道程序执行,程序大多是由专业人员编写调试好了,才能预约到一个时间去上机运行,没有现代操作系统的概念。
|
||||
|
||||
后来有DOS操作系统,也是单道程序系统,不具备执行多道程序的能力,所以CPU这种模式也能很好地工作。
|
||||
|
||||
下面我们就从最简单,也是最原始的实模式开始讲起。
|
||||
|
||||
## 实模式
|
||||
|
||||
实模式又称实地址模式,实,即真实,这个真实分为两个方面,一个方面是**运行真实的指令**,对指令的动作不作区分,直接**执行指令的真实功能**,另一方面是**发往内存的地址是真实的**,对任何地址**不加限制**地发往内存。
|
||||
|
||||
### 实模式寄存器
|
||||
|
||||
由于CPU是根据指令完成相应的功能,举个例子:ADD AX,CX;这条指令完成加法操作,AX、CX为ADD指令的操作数,可以理解为ADD函数的两个参数,其功能就是把AX、CX中的数据相加。
|
||||
|
||||
指令的操作数,可以是寄存器、内存地址、常数,其实通常情况下是寄存器,AX、CX就是x86 CPU中的寄存器。
|
||||
|
||||
下面我们就去看看x86 CPU在实模式下的寄存器。表中每个寄存器都是16位的。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f8/f8/f837811192730cc9c152afbcccf4eff8.jpeg" alt="" title="实模式下的寄存器">
|
||||
|
||||
### 实模式下访问内存
|
||||
|
||||
虽然有了寄存器,但是数据和指令都是存放在内存中的。通常情况下,需要把数据装载进寄存器中才能操作,还要有获取指令的动作,这些都要访问内存才行,而我们知道访问内存靠的是地址值。
|
||||
|
||||
那问题来了,这个值是如何计算的呢?计算过程如下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/14/13/14633ea933972e19f3439eb6aeab3d13.jpg" alt="" title="实模式下访问内存">
|
||||
|
||||
结合上图可以发现,所有的内存地址都是由段寄存器左移4位,再加上一个通用寄存器中的值或者常数形成地址,然后由这个地址去访问内存。这就是大名鼎鼎的分段内存管理模型。
|
||||
|
||||
只不过这里要特别注意的是,**代码段是由CS和IP确定的,而栈段是由SS和SP段确定的。**
|
||||
|
||||
下面我们写一个DOS下的Hello World应用程序,这是一个工作在实模式下的汇编代码程序,一共16位,具体代码如下:
|
||||
|
||||
```
|
||||
data SEGMENT ;定义一个数据段存放Hello World!
|
||||
hello DB 'Hello World!$' ;注意要以$结束
|
||||
data ENDS
|
||||
code SEGMENT ;定义一个代码段存放程序指令
|
||||
ASSUME CS:CODE,DS:DATA ;告诉汇编程序,DS指向数据段,CS指向代码段
|
||||
start:
|
||||
MOV AX,data ;将data段首地址赋值给AX
|
||||
MOV DS,AX ;将AX赋值给DS,使DS指向data段
|
||||
LEA DX,hello ;使DX指向hello首地址
|
||||
MOV AH,09h ;给AH设置参数09H,AH是AX高8位,AL是AX低8位,其它类似
|
||||
INT 21h ;执行DOS中断输出DS指向的DX指向的字符串hello
|
||||
MOV AX,4C00h ;给AH设置参数4C00h
|
||||
INT 21h ;调用4C00h号功能,结束程序
|
||||
code ENDS
|
||||
END start
|
||||
|
||||
```
|
||||
|
||||
上述代码中的结构模型,也是符合CPU实模式下分段内存管理模式的,它们被汇编器转换成二进制数据后,也是以段的形式存在的。
|
||||
|
||||
代码中的注释已经很明确了,你应该很容易就能理解,大多数是操作寄存器,其中LEA是取地址指令,MOV是数据传输指令,就是INT中断你可能还不太明白,下面我们就来研究它。
|
||||
|
||||
### 实模式中断
|
||||
|
||||
中断即中止执行当前程序,转而跳转到另一个特定的地址上,去运行特定的代码。在实模式下它的实现过程是先保存CS和IP寄存器,然后装载新的CS和IP寄存器,那么中断是如何产生的呢?
|
||||
|
||||
第一种情况是,中断控制器给CPU发送了一个电子信号,CPU会对这个信号作出应答。随随后中断控制器会将中断号发送给CPU,这是**硬件中断**。
|
||||
|
||||
第二种情况就是CPU执行了**INT指令**,这个指令后面会跟随一个常数,这个常数即是软中断号。这种情况是软件中断。
|
||||
|
||||
无论是硬件中断还是软件中断,都是CPU响应外部事件的一种方式。
|
||||
|
||||
为了实现中断,就需要在内存中放一个中断向量表,这个表的地址和长度由CPU的特定寄存器IDTR指向。实模式下,表中的一个条目由代码段地址和段内偏移组成,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/57/e8876e8561b949b8af5d5237e48f8757.jpg" alt="" title="实模式中断表">
|
||||
|
||||
有了中断号以后,CPU就能根据IDTR寄存器中的信息,计算出中断向量中的条目,进而装载CS(装入代码段基地址)、IP(装入代码段内偏移)寄存器,最终响应中断。
|
||||
|
||||
## 保护模式
|
||||
|
||||
随着软件的规模不断增加,需要更高的计算量、更大的内存容量。
|
||||
|
||||
内存一大,首先要解决的问题是**寻址问题**,因为16位的寄存器最多只能表示$2^{16}$个地址,所以CPU的寄存器和运算单元都要扩展成32位的。
|
||||
|
||||
不过,虽然扩展CPU内部器件的位数解决了计算和寻址问题,但仍然没有解决前面那个实模式场景下的问题,导致前面场景出问题的原因有两点。第一,CPU对任何指令不加区分地执行;第二,CPU对访问内存的地址不加限制。
|
||||
|
||||
基于这些原因,CPU实现了保护模式。保护模式是如何实现保护功能的呢?我们接着往下看。
|
||||
|
||||
### 保护模式寄存器
|
||||
|
||||
保护模式相比于实模式,增加了一些控制寄存器和段寄存器,扩展通用寄存器的位宽,所有的通用寄存器都是32位的,还可以单独使用低16位,这个低16位又可以拆分成两个8位寄存器,如下表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0f/2a/0f564d0aac8514245805eea31aa32c2a.jpeg" alt="" title="保护模式下的寄存器">
|
||||
|
||||
### 保护模式特权级
|
||||
|
||||
为了区分哪些指令(如in、out、cli)和哪些资源(如寄存器、I/O端口、内存地址)可以被访问,CPU实现了特权级。
|
||||
|
||||
特权级分为4级,R0~R3,每个特权级执行指令的数量不同,R0可以执行所有指令,R1、R2、R3依次递减,它们只能执行上一级指令数量的子集。而内存的访问则是靠后面所说的段描述符和特权级相互配合去实现的。如下图.
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d2/2b/d29yyb3f4ac30552e4c0835525d72b2b.jpg" alt="" title="CPU特权级示意图">
|
||||
|
||||
**上面的圆环图,从外到内,既能体现权力的大小,又能体现各特权级对资源控制访问的多少,还能体现各特权级之间的包含关系。**R0拥有最大权力,可以访问低特权级的资源,反之则不行。
|
||||
|
||||
### 保护模式段描述符
|
||||
|
||||
目前为止,内存还是分段模型,要对内存进行保护,就可以转换成对段的保护。
|
||||
|
||||
由于CPU的扩展导致了32位的段基地址和段内偏移,还有一些其它信息,所以16位的段寄存器肯定放不下。放不下就要找内存借空间,然后把描述一个段的信息封装成特定格式的**段描述符**,**放在内存中**,其格式如下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/34/b40a64dd5ca1dc1efd8957525e904634.jpg" alt="" title="保护模式段描述符">
|
||||
|
||||
一个段描述符有64位8字节数据,里面包含了段基地址、段长度、段权限、段类型(可以是系统段、代码段、数据段)、段是否可读写,可执行等。虽然数据分布有点乱,这是由于历史原因造成的。
|
||||
|
||||
多个段描述符在内存中形成全局段描述符表,该表的基地址和长度由CPU和GDTR寄存器指示。如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ab/f7/ab203e85dd8468051eca238c3ebd81f7.jpg" alt="" title="全局段描述符表">
|
||||
|
||||
我们一眼就可以看出,段寄存器中不再存放段基地址,而是具体段描述符的索引,访问一个内存地址时,段寄存器中的索引首先会结合GDTR寄存器找到内存中的段描述符,再根据其中的段信息判断能不能访问成功。
|
||||
|
||||
### 保护模式段选择子
|
||||
|
||||
如果你认为CS、DS、ES、SS、FS、GS这些段寄存器,里面存放的就是一个内存段的描述符索引,那你可就草率了,其实它们是由影子寄存器、段描述符索引、描述符表索引、权限级别组成的。如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/a4/d08ec3163c80a5dd94e488a71588f8a4.jpg" alt="" title="保护模式段选择子">
|
||||
|
||||
上图中**影子寄存器**是靠硬件来操作的,对系统程序员不可见,是硬件为了**减少性能损耗**而设计的一个段描述符的高速缓存,不然每次内存访问都要去内存中查表,那性能损失是巨大的,影子寄存器也正好是64位,里面存放了8字节段描述符数据。
|
||||
|
||||
低三位之所以能放TI和RPL,是因为段描述符8字节对齐,每个索引低3位都为0,我们不用关注LDT,只需要使用GDT全局描述符表,所以TI永远设为0。
|
||||
|
||||
通常情况下,CS和SS中RPL就组成了CPL(当前权限级别),所以常常是RPL=CPL,进而CPL就表示发起访问者要以什么权限去访问目标段,当CPL大于目标段DPL时,则CPU禁止访问,只有CPL小于等于目标段DPL时才能访问。
|
||||
|
||||
### 保护模式平坦模型
|
||||
|
||||
分段模型有很多缺陷,这在后面课程讲内存管理时有详细介绍,其实现代操作系统都会使用分页模型(这点在后面讲MMU那节课再探讨)。
|
||||
|
||||
但是x86 CPU并不能直接使用分页模型,而是要在分段模型的前提下,根据需要决定是否要开启分页。因为这是硬件的规定,程序员是无法改变的。但是我们可以简化设计,来使分段成为一种“虚设”,这就是保护模式的平坦模型。
|
||||
|
||||
根据前面的描述,我们发现CPU 32位的寄存器最多只能产生4GB大小的地址,而一个段长度也只能是4GB,所以我们把所有段的基地址设为0,段的长度设为0xFFFFF,段长度的粒度设为4KB,这样所有的段都指向同一个(0~4GB-1)字节大小的地址空间。
|
||||
|
||||
下面我们还是看一看前面Hello OS中段描述符表,如下所示。
|
||||
|
||||
```
|
||||
GDT_START:
|
||||
knull_dsc: dq 0
|
||||
;第一个段描述符CPU硬件规定必须为0
|
||||
kcode_dsc: dq 0x00cf9e000000ffff
|
||||
;段基地址=0,段长度=0xfffff
|
||||
;G=1,D/B=1,L=0,AVL=0
|
||||
;P=1,DPL=0,S=1
|
||||
;T=1,C=1,R=1,A=0
|
||||
kdata_dsc: dq 0x00cf92000000ffff
|
||||
;段基地址=0,段长度=0xfffff
|
||||
;G=1,D/B=1,L=0,AVL=0
|
||||
;P=1,DPL=0,S=1
|
||||
;T=0,C=0,R=1,A=0
|
||||
GDT_END:
|
||||
|
||||
GDT_PTR:
|
||||
GDTLEN dw GDT_END-GDT_START-1
|
||||
GDTBASE dd GDT_START
|
||||
|
||||
```
|
||||
|
||||
上面代码中注释已经很明白了,段长度需要和G位配合,若G位为1则段长度等于0xfffff个4KB。上面段描述符的DPL=0,这说明需要最高权限即CPL=0才能访问。
|
||||
|
||||
### 保护模式中断
|
||||
|
||||
你还记得实模式下CPU是如何处理中断的吗?如果不记得了请回到前面看一看。
|
||||
|
||||
因为实模式下CPU不需要做权限检查,所以它可以直接通过中断向量表中的值装载CS:IP寄存器就好了。
|
||||
|
||||
而保护模式下的中断要权限检查,还有特权级的切换,所以就需要扩展中断向量表的信息,即每个中断用一个中断门描述符来表示,也可以简称为中断门,中断门描述符依然有自己的格式,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e1/0b/e11b9de930a09fb41bd6ded9bf12620b.jpg" alt="" title="保护模式中断门描述符">
|
||||
|
||||
同样的,保护模式要实现中断,也必须在内存中有一个中断向量表,同样是由IDTR寄存器指向,只不过中断向量表中的条目变成了中断门描述符,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ff/5b/ff5c25c85a7fa28b17f386848f19fb5b.jpg" alt="" title="保护模式段中断表">
|
||||
|
||||
产生中断后,CPU首先会检查中断号是否大于**最后一个中断门描述符**,x86 CPU最大支持256个中断源(即中断号:0~255),然后检查描述符类型(是否是中断门或者陷阱门)、是否为系统描述符,是不是存在于内存中。
|
||||
|
||||
接着,检查中断门描述符中的段选择子指向的段描述符。
|
||||
|
||||
最后做**权限检查**,如果CPL小于等于中断门的DPL并且CPL大于等于中断门中的段选择子,就指向段描述符的DPL。
|
||||
|
||||
进一步的,CPL等于中断门中的段选择子指向段描述符的DPL,则为同级权限不进行栈切换,否则进行栈切换。如果进行栈切换,还需要从TSS中加载具体权限的SS、ESP,当然也要对SS中段选择子指向的段描述符进行检查。
|
||||
|
||||
做完这一系列检查之后,CPU才会加载中断门描述符中目标代码段选择子到CS寄存器中,把目标代码段偏移加载到EIP寄存器中。
|
||||
|
||||
### 切换到保护模式
|
||||
|
||||
x86 CPU在第一次加电和每次reset后,都会自动进入实模式,要想进入保护模式,就需要程序员写代码实现从实模式切换到保护模式。切换到保护模式的步骤如下。
|
||||
|
||||
第一步,准备全局段描述符表,代码如下。
|
||||
|
||||
```
|
||||
GDT_START:
|
||||
knull_dsc: dq 0
|
||||
kcode_dsc: dq 0x00cf9e000000ffff
|
||||
kdata_dsc: dq 0x00cf92000000ffff
|
||||
GDT_END:
|
||||
GDT_PTR:
|
||||
GDTLEN dw GDT_END-GDT_START-1
|
||||
GDTBASE dd GDT_START
|
||||
|
||||
```
|
||||
|
||||
第二步,加载设置GDTR寄存器,使之指向全局段描述符表。
|
||||
|
||||
```
|
||||
lgdt [GDT_PTR]
|
||||
|
||||
```
|
||||
|
||||
第三步,设置CR0寄存器,开启保护模式。
|
||||
|
||||
```
|
||||
;开启 PE
|
||||
mov eax, cr0
|
||||
bts eax, 0 ; CR0.PE =1
|
||||
mov cr0, eax
|
||||
|
||||
```
|
||||
|
||||
第四步,进行长跳转,加载CS段寄存器,即段选择子。
|
||||
|
||||
```
|
||||
jmp dword 0x8 :_32bits_mode ;_32bits_mode为32位代码标号即段偏移
|
||||
|
||||
```
|
||||
|
||||
你也许会有疑问,为什么要进行长跳转,这是因为我们无法直接或间接mov一个数据到CS寄存器中,因为刚刚开启保护模式时,CS的影子寄存器还是实模式下的值,所以需要告诉CPU加载新的段信息。
|
||||
|
||||
接下来,CPU发现了CRO寄存器第0位的值是1,就会按GDTR的指示找到全局描述符表,然后根据索引值8,把新的段描述符信息加载到CS影子寄存器,当然这里的前提是进行一系列合法的检查。
|
||||
|
||||
到此为止,CPU真正进入了保护模式,CPU也有了32位的处理能力。
|
||||
|
||||
## 长模式
|
||||
|
||||
长模式又名AMD64,因为这个标准是AMD公司最早定义的,它使CPU在现有的基础上有了64位的处理能力,既能完成64位的数据运算,也能寻址64位的地址空间。这在大型计算机上犹为重要,因为它们的物理内存通常有几百GB。
|
||||
|
||||
### 长模式寄存器
|
||||
|
||||
长模式相比于保护模式,增加了一些通用寄存器,并扩展通用寄存器的位宽,所有的通用寄存器都是64位,还可以单独使用低32位。
|
||||
|
||||
这个低32位可以拆分成一个低16位寄存器,低16位又可以拆分成两个8位寄存器,如下表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cc/34/cce7aa5fe43552357bc51455cd86a734.jpg" alt="" title="长模式下的寄存器">
|
||||
|
||||
### 长模式段描述符
|
||||
|
||||
长模式依然具备保护模式绝大多数特性,如特权级和权限检查。相同的部分就不再重述了,这里只会说明长模式和保护模式下的差异。
|
||||
|
||||
下面我们来看看长模式下段描述的格式,如下图所示。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/97/c4/974b59084976ddb3df9bdc3bea9325c4.jpg" alt="" title="长模式段描述符">
|
||||
|
||||
在长模式下,CPU不再对段基址和段长度进行检查,只对DPL进行相关的检查,这个检查流程和保护模式下一样。
|
||||
|
||||
当描述符中的L=1,D/B=0时,就是64位代码段,DPL还是0~3的特权级。然后有多个段描述在内存中形成一个全局段描述符表,同样由CPU的GDTR寄存器指向。
|
||||
|
||||
下面我们来写一个长模式下的段描述符表,加深一下理解,如下所示.
|
||||
|
||||
```
|
||||
ex64_GDT:
|
||||
null_dsc: dq 0
|
||||
;第一个段描述符CPU硬件规定必须为0
|
||||
c64_dsc:dq 0x0020980000000000 ;64位代码段
|
||||
;无效位填0
|
||||
;D/B=0,L=1,AVL=0
|
||||
;P=1,DPL=0,S=1
|
||||
;T=1,C=0,R=0,A=0
|
||||
d64_dsc:dq 0x0000920000000000 ;64位数据段
|
||||
;无效位填0
|
||||
;P=1,DPL=0,S=1
|
||||
;T=0,C/E=0,R/W=1,A=0
|
||||
eGdtLen equ $ - null_dsc ;GDT长度
|
||||
eGdtPtr:dw eGdtLen - 1 ;GDT界限
|
||||
dq ex64_GDT
|
||||
|
||||
```
|
||||
|
||||
上面代码中注释已经很清楚了,段长度和段基址都是无效的填充为0,CPU不做检查。但是上面段描述符的DPL=0,这说明需要最高权限即CPL=0才能访问。若是数据段的话,G、D/B、L位都是无效的。
|
||||
|
||||
### 长模式中断
|
||||
|
||||
保护模式下为了实现对中断进行权限检查,实现了中断门描述符,在中断门描述符中存放了对应的段选择子和其段内偏移,还有DPL权限,如果权限检查通过,则用对应的段选择子和其段内偏移装载CS:EIP寄存器。
|
||||
|
||||
如果你还记得中断门描述符,就会发现其中的段内偏移只有32位,但是长模式支持64位内存寻址,所以要对中断门描述符进行修改和扩展,下面我们就来看看长模式下的中断门描述符的格式,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/28/c4/28f28817ca5a3e47f80ea798698dbdc4.jpg" alt="" title="长模式中断门描述符">
|
||||
|
||||
结合上图,我们可以看出**长模式下中断门描述符的格式变化**。
|
||||
|
||||
首先为了支持64位寻址中断门描述符在原有基础上增加8字节,用于存放目标段偏移的高32位值。其次,目标代码段选择子对应的代码段描述符必须是64位的代码段。最后其中的IST是64位TSS中的IST指针,因为我们不使用这个特性,所以不作详细介绍。
|
||||
|
||||
长模式也同样在内存中有一个中断门描述符表,只不过表中的条目(如上图所示)是16字节大小,最多支持256个中断源,对中断的响应和相关权限的检查和保护模式一样,这里不再赘述。
|
||||
|
||||
### 切换到长模式
|
||||
|
||||
我们既可以从实模式直接切换到长模式,也可以从保护模式切换长模式。切换到长模式的步骤如下。
|
||||
|
||||
第一步,准备长模式全局段描述符表。
|
||||
|
||||
```
|
||||
ex64_GDT:
|
||||
null_dsc: dq 0
|
||||
;第一个段描述符CPU硬件规定必须为0
|
||||
c64_dsc:dq 0x0020980000000000 ;64位代码段
|
||||
d64_dsc:dq 0x0000920000000000 ;64位数据段
|
||||
eGdtLen equ $ - null_dsc ;GDT长度
|
||||
eGdtPtr:dw eGdtLen - 1 ;GDT界限
|
||||
dq ex64_GDT
|
||||
|
||||
```
|
||||
|
||||
第二步,准备长模式下的MMU页表,这个是为了开启分页模式,**切换到长模式必须要开启分页**,想想看,长模式下已经不对段基址和段长度进行检查了,那么内存地址空间就得不到保护了。
|
||||
|
||||
而长模式下内存地址空间的保护交给了MMU,MMU依赖页表对地址进行转换,页表有特定的格式存放在内存中,其地址由CPU的CR3寄存器指向,这在后面讲MMU的那节课会专门讲。
|
||||
|
||||
```
|
||||
mov eax, cr4
|
||||
bts eax, 5 ;CR4.PAE = 1
|
||||
mov cr4, eax ;开启 PAE
|
||||
mov eax, PAGE_TLB_BADR ;页表物理地址
|
||||
mov cr3, eax
|
||||
|
||||
```
|
||||
|
||||
1. 加载GDTR寄存器,使之指向全局段描述表:
|
||||
|
||||
```
|
||||
lgdt [eGdtPtr]
|
||||
|
||||
```
|
||||
|
||||
1. 开启长模式,要同时开启保护模式和分页模式,在实现长模式时定义了MSR寄存器,需要用专用的指令rdmsr、wrmsr进行读写,IA32_EFER寄存器的地址为0xC0000080,它的第8位决定了是否开启长模式。
|
||||
|
||||
```
|
||||
;开启 64位长模式
|
||||
mov ecx, IA32_EFER
|
||||
rdmsr
|
||||
bts eax, 8 ;IA32_EFER.LME =1
|
||||
wrmsr
|
||||
;开启 保护模式和分页模式
|
||||
mov eax, cr0
|
||||
bts eax, 0 ;CR0.PE =1
|
||||
bts eax, 31
|
||||
mov cr0, eax
|
||||
|
||||
```
|
||||
|
||||
1. 进行跳转,加载CS段寄存器,刷新其影子寄存器。
|
||||
|
||||
```
|
||||
jmp 08:entry64 ;entry64为程序标号即64位偏移地址
|
||||
|
||||
```
|
||||
|
||||
切换到长模式和切换保护模式的流程差不多,只是需要准备的段描述符有所区别,还有就是要注意同时开启保护模式和分页模式。原因在上面已经说明了。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
好,这节课的内容告一段落了,我来给你做个总结。
|
||||
|
||||
今天我们从一段死循环的代码开始思考,研究这类代码产生的问题和解决思路,然后一步步探索CPU为了处理这些问题而做出的改进和升级。这些功能上的改进和升级,渐渐演变成了CPU的工作模式,这也是系统开发人员需要了解的编程模型。这三种模式梳理如下。
|
||||
|
||||
1.实模式,早期CPU是为了支持单道程序运行而实现的,单道程序能掌控计算机所有的资源,早期的软件规模不大,内存资源也很少,所以实模式极其简单,仅支持16位地址空间,分段的内存模型,**对指令不加限制地运行,对内存没有保护隔离作用**。
|
||||
|
||||
2.保护模式,随着多道程序的出现,就需要操作系统了。内存需求量不断增加,所以CPU实现了保护模式以支持这些需求。
|
||||
|
||||
保护模式包含**特权级**,对指令及其访问的资源进行控制,对内存段与段之间的访问进行严格检查,没有权限的绝不放行,对中断的响应也要进行严格的权限检查,扩展了CPU寄存器位宽,使之能够寻址32位的内存地址空间和处理32位的数据,从而CPU的性能大大提高。
|
||||
|
||||
3.长模式,又名AMD64模式,最早由AMD公司制定。由于软件对CPU性能需求永无止境,所以长模式在保护模式的基础上,把寄存器扩展到64位同时增加了一些寄存器,使CPU具有了能处理64位数据和寻址64位的内存地址空间的能力。
|
||||
|
||||
长模式**弱化段模式管理**,只保留了权限级别的检查,忽略了段基址和段长度,而地址的检查则交给了MMU。
|
||||
|
||||
## 思考题
|
||||
|
||||
请问实模式下能寻址多大的内存空间?
|
||||
|
||||
期待你在留言区跟我交流互动,如果你身边有对CPU工作模式感兴趣的朋友,也欢迎把这节课的内容转发给他,我们一起学习进步。
|
||||
266
极客时间专栏/操作系统实战45讲/程序的基石:硬件/06 | 虚幻与真实:程序中的地址如何转换?.md
Normal file
266
极客时间专栏/操作系统实战45讲/程序的基石:硬件/06 | 虚幻与真实:程序中的地址如何转换?.md
Normal file
@@ -0,0 +1,266 @@
|
||||
<audio id="audio" title="06 | 虚幻与真实:程序中的地址如何转换?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/93/93/93a78183f65a089cd2e950a90d2b7393.mp3"></audio>
|
||||
|
||||
你好,我是LMOS。
|
||||
|
||||
从前面的课程我们得知,CPU执行程序、处理数据都要和内存打交道,这个打交道的方式就是内存地址。
|
||||
|
||||
读取指令、读写数据都需要首先告诉内存芯片:hi,内存老哥请你把0x10000地址处的数据交给我……hi,内存老哥,我已经计算完成,请让我把结果写回0x200000地址的空间。这些地址存在于代码指令字段后的常数,或者存在于某个寄存器中。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/fc/b0e93b744dfdc62c4a3ce8816b25b1fc.jpg" alt="">
|
||||
|
||||
今天,我们就来专门研究一下程序中的地址。说起程序中的地址,不知道你是否好奇过,为啥系统设计者要引入虚拟地址呢?
|
||||
|
||||
我会先带你从一个多程序并发的场景热身,一起思考这会导致哪些问题,为什么能用虚拟地址解决这些问题。
|
||||
|
||||
搞懂原理之后,我还会带你一起探索**虚拟地址和物理地址的关系和转换机制**。在后面的课里,你会发现,我们最宝贵的内存资源正是通过这些机制来管理的。
|
||||
|
||||
## 从一个多程序并发的场景说起
|
||||
|
||||
设想一下,如果一台计算机的内存中只运行一个程序A,这种方式正好用前面CPU的[实模式](https://time.geekbang.org/column/article/375278)来运行,因为程序A的地址在链接时就可以确定,例如从内存地址0x8000开始,每次运行程序A都装入内存0x8000地址处开始运行,没有其它程序干扰。
|
||||
|
||||
现在改变一下,内存中又放一道程序B,程序A和程序B各自运行一秒钟,如此循环,直到其中之一结束。这个新场景下就会产生一些问题,当然这里我们只关心内存相关的这几个核心问题。
|
||||
|
||||
1.谁来保证程序A跟程序B **没有内存地址的冲突**?换句话说,就是程序A、B各自放在什么内存地址,这个问题是由A、B程序协商,还是由操作系统决定。
|
||||
|
||||
2.怎样保证程序A跟程序B **不会互相读写各自的内存空间**?这个问题相对简单,用保护模式就能解决。
|
||||
|
||||
3.如何解决**内存容量**问题?程序A和程序B,在不断开发迭代中程序代码占用的空间会越来越大,导致内存装不下。
|
||||
|
||||
4.还要考虑一个**扩展后的复杂情况**,如果不只程序A、B,还可能有程序C、D、E、F、G……它们分别由不同的公司开发,而每台计算机的内存容量不同。这时候,又对我们的内存方案有怎样的影响呢?
|
||||
|
||||
要想完美地解决以上最核心的4个问题,一个较好的方案是:让所有的程序都各自享有一个从0开始到最大地址的空间,这个地址空间是独立的,是该程序私有的,其它程序既看不到,也不能访问该地址空间,这个地址空间和其它程序无关,和具体的计算机也无关。
|
||||
|
||||
事实上,计算机科学家们早就这么做了,这个方案就是**虚拟地址**,下面我们就来看看它。
|
||||
|
||||
## 虚拟地址
|
||||
|
||||
正如其名,这个地址是虚拟的,自然而然地和具体环境进行了解耦,这个环境包括系统软件环境和硬件环境。
|
||||
|
||||
虚拟地址是逻辑上存在的一个数据值,比如0~100就有101个整数值,这个0~100的区间就可以说是一个虚拟地址空间,该虚拟地址空间有101个地址。
|
||||
|
||||
我们再来看看最开始Hello World的例子,我们用objdump工具反汇编一下Hello World二进制文件,就会得到如下的代码片段:
|
||||
|
||||
```
|
||||
00000000000004e8 <_init>:
|
||||
4e8: 48 83 ec 08 sub $0x8,%rsp
|
||||
4ec: 48 8b 05 f5 0a 20 00 mov 0x200af5(%rip),%rax # 200fe8 <__gmon_start__>
|
||||
4f3: 48 85 c0 test %rax,%rax
|
||||
4f6: 74 02 je 4fa <_init+0x12>
|
||||
4f8: ff d0 callq *%rax
|
||||
4fa: 48 83 c4 08 add $0x8,%rsp
|
||||
4fe: c3 retq
|
||||
|
||||
```
|
||||
|
||||
上述代码中,左边第一列数据就是虚拟地址,第三列中是程序指令,如:“mov 0x200af5(%rip),%rax,je 4fa,callq *%rax”指令中的数据都是虚拟地址。
|
||||
|
||||
事实上,所有的应用程序开始的部分都是这样的。这正是因为每个应用程序的虚拟地址空间都是相同且独立的。
|
||||
|
||||
那么这个地址是由谁产生的呢?
|
||||
|
||||
答案是链接器,其实我们开发软件经过编译步骤后,就需要链接成可执行文件才可以运行,而链接器的主要工作就是把多个代码模块组装在一起,并解决模块之间的引用,即处理程序代码间的地址引用,形成程序运行的静态内存空间视图。
|
||||
|
||||
只不过这个地址是虚拟而统一的,而根据操作系统的不同,这个虚拟地址空间的定义也许不同,应用软件开发人员无需关心,由开发工具链给自动处理了。由于这虚拟地址是独立且统一的,所以各个公司开发的各个应用完全不用担心自己的内存空间被占用和改写。
|
||||
|
||||
## 物理地址
|
||||
|
||||
虽然虚拟地址解决了很多问题,但是虚拟地址只是逻辑上存在的地址,无法作用于硬件电路的,程序装进内存中想要执行,就需要和内存打交道,从内存中取得指令和数据。而内存只认一种地址,那就是**物理地址**。
|
||||
|
||||
什么是物理地址呢?物理地址在逻辑上也是一个数据,只不过这个数据会被地址译码器等电子器件变成电子信号,放在地址总线上,地址总线电子信号的各种组合就可以选择到内存的储存单元了。
|
||||
|
||||
但是地址总线上的信号(即物理地址),也可以选择到别的设备中的储存单元,如显卡中的显存、I/O设备中的寄存器、网卡上的网络帧缓存器。不过如果不做特别说明,我们说的物理地址就是指**选择内存单元的地址**。
|
||||
|
||||
## 虚拟地址到物理地址的转换
|
||||
|
||||
明白了虚拟地址和物理地址之后,我们发现虚拟地址必须转换成物理地址,这样程序才能正常执行。要转换就必须要转换机构,它相当于一个函数:p=f(v),输入虚拟地址v,输出物理地址p。
|
||||
|
||||
那么要怎么实现这个函数呢?
|
||||
|
||||
用软件方式实现太低效,用硬件实现没有灵活性,最终就用了软硬件结合的方式实现,它就是MMU(内存管理单元)。MMU可以接受软件给出的地址对应关系数据,进行地址转换。
|
||||
|
||||
我们先来看看逻辑上的MMU工作原理框架图。如下图所示:<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/d5/99/d582ff647549b8yy986d90e697d33499.jpg" alt="" title="MMU工作原理图">
|
||||
|
||||
上图中展示了MMU通过地址关系转换表,将0x80000~0x84000的虚拟地址空间转换成 0x10000~0x14000的物理地址空间,而地址关系转换表本身则是放物理内存中的。
|
||||
|
||||
下面我们不妨想一想地址关系转换表的实现.如果在地址关系转换表中,这样来存放:一个虚拟地址对应一个物理地址。
|
||||
|
||||
那么问题来了,32位地址空间下,4GB虚拟地址的地址关系转换表就会把整个32位物理地址空间用完,这显然不行。
|
||||
|
||||
要是结合前面的保护模式下分段方式呢,地址关系转换表中存放:一个虚拟段基址对应一个物理段基址,这样看似可以,但是因为段长度各不相同,所以依然不可取。
|
||||
|
||||
综合刚才的分析,系统设计者最后采用一个折中的方案,即**把虚拟地址空间和物理地址空间都分成同等大小的块,也称为页,按照虚拟页和物理页进行转换。**根据软件配置不同,这个页的大小可以设置为4KB、2MB、4MB、1GB,这样就进入了现代内存管理模式——**分页模型**。
|
||||
|
||||
下面来看看分页模型框架,如下图所示:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/9b/d0/9b19677448ee973c4f3yya6b3af7b4d0.jpg" alt="" title="分页模型框架图">
|
||||
|
||||
结合图片可以看出,一个虚拟页可以对应到一个物理页,由于页大小一经配置就是固定的,所以在地址关系转换表中,只要存放**虚拟页地址对应的物理页地址**就行了。
|
||||
|
||||
我知道,说到这里,也许你仍然没搞清楚MMU和地址关系转换表的细节,别急,我们现在已经具备了研究它们的基础,下面我们就去探索它们。
|
||||
|
||||
## MMU
|
||||
|
||||
MMU即内存管理单元,是用硬件电路逻辑实现的一个地址转换器件,它负责接受虚拟地址和地址关系转换表,以及输出物理地址。
|
||||
|
||||
根据实现方式的不同,MMU可以是独立的芯片,也可以是集成在其它芯片内部的,比如集成在CPU内部,x86、ARM系列的CPU就是将MMU集成在CPU核心中的。
|
||||
|
||||
SUN公司的CPU是将独立的MMU芯片卡在总线上的,有一夫当关的架势。下面我们只研究x86 CPU中的MMU。x86 CPU要想开启MMU,就必须先开启保护模式或者长模式,实模式下是不能开启MMU的。
|
||||
|
||||
由于保护模式的内存模型是分段模型,它并不适合于MMU的分页模型,所以我们要使用保护模式的平坦模式,这样就绕过了分段模型。这个平坦模型和长模式下忽略段基址和段长度是异曲同工的。地址产生的过程如下所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b4/88/b41a2bb00e19e662b34a1b7b7c0ae288.jpg" alt="" title="CPU地址转换图">
|
||||
|
||||
上图中,程序代码中的虚拟地址,经过CPU的分段机制产生了线性地址,平坦模式和长模式下线性地址和虚拟地址是相等的。
|
||||
|
||||
如果不开启MMU,在保护模式下可以关闭MMU,这个线性地址就是物理地址。因为长模式下的分段**弱化了地址空间的隔离**,所以开启MMU是必须要做的,开启MMU才能访问内存地址空间。
|
||||
|
||||
### MMU页表
|
||||
|
||||
现在我们开始研究地址关系转换表,其实它有个更加专业的名字——**页表**。它描述了虚拟地址到物理地址的转换关系,也可以说是虚拟页到物理页的映射关系,所以称为页表。
|
||||
|
||||
为了增加灵活性和节约物理内存空间(因为页表是放在物理内存中的),所以页表中并不存放虚拟地址和物理地址的对应关系,只存放物理页面的地址,MMU以虚拟地址为索引去查表返回物理页面地址,而且页表是分级的,总体分为三个部分:一个顶级页目录,多个中级页目录,最后才是页表,逻辑结构图如下.
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/yf/2df904c8ba75065e1491138d63820yyf.jpg" alt="" title="MMU页表原理图">
|
||||
|
||||
从上面可以看出,一个虚拟地址被分成从左至右四个位段。
|
||||
|
||||
第一个位段索引顶级页目录中一个项,该项指向一个中级页目录,然后用第二个位段去索引中级页目录中的一个项,该项指向一个页目录,再用第三个位段去索引页目录中的项,该项指向一个物理页地址,最后用第四个位段作该物理页内的偏移去访问物理内存。**这就是MMU的工作流程。**
|
||||
|
||||
## 保护模式下的分页
|
||||
|
||||
前面的内容都是理论上帮助我们了解分页模式原理的,分页模式的**灵活性、通用性、安全性**,是现代操作系统内存管理的基石,更是事实上的标准内存管理模型,现代商用操作系统都必须以此为基础实现虚拟内存功能模块。
|
||||
|
||||
因为我们的主要任务是开发操作系统,而开发操作系统就落实到真实的硬件平台上去的,下面我们就来研究x86 CPU上的分页模式。
|
||||
|
||||
首先来看看保护模式下的分页,保护模式下只有32位地址空间,最多4GB-1大小的空间。
|
||||
|
||||
根据前面得知32位虚拟地址经过分段机制之后得到线性地址,又因为通常使用平坦模式,所以线性地址和虚拟地址是相同的。
|
||||
|
||||
保护模式下的分页大小通常有两种,一种是4KB大小的页,一种是4MB大小的页。分页大小的不同,会导致虚拟地址位段的分隔和页目录的层级不同,但虚拟页和物理页的大小始终是等同的。
|
||||
|
||||
### 保护模式下的分页——4KB页
|
||||
|
||||
该分页方式下,32位虚拟地址被分为三个位段:**页目录索引、页表索引、页内偏移**,只有一级页目录,其中包含1024个条目 ,每个条目指向一个页表,每个页表中有1024个条目。其中一个条目就指向一个物理页,每个物理页4KB。这正好是4GB地址空间。如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/00/f8/00b7f1ef4a1c4f6fc9e6b69109ae0bf8.jpg" alt="" title="保护模式下的4KB分页">
|
||||
|
||||
上图中CR3就是CPU的一个32位的寄存器,MMU就是根据这个寄存器找到页目录的。下面,我们看看当前分页模式下的CR3、页目录项、页表项的格式。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/c9/361c48e1876a412f9ff9f29bf2dbecc9.jpg" alt="">
|
||||
|
||||
可以看到,页目录项、页表项都是4字节32位,1024个项正好是4KB(一个页),因此它们的地址始终是4KB对齐的,所以低12位才可以另作它用,形成了页面的相关属性,如是否存在、是否可读可写、是用户页还是内核页、是否已写入、是否已访问等。
|
||||
|
||||
### 保护模式下的分页——4MB页
|
||||
|
||||
该分页方式下,32位虚拟地址被分为两个位段:**页表索引、页内偏移**,只有一级页目录,其中包含1024个条目。其中一个条目指向一个物理页,每个物理页4MB,正好为4GB地址空间,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/52/76932c52a7b6109854f2de72d71bba52.jpg" alt="" title="保护模式下的4MB分页">
|
||||
|
||||
CR3还是32位的寄存器,只不过不再指向顶级页目录了,而是指向一个4KB大小的页表,这个页表依然要4KB地址对齐,其中包含1024个页表项,格式如下图。<br>
|
||||
<img src="https://static001.geekbang.org/resource/image/9a/08/9a4afdc60b790c3e2b7e94b0c7fd4208.jpg" alt="">
|
||||
|
||||
可以发现,4MB大小的页面下,页表项还是4字节32位,但只需要用高10位来保存物理页面的基地址就可以。因为每个物理页面都是4MB,所以低22位始终为0,为了兼容4MB页表项低8位和4KB页表项一样,只不过第7位变成了PS位,且必须为1,而PAT位移到了12位。
|
||||
|
||||
## 长模式下的分页
|
||||
|
||||
如果开启了长模式,则必须同时开启分页模式,因为长模式弱化了分段模型,而分段模型也确实有很多不足,不适应现在操作系统和应用软件的发展。
|
||||
|
||||
同时,长模式也扩展了CPU的位宽,使得CPU能使用64位的超大内存地址空间。所以,长模式下的虚拟地址必须等于线性地址且为64位。
|
||||
|
||||
长模式下的分页大小通常也有两种,4KB大小的页和2MB大小的页。
|
||||
|
||||
### 长模式下的分页——4KB页
|
||||
|
||||
该分页方式下,64位虚拟地址被分为6个位段,分别是:保留位段,顶级页目录索引、页目录指针索引、页目录索引、页表索引、页内偏移,顶级页目录、页目录指针、页目录、页表各占有4KB大小,其中各有512个条目,每个条目8字节64位大小,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/c9/ecdea93c2544cf9c1d84461b602b03c9.jpg" alt="" title="长模式下的4KB分页">
|
||||
|
||||
上面图中CR3已经变成64位的CPU的寄存器,它指向一个顶级页目录,里面的顶级页目项指向页目录指针,依次类推。
|
||||
|
||||
需要注意的是,虚拟地址48到63这6位是根据**第47位**来决定的,47位为1,它们就为1,反之为0,这是因为x86 CPU并没有实现全64位的地址总线,而是只实现了48位,但是CPU的寄存器却是64位的。
|
||||
|
||||
这种最高有效位填充的方式,即使后面扩展CPU的地址总线也不会有任何影响,下面我们去看看当前分页模式下的CR3、顶级页目录项、页目录指针项、页目录项、页表项的格式,我画了一张图帮你理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e3/55/e342246f5cfa21c5b5173b9e494bdc55.jpg" alt="">
|
||||
|
||||
由上图可知,长模式下的4KB分页下,由一个顶层目录、二级中间层目录和一层页表组成了64位地址翻译过程。
|
||||
|
||||
顶级页目录项指向页目录指针页,页目录指针项指向页目录页,页目录项指向页表页,页表项指向一个4KB大小的物理页,各级页目录项中和页表项中依然存在各种属性位,这在图中已经说明。其中的XD位,可以控制代码页面是否能够运行。
|
||||
|
||||
### 长模式下的分页——2MB页
|
||||
|
||||
在这种分页方式下,64位虚拟地址被分为5个位段 :保留位段、顶级页目录索引、页目录指针索引、页目录索引,页内偏移,顶级页目录、页目录指针、页目录各占有4KB大小,其中各有512个条目,每个条目8字节64位大小。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/68/ea/68bf70d8bcae7802e5291140ac1ec6ea.jpg" alt="" title="长模式下的2MB分页">
|
||||
|
||||
可以发现,长模式下2MB和4KB分页的区别是,2MB分页下是页目录项直接指向了2MB大小的物理页面,放弃了**页表项**,然后把虚拟地址的低21位作为页内偏移,21位正好索引2MB大小的地址空间。
|
||||
|
||||
下面我们还是要去看看2MB分页模式下的CR3、顶级页目录项、页目录指针项、页目录项的格式,格式如下图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/45/0b/457f6965d0f25bf64bfb9ec698ab7e0b.jpg" alt="">
|
||||
|
||||
上图中没有了页表项,取而代之的是,页目录项中直接存放了2MB物理页基地址。由于物理页始终2MB对齐,所以其地址的低21位为0,用于存放页面属性位。
|
||||
|
||||
## 开启MMU
|
||||
|
||||
要使用分页模式就必先开启MMU,但是开启MMU的前提是CPU进入保护模式或者长模式,开启CPU这两种模式的方法,我们在前面[第五节课](https://time.geekbang.org/column/article/375278)已经讲过了,下面我们就来开启MMU,步骤如下:
|
||||
|
||||
1.使CPU进入保护模式或者长模式。
|
||||
|
||||
2.准备好页表数据,这包含顶级页目录,中间层页目录,页表,假定我们已经编写了代码,在物理内存中生成了这些数据。
|
||||
|
||||
3.把顶级页目录的物理内存地址赋值给CR3寄存器。
|
||||
|
||||
```
|
||||
mov eax, PAGE_TLB_BADR ;页表物理地址
|
||||
mov cr3, eax
|
||||
|
||||
```
|
||||
|
||||
1. 设置CPU的CR0的PE位为1,这样就开启了MMU。
|
||||
|
||||
```
|
||||
;开启 保护模式和分页模式
|
||||
mov eax, cr0
|
||||
bts eax, 0 ;CR0.PE =1
|
||||
bts eax, 31 ;CR0.P = 1
|
||||
mov cr0, eax
|
||||
|
||||
```
|
||||
|
||||
## MMU地址转换失败
|
||||
|
||||
MMU的主要功能是根据页表数据把虚拟地址转换成物理地址,但有没有可能转换失败?
|
||||
|
||||
绝对有可能,例如,页表项中的数据为空,用户程序访问了超级管理者的页面,向只读页面中写入数据。这些都会导致MMU地址转换失败。
|
||||
|
||||
MMU地址转换失败了怎么办呢?失败了既不能放行,也不是reset,MMU执行的操作如下。
|
||||
|
||||
1.MMU停止转换地址。<br>
|
||||
2.MMU把转换失败的虚拟地址写入CPU的CR2寄存器。<br>
|
||||
3.MMU触发CPU的14号中断,使CPU停止执行当前指令。<br>
|
||||
4.CPU开始执行14号中断的处理代码,代码会检查原因,处理好页表数据返回。<br>
|
||||
5.CPU中断返回继续执行MMU地址转换失败时的指令。
|
||||
|
||||
这里你只要先明白这个流程就好了,后面课程讲到内存管理的时候我们继续探讨。
|
||||
|
||||
## 重点回顾
|
||||
|
||||
又到了课程的尾声,把心情放松下来,我们一起来回顾这节课的重点。
|
||||
|
||||
首先,我们从一个场景开始热身,发现多道程序同时运行有很多问题,都是内存相关的问题,内存需要**隔离和保护**。从而提出了虚拟地址与物理地址分离,让应用程序从实际的物理内存中解耦出来。
|
||||
|
||||
虽然虚拟地址是个非常不错的方案,但是虚拟地址必须转换成物理地址,才能在硬件上执行。为了执行这个转换过程,才开发出了MMU(内存管理单元),MMU**增加了转换的灵活性**,它的实现方式是**硬件执行转换过程,但又依赖于软件提供的地址转换表。**
|
||||
|
||||
最后,我们下落到具体的硬件平台,研究了x86 CPU上的MMU。
|
||||
|
||||
x86 CPU上的MMU在其保护模式和长模式下提供4KB、2MB、4MB等页面转换方案,我们详细分析了它们的**页表格式**。同时,也搞清楚了**如何开启MMU,以及MMU地址转换失败后执行的操作。**
|
||||
|
||||
## 思考题
|
||||
|
||||
在分页模式下,操作系统是如何对应用程序的地址空间进行隔离的?
|
||||
|
||||
欢迎你在留言区和我交流互动。如果这节课对你有启发的话,也欢迎你转发给朋友、同事,说不定就能帮他解决疑问。
|
||||
|
||||
我是LMOS,我们下节课见!
|
||||
235
极客时间专栏/操作系统实战45讲/程序的基石:硬件/07 | Cache与内存:程序放在哪儿?.md
Normal file
235
极客时间专栏/操作系统实战45讲/程序的基石:硬件/07 | Cache与内存:程序放在哪儿?.md
Normal file
@@ -0,0 +1,235 @@
|
||||
<audio id="audio" title="07 | Cache与内存:程序放在哪儿?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/40/d8/405e834be71c1c94339022eef65e0cd8.mp3"></audio>
|
||||
|
||||
你好,我是LMOS。
|
||||
|
||||
在前面的课程里,我们已经知道了CPU是如何执行程序的,也研究了程序的地址空间,这里我们终于到了程序的存放地点——内存。
|
||||
|
||||
你知道什么是Cache吗?在你心中,真实的内存又是什么样子呢?今天我们就来重新认识一下Cache和内存,这对我们利用Cache写出高性能的程序代码和实现操作系统管理内存,有着巨大的帮助。
|
||||
|
||||
通过这节课的内容,我们一起来看看内存到底是啥,它有什么特性。有了这个认识,你就能更加深入地理解我们看似熟悉的局部性原理,从而搞清楚,为啥Cache是解决内存瓶颈的神来之笔。最后,我还会带你分析x86平台上的Cache,规避Cache引发的一致性问题,并让你掌握获取内存视图的方法。
|
||||
|
||||
那话不多说,带着刚才的问题,我们正式进入今天的学习吧!
|
||||
|
||||
## 从一段“经典”代码看局部性原理
|
||||
|
||||
不知道,你还记不记得C语言打印九九乘法表的代码,想不起来也没关系,下面我把它贴出来,代码很短,也很简单,就算你自己写一个也用不了一分钟,如下所示。
|
||||
|
||||
```
|
||||
#include <stdio.h>
|
||||
int main(){
|
||||
int i,j;
|
||||
for(i=1;i<=9;i++){
|
||||
for(j=1;j<=i;j++){
|
||||
printf("%d*%d=%2d ",i,j,i*j);
|
||||
}
|
||||
printf("\n");
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
我们当然不是为了研究代码本身,这个代码非常简单,这里我们主要是观察这个结构,代码的结构主要是**顺序、分支、循环**,这三种结构可以写出现存所有算法的程序。
|
||||
|
||||
我们常规情况下写的代码是顺序和循环结构居多。上面的代码中有两重循环,内层循环的次数受到外层循环变量的影响。就是这么简单,但是越简单的东西越容易看到本质。
|
||||
|
||||
可以看到,这个代码大数时间在执行一个乘法计算和调用一个printf函数,而程序一旦编译装载进内存中,它的地址就确定了。也就是说,CPU大多数时间在访问相同或者与此相邻的地址,换句话说就是:CPU大多数时间在执行相同的指令或者与此相邻的指令。这就是大名鼎鼎的**程序局部性原理**。
|
||||
|
||||
## 内存
|
||||
|
||||
明白了程序的局部性原理之后,我们再来看看内存。你或许感觉这跨越有点大,但是只有明白了内存的结构和特性,你才能明白程序局部性原理的应用场景和它的重要性。
|
||||
|
||||
内存也可称为主存,不管硬盘多大、里面存放了多少程序和数据,只要程序运行或者数据要进行计算处理,就必须先将它们装入内存。我们先来看看内存长什么样(你也可以上网自行搜索),如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/8e/0d0d85383416f2f8841aeebe7021a88e.jpg" alt="">
|
||||
|
||||
从上图可以看到在PCB板上有内存颗粒芯片,主要是用来存放数据的。SPD芯片用于存放内存自身的容量、频率、厂商等信息。还有最显眼的金手指,用于连接数据总线和地址总线,电源等。
|
||||
|
||||
其实从专业角度讲,内存应该叫**DRAM**,即动态随机存储器。内存储存颗粒芯片中的存储单元是由电容和相关元件做成的,电容存储电荷的多、少代表数字信号0和1。
|
||||
|
||||
而随着时间的流逝,电容存在漏电现象,这导致电荷不足,就会让存储单元的数据出错,所以DRAM需要周期性刷新,以保持电荷状态。DRAM结构较简单且集成度很高,通常用于制造内存条中的储存颗粒芯片。
|
||||
|
||||
虽然内存技术标准不断更新,但是储存颗粒的内部结构没有本质改变,还是电容存放电荷,标准看似更多,实际上只是提升了位宽、工作频率,以及传输时预取的数据位数。
|
||||
|
||||
比如DDR SDRAM,即双倍速率同步动态随机存储器,它使用2.5V的工作电压,数据位宽为64位,核心频率最高为166MHz。下面简称DDR内存,它表示每一个时钟脉冲传输两次数据,分别在时钟脉冲的上升沿和下降沿各传输一次数据,因此称为双倍速率的SDRAM。
|
||||
|
||||
后来的DDR2、DDR3、DDR4也都在核心频率和预取位数上做了提升。最新的DDR4采用1.2V工作电压,数据位宽为64位,预取16位数据。DDR4取消了双通道机制,一条内存即为一条通道,工作频率最高可达4266MHz,单根DDR4内存的数据传输带宽最高为34GB/s。
|
||||
|
||||
其实我们无需过多关注内存硬件层面的技术规格标准,重点需要关注的是,**内存的速度还有逻辑上内存和系统的连接方式和结构**,这样你就能意识到内存有多慢,还有是什么原因导致内存慢的。
|
||||
|
||||
我们还是画幅图说明吧,如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1b/ed/1b41056cce55b17a2366d7b9dd922aed.jpg" alt="" title="DDR内存逻辑结构连接图">
|
||||
|
||||
结合图片我们看到,控制内存刷新和内存读写的是内存控制器,而内存控制器集成在北桥芯片中。传统方式下,北桥芯片存在于系统主板上,而现在由于芯片制造工艺的升级,芯片集成度越来越高,所以北桥芯片被就集成到CPU芯片中了,同时这也大大提升了CPU访问内存的性能。
|
||||
|
||||
而作为软件开发人员,从逻辑上我们只需要把内存看成一个巨大的字节数组就可以,而内存地址就是这个数组的下标。
|
||||
|
||||
## CPU到内存的性能瓶颈
|
||||
|
||||
尽管CPU和内存是同时代发展的,但CPU所使用技术工艺的材料和内存是不同的,侧重点也不同,价格也不同。如果内存使用CPU的工艺和材料制造,那内存条的昂贵程度会超乎想象,没有多少人能买得起。
|
||||
|
||||
由于这些不同,导致了CPU和内存条的数据吞吐量天差地别。尽管最新的DDR4内存条带宽高达34GB/s,然而这相比CPU的数据吞吐量要慢上几个数量级。再加上多核心CPU同时访问内存,会导致总线争用问题,数据吞吐量会进一步下降。
|
||||
|
||||
CPU要数据,内存一时给不了怎么办?CPU就得等,通常CPU会让总线插入等待时钟周期,直到内存准备好,到这里你就会发现,无论CPU的性能多高都没用,而**内存才是决定系统整体性能的关键**。显然依靠目前的理论直接提升内存性能,达到CPU的同等水平,这是不可行的,得想其它的办法。
|
||||
|
||||
## Cache
|
||||
|
||||
让我们重新回到前面的场景中,回到程序的局部性原理,它告诉我们:CPU大多数时间在访问相同或者与此相邻的地址。那么,我们立马就可以想到用一块**小而快**的储存器,放在CPU和内存之间,就可以利用程序的局部性原理来缓解CPU和内存之间的性能瓶颈。这块**小而快**的储存器就是Cache,即高速缓存。
|
||||
|
||||
Cache中存放了内存中的一部分数据,CPU在访问内存时要先访问Cache,若Cache中有需要的数据就直接从Cache中取出,若没有则需要从内存中读取数据,并同时把这块数据放入Cache中。但是由于程序的局部性原理,在一段时间内,CPU总是能从Cache中读取到自己想要的数据。
|
||||
|
||||
Cache可以集成在CPU内部,也可以做成独立的芯片放在总线上,现在x86 CPU和ARM CPU都是集成在CPU内部的。其逻辑结构如下图所示。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/47/56/474189597993406a01a2ae171b754756.jpg" alt="" title="Cache结构框架图">
|
||||
|
||||
Cache主要由高速的静态储存器、地址转换模块和Cache行替换模块组成。
|
||||
|
||||
Cache会把自己的高速静态储存器和内存分成大小相同的行,一行大小通常为32字节或者64字节。Cache和内存交换数据的最小单位是一行,为方便管理,在Cache内部的高速储存器中,多个行又会形成一组。
|
||||
|
||||
除了正常的数据空间外,Cache行中还有一些标志位,如脏位、回写位,访问位等,这些位会被Cache的替换模块所使用。
|
||||
|
||||
Cache大致的逻辑工作流程如下。
|
||||
|
||||
1.CPU发出的地址由Cache的地址转换模块分成3段:组号,行号,行内偏移。
|
||||
|
||||
2.Cache会根据组号、行号查找高速静态储存器中对应的行。如果找到即命中,用行内偏移读取并返回数据给CPU,否则就分配一个新行并访问内存,把内存中对应的数据加载到Cache行并返回给CPU。写入操作则比较直接,分为回写和直通写,回写是写入对应的Cache行就结束了,直通写则是在写入Cache行的同时写入内存。
|
||||
|
||||
3.如果没有新行了,就要进入行替换逻辑,即找出一个Cache行写回内存,腾出空间,替换行有相关的算法,**替换算法是为了让替换的代价最小化**。例如,找出一个没有修改的Cache行,这样就不用把它其中的数据回写到内存中了,还有找出存在时间最久远的那个Cache行,因为它大概率不会再访问了。
|
||||
|
||||
以上这些逻辑都由Cache硬件独立实现,软件不用做任何工作,对软件是透明的。
|
||||
|
||||
## Cache带来的问题
|
||||
|
||||
Cache虽然带来性能方面的提升,但同时也给和硬件和软件开发带来了问题,那就是数据一致性问题。
|
||||
|
||||
为了搞清楚这个问题,我们必须先搞清楚Cache在硬件层面的结构,下面我画了x86 CPU的Cache结构图:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/bd/976f5cf91bc656e2a876235a5d2efabd.jpg" alt="" title="x86 CPU的Cache结构图">
|
||||
|
||||
这是一颗最简单的双核心CPU,它有三级Cache,第一级Cache是指令和数据分开的,第二级Cache是独立于CPU核心的,第三级Cache是所有CPU核心共享的。
|
||||
|
||||
下面来看看Cache的一致性问题,主要包括这三个方面.
|
||||
|
||||
1.一个CPU核心中的指令Cache和数据Cache的一致性问题。<br>
|
||||
2.多个CPU核心各自的2级Cache的一致性问题。<br>
|
||||
3.CPU的3级Cache与设备内存,如DMA、网卡帧储存,显存之间的一致性问题。这里我们不需要关注这个问题。
|
||||
|
||||
我们先来看看CPU核心中的指令Cache和数据Cache的一致性问题,对于程序代码运行而言,指令都是经过指令Cache,而指令中涉及到的数据则会经过数据Cache。
|
||||
|
||||
所以,对自修改的代码(即修改运行中代码指令数据,变成新的程序)而言,比如我们修改了内存地址A这个位置的代码(典型的情况是Java运行时编译器),这个时候我们是通过储存的方式去写的地址A,所以新的指令会进入数据Cache。
|
||||
|
||||
但是我们接下来去执行地址A处的指令的时候,指令Cache里面可能命中的是修改之前的指令。所以,这个时候软件需要把数据Cache中的数据写入到内存中,然后让指令Cache无效,重新加载内存中的数据。
|
||||
|
||||
再来看看多个CPU核心各自的2级Cache的一致性问题。从上图中可以发现,两个CPU核心共享了一个3级Cache。比如第一个CPU核心读取了一个A地址处的变量,第二个CPU也读取A地址处的变量,那么第二个CPU核心是不是需要从内存里面经过第3、2、1级Cache再读一遍,这个显然是没有必要的。
|
||||
|
||||
在硬件上Cache相关的控制单元,可以把第一个CPU核心的A地址处Cache内容直接复制到第二个CPU的第2、1级Cache,这样两个CPU核心都得到了A地址的数据。不过如果这时第一个CPU核心改写了A地址处的数据,而第二个CPU核心的2级Cache里面还是原来的值,数据显然就不一致了。
|
||||
|
||||
为了解决这些问题,硬件工程师们开发了多种协议,典型的多核心Cache数据同步协议有MESI和MOESI。MOESI和MESI大同小异,下面我们就去研究一下MESI协议。
|
||||
|
||||
## Cache的MESI协议
|
||||
|
||||
MESI协议定义了4种基本状态:M、E、S、I,即修改(Modified)、独占(Exclusive)、共享(Shared)和无效(Invalid)。下面我结合示意图,给你解释一下这四种状态。
|
||||
|
||||
1.M修改(Modified):当前Cache的内容有效,数据已经被修改而且与内存中的数据不一致,数据只在当前Cache里存在。比如说,内存里面X=5,而CPU核心1的Cache中X=2,Cache与内存不一致,CPU核心2中没有X。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/61/b19d638e9a37290c1ea1feebce2d7e61.jpg" alt="" title="MESI协议-M">
|
||||
|
||||
1. E独占(Exclusive):当前Cache中的内容有效,数据与内存中的数据一致,数据只在当前Cache里存在;类似RAM里面X=5,同样CPU核心1的Cache中X=5(Cache和内存中的数据一致),CPU核心2中没有X。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/e2/bb1fc473f93089a1414a4e01f888dae2.jpg" alt="" title="MESI协议-E">
|
||||
|
||||
1. S共享(Shared):当前Cache中的内容有效,Cache中的数据与内存中的数据一致,数据在多个CPU核心中的Cache里面存在。例如在CPU核心1、CPU核心2里面Cache中的X=5,而内存中也是X=5保持一致。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/03/b0/030bd3e97c93bdef900abc0c6a72b9b0.jpg" alt="" title="MESI协议-S">
|
||||
|
||||
1. 无效(Invalid):当前Cache无效。前面三幅图Cache中没有数据的那些,都属于这个情况。
|
||||
|
||||
最后还要说一下Cache硬件,它会监控所有CPU上Cache的操作,根据相应的操作使得Cache里的数据行在上面这些状态之间切换。Cache硬件通过这些状态的变化,就能安全地控制各Cache间、各Cache与内存之间的数据一致性了。
|
||||
|
||||
这里不再深入探讨MESI协议了,感兴趣的话你可以自行拓展学习。这里只是为了让你明白,有了Cache虽然提升了系统性能,却也带来了很多问题,好在这些问题都由硬件自动完成,对软件而言是透明的。
|
||||
|
||||
不过看似对软件透明,这却是有代价的,因为硬件需要耗费时间来处理这些问题。如果我们编程的时候不注意,不能很好地规避这些问题,就会引起硬件去维护大量的Cache数据同步,这就会使程序运行的效能大大下降。
|
||||
|
||||
## 开启Cache
|
||||
|
||||
前面我们研究了大量的Cache底层细节和问题,就是为了使用Cache,目前Cache已经成为了现代计算机的标配,但是x86 CPU上默认是关闭Cache的,需要在CPU初始化时将其开启。
|
||||
|
||||
在x86 CPU上开启Cache非常简单,只需要将CR0寄存器中CD、NW位同时清0即可。CD=1时表示Cache关闭,NW=1时CPU不维护内存数据一致性。所以**CD=0、NW=0的组合**才是开启Cache的正确方法。
|
||||
|
||||
开启Cache只需要用四行汇编代码,代码如下:
|
||||
|
||||
```
|
||||
mov eax, cr0
|
||||
;开启 CACHE
|
||||
btr eax,29 ;CR0.NW=0
|
||||
btr eax,30 ;CR0.CD=0
|
||||
mov cr0, eax
|
||||
|
||||
```
|
||||
|
||||
## 获取内存视图
|
||||
|
||||
作为系统软件开发人员,与其了解内存内部构造原理,不如了解系统内存有多大。这个作用更大。
|
||||
|
||||
根据前面课程所讲,给出一个物理地址并不能准确地定位到内存空间,内存空间只是映射物理地址空间中的一个子集,物理地址空间中可能有空洞,有ROM,有内存,有显存,有IO寄存器,所以获取内存有多大没用,关键是**要获取哪些物理地址空间是可以读写的内存**。
|
||||
|
||||
物理地址空间是由北桥芯片控制管理的,那我们是不是要找北桥要内存的地址空间呢?当然不是,在x86平台上还有更方便简单的办法,那就是BIOS提供的实模式下中断服务,就是int指令后面跟着一个常数的形式。
|
||||
|
||||
由于PC机上电后由BIOS执行硬件初始化,中断向量表是BIOS设置的,所以执行中断自然执行BIOS服务。这个中断服务是int 15h,但是它需要一些参数,就是在执行int 15h之前,对特定寄存器设置一些值,代码如下。
|
||||
|
||||
```
|
||||
_getmemmap:
|
||||
xor ebx,ebx ;ebx设为0
|
||||
mov edi,E80MAP_ADR ;edi设为存放输出结果的1MB内的物理内存地址
|
||||
loop:
|
||||
mov eax,0e820h ;eax必须为0e820h
|
||||
mov ecx,20 ;输出结果数据项的大小为20字节:8字节内存基地址,8字节内存长度,4字节内存类型
|
||||
mov edx,0534d4150h ;edx必须为0534d4150h
|
||||
int 15h ;执行中断
|
||||
jc error ;如果flags寄存器的C位置1,则表示出错
|
||||
add edi,20;更新下一次输出结果的地址
|
||||
cmp ebx,0 ;如ebx为0,则表示循环迭代结束
|
||||
jne loop ;还有结果项,继续迭代
|
||||
ret
|
||||
error:;出错处理
|
||||
|
||||
```
|
||||
|
||||
上面的代码是在迭代中执行中断,每次中断都输出一个20字节大小数据项,最后会形成一个该数据项(结构体)的数组,可以用C语言结构表示,如下。
|
||||
|
||||
```
|
||||
#define RAM_USABLE 1 //可用内存
|
||||
#define RAM_RESERV 2 //保留内存不可使用
|
||||
#define RAM_ACPIREC 3 //ACPI表相关的
|
||||
#define RAM_ACPINVS 4 //ACPI NVS空间
|
||||
#define RAM_AREACON 5 //包含坏内存
|
||||
typedef struct s_e820{
|
||||
u64_t saddr; /* 内存开始地址 */
|
||||
u64_t lsize; /* 内存大小 */
|
||||
u32_t type; /* 内存类型 */
|
||||
}e820map_t;
|
||||
|
||||
```
|
||||
|
||||
## 重点回顾
|
||||
|
||||
又到了课程尾声,内存和Cache的学习就告一段落了。今天我们主要讲了四部分内容,局部性原理、内存结构特性、Cache工作原理和x86上的应用。我们一起来回顾一下这节课的重点。
|
||||
|
||||
首先从一个场景开始,我们了解了程序通常的结构。通过观察这种结构,我们发现CPU大多数时间在访问相同或者与此相邻的地址,执行相同的指令或者与此相邻的指令。这种现象就是程序**局部性原理**。
|
||||
|
||||
然后,我们研究了内存的结构和特性。了解它的工艺标准和内部原理,知道内存容量相对可以做得较大,程序和数据都要放在其中才能被CPU执行和处理。但是内存的速度却远远赶不上CPU的速度。
|
||||
|
||||
因为内存和CPU之间性能瓶颈和程序局部性原理,所以才开发出了Cache(即高速缓存),它由高速静态储存器和相应的控制逻辑组成。
|
||||
|
||||
Cache容量比内存小,速度却比内存高,它在CPU和内存之间,CPU访问内存首先会访问Cache,如果访问命中则会大大提升性能,然而它却带来了问题,那就是**数据的一致性问题**,为了解决这个问题,工程师又开发了Cache**一致性协议MESI**。这个协议由Cache硬件执行,对软件透明。
|
||||
|
||||
最后,我们掌握了x86平台上开启Cache和获取物理内存视图的方法。
|
||||
|
||||
因为这节课也是我们硬件模块的最后一节,可以说**没有硬件平台知识,写操作系统就如同空中建楼**,通过这个部分的学习,就算是为写操作系统打好了地基。为了让你更系统地认识这个模块,我给你整理了这三节课的知识导图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c0/96/c0f2fe32b7b9753df692618cf5be2696.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
请你思考一下,如何写出让CPU跑得更快的代码?由于Cache比内存快几个数量级,所以这个问题也可以转换成:如何写出提高Cache命中率的代码?
|
||||
Reference in New Issue
Block a user