| 目录 |

12 手动内存分配

这很可能是 C 与你已经熟悉的语言差别最大的领域之一:手动内存管理。

其他语言使用引用计数、垃圾回收或其他手段,来决定什么时候为某些数据分配新内存——以及在没有变量引用它时什么时候释放。

这很好。能不用操心这件事确实很好:只要丢掉对某个条目的所有引用,然后相信它关联的内存在某个时刻会被释放就行。

但 C 并不完全是那样。

当然,在 C 中,有些变量在进入和离开作用域时会自动分配和释放。我们称它们为自动变量。它们就是最普通的那种块作用域“局部”变量。没什么问题。

但如果你希望某个东西比某个块活得更久呢?这时手动内存管理就派上用场了。

你可以明确告诉 C 为你分配一定数量的字节,随你怎么用。这些字节会一直保持已分配状态,直到你显式释放那块内存89。

用完之后释放内存很重要!如果不释放,我们就称之为内存泄漏,你的进程会一直占着那块内存,直到它退出。

如果是你手动分配的,那用完之后就得手动释放。

那么该怎么做呢?我们要学几个新函数,并利用 sizeof 运算符来帮我们弄清楚该分配多少字节。

在 C 的日常说法里,开发者会说自动局部变量分配“在栈上”,而手动分配的内存“在堆上”。规范并没有谈及这两样东西,但只要你提起,所有 C 开发者都明白你在说什么。

本章要学的所有函数都能在 <stdlib.h> 里找到。

12.1 分配与释放:malloc() 与 free()

malloc() 函数接受要分配的字节数,并返回一个指向那块新分配内存的 void 指针。

由于它是 void*,你可以把它赋给任何你想要的指针类型……通常这会在某种程度上与你分配的字节数相对应。

那么……我该分配多少字节?我们可以用 sizeof 来帮忙。如果想为单个 int 分配足够的空间,可以用 sizeof(int),再把它传给 malloc()。

某块已分配的内存用完之后,我们可以调用 free(),表示这块内存用完了,可以拿去做别的事。作为实参,你要传的是从 malloc() 得到的同一个指针(或它的副本)。在 free() 之后再使用这块内存属于未定义行为。

来试试。我们为 int 分配足够的内存,往里存点东西,然后把它打印出来。

// 为一个 int 分配空间(sizeof(int) 字节):

int *p = malloc(sizeof(int));

*p = 12;  // 往里面存点东西

printf("%d\n", *p);  // 打印它:12

free(p);  // 那块内存用完了

//*p = 3490;  // 错误:未定义行为!在 free() 之后使用!

不过在那个刻意造出来的示例里,这么做其实没什么好处。我们本来直接用自动的 int 就能跑通。但后面我们会看到,这种分配内存的能力自有其优势,尤其是在处理更复杂的数据结构时。

你还常会看到另一种写法,它利用了这样一个事实:sizeof 能给出任何常量表达式的结果类型的大小。所以你也可以把变量名放进去,用它的类型。下面是这种写法的示例,和上一个差不多:

int *p = malloc(sizeof *p);  // *p 是 int,所以等同于 sizeof(int)

12.2 错误检查

所有分配函数都返回指向新分配的那段内存的指针;如果由于某种原因无法分配内存,则返回 NULL。

有些操作系统(比如 Linux)可以配置成让 malloc() 永远不返回 NULL,即使内存已经耗尽。但即便如此,写代码时也应该始终考虑加上防护。

int *x;

x = malloc(sizeof(int) * 10);

if (x == NULL) {
    printf("Error allocating 10 ints\n");
    // 在这里做点处理
}

你会经常看到下面这种写法:把赋值和条件判断放在同一行里:

int *x;

if ((x = malloc(sizeof(int) * 10)) == NULL) {
    printf("Error allocating 10 ints\n");
    // 在这里做点处理
}

12.3 为数组分配空间

我们已经看到如何为单个东西分配空间;那如果是一堆东西组成的数组呢?

在 C 中,数组就是一堆相同的东西挨个排在一段连续的内存里。

我们可以分配一段连续的内存——这个我们已经知道怎么做了。如果想要 3490 字节的内存,直接要就行了:

char *p = malloc(3490);  // 大功告成

而——没错!——那就是一个含 3490 个 char 的数组(也就是字符串!),因为每个 char 占 1 字节。换句话说,sizeof(char) 就是 1。

注意:新分配的内存不会做任何初始化——里面全是垃圾数据。想清空的话可以用 memset(),或者看看下面的 calloc()。

我们只要把想要的东西的大小乘以想要的元素个数,然后用指针写法或数组写法访问它们就行了。上示例!

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    // 为 10 个 int 分配空间
    int *p = malloc(sizeof(int) * 10);

    // 给它们赋值 0-45:
    for (int i = 0; i < 10; i++)
        p[i] = i * 5;

    // 打印所有值 0, 5, 10, 15, ..., 40, 45
    for (int i = 0; i < 10; i++)
        printf("%d\n", p[i]);

    // 释放这块空间
    free(p);
}

