一个过程调用包括将数据(以参数返回值的形式)与控制从代码的一部分传递到另一部分。除此之外,在进入时为过程的局部变量分配空间,在退出的时候释放这些空间。数据传递、局部变量的分配和释放通过操纵程序栈来实现。栈作为一种能够实现先进后出、后进先出的数据结构,非常适合用于实现函数调用以及返回的机制。

在过程调用中主要涉及三个重要的方面:

  1. 传递控制:包括如何开始执行过程代码,以及如何返回到开始的地方
  2. 传递数据:包括过程需要的参数以及过程的返回值
  3. 内存管理:如何在过程执行的时候分配内存,以及在返回之后释放内存

栈结构

程序栈其实就是一块内存区域,这个区域内的数据满足先进后出的原则。从栈底到栈顶,地址由高变低。所以新加入栈的以及新开辟的空间的地址都是较小的。有两个特殊寄存器是与栈有关的。寄存器 %ebp 叫做帧指针,保存当前栈帧开始的位置。寄存器 %esp 叫做栈指针,始终指向栈顶。栈帧(stack frame)是指为单个过程分配的那一小部分栈。大多数信息访问都是相对于帧指针访问的。以前经常看到这类代码:movl 8(%ebp), %eax 意思就是将存放在比帧指针地址大8的变量移动到寄存器里。

假设过程 P(调用者)调用了过程 Q(被调用者),则 Q 的参数存放在 P 的栈帧中。调用 Q 时,P 的返回地址被压入栈中,形成 P 的栈帧的末尾。返回地址就是当过程 Q 返回时应该继续执行的地方。Q 的栈帧紧跟着被保存的帧指针副本开始,后面是其他寄存器的值。

栈中会存放局部变量。有下面三个原因:

  1. 不是所有的变量都能放到寄存器中的,没有那么多寄存器。
  2. 有些局部变量是数组,或者结构体。
  3. 有些时候需要对某些变量使用 & 运算符,获得其地址,因此要将其放在栈中。寄存器变量是没有地址的。

栈向低地址方向增长。可以利用指令pushl将数据存入栈,利用popl将指令从栈中取出。由于栈指针%esp 始终指向栈顶,所以可以通过减小栈指针的值来分配空间,增加栈指针来释放空间。

调用方式

下面是有关过程调用和返回的指令:

指令 描述
call Label 过程调用
call Operand 过程调用
leave 为返回准备栈
ret 返回

call 指令的效果是将返回地址压入栈中(也就是保存返回地址),然后跳转到被调用过程的起始处。返回地址是在程序中紧跟在 call 后面的那条指令的地址。这样当被调用过程返回时,执行从此(call 指令的下一条指令)继续。

ret 指令从栈中弹出返回地址,然后跳转到返回地址的位置。

寄存器共享

寄存器是在过程调用中唯一能被所有过程共享的资源。因此我们必须保证被调用者不会覆盖某个调用者稍后会使用的寄存器的值。根据惯例,寄存器%eax%edx%ecx被划分为调用者保存寄存器。当过程 P 调用过程 Q 时,Q 可以覆盖这些寄存器的数据,而不会破坏 P 所需的数据。寄存器%ebx%esi%edi被划分为被调用者保存寄存器,Q 在覆盖这些寄存器的值之前,必须将其压入栈中,然后在返回前恢复他们。看下面的例子:

int P(int x)
{
int y = x * x; //变量 y 是在调用前计算的
int z = Q(y);
return y + z; //要保证变量 y 在 Q 返回后还能使用。
}
  • 基于调用者保存:过程 P 在调用 Q 之前,将 y 的值保存在自己的栈帧中;当 Q 返回时,过程 P 由于自己保存了这个值,就可以从自己的栈中取出来。

  • 基于被调用者保存:过程 Q 将值 y 保存在被调用者保存寄存器。如果过程 Q 和其他任何 Q 调用的过程,想使用保存 y 值的被调用者保护寄存器,它必须将这个寄存器的值存放到栈帧中,然后在返回前恢复 y 的值。

这两种方案都是可行的。

过程实例

考虑下面给出的C语言代码。函数 caller 中包括一个对函数 swap_add 的调用。

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 和 swap_add 的栈帧。左图是还未执行到 int sum = swap_add(&arg1, &arg2);语句之前。可以看到因为要对局部变量取地址,所以要把局部变量放到栈中。栈指针一直指在栈顶。帧指针目前指在最上面,代表过程 caller 的栈帧开始的位置。

