访问全局数组超出其边界是否是未定义行为?

62

我今天在课堂上参加了一场考试,内容是阅读C代码和输入,并要求回答程序实际运行时屏幕上会显示什么。其中一个问题将a[4][4]定义为全局变量,并在程序的某个点尝试访问a[27][27],所以我回答类似于“访问超出数组边界的行为是未定义的”(参考自这里),但老师说a[27][27]的值将为0

之后,我尝试了一些代码来检查“所有未初始化的全局变量是否都设置为0”是否成立。嗯,看起来是成立的。

现在我的问题是:

  • 似乎为代码清除并保留了一些额外的内存。保留了多少内存?编译器为什么会保留比它应该的更多的内存,这是为什么?
  • a[27][27]在所有环境中都将是0吗?

编辑:

在那段代码中,a[4][4]是唯一声明的全局变量,在main()函数中还有一些局部变量。

我在DevC++中再次尝试了那段代码。它们全部为0。但在VSE中不是这样的,大多数值均为0,但像Vyktor所指出的一样,有一些随机值。


77
你是正确的,你的老师完全错了。在这个班上要谨慎行事。 - nobody
6
你应该问的是,“a[27][270]怎么样?”、“a[270][270]呢?” - axiom
17
如果a[27][27]恰好因为偶然性而为0,且a是全局变量或静态变量,则你得到0的概率很高,因为所有全局和静态变量在程序启动前都会被初始化为0。如果在a之后还有更多的全局/静态变量,那么a[27][27]只是这些变量中的一个。但基本上这是未定义行为。 - Jabberwocky
7
我想抱怨这个问题在C语言课程的背景下无法回答,要找出实际发生的情况需要检查编译后的对象并观察其行为。这样的问题更适合于逆向工程课程。 - Vality
14
不要忘记向您的老师提供此问题的链接! :-) - Mark Garcia
显示剩余11条评论
8个回答

53

你是正确的:这是未定义行为,不能保证它总是产生0

至于为什么在这种情况下会看到零:现代操作系统将内存分配给进程时使用比单个变量要大得多的粗粒度块,称为页面(在x86上至少为4KB)。当你有一个单一的全局变量时,它将位于某个页面的某个位置。假设a的类型为int[][],且在你的系统上,int占4个字节,则a[27][27]将位于a开头的大约500个字节处。因此,只要a靠近页面的开头,访问a[27][27]将被实际内存支持,读取它不会导致页面故障/访问冲突。

当然,你不能保证这一点。例如,如果a之前有近4KB的其他全局变量,则a[27][27]将不会被内存支持,并且当你尝试读取它时,进程将崩溃。

即使进程没有崩溃,你也不能保证得到值0。如果你在现代多用户操作系统上运行一个非常简单的程序,该程序仅分配此变量并打印该值,则可能会看到0。操作系统在将内存交给进程时将内存内容设置为某些良性值(通常全部为零),以使来自一个进程或用户的敏感数据无法泄漏给另一个进程或用户。

但是,并没有一般性保证你所读取的任意内存都将为零。你可以在不初始化内存的平台上运行程序,那么你将会看到上次使用时的任何值。

而且,如果a之后有足够多的其他全局变量被初始化为非零值,那么访问a[27][27]将显示其中的任何值。


4
不,你不能假设这一点。根据编译器选项,它可能无法编译或者可能导致运行时错误。同时也有可能其他代码自动链接到你的程序中(例如C运行库),在main之前运行并使用该区域作为临时空间并在那里放置一些非零值。 - nobody
1
你从哪里得到了3000?&a[27][27] == (&a[0][0] + (27 * 4) + 27) == &a[0][0] + 135 如果 sizeof(int) == 4, 那么这是相对于数组开头的 540 字节偏移量。 - bcrist
1
@AndrewMedico 这取决于你所工作的抽象级别。如果你在语言层面上工作,那么你说得没错,你不能假设它。如果你在操作系统层面上工作,情况就不同了。 - Paul Manta
1
@SantiSantichaivekin 不,但静态值通常存储在 .bss 部分中。操作系统将使用零初始化整个部分。语言不知道 .bss,但操作系统知道。如果您确定您的程序始终在使用 .bss 部分的操作系统下运行,则可以安全地做出一些语言本身不允许的假设。 - Paul Manta
2
即使是在清零了.bss的操作系统上运行的C程序,也不能保证非故障的越界读取会产生0。超出自己变量范围意味着你可能会读取由C运行时库使用的非零全局变量或(在Windows上)由AppInit DLL写入的非零值。 - nobody
显示剩余4条评论

