栈和函数调用
最近在忙Dump收集和分析的事情,本文写一下堆栈的知识。也可以说是对 《软件调试》 一书22章的笔记。
本文介绍:X86CPU堆栈原理
栈产生
当操作系统创建线程的时候会为每个线程创建栈,在windows系统中,每个线程至少有一个栈,系统线程之外的每个线程都有两个栈,一个称谓用户态栈,一个称为内核态栈。
对CPU而言,它只使用当前栈,即SS和ESP寄存器所指向的栈,当线程间切换或者内核态和用户态间切换的时候,系统会保证SS和ESP寄存器始终指向合适的栈。
栈是超低地址方向生长的,故压栈操作使ESP的值减小。
CALL指令
用作函数调用的指令。分为Near Call和Far Call两种。
Near Call操作
- EIP当前值压入到栈中供返回时使用
- 将被调用过程的偏移(相对于当前段)加载到EIP中 #. 开始执行被调用个过程
Far Call操作
- 将CS的当前值压入到栈中供返回时使用
- EIP当前值压入到栈中供返回时使用
- 将包含被调用过程的代码段的段选择子加载到CS中
- 将被调用过程的偏移加载到EIP中 #. 开始执行被调用个过程
在NT系列的Windows因为采用了平坦内存模型,统一进程内的代码都在一个大的4GB段中,因此不必再考虑段的差异,几乎所有时候使用的都是Near Call
RET指令
用于被调用过程返回到发起调用的过程。分为Near Return和Far Return两种。 RET指令存在一个可选的参数n,一般用来释放压在栈上的参数。
Near Return操作
- 将位于栈顶的数据弹出到EIP寄存器。这个值应该是发起Near Call是压入的返回地址。
- 如果RET指令包含参数n,那么便将ESP的字节数递增n
- 继续执行程序指针所指向的指令,通常就是父函数中调用指令的下一条指令。
Far Return操作
相对NearReturn操作1、2步之间,CPU会弹出执行远调用时压入的CS寄存器。
关于CALL指令和RET指令在《软件调试》22.3.3节有详细的实例分析。(P591)
局部变量的分配和释放
局部变量的分配和释放是由编译器插入的代码通过调整栈指针(StackPointer)的位置来完成的。 编译器在编译时,会计算当前代码块(如函数或过程)中所声明的所有局部变量所需要的空间,并将其按照内存对齐规则趣味满足对其要求的最接近整数值。(常说的:字节对齐)
关于局部变量的分配在《软件调试》22.4.1节有详细的实例分析。(P596)
EBP寄存器和栈帧(Stack Frame)
使用EBP寄存器,函数可以把自己将要使用的栈空间的基准地址记录下来,然后使用这个基准地址来引用局部变量和参数。在同一个函数内,EBP的基准地址是保持不变的,从而函数内的局部变量有了一个固定的参照物。
通常,一个函数在入口处将当时的EBP值压入堆栈,然后把ESP值(栈顶)赋给EBP,这样EBP中的地址就是进入本函数的栈顶地址,大于EBP数值的地址是父函数使用的空间,小于EBP数值的地址是这个函数将要使用的栈空间。
EBP+4 是Call指令压入的函数返回地址; EBP+8是父函数压在栈上的第一个参数,EBP+0xC是的第二个参数,依次类推; EBP-n是第一个局部变量的起始地址(n为变量的长度)。 栈回溯原理:根据当前EBP位置可以得到父函数EBP的值,依次类推,直到当前线程的最顶层函数。 栈帧:每个函数再战中所使用的区域称为一个栈帧。
- 一个栈中,根据函数调用关系,发起调用的函数的栈帧在高地址,被调用的函数的栈帧在低地址。
- 每发生一次函数调用 便产生一个新的栈帧,当一个函数返回时,这个函数对应的被消除。
- 线程正在执行的那个函数所对应的栈帧卑微与栈的最顶部,他是栈内建立时间最晚的栈帧。
帧指针省略(FPO)
某些优化过的函数省去了建立和维护帧指针所需的指令,所以这些函数所对应的栈帧不再有帧指针,这种情况被称为帧指针省略。(Frame Pointer Omission)
函数返回值。
如果返回值是EAX寄存器能够容纳的整数、字符或指针(4字节或少于4字节)那么使用EAX寄存器。
如果返回值是超过4字节但是少于8字节的证书,那么使用EDX寄存器来存放两字节以上的值。
如果要返回一个结构或类,那么分配一个临时变量作为隐含的参数传递给被调用函数,被调用函数将返回值复制到这个隐含参数之中 ,并且将其地址赋给EAX寄存器。
浮点类型的返回值和参数通常是通过专门的浮点指令使用栈来传递的,其细节略。
栈溢出
当栈溢出的时候,当前线程还有4KB可用,可用来开启线程,记录Dump数据。