Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

从底层角度看待函数的调用 #10

Open
AlexiaChen opened this issue Oct 16, 2019 · 0 comments
Open

从底层角度看待函数的调用 #10

AlexiaChen opened this issue Oct 16, 2019 · 0 comments
Labels
c/cpp C and CPP programming language 计算机体系结构 CPU处理器相关

Comments

@AlexiaChen
Copy link
Owner


title: 从底层角度看待函数的调用
date: 2014-03-05 12:01:23
tags:
- 内存模型
- C/C++
- 汇编语言

可能分析的时候会用到一点汇编语言,不过都很简单,不影响理解文章。

预备知识

在开始正文之前,需要复习下函数调用的约定。

  • __cdecl: C/C++函数默认调用约定,参数依次从右向左传递,并压入堆栈,最后由调用函数清空堆栈,这种方式适用于传递参数个数可变的被调用函数,只有被调用函数才知道它传递了多少个参数给被调用函数,比如printf();

  • __stdcall:参数由右向左传递,并压入堆栈,由被调用函数清空堆栈,当函数有可变参数个数时,函数调用约定自动转换成__cdecl调用约定;

  • __thiscall:C++非静态成员函数默认调用约定,不能使用个数可变参数,调用非静态成员函数时,this指针直接保存在ecx寄存器中,不入栈,其他方面同__stdcall;

  • __fastcall:凡是接口函数都必须指明其调用规范,除非接口函数是类的非静态成员函数;

简洁的内存模型抽象

由于是简单而本质的抽象,因此我们不考虑分页机制、MMU(memory management unit)之类的。正是如此,它们本来对于我们就是透明的。

所以内存就被考虑为一个从编号(地址)0开始、以编号(地址)0xffff ffff结束的字节序列。每一个字节都被顺序地编号。编号就是字节的地址。
在32位FLAT模式汇编中,本来就是如此。

在程序加载入内存后,程序的指令和数据都按某种方式存放在内存里面。要访问和执行他们,只需要知道他们的地址就可以了。

最重要的东西登场,它就是eip,指令指针寄存器,或称程序计数器。eip中的值程序员无法修改(嗯,可是汇编程序员呢?汇编程序员也无法修改它的值吗?废话,汇编程序员也是程序员啊!),它的值就是下一条即将执行的指令的地址。就是说eip永远指向下一条指令。

然后就是esp,它指向栈的栈顶。当向栈压入数据或从栈弹出数据时,esp的值不断变化,但无论如何变化,它都指向栈顶。

最后就是ebp,它用来把栈中的某个地址作为基址(基本地址,这样理解就是了),它用来标识栈中的某个固定位置,因此可以通过它访问这个固定位置附近的数据。

80X86的栈是向下增长的。也就是说,当向栈压入4个字节的数据时,esp = esp - 4; 当从栈中弹出4个字节时,esp = esp + 4。

栈帧与函数调用

关于计算机,最重要的三个抽象是什么?答案是虚拟地址空间、进程、文件。

一个进程就是一个运行中的程序,或者被加载到内存中的程序。现代操作系统使进程看上去独占了所有的系统资源,但实际上系统中运行着多个进程。

所以从一个进程的视角看去,它独占了系统中的所有内存资源和CPU资源。对于32位系统虚拟地址空间被抽象为编号0~0xffff ffff的字节序列,它是平坦的,线性的,被系统抽象了的,所以叫它平坦地址或线性地址、虚拟地址。

对于Linux来说,保留高1G为系统使用。0-3G空间被应用程序也就是进程独占。

对于一个被加载了的程序也就是进程,其在内存中的分布为:

共享内存段
自由存储区(堆)
BSS段
数据段
只读数据段
代码段

栈向下增长。

每一个函数调用,都是一个栈帧(stack frame)。
以下代码:

int add(int x, int y)
{
    int z;
    z = x + y;
    return z;
}

int main(int argc, char* argv[])
{
    add(3, 5);
    return 0;
}

那么main函数是一个栈帧,add是一个栈帧。
当程序运行时,main函数栈帧先被建立,这个栈帧在高地址。然后调用add函数。此时add函数栈帧被建立,在低地址。当程序执行流进入add函数时,add函数内的局部变量在add函数栈帧中被建立。然后add返回。当add函数返回,此时add函数栈帧被销毁,同时add函数内的局部变量也被销毁。所以,C/C++编程原则告诉我们:永远不要返回一个指向局部对象的指针。也就是说如下代码是错误的:

int* getNumber(void)
{
    int a = 3;
    return &a;
}

那么运行时的栈是什么样子的呢?它是一个随着运行,不断增长(进入新的函数调用)和缩短(函数返回)的动态影像。

从汇编语言看函数调用

以下是MASM文法编写的汇编语言程序

