内存中的程序剖析

内存中的程序剖析 | 许多但有限

内存管理是操作系统的核心;它对于编程和系统管理都至关重要。在接下来的几篇文章中,我将着眼于实际方面来介绍内存,但不会回避内部问题。虽然这些概念是通用的,但示例主要来自 32 位 x86 上的 Linux 和 Windows。第一篇文章描述了程序在内存中的布局。

多任务操作系统中的每个进程都在自己的内存沙箱中运行。这个沙箱是虚拟地址空间,它在 32 位模式下总是一个 4GB 的内存地址块这些虚拟地址通过页表映射到物理内存,页表由操作系统内核维护并由处理器查阅。每个进程都有自己的一组页表,但有一个问题。一旦启用虚拟地址,它们就会应用于机器中运行的所有软件包括内核本身因此,必须为内核保留一部分虚拟地址空间:

内核/用户内存拆分

但这并不意味着内核使用了多少物理内存,只知道它拥有的可用映射它期望的物理内存地址空间的部分。内核空间在页表中被标记为特权代码(环 2 或更低)专用,因此如果用户模式程序尝试触摸它,则会触发页面错误。在 Linux 中,内核空间始终存在并在所有进程中映射相同的物理内存。内核代码和数据总是可寻址的,随时可以处理中断或系统调用。相比之下,每当进程切换发生时,地址空间的用户模式部分的映射就会发生变化:

进程切换对虚拟内存的影响

蓝色区域代表映射到物理内存的虚拟地址,而白色区域是未映射的。在上面的例子中,由于其传奇的内存饥饿,Firefox 使用了更多的虚拟地址空间。地址空间中的不同带对应于内存段,如堆、堆栈等。请记住,这些段只是一个内存地址范围,Intel 风格的段无关无论如何,这是 Linux 进程中的标准段布局:

Linux 中灵活的进程地址空间布局

当计算是快乐、安全和可爱的时候,上面显示的段的起始虚拟地址对于机器中的几乎每个进程都是完全相同的这使得远程利用安全漏洞变得容易。漏洞利用通常需要引用绝对内存位置:堆栈上的地址、库函数的地址等。远程攻击者必须盲目选择该位置,因为地址空间都是相同的。当他们这样做时,人们就会被骗。因此地址空间随机化变得流行。Linux 随机化堆栈、 内存映射段通过向它们的起始地址添加偏移量。不幸的是,32 位地址空间非常紧张,几乎没有空间进行随机化并影响其有效性

进程地址空间的最顶层是栈,在大多数编程语言中,它存储局部变量和函数参数。调用一个方法或函数会将一个新的栈帧压入栈中。当函数返回时,栈帧被销毁。这种简单的设计可能是因为数据遵循严格的LIFO顺序,这意味着不需要复杂的数据结构来跟踪堆栈内容——一个指向堆栈顶部的简单指针就可以了。因此,pushing 和 popping 非常快速且具有确定性。此外,堆栈区域的不断重用往往会使cpu 缓存中的堆栈内存保持活动状态,从而加快访问速度。进程中的每个线程都有自己的堆栈。

可以通过推送更多的数据来耗尽映射堆栈的区域。这会触发一个在 Linux 中由expand_stack()处理的页面错误,后者又调用acct_stack_growth()来检查是否适合增长堆栈。如果堆栈大小低于RLIMIT_STACK(通常为 8MB),那么通常堆栈会增长并且程序继续愉快地运行,不知道刚刚发生了什么。这是堆栈大小根据需求进行调整的正常机制。但是,如果已达到最大堆栈大小,则会发生堆栈溢出并且程序收到一个分段错误。当映射堆栈区域扩展以满足需求时,当堆栈变小时它不会收缩。就像联邦预算一样,它只会扩大。

动态堆栈增长是访问未映射内存区域(如上图白色所示)可能有效唯一情况对未映射内存的任何其他访问都会触发导致分段错误的页面错误。一些映射区域是只读的,因此对这些区域的写入尝试也会导致段错误。

在堆栈下方,我们有内存映射段。这里内核将文件的内容直接映射到内存。任何应用程序都可以通过 Linux mmap()系统调用(实现)或Windows 中的CreateFileMapping() / MapViewOfFile()请求这样的映射内存映射是一种方便且高性能的文件 I/O 方式,因此用于加载动态库。也可以创建一个不对应任何文件匿名内存映射,而是用于程序数据。在 Linux 中,如果您通过malloc()请求大块内存,C 库将创建这样的匿名映射而不是使用堆内存。“大”是指大于MMAP_THRESHOLD字节,默认为 128 kB,可通过mallopt() 调整

