关于linux:特性介绍-Linux-内存管理机制解析

47次阅读

共计 5524 个字符,预计需要花费 14 分钟才能阅读完成。

本文首发于 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 的实现切实让人难有摸索的欲望,在这里就不赘述了。

用户空间内存治理

malloclibc 的库函数,用户程序个别通过它(或相似函数)来分配内存空间。

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)。

可见,线程的栈在某种意义上并不是真正栈,它是一个固定的区域,并且容量很无限。


欢送关注我的微信公众号【数据库内核】:分享支流开源数据库和存储引擎相干技术。

题目网址
GitHubhttps://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
正文完
 0