欢迎访问作者博客原文,更好的阅读体验: 陈同学 | Procedure Call and Stack
文章简介
最近查资料时,偶然在youtobe看到了华盛顿大学自然科学与工程一位老师 关于 Procedure & Stacks 的课程,深入讲解了基于Stack的过程调用,展示了应用级别和寄存器级别的处理过程,演示非常形象,受益良多。以下是课程重点及视频链接,可以自行访问外国网站观看。
- 1-Stacks
- 2-Procedure Calls and Returns
- 3-Stack-based languages
- 4-Linux stack frame
- 5-Registers and variables
- 6-x86-64 Procedure Calling Convention
文本作为学习笔记,仅先记录过程调用时Stack和寄存器的变化.
课程笔记
Procedure Call Overview
下图为Caller(调用方) 调用 Callee(被调用方)的示例.
Caller需要保存它在寄存器上的数据,因为Callee会覆盖;Caller需要设置参数,调用Callee,然后清理参数,将数据重新存储到寄存器,然后找到返回值。
Callee需要保存局部变量,存储返回值,将一些数据存储到寄存器,再返回到Caller
save regs 表示保存寄存器数据;args 表示参数;local vars 表示局部变量; return val 表示返回值
为了实现上述过程,需要解决以下问题。
- Callee 需知道去哪儿找参数(机器没有传参之说,它只知道去哪儿读取数据,然后做何种计算)
- Callee 需知道去哪儿找 "return address", 即Callee执行结束后如何返回到上图中Caller部分的
call
代码处,并继续执行Caller中的指令 - Caller 需要去哪儿找Callee返回的结果
- 由于Caller 和 Callee 运行在同一个CPU上,它们共享寄存器,因此它们需要自行存储寄存器上的数据。
- Caller 和 Callee 之间需要一定的约定,例如:Callee约定将返回值存到某个寄存器,Caller去某个寄存器读取数据即可,这是一种通过约定共享信息的方式。这种约定成为 Procedure call linkage
Procedure Control Flow
通过 Stack 来支持 procedure call 和 return.
先假设几个概念,方法main调用方法B,假设方法main的代码如下:
代码语言:javascript复制
B(123); // call
println("123"); // return address
我们暂且称调用B()方法的指令为call
指令,称call
之后需要执行的指令(println("123")
)的地址为 return address(返回地址)
那么调用时执行的指令可以用下图来表示:
- call 8048b90: 表示调用方法B()的指令
- 8048553: 表示返回地址,即执行
call
之后需要返回到Caller处继续执行(println("123")
)的指令,需要把这条指令push到栈顶,这样B()执行完后可以返回回来,那么 return address 的值就是8048553 - 当B() return时,将 return address 从stack 中pop出来,这样就拿到了下一条需要执行的指令。然后再读取return address上存储的指令并执行即可(这条指令做的事情就是
println(result)
)
Procedure Call Example
说明:%eip、%esp、�x等都是通用寄存器 esp专门作为存放当前线程的栈顶指针; eip用于存放下一个待执行的CPU指令的内存地址,当CPU执行完当前指令后会从eip寄存器读取下一个指令的地址并继续执行 eax是累加器,例如:add eax,-2 可以表示给给变量eax的当前值加上2
下图有2条待执行的指令:
804854e:这条指令中的call表示在main方法中调用下一个方法
8048553 :这条指令表示main方法中执行call之后待执行的下一条指令
此时,栈顶存储的123 是某个参数值;esp寄存器指向栈顶0x108,eip寄存器存储了下一条准备执行的指令 804854e
在准备执行call 8048b90
之前. 为了在call之后能正常返回到Caller而且正确执行Caller的下一条指令,需先把return address即下一条要执行的指令(8048553)push到栈顶, 变化如下图:
此时,栈顶变为 0x8048553,同时esp存储新的栈顶元素0x104,eip存储了下一条待执行的地址0x8048553
接下来,准备执行 call 8048b90
,所eip寄存器存储了8048b90
作为待执行的指令
在call
调用的方法执行结束后,需要返回到Caller继续执行Caller的后续指令。如下图:
8048591: 表示return到caller,结束当前方法的调用
因为马上要执行ret
命令,因此将8048591
指令存到了eip寄存器,表示下一条待执行的指令是0x8048591
执行ret
之后,我们从栈顶去读取返回地址,读取的8048553
就是下一条需要执行的指令。
然后我们将8048553
从栈顶pop出来,此时esp指向0x108(即存储123的位置), 0x104上的值虽然存在,但是没有任何意义。
eip指向了下一条待执行的指令8048553
. 而8048553
是返回地址,也就是call
之后需要执行的下一条指令,这样就结束了Callee的方法调用,正常回到了Caller中.
JVM Stack
通过上述学习,对于JVM Stack的理解就不再浮于表面的理解,类似于这种苍白的阐述:JMM包含虚拟机栈,栈包含栈帧,栈帧有局部变量表、操作数栈、返回链接, blablabla…...
JMM之所以有Stack,是基于Stack数据结构来实现方法调用,保存方法调用轨迹(是不是用LinkedList也可以实现呢?)。
栈帧(Stack Frame):执行一个方法时会创建栈帧,用来存储局部变量(参数、方法内变量等)、返回地址(Caller call之后的下一条指令,提供给CPU来执行下一条指令)、指向上一个栈帧的指针等。
Stack中一个个栈帧的入栈/出栈就表示一个方法调用的开始与结束。栈中连续的栈帧可以体现出方法调用链,所以在发生异常时,我们才能获取到stacktrace(就是调用链轨迹,抓取栈中的所有栈帧即可)。同时,每个栈帧都存储了调用某个方法时的状态(即各种数据,如参数、变量等),因此除了获取到stacktrace,应该还可以获取到栈帧中的各种数据。