说到堆,接下来是我们对地址空间的探索。与堆栈不同,堆提供运行时内存分配,就像堆栈一样,用于必须比执行分配的函数存活时间更长的数据。大多数语言为程序提供堆管理。因此,满足内存请求是语言运行时和内核之间的共同事务。在 C 中,堆分配的接口是malloc()和朋友,而在像 C# 这样的垃圾收集语言中,接口是new关键字。

如果堆中有足够的空间来满足内存请求,它可以由语言运行时处理,而无需内核参与。否则堆会通过brk()系统调用(实现)扩大,为请求的块腾出空间。堆管理是复杂的,需要复杂的算法,在我们程序的混乱分配模式面前努力提高速度和有效的内存使用。为堆请求提供服务所需的时间可能会有很大差异。实时系统有专门的分配器来处理这个问题。堆也变得碎片化,如下所示:

碎片堆

最后,我们到达内存的最低段:BSS、数据和程序文本。BSS 和数据都存储 C 中静态(全局)变量的内容。不同之处在于 BSS 存储未初始化的静态变量的内容,其值不是由程序员在源代码中设置的。BSS 内存区域是匿名的:它不映射任何文件。如果说静态INT cntActiveUsers,内容cntActiveUsers住在BSS。

另一方面,数据段保存在源代码中初始化的静态变量的内容。这个内存区域不是匿名的它映射包含源代码中给出的初始静态值的程序二进制映像部分。所以如果你说static int cntWorkerBees = 10,那么 cntWorkerBees 的内容存在于数据段中,并且从 10 开始。即使数据段映射了一个文件,它也是一个私有内存映射,这意味着对内存的更新不会被反映在底层文件中。必须是这种情况,否则对全局变量的赋值会改变您的磁盘二进制映像。不可思议!

图中的数据示例比较棘手,因为它使用了指针。在这种情况下,指针gonzo内容- 一个 4 字节的内存地址 - 位于数据段中。然而,它指向的实际字符串没有。该字符串位于文本段中,该段是只读的,除了字符串文字等花絮外,还存储所有代码。文本段也将您的二进制文件映射到内存中,但写入此区域会使您的程序出现段错误。这有助于防止指针错误,尽管不如首先避免 C 有效。这是显示这些段和我们的示例变量的图表:

映射到内存中的 ELF 二进制图像

您可以通过读取文件/proc/pid_of_process/maps来检查 Linux 进程中的内存区域请记住,一个段可能包含许多区域。例如,每个内存映射文件通常在mmap 段中都有自己的区域,而动态库则有类似于BSS 和数据的额外区域。下一篇文章将阐明“区域”的真正含义。另外,有时人们说“数据段”的意思是所有数据 + bss + 堆。

您可以使用nmobjdump命令检查二进制图像以显示符号、它们的地址、段等。最后,上面描述的虚拟地址布局是Linux中的“灵活”布局,几年来一直默认。它假设我们有RLIMIT_STACK的值如果不是这种情况,Linux 将恢复到如下所示的“经典”布局:

Linux 中的经典进程地址空间布局

这就是虚拟地址空间布局。下一篇文章讨论内核如何跟踪这些内存区域。接下来我们将研究内存映射、文件读取和写入如何与所有这些相关以及内存使用数据的含义。

缓存:隐藏和保管的地方

这篇文章简要介绍了现代英特尔处理器中 CPU 缓存的组织方式。缓存讨论往往缺乏具体的例子,混淆了所涉及的简单概念。或者也许我漂亮的小脑袋很慢。无论如何,这是关于如何访问 Core 2 L1 缓存的一半故事:

选择 L1 缓存集(行)

缓存中的数据单位是line,它只是内存中的一个连续字节块。此缓存使用 64 字节行。这些行存储在缓存库或路中,每个路都有一个专门的目录来存储其内务信息。您可以将每种方式及其目录想象成电子表格中的列,在这种情况下,行是集合然后路列中的每个单元格都包含一个缓存行,由目录中的相应单元格跟踪。这个特殊的缓存有 64 个组和 8 路,因此有 512 个单元来存储缓存行,这增加了 32KB 的空间。

在这个缓存的世界观中,物理内存被划分为 4KB 的物理页。每个页面有4KB / 64 字节== 64 个缓存行。当您查看 4KB 页面时,该页面中的字节 0 到 63 位于第一个缓存行中,字节 64-127 位于第二个缓存行中,依此类推。每个页面都会重复该模式,因此第 0 页中的第 3 行与第 1 页中的第 3 行不同。

