深入理解计算机系统(第3章 程序的机器级表示③)
3.6 控制
到目前为止,我们只考虑了直线代码的行为——指令一条接着一条顺序地执行。
条件语句、循环语句、分支语句——要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。
机器代码提供两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或者数据流。
3.6.1 条件码
CPU还维护着一组单个位的条件码(condition code)寄存器,用来描述最近的算术或逻辑操作的属性。我们可以通过检测这些寄存器来执行条件分支指令。
常用的条件码:
- CF:进位标志。最近的操作使最高位产生了进位(可用来检查无符号操作的溢出)
- ZF:零标志。最近的操作得出的结果位0
- SF:符号标志。最近的操作得到的结果为负数。
- OF:溢出标志。最近的操作导致一个补码溢出——正溢出/负溢出
lea指令不改变任何条件码,因为它是用来进行地址计算的,其余上述列出的所有指令都会设置条件码。
3.6.2 访问条件码
条件码的三种使用方法:
- 可以根据条件码的某种组合,将一个字节设置为0或者1。
- 可以条件跳转到程序的某个其他部分。
- 可以有条件地传送数据。
这里讨论第一种,我们将这一整类指令称为SET指令:
一条SET指令的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置成0或者1。为了得到一个32位或64位结果,必须对高位清零。
3.6.3 跳转指令
跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用一个标号(label)声明。
在产生目标代码文件时,汇编器会确定所有带标号指令的地址,并将跳转目标(目的指令的地址)编码位跳转指令的一部分。
3.6.4 跳转指令的编码
我们依然采用上面的例子:
观察指令的字节编码:
- 第一条跳转指令的目标编码为0x03,把它加上0x5,也就是下一条指令的地址,就得到跳转目标地址0x8,也就是第4行指令的地址。
- 第二条跳转指令的目标用单字节、补码表示编码为0xf8(十进制-8)。将这个数加上0xd(十进制13),即第6行指令的地址,我们得到0x5,即第3行指令的地址。
说明:当执行PC相对寻址时,程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
3.6.5 用条件控制来实现条件分支
将条件表达式和语句从C语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。(另一种方式在3.6.6节中会看到,有些条件可以用数据的条件转移实现,而不是用控制的条件转移来实现)
使用goto语句通常认为是一种不好的编程风格,使代码非常难以阅读和调试。本文中使用goto语句,是为了构造描述汇编代码程序控制流的C程序。我们称这样的编程风格为“goto代码”。
C语言中的if-else语句的通用形式模板如下:
if (test-expr)
then-statement
else
else-statement
对于这种通用形式,汇编实现通常会使用下面这种形式,这里我们用C语法来描述控制流(汇编器为then-statement和else-statement产生各自的代码块,并插入条件和无条件分支以保证能执行正确的代码块):
t = test-expr
if (!t)
goto false;
then-statement
goto done;
false:
else-statement
done:
3.6.6 用条件传送来实现条件分支
上述使用控制的条件转移的方法简单而通用,但在现代处理器上可能会非常低效。
一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。(只有在一些受限制的情况中可行,此时可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性——流水线(pipelining),控制流不依赖于数据,使得处理器更容易保持流水线是满的)
处理器采用非常精密的分支预测逻辑来猜测每条跳转指令是否会执行。(计组中强跳转11、弱跳转10、弱不跳转01、强不跳转00的例子)分支预测错误处罚主导着这个函数的性能。
下面列举出x86-64上一些可用的条件传送指令。每条指令都有两个操作数:源寄存器或者内存地址S,和目的寄存器R。与各种SET和跳转指令语言,这些指令的结果取决于条件码的值。源值可以从内存或者源寄存器中读取,但是只有在指定条件满足时,才会被复制到目的寄存器中。(不支持单字节的条件传送)
同条件跳转不同,处理器无需预测测试的结果就可以执行条件传送。处理器只是读源值,检查条件码,然后要么更新目的寄存器,要么保持不变。
(因为无论测试结果如何,总会对所有分支都求值,如果其中任意一个可能产生错误条件或者副作用,就会导致非法的行为)
3.6.7 循环
3.6.7.1 do-while循环
do-while语句的通用形式如下:
do
body-statement
while (test-expr);
这种通用形式可以被翻译成如下所示的条件和goto语句:
loop:
body-statement
t = test-expr;
if (t)
goto loop;
3.6.7.2 while循环
while语句的通用形式如下:
while (test-expr)
body-statement
第一种翻译方法,我们称之为跳转到中间(jump to middle),它执行一个无条件跳转跳到循环结尾处的测试,以此来执行初始的测试:
goto test;
loop:
body-statement
test:
t = test-expr;
if (t)
goto loop;
第二种翻译方法,我们称之为guarded-do,首先使用条件分支,如果初始条件不成立就跳过循环,把代码变换为do-while循环:
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done;
3.6.7.3 for循环
for循环的通用形式如下:
for (init-expr; test-expr; update-expr)
body-statement
GCC为for循环产生的代码时while循环的两种翻译之一,这取决于优化等级。
跳转到中间策略:
init-expr;
goto test;
loop:
body-statement
update-expr;
test:
t = test-expr;
if (t)
goto loop;
guarded-do策略:
init-expr;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
update-expr;
t = test-expr;
if (t)
goto loop;
done;
综上所述,C语言中三种形式的所有循环都可以用一种简单的策略来翻译,产生包含一个或多个条件分支的代码。控制的条件转移提供了将循环翻译成机器代码的基本机制。
3.6.8 switch语句
switch(开关)语句可以根据一个整数索引值进行多重分支(multiway branching)。
在处理具有多种可能结果的测试时,这种语句特别有用,它们不仅提高了C代码的可读性,而且通过使用跳转表(jump table)这种数据结构使得实现更加高效。(跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值等于i时程序应该采取的动作)
书上给出的例子:
再有一个简单的例子:
稀疏的switch语句:
总结
3.7 过程
过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。然后可以在程序中不同的地方调用这个函数。
设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义。
不同编程语言中,过程的形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等,但是它们有一些共有的特性:
假设过程P调用过程Q,Q执行后返回到P。这些动作包括下面一个或多个机制:
- 传递控制——在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为P中调用Q后面那条指令的地址
- 传递数据——P必须能够向Q提供一个或多个参数,Q必须能够向P返回一个值
- 分配和释放内存——在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间
x86-64的过程实现包括一组特殊的指令和一些对机器资源(例如寄存器和程序内存)使用的约定规则。我们尽量减少过程调用的开销,只实现所必需的那些。
3.7.1 运行时栈
x86-64的栈向低地址方向增长,而栈指针%rsp%指向栈顶元素。可以用pushq和popq指令将数据存入栈中或是从栈中取出。(压入和弹出栈数据深入理解计算机系统(第3章 程序的机器级表示④)
https://www.cnblogs.com/kirin-dev/p/Computer-Systems_Chapter-3-2.html )
当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间——栈帧(stack frame)。(x86-64过程只分配自己所需要的栈帧部分)
当过程P调用过程Q时,会把返回地址压入栈中,指名当Q返回时,要从P程序的哪个位置继续执行。
我们把这个返回地址当做P的栈帧的一部分,因为它存放的是与P相关的状态。Q的代码会扩展当前栈的边界,分配它的栈帧所需的空间。在这个空间中,它可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。
大多数过程的栈帧都是定长的,在过程的开始就分配好了,但是有些过程需要变长的帧。
通过寄存器,过程P可以传递最多6个整数值(也就是指针和整数),但是如果Q需要更多的参数,P可以在调用Q之前在自己的栈帧里存储好这些参数。
3.7.2 转移控制
将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的起始位置。
从Q返回时,处理器必须记录好它需要继续P的执行的代码位置。
call Q把地址A(返回地址,为call指令后一指令的地址)压入栈中,并将PC设置为Q的起始地址。
ret把地址A从栈中弹出,并把PC设置为A。
3.7.3 数据传送
x86-64中,可以通过寄存器最多传递6个整型(即整数和指针)参数。
寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递的数据类型的大小。
如果一个函数有大于6个整型参数,超出6个的部分就要通过栈来传递。(把参数1~6复制到对应的寄存器,参数7~n放到栈上,参数7位于栈顶——对应前图中的“参数构造区”)
下面给出数据传送超出6个整型参数的例子:
3.7.4 栈上的局部存储
局部数据无法放在寄存器中,必须放在内存中的情况:
- 寄存器不足够存放所有的本地数据。
- 对一个局部变量使用地址运算符‘&’,因此必须能够为它产生一个地址。
- 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
下面给出过程调用的例子(我们延用刚才提到的proc函数)(涉及前述栈帧的知识):
3.7.5 寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。
虽然在给定时刻只有一个过程是活动的,我们仍然必须确保当一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器值。
为此,x86-64采用了一组统一的寄存器使用惯例,所有的过程(包括程序库)都必须遵循。
- 寄存器%rbx、%rbp和%r12~%r15被划分为被调用者保存寄存器——当过程P调用过程Q时,Q必须保存这些寄存器的值,保证它们的值在Q返回到P时与Q被调用时是一样的。
- 所有其他的寄存器,除了栈指针%rsp,都分类为调用者保存寄存器——任何函数都能修改它们。
下面给出被调用者保存寄存器的例子:
3.7.6 递归过程
栈和寄存器的惯例告诉我们:每个过程调用在栈中都有它自己的私有空间,多个未完成调用的局部变量不会互相影响。
递归调用一个函数本身与调用其他函数是一样的。
下面给出递归过程的例子:
对递归函数的观察: