c代码
在linux系统上,动手写一个demo.c小程序
#include <stdio.h>
int func0(int a, int b)
{
int t;
t = a + b;
return t;
}
int main(void)
{
int t;
t = func0(10, 20);
printf("%d\n", t);
return 0;
}
该程序在func0函数中做了加法运算,并通过printf系统调用打印了出来。
gcc demo.c -o demo
./demo 运行之后将在屏幕上打印30。
汇编
加下来,我们可以根据c语言代码执行流程写一个汇编文件demo.S,如下
.global main
PRINTTEXT:
.string "%d\n"
.text
func0:
/*进入func0堆栈*/
pushl %ebp
movl %esp, %ebp
/*预留空间*/
subl $16, %esp
/*
取出参数10
相当于 movl $10, %eax
*/
movl 8(%ebp), %eax
/*
取出参数20
相当于movl $20, %edx
*/
movl 12(%ebp), %edx
/* 做加法,30赋给了edx */
leal (%eax, %edx), %eax
/* 将加法的值赋值给t,既放入预留空间的其他空余位置 */
movl %eax, -4(%ebp)
/* eax承接了返回值大小 */
movl -4(%ebp), %eax
/* func0函数出栈 */
leave
ret
main:
/* 进入main函数堆栈 */
pushl %ebp
movl %esp, %ebp
/* 预留空间,大小调整可根据情况设定 */
subl $32, %esp
/* 参数入栈,使用的是预留空间 */
movl $10, (%esp)
movl $20, 4(%esp)
/* 调用func0函数 */
call func0
/* 将func0返回值放入变量t,预留空间的其他空余位置 */
movl %eax, 16(%esp)
/* eax和edx承接系统调用printf的两个参数 */
movl %eax, %edx
movl $PRINTTEXT, %eax
movl %eax, (%esp)
movl %edx, 4(%esp)
call printf
/* return 0, 需要提前将返回值0赋给eax */
movl $0, %eax
/* main函数出栈 */
leave
ret
我们可以通过 gcc demo.S -o demo -m32,来完成32位demo例子程序的编译,还可以通过调整demo.S的汇编指令以及数值来观察demo程序运行结果的变化。
一些思考
上述汇编中有详细的注释可供参考,大家可以尝试着自己去写一个汇编,通过自己动手书写汇编程序,我们可以总结出来几个小知识点。
1、堆栈平衡
网上有好多关于堆栈平衡的知识讲解,可自行百度学习一下。一个函数的调用过程其实堆栈空间和寄存器的配合使用。
单纯从汇编程序来看,我们发现call指令调用的函数结束之后,我们所接下来使用esp和ebp寄存器就是call指令之前的值,既一个call指令调用完成(函数执行完成),我们的堆栈状态是没有发生任何变化的。
2、关于局部变量
学习c语言时,我们知道函数中的局部变量的作用域只限定于函数内部,函数体之外无法使用。
单纯从汇编程序来看,函数内部的局部变量其实是通过修改esp寄存器的值,所预留出来的空间,其预留大小以及每个变量存放的对应位置是由编译器决定的,当然,我们自己动手写汇编的时候可进行调整。当函数执行完成,esp与ebp复位到函数调用之前,函数运行期间所使用的堆栈空间已经不存在了(或许对应地址还有残留值,但已经无法通过正常途径访问和使用)。
3、关于传参
我们所传递的参数使用的是调用者参数的预留空间,并根据参数从右到左的顺序从高地址向低地址(栈地址空间是从高地址向低地址延伸的)依次放入该空间。预留空间可以当成一个桶,高地址是桶底,低地址是顶部,将需要传递的参数从右到左依次放入桶中,我们可以把这个过程叫做参数压入堆栈。
当然了,我们自己手写汇编的时候,顺序是可以自己指定的,只要将参数放到预留空间的某位置,并在调用函数中通过偏移ebp寄存器地址取到即可。
4、关于返回值
- 从手写的汇编程序中可以发现,我们将返回值放到了eax寄存器里面,eax寄存器可以承接返回值,并由调用者来使用。
- 如果我们返回的是一个结构体呢,eax承接不了这么多数据。这时候会在调用者的预留空间里面预留出来承接返回值的空间,并将承接返回值的空间的起始地址A和参数B、C或者D......依次倒序压入堆栈中。于是我们在堆栈中就可以通过ebp寄存器的固定地址偏移取出来A,并将我们想传递的参数放入到A中即可。
struct t_node
{
int value;
int time;
};
struct t_node func0(int a, int b)
{
int t;
struct t_node node;
node.value = a;
node.time = b;
return node;
}
int main(void)
{
int t;
struct t_node node;
node = func0(10, 20);
return 0;
}
对应的汇编:
subl $28, %esp
/*将预留的返回值空间地址传递给eax*/
leal -12(%ebp), %eax
movl $20, 8(%esp)
movl $10, 4(%esp)
/*eax值压入堆栈中*/
movl %eax, (%esp)
call func0
- 如果我们返回的是一个指针变量,既是一个地址值,那就可以将该地址值赋值给eax寄存器,通过eax寄存器传递给调用者。
5、关于函数调用过程
以函数A的调用为例
call A -> enter A stack -> 函数体 -> leave A stack
- call A的本质其实是操作了eip/rip指令寄存器,其实相当于 pushl %eip, movl $A %eip。
- enter A stack有固定的套路,基本上就是 pushl %ebp, movl %esp, %ebp 组合。
- leave A stack也有固定的套路,基本上就是 leave, ret 组合。其中leave的本质是 movl %ebp, %esp, popl %ebp,用来做堆栈恢复, 而ret的本质则是将call押入堆栈的eip地址出栈: popl %eip。
- 而经常用到的入栈操作pushl,以 pushl %ebp为例,其实相当于 subl $4, %esp, movl %ebp, %esp。
- 而经常用到的出栈操作popl,以popl %ebp,其实相当于movl %esp, %ebp, addl $4, %esp。