Linux APUE学习:5、C程序的存储空间分配


共享库

​ 在Unix系统中,共享库(Shared Library)是一种可重用的代码库,它包含了许多可由多个程序共享和调用的函数和资源。共享库在许多程序之间提供了一种共享代码的方式,以减少重复和节省系统资源。

共享库的主要优点包括:

  1. 节省内存:由于共享库可以被多个程序共享,系统只需将库加载到内存一次,而不是每个程序都加载一份独立的代码。这减少了内存的使用量,特别是当多个程序使用相同的库时。
  2. 减少程序体积:共享库允许程序仅包含其自身所需的最小代码量,而将其他功能委托给库。这样可以减小程序的体积,使得程序更加轻便。
  3. 简化更新:如果一个共享库需要进行更新或修复,只需更新库本身,而不需要重新编译和分发所有使用该库的程序。这样可以简化维护过程,并提供更快的修复和升级。

​ 在Unix系统中,共享库通常使用动态链接的方式加载。这意味着在程序运行时,操作系统会在内存中加载共享库,并将程序中的函数调用链接到库中的对应函数。这种动态链接的机制使得程序可以在运行时动态加载和卸载共享库,从而提供了更大的灵活性和可扩展性。

共享库在Unix系统中使用一些常见的文件扩展名来标识,例如:

  • .so(Shared Object):在Linux和其他类Unix系统中使用的共享库扩展名。
  • .dylib(Dynamic Library):在macOS系统中使用的共享库扩展名。
  • .dll(Dynamic Link Library):在Windows系统中使用的共享库扩展名。

​ 使用共享库时,程序需要指定库的路径以及需要调用的函数。编译程序时需要链接到共享库,以便在程序执行时可以正确地加载和使用库中的函数和资源。

​ 总而言之,共享库是Unix系统中一种重要的机制,它提供了代码共享、节省资源和简化维护的好处,使得程序开发和运行更加高效和灵活。

1
$ gcc -static hello.c	# 阻止gcc使用共享库

存储空间分配

存储空间分配的函数

​ ISO C说明了3个用于存储空间分配的函数

  1. malloc,分配指定字节数的存储区。此存储区中的初始值不确定。
  2. calloc,为指定数量指定长度的对象分配存储空间。该空间内的每一位bit都将被初始化为0.
  3. realloc,增加或减少以前分配区的长度。当增加长度时,可能需要将以前分配区的内容移到另一足够大的区域,以便在尾端提供增加的存储区,新增区域内的初始值不确定
1
2
3
4
5
6
#include <stdlib.h>
void *malloc(size_t size);
void *calloc(size_t nobj, size_t size);
void *realloc(void *ptr, size_t newsize);
// 3个函数返回值:若成功返回非空指针,若出错返回NULL
void free(void *ptr);

​ 这三个分配函数返回的指针已经经过适当的对齐,因此可以用于分配任何类型的数据对象。

​ 在内存分配过程中,对齐是指要求分配的内存地址与特定的边界对齐。对齐是为了优化内存访问的效率,因为许多计算机体系结构对于对齐的数据访问要比不对齐的数据访问更高效。对齐要求通常由具体的硬件平台或操作系统定义。

​ 这是因为这些分配函数是为了满足通用的内存分配需求而设计的,它们会返回适当对齐的指针,以确保可以存储任意类型的数据对象。因此,你可以直接使用这些返回的指针,而不需要担心对齐的问题。这样可以简化代码,提高可移植性,并确保数据的正确存储和访问。

注意:

  • realloc的第二个参数是存储区的新长度,而非新旧存储区长度之差。
  • 作为特例,若*ptr为NULL,则realloc的功能与malloc相同,用于分配一个指定长度为newsize的存储区。

​ realloc函数使我们可以增加、减少之前分配过的存储区的长度(最常见的做法是增加该存储区的大小)。例如,如果先为一个数组分配存储空间为512字节,然后在运行时填充它,但运行一段时间后,发现数组的长度不够用,这是就可以调用realloc扩充相应的存储空间。

​ 调用realloc函数时,如果在该存储区后有足够的空间可供扩充,则在原存储区的位置上向高地址方向扩充,无需移动任何原先的内容,并返回与传给它相同的指针值(即回传相同的起始地址)。但如果原存储区后没有足够的空间,则realloc分配另一个足够大的存储区,将原本存储区的内容复制到新的足够大的新分配的存储区,然后再释放原存储区,返回新分配的指针(新的足够大的存储区的起始地址)。因为这种存储区可能会移动位置,所以不应当使任何指针指在该区中


