提问



我有以下代码。


#include <iostream>

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

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


并且代码正在运行,没有运行时异常!


输出是58


怎么会这样?是不是在其功能之外无法访问本地变量的内存?

最佳参考



  怎么会这样?是不是在其功能之外无法访问本地变量的内存?



你租了一个酒店房间。你把一本书放在床头柜的顶部抽屉里睡觉。你第二天早上退房,但忘记给你钥匙。你偷了钥匙!


一周后,您返回酒店,不要办理登机手续,用偷来的钥匙潜入您的旧房间,然后看看抽屉。你的书还在那里。惊人!


怎么会这样?如果你还没出租房间,那么酒店房间抽屉的内容是否无法进入?


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


酒店管理层不 删除您的图书。你没有与他们订立合同,如果你把东西留在后面,他们会为你撕碎它。如果您使用被盗钥匙非法重新进入您的房间以便将其取回,酒店保安人员不会要求让您偷偷溜进去。您没有与他们签订合同说如果我试图稍后潜入我的房间,你必须阻止我。相反,你和他们签了一份合同,上面写着我保证不会在以后偷偷回到我的房间,这是一份你破坏的合同的。


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


你不知道将会发生什么;当你退出酒店并偷了钥匙以后非法使用时,你放弃了生活在一个可预测,安全的世界的权利,因为你选择了打破系统的规则。


C ++不是一种安全的语言。它会愉快地让你打破系统的规则。如果你试图做一些非法和愚蠢的事情,比如回到房间,你就没有被授权进入并且通过一张甚至可能不在那里的桌子翻找,C ++也不会阻止你。比C ++更安全的语言解决了这个问题例如,限制你的权力的问题 - 通过对密钥进行更严格的控制。


更新



圣洁的善良,这个答案得到了很多关注。 (我不确定为什么 - 我认为它只是一个有趣的小类比,但无论如何。)


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


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


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


第二种是具有某种短暂存储存储区域,其中存储器中每个字节的寿命是众所周知的,并且特别地,存储器的寿命遵循嵌套模式。也就是说,短寿命变量的最长寿命的分配严格地重叠在它之后的短寿命变量的分配。


局部变量遵循后一种模式;当输入方法时,其局部变量变为活跃状态。当该方法调用另一个方法时,新方法的局部变量就会生效。它们将在第一个方法的局部变量死亡之前消失。与局部变量相关的存储寿命的开始和结束的相对顺序可以提前解决。


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


这就像酒店决定只按顺序出租房间,你不能结账,直到房间号码高于你的每个人都检查出来。


所以让我们考虑一下堆栈。在许多操作系统中,每个线程获得一个堆栈,堆栈被分配为一定的固定大小。当你调用一个方法时,东西被压入堆栈。如果你然后传递一个指针正如原始海报在这里所做的那样,从你的方法中回到堆栈,这只是一个指向一些完全有效的百万字节内存块中间的指针。在我们的比喻中,您退房酒店;当你这样做时,你刚刚检查出编号最高的房间。如果没有其他人在您之后办理登机手续,并且您非法回到您的房间,那么您所有的东西都将保证在这个特定的酒店中 。


我们使用堆栈作为临时商店,因为它们非常便宜且容易。使用堆栈存储本地文件不需要C ++的实现;它可以使用堆。它没有,因为这会使程序变慢。


不需要实现C ++就可以保持你在堆栈中留下的垃圾不受影响,这样你就可以非法地回来了。编译器生成的代码在您刚刚腾出的房间中变回零是完全合法的。它并不是因为那将是昂贵的。


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


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


这是有问题的。有很多规则,很容易意外地打破它们。我当然有很多次。更糟糕的是,这个问题通常只会在腐败发生后检测到内存损坏数十亿纳秒后才会出现,而很难弄清楚是谁弄乱了它。


