【问题标题】:Why does the first element outside of a defined array default to zero?为什么定义数组之外的第一个元素默认为零?
【发布时间】:2022-01-17 06:47:55
【问题描述】:

我正在为 C++ 课程介绍的期末考试而学习。我们的教授给我们练习了这个问题:

解释代码产生以下输出的原因:120 200 16 0

using namespace std;
int main()
{
  int x[] = {120, 200, 16};
  for (int i = 0; i < 4; i++)
    cout << x[i] << " ";
}

问题的示例答案是:

cout 语句只是循环遍历数组元素,其下标由 for 循环的增量定义。元素大小不是由数组初始化定义的。 for 循环定义了数组的大小,恰好超过了初始化元素的数量,因此最后一个元素默认为零。 第一个 for 循环打印元素 0 (120),第二个打印元素 1 (200),第三个循环打印元素 2 (16),第四个循环打印默认数组值 0,因为没有为元素 3 初始化任何内容。此时 i 现在超出条件并且 for 循环终止。

我有点困惑,为什么数组外的最后一个元素总是“默认”为零。只是为了进行实验,我将问题中的代码粘贴到我的 IDE 中,但将 for 循环更改为 for (int i = 0; i &lt; 8; i++)。然后输出变为120 200 16 0 4196320 0 547306487 32655。为什么尝试访问超出定义大小的数组中的元素时没有错误?程序是否只输出上次将值保存到该内存地址时的“剩余”数据?

【问题讨论】:

  • 行为未定义。其他的都无所谓。
  • 默认不为零。示例答案是错误的。未定义的行为是未定义的。
  • "for 循环定义了数组的大小" --> 否并且"因此对于最后一个元素默认为零。" --> 没有。要求退还学费。
  • "元素大小不是数组初始化定义的。for循环定义了数组的大小,..."这两个语句都是错误的。
  • 如果int x[4] = {120, 200, 16}; 会有意义

标签: c++ arrays initialization undefined-behavior zero-initialization


【解决方案1】:

更正答案

不,它不默认为 0。这是未定义的行为。在这种情况下,这种优化和这种编译器恰好是 0。尝试访问未初始化或未分配的内存是未定义的行为。

因为它实际上是“未定义的”,并且标准对此没有其他要说的,所以您的程序集输出不会是一致的。编译器可能会将数组存储在 SIMD 寄存器中,谁知道输出会是什么?

引用示例答案:

第四个循环打印默认数组值零,因为没有为元素 3 初始化任何内容

这是有史以来最错误的说法。我猜代码中有错字,他们想写出来

int x[4] = {120, 200, 16};

并错误地将 x[4] 变成了 x[]。如果不是,而且是故意的,我不知道该说什么。他们错了。

为什么不是错误?

这不是错误,因为堆栈就是这样工作的。您的应用程序不需要在堆栈中分配内存来使用它,它已经是您的了。你可以随心所欲地用你的堆栈做任何事情。当你像这样声明一个变量时:

int a;

您所做的只是告诉编译器,“我希望堆栈的 4 个字节用于 a,请不要将这些内存用于其他任何事情。”在编译时。看这段代码:

#include <stdio.h>

int main() {
    int a;
}

组装:

    .file   "temp.c"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    pushq   %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6 /* Init stack and stuff */
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret /* Pop the stack and return? Yes. It generated literally no code.
           All this just makes a stack, pops it and returns. Nothing. */
    .cfi_endproc /* Stuff after this is system info, and other stuff
                 we're not interested. */
.LFE0:
    .size   main, .-main
    .ident  "GCC: (Ubuntu 11.1.0-1ubuntu1~20.04) 11.1.0"
    .section    .note.GNU-stack,"",@progbits
    .section    .note.gnu.property,"a"
    .align 8
    .long   1f - 0f
    .long   4f - 1f
    .long   5
0:
    .string "GNU"
