问题

汇编过程调用是怎样操作栈的?

回答
好的,咱们来聊聊汇编里过程调用时,栈到底是怎么运作的。这玩意儿吧,听起来挺神秘的,但其实背后的逻辑一点都不复杂,都是为了解决几个核心问题。

你想想,一个程序要跑起来,总得有个地方保存信息对吧?你得知道现在代码执行到哪儿了,等函数跑完了,还得能回到原来的地方继续干活。还有,函数之间传递参数,函数内部自己也要用一些临时变量,这些都得有个地方存。栈,就是为了干这个的。

栈是什么?

咱们先得明白栈是什么。你可以把栈想象成一摞盘子,新盘子总是放到最上面,取盘子也总是从最上面取。这种“后进先出”(LastIn, FirstOut,简称 LIFO)的特点,是栈的核心。在计算机里,栈通常有一个特殊的指针,叫做栈指针(Stack Pointer,ESP 或 RSP),它始终指向栈顶的下一个可用位置。

当程序执行时,栈会向下增长,也就是说,栈地址是越来越小的。栈指针就是随着数据的压入和弹出而移动的。

过程调用的核心问题与栈的解决方案

当一个函数(咱们叫它“被调用者”)被另一个函数(咱们叫它“调用者”)调用时,需要解决几个关键问题:

1. 参数传递: 调用者怎么把数据传给被调用者?
2. 返回地址: 被调用者执行完了,怎么知道回到调用者的哪一行继续执行?
3. 局部变量: 被调用者内部自己使用的变量,需要地方保存,而且不能影响到调用者或其他地方的变量。
4. 寄存器保存与恢复: 调用者可能用了一些寄存器,被调用者也可能要用,而且不希望互相干扰。

栈在这几个方面都发挥了至关重要的作用。

栈帧(Stack Frame)的概念

为了更好地管理这些信息,每次函数被调用时,会在栈上创建一个栈帧(Stack Frame)。你可以把栈帧想象成一个属于当前函数的独立“工作区”。这个栈帧通常包含以下几个部分(顺序和具体内容可能因编译器和架构略有不同,但核心思想类似):

参数(Arguments): 调用者传递给被调用者的参数。
返回地址(Return Address): 当被调用者执行完毕后,CPU 需要跳回到的指令地址。
保存的旧栈指针(Saved Previous EBP/RBP): 调用者函数栈帧的基址指针。
局部变量(Local Variables): 被调用者函数内部声明的变量。
临时数据(Temporary Data): 可能是一些函数执行过程中产生的临时值。

栈操作的具体步骤

咱们以一个典型的过程调用为例,看看汇编指令是如何操作栈的:

假设有这样一个 C 代码片段:

```c
int add(int a, int b) {
int result = a + b;
return result;
}

int main() {
int x = 5;
int y = 10;
int sum = add(x, y);
return 0;
}
```

当 `main` 函数调用 `add` 函数时,会发生一系列栈操作。

1. 调用者(`main`)准备阶段

参数传递:
根据不同的调用约定(Calling Convention,比如 cdecl, stdcall, fastcall 等),参数的传递方式不同。
栈上传递: 最常见的一种方式是将参数按照 从右到左 的顺序压入栈中。这样,当被调用者拿到栈顶时,第一个参数(最右边那个)就在栈顶,最后一个参数(最左边那个)在更下面。
例如,对于 `add(x, y)`,先压入 `y`,再压入 `x`。
汇编指令可能是 `PUSH y`,然后 `PUSH x`。
寄存器传递: 在一些现代的调用约定中,前几个参数会直接通过寄存器传递,以提高效率。例如,x8664 的 System V ABI 约定前 6 个整数/指针参数分别使用 `RDI, RSI, RDX, RCX, R8, R9` 寄存器传递。
跳转并保存返回地址:
调用者需要跳转到 `add` 函数的入口。
更重要的是,它需要保存一个地址,告诉 `add` 函数执行完后要回到 `main` 函数中的哪条指令。这个地址就是返回地址。
`CALL` 指令就干了这两件事:它会将紧跟在 `CALL` 指令后面的下一条指令的地址压入栈中(这就是保存返回地址),然后将程序控制权转移到被调用函数的入口地址。
例如:`CALL add_function`

