首先是执行控制权的转移,我计划在计算机量化架构系列文章中进行描述。
目前,可以简单地认为CPU提供了一些简单的指令来实现控制传输。
第二,数据的传输。 这部分其实就是传递参数和返回,也就是本文的内容。
文章介绍栈框架结构及构建原理
x86中函数调用使用的堆栈框架图
x86-64 规范中寄存器的部分描述
栈帧结构解读(x86平台):
x86中只有8个32位通用寄存器,eax、ebx、ecx、edx、ebp、esp、esi、edi。 ebp基址指针寄存器(base),可以用来直接访问栈中的数据。 它用于保存我们的堆栈空间中的堆栈底部地址。 从编程的角度来看,栈底指针保存在ebp中。 esp栈指针寄存器(stack),用于保存栈空间中的栈顶地址。 这8个通用寄存器扩展为64位,与x86相比,通用寄存器的数量增加到16个。实际上,基地址寄存器是rbp,堆栈指针寄存器是rsp。 每个函数在执行时都有自己的堆栈空间,称为堆栈帧。 栈帧代表一个处于执行状态的函数。 每个堆栈帧都有自己的堆栈底部和顶部。 栈底ebp的值和栈顶esp的值所代表的地址指针数据传输需要依赖ebp和esp这两个寄存器(此时请注意区分差异) ebp 的值、esp 的值以及 ebp 寄存器和 esp 寄存器之间。)。 ebp寄存器和esp寄存器只能保存当前堆栈空间中最新堆栈帧的栈底地址和栈顶地址。 这也体现了函数调用时,需要保存函数间栈底和栈顶的值,即被调用函数需要保存被调用函数的栈帧中的ebp和esp的值调用函数,释放 ebp 和 esp 寄存器以供使用。 被调用函数的堆栈帧描述。
每个函数在活动时都有自己的堆栈空间。 栈空间由栈底的ebp帧指针和栈顶的esp栈指针描述。 栈指针esp随着数据的压入和出栈而上下移动,以扩展或收敛栈帧空间,而帧指针ebp在栈的第一步就已经被初始化并固定在栈帧的底部帧的生成,因此大部分堆栈数据的寻址都会通过帧指针ebp的偏移来实现。
当堆栈帧空间发生变化时,堆栈指针寄存器的值将由CPU自动维护。 即当栈帧扩展或收缩时,esp指针寄存器中的值会实时自动更新。 当然,也有可能是编译器主动更新的。 这些好处包括允许程序员更好地调试程序。
在函数调用开始时,比如使用call指令进行函数调用,call指令会先保存返回地址,将call指令的下一条指令的地址压入调用函数的栈帧中,然后跳转到被调用函数的栈帧。 初始化一个新的堆栈帧操作。 因此,在函数调用过程中,调用函数的栈帧顶部就是返回地址。
函数参数传递
函数参数传递在不同的架构、不同的编译器中可能有不同的使用场景。 例如,在一个平台上,如果参数少于6个,就会使用通用寄存器来传递参数。 如果被调用函数使用这些参数,它也会按寄存器顺序压入被调用函数的堆栈中。 此时,这些参数可以看作是从左到右压入堆栈的。 但如果函数调用时的参数传递直接使用栈,那么函数的参数是按照从右到左的顺序压入栈的。
几乎在所有函数调用汇编代码的开头,都可以看到下面的代码段
pushq %rbp
movq %rsp, %rbp
或者
pushl %ebp
movl %esp, %ebp
这部分是函数调用栈帧的初始化代码。 压入基地址寄存器就是将基地址指针寄存器的值压入堆栈,即将调用函数当前保存的栈帧地址压入堆栈。 当被调用函数稍后返回时,可以重新获取这个帧指针的值,同时腾出ebp寄存器,为新的栈帧做准备。
mov %esp, %ebp就是将esp寄存器中当前保存的栈顶地址放入%ebp寄存器中,从而更新帧地址。 这个新的帧地址就是被调用函数在栈空间中的起始地址,后续可以方便的使用ebp的偏移量来寻址调用函数或者被调用函数的栈帧中的值。
测试程序以验证函数调用
//swap.c
#include "stdio.h"
#define P(...) printf(__VA_ARGS__)
void swap(int *a, int *b, int c, int d)
{
int tmp;
tmp = *a; *a = *b; *b = tmp;
P("a = %p\n", a);
P("b = %p\n", b);
P("&c = %p\n", &c);
P("&d = %p\n", &d);
}
/*__attribute__((regparm(0))) */int main()
{
int a = 10, b = 5, c = 3, d = 1;
swap(&a, &b, c, d);
return 0;
}
64位编译获得程序集:gcc -Wall -S -m64 -O0 -o .s swap.c
.s中主要部分的截图
分析如下:
在主函数中定义并初始化的巨大局部变量int a=10、b=5、c=3、d=1将首先按顺序压入堆栈。 然后准备调用函数swap,将传递给swap的参数放入四个通用寄存器中,然后调用swap。 也就是说,在64位环境下,参数确实是通过寄存器传递的。
64位获取可执行文件:
gcc -m64 swap.c -o swap64
./swap64
a = 0x7fffccc9db58
b = 0x7fffccc9db5c
&c = 0x7fffccc9db1c
&d = 0x7fffccc9db18
以上印值是一个介绍,涉及的内容较多,会在组装系列中展开。
32位
gcc -Wall -S -m32 -O0 -o .s swap.c
main:
endbr32
leal 4(%esp), %ecx
andl $-16, %esp
pushl -4(%ecx)
pushl %ebp
movl %esp, %ebp
pushl %ecx
subl $36, %esp
call __x86.get_pc_thunk.ax
addl $_GLOBAL_OFFSET_TABLE_, %eax
movl %gs:20, %eax
movl %eax, -12(%ebp)
xorl %eax, %eax
movl $10, -28(%ebp)
movl $5, -24(%ebp)
movl $3, -20(%ebp)
movl $1, -16(%ebp)
pushl -16(%ebp)
pushl -20(%ebp)
leal -24(%ebp), %eax
pushl %eax
leal -28(%ebp), %eax
pushl %eax
call swap
addl $16, %esp
movl $0, %eax
movl -12(%ebp), %edx
xorl %gs:20, %edx
je .L4
call __stack_chk_fail_local
.L4:
movl -4(%ebp), %ecx
leave
leal -4(%ecx), %esp
ret
可以看到,在32位环境下,栈是从右向左压栈的。
可执行文件
gcc -m32 swap.c -o swap32
./swap32
a = 0xffaf82dc
b = 0xffaf82e0
&c = 0xffaf82c8
&d = 0xffaf82cc
深层思考
第一个问题:执行上面的64位和32位可执行目标文件后,打印出的值是多少?
这个问题不可能一下子全部回答出来,所以这就涉及到其他内存空间的布局。 稍后会发表新文章,仅从这个角度进行验证。
第二个问题:从上面的汇编可以看出,扩展栈帧空间时,并没有按照压栈顺序逐步扩展栈空间,并且移动esp指针。 而是扩展到能够满足功能的栈帧。 空间,为什么?
第三个问题:在栈帧空间中,似乎有些空间没有被使用。 比如可以看到栈是从-20(%rbp)直接压入栈底的,中间看不到-4(%rbp)。 用法,为什么?
C语言实现简单的用户栈
#include "stdio.h"
#include "stdlib.h"
//data struct
typedef struct stack {
int data;
struct stack *next;
}Stack;
//push stack
Stack* push(Stack *stk, int newdata)
{
Stack *newstack = (Stack*)malloc(sizeof(Stack));
newstack->data = newdata;
newstack->next = stk;
printf("push %d\n", newdata);
return newstack;
}
Stack* pop(Stack *stk)
{
if(stk != NULL)
{
Stack *tempstack;
tempstack = stk->next;
printf("pop %d\n", stk->data);
free(stk);
return tempstack;
}
else
printf("empty stack now!\n");
return stk;
}
void print_stack(Stack *stack)
{
printf("stack data :\n");
while(stack != NULL)
{
printf("%d\n", stack->data);
stack = stack->next;
}
}
int main(int argc, char *argv[])
{
Stack *stack = NULL;//初始化一个空栈
if(argc < 2)
{
printf("please input data into stack try again!\n");
return 0;
}
for(int tmp = 1; tmp < argc; tmp++)
{
int data = atoi(argv[tmp]);
stack = push(stack, data);
}
stack = pop(stack);
print_stack(stack);
return 0;
}
编译后执行
./a.out 1 2 3 5
push 1
push 2
push 3
push 5
pop 5
stack data :
3
2
1
总结
函数调用可分为5步
初始化函数的栈帧:
将前一个堆栈帧的帧指针和更新后的堆栈空间的堆栈指针保存到基地址寄存器中,设置当前堆栈帧的帧指针地址,并设置当前函数堆栈帧的局部变量。
初始化局部变量,包括分配内存空间、压入堆栈等,以执行当前函数的操作。
这里你可能需要执行save等操作来保存函数调用中传递的参数。
x64默认使用6个通用寄存器进行参数传递。 这不一定会发生。 我们可以使用编程方法通过栈来传递参数,比如宏; x86 默认情况下从右到左通过堆栈传递参数。 传递的参数按顺序压入堆栈,保存返回地址并执行函数跳转。
上面的步骤不一定是绝对的,但如果你熟悉计算机的底层原理,如何改变应该是一个难题。
有兴趣的朋友可以关注我的公众号