7.1 内存管理

在所有计算机系统中,内存都是一种稀缺资源。无论有多少可用内存,它总是显得不够。在过去,人们还认为256MB的内存已经足够了。但现在,即使对桌面系统,2GB的内存也已经是其最低要求了,服务器系统通常需要的内存量就更多了。

从最早期的操作系统版本开始,UNIX风格的操作系统就以一种非常干净的方式来管理内存,因为Linux系统实现了X/Open规范,所以它也继承了这一特点。除了一些特殊的嵌入式应用程序以外,Linux程序决不允许直接访问物理内存。也许应用程序看起来好像可以这样做,但应用程序看到的只是一个精心控制的假象而已。

Linux为应用程序提供了一个简洁的视图,它能反映一个巨大的可直接寻址的内存空间。此外,Linux还提供了内存保护机制,它避免了不同的应用程序之间的互相干扰。如果机器被正确配置并且有足够的交换空间,Linux还允许应用程序访问比实际物理内存更大的内存空间。

7.1.1 简单的内存分配

使用标准C语言函数库中的malloc调用来分配内存:

7.1 内存管理 - 图1

注意,遵循X/Open规范的Linux与一些UNIX系统不同,它不要求包含malloc.h头文件。此外,用来指定待分配内存字节数量的参数size不是一个简单的整型,虽然它通常是一个无符号整型。

你可以在大多数Linux系统上分配大量的内存。让我们从一个非常简单的例子开始,但这个例子却足以打败旧式的基于MS-DOS的程序,因为在DOS下的程序不能访问超过640K内存映射限制的内存范围。

实 验 简单的内存分配

输入下面这个程序memory1.c:

7.1 内存管理 - 图2

运行这个程序时,它的输出如下所示:

7.1 内存管理 - 图3

实验解析

这个程序要求malloc函数给它返回一个指向1MB内存空间的指针。首先检查并确保malloc函数被成功调用,然后通过使用其中的部分内存来表明分配的内存确实已经存在。当运行这个程序时,你会看到程序输出Hello World,这表明malloc确实返回了1MB的可用内存。我们并未对这个1MB的空间进行全面检查,对于malloc调用的代码总得有点信任吧!

注意,由于malloc函数返回的是一个void指针,因此需要通过类型转换,将其转换至你需要的char类型指针。malloc函数可以保证其返回的内存是地址对齐的,所以它可以被转换为任何类型的指针。

可以这样做的原因很简单,因为目前大多数Linux系统都使用32位的整数和32位的指针来指向内存,32位的指针可寻址的地址空间可达4GB。系统直接用32位的指针来寻址,而不再需要段寄存器或其他技巧的能力被称为32位平面内存模型。这个模型还被用于32位版本的Windows XP和Vista系统。但你并不能因此认为整数永远都是32位的,因为正有越来越多的64位Linux版本投入实际使用。

7.1.2 分配大量的内存

现在,你已经看到Linux能轻松打破MS-DOS内存模型的上限,我们不妨给它出个更难的题目。下面这个程序将请求系统分配比机器本身所拥有的物理内存更多的内存。你可能会认为,malloc会在接近实际物理内存容量的某个地方出现问题,因为内核和其他运行中的程序也会占用部分内存。

实 验 请求全部的物理内存

在程序memory2.c中,我们将请求比机器物理内存容量更多的内存。你需要根据机器的具体情况来调整宏定义PHY_MEM_MEGS:

7.1 内存管理 - 图4

这个程序的输出如下所示,我们对输出结果做了一些简化:

7.1 内存管理 - 图5

实验解析

这个程序与前面的例子十分类似。它只是通过循环来不断申请越来越多的内存,直到它已分配了在PHY_MEM_MEGS中定义的物理内存容量的2倍为止。看上去这个程序似乎耗尽了机器上物理内存中的每个字节,但出乎意料的是这个程序竟然运行良好。注意,我们为malloc调用的参数使用了size_t类型。

另一个有趣的现象是,至少在我的这台机器上,整个程序的运行时间也就是一眨眼的功夫。也就是说,我们不仅很明显地耗尽了所有的内存,而且还非常快速。

我们用程序memory3.c做进一步的研究,看看这台机器到底有多少内存可以分配。因为现在我们能很清楚地发现Linux在处理内存请求时表现得非常聪明,所以我们将每次只分配1K字节的内存并在获得的每个内存块上写入数据。

实 验 可用内存

下面就是程序memory3.c的源代码。就其本质而言,这个程序对于系统极不友好,而且会严重影响一台多用户机器的运行。如果对可能的风险有所顾虑,最好不要运行它,因为这不会妨碍你对这部分内容的理解:

7.1 内存管理 - 图6

这一次,程序的输出如下(经简化):

7.1 内存管理 - 图7