关键就在那行 malloc() 里。如果我们知道每个 int 需要 sizeof(int) 字节来存放,而且知道要 10 个,那就可以用下面这行精确分配那么多字节:

sizeof(int) * 10

这个技巧对每种类型都管用。把它交给 sizeof,再乘以数组的大小即可。

12.4 另一种选择:calloc()

这是另一个分配函数,用法和 malloc() 类似,但有两个关键区别:

通过 calloc() 得到的内存,仍然要用 free() 来释放。

下面是 calloc() 与 malloc() 的对比。

// 用 calloc() 为 10 个 int 分配空间,初始化为 0:
int *p = calloc(10, sizeof(int));

// 用 malloc() 为 10 个 int 分配空间,初始化为 0:
int *q = malloc(10 * sizeof(int));
memset(q, 0, 10 * sizeof(int));   // 置为 0

同样,两者的结果一样,只不过 malloc() 默认不会把内存清零。

12.5 用 realloc() 改变已分配的大小

如果你已经分配了 10 个 int,后来发现需要 20 个,能怎么办?

一种做法是另分配一块新空间,然后用 memcpy() 把内存搬过去……但事实证明,有时候根本不需要搬。而有一个函数足够聪明,能在各种恰当的情况下做出正确的处理:realloc()。

它接受一个指向先前分配的内存(由 malloc() 或 calloc() 分配)的指针,以及内存区域的新大小。

然后它会把那块内存扩大或缩小,并返回指向它的指针。有时它返回的是同一个指针(如果数据不必复制到别处),有时返回的是另一个指针(如果数据确实不得不复制)。

调用 realloc() 时,一定要指定要分配的字节数,而不只是数组元素的个数!也就是说:

num_floats *= 2;

np = realloc(p, num_floats);  // 错误:需要的是字节数,不是元素个数!

np = realloc(p, num_floats * sizeof(float));  // 这样更好!

我们来分配一个含 20 个 float 的数组,然后改主意,把它变成含 40 个元素的数组。

我们会把 realloc() 的返回值赋给另一个指针,这样才能检查它是否为 NULL。如果不是,就可以再把它赋回原来的指针。(如果我们直接把返回值赋给原来的指针,那么当函数返回 NULL 时,原指针就丢了,再也拿不回来。)

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    // 为 20 个 float 分配空间
    float *p = malloc(sizeof *p * 20);  // sizeof *p 等同于 sizeof(float)

    // 给它们赋 0.0-1.0 的小数值:
    for (int i = 0; i < 20; i++)
        p[i] = i / 20.0;

    // 等等!我们把它改成 40 个元素的数组
    float *new_p = realloc(p, sizeof *p * 40);

    // 检查重新分配是否成功
    if (new_p == NULL) {
        printf("Error reallocing\n");
        return 1;
    }

    // 如果成功,直接把结果赋回 p
    p = new_p;

    // 并给新元素赋 1.0-2.0 范围内的值
    for (int i = 20; i < 40; i++)
        p[i] = 1.0 + (i - 20) / 20.0;

    // 打印这 40 个元素中所有 0.0-2.0 的值:
    for (int i = 0; i < 40; i++)
        printf("%f\n", p[i]);

    // 释放这块空间
    free(p);
}

注意我们在里面是怎么把 realloc() 的返回值重新赋给传入的同一个指针变量 p 的。这种写法很常见。

另外,如果第 7 行里那个 sizeof *p 看着奇怪,请记住:sizeof 作用的是表达式的类型的大小。而 *p 的类型是 float,所以那一行等价于 sizeof(float)。

最后,代码里任何地方都没有 free(new_p),这也许有点奇怪,毕竟那才是 realloc() 返回的指针。原因是在第 23 行我们把 new_p 复制进了 p,所以两者的值相同;也就是说,它们都指向同一块内存,而且只有这一块。因此 free() 的时候,释放其中任何一个效果都一样。

12.5.1 读入任意长度的行

我想用这个完整的示例演示两件事。

  1. 用 realloc() 在读入更多数据时扩充缓冲区。
  2. 用 realloc() 在读完之后把缓冲区缩到恰好的大小。

这里看到的是一个循环:不断调用 fgetc() 往缓冲区里追加,直到发现最后一个字符是换行符。

一旦找到换行符,它就把缓冲区缩到刚好的大小,然后返回。

#include <stdio.h>
#include <stdlib.h>

// 从文件中读取任意长度的一行
//
// 返回指向该行的指针。
// 遇到 EOF 或错误时返回 NULL。
//
// 用完之后,由调用者负责 free() 这个指针。
//
// 注意,这会从结果中去掉换行符。如果你需要
// 把它保留在里面,最好把这里改成 do-while。

