1125

I have the following code.

#include <iostream>

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

int main()
{
    int* p = foo();
    std::cout << *p;
    *p = 8;
    std::cout << *p;
}

And the code is just running with no runtime exceptions!

The output was 58

How can it be? Isn't the memory of a local variable inaccessible outside its function?

4

21 回答 21

4931

怎么会这样?局部变量的内存不是在其函数之外无法访问吗?

你租了一个旅馆房间。你把一本书放在床头柜最上面的抽屉里,然后去睡觉。您第二天早上退房,但“忘记”归还您的钥匙。你偷了钥匙!

一周后,你回到酒店,不办理入住手续,用偷来的钥匙潜入旧房间,然后在抽屉里翻找。你的书还在。惊人!

这个怎么可能?如果您没有租过房间,酒店房间抽屉里的东西是不是无法访问?

好吧,显然这种情况可以在现实世界中发生,没问题。当您不再被授权进入房间时,没有神秘的力量会导致您的书消失。也没有神秘的力量阻止你带着偷来的钥匙进入房间。

酒店管理人员无需移除您的图书。您没有与他们签订合同,说如果您留下东西,他们会为您撕碎。如果您使用偷来的钥匙非法重新进入您的房间以取回它,酒店保安人员不需要抓住您偷偷溜进来。您没有与他们签订合同,上面写着“如果我试图偷偷溜回我的房间”以后有房间,你必须阻止我。” 相反,您与他们签订了一份合同,上面写着“我保证以后不会偷偷溜回我的房间”,这是您违反的合同。

在这种情况下,任何事情都可能发生。这本书可以在那里——你很幸运。别人的书可能在那里,你的书可能在酒店的炉子里。当你进来时,有人可能会在那里,把你的书撕成碎片。酒店本可以完全拆除桌子并预订,并用衣柜取而代之。整个酒店可能即将被拆除,取而代之的是一个足球场,而你会在偷偷摸摸的时候死于爆炸。

你不知道会发生什么;当您退房并偷走钥匙以供以后非法使用时,您就放弃了生活在可预测的安全世界中的权利,因为选择了违反系统规则。

C++ 不是一种安全的语言。它会很高兴地让你打破系统的规则。如果你试图做一些非法和愚蠢的事情,比如回到一个你无权进入的房间并在一张可能不再存在的桌子上翻找,C++ 不会阻止你。比 C++ 更安全的语言通过限制你的权力来解决这个问题——例如,通过对键进行更严格的控制。

更新

天哪,这个答案引起了很多关注。(我不知道为什么——我认为这只是一个“有趣”的小类比,但无论如何。)

我认为用更多的技术思想来更新这一点可能是密切相关的。

编译器的业务是生成代码,该代码管理由该程序操作的数据的存储。有很多不同的方法可以生成代码来管理内存,但随着时间的推移,两种基本技术已经变得根深蒂固。

第一个是有某种“长寿命”的存储区域,其中存储中每个字节的“寿命”——即它与某个程序变量有效关联的时间段——不能轻易地提前预测的时间。编译器生成对“堆管理器”的调用,该“堆管理器”知道如何在需要时动态分配存储,并在不再需要时回收它。

第二种方法是有一个“短期”存储区域,其中每个字节的生命周期是众所周知的。在这里,生命周期遵循“嵌套”模式。这些短期变量中寿命最长的将在任何其他短期变量之前分配,并将最后释放。寿命较短的变量将在寿命最长的变量之后分配,并在它们之前被释放。这些寿命较短的变量的寿命“嵌套”在寿命较长的变量的寿命中。

局部变量遵循后一种模式;当一个方法被输入时,它的局部变量就会活跃起来。当该方法调用另一个方法时,新方法的局部变量就会活跃起来。在第一个方法的局部变量死亡之前,它们就会死亡。可以提前计算出与局部变量相关的存储生命周期的开始和结束的相对顺序。

出于这个原因,局部变量通常作为存储在“堆栈”数据结构上而生成,因为堆栈具有这样的属性:第一个压入它的东西将是最后一个弹出的东西。

这就好比酒店决定只按顺序出租房间,等到房间号比你大的人都退房后才能退房。