1:
    .align 8
    .long   0xc0000002
    .long   3f - 2f
2:
    .long   0x3
3:
    .align 8
4:

阅读代码中的cmets进行解释。

所以,你可以看到int x; 什么都不做。如果我打开优化,编译器甚至不会费心制作堆栈并做所有这些事情,而是直接返回。 int x; 只是一个编译时命令给编译器说:

x 是一个带符号整数的变量。它需要4个字节,请跳过这4个字节(和对齐)后继续声明。

(堆栈的)高级语言中的变量的存在只是为了使堆栈的“分布”更加系统化并且以一种可读的方式存在。变量的声明不是运行时过程。它只是教编译器如何在变量之间分配堆栈并相应地准备程序。执行时,程序分配一个堆栈(这是一个运行时进程),但它已经硬编码了哪些变量获得了堆栈的哪个部分。例如。变量a 可能将-0(%rbp) 变为-4(%rbp),而b-5(%rbp) 变为-8(%rbp)。这些值是在编译时确定的。变量的名称在编译时也不存在,它们只是教编译器如何准备程序以使用其堆栈的一种方式。

您作为用户可以随意使用堆栈;但你可能不会。您应该始终声明变量或数组以让编译器知道。

边界检查

在像 Go 这样的语言中,即使您的堆栈是您的,编译器也会插入额外的检查以确保您不会意外使用未声明的内存。出于性能原因,在 C 和 C++ 中没有这样做,它会导致可怕的未定义行为和分段错误更频繁地发生。

堆和数据部分

堆是存储大数据的地方。这里没有存储变量,只有数据;并且您的一个或多个变量将包含指向该数据的指针。如果您使用尚未分配的内容(在运行时完成),则会出现分段错误。

数据部分是另一个可以存储东西的地方。变量可以存储在这里。它与您的代码一起存储,因此超出分配非常危险,因为您可能会不小心修改程序的代码。由于它与您的代码一起存储,因此显然也是在编译时分配的。我实际上对数据部分的内存安全知之甚少。显然,您可以在操作系统不抱怨的情况下超过它,但我不知道更多,因为我不是系统黑客,也没有可疑的目的将其用于恶意意图。基本上,我不知道在数据部分超出分配。希望有人对此发表评论(或回答)。

上面显示的所有程序集都是在 Ubuntu 机器上由 GCC 11.1 编译的 C。它使用 C 而不是 C++ 来提高可读性。

【讨论】:

  • “我猜代码中有错字,他们想改成int x[4]...”——他们还说“for循环定义了数组的大小”,所以看起来它不是错字,但他们完全错了。
  • ^ 就我个人而言,正是后一个引用(“for 循环定义数组的大小”)作为讲师解决方案中最错误的陈述跳出来。它甚至根本没有任何意义。
  • @DanielR.Collins 这意味着什么?这是否意味着数组就像一个列表,每次迭代都会向其中添加数据?什么......?
【解决方案2】:

元素大小不是由数组初始化定义的。 for 循环定义了数组的大小,恰好超过了初始化元素的个数,因此最后一个元素默认为零。

这是完全不正确的。来自C++17 standard 的第 11.6.1p5 节:

用大括号括起来的未知边界数组 initializer-list 包含 n initializer-clauses,其中 n 应为 大于零,定义为具有 n 个元素 (11.3.4)。 [ 例子

int x[] = { 1, 3, 5 };

将 x 声明并初始化为一个一维数组,该数组包含三个 元素,因为没有指定大小并且有三个初始值设定项。 — 结束示例 ]

所以对于没有明确大小的数组,初始化器定义数组的大小。 for 循环读取到数组的末尾,这样做会触发 undefined behavior

0 正在为不存在的第 4 个元素打印这一事实只是未定义行为的表现。无法保证会打印该值。事实上,当我运行这个程序时,当我使用 -O0 编译时,我得到 3 作为最后一个值,而在使用 -O1 编译时得到 0。