sbrk系统调用

sbrk是一个Unix系统调用,用于调整进程的堆空间大小。它允许进程通过增加或减少堆的大小来动态地分配和释放内存。

​ 上述三种分配函数通常用sbrk系统调用实现,该系统调用扩充(或缩小)进程的堆。

​ 虽然sbrk可以扩充或缩小进程的存储空间。但是大多数malloc和free的实现都不会减小进程的存储空间。释放的空间可供以后再分配,但将它们保持在malloc池中而不返回给内核。

1
2
3
#include <unistd.h>

void *sbrk(intptr_t increment);

参数increment表示要增加(正值)或减少(负值)的内存字节数。返回值是一个指向调整后的堆的起始地址的指针,如果调用失败,则返回(void *)-1

使用sbrk时,需要注意以下几点:

  1. sbrk系统调用只能增加或减少整个堆的大小,不能精确地分配和释放单个对象或特定大小的内存块。通常,使用更高级的内存分配函数(如mallocfree)来管理动态内存分配,而不是直接使用sbrk
  2. 在增加堆大小时,sbrk将扩展堆的末尾,并返回新分配内存的起始地址。在减少堆大小时,sbrk将释放堆的末尾的内存,并返回新的堆末尾的地址。
  3. 调用sbrk可能会导致内存分配的碎片化问题,因为它只在堆的末尾进行分配和释放。这可能导致堆中出现不连续的空闲块,从而降低内存利用率。
  4. 在多线程环境中使用sbrk需要谨慎,因为它可能导致竞态条件和内存管理的并发问题。在多线程程序中,更好地使用线程安全的内存分配函数。

总的来说,sbrk系统调用是一个用于动态调整进程堆大小的底层接口,但在实际编程中,更常用的是使用标准库提供的内存管理函数,如mallocfree,它们提供了更灵活和方便的内存分配和释放方式。


悬空指针问题

​ 上面的最后一句中的不应当使任何指针指在该区中,试想一下,若有指针指向原存储区,而使用realloc扩容,而原存储区后空间不足,原存储区中的内容被复制到新的更大的新存储区,然后原存储区的内容被释放。这里就需要注意了,存储区的地址变了,而指针还在指向旧的已经被释放的存储区,这就导致了出现“悬空指针”问题(Dangling Pointer Problem)。

​ 悬空指针问题发生在程序试图通过指针来访问已经释放的内存区域。当一个指针指向的内存被释放或销毁后,指针仍然保留对该内存位置的引用,但该内存已经不再有效。如果程序继续使用这个悬空指针,就可能导致未定义的行为。

指针访问已经被释放的存储区域被称为“悬空指针”问题(Dangling Pointer Problem)。

常见的导致悬空指针问题的情况包括:

  1. 释放后未将指针置空:当通过free()函数释放内存后,如果没有将指针设置为NULL,指针仍然保留之前内存的地址,就成为悬空指针。
  2. 返回局部变量的指针:如果函数返回一个指向其局部变量的指针,当函数结束时,这个指针将指向一个无效的内存区域,成为悬空指针。
  3. 使用已经释放的动态分配内存:如果程序在某个地方释放了动态分配的内存,并且在其他地方继续使用该指针来访问内存,则会导致悬空指针问题。

​ 悬空指针问题可能导致程序崩溃、不可预测的行为和安全漏洞,因为程序可能会误用无效的内存位置。为了避免悬空指针问题,应该遵循以下几点:

  • 在释放内存后,将指针设置为NULL,避免使用悬空指针。
  • 避免返回指向局部变量的指针,或者在返回前将局部变量的数据复制到动态分配的内存中。
  • 在使用指针之前,检查它是否为悬空指针,即指向已释放的内存。
  • 尽可能使用自动化的内存管理工具,如智能指针或垃圾回收机制,来避免手动管理内存时出现悬空指针问题。

通过遵循良好的编程实践和内存管理原则,可以有效地减少悬空指针问题的发生。


堆溢出问题

​ 大多数实现所分配的存储空间都会比所要求的size要稍大一些,这些额外的空间用来记录管理信息——分配块的长度、指向下一个分配块的指针等。这就意味着,如果在一个已分配区的尾端之后或者在已分配区的首端之前进行写操作,则会改写另一块的管理记录信息。这种类型的错误是灾难性的,但是由于堆溢出错误通常不会立即暴露出来,而是在后续的内存分配或释放操作中才会产生问题,因此很难被及时发现和调试。

