相信很多人都知道Windows页表自映射一说,也晓得Linux内核的一一线性映射。然而很多人也仅仅就是知道而已,记住一个结论比理解一个原因要简单得多。
上周末,有人极具挑衅态度的问我能否分别用一句话描述它们,我承认我不是布道者,也难以说出”道可道,非常道“的玄语,但我十分赞同老子的观点,能说出来的道就不是大道,虽难以说出,但却可以解释,说到解释,那就是越详细越好了,冗长并不总是贬义词。在这样的心理慰藉下,才会有以下的文字,虽已深更,却不无聊...
本文基于32位Intel体系结构讨论!怕打字跟不上思维,有些地方只好缺失严谨性,比如在应该写下”1G内存或者2G内存(后者处在2G/2G模式下)“的时候,我会直接写”1G内存“,但是并不总是这样。
1.虚拟地址空间概述
现代操作系统上,物理内存不再对程序可见。也就是说,程序指令本身以及其访问的任何数据都处在虚拟地址空间,机器通过一个叫做MMU的机构将其映射为真实的物理内存页面。
程序直接访问的地址为虚拟地址,访问地址(也包含取指等)会触发MMU工作,MMU自动将访问的地址映射到真实的物理地址,如果没有分配物理页面,将会触发缺页异常,系统捕获该异常,之后默默地分配一个页面,重新发起由于没有分配页面而失败的访问,所有这一切都是自动且默默地发生的,对应用程序是完全透明的。页面调度这个机制完美地迎合了程序访问的局部性原则。
虚拟地址填充整个32位地址空间,为了管理的高效性,很多的系统将这32位的地址空间拆分成了两个部分,即用户空间和内核空间。但是记住,这个拆分并不是必须的!所谓的内核空间和用户空间在Intel体系上表现为特权环0和特权环3。根本上的意义是,一个任务有一个满32位的地址空间,如果某个系统将进程32位的地址空间拆成了两个部分,那么则说明该任务进程本身包含内核特权环0的部分,如果没有拆分,那么就说明该任务进程没有内核部分。Remenber,一个满32位的地址空间使用一套MMU页表!
如果一个进程没有内核部分,当系统中断,系统异常,或者该进程本身调用系统调用的时候,怎么办呢?不要被现有的Linux,Windows的实现迷惑了,再次声明,拆分地址空间并不是必须的!如果在没有拆分地址空间的情况下出现上述情况,很简单,切换MMU页表即可,也就是说,系统单独维护一个满32位的内核地址空间为所有的满32位地址空间的进程服务!
说了这么多,该来点实例了,我们熟知的Linux,Windows系统,可以支持3G/1G模式,意即满32位的进程地址空间中,用户态占3G,内核态占1G;可以是2G/2G模式,解释同上,这些都是拆分地址空间的情况,这些情况在进入内核态的时候叫做陷入内核,因为即使进入了内核态,还处在同一个地址空间中,并不切换CR3寄存器。还有一种模式是4G/4G模式,即不拆分地址空间的情况,内核单独占有一个4G的地址空间,所有的用户进程独享自己的4G地址空间,这种模式下,在进入内核态的时候,叫做切换到内核,因为需要切换CR3寄存器(切换MMU页表),所以进入了不同的地址空间!
说到这里,应该知道为何文档上说4G/4G模式虽然解放了内核地址空间,使其可以容纳更多的管理机构,然而会付出一点小的代价了吧,所谓的代价就是切换CR3以及所有因此而引发的副作用!
2.Windows地址空间
一直我都以为,一个好的开始会带来令人愉快的结果,一个不好的开始会让人很累!确实是这样。很多人想理解Windows页表自映射,然后去google,去百度,得到的结果几乎都是在解释以下这个宏定义:
- #define MiGetVirtualAddressMappedByPte(PTE) ((PVOID)((ULONG)(PTE) << 10))
于是很多人都在纠结于那个魔术字10,画了N个图,但是基本都是在抄袭Dave Probert很久前写的一篇文章《Windows Kernel Internals II Processes, Threads, VirtualMemory》。关键是最终还是没有讲明白。本来是一个很简单的事情,被却无端复杂化了。我觉得就是没有找到一个好的开始。什么是好的开始呢?
我认为我找到了,那就是WIndows进程虚拟地址空间的布局!如果这个布局设计的原则你搞明白了,那些宏你自己也能写出来了!不管怎么说,在看图之前,还是要先说一下Windows地址空间设计的原则,那就是:每个进程拥有自己单独的满32位地址空间!不管是3G/1G模式,还是2G/2G模式,还是4G/4G模式,每个进程都是独立的虚拟地址空间,这也是现代操作系统的设计原则,并非Windows独创。在这些单独的地址空间中,所有进程拥有相同的映射规则,比如虚拟地址XX不管在进程A还是在进程B,映射的都是自己的进程控制块PCB...如下图所示:
其实知道了这个,悟性好的同学可能已经知道自映射的设计细节了,但是我还是继续下去吧,以免让人家说我虎头蛇尾。
页表自映射,一个神奇的映射方式,为什么呢?它可不仅仅是为了节省4K的内存空间,虽然它确实可以节省4K的内存空间。最要紧的是,页表自映射机制提供了一套内核空间直接访问任意页面的高效方式。在讲页表自映射前,我先说一下WIndows的线性映射机制,即“页表项虚拟地址和进程地址空间虚地址页面的线性映射关系”,简称页表的线性映射。(注意和下一节中要讲的Linux的内核虚拟地址和物理地址的线性映射相区分)
Windows页表的线性映射理解起来很简单。Windows的所有页表处在地址空间的固定部分且按照虚拟地址连续分布,那么所有的页表项的虚拟地址也是连续分布,从最开始处,连续的页表项负责连续的虚拟地址的映射,如下图所示:
注意,直到现在,我都没有提到页目录,因为页目录纯粹是为了多级页表而引入的,Windows只是借助了页目录的概念,无形中用将页表映射到虚拟地址空间而取消了页目录带来的4K开销。Windows只是在地址空间的固定位置开始连续映射所有的页表,这些页表当中存在一个页表的页表,即页目录,页目录就这样湮没在页表中了。且往下看!
有了这个基础做依托,后面的自映射以及神奇的宏就是一个自然而然的结果了。为何这么说呢?分别来说。
2.1.自映射
按照32位X86的MMU规范,采用多级页表机制,页表通过页目录所引,因为页目录本身也是一个页表,它是页表的页表。页目录常驻物理内存在进程初始化时分配,大小一个页面,由于它也是一个页表,而页表由页目录索引,那么页目录这个页面本身也应该有一个指向它自身的映射项!
假设该页目录项在虚拟地址空间的地址为B1|B2|B3,其中B1是高10位,代表页目录项的索引,B2为中间10位,为页表项索引,B3为页内偏移。实际上,我们可以看到,由于页目录是页表的页表,也是一个页表,所以高10位索引的页目录项指向该页目录本身,由于现在要定位的物理页面就是页目录,因此中间10位索引指向的页面也是页目录本身,一个进程的页表虚拟地址和整个地址空间是线形映射的,不能有两个表项指向同一个页面,因此B1和B2是相同的!最后,B3指向该目录项在本页的偏移索引!如下图所示:
通过以上的分析和图示,我们可以明显看出,B1,B2,B3的关系,即B1=B2=B3,只要满足这个,那就是所谓的自映射!这就明确导出了自映射机制,原来它只是Windows如此组织虚拟地址空间的一个结果罢了!
在讨论自映射的时候,并没有谈到页表虚拟地址和地址空间虚拟地址线性映射的影响,实际上有了这个线性映射的前提,我们可以根据自映射页目录项的虚拟地址B1|B2|B3导出所有页表的起始虚拟地址,这个的关键就是B3,虽然B1,B2和B3都是相等的。B3是页内偏移,也就是说第B1个页目录项处在页目录物理页面的第B3项这个位置,而这个位置就是它本身!换句话说,这个页目录是第B3个页表(永远记住,页目录是一个页表)!那么就是说在它前面有B3-1个页表,由于页表的虚拟地址是连续的,那么就可以推算出页表开始的虚拟地址,首先算出页目录的虚拟地址,其实根本不用算,将B2,B3置0即是,即B1|0|0,那么页表开始的虚拟地址就是:B1|0|0-1K*(B3-1)。我们在Windows中看到的0XC0300C00这个自映射页目录项地址只是一个实现特例,实际上其它的地址也是可以的!
2.2.神奇的宏
有了上述的基础,那几个神奇的宏就不在话下了。说白了就是页表虚拟地址三元组和虚拟地址空间页面虚拟地址三元组之间的关系。分析一下页表项的虚拟地址,想找到它的物理地址,首先要找到它的页表,由于页表的页表是页目录,因此页表项的高10位一定定位到了页目录,也就是第B1个页表上,接下来中间10位索引到了该页表项所在的页面,即第B2个页表所在的页面,低10位B3给出该页表项在该页面的偏移,最终定位到物理位置。由最上面的Windows虚拟地址的线性映射图可以看出,每一个页表对应1K个页面,那么第N个页表一定可以索引从N*1K页面到(N+1)*1K之间这1K页面中的所有页面,具体哪个页面可以通过页表项虚拟地址的B3部分给出。B3的含义是该页表项虚拟地址从该页表开始处虚拟地址的字节偏移,这个偏移按页面为单位就是1K页面中该页表项指向的页面的虚拟地址的B2部分!因此就可以直接导出:
MiGetVirtualAddressMappedByPte(PTE)
这个宏定义了!一切就在上面那幅虚拟地址线性映射的图!
由此可见,所有的所谓奇妙的东西都是Windows虚拟内存布局设计的自然而然的结果。Windows每进程独享全部虚拟地址空间的设计推演出规整的地址空间布局,从而为在内核中或者其它进程中高效方便且直接的操作页表内容以及访问内核虚拟地址空间中的页面。
然而如果按照一种正常的方法,则页表属于MMU机构,并不对进程开放,因此也不必映射在虚拟地址空间。但是由于操作系统操作的地址都必须是虚拟地址,所以在操作页表本身的时候就会陷入两难境地,因此就有一种临时映射的方案,即在操作页表的党史将页表所在页面临时映射为一个虚拟地址空间的虚拟地址,操作完毕后立即解除映射,这种方式很自然,但是并不绝对,毕竟它迎合了一种论调,即页表不必处在地址空间,除了MMU机构以及其代理,没有谁会触及页表。
在继续同样冗长的Linux内存布局之前,我来总结一下Windows的方式,记住,自映射以及神秘宏都不是什么了不起的,其背后的根本就是Windows采用了独占的虚拟地址空间的方式,既然采用了这种方式,接下来自然而然的结论就是所有页表会被安排在一个连续的虚拟地址段,由它们来映射整个虚拟地址空间,最终的结论就是自映射!既然页表的虚拟地址是连续的,页目录作为页表的页表也处在其中并且被分配了物理页面,CR3寄存器指向了该页面的物理位置。既然被分配了物理页面,一定有一个页表项指向了它,该页表就是这个页目录本身,这就是自然而然的自映射。最终有了这些之后,那些被认为i神秘的宏也不再神秘。
Windows统一且规则的虚拟地址空间管理直接影响到了其内存管理API接口,大家应该都知道Windows内存分配的保留,提交两阶段吧!
Linux采用了一种截然不同的方式对待页表以及其它的管理机构。我们将看到,地址空间布局的不同,将会多么激烈得影响内存映射的方式啊!
3.Linux地址空间
和共享库的思想一样,3G/1G模式下的Linux进程的地址空间对于内核空间部分是共享的,也就是说所有的进程在内核中操作的地址空间是同一个。虽然每一个进程都有单独的页表,但是这些页表的内核部分,即高1G或者2G的部分的内容是一致的。
现在的问题是,虚拟地址空间资源宝贵吗?其实你大可不必认真地对待一切虚拟的东西,画出来的饼是不能充饥的!微软深知此道,于是直接将4M的页表映射进了虚拟地址空间而不会觉得浪费了4M的内存!实际上物理内存该用多少还是多少,很难用完这4M。由此看来,虚拟地址空间是可以浪费的,只要别落实到物理内存,一切就都无所谓。
然而,Linux的虚拟内存映射方式让人不能不考虑虚拟内存的开销,把虚拟的东西带到前台的不是虚拟内存一开始便分配了物理内存,而是管理成本的开销。Linux共享内核地址空间的开销在于,所有的进程以及操作系统本身的管理机构都要使用这1G或者2G的地址空间(不考虑4G/4G模式),接下来我来说明一下Linux内核地址空间的设计,部分信息来自于早期的Maillist以及Linux早期的blog,我比较喜欢从历史中找根本原因。我不喜欢那种当有人提问为何Linux采用一一映射时信口开河“为了高效”的那种做派。
由于每一个进程的地址空间的内核部分都一样,就不能像Windows那样设计固定的地址空间区域,以页表为例,如果你将a-b这段虚拟地址空间给了进程A的页表,那么进程B就不能使用了,注意,它们的内核部分是共享的!!即使你把一个很大的范围比如X-Y给了所有的进程的页表,那么由于进程数量的不可预估性,要么会带来空间不足,要么就是严重的地址空间碎片。因此所有的地址映射都必须是离散分布,地址不固定且不分类的。也就是说根本就不能做到Windows那样的布局方式,另一方面,由于共享内核地址空间,真的不便于将每一个进程的页表虚拟地址都在该空间找一个位置,如果真的这样,1G或者2G的空间瞬间就用光了,要知道,一个进程的页表需要4M的空间啊!如何做呢?
页表本不应该被映射在虚拟地址空间里面,它属于MMU管理机构,不属于进程本身的上下文,因此Linux最初设想的方式更加自然,即不对页表进行虚拟地址映射,同样的道理,也不对诸如task_struct,page这类结构体进行映射,原因在于它们都属管理机构的成员,不属于进程地址的上下文。然而无论怎样都绕不开32位保护模式体系机构给设置的机关或者障碍。你无论如何都要映射,因为必须通过MMU来访问物理内存!现在的问题就是怎么来映射的问题。
理论上的解决方案是简单的,页目录物理页面是存在的,只是页表以及进程需要的物理页面不一定被分配,那就靠缺页异常来处理。然而事实上,有些管理数据结构是要常驻内存的,比如中断处理的代码等,另外页目录也要常驻,这样一来实际上在这些常驻的页面生命周期内,它们的映射就是固定的。很多的管理结构的生命周期就是操作系统的生命周期...该做权衡的时候到了,预分配物理页面,然后映射到连续的虚拟地址空间即可,为了不把物理内存拆散,连续的分配物理内存就成了唯一的选择,这就是Linux的一一线形映射!
关键是预分配多少连续物理页面比较合适,这个问题导致了Linux的最精彩的设计!这个设计就是将全部的前1G物理内存全部一一线性映射到高1G的内核地址空间(如果2G/2G模式则是映射2G物理内存),这样就可以随便访问了,系统陷入内核,访问内核空间的时候,实际上映射的物理地址是虚拟地址减去3G,映射这1G内存的这些页表会保存在一系列连续的物理页面上,访问它们的时候,其虚拟地址就是物理地址加上3G。因此,Linux内存映射的本质出来了。
所有的前1G(或者前2G)的物理内存全部纳入管理范畴,不足1G的按实际的算。这些物理页面线性映射到虚拟地址空间的最上面1G的范围内。这并不是说用户进程就不能使用这些物理内存了,要知道在当时Linux早期那个年代,是不可能有那么多的物理内存的。一般都不会到512M。这些物理内存可以映射在地址空间的任意地方,如果用户进程需要物理内存,比如缺页异常调页的时候,如果处在1G的范围内的物理内存没有被内核使用,它便可以被分配给用户进程,它的位置便填充了用户进程的用户态的页表项中,注意,此时并不清除其在内核页表项的映射,也就是说,通过内核一一映射的方式,还是可以访问到的,这就是妙处所在!也就是说内核态可以通过一一映射的方是访问任意前1G的物理内存,即使该物理页面被分配给了某个用户进程也是如此,靠内核的其它管理机制来判断页面是不是已经被分配给了内核关键数据结构或者用户进程,比如伙伴系统,以及伙伴系统之上的空闲内存链表等,在为进程分配页面的时候,内核本身肯定知道该页面有没有被内核数据结构使用。
内核一一线性映射的意义在于,内核空间可以用一种直接的方式来访问1G范围内的物理内存,虽然也是通过MMU,但是看样子这种例行公事敷衍得好精彩!一一映射并不影响用户进程对内存的使用,内核空间的管理机构无论如何也用不了1G的内存。在Linux这种共享内核地址空间的系统中,一一映射是一种非常巧妙的方式,注意,虽然是一一映射,感觉好像是一下子分配了1G的物理内存,太浪费了,但是分不分配是操作系统说了算的,即使这些一一映射的页表项的存在位一直都是1,只要页面没有被内核引用,这些页面还是可以分配给用户进程的,只需要填写一下进程页表的私有的用户空间部分的某个页表项即可!因此内核空间的一一映射就像是进程私用用户空间映射的更高级别的映射,一个页面可以同时被用户进程和内核的一一映射所映射。
3.1.问题和对策
好的设计都有一个微小简单的核心框架思想,然后通过局部调整来适应不同的场景。Linux内核地址空间和前1G物理内存之间一一线性映射就是这个核心,然而它有一些问题。
问题1:一一映射的结果就是连续的物理页面的虚拟地址也是连续的,但是有些内核数据结构并不需要物理内存的连续,1G空间内的连续页面数量毕竟太有限了,不需要连续页面的数据结构占据了太多的连续页面
问题2:如果严格按照一一映射来映射,就意味着在内核空间无法访问高于1G的物理内存,这些高端内存只能在用户空间访问(只需要将这些高端内存的物理地址填入页表项,而页表项通过一一映射来访问)
如何解决这些问题呢?很明确的是,内核虚拟地址的高1G空间不能全部一一映射了,需要留出一些空间来满足以上两个问题以及更多的问题的映射需求,最终就有了896M这个阀点,在虚拟地址的3G到3G+896M和物理地址的0M到896M之间,严格执行一一线性映射,1G的虚拟地址空间中空出的1G-896M,即128M的空间用于满足非一一映射的需求,这段空间又被分为了动态映射区,永久映射区,临时映射区,其中后两种我统称为PageMap空间。
动态映射区:用于映射一些不连续的物理页面,这些页面可以处在物理内存大于896M的位置。一般内核模块的虚拟地址处在该区域,由于该区域空间有限,所以对模块的大小提出了要求。
永久映射区:可以将任意的单独内存页面映射在该区域,最终的虚拟地址是接近于4G的一个值,所谓的永久映射,值得是该虚拟地址如果已经映射了一个物理页面,只能等到它被释放才能再次映射一个新的物理页面。
临时映射区:最有意思的就是它了。映射方式和永久映射一样,无非就是写一个页表项。所谓的临时就是后面的映射可以覆盖前面的映射,这就不需要锁机制来确保互斥操作,为了保证映射不被覆盖,Linux使用了另外一种机制而不是锁。该区域分为了N个子区域,每一个CPU有一个子区域,只要保证同一个CPU在使用映射的时候不会有第二个映射就可以了,由于限制在了一个CPU上,而一个CPU可以很简单得限制它执行一个执行绪,这是很好办的,保证CPU不切换即可(关中断?等等),但是有一些映射被覆盖了也无所谓,于是这个临时映射区域按照映射的目的又一次被划分。
3.2.代价和弥补
通过以上分析可以看得出,Linux共享内核地址空间的机制设计得非常精巧,但是却付出了巨大的代价。Linux第一次面临了对画出的饼感到不满的情况!1G的地址空间实在太小了。
线性映射空间的不足:896M的一一映射空间中充满了必须物理内存连续的映射,比如内核代码中的静态数组之类的,典型的就是mem_map,即page数组,它会随着物理内存的增加而增加,会占据巨大的一一线性映射空间。另外,task_struct结构体也是一笔开销,每一个进程还要有一个页目录,若干页表等,这些都是线性空间的开销,如果线性空间被占满了,其它必需使用线性空间的该怎么办?只能PANIC。
动态空间的不足:所有的内核模块中的代码和数据都需要虚拟地址,而这些地址处(挤)在128M不到的地址空间中,不说也知道了吧,你可以试一下调用一下vmalloc(...),然后再加载一个模块...
永久映射和临时映射的不足:这个空间比动态映射空间还要小得多。
这就是共享地址空间的代价,如果使用4G/4G模式,当然更好,但是难道在3G/1G模式中就没有办法了吗?彻底解决是不可能的,管理毕竟是需要付出代价的。于是设计精巧的数据结构也成了一个解决之道,另外,将一些不必非要在线性空间的数据结构移出去,让它们从高端内存即大于896M的物理地址处分配,然后不映射任何虚拟地址,因此也就不占据虚拟地址空间的席位,只有等待真正读写它们的时候,再映射到永久或临时的空间里,得到一个虚拟地址,读写该虚拟地址即可!HIGHPTE这个编宏就是一个实现实例!
3.3.总结
Linux进程的虚拟地址空间中,最上面1G的内核空间是共享的,Linux采用了更加直接的方式敷衍了MMU,即将这部分地址空间一一线性映射到了物理内存的前面896M空间,同时保留了128M的其它映射空间。对于每一个进程而言,其MMU关键机构页表并不直接映射到虚拟内存空间,虽然由于一一线性映射的缘故,知道了其page下标后,它确实可以直接被访问,访问方式为:
- ((pte_t *)page_address(pmd_page(*(dir))) + pte_index(address))
- page_address对于非高端物理内存会直接返回lowmem_page_address:
- static inline void *lowmem_page_address(struct page *page)
- {
- return __va(page_to_pfn(page) << PAGE_SHIFT);
- }
而__va宏说明了什么是一一线性映射:
- #define __va(x) ((void *)((unsigned long)(x)+PAGE_OFFSET))
虽然费了这么大的周折,实际上定位一个页表的虚拟地址是很简单的。如果这些页表被分配在了前896M的物理内存,MMU会将其一一映射到3G至3G+896M的虚拟地址空间,导致该空间被消耗。因此更加正常的方式是在大于896M的高端内存分配页表页面,由于不再是一一映射,那么它就是一个游离的页面了,需要设置页表项时,必须将页表物理页面映射到PageMap区:
- #if defined(CONFIG_HIGHPTE)
- #define pte_offset_map(dir, address) \
- ((pte_t *)kmap_atomic(pmd_page(*(dir)),KM_PTE0) + pte_index(address))
- ...
设置页表项完毕后,随即释放掉:
- #define pte_unmap(pte) kunmap_atomic(pte, KM_PTE0)
3.4.图示
是时候给出一幅图了。我一直没有画图的原因在于这部分的图已经很多了,实际上Linux的映射方式和Windows一样简单,不知道为什么很多人都纠结于诸如高端内存的概念,像纠结与Windows自映射一样痛苦不堪,实际上它们都是虚拟地址空间布局方式的一种自然而然的结果,并没有那么多为什么。在给出我自己的图之前,再简略回答一个问题。
很多人都在问,如果我的物理内存条只有512M,那么高端内存在哪里?很简单如果只有512M内存,那么就没有高端内存!Linux内核地址空间的映射同样不变,所有的512M物理地址全部线性映射到虚拟地址的3G+512M这个空间,所有的用户进程,内核模块等的物理内存也从这个512M的物理内存中分配,只要内核不在引用的页面都可以分配(实际上还保留着一一线性映射),然后这些页面可以被映射在不同的地方,小于3G的用户空间,VMALLOC的动态空间,或者PageMap空间。有一个疑问就是,此时的896M限制还有吗?也就是说,既然内核虚拟地址空间只能一一线性映射到512M,那么从地址空间的3G+512M到3G+896M之间的空洞怎么用?答案很明确,全都给VMALLOC的动态区域使用,因为1G的地址空间中,最下面的是一一映射空间,大小最大896M,最上面是PageMap空间,大小固定,处在中间的VMALLOC理所当然可以吃掉由于物理内存不到896M而被空出的虚拟地址空间。这就是说物理内存小于896M的时候,你可以使用更多的VMALLOC空间,注意,是虚拟的!唯一的好处,那就是你可以搜罗大量不连续的物理内存,将其映射到很大的VMALLOC空间中去,可是你又能搜罗多少呢?VMALLOC空间可是常驻的,所有的页面必须从实际的物理内存中获取,而你的实际物理内存只有896M不到!哈哈,是不是有种被耍的感觉!
好了,该上图了!