我试图更深入地了解编程语言的低级操作是如何工作的,尤其是它们如何与 OS/CPU 交互。我可能已经阅读了 Stack Overflow 上每个堆栈/堆相关线程中的每个答案,它们都很出色。但是还有一件事我还没有完全理解。
在伪代码中考虑这个函数,它往往是有效的 Rust 代码;-)
fn foo() {
let a = 1;
let b = 2;
let c = 3;
let d = 4;
// line X
doSomething(a, b);
doAnotherThing(c, d);
}
这就是我假设堆栈在 X 行上的样子:
Stack
a +-------------+
| 1 |
b +-------------+
| 2 |
c +-------------+
| 3 |
d +-------------+
| 4 |
+-------------+
现在,我读到的关于堆栈如何工作的所有内容都是它严格遵守 LIFO 规则(后进先出)。就像 .NET、Java 或任何其他编程语言中的堆栈数据类型一样。
但如果是这样的话,那么在第 X 行之后会发生什么?因为很明显,接下来我们需要使用 a
和 b
,但这意味着 OS/CPU (?) 必须先弹出 d
和 c
才能返回到 {1 } 和 b
。但是它会在脚下射击自己,因为它需要在下一行中使用 c
和 d
。
所以,我想知道幕后究竟发生了什么?
另一个相关的问题。考虑我们传递对其他函数之一的引用,如下所示:
fn foo() {
let a = 1;
let b = 2;
let c = 3;
let d = 4;
// line X
doSomething(&a, &b);
doAnotherThing(c, d);
}
根据我的理解,这意味着 doSomething
中的参数本质上指向相同的内存地址,如 foo
中的 a
和 b
。但话又说回来,这意味着在我们到达 a
和 b
之前没有弹出堆栈。
这两种情况让我觉得我还没有完全掌握堆栈的工作原理以及它如何严格遵循 LIFO 规则。
LIFO
意味着您只能在堆栈末尾添加或删除元素,并且您始终可以读取/更改任何元素。
调用栈也可以称为帧栈。
按照 LIFO 原则堆叠的东西不是局部变量,而是整个栈帧(“调用”)函数被调用。局部变量分别与所谓的 function prologue 和 epilogue 中的那些帧一起推送和弹出。
在框架内,变量的顺序是完全未指定的;编译器 "reorder" the positions of local variables inside a frame 适当地优化它们的对齐方式,以便处理器可以尽快获取它们。关键的事实是变量相对于某个固定地址的偏移量在帧的整个生命周期中是恒定的 - 所以取一个锚地址就足够了,比如帧本身的地址,并且使用该地址到变量的偏移量。这样的锚地址实际上包含在所谓的base或帧指针中,它存储在EBP寄存器中。另一方面,偏移量在编译时是清楚的,因此被硬编码到机器代码中。
Wikipedia 中的这张图显示了典型调用堆栈的结构类似于1:
https://i.stack.imgur.com/uiCRx.png
将我们要访问的变量的偏移量添加到帧指针中包含的地址,我们就得到了变量的地址。简而言之,代码只是通过基指针的常量编译时偏移量直接访问它们;这是简单的指针算法。
例子
#include <iostream>
int main()
{
char c = std::cin.get();
std::cout << c;
}
gcc.godbolt.org 给我们
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl std::cin, %edi
call std::basic_istream<char, std::char_traits<char> >::get()
movb %al, -1(%rbp)
movsbl -1(%rbp), %eax
movl %eax, %esi
movl std::cout, %edi
call [... the insertion operator for char, long thing... ]
movl $0, %eax
leave
ret
.. 对于 main
。我将代码分为三个小节。函数序言由前三个操作组成:
基指针被压入堆栈。
堆栈指针保存在基指针中
减去堆栈指针以为局部变量腾出空间。
然后将 cin
移入 EDI 寄存器2 并调用 get
;返回值在 EAX 中。
到目前为止,一切都很好。现在有趣的事情发生了:
EAX 的低位字节,由 8 位寄存器 AL 指定,被取出并存储在基指针后面的字节中:即-1(%rbp)
,基指针的偏移量为-1
。 这个字节是我们的变量 c
。偏移量是负数,因为堆栈在 x86 上向下增长。下一个操作将 c
存储在 EAX 中:EAX 移至 ESI,cout
移至 EDI,然后以 cout
和 c
作为参数调用插入运算符。
最后,
main 的返回值存储在 EAX: 0 中。这是因为隐式的 return 语句。您可能还会看到 xorl rax rax 而不是 movl。
离开并返回呼叫站点。 leave 是此结尾的缩写,并隐式将堆栈指针替换为基指针并弹出基指针。
将堆栈指针替换为基指针和
弹出基指针。
在执行了这个操作和 ret
之后,框架已经被有效地弹出,尽管调用者仍然需要清理参数,因为我们使用的是 cdecl 调用约定。其他约定,例如 stdcall,要求被调用者进行整理,例如通过将字节数传递给 ret
。
帧指针省略
也可以不使用基/帧指针的偏移量,而是使用堆栈指针 (ESB) 的偏移量。这使得原本包含帧指针值的 EBP 寄存器可用于任意使用 - 但它可以使 debugging impossible on some machines 成为 implicitly turned off for some functions。在为只有很少寄存器的处理器(包括 x86)进行编译时,它特别有用。
这种优化称为 FPO(帧指针省略),由 GCC 中的 -fomit-frame-pointer
和 Clang 中的 -Oy
设置;请注意,它是由每个优化级别隐式触发的 > 0 当且仅当调试仍然可能时,因为除此之外它没有任何成本。有关详细信息,请参阅 here 和 here。
正如评论中所指出的,帧指针可能是指向返回地址之后的地址。
2 请注意,以 R 开头的寄存器与以 E 开头的寄存器是 64 位对应的。EAX 表示 RAX 的四个低位字节。为了清楚起见,我使用了 32 位寄存器的名称。
因为很明显,接下来我们需要使用 a 和 b,但这意味着 OS/CPU(?)必须首先弹出 d 和 c 才能返回到 a 和 b。但是它会在脚上开枪,因为它需要在下一行中使用 c 和 d。
简而言之:
没有必要弹出参数。调用方 foo
传递给函数 doSomething
的参数和 doSomething
中的局部变量 都可以作为 base pointer 的偏移量来引用。
所以,
当进行函数调用时,函数的参数被压入堆栈。这些参数被基指针进一步引用。当函数返回给它的调用者时,返回函数的参数使用 LIFO 方法从堆栈中弹出。
详细地:
规则是每个函数调用都会创建一个堆栈帧(最小值是要返回的地址)。因此,如果 funcA
调用 funcB
并且 funcB
调用 funcC
,则三个堆栈帧被设置为一个在另一个之上。 当函数返回时,它的框架变得无效。一个行为良好的函数只作用于它自己的栈帧,不会侵入另一个栈帧。换句话说,对顶部的堆栈帧执行 POP(从函数返回时)。
https://i.stack.imgur.com/x4A9Q.png
您问题中的堆栈由调用者 foo
设置。调用 doSomething
和 doAnotherThing
时,它们会设置自己的堆栈。该图可以帮助您理解这一点:
https://i.stack.imgur.com/w19l1.png
请注意,要访问参数,函数体必须从存储返回地址的位置向下遍历(较高地址),而要访问局部变量,函数体必须向上遍历堆栈(较低地址) 相对于存储返回地址的位置。事实上,典型的编译器为函数生成的代码正是这样做的。编译器为此专用一个称为 EBP 的寄存器(基指针)。另一个名称是帧指针。编译器通常作为函数体的第一件事,将当前 EBP 值压入堆栈并将 EBP 设置为当前 ESP。这意味着,一旦完成,在函数代码的任何部分,参数 1 是 EBP+8(每个调用者的 EBP 和返回地址 4 个字节),参数 2 是 EBP+12(十进制),局部变量距离 EBP-4n 远。
.
.
.
[ebp - 4] (1st local variable)
[ebp] (old ebp value)
[ebp + 4] (return address)
[ebp + 8] (1st argument)
[ebp + 12] (2nd argument)
[ebp + 16] (3rd function argument)
看看下面这个函数的栈帧形成的C代码:
void MyFunction(int x, int y, int z)
{
int a, int b, int c;
...
}
当调用者调用它时
MyFunction(10, 5, 2);
将生成以下代码
^
| call _MyFunction ; Equivalent to:
| ; push eip + 2
| ; jmp _MyFunction
| push 2 ; Push first argument
| push 5 ; Push second argument
| push 10 ; Push third argument
并且该函数的汇编代码将是(在返回之前由被调用者设置)
^
| _MyFunction:
| sub esp, 12 ; sizeof(a) + sizeof(b) + sizeof(c)
| ;x = [ebp + 8], y = [ebp + 12], z = [ebp + 16]
| ;a = [ebp - 4] = [esp + 8], b = [ebp - 8] = [esp + 4], c = [ebp - 12] = [esp]
| mov ebp, esp
| push ebp
参考:
函数调用约定和堆栈。
帧指针和局部变量。
x86 反汇编/函数和堆栈帧。
EBP
和 ESP
吗?
像其他人指出的那样,没有必要弹出参数,直到它们超出范围。
我将从 Nick Parlante 的“指针和内存”中粘贴一些示例。我认为情况比你想象的要简单一些。
这是代码:
void X()
{
int a = 1;
int b = 2;
// T1
Y(a);
// T3
Y(b);
// T5
}
void Y(int p)
{
int q;
q = p + 2;
// T2 (first time through), T4 (second time through)
}
时间点 T1, T2, etc
。在代码中标出,当时的内存状态如图:
https://i.stack.imgur.com/YqZhq.png
不同的处理器和语言使用几种不同的堆栈设计。 8x86 和 68000 上的两种传统模式称为 Pascal 调用约定和 C 调用约定;除了寄存器的名称外,每个约定在两个处理器中的处理方式相同。每个都使用两个寄存器来管理堆栈和相关变量,称为堆栈指针(SP 或 A7)和帧指针(BP 或 A6)。
当使用任一约定调用子例程时,在调用例程之前将所有参数压入堆栈。例程的代码然后将帧指针的当前值压入堆栈,将堆栈指针的当前值复制到帧指针,并从堆栈指针中减去局部变量使用的字节数[如果有的话]。一旦完成,即使将额外的数据压入堆栈,所有局部变量都将存储在与堆栈指针具有恒定负位移的变量中,并且调用者压入堆栈的所有参数都可以在一个帧指针的恒定正位移。
这两种约定之间的区别在于它们处理子程序退出的方式。在 C 约定中,返回函数将帧指针复制到堆栈指针[将其恢复到旧帧指针被压入后的值],弹出旧帧指针值,然后执行返回。调用者在调用之前压入堆栈的任何参数都将保留在那里。在 Pascal 约定中,处理器在弹出旧的帧指针后,弹出函数返回地址,将调用者压入的参数字节数加到堆栈指针中,然后转到弹出的返回地址。在最初的 68000 上,需要使用 3 指令序列来删除调用者的参数;原版之后的 8x86 和所有 680x0 处理器包含一个“ret N”[或 680x0 等效]指令,该指令将在执行返回时将 N 添加到堆栈指针。
Pascal 约定的优点是在调用方节省了一点代码,因为调用方不必在函数调用后更新堆栈指针。但是,它要求被调用函数确切地知道调用者将要放入堆栈的参数的字节数。在调用使用 Pascal 约定的函数之前未能将正确数量的参数压入堆栈几乎肯定会导致崩溃。然而,这被抵消了,因为每个被调用方法中的一些额外代码会将代码保存在调用该方法的位置。出于这个原因,大多数原始 Macintosh 工具箱例程都使用 Pascal 调用约定。
调用约定的优点是允许例程接受可变数量的参数,并且即使例程不使用传递的所有参数也很健壮(调用者将知道它推送了多少字节的参数,并且因此将能够清理它们)。此外,没有必要在每次函数调用后执行堆栈清理。如果一个例程按顺序调用四个函数,每个函数使用四个字节的参数,它可以——而不是在每次调用后使用 ADD SP,4
,而是在最后一次调用后使用一个 ADD SP,16
来清除所有四个参数来电。
如今,所描述的调用约定被认为有些过时了。由于编译器在寄存器使用方面变得更加高效,因此通常让方法接受寄存器中的一些参数,而不是要求所有参数都被压入堆栈;如果一个方法可以使用寄存器来保存所有的参数和局部变量,就不需要使用帧指针,也就不需要保存和恢复旧的了。尽管如此,在调用链接到使用它们的库时,有时还是需要使用旧的调用约定。
(g==4)
然后 int d = 3
和 g
我使用 scanf
接受输入,然后我定义另一个变量 int h = 5
。现在,编译器现在如何在堆栈中提供 d = 3
空间。偏移量是如何完成的,因为如果 g
不是 4
,那么堆栈中将没有 d 的内存,并且简单地将偏移量提供给 h
,如果 g == 4
则偏移量将首先用于 g 和然后是 h
。编译器在编译时如何做到这一点,它不知道我们对 g
的输入
这里已经有一些非常好的答案。但是,如果您仍然关心堆栈的 LIFO 行为,请将其视为帧堆栈,而不是变量堆栈。我的意思是,虽然一个函数可以访问不在栈顶的变量,但它仍然只对栈顶的项进行操作:单个栈帧。
当然,这也有例外。整个调用链的局部变量仍然被分配并且可用。但它们不会被直接访问。相反,它们是通过引用(或通过指针,这实际上只是语义上的不同)传递的。在这种情况下,可以访问更下方的堆栈帧的局部变量。但即使在这种情况下,当前正在执行的函数仍然只是对自己的本地数据进行操作。它正在访问存储在其自己的堆栈帧中的引用,该引用可能是对堆、静态内存或堆栈下方的某些内容的引用。
这是堆栈抽象的一部分,它使函数可以按任何顺序调用,并允许递归。顶部堆栈帧是代码直接访问的唯一对象。其他任何东西都是间接访问的(通过位于顶部堆栈帧中的指针)。
查看您的小程序的汇编可能很有启发性,特别是如果您在没有优化的情况下进行编译。我想您会看到函数中的所有内存访问都是通过堆栈帧指针的偏移量发生的,这就是编译器编写函数代码的方式。在按引用传递的情况下,您会看到通过存储在堆栈帧指针某个偏移处的指针的间接内存访问指令。
调用堆栈实际上不是堆栈数据结构。在幕后,我们使用的计算机是随机存取机器架构的实现。因此,a 和 b 可以直接访问。
在幕后,机器会:
get "a" 等于读取栈顶以下第四个元素的值。
get "b" 等于读取栈顶以下第三个元素的值。
http://en.wikipedia.org/wiki/Random-access_machine
这是我为 Windows 上使用 Windows x64 调用约定的 C++ 程序的调用堆栈创建的图表。它比谷歌图像版本更准确和现代:
https://i.stack.imgur.com/45bwQ.png
并且对应上图的具体结构,这里是windows 7下notepad.exe x64的debug,函数的第一条指令'current function'(因为忘记是什么函数了)即将执行.
https://i.stack.imgur.com/vVYgW.png
交换了低地址和高地址,因此该图中的堆栈向上爬(这是第一个图的垂直翻转,还要注意数据被格式化为显示四字而不是字节,因此看不到小端序) .黑色是家居空间;蓝色是返回地址,它是调用函数的偏移量或调用函数中的标签到调用之后的指令;橙色是对齐方式;并且粉红色是 rsp
指向函数序言之后的位置,或者更确切地说,如果您使用的是 alloca,则在调用之前。 homespace_for_the_next_function+return_address
值是 windows 上允许的最小帧,因为必须保持被调用函数开头的 16 字节 rsp 对齐,它也包括 8 字节对齐,使得 rsp
指向第一个返回地址后的字节将对齐到 16 个字节(因为在调用函数时保证 rsp
与 homespace+return_address = 40
对齐到 16 个字节,它不能被 16 整除,因此您需要额外的 8 个字节来确保rsp
将在函数调用后对齐)。因为这些函数不需要任何堆栈局部变量(因为它们可以优化到寄存器中)或堆栈参数/返回值(因为它们适合寄存器)并且不使用任何其他字段,所以绿色的堆栈帧都是 {7 } 在尺寸方面。
红色的函数行概述了被调用函数在逻辑上“拥有”+ 在调用约定中按值读取/修改的内容,而无需对其进行引用(它可以修改在堆栈上传递的参数太大而无法在寄存器中传递 - Ofast),并且是堆栈帧的经典概念。绿色框标出了调用的结果和被调用函数所做的分配:第一个绿色框显示了 RtlUserThreadStart
在函数调用期间实际分配的内容(从调用之前到执行下一条调用指令)和从返回地址之前的第一个字节到函数序言分配的最后一个字节(如果使用 alloca 则更多)。 RtlUserThreadStart
将返回地址本身分配为 null,因此您在序言中看到 sub rsp, 48h
而不是 sub rsp, 40h
,因为没有调用 RtlUserThreadStart
,它只是从位于底部的 rip
开始执行堆栈。
函数所需的堆栈空间在函数序言中通过递减堆栈指针来分配。
例如,采用以下 C++ 及其编译为 (-O0
) 的 MASM。
typedef struct _struc {int a;} struc, pstruc;
int func(){return 1;}
int square(_struc num) {
int a=1;
int b=2;
int c=3;
return func();
}
_DATA SEGMENT
_DATA ENDS
int func(void) PROC ; func
mov eax, 1
ret 0
int func(void) ENDP ; func
a$ = 32 //4 bytes from rsp+32 to rsp+35
b$ = 36
c$ = 40
num$ = 64
//masm shows stack locals and params relative to the address of rsp; the rsp address
//is the rsp in the main body of the function after the prolog and before the epilog
int square(_struc) PROC ; square
$LN3:
mov DWORD PTR [rsp+8], ecx
sub rsp, 56 ; 00000038H
mov DWORD PTR a$[rsp], 1
mov DWORD PTR b$[rsp], 2
mov DWORD PTR c$[rsp], 3
call int func(void) ; func
add rsp, 56 ; 00000038H
ret 0
int square(_struc) ENDP ; square
可以看出,保留了 56 字节,当 call
指令分配 8 字节返回地址时,绿色堆栈帧的大小将是 64 字节。
这 56 个字节由 12 个字节的局部变量、32 个字节的主空间和 12 个字节的对齐组成。
所有被调用者寄存器在主空间中保存和存储寄存器参数都发生在序言中,在序言保留(使用sub rsp, x
指令)函数主体所需的堆栈空间之前。对齐是在 sub rsp, x
指令保留的空间的最高地址处,并且函数中的最终局部变量被分配到之后的下一个低地址(并且在该原始数据类型本身的分配中,它从该分配的最低地址并按字节向更高地址工作,因为它是小端),因此函数中的第一个原始类型(数组单元,变量等)位于堆栈的顶部,尽管本地人可以以任何顺序分配。下图中显示了与上述不同的随机示例代码,它不调用任何函数(仍然使用 x64 Windows cc):
https://i.stack.imgur.com/GQcWi.png
如果去掉对 func()
的调用,它只保留 24 个字节,即 12 个字节的局部变量和 12 个字节的对齐。对齐位于帧的开头。当一个函数将某些东西压入堆栈或通过递减 rsp
在堆栈上保留空间时,无论是否要调用另一个函数,rsp
都需要对齐。如果堆栈空间的分配可以优化,并且因为函数不调用而不需要 homespace+return_addreess
,那么将没有对齐要求,因为 rsp
不会改变。如果堆栈仅与它需要分配的局部变量(+ homespace+return_address
,如果它进行调用)对齐,它也不需要对齐,本质上它将需要分配的空间四舍五入为 16 字节边界。
除非使用 alloca
,否则在 x64 Windows 调用约定中不使用 rbp
。
在 gcc 32 位 cdecl 和 64 位 system V 调用约定上,使用 rbp
,新的 rbp
指向旧 rbp
之后的第一个字节(仅当使用 -O0
编译时,因为它保存到-O0
上的堆栈,否则,rbp
将指向返回地址之后的第一个字节)。在这些调用约定上,如果使用 -O0
进行编译,它会在被调用者保存寄存器后将寄存器参数存储到堆栈中,这将与 rbp
相关,并且部分堆栈保留由 rsp
减量完成。与 Windows x64 cc 不同,通过 rsp
减量完成的堆栈保留中的数据是相对于 rbp
而不是 rsp
访问的。在 Windows x64 调用约定中,如果它是可变参数函数或使用 -O0
编译,它会将传递给它的参数存储在为其分配的主空间的寄存器中。如果它不是可变参数函数,那么在 -O1
上,它不会将它们写入主空间,但主空间仍将由调用函数提供给它,这意味着它实际上是从寄存器而不是从主空间访问这些变量将其存储在堆栈中之后的位置,这与 O0
不同(它将它们保存到主空间,然后通过堆栈而不是寄存器访问它们)。
如果将函数调用放在上图表示的函数中,则在被调用函数的序言开始之前,堆栈现在将如下所示(Windows x64 cc):
https://i.stack.imgur.com/cL8yi.png
橙色表示被调用者可以自由排列的部分(数组和结构当然保持连续,并朝着更高的地址工作,每个元素都是小端),因此它可以按任何顺序放置变量和返回值分配,并且当它正在调用的函数的返回类型无法在 rax
中传递时,它会在 rcx
中传递一个用于返回值分配的指针,以供被调用者写入。在 -O0
上,如果返回值不能传入 rax
,那么还创建了一个匿名变量(以及返回值空间以及它分配给的任何变量,因此可以有 3 个副本结构)。 -Ofast
不能优化返回值空间,因为它是按值返回的,但是如果返回值没有被使用,它会优化匿名返回变量,或者将它直接分配给返回值被分配给的变量而不创建一个匿名变量,因此 -Ofast
有 2 / 1 个副本,而 -O0
有 3 / 2 个副本(分配给变量的返回值/未分配给变量的返回值)。蓝色表示被调用者必须按照被调用者调用约定的确切顺序提供的部分(参数必须按照该顺序,使得函数签名中从左到右的第一个堆栈参数位于堆栈顶部,这与 cdecl (这是一个 32 位 cc) 对其堆栈参数的排序方式相同。然而,被调用者的对齐可以在任何位置,尽管我只见过它位于本地和被调用者推送寄存器之间。
如果函数调用多个函数,对于函数中所有不同的可能调用点,调用在堆栈上的相同位置,这是因为序言迎合了整个函数,包括它进行的所有调用,以及参数和主空间任何被调用的函数总是在序言中分配的末尾。
事实证明,C/C++ Microsoft 调用约定仅在寄存器中传递一个结构,如果它适合一个寄存器,否则它会复制本地/匿名变量并在第一个可用寄存器中传递一个指向它的指针。在 gcc C/C++ 上,如果结构不适合前 2 个参数寄存器,那么它会在堆栈上传递,并且不会传递指向它的指针,因为调用者知道它在哪里,因为调用约定。
数组通过引用传递,不管它们的大小。因此,如果您需要使用 rcx
作为返回值分配的指针,那么如果第一个参数是一个数组,则指针将在 rdx
中传递,这将是一个指向正在传递的局部变量的指针。在这种情况下,它不需要将它作为参数复制到堆栈中,因为它不是按值传递的。但是,如果没有可用于传递指针的寄存器,则指针在通过引用传递时传递到堆栈上。
rbp
来做其他工作。