函数调用栈的一些简单认识

   程序的执行可以理解为连续的函数调用,每一个用户态(用户态指的是CPU指令集权限ring 0,用户只能访问常用CPU指令集,在应用程序中运行)进程都对应一个调用栈结构,当一个函数执行完毕后,会自动回到原先调用函数的位置(call指令)的下一步命令并执行,堆栈结构的作用是保存函数返回地址、传递函数参数、记录本地变量、临时保存函数上下文(上下文,也就是执行函数所需要的相关信息)。

       寄存器:

      寄存器分配:

      寄存器是处理器加工数据和运行程序的重要载体,寄存器在程序执行中中负责存储数据和指令,因此函数调用与寄存器有重要联系。

  32位CPU所含有的寄存器有:

    8个32位通用寄存器,其中包含4个数据寄存器(EAX、EBX、ECX、EDX)、2个变址寄存器(ESI和EDI)和2个指针寄存器(ESP和EBP)
    6个段寄存器(ES、CS、SS、DS、FS、GS)
    1个指令指针寄存器(EIP)
    1个标志寄存器(EFLAGS)
  最初的8086平台使用16位寄存器,每个寄存器都有具体特定的用途,但随着32位寄存器的出现,32位寄存器采用平台寻址方式,因此对特殊寄存器没有过多要求,但由于历史原因,16位寄存器的名字被保存,EAX,EBX,ECX,EDX,ESI,EDI这六个寄存器通常作为通用寄存器使用,但是部分指令会有特定的源寄存器或者目的寄存器(比如%eax通常用于保存函数返回值),因此为避免兼容性问题,ABI规范各个寄存器的作用,EAX通常用于保存函数返回值,EBX用于存储基地址,ECX是计数器,重复前缀指令(REP,X86汇编指令,使指定指令重复n次,但只能指定一条语句)和LOOP指令(循环指令,能够执行代码块)的内定计数器,循环重复执行次数将保留在cx中,EDX一般用来储存整数除法中的余数部分(当函数体中包含除法时,EAX保留整数部分,EDX则负责保存余数部分,乘除关系一般都与EAX、EDX有关),而EDI、ESI则通常用于储存函数参数

  EIP指令寄存器通常指向下一条待执行的指令地址(代码段内的偏移量),每完成一条汇编指令,EIP的值就会增加,ESP指向当前函数的栈帧结构的栈顶位置,EBP则始终指向当前函数的栈帧结构的栈底位置,同时注意EIP寄存器不能通过寻常方式访问到(无法获得opcode)

     在Intel  CPU中,通常将EBP寄存器作为栈帧指针寄存器,存储基地址,对于函数参数,偏移量为正值,对于局部变量,偏移量为负值

  寄存器的使用原则:

     主调函数指的是调用其他函数的函数,被调函数指的是被其他函数调用的函数

    主调函数一般使用eax、ecx、edx寄存器作为主调函数保存寄存器,当函数调用时,若主调函数希望保持这些寄存器的值,则必须在调用前显式地将其保存在栈中;被调函数可以覆盖这些寄存器,而不会破坏主调函数所需的数据,被调函数一般使用ebx、edi、esi作为被调函数保存寄存器,被调函数在覆盖这些寄存器的值时,必须先将寄存器原值压入栈中保存起来,并在函数返回前从栈中恢复其原值,因为主调函数可能也在使用这些寄存器。此外,被调函数必须保持寄存器%ebp和%esp,并在函数返回后将其恢复到调用前的值,亦即必须恢复主调函数的栈帧

  栈帧结构:

   函数的调用通常是嵌套的,在同一时刻,堆栈中会有多个函数的信息,每一个未执行完成的函数都有一个连续独立的区域即栈帧,栈帧是堆栈的一个逻辑片段,当函数调用时,逻辑栈帧被压入堆栈中,当函数返回时,栈帧从堆栈中弹出,栈帧主要储存函数参数、函数内局部变量以及返回前一栈帧所需要的信息

          栈帧的作用:

         1、保存主调函数的局部变量

   2、向被调函数传递参数

   3、返回被调函数的返回值

   4、返回函数的返回地址(即当被调用函数执行完成时应当执行的下一条指令)

   栈帧的边界由栈帧基寄存器EBP和栈顶寄存器ESP来界定,EBP位于栈底,高地址,在栈帧内位置固定,ESP位于栈顶,低地址,位置随着出栈和入栈而发生变化,因而数据访问通常通过EBP来进行(通过偏移量来访问)

          ESP指向栈顶,EBP一般指向栈帧的开始位置

   现在在假定有一个程序:A()——>B()——>C()

   那么A中元素包括A函数的局部变量,传给函数B的参数、B函数的返回值、执行完B的下一条指令的地址

                  B中元素包括B函数的局部变量、传给函数C的参数、C函数的返回值、执行完C的下一条指令的地址

     C中元素包括C函数的局部变量

因此:

         (1)被调函数的参数和返回值保存在主调函数的栈帧中

    (2)以栈帧为单位,那么C函数栈帧位于栈顶,ESP寄存器指向C函数栈帧的栈顶(即整个栈的栈顶),而EBP寄存器则指向C函数栈帧的起始位置

    (3)同时,因为主调函数尚未执行完成,所以被调函数的栈帧并不能覆盖主调函数的栈帧,只能通过push和pop指令实现调用

         (4)栈的生长方向为由高地址到低地址,数据填充则是由低地址到高地址

接下来再介绍几个汇编指令:

  (1)call指令:执行call指令时,会将EIP的值通过push压入栈中(因为EIP保存的是CPU即将执行的下一条指令的地址,所以这一步就对应前面说的保存函数返回地址,解释了为什么函数的返回地址保存在主调函数中),然后将EIP的值修改为被调函数的值,则当call执行完成后,将自动调用目标函数(被调函数

  (2)ret指令:将call指令中压入栈中的EIP的值(返回地址)pop回到EIP中,则ret指令执行完成后,将执行主调函数的下一条指令

  (3)push指令:将ESP寄存器减去八,将ESP寄存器向高地址移动,从而开辟新的空间,然后把操作数复制到ESP所指的位置上

          在AT&T格式下:

         sub   $8    %esp

         mov   源操作数   esp

  (4)pop指令:将ESP寄存器的所存的值传到指定位置,然后将ESP寄存器加上八

          在AT&T格式下:

          mov   %esp    目标操作数

          add    $8   %esp

  (5)leave指令:跟在ret指令后面,作用是交换esp和ebp的值

 

以下面一段程序为例:

 

#include <stdio.h>

int sum(int a, int b)
{
    int s = a + b;

    return s;
}

int main(int argc, char *argv[])
{
    int n = sum(1, 2);
    return 0;
}

 

通过gdb调试后:

  0x0000000000400540 <+0>:push  %rbp
  0x0000000000400541 <+1>:mov   %rsp,%rbp
  0x0000000000400544 <+4>:sub   $0x20,%rsp
  0x0000000000400548 <+8>:mov   %edi,-0x14(%rbp)
  0x000000000040054b <+11>:mov   %rsi,-0x20(%rbp)
  0x000000000040054f <+15>:mov   $0x2,%esi
  0x0000000000400554 <+20>:mov   $0x1,%edi
  0x0000000000400559 <+25>:callq 0x400526 <sum>
  0x000000000040055e <+30>:mov   %eax,-0x4(%rbp)
  0x0000000000400561 <+33>:mov   -0x4(%rbp),%eax
  0x0000000000400564 <+36>:mov   %eax,%esi
  0x0000000000400566 <+38>:mov   $0x400604,%edi
  0x0000000000400575 <+53>:mov   $0x0,%eax
  0x000000000040057a<+58>:leaveq  0x000000000040057b <+59>:retq  

现在开始执行第一条指令:

0x0000000000400540 <+0>:push %rbp 
push %rbp:push指令将rsp寄存器减8开辟新的空间后,将rbp的值压入栈中,此时rbp的值为调用main函数的函数的帧基地址,push rbp的原因是main函数需要rbp寄存器存储自己的帧基地址,

但是又不能覆盖调用main函数的函数的帧基地址,因此通过push指令开辟八个字节的空间来存储调用main函数的函数的帧基地址,所以目前为止,main函数的栈帧中只有调用main函数的函数的帧基地址
同时在这条指令之前,代码还没有到main函数,从这条指令开始进入main函数

0x0000000000400541 <+1>:mov %rsp,%rbp 
将rsp寄存器的值赋值给rbp,使rbp和rsp指向同一个位置,即main函数栈帧的起始位置

0x0000000000400544 <+4>:sub $0x20,%rsp
将rsp寄存器减去32字节,使其指向更低位置,这是为了给main函数中局部变量和临时变量预留空间,这里注意的是,当程序开始运行时,操作系统会自动为程序分配32字节空间,但具体使用多少由rsp寄存器决定
另外,当该指令执行完后,main函数的空间就全部分配完成,分别是存储主调函数的8字节和预留的32字节

0x0000000000400548 <+8> :mov %rdi,-0x14(%rbp) #保存main函数的第1个参数
0x000000000040054b <+11>:mov %rsi,-0x20(%rbp) #保存main函数的第2个参数
0x000000000040054f <+15>:mov $0x2,%rsi #sum函数的第2个参数放入esi寄存器
0x0000000000400554 <+20>:mov $0x1,%rdi #sum函数的第1个参数放入edi寄存器
前两条指令的目的是保存rdi和rsi的值,因为在调用main函数时,rdi和rsi分别保存了argc和argv两个参数,而接下来要调用sum函数,则为了防止rdi和rsi中的数值被覆盖,就提前将他们存入栈帧中
通过rbp加偏移量的方式
后两条指令的目的是传递sum函数的实参,将rsi和rdi分别赋值为2和1,同时这里有一条规定,就是函数参数保存默认寄存器顺序为rdi、rsi、rdx。。。

0x0000000000400559 <+25>:callq 0x400526 <sum>  
使用call指令,如上文提到一般,call指令先将rip的值压入栈中保存起来,也就是0x40055e 这个地址,这里会将rsp的值减8来开辟新的空间,然后将rip的值修改为目标函数的值,
也就是call指令的操作数0x400526,执行完成后
跳转到sum函数

0x0000000000400526 <+0>:push  %rbp  # 保存main函数的rbp的值入栈             
0x0000000000400527 <+1>:mov   %rsp,%rbp # 修改当前rbp的值为当前的栈顶
0x000000000040052a <+4>:mov   %edi,-0x14(%rbp) # 把第1个参数放入临时变量
0x000000000040052d <+7>:mov   %esi,-0x18(%rbp) # 把第2个参数放入临时变量
0x0000000000400530 <+10>:mov  -0x14(%rbp),%edx # 将第1个临时变量放入到 edx 当中
0x0000000000400533 <+13>:mov  -0x18(%rbp),%eax # 将第2个临时变量放入到 eax 当中
0x0000000000400536 <+16>:add  %edx, %eax # 进行加法计算, 结果保存在 eax 当中
0x0000000000400538 <+18>:mov  %eax,-0x4(%rbp) # eax 的值保存到临时变量中
0x000000000040053b <+21>:mov  -0x4(%rbp),%eax # 将临时变量的值放入到 eax 寄存器当中
0x000000000040053e <+24>:pop  %rbp # 出栈, 恢复main函数的 rbp 的值
0x000000000040053f <+25>:retq  # 函数返回

这里要注意一点就是之所以sum函数没有修改rsp的值来预留空间是因为sum是最后一个被调用的函数,他没有使用call指令,也就是说没有将rip的值压入栈中,
不需要修改rsp的值,也就是它预留的空间为栈中的所有剩余空间

然后继续执行 retq 指令, 该指令把 rsp 指向的栈单元当中的 0x40055e 取出给 rip 寄存器, 同时 rsp 加8, 这样,
rip 寄存器 中的值就变成了 main 函数中调用 sum 的 call 指令的下一条指令, 于是返回到 main 函数中继续执行.

继续执行 main 函数中的:

mov %eax,-0x4(%rbp)  # sum函数的返回值赋给变量n

该指令是把 rax 寄存器当中的值(sum函数返回值), 放入到 rbp-4 所指的内存, 也就是变量 n 所在的位置,继续执行程序结束



 

 

   

    

         

           

          

posted @ 2023-05-03 22:40  alexlance  阅读(162)  评论(0编辑  收藏  举报