是否可以在其范围之外访问局部变量的内存?


1027

我有以下代码。

#include <iostream>

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

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

并且代码仅在没有运行时异常的情况下运行!

输出是 58

怎么会这样?局部变量的存储不是在其功能之外不可访问的吗?


14
这甚至不会按原样编译;如果您修复了不良业务,gcc仍会发出警告address of local variable ‘a’ returned;valgrind节目Invalid write of size 4 [...] Address 0xbefd7114 is just below the stack ptr
sehe 2011年

76
@Serge:在我年轻的时候,我曾经在Netware操作系统上运行过一些棘手的零环代码,该代码涉及巧妙地在堆栈指针周围移动,而这种方式并未得到操作系统的完全认可。我会知道什么时候犯错了,因为通常堆栈最终会覆盖屏幕内存,并且我只能看到字节被正确地写到了显示器上。这些天你无法摆脱这种事情。
埃里克·利珀特

23
大声笑。在理解问题所在之前,我需要阅读问题和一些答案。这实际上是关于变量的访问范围的问题吗?您甚至不在函数外使用“ a”。这就是全部。抛出一些内存引用与变量作用域是完全不同的主题。
erikbwork

10
重复答案并不意味着重复问题。人们在这里提出的许多欺骗性问题是完全不同的问题,碰巧是指相同的潜在症状……但是发问者已经知道了一种方式,因此应该保持公开状态。我关闭了一个较早的假人,并将其合并到这个问题中,该问题应该保持开放,因为它有很好的答案。
Joel Spolsky

16
@Joel:如果这里的答案很好,应该将其合并为较老的问题,这是一个重复,而不是相反。这个问题确实是这里提出的其他问题的重复,然后是其他一些问题的伪装(即使其中一些提出的问题比其他提出的问题更适合)。请注意,我认为Eric的答案很好。(实际上,我将这个问题标记为将答案合并到一个较旧的问题中,以挽救这些较旧的问题。)
2011年

Answers:


4796

怎么会这样?局部变量的存储不是在其功能之外不可访问的吗?

您租了旅馆房间。您将一本书放在床头柜的顶部抽屉中,然后入睡。您第二天早上退房,但是“忘记了”退还您的钥匙。您偷了钥匙!

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

怎么可能?如果您没有租房,不是不是无法进入酒店房间抽屉的内容吗?

好吧,显然,这种情况可以在现实世界中发生,没有问题。当您不再被授权进入房间时,没有任何神秘的力量会使您的书消失。也没有一种神秘的力量阻止您使用失窃的钥匙进入房间。

不需要酒店管理人员删除您的书。您没有与他们订立合同,说如果您留下东西,他们会为您切碎。如果您使用偷来的钥匙非法重新进入房间以将其取回,则无需酒店安全人员抓到您潜行。您没有与他们订立合同,说“如果我尝试潜入我的房间,房间过后,您必须阻止我。” 相反,您与他们签订了一份合同,上面写着“我保证以后不会再潜入我的房间”,这是您违反的合同。

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

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

C ++不是安全的语言。它将乐意让您打破系统规则。如果您尝试做一些非法和愚蠢的事情,例如回到没有权限的房间,或者翻阅一张可能根本不在那儿的桌子,那么C ++不会阻止您。比C ++更安全的语言通过限制您的力量来解决此问题-例如,通过对键进行更严格的控制。

更新

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

我认为用一些其他技术思想来对此进行更新可能是很紧要的。

编译器负责生成代码,该代码管理该程序处理的数据的存储。生成代码来管理内存的方法有很多,但是随着时间的流逝,已经确立了两种基本技术。

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

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

局部变量遵循后一种模式;输入方法后,其局部变量将生效。当该方法调用另一个方法时,新方法的局部变量将生效。在第一个方法的局部变量失效之前,它们将失效。可以提前确定与局部变量关联的存储生命周期的开始和结束的相对顺序。

由于这个原因,局部变量通常作为“堆栈”数据结构上的存储生成,因为堆栈具有的属性是,首先要压入的堆栈将是最后弹出的堆栈。

就像酒店决定只按顺序出租房间一样,只有在房间号高于您所选择的每个人之前,您都无法退房。

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

我们将堆栈用于临时存储,因为它们确实便宜又容易。不需要使用C ++的实现就可以使用堆栈来存储本地对象;它可以使用堆。事实并非如此,因为那会使程序变慢。

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

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

取而代之的是,实现使您犯错并摆脱错误。大多数时候。直到一天,真正可怕的事情出了问题,整个过程爆炸了。

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

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

进一步阅读:


55
@muntoo:不幸的是,这并不像操作系统在解除或分配虚拟内存页面之前发出警报声。如果您在不再拥有该内存的情况下对其进行处理,那么当您触摸释放的页面时,操作系统完全有权删除整个过程。繁荣!
埃里克·利珀特

81
@Kyle:只有安全的酒店才能做到这一点。不安全的酒店不必浪费时间在编程钥匙上,从而获得可观的利润。
2011年

