为什么递归调用在不同的堆栈深度会导致堆栈溢出错误?
为什么递归调用在不同的堆栈深度会导致堆栈溢出错误?
我试图亲自了解C#编译器如何处理尾调用。
(答案是:它们不会进行优化,但64位JIT(s)将进行尾调用消除。 有限制。)
因此,我编写了一个小测试,使用递归调用并打印在StackOverflowException
终止进程之前调用了多少次。
class Program { static void Main(string[] args) { Rec(); } static int sz = 0; static Random r = new Random(); static void Rec() { sz++; //取消注释以获得更快、更不准确的运行 //if (sz % 100 == 0) { //用于防止此方法被内联的一些代码 var zz = r.Next(); Console.Write("{0} Random: {1}\r", sz, zz); } //取消注释会阻止尾调用消除 //else //{ // Console.Write("{0}\r", sz); //} Rec(); }
准时地,程序在以下情况下以SO异常结束:
- 关闭“优化生成”(无论是Debug还是Release)
- 目标:x86
- 目标:AnyCPU + “首选32位”(这是VS 2012中的新功能,也是我第一次看到。 更多信息请查看。)
- 代码中的某些看似无害的分支(请参见已注释的“else”分支)。
相反,使用“优化生成”+(目标= x64或具有关闭“首选32位”的AnyCPU(在64位CPU上)),尾调用消除会发生,并且计数器将无限增加(好吧,可以说每次值溢出时会减小)。
但我注意到一个无法解释的行为在StackOverflowException
的情况下:它永远不会在完全相同的堆栈深度发生(可能)。以下是几次32位运行的输出,Release生成:
51600 Random: 1778264579 由于StackOverflowException终止进程。 51599 Random: 1515673450 由于StackOverflowException终止进程。 51602 Random: 1567871768 由于StackOverflowException终止进程。 51535 Random: 2760045665 由于StackOverflowException终止进程。
和Debug生成:
28641 Random: 4435795885 由于StackOverflowException终止进程。 28641 Random: 4873901326 //永远不要说永远 由于StackOverflowException终止进程。 28623 Random: 7255802746 由于StackOverflowException终止进程。 28669 Random: 1613806023 由于StackOverflowException终止进程。
堆栈大小是恒定的(默认为1MB)。堆栈帧的大小是恒定的。
那么,当StackOverflowException
发生时,堆栈深度的(有时非凡的)变化可以由什么解释?
更新
Hans Passant提出了Console.WriteLine
触及P/Invoke、互操作和可能是非确定性锁定的问题。
因此,我将代码简化为以下内容:
class Program { static void Main(string[] args) { Rec(); } static int sz = 0; static void Rec() { sz++; Rec(); } }
我在Release/32bit/Optimization ON的情况下运行它,没有调试器。当程序崩溃时,我附加调试器并检查计数器的值。
然而,即使在多次运行中,它的值仍然不同(或者我的测试有缺陷)。
更新:封闭
正如fejesjoco建议的那样,我研究了ASLR(地址空间布局的随机化)。
这是一种安全技术,通过随机化进程地址空间中的各种内容,包括堆栈位置和大小,使得缓冲区溢出攻击难以找到(例如)特定系统调用的精确位置。
理论听起来很好。让我们实践一下吧!
为了测试这一点,我使用了一个专门用于此任务的微软工具:EMET或Enhanced Mitigation Experience Toolkit。它允许在系统或进程级别设置ASLR标志(还有更多)。
(还有一个系统范围的注册表修改替代方案,我没有尝试)
为了验证工具的有效性,我还发现Process Explorer在进程的“属性”页面上报告了ASLR标志的状态。直到今天我才看到这个 🙂
理论上,EMET可以在单个进程中(重新)设置ASLR标志。实际上,它似乎没有改变任何事情(见上图)。
但是,我为整个系统禁用了ASLR,并且(重启后)我终于验证了确实,SO异常现在总是在相同的堆栈深度发生。
奖励
与ASLR相关的较旧新闻:Chrome是如何被攻破的
为什么递归调用会在不同的堆栈深度上导致堆栈溢出?这个问题出现的原因是由于递归调用导致堆栈空间不足,无法继续压入新的函数调用。解决方法是通过优化代码,减少递归调用的深度或者使用迭代替代递归。下面的内容可以整理成一篇文章:
递归调用在不同的堆栈深度上导致堆栈溢出的原因是由于每次递归调用都会将新的函数调用压入堆栈中,如果递归调用的深度过大,堆栈空间就会不足,无法继续压入新的函数调用。即使使用不同的随机种子或移除随机函数,问题仍然存在。
解决方法之一是通过优化代码,减少递归调用的深度。可以尝试使用迭代的方式替代递归,避免堆栈溢出的发生。另一种解决方法是增加堆栈空间的大小,可以通过调整堆栈大小的方式来解决问题。
下面是一些实际案例中的观点:
- 使用不同的随机种子或移除随机函数并不会解决问题,递归调用仍然会在不同的堆栈深度上导致堆栈溢出。
- 在某些环境中,递归调用可能正常工作,但在另一些环境中却不起作用,这可能与操作系统、开发工具版本和.NET框架版本等因素有关。
- 在Windows XP操作系统上,由于没有ASLR(地址空间布局随机化)功能,堆栈深度的随机性可能不够,导致问题不随机。
- 堆栈上保存的是指向堆上分配的字符串的指针,而不是字符串本身。因此,即使字符串的长度不同,堆栈上的指针数量和大小仍然是相同的。
总之,递归调用导致堆栈溢出的原因是堆栈空间不足,解决方法包括优化代码、减少递归调用深度、使用迭代替代递归和增加堆栈空间的大小。不同的环境和系统可能会影响问题的出现。
为什么递归调用在不同的堆栈深度会导致堆栈溢出?这个问题的出现原因是在于堆栈在页面内的起始地址被随机化,这在安全性方面是没有意义的。从性能的角度来看也是没有意义的,因为在SMT系统上无法发生高速缓存行别名,因为现在的高速缓存具有足够的关联性。
然而,有很多攻击需要知道堆栈到其他内存的确切偏移量。使这些偏移量更不可预测是一个胜利。如果不随机化低12位,那么在47位用户空间中仍然有35位的随机化,这使得使用任意堆栈地址的攻击变得不可能。
但是,你假设指向堆内存的指针的熵是唯一重要的安全问题。你是否在说使堆栈对齐不可预测永远不会有任何价值来减轻攻击?或者你担心这样做的任何价值都被实现的成本所抵消?你确定没有任何假设攻击可以对堆栈内存进行操作,但需要对齐的知识吗?
因为在堆栈递归调用时,每次调用都会在堆栈上分配一定的内存空间。随着递归的深入,堆栈的大小会越来越大,最终超过了堆栈的容量,导致堆栈溢出。解决该问题的方法是增大堆栈的容量,以便可以处理更深层次的递归调用。
对于C++11代码,可以通过更改系统的堆栈大小来解决堆栈溢出问题。可以使用ulimit命令来增加堆栈的软限制和硬限制。例如,可以运行命令"ulimit -s unlimited"来将堆栈限制设置为无限制。
对于Linux代码,可以使用pthread_attr_setstacksize函数来设置线程的堆栈大小。可以通过增加堆栈的大小来解决堆栈溢出的问题。例如,可以使用pthread_attr_setstacksize(&attr, stacksize)来设置堆栈的大小。
通过增加堆栈的容量,可以有效地解决递归调用导致的堆栈溢出问题。这将确保堆栈有足够的空间来处理更深层次的递归调用,从而避免堆栈溢出。
为什么递归调用会导致不同堆栈深度下的堆栈溢出?
在这个问题中,递归调用导致堆栈溢出的原因可能是ASLR的作用。你可以关闭DEP来测试这个理论。
在这里可以找到一个C#实用类来检查内存信息: https://stackoverflow.com/a/8716410/552139
顺便说一下,通过这个工具,我发现最大堆栈大小和最小堆栈大小之间的差异约为2 KiB,即半页的大小。这很奇怪。
更新:好吧,现在我知道我是对的。我对半页理论进行了跟进,并找到了这个检查Windows中ASLR实现的文档: http://www.symantec.com/avcenter/reference/Address_Space_Layout_Randomization.pdf
引用:
一旦堆栈被放置好,初始堆栈指针会被随机减少。初始偏移量被选择为半页大小(2,048字节)。
这就是你问题的答案。ASLR会随机取走初始堆栈大小的0到2048字节。
我以前从未听说过ASLR。到目前为止我喜欢它,因为我学到了新的东西。明天会进行测试。
:Symantec研究确切地说,堆栈会随机偏移一个随机量,最多半页大小,因此实际上堆栈大小会减小。
好的,我确实更喜欢你的解释。
ASLR会随机选择页面,而不是页面内的位。页面内的起始偏移量由用户空间选择。这个选择是没有意义的。