作为一个C语言15年+的老玩家,今天载了个跟头,记录反思之。

一切源自于一个bug,如下:

#include <stdio.h>

int *func() {
        int p[10];
        return &p[2];
}

int main() {
        int * tmp = func();
        printf("%p\n", tmp);
        return 0;
}

编译的时候,编译器(gcc)报了个警告: warning: function returns address of local variable。没错,func函数返回了一个局部变量的地址,这是很明显的错误:数组p[]是在栈上分配的,从func返回后,局部变量就消亡了,所以在函数外引用一个局部变量时没有意义。

但关键问题是,main函数里能拿到tmp能拿到这个不应该返回的局部变量地址吗?或者说,尽管无意义,但是func是否可以把这个局部变量的地址返回给调用者(caller)吗?

C语言最大的魅力就在于灵活高效,其精髓就在于指针,但指针是把双刃剑。在遇到这个问题前,我认为caller是可以拿到栈上的这个地址的,也是基于C语言给我的这个印象。我想:虽然这样做是无意义或者危险的,但是C语言并不会阻止我们拿到一个局部变量的地址,毕竟那个局部变量曾经确实存在于一个确定的栈地址!而且拿到这个栈地址跟访问或使用这个内存地址并不是一回事儿!

结果出乎我的意料。caller确实拿到了func的返回值(程序没有报错),但是拿到的却不是&p[2]的地址,而是0。(看到这个结果时,脑子里是OMG,Why?)

全网搜索,大家都在讨论这个warning的原因,但是没有人讨论gcc为什么会给caller一个0。看来只能从C语言标准和编译器实现入手了。用gccreturn local variable address进行关键字搜索,总算有了一个明确的方向:这是一个未定义的行为(undefined behavior)。也就是说C语言没有对这种行为的后果做任何约束,编译器想怎么实现都可以!而gcc遇到这种情况就会给caller返回一个0。

我们来看看以上例子的反汇编:

struct possibleSet *func() {
    1149:       f3 0f 1e fa             endbr64
    114d:       55                      push   %rbp
    114e:       48 89 e5                mov    %rsp,%rbp
    1151:       48 81 ec b0 00 00 00    sub    $0xb0,%rsp
    1158:       64 48 8b 04 25 28 00    mov    %fs:0x28,%rax
    115f:       00 00
    1161:       48 89 45 f8             mov    %rax,-0x8(%rbp)
    1165:       31 c0                   xor    %eax,%eax
        struct possibleSet p[10];
        return &p[0];
    1167:       b8 00 00 00 00          mov    $0x0,%eax
}
    116c:       48 8b 55 f8             mov    -0x8(%rbp),%rdx
    1170:       64 48 33 14 25 28 00    xor    %fs:0x28,%rdx
    1177:       00 00
    1179:       74 05                   je     1180 <func+0x37>
    117b:       e8 d0 fe ff ff          callq  1050 <__stack_chk_fail@plt>
    1180:       c9                      leaveq
    1181:       c3                      retq

这里可以看到1167地址处mov $0x0,%eax,返回值(eax寄存器)被赋了零。如果对例子稍作修改,将局部变量修改为静态变量,那么反汇编里就没有类似的处理了(具体实验代码略)。

那么,这个undefined behavior在C标准里到底是怎么规定的呢?这里找到了一份C标准(ISO/IEC 9899),里面对这个问题有专门的说明。其中J.2附录里是所有undefined behavior的列表(节选):

The behavior is undefined in the following circumstances:

— A ‘‘shall’’ or ‘‘shall not’’ requirement that appears outside of a constraint is violated (clause 4).

— A nonempty source file does not end in a new-line character which is not immediately preceded by a backslash character or ends in a partial preprocessing token or comment (5.1.1.2).

— Token concatenation produces a character sequence matching the syntax of a universal character name (5.1.1.2).

— A program in a hosted environment does not define a function named main using one of the specified forms (5.1.2.2.1).

— A character not in the basic source character set is encountered in a source file, except in an identifier, a character constant, a string literal, a header name, a comment, or a preprocessing token that is never converted to a token (5.2.1).

— An identifier, comment, string literal, character constant, or header name contains an invalid multibyte character or does not begin and end in the initial shift state (5.2.1.2).

— The same identifier has both internal and external linkage in the same translation unit (6.2.2). — An object is referred to outside of its lifetime (6.2.4).

— The value of a pointer to an object whose lifetime has ended is used (6.2.4).

— The value of an object with automatic storage duration is used while it is indeterminate (6.2.4, 6.7.8, 6.8).

— A trap representation is read by an lvalue expression that does not have character type (6.2.6.1).

这里注意到有一条The value of a pointer to an object whose lifetime has ended is used就是我们遭遇到的场景。其具体规定在6.2.4 Storage durations of objects章节有明确说明。

The lifetime of an object is the portion of program execution during which storage is guaranteed to be reserved for it. An object exists, has a constant address, and retains its last-stored value throughout its lifetime. If an object is referred to outside of its lifetime, the behavior is undefined. The value of a pointer becomes indeterminate when the object it points to reaches the end of its lifetime.

可以看到,如果在一个object的lifetime之外引用它,那么行为就是未定义的。行为未定义,结果也就没有标准。一个object有其生命周期,在生命周期之内,object可以保留最新的值,并且object具有确定的地址。但是如果object的lifetime结束了,那么object的地址也就没意义了(在标准里字面上就是indeterminate :不确定的)。

后记

我从这件小事儿所学到的:

  1. 看似一个小问题,也蕴含着大量可以挖掘的知识。仅仅是一个undefined behavior,折腾了一个晚上。所谓活到老,学到老也。
  2. 事物是发展变化的,不能用静态的眼光来看待。C语言走到现在30多年,但是C的标准在2020年以后还有修订完善,现代C跟K&R时期的C已经有不少变化。
  3. 事物除了自身的改良,还有被其他新事物颠覆的可能。Rust语言在应对C语言缺陷相关和确保性能之间实现了很好的平衡,也许不远的将来能成为系统语言的第一选择。