完全关联的缓存中,内存中的任何行都可以存储在任何缓存单元中。这使得存储变得灵活,但是在访问它们时搜索单元变得昂贵。由于 L1 和 L2 缓存在功耗、物理空间和速度的严格限制下运行,因此在大多数情况下,完全关联的缓存并不是一个好的折衷方案。

相反,此缓存设置为关联,这意味着内存中的给定行只能存储在上面显示的一个特定集合(或行)中。因此,任何物理页面的第一行(页面中的字节 0-63)必须存储在第 0 行,第二行在第 1 行,依此类推。每行有 8 个单元可用于存储与其关联的缓存行,使得这是一个 8 路关联集。查看内存地址时,位 11-6 确定 4KB 页内的行号,因此确定要使用的集。例如,物理地址 0x800010a0在这些位中000010,因此它必须存储在集合 2 中。

但是我们仍然有问题要找到行中的哪个单元格保存数据(如果有的话)。这就是目录的来源。每个缓存的行都由其相应的目录单元标记标签只是该行来自的页面的编号。处理器可以寻址 64GB 的物理 RAM,因此这些页面64GB / 4KB == 2 24 个,因此我们的标签需要 24 位。我们的示例物理地址 0x800010a0 对应于页码524,289这是故事的后半部分:

通过匹配标签查找缓存行

由于我们只需要看一组8种方式,标签匹配非常快;事实上,电气上所有的标签都是同时比较的,我试图用箭头来展示。如果有一个带有匹配标签的有效缓存行,我们就有一个缓存命中。否则,请求将被转发到 L2 缓存,并在主系统内存中失败。英特尔通过玩弄路的大小和数量来构建大型 L2 缓存,但设计是相同的。例如,您可以通过再添加 8 条路将其转换为 64KB 缓存。然后将集数增加到4096,每路可存储256KB这两个修改将提供 4MB L2 缓存。在这种情况下,标签需要 18 位,集合索引需要 12 位;缓存使用的物理页面大小等于其路径大小。

如果一个集合填满了,那么在另一个缓存行可以被存储之前,一个缓存行必须被逐出。为了避免这种情况,对性能敏感的程序会尝试组织它们的数据,以便内存访问在缓存行之间均匀分布。例如,假设一个程序有一个 512 字节的对象数组,其中一些对象在内存中相距 4KB。这些对象中的字段属于同一行并竞争同一个缓存集。如果程序频繁访问给定字段(例如vtable通过调用虚拟方法),集合可能会填满,缓存将开始垃圾,因为行被反复驱逐并随后重新加载。由于设置的大小,我们的示例 L1 缓存只能保存其中 8 个对象的 vtable。这是集合关联权衡的代价:即使整体缓存使用量不高,我们也可能因集合冲突而导致缓存未命中。但是,由于计算机中相对速度,大多数应用程序无论如何都不需要担心这一点。

内存访问通常以线性(虚拟)地址开始,因此 L1 缓存依赖分页单元来获取用于缓存标签的物理页面地址。相比之下,集合索引来自线性地址的最低有效位,无需转换即可使用(在我们的示例中为位 11-6)。因此,L1 缓存被物理标记但被虚拟索引,帮助 CPU 并行化查找操作。由于 L1 方式永远不会大于 MMU 页,因此即使使用虚拟索引,也可以保证给定的物理内存位置与相同的集合相关联。另一方面,L2 缓存必须进行物理标记和物理索引,因为它们的路径大小可能大于 MMU 页面。但话又说回来,当请求到达 L2 缓存时,L1 缓存已经解析了物理地址,因此效果很好。

最后,目录单元还存储其相应缓存行状态L1 代码缓存中的一行要么是无效的,要么是共享的(这意味着有效,真的)。在 L1 数据缓存和 L2 缓存中,一行可以处于 4 个 MESI 状态中的任何一个:Modified、Exclusive、Shared 或 Invalid。Intel 缓存是包含性的:L1 缓存的内容在 L2 缓存中重复。这些状态将在以后关于线程、锁定等内容的帖子中发挥作用。下一次我们将看看前端总线以及内存访问如何真正工作的。这将是记忆周。

更新Dave下面评论中提出了直接映射缓存它们基本上是只有一种方式的集合关联缓存的特例。在权衡范围内,它们与完全关联的缓存相反:极快的访问,大量的冲突未命中。

=========================

工作机会(内部推荐):发送邮件至gaoyabing@126.com,看到会帮转内部HR。

邮件标题:X姓名X_X公司X_简历(如:张三_东方财富_简历),否则一律垃圾邮件!

公司信息:

  1. 1.东方财富|上海徐汇、南京|微信客户端查看职位(可自助提交信息,微信打开);
原文地址:https://www.cnblogs.com/Chary/p/15757311.html