为什么这个for循环在某些平台上退出而不在其他平台上退出?

Jon*_*Cav 240 c debugging buffer-overflow undefined-behavior

我最近开始学习C,我正在以C为主题.我正在玩循环,我遇到了一些我不知道如何解释的奇怪行为.

#include <stdio.h>

int main()
{
  int array[10],i;

  for (i = 0; i <=10 ; i++)
  {
    array[i]=0; /*code should never terminate*/
    printf("test \n");

  }
  printf("%d \n", sizeof(array)/sizeof(int));
  return 0;
}
Run Code Online (Sandbox Code Playgroud)

在我的运行Ubuntu 14.04的笔记本电脑上,此代码不会中断.它运行完成.在我学校的运行CentOS 6.6的计算机上,它运行良好.在Windows 8.1上,循环永远不会终止.

更奇怪的是,当我将for循环条件编辑为:时i <= 11,代码只会在运行Ubuntu的笔记本电脑上终止.它永远不会在CentOS和Windows中终止.

任何人都可以解释内存中发生的事情以及运行相同代码的不同操作系统为什么会产生不同的结果?

编辑:我知道for循环超出范围.我是故意这样做的.我无法弄清楚不同操作系统和计算机之间的行为有何不同.

Que*_*onC 356

在我的运行Ubuntu 14.04的笔记本电脑上,此代码不会破坏它运行完成.在我学校的运行CentOS 6.6的计算机上,它运行良好.在Windows 8.1上,循环永远不会终止.

更奇怪的是,当我将for循环的条件编辑为:时i <= 11,代码只会在运行Ubuntu的笔记本电脑上终止.CentOS和Windows永远不会终止.

你刚刚发现内存踩踏.你可以在这里阅读更多相关内容:什么是"内存踩踏"?

分配时int array[10],i;,这些变量进入内存(具体来说,它们是在堆栈上分配的,这是与函数关联的内存块). array[]并且i可能在记忆中彼此相邻.似乎在Windows 8.1上,i位于array[10].在CentOS上,i位于array[11].在Ubuntu上,它既没有位置(也许它在array[-1]?).

尝试将这些调试语句添加到您的代码中.您应该注意到在迭代10或11上,array[i]指向i.

#include <stdio.h>

int main() 
{ 
  int array[10],i; 

  printf ("array: %p, &i: %p\n", array, &i); 
  printf ("i is offset %d from array\n", &i - array);

  for (i = 0; i <=11 ; i++) 
  { 
    printf ("%d: Writing 0 to address %p\n", i, &array[i]); 
    array[i]=0; /*code should never terminate*/ 
  } 
  return 0; 
} 
Run Code Online (Sandbox Code Playgroud)

  • 例如,如果写入`array [10]`会破坏堆栈帧,@ jonCav"它会挂起".如果有或没有调试输出的代码之间有什么区别?如果永远不需要`i`的地址,编译器*可以*优化`i`.进入寄存器,从而改变堆栈上的内存布局...... (12认同)
  • @JonCav我会说一般你*不需要了解更多关于内存管理的知识,而只是知道不写未定义的代码,具体来说,不要写过数组的末尾... (9认同)
  • 嘿谢谢!这真的解释了很多.在Windows中,它声明我是从数组偏移10,而在CentOS和Ubuntu中,它是-1.如果我将调试器代码注释掉,那么更奇怪的是,CentOS无法运行代码(它挂起),但运行调试代码.到目前为止,C似乎是一种非常语言的X_x (6认同)
  • 另一种选择是优化编译器完全删除数组,因为它没有可观察到的影响(在问题的原始代码中).因此,生成的代码可以打印出该常量字符串十一次,然后打印常量大小,从而使溢出完全不明显. (4认同)
  • 我不认为它是挂起的,我认为它处于一个无限循环中,因为它正在从内存重新加载循环计数器(刚刚被`array [10] = 0`归零.如果你编译你的代码并进行优化,这可能不会"T发生.(因为C具有别名规则限制访问必须承担什么样的内存可能重叠其他内存.至于说你从来没有冒的地址的局部变量,我认为编译器应该能够假设没有别名无论如何,写下数组的结尾是不确定的行为.总是尽量避免依赖它. (2认同)
  • 无论如何,有一点我认为没有人明确表示你在Linux与Windows上有不同的行为,可能是因为使用了不同的编译器.gcc可能在寄存器中保留`i`,而不是从`array [10]`引用的堆栈位置重新加载它. (2认同)
  • @ T.Kiley,是的,我知道超越界限会导致意外行为,我只是想了解记忆中发生的事情. (2认同)
  • @JonCav,...但你*不知道内存中发生了什么,除非你也知道确切的编译器版本,它所针对的平台和架构,以及可能的编译时标志和编译指示.了解你所知道的和你不知道的事情比了解一些你不知道的具体例子更重要.:) (2认同)

