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

Avi*_*ron 990 c++ memory-management local-variables dangling-pointer

我有以下代码.

#include <iostream>

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

int main()
{
    int* p = foo();
    std::cout << *p;
    *p = 8;
    std::cout << *p;
}
Run Code Online (Sandbox Code Playgroud)

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

输出是 58

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

Eri*_*ert 4751

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

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

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

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

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

酒店管理层无需删除您的图书.你没有与他们签订合同,如果你留下东西,他们会为你粉碎它.如果您用偷来的钥匙非法重新进入您的房间以便将其取回,酒店保安人员无需让您偷偷溜进去.您没有与他们签订合同,说"如果我试图潜入我的房间以后,你需要阻止我." 相反,你和他们签了一份合同,上面写着"我保证不会再偷回我的房间",这是你破坏的合同.

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

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

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

UPDATE

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

我认为通过一些技术性的想法更新这一点可能是密切相关的.

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

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

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

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

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

这就像酒店决定只按顺序出租房间,在房间号码高于您的所有人都检查出来之前,您不能退房.

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

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

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

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

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

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

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

进一步阅读:

  • @cyberguijarro:C++不是内存安全只是一个事实.这不是"抨击"任何事情.例如,如果我说过,"C++是一个可怕的混乱,在一个脆弱的,危险的记忆模型之上堆积的指定不足,过于复杂的特征,我感谢每一天我不再为了自己的理智而工作",那会抨击C++.指出它不是内存安全的*解释*为什么原始海报看到这个问题; 它正在回答这个问题,而不是编辑. (492认同)
  • 请至少考虑一天写一本书.我会买它,即使它只是一个修改和扩展的博客文章的集合,我很肯定会有很多人.但是,一本关于各种与编程相关的事情的原创思想的书将是一本很好的读物.我知道很难找到时间,但请考虑写一个. (137认同)
  • @Kyle:只有安全的酒店才能这样做.不安全的酒店可以获得可衡量的利润收益,而不必浪费时间在编程密钥上. (81认同)
  • @muntoo:不幸的是,它不像操作系统在取消释放或释放一页虚拟内存之前发出警告警报.如果您不再拥有该内存,那么当您触摸已解除分配的页面时,操作系统完全有权取消整个过程.繁荣! (54认同)
  • 严格来说,类比应该提到酒店的接待员很高兴你能带上钥匙."哦,你介意我带上这把钥匙吗?" "来吧.我为什么要关心?我只在这里工作".在您尝试使用它之前,它不会成为非法的. (46认同)
  • @Eric:在这方面,C#(真的,.NET)并不"安全".我可以组合`Math.Random`,`IntPtr`和`Marshal.Copy`并导致总混乱(不需要`unsafe`关键字或`/ unsafe`编译器开关).安全来自于遵守合同,而不是来自语言设计(尽管语言可以而且应该以尽可能简单的方式编写符合合同的编码,并在合同被违反时提供警告.) (29认同)
  • @Bitmap:LOGO非常安全. (29认同)
  • @cyberguijarro我不认为他最终会抨击C++.C++并不安全,正如你所说,在很多情况下这是一件好事.同样,更安全的语言不那么强大,但可以更容易使用.他们只是不同. (27认同)
  • @Thaddee:首先,有许多实用语言是内存安全的.但是,C++的问题并不在于它不安全.问题在于*很容易*偶然*做一些非常不安全的事情并且没有意识到你这样做直到你让最终用户的机器崩溃.内存不安全的语言通常非常有用,我同意,但是应该有一种方法可以将这种不安全性与那些真正需要它的"棘手,黑客"代码隔离开来. (23认同)
  • @Dyppl:谢谢你的客气话.已经写了几本书我很清楚这是多少工作!我曾考虑将博客变成一本书,如果我能找到时间和自愿的出版商,我可能会在某个时候. (16认同)
  • @Ben:@ShuggyCoUk是对的; 如果你滥用它们,那么库函数会做一些可怕的事情是这些库函数的属性,而不是C#语言.C#*语言*是内存安全和类型安全的,前提是你没有"不安全"的代码块.如果你这样做,那么它就像C++一样对内存不安全.关键是将记忆不安全的区域与易于识别和彻底审查的区域隔离开来. (13认同)
  • 这是一个很好的类比,但最后抨击C++并不行.C++并没有施加太多限制,但是缺乏限制通常会带来可衡量的性能提升. (12认同)
  • @Deji:你的精神力量比我强大得多; 我不知道原始海报在想什么. (10认同)
  • @Kyle Cronin你的观点只会进一步推断这个比喻.回到C++发明时,酒店的可编程卡密钥不太常见甚至不存在.较新的酒店自然采用更安全的做法,新语言也是如此.即使是较旧的酒店也已经改装了新锁,C++也是如此(智能指针是谁?) (9认同)
  • @meet:你应该问一下C++设计专家的这些问题; 我不想推测C++语言设计者的动机.我会注意到"阻止用户做一些愚蠢的事情"似乎并没有被C设计师认为令人钦佩的特征列表中的太高. (9认同)
  • 如果酒店即将被一个足球场所取代,你不会注意到缺少人吗?还是外面巨大的推土机的巨大军队? (7认同)
  • @Ben well duh,显然有一些方法变得不安全,其中包括标记为此类的库函数(因此如果需要,权限启动).如果某人使用库函数执行LOGO实现,允许intptr道德等价物,那么它也会因您的指标而停止安全. (7认同)
  • 我喜欢这个类比,但几乎所有酒店都使用可编程钥匙卡,这些钥匙卡在指定时间锁定,或者当为该房间发出新钥匙时(以先到者为准).我想,很少有不使用这种系统的酒店会非常坚持你在结账时退还钥匙. (6认同)
  • @VikasVerma:一些内存管理器在内存不再可用时故意破坏内存.例如,Microsoft C运行时的调试版本将未使用的内存设置为"0xCC",因为(1)在调试器内存窗口中很容易看到特定的内存块现在不再有效,并且(2)那就是"打入调试器"指令代码; 如果破碎的内存被*执行*则调试器将被激活. (6认同)
  • Eric:这个问题可能会增加流量,因为它是黑客新闻的最高职位:http://news.ycombinator.com/item?id = 2686580.无论如何,在24小时内1100投票?!到目前为止,这肯定是一个记录. (5认同)
  • @chosentorture用科学回答你的问题.获得几百个c编译器,并尝试各自的几百个不同的配置,很快您将获得优秀的经验数据.别的什么都在猜测. (5认同)
  • 我不得不同意@Dyppl,我想读一本你写的书.除了由jOOQ工作人员或Josh Bloch/Goetz撰写的一些博客文章/答案之外,您的答案提供了一个非常详细且易于理解的幕后资料/关于编程语言的详细信息 (5认同)
  • @VikasVerma这意味着您的抽屉正在重建. (3认同)
  • @PhilNash:它在那里有点崩溃,因为钥匙通常是酒店的财产。 (2认同)
  • @timo您必须永远不要将地址用于其生命周期已结束的本地.如果你这样做并且它恰好工作了,那么,当你违反规则时,运行时不需要失败.出于某种原因,不安全代码被标记为不安全. (2认同)
  • @ErricLippert 这不是精神力量,而是更熟悉混乱,他使用的例子以及他提出的实际问题。他问记忆是否无法访问,这意味着他可能认为记忆不再存在。两者都是不真实的,内存是可访问的,它确实存在。原因是这些存储方式的不同,这就是为什么我认为“答案”应该在技术层面上关注这一点。 (2认同)
  • @EricLippert:感谢您的精彩回答。您对使用智能指针的 C++11 和 C++14 自由商店管理方式有何看法?我现在可以说现代 C++ 是安全的语言,因为不需要使用删除运算符 (2认同)
  • @meet:对于 C++ 11 和 14 中添加的内容,我绝不是专家,尽管在与专家交谈时,我觉得里面有很多好东西。更一般地说,我很高兴看到 C++ 委员会愿意既大胆又积极,因为他们将语言推向更现代、更不容易出错的东西。 (2认同)
  • @EricLippert:好的。但问题是,如果我做了一些愚蠢的事情,为什么 C++ 不会阻止我?如果当我尝试获取局部变量的地址时编译器给我错误,那不是很好吗?为什么 C++ 为程序员提供了如此多的自由?或者这些是C++继承自C的问题?您的帮助将不胜感激。 (2认同)
  • @Meet 请记住,C++ 是一种通用的propuse 语言。广泛的应用程序(破解工具)需要总内存控制。 (2认同)
  • @EricLippert:C++ 没有定义这种行为,真的。但我认为,如果您在 x86 上运行,该架构保证您可以安全地写入和读取堆栈指针(`esp`)上方最多 128 个字节,而不会冒内存变化的风险。现在,如果编译器不编译任何主动修改该内存的指令(可能是这种情况,因为它只会增加 esp 并在离开函数时跳回),我认为您可以从技术上说,在 x86 上,这是定义的行为。这是真的?对此有什么想法吗? (2认同)
  • @MartijnCourteaux:谁说编译器必须使用esp来确定局部变量的位置?如果特定编译器供应商为特定实现定义了行为,则该行为是*实现定义*。 (2认同)
  • “C++ 不是一种安全的语言”。链锯也不安全,但是,只要你正确使用它们,它们就比替代方案好得多:-)当然,除非替代方案是 Python。 (2认同)
  • @Destructor“但问题是,如果我做了一些愚蠢的事情,为什么 C++ 不会阻止我?当我尝试获取局部变量的地址时,如果编译器给我错误,那不是很好吗?为什么 C++ 为程序员提供了如此多的自由?” 让 C++ 的设计者来回答:https://www.stroustrup.com/bs_faq.html#unsafe (2认同)

