在 c 中初始化整数指针,不会导致预期的未指定行为

Initializing integer pointers in c, not leading to unspecified behavior as expected

最近切换到 c,周日我被告知有一千种方法引用未初始化的值不是好的做法,并且会导致意外行为。具体来说,(因为我以前的语言将整数初始化为 0)我被告知整数在未初始化时可能不等于零。所以我决定测试一下。

我写了下面的代码来测试这个说法:

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

int main(){
    size_t counter = 0;
    size_t testnum = 2000; //The number of ints to allocate and test.
    for(int i = 0; i < testnum; i++){
        int* temp = malloc(sizeof(int));
        assert(temp != NULL); //Just in case there's no space.
        if(*temp == 0) counter++;
    }
    printf(" %d",counter);
    return 0;
}

我是这样编译的(以防万一):

gcc -std=c99 -pedantic name-of-file.c

根据我的导师所说,我希望 temp 指向一个随机整数,并且计数器不会经常递增。然而,我的结果完全否定了这个假设:

testnum:  ||  code returns:
2             2
20            20
200           200
2000          2000
20000         20000
200000        200000
2000000       2000000
...           ...

结果是 10 的几次幂 (*2),但您明白了。

然后我测试了上述代码的类似版本,但我初始化了一个整数数组,将每个偶数索引设置为其先前值(未初始化)加 1,释放数组,然后执行上面的代码,测试与数组大小相同数量的整数(即 testnum)。这些结果更有趣:

testnum:  ||  code returns:
2             2
20            20
200           175
2000          1750
20000         17500
200000        200000
2000000       2000000
...           ...

基于此,可以合理地得出结论,c 重用释放的内存(显然),并将其中一些新的整数指针设置为指向包含先前递增的整数的地址。我的问题是为什么我在第一个测试中的所有整数指针始终指向 0。它们不应该指向我的计算机提供程序的堆上的任何空 spaces,它可以(并且应该,在某点)包含非零值?

换句话说,为什么我的 c 程序可以访问的所有新堆 space 都被清除为全 0?

首先,您需要了解,C 是一种在标准中描述并由多个编译器(gcc、clang、icc 等)实现的语言。在一些情况下,该标准提到某些表达式或操作会导致未定义的行为。

了解的重要一点是,这意味着您无法保证行为会是什么。事实上,任何 compiler/implementation 基本上都可以 为所欲为!

在您的示例中,这意味着您不能对未初始化的内存何时包含进行任何假设。因此,假设它将是随机的或包含先前释放的对象的元素与假设它为零一样错误,因为任何一种情况都可能随时发生。

许多编译器(或 OS's)会始终如一地做同样的事情(比如你观察者的 0),但这也不能保证。

(要可能看到不同的行为,请尝试使用不同的编译器或不同的标志。)

未定义的行为不意味着 "random behavior" 也不意味着 "the program will crash." 未定义的行为意味着 "the compiler is allowed to assume that this never happens," 和 "if this does happen, the program could do anything." 任何事情都包括做一些无聊和可预测的事情。

此外,允许实现定义任何未定义行为的实例。例如,ISO C 从未提及 header unistd.h,因此 #include <unistd.h> 具有未定义的行为,但在符合 POSIX 的实现中,它具有 well-defined 并记录在案行为。

您编写的程序 可能 观察到未初始化的 malloced 内存为零,因为如今,用于分配内存的系统原语(sbrkmmap 在 Unix 上,VirtualAlloc 在 Windows 上)总是在返回之前将内存清零。这是原语 的记录行为 ,但 malloc 的记录行为 不是 ,因此您只能在调用时依赖它原语直接。 (注意只有 malloc 实现可以调用 sbrk。)

更好的演示是这样的:

#include <stdio.h>
#include <stdlib.h>
int
main(void)
{
    {
        int *x = malloc(sizeof(int));
        *x = 0xDEADBEEF;
        free(x);
    }
    {
        int *y = malloc(sizeof(int));
        printf("%08X\n", *y);
    }
    return 0;
}

打印 "DEADBEEF" 的几率非常高(但是 允许 打印 00000000 或 5E5E5E5E,或者让恶魔从你的鼻子里飞出来)。

另一个更好的演示是根据未初始化变量的值做出 control-flow 决定的任何程序,例如

int foo(int x)
{
    int y;
    if (y == 5)
        return x;
    return 0;
}

Current versions of gcc and clang will generate code that always returns 0, but the current version of ICC will generate code that returns either 0 or the value of x, depending on whether register EDX is equal to 5 when the function is called. 两种可能性都是正确的,因此生成的代码总是 returns x,生成的代码也会让恶魔从你的鼻子里飞出来。

无用的考虑,错误的假设,错误的测试。在您的测试中,每次您 malloc sizeof int 的新鲜内存。要查看您想查看的那个 UB,您应该在分配的内存中放入一些东西,然后释放它。否则你不会重用它,你只是泄漏它。出于安全原因,大多数 OS-es 在执行程序之前清除分配给程序的所有内存(因此当您启动程序时,所有内容都被清零或初始化为静态值)。

将您的程序更改为:

int main(){
    size_t counter = 0;
    size_t testnum = 2000; //The number of ints to allocate and test.
    for(int i = 0; i < testnum; i++){
        int* temp = malloc(sizeof(int));
        assert(temp != NULL); //Just in case there's no space.
        if(*temp == 0) counter++;
        *temp = rand();
        free(temp);
    }
    printf(" %d",counter);
    return 0;
}

如您所知,您正在调用未定义的行为,所以所有的赌注都没有了。要解释您观察到的特定结果 ("why is uninitialized memory that I haven't written to all zeros?"),您首先必须了解 malloc 的工作原理。

首先,malloc不只是在调用时直接向系统请求页面。它有一个内部 "cache" 可以从中传递记忆。假设您调用 malloc(16) 两次。第一次调用 malloc(16) 时,它将扫描缓存,发现它是空的,然后从 OS 请求一个新页面(在大多数系统上为 4KB)。然后它将这个页面分成两个块,给你较小的块,并将另一个块保存在它的缓存中。第二次调用 malloc(16) 时,它会发现它的缓存中有足够大的块,并通过再次拆分该块来分配内存。

free 将内存简单地 return 发送到缓存。在那里,它可能会(也可能不会)与其他块合并以形成更大的块,然后用于其他分配。根据您的分配器的详细信息,如果可能,它还可以选择 return 将页面释放到 OS。

现在是拼图的第二块 -- 您从 OS 获得的任何新页面都填充了 0。为什么?想象一下,它只是向您提供了一个未使用的页面,该页面以前被其他一些现在已终止的进程使用过。现在您遇到了安全问题,因为通过扫描 "uninitialized memory",您的进程可能会找到敏感数据,例如先前进程使用的密码和私钥。请注意,C 语言不保证会发生这种情况(OS 可能会保证,但 C 规范并不关心)。 OS 可能用随机数据填充了页面,或者根本没有清除它(在嵌入式设备上尤其常见)。

现在您应该能够解释您观察到的行为了。第一次,您从 OS 获取 新页面 ,所以它们是空的(同样,这是您的 OS 的实现细节,而不是 C语)。但是,如果您再次 mallocfree,然后再次 malloc,则您有可能取回缓存中的相同内存。此缓存内存 被擦除,因为唯一可以写入它的进程是您自己的进程。因此,您只需获取之前存在的任何数据。

注意:这解释了 您的特定 malloc 实现 的行为。它不会推广到所有 malloc 实现。