496
@cyberguijarro:C ++不是内存安全的事实。这不是在“扑扑”任何东西。例如,我是否曾说过:“ C ++混杂在脆弱,危险的内存模型之上,是一种未指定的,过于复杂的功能,我每天都为自己的理智而不再为此工作而心存感激”,那将打击C ++。指出不安全的原因是在解释原始海报出现此问题的原因。它正在回答问题,而不是编辑。
埃里克·利珀特

48
严格来说,类比应该提到酒店的接待员很高兴您带上钥匙。“哦,你介意我带上这个钥匙吗?” “继续。我为什么要在乎?我只在这里工作”。除非您尝试使用它,否则它不会成为非法。
philsquared 2011年

138
请,请至少考虑有一天写一本书。即使只是修订和扩展的博客文章的集合,我也愿意购买,而且我相信很多人也会这样做。但是,如果您对各种编程相关问题有独到的见解,那将是一本好书。我知道很难找到时间,但是请考虑写一个。
Dyppl

275

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

当你从 foo,您告诉操作系统您不再使用该内存,可以将其重新分配给其他内存。如果您很幸运,但是它从未被重新分配,并且操作系统没有抓住您再次使用它的机会,那么您将可以摆脱谎言。尽管您最终可能会写满以该地址结尾的其他内容,但很有可能。

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

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


151

因为存储空间尚未增加。不要指望这种行为。


1
男人,那是最长的等待评论的时间,因为“什么是真相?开玩笑说彼拉多。” 也许那是旅馆抽屉里的基甸圣经。他们到底发生了什么?请注意,它们至少在伦敦不再存在。我想根据平等法律,您将需要一个图书馆。
罗伯·肯特

我本来可以很早就宣誓就写过,但是最近突然出现了,发现我的回答不存在。现在,我必须找出上面的暗示,因为我希望当我这样做时会感到好笑。<
msw 2015年

1
哈哈。弗朗西斯·培根(Francis Bacon)是英国最伟大的散文家之一,有人怀疑莎士比亚的戏剧是他写的,因为他们不能接受来自该国的一个语法学校孩子(一个戴着手套的人)可能是一个天才。这就是英语课堂系统。耶稣说:“我就是真理”。oregonstate.edu/instruct/phl302/texts/bacon/bacon_essays.html
Rob Kent

83

对所有答案的补充:

如果您这样做:

#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()重用。如果您对可执行文件进行反汇编,则会清楚看到它。


2
理解底层堆栈理论的简单但出色的示例。只需添加一个测试,声明“ int a = 5;”即可。在foo()中为“ static int a = 5;” 可用于了解静态变量的范围和寿命。
控制

15
-1“ for 可能为7 ”。编译器可能会在boo中注册a。可能会删除它,因为它是不必要的。* p很有可能不会为5,但这并不意味着有特别好的理由使它可能为7
马特

2
这称为未定义行为!
弗朗西斯·库格勒

为什么以及如何boo重用foo堆栈?函数堆栈不是彼此分开的,我在Visual Studio 2015上运行此代码也很垃圾
ampawd '16

1
@ampawd已经快一年了,但是没有,“功能栈”没有彼此分开。上下文具有堆栈。该上下文使用其堆栈进入main,然后下降为foo(),存在,然后下降为boo()Foo()并且Boo()都在同一位置堆栈指针进入。但是,这不是应该依赖的行为。其他“东西”(如中断,或操作系统)可以使用的调用之间的堆栈boo()foo(),修改它的内容...
拉斯·舒尔茨

71

在C ++中,您可以访问任何地址,但这并不意味着您应该。您正在访问的地址不再有效。它的工作原理,因为没有别的炒内存FOO后返回,但它可能在很多情况下崩溃。尝试使用Valgrind分析您的程序,甚至只是对其进行优化编译,然后查看...


5
您可能是说您可以尝试访问任何地址。因为当今大多数操作系统都不允许任何程序访问任何地址;有大量的保护措施来保护地址空间。这就是为什么那里不会再有另一个LOADLIN.EXE的原因。
v010dya 2015年

66

您永远不会通过访问无效的内存而引发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会很乐意这样做,因此您应该通过它运行程序并见证错误。


9
我现在要编写一个程序,继续运行该程序,以便4) I win the lottery
Aidiakapi 2015年

28

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

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


那是我的赌注。优化程序转储了函数调用。
Erik Aronesty,2011年

9
那是没有必要的。由于在foo()之后没有调用新函数,因此该函数的本地堆栈帧尚未完全被覆盖。在foo()之后添加另一个函数调用,5将会更改...
Tomas

我使用GCC 4.8运行了该程序,用printf(包括stdio)替换了cout。正确地警告“警告:返回[-Wreturn-local-addr]的本地变量'a'的地址”。没有优化的输出58和-O3的输出08。奇怪的是,即使P的值为0,它也确实具有地址。我希望NULL(0)作为地址。
凯文夫'17

22

