进程的虚拟地址在内核中通过三/四级页表到达物理地址。 而内核的虚拟地址在NORMAL部分算是逻辑地址只是线性的映射。
这两者有什么关系么?或者说内核态为什么还要有虚拟地址存在?
开场白:
按照以前书上,或linux内核2.6 内核的逻辑地址 与 用户空间逻辑地址 (逻辑地址有时也被叫虚拟地址) 都是位于 0x00000000~0xFFFFFFFF 这段虚拟地址空间 ,其中用户空间逻辑地址 位于 逻辑地址 0x00000000~ 0xBFFFFFFF ,共3g , 内核逻辑地址是 0xC0000000~0XFFFFFFFF,共1g。而且这个地址空间对于每个进程来说都是独立的。
这里解释一下独立是什么含义。
看一下这个图
每个进程看到的 地址空间都是一样的,比如.text 都是从0x80048000 开始,然后用户栈都是从0xBFFFFFFF 向低地址增长,内核地址空间都是0xC0000000~0xFFFFFFFF。
每个进程看到的 地址空间都是一样的,比如.text 都是从0x80048000 开始,然后用户栈都是从0xBFFFFFFF 向低地址增长,内核地址空间都是0xC0000000~0xFFFFFFFF。
但是,每个进程的逻辑地址0x08048000 ~ system break 以及stack中对应的内容应该是不一样的(除非两个共享地址空间,那就是线程了)。 那问题来了,不同进程 有相同的逻辑地址,但是却又不同的内容,这怎么实现呢?
这就要靠 每个进程的的 页表了。每个进程都有一个自己的页表,使得 某逻辑地址对应于某个物理内存。 正因为 每个进程都有一个自己的页表,使得相同的逻辑地址映射到 不同的物理内存。对于线程 ,它也有自己的页表,只是页表的 逻辑地址 映射到的物理内存相同。
那进程的页表是怎样的呢?首先,内核本身就有一个页表了,而且 对于normal_area 都是一一映射到物理内存的,具体可查一下网上资料关于低端内存和高端内存。 这里可以不用知道到底怎么映射,只需要知道 内核中有一个页表, 能把 内核逻辑地址映射到内核物理地址。
这个内核逻辑地址对于每一个进程来说都应该是一样的,所以 ,在创建进程表时候,就可以直接拷贝该内核的页表,作为该进程 的页表的一部分,另外对于 该进程的用户部分的页表,可以简单地理解为 把逻辑地址 映射到一个 空闲的 物理内存区域。
每当切换到另一个进程时,就要设置这个进程的页表,通过 设置MMU的某些寄存器 ,然后 MMU 就可以把 cpu 发出的逻辑地址 转化为 物理地址了。
虽然看起来 ,该进程 拥有 0x00000000~0xFFFFFFFF 的 逻辑地址空间 ,但是0xC0000000~0xFFFFFFFF 这段是内核的逻辑地址 ,在用户态时访问会出错,权限不够,如果想访问,需要切换到内核态 ,可以通过 系统调用等。系统调用代表某个进程运行于内核,此时,相当于该进程可以访问0xC0000000~0xFFFFFFFF 这个地址了(但实际上 只能访问 该进程的某个8KB的内核栈 ,这里不是很确定,因为每个进程都有自己独立的8KB的内核栈,你应该是不能访问别的内核的内核栈),此时可以把用户空间逻辑地址 在 内核逻辑地址 之间 进行内存拷贝。
另外 0X00000000 ~0x08048000 是不能给用户访问的,这里面是一些C运行库的内容。访问会报segement fault 错误。
另外linux 对只读的内容可以共享,在物理内存中只有一份拷贝。这样,即使在逻辑地址上看起来有很多c库等运行库在里面,但整个内存只有一份拷贝,当然,对于可写的数据段,每个进程都应该有独立一份。
步入正题:
1. 现在 Linux 内核是4级页表结构,3级页表的时代是10年前了。 X86_64 架构下,无论 Intel 还是 AMD 的 CPU, 都是四级的硬件页表,所以软件层面的页表至少要4级(否则,进程访问的空间将受限, 因为有一级页表被固定住了,所以3级页表时代,X86_64 只能访问 512GB 空间, 而 X86_64 的设计可访问空间达到 131 072( = 2^47) GB。打个比方就是,省,市,区,县 四级行政规划,硬要嵌套进三级规划,只能表达市,区,县三级,省一级给固定住了, 访问范围缩小了)。
2. 不过你会问:i386 只有三级硬件页表:PUD -> PMD -> PTE, 怎么嵌入四级软件页表结构? 答案就是虚设一层。打个比方:北京是省级行政单位,如果要按省,市,区,县结构来表达某县,就是: 北京(省)北京(市)XX区XX县, 有一层完全就是占个位而已。有兴趣了解 Linux 页表的变迁历史,可以看我之前写过的文章: Linux内核4级页表的演进
3. 内核空间,用户空间的地址都是虚拟地址,都要经过 MMU 的翻译,变成物理地址。用户空间的虚拟地址,就是按前面所述的走四级页表来翻译。 内核空间虚拟地址是所有进程共享的,重要的是,从效率角度看, 如果同样走四级页表翻译的流程,速度太慢;于是,内核在初始化时,就创建内核空间的映射(因为所有进程共享,有一份就够了),并且,采用的就是线性映射,而不是走页表翻译这种类似哈希表的方式。这样,内核地址的翻译,简化为一条偏移加减指令就行,相比走页表,效率大大提高(不过,内核空间并非完全不用页表,此处讲原理所以简化,详细的看尾注).
4. 至于为什么用户空间不能也像内核空间这么做,原因是用户地址空间是随进程创建才产生的,它的页面可能散布在不同的物理内存中,无法这么做。另外,走页表的过程,不止是翻译的过程,还是一个权限检查的过程,对于不可控的用户态地址,这安全性检查必不可省。而内核空间,只有一份,所有可以提前固定下来一片连续的物理地址空间,按线性方法来映射。这是很正常的优化方法。
5. 那么问题来了,在 Linux 刚引入的时候, i386 4G 的进程空间典型的是 3G user + 1G kernel 的划分,这教科书上都有说。 那按前面的线性方法, 1G 内核空间,只能映射 1G 物理地址空间,这对内核来说,太掣肘了。所以,折衷方案是, Linux 内核只对 1G 内核空间的前 896 MB 按前面所说的方法线性映射, 剩下的 128 MB 的内核空间, 采用动态映射[1]的方式,即按需映射的方式 ,这样,内核态的访问空间更多了。 这个直接映射的部分, 就是题主所说的 NORMAL 区, 就是所谓低端内存。到了 64 位时代, 内核空间大大增大, 这种限制就没了,内核空间可以完全进行线性映射,不过,基于[1]的缘故, 仍保留有动态映射这部分。
[1] 动态映射不全是为了内核空间可以访问更多的物理内存,还有一个重要原因: 当内核需要连续多页面的空间时,如果内核空间全线性映射,那么,可能会出现内核空间碎片化而满足不了这么多连续页面分配的需求。基于此,内核空间也必须有一部分是非线性映射,从而在这碎片化物理地址空间上,用页表构造连续虚拟地址空间,这就是所谓vmalloc空间。
结束语:
从OS实现的角度做个补充(已上都是从硬件实现的原理为出发点的)。
首先的首先,开启分页机制后内核也不能绕过该机制,所以内核也要有虚拟地址。
首先,进程的虚拟地址和内核的虚拟地址有一点不同:内核的虚拟地址如果触发了缺页中断整个系统就panic了,而进程的虚拟地址不是这样。为什么这样设计呢?这是因为如果不做此限制,内核上下文中触发缺页中断后进行中断处理时还可能继续发生嵌套的缺页中断,如此会一直嵌套。
其次,怎么避免在内核上下文中不产生缺页中断呢?最简单的方法就是把所有的物理内存映射到内核的某段虚拟地址空间,从此段空间内malloc的虚拟地址都已经映射好了不会触发缺页中断。其他的内核的虚拟地址访问产生的缺页中断,肯定是代码错误或者是硬件错误,只能panic。而最简单的映射方法就是物理地址加上固定的偏移就得到虚拟地址,偷懒的做法
最后,实际的OS的实现上(比如FreeBSD)不会按照4k的页大小把所有的物理地址都预先映射出来,因为这样需要浪费不少内存作为页表。实际上是按照2M(64位系统)的页做映射,称之为direct mapping,需要做4k页面映射的时候,除了分配出一页,还可能需要申请一页作为页表,并把对应的物理地址写入作为页表的这一页的对应PSN处。如果没有direct mapping,上述做法的实现会很复杂。