一个本地变量的内存是否可以在其作用域外被访问?

1160

我有以下的代码。

#include <iostream>

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

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

而且代码只是运行而没有运行时异常!

输出为58

怎么可能?一个局部变量的内存不是在其函数外部无法访问吗?


15
如果不进行修改,这段代码甚至无法编译;即使你修复了格式错误,gcc仍然会警告“返回本地变量'a'的地址”;而valgrind则显示“大小为4的非法写入[...]地址0xbefd7114刚好在堆栈指针下方”。请注意,我的翻译尽可能准确地呈现原始文本的含义,同时也更易于理解。 - sehe
89
@Serge: 在我年轻时,我曾经在Netware操作系统上工作过一些有点棘手的零环代码,涉及到巧妙地移动堆栈指针,这种方式并不被操作系统完全批准。当我犯了错误时,通常堆栈会重叠在屏幕内存上,我可以直接观察到字节被写入显示器上。这种做法现在行不通了。 - Eric Lippert
23
在理解问题所在之前,我需要阅读问题和一些答案。这实际上是关于变量访问范围的问题吗?你甚至没有在函数外使用“a”。这就是问题的全部。从内存引用方面展开讨论是完全不同的话题,与变量范围无关。 - erikbstack
12
重复的答案并不意味着重复的问题。很多人在这里提出的重复问题实际上是完全不同的问题,只是碰巧涉及到相同的潜在症状...但是提问者没有办法知道这一点,所以这些问题应该保持开放状态。我关闭了一个旧的重复问题,并将其合并到这个问题中,因为它有一个非常好的答案,所以应该保持开放状态。 - Joel Spolsky
16
@Joel:如果这里的答案不错,应该将其___合并到更早的问题___中,而不是相反。而这个___问题___确实是其他在这里提出的问题的副本,甚至更多(尽管其中一些比其他问题更适合)。请注意,我认为Eric的回答很好。(事实上,我标记了这个问题以便将答案合并到更早的问题中,以挽救早期的问题。) - sbi
显示剩余13条评论
21个回答

5001

怎么可能?局部变量的内存不是在函数外部无法访问吗?

你租了一间酒店房间,在床头柜的顶层抽屉里放了一本书,然后去睡觉了。第二天早上你退房了,但“忘记”归还钥匙。你偷了钥匙!

一周后,你回到了酒店,没有办理入住手续,用偷来的钥匙溜进了你以前的房间,看了看抽屉里。你的书还在那里,惊人!

这怎么可能?如果你没有租房间,酒店房间抽屉里的内容不是无法访问吗?

显然,这种情况在现实中可以轻易发生。没有神秘的力量会导致你离开房间后书本消失。也没有神秘的力量阻止你用偷来的钥匙进入房间。

酒店管理方并不必须清理你的书。你没有与他们签订合同,说如果你遗留物品,他们会为你销毁它。如果你用偷来的钥匙非法进入房间取回物品,酒店保安不必须抓住你溜进去。你没有与他们签订合同,说“如果我试图以后潜回房间,你必须阻止我。”相反,你与他们签订了一份合同,承诺“我保证不会在以后偷偷回到我的房间”,而你违约了

在这种情况下,任何事情都可能发生。书可能在那里——你很幸运。别人的书可能在那里,而你的书可能已经被酒店的炉子烧掉了。当你进来的时候,有人可能正在那里,把你的书撕成碎片。酒店可能已经把桌子和书全部拿走了,并用衣柜代替了它们。整个酒店可能就要被拆除,换成一个足球场,而你正在偷偷摸摸地四处溜达时,会死于爆炸。

你不知道会发生什么;当你从旅馆退房并偷了一把钥匙以后,你放弃了生活在一个可预测、安全的世界的权利,因为选择打破系统的规则。

C++不是一种安全的语言。它会欣然让你打破系统的规则。如果你试图做一些非法和愚蠢的事情,比如回到一个你没有权限进入的房间并翻找可能已经不存在的抽屉,C++不会阻止你。比C++更安全的语言通过限制你的权力来解决这个问题——例如更严格地控制钥匙。

更新

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

我想更新一下这个问题,加入一些更多的技术思考。

编译器的工作是生成管理程序所操作的数据存储的代码。有很多不同的生成内存管理代码的方式,但随着时间的推移,两种基本技术已经得到了巩固。

