为什么这个悬空指针在 C 中? 更长的解释例如使用函数栈帧进行实验

问题描述

为什么 ptr 是悬空指针。我知道“ch”超出范围,但 ch 的地址在内部块之外仍然有效。当我打印 *ptr 时,我得到正确的值,即 5。

void main()
{
   int *ptr;
   
   {
       int ch = 5;
       ptr = &ch;
   } 
  
  
  printf("%d",*ptr);
}

解决方法

ptr 是指向不再属于您的内存的指针,它可以包含任何内容,包括您期望的值。你看到的 5 只是一个剩余的,它可以随时被覆盖。您在此处看到的是未定义行为

在这种简单情况下,编译器生成的代码很可能与编译器为此程序生成的代码相同(这是完全合法的),这可能就是您得到 5 的原因:

void main()
{
   int *ptr;
   
   int ch = 5;
   ptr = &ch;
   
   printf("%d",*ptr);
}

考虑这个稍微复杂一点的案例:

int *foo()
{
    int ch = 5;
    return &ch;
}

void main()
{
  int* ptr = foo();

  printf("%d ",*ptr);
  printf("%d ",*ptr);
}

这里的输出可能是这样的:

5 45643

第一次你可能会得到 5,因为内存还没有被覆盖,第二次你得到其他东西,因为在此期间内存已经被覆盖。

请注意,输出可能是其他任何内容,甚至可能会崩溃,因为这是未定义的行为。

另请阅读:Can a local variable's memory be accessed outside its scope?,本文适用于 C++,但也适用于 C 语言。

,

您遇到这种情况是因为您很可能没有尝试在启用优化的情况下编译代码。当您这样做时,您将获得应用程序输出的不可预测行为,因为违反了 CC++ 中的作用域语义。

如果您不使用编译时优化,即使您违反了语义规则,您仍然可以具有某种可预测性。这是因为编译器将自身限制为按照编写的顺序和逻辑生成代码。

一旦优化开始,只有您的编程语言的语义规则将继续让您控制和可预测生成的机器代码。这就是为什么在生产代码中(您几乎总是希望在发布二进制文件中启用优化),您永远不会尝试这些学术黑客

更长的解释

编译器管理堆栈的方式遵循两种契约

  1. strong 约定 - 就像不同二进制文件(如共享库)之间的函数调用情况,称为校准约定(请参阅{{3} })。粗略地说,这个调用约定定义了调用函数时堆栈帧的管理方式。这是一个强契约,因为它不会因优化设置、其他编译器设置甚至不同版本的编译器而改变。否则,here 会损坏。

  2. 契约 - 就像函数、语句或复合语句中的局部变量或对仅在特定编译单元中可见的函数的调用的情况。这里没有关于编译器将如何管理堆栈的标准。它可以为所欲为,只要它遵循该编程语言的语义,并且它将成为编译时优化算法的目标。

在您或我的示例(见下文)中,语义被破坏:我们定义了一个复合语句,退出其范围,但仍保留(或使用)对该范围内使用的内存的一些引用。

例如

让我们用这个扩展您的示例并将其保存到 local.c 文件:

int main(int argc,char * argv[]) {
    int *ptr1,*ptr2;

    {
        int ch = 5;
        ptr1 = &ch;
    } 
    {
        int ch = 10;
        ptr2 = &ch;
    }

    printf(
        "pointer1: %d\n"
        "pointer2: %d\n",*ptr1,*ptr2
    );
    
    return 0;
}

现在,让我们使用 gcc 并以两种不同的方式编译它,看看会发生什么:

  1. 禁用优化
  2. 启用优化
1.禁用优化
# gcc local.c -O0 -o local; ./local
pointer1: 10
pointer2: 10

好吧,我们看到 ptr1ptr2 都指向确切的位置。这在某种程度上是有道理的,因为在第一个复合语句关闭后,编译器将其保留空间用于第二个语句。一旦我们通过使用 {} 括号用这些复合语句定义作用域,这是我们确实期望的行为。

这也是您在示例中遇到的情况。您正在保存一个指向堆栈位置的地址,编译器知道它在遇到右括号 } 后可以立即使用。但是,您的示例没有即将发布的语句来查看实际效果。

2.启用优化
# gcc local.c -O1 -o local; ./local
pointer1: 0
pointer2: 0
等等,什么?

是的,相同的代码会产生两个不同的输出。随着优化的开启,行为发生了变化,现在编译器决定用更快或更小的代码替换您的代码。

使用函数栈帧进行实验

为了好玩,让我们试试同样的函数:

void fn_set() { char a = 5; printf("fn_set: a=%d\n",a); }
void fn_get() { char a    ; printf("fn_get: a=%d\n",a); }

int main(int argc,char * argv[]) {
    fn_set();
    fn_get();  
    return 0;
}

我们希望 fn_get 打印 5,就像我们前面的例子一样。

让我们再次测试一下:

# gcc local.c -O0 -o local; ./local # without optimizations
fn_set: a=5
fn_get: a=5

# gcc local.c -O1 -o local; ./local # with optimizatins enabled
fn_set: a=5
fn_get: a=0

结果是一样的。理论上,函数 fn_getfn_set 具有相同的堆栈指纹。它们应该很好地重叠。在实践中,没有语义或规则与之绑定,因此编译器优化删除了不必要的代码(如 a 中未使用的变量 fn_get)并采用最简单/最快的版本。>