内存中程序剖析

内存中程序剖析

1.引言

内存管理一直是操作系统的核心问题,它对于编程和系统管理都是异常重要。接下来会有一系列博文从实际角度给大家介绍内存管理的一系列内容,尽管这一概念比较宽泛,但是博文中列举的示例都是来自于Linux或Windows这些32位的x86系统。作为这系列的第一篇论文,首先简单描述一下程序如何在内存中布局。

2.内存空间简要介绍

每个多任务系统中的进程都运行在它自己的内存“沙盒”里,这个“沙盒”就是虚拟内存空间,这在32位模式下往往指4GB内存地址块。这些虚拟地址通过页表映射到物理内存,这些页表是由操作系统内核来维护并且被处理器(CPU)来查询。每个进程都有它自己的页表,一旦虚拟地址启动,这些叶表就必须适用于机器上的所有软件程序,包括系统内核本身。因此,虚拟地址的一部分必须保存在内核中:

这不是意味着内核使用页表来匹配物理内存,只是内核需要使用那部分虚拟空间用来映射任意的物理空间。内核空间的页面在页表上被标记为特权级(环2或更低),因此,如果一个用户模式下的程序试图去访问一页特权级内存页,常常会导致一个页异常(page fault----编程中常见报错总结)。在Linux下,内核空间在所有用户进程中的物理内存空间映射都是一致的。任何时候,内存代码和数据,可以被中断处理程序和系统调用所寻址和使用。相比之下,映射为用户模式那部分的地址空间,随着一个任务切换的发生,也会映射到不同的物理地址空间:

上图中,蓝色区域表示映射到物理地址空间的虚拟地址空间,而白色则是还未映射的虚拟地址。地址空间中不同的段对于不同的内存段----堆、栈等等。记住,这些段仅仅表示一段内存地址范围,和实际的硬件架构中的段不同。总之,下图就是Linux下一个进程的标准段布局:

当计算和程序上一切运行顺利,那么在机器上每个进程的不同段的布局基本一致,都如上图所示,这样有利于避免很多安全注入问题。一次注入攻击常常需要访问绝对内存位置:例如堆栈上的地址、函数库的地址等等。远程攻击者往往是盲目选择内存位置,主要是希望每个进程的地址空间都是一致的,如果是这样的话,那么个人的隐私就受到威胁;所以,如今线程地址空间的随机映射大行其道。Linux下,系统通过让基地址随机加上一个偏移地址,从而得到栈地址、堆地址以及其他的内存映射段地址。然而,如今的32位地址空间显得有点紧凑,这样就导致随机的空间很小,进而可能影响其安全性

3.Stack

在进程中最重要的段----堆栈段,很多编程语言下,它是用来存放局部变量和函数参数的。调用一个函数或者方法,就会在栈上压入一个新的栈帧,并且随着函数返回,所对应的栈帧也就被释放掉。这种简单的设计,主要的思想来源可能是数据遵从“先进先出”的规则,这也意味着不需要复杂的数据结构来追踪栈内容,一个简单的栈顶指针就可以实现内容查询----push和pop操作是非常迅速且稳定。同时,将那些被多次重复使用的栈内容存放到“CPU缓存中(Cache)”,可以带来更快的访问速度。

如果在内存空间中,放入过多的数据内容,会导致栈空间的耗尽。Linux下,这样所引起的页异常,可以通过expand_stack()来解决,这就会导致调用acct_stack_growth()来检查是否可以增长堆栈空间。如果栈空间尺度小于RLIMIT_STACK(一般是8MB),那么栈空间增长可以没有问题的执行下去。但是,如果栈空间已经达到上限,那么我们进行上述操作,最终会收到一个段异常(segmentation fault)----也就是“栈溢出”。如果栈增长操作顺序运行,娜美操作完成后不回收缩栈大小。

动态堆栈增长是访问未映射内存区域的唯一正确方式。任何其他访问未映射内存区域,都会触发一个页异常,进而导致段异常。某些映射内存区域是只读内存区域,因此,试图写这些区域同样会导致异常。

在栈下面,就是内存映射段区域。这里,内存直接将文件内容映射到内存。任何应用可以通过调用Linux下的mmap()系统调用或者在Windows下的CreateFileMapping()/MapViewOfFile()来实现。内存映射对于文件I/O操作来说是非常方便且高效的,所以它经常被用来加载动态库。也可以创建一个匿名内存映射,不对应任何文件,而专门用做程序数据。Linux下,如果程序通过malloc()需求一片大的内存块,C库将会创建一片匿名映射空间,这里的“大”是指大于MMAP_THRESHOLD字节,该字段的默认值是128KB,可以通过mallopt()动态修改。

4.Heap

接下来,就是堆的介绍。堆----常用来提供运行时内存分配,这里点和栈比较类似;但是,堆也可以分配存放栈范围之外的数据变量,这一点区别于栈。大部分语言都提供堆管理程序。因此,满足内存请求是语言运行时和内核的共同事务。在C语言中,调用堆分配空间的接口是malloc(),以及在具有垃圾回收机制的C#语言中,对应的接口的new关键字。

如果在堆上有足够的内存空间满足内存调用,那么仅仅通过语言运行时就可以满足相关操作,而不需要调用系统内核操作。否则的话,就需要通过调用brk()系统调用来分配更多的空间来满足需求。在我们实际程序复杂的内存分配的模式下,堆的管理是很复杂的,需要精妙的算法来平衡速度和内存使用效率,因而,用于分配堆空间的时间消耗可能差异很大;实时系统主要通过special-purpose allocators来处理这类问题。堆在内存中示意图如下所示:

5.BSS、数据段和代码段

最后,我们来分析最下面的一系列内存段----BSS、数据段和程序代码段。在C语言中,BSS、数据段都用来存储静态(static)变量。不同之处是,BSS中存储的内容是未初始化的静态变量,这些变量的值是通过在程序代码中进行设置的。BSS内存区域是匿名的:它不会映射到任何文件。如果,你输入static int cntActiveUsers;语句,那么这个变量就存放在BSS段。

数据段,存放源码中已经被初始化的静态变量,所以,这段内存区域不是匿名的,它被映射到文件的二进制镜像的某个部分,并且包含这些静态变量在源码中被初始化的值。所以,当你输入static int cntWorkBees = 10;语句,则变量的内容存放于数据段且值为10。尽管数据段映射成一个文件,它仍是私有内存映射,这也意味着对内存数据的更新不会同步到所映射的文件中。否则的话,对于静态变量的赋值,会导致磁盘上文件内容的变化。

下图中,数据段的示例比较复杂,因为使用了指针。下例中,指针gonzo内容----也就是一个4字节内存地址----存放在数据段。但是,它指向的实际字符串并不存放在数据段,而是在代码段,也就是一个只读段,也就是存放所有代码和所有字符串的区域。代码段同样映射内存中的二进制文件,但是要写代码段会导致一个段异常。这有助于阻止很多指针错误,下图是这些段的示意图:

6.总结

你可以通过阅读Linux源码文件中的/proc/pid_of_process/maps来进一步了解内存区域,请记住,一个段可能包含很多不同的内存区域。例如,每个内存映射文件通常在mmap段中都有自己的区域,而动态库具有类似于BSS和数据的额外区域。你可以借助Linux下的工具nmobjdump来查阅一个目标文件的符号、地址、段等等。最终的虚拟地址空间的布局在Linux中是一种灵活的方式,Linux下的经典内存布局如下:

posted @ 2021-02-01 22:56  匡吉  阅读(132)  评论(0编辑  收藏  举报