​ 在动态分配的缓冲区前或后进行写操作,破坏的可能不仅仅是该区的管理记录信息。在动态分配的缓冲区前后的存储空间很可能用于其他动态分配的对象。这些对象与破坏它们的代码可能无关,这造成寻求信息破坏的源头更加困难。

这种错误被称为”堆溢出”或”堆破坏”(Heap Overflow/Heap Corruption)。

​ 堆溢出是指在堆内存区域中进行写操作时,超出了分配的内存块的边界,覆盖了相邻内存块的管理信息。由于堆管理信息包含了关键的内存块大小、指针等数据,当这些管理信息被改写时,可能导致程序出现严重的错误或不可预测的行为。

堆溢出可能引发以下问题:

  1. 内存损坏:堆管理信息被破坏可能导致错误的内存分配和释放,甚至可能破坏整个堆结构。
  2. 崩溃和异常行为:被破坏的堆管理信息可能导致程序在后续的内存操作中发生崩溃、段错误、无限循环等异常行为。
  3. 安全漏洞:如果堆溢出使得程序将恶意数据写入到其他敏感数据所在的内存区域,可能导致安全漏洞,如缓冲区溢出攻击。

为了防止堆溢出错误,需要注意以下几点:

  • 确保正确管理内存:遵循内存分配和释放的规则,确保每次分配的内存大小正确,并在不再使用时及时释放内存。
  • 不要越界访问:确保在写操作时不会超出已分配内存块的边界,注意检查索引、指针操作等。
  • 使用安全的内存管理工具:使用安全的内存管理函数,如mallocfreerealloc,它们提供了更可靠的内存管理机制,并避免了常见的堆溢出问题。
  • 进行边界检查和内存检测:对于涉及堆操作的代码,进行严格的边界检查和内存检测,以确保不会发生越界访问和潜在的内存破坏。

双重释放问题

释放已经被释放的内存块被称为”双重释放”(Double Free)问题。

​ 双重释放指的是在程序中多次释放同一个内存块的错误。当程序对一个已经被释放的内存块执行释放操作时,会导致内存管理系统处于一个不一致的状态,可能引发以下问题:

  1. 内存破坏:双重释放可能导致内存块的内容被破坏,因为该内存块的内存已经被回收,后续的释放操作会尝试释放已经被回收的内存,从而破坏其他数据结构或内存块。
  2. 崩溃和异常行为:双重释放可能导致程序在后续的内存操作中发生崩溃、段错误、无限循环等异常行为。
  3. 安全漏洞:恶意用户可能利用双重释放漏洞进行攻击,如利用双重释放漏洞进行内存破坏、绕过安全检查或执行任意代码等。

为了避免双重释放问题,需要注意以下几点:

  • 仅释放有效分配的内存块:确保每次释放的内存块都是通过合法的内存分配函数(如malloccallocrealloc)分配得到的,而不是已经被释放的内存。
  • 避免重复释放:确保每个内存块只被释放一次,在释放后将指针设置为NULL,以避免重复释放。
  • 使用合理的内存管理工具:使用高级内存管理工具(如智能指针或垃圾回收机制),它们可以自动管理内存并避免双重释放问题。
  • 谨慎处理指针的生命周期:在涉及内存释放的代码中,仔细管理指针的生命周期,确保在使用指针之前和之后都进行适当的检查和处理。

​ 总的来说,双重释放是一种严重的内存错误,可能导致内存破坏、崩溃和安全漏洞。为了避免双重释放问题,应该遵循良好的内存管理实践,仅释放有效分配的内存块,并避免重复释放同一个内存块。


释放无效指针问题

如果调用 free 函数时使用的不是之前通过动态内存分配函数(如 malloccallocrealloc)返回的指针,将会产生问题,这种问题被称为”释放无效指针”(Freeing Invalid Pointer)。

​ 释放无效指针是指将一个未通过动态内存分配函数分配而得到的指针传递给 free 函数进行内存释放操作。这种情况下,free 函数将无法正确处理这个无效指针,可能导致以下问题:

  1. 内存破坏:释放无效指针可能导致内存破坏,因为 free 函数无法确定要释放的内存块的大小和位置,从而可能影响到其他有效的内存块或数据结构。
  2. 崩溃和异常行为:释放无效指针可能导致程序在后续的内存操作中发生崩溃、段错误、无限循环等异常行为。
  3. 数据不一致:如果释放无效指针后程序继续使用该指针,可能导致数据不一致或未定义的行为,因为这个指针现在指向的是已经释放的内存区域。

