Assembly 调用堆栈究竟是如何工作的?
我试图深入了解编程语言的底层操作是如何工作的,尤其是它们如何与OS/CPU交互。我可能已经阅读了StackOverflow上每个堆栈/堆相关线程中的每个答案,它们都非常出色。但还有一件事我还没有完全理解 在伪代码中考虑此函数,伪代码往往是有效的锈代码;-) 这就是我假设堆栈在第X行上的样子:Assembly 调用堆栈究竟是如何工作的?,assembly,cpu,callstack,low-level,calling-convention,Assembly,Cpu,Callstack,Low Level,Calling Convention,我试图深入了解编程语言的底层操作是如何工作的,尤其是它们如何与OS/CPU交互。我可能已经阅读了StackOverflow上每个堆栈/堆相关线程中的每个答案,它们都非常出色。但还有一件事我还没有完全理解 在伪代码中考虑此函数,伪代码往往是有效的锈代码;-) 这就是我假设堆栈在第X行上的样子: Stack a +-------------+ | 1 | b +-------------+ | 2 | c +-------------
Stack
a +-------------+
| 1 |
b +-------------+
| 2 |
c +-------------+
| 3 |
d +-------------+
| 4 |
+-------------+
现在,我读到的关于堆栈如何工作的所有内容都是,它严格遵守后进先出规则。就像.NET、Java或任何其他编程语言中的堆栈数据类型一样
但是如果是这样的话,那么在X行之后会发生什么呢?因为很明显,我们需要的下一件事是使用a
和b
,但这意味着操作系统/CPU(?)必须首先弹出d
和c
,才能返回a
和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
之前,堆栈不会弹出
这两种情况让我觉得我还没有完全掌握堆栈是如何工作的,以及它是如何严格遵循后进先出规则的。调用堆栈也可以称为帧堆栈。
后进先出原则之后堆叠的不是局部变量,而是被调用函数的整个堆栈帧(“调用”)。局部变量分别与所谓和中的帧一起推送和弹出 在框架内,变量的顺序完全未指定;编译器可以适当地优化它们的对齐方式,以便处理器能够尽快获取它们。关键的事实是,变量相对于某个固定地址的偏移量在帧的整个生命周期内都是恒定的——因此只需获取一个锚地址,比如帧本身的地址,并使用该地址相对于变量的偏移量就足够了。这种锚定地址实际上包含在所谓的基址或帧指针中,该指针存储在EBP寄存器中。另一方面,偏移量在编译时是清楚的,因此硬编码到机器代码中 此图显示了典型调用堆栈的结构,如1: 将要访问的变量的偏移量添加到帧指针中包含的地址,我们就得到了变量的地址。简而言之,代码只是通过与基指针的恒定编译时偏移量直接访问它们;这是一个简单的指针算法 例子
#包括
int main()
{
char c=std::cin.get();
标准::cout
因为很明显,下一步我们需要的是处理a和b,但这意味着OS/CPU(?)必须先弹出d和c,才能返回到a和b。但随后它会击中自己的脚,因为下一行需要c和d
简言之:
无需弹出参数。调用者foo
传递给函数doSomething
的参数和doSomething
中的局部变量都可以作为偏移量引用
所以
- 当进行函数调用时,函数的参数被推送到堆栈上。这些参数由基指针进一步引用
- 当函数返回到它的调用者时,返回函数的参数将使用LIFO方法从堆栈中弹出
详细内容:
规则是,每个函数调用都会创建一个堆栈帧(最小值是要返回的地址)因此,如果funcA
调用funcB
和funcB
调用funcC
,三个堆栈帧将一个接一个地设置。当函数返回时,其帧将无效。。行为良好的函数仅对其自身的堆栈帧起作用,而不会主动变更其他堆栈帧。换句话说,POPing对顶部的堆栈帧执行(从函数返回时)
问题中的堆栈是由调用方foo
设置的。当调用doSomething
和doAnotherThing
时,它们会设置自己的堆栈。此图可能有助于您理解这一点:
请注意,要访问参数,函数体必须从存储返回地址的位置向下遍历(较高地址),要访问局部变量,函数体必须向上遍历堆栈(较低地址)相对于存储返回地址的位置。。事实上,典型的编译器生成的函数代码正是这样做的。编译器为此专门使用一个名为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);
th
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
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)
}
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