.386 ;386系统
.MODEL FLAT ;32位平坦地址模式

Exit PROTO NEAR32 stdcall, dwPara:DWORD ;退出函数原型
                                                ;Exit是函数名dwPara是函数参数

.STACK 4096 ;保留4096字节栈空间

.DATA ;数据段定义全局变量
number1 DWORD 11111111h ;定义变量number1大小4字节
number2 DWORD 22222222h ;定义变量number2, 大小4字节

.CODE ;程序代码
Init PROTO NEAR32 ;定义函数Init
        mov number1, 0 ;假设该指令地址为0x0040 0000

mov number2, 0
        ret ;函数Init返回
Init ENDP ;函数Init结束

_start: ;相当于main函数
        call Init ;调用函数Init此指令地址为0x0040 000f
         ...... ;该处指令地址为0x0040 0014
       
        INVOKE Exit, 0 ;调用Exit退出

PUBLIC _start ;公开入口点

END ;程序结束

其实代码不用看的...
假设程序被加载入内存,这时esp被初始化,然后esp指向栈顶。设此时栈顶地址为0x0063 00f8.一切为了说明方便哈。总之程序加载后,栈被初始化,也就是esp被初始化,esp会指向内存中的某个地址,并以这个地址作为栈的起始。
eip始终指向执行流,也就是“下一条指令”。

那么程序加载。栈初始化了。数据区域在内存中开辟出来了,全局变量被给予确切地址(这里是虚拟地址,因为这是一个进程,它的地址只管在虚拟地址空间中给就可以了,虚拟地址到物理地址的映射由操作系统和MMU完成)。代码段(也就是要执行的指令)也被放入内存中并给予确切地址。eip指向代码段的开始,并开始执行程序...

所以eip只管指向某个内存地址,这个内存地址存储着程序员编写的指令,然后CPU把指令取出来执行就是了。所以计算机叫做“顺序存储控制机”。对不起我啰嗦了。

好的。我们假设了,在程序加载后,esp被初始化为0x0063 00f8,并假设了mov number1, 0这个指令的地址在0x0040 0000,根据这个假设的地址和每个指令码的长度(这些指令都放在代码段,而且一个一个指令就是挨着放的),推断出call指令的地址是0x0040 000f,call指令的下一条指令的地址是0x0040 0014(因为这个call指令的长度占用5个字节,0x0040 000f + 5 = 0x0040 0014)。这里不算我对指令长度的计算错误,总之假设我的地址计算是正确的。

OK开始了。程序已经加载。那么开始程序执行。eip首先指向call指令,因为_start开始那里就是call指令。嗯,eip就是一个32位寄存器,这个寄存器里面的值永远是即将执行的指令的内存地址,这时eip里面的值是0x0040 000f。

call指令执行!该指令首先将下一条指令的地址压入栈,也就是说,call指令的第一个动作是将0x0040 0014(call指令的下一条指令地址)压入栈。esp此时变化,其值变为0x0063 00f4。为什么?因为esp被初始化为0x0063 00f8,一个地址4个字节入栈之后,esp = esp - 4。然后call指令转去调用Init过程代码。eip变化为0x0040 0000,为什么?因为Init过程的第一个指令地址就是0x0040 0000.这个过程是由CPU自动完成的,也就是说,call指令,让CPU自动完成这一系列动作。

然后Init过程执行到ret指令。
ret指令干什么?它将栈内数据弹出,并用该数据填充eip。栈内数据是什么?就是0x0040 0014,它就是call指令的下一条指令的地址!同时esp = esp + 4.也就是说,ret指令执行后,eip值变为0x0040 0014, esp的值变回0x0063 00f8.这个过程由CPU自动完成。ret指令让CPU自动完成这一系列动作。

整理:执行call,call指令首先将下一条指令地址入栈,然后跑去执行过程代码;过程代码中执行ret,ret首先从栈中将下一条指令地址弹回eip,这样程序就开始执行call指令后的指令。一句话:eip始终指向下一条指令地址。

以上!就是汇编函数调用和返回的过程。就是一个call和一个ret.eip在这个执行过程中通过栈来保存。

接下来,让我们开始考察C语言的过程调用和返回,也就是C语言函数的参数压栈和参数访问过程。

先看一个汇编调用压参和参数访问过程。

假设有一个add过程,这个过程的工作是将两个整型值(每个整型值4字节)相加,并将相加的和返回eax寄存器。
如果通过把参数压入堆栈来传递参数调用过程,那么调用方(caller)代码如下:

 push var1 ;第一个变量值
 push var2 ;第二个变量值
 call add ;调用add过程
 add esp, 8 ;从栈移除参数

而被调用过程(callee)add的代码如下:

add PROC NEAR32 ;add过程该过程将两个整型值相加
    push ebp ;保存基栈指针
    mov ebp, esp ;建立栈
    mov eax, [ebp + 8] ;复制第二个参数值(var2)
    mov eax, [ebp + 12] ;加上第一个参数值(var1)
    pop ebp ;恢复ebp寄存器
    ret ;过程返回
add ENDP ;过程结束

首先,esp是栈顶,直接从caller栈顶看起。也就是,在调用前,esp指向某个内存地址。
在调用函数前将参数压入栈中。
push var1
push var2
这两行代码使esp - 8. 然后压参完毕,图中即为压参完毕esp.
然后调用函数:
call add
嗯,之前复习call指令时说什么了?call指令执行时,首先将返回地址压入栈。
也就是将add esp, 8 这条指令的地址压入栈。

然后call指令执行过程调用,eip指向add函数内第一条指令的地址:
push ebp ;将ebp保存到栈中,同时esp - 4(说过了80X86的栈是向低地址方向增长的).
此时ebp原值被保存入栈中。
然后:
mov ebp, esp
此时以ebp为基准的栈建立了。此时ebp和esp都指向栈顶(ebp原值被栈保存起来了哦)。
为什么要这么做?
因为esp是随时变动的,只要有压栈和出栈的操作,esp的值就随着压栈和出栈的操作变化(随着push和pop操作变化,甚或,程序员直接改动esp的值)。
而ebp却不会随着push和pop操作变化。程序员在callee中不会修改ebp的值,而是使用ebp作为基准访问参数。

那么接下来就很好理解了,第二个参数的地址是ebp + 8, 第一个参数的地址是ebp + 12.
所以
mov eax, [ebp + 8] ;复制第二个参数值(var2)到eax
mov eax, [ebp + 12] ;加上第一个参数值(var1)
就不难理解了。

在过程把实现代码处理完毕的最后,pop ebp将ebp原值从栈中弹出恢复。
然后ret返回指令将返回地址弹出并赋给eip(请注意,返回地址弹出后,esp + 4, 这时esp正好指向调用者压参完毕的位置),...
回到调用者的地方并继续执行。

那么调用处的add esp, 8 ;从栈移除参数
是干什么用的?注释已经说得很清楚了。
调用者将var1和var2压到栈中,由于调用者的压栈,esp被往下移动了8;那么这个esp的原始位置也就是caller的栈顶应该在过程调用后恢复,add esp, 8就是恢复esp的。

ok。基本上就是如此了!

对于C语言的过程调用,比如,在main函数里面调用add

int main(int argc, char* argv[])
{
    ...
    add(x, y);
    ...
}

实际上,这里add(x, y)(调用者处)被编译器编译成如下汇编代码:

push y
push x
call add
add esp, 8

以上,这就是C过程调用的汇编解释。

接下来给出一般过程的入口代码和出口代码。

不难猜测,所有的过程(被调用函数)都有一样的入口代码和出口代码:

所有的C函数,在被编译器编译成汇编代码之后,
函数开始的几行汇编代码总是这样的,所以我们称这它为入口代码(entry code):

push ebp ;保存基址
mov ebp, esp ;建立ebp偏移基准
sub esp, n ;n个字节的局部变量参数
push ... ;保存过程中会用到的通用寄存器
...
pushf ;保存标识寄存器也就是保存标志位

而结尾的几行总是这样的,所以称其为出口代码:

popf ;恢复标识寄存器
pop ... ;恢复寄存器
...
mov esp, ebp ;恢复callee esp
pop ebp ;恢复ebp
ret ;返回

stdcall和cdcel


既然已经了解了上述内容,那么调用惯例就很容易理解了。
cdcel和stdcall是约定俗成的调用惯例,它们的区别在于由谁来恢复esp。

cdcel是由调用者恢复esp的调用惯例,
也就是说

push var1
push var2
call add
add esp, 8

这是cdcel调用惯例

而stdcall则是由callee恢复esp的调用惯例
stdcall会在callee里面将ret这样写:

ret 8

意思是返回的同时esp + 8.

这两种调用惯例,stdcall的好处是不用每次都在调用过程后写add esp, 8这样就减小了代码量,减小了目标文件的体积。
而stdcall的缺陷更明显,那就是callee有时候无法推断参数的个数和长度,这样的话esp只能由调用者恢复(比如变参数函数,这种函数callee是无法推断参数个数的,也就无法知道应该在ret后面加多少偏移量)。

EOF

@AlexiaChen AlexiaChen added c/cpp C and CPP programming language 计算机体系结构 CPU处理器相关 labels Oct 16, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c/cpp C and CPP programming language 计算机体系结构 CPU处理器相关
Projects
None yet
Development

No branches or pull requests

1 participant