33、Linux 内存管理:机制、操作与优化

Linux 内存管理:机制、操作与优化

1. 内存统计信息获取

在 Linux 编程中,我们可以使用 mallinfo() 函数来获取内存分配的统计信息。该函数返回一个 mallinfo 结构体,结构体的定义如下:

/* all sizes in bytes */
struct mallinfo {
    int arena;    /* size of data segment used by malloc */
    int ordblks;  /* number of free chunks */
    int smblks;   /* number of fast bins */
    int hblks;    /* number of anonymous mappings */
    int hblkhd;   /* size of anonymous mappings */
    int usmblks;  /* maximum total allocated size */
    int fsmblks;  /* size of available fast bins */
    int uordblks; /* size of total allocated space */
    int fordblks; /* size of available chunks */
    int keepcost; /* size of trimmable space */
};

使用示例如下:

struct mallinfo m;
m = mallinfo ();
printf ("free chunks: %d\n", m.ordblks);

此外,Linux 还提供了 malloc_stats() 函数,它可以将内存相关的统计信息打印到标准错误输出:

#include <malloc.h>
void malloc_stats (void);

在内存密集型程序中调用 malloc_stats() 会输出一些较大的数值,示例输出如下:

Arena 0:
system bytes     =  865939456
in use bytes     =  851988200
Total (incl. mmap):
system bytes     = 3216519168
in use bytes     = 3202567912
max mmap regions =      65536
max mmap bytes   = 2350579712
2. 基于栈的内存分配

在之前的学习中,我们了解到动态内存分配机制大多使用堆或内存映射来获取动态内存。然而,栈作为程序地址空间中的常见结构,也可以用于动态内存分配。只要分配不会导致栈溢出,这种方法简单且性能良好。

要从栈中进行动态内存分配,可以使用 alloca() 系统调用:

#include <alloca.h>
void * alloca (size_t size);

调用 alloca() 成功时,会返回一个指向 size 字节内存的指针。这块内存位于栈上,当调用函数返回时会自动释放。有些实现可能在失败时返回 NULL ,但大多数 alloca() 实现不会失败或无法报告失败,失败表现为栈溢出。

使用 alloca() malloc() 类似,但不需要(实际上也不能)显式释放分配的内存。以下是一个示例函数,用于打开系统配置目录中的文件:

int open_sysconf (const char *file, int flags, int mode)
{
    const char *etc = SYSCONF_DIR; /* "/etc/" */
    char *name;
    name = alloca (strlen (etc) + strlen (file) + 1);
    strcpy (name, etc);
    strcat (name, file);
    return open (name, flags, mode);
}

需要注意的是,不应在函数调用的参数中使用 alloca() 分配的内存,因为分配的内存会位于为函数参数保留的栈空间中间。例如,以下代码是不允许的:

/* DO NOT DO THIS! */
ret = foo (x, alloca (10));

alloca() 接口的历史比较复杂。在许多系统上,它的表现不佳或会导致未定义行为。在栈大小固定且较小的系统上,使用 alloca() 很容易导致栈溢出并使程序崩溃。在其他一些系统上, alloca() 甚至不存在。因此,如果程序需要具备可移植性,应避免使用 alloca() 。但在 Linux 上, alloca() 是一个非常有用且未被充分利用的工具,它的性能非常好,对于小内存分配, alloca() 可以带来出色的性能提升。

3. 栈上字符串复制

alloca() 的一个常见用途是临时复制字符串。例如:

/* we want to duplicate 'song' */
char *dup;
dup = alloca (strlen (song) + 1);
strcpy (dup, song);
/* manipulate 'dup'... */
return; /* 'dup' is automatically freed */

由于这种需求很频繁,且 alloca() 具有速度优势,Linux 系统提供了 strdupa() strndupa() 函数,用于将给定字符串复制到栈上:

#define _GNU_SOURCE
#include <string.h>
char * strdupa (const char *s);
char * strndupa (const char *s, size_t n);

调用 strdupa() 会返回 s 的副本,调用 strndupa() 会复制 s 的前 n 个字符。如果 s 的长度超过 n ,复制会在 n 处停止,并在末尾添加一个空字节。这些函数与 alloca() 具有相同的优点,复制的字符串在调用函数返回时会自动释放。

4. 变长数组

C99 引入了变长数组(VLAs),其数组大小在运行时设置,而不是在编译时。GNU C 支持变长数组已有一段时间,现在 C99 对其进行了标准化,这使得它们更值得使用。

VLAs 避免了动态内存分配的开销,其使用方式如下:

for (i = 0; i < n; ++i) {
    char foo[i + 1];
    /* use 'foo'... */
}

在这个代码片段中, foo 是一个大小为 i + 1 的字符数组。在每次循环迭代时, foo 会动态创建,并在超出作用域时自动清理。如果使用 alloca() 而不是 VLA,内存将在函数返回时才会释放。使用 VLA 可以确保内存在每次循环迭代时都被释放,因此使用 VLA 最多消耗 n 字节的内存,而 alloca() 会消耗 n * (n + 1) / 2 字节。

我们可以使用变长数组重写 open_sysconf() 函数:

int open_sysconf (const char *file, int flags, int mode)
{
    const char *etc; = SYSCONF_DIR; /* "/etc/" */
    char name[strlen (etc) + strlen (file) + 1];
    strcpy (name, etc);
    strcat (name, file);
    return open (name, flags, mode);
}

alloca() 和变长数组的主要区别在于,通过 alloca() 获取的内存会在函数执行期间一直存在,而通过变长数组获取的内存会在持有变量超出作用域时释放,这可能在当前函数返回之前。在一个函数中混合使用 alloca() 和变长数组可能会导致奇怪的行为,因此建议在一个函数中只使用其中一种。

5. 选择内存分配机制

面对众多的内存分配选项,程序员可能会疑惑哪种解决方案最适合特定的任务。在大多数情况下, malloc() 是最佳选择,但有时其他方法可能更合适。以下是 Linux 中各种内存分配方法的优缺点总结:
| 分配方法 | 优点 | 缺点 |
| ---- | ---- | ---- |
| malloc() | 简单、常见 | 返回的内存不一定为零 |
| calloc() | 便于分配数组,返回的内存会被清零 | 如果不分配数组,接口会比较复杂 |
| realloc() | 可调整现有分配的大小 | 仅用于调整现有分配的大小 |
| brk() sbrk() | 对堆提供精细控制 | 对于大多数用户来说,级别太低 |
| 匿名内存映射 | 易于使用,可共享,允许开发者调整保护级别并提供建议,适合大内存映射 | 对于小分配不是最优选择; malloc() 在合适时会自动使用匿名内存映射 |
| posix_memalign() | 可分配按任意合理边界对齐的内存 | 相对较新,可移植性存疑;除非对齐问题很关键,否则有些大材小用 |
| memalign() valloc() | 在其他 Unix 系统上比 posix_memalign() 更常见 | 不是 POSIX 标准,对齐控制不如 posix_memalign() |
| alloca() | 分配速度非常快,无需显式释放内存,适合小分配 | 无法返回错误,不适合大分配,在某些 Unix 系统上有问题 |
| 变长数组 | 与 alloca() 类似,但数组超出作用域时释放内存,而不是函数返回时 | 仅适用于数组;在某些情况下, alloca() 的释放行为可能更合适;在其他 Unix 系统上不如 alloca() 常见 |

此外,我们也不应忘记自动和静态内存分配。在栈上分配自动变量或在堆上分配全局变量通常更简单,并且不需要程序员管理指针和担心释放内存。

6. 内存操作

C 语言提供了一系列用于操作原始字节内存的函数。这些函数在很多方面与字符串操作接口(如 strcmp() strcpy() )类似,但它们依赖于用户提供的缓冲区大小,而不是假设字符串以空字符结尾。需要注意的是,这些函数都不能返回错误,防止错误的责任在于程序员,如果传入错误的内存区域,可能会导致段错误。

6.1 设置字节

在内存操作函数中,最常见的是 memset()

#include <string.h>
void * memset (void *s, int c, size_t n);

调用 memset() 会将从 s 开始的 n 个字节设置为字节 c ,并返回 s 。常见的用法是将一块内存清零:

/* zero out [s,s+256) */
memset (s, '\0', 256);

bzero() 是 BSD 引入的一个较旧的、已弃用的接口,用于执行相同的任务。新代码应使用 memset() ,但 Linux 提供 bzero() 以实现向后兼容和与其他系统的可移植性:

#include <strings.h>
void bzero (void *s, size_t n);

以下调用与前面的 memset() 示例相同:

bzero (s, 256);

需要注意的是, bzero() (以及其他以 b 开头的接口)需要包含 <strings.h> 头文件,而不是 <string.h>

如果可以使用 calloc() ,则不要使用 memset() 。避免使用 malloc() 分配内存后立即使用 memset() 将其清零。虽然结果可能相同,但使用单个 calloc() 函数返回已清零的内存会更好。这样不仅可以减少一次函数调用,而且 calloc() 可能能够从内核获取已经清零的内存,从而避免手动将每个字节设置为 0,提高性能。