然后程序就结束了。运行它所花费的时间还不少,并且当分配的内存大小接近机器物理内存容量时,运行速度明显慢了下来,而且你能很明显地感觉到硬盘的操作。但这个程序还是分配了大大超出机器物理内存容量的内存。最后,系统为了保护自己的安全运行,终止了这个贪婪的程序。在一些系统中,当malloc调用失败时,程序可能只是退出而不输出任何内容。

实验解析

应用程序所分配的内存是由Linux内核管理的。每次程序请求内存或者尝试读写它已经分配的内存时,便会由Linux内核接管并决定如何处理这些请求。

刚开始时,内核只是通过使用空闲的物理内存来满足应用程序的内存请求,但是当物理内存耗尽时,它便会开始使用所谓的交换空间(swap space)。在Linux系统中,交换空间是一个在安装系统时分配的独立的磁盘区域。如果熟悉Windows操作系统的话,Linux交换空间的作用有点像隐藏的Windows交换文件。但与Windows不同,Linux的交换空间中没有局部堆、全局堆或可丢弃内存段等需要在代码中操心的内容——Linux内核会为你完成所有的管理工作。

内核会在物理内存和交换空间之间移动数据和程序代码,使得每次读写内存时,数据看起来总像是已存在于物理内存中,而不管在你访问它们之前,它们究竟是在哪里。

用更专业的术语来说,Linux实现了一个“按需换页的虚拟内存系统”。用户程序看到的所有内存全是虚拟的,也就是说,它并不真正存在于程序使用的物理地址上。Linux将所有的内存都以页为单位进行划分,通常每一页的大小为4096字节。每当程序试图访问内存时,就会发生虚拟内存到物理内存的转换,转换的具体实现和耗费的时间取决于你所使用的特定硬件情况。当所访问的内存在物理上并不存在时,就会产生一个页面错误并将控制权交给内核。

Linux内核会对访问的内存地址进行检查,如果这个地址对于程序来说是合法可用的,内核就会确定需要向程序提供哪一个物理内存页面。然后,如果该页面之前从未被写入过,内核就直接分配它,如果它已经被保存在硬盘的交换空间上,内核就读取包含数据的内存页面到物理内存(可能需要把一个已有页面从内存中移出到硬盘)。接着,在完成虚拟内存地址到物理地址的映射之后,内核允许用户程序继续运行。Linux应用程序并不需要操心这一过程,因为所有的具体实现都已隐藏在内核中了。

最终,当应用程序耗尽所有的物理内存和交换空间,或者当最大栈长度被超过时,内核将拒绝此后的内存请求,并可能提前终止程序的运行。

这种“终止进程”的行为和早期的Linux版本以及许多其他版本的UNIX系统有所不同,后者只是让malloc失败。这在术语上被称为“内存耗尽(OOM)杀手”。尽管这看上去好像非常严厉,但实际上这是为了既能让进程快速高效地分配内存,又能让Linux内核保护自己免受资源耗尽的破坏(这是一个严重的问题)而做的一个很好的妥协。

那么,这一切对于应用程序的程序员来说意味着什么呢?简单地说,这都是好消息。Linux非常善于管理内存,它允许应用程序使用数量非常巨大的内存,甚至使用一个单独的非常大的内存块。但你必须记住的是,分配两块内存并不会得到一个单独的可以连续寻址的内存块。你得到的是你所要求的:两个独立的内存块。

那么,这种明显的没有限制的内存供应和在内存耗尽前系统提前终止进程的做法是否意味着,对malloc函数的返回值进行检查没有意义呢?显然不是。在使用动态分配内存的C语言程序中,一个最常见的问题是试图在一个已分配的内存块之后写数据。在发生这种情况时,程序可能并不会立即终止,但你可能已覆盖了malloc库例程内部使用的一些数据。

通常这可能会导致后续的malloc调用失败,但这并不是因为没有足够的内存可以分配,而是因为内存的结构已经被破坏。追踪这类问题非常困难,在程序里越早检测到这类错误,就越有机会找到其原因。在本书的第10章介绍调试和优化的时候,我们将讨论一些有助于追踪这类内存问题的工具。

7.1.3 滥用内存

假设想要对内存干点“坏事”。在下面这个程序memory4.c中,先分配一些内存,然后尝试在它之后写些数据。

实 验 滥用内存

7.1 内存管理 - 图8

7.1 内存管理 - 图9

程序的输出很简单,如下所示:

7.1 内存管理 - 图10

实验解析

Linux内存管理系统能保护系统的其他部分免受这种内存滥用的影响。为了确保一个行为恶劣的程序(如本例)无法破化任何其他程序,Linux会终止其运行。

每个在Linux系统中运行的程序都只能看到属于自己的内存映像,不同的程序看到的内存映像不同。只有操作系统知道物理内存是如何安排的,它不仅为用户程序管理内存,同时也为用户程序提供彼此之间的隔离保护。