第一种方法是拥有某种“长期存储”区域,其中存储中每个字节的“寿命”,即其与某个程序变量有效关联的时间段,不能事先轻易预测。编译器生成对“堆管理器”的调用,该管理器知道如何在需要时动态分配存储空间,并在不再需要时回收它。
第二种方法是拥有一个“短期存储”区域,其中每个字节的寿命都是众所周知的。在这里,寿命遵循“嵌套”模式。这些短期变量中最长寿命的变量将在任何其他短期变量之前分配,并在最后释放。较短寿命的变量将在最长寿命变量之后分配,并在其之前释放。这些较短寿命变量的寿命“嵌套”在较长寿命变量的寿命内。
局部变量遵循后者的模式;当进入方法时,其局部变量变为活动状态。当该方法调用另一个方法时,新方法的局部变量变为活动状态。他们会在第一个方法的局部变量死亡之前死亡。与局部变量相关联的存储的开始和结束的相对顺序可以事先计算出来。
因此,局部变量通常作为“堆栈”数据结构上的存储生成,因为堆栈具有以下特性:第一个推入堆栈的东西将是最后一个弹出堆栈的东西。
这就像酒店决定只按顺序出租房间,只有当所有房间号比你高的人都退房后,你才能退房。
所以让我们考虑一下堆栈。在许多操作系统中,每个线程都有一个堆栈,并且堆栈被分配为固定大小。当您调用方法时,会将内容推送到堆栈中。如果您将指针传递回您的方法之外,就像原始帖子在这里做的那样,那只是指向某个完全有效的百万字节内存块的中间指针。在我们的比喻中,您退房了; 当您这样做时,您刚刚从最高编号的已占用房间退房。如果在您之后没有其他人入住,并且您非法返回房间,则可以保证所有物品仍然在该特定酒店的房间内。

我们使用堆栈作为临时存储,因为它们非常便宜和易于使用。C++的实现不需要使用堆栈来存储本地变量;它可以使用堆。但实际上并没有这样做,因为那会使程序变慢。

C++的实现不需要保留您在堆栈上留下的垃圾,以便您以后非法返回获取它;编译器生成代码将"房间"中的所有内容都归零是完全合法的。但它不这样做,因为那样会很昂贵。

C++的实现不需要确保当堆栈逻辑缩小时,以前有效的地址仍然映射到内存中。该实现允许告诉操作系统“我们现在不再使用此堆栈页面。除非我另有说明,否则发出一个异常,如果任何人触摸以前有效的堆栈页面,则销毁进程”。同样,实现实际上并不这样做,因为这是缓慢和不必要的。

相反,实现允许您犯错误并逃脱惩罚。大多数时候。直到某天出现真正可怕的问题,进程才会崩溃。

这是一个问题。有许多规则,很容易意外地违反它们。我肯定已经犯了很多次。更糟糕的是,当检测到内存损坏时,问题通常只会在十亿纳秒之后浮出水面,此时很难弄清楚是谁搞砸了它。
更多内存安全的语言通过限制您的权限来解决这个问题。在“普通”的 C# 中,根本没有办法获取局部变量的地址并返回它或将其存储以供以后使用。您可以获取局部变量的地址,但是编程语言被巧妙地设计为在局部变量的生命周期结束后无法使用它。为了获取局部变量的地址并将其传回,请将编译器置于特殊的“不安全”模式中,并在程序中放置单词“不安全”,以引起注意,表明您可能正在做一些危险的事情,可能会违反规则。
进一步阅读:
- 如果 C# 允许返回引用怎么办?恰好今天的博客文章就是这个主题:Ref returns and ref locals - 为什么我们使用堆栈来管理内存?C# 中的值类型是否总是存储在堆栈上?虚拟内存是如何工作的?以及 C# 内存管理器的许多其他主题。这些文章中的许多也适用于 C++ 程序员:Memory management