6.2 比较字节

strcmp() 类似, memcmp() 用于比较两个内存块是否相等:

#include <string.h>
int memcmp (const void *s1, const void *s2, size_t n);

调用该函数会比较 s1 s2 的前 n 个字节,如果内存块相等则返回 0,如果 s1 小于 s2 则返回小于 0 的值,如果 s1 大于 s2 则返回大于 0 的值。

BSD 还提供了一个现在已弃用的接口 bcmp() ,用于执行大致相同的任务:

#include <strings.h>
int bcmp (const void *s1, const void *s2, size_t n);

调用 bcmp() 会比较 s1 s2 的前 n 个字节,如果内存块相等则返回 0,如果不同则返回非零值。

由于结构体填充的原因,使用 memcmp() bcmp() 比较两个结构体是否相等是不可靠的。在结构体的填充部分可能存在未初始化的垃圾数据,这会导致两个原本相同的结构体实例比较结果不同。因此,以下代码是不安全的:

/* are two dinghies identical? (BROKEN) */
int compare_dinghies (struct dinghy *a, struct dinghy *b)
{
    return memcmp (a, b, sizeof (struct dinghy));
}

程序员如果要比较结构体,应该逐个比较结构体的每个元素。这种方法虽然更繁琐,但比不安全的 memcmp() 方法更可靠。以下是等效的代码:

/* are two dinghies identical? */
int compare_dinghies (struct dinghy *a, struct dinghy *b)
{
    int ret;
    if (a->nr_oars < b->nr_oars)
        return -1;
    if (a->nr_oars > b->nr_oars)
        return 1;
    ret = strcmp (a->boat_name, b->boat_name);
    if (ret)
        return ret;
    /* and so on, for each member... */
}
6.3 移动字节

memmove() 用于将 src 的前 n 个字节复制到 dst ,并返回 dst

#include <string.h>
void * memmove (void *dst, const void *src, size_t n);

BSD 提供了一个已弃用的接口 bcopy() ,用于执行相同的任务:

#include <strings.h>
void bcopy (const void *src, void *dst, size_t n);

需要注意的是,虽然两个函数的参数相同,但 bcopy() 中前两个参数的顺序是相反的。

bcopy() memmove() 都可以安全地处理重叠的内存区域。由于这种情况比较罕见,C 标准定义了一个 memmove() 的变体 memcpy() ,它不支持重叠的内存区域,但可能更快:

#include <string.h>
void * memcpy (void *dst, const void *src, size_t n);

该函数的行为与 memmove() 相同,除了 dst src 不能重叠。如果重叠,结果是未定义的。

另一个安全的复制函数是 memccpy()

#include <string.h>
void * memccpy (void *dst, const void *src, int c, size_t n);

memccpy() 的行为与 memcpy() 相同,除了如果在 src 的前 n 个字节中找到字节 c ,复制会停止。调用返回 dst c 后面的下一个字节的指针,如果未找到 c 则返回 NULL

最后,还可以使用 mempcpy() 来遍历内存:

#define _GNU_SOURCE
#include <string.h>
void * mempcpy (void *dst, const void *src, size_t n);

mempcpy() 的行为与 memcpy() 相同,除了它返回复制的最后一个字节后面的下一个字节的指针。这在将一组数据复制到连续的内存位置时很有用,但改进并不大,因为返回值只是 dst + n 。该函数是 GNU 特定的。

6.4 搜索字节

memchr() memrchr() 用于在内存块中查找给定的字节:

#include <string.h>
void * memchr (const void *s, int c, size_t n);

memchr() 会扫描 s 指向的 n 个字节的内存,查找字符 c (被解释为无符号字符)。

#define _GNU_SOURCE
#include <string.h>
void * memrchr (const void *s, int c, size_t n);

memrchr() memchr() 相同,除了它从 s 指向的 n 个字节的末尾开始向后搜索,而不是从开头向前搜索。 memrchr() 是 GNU 扩展,不是 C 语言的一部分。

对于更复杂的搜索任务,可以使用 memmem() 函数在内存块中搜索任意字节数组:

#define _GNU_SOURCE
#include <string.h>
void * memmem (const void *haystack,
               size_t haystacklen,
               const void *needle,
               size_t needlelen);