29

访问超出数组边界是未定义行为,这意味着结果是不可预测的,因此 a[27][27] 的结果为 0 是不可靠的。

如果我们使用 -fsanitize=undefinedclang 会非常清楚地告诉您:

runtime error: index 27 out of bounds for type 'int [4][4]'

一旦出现未定义的行为,编译器就可以做任何事情,我们甚至看到过 gcc 在未定义行为的优化上 将一个有限循环变成了无限循环 的例子。在某些情况下,clanggcc生成未定义指令操作码,如果它检测到未定义行为。

为什么是未定义的行为,为什么越界指针算术是未定义行为? 提供了一个很好的理由总结。例如,结果指针可能不是有效地址,指针现在可能指向分配的内存页面之外,您可能正在使用映射到硬件而不是 RAM 的内存等等...

最有可能的情况是静态变量存储的段比你分配的数组要大,或者你正在遍历的段碰巧被清零了,所以在这种情况下你只是幸运而已,但是这完全是不可靠的行为。很可能你的页面大小为4ka[27][27] 的访问在该范围内,这可能就是为什么你没有看到段错误的原因。 标准规定 草案C99标准告诉我们,在涉及指针算术的部分中,即数组访问的本质,这是未定义的行为,它在第6.5.6加法运算符中进行了说明。它说:
当将整数类型的表达式添加到指针或从指针中减去时,结果具有指针操作数的类型。如果指针操作数指向数组对象的元素,并且数组足够大,则结果指向距离原始元素偏移的元素,使得结果和原始数组元素的下标之差等于整数表达式。
如果指针操作数和结果都指向同一个数组对象的元素或数组对象的最后一个元素之一,则评估不会产生溢出;否则,行为是未定义的。如果结果指向数组对象的最后一个元素之一,则不应将其用作计算的一元*运算符的操作数。
标准对未定义行为的定义告诉我们,标准对行为没有要求,并且可能的行为是不可预测的:
在使用非便携或错误的程序构造或错误数据时的行为,对此国际标准不施加任何要求
注意:可能的未定义行为范围从完全忽略具有不可预测结果的情况开始。

我不理解“如果指针操作数和结果都指向同一数组对象的元素[...]则评估不会产生溢出;否则,行为是未定义的。”这里所说的“如果指针操作数和结果都指向同一数组对象的元素”是什么意思?虽然我理解其余部分。 - Santi Santichaivekin
@SantiSantichaivekin 我添加了更多的段落,如果有什么不清楚的地方,请告诉我。整个段落很长,我想避免发布整个段落。 - Shafik Yaghmour
难道“指针操作数和结果都指向同一数组对象”不就像a[0] = a[0] + 1这样吗? - Santi Santichaivekin
@SantiSantichaivekin,关键在于E1[E2]与(*((E1)+(E2)))相同。,这是来自6.5.2.1数组下标章节的内容。 - Shafik Yaghmour
很明显这段代码没有定义,因为没有人应该写那种代码。这段代码是错误的。但是,我不明白为什么不同的环境会产生不同的结果。为什么一个编译器将叫做“页面”的空间作为库的草稿空间,而另一个则没有(库应该在所有编译器上都一样吧?)。嗯,尽管如此,这已经超出了C编程的范畴。 - Santi Santichaivekin
显示剩余2条评论

13

这里是标准中规定的未定义行为的引用。

J.2 未定义行为

  • 即使对象在给定下标(如 int a[4][5] 的左值表达式 a[1][7])时看起来可访问,但数组下标超出范围 (6.5.6)。

  • 将指针加或减一个整数类型,并将其指向数组对象之内或之外产生的结果指向该数组对象之外并作为一元*运算符的操作数被评估 (6.5.6)。

在你的情况下,数组下标完全超出了数组范围。依赖于该值为零是完全不可靠的。

此外,整个程序的行为受到质疑。