更多内存安全语言通过限制功率来解决此问题。在普通C#中,根本无法获取本地的地址并将其返回或存储以供日后使用。您可以获取本地的地址,但语言设计巧妙,因此在本地生命周期结束后无法使用它。为了获取本地的地址并将其传回,您必须将编译器置于特殊的不安全模式,和在您的程序中添加unsafe一词,以引起注意事实上,你可能正在做一些可能违反规则的危险事件。


进一步阅读:



  • 如果C#允许返回引用怎么办?巧合的是,这是今天博客文章的主题:


    http://blogs.msdn.com/b/ericlippert/archive/2011/06/23/ref-returns-and-ref-locals.aspx[52]

  • 为什么我们使用堆栈来管理内存? C#中的值类型是否始终存储在堆栈中?虚拟内存如何工作?关于C#内存管理器如何工作的更多主题。其中许多文章也与C ++程序员密切相关:


    https://blogs.msdn.microsoft.com/ericlippert/tag/memory-management/[53]


其它参考1


你在这里做的只是简单地读取和写入曾经作为a的地址的记忆。现在你已经超出了foo,它只是指向一些随机存储区域的指针。恰好在你的例子中,存储区域确实存在,此刻没有其它东西正在使用它。你不会通过继续使用它来破坏任何东西,没有别的东西覆盖它然而。因此,5仍然存在。在一个真实的程序中,这个内存几乎可以立即重复使用,你可以通过这样做来破坏某些东西(虽然症状可能要到很晚才出现!)


当你从foo返回时,告诉操作系统你不再使用那个内存,它可以被重新分配给别的东西。如果你很幸运,它永远不会被重新分配,操作系统也不会抓住你再次使用它,然后你就会逃脱谎言。尽管如此,你最终还是会写到最后用这个地址写的东西。


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


简而言之:这通常不会起作用,但有时会偶然发生。

其它参考2


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

其它参考3


所有答案的一点点补充:


如果你做那样的事情:


#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);
}


输出可能是:7


这是因为从foo()返回后,堆栈被释放,然后由boo()重用。
如果您拆卸可执行文件,您将清楚地看到它。

其它参考4


在C ++中,你可以访问任何地址,但它并不意味着你应该。你正在访问的地址不再有效。它工作因为在foo返回之后没有别的东西扰乱了内存,但是在很多情况下它可能会崩溃。尝试用Valgrind分析你的程序,或者甚至只是编译优化,看看...... [54]

其它参考5


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


unsigned int q = 123456;

*(double*)(q) = 1.2;


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



  1. q实际上可能确实是双重的有效地址,例如double p; q = &p;

  2. q可能指向已分配内存中的某处,我只是在那里覆盖8个字节。

  3. q指向已分配的内存,操作系统的内存管理器向我的程序发送分段错误信号,导致运行时终止它。

  4. 你赢了彩票。



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


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

其它参考6


您是否在启用优化器的情况下编译程序?


foo()函数非常简单,可能在结果代码中内联/替换。


但是我和马克B一致认为结果行为是未定义的。

其它参考7


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


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

其它参考8


你只是返回一个内存地址,它被允许但可能是一个错误。


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


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
}

其它参考9


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

其它参考10


正如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,但结果可能会有所不同(真的!)。你的指针正在破坏其他无关的局部变量。

其它参考11


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

其它参考12


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


返回临时工作的地址,但由于临时工作在函数末尾被销毁,访问它们的结果将是不确定的。


所以你没有修改a,而是a曾经存在的内存位置。这种差异非常类似于崩溃和不崩溃之间的区别。

其它参考13


在典型的编译器实现中,您可以将代码视为使用以前占用的地址打印出内存块的值。另外,如果你将一个新的函数调用添加到一个本地int的函数中,那么a的值(或a用来指向的内存地址很有可能) )更改。这是因为堆栈将被包含不同数据的新帧覆盖。


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

其它参考14


注意所有警告。不仅要解决错误。

GCC显示此警告



  警告:返回本地变量a的地址