memmem() 函数返回 needle 子块(长度为 needlelen 字节)在 haystack 内存块(长度为 haystacklen 字节)中首次出现的指针。如果未找到 needle ,则返回 NULL 。该函数也是 GNU 扩展。

6.5 混淆字节

Linux C 库提供了一个用于简单混淆数据字节的接口:

#define _GNU_SOURCE
#include <string.h>
void * memfrob (void *s, size_t n);

调用 memfrob() 会通过将从 s 开始的前 n 个字节与数字 42 进行异或运算来混淆这些字节,并返回 s

再次对同一内存区域调用 memfrob() 可以反转 memfrob() 的效果。因此,以下代码片段对 secret 没有实际作用:

memfrob (memfrob (secret, len), len);

该函数绝不是加密的合适替代品,其用途仅限于简单的字符串混淆。它是 GNU 特定的。

7. 锁定内存

Linux 实现了按需分页,即页面根据需要从磁盘调入,不再需要时调出到磁盘。这使得系统上进程的虚拟地址空间与物理内存总量没有直接关系,因为二级存储可以提供几乎无限的物理内存供应的假象。

分页是透明进行的,应用程序通常不需要关心(甚至不需要知道)Linux 内核的分页行为。然而,在以下两种情况下,应用程序可能希望影响系统的分页行为:
- 确定性 :有时间限制的应用程序需要确定性的行为。如果某些内存访问导致页面错误(会导致昂贵的磁盘 I/O 操作),应用程序可能会超出其时间需求。通过确保所需的页面始终在物理内存中,并且永远不会被分页到磁盘,应用程序可以保证内存访问不会导致页面错误,从而提供一致性、确定性和提高性能。
- 安全性 :如果秘密数据存储在内存中,这些数据可能会被分页到磁盘并以未加密的形式存储。例如,如果用户的私钥通常以加密形式存储在磁盘上,内存中未加密的密钥副本可能会最终存储在交换文件中。在高安全环境中,这种行为可能是不可接受的。可能存在此问题的应用程序可以要求包含密钥的内存始终保留在物理内存中。

Linux 内存管理:机制、操作与优化

8. 锁定内存的实现

为了实现内存锁定,Linux 提供了一系列相关的系统调用。以下是常用的几个函数:

8.1 mlock() 和 mlockall()

mlock() 函数用于锁定指定的内存区域,使其不会被分页到磁盘:

#include <sys/mman.h>
int mlock(const void *addr, size_t len);
  • addr :指向要锁定的内存区域的起始地址。
  • len :要锁定的内存区域的长度(字节)。

调用成功时返回 0,失败时返回 -1,并设置 errno

mlockall() 函数用于锁定进程的所有内存,包括已映射的文件、堆、栈等:

#include <sys/mman.h>
int mlockall(int flags);
  • flags :可以是以下标志的组合:
  • MCL_CURRENT :锁定当前已映射的内存。
  • MCL_FUTURE :锁定未来映射的内存。

调用成功时返回 0,失败时返回 -1,并设置 errno

以下是一个使用 mlock() 的示例:

#include <stdio.h>
#include <sys/mman.h>
#include <stdlib.h>

#define SIZE 1024

int main() {
    char *buf = (char *)malloc(SIZE);
    if (buf == NULL) {
        perror("malloc");
        return 1;
    }

    if (mlock(buf, SIZE) == -1) {
        perror("mlock");
        free(buf);
        return 1;
    }

    // 使用锁定的内存
    for (int i = 0; i < SIZE; i++) {
        buf[i] = 'A';
    }

    if (munlock(buf, SIZE) == -1) {
        perror("munlock");
    }

    free(buf);
    return 0;
}
8.2 munlock() 和 munlockall()

mlock() mlockall() 相对应, munlock() 用于解锁指定的内存区域:

#include <sys/mman.h>
int munlock(const void *addr, size_t len);
  • addr :指向要解锁的内存区域的起始地址。
  • len :要解锁的内存区域的长度(字节)。

调用成功时返回 0,失败时返回 -1,并设置 errno

munlockall() 用于解锁进程的所有锁定内存:

#include <sys/mman.h>
int munlockall(void);

调用成功时返回 0,失败时返回 -1,并设置 errno

9. 内存管理的性能优化建议

在进行 Linux 内存管理时,为了提高性能和稳定性,我们可以遵循以下一些建议:

9.1 合理选择内存分配函数

根据具体的需求,选择合适的内存分配函数。例如:
- 如果只需要简单的内存分配,且不需要清零,优先使用 malloc()
- 如果需要分配数组并清零,使用 calloc()
- 如果需要调整已分配内存的大小,使用 realloc()
- 对于小内存分配,在 Linux 系统上可以考虑使用 alloca() 或变长数组,但要注意其局限性。