o11*_*11c 98

这些代码之间存在错误:

int array[10],i;

for (i = 0; i <=10 ; i++)

array[i]=0;
Run Code Online (Sandbox Code Playgroud)

由于array只有10个元素,在最后一次迭代中array[10] = 0;是缓冲区溢出.缓冲区溢出是未定义的行为,这意味着它们可能会格式化您的硬盘驱动器或导致恶魔飞出您的鼻子.

所有堆栈变量彼此相邻布局是相当常见的.如果i位于array[10]写入的位置,则UB将重置i0,从而导致未终止的循环.

要修复,请将循环条件更改为i < 10.

  • @Kevin当你援引UB时,你放弃了对理智的任何要求. (26认同)
  • 你的代码是否理智并不重要.操作系统不会让你这样做. (7认同)
  • Nitpick:你不能在市场上任何理智的操作系统上实际格式化硬盘,除非你以root用户身份运行(或等效的). (6认同)
  • @Kevin但未定义的行为可以利用操作系统漏洞,然后提升自身以安装新的硬盘驱动器,然后开始擦除驱动器. (5认同)
  • @higuaro这是旧的术语:http://catb.org/jargon/html/N/nasal-demons.html (4认同)
  • @Kevin格式化硬盘驱动器的例子很久以前就是这种情况.即使是当时的unix(C源自其中)也非常乐意让你做这样的事情 - 即使在今天,很多发行版都会很高兴地允许你用`rm -rf /`开始删除所有东西,即使你当然,不是root,不是"格式化"整个驱动器,但仍然会破坏所有数据.哎哟. (2认同)

Gil*_*il' 38

在应该是循环的最后一次运行时,你写入array[10],但是数组中只有10个元素,编号为0到9.C语言规范说这是"未定义的行为".这在实践中意味着你的程序将尝试写入int内存之后array的大小内存.然后发生什么取决于实际上是什么呢,这不仅取决于操作系统,还取决于编译器,编译器选项(例如优化设置),处理器体系结构,周围代码等等.它甚至可能因执行而异,例如由于地址空间随机化(可能不是在这个玩具示例中,但它确实发生在现实生活中).一些可能性包括:

  • 该位置未使用.循环正常终止.
  • 该位置用于碰巧具有值0的东西.循环正常终止.
  • 该位置包含函数的返回地址.循环正常终止,但程序崩溃,因为它试图跳转到地址0.
  • 该位置包含变量i.循环永远不会终止,因为i重新启动为0.
  • 该位置包含一些其他变量.循环正常终止,但随后发生"有趣"的事情.
  • 该位置是无效的内存地址,例如,因为array它位于虚拟内存页面的末尾,并且未映射下一页.
  • 恶魔飞出你的鼻子.幸运的是,大多数计算机缺少必要的硬件.

您在Windows上观察到的是编译器决定将变量i紧跟在数组后面的内存中,因此array[10] = 0最终分配给i.在Ubuntu和CentOS上,编译器没有放在i那里.几乎所有的C实现都在内存堆栈中对局部变量进行分组,但有一个主要的例外:一些局部变量可以完全放在寄存器中.即使变量在堆栈上,变量的顺序也由编译器决定,它可能不仅取决于源文件中的顺序,还取决于它们的类型(以避免浪费内存到会留下漏洞的对齐约束)在他们的名字上,在编译器的内部数据结构中使用的一些哈希值等.