【讨论】:

    【解决方案3】:

    它会导致未定义的行为,这是唯一有效的答案。编译器期望您的数组x 恰好包含三个元素,您在读取第四个整数时在输出中看到的内容是未知的,并且在某些系统/处理器上可能会导致由于尝试读取不可寻址的内存而导致硬件中断(系统不知道如何访问该地址的物理内存)。编译器可能会从堆栈中为x 保留内存,或者可能使用寄存器(因为它非常小)。你得到 0 的事实实际上是偶然的。通过在 clang 中使用地址清理器(-fsanitize=address 选项),您可以看到:

    https://coliru.stacked-crooked.com/a/993d45532bdd4fc2

    简短的输出是:

    ==9469==ERROR: AddressSanitizer: stack-buffer-overflow
    

    您可以在编译器资源管理器中使用un-optimized GCChttps://godbolt.org/z/8T74cr83z(包括 asm 和程序输出)进一步研究它
    在那个版本中,输出是 120 200 16 3,因为 GCC 将 i 放在了数组之后的堆栈中。

    您将看到 gcc 为您的数组生成以下程序集:

        mov     DWORD PTR [rbp-16], 120    # array initializer
        mov     DWORD PTR [rbp-12], 200
        mov     DWORD PTR [rbp-8], 16
        mov     DWORD PTR [rbp-4], 0       # i initializer
    

    确实如此 - 有第四个元素的值为 0。但它实际上是 i 初始化程序,并且在循环中读取时具有不同的值。编译器不会发明额外的数组元素;充其量只会在它们之后有未使用的堆栈空间。

    查看此示例的优化级别 - 它的 -O0 - 如此一致的调试最小优化;这就是为什么i 保存在内存中而不是保留调用的寄存器中的原因。开始添加优化,比如说-O1,你会得到:

        mov     DWORD PTR [rsp+4], 120
        mov     DWORD PTR [rsp+8], 200
        mov     DWORD PTR [rsp+12], 16
    

    更多优化可能会完全优化您的数组,例如展开并仅使用立即操作数来设置对cout.operator&lt;&lt; 的调用。那时,未定义的行为将对编译器完全可见,并且它必须想出一些事情来做。 (如果数组值仅通过常量(优化后)索引访问,则数组元素的寄存器在其他情况下是合理的。)

    【讨论】:

    • "memory on stack" 我不相信标准说像这样的声明 must 在堆栈上,如果不是所有编译器,大多数编译器都会将它放在堆栈上,但是标准是矛盾的。
    • @sam 我同意,编译器可能会将这样的数组放入寄存器 - 就像我在编译器资源管理器中展示的那样。我将澄清我的第一句话。
    • @Sam:确实,一些 C 和 C++ 实现根本不使用 asm“堆栈”,而是使用自动存储的动态分配(尤其是 IBM zSeries:Does C need a stack and a heap in order to run?)。该标准规定每个对象都有一个地址(register vars 除外),但根据 as-if 规则允许将对象放入寄存器中。当然,这并不意味着该案例标准所要求的任何行为;在错误访问之前或之后,整个程序都没有;这就是 UB 的全部意义所在。
    • 但是是的,编译器会将其编译为给定构建的一些具体行为;如果他们没有完全展开循环,那么内存中肯定会有一个数组来索引(因为你不能可变地索引 regs)。如果他们在编译时没有发现 UB,您甚至可以预测一些可能发生的事情。如果他们确实注意到了 UB,您的编译器可能会停止为此执行路径生成代码,例如让执行落入main之后下一个链接的任何函数。或者发出像 x86 ud2 这样的非法指令。
    • -O0下第四个值为0的元素实际上是变量i的初始值。
    【解决方案4】:

    我有点困惑,为什么数组之外的最后一个元素 总是“默认”为零。

    在此声明中

    int x[] = {120, 200, 16};
    

    数组x 正好有三个元素。因此,访问数组边界之外的内存会调用未定义的行为。

    也就是这个循环

     for (int i = 0; i < 4; i++)
     cout << x[i] << " ";
    

    调用未定义的行为。数组最后一个元素之后的内存可以包含任何东西。

    另一方面,如果数组被声明为

    int x[4] = {120, 200, 16};
    

    也就是说,如果有四个元素,那么数组中没有显式初始化器的最后一个元素确实会被初始化为零。

    【讨论】:

    • 所以答案是“靠运气”
    • @lalala 在某种意义上,但更具体地说,它可能是“实现定义的行为,取决于编译器标志”。如果结果始终为零,则 something 必须将其设置为零。
    • @kdb 请注意,实现定义的行为 在 C 和 C++ 标准的上下文中具有非常特定的含义,但事实并非如此。 未定义的行为是一个更有力的主张,具有更深远的影响。见this overview
    • @kdb:我们不使用术语“实现定义”来描述在 UB 案例中实际发生的情况。显然它实际上不会是鼻恶魔。相反,它取决于编译器碰巧产生的 asm 的详细信息,以及之前在内存中的内容。 “实现定义”意味着实际的编译器实际上会注意确保你得到零,而不是碰巧让你读取一些仍然被内核归零的堆栈内存(就像所有新页面都是为了避免泄漏内核数据)。这可以解释一个未优化的构建总是打印 0。
    • 更强烈的是,他们整个程序具有未定义的行为。它不必打印 4 个数字,它可以打印 3 或 5,或者格式化您的硬盘。
    【解决方案5】:

    它不默认为零。示例答案是错误的。未定义的行为是未定义的;该值可能是0,也可能是100。访问它可能会导致seg错误,或者导致您的计算机被格式化。

    至于为什么它不是错误,这是因为 C++ 不需要对数组进行边界检查。您可以使用向量并使用 at 函数,如果超出范围会引发异常,但数组不会。

    【讨论】:

    • 为了不吓到 OP,虽然理论上它可以生成格式化您的计算机的代码,但通常会发生的是您得到一个“随机”数字,这通常是该位置的内存包含的内容。现在的编译器保护程序员免受他们自己的伤害。
    • 我真的不喜欢“或导致您的计算机被格式化”之类的吓人的例子。虽然假设未定义行为不会发生的编译器确实会导致非常令人惊讶的结果,但仍然很难看出破坏计算机的代码会如何神奇地出现。除非程序已经包含这样的代码,但是这只是一个由于UB而导致程序流程跳跃的问题,这并不牵强。
    • @DavidHammen,是的,如果实现忽略了 UB,或者只是假设 UB 不会发生(比如在著名的 Linux 错误中,他们在检查指针是否之前取消引用它)是 NULL),然后它会执行 something,可能是 wrong,但是插入代码以破坏只是“因为标准允许它”的实现是积极恶意的,并且问题不再是错误代码了。
    • 我的观点是,像模因一样重复出现这种奇幻结果的可怕故事并不太富有成效。专注于现实或真实的问题,源于本身无辜甚至明智的逻辑会更有用。 (当然,在 Linux 的这种情况下,对于编译器逻辑是否“合理”,意见不一。)
    • @ilkkachu 你想象计算机有一个 MMU。如果您有内存映射 IO 并且没有内存保护,那么任何写入返回地址的溢出都可能跳转到任何地方并执行任何操作。写入控制磁盘的内存映射 IO 位置是绝对可能的——我曾经有一个错误导致间歇性中断将单个随机字符写入磁盘上的随机位置,因此一个文件中的一个字符经常会更改没有理由。
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2011-11-24
    • 1970-01-01
    • 1970-01-01
    • 2018-09-10
    • 2022-01-13
    • 2018-07-12
    • 1970-01-01
    相关资源
    最近更新 更多