9.2 避免不必要的内存锁定

虽然内存锁定可以提高确定性和安全性,但也会增加系统的内存压力。只有在确实需要时才使用内存锁定,并且在使用完毕后及时解锁。

9.3 减少内存碎片

频繁的内存分配和释放可能会导致内存碎片,影响内存的使用效率。可以采用以下方法减少内存碎片:
- 尽量分配和释放大小相近的内存块。
- 使用内存池技术,预先分配一定数量的内存块,避免频繁的系统调用。

9.4 优化内存访问模式

合理的内存访问模式可以减少缓存缺失,提高内存访问速度。例如:
- 尽量按顺序访问内存,避免随机访问。
- 利用局部性原理,将相关的数据存储在相邻的内存位置。

10. 内存管理的常见错误及解决方法

在进行内存管理时,可能会遇到一些常见的错误,以下是一些常见错误及解决方法:

10.1 内存泄漏

内存泄漏是指程序在运行过程中分配的内存没有被正确释放,导致内存占用不断增加。常见的原因包括忘记调用 free() munmap() 等释放函数。

解决方法:
- 仔细检查代码,确保所有分配的内存都有对应的释放操作。
- 可以使用内存检测工具,如 Valgrind,来检测内存泄漏。

10.2 悬空指针

悬空指针是指指针指向的内存已经被释放,但仍然被使用。这会导致未定义行为,如程序崩溃或数据损坏。

解决方法:
- 在释放内存后,将指针设置为 NULL ,避免再次使用。
- 检查代码,确保没有在内存释放后继续使用指针。

10.3 缓冲区溢出

缓冲区溢出是指写入的数据超出了缓冲区的边界,可能会覆盖相邻的内存区域,导致程序崩溃或安全漏洞。

解决方法:
- 确保在写入数据时,不会超出缓冲区的大小。
- 使用安全的字符串处理函数,如 strncpy() strncat() ,而不是 strcpy() strcat()

11. 总结

Linux 内存管理涉及到多个方面,包括内存统计信息获取、不同的内存分配机制、内存操作函数、内存锁定以及性能优化等。通过合理选择内存分配函数、避免常见的内存错误、优化内存访问模式等方法,可以提高程序的性能和稳定性。

在实际开发中,要根据具体的需求和场景,灵活运用各种内存管理技术。同时,要注意内存管理的安全性,避免出现内存泄漏、悬空指针和缓冲区溢出等问题。通过不断学习和实践,我们可以更好地掌握 Linux 内存管理的技巧,编写出高效、稳定的程序。

以下是一个简单的流程图,展示了内存分配和释放的基本流程:

graph TD;
    A[开始] --> B[选择内存分配函数];
    B --> C{分配成功?};
    C -- 是 --> D[使用内存];
    C -- 否 --> E[处理错误];
    D --> F[释放内存];
    F --> G[结束];
    E --> G;

另外,为了方便对比不同内存分配函数的特点,再次给出之前的表格:
| 分配方法 | 优点 | 缺点 |
| ---- | ---- | ---- |
| malloc() | 简单、常见 | 返回的内存不一定为零 |
| calloc() | 便于分配数组,返回的内存会被清零 | 如果不分配数组,接口会比较复杂 |
| realloc() | 可调整现有分配的大小 | 仅用于调整现有分配的大小 |
| brk() sbrk() | 对堆提供精细控制 | 对于大多数用户来说,级别太低 |
| 匿名内存映射 | 易于使用,可共享,允许开发者调整保护级别并提供建议,适合大内存映射 | 对于小分配不是最优选择; malloc() 在合适时会自动使用匿名内存映射 |
| posix_memalign() | 可分配按任意合理边界对齐的内存 | 相对较新,可移植性存疑;除非对齐问题很关键,否则有些大材小用 |
| memalign() valloc() | 在其他 Unix 系统上比 posix_memalign() 更常见 | 不是 POSIX 标准,对齐控制不如 posix_memalign() |
| alloca() | 分配速度非常快,无需显式释放内存,适合小分配 | 无法返回错误,不适合大分配,在某些 Unix 系统上有问题 |
| 变长数组 | 与 alloca() 类似,但数组超出作用域时释放内存,而不是函数返回时 | 仅适用于数组;在某些情况下, alloca() 的释放行为可能更合适;在其他 Unix 系统上不如 alloca() 常见 |

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值