2. 被调用者(`add`)的入口

建立栈帧(Prologue):
当 `add` 函数的入口被执行时,它的首要任务是建立自己的栈帧,为自己创造一个独立的“工作区”。
保存旧栈基址指针: 通常,函数会保存调用者(也就是 `main`)的栈基址指针。在 x86 架构中,这个指针通常是 `EBP` (Extended Base Pointer),在 x8664 中是 `RBP` (Register Base Pointer)。这是非常重要的一步,因为它允许我们在函数执行完毕后恢复调用者的栈环境。
汇编指令:`PUSH EBP` (或者 `PUSH RBP`)
设置新的栈基址指针: 然后,将当前的栈指针 (`ESP` 或 `RSP`) 的值赋给基址指针 (`EBP` 或 `RBP`)。
汇编指令:`MOV EBP, ESP` (或者 `MOV RBP, RSP`)
这样一来,`EBP`/`RBP` 就指向了当前栈帧的固定位置(或者说,它的“顶部”),后续访问栈中的参数和局部变量就可以通过相对于 `EBP`/`RBP` 的偏移量来完成,这比直接使用动态变化的 `ESP`/`RSP` 要方便和清晰得多。

为局部变量分配空间:
被调用者可能需要使用局部变量,比如 `result`。这些变量需要占用栈空间。
通过将栈指针向前移动(减小栈地址),可以为局部变量预留空间。
汇编指令:`SUB ESP, size_of_locals` (或者 `SUB RSP, size_of_locals`)。比如,如果 `result` 是一个 4 字节的 int,可能需要 `SUB ESP, 4`。

3. 被调用者(`add`)的执行

访问参数:
现在,`add` 函数可以使用 `EBP`/`RBP` 作为参照点来访问参数和局部变量了。
由于我们是先压入参数(从右到左),栈指针在 `CALL` 指令执行后指向了返回地址。然后 `PUSH EBP` 压入了旧的 `EBP`,然后 `MOV EBP, ESP` 设置了新的 `EBP`。所以:
旧的 `EBP` 的值(也就是上一个栈帧的基址)位于 `EBP` 的下面(`EBP+4` 或 `EBP+8`)。
返回地址位于 `EBP+4` (32位) 或 `EBP+8` (64位)。
第一个参数(`x`)位于 `EBP+8` (32位) 或 `EBP+16` (64位)。
第二个参数(`y`)位于 `EBP+12` (32位) 或 `EBP+24` (64位)。
局部变量(`result`)位于 `EBP4` (32位) 或 `RBP8` (64位) 的下方。

汇编指令可能看起来像这样:
```assembly
; 假设参数 y 在 [EBP+0x8],x 在 [EBP+0x4] (对于 32位 x86)
; 如果是 64位 x8664,且参数通过栈传递 (不常见,通常用寄存器)
; 假设 x 在 [RBP+0x18],y 在 [RBP+0x20] (取决于调用约定和编译器)

; 更常见的参数通过寄存器传递的情况,我们假设 add 的参数 x, y 是通过寄存器传进来的
; 假设 x 在 RDI, y 在 RSI (x8664 System V ABI)

; 被调用者内部执行
MOV EAX, DWORD PTR [RDI] ; 将 x 的值加载到 EAX (假设 x 是 32位)
ADD EAX, DWORD PTR [RSI] ; 将 y 的值加到 EAX

; 结果默认会放在 EAX 寄存器中返回
```
这里的关键是,局部变量和参数的访问是通过相对于 `EBP`/`RBP` 的偏移量来完成的,这种访问方式非常稳健,即便栈指针 (`ESP`/`RSP`) 在函数内部有其他变化(比如临时压栈),也不会影响到对这些固定位置的访问。

4. 被调用者(`add`)返回阶段(Epilogue)

