内存管理模块可谓是操作系统的核心,对应用程序和系统管理至关重要。今天我们将深入内存管理中的一些实际问题,同时揭开其中的技术面纱。由于许多概念在多种平台上都是通用的,我们将以32位x86平台上的Linux和Windows系统为例展开讲解。本系列的第一篇文章将重点介绍应用程序的内存布局。
在多任务操作系统中,每个进程都在自己的内存池中运行。这个内存池被称为虚拟地址空间,在32位模式下,它通常是一个4GB的内存地址块。这些虚拟地址通过页表映射到物理内存,页表由操作系统维护并由处理器调用。每个进程都有自己的页表,还有一个隐秘的故事。只要虚拟地址被激活,它们就会作用于机器上运行的所有软件,包括内核本身。部分虚拟地址必须保留给内核使用。
这并不意味着内核占用了大量的物理内存,而只是表示它拥有可支配的广阔地址空间,可以根据需要将其映射到物理内存。内核空间在页表中享有较高的特权级别(ring 2或以下),如果用户态的程序试图访问这些页面,就会触发页面错误。在Linux中,内核空间是永久存在的,并在所有进程中映射到相同的物理内存。内核代码和数据始终可访问,随时准备处理中断和系统调用。相反,用户模式地址空间的映射会随进程切换而不断变化。
想象一下,当计算机平稳运行时,几乎每个进程的内存段布局起始虚拟地址都是一致的,这为远程程序安全漏洞挖掘打开了方便之门。漏洞挖掘过程往往需要引用绝对内存地址,如栈地址、库函数地址等。远程攻击者依赖地址空间布局的一致性来猜测这些地址。为了增加安全性,地址空间的随机布局逐渐流行起来。Linux通过对栈、内存映射段和堆的起始地址添加随机偏移来打乱布局。32位地址空间的紧凑性限制了随机化的效果。
进程地址空间中最顶部的段是栈,大多数编程语言都在这里存储局部变量和函数参数。调用方法或函数时,新的栈帧会被推入栈中。当函数返回时,栈帧被清理。由于数据严格遵循LIFO顺序,这个设计意味着不需要复杂的数据结构来追踪栈的内容,只需一个简单的指针指向栈顶即可。压栈和弹栈过程迅速且准确。持续重用栈空间有助于保持活跃的栈内存靠近CPU缓存,从而加快访问速度。每个进程中的线程都有自己的栈。
当向栈中压入的数据超出其容量时,会耗尽对应的内存区域,触发页面错误并由Linux的expand_stack()处理。该函数会检查是否还有足够的空间供栈扩展。如果栈大小低于RLIMIT_STACK(通常为8MB),则通常会加长栈,程序继续运行而不会被察觉。这是一种常规机制,用于将栈扩展到所需大小。如果达到最大栈空间,就会发生栈溢出,程序会收到段错误并终止运行。映射的栈区域扩展到所需大小后不会收缩,即使栈的使用率降低。这就像联邦预算总是不断增长一样。
动态栈增长是访问未映射内存区域的唯一允许情况。任何对未映射内存区域的访问都会触发页面错误并导致段错误。一些已映射的区域是只读的,尝试写入这些区域也会导致段错误。
在栈的下方是我们的内存映射段。在这里,内核直接将文件内容映射到内存中。任何应用程序都可以通过Linux的mmap()系统调用或Windows的CreateFileMapping()/MapViewOfFile()请求这种映射。内存映射是一种便捷高效的文件I/O方式,因此被用于加载动态库。创建不对应任何文件的匿名内存映射也是可能的,这种方法用于存储程序数据。在Linux中,如果你通过malloc()请求大量内存,C运行时库将创建一个这样的匿名映射,而不是使用堆内存。深入计算机内存中的堆与数据段:虚拟地址空间的布局与拓展
在程序的运行环境中,内存是其至关重要的组成部分。除了我们熟知的栈外,还有另一块重要的地址空间堆。与栈不同,堆用于存储那些与函数调用无关的数据,满足内存请求成为语言运行时库及内核的共同任务。接下来,我们将深入堆及其与数据段、代码段等内存区域的关系,以及虚拟地址空间的布局和拓展。
堆,作为运行时内存分配的重要场所,其大小可以动态调整。当我们提到“大块”时,它指的是比默认阈值MMAP_THRESHOLD更大的内存块,这个默认值通常是128KB。我们可以通过mallopt()函数来调整这个值。当堆中的空间足以满足内存请求时,语言运行时库可以独立处理,否则,堆会通过brk()系统调用来分配请求的内存块。这一过程可能需要内核的参与。
在深入讨论堆之前,我们先来了解一下其他内存区域。在C语言中,BSS和数据段保存的是静态(全局)变量的内容。BSS保存的是未被初始化的静态变量,它们的值并非在源代码中直接设定,而是一个未明确指定的默认值。这个内存区域是匿名的,不映射到任何文件。而数据段则保存了已经初始化了的静态变量的内容,它映射了部分的程序二进制镜像。这意味着对全局变量的更改不会影响到原始文件。
当我们讨论虚拟地址空间的布局时,代码段也是一个重要的部分。代码段包含了程序的全部代码以及一些字符串字面值,它是只读的,以防止程序意外地(或恶意地)修改自身的代码。这个区域将二进制文件映射到内存中。
接下来我们来看一个具体的例子。在这个例子中,一个指针变量gonzo和其指向的字符串分别保存在数据段和代码段中。数据段保存了指针本身的值,而代码段则保存了字符串的实际内容。这个字符串是只读的,因为它位于代码段中。
在Linux中,虚拟地址空间的布局是灵活的,但有时也会使用经典布局。你可以通过/proc/pid_of_process/maps来查看一个进程中的内存区域。你还可以使用nm和objdump命令来查看二进制镜像中的符号、地址、段等信息。
要理解虚拟地址空间的布局,还需要知道堆是如何与这些数据段交互的。当堆空间不足时,它会通过系统调用来扩大,这一过程中可能涉及到内核的参与。而数据段、代码段等其他区域则相对固定,它们映射了程序的二进制文件或包含了特定的数据。
下一篇文章将内核如何跟踪这些内存区域,分析内存映射,以及文件读写操作与内存使用概况的关系。通过深入理解虚拟地址空间的布局和拓展,我们能更好地管理程序内存,提高程序的运行效率和稳定性。
理解堆、数据段、代码段等内存区域的作用以及它们在虚拟地址空间中的布局和拓展方式,对于开发高效、稳定的程序至关重要。希望通过的讲解,你能对这些概念有更深入的理解。