在绑定的未定义行为之外访问全局数组?
我今天刚刚在我的课堂上进行了一次考试 – 阅读C代码和input,如果程序实际运行,所需的答案就是屏幕上显示的内容。 其中一个问题宣称a[4][4]
是一个全局variables,在该程序的一个点上,它尝试访问a[27][27]
,所以我回答了这样一个问题:“ 访问一个数组在外部是一个未定义的行为 “,但老师说a[27][27]
的值为0
。
之后,我尝试了一些代码来检查“所有未初始化的golbalvariables是否设置为0
”是否为真。 那么,这似乎是真的。
所以现在我的问题:
- 似乎已经清除了一些额外的内存并保留供代码运行。 预留多less内存? 为什么编译器会预留更多的内存,这是什么原因?
- 对所有的环境来说,
a[27][27]
是0
吗?
编辑:
在该代码中, a[4][4]
是唯一声明的全局variables, main()
中有更多的局部variables。
我在DevC ++中再次尝试了这个代码 。 他们都是0
。 但是在VSE中这是不正确的,其中大多数值是0
但有一些是Vyktor指出的随机值。
你是对的:这是不确定的行为,你不能总是产生0
。
至于为什么你在这种情况下看到零:现代操作系统分配内存到相对较粗粒度的块,称为比单个variables(x86上至less4KB)大得多的页面的进程。 当你有一个单一的全局variables,它将位于页面上的某个地方。 假设a
int[][]
types和int
s在你的系统上是四个字节, a[27][27]
将从a
的开始位置开始大约500个字节。 所以只要a
接近页面的开头,访问a[27][27]
将由实际内存支持,并且读取它不会导致页面错误/访问冲突。
当然,你不能指望这一点。 例如,如果a
之前有大约4KB的其他全局variables,那么a[27][27]
将不会被内存支持,当您尝试读取它时,您的进程将崩溃。
即使进程没有崩溃,也不能指望得到值0
。 如果你在一个现代的多用户操作系统上有一个非常简单的程序,除了分配这个variables并且输出这个值,你可能会看到0
。 操作系统在将内存移交给进程时将内存内容设置为一些良性值(通常是全零),以便来自一个进程或用户的敏感数据不会泄漏到另一个进程。
但是,不能保证读取的任意内存为零。 你可以在一个平台上运行你的程序,这个平台的内存在分配时没有被初始化,你会看到上次使用时发生的任何值。
另外,如果a
后面有足够的其他全局variables被初始化为非零值,那么访问a[27][27]
会显示出恰好在那里的任何值。
访问数组越界是未定义的行为,这意味着结果是不可预知的,因此a[27][27]
为0
根本不可靠。
clang
告诉你这个很清楚,如果我们使用-fsanitize=undefined
:
runtime error: index 27 out of bounds for type 'int [4][4]'
一旦你有了未定义的行为,编译器就可以做任何事情,甚至可以看到gcc
已经通过围绕未定义行为的优化将有限循环变成了一个无限循环的例子。 如果检测到未定义的行为, clang
和gcc
在某些情况下都可以生成未定义的指令操作码 。
为什么它是未定义的行为, 为什么超出界限的指针算术未定义的行为? 提供了一个很好的总结原因。 例如,生成的指针可能不是一个有效的地址,指针现在可以指向分配的内存页之外,您可以使用内存映射硬件而不是RAM等。
最有可能的地方在于存储静态variables的部分比要分配的数组要大得多,或者你正在跺脚的部分虽然刚刚被清零,所以在这种情况下你只是幸运的,但是又是完全不可靠的行为。 最有可能你的页面大小是4K,并且a[27][27]
是在这个范围内,这可能是为什么你没有看到分段错误。
标准说的是什么
C99标准草案告诉我们这是6.5.6
节中的未定义的行为,其中涵盖了指针算术,它是一个数组访问所涉及的指针算术。 它说:
当具有整数types的expression式被添加到指针或从指针中减去时,结果具有指针操作数的types。 如果指针操作数指向数组对象的一个元素,并且数组足够大,则结果指向与原始元素偏移的元素,使得结果数组元素和原始数组元素的下标之差等于整数expression式。
[…]
如果指针操作数和结果都指向同一个数组对象的元素,或者指向数组对象的最后一个元素,则评估不会产生溢出; 否则,行为是不确定的。 如果结果指向一个超过数组对象的最后一个元素,则不应将其用作所评估的一元运算符的操作数。
未定义行为的标准定义告诉我们,标准对行为没有要求,注释可能的行为是不可预测的:
行为,在使用不可移植或错误的程序结构或错误的数据时,本国际标准对此没有要求
注意可能存在的未定义的行为范围从完全忽略情况而导致不可预知的结果,
这里是标准的引用,它指定什么是未定义的行为。
J.2不确定的行为
一个数组下标超出范围,即使一个对象明显可以用给定的下标访问(如左值expression式a [1] [7]给定了声明int a [4] [5])(6.5.6)。
将指针join或减去数组对象和整数types会产生一个结果,该结果仅指向数组对象之外,并用作评估(6.5.6)的一元*运算符的操作数。
在你的情况下,你的数组下标完全在数组之外。 取决于该值将是零是完全不可靠的。
而且整个程序的行为是有问题的。
如果只是从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
。 当你从位置00FB8130
( a
)打开记忆视图时,你可能会看到如下所示:
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
代码,因为它是如何使用内存的,但是只有几个字节就可以find另一个variables。
例如上面显示的内存a[6][0]
指向地址0x00FB8190
,其中包含整数值4559040
。
然后让你的老师来解释一下。
我不知道这是否可以在你的系统上工作,但是在数组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中查看内存。 最简单的方法是在代码中添加一个断点(暂停执行),然后进入Debug …窗口… Memory菜单,select例如Memory 1.然后,您可以findarrays的内存地址a
。 在我的情况下地址是0x0130EFC0
。 所以你在地址恶魔里input0x0130EFC0
,然后回车。 这显示在那个位置的记忆。
例如在我的情况。
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 sizeof
一个int(在我的情况下是4)= 64个字节。 地址0x0130EFC0的字节每个都是0xFF(来自b,c或d的内容)。
注意:
0x130EFC0 + 64 = 0x130EFC0 + 0x40 = 130F000
这就是你看到的所有ff
字节的开始。 可能arraysb
。
对于常见的编译器来说,只有在非常特殊的情况下,访问一个超出范围的数组才能给出可预测的结果,你不应该依赖这个。 例如:
int a[4][4]; int b[4][4];
假如不存在alignment问题,并且既不要求进行严格的优化也不要进行消毒检查, a[6][1]
实际上应该是b[2][1]
。 但请不要在生产代码中这样做!
在一个特定的系统上,你的老师可能是正确的 – 这可能是你的特定编译器和操作系统的performance。
在一个通用的系统(即没有“内幕”知识),那么你的答案是正确的:这是UB。
首先C语言没有边界检查。 实际上,几乎所有东西都没有检查。 这是C的欢乐和厄运
现在回到这个问题,如果你溢出内存并不意味着你触发一个段错误。 让我们仔细看看它是如何工作的。
当你启动一个程序,或者input一个子程序时,处理器在堆栈中保存函数结束时返回的地址。
在进程内存分配过程中,堆栈已经从操作系统初始化,并获得了一系列合法的内存,您可以随意读写,而不仅仅是存储返回地址。
编译器用于创build本地(自动)variables的常见做法是在栈上保留一些空间,并将该空间用于variables。 看下面熟知的32位汇编序列,命名为序言,你可以在任何函数中findinput:
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
这就是所谓的堆栈框架。
现在考虑四个字节的string,从0x0 …. 0开始,结束于0x …. 3。 如果我们在数组中写入3个以上的字符,我们将依次replace:EBP的保存副本,返回地址,参数,上一个函数的局部variables,然后是EBP,返回地址等。
我们得到的最有视觉效果的是,在函数返回时,CPU尝试跳回到产生段错误的错误地址。 如果其中一个局部variables是指针,则可以实现相同的行为,在这种情况下,我们将尝试读取或写入错误的位置,再次触发段错误。
当segfault不可能发生的时候 :当bloatevariables不在栈上,或者你有很多局部variables,你覆盖他们而不接触返回地址(而且他们不是指针)。 另一种情况是处理器在本地variables和返回地址之间保留一个保护空间,在这种情况下缓冲区溢出没有到达地址。 另一种可能性是随机访问数组元素,在这种情况下,超大的数组可能会超过堆栈空间并溢出其他数据,但幸运的是,我们不会触及那些映射到保存返回地址的地方(everythibng可能发生…) 。
当我们可以有不在堆栈上的段错误膨胀variables? 当溢出数组绑定或指针。
我希望这些是有用的信息…