【问题标题】:NULL behavior when function returns address of local variable in C函数返回 C 中局部变量地址时的 NULL 行为
【发布时间】:2020-12-30 20:11:48
【问题描述】:

我有以下 C 代码:

#include <stdlib.h>
#include <stdio.h>

char* foo() {
    char abc[4] = "abc";
    return abc;
}

int main() {
    printf("%s", foo());
    return 0;
}

如果我用 gcc 编译它并运行可执行文件,我得到(null)% 作为输出。

如果我运行稍加修改的代码:

#include <stdlib.h>
#include <stdio.h>

char* foo() {
    char abc[4] = "abc";
    return abc;
}

int main() {
    printf("%c", *(foo()));
    return 0;
}

我遇到了分段错误。

我的问题是:为什么我的第一个代码不会出现分段错误?我正在运行 Linux 和 gcc 版本:gcc (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0

这两个代码在编译时都会生成一个warning: function returns address of local variable [-Wreturn-local-addr] 警告

【问题讨论】:

  • 关于“为什么我的第一个代码不会出现分段错误?”:在这两种情况下,程序的行为都好像foo 返回了一个空指针。 (这种行为不是由 C 标准定义的,可能是优化器行为的结果,而不是您应该依赖的东西。)当您将指针传递给 %s 时,printf 实现检查是否它是一个空指针并打印“(null)”而不是尝试取消引用它。当您尝试自己取消引用指针以将 printf 传递给 %c 时,没有进行初步检查,因此程序崩溃了。
  • “为什么这个明显不正确的程序没有崩溃”这个问题从来没有一个有趣的答案。这次只是走运了。继续前进,市民,这里没什么可看的。不要编写错误的程序。显然这说起来容易做起来难,但你至少可以做的是paying attention to compiler warnings
  • @n.'pronouns'm.:“为什么这个明显不正确的程序崩溃没有一个有趣的答案”这个问题是错误的。有一些东西需要学习,包括编译器如何工作、链接器如何工作、操作系统如何工作等等。在这种情况下,至少有三个答案是错误的,因此显然有信息需要学习。一般来说,了解崩溃的原因有助于诊断未来的错误,从而加快、改进和降低软件开发成本。
  • @n.'pronouns'm.:不,这并不明显。那里有很多东西要学。一是违反的规则。取消引用指向生命周期已结束的对象的指针并不是未定义的。违反的规则是使用这样的指针具有未定义的行为。三个答案错了​​,这意味着三个人,可能还有更多的读者,不知道或注意到可能导致其他程序行为不端的规则。学习正确的规则并学会识别它,对于避免错误很有用。
  • @n.'pronouns'm.:要了解的另一件事是行为是如何表现出来的。编译器在这里没有做一件简单的事情。代码的直接实现会留下一个地址。它没。许多人不知道编译器对程序进行了如此全面的转换。他们在课堂上学习了一个简单的 C 计算模型,并将优化视为常见子表达式的合并,或者可能重写一些算术。了解当今的编译器进行大型抽象转换是新的。

标签: c string pointers memory


【解决方案1】:

return abc; 开始执行的那一刻,abc 是一个指向在foo 中定义的数组的指针。 (正式地,它指定数组本身,但它会自动转换为第一个元素的地址。)该函数将返回此指针值。但是,当函数执行结束时,数组的生命周期也结束了。

每 C 2018 6.2.4 2:

当指针指向(或刚刚过去)的对象到达其生命周期结束时,指针的值变得不确定。

当一个值在 C 中不确定时,它可能表现得好像它具有任何值,包括每次尝试使用它时具有不同的值或具有陷阱值(C 2018 3.19. 2 和 3.19.3)。请注意,这不仅仅意味着指针值指向的内容是不确定的; 指针本身的值是不确定的。

所以,即使abc 在内存中有一些地址,比如 100400,这并不意味着 100400 会返回给调用者。返回给调用者的是不确定的:它可以是任何东西,包括空指针值。

您的编译器的优化器似乎通过提供或允许空指针值作为函数 foo 的返回值来响应代码中未定义的行为。这是 C 标准允许的。

当您将此空指针传递给printf 以与%s 一起使用时,您的printf 实现会检查该指针,发现它是一个空指针,并打印“(null)”而不是尝试使用它来访问内存中的字符串。

当您尝试使用*(foo()) 取消引用指针时,没有对指针值进行初步检查。程序机器码试图使用空指针访问内存,导致段错误。

【讨论】:

  • 我有一个后续问题:在我重复运行这两个代码的另一台机器上,第一个代码在每次运行时都会生成不同的无意义字符串(这是预期的)。然而,第二个代码总是成功打印a%。对此有什么好的解释?该编译器是否会选择以某种方式保留指向abc 的指针的实际值,并且编译器会给调用者访问该内存的权限?直觉上我会说这不应该是一种未定义的行为,或者至少不是一个好的未定义行为?或者,这里还有其他事情吗?
  • @Go_printf:“a%”的“%”是错字吗?因为打印“a”很容易发生:调用foo,它初始化一个数组abc以包含“abc”,它返回该数组的第一个元素的地址,这个地址在编译器优化后仍然存在,调用者取消引用它为了得到“a”,调用者将它传递给printfprintf 打印“a”。
  • ““a%”的“%”是错字吗?百分号出现在对比鲜明的背景中。我认为这是因为我没有在printf 中添加\n。 “幸存的编译器优化”是指该编译器进行了优化以返回 abc 的实际地址?
  • @Go_printf: 是的,如果你不打印换行符,程序结束时光标会留在打印的文本后面,之后命令行shell会打印它的提示符,即可能是“%”字符。 “幸存的编译器优化”是指编译器可能工作的部分方式是生成与人类编写的代码相似的代码,然后对其应用优化技术——幸存下来意味着最初生成的代码通过优化的结果,而不是被转化为不同的东西。
【解决方案2】:

因为您正在创建一个局部变量 abc,所以该变量仅在函数 foo 的范围内有效。一旦您从 foo 返回,返回该变量的地址就没有意义了,该地址将不再有效。 还要记住 C 使用堆栈将参数传递给函数并从函数中返回值。同样,局部变量也在堆栈中创建,它将被函数调用机制修改,因此使用该地址最终会破坏堆栈。
要创建指针,您应该使用堆分配(使用 malloc 系列函数),或者您必须确保变量在使用时位于现有范围内。

【讨论】:

  • 对象不存在于它们的生命周期之外,而不是它们的作用域。范围是名称可见的地方。生命周期是对象存在的时间。一个对象可以在其生命周期之外的代码中通过传递一个指针来访问它,就像将一个对象的地址传递给一个子例程一样。
【解决方案3】:

当您尝试取消引用指向局部变量的指针时,您的第二个代码会调用未定义的行为。现在这个局部变量不存在于它的范围之外。因此,内存无效

在第一个代码中,您尝试访问其范围之外的局部变量。现在在这种情况下,函数应该返回一个char *。当您返回一个局部变量时,您得到的是不会导致分段错误的空打印。

【讨论】:

  • 对象不存在于它们的生命周期之外,而不是它们的作用域。范围是名称可见的地方。生命周期是对象存在的时间。一个对象可以在其生命周期之外的代码中通过传递一个指针来访问它,就像将一个对象的地址传递给一个子例程一样。
【解决方案4】:

考虑以下事件序列:

  • 您入住酒店,然后被安排在 137 号房间。
  • 您告诉前台给您的朋友打电话,并邀请他们明天凌晨 2 点在您的房间参加一场狂野的舞会。
  • 但是,您的预订将持续到明天凌晨 2 点。也许还有另一位客人的预订。也许房间会一直空着。谁知道。可能前台的人都知道。也许他们没有。

那么前台应该怎么做呢?

他们仍然可以发送指示房间 137 的邀请,可能不知道您当时不会在那里,因为他们忘记检查他们的预订记录。或者他们只是不在乎。

或者他们可以拒绝发送邀请并告诉你。

或者他们可能会忽略您的请求,不发送任何内容,也不告诉任何人。

或者他们可以发送邀请,但提供虚假的房间号。也许他们事先准备好了请柬空白,他们只需要填写时间和房间号。但由于技术先进,如果他们知道房间号不是为特定客人预留的,他们不会填写房间号,而是发送一个带有默认房间号的房间号——也许为零?

也许如果我们生活在未来,他们甚至可能会发送一封电子邀请函,其中标有 137 号房间——当您从酒店退房时,它会自毁!

无论他们做什么,他们都无法发送指示正确房间号的邀请,因为没有正确的房间号。你不会在任何房间号。所以他们什么都做。他们可能总是选择一种策略来处理这种情况。或者他们可能会掷硬币。或者也许不同的员工会做不同的事情。谁知道呢。

所以他们的一些策略会导致严重的崩溃(你的朋友在错误的时间叫醒了错误的客人,他们打电话给警察,但一切都不会很好)。

其他策略会产生不那么引人注目的结果。拒绝继续并让您知道?让您知道出了点问题,但仍要继续吗?忽略危险指令?用不那么危险的指令代替它?所有这些都是可能的。

这对应于当您指示编译器执行明显危险且非法的事情时编译器可能会执行的操作。忽略危险,或拒绝继续发送诊断消息,或生成诊断消息并继续,或完全跳过危险指令(但仅当它 100% 确定破坏迫在眉睫时),或稍微调整它以使其危险性较小。真正的编译器实际上是在不同的情况下完成所有这些事情。重要的是要知道,向编译器询问不可能的事情并不总是导致程序实际上尝试做不可能的事情。


此答案部分基于其作者已删除的答案https://stackoverflow.com/a/63862176/775806

【讨论】:

    【解决方案5】:

    取消引用不存在的对象是未定义的行为。

    为什么首先有效:我的猜测是因为编译器已经优化了对函数的调用。

    【讨论】:

      猜你喜欢
      • 2011-10-17
      • 2014-04-12
      • 1970-01-01
      • 2012-09-05
      • 2012-09-07
      • 1970-01-01
      相关资源
      最近更新 更多