8
如果你刚刚从Visual Studio 2012运行代码并得到了以下结果(每次运行都不同):
Address of a: 00FB8130
Address of a[4][4]: 00FB8180
Address of a[27][27]: 00FB834C
Value of a[27][27]: 0
Address of a[1000][1000]: 00FBCF50
Value of a[1000][1000]: <<< Unhandled exception at 0x00FB3D8F in GlobalArray.exe:
                            0xC0000005: Access violation reading location 0x00FBCF50.

当您查看模块窗口时,您会发现应用程序模块内存范围为00FA0000-00FBC000。除非您打开CRT Checks 否则什么也不会控制你在内存中做什么(只要您不违反内存保护)。
因此,您在a [27] [27]处得到了0 纯粹是偶然的。当您从位置00FB8130a)打开内存视图时,您可能会看到类似于以下内容:
0x00FB8130  08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0x00FB8140  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0x00FB8150  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0x00FB8160  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0x00FB8170  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0x00FB8180  01 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00  ................
0x00FB8190  c0 90 45 00 b0 e9 45 00 00 00 00 00 00 00 00 00  À.E.°éE.........
0x00FB81A0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0x00FB81B0  00 00 00 00 80 5c af 0f 00 00 00 00 00 00 00 00  ....€\¯.........
0x00FB81C0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
.......... 
0x00FB8330  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0x00FB8340  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................ <<<<
0x00FB8350  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
..........                                      ^^ ^^ ^^ ^^

有可能由于编译器使用内存的方式,你的编译器始终会返回0,但在几个字节之外,你可以找到另一个变量。例如,在上面显示的内存中,a[6][0] 指向地址 0x00FB8190,其中包含整数值 4559040。

7

请让您的老师为您解释这个问题。

我不确定这个方法是否适用于您的系统,但在数组a之后使用未清零字节的内存进行操作会使a[27][27]的结果不同。

在我的系统上,当我打印a[27][27]的内容时,它是0xFFFFFFFF。也就是说,将-1转换为无符号数在二进制补码中表示为所有位均设置。

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

#define printer(expr) { printf(#expr" = %u\n", expr); }

   unsigned int d[8096];
   int a[4][4];  /* assuming an int is 4 bytes, next 4 x 4 x 4 bytes will be initialised to zero */
   unsigned int b[8096];
   unsigned int c[8096];


int main() {

   /* make sure next bytes do not contain zero'd bytes */
   memset(b, -1, 8096*4);
   memset(c, -1, 8096*4);
   memset(d, -1, 8096*4);

   /* lets check normal access */
   printer(a[0][0]);
   printer(a[3][3]);

   /* Now we disrepect the machine - undefined behaviour shall result */
   printer(a[27][27]);

   return 0;
}

这是我的输出结果:
a[0][0] = 0
a[3][3] = 0
a[27][27] = 4294967295

我在评论中看到了有关在Visual Studio中查看内存的内容。最简单的方法是在代码中添加断点(以停止执行),然后进入“调试”...“窗口”...“内存”菜单,选择例如“内存1”。然后您可以找到数组a的内存地址。在我的情况下,地址为0x0130EFC0。因此,您需要在地址栏中输入0x0130EFC0并按Enter键。这将显示该位置处的内存。
例如,在我的情况下。
0x0130EFC0  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ..................................
0x0130EFE2  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ff ff ff ff  ..............................ÿÿÿÿ
0x0130F004  ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff  ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ 
0x0130F026  ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff  ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ
0x0130F048  ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff ff  ÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿÿ

零当然是数组a中的元素,它的字节大小为4 x 4 x int的sizeof(在我的情况下为4) = 64个字节。地址0x0130EFC0处的字节内容均为0xFF(来自b、c或d的内容)。

注意:

0x130EFC0 + 64 = 0x130EFC0 + 0x40 = 130F000

这个问题指的是你所看到的所有ff字节的开始。很可能是数组b


5

对于普通的编译器,访问数组超出其边界只有在非常特殊的情况下才会得到可预测的结果,您不应该依赖它。例如:

int a[4][4];
int b[4][4];

假如没有对齐问题,也不进行激进的优化或者检查消毒,a[6][1] 实际上应该为 b[2][1]。但请不要在生产代码中这样做!


