今天看到一篇微信推送,说下面这段代码有可能引起死循环:

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的问题和代码都是耍流氓!