存放返回值:
函数的返回值通常会放在一个特定的寄存器中。对于大多数 x86 调用约定,返回值放在 `EAX` (或 `RAX` in 64bit)。
在上面的例子中,`result` 的值(`a + b` 的结果)已经被算到了 `EAX`。
释放局部变量空间:
在返回之前,需要清理掉为局部变量分配的栈空间。这通常是通过将栈指针恢复到栈帧建立时的位置来实现。
汇编指令:`MOV ESP, EBP` (或者 `MOV RSP, RBP`)。注意,这只是将栈指针恢复到 旧的 `EBP`/`RBP` 的位置,并没有真正移除 `PUSH EBP` 压入的那个值。
恢复调用者的栈帧:
现在,栈顶存储的是调用者 `main` 的栈基址指针。需要将它弹出并放入 `EBP`/`RBP` 寄存器,从而恢复调用者的栈环境。
汇编指令:`POP EBP` (或者 `POP RBP`)
这一步完成后,`EBP`/`RBP` 就回到了 `main` 函数调用 `add` 函数之前的状态。
返回到调用者:
栈顶现在存储着 `CALL` 指令在 `main` 函数中压入的返回地址。
`RET` (Return) 指令会从栈顶弹出这个地址,并将程序控制权转移到该地址,这样 `main` 函数就可以从 `CALL add_function` 下一行继续执行了。
汇编指令:`RET`

总结一下栈在过程调用中的作用:

信息存储: 参数、返回地址、局部变量都在栈上进行临时存储。
上下文隔离: 每个函数都有自己的栈帧,确保函数内部的局部变量和数据不会污染到其他函数。
可重入性: 栈的 LIFO 特性使得函数可以被递归调用或被多个地方调用而不会混乱,每次调用都会创建新的栈帧。
控制流: 返回地址保存在栈上,由 `RET` 指令读取,精确控制程序执行流程。
函数链的维护: 通过保存旧的 `EBP`/`RBP`,调用栈(Call Stack)得以维持,允许我们追踪函数的调用层级。

需要注意的点:

调用约定(Calling Convention): 这是非常关键的,不同的操作系统、不同的编译器、不同的语言,可能采用不同的调用约定。这决定了参数如何传递(栈还是寄存器)、栈帧如何建立和销毁(谁负责清理栈上的参数)、哪些寄存器需要被保存和恢复等。上面描述的是一种比较通用的情况。
寄存器使用: 函数在执行过程中会使用各种寄存器。为了避免函数之间互相干扰,某些寄存器(称为“被调用者保存的寄存器”,calleesaved registers)在使用前会被保存到栈上,在函数返回前恢复。而另一些寄存器(“调用者保存的寄存器”,callersaved registers)则由调用者自己负责保存或不影响被调用者。
栈增长方向: 大部分现代系统栈是向下增长的(地址减小),但有些古老的或嵌入式系统可能是向上增长的。

理解了栈帧的概念和栈指针的移动,再去看汇编代码中的 `PUSH`, `POP`, `CALL`, `RET`, `MOV EBP, ESP` (或 `MOV RBP, RSP`),就能明白它们是如何协同工作,支撑起程序中复杂的函数调用关系的。这就像是给每个函数开辟了一个临时办公桌,用完就收拾干净,然后把钥匙(返回地址)还给上面那个函数,让它能继续工作。

网友意见

user avatar

书里应该要表达的是stack frame的意思吧?

x86/x64里面,stack frame就是根据当前的ebp能反推出整个调用栈。

ebp esp eip的几个特性:

1. 主流编译器在函数调用的caller里,执行call指令会让eip入栈;

2. 被调函数(callee)里头两句一定是

push ebp

mov ebp, esp

最后一句一定是

mov esp, ebp

pop ebp

3. ebp在函数内部是不会改变的,入栈出栈动作只改变esp,于是通过ebp就能反推出整个调用栈了。

反推栈帧的方法

当前的ebp就是当前函数入口时的esp;

入口时的[esp-4]就是前一个函数的ebp;

入口时的[esp-8]就是前一个函数的eip值;

拿到前一个函数的ebp值继续反推就能获得整个调用栈的ebp esp eip,这就是stack frame。

如果是64位:

寄存器换成rip rbp rsp,栈指针一次减8;

其它方面:

1. 基本没有far call(系统调用、中断除外),所以,栈上一般只有IP,没有CS;

