【译】进程的内存剖析

2014 年 6 月 15 日

原e文博客地址在这里: 《Anatomy of a Program in Memory》, 偶然间看到的, 写的很清晰.

内存管理(MM)是OS的核心, 也是编程和系统管理的关键. 本篇文章主要分析了内存中的进程布局, sample则大部分来自32位X86架构下的Linux和Windows.

在多任务的操作系统中, 每个进程都运行在虚拟内存地址空间的沙盒中. 32位操作系统下, 这个空间是4GB. 虚拟内存地址地址, 根据页表(page table)映射成物理地址, 页表由kernel维护, 进程向kernel查询. 每个进程都有自己的页表集, 但是有一个小trick: 虚拟内存地址空间作用于所有的进程, 包括kernel自身, 所以需要预留一部分地址空间给kernel.

这不是说kernel一定要用那么大的内存(1GB或者更多), 只是kernel可以用任何kernel地址空间内(例如, 32位Linux的3GB-4GB)的地址, 去映射物理内存. kernel地址空间在页表中有特殊标记, 只有特权代码(ring 2 以下)才能使用, 当用户态程序尝试读取它的时候, 会触发页错误(page fault). 在Linux中,kernel地址空间是不变的, 在所有的进程中, 都映射到相同的物理内存. kernel代码和数据都可寻址, 在任何时刻, 都可以处理中断或者系统调用. 与之相对的是, 不同进程切换时, 用户态地址空间也随之变化, 相同的虚拟内存地址会被映射到不同的物理内存.

蓝色的区域是代表已经映射到物理内存的地址空间, 白色的则还没有. 在上面的sample中, Firefox(这货是个吃内存的老虎机)已经用掉了地址空间的大部分内存. 进程的地址空间会分为不同的段: 堆(heap), 栈(stack), 等等. 需要记住的是: 这里的段, 只是内存地址空间上的一段范围, 跟Intel风格的分段式内存管理(实际我们使用的是分页模式)毫无关系. 下图是一个Linux进程的标准分段布局:

假如没有随机化, 每台物理机上每个进程的不同段的内存起始地址, 基本都相同, 这是件很危险的事情. 黑客可以远程利用安全漏洞, 指向某些固定的内存地址: 例如在栈上, 或者在函数库中的某个地址等, 如果内存布局都一样, 那么黑客很容易就能指向他需要的内存位置, 达到不可告人的目的. 所以地址空间的随机化很常见, Linux随机化了栈(stack), 内存映射区(memory mapping segment), 和堆(heap), 原理很简单, 就是给这些段的起始地址加一个随机偏移. 不过不幸的是, 32位地址空间相当紧凑, 没有太多可以随机化的空间, 实用性不是很强.

最顶上的内存段是栈(stack), 栈上保存了局部变量和大部分编程语言中的函数参数. 调用一个函数, 会触发压栈的行为, 函数返回时栈帧被销毁. 这是一个简单的设计, 因为栈上的数据都严格的遵循LIFO顺序, 不需要复杂的数据结构去追溯栈帧的内容, 一个简单的指向栈顶的指针足以(stack pointer). 而且, 栈区域的内存频繁重用, 有助于CPU cache的命中, 能提升读取性能. 进程中的每个线程, 都有自己的栈.

栈有可能被耗光, 例如函数调用栈过深, 或者过多的大局部变量等. 这会触发一个页错误(page fault), 在Linux中一般由expand_stack()函数处理, 然后调用acct_stack_growth()来检查是否需要扩展栈. 如果栈大小低于RLIMIT_STACK(一般是8MB), 通常会扩展栈, 整个过程对程序透明: 这就是栈按需扩展的机制. 但是, 如果栈已经到了最大(RLIMIT_STACK), 则会触发栈溢出(stack overflow), 程序中会收到段错误(segment fault). 当栈区域按需扩展时, 不会因为栈变小而再变回去, 只增不长, 跟大家交的税一样.

任何尝试操作没有映射的虚内存区域(上面图中的白色部分), 除了动态的栈扩展是可能唯一合法的之外, 其他都会触发页错误(page fault), 从而导致段错误(segment fault). 内存中的一些映射区域是只读的, 所以尝试去写这些区域也会导致段错误.

在栈之下, 则是内存映射段(memory mapping segment). kernel在这里直接将文件的内容映射到内存. 任何应用都可以调用Linux的mmap()系统调用, 或者Windows的CreateFileMapping()/MapViewOfFile(), 向kernel申请内存映射. 内存映射是一个很方便、高效的做文件IO的方式, 所以一般用来加载动态链接库(dynamic libraries). 也可以创建一块匿名的映射内存, 不对应任何文件, 在程序中使用. 在Linux下, 如果通过malloc()申请一大块内存, C 库会创建这样的一块匿名映射内存, 而不是直接从堆(heap)上分配, '一大块'是指超过了MMAP_THRESHOLD, 默认是128KB, 可以通过mallopt()调整.

再接下来的段是堆(heap), 堆提供了运行时的内存分配, 像栈(stack)一样; 与栈不一样的是, 堆上分配的内存, 在调用的函数返回之后依然存在. 大部分的编程语言提供了堆管理. 满足内存需求, 是语言运行库和kernel之间的纽带. 在C中, 堆分配内存的接口是malloc(); 而在有垃圾回收机制的语言中, 例如C#中接口的关键字是new.

当堆中的内存不够, 不足以满足程序需求时, 需要kernel的介入, 通过brk()系统调用来增大堆, 保证有够用的内存可分配. 堆管理(heap management)很复杂, 需要复杂的算法来保证各种情况下内存分配的速度和效率. 向堆发起请求的响应时间可能差别很大. 实时的系统一般都有特殊用途的分配器来处理此类问题. 堆也因此而碎片化, 如下所示:

最底部的段包括有: BSS, 数据段(data)和代码段(program text). 在C中, BSS和数据段都存储了静态(全局)的变量, 唯一不同的在于BSS存储的是在代码中未初始化的静态变量. BSS内存区是匿名的, 没有映射到任何文件. 数据段, 保存了所有在代码中初始化的静态变量. 与BSS不一样, 数据段是具名的, 它映射了二进制进程文件中静态变量初始值的部分. 尽管数据段映射到文件, 它还是一个私有的(private)内存映射, 这意味着内存的更新并不会修改映射的文件, 这是必须的, 否则给全局变量的赋值就会修改磁盘上的二进制程序了.

参考下图的示例, gonzo指针(一个4字节的内存地址), 保存在数据段, 它实际指向的字符串, 则保存在代码段. 代码段只读, 并且保存了程序的所有指令代码, 以及字符串常量. 代码段和数据段一样, 也是具名的, 映射到二进制程序文件, 任何对代码段的写操作, 都会导致段错误(segment fault). 这可以避免一些指针bug, 尽管没有避免用C来的有效, --!

在 Linux 中, 可以读取/proc/pid/maps文件来检查进程的内存分段. 需要记住, 一个段(segment)可能包含多个区域(areas), 例如上文说的多线程每个线程都有自己的栈, 再如每个内存映射的文件都有自己的mmap段, 等等. (某些人, 会把 BSS + 数据段 + 堆 统称为"数据段(data segmeng)").

在Linux中, 还可以使用nm或者objdump工具检查二进制程序中的符号表、内存地址、分段信息等等. 最后, 上面描述的虚拟内存地址空间的布局是很灵活的(随机偏移的引入), 已经默认使用了很多年. 如果操作系统发现没有RLIMIT_STACK定义, Linux会使用更原始的经典布局(没有随机), 如下图所示: