栈里面有什么?

11

如果我运行一个程序,就像

#include <stdio.h>
int main(int argc, char *argv[], char *env[]) {
  printf("My references are at %p, %p, %p\n", &argc, &argv, &env);
}

我们可以看到那些区域实际上是在堆栈中的。 但还有什么?如果我们在Linux 3.5.3中运行一个循环(例如,直到segfault),我们可以看到一些奇怪的数字,以及由一堆零分隔的两个区域,可能是为了防止意外覆盖环境变量。

无论如何,在第一个区域中必须有很多数字,例如每个函数调用的所有帧。

我们如何区分每个帧的结尾,在哪里是参数,编译器添加的canary在哪里,返回地址,CPU状态等等?


我们能否在这里保持评论主题相关。谢谢。 - Kev
4个回答

5
没有关于覆盖层的知识,你只会看到一些片段或数字。虽然某些区域受机器特定性影响,但许多细节是相当标准的。
如果你没有远离嵌套程序,那么你可能正在查看内存中的调用堆栈部分。使用一些通常被认为是“不安全”的C语言,你可以编写有趣的函数,访问几个“调用”之前的函数变量,即使这些变量在源代码中没有“传递”给该函数。
调用堆栈是一个很好的起点,因为第三方库必须能够被尚未编写的程序调用。因此,它相当标准化。
超出进程内存边界将导致可怕的分段违规,因为内存围栏将检测到进程试图访问非授权内存。在具有内存分段功能的系统上,Malloc不仅“只”返回指针,还将“标记”内存可供该进程访问,并检查进程分配的所有内存访问是否违反了规定。
如果您一直沿着这条路走,迟早会对内核或对象格式产生兴趣。在Linux中,源代码可用,因此更容易研究事物的执行方式之一。有源代码可以避免通过查看二进制文件来反向工程数据结构。起初,难点在于学习如何找到正确的头文件。稍后,将学习如何探索并可能更改在非调试条件下应该不更改的东西。
PS. 您可能会考虑将此内存称为“堆栈”,但是过一段时间后,您会发现实际上它只是一个大的可访问内存块,其中一部分被认为是堆栈...

那么,您是说从&first_argument开始的所有内容都可能不仅仅是堆栈吗?调用堆栈之后呢?或者,我们应该如何称呼它? - ssice
1
如果你在内存中不断地走动,假设你没有发生段错误并且操作系统为程序分配了一个连续的内存范围(这比一堆块更容易监视),那么你可能会遇到堆。此外,一些实现甚至存储更多数据,以帮助调试器或作为操作系统和进程之间的通信渠道。 - Edwin Buck
1
请注意,通过指针访问内存并具备一些知识可以使子程序执行语言不支持的操作。这就是为什么Java放弃了指针而采用引用,以防止访问/修改私有变量等。 - Edwin Buck
1
虽然一些操作系统在加载程序之前可能会将内存清零,但不要忘记C库很可能会在调用main()之前调用一整套的初始化子例程。因此,即使您的起始堆栈也很可能已经被混乱了。在C++中情况变得更糟,因为应用程序初始化可能会在调用main()之前发生。 - Gilbert
我认为它不应该是堆,因为除了 #include <stdio.h> 和使用 printf(3) 之外,我没有更多的代码,所以那仍然是栈内存。*argv[] 和 *env[] 存储的位置是否仍在栈中?那么为什么调用栈有这么多空间,如果栈会朝相反的方向增长呢? - ssice

4

栈的内容基本上包括:

  • 操作系统传递给程序的任何东西。
  • 调用帧(也称为堆栈帧、激活区域等)

操作系统向程序传递什么?典型的*nix将传递环境、程序参数、可能的一些辅助信息和指向它们的指针,以传递给main()

在Linux中,你会看到:

  • 一个空指针
  • 程序文件名。
  • 环境字符串
  • 参数字符串(包括argv[0]
  • 填充零
  • auxv数组,用于从内核传递信息给程序
  • 指向环境字符串的指针,以NULL指针结尾
  • 指向参数字符串的指针,以NULL指针结尾
  • argc

接下来,在这之下是堆栈帧,包含:

  • 参数
  • 返回地址
  • 可能是旧的帧指针值
  • 可能是canary
  • 局部变量
  • 为了对齐而填充的一些内容

如何在每个堆栈帧中知道哪个是哪个?编译器知道,因此它只需适当地处理其在堆栈帧中的位置。如果可用,调试器可以使用每个函数的注释形式的调试信息。否则,如果有框架指针,则可以相对于它来标识事物:局部变量在框架指针下方,参数在堆栈指针上方。否则,必须使用启发式方法,类似代码地址的东西可能是代码地址,但有时这会导致不正确和令人讨厌的堆栈跟踪。


3
堆栈的内容取决于体系结构ABI、编译器,以及可能存在的各种编译设置和选项。
一个好的起点是针对目标体系结构发表的ABI,然后检查您的特定编译器是否符合该标准。最终,您可以分析编译器的汇编输出或在调试器中观察指令级操作。
还要记住,编译器不需要初始化堆栈,并且在完成后肯定不会“清除它”,因此当它被分配给进程或线程时,它可能包含任何值-即使在通电时,SDRAM也不会包含任何特定或可预测的值,如果物理RAM地址自通电以来已被另一个进程使用或甚至是同一进程中早期调用的函数,则其内容将保留那个进程保留的值。 因此,仅查看原始堆栈并不能告诉您太多信息。
通常,通用堆栈帧可能包含控制返回时跳转到的地址,传递的所有参数的值,以及函数中所有自动局部变量的值。 但是,例如ARM ABI将前四个参数通过R0到R3寄存器传递给函数,并将leaf函数的返回值保存在LR寄存器中,因此并非所有情况都像我建议的“典型”实现那样简单。

我们至少可以说,堆栈帧上方的所有内容都将被清理,否则它就毫无意义。而且,任何在其上方的内容可能甚至不会被寻址到当前进程,因此总是说我们可以查看它是不正确的,因为特定地址可能尚未有物理映射。 - ssice
@ssice:当然有区别,栈本身是完全有意义且大小可变的,而栈空间则是栈增长和缩小的空间。这一区别在我的回答中并不清晰。 - Clifford

2
细节非常依赖于您的环境。操作系统通常定义ABI,但实际上只对系统调用执行强制执行。
每种语言(即使是编译相同语言的每个编译器)实际上可能会有所不同。
然而,至少在与动态加载库进行交互的意义上,存在某种系统范例。
但是,细节差异很大。
一个非常简单的“入门指南”可以参考http://kernelnewbies.org/ABI 一个非常详细和完整的规范,您可以查看以了解定义ABI所涉及的复杂性和细节水平,是“System V Application Binary Interface AMD64 Architecture Processor Supplement” http://www.x86-64.org/documentation/abi.pdf

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