本文首发于 2014-03-12 21:27:30
Linux 内存地址映射图
后文中
图:XXX
指的就是上图中对应区域。
地址映射(图:左中)
inux 内核应用页式内存治理,应用程序给出的内存地址是虚拟地址,它须要通过若干级页表一级一级的变换,才变成真正的物理地址。
想一下,地址映射还是一件很恐怖的事件。当拜访一个由虚拟地址示意的内存空间时,须要先通过若干次的内存拜访,失去每一级页表中用于转换的页表项(页表是寄存在内存外面的),能力实现映射。也就是说,要实现一次内存拜访,实际上内存被拜访了N+1次(N=页表级数),并且还须要做N次加法运算。
所以,地址映射必须要有硬件反对,mmu(内存治理单元)
就是这个硬件。并且须要有cache来保留页表,这个 cache 就是 TLB
(Translation lookaside buffer)。
尽管如此,地址映射还是有着不小的开销。假如 cache 的访存速度是内存的10倍,命中率是40%,页表有三级,那么均匀一次虚拟地址拜访大略就耗费了两次物理内存拜访的工夫。于是,一些嵌入式硬件上可能会放弃应用 mmu,这样的硬件可能运行 VxWorks(一个很高效的嵌入式实时操作系统)、linux(linux 也有禁用 mmu 的编译选项)等零碎。
然而应用 mmu 的劣势也是很大的,最次要的是出于安全性思考。各个过程都是互相独立的虚拟地址空间,互不烦扰。而放弃地址映射之后,所有程序将运行在同一个地址空间。于是,在没有mmu的机器上,一个过程越界访存,可能引起其余过程莫名其妙的谬误,甚至导致内核解体。
在地址映射这个问题上,内核只提供页表,理论的转换是由硬件去实现的。那么内核如何生成这些页表呢?这就有两方面的内容:虚拟地址空间的治理和物理内存的治理。(实际上只有用户态的地址映射才须要治理,内核态的地址映射是写死的。)
虚拟地址治理(图:左下)
每个过程对应一个 task 构造,它指向一个 mm 构造,这就是该过程的内存管理器。(对于线程来说,每个线程也都有一个 task 构造,然而它们都指向同一个 mm,所以同一过程中的多个线程的地址空间是共享的。)
mm->pgd
指向包容页表的内存,每个过程有自已的 mm,每个 mm 有本人的页表。于是,过程调度时,页表被切换(个别会有一个CPU寄存器来保留页表的地址,比方X86下的CR3,页表切换就是扭转该寄存器的值)。所以,各个过程的地址空间互不影响(因为页表都不一样了,当然无法访问到他人的地址空间上。然而共享内存除外
,这是成心让不同的页表可能拜访到雷同的物理地址上)。
用户程序对内存的操作(调配、回收、映射、等)都是对 mm 的操作,具体来说是对 mm 上的 vma(虚拟内存空间) 的操作。这些 vma 代表着过程空间的各个区域,比方堆、栈、代码区、数据区、各种映射区 等。
用户程序对内存的操作并不会间接影响到页表,更不会间接影响到物理内存的调配。比方 malloc 胜利,仅仅是扭转了某个 vma,页表不会变,物理内存的调配也不会变。
假如用户调配了内存,而后拜访这块内存。因为页表外面并没有记录相干的映射,CPU产生一次缺页异样。内核捕获异样,查看产生异样的地址是不是存在于一个非法的vma中,如果不是,则给过程一个”段谬误”,让其解体;如果是,则调配一个物理页,并为之建设映射。
物理内存治理(图:右上)
那么物理内存是如何调配的呢?
首先,linux 反对 NUMA (Non Uniform Memory Access)。物理内存治理的第一个档次就是介质的治理,pg_data_t
构造就形容了介质。一般而言,咱们的内存治理介质只有内存,并且它是平均的,所以能够简略地认为零碎中只有一个 pg_data_t 对象。
每一种介质上面有若干个zone,个别是三个:DMA、NORMAL和HIGH。
- DMA:因为有些硬件零碎的DMA总线比系统总线窄,所以只有一部分地址空间可能用作 DMA,这部分地址被治理在 DMA 区域(这属于是高级货了);
- HIGH:高端内存。在32位零碎中,地址空间是4G,其中内核规定 3~4G 的范畴是
内核空间
,0~3G 是用户空间
(每个用户过程都有这么大的虚拟空间)(图:中下)。后面提到过内核的地址映射是写死的,就是指这3~4G的对应的页表是写死的,它映射到了物理地址的0~1G上。(实际上没有映射1G,只映射了896M
。剩下的空间留下来映射大于1G的物理地址,而这一部分显然不是写死的)。所以,大于896M的物理地址是没有写死的页表来对应的,内核不能间接拜访它们(必须要建设映射),称它们为高端内存(当然,如果机器内存不足896M,就不存在高端内存。如果是64位机器,也不存在高端内存,因为地址空间很大很大,属于内核的空间也不止1G了); - NORMAL:不属于 DMA 或 HIGH 的内存就叫 NORMAL 。
在 zone
之上的 zone_list
代表了调配策略,即内存调配时的 zone 优先级。一种内存调配往往不是只能在一个zone里进行调配的,比方调配一个页给内核应用时,最优先是从 NORMAL 外面调配,不行的话就调配 DMA 外面的好了( HIGH 就不行,因为还没建设映射),这就是一种调配策略。
每个内存介质保护了一个 mem_map
,为介质中的每一个物理页面建设了一个 page
构造与之对应,以便治理物理内存。
每个zone记录着它在 mem_map
上的起始地位。并且通过 free_area
串连着这个 zone 上闲暇的 page。物理内存的调配就是从这里来的,从 free_area
上把 page 摘下,就算是调配了。(内核的内存调配与用户过程不同,用户应用内存会被内核监督,使用不当就"段谬误"
;而内核则无人监督,只能靠盲目,不是本人从 free_area
摘下的 page 就不要乱用。)
建设地址映射
内核须要物理内存时,很多状况是整页调配的,这在下面的 mem_map
中摘一个 page 下来就好了。比方后面说到的内核捕获缺页异样,而后须要调配一个 page 以建设映射。
说到这里,会有一个疑难:内核在调配 page、建设地址映射的过程中,应用的是虚拟地址还是物理地址呢?
首先,内核代码所拜访的地址都是虚拟地址,因为CPU指令接管的就是虚拟地址(地址映射对于CPU指令是通明的)。然而,建设地址映射时,内核在页表外面填写的内容却是物理地址,因为地址映射的指标就是要失去物理地址。
那么,内核怎么失去这个物理地址呢?其实,下面也提到了,mem_map 中的 page 就是依据物理内存来建设的,每一个 page 就对应了一个物理页。
于是咱们能够说,虚拟地址的映射是靠这里 page 构造来实现的,是它们给出了最终的物理地址。然而,page 构造显然是通过虚拟地址来治理的(后面曾经说过,CPU指令接管的就是虚拟地址)。那么,page 构造实现了他人的虚构地址映射,谁又来实现 page 构造本人的虚构地址映射呢?没人可能实现。
这就引出了后面提到的一个问题,内核空间的页表项是写死的。在内核初始化时,内核的地址空间就曾经把地址映射写死了。page 构造显然存在于内核空间,所以它的地址映射问题曾经通过“写死”解决了。
因为内核空间的页表项是写死的,又引出另一个问题,NORMAL(或DMA)区域的内存可能被同时映射到内核空间和用户空间。被映射到内核空间是显然的,因为这个映射曾经写死了。而这些页面也可能被映射到用户空间的,在后面提到的缺页异样的场景外面就有这样的可能。映射到用户空间的页面应该优先从 HIGH 区域获取,因为这些内存被内核拜访起来很不不便,拿给用户空间再适合不过了。然而 HIGH 区域可能会耗尽,或者可能因为设施上物理内存不足导致系统外面基本就没有 HIGH 区域,所以,将 NORMAL 区域映射给用户空间是必然存在的。
然而 NORMAL 区域的内存被同时映射到内核空间和用户空间并没有问题,因为如果某个页面正在被内核应用,对应的 page 应该曾经从 free_area 被摘下,于是缺页异样解决代码中不会再将该页映射到用户空间。反过来也一样,被映射到用户空间的 page 天然曾经从 free_area 被摘下,内核不会再去应用这个页面。
内核空间治理(图:右下)
除了对内存整页的应用,有些时候,内核也须要像用户程序应用 malloc 一样,调配一块任意大小的空间。这个性能是由 slab 零碎来实现的。
slab 相当于为内核中罕用的一些构造体对象建设了对象池,比方对应 task 构造的池、对应 mm 构造的池、等等。
而 slab 也保护有通用的对象池,比方”32字节大小”的对象池、”64字节大小”的对象池、等等。内核中罕用的 kmalloc 函数(相似于用户态的malloc)就是在这些通用的对象池中实现调配的。
slab除了对象理论应用的内存空间外,还有其对应的控制结构。有两种组织形式:如果对象较大,则控制结构应用专门的页面来保留;如果对象较小,控制结构与对象空间应用雷同的页面。
除了slab,linux 2.6 还引入了mempool(内存池)
。其用意是:某些对象咱们不心愿它会因为内存不足而调配失败,于是咱们事后调配若干个,放在 mempool 中存起来。失常状况下,调配对象时是不会去动 mempool 外面的资源的,照常通过 slab 去调配。当零碎内存紧缺,曾经无奈通过 slab 分配内存时,才会应用 mempool 中的内容。
页面换入换出(图:左上 & 图:右上)
页面换入换出又是一个很简单的零碎。内存页面被换出到磁盘,与磁盘文件被映射到内存,是很类似的两个过程(内存页被换出到磁盘的动机,就是今后还要从磁盘将其载回内存)。所以 swap 复用了文件子系统的一些机制。
页面换入换出是一件很费CPU和IO的事件,然而因为内存低廉这一历史起因,咱们只好拿磁盘来扩大内存。然而当初内存越来越便宜了,咱们能够轻松装置数G的内存,而后将 swap 零碎敞开。于是 swap 的实现切实让人难有摸索的欲望,在这里就不赘述了。
用户空间内存治理
malloc
是libc
的库函数,用户程序个别通过它(或相似函数)来分配内存空间。
libc
对内存的调配有两种路径:一是调整堆的大小,二是mmap一个新的虚拟内存区域(堆也是一个vma)。
在内核中,堆是一个一端固定、一端可伸缩的vma(图:左中)。可伸缩的一端通过零碎调用 brk 来调整。libc 治理着堆的空间,用户调用 malloc 分配内存时,libc 尽量从现有的堆中去调配。如果堆空间不够,则通过 brk 增大堆空间。
当用户将已调配的空间 free 时,libc 可能会通过 brk 减小堆空间。然而堆空间增大容易减小却难,思考这样一种状况,用户空间间断调配了10块内存,前9块曾经free。这时,未free的第10块哪怕只有1字节大,libc也不可能去减小堆的大小。因为堆只有一端可伸缩,并且两头不能掏空。而第10块内存就死死地占据着堆可伸缩的那一端,堆的大小没法减小,相干资源也没法偿还内核。
当用户 malloc 一块很大的内存时,libc 会通过 mmap 零碎调用映射一个新的vma。因为对于堆的大小调整和空间治理还是比拟麻烦的,从新建一个 vma 会更不便(下面提到的free的问题也是起因之一)。
那么为什么不总是在 malloc 的时候去 mmap 一个新的 vma 呢?
第一,对于小空间的调配与回收,被 libc 治理的堆空间曾经可能满足需要,不用每次都去进行零碎调用。 并且 vma 是以 page 为单位的,最小就是调配一个页;
第二,太多的vma会升高零碎性能。缺页异样、vma 的新建与销毁、堆空间的大小调整、等等状况下,都须要对 vma 进行操作,须要在以后过程的所有 vma 中找到须要被操作的那个(或那些)vma。vma 数目太多,必然导致性能降落。(在过程的 vma 较少时,内核采纳链表来治理 vma;vma 较多时,改用红黑树来治理。)
用户的栈
与堆一样,栈也是一个vma(图:左中),这个vma是一端固定、一端可伸(留神,不能缩)的。这个vma比拟非凡,没有相似 brk 的零碎调用让这个 vma 舒展,它是主动舒展的。
当用户拜访的虚拟地址越过这个 vma 时,内核会在解决缺页异样的时候将主动将这个 vma增大。内核会查看过后的栈寄存器(如:ESP),拜访的虚拟地址不能超过 ESP加n(n为CPU压栈指令一次性压栈的最大字节数)。也就是说,内核是以ESP为基准来查看拜访是否越界。
然而,ESP的值是能够由用户态程序自在读写的,用户程序如果调整ESP,将栈划得很大很大怎么办呢?
内核中有一套对于过程限度的配置,其中就有栈大小的配置,栈只能这么大,再大就出错。
对于一个过程来说,栈个别是能够被舒展得比拟大(如:8MB)。然而对于线程呢?
首先线程的栈是怎么回事?后面说过,线程的 mm 是共享其父过程的。尽管栈是mm中的一个vma,然而线程不能与其父过程共用这个vma(两个运行实体显然不必共用一个栈)。于是,在线程创立时,线程库通过mmap新建了一个vma,以此作为线程的栈(大于个别为:2M)。
可见,线程的栈在某种意义上并不是真正栈,它是一个固定的区域,并且容量很无限。
欢迎关注搞代码gaodaima网的微信公众号【数据库内核】:分享支流开源数据库和存储引擎相干技术。
题目 | 网址 |
---|---|
GitHub | https://dbkernel.github.io |
知乎 | https://www.zhihu.com/people/… |
思否(SegmentFault) | https://segmentfault.com/u/db… |
掘金 | https://juejin.im/user/5e9d3e… |
开源中国(oschina) | https://my.oschina.net/dbkernel |
博客园(cnblogs) | https://www.cnblogs.com/dbkernel |