所以让我们考虑一下堆栈。在许多操作系统中,每个线程都有一个堆栈,堆栈被分配为某个固定大小。当你调用一个方法时,东西会被压入堆栈。如果你然后将一个指向堆栈的指针从你的方法中传回,就像原始海报在这里所做的那样,那只是一个指向一些完全有效的百万字节内存块中间的指针。在我们的类比中,您从酒店退房;当你这样做时,你只是从最高编号的房间里签了出来。如果没有其他人在您之后入住,并且您非法返回房间,则可以保证您的所有东西仍然在这家特定的酒店中。

我们将堆栈用于临时商店,因为它们非常便宜且容易。C++ 的实现不需要使用堆栈来存储局部变量;它可以使用堆。它没有,因为这会使程序变慢。

C++ 的实现不需要让您留在堆栈上的垃圾保持原样,以便您以后可以非法返回;编译器生成将您刚刚腾出的“房间”中的所有内容归零的代码是完全合法的。这不是因为再次,那会很昂贵。

不需要 C++ 的实现来确保当堆栈逻辑收缩时,曾经有效的地址仍然映射到内存中。该实现被允许告诉操作系统“我们现在已经完成了使用这个堆栈页面。除非我另有说明,否则如果有人触摸先前有效的堆栈页面,则发出一个破坏进程的异常”。同样,实现实际上并没有这样做,因为它很慢而且没有必要。

相反,实现可以让你犯错并侥幸逃脱。大多数时候。直到有一天,真正可怕的事情出错了,这个过程爆炸了。

这是有问题的。有很多规则,很容易不小心打破它们。我当然有很多次。更糟糕的是,问题通常只有在内存损坏发生数十亿纳秒后检测到内存损坏时才会出现,此时很难弄清楚是谁搞砸了它。

更多的内存安全语言通过限制你的能力来解决这个问题。在“普通”C# 中,根本没有办法获取本地地址并将其返回或存储以备后用。您可以获取本地地址,但该语言设计巧妙,因此在本地生命周期结束后无法使用它。为了获取本地地址并将其传回,您必须将编译器置于特殊的“不安全”模式,并将“不安全”一词放入程序中,以提醒您注意您可能正在做的事实可能违反规则的危险事物。

进一步阅读:

于 2011-06-22T20:01:23.010 回答
283

您在这里所做的只是读取和写入曾经a. 现在您不在foo,它只是一个指向一些随机内存区域的指针。碰巧的是,在您的示例中,该内存区域确实存在,目前没有其他任何东西在使用它。继续使用它不会破坏任何东西,也没有其他任何东西覆盖它。因此,5它仍然存在。在一个真正的程序中,该内存几乎会立即被重新使用,并且您这样做会破坏某些东西(尽管症状可能要到很久以后才会出现!)

当您从 中返回时foo,您告诉操作系统您不再使用该内存,并且可以将其重新分配给其他东西。如果你很幸运并且它永远不会被重新分配,并且操作系统没有发现你再次使用它,那么你就会摆脱谎言。尽管您最终可能会写下该地址的任何其他内容。

现在,如果您想知道为什么编译器不抱怨,那可能是因为foo被优化消除了。它通常会警告你这种事情。C 假设您知道自己在做什么,并且从技术上讲,您没有违反这里的范围(在a之外没有对自身的引用foo),只有内存访问规则,它只会触发警告而不是错误。

简而言之:这通常不会起作用,但有时会碰巧。

于 2011-06-23T05:43:54.023 回答
155

因为存储空间还没有被踩到。不要指望这种行为。

于 2010-05-19T02:33:30.010 回答
91

A little addition to all the answers:

if you do something like that:

#include<stdio.h>
#include <stdlib.h>
int * foo(){
    int a = 5;
    return &a;
}
void boo(){
    int a = 7;

}
int main(){
    int * p = foo();
    boo();
    printf("%d\n",*p);
}

the output probably will be: 7

That is because after returning from foo() the stack is freed and then reused by boo(). If you deassemble the executable you will see it clearly.

于 2011-06-25T14:19:49.880 回答
72

在 C++ 中,您可以访问任何地址,但这并不意味着您应该. 您访问的地址不再有效。它之所以有效,是因为 foo 返回后没有其他任何东西扰乱内存,但在许多情况下它可能会崩溃。尝试使用Valgrind分析您的程序,甚至只是优化编译它,然后查看...