调用了 swap_add 之后,栈成了左边的样子。因为调用函数会使用一个 call 指令。这个指令会压入一个返回地址。紧接着开始了 swap_add 的栈帧。此时 %ebp被更新。在被调用者函数内,使用12(%ebp)8(%ebp)就可以取到两个参数。

下面看一下汇编代码:

caller:
pushl %ebp # 保存旧的 %ebp 的一个副本
movl %esp, %ebp # 设置新的栈顶指针,到栈顶
subl $24, %esp # 栈指针减去24,即分配24个字节的空间
movl $534, -4(%ebp) # int arg1 = 534;
movl $1057, -8(%ebp) # int arg2 = 1057; 这两个局部变量都保存在栈上
leal -8(%ebp), %eax # 取地址 &arg2,放到寄存器中
movl %eax, 4(%esp) # 保存到栈上
leal -4(%ebp), %eax #
movl %eax, (%esp) # 同上
call swap_add # 参数都齐全了,可以调用函数了 swap_add:
push %ebp # %ebp移动了,栈发生了改变
movl %esp, %ebp # 这两步同 caller,是过程调用的“建立部分”
push %ebx # 这是一个被调用者保存寄存器,将旧值压入栈中,作为栈帧的一部分 # 从这里开始才是真正的C语言代码体现的 swap_add 内容
movl 8(%ebp), %edx # 获得参数1,放到寄存器中
movl 12(%ebp), %ecx # 获得参数2,放到寄存器中
movl (%edx), %ebx # int x = *xp;
movl (%ecx), %eax # int y = *yp;
movl %eax, (%edx) # *xp = y;
movl %ebx, (%ecx) # *yp = x;
addl %ebx, %eax # 计算 x + y,结果放在%eax中,所以会返回%eax中的值 popl %ebx # 这是被调用过程的“结束过程”
pop %ebp # 恢复保护寄存器,弹出栈帧指针
ret # 此时栈顶是返回地址,ret指令就弹出这个地址,然后跳转到这个地址 # caller 剩余的代码会紧跟在后面
movl -4(%ebp), %edx # 此时 %ebp 指的是 caller 自己的 %ebp
subl -8(%ebp), %edx # int diff = arg1 - arg2;
imull %edx, %eax # 计算 sum * diff,%eax是存放 swap_add 返回值的
leave
ret

说句题外话,看到这个 swap 函数之后,也能解答一个初学者的问题:为什么下面这个 swap 函数不能交换参数的值?

void swap(int a, int b)
{
int temp;
temp = a;
a = b;
b = temp;
}

swap 函数被调用时,用寄存器存参数 a 与 b,作为临时存储,然后对寄存器内的两个值做了一通操作,并没有影响到存储器中 a 与 b 的值

leave 指令可以使栈做好返回的准备。其作用就是将帧指针移到栈顶然后抛出。

分配给 caller 的栈帧有24个字节,8个用于局部变量,8个用于传参,还有8个字节未使用。这主要是满足 x86 的一个编程指导方针——对齐(alignment)的要求:一个函数使用的栈空间必须是 16 字节的整数倍,包括一开始保存的 %ebp 的 4 字节和返回值的 4 字节,所以共分配了 24 个字节。

从这个例子我们可以看到,编译器根据简单的惯例来产生管理栈结构的代码。栈帧中需要包含:

  • 帧指针副本,标识自己的栈帧从哪里开始
  • 局部变量(如果需要)
  • 临时空间(如果需要)
  • 调用其他函数之后,压入返回信息

可以用相对于 %ebp 的偏移量来访问变量和参数。可以用通过加减栈顶指针来释放或分配空间。在返回时,必须将栈恢复到调用前的状态,恢复所有的被调用者保护寄存器和 %ebp,重置 %esp。为了让程序能正确执行,让所有过程遵循一个统一一致的惯例是很重要的。

一个调用过程(call 指令之后)的汇编代码包括三个部分:

  1. 建立部分:压入帧指针,移动栈指针,压入需要保存的寄存器的值。
  2. 主体部分:函数的功能部分
  3. 结束部分:恢复需要保存的值,弹出帧指针,返回。

