- 系列1 C语言执行过程
- 系列2 C程序方法调用
- 系列3 CS:IP 寄存器
操作系统在内核程序初始化之后就会跳转执行我们的额main函数,操作系统是怎么将控制权转移给mian函数的呢?我们首先描述一下C函数的调用机制,控制权转移机制,然后使用案例代码逐步模拟运行过程。
函数调用操作是从一块代码到另一块代码之间的双向数据传递和执行控制转移。数据传递是通过函数参数和返回值来实现的。执行控制权转移是通过寄存器来完成的。
栈帧结构和执行控制权转移大多数 CPU 上的程序使用栈来支持函数调用操作。栈被用来传递函数参数、储存返回信息、临时保存寄存器原有值以备恢复以及用来储存局部数据。**单个函数调用操作所使用的栈部分被称为栈帧(stack frame)结构。**以OS栈为运行时栈的栈帧结构的两端由两个指针来指定(JavaScript运行时的栈是 V8 控制的,所以JavaScript运行时栈应该被称为JavaScript栈,不是OS栈,V8 将 JavaScript 数据结构处理成对应的C语言数据结构以后,对应的运行时栈是 OS 栈)。
通常寄存器 ebp 被用作帧指针,随程序的运行而移动,esp 用作栈指针,指向当前栈帧结构的栈底。
函数调用举例接下来使用一个例子来观察C语言中函数函数调用的处理过程。
int add();
int main() {
int c = add();
return 0;
}
int add() {
int z = 1 + 2;
return z;
}
我们将上面的C程序对应的汇编简化后得到的结果是:
main:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
call add
movl $0, %eax
leave
ret
add:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl $3, -4(%ebp)
movl -4(%ebp), %eax
leave
ret
1 保存栈基并分配栈空间
pushl %ebp movl %esp, %ebp subl $16, %esp
pushl %ebp 保存调用者的栈基地址。这条指令的调用者是谁呢?也就是说谁能调用C语言的main函数呢?当然是操作系统。可以参考下文的main函数解释。movl %esp, %ebp 将调用者的栈基地址指向栈顶,为接下来将调用者的栈顶作为被调用者的栈基做准备。这两句指令完成了函数运行时栈空间的分配和运行时栈控制权的转移。
subl $16, %esp 分配栈空间。其中subl表示减,指令中的%esp表示当前栈顶,就是将当前栈顶减去32字节的长度(在linux中,栈是向下增长的,从内存的高地址向低地址增长)。执行该指令之前main函数的栈空间如图所示:
执行该指令之后main函数的栈空间如图所示:
因为寄存器的数量是有限的,在函数调用过程中我们要尽可能复用寄存器,所以 %ebp 和 %esp 之间的空间就被看当前运行函数的栈空间,一直被复用。调用 ret 指令之后会恢复调用者的 %ebp 和 %esp 的值,也就能使栈的控制权在不同的函数之间转移。
2 函数调用指令 callcall add
调用 call 指令。
这条指令相当于执行了两个指令:push eip & jump near ptr。将寄存器 eip 压栈,跳转到下一条指令的位置。
接下来执行:
pushl %ebp movl %esp, %ebp subl $16, %esp
此时栈空间如图所示:
此时发现在 main 函数和 add 函数之间有8字节用来储存eip和ebp的值,他们各自有什么用处呢?答案很简单,使用这两个值是为了在add函数执行完毕之后能够恢复 main 函数对栈的控制权。
CPU所执行的指令位置由 CS:IP 这两段寄存器共同决定,这里的IP就是指eip寄存器。关于 CS:IP 寄存器可以参考:(CS:IP 寄存器)[https://blog.csdn.net/FYSGXFZ/article/details/125384601]。eip 保存的是 call add 下一条指令的位置。ebp保存的是main函数的栈底位置。
3 计算数据movl $3, -4(%ebp)
这句指令的含义是将数字 3 储存在相对于 ebp 向下移动四个字节的位置。
如图所示:
4 add 函数返回函数返回很简单,将返回值保存到寄存器中,然后执行两条例行执行就OK了。
movl -4(%ebp), %eax leave ret
movl -4(%ebp), %eax 将3储存在eax寄存器中。
函数的返回一般逻辑是,如果有返回值,就把返回值放在eax寄存器中,然后执行 leave 和 ret 指令。如果没有返回值,则直接执行 leave 和 ret 指令。 leave 和 ret 指令实际上封装了多个指令。接下来我们分析一下程序是怎么回到main函数中的?
这时候前面入栈的eip就起到作用了。我们将 leave 指令拆解,可以得到:
movl %ebp %esp popl %ebp
将 ret 指令拆解,得到:
pop eip
mov %ebp, %esp 将ebp记录的值赋值给esp,相当于回收add函数栈帧。
如图所示:
当指令 pop %ebp & pop eip 执行结束以后,将ebp和eip弹出栈。
ebp记录main函数栈底位置,eip记录main函数将要执行的下一条指令的位置。
此时内存图为:
执行指令:
movl $0, %eax leave ret
逻辑和add函数相同。最终main函数的栈帧将会被回收。
main函数解释**C语言主程序中的main()只是一个普通的函数。**在 gcc 之前的版本中,源程序经过链接之后会将main函数作为汇编程序的一个函数调用。如下所示:
// 这段代码是gcc源码中的一部分。 pushl %ebp movl %esp, %ebp call 0x402100 <__main>
在由gcc代码将栈控制权转移到main函数的时候,会自动将eip和ebp入栈。