如果你想知道你的编译器决定做什么,你可以告诉它向你展示汇编代码.哦,并学习解密汇编程序(它比编写它更容易).使用GCC(以及其他一些编译器,特别是在Unix世界中),传递选项-S以生成汇编代码而不是二进制代码.例如,这里是使用优化选项-O0(无优化)在amd64上使用GCC编译循环的汇编程序片段,并手动添加注释:

.L3:
    movl    -52(%rbp), %eax           ; load i to register eax
    cltq
    movl    $0, -48(%rbp,%rax,4)      ; set array[i] to 0
    movl    $.LC0, %edi
    call    puts                      ; printf of a constant string was optimized to puts
    addl    $1, -52(%rbp)             ; add 1 to i
.L2:
    cmpl    $10, -52(%rbp)            ; compare i to 10
    jle     .L3
Run Code Online (Sandbox Code Playgroud)

这里变量i是堆栈顶部下方52个字节,而数组在堆栈顶部下方48个字节处开始.所以这个编译器碰巧放在i数组之前; i如果你碰巧写信,你会被覆盖array[-1].如果array[i]=0改为array[9-i]=0,那么使用这些特定的编译器选项,您将在此特定平台上获得无限循环.

现在让我们用你的程序编译你的程序gcc -O1.

    movl    $11, %ebx
.L3:
    movl    $.LC0, %edi
    call    puts
    subl    $1, %ebx
    jne     .L3
Run Code Online (Sandbox Code Playgroud)

那更短了!编译器不仅拒绝为堆栈位置分配i- 它只存储在寄存器中ebx- 但它没有为分配任何内存array或生成代码来设置其元素而烦恼,因为它注意到没有任何元素永远使用.

为了使这个例子更具说服力,让我们确保通过为编译器提供无法优化的东西来执行数组赋值.一种简单的方法是使用另一个文件中的数组 - 由于单独的编译,编译器不知道在另一个文件中发生了什么(除非它在链接时优化,哪个gcc -O0或哪个gcc -O1不优化).创建一个use_array.c包含的源文件

void use_array(int *array) {}
Run Code Online (Sandbox Code Playgroud)

并将您的源代码更改为

#include <stdio.h>
void use_array(int *array);

int main()
{
  int array[10],i;

  for (i = 0; i <=10 ; i++)
  {
    array[i]=0; /*code should never terminate*/
    printf("test \n");

  }
  printf("%zd \n", sizeof(array)/sizeof(int));
  use_array(array);
  return 0;
}
Run Code Online (Sandbox Code Playgroud)

编译

gcc -c use_array.c
gcc -O1 -S -o with_use_array1.c with_use_array.c use_array.o
Run Code Online (Sandbox Code Playgroud)

这次汇编程序代码如下所示:

    movq    %rsp, %rbx
    leaq    44(%rsp), %rbp
.L3:
    movl    $0, (%rbx)
    movl    $.LC0, %edi
    call    puts
    addq    $4, %rbx
    cmpq    %rbp, %rbx
    jne     .L3
Run Code Online (Sandbox Code Playgroud)

现在数组在堆栈上,从顶部44个字节.怎么样i?它没有出现在任何地方!但循环计数器保存在寄存器中rbx.这不完全是i,但是地址array[i].编译器已经决定,由于i从未直接使用过的值,因此在每次循环运行期间执行算术计算存储0的位置没有意义.相反,地址是循环变量,并且确定边界的算法部分在编译时执行(每个数组元素乘以11个迭代乘以4个字节得到44),部分在运行时但在循环开始之前一次又一次地执行(执行减法以获得初始值).

即使在这个非常简单的例子中,我们已经看到了如何改变编译器选项(打开优化)或改变一些次要(array[i]to array[9-i])甚至改变一些显然不相关的东西(添加调用use_array)可以对可执行程序生成的内容产生重大影响由编译器做.编译器优化可以执行许多在调用未定义行为的程序上可能看起来不直观的事情.这就是为什么未定义的行为完全未定义的原因.当你偏离轨道时,在现实世界的程序中,很难理解代码的作用和应该做的事情之间的关系,即使对于有经验的程序员也是如此.


Yu *_*Hao 25