递归

有了前面的的基础,要理解递归就简单很多了。为什么过程能调用自己本身呢?因为每个调用在栈中都有自己的私有空间。多个未完成的调用,他们局部变量,之间不会相互影响。栈的原则很自然地提供了一个策略:过程被调用时分配局部存储,返回时释放。

上一个课后题的例子:

一个具有通用结构的C函数如下:


int rfun(unsigned x)
{
if(???)
return ??? ;
unsigned nx = ??? ;
int rv = rfun(nx);
return ???;
}

给出对应的汇编代码,其中省略了建立和完成代码,请通过汇编代码分析:

  1. 被调用者保护寄存器 %ebx 存的是什么?
  2. C语言代码中的问号应该填什么?
  3. 描述C语言代码的作用。

汇编代码如下:


movl 8(%ebp), %ebx # 开头第一句一般是取参数。
movl $0, %eax # int y = 0; (变量名随便取的)
testl %ebx, %ebx # 测试 x
je .L3 # if(x==0) return;
movl %ebx, %eax # else { y = x;
shrl %eax # y >>= 1;}
movl %eax, (%esp) # 压入栈中,很明显,是为了下一次调用使用
call rfun # 递归调用
# 别忘了 call 会压入返回地址,然后跳转
movl %ebx, %edx
andl $1, %edx # %edx 中存的值是 (x & 1)
leal (%edx, %eax), %eax # 寄存器是公用的资源。
# y = (x >> 1) + ( x & 1)
.L3:

经过上面的分析可以得出解答:

  • %ebx 存放的是参数 x 的值。
  • 我们在汇编中分析出来的一个新变量 y 应该就是C语言代码中的 nx
  • C语言代码如下:

int rfun(unsigned x)
{
if(x == 0)
return 0;
unsigned nx = x >> 1;
int rv = rfun(nx);
return rv + (x & 0x01);
}

这段代码的作用是:递归地计算一个无符号数的每一位上的数字之和。

对于递归,目前我觉得有一个很恰当的比喻:

我们使用词典查词,本身就是递归,为了解释一个词,需要用到更多词。当你查一个词,发现要解释这个词的一句话里有一个词你不懂,于是你开始查这第二个词。可惜的是,查第二个词的时候仍然有不懂的词,于是查第三个词……这样一直查下去,知道有一个词的解释你完全能看懂,那么递归走到了尽头,开始返回,然后你按照查词顺序的倒序逐个看明白了之前你所查的每个词的意思。你最开始查的那个词,是最后才知道其意思的。

所以对于理解递归,很重要的一点是要理解递归什么时候触及到边界,开始返回了。

古之欲明明德于天下者,先治其国;欲治其国者,先齐其家;欲齐其家者,先修其身;欲修其身者,先正其心;欲正其心者,先诚其意;欲诚其意者,先致其知,致知在格物。物格而后知至,知至而后意诚,意诚而后心正,心正而后身修,身修而后家齐,家齐而后国治,国治而后天下平。(注:这不是递归,只是函数调用嵌套比较深)

参考资料

