函数调用栈

阅读经典——《深入理解计算机系统》04

函数调用时的栈结构变化是一个很有趣的话题,本文就来详细剖析这个过程。

  1. 栈帧结构
  2. 寄存器使用惯例
  3. 这段代码的含义?

栈帧结构

计算机系统概述中我们介绍了虚拟地址空间,其中有一部分是栈,用于函数调用和存放局部变量。本文将详细介绍这部分栈空间是如何使用的。

首先引入一个概念:栈帧。栈帧是指为一个函数调用单独分配的那部分栈空间。比如,当运行中的程序调用另一个函数时,就要进入一个新的栈帧,原来函数的栈帧称为调用者的帧,新的栈帧称为当前帧。被调用的函数运行结束后当前帧全部收缩,回到调用者的帧。栈帧的详细结构如下图所示:

栈帧结构

不管是较早的帧,还是调用者的帧,还是当前帧,它们的结构是完全一样的,因为每个帧都是基于一个函数,帧随着函数的生命周期产生、发展和消亡。这里用到了两个寄存器,%ebp是帧指针,它总是指向当前帧的底部;%esp是栈指针,它总是指向当前帧的顶部。这两个寄存器用来定位当前帧中的所有空间,在后面的代码中将会经常出现。编译器需要根据IA32指令集的规则小心翼翼地调整这两个寄存器的值,一旦出错,参数传递、函数返回都可能出现问题。

下面来看一个例子。函数caller中调用函数swap_add,完成交换并相加的工作,C代码如下:

int swap_add(int *xp, int *yp)
{
    int x = *xp;
    int y = *yp;
    *xp = y;
    *yp = x;
    return x + y;
}

int caller()
{
    int arg1 = 534;
    int arg2 = 1057;
    int sum = swap_add(&arg1, &arg2);
    int diff = arg1 - arg2;
    return sum * diff;
}

首先,程序从caller开始运行,为了详细说明每一行程序都做了什么操作,我们将caller函数的C代码编译成汇编码,并给每一句附上注释:

1    caller:
2        pushl %ebp              #Save old %ebp
3        movl %esp, %ebp         #Set %ebp as frame pointer
4        subl $24, %esp          #Allocate 24 bytes on stack
5        movl $534, -4(%ebp)     #Set arg1 to 534
6        movl $1057, -8(%ebp)    #Set arg2 to 1057
7        leal -8(%ebp), %eax     #Compute &arg2
8        movl %eax, 4(%esp)      #Store on stack
9        leal -4(%ebp), %eax     #Compute &arg1
10       movl %eax, (%esp)       #Store on stack
11       call swap_add           #Call the swap_add function
12       ...

进入caller函数后,先调整两个指针的值,第2行保存旧的帧指针到当前的栈顶位置,第3行将当前栈指针的值赋值给帧指针,此时,帧指针和栈指针都指向栈顶。第4行将栈指针减小24,意思是从栈中申请24字节的空间作为当前帧空间(即caller函数所用的帧空间,至于为什么是24字节,我们最后再说)。现在,刚刚完成了预处理工作,接下来就要实现函数体要完成的功能。

分析下一步代码之前,我们先回过头来对照一下前面的栈帧结构图,将caller视为调用者的帧,预处理工作完成后,此时的帧指针指向调用者的帧的底部(在图中以我们的视角来看是上面),而栈指针指向调用者的帧的顶部。

在C代码中,我们给两个整型变量分别赋了值。对应的汇编代码为第5行,将534存入栈的-4(%ebp)位置,这是一个基址+偏移量寻址,即%ebp中的数减4的地址。第6行同理。对应栈帧结构图,5341057就保存在调用者的帧的底部和参数n之间的某个位置。接下来计算两个整型变量的地址,并将其作为swap_add的参数。在汇编代码中,函数调用的参数传递是通过把参数依次放在靠近调用者的帧的顶部来实现的。那么这两个参数就应该放在相对于当前栈顶指针%esp的+4和+0位置,第7~10行就在做这个操作。放置好参数后,就可以正式调用函数swap_add了,如第11行的指令。call指令不仅仅是跳转到子函数的位置,而且还要为子函数的正确返回做准备。事实上,call指令可以分为两步,第一步将当前程序段的下一行代码的地址入栈,第二步才是跳转到子函数的代码段,相当于如下两行指令