7.1.4 空指针

与MS-DOS不同,现代的Linux系统更像新版本的Windows系统,虽然实际的行为和具体实现相关,但它对空指针指向地址的读写提供了很强的保护。

实 验 访问空指针

我们通过memory5a.c程序来看看访问空指针时会发生的情况:

7.1 内存管理 - 图11

其输出为:

7.1 内存管理 - 图12

实验解析

第一个printf函数试图打印一个取自空指针的字符串,接着sprintf函数尝试向一个空指针里写数据。在本例中,Linux(在GNU C函数库的包装下)容忍了读操作,它只输出一个包含(null)\0的“魔术”字符串。但对于写操作就没有如此宽容了,它直接终止了该程序。这在有些时候能够帮助我们追踪程序中的漏洞。

如果再试一次,但这次不使用GNUC函数库,你将发现从零地址处读数据也是不允许的。请看下面的memory5b.c程序:

7.1 内存管理 - 图13

其输出为:

7.1 内存管理 - 图14

这次,你尝试直接从零地址处读取数据,而且这次在你和内核之间并没有GNU的libc库存在,于是,程序被终止了。要注意的是,有些版本的UNIX系统允许从零地址处读取数据,但Linux不允许。

7.1.5 释放内存

到目前为止,我们只是分配内存,然后希望当程序结束时,我们使用的内存不会丢失。幸运的是,Linux内存管理系统完全有能力保证在程序结束时,把分配给它的内存返回给系统。但是,大多数程序需要的并不仅仅是分配一些内存,使用一小段时间,然后就退出。一种更常见的用法是根据需要动态地使用内存。

动态使用内存的程序应该总是通过free调用,来把不用的内存释放给malloc内存管理器。这样做可以将分散的内存块重新合并到一起,并由malloc函数库而不是应用程序来管理它。如果一个运行中的程序(进程)自己使用并释放内存,则这些自由内存实际上仍然处于被分配给该进程的状态。在幕后,Linux将程序员使用的内存块作为一个物理页面集来管理,通常内存中的每个页面为4K字节。但如果一个内存页面未被使用,Linux内存管理器就可以将其从物理内存置换到交换空间中(术语叫换页),从而减轻它对资源使用的影响。如果程序试图访问位于已置换到交换空间中的内存页中的数据,那么Linux会短暂地暂停程序,将内存页从交换空间再次置换到物理内存,然后允许程序继续运行,就像数据一直存在于内存中一样。

7.1 内存管理 - 图15

调用free时使用的指针参数必须是指向由malloc、calloc或realloc调用所分配的内存。你很快就将看到calloc和realloc函数。

实 验 释放内存

下面这个程序被命名为memory6.c:

7.1 内存管理 - 图16

输出结果是:

7.1 内存管理 - 图17

实验解析

这个程序显示了如何调用free来释放内存,free函数带有一个指向先前分配内存的指针参数。

请记住:一旦调用free释放了一块内存,它就不再属于这个进程。它将由malloc函数库负责管理。在对一块内存调用free之后,就绝不能再对其进行读写操作了。

7.1.6 其他内存分配函数

另外两个内存分配函数并不像malloc和free使用的那样频繁,它们是calloc和realloc,其原型为:

7.1 内存管理 - 图18

虽然calloc分配的内存也可以用free来释放,但它的参数与malloc有所不同。它的作用是为一个结构数组分配内存,因此需要把元素个数和每个元素的大小作为其参数。它所分配的内存将全部初始化为0。如果calloc调用成功,将返回指向数组中第一个元素的指针。与malloc调用类似,后续的calloc调用无法保证能返回一个连续的内存空间,因此不能通过重复调用calloc,并期望第二个调用返回的内存正好接在第一个调用返回的内存之后来扩大calloc调用创建的数组。

realloc函数用来改变先前已经分配的内存块的长度。它需要传递一个指向先前通过malloc、calloc或realloc调用分配的内存的指针,然后根据new_size参数的值来增加或减少其长度。为了完成这一任务,realloc函数可能不得不移动数据,因此特别重要的一点是,你要确保一旦内存被重新分配之后,你必须使用新的指针而不是使用realloc调用前的那个指针去访问内存。

另外一个需要注意的问题是,如果realloc无法调整内存块大小的话,它会返回一个null指针。这就意味着在一些应用程序中,你必须避免使用类似下面这样的代码:

7.1 内存管理 - 图19

如果realloc调用失败,它将返回一个空指针,my_ptr就将指向null,而先前用malloc分配的内存将无法再通过my_ptr进行访问。因此,在释放老内存块之前,最好的方法是先用malloc请求一块新内存,再通过memcpy调用把数据从老内存块复制到新的内存块。这样即使出现错误,应用程序还是可以继续访问存储在原来内存块中的数据,从而能够实现一个干净的程序终止。