控制
上一篇的指令基本都是一条接着一条顺序的执行。而C语言中还有诸如条件语句、循环语句和分支语句等,要求有条件的执行。
机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
条件码
除了整数寄存器,CPU还维护着一组单个位的条件码寄存器,它们描述了最近的算术或逻辑操作的属性,可以检测这些寄存器来执行条件分支指令。常用条件码有:
- CF:进位标志位,可用来检测无符号操作的溢出。
- ZF:零标志,最近操作所得为0。
- SF:符号标志,最近操作所得为负数。
- OF:溢出标志,最近操作导致补码溢出(正溢出或负溢出)。
访问条件码
条件码通常不会直接读取,常用的使用方法有三种:
- 可以根据条件码的某种组合,将一个字节设置为0或者1
- 可以条件跳转到程序的某个其他部分
- 可以有条件的传送数据
跳转指令
正常执行下,指令按照它们出现的顺序一条一条的执行。跳转(jump)会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label)指明。考虑下面的汇编代码:
1 | movq $0,%rax |
指令jmp.L1会导致程序跳过movq指令,而从popq指令开始继续执行。在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码为跳转指令的一部分。
上图列举了不同的跳转指令。jmp指令是无条件跳转。它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或内存位置中读出的。汇编语言中,直接跳转是给出一个标号作为跳转目标的,比如例子中的标号“.L1”。间接跳转的写法是“*”后面跟一个操作数指示符。
比如:
1 | jmp *%rax |
表示用寄存器%rax中的值作为跳转目标,而指令
1 | jmp *(%rax) |
以%rax中的值作为读地址,从内存中读出跳转目标。
跳转指令的编码
虽然我们不关心机器代码格式的细节,但是需要理解跳转指令的目标如何编码。
跳转指令有几种不同的编码,但是最常用都是PC相对的。也就是它们会将目标指令的地址与紧跟在跳转指令后面的地址之间的差作为编码。这些地址偏移量可以编码为1、2或4个字节。第二种编码方法是给出“绝对”地址,用4个字节直接指定目标,汇编器和链接器会选择适当的跳转目的编码。
下面是一个PC相对寻址的例子:
1 | movq %rdi,%rax |
它包含两个跳转;第二行的jmp指令跳转到更高的地址,第七行的jmp指令跳转到较低的地址。
“.o”格式的反汇编版本如下:
1 | 0: 48 89 f8 mov %rdi,%rax |
第二行跳转指令的跳转目标指明为0x8,第5行中跳转指令的跳转目标是0x5(反汇编器以十六进制格式给出所有的数字),不过,观察指令编码,第一条跳转指令“eb 03”的第二个字节为0x03。把它加上0x5,就是下一条指令的地址,就得到跳转目标地址0x8,也就是第4行指令的地址。
说明当执行PC相对寻址是,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
循环
C语言提供了多种循环结构,即do-while、while和for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
1.do-while循环
do-while语句的通用形式如下:
1 | do |
该循环的效果是重复运行body-statement,对test-expr求值,如果求值的结果为非零,就继续循环。可以看到,body-statement至少会执行一次。
该通用形式可以翻译成如下语句:
1 | loop: |
每次循环执行nody-statement,然后测试test-expr,如果为真,继续循环。
例子:
C代码:
1 | long fact_do(long n) |
等价的goto版本
1 | long fact_do(long n) |
对应的汇编代码:
1 | fact_do: |
n存在%rdi中,传递给函数。寄存器%rax初始化为1。而且%rax用来返回函数值,所以通常会用来存放需要返回的程序值,因此%rax对应程序值result。
2.while循环
通用形式:
1 | while (test-expr) |
和do-while不同,可能在body-statement执行之前,循环就中止了。Gcc有两种方法翻译成机器代码。
第一种是jump to middle(跳转到中间),goto代码如下:
1 | goto test; |
第二种是guarded-do,首先用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-while循环。goto代码如下:
1 | t = test-expr; |
3.for循环
通用形式如下:
1 | for (init-expr; test-expr; update-expr) |
程序首先对初始表达式init-expr求值,然后进入循环,在循环中它先对测试条件test-expr求值,如果测试结果为假就退出,否则执行循环体body-statement;最后更新表达式update-expr求值。
GCC为for循环产生的代码weiwhile循环的两种翻译之一:
1 | init-expr; |
或
1 | init-expr; |
4.switch语句
switch语句可以进行多重分支。
例子:
1 | void switch_ag(long x, long n,long *dest) |
汇编代码如下:
1 | switch_ag: |
可以看到原始C代码分100,102-104和106的情况,但变量n可以为任意整数。编译器首先将n减100,取值范围变为0-6之间。然后创建一个新的程序变量index。通过测试index是否大于6来判定index是否在0-6范围之外。根据index的值,有五个不同的跳转位置.L3,.L5,.L6,.L7,.L8,最后一个是默认的目的地址。在C的汇编代码中,程序都是将index和6做比较,如果大于6就跳转到默认的代码中。
汇编代码第七行中,jmp指令的操作数有前缀‘*’,表明这是一个间接跳转,操作数指定一个内存位置,索引由寄存器%rsi指出,这个寄存器保存着index的值。
汇编代码.L4里有跳转表的声明。这些声明表明在叫做“.rodata”(只读数据,Read-Only Data)的目标代码文件的段中,应该有一组7个“四”“字(8个字节),每个字的值都是与指定的汇编代码标号(比如.L3)相关联的指令地址。标号标记出这个分配地址的起始。与这个标号相对的地址会作为间接跳转(第七行jmp)的基地址。
过程
过程是软件中的一个很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能,然后可以在程序中不同的地方调用这个函数。过程的形式多种多样:函数、方法、子例程、处理函数等等。
假设过程P调用过程Q,Q执行后返回到P。这些动作需要多个机制。
- 传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址。
- 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值。
- 分配和释放内存。在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。
运行时栈
C语言过程调用机制的一个关键特性,在于使用了栈数据结构提供的后进先出的内存管理原则。例子如图:
过程P:调用者的帧;过程Q:当前帧
可以看到,当Q在执行的时候,P以及所有在向上追溯到P的调用链中的过程,都是暂时被挂起的。当Q运行时,它只需要为局部变量分配新的分配空间,或者设置到另一个过程的调用。另一方面,当Q返回时,任何它所分配的局部存储空间都可以被释放。因此,程序可以用栈来管理存放着传递控制和数据、分配内存所需要的信息。当P调用Q时,控制和数据信息添加到栈尾。当P返回时,这些信息会释放掉。
当过程需要的存储空间超出寄存器能够存放的大小,就会在栈上分配空间。这部分称为过程的栈帧。如图,当前正在执行的过程的帧总是在栈顶。当过程P调用过程Q时,会把返回地址压入栈中,指明当Q返回时,要从P程序的那个位置继续执行。我们把这个返回地址当中P的栈帧的一部分,因为它存放的是与P相关的状态。Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。大多数过程的栈帧都是定长的,在过程的开始就分配好了。但是有些过程需要变长的帧。通过寄存器,过程P可以传递最多6个整数值(也就是指针和整数),但是如果Q需要更多的参数,P可以在调用Q之前在自己的栈帧里存储好这些参数。
转移控制
将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。不过,当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行代码位置。这个信息是用指令call Q调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。
下面是call和ret指令的一般形式:
1 | call Label 过程调用 |
call指令有一个目标,即指明被调用过程起始的指令地址。同跳转一样,调用可以是直接的,也可以是间接的。在汇编代码中,直接调用的目标是一个标号,而间接调用的目标是*后面跟一个操作数指示符。
数据传送
当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。x86-64中大部分过程间的数据传送是通过寄存器实现的。
x86-64中,可以通过寄存器最多传递6个整型(即整数和指针)参数。寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递的数据类型的大小,如图所示。
如果一个函数有大于6个整型参数,超出6个的部分就要通过栈来传递。假设过程P调用过程Q,有n个整型参数,且n>6,那么P的代码分配的栈帧必须要能容纳7到n号参数的存储空间。要把参数1-6复制到对应的寄存器,把参数7-n放到栈上,而参数7位于栈顶。通过栈传递参数时,所有的数据大小都向8的倍数对齐。参数到位以后,程序就可以执行call指令将控制转移到过程Q了。过程Q可以通过寄存器访问参数,有必要的话可以通过栈访问。相应的,如果Q也调用了某个有超过6个参数的函数,它也需要在自己的栈帧中为超出6个部分的参数分配空间。
例子:
1 | void proc(long a1, long *a1p, int a2, int *a2p, short a3, short *a3p, char a4, char *a4p) |
汇编代码:
1 | proc: |
可以看到,前面6个参数通过寄存器传递,后面2个通过栈传递。
作为过程调用的一部分,返回地址被压入栈中,因而这两个参数位于相对于栈指针距离为8和16的位置。
我们同样看到根据操作数的大小,使用了ADD指令的不同版本:a1(long)使用addq,a2(int)使用addl,a3(short)使用addw,而a4(char)使用addb。注意第六行的movl指令从内存读入4字节,而后面的addb指令只使用其中的低位一字节。
栈上的局部存储
到目前为止,大多数例子都不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见情况包括:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符“&“,因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
例子如下:
1 | long swap_add(long *xp, long *yp) |
可以看到,swap_add交换指针xp和yp指向的两个值,并返回这两个值的和。函数caller创建到局部变量arg1和arg2的指针,把它们传递给swap_add。
1 | caller: |
caller的代码在一开始把栈指针减掉了16;实际上这就是在栈上分配了16个字节。若S表示栈指针的值,&arg2为S+8(第五行),而&arg1为S。因此可以推断局部变量arg1和arg2存放在栈帧中相对于栈指针偏移量为0和8的地方。当对swap_add的调用完成后,caller的代码会从栈上取出这两个值(第8-9行),计算它们的差,再乘以swap_add在寄存器%rax中返回的值(第10行)。最后,该函数把栈指针加16,释放栈帧(第11行)。在这个例子中,运行时栈提供了一种简单的、在需要时分配、函数完成时释放局部存储的机制。
如下还有一个例子:
1 | long call_proc() |
汇编代码如下:
1 | call_proc: |
汇编代码中一大部分是为proc做准备,其中包括为局部变量和函数参数建立栈帧,将函数参数加载至寄存器。
如上图所示,在栈上分配局部变量x1-x4,它们具有不同的大小:24-31(x1),20-23(x2),18-19(x3)和17(s3)。用leap指令生成到这些位置的指针(第7、10、12、14行)。参数7和8存放在栈中相当于栈指针偏移量为0和8的地方。
当调用过程proc时,参数7和8 位于栈指针偏移量为8和16的地方,因为返回地址以及被压入栈中了(占了0-7的位置)。
当程序返回call _proc时,代码会取出4个局部变量(第17-20行),并执行最终计算。在程序结束时,把栈指针加32,释放栈帧。
寄存器中的局部存储空间
之前说过,当被调用函数使用的局部变量满足三个条件之一时,就需要存放在栈上,若用的局部变量不满足任一条件,就用寄存器存放起来。假若现在P调用了Q,而Q自己定义并使用了局部变量,而且那个局部变量是用寄存器存的,那么用哪个寄存器来存它呢?之前讲了参数传递时有6个寄存器是用来传参的,即使这次调用传的参数很少,肯定也不能用那6个寄存器。
根据惯例,我们会用%rbx,%rbp和%r12-%r15来保存这些不满足条件的局部变量,这些寄存器叫做被调用者保存寄存器。那么问题来了,假如在P调用Q之前,P自己定义了一些局部变量,调用Q并返回后,P想用Q返回的结果和自己的局部变量作运算,而P自己定义的局部变量也用了一些寄存器,假如这些寄存器和Q内部使用的寄存器冲突,导致P的局部变量被Q的局部变量覆盖怎么办?
因此,在被调函数中,对于这些不满足条件的局部变量,虽然不会被直接压到栈中,但在它们被存到寄存器之前,会先将要用的寄存器的值压到栈上,等返回的时候,再把值从栈上弹回到对应的寄存器中。这些寄存器的使用顺序是怎样的呢?和之前传参对应的6个寄存器一样,局部变量也是依次使用这6个寄存器的,比如只有一个不满足条件局部变量,则会存在%rbx中,在此之前,在被调函数中先把%rbx压栈即可。
递归
前面的寄存器和栈的惯例使x86-64过程能够递归的调用它们自身,每个过程调用在栈中都有它的私有空间,因此多个未完成调用的局部变量不会相互影响。栈的原则提供适当策略,当过程被调用时分配局部存储,当返回时释放存储。
例子如下:
1 | long rfact(long n) |
汇编代码如下:
1 | rfact: |
寄存器%rbx保存参数n,先把已有的值保存在栈上(第2行),然后在返回前恢复该值(第11行)。根据栈与寄存器特性规则,保证当递归调用rfact(n-1)返回时(第9行):
- 该次调用的结果会保存在寄存器%rax中。
- 参数n的值仍然在寄存器%rbx中。
所以递归调用一个函数本身和调用其他函数是一样的。栈规则提供一种机制,每次函数调用都有它自己私有的状态信息(保存的返回值和被调用者保存寄存器的值)存储空间。