61
不幸的是,操作系统在解除或释放虚拟内存页面之前并不会发出警告。如果您在您不再拥有该内存的情况下操作该内存,则操作系统完全有权在您触碰到已被释放的页面时关闭整个进程。嘭! - Eric Lippert
90
@Kyle:只有安全的酒店会这么做。不安全的酒店因为无需浪费时间编程钥匙而获得可衡量的盈利增长。 - Alexander Torstling
529
@cyberguijarro说C++不具备内存安全性这是事实,这并不是对C++的抨击。如果他说:“C++是一堆过于复杂、缺乏明确规范的特性,累赘而危险的内存模型交织在一起,每天不再用它工作我都感到非常庆幸,为了自己的心理健康着想”,那就是在抨击C++了。指出它不具备内存安全性只是解释原帖中出现问题的原因,回答了问题而不是发表评论。 - Eric Lippert
64
严格来说,这个类比应该提到酒店接待员非常乐意让你带走钥匙。 “哦,我能把这把钥匙带走吗?” “没问题。我为什么要在意呢?我只是在这里工作而已。” 只有当你试图使用钥匙时才算违法。 - philsquared
154
请至少考虑有一天写一本书。即使它只是一本修订和扩展的博客文章集合,我也会买它,我相信很多人也会。但是一本关于各种编程相关问题的原创思想的书将是一篇很棒的读物。我知道这很难实现,但请考虑写一本。 - Dyppl
显示剩余48条评论

283
你只是在读写曾经是a地址的内存。现在你已经离开了foo,它只是一个指向某个随机内存区域的指针。恰好在你的例子中,那个内存区域确实存在,并且此时没有其他东西在使用它。
继续使用它不会破坏任何东西,也没有其他东西覆盖它。因此,5仍然存在。在真正的程序中,该内存将几乎立即被重用,这样做会破坏程序(尽管症状可能要到很久之后才会出现!)。
当你从foo返回时,你告诉操作系统你不再使用那块内存,它可以被重新分配给其他东西。如果你很幸运,它永远不会被重新分配,并且操作系统不会发现你再次使用它,那么你就可以逃脱这个谎言。但很有可能你最终会覆盖掉那个地址上的其他内容。
现在如果你想知道为什么编译器不抱怨,那可能是因为优化消除了foo。通常它会警告你这种情况。C假设你知道自己在做什么,从技术上讲,你没有违反作用域(a本身在foo外没有引用),只是违反了内存访问规则,这只会触发一个警告而不是错误。
简而言之:这通常不起作用,但有时候会碰巧成功。

160

因为存储空间尚未被覆盖。不要指望这种行为。


2
兄弟,这是自从“什么是真理?皮拉多戏言地说。”以来最长的等待评论时间了。也许那是旅馆抽屉里的基督教圣经。他们到底发生了什么事呢?注意到他们在伦敦已经不再存在了。我猜根据平等法规,你需要一堆宗教小册子。 - Rob Kent
我本以为我很久以前就写过那个了,但最近它又出现了,发现我的回复不在那里。现在我必须去弄清楚你上面的暗示,因为我期待着当我这样做时会感到有趣 >.< - msw
2
哈哈。弗朗西斯·培根是英国最伟大的散文家之一,有些人怀疑他写了莎士比亚的剧本,因为他们无法接受一个来自乡村、父亲是手套匠的语法学校孩子能成为天才。这就是英国的阶级制度。耶稣说:“我就是真理。”http://oregonstate.edu/instruct/phl302/texts/bacon/bacon_essays.html - Rob Kent

95

对于所有答案的补充:

如果你做了以下这样的事情:

#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() 重新使用。

如果您反汇编可执行文件,将会清楚地看到它。


3
一个简单但很好的例子,可以理解底层堆栈理论。只需在foo()中声明“int a = 5;”为“static int a = 5;”,就可以用来理解静态变量的作用域和生命周期。 - proton
18
“将可能是7”。“编译器可能会在boo中enregister a。它可能会删除它,因为它是不必要的。很有可能*p不会是5,但这并不意味着有特别好的理由来解释为什么它很可能是7。” - Matt
2
这被称为未定义行为! - Francis Cugler
1
@ampawd 这个问题已经有近一年的历史了,但是“函数堆栈”并没有被分开。一个上下文有一个堆栈。该上下文使用其堆栈进入主函数,然后进入 foo(),退出,然后进入 boo()Foo()Boo() 都在相同位置的堆栈指针处进入。然而,这不是应该依赖的行为。其他“东西”(如中断或操作系统)可以在调用 boo()foo() 之间使用堆栈,修改其内容... - Russ Schultz
如果在两个函数调用之间没有发生,则堆栈将被重用,如果内核没有分页出该堆栈页,则可能会出现相同值条件,如果地址位于不同的页中,则在下面的示例中 #include <stdlib.h> int * foo(){ int a = 5; return &a; }int main(){ int i = 0; int k = 0; int n = 0; int * p = foo(); while ( i <10000000) { while (k <10000000) { n= n+1; } n = n-1; } printf("%d\n",*p); //它将打印值5,因为在解开时堆栈内容未被清零 - anshkun
显示剩余3条评论