char *readline(FILE *fp)
{
    int offset = 0;   // 下一个字符在缓冲区中的索引位置
    int bufsize = 4;  // 初始大小最好是 2 的幂
    char *buf;        // 缓冲区
    int c;            // 我们读入的那个字符

    buf = malloc(bufsize);  // 分配初始缓冲区

    if (buf == NULL)   // 错误检查
        return NULL;

    // 主循环——一直读到换行符或 EOF
    while (c = fgetc(fp), c != '\n' && c != EOF) {

        // 检查缓冲区是否已经没有空间,同时还要算上
        // NUL 终止符占用的那个额外字节
        if (offset == bufsize - 1) {  // 减去 1 留给 NUL 终止符
            bufsize *= 2;  // 空间翻倍

            char *new_buf = realloc(buf, bufsize);

            if (new_buf == NULL) {
                free(buf);   // 出错时释放内存并退出
                return NULL;
            }

            buf = new_buf;  // realloc 成功
        }

        buf[offset++] = c;  // 把这个字节加到缓冲区里
    }

    // 我们遇到了换行符或 EOF……

    // 如果已到 EOF 且一个字节也没读到,就释放缓冲区并
    // 返回 NULL,表示已到达 EOF:
    if (c == EOF && offset == 0) {
        free(buf);
        return NULL;
    }

    // 收缩到实际大小
    if (offset < bufsize - 1) {  // 如果还没到末尾
        char *new_buf = realloc(buf, offset + 1); // 加 1 给 NUL 终止符

        // 如果成功,就让 buf 指向 new_buf;
        // 否则就让 buf 保持原样
        if (new_buf != NULL)
            buf = new_buf;
    }

    // 加上 NUL 终止符
    buf[offset] = '\0';

    return buf;
}

int main(void)
{
    FILE *fp = fopen("foo.txt", "r");

    char *line;

    while ((line = readline(fp)) != NULL) {
        printf("%s\n", line);
        free(line);
    }

    fclose(fp);
}

像这样扩充内存时,常见做法(虽然算不上铁律)是每一步把所需空间翻倍,以尽量减少 realloc() 的次数。

最后你可能会注意到,readline() 返回的是指向一个由 malloc() 分配的缓冲区的指针。因此,调用方用完之后要自己显式 free() 那块内存。

12.5.2 realloc() 与 NULL

冷知识时间!下面这两行是等价的:

char *p = malloc(3490);
char *p = realloc(NULL, 3490);

如果你有一个分配循环,又不想对第一次 malloc() 做特殊处理,这就会很方便。

int *p = NULL;
int length = 0;

while (!done) {
    // 再分配 10 个 int:
    length += 10;
    p = realloc(p, sizeof *p * length);

    // 干点了不起的事
    // ...
}

在那个示例里,我们不需要先用一次 malloc(),因为 p 一开始就是 NULL。

12.6 对齐分配

你大概不会用到这个。

我现在不想在这个话题上扯得太远,但确实有这么个东西叫内存对齐,它跟内存地址(指针值)是某个数的倍数有关。

例如,某个系统可能要求 16 位值从 2 的倍数的内存地址开始。又或者,比如要求 64 位值从 2、4 或 8 的倍数的内存地址开始。这取决于 CPU。

有些系统要求这种对齐才能快速访问内存,有些甚至要求这样才能访问内存。

如果你用的是 malloc()、calloc() 或 realloc(),C 给你的内存块对任何值都做了良好对齐,连 struct 也不例外。所有情况都能应付。

但有时你知道某些数据可以按更小的边界对齐,或者出于某种原因必须按更大的边界对齐。我猜这在嵌入式系统编程中更常见。

在这些情况下,你可以用 aligned_alloc() 指定对齐。

对齐值是一个大于零的 2 的整数次幂,所以是 2、4、8、16 之类,你要在 aligned_alloc() 里把它放在你关心的字节数之前传进去。

另一项限制是,你分配的字节数必须是该对齐值的倍数。不过这一点可能会改变。参见 C 缺陷报告 46090

我们来举个例子,在 64 字节边界上分配:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    // 分配 256 字节,按 64 字节边界对齐
    char *p = aligned_alloc(64, 256);  // 256 == 64 * 4

    // 把一个字符串复制进去并打印出来
    strcpy(p, "Hello, world!");
    printf("%s\n", p);

    // 释放这块空间
    free(p);
}

这里我要就 realloc() 和 aligned_alloc() 提一句。realloc() 没有任何对齐保证,所以如果你需要得到重新分配过的对齐空间,只能自己用 memcpy() 费劲地实现。

如果你需要,这里有一个非标准的 aligned_realloc() 函数:

void *aligned_realloc(void *ptr, size_t old_size, size_t alignment, size_t size)
{
    char *new_ptr = aligned_alloc(alignment, size);

    if (new_ptr == NULL)
        return NULL;

    size_t copy_size = old_size < size? old_size: size;  // 求最小值

    if (ptr != NULL)
        memcpy(new_ptr, ptr, copy_size);

    free(ptr);

    return new_ptr;
}

注意它总是会复制数据,很耗时,而真正的 realloc() 在可能时会避免复制。所以这远谈不上高效。最好避免出现需要重新分配自定义对齐数据的情况。


| 目录 |