执行过程

首先,程序在Linux中由若干条指令构成,这些指令存在内存的某个位置,这个位置是由Linux指定分配,在分配到内存之前,存储在硬盘中,CPU要调用时,Linux才将他们分配至内存。

随后Linux告诉CPU程序入口点,即第一条指令的地址,CPU到相应地址获取指令,随后开始执行。

那么CPU是如何读取指令的呢?

这里简单说一下,程序映射至内存后,PE loader将程序入口赋值给CPU的eip寄存器,然后通知CPU去执行,CPU读取并传送至指令缓冲区,此时eip的值增加,即下一条指令,最后执行。如此反复。

eip寄存器:用来存储CPU要读取指令的地址,CPU通过eip寄存器读取即将要执行的指令。每次CPU执行完相应的汇编指令之后,eip寄存器的值就会增加。

指令一般包括以下几类:

1.把数据从内存加载到寄存器中

2.对寄存器数据进行运算

3.将寄存器的数据写入到内存

一旦遇到这样的指令:"把寄存器ebp的值压到栈中",函数调用就开始了。

ebp寄存器:一种特殊的寄存器,始终指向当前函数在一个栈的开始地址。对应栈帧开头。

esp寄存器:一种特殊的寄存器,始终指向当前函数在一个栈的结束地址。对应栈帧结尾。

所以epb和esp之间的地址包含的指令就是对应的一个函数的信息。

那么函数信息为什么在栈中?这里要说一下JVM内存模型。

JVM内存区域分为:线程私有区(程序计数器、虚拟机栈、本地方法区)、线程共享区(堆、方法区)、直接内存。

直接内存:不受GC管控,用于提升特定场景下的性能(比如nio),避免java堆和native堆之间频繁来回复制数据。

本地方法区:和虚拟机栈类似,区别为它是为native方法服务。HotSpot直接将本地方法栈和虚拟机栈合二为一了。

方法区:存储被JVM加载的类信息、常量、静态变量、即时编译后的代码等。HotSpot为了将GC分代收集扩展至方法区,使用Java堆的永久代实现方法区。Java8中永久代被移除,使用元数据区代替,不在虚拟机中,使用本地内存。

虚拟机栈:java方法执行的内存模型,每个方法执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

所以,多个栈帧(函数帧)在内存中排列起来,就像一个先进后出的栈一样,不过这个栈是从高地址向低地址排列。(栈底在上面)

那么以下代码是如何运行的呢?
int x = 10;
int y = 20;
int sum = add(&x, &y);
printf("the sum is %d\n",sum);

假如ebp地址为800,esp地址为776,每次操作4字节(在32位平台上,esp每次减少4字节)

“将值10放入ebp减4的地址(796)”

“将值20放入ebp减8的地址(792)”

“将地址796作为数据放到esp指向的地址776中”

“将地址792作为数据放到esp+4指向的地址780中”

“调用函数add”

到这里CPU会找到add函数返回以后的那条指令地址(假设地址是100),把它压入栈中

这时,将地址100的指令压入栈后,esp也发生了变化,指向了772的位置(776减4)

因为esp始终指向当前栈帧尾部

找到函数add 的指令,继续执行(ebp:800、esp:772)

一个标准的函数起始代码
push ebp ;保存当前ebp
mov ebp,esp ;EBP设为当前堆栈指针
sub esp, xxx ;预留xxx字节给函数临时变量.

“把寄存器ebp的值压到栈里去”(ebp:800、esp:768)

“把esp的值赋给ebp”(ebp:768)

“把寄存器ebx的值压入栈”(ebp:768、esp:764)

这里额外把ebx这个寄存器压入栈, 是因为ebx可能被上个函数使用, 但是在add函数中也会用 , 为了不破坏之前的值, 只能暂时放到内存里。

此时,768位置存储着 800,即上个函数帧的开始位置,764存储着ebx的值。

800到772为调用者的函数帧,768到764为被调用函数的帧

“把ebp加8的数据取出放到edx寄存器,即776,&x”

“把ebp加12的数据取出放到ecx寄存器,即780,&y”

“把edx中的值所指向的地址的数据取出来放到ebx中”

“把ecx中的值所指向的地址的数据取出来放到eax中”

此时就取到了值,ebx=10,eax=20

想必add函数的源码应该是

int add(int *xp , int *yp){
    int x = *xp;
    int y = *yp;
    ...
}

“把ebx和eax的值加起来,放到eax寄存器中”

add函数已经完成,准备返回

“把esp指向的数据弹出到ebx寄存器”(恢复ebx之前的值)

“把ebp指向的数据弹出到ebp寄存器”(将ebp重新指向原函数帧所指向的800位置)

ebp:800、esp:772(因为数据弹出,所以栈底变为772)

此时add函数帧消失,换句话说,add函数帧的数据还在内存里,只不过我们不再关心。

“返回”

CPU会取出那个返回地址,也就是100,去这里找指令接着执行

printf(“the sum is %d\n”,sum);

而sum的值就存在eax寄存器里。

eax 是累加器(accumulator),它是很多加法乘法指令的缺省寄存器。

ebx 是基地址(base)寄存器,在内存寻址时存放基地址。

ecx 是计数器(counter),是重复(REP)前缀指令和LOOP指令的内定计数器。

edx 总是被用来放整数除法产生的余数。

总结

函数调用,关键是:

1.把参数和返回地址准备好

2.然后各自都遵循约定,每次新函数都建立新的函数帧,即上文提到的标准的函数起始代码

3.函数调用完,重置ebp和esp,让他们重新指向调用者的函数帧。

参考文章:函数调用的秘密