手写操作系统(5)——CPU工作模式与虚拟地址(上):https://developer.aliyun.com/article/1508574
长模式
长模式最早是由AMD指定的标准。相比于保护模式,长模式进一步将地址拓宽到了64位,并弱化了内存的段管理机制,采用页面管理的方式并引进了MMU进行内存地址转换。
内存寻址
长模式下的寄存器最大可使用64位,最小可使用8位,如下:
长模式下段描述符格式如下:
在长模式下,CPU 不再对段基址和段长度进行检查,只对 DPL 权限进行相关的检查。
开启长模式
来看看如何实现保护模式切换到长模式:
- 准备长模式全局段描述符
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 ;段长度和段基址都是无效的填充为 0,CPU 不做检查。 ;但是上面段描述符的 DPL=0,这说明需要最高权限即 CPL=0 才能访问。 d64_dsc:dq 0x0000920000000000 ;64位数据段 ;数据段的话,G、D/B、L 位都是无效的 eGdtLen equ $ - null_dsc ;GDT长度 eGdtPtr:dw eGdtLen - 1 ;GDT界限 dq ex64_GDT
- 准备MMU页表
mov eax, cr4 bts eax, 5 ;CR4.PAE = 1 mov cr4, eax ;开启 PAE mov eax, PAGE_TLB_BADR ;页表物理地址,假设页表数据已经准备好了 mov cr3, eax ;上面的操作是为了指定页表位置
提一句,长模式有关内存的保护都由MMU来进行,而MMU主要根据页表对内存地址进行转换,CPU的CR3寄存器指向页表。
- 加载GDT到GDTR寄存器
lgdt [eGdtPtr]
- 开启长模式
;开启 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
- 加载段寄存器,刷新CS寄存器
jmp 08:entry64 ;entry64为程序标号即64位偏移地址
同保护模式的作用。
虚拟地址
基本概念
在多道程序的场景下,关于内存有四个核心问题:
- 多个程序之间如何保证内存地址不冲突?由操作系统决定还是多个程序来决定?
- 如何保证多个程序之间不会访问彼此的内存?保护模式?
- 如何解决内存容量的问题?即内存装不下了。
- 每台计算机的内存是不一样的,如何保证程序在这些计算机上兼容运行?
解决上述问题的一个方案是:每个程序都享有一个从0到最大内存的地址空间,这个地址是程序之间独立的,每个程序所私有的。
上述方案就是虚拟地址空间。
如何形象化地理解虚拟地址空间?关键就在于映射。
假设实际物理内存地址空间大小从0~999。
我们让程序A、B、C觉得自己能够使用的内存地址空间也为0~999,它们可以随意访问这些地址。
等等……这样三个程序难道不会冲突么?要是三个程序同时访问599地址怎么办?
关键来了,那就是地址映射表。三个程序分别有三个表,其中索引为每个程序访问的地址,结果为实际物理内存地址,假设程序A的映射表如下:
索引(虚拟地址) | 结果(实际地址) |
599 | 123 |
123 | 599 |
… | … |
每个程序都有一个上面这样的映射表,表示自己的地址与实际地址的一一对应关系,我们将这个映射表称为页表。不用考虑会不会出现两个程序映射的实际地址是一样的情况,放心,不存在的,操作系统已经协调好嘞!
PS:程序自身的地址其实是由链接器生成的,链接阶段就是对多个模块进行地址的重排和引用。
页表
值得注意的是,如果页表中存储的是每一个虚拟地址到物理地址的映射关系,那么整个内存只能全部用来存储这个映射关系了。
整个地址空间存储的都是每个虚拟地址对应的实际地址,那么内存还有空余么?
我们采用一种折中的方式,**将地址空间分成一个一个小块,**每一块的大小可以为1KB、2KB、4KB甚至1GB等,其中小块也称为页,这就是分页模型。将原本每一个地址与地址的映射关系,改为虚拟也与物理页的对应关系,于是这样的映射关系如下:
MMU下一节会讲,简单理解它就是一个加快映射的硬件。
经过上面的讨论,也许会认为分页模型就是简单的一张表,从而实现虚拟地址到物理地址的转换,但实际设计中却并没有这么简单。
假设有4GB的内存(32位机),将分页大小设为4KB,于是一共有1M个内存页。
那么存储这些对应关系需要多少内存?
如果一个页表映射关系4B,1MB个内存页映射就需要4MB连续的内存空间,当以后内存地址空间增大,用于存储映射关系的空间还会继续膨胀,浪费内存空间。
因此,实际的页表使用一般是采用分级的思想,类似于查字典,先查部首或者拼音首字母,再查询接下来的部分,如下图是一个三级页表的概念图:
以一次三级页表查询为例,将虚拟地址分为了四段:
- 第一次,MMU使用虚拟地址第一段的中间页目录索引去访问顶级页目录,获得了中级页目录的地址;
- 第二次,MMU使用第二段的页表地址索引去访问中级页目录,获得了页表的地址;
- 第三次,MMU使用第三段的物理页地址去访问页表,获得了物理内存页的地址;
- 第四次,MMU使用第四段的偏移地址在物理内存页找到真实的内存地址,从而完成一次内存访问。
从上述流程可以看出,多级页表有一个显著缺点——增加内存访问次数。
原本我只想访问一个内存地址,但是由于三级页表的存在,我需要先进行多次中间页表的获取,才能够进行最终的内存访问。
它的优点也很明显,每一级页表所占用的空间减小,可以离散存储页表,并且在某种程度上节省页表的内存空间。
PS:因为对于一些页,甚至不需要构建它的完整页表,因为没有使用。
比如hello world程序,这样一个几kb的程序却需要4MB的内存空间是很浪费的。如果采用二级页表,那么一级页表只需要4KB的空间用来索引二级页表的地址,像hello world这样的程序可能只需要一个物理页,那么只需要一条记录就可以了,故对于二级页表也只要4KB就足够了,而一级页表中的其他表项可能为空,所以这样只需要8KB就能解决问题。
但是如果因为节约内存而增加内存访问次数,似乎总感觉有点不得劲……
工程师为了解决这个问题,给MMU配了一个伙伴——TLB(快表,或者叫做页表缓存、旁路转址缓存),它是CPU的一个cache,主要工作就是缓存最近使用的页表,不用再次去查询内存,由于cache速度跟CPU相差无几,因此页表的查询效率有了很大的提升。
MMU
上面提到了MMU,那么MMU到底是什么?
MMU译作内存管理单元,是一个硬件设备,它大都集成在CPU中,也可以作为独立芯片置于CPU与总线之间,其主要工作是通过虚拟地址与页表(地址转换关系表)获得物理地址。
MMU只能在保护模式或者长模式下才能够开启,在保护模式下也必须使用保护模式平坦模型,使得分段形同虚设,详细内容见之前的保护模式,分段模型下的分页模式如下:
接下来来看看在保护模式和长模式下的MMU是怎么工作的,如何完成地址转换?
保护模式
保护模式下CPU位数为32,地址空间从0~4GB-1。
假设分页大小为4KB,采用二级页表的方式,于是32位的虚拟地址就被划分为3段:页目录索引、页表索引、页内偏移。
它们之间的对应关系为:页目录中有1024个页表,每个页表中有1024页,每页的大小为4KB,则空间为1K*1K*4K=4GB。
保护模式下,页目录存储在CPU的一个CR3寄存器中,MMU正是据此找到页目录的:
CR3寄存器中值、页目录表、页表这三者分别的格式如下:
仔细分析上图三个格式可以看到低10位被用作页面相关属性,这是由于三者每一个都是32位4字节,1024项正好是4KB,从而在物理地址中4K对齐,从而低10位其实不影响其地址计算,可以另做它用,这与保护模式的段选择子的设置有异曲同工之妙。
若是将分页大小设为4MB,则只有一级页表,同样要进行4K对齐,方便查找与设置页面属性。
长模式
长模式下4K的分页模型,将64位的虚拟地址分为了6段:保留段、顶级目录索引、页目录指针索引、页目录索引、页表索引、页内偏移,也就是四级页表结构,见下:
从上图可以看出,每个目录项有512个表项,每个表项的大小为64位8字节,总共可以表示的大小为:0.5K0.5K0.5K0.5K4K=0.25P!但其实实际使用中没有利用这么大的内存,只是有这个潜力。
来看看此时的CR3、顶级页目录项、页目录指针项等的格式:
总之,不管是保护模式还是长模式,在使用分页模型下的虚拟地址时,都是根据自身位数以及层级的关系来确定最终的物理地址的,为方便查找,往往使用4KB对齐的方式。
页缺失
有没有可能MMU出现转换失败?
比如页表中不存在对应关系、或是权限不足等情况?
这些情况是可能存在的,当出现这种情况时,MMU会触发中断——页中断的东西,而后让CPU来处理相应的逻辑,当逻辑处理完毕之后,再由MMU来进行地址转换,这个部分留到第7章来讲。
进程隔离
在分页模型下,由于没有了分段模型对各个段的保护,那么进程之间是如何进行地址隔离的呢?
——每一个进程在运行的时候,页表数据都是不一样的!
也就是说每一个进程都有一个独立的页表,当进程切换时,页表数据也会随之切换,这种方式就将进程的地址进行了隔离,从而不会造成冲突。
如何获取内存视图?
前面我们一直在讲内存相关的知识,那么当电脑开机的时候,如何获取当前电脑中的物理内存状态呢?比如如何知道物理内存有多少GB?值得注意的是,给出一个物理地址并不能准确地定位到内存空间,内存空间只是映射物理地址空间中的一个子集,物理地址空间中可能有空洞,有 ROM,有内存,有显存,有 I/O 寄存器,所以获取内存有多大没用,关键是要获取哪些物理地址空间是可以读写的内存。
在第4章介绍计算机启动的过程中提到,BIOS启动阶段会设置计算机的中断向量表,恰好其中有一个中断就可以用来获取内存视图,即INT 15H,该中断需要在实模式下运行,因为切换到保护模式时,中断向量表会重设(变成中断门了):
_getmemmap: xor ebx,ebx ;ebx设为0 mov edi,E80MAP_ADR ;edi设为存放输出结果的1MB内的物理内存地址loop: mov eax,0e820h ;eax必须为0e820h;输出结果数据项的大小为20字节=;8字节内存基地址,8字节内存长度,4字节内存类型 mov ecx,20 mov edx,0534d4150h ;edx必须为0534d4150h int 15h ;执行中断 jc error ;如果flags寄存器的C位置1,则表示出错 add edi,20;更新下一次输出结果的地址 cmp ebx,0 ;如ebx为0,则表示循环迭代结束 jne loop ;还有结果项,继续迭代 reterror:;出错处理
上述迭代过程中的中断每次执行都会输出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——TPL快表。
现代计算机,由于程序局部性的存在以及CPU处理速度与内存速度的脱节,cache已经成为标配,并且在多核CPU中,cache还是分级的:
1级2级cache是单个核心独有的,3级cache是多个核心共享的。但是这也会引发一个问题——数据一致性,比如两个核心都对某一个数据进行了修改,由于cache的存在就可能会造成数据不一致。
为了解决这个问题,出现了MESI和MOESI协议。以MESI协议为例,它将cache中的内容分为了四种状态:
- M(modified,修改的)
- E(Elusive,独占的)
- S(Shared,共享的)
- I(Invalid,无效的)
更详细一点(来自):
最开始只有一个核读取了A数据,此时状态为E独占,数据是干净的;
后来另一个核又读取了A数据,此时状态为S共享,数据还是干净的;
接着其中一个核修改了数据A,此时会向其他核广播数据已被修改,让其他核的数据状态变为I失效,而本核的数据还没回写内存,状态则变为M已修改;
等待后续刷新缓存后,数据变回E独占,其他核由于数据已失效,读数据A时需要重新从内存读到高速缓存,此时数据又共享了。
X86 CPU默认将cache关闭,可以通过将CR0寄存器的CD设置为0(打开cache)、NW设置为0(维护内存数据一致性)。
mov eax, cr0 ;开启 CACHE btr eax,29 ;CR0.NW=0 btr eax,30 ;CR0.CD=0 mov cr0, eax