为了避免释放无效指针问题,应该遵循以下几点:

  1. 仅使用有效的动态分配指针:确保将 free 函数用于之前通过动态内存分配函数(如 malloccallocrealloc)返回的有效指针。
  2. 避免重复释放:确保每个动态分配的内存块只被释放一次,重复释放同一个指针也会导致释放无效指针问题。
  3. 注意指针的生命周期:在涉及内存释放的代码中,仔细管理指针的生命周期,确保在使用指针之前和之后都进行适当的检查和处理。
  4. 使用内存调试工具:使用内存调试工具或工具链来检测和识别释放无效指针问题,以帮助发现和修复这类错误。

​ 总结来说,释放无效指针是一种常见的内存错误,可能导致内存破坏、崩溃和数据不一致。为了避免释放无效指针问题,应该确保使用 free 函数时传递的是之前通过动态内存分配函数得到的有效指针,并避免重复释放同一个指针。


内存泄漏问题

如一个进程调用 malloc 函数,但却忘记调用 free 函数,那该进程占用的存储空间就会连续增加,这被称为内存泄漏(leakage)。如果不调用free函数释放已经不再使用的空间,那么进程地址空间长度就会慢慢增长,甚至不再用空闲空间。此时,由于过度的换页开销,会造成性能下降。

内存泄漏(Memory Leak)指的是在程序中动态分配的内存在不再被使用时没有被正确释放,导致该内存无法再次被程序使用,从而造成存储空间的连续增加。当一个进程频繁地分配内存但不释放,就会导致越来越多的内存被占用而无法再被其他部分使用,从而造成内存泄漏。

内存泄漏可能导致以下问题:

  1. 内存耗尽:内存泄漏会导致系统中的可用内存逐渐减少,最终可能导致系统内存耗尽,使得进程无法继续执行或系统变得不稳定。
  2. 性能下降:内存泄漏会导致程序的内存占用不断增加,从而增加了内存管理的负担,使得程序的性能下降,运行速度变慢。
  3. 内存碎片化:内存泄漏会导致内存空间的碎片化,使得后续的内存分配变得困难,影响内存的有效利用率。
  4. 崩溃和异常行为:当内存泄漏严重时,可能导致程序出现崩溃、段错误、内存访问异常等不可预测的行为。

为了避免内存泄漏问题,应该注意以下几点:

  1. 确保及时释放内存:在动态分配内存后,确保在不再使用时及时调用相应的释放函数(如 free)进行内存释放。
  2. 注意释放所有分配的内存:确保每个动态分配的内存都得到正确的释放,防止遗漏释放导致内存泄漏。
  3. 使用工具进行内存泄漏检测:使用内存泄漏检测工具或编程工具链来检测和识别潜在的内存泄漏问题,以帮助发现和修复泄漏的内存。
  4. 使用自动内存管理工具:使用自动内存管理的工具或技术,如垃圾回收机制或智能指针,可以减少手动内存管理的复杂性和潜在的泄漏问题。

​ 总的来说,内存泄漏是一种常见的内存错误,会导致内存耗尽、性能下降和程序异常等问题。为了避免内存泄漏,应该确保及时释放动态分配的内存,并使用工具进行泄漏检测和自动内存管理。


写保护错误问题

当尝试将数据写入只读数据(rodata)区域时,会引发一个问题,这被称为”写入只读段”或”写保护错误”(Write Protection Error)。

rodata区域是指存储只读数据的内存段,例如字符串常量、静态常量等。它被标记为只读,目的是保护这些常量数据不被修改。如果尝试通过写操作修改rodata区域的内容,操作系统会捕获到这个错误,并抛出相应的异常或错误。

这种问题的发生可能有以下几种情况:

  1. 试图修改字符串常量:如果尝试将新的值赋给一个字符串常量,例如 char* str = "Hello"; str[0] = 'h';,则会导致写入只读段错误。
  2. 非法指针操作直接写入rodata内存:如果尝试通过指针操作直接写入rodata内存区域,例如 char* rodataPtr = <rodata地址>; *rodataPtr = 'x';,同样会触发写保护错误。
  3. 缓冲区溢出:如果将数据写入到 rodata 内存段之后的内存区域,可能会覆盖到 rodata 内存段的管理信息或其他数据,导致未定义的行为。

​ 这个问题的发生是由于操作系统和硬件对只读数据区域的保护机制。只读数据区域被设置为只读权限,以防止对其进行修改。当程序尝试写入这些只读数据时,操作系统会检测到这个非法操作并中止程序的执行。

