今天看到一篇微信推送,说下面这段代码有可能引起死循环:
1 2 3 4 5 6 7 8 9
| int main() { int a[10], i; for (i = 1; i <= 10; i++) { a[i] = 0; } return 0; }
|
再看看文章下面的评论,基本上都是认为对于a[10]
的引用实际上是变量i
的地址,然而即使是我这个半吊子水平也知道这显然是错误的解释。但是实际实验了一下之后发现我的第一印象也是有点想当然了。
第一反应
我之所以认为评论中的解释是错误的,所基于的理由是:函数栈是从高地址往低地址生长的。因此在不考虑使用寄存器的情况下,先定义的变量应该会出现在高地址,如果编译器按照声明的顺序来分配栈,那么i
应该在a[0]
之前而非a[9]
之后,因此数组越界写入到i
里面去是显然错误的。我在我的机器上使用GCC 5.4.0版本编译上述代码的结果也符合我的预期:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| .file "main.c" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 subq $64, %rsp movq %fs:40, %rax movq %rax, -8(%rbp) xorl %eax, %eax movl $1, -52(%rbp) jmp .L2 .L3: movl -52(%rbp), %eax cltq movl $0, -48(%rbp,%rax,4) addl $1, -52(%rbp) .L2: cmpl $10, -52(%rbp) jle .L3 movl $0, %eax movq -8(%rbp), %rdx xorq %fs:40, %rdx je .L5 call __stack_chk_fail .L5: leave .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.4) 5.4.0 20160609" .section .note.GNU-stack,"",@progbits
|
可以看到变量i
的位置是在-52(%rbp)
中,而数组a
是从-48(%rbp)
开始的,并不存在a[10]
就是i
的情况。以上的汇编代码执行结果是
1 2
| *** stack smashing detected ***: ./main terminated 已放弃 (核心已转储)
|
原因是第31行的call __stack_chk_fail
检测到了数组越界,而如何检测数组越界?是靠的第14、15行对于-8(%rbp)
的设置和第28、29行对它的检测(这种检测也并不是很靠谱)。
然而……
我在另一台机器上使用GCC 4.4.6编译却得到了完全不同的结果。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| .file "main.c" .text .globl main .type main, @function main: .LFB0: .cfi_startproc pushq %rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 movq %rsp, %rbp .cfi_def_cfa_register 6 movl $1, -4(%rbp) jmp .L2 .L3: movl -4(%rbp), %eax cltq movl $0, -48(%rbp,%rax,4) addl $1, -4(%rbp) .L2: cmpl $10, -4(%rbp) jle .L3 movl $0, %eax leave .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0: .size main, .-main .ident "GCC: (GNU) 4.4.6 20110731 (Red Hat 4.4.6-4)" .section .note.GNU-stack,"",@progbits
|
这次编译器把变量i
给放到了数组a
之后的-4(%rbp)
中(但是这段代码也仍然不会出现死循环,把循环终止条件从<=10
变成<=11
就可以造成死循环了,原因从汇编中很容易就能发现)。同一编译器的不同版本都把相同声明顺序的变量放到了不同的地址,说明我的第一印象是错误的,编译器并不会严格地按顺序在栈中分配变量空间,所有对于顺序的假定都是没什么意义的。
编译器的警告
实际上,在GCC 5.4.0中使用-O2
或-O3
选项编译开头代码的话,是会产生警告的
1 2 3 4 5 6 7
| main.c: In function ‘main’: main.c:6:8: warning: iteration 9u invokes undefined behavior [-Waggressive-loop-optimizations] a[i] = 0; ^ main.c:4:2: note: containing loop for (i = 1; i <= 10; i++) ^
|
而这个警告的含义是:
Warn if in a loop with constant number of iterations the compiler detects undefined behavior in some statement during one or more of the iterations.
也就是说,这段代码会引起Undefined Behavior。
常见的误解与老生常谈
很多人一直认为(包括我),数组越界最多只是引起Segment Fault或者写坏其他变量,然而这是错误的,数组越界是一个未定义行为(Undefined Behavior)。具体原因可参考这个链接。
未定义行为是一个常常被提起的概念,它常常被调侃为一旦出现,编译器可以选择忽略,或者烧掉你的CPU,或者格式化整个硬盘,又或者按下核弹发射按钮。这些都是符合标准的行为,因为标准把处理方式的选择权交给了编译器实现者,实现者可以自由发挥。所以,开头的那段代码说可能引起死循环也不能算错,因为完全可能出现任何情况!
任何依赖于UB的问题和代码都是耍流氓!