与Java不同,C不进行数组边界检查,即没有ArrayIndexOutOfBoundsException,确保数组索引有效的工作留给程序员.故意这样做会导致未定义的行为,任何事情都可能发生.


对于数组:

int array[10]
Run Code Online (Sandbox Code Playgroud)

指标仅在范围内有效09.但是,您正在尝试:

for (i = 0; i <=10 ; i++)
Run Code Online (Sandbox Code Playgroud)

访问array[10]此处,将条件更改为i < 10

  • 不是故意这样做也会导致未定义的行为 - 编译器无法分辨!;-) (6认同)

Der*_*nes 19

你有一个边界违规,并且在非终止平台上,我相信你i在循环结束时无意中设置为零,以便它重新开始.

array[10]是无效的; 它包含10个元素,array[0]通过array[9],并且array[10]是第11个.你的循环应该写在之前 停止10,如下所示:

for (i = 0; i < 10; i++)
Run Code Online (Sandbox Code Playgroud)

如果array[10]土地是实施定义的,并且有趣的是,在你的两个平台上,它就会落地i,这些平台显然是直接布局的array. i设置为零,循环继续.对于您的其他平台,i可能位于之前array,或之后array可能有一些填充.


rak*_*rul 12

你申报int array[10]的手段array有指数0,以9(总10能容纳整数元素).但是以下循环,

for (i = 0; i <=10 ; i++)
Run Code Online (Sandbox Code Playgroud)

将循环010表示11时间.因此,i = 10它会溢出缓冲区并导致未定义的行为.

试试这个:

for (i = 0; i < 10 ; i++)
Run Code Online (Sandbox Code Playgroud)

要么,

for (i = 0; i <= 9 ; i++)
Run Code Online (Sandbox Code Playgroud)


DDP*_*AGE 7

它未定义array[10],并提供如前所述的未定义行为.想想这样:

我的杂货车里有10件物品.他们是:

0:一盒麦片
1:面包
2:牛奶
3:馅饼
4:鸡蛋
5:蛋糕
6:2升苏打
7:沙拉
8:汉堡
9:冰淇淋

cart[10]未定义,并且可能在某些编译器中给出一个超出范围的异常.但是,很多人显然没有.明显的第11项是实际上不在购物车中的项目.第11项指向,我将要称之为"恶作剧项目".它从来没有存在过,但它就在那里.

为什么有些编译器会给出i的指数array[10]或者array[11]甚至array[-1]是因为你的初始化/声明语句.一些编译器将此解释为:

  • "分配10个ints 块array[10]和另一个int块.使它更容易,将它们放在彼此旁边."
  • 和以前一样,但是将它移动一两个距离,这样array[10]就不会指向i.
  • 做和以前一样,但分配iarray[-1](因为数组的索引不能或不应该是负的),或在完全不同的点分配,因为操作系统可以处理它,和它的安全.

有些编译器希望事情变得更快,有些编译器更喜欢安全性.这完全取决于背景.例如,如果我正在为古老的BREW OS(基本手机的操作系统)开发应用程序,它就不会关心安全性.如果我正在为iPhone 6开发,那么它无论如何都可以快速运行,所以我需要强调安全性.(说真的,您是否阅读过Apple的App Store指南,或者阅读Swift和Swift 2.0的开发?)


Ste*_*hen 6

由于您创建了一个大小为10的数组,因此for循环条件应如下所示:

int array[10],i;

for (i = 0; i <10 ; i++)
{
Run Code Online (Sandbox Code Playgroud)

目前您正在尝试使用内存访问未分配的位置array[10],这会导致未定义的行为.未定义的行为意味着您的程序将以不确定的方式运行,因此它可以在每次执行时提供不同的输出.


unx*_*nut 5

好吧,C编译器传统上不检查边界.如果您引用了不属于您的流程的位置,则可能会出现细分错误.但是,局部变量是在堆栈上分配的,并且根据内存的分配方式,array(array[10])之外的区域可能属于进程的内存段.因此,不会抛出分段故障陷阱,这就是您似乎遇到的情况.正如其他人所指出的那样,这是C中未定义的行为,您的代码可能被认为是不稳定的.由于您正在学习C,因此最好养成检查代码中边界的习惯.