第三章 程序的机器级表示
用高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。
2.程序编码
unix>
gcc -o1 -o p p1.c p2.c
-o1:告诉编译器使用第一级优化;通常,提高优化级别会使最终程序运行得更快,但是编译时间可能会变长,用调试工具对代码进行调试会更困难。正如我们会看到的,使用更高级别的优化产生的代码会严重改变形式,以至于产生的机器代码和出事源代码之间的关系非常难以理解。因此我们会使用第一级优化作为学习工具,然后当我们增加优化级别时,在看会发生什么。实际中,从得到的程序性能方面考虑,第二级优化被认为是较好的选择。
机器级代码:
对于机器级编程来说,其中两种抽象很重要,第一种是
机器级程序的格式和行为,定义为
指令集体系结构(Instruction set architecture,ISA),它定义了
处理器状态、指令的格式,以及每条指令对状态的影响。第二种是
机器级程序使用的存储器地址是虚拟地址,提供的存储器模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
一些通常对C语言程序员隐藏的处理器状态是可见的:
程序计数器指示将要执行的下一条指令在存储器中的地址;
整数寄存器文件包含8个命名的位置,分别存储32位的值,这些存储器可以存储地址(对应于C的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他则用来保存临时数据,例如过程的局部变量和函数的返回值;
条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如用来实现if和while语句;
一组浮点寄存器存放浮点数据。
程序存储器(program memory)包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的存储器快(比如用malloc库函数分配的)。程序存储器用虚拟地址来寻址,操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器存储器中的物理地址。
1
int accum = 0;
2
3
int sum(int x, int y)
4
{
5
int t = x + y;
6
accum += t;
7
return t;
8
}
|
sum:
pushl %ebp
movl %esp,%ebp
movl 12(%ebp),%eax
addl 8(%ebp),%eax
addl %eax,accum
movl %ebp,%esp
popl %ebp
ret
|
unix> gcc -O2 -c code.c–GCC会编译并汇编该代码产生目标代码文件code.o,二进制形式,无法直接查看。选17个字节序列,十六进制表示:
……55 89 e5 8b 45 0c 03 45 08 01 05 00 00 00 00 89 ec 5d c3……
如何找到程序的字节表示?
要查看目标代码文件的内容,最优价值的是反汇编器。这些程序根据目标代码产生一种类似汇编代码的格式。
反汇编只是基于机器代码文件中的字节序列来确定汇编代码,不需要访问程序的源代码或汇编代码;
设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令pushl %ebp是以字节值55开头的。
生成实际可执行的代码需要对一组目标代码文件运行链接器,链接器将代码的地址移到了一段不同的地址范围中,并确定了存储全局变量的地址。P109
3.数据格式
4.访问信息
一个IA32中央处理单元(CPU)包含一组8个存储32位值的寄存器。
如图,字节操作指令可以独立地读或者写前四个寄存器的2个低位字节。
操作数指示符
源数据值可以以常数的形式给出,或是从寄存器或存储器中读出,结果可以存放在寄存器或存储器中。
因此,各种不同的操作数的可能性被分为三种类型:
1)立即数(immediate),也就是常数值。用$后面跟一个标准C表示法表示的整数,例如$-377或$0x1F。任何能放入32位的字里的数值都可以用作立即数,
不过汇编器在可能时会使用一个或两个字节的编码。(会拆开存在不同的寄存器中?)
2)寄存器(regiser),表示某个寄存器的内容。Ea表示任意寄存器a,Reg[Ea]表示它的值,这是将寄存器集合看成一个数组Reg,用寄存器标识符作为索引。
3)存储器(memory)引用,它会根据计算出来的地址(通常称为有效地址)访问某个存储器位置。Mb[Addr]表示对存储在存储器中从地址Addr开始的b个字节值的引用。
寻址模式
The scaling factor s must be either 1, 2, 4, or 8.
Assume initially that %dh = 8D, %eax = 98765432
1 movb %dh,%al %eax = 9876548D
2 movsbl %dh,%eax %eax = FFFFFF8D
3 movzbl %dh,%eax %eax = 0000008D
例子中都是将寄存器%eax的低位字节设置成%edx的第二个字节。
movb指令不改变其他三个字节,movsbl指令将其他三个字节设为全1或全0,movzbl指令无论如何都是将其他三个字节设为全0.
栈操作说明,根据惯例,我们的栈是倒过来画的,因而栈“顶”在底部,IA32的栈向低地址方向增长,所以压栈是减小栈指针(%esp)的值,并将数据存放到存储器中,而出战是从存储器中读,并增加栈指针的值。
程序栈存放在存储器中的某个区域。栈指针
%esp保存着栈顶元素的地址。
int exchange(int *xp,int y)
{
int x = *xp;
|
xp at %ebp+8,y at %ebp+12 1. movl 8(%ebp),%edx Get xp By copying to %eax below,x becomes the return value 2. movl (%edx),%eax Get x at xp 3. movl 12(%ebp),%ecx Get y 4. movl %ecx,(%edx) Store y at xp |
5.算术和逻辑操作
1)加载有效地址(load effective address)
指令leal实际上是movl指令的变形。它的指令形式是从
存储器读数据到
寄存器,但实际上它根本就没有引用存储器。
它的第一个操作数看上去是一个存储器引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。
leal S
,
D D <–&S
Load Effective Address
For example, if register %edx contains value x, then the instruction leal 7(%edx,%edx,4), %eax will set register %eax to 5x + 7. The destination operand must be a register.
AT&T语法,base(offset, index, i),就是 base+offset+index*i
2)一元操作和二元操作
一元操作只有一个操作数,既是源又是目的;
二元操作,源操作数是第一个,目的操作数是第二个,subl %eax,%edx是使寄存器%edx的值减去%eax中的值。第一个操作数可以是立即数、寄存器或是存储器位置,第二个操作数可以是寄存器或是存储器位置,不过同movl指令一样,两个操作数不能同时是存储器位置。
3)移位操作
先给出移位量,然后第二项给出的是要移位的数值。它可以进行算术和逻辑右移。
移位量用单个字节编码,因为只允许进行0到31位的移位(只考虑移位量的低5位)。移位量可以是一个立即数,或者存放在单字节寄存器元素%cl中。(这些指令很特别,因为只允许以这个特定的寄存器作为操作数)。
左移指令:SAL和SHL,两者效果是一样的,都是将右边填上0.
右移指令:SAR执行算术移位(填上符号位)>>A,SHR执行逻辑移位>>L(填上0)。
6.控制
条件码(condition code)寄存器描述了最近的算术或逻辑操作的属性,可以检测这些寄存器来执行条件分支指令。
最常用的条件码有:
CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号操作数的溢出。
ZF:零标志。最近的操作得出的结果为0.
SF:符号标志。最近的操作得到的结果为负数。
OF:溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。
比较和测试指令CMP和TEST不修改任何寄存器的值,只设置条件码。
jmp指令是无条件跳转,它可以是直接跳转,即跳转目标是作为指令的一部分编码的;也可以是间接跳转,即跳转目标是从寄存器或存储器位置中读出的。汇编语言中,直接跳转是给出一个标号作为跳转目标的,例如”jmp .L1″。间接跳转的写法是”*”后面跟一个操作数指示符,例如jmp *%eax用寄存器%eax中的值作为跳转目标,而指令jmp *(%eax)以%eax中的值作为读地址,从存储器中读出跳转目标。
其他跳转指令都是有条件的——根据条件码的某个组合,或者跳转,或者继续执行代码序列中的下一条指令。
循环
C语言中提供了多种循环结构,即do-while、while和for,汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
do-while循环:
do
body-statement
while(test-expr)
=============
loop:
body-statement
t=test-expr;
if(t)
goto loop;
while循环:
while (
test-expr
)
body-statement
============转换为do-while
if (!test-expr)
goto done;
do
body-statement
while (test-expr);
done:
=============直接翻译成goto代码
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:
for循环:
for (
init-expr
;
test-expr
;
update-expr
)
body-statement
===========================while
init-expr;
while (test-expr) f body-statement
update-expr;
}
=========================do-while
init-expr;
if (!test-expr)
goto done;
do f body-statement
update-expr;
g while (test-expr);
done:
===============================goto
init-expr;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
update-expr;
t = test-expr;
if (t)
goto loop;
done:
swith语句:
可以根据一个整数索引值进行多重分支,处理具有多种可能结果的测试时,这种语句特别有用。
它们不仅提高了C代码的可读性,而且通过使用
跳转表这种数据结构使得实现更加高效。跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当开关索引值i时程序应该采取的动作。程序代码用开关索引值来执行一个跳转表内的数组引用,确定跳转指令的目标。
和使用一组很长的if-else语句相比,使用跳转表的优点是执行开关语句的时间与开关情况的数量无关。GCC根据开关情况的数量和开关情况值的稀少程度来翻译开关语句,当开关情况数量比较多,并且值的范围跨度比较小时,就会使用跳转表。
7.过程
一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。
它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。
大多数机器,只提供转移控制到过程和从过程中转移出控制这种简单的指令。数据传递、局部变量的分配和释放通过操纵程序栈来实现。
栈帧结构:
机器用栈来传递过程参数、存储返回信息、保存寄存器用于以后恢复,以及本地存储。
为单个过程分配的那部分栈称为栈帧(stack frame)。
Figure 3.16: Stack Frame Structure. The stack is used for passing arguments, for storing return information,
for saving registers, and for local storage.
过程P调用过程Q,则Q的参数放在P的栈帧中。另外,当P调用Q时,P中的返回地址被压入栈中,形成P的栈帧的末尾。返回地址就是当程序从Q返回时应该继续执行的地方。Q的栈帧从保存的帧指针的值(如%ebp)开始,后面是保存的其他寄存器的值。
过程Q也用栈来保存其他不能存放在寄存器中的局部变量,原因:
1.没有足够多的寄存器存放所有的局部变量
2.有些局部变量是数组或结构,因此必须通过数组或结构引用来访问
3。要对一个局部变量使用地址操作符&,我们必须为它生成一个地址
另外,Q也会用栈帧来存放它调用的其他过程的参数。
call的指令效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是在程序中紧跟在call后面的那条指令的地址,这样当被调用过程返回时,执行会从此处继续。
ret指令从栈中弹出地址,并跳转到这个位置,正确使用这条指令,可以使栈做好准备–leave,栈指针要指向前面call指令存储返回地址的位置。
寄存器使用惯例:
程序寄存器组是唯一能被所有过程共享的资源。
需要保证被调用者不会覆盖某个调用者稍后会使用的寄存器的值,所以使用惯例,所有的程序必须遵守。
寄存器
%eax,%edx和%ecx被划分为
调用者保存寄存器。当P调用Q时,Q可以覆盖这些寄存器,而不会破坏任何P所需要的数据。
寄存器
%ebx,%esi和%edi被划分为
被调用者保存寄存器。Q必须在覆盖这些寄存器的值之前,先把它们保存到栈中,并在返回前恢复它们。
此外,必须保持寄存器%ebp和%esp。
1 int P(int x)
2 {
3 int y = x*x;
4 int z = Q(y);
5
6 return y + z;
7 }
Procedure P computes y before calling Q, but it must also ensure that the value of y is available after Q returns. It can do this by one of two means:
_ Store the value of y in its own stack frame before calling Q. When Q returns, it can then retrieve the value of y from the stack.
_ Store the value of y in a callee save register. If Q, or any procedure called by Q, wants to use this register, it must save the register value in its stack frame and restore the value before it returns. Thus,when Q returns to P, the value of y will be in the callee save register, either because the register was never altered or because it was saved and restored.
1 int swap_add(int *xp, int *yp)
2 {
3 int x = *xp;
4 int y = *yp;
5
6 *xp = y;
7 *yp = x;
8 return x + y;
9 }
10
11 int caller()
12 {
13 int arg1 = 534;
14 int arg2 = 1057;
15 int sum = swap_add(&arg1, &arg2);
16 int diff = arg1 – arg2;
17
18 return sum * diff;
19 }
GCC坚持一个x86编程指导方针,也就是一个函数使用的所有栈空间必须是16字节的整数倍,包括保存%ebp值的4个字节和返回值的4个字节。采用这个规则是为了保证访问数据的严格对齐。
为什么popl相当于恢复寄存器的值?P154
GCC产生的代码有时候会使用leave指令来释放栈帧(leave指令在ret前使用,既重置了栈指针,又重置了帧指针),而有时会使用一个或两个popl指令。
可以用push指令或是从栈指针减去偏移量来在栈上分配空间。在返回前,函数必须将栈恢复大原始条件,可以恢复所有被调用者保存寄存器和%ebp,并且重置%esp使其指向返回地址。
8.数组分配和访问
T A[N];
–在存储器中分配一个L*N字节的连续区域;用XA表示起始位置。
–引入了标识符A;可以用A作为指向数组开头的指针,这个指针的值就是XA。可以用0-N-1之间的整数索引来访问数组元素,数组元素i会被存放在地址为XA+L*i的地方。
10.综合理解指针
–每个指针都对应一个类型。如果对象类型T,那么指针的类型为*T。特殊的*void类型代表通用指针,比如malloc函数返回一个通用指针,然后通过显式强制类型转换或者赋值操作那样的隐式强制类型转换,将它转换成一个有类型的指针。指针不是机器代码中的一部分,是C语言提供的一种抽象,帮助程序员避免寻址错误。
–每个指针都有一个值。这个值是某个指定类型对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。
–指针用&运算符创建。可以出现在赋值语句左边的表达式。这样的例子包括变量以及结构、联合和数组的元素。leal指令设计用来计算存储器引用的地址,&运算符的机器代码实现常常用这条指令来计算表达式的值。
–操作符用于指针的间接引用。间接引用是通过存储器引用来实现的,要么是存储到一个指定的地址,要么是从指定的地址读取。其结果是一个值,它的类型与该指针的类型相关。
–数组与指针紧密联系。一个数组的名字可以像一个指针变量一样引用(但不能修改)。数组引用a[3]与指针运算和间接引用*(a+3)有一样的效果。
–将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
–指针也可以指向函数。这提供了一个很强大的存储和向代码传递引用的功能,这些引用可以被程序的某个其他部分调用。函数指针的值是该函数机器代码表示中第一条指令的地址。
int (*f)(int*)
要从里往外读。因此,我们看到(*f)表明,f是一个指针;而(*f)(int*)表明f是一个指向函数的指针,这个函数以int*作为参数。最后,它是以int*为参数并返回int的函数的指针。
int *f(int*)->
(int *) f(int*)
函数原型,声明了一个函数f,它以一个int*作为参数并返回一个int*。
第四章 处理器体系结构
1.Y86指令集体系结构
Y86程序用虚拟地址来引用存储器位置。
硬件和操作系统软件联合起来将虚拟地址翻译成实际或物理地址,指明数据实际保存在存储器中哪个地方。
RISC-精简指令集
CISC-复杂指令集
2.逻辑设计和硬件控制语言HCL
存储器和时钟:
时钟寄存器(简称寄存器):存储单个位或字。时钟信号控制寄存器加载输入值。
随机访问存储器(简称存储器):存储多个字。用地址来选择该读或写哪个字,随机访问存储器的例子:1)处理器的虚拟存储器系统,硬件和操作系统软件结合起来使处理器可以在一个很大的地址空间内访问任意的字;2)寄存器文件,再此,寄存器标识符作为地址。
3.Y86的顺序实现
通常处理一条指令包括很多操作。将它们组织成某个特殊的阶段系列,即使指令的动作差异很大,但所有的指令都遵循统一的序列。每一步的具体处理取决于正在执行的指令。
取指(fecth):取指阶段从存储器读取指令字节,地址为程序计数器的值。从指令中抽取出指令指示符字节的两个四位部分,称为icode(指令代码)和ifun(指令功能)。它可能取出一个寄存器指示符字节,指明一个或两个寄存器操作数指示符rA和rB。它还可能取出一个四字节常数字valC。它按顺序方式计算当前指令的下一条指令的地址valP。也就是说,valP等于PC的值加上已取出指令的长度。
译码(decode):译码阶段从寄存器文件读入最多两个操作数,得到值valA和/或valB。通常,它读入指令rA和rB字段指明的寄存器,不过有些指令是读寄存器%esp的。
执行(execute):算术/逻辑单元ALU要么执行指令指明的操作,计算存储器引用的有效地址,要么增加或减少栈指针。得到的值我们成为valE。再次,也可能设置条件码。对于一条跳转指令来说,这个阶段会检验条件码和分支条件,看是不是该选择分支。
访存(memory):访存阶段可以将数据写入存储器,或者从存储器读出数据。
写回(write back):最多可以写两个结果到寄存器文件。
更新PC(PC update):设置成下一条指令的地址。
4.流水线的通用原理
流水线化的一个重要特征就是增加了系统的吞吐量,也就是单位时间内服务的顾客总数,不过它也会轻微地增加延迟,也就是服务一个用户所需要的时间。