​ 写入只读段错误的解决方法是避免对只读数据进行写操作。确保在程序设计和实现中,只读数据被正确地区分并用于只读目的,而不是被错误地修改。如果需要在运行时修改数据,应该将其存储在可写的内存区域,如数据段或堆内存,并使用合适的数据结构进行管理。

​ 为了避免这个问题,需要确保程序中的代码和数据在使用过程中遵循只读数据的原则。如果需要修改数据,应该将其存储在可写的数据段(如数据段或堆内存)中,并通过合适的方式进行修改。


缓冲区溢出问题

缓冲区溢出(Buffer Overflow)是一种常见的安全漏洞,发生在程序中使用的缓冲区(如数组)被写入超过其分配空间的数据量时。

​ 当向一个缓冲区写入超过其容量的数据时,多余的数据会溢出到相邻的内存区域,可能覆盖其他变量、函数返回地址或控制数据结构等关键信息。这可能导致以下问题:

  1. 内存破坏:溢出的数据可能覆盖其他变量或数据结构,导致程序在后续操作中发生未定义的行为,甚至崩溃。
  2. 安全漏洞:攻击者可以利用缓冲区溢出来修改程序的行为,例如执行恶意代码、获取敏感信息或提升权限。
  3. 执行任意代码:通过溢出覆盖函数的返回地址,攻击者可以强制程序执行任意代码,进而控制程序的行为。

​ 缓冲区溢出通常发生在使用不安全的函数(如 getsstrcpyscanf)读取用户输入时,或者在处理来自外部输入的数据时没有正确检查长度。

为了防止缓冲区溢出,应采取以下措施:

  1. 使用安全的函数:使用安全的字符串处理函数,如 fgets 替代 getsstrncpy 替代 strcpy,可以指定字符串的最大长度,避免溢出。
  2. 输入验证和边界检查:对于用户输入或外部输入的数据,进行验证和边界检查,确保数据不会超出缓冲区的容量。
  3. 使用安全的编程实践:遵循安全的编程实践,如限制输入的长度、使用合适的数据结构、避免隐式类型转换等。
  4. 使用编译器和工具支持:使用编译器提供的编译选项或静态分析工具来检测和预防缓冲区溢出。
  5. 定期更新和修补程序:及时更新和修补程序,包括修复已知的缓冲区溢出漏洞。

作用域限定问题

​ 在C和C++等编程语言中,花括号 { } 用于定义作用域块(也称为代码块、作用域或局部作用域),其中声明的变量仅在该作用域块内可见和可用。当程序流程离开该作用域块时,其中的变量将不再可用。

​ 由于变量的生命周期受到其所在作用域的限制,当作用域块结束时,其中声明的变量将被销毁,所占用的内存将被释放。这意味着在作用域块外部无法访问和使用在作用域块内部声明的变量。

​ 例如,参考以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdio.h>

void someFunction() {
int x = 10;
{
int y = 20;
printf("x = %d, y = %d\n", x, y);
}
// 在这里无法访问变量 y
printf("x = %d\n", x);
}

int main() {
someFunction();
return 0;
}

​ 在 someFunction() 函数中,我们创建了一个作用域块,并在其中声明了变量 x。在作用域块内,我们可以使用变量 x,并在 printf 语句中打印其值。然而,一旦程序流程离开该作用域块,变量 x 将不再可用。

​ 这是因为变量 x 是在作用域块内部声明的,它的作用域仅限于该作用域块内。一旦作用域块结束,该变量所占用的内存将被释放,其他部分的代码将无法再使用该变量。

​ 所以,栈区里的内存在离开其作用域后无法再使用,这是由变量作用域限定规则所导致的。这种限制有助于避免变量名称冲突和有效地管理内存的自动分配和释放。


各系统的附加检错

​ 由于存储空间分配错误很难被追踪和调试,所以一些系统提供了这些函数(可能指的是内存分配和释放函数,如mallocfree)的替代实现版本。

​ 在某些系统或开发环境中,为了帮助开发人员更好地管理内存,提供了针对内存分配和释放的定制实现版本。这些替代实现版本通常会添加额外的功能,如内存泄漏检测、内存使用跟踪、内存错误检测等,以帮助开发人员更容易地发现和修复存储空间分配问题。

​ 这些替代实现版本可以记录分配的内存块的信息、跟踪其使用情况,并在程序结束时或特定条件下生成报告,以便开发人员分析和解决存储空间分配错误。这样的实现版本可以提供更强大的工具和功能,有助于提高程序的可靠性和性能。