71

在 C++ 中,你可以访问任何地址,但这并不意味着你应该这样做。你正在访问的地址已经无效了。它能够工作是因为在 foo 返回后没有其他东西破坏了内存,但在许多情况下它可能会崩溃。尝试使用 Valgrind 分析你的程序,甚至只是编译优化,看看...


6
您可能是指您可以尝试访问任何地址。因为现今大多数操作系统都不会让任何程序访问任何地址;有很多保障措施来保护地址空间。这就是为什么不会再出现另一个LOADLIN.EXE的原因。 - v010dya

68

访问无效内存时,并不会抛出 C++ 异常。你只是举了一个引用任意内存位置的一般性例子。我也可以像这样做:

unsigned int q = 123456;

*(double*)(q) = 1.2;

我这里简单地将123456视为double的地址并将其写入。可能会发生以下任何一种情况:

  1. q实际上可能是一个有效的double地址,例如:double p; q = &p;
  2. q可能指向已分配内存中的某个位置,我只是覆盖了其中8个字节。
  3. q指向已分配内存之外,操作系统的内存管理器将向我的程序发送一个分段故障信号,导致运行时终止。
  4. 你中了彩票。

你设置的方式使返回的地址在内存中的一个有效区域中更为合理,因为它可能只是栈中稍微往下一点,但它仍然是一个无效的位置,你不能以确定的方式访问它。

在正常程序执行期间,没有人会自动为您检查像那样的内存地址的语义有效性。但是,内存调试器(例如Valgrind)将乐意执行此操作,因此您应该通过它运行您的程序并观察错误。


11
我现在要写一个程序,让它一直运行这个程序,以便“4) 我赢得彩票”。 - Aidiakapi
常量0xDEADBEEF更加有趣(且不均匀),并且在大多数系统上保证会有一些可见的动作。 - Peter Mortensen

29

你编译程序时启用了优化器吗?foo()函数非常简单,可能已被内联或替换为生成的代码。

但我同意Mark B的观点,结果的行为是未定义的。


那是我的赌注。优化器取消了函数调用。 - Erik Aronesty
10
没必要。因为在foo()之后没有调用新的函数,所以该函数的本地堆栈框架仅仅还没有被覆盖。在foo()之后添加另一个函数调用,那个5就会被改变... - Tomas
我使用GCC 4.8运行了该程序,将cout替换为printf(并包括stdio)。正确地发出警告:“warning: address of local variable ‘a’ returned [-Wreturn-local-addr]”。没有优化时输出58,使用-O3输出08。奇怪的是,P确实有一个地址,尽管它的值为0。我预期地址为NULL(0)。 - Kevin

24

你的问题与作用域无关。在你展示的代码中,函数main看不到函数foo中的变量名,所以你不能在foo外直接使用this名字访问a

你遇到的问题是为什么程序在引用非法内存时没有发出错误信号。这是因为C++标准并没有明确规定非法内存和合法内存之间的很清晰的界限。有时从弹出的堆栈中引用某些东西会导致错误,有时则不会。这取决于环境。不要依赖这种行为。当你编程时,应该假设它总是会产生错误,但当你进行调试时,则应该假设它永远不会发出错误信号。


1
我记得从一本旧的《Turbo C Programming for the IBM》副本中,我曾经玩过一些,其中详细描述了直接操作图形内存和IBM文本模式视频内存布局。当然,那时候,代码运行的系统清楚地定义了写入这些地址的含义,只要你不担心可移植性到其他系统,一切都很好。如果我没记错的话,该书中void指针是一个常见主题。 - user
@Michael Kjörling:当然!人们偶尔喜欢做些肮脏的工作 ;) - Chang Peng

21

注意所有的警告,不要只解决错误。

GCC 显示了这个 警告

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

这就是 C++ 的威力。你应该关心内存。使用 -Werror 标志,这个警告将变成一个错误,现在你必须进行调试。


这是最实用的答案。将默认编译器标志视为“兼容性模式”。除非处理遗留代码,否则不要使用此模式。相反,打开警告(“-Werror -Wall -Wextra”是一个很好的开始)。此外,如果您不确定程序是否正确,请添加运行时检查“-fsanitize = address,undefined”像这样 - John McFarlane

20

这是因为自从放置了变量a之后,栈还没有被改变(但现在还没有)。在再次访问a之前,调用一些其他函数(这些函数也在调用其他函数),你可能就不那么幸运了... ;-)


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接