Ren*_*ena 271

你在这里做的只是读取和写入曾经是地址的内存a.既然你在外面foo,它只是指向一些随机存储区的指针.事实上,在您的示例中,该内存区域确实存在,此刻没有其他任何内容正在使用它.你不会因为继续使用它而破坏任何东西,而其他任何东西都没有覆盖它.因此,5仍然存在.在一个真实的程序中,该内存几乎可以立即重用,你可以通过这样做来破坏某些东西(尽管这些症状可能要到很晚才出现!)

当您返回时foo,您告诉操作系统您不再使用该内存,并且可以将其重新分配给其他内容.如果你很幸运并且它永远不会被重新分配,并且操作系统不会让你再次使用它,那么你就会逃脱谎言.尽管如此,你最终还是会写完最后的那个地址.

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

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


msw*_*msw 148

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

  • 伙计,这是自“什么是真理?开玩笑的彼拉多说”以来等待评论的最长时间。也许酒店抽屉里有一本吉迪恩的圣经。无论如何,他们到底发生了什么?请注意,他们不再存在,至少在伦敦。我想根据平等立法,你需要一个宗教小册子图书馆。 (2认同)
  • 哈哈。弗朗西斯·培根是英国最伟大的散文家之一,有些人怀疑他写了莎士比亚的戏剧,因为他们无法接受一个来自乡下的文法学校的孩子,一个格洛夫的儿子,可能是一个天才。这就是英语的班级制度。耶稣说:“我就是真理”。http://oregonstate.edu/instruct/phl302/texts/bacon/bacon_essays.html (2认同)