于 2011-06-22T14:15:01.713 回答
69

您永远不会通过访问无效内存来引发 C++ 异常。您只是给出了引用任意内存位置的一般概念的示例。我可以这样做:

unsigned int q = 123456;

*(double*)(q) = 1.2;

在这里,我只是将 123456 视为 double 的地址并写入它。任何数量的事情都可能发生:

  1. q实际上可能真的是双精度的有效地址,例如double p; q = &p;.
  2. q可能指向分配的内存内的某个地方,我只是在那里覆盖了 8 个字节。
  3. q指向分配的内存之外,操作系统的内存管理器向我的程序发送分段错误信号,导致运行时终止它。
  4. 你中了彩票。

您设置它的方式更合理的是,返回的地址指向有效的内存区域,因为它可能只是在堆栈的下方,但它仍然是您无法访问的无效位置确定性时尚。

在正常程序执行期间,没有人会自动为您检查内存地址的语义有效性。但是,像这样的内存调试器valgrind会很乐意这样做,因此您应该通过它运行您的程序并见证错误。

于 2011-06-22T14:15:11.937 回答
29

您是否在启用优化器的情况下编译程序?该foo()函数非常简单,可能已在生成的代码中内联或替换。

但我同意 Mark B 的观点,即由此产生的行为是不确定的。

于 2011-06-22T14:12:51.937 回答
24

您的问题与范围无关。在您显示的代码中,该函数main看不到 function 中的名称foo,因此您无法a在 foo 中使用名称直接访问 outside foo

您遇到的问题是为什么程序在引用非法内存时没有发出错误信号。这是因为 C++ 标准没有在非法内存和合法内存之间指定一个非常明确的界限。在弹出的堆栈中引用某些内容有时会导致错误,有时不会。这取决于。不要指望这种行为。假设它在您编程时总是会导致错误,但假设它在您调试时永远不会发出错误信号。

于 2011-06-23T04:45:29.920 回答
21

注意所有警告。不要只解决错误。
GCC 显示此警告

警告:返回局部变量“a”的地址

这就是 C++ 的力量。你应该关心内存。有了这个-Werror标志,这个警告就变成了一个错误,现在你必须调试它。

于 2015-08-17T06:30:16.290 回答
18

您只是返回一个内存地址,这是允许的,但可能是一个错误。

是的,如果您尝试取消引用该内存地址,您将有未定义的行为。

int * ref () {

 int tmp = 100;
 return &tmp;
}

int main () {

 int * a = ref();
 //Up until this point there is defined results
 //You can even print the address returned
 // but yes probably a bug

 cout << *a << endl;//Undefined results
}
于 2010-05-19T02:33:39.557 回答
18

这是两天前在这里讨论过的经典未定义行为——在网站上搜索一下。简而言之,您很幸运,但任何事情都可能发生,您的代码正在对内存进行无效访问。

于 2011-06-24T21:57:55.213 回答
18

它之所以有效,是因为自从 a 被放在那里以来,堆栈还没有改变(还)。在再次访问之前调用一些其他函数(它们也调用其他函数)a,您可能不会再那么幸运了...... ;-)

于 2011-06-23T15:31:51.980 回答
18

正如 Alex 指出的那样,这种行为是未定义的——事实上,大多数编译器都会警告不要这样做,因为这是一种容易导致崩溃的方法。

有关您可能会遇到的那种怪异行为的示例,请尝试以下示例:

int *a()
{
   int x = 5;
   return &x;
}

void b( int *c )
{
   int y = 29;
   *c = 123;
   cout << "y=" << y << endl;
}

int main()
{
   b( a() );
   return 0;
}

这会打印出“y=123”,但您的结果可能会有所不同(真的!)。您的指针正在破坏其他不相关的局部变量。

于 2011-06-24T22:04:21.467 回答
16

您实际上调用了未定义的行为。

返回临时作品的地址,但由于临时作品在函数结束时被销毁,访问它们的结果将是未定义的。

因此,您没有修改a,而是修改了a曾经所在的内存位置。这种差异与崩溃和不崩溃的区别非常相似。

于 2011-06-24T21:57:13.623 回答
14

它可以,因为它是一个在其作用域(函数)a的生命周期内临时分配的变量。foofoo内存中返回后是空闲的并且可以被覆盖。