这是C ++的强大功能。你应该关心记忆。使用-Werror标志,此警告会出错,现在您必须对其进行调试。

其它参考15


它可以,因为a是在其范围的生命周期内临时分配的变量(foo函数)。从foo返回后,内存空闲,可以被覆盖。


您正在做的事情被描述为未定义的行为。结果无法预测。

其它参考16


如果你使用:: printf而不是cout,那么具有正确(?)控制台输出的东西可能会发生巨大变化。
您可以在下面的代码中使用调试器(在x86,32位,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); 
}

其它参考17


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


因此,函数foo()返回a的地址,a在返回其地址后被销毁。您可以通过返回的地址访问修改后的值。


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


假设一个人在某个地方隐藏钱并告诉你该位置。过了一段时间,那个告诉你钱位置的男人死了。但是你仍然可以获得隐藏的钱。

其它参考18


它是使用内存地址的脏方式。当你返回一个地址(指针)时,你不知道它是否属于一个函数的局部范围。它只是一个地址。现在您调用了foo函数,a的地址(内存位置)已经分配到应用程序(进程)的(安全地,至少现在至少)可寻址内存中。在返回foo函数之后,a的地址可以被认为是脏但它在那里,没有被清理,也没有受到程序其他部分中的表达式的干扰/修改(至少在这个特定情况下)。 AC/C ++编译器不会阻止你进行这种脏访问(如果你关心,可能会警告你)。你可以安全地使用(更新)程序实例(进程)数据段中的任何内存位置,除非你通过某种方式保护地址。

其它参考19


这绝对是一个时间问题!如果foo指示符超出foo的范围,则p指针所指向的对象被调度被破坏。但是,此操作不会立即发生,而是在多个CPU周期之后发生无论这是不确定的行为,还是C ++实际上在后台做了一些预清理工作,我都不知道。


如果在调用foocout语句之间插入对操作系统的sleep函数的调用,在取消引用指针之前使程序等待一秒左右,您将注意到你想要阅读它时数据已经消失了!看看我的例子:


#include <iostream>
#include <unistd.h>
using namespace std;

class myClass {
public:
    myClass() : i{5} {
        cout << "myClass ctor" << endl;
    }

    ~myClass() {
        cout << "myClass dtor" << endl;
    }

    int i;
};

myClass* foo() {
    myClass a;
    return &a;
}

int main() {

    bool doSleep{false};

    auto p = foo();

    if (doSleep) sleep(1);

    cout << p->i << endl;
    p->i = 8;
    cout << p->i << endl;
}


(请注意,我使用unistd.h中的sleep函数,该函数仅存在于类Unix系统上,因此您需要将其替换为Sleep(1000)Windows.h如果你在Windows上。)


我用类替换了int,所以我可以确切地看到析构函数被调用的时间。


此代码的输出如下:


myClass ctor
myClass dtor
5
8


但是,如果将doSleep更改为true:


myClass ctor
myClass dtor
0
8


正如您所看到的,应该销毁的对象实际上已被销毁,但我认为在对象(或仅仅是变量)被销毁之前必须执行一些预清理指令,因此在完成这些操作之前,数据仍然可以在短时间内访问(但当然不能保证,所以请不要编写依赖于此的代码)。


这非常奇怪,因为析构函数在退出范围后立即被调用,但是实际的破坏会稍微延迟。


我从来没有真正阅读过指定此行为的官方ISO C ++标准部分,但很可能,标准只承诺一旦数据超出范围就会被销毁,但它没有说明任何事情。这种情况会在执行任何其他指令之前立即发生。如果是这种情况,那么这种行为完全正常,人们只是误解了标准。


或者另一个原因可能是那些不能正确遵循标准的厚颜无耻的编译器。实际上,这并不是编译器交换一点标准一致性以获得额外性能的唯一情况!


无论原因是什么,很明显数据被销毁,而不是立即销毁。