Mic*_*ael 80

所有答案的一点点补充:

如果你做那样的事情:

#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);
}
Run Code Online (Sandbox Code Playgroud)

输出可能是:7

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

  • -1"for will**可能是7**".编译器可能会注册一个boo.它可能会删除它,因为它是不必要的.很有可能*p将**不是5**,但这并不意味着它有任何特别好的理由**它可能是****. (14认同)
  • 理解底层堆栈理论的简单但很好的例子.只需添加一个测试,声明"int a = 5;" 在foo()中为"static int a = 5;" 可用于了解静态变量的范围和生命周期. (2认同)
  • 它被称为未定义的行为! (2认同)

Cha*_*net 68

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

  • 您可能意味着您可以尝试访问任何地址.因为今天的大多数操作系统都不会让任何程序访问任何地址; 有很多保护地址空间的保障措施.这就是为什么不存在另一个LOADLIN.EXE的原因. (4认同)

Ker*_* SB 66

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

unsigned int q = 123456;

*(double*)(q) = 1.2;
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

  • 我现在要编写一个程序,继续运行这个程序,以便'4)我赢得彩票 (9认同)

gas*_*ush 28

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

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

但我与马克B签约,结果是未定义的行为.

  • 这不是必要的.由于在foo()之后没有调用新函数,因此本地堆栈帧的函数根本就没有被覆盖.在foo()之后添加另一个函数调用,并且将更改`5` ... (9认同)

Cha*_*eng 22

您的问题与范围无关.在您显示的代码中,该函数main没有在函数中看到名称foo,因此您无法a在外部使用名称直接访问foo foo.

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


Bri*_*ndy 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
}
Run Code Online (Sandbox Code Playgroud)


sam*_*sam 17

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

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

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


Adr*_*ore 16

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


Ker*_* SB 16

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


AHe*_*lps 16

正如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;
}
Run Code Online (Sandbox Code Playgroud)

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


Ale*_*ler 15

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

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

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


lar*_*moa 13

在典型的编译器实现中,您可以将代码视为" 使用以前占用的地址打印出内存块的值".此外,如果向一个维护本地的函数添加一个新的函数调用int,那么a(或者a用于指向的内存地址)的值很可能会发生变化.发生这种情况是因为堆栈将被包含不同数据的新帧覆盖.

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

  • "用*曾经被*占用的地址打印出内存块的值是不太正确的.这听起来好像他的代码有一些明确定义的含义,但事实并非如此.你是对的,这可能是大多数编译器实现它的方式. (3认同)

lit*_*adv 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); 
}
Run Code Online (Sandbox Code Playgroud)


Ayu*_*yub 7

这是使用内存地址的“肮脏”方式。当您返回一个地址(指针)时,您不知道它是否属于函数的本地范围。这只是一个地址。

现在您调用了“foo”函数,“a”的地址(内存位置)已经分配在应用程序(进程)的(至少目前是安全的)可寻址内存中。

在“foo”函数返回之后,“a”的地址可以被认为是“脏”的,但它就在那里,没有被清理,也没有被程序其他部分中的表达式干扰/修改(至少在这个特定情况下)。

AC/C++ 编译器不会阻止您进行此类“脏”访问(不过,如果您关心的话,它可能会警告您)。您可以安全地使用(更新)程序实例(进程)数据段中的任何内存位置,除非您通过某种方式保护该地址。


小智 5

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

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

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

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