C语言函数栈帧实例

我们通过把一段具有函数调用的C语言代码编译成汇编,逐步分析函数栈帧生命周期的完整变化过程。

有如下C语言代码:

long callee(long arg1, long arg2, long arg3, long arg4, long arg5, long arg6, long arg7, long arg8) {
    return arg7 + arg8;
}

int main() {
    long a = 7;
    long b = 8;
    callee(1, 2, 3, 4 ,5 ,6, a, b);
    return 0;
}

代码中callee函数有8个参数分别是arg1~arg8,它返回arg7arg8相加后的结果。由于我们的代码是运行在X64的机器上,所以arg1~arg6会通过寄存器来传递,arg7arg8通过栈来传递。main中定义了两个局部变量ab,它对callee发起调用,局部变量a,b分别对应callee函数的arg7,arg8

将上面C语言代码编译成汇编,把由编译器产生的其他与我们分析函数栈帧不相关的指令删除,得到如下指令(根据编译器版本以及操作系统的不同编译出的汇编指令会有所差异,这里使用的编译器和操作系统分别是gcc9.0ubuntu 20.04 x-64):

callee:
    pushq    %rbp
    movq    %rsp, %rbp
    movq    %rdi, -8(%rbp)
    movq    %rsi, -16(%rbp)
    movq    %rdx, -24(%rbp)
    movq    %rcx, -32(%rbp)
    movq    %r8, -40(%rbp)
    movq    %r9, -48(%rbp)
    movq    16(%rbp), %rdx
    movq    24(%rbp), %rax
    addq    %rdx, %rax
    popq    %rbp
    ret
main:
    pushq    %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp
    movq    $7, -16(%rbp)
    movq    $8, -8(%rbp)
    pushq    -8(%rbp)
    pushq    -16(%rbp)
    movl    $6, %r9d
    movl    $5, %r8d
    movl    $4, %ecx
    movl    $3, %edx
    movl    $2, %esi
    movl    $1, %edi
    call    callee
    addq    $16, %rsp
    movl    $0, %eax
    leave
    ret

指令中的callee:main:表示的是为它下面的指令取一个名字,可以理解为函数名。

X64栈指针栈基址分别叫rsp,rbp

我们逐步分析这些指令对堆栈的影响,首先是main的第前两行指令:

pushq    %rbp
movq    %rsp, %rbp

实际上main函数是被系统内一个叫做_start的函数所调用,所以第1条指令将调用者_start的栈基址压栈保存,第2条指令移动栈基址使它指向和栈指针同样的位置。

栈变化过程(在一开始我们栈指针栈基址指向_start的栈帧,这里没有画出_start的栈帧也就没有标注它们):

初始化局部变量并准备callee的后两个参数:

subq    $16, %rsp
movq    $7, -16(%rbp)
movq    $8, -8(%rbp)
pushq    -8(%rbp)
pushq    -16(%rbp)

第1条指令中的subq是减法指令,这里用于把栈指针减去16,使它向低地址移动来给局部变量a,b分配16字节的空间(long占8个字节)。

第2~3条指令中的movq是数据移动指令,在这里它通过对栈基址进行适当偏移来对a,b赋值。例如movq $7, -16(%rbp)表示把7放到相对于栈基址-16的内存中。下图中可以看到这两条指令执行完以后-16-8的位置刚好放置了变量a,b

第4~5条指令把栈帧中a,b的值复制一份然后把它们压栈,这是为了准备函数callee的后2个参数arg7arg8

栈变化过程(图中右侧的数字表示基于栈基址的偏移量,每个格子8字节,由于栈空间是向低地址发展的,所以相对于栈基址上面内存的偏移量是负的):

callee的前6个参数arg1~arg6存放在寄存器中:

movl    $6, %r9d
movl    $5, %r8d
movl    $4, %ecx
movl    $3, %edx
movl    $2, %esi
movl    $1, %edi

开始调用callee

call    callee

这条指令对应了两个操作,首先将它下面一条指令的地址也就是addq $16, %rsp的地址(返回地址)压入栈中,随后修改程序计数器(PC)为callee的第一条指令的地址,最后CPU就从callee处开始执行。

这条指令执行后的栈:

现在CPU从callee开始执行:

pushq    %rbp
movq    %rsp, %rbp

先压入main栈帧的栈基址,然后移动栈基址使它指向和栈指针同样的位置。此时产生了callee的栈帧:

第2条指令使栈基址发生了改变,相应的右图中基于栈基址的偏移量也需要发生变化(由于栈空间是向低地址发展的,所以相对于栈基址下面内存的偏移量是正的):

复制前6个参数到栈帧中:

movq    %rdi, -8(%rbp)
movq    %rsi, -16(%rbp)
movq    %rdx, -24(%rbp)
movq    %rcx, -32(%rbp)
movq    %r8, -40(%rbp)
movq    %r9, -48(%rbp)

这6个寄存器rdi,rsi,rdx,rcx,r8,r9分别存放了callee的前6个参数,现在将它们从寄存器中复制到callee自己的栈帧中。我对这个操作有点迷,为啥还需要复制呢,直接从寄存器中取出来使用不就行了吗?我想可能是为了腾出寄存器吧。

执行之后的栈结构:

执行到这里奇怪的事情发生了,栈指针并没有继续向低地址移动,让它指向栈顶的位置。其实这里是编译器的优化,栈指针的目的是为了确定被调用函数的栈基址,由于callee没有再继续调用其他任何函数,因此也无需修改栈指针

执行相加:

movq    16(%rbp), %rdx
movq    24(%rbp), %rax
addq    %rdx, %rax

arg7arg8相加,然后把结果放在rdx寄存器,mian函数可以访问rdx寄存器来获取返回值,以此来达到函数返回值传递的目的。

返回到main

popq    %rbp
ret

恢复栈基址并跳转到返回地址处开始继续执行。

栈变化过程:

返回到_start

addq    $16, %rsp
movl    $0, %eax
leave
ret

第1条指令中的addq是加法指令,这里用于把栈指针加上16,使它向高地址移动以此来释放arg7,arg8的内存。通过这一步可以发现,函数执行完后会立马释放参数的栈内存。

第2条指令把main的返回值0放入到eax寄存器中。

第3条指令leave隐含执行了两个操作,它等价与下面两条指令:

movq    %rbp, %rsp
popq    %rbp

先修改栈指针使它与栈基址指向同样的位置,这一步主要用于释放局部空间,然后恢复栈基址。此时栈指针栈基址就分别指向了_start的栈帧。

到这里我们发现maincallee的返回过程不一样,这是由于在callee一开始没有修改栈基址,所以返回的时候也就不需要再对它进行恢复。由此我们可以得出一个结论:如果某个函数调用了其他函数和没有调用其他函数,它们在返回时的过程有略微的不一致。

第4条指令ret使CPU返回到_start中去执行,最后一切又恢复了平静。

栈变化过程:

最后我们发现数据仍然还保存在栈中,因为释放栈空间并不会修改里面的数据,只是对栈指针栈基址做了移动。

results matching ""

    No results matching ""