8
这取决于变量在内存中排列的顺序,不能保证a会位于比b更低的地址,也不能保证ab在内存中相邻且没有间隙。虽然大多数情况下你可能不会出问题,但这仍然是正式未定义的行为。 - Jonathan Leffler

5
在某个特定的系统上,你的老师可能是对的,这可能是你特定的编译器和操作系统的行为。但在一个通用的系统上(即没有“内部人员”知识),你的答案是正确的:这是未定义的行为。

由于这是未定义行为,即使在特定系统上的事实允许这样做,编译器仍然可以自由进行任何操作。仅仅因为目前没有编译器对此做出任何不良反应并不意味着它们将来不会这样做。 - Shafik Yaghmour
1
@ShafikYaghmour:当我说“系统”时,这包括编译器和操作系统。 - user541686

1
首先,C语言没有边界检查。实际上,它几乎没有对任何东西进行检查。这是C语言的乐趣和厄运。
现在回到问题上来,如果溢出内存并不意味着你会触发段错误。让我们更仔细地看看它是如何工作的。
当你启动一个程序或进入子例程时,处理器将返回函数结束时要返回的地址保存在堆栈中。
堆栈已经从操作系统中进行了初始化,在进程内存分配期间获得了一些合法内存范围,您可以随意读写,不仅可以存储返回地址。
编译器用于创建局部(自动)变量的常见做法是在堆栈上保留一些空间,并使用该空间来存储变量。请看以下著名的32位汇编序列,称为前言,在任何函数输入中都可以找到:
push ebp      ;save register on the stack
mov ebp,esp   ;get actual stack address
sub esp,4     ;displace the stack of 4 bytes that will be used to store a 4 chars array

考虑到栈是按照数据的反向增长的,内存的布局如下:
0x0.....1C   [Parameters (if any)]    ;former function
0x0.....18   [Return Address]
0x0.....14   EBP
0x0.....10   0x0......x               ;Local DWORD parameter
0x0.....0C   [Parameters (if any)]    ;our function
0x0.....08   [Return Address]
0x0.....04   EBP
0x0.....00   0, 'c', 'b', 'a'    ;our string of 3 chars plus final nul

这被称为堆栈帧。 现在考虑从0x0....0开始,以0x....3结束的四个字节的字符串。如果我们在数组中写入超过3个字符,我们将按顺序替换:保存的EBP副本、返回地址、参数、前一个函数的局部变量,然后是它的EBP、返回地址等。 最具场景效果的是,在函数返回时,CPU尝试跳转回到错误的地址,从而生成segfault。如果其中一个局部变量是指针,则可以实现相同的行为,在这种情况下,我们将尝试读取或写入错误位置,从而再次触发segfault。 当不可能发生segfault时: 当膨胀的变量不在堆栈上时,或者您有太多的局部变量,可以覆盖它们而不触及返回地址(并且它们不是指针)。 另一种情况是处理器在局部变量和返回地址之间保留了警戒空间,在这种情况下,缓冲区溢出不会达到该地址。 另一种可能性是随机访问数组元素,在这种情况下,超大数组可能会超过堆栈空间,并在其他数据上溢出,但幸运的是,我们没有触及那些映射返回地址保存位置的元素(一切皆有可能……)。

我们何时会出现堆栈以外的变量溢出导致段错误? 当数组越界或指针溢出时。

希望这些信息有用...


但是堆栈不是作为全局变量使用的,对吧(或者我错了)。每次调用新的函数/子程序都会创建另一个堆栈帧。从那时起,您无法访问来自另一个/上一个堆栈帧的变量。(实际上您可以,但我怀疑您是否可以在C或C++中这样做)。全局变量在主程序中初始化,甚至在其开始之前(void main(void)),因此不会在堆栈上生成。如果我错了,请告诉我,我是汇编程序员,在那里全局、局部的概念有点不同... - Agguro
@Agguro 好的,栈就是栈,帧之间没有障碍。只要沿着为栈分配的内存移动,您可以合法地访问到分配给它的内存的两个极端之间的所有内容。自动变量的空间是通过从实际堆栈指针中减去所需空间来实现的。全局变量(从所有函数和单元访问)不会分配在堆栈上。从汇编程序的角度来看,除非一些程序员将自动变量分配在不在堆栈上的临时区域中,否则它与堆栈完全相同。有些人使用push来处理本地变量。 - Frankie_C

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接