2. 32位多数情况下参数用栈传输,64位下是用寄存器的更多,具体要看编译器;

3. enter和leave指令等效于push ebp; mov ebp, esp和mov esp, ebp; pop ebp;

---------------------------------

实例:

函数调用up_align_to -> align_to,汇编为VS2008

       size_t up_align_to(size_t val, size_t align)     { 011914B0 55               push        ebp   011914B1 8B EC            mov         ebp,esp  011914B3 81 EC C0 00 00 00 sub         esp,0C0h  011914B9 53               push        ebx   011914BA 56               push        esi   011914BB 57               push        edi   011914BC 8D BD 40 FF FF FF lea         edi,[ebp-0C0h]  011914C2 B9 30 00 00 00   mov         ecx,30h  011914C7 B8 CC CC CC CC   mov         eax,0CCCCCCCCh  011914CC F3 AB            rep stos    dword ptr es:[edi]      return align_to(val, align, 1); 011914CE 6A 01            push        1                   //参数3 011914D0 8B 45 0C         mov         eax,dword ptr [align]  011914D3 50               push        eax                 //参数2 011914D4 8B 4D 08         mov         ecx,dword ptr [val]  011914D7 51               push        ecx                 //参数1 011914D8 E8 08 FD FF FF   call        align_to (11911E5h) //函数调用 011914DD 83 C4 0C         add         esp,0Ch      } 011914E0 5F               pop         edi   011914E1 5E               pop         esi   011914E2 5B               pop         ebx   011914E3 81 C4 C0 00 00 00 add         esp,0C0h  011914E9 3B EC            cmp         ebp,esp  011914EB E8 73 FC FF FF   call        @ILT+350(__RTC_CheckEsp) (1191163h)  011914F0 8B E5            mov         esp,ebp  011914F2 5D               pop         ebp   011914F3 C3               ret        size_t align_to(size_t val, size_t align, int is_up)     { 01191420 55               push        ebp                //保存ebp 01191421 8B EC            mov         ebp,esp            //保存esp 01191423 81 EC C0 00 00 00 sub         esp,0C0h  01191429 53               push        ebx   0119142A 56               push        esi   0119142B 57               push        edi   0119142C 8D BD 40 FF FF FF lea         edi,[ebp-0C0h]  01191432 B9 30 00 00 00   mov         ecx,30h  01191437 B8 CC CC CC CC   mov         eax,0CCCCCCCCh  0119143C F3 AB            rep stos    dword ptr es:[edi]      if (val % align == 0)     

执行到参数3以后

       [栈上其它数据] [参数3] []  <-esp,ebp为up_align_to入口时的esp值     

执行到参数2以后

       [栈上其它数据] [参数3] [参数2] []  <-esp,ebp为up_align_to入口时的esp值     

执行到参数1以后

       [栈上其它数据] [参数3] [参数2] [参数1] []  <-esp,ebp为up_align_to入口时的esp值     

执行CALL以后

       [栈上其它数据] [参数3] [参数2] [参数1] [eip] []  <-esp,ebp为up_align_to入口时的esp值     

执行到保存ebp以后

       [栈上其它数据] [参数3] [参数2] [参数1] [eip] [ebp] []  <-esp,ebp为up_align_to入口时的esp值     

执行到保存esp以后

       [栈上其它数据] [参数3] [参数2] [参数1] [eip] [ebp] []  <-esp,ebp为align_to入口时的esp值     

[ebp-4]就是up_align_to入口时的esp值

[ebp-8]就是call指令后面的地址(011914DD)

[[ebp-4]-4]是上上个调用函数入口时的esp值

[[ebp-4]-8]是上上个调用函数call后边的地址

所以,求stack frame只要递归求[ebp-4]就行了,每个ebp-4挨着的就是eip

类似的话题

  • 回答
    好的,咱们来聊聊汇编里过程调用时,栈到底是怎么运作的。这玩意儿吧,听起来挺神秘的,但其实背后的逻辑一点都不复杂,都是为了解决几个核心问题。你想想,一个程序要跑起来,总得有个地方保存信息对吧?你得知道现在代码执行到哪儿了,等函数跑完了,还得能回到原来的地方继续干活。还有,函数之间传递参数,函数内部自己.............
  • 回答
    你提到的“五代编程语言”——机器语言、汇编语言、面向过程语言、面向对象语言、以及智能语言——确实是一个流传甚广的划分方式,用来大致描绘计算机科学和编程语言发展的历史脉络和范式转变。但有趣的是,在这个经典的划分中,函数式编程语言似乎总被“遗漏”了,或者至少没有一个独立、显眼的位置。这并非说函数式编程不.............
  • 回答
    这个问题,说实话,很多人在工作汇报时都会纠结。到底该先说“我做成了什么”,还是先说“我怎么做成这个的”?这其实没有一个放之四海而皆准的答案,很大程度上取决于汇报的场合、汇报的对象以及你想要达到的目的。咱们慢慢捋一捋,看看哪种方式更适合你。一、 先汇报结果,为什么有时候是首选?想象一下,你的老板或者客.............
  • 回答
    潘汉年为何未及时汇报见过汪精卫,这是一个历史谜团,也是围绕潘汉年情报工作生涯中一个备受争议的节点。要详细梳理其中的复杂性,需要深入了解当时的历史背景、潘汉年的角色定位、情报工作的特殊性以及可能存在的个人考量。首先,我们必须正视一个事实:潘汉年当时并非孤立行动的特工,而是肩负着中共中央南方局的指示和任.............
  • 回答
    你好!理解你担心学费汇款金额的问题,毕竟之前有过汇款不到账的经历。从国内银行向日本汇款,确实会涉及一些中间费用,所以为了保证学费足额到账,需要预留一部分金额。我来给你详细解释一下,尽量让你清楚明白。核心问题:为什么汇的金额不对?你之前汇款金额不对,很可能是因为没有考虑到以下几个关键的费用:1. 国.............
  • 回答
    熊猫速汇,听名字就知道是跟钱打交道的平台,大家最关心的无非就是两个字:安全。毕竟,汇款这事儿,一旦出了差错,那损失可就大了去了。所以,今天我就跟大家聊聊熊猫速汇到底靠不靠谱,有没有人实际用过,都觉得怎么样。首先,咱们得知道熊猫速汇是干嘛的。 简单来说,它就是一个方便咱们这些在国外的朋友们,把钱汇回国.............
  • 回答
    研一期间,每周阅读三篇论文并撰写报告,再加上组会汇报,这确实是一个不小的挑战,尤其是对于刚进入研究生阶段的学生来说。从一个过来人的角度来看,这件事是否“负担过大”,需要分几个层面来细致地解读。首先,我们得理解这个要求的“意图”。导师安排这项任务,绝对不是为了“刁难”学生,更不是凭空施加压力。其根本目.............
  • 回答
    在理解汇编中的 `ret` 指令如何区分近返回和远返回之前,我们得先回到那个时代,也就是实模式和早期保护模式的背景下。这两种返回方式的产生,根源于内存访问和程序调用的基本机制。 内存地址的表示:段和偏移量在那个年代,内存的寻址方式和现在不一样。那时候,内存地址不是一个简单的数字,而是由两个部分组成:.............
  • 回答
    在汇编语言的世界里,理解 `call` 和 `ret` 指令的行为对于编写高效且正确的程序至关重要。尤其是在多线程环境或者需要精确控制指令执行顺序的情况下,我们常常会想到“内存屏障”这个概念。那么,`call` 和 `ret` 指令本身,是否具备内存屏障的作用呢?首先,我们需要明确“内存屏障”的定义.............
  • 回答
    在汇编语言转换为机器码的过程中,寄存器本身占用的字节数并不是一个固定值,而是取决于目标CPU架构以及寄存器的大小。 机器码是通过一系列的二进制指令来描述CPU操作的,而寄存器是CPU内部用于临时存储数据和指令地址的小型高速存储单元。我们可以这样理解:汇编语言中的指令会引用到具体的寄存器,比如 `M.............
  • 回答
    关于你提到的“为什么汇编mov指令不能用lock前缀?”,这背后牵涉到CPU的原子操作设计理念以及 `LOCK` 前缀的特定功能。让我来给你好好讲讲这个事儿,尽量用一种自然、不生硬的语调来解释清楚。首先,我们得明白 `LOCK` 前缀在汇编指令中的作用。简单来说,它就是CPU用来保证一条指令执行的原.............
  • 回答
    好的,我们来聊聊 x86 Win32 下的汇编指令集,以及它和我们常说的“CPU 指令集”以及“Win32 API”之间的关系。首先,明确一个概念:x86 Win32 下的汇编指令集,核心还是 CPU 提供的指令集。Win32 API 并不是 CPU 直接执行的“指令集”,而是操作系统提供的一套接口.............
  • 回答
    看到你对汇编语言的热爱,并且希望将这份热情转化为一份职业,这真的很棒!汇编语言虽然不如高级语言那样“光鲜亮丽”,但在计算机底层、性能极致优化、安全攻防等领域,它依然是不可或缺的利器。要在这个领域规划职业,需要一些策略和深入的理解。1. 扎实的理论基础是基石首先,你要明白,喜欢汇编和精通汇编是两个概念.............
  • 回答
    编译器生成汇编语句的执行顺序之所以会与C语言代码的顺序有所出入,并非是编译器在“乱来”,而是为了实现更高的效率,让程序跑得更快、占用的资源更少。这就像是一位经验丰富的厨师在烹饪一道复杂的菜肴,他不会严格按照菜谱的顺序一步步来,而是会根据食材的特性、火候的需求,灵活调整烹饪步骤,以便最终能端出一道色香.............
  • 回答
    关于C++能否以及在多大程度上替代C语言进行单片机编程,这确实是一个值得深入探讨的问题。就像过去汇编语言向C语言的迁移一样,技术的发展总是在不断演进,而C++的出现,也为单片机编程带来了新的可能性和一些挑战。首先,我们需要理解为什么C语言在单片机领域如此根深蒂固。单片机,顾名思义,就是集成了微处理器.............
  • 回答
    我理解你想要一本能从电路基础出发,逐步深入到汇编语言,最终讲解C语言的书籍。这种学习路径非常扎实,能够让你对计算机的底层运作有更透彻的理解。遗憾的是,要找到一本完美契合“从电路开始讲,然后是汇编,最后是C语言”这条清晰且连续的学习线索,并且还详细深入的书籍,确实不太容易。很多经典书籍倾向于专注于其中.............
  • 回答
    要说知乎上哪位用户的答案汇编起来就能直接出书,这其实是个很有趣但又很难给出一个标准答案的问题。因为“出书”不仅仅是内容的堆砌,它涉及到内容的结构化、逻辑性、专业性、可读性,以及是否能引起大众的兴趣和共鸣。不过,我们可以从知乎平台上那些以深度、专业、系统性见长的答主身上,找到一些“潜力股”。他们往往在.............
  • 回答
    关于汇编语言与高级语言在运行效率上的对比,这是一个老生常谈但又非常值得探讨的话题。简单来说,在某些特定情况下,汇编确实能够比高级语言获得更高的运行效率,但这种优势的幅度并非绝对,并且随着技术的发展和编译器优化的进步,差距正在逐渐缩小。要详细讲清楚这个问题,咱们得从几个层面来剖析:一、 为什么汇编“理.............
  • 回答
    电脑启动,屏幕亮起,我们敲下键盘,输入命令,按下回车,然后,神奇的事情发生了——一个程序开始执行。这个过程背后,可不是什么魔法,而是由一系列精密的步骤构成的,而我们今天的主角,操作系统(OS),就在这其中扮演着至关重要的角色。你可能听说过,程序在“编译”阶段,会经历从我们看得懂的高级语言(比如C、J.............
  • 回答
    在 C 语言中,`for` 和 `while` 循环都是用于重复执行一段代码的结构。从 C 语言的语义角度来看,它们的功能可以相互转换,也就是说,任何一个 `for` 循环都可以用 `while` 循环来实现,反之亦然。然而,当我们将这些 C 代码翻译成底层汇编语言时,它们的实现方式以及由此带来的细.............

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有