Linux进程的内存布局

在多任务的操作系统中每个进程运行在属于自己的内存沙盒中,这个沙盒就是虚拟地址空间,对于32位模式的操作系统通常是有4GB的内存地址空间。这些虚拟地址通过页表映射在物理内存上,页表是由操作系统内核维护,由处理器进行访问的。每个处理器有独属的页表。但是这里有个问题,一旦启用虚拟地址,跑在操作系统上的所有软件都将使用虚拟地址,包括操作系统内核。所以必须在虚拟地址空间给操作系统保留一部分空间。

这并不意味着内核会使用这么大的物理内存,只是保证在内核在映射物理内存时有足够的空间可以使用。内核空间在页表中被标记为特权代码(ring 2 或更低级)专用,因此,如果用户模式程序尝试访问内核空间,就会触发页故障。在Linux中,所有处理器的内核空间是被映射到一个相同的、连续的物理地址上。相反,用户空间的代码随着进程的切换映射在不同的物理地址上:

图片中蓝色的区域表示被映射到物理地址的虚拟地址,白色的区域表示没有被映射的。

下面是Linux进程标准的段布局:

上图展示的段布局对于每个进程来说是相同的。使得远程利用安全漏洞变得轻而易举。漏洞利用往往需要引用绝对内存位置:堆栈地址、库函数地址等。 远程攻击者必须盲目地选择这个位置,因为地址空间都是一样的。 一旦它们相同,人们就会被攻击。 因此,地址空间随机化开始流行起来。 Linux 通过在堆栈、内存映射段和堆的起始地址上添加偏移量,实现了堆栈、内存映射段和堆的随机化。 遗憾的是,32 位地址空间非常狭小,随机化的空间很小,影响了随机化的效果。

1. 栈

在大多数编程语言中,进程地址空间最顶端的部分是堆栈,用于存储局部变量和函数参数。 调用方法或函数时会向堆栈推送一个新的堆栈帧。 当函数返回时,栈帧将被销毁。 由于数据严格遵守后进先出的顺序,这种简单的设计意味着不需要复杂的数据结构来跟踪堆栈内容--一个指向堆栈顶部的简单指针就可以了。 因此,推入和弹出的速度非常快,而且具有确定性。 此外,堆栈区域的不断重复使用往往会将有效的堆栈内存保留在 CPU 缓存中,从而加快访问速度。 进程中的每个线程都有自己的堆栈。

如果推送的数据超过了堆栈所能容纳的范围,就有可能耗尽堆栈的映射区域。 在 Linux 中,这将引发页面故障,由 expand_stack()处理,反过来,它会调用 acct_stack_growth()来检查是否适合增加堆栈。 如果堆栈大小低于 RLIMIT_STACK(通常为 8MB),那么堆栈通常会增长,程序会继续欢快地运行,而不会意识到刚刚发生了什么。 这是堆栈大小根据需求进行调整的正常机制。 但是,如果已达到最大堆栈大小,就会出现堆栈溢出,程序会收到分段故障(Segmentation Fault)。 虽然映射的堆栈区域会扩大以满足需求,但当堆栈变小时,它不会缩回。 只有在动态堆栈增长的情况下,访问未映射内存区域(如上图白色所示)才是有效的。 对未映射内存的任何其他访问都会引发页面故障,导致分段故障。 某些映射区域是只读的,因此尝试写入这些区域也会导致分段故障。

2. 内存映射

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

3. 堆

如果堆中有足够的空间来满足内存请求,就可以由语言运行时处理,无需内核参与。 否则,堆将通过 brk() 系统调用(实现)被扩大,以便为请求的块腾出空间。 堆管理非常复杂,需要复杂的算法在程序混乱的分配模式下,努力提高速度和内存使用效率。 处理一个堆请求所需的时间可能相差很大。 实时系统有专门的分配器来解决这个问题。 堆也会变得支离破碎,如下图所示:

4. BSS、数据段和代码段

最后,我们进入内存的最底层: BSS、数据和程序文本。 BSS 和数据段都存储 C 语言中静态(全局)变量的内容,区别在于 BSS 存储未初始化的静态变量的内容,程序员不会在源代码中设置这些变量的值。 BSS 内存区是匿名的:它不映射任何文件。 如果说 static int cntActiveUsers,那么 cntActiveUsers 的内容就存在 BSS 中。

而数据段则保存在源代码中初始化的静态变量的内容。 该内存区域不是匿名的。 它映射了程序二进制映像中包含源代码中给出的初始静态值的部分。 因此,如果你说 static int cntWorkerBees = 10,cntWorkerBees 的内容就会存在于数据段中,并且一开始就是 10。 尽管数据段映射了一个文件,但它是一个私有内存映射,这意味着内存的更新不会反映在底层文件中。 情况必须如此,否则对全局变量的赋值就会改变磁盘上的二进制映像。

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

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

reference

Anatomy of a Program in Memory | Many But Finite

posted @ 2024-12-12 17:09  cockpunctual  阅读(495)  评论(0)    收藏  举报