【CSAPP笔记】7. 汇编语言——过程调用的更多相关文章

  1. 【CSAPP笔记】9. 汇编语言——缓冲区溢出

    x86-64 Linux 内存结构 先来看看一个程序在内存中是如何组织的.Linux 为每个进程维持了一段单独的虚拟地址空间.(进程是计算机科学中很深刻.很成功的一个概念.当我们在运行一个程序时,会得 ...

  2. ARM过程调用标准---APCS简单介绍

    介绍 APCS,ARM 过程调用标准(ARM Procedure Call Standard),提供了紧凑的编写例程的一种机制,定义的例程能够与其它例程交织在一起.最显著的一点是对这些例程来自哪里没有 ...

  3. 【CSAPP笔记】14. 异常控制流和进程

    从给处理器加电,到断电为止,处理器做的工作其实就是不断地读取并执行一条条指令.这些指令的序列就叫做 CPU 的控制流(control flow).最简单的控制流是"平滑的",也就是 ...

  4. 【CSAPP笔记】10. 代码优化

    写程序的主要目标是使它在所有可能的情况下都能正确运行(bug free),一个运行得很快但有 bug 的程序是毫无用处的.在 bug free 的基础上,程序员必须写出清晰简洁的代码,这样做是为了今后 ...

  5. C语言与汇编语言相互调用原理以及实例

    C语言与汇编语言相互调用原理以及实例 1.原理 其实不管是C语言还是汇编语言想要执行都是最终编译链接成为二进制文件. 这里一定要明确编译和链接是两个步骤,生成的文件格式也是不一样的. 编译生成的文件是 ...

  6. 从汇编角度分析C语言的过程调用

    ➠更多技术干货请戳:听云博客 基本术语定义 1.系统栈(system stack)是一个内存区,位于进程地址空间的末端. 2.在将数据压栈时,栈是自顶向下增长的,该内存区用于函数的局部变量提供内存.它 ...

  7. Thrift架构~从图中理解thrift,它事实上是一种远程过程调用

    thrift为我们简化了tcp通讯,它可以使用我们方便的建立各种语言的服务端与客户端,并实现客户端对服务器的远程过程调用,简单的说就是服务器通过thrift架构对外开放一些接口,并自己实现这些接口,如 ...

  8. RPC-远程过程调用协议

    远程过程调用协议 同义词 RPC一般指远程过程调用协议 RPC(Remote Procedure Call Protocol)——远程过程调用协议,它是一种通过网络从远程计算机程序上请求服务,而不需要 ...

  9. 无效的过程调用或参数: 'Instr'解决方法

    以前我一直使用ASP无组件上传类来上传文件.但是今天又个客户反映说.不能上传.出现错误.,但在我电脑上测试没问题.后来发现客户用的是IE8 于是开始找解决方法 错误如下:Microsoft VBScr ...

随机推荐

  1. 基于Babylon.js编写简单的骨骼动画生成器

    使用骨骼动画技术可以将网格的顶点分配给若干骨头,通过给骨头设定关键帧和父子关系,可以赋予网格高度动态并具有传递性的变形 效果.这里结合之前的相关研究在网页端使用JavaScript实现了一个简单的骨骼 ...

  2. maven第一天——入门与基本概念

    一.什么是maven? 1.概述 核心点:项目构建.依赖管理.[更新]:源码关联 (如何关联源码:在依赖的jar上右击 maven download source即可) Maven是一个项目管理和综合 ...

  3. WPF 学习笔记-在WPF下创建托盘图标

    原文:WPF 学习笔记-在WPF下创建托盘图标 首先需要在项目中引用System.Windows.Forms,System.Drawing; using System; using System.Co ...

  4. JavaWeb总结(十三)

    Web开发模式的变迁 了解了Servlet和JSP,知道利用Servlet就可以开发一个Web应用程序,但是Servlet的缺陷使Web应用程序开发变得非常繁琐且不利于分工协作.使用JSP(表达式.声 ...

  5. mfc 创建一个C++ 类

     类创建向导  添加一个C++类  #pragma once的作用  认识类视图 一.类创建向导 二.添加一个C++类 认识类创建向导: 创新一个处理文字信息的类CMessage CMessa ...

  6. VC编译连接选项详解

    VC编译连接选项详解 大家可能一直在用VC开发软件,但是对于这个编译器却未必很了解.原因是多方面的.大多数情况下,我们只停留在“使用”它,而不会想去“了解”它.因为它只是一个工具,我们宁可把更多的精力 ...

  7. 5-[HTML]-body常用标签2

    1.表格标签 table 表格由<table> 标签来定义.每个表格均有若干行(由 <tr> 标签定义),每行被分割为若干单元格(由<td>标签定义).字母 td ...

  8. ssm 配置事务回滚

    参考:https://blog.csdn.net/Mint6/article/details/78363761 在 applicationContext.xml 中配置好了事务和数据源等必须要用到的配 ...

  9. [webpack]-webpack超级详细搭建实用前端环境

    前言: webpack 超级实用前端环境搭建 一.我们日常使用的前端开发环境应该是怎样的? 构建我们需要发布的html,css ,js 文件 使用css 预处理器来编写样式 处理压缩图片 使用Babl ...

  10. Laya资源加载小记

    Laya.Loader负责资源的加载逻辑,被LoaderManager管理. Laya支持多种类型资源加载,也支持自定义类型加载.不同类型的加载方式可能不同. Laya.Loader缓存已经被加载过得 ...