深入理解计算机系统 3.5 过程

过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后,可以在程序中不同的地方调用这个函数。设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会对程序状态产生什么样的影响。不同编程语言中,过程的形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等,但是它们有一些共有的特性。

要提供对过程的机器级支持,必须要处理许多不同的属性。为了讨论方便,假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制:

传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。

传递教据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。

分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须
释放这些存储空问。

3.5.1 运行时栈

C语言过程调用机制的一个关键特性(大多数其他语言也是如此)在于使用了栈数据结构提供的后进先出的内存管理原则。在过程P调用过程的例子中,可以看到当Q在执行时,P以及所有在向上追溯到P的调用链中的过程,都是暂时被挂起的。当Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用另一方面,当Q返回时,任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息当P调用Q时,控制和数据信息添加到尾。当返回时,这些信息会释放掉。

 x86-64的栈向低地址方向增长,而栈指针%rsp指向栈顶元素。可以用pushq和popg指令将数据存人栈中或是从栈中取出。将栈指针减小一个适当的量可以为没有指定初始值的数据在栈上分配空间。类似地,可以通过增加栈指针来释放空间。

当X86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。这个部分称为过程的栈频(stack fram)。图3-25给出了运行时栈的通用结构,包括把它划分为栈锁。当前正在执行的过程的帧总是在栈顶。当过程P调用过程Q时,会把返回地址压人栈中,指明当Q返回时,要从P程序的个位置继续执行。我们把这个返回地址当做P的的一部分,因为它存放的是与P相关的状态。Q的代码会扩展当前栈的边界,分配它的栈所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了。但是有些过程需要变长的帧,这个问题会在后面讨论。通过寄存器,过程P可以传递最多6个整数值(也就是指针和整数),但是如果Q需要更多的参数,P可以在调用Q之前在自己的栈帧里存储好这些参数。

 图3-25


为了提高空间和时间效率,x86-64过程只分配自己所需要的部分。例如,许多过程有6个或者更少的参数,那么所有的参数都可以通过寄存器传递。因此,图3-25中画出的某些栈帧部分可以省略。实际上,许多函数甚至根本不需要栈。当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数(有时称之为叶子过程,此时把过程调用看做树结构)时,就可以这样处理。

3.5.2 转移控制

CALL指令和JMP指令在计算机科学中主要用于控制流的转移,但它们在功能、行为和应用场景上有显著的区别。

功能和行为

  • CALL指令‌:用于调用子程序。它不仅跳转到目标地址,还保存了返回地址,以便在子程序执行完毕后能够返回调用点继续执行。执行CALL时,当前指令的下一条指令的地址会被压入堆栈(作为返回地址),然后程序跳转到目标地址。子程序执行完后,通过RET指令可以从堆栈中弹出返回地址并跳回原来的调用点‌。
  • JMP指令‌:用于无条件跳转,直接将程序的执行流跳转到指定的地址。执行JMP后,程序不会记录跳转前的位置,也不会在执行完目标代码后返回。它只是简单地更改了指令指针(IP/EIP/RIP),程序从新的地址继续执行‌。

应用场景

  • CALL指令‌:主要用于函数调用,确保在执行完子程序后程序能够继续从调用点之后的指令执行。这在需要返回调用点的场景中非常有用,比如在系统调用或者中断处理程序中‌。
  • JMP指令‌:常用于跳转到函数的某个位置、循环控制、跳过某些代码段等。由于JMP不会保存返回地址,它通常用于不需要返回的场景‌。

具体例子

  • CALL指令‌:在内核的上下文切换中,如果希望在执行完一个子任务后继续执行当前任务,通常会使用CALL。这样可以在任务执行完后通过RET返回调用点,从而继续执行原来的代码‌。
  • JMP指令‌:在上下文切换中,如果只需要切换到另一个任务的代码而不返回原来的位置,可以使用JMP。这样可以直接跳转到新的任务并立即开始执行,而不需要保存当前代码的位置‌。

综上所述,CALL和JMP在功能、行为和应用场景上有明显的区别,选择使用哪一种指令取决于具体的需求和上下文环境。

 3.5.3 栈上的局部存储

到目前为止我们看到的大多数过程示例都不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:

1.寄存器不足够存放所有的本地数据。

2.对一个局部变量使用地址运算符'&’,因此必须能够为它产生一个地址。

3.某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。在描述数组和结构分配时,我们会讨论这个问题。

一般来说,过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标号为“局部变量”。来看一个处理地址运算符的例子,图3-31a中给出的两个函数。函数swap_add 交换指针 xp和 yp指向的两个值,并返回这两个值的和。函数caller 创建到局部变量 arg1和arg2的指针,把它们传递给swap add。图3-31b展示了caller是如何用栈帧来实现这些局部变量的。caller的代码开始的时候把栈指针减掉了16;实际上这就是在栈上分配了16个字节。S表示栈指针的值,可以看到这段代码计算&arg2为S+8(第5行),而&arg1为S。因此可以推断局部变量arg1和arg2存放在栈帧中相对于栈指针偏移量为0和8的地方。当对swap_add的调用完成后,caller的代码会从栈上取出这两个值(第8~9 行),计算它们的差,再乘以 swap add在寄存器%rax中返回的值(第10行)。最后该函数把栈指针加16,释放栈帧(第11行)。通过这个例子可以看到,运行时栈提供了种简单的、在需要时分配、函数完成时释放局部存储的机制。

图 3-31

3.5.4 寄存器中的局部存储空间

寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。为此,x86-64采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循。

根据惯例,寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器。当过程P调用过程Q时。Q必须保存这些寄存器的值,保证它们的值在Q返回到p时与Q被调用时是一样的。过程Q保存一个寄存器的值不变,要么就是根本不去改变它,要么就是把原始值压人栈中,改变寄存器的值,然后在返回前从栈中弹出旧值。压入寄存器的值会在栈中创建标号为“保存的寄存器”的一部分。有了这条惯例,P的代码就能安全地把值存在被调用者保存寄存器中(当然,要先把之前的值保存到栈上),调用Q然后继续使用寄存器中的值,不用担心值被破坏。

所有其他的寄存器,除了栈指针%rsp,都分类为调用者保在寄存器。这就意味着任何数都能修改它们,可以这样来理解“调用者保存”这个名字:过程P在某个此类寄存器中有局部数据,然后调用过程Q。因为Q可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是P(调用者)的责任。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值