LINUX程序(进程)在内存中的布局

时间:2021-12-19 14:55:08

    翻译自: http://duartes.org/gustavo/blog/post/anatomy-of-a-program-in-memory/感谢作者, 
尤其一些图很漂亮,建议读者亲自读一遍英文.

    内存管理是操作系统的核心; 是编程和系统管理的关键部分,在接下来的几篇文章中会从实际应用和
内部角度对内存管理模块进行分析. 内存管理的相关概念都是通用的,我们依照32位的linux和window
x86架构来分析. 这篇文章主要介绍了进程的内存布局.

    在多任务操作系统中每一个进程都有自己的内存空间,这个内存空间是虚拟内存空间,在32位机器中,每
一个进程有都4G的虚拟内存空间, 这些虚拟内存地址通过页表映射到真实的物理地址空间中, 这个映射关系
是由内核来管理的. 每一个进程有自己的页表,但是一旦虚拟内存被启用,所有程序包括内核本身都会使用
虚拟内存(在X86 CPU中大概只有一个CR3的寄存机存放的是物理地址,指向页表),所以虚拟地址空间的一
部分需要划分给内核使用:

LINUX程序(进程)在内存中的布局

    这并不意味着内核使用了很多物理内存, 内核只是预留了一段虚拟内存空间,当内核需要时可以映射为物
理内存, 内核的空间的页表有特殊flag(ring 2 or lower),当用户空间的代码访问内核页表时会触发
page fault, 在linux中, 内核空间在所有程序中指向的物理地址是一样的. 内核代码总是可寻址的.随时
接受中断或系统调用. 与此相反用户的地址空间则会随着进程的切换不断变化, 


LINUX程序(进程)在内存中的布局

    蓝色区域表示已映射物理内存的虚拟地址空间, 而白色表示还未映射.  在上面的例子中Firefox已经使用了
大量的虚拟地址空间. 不同的地址空间段对应程序不同的内存段包括堆、栈等等. 这些内存段落只是简单表示
了一段空间而已,下图是一个标准的进程内存空间的布局:


LINUX程序(进程)在内存中的布局
 

   正常情况下每个进程的虚拟地址空间都基本上与上图类似. 这导致很容易远程利用安全漏洞攻击,这种攻
击往往需要知道进程的一个地址: 一个栈上的地址,或者一个lib库的一个函数的地址等等. 远程攻击者必
须猜测到这个地址然后进行攻击, 因为地址空间基本一样,早期的攻击比较容易,后来慢慢有了地址空间随
机化技术,linux会随机stack、mmap、heap在虚拟空间中的地址,一般通过在这些段空间起始地址加一个
随机的offset. 但是32位的地址空间本来就很小,导致地址随机化的效果并不明显。

    在进程的虚拟地址空间中靠近上面是栈,栈保存了本地变量、函数入参等. 调用一个新的函数会在栈上
创建一个新的栈帧,每当函数返回值这个栈帧会被自动销毁,栈地址的管理非常简单可能是因为数据严格遵循
LIFO的顺序,不需要复杂的数据结构来跟踪栈地址,只需要一个栈顶指针可以搞定.而且栈的push和pop操作
都非常快速和简单. 另外栈空间一直重复使用(push\pop)有利于栈内存活跃在cpu cache中加快访问速度.
对于线程来说每一个线程都有自己的栈空间.

    当栈空间用尽后继续push数据会触发栈空间的扩展. 这会触发一个 page fault 然后在内核中调用
expand_stack()函数. 该函数调用acct_stack_growth()来判断是否可以增长占空间. 如果当前栈空间
的大小小于RLIMIT_STACK(8M),可以继续增长栈空间. 该过程由内核完成进程不会感知到.  当用户的占
空间已经达到允许的最大值时,内核会给进程发送一个Segmentation Fault信号终止该进程.  进程的栈空
间只会增大不会缩小,有点像联邦运算,只增不减.

    栈空间的动态增长是唯一一种可以访问未映射的地址空间的情况. 其他任何访问未映射地址空间的操作都
会触发Segmentation Fault. 当然去写一个只读的地址空间也肯定会触发Segmentation Fault.
    在栈空间下面是mmap区域, 在这些区域中内核将文件直接映射到地址空间中. 应用程序可以显示创建这些
区域通过调用mmap()/ CreateFileMapping()/MapViewOfFile(). 内存映射是一种高效和方便操作文件的
一种方式, 所以内存映射通常被用来加载动态链接库.  也可以创建一个匿名的mmap空间不指向任何文件而用来
存储程序数据. 在linux中如果用户malloc 一个非常大块的内存,标准c库会通过mmap来创建这块内存区间而
不使用Heap内存, 这里的大块是指超过MMAP_THRESHOLD( 128k), MMAP_THRESHOLD可以通过
mallopt()函数动态调整.

    说到堆内存,它一个我们下面要讲述的重要的一个地址空间,堆提供了程序运行时的内存分配, 堆内存的生
命周期在函数之外. 大部分语言都提供了堆内存管理函数, 如C语言的malloc() free().
    如果当前堆的内存足够程序使用,不许要与内核交互,在当前堆中寻找可用内存就行, 否则的话需要调用
brk()系统调用在内核中增大堆内存. 堆内存分贝的算法非常复杂, 既要保证内存分配的实时性和快速,又要尽
量避免堆中出现过多碎片, 如下图所示:

LINUX程序(进程)在内存中的布局

    最后,我看来看一下最先面的BSS、data和代码段,  在C语言中BSS和data存放了静态(全局)变量, 其中
BSS存放了未初始化的变量(static int cntActiveUsers),BSS段是匿名的不映射任何文件.
data段存放了代码中已初始化的静态变量, data段不是匿名的而是映射了程序二进制文件中存已初始化静态变量
的部分. 例如 static int cntWorkerBees = 10 会存放在data段, 虽说data段映射文件的一部分,这是
私有映射数据在内存中的改变不会影响到文件.

    下图的data段 包含一个4-byte的内存指针, 这个指针在data段, 但是这个指针指向的字符串却在text段,
text段存放了一些子只读的字符串信息, text段也是你二进制文件在内存中的映射,同样向text段写操作会导致
Segmetation Fault. 这样可以避免一些指针错误. 当然这些指针错误最好在编码时就能发现

LINUX程序(进程)在内存中的布局


    在linux中可以通过 查看/proc/进程ID/maps文件内容来获得进程的内存布局信息,一个内存段可能
包含多个子区域例如 通过mmap的内存通常有自己的区域, 但是程序加载的so还包括自己的类似BSS和
data部分.下一篇文章会详细介绍一下内存管理中的区域概念.

    你可以通过nm和objdump命令来查看某一个可执行文件的符号,地址,段等信息, 上文所描述的就是
linux所谓的"灵活"的虚拟内存布局, 这样的默认布局已经好多年了, 它假设我们有一个RLITMIT_STACK变量,
如果没有的话, linux的经典内存布局如下:

LINUX程序(进程)在内存中的布局