pushl [下一句代码的地址]
jmp swap_add

至此,在swap_add函数代码执行前,调用者的帧已经准备完毕。可以注意到,栈帧结构图中调用者的帧的栈顶正是call指令导致入栈的返回地址,后面将会介绍这个地址的用途。

接下来看swap_add函数的汇编代码:

1    swap_add:
2        pushl %ebp              #Save old %ebp
3        movl %esp, %ebp         #Set %ebp as frame pointer
4        pushl %ebx              #Save %ebx

5        movl 8(%ebp), %edx      #Get xp
6        movl 12(%ebp), %ecx     #Get yp
7        movl (%edx), %ebx       #Get x
8        movl (%ecx), %eax       #Get y
9        movl %eax, (%edx)       #Store y at xp
10       movl %ebx, (%ecx)       #Store x at yp
11       addl %ebx, %eax         #Return value = x+y

12       popl %ebx               #Restore %ebx
13       popl %ebp               #Restore %ebp
14       ret                     #Return

这段代码被我人为地分割成了三部分,2~4行为预处理部分,同前面分析过的预处理相似,保存旧的帧指针,设置新的帧指针,但多了一步:第4行将%ebx寄存器入栈。该操作是为了保存%ebx寄存器的值,以便在函数结束时恢复原值,即第12行的popl %ebx

5~11行为swap_add函数的功能实现代码。首先第5、6行从调用者的帧中取出之前保存的两个参数,可以看到,这两个参数相对于当前帧指针的偏移量为+8和+12。然后第7、8行将参数的值作为地址取出对应的两个数(这两个数实际上是caller代码中第5、6行存入的数),存入%ebx%eax寄存器。第9、10行将两个数交换放回原来的地址。第11行将两个数相加,和作为返回值保存在%eax寄存器。

12~14行为结束代码,做一些函数的收尾工作。首先第12行恢复%ebx寄存器的值,接着第13行恢复%ebp寄存器的值,最后ret返回。而ret指令也分为两步,第一步取出当前栈顶的值,第二步将这个值作为跳转指令的地址跳转,相当于下面两行代码:

popl %edx
jmp %edx

让我们回想这个地址是哪来的?哈哈,正是call指令自动压栈的下一行代码的地址。因此,ret之后将会执行call swap_add指令紧跟着的下一行代码。好的,接下来给出caller函数剩下的汇编代码:

12       movl -4(%ebp), %edx
13       subl -8(%ebp), %edx
14       imull %edx, %eax
15       leave
16       ret

12~14行都是在完成之后的一些运算而已,不必追究。奇怪的是15行用了一个没见过的指令leave,这又是什么意思呢?

我们来分析一下,这段代码和swap_add最后三行代码相比,少了两句popl %ebxpopl %ebp,多了一句leave。首先,popl %ebx不用考虑了,因为在caller的开头并没有pushl %ebx,因此也就没必要popl %ebx。那么我猜测leave是否替代了popl %ebp的功能呢?之所以这样猜测,首先我们得弄懂popl %ebp到底是什么功能。

很简单,每个函数结束前需要将栈恢复到函数调用前的样子,其实就是恢复两个指针——帧指针和栈指针的位置。popl %ebp的作用就是恢复帧指针的位置。而栈指针%esp呢?似乎没有看到哪条指令把它恢复。让我们再仔细捋一遍。先看子函数swap_add运行过程中的栈指针。使栈指针变化的只有四条语句,2、4行的pushl指令和12、13行的popl指令,而且两对指令对栈指针的影响正好对消,于是栈指针在函数结束时已经回到了最初的位置,因此根本不需要额外的调整。再考虑caller函数,与swap_add不同的地方在于第4行申请了24字节的栈空间,即手动将%esp寄存器的值减去了24。这就导致函数结束时栈指针无法回到最初的位置,需要我们手动将它恢复,leave指令就是这个作用。该指令相当于下面两条指令的合成:

movl %ebp, %esp   #Set stack pointer to the beginning of frame
popl %ebp         #Restore the saved %ebp and set stack pointer to the end of caller's frame

