进来看看你对进程虚拟内存的了解有多深?
在 Linux 中每个进程都好像是楚门生活在一个别人为它精心构建的世界里而它却以为自己独占了整个系统的内存空间。这正是内核通过虚拟内存机制实现的。本文将带你穿过用户态的表象深入 Linux 内核源码与底层硬件分析这一套复杂的协作机制。在我们正式进入主题之前不妨先试着思考下面几个问题在一个父子进程中如果你同时打印一个全局变量的地址你会发现它们的逻辑地址也就是虚拟地址完全一样。但是当子进程修改这个变量时父进程的值却并没有改变。既然地址一样为什么在子进程中变了父进程中却没变如果你定义一个全局数组int arr[1024*1024] {1}已初始化编译出的可执行文件会多出 4MB。但如果你定义int arr[1024*1024]未初始化可执行文件的大小几乎没变。明明都是定义了 4MB 的数组为什么差别这么大在只有 8GB 物理内存的 Linux 系统上为什么你可以成功malloc出 100GB 的内存而不报错Linux 内核在管理进程的虚拟内存区域VMA时既然已经有了一个双向链表为什么还要大费周章地维护一棵红黑树在什么场景下这棵树会决定你的程序响应速度2018 年Linux 引入了KPTI 机制强制将用户态和内核态的页表分离。这导致了明显的性能下降内核开发者为什么要冒着性能大跌的风险非要在内存布局里建起这堵墙这些问题如果你能答出来那你很厉害了。如果某些地方还有些疑惑也请不要担心下面我会带大家深入了解这背后的底层原理。1. 进程虚拟内存的宏观布局在 Linux 中每个进程眼里的内存都是独占且连续的。但不同架构下进程眼里内存的大小和边界也完全不同。1.1 经典的32位系统在 32 位架构如 x86中地址空间共 2^324GB Linux 默认采用 3:1 分割用户空间 (0 ~ 3GB)进程自己折腾的地方。内核空间 (3GB ~ 4GB)所有进程共享用于存放内核代码、页表、物理内存映射区等。而由于内核空间仅 1GB当物理内存超过 1GB 时内核无法直接映射所有物理内存被迫引入了高端内存机制这极大地增加了内核开发的复杂性。下面介绍一下具体的细节在理想状态下内核希望通过直接映射来工作也就是把这 1GB 的内核虚拟地址直接映射在物理内存的最前面 1GB 上。如果物理内存只有 512MB那么内核这 1GB 的虚拟空间就绰绰有余每一寸物理内存都能在内核里找到相应的映射空间。如果物理内存有 4GB而内核只有 1GB 的虚拟地址。如果内核直接占用了这 1GB 虚拟地址它就只能看到前面 1GB 的物理内存剩下的 3GB 物理内存因为没有对应的虚拟地址映射内核根本无法直接访问。为了解决较小的虚拟内存映射较大的物理内存的问题内核引入了高端内存机制物理内存的前 896MB这部分是直接映射的内核随时可以访问效率极高而 896MB 之后的物理内存内核不给它们分配固定的虚拟地址。当内核需要访问这些 896MB 之后的物理内存时它会使用没有映射物理内存的剩下 128MB 虚拟内存临时建立一个映射指向目标物理内存用完后再拆掉。1.2 现代64位系统在 x86_64 架构下虽然地址线有 64 位但目前 Linux 仅使用了其中的 48 位部分新 CPU 支持 57 位。48 位的地址线提供了 256TB 的虚拟地址空间用户空间 (低 128TB)从 0x0000 0000 0000 0000 到 0x0000 7FFF FFFF F000。内核空间 (高 128TB)从 0xFFFF 8000 0000 0000 到 0xFFFF FFFF FFFF FFFF。由于只使用了 48 位地址线导致中间有一段巨大的、地址位不合法的区域如果程序尝试访问这里硬件会直接抛出通用保护异常。这种设计既精简了硬件电路也为未来扩展留下了空间。在内核空间如此庞大的情况下整块物理内存都会被映射到虚拟地址空间的某一个起始点内核访问任何物理地址都只需要简单的加法偏移。但是带来了便利的同时也付出了一些代价 由于映射范围极大页表本身占用的内存变多了内核现在普遍使用4 级甚至 5 级页表每次翻译地址的开销比 32 位2 级要高。此外所有的指针从 4 字节变成了 8 字节在处理大量包含指针的数据结构时内存占用会显著增加并且对L1/L2 缓存的压力更大因为缓存行能容纳的元素变少了。2. 用户空间详细布局我们从虚拟地址的最低端一路向上扫过看看一个运行中的程序到底把东西都藏在哪了。1.代码段(.text)存放编译后的机器指令。只读、可执行这是为了保护程序不被意外篡改。多个进程运行同一个程序时物理内存中实际只存一份代码。2.常量区 (.rodata)存放const修饰的全局变量、字符串常量。只读尝试修改这里会导致段错误。3.已初始化数据段 (.data)明确赋了初值且初值不为 0 的全局变量和静态变量。可读写它们在程序启动瞬间就有了初始值。4.未初始化数据段 (.bss)未初始化或初值为 0 的全局或静态变量。不占磁盘空间在可执行文件中仅记录一个大小当程序加载到内存时内核会分配内存并将其全部清零。这也是为什么全局变量默认值是 0 的底层原因。5.堆由低地址向高地址生长。6.内存映射区 (mmap)动态链接库、大块内存分配malloc 超过 128KB 时、文件映射。现代 Linux 中通常从高地址向低地址生长紧贴栈底下方。7.栈存放局部变量、函数参数、返回地址。由高地址向低地址生长底部通常设有 Guard Page 保护页一旦触碰即触发溢出报错。8.命令行参数与环境变量用户空间的最高端。存储main(int argc, char argv, char envp)中的参数和系统环境变量。它们由父进程通常是 Shell在execve时压入。3. 内核如何管理这些区域3.1 mm_struct每个进程都有一个task_struct进程控制块而每个进程控制块中都有一个mm指针指向mm_struct它是内存管理的核心数据结构描述了一个进程所拥有的全部虚拟内存视图。所有用户线程共享同一个mm_struct而内核线程mm指针为 NULL因为内核线程没有用户空间它们直接借用上一个进程的内核页表。我们可以把mm_struct的内容分为四大块1.内存映射区域这是最核心的部分进程的虚拟地址空间不是连续的而是由许多离散的块组成的如代码段、数据段、堆、栈、mmap区。mmap指向vm_area_struct的链表按虚拟地址排序方便遍历。mm_rb指向红黑树的根节点红黑树用于快速查找某个地址属于哪个区域也就是寻找 VMA。2.内存段的起止位置start_codeend_code: 可执行代码段的范围。start_dataend_data: 已初始化数据的范围。start_brkbrk: 堆的起点和当前的终点。start_stack: 栈的起点。3.页表指针pgd: 指向当前进程的一级页表也就是全局页目录。当进程切换时内核会把这个pgd的物理地址加载到 CPU 的 CR3 寄存器中。CPU 的 MMU 就能根据这套页表将虚拟地址翻译成物理地址。4.状态统计total_vm: 进程总的虚拟内存大小。rss: 进程当前实际占用的物理内存大小。mm_users/mm_count: 引用计数多个线程共享同一个mm_struct时计数会增加。可能有人会产生这样的疑惑既然有了页表为什么还要mm_struct下面的 VMA 链表其实页表是给内存管理单元 MMU 看的它只负责告诉 CPU地址 A 对应物理地址 B权限是只读。而 VMA 是给内核看的当程序发生缺页中断时内核需要通过mm_struct查表这个地址合法吗如果不合法报段错误。如果合法是因为还没分配物理内存吗如果是则分配一个物理页并填入页表。3.2 为什么有了链表还需要红黑树这其实是 Linux 内核设计中的经典权衡1.双向链表按地址顺序排列方便内核遍历所有的内存区域。比如当你运行cat /proc/pid/maps时内核就是沿着链表走一遍把信息打印出来。2.红黑树当 CPU 访问一个地址时内核需要以极快的速度判断这个地址是否合法并查看这个地址属于哪个 VMA。如果只用链表查找复杂度是O(N)如果进程映射了成千上万个动态库查找会非常慢。而红黑树将查找复杂度降到了O(logN)无论是缺页异常还是内存保护检查红黑树都决定了系统的响应效率。4. 问题解析现在我们来分析一下开头的问题。4.1 延迟分配当你调用malloc分配内存时内核其实是非常聪明的。它并不会立刻跑到物理内存条上去给你占坑而是在mm_struct里加一个 VMA 记录然后告诉你这块内存已经是你的了。这就是为什么你能在 8GB 的机器上申请 100GB 的虚拟内存前提是系统开启了 Overcommit。只有当你真正开始读写这块内存时硬件会发现对应的页表项是空的触发一个缺页异常。这时内核才去分配物理内存并更新页表。4.2 BSS段与已初始化数据段的区别已初始化数据段.data里面存的是具体的初值。这些值必须实实在在地写进磁盘的可执行文件里加载时原样搬进内存。BSS段.bss里面全是初始为 0 的变量。内核只需要记住这里有 4MB 的空间需要清零即可没必要在磁盘上存 4MB 的零。程序启动时内核直接分配一批零页给它既省磁盘又省 IO。4.3 写时复制这是回答开头关于父子进程地址相同但值不同的关键。在 Linux 下fork()产生子进程时并不会复制一份物理内存。相反它让子进程的页表直接指向父进程的物理页并把这些页的权限全部设为只读。对于读操作父子进程共享同一块物理内存相安无事。而对于写操作当子进程尝试修改变量时硬件触发异常。内核查看之后得知这是写时复制页。于是内核会为子进程 **拷贝 **一份物理页再让子进程的页表指向这个新页面然后加上写权限。因此虽然虚拟地址是一样的但背后映射的物理地址已经在你不知不觉中改变了。5. 多级页表与 MMU要理解现代内存管理的精髓必须把MMU和多级页表放在一起看。5.1 页表5.1.1 为什么需要页表假设物理内存是 4GB页面大小为 4KB那么总共有 1M 个页框。如果使用单级页表为了映射这 4GB 空间每个进程都需要一个包含 1M 个表项的数组。如果每个表项占 4B那每个进程光页表就要占用 4MB 内存。此外单级页表要求物理上连续即使中间大片空间没用到你也得为这些空洞预留页表项这太浪费了。5.1.2 多级页表多级页表的核心思想是只有当某个区域真的存了数据才为它建立下一级页表。以 32 位系统两级页表为例一级页表 (PGD/页目录) 将 4GB 分成 1024 份每份 4MB。二级页表 (PTE/页表项) 只有当进程真的访问了某个 4MB 范围时内核才会创建一个二级页表。5.2 MMUMMU是 CPU 内部的一个硬件单元它的唯一任务就是把虚拟地址转换为物理地址。以 64 位 4 级页表为例CPU 从控制寄存器如 x86 的 CR3中读取当前进程第一级页表PGD的物理基地址。逐级拆解地址MMU 会把 64 位的虚拟地址拆成好几段。9bit 索引第一级页表找到第二级地址以此类推。最后一级页表取出物理页的基地址加上虚拟地址末尾的 12bit偏移量得到最终的物理内存地址。5.3 TLB快表由于多级页表需要多次访问内存4 级页表意味着翻译一个地址要查 4 次内存这比 CPU 执行指令慢得多。为了提速MMU 内部有一个超高速缓存TLB。它缓存了最近翻译过的虚拟页到物理页的映射关系。现代系统 TLB 命中率通常在 99% 以上翻译几乎是瞬时的。但是当进程切换也就是切换mm_struct时由于页表变了旧的 TLB 缓存通常必须失效这就是进程切换开销大的原因之一。6. 安全与性能的博弈最后我们聊聊第五个问题。有些系统调用比如gettimeofday频率极高。如果每次都切入内核态上下文切换的开销很大。所以内核会在用户空间最高端映射一个 vDSO 区域里面放的是内核提供的只读代码用户态直接调用无需切换模式。2018 年由于 Intel CPU 的熔断漏洞黑客可以通过预测执行在用户态窃取内核内存的信息。以前用户态和内核态共用一套页表只是权限位不同。现在内核被迫做了隔离用户态运行时页表里几乎不包含内核地址。因此现在每次从用户态进入内核态都要切换页表性能损耗由此而来。但为了安全我们不得不接受这个改变。写在最后:进程虚拟内存不只是为了给进程提供独占空间它是 Linux 内核在效率、性能与安全之间不断权衡的杰作,理解了虚拟内存才算是真正触碰到了操作系统的灵魂。当你下次看到Segmentation Fault时希望你脑海中的认知将不再只是一个简单的报错而是整个 VMA 红黑树在警告“你越界了那是我不曾承诺给你的世界。”本文结束。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2408668.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!