你在做什么被描述为未定义的行为。结果无法预测。

于 2011-06-24T21:57:54.787 回答
14

在典型的编译器实现中,您可以将代码视为“打印出带有地址的内存块的值,该地址曾经被a 占用”。此外,如果您将新的函数调用添加到包含局部变量的函数,那么(或曾经指向的内存地址)int的值很可能会发生变化。发生这种情况是因为堆栈将被包含不同数据的新帧覆盖。aa

但是,这是未定义的行为,您不应该依赖它来工作!

于 2011-06-22T14:18:00.463 回答
12

The things with correct (?) console output can change dramatically if you use ::printf but not cout. You can play around with debugger within below code (tested on x86, 32-bit, MSVisual Studio):

char* foo() 
{
  char buf[10];
  ::strcpy(buf, "TEST”);
  return buf;
}

int main() 
{
  char* s = foo();    //place breakpoint & check 's' varialbe here
  ::printf("%s\n", s); 
}
于 2011-06-24T15:07:13.423 回答
5

从函数返回后,所有标识符都被销毁,而不是保存在内存位置中的值,如果没有标识符,我们无法找到值。但是该位置仍然包含前一个函数存储的值。

所以,这里foo()的函数是返回地址,返回地址aa销毁。您可以通过返回的地址访问修改后的值。

让我举一个现实世界的例子:

假设一个人把钱藏在一个地方并告诉你这个位置。过了一段时间,告诉你钱的位置的人死了。但是您仍然可以使用隐藏的钱。

于 2017-07-19T07:07:55.830 回答
5

这是使用内存地址的“肮脏”方式。当您返回地址(指针)时,您不知道它是否属于函数的本地范围。这只是一个地址。现在您调用了“foo”函数,“a”的地址(内存位置)已经在应用程序(进程)的(至少目前是安全的)可寻址内存中分配了。在“foo”函数返回后,“a”的地址可以被认为是“脏”的,但它就在那里,没有被清理,也没有被程序其他部分的表达式干扰/修改(至少在这种特定情况下)。AC/C++ 编译器不会阻止你进行这种“肮脏”的访问(如果你在乎的话,可能会警告你)。

于 2017-03-08T15:25:53.130 回答
1

您的代码非常危险。您正在创建一个局部变量(在函数结束后被认为已销毁),并在该变量被销毁后返回该变量的内存地址。

这意味着内存地址可能有效或无效,并且您的代码将容易受到可能的内存地址问题(例如分段错误)的影响。

这意味着您正在做一件非常糟糕的事情,因为您将内存地址传递给一个根本不可信的指针。

相反,考虑这个例子,并测试它:

int * foo()
{
   int *x = new int;
   *x = 5;
   return x;
}

int main()
{
    int* p = foo();
    std::cout << *p << "\n"; //better to put a new-line in the output, IMO
    *p = 8;
    std::cout << *p;
    delete p;
    return 0;
}

与您的示例不同,在此示例中,您是:

  • 为 int 分配内存到本地函数
  • 该内存地址在函数到期时仍然有效,(它不会被任何人删除)
  • 内存地址是可信任的(该内存块不被认为是空闲的,因此在删除之前不会被覆盖)
  • 不使用时应删除内存地址。(见程序末尾的删除)
于 2019-05-02T10:17:27.107 回答
0

这取决于语言。在C 和 C++/Cpp中,的,您在技术上可以,因为它对任何给定指针是否实际上指向某个有效或无效的位置的检查非常弱。如果您在变量超出范围时尝试访问变量本身,编译器将报告错误,但它可能不够聪明,无法知道您是否故意将指向该变量位置的指针复制到仍将在其中的其他变量以后的范围。

但是,一旦变量超出范围就修改该内存将产生完全未定义的效果。您可能会破坏 stack,它可能已将该空间重用于新变量。

更现代的语言(如Java 或 C# )经常竭尽全力避免程序员首先需要访问变量的实际地址,以及边界检查数组访问,保持指向对象的变量的引用计数在堆中,这样它们就不会被过早地释放,等等。所有这些都是为了帮助防止程序员无意中做一些不安全的事情和/或超出范围内变量的范围。

于 2021-05-02T06:44:35.650 回答