先将栈指针恢复到当前帧的起始位置,再恢复帧指针。这样的话,在第二步恢复帧指针的时候栈指针也会自动减一,从而完全退出了当前帧。

最后再来解释栈帧为什么申请了24字节的空间。在现代处理器中,栈帧必须16字节对齐,就是说栈底和栈顶的地址必须是16的整数倍。至于为什么会有这样的要求,请查看下一篇文章《联合、数据对齐和缓冲区溢出攻击》。现在,既然要求是16的整数倍,24字节肯定是不够的,仔细观察就能明白,栈帧除了这额外申请的24字节空间外,还有最初压栈的%ebp寄存器占用4字节,以及调用子函数前保存的返回地址占用4字节,加起来正好32字节,实现了16字节对齐。

其实写到一半我就不想写下去了,因为对读者来说这么长篇的文字叙述恐怕早已超出正常人的理解能力,如果能多配些图想必会好得多,无奈本人绘图技能实在有限,只能一股脑写到底了。函数调用时的栈结构变化的确是个迷人的过程,编译器的精妙之处在此可见一斑。透过现象看本质,我们才真正接触到了程序本身。

寄存器使用惯例

在上面的叙述中有一现象尚未解释,为什么caller中没有保存%ebxswap_add中却保存了呢?这涉及到IA32指令集的寄存器使用惯例,这个惯例保证了函数调用时寄存器的值不会丢失或紊乱。

%eax%edx%ecx称为调用者保存寄存器,被调用者使用这三个寄存器时不必担心它们原来的值有没有保存下来,这是调用者自己应该负责的事情。

%ebx%esi%edi称为被调用者保存寄存器,被调用者如果想要使用它们,必须在开始时保存它们的值并在结束时恢复它们的值,一般通过压栈和出栈来实现。

这就可以解释我们的疑问了。由于%ebx是被调用者保存寄存器,因此在swap_add中我们通过pushl %ebxpopl %ebx来保存该寄存器的值在函数执行前后不变。

这段代码的含义?

下面的代码片段常常出现在库函数的编译版本中。

    call next
next:
    popl %eax

乍看上去好像很奇怪,调用的next函数中并没有ret语句。这三行代码有什么作用呢?或者说,最后%eax寄存器的值会是什么呢?

相信理解了本文前两部分的读者应该很容易分析出来。call指令执行时会把下一句代码的地址压栈,此处对应于第三行popl %eax的地址。之后跳转到第三行执行,正好把刚才压栈的内容弹出到%eax寄存器中,这个值正好是当前的程序计数器的值。

结论:这是一个汇编代码的习惯用法,结果是把popl指令地址放入%eax中——这是将程序计数器值放入寄存器的唯一方法

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,547评论 4 374
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,787评论 2 308
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,175评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,752评论 0 223
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,169评论 3 297
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,056评论 1 226
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,195评论 2 321
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,980评论 0 214
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,724评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,872评论 2 254
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,344评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,667评论 3 264
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,379评论 3 245
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,202评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,992评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,189评论 2 286
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,987评论 2 279

推荐阅读更多精彩内容

  • 原文地址:C语言函数调用栈(一)C语言函数调用栈(二) 0 引言 程序的执行过程可看作连续的函数调用。当一个函数执...
    小猪啊呜阅读 4,511评论 1 19
  • 栈: 在函数调用时,第一个进栈的是主函数中函数调用后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函...
    zjfclimin阅读 3,765评论 0 5
  • 由于不是科班出生,又是自学开发,对很多方面的知识都是只知其然而不知其所以然。加上最近公司事情不多,刚好乘此机会把长...
    寒咯阅读 12,717评论 3 8
  • 首先先看图: 在main函数调用func_A的时候,首先在自己的栈帧中压入函数返回地址,然后为func_A创建新栈...
    zjfclimin阅读 7,169评论 1 2
  • 如何把球怼到对方后场? 我想了想怼后场那么费劲,轻挡网前不是更省力,更稳妥吗? 其实不是的,接杀怼后场有两种情况。...
    羽你同乐阅读 1,436评论 1 2