您的问题与范围无关。在你展示的代码,该功能main并没有看到在函数的名称foo,所以你不能访问afoo中直接与名之外foo

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


我回想起IBMTurbo C编程的旧版本,我曾经详细回顾过如何详细描述如何直接操纵图形内存以及IBM文本模式视频内存的布局。当然,然后,运行代码的系统会清楚地定义写入这些地址的含义,因此,只要您不必担心可移植到其他系统的问题,一切都会很好。IIRC,指向无效的指针是该书中的常见主题。
的CVn

@MichaelKjörling:好的!人们喜欢偶尔做一些肮脏的工作;)
Chang Peng

17

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

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

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
}

我不同意:之前有问题cout*a指向未分配(释放)的内存。即使您不取消引用它,它仍然是危险的(并且可能是虚假的)。
ereOn 2010年

@ereOn:我进一步阐明了问题的含义,但是不,对于有效的c ++代码而言,这并不危险。但这很危险,因为用户可能会犯错,并且会做不好的事情。举例来说,也许您正在尝试查看堆栈的增长方式,而您只关心地址值,而永远不会取消引用它。
Brian R. Bondy

17

这是经典的未定义行为,前两天在这里进行了讨论-在站点周围进行搜索。简而言之,您很幸运,但是任何事情都可能发生,并且您的代码使对内存的访问无效。


17

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


17

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

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

这是C ++的强大功能。您应该关心内存。使用该-Werror标志,此警告变为错误,现在您必须对其进行调试。


16

之所以起作用,是因为自将a放入堆栈以来,堆栈尚未更改(但尚未更改)。在a再次访问之前调用一些其他函数(也在调用其他函数),您可能不会再那么幸运了……;-)


15

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

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

因此,您没有修改a,而是修改了a曾经的存储位置。这种差异与崩溃与不崩溃之间的差异非常相似。


13

在典型的编译器实现中,您可以将代码视为“ 用以前被a占用的地址打印出内存块的值”。另外,如果您向包含局部函数的函数添加新的函数调用,int则很有可能a(或a更改用于指向)的值。发生这种情况是因为堆栈将被包含不同数据的新帧覆盖。

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


3
用以前被a占用的地址打印出内存块的值”不是很正确。这听起来像他的代码具有一些明确定义的含义,事实并非如此。没错,尽管这可能是大多数编译器将其实现的方式。
布伦南·文森特

@BrennanVincent:当存储空间被占用时a,指针保留的地址a。尽管该标准不要求实现在其目标生命周期结束后定义地址的行为,但该标准还认识到在某些平台上,UB是按照环境特征的书面方式进行处理的。尽管局部变量的地址超出范围后通常不会有太大用处,但某些其他类型的地址在其各自目标的生存期之后可能仍然有意义。
超级猫

@BrennanVincent:例如,尽管标准可能不要求实现允许将要传递的指针realloc与返回值进行比较,也不允许对指向旧块内地址的指针进行调整以指向新的指针,但是某些实现却这样做,并且利用这种功能的代码可能比必须避免采取任何行动(甚至是比较)的代码(包括涉及指向分配给该指针的代码)的效率更高realloc
超级猫

13

因为a是在其作用域(foo函数)的生存期内临时分配的变量,所以它可以。从foo内存中返回后是免费的,可以被覆盖。

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


11

如果使用:: 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); 
}

4

从函数返回后,所有标识符都会被破坏,而不是将值保留在内存位置中,并且如果没有标识符,我们将无法定位这些值。但是该位置仍然包含先前函数存储的值。

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

让我以现实世界为例:

假设某人将钱藏在某个地点,然后告诉您该地点。一段时间后,告诉您钱款所在地的那个人去世了。但是您仍然可以使用这些隐藏的资金。


3

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


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分配内存到局部函数
  • 当功能到期时,该内存地址也仍然有效(任何人都不会删除它)
  • 内存地址是可信任的(该内存块不被认为是空闲的,因此在被删除之前不会被覆盖)
  • 不使用时应删除内存地址。(请参阅程序末尾的删除)

您是否添加了现有答案尚未涵盖的内容?并且请不要使用原始指针/ new
轻轨赛

1
询问者使用原始指针。我做了一个例子,它恰好反映了他所做的例子,以便让他看到不可信指针和可信指针之间的区别。实际上,还有另一个类似于我的答案,但是它对新手程序员来说,使用strcpy夹,恕我直言,可能比我的使用new的示例不清楚。
Nobun

他们没有使用new。您正在教他们使用new。但是您不应该使用new
轻轨赛

因此,您认为将地址传递给函数中被破坏的局部变量比实际分配内存更好?这是没有道理的。恕我直言,了解分配e分配内存的概念很重要,恕我直言,主要是在询问指针时(问问者未使用新指针,而是使用了指针)。
Nobun

我什么时候说的 不,最好使用智能指针正确指示所引用资源的所有权。不要使用new在2019年(除非您正在编写库代码),也不要教新手这样做!干杯。
Lightness Races in Orbit
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.