《计算机组成与设计ARM版》网页:Patterson, Hennessy: Computer Organization and Design
Youtube上面 ARM DS-5 教程:Part 3 - Create a Project for ARMv8 Model
toolchain下载地址https://releases.linaro.org/components/toolchain/binaries/latest-7/aarch64-elf/
指令在计算机内部是以一系列或高或低的电信号表示的,形式上和数的表示相同。实际上,指令的各部分都可以看成一个独立的数,将这些数拼接在一起就形成了指令。LEGv8中的32个寄存器用编号0~31表示。
例题
将LEGv8汇编语言指令翻译成机器指令
下面以LEGv8汇编语言为例。对于符号表示的LEGv8指令
ADD X9,X20,X21
首先表示为十进制数的组合,然后表示为二进制数的组合。
答案
其十进制数表示为
1112 | 21 | 0 | 20 | 9 |
---|
指令分为若干字段(field)。第一个字段(本例中包含1112的字段)告诉LEGv8计算机该指令要执行加法运算。第二个字段指明加法操作中第二个源操作数的寄存器编号(即X21的编号21),第四个四段指出另一个源操作数的寄存器编号(X20的20).第五个字段表示存放运算结果的目的寄存器编号(X9的9).第三个字段在这条指令中没有用到,故置为0.这条指令将寄存器X20和寄存器X21的内容相加,并将和放在寄存器X9中。
这条指令中的各个字段也可以表示成二进制的形式:
10001011000 | 10101 | 000000 | 10100 | 01001 |
---|---|---|---|---|
11位 | 5位 | 6位 | 5位 | 5位 |
指令的布局形式叫做指令格式(instruction format)。从二进制位的数目可以看出,LEGv8指令占32位,即一个字或半个双字。遵循简单源于规整的原则,所有的LEGv8指令都是32位长。
虽然早期的计算机仅仅对整字进行操作,但人们很快发现,对字中由若干位组成的字段甚至对单个位进行操作是很有用的。 于是,编程语言和指令集体系结构中增加了一些指令,用于简化对字中若干位进行打包或者拆包的操作。这些指令被称为逻辑操作。
逻辑操作 | C操作符 | Java操作符 | LEGv8指令 |
---|---|---|---|
逻辑左移 | << | << | LSL |
逻辑右移 | >> | >>> | LSR |
按位与 | & | & | AND,ANDI |
按位或 | | | | | OR,ORI |
按位取反 | ~ | ~ | EOR,EORI |
LEGv8实现NOT(取反)操作的一种方式是与全1数做异或处理。
LEGv8汇编语言中有两条决策指令,和if以及go to语句类似。
第一条是:
CBZ register,L1
该指令表示:如果register的数值为0,则转到标签为L1的语句执行。助记符CBZ表示比较为0分支(compare and branch if zero)。
第二条指令是
CBNZ register,L1
该指令表示:如果register的数值不为0,则转到标签为L1的语句执行。助记符CBNZ表示比较不为0分支(compare and branch if not zero)。这两条指令传统上称为条件分支(conditional branch) 指令。
例题
将if-then-else语句编译成条件分支指令
在下面这段代码中,f,g,h,i,j都是变量,假设这五个变量依次对应于五个寄存器X19到X23.请写出这条C语言编写的if语句编译后形成的LEGv8代码。
if (i==j) f=g+h;
else f=g-h;
答案
前面介绍的条件分支指令只能判断一个寄存器的值是否为0,因此第一步要将i和j相减,检查结果是否为0.接下来要做的似乎是如果结果为0,则进行分支,即使用CBZ指令。通常,通过测试分支的相反条件来跳过比较不相等要执行的代码,这样的代码效率会更高。故这里使用CBNZ指令。
SUB X9,X22,X23 //X9=i-j
CBNZ X9,Else // go to Else if i≠ j(X9≠0)
ADD X19,X20,X21 //f=g+h (skipped if i≠ j)
Else: SUB X19,X20,X21 //f=g-h(skipped if i=j)
B Exit
Exit:
例题
编译C语言中的while循环
下面是用C语言编写的一个传统循环程序
while(save[i]==k)
i+=1;
假设i和k存放在寄存器X22和X24中,数组save的基址存放在寄存器X25中。请写出这段C程序对应的LEGv8汇编代码。
变量 | i | k | save基地址 | 临时寄存器 |
---|---|---|---|---|
寄存器 | X22 | X24 | X25 | X9,X10,X11 |
答案
Loop: LSL X10,X22,#3 //临时寄存器存放偏移量,i左移3位,因为按字节编址
ADD X10,X10,X25 //save[i]的地址,在寄存器X10中,此时save[i] 还在 存储器中
LDUR X9,[X10,#0] //save[i]读入到临时寄存器X9中
SUB X11,X9,X24 //作差,当作循环判断条件
CBNZ X11,Exit //不为0,跳出循环
ADDI X22,X22,#1 //i+1
B Loop // 指令跳转到循环开始的地方
Exit:
分析
第一步:将save[i]读入到一个临时寄存器中; save[i]和k相减,差值保存在X11中用于循环测试。当然,这段代码可以进行优化
体系结构设计师通过增加四个额外的二进制位来记录指令执行的状态信息,这些增加的位称为条件码(condition code)或标志位(flag):
条件分支指令通过组合使用这些条件码完成条件判断。在LEGv8指令集中,这种条件分支指令是B.cond
,cond可以用于任意有符号数的比较指令中,比如EQ(等于),NE(不等于),LT(小于),LE(小于等于),GT(大于),GE(大于等于).cond也可以用于无符号数的比较指令,比如LO(低于),LS(低于或相同),HI(高于)或者HS(高于或相同)。
条件码的一个缺点是,如果许多指令频繁设置条件码,就可能造成依赖性问题,使得指令很难流水执行。LEGv8规定只有少数指令—ADD,ADDI,AND,ANDI,SUB和SUBI–能设置条件码,并且条件码的设置是可选择的。 在LEGv8汇编语言中,如果想设置条件码,只需要在相应指令的尾部追加S,如ADDS,ADDIS,ANDS,ANDIS,SUBS,SUBIS.指令名称中实际上使用了术语“flag”,因此ADDS的正确解释应该是“add and set flags”,即加并设置标志位。
边界检查的简便方法
将有符号数作为无符号数来处理,是一种检验 0 ≤ x < y 0≤x
例题
用以下方法检查索引是否越界:如果X20≥X11或X20是负数,则跳转到IndexOutOfBounds处。
答案(这里暂时不懂,后续填坑)
只需使用一条无符号数大于或等于指令即可完成两种检查:
SUBS XZR,X20,X11
B.HS IndexOutOfBounds
翻阅博客文章,找到类似的汇编语句
cmp x11, #4; // 相当于 subs xzr, x11, #4.
// 如果 x11 - 4 == 0, 那么状态寄存器NZCV.Z = 1
// 如果 x11 - 4 < 0, 那么 NZCV.N = 1
来源:0x231 运算
备注
零寄存器XZR
零寄存器忽略所有对它的写操作,并且所有对它的读操作都返回0.您可以在大多数(但不是全部)指令中使用零寄存器。
过程(procedure)或函数是程序员进行结构化编程的工具,其定义为:根据提供的参数完成一定任务的子程序。
在过程执行时,程序必须遵守以下6个步骤:
1 将参数放到过程可以访问的地方
2 将执行的控制权转交给过程
3 获得过程执行所需的存储资源
4 执行需要的任务
5 将结果值放在调用程序可以访问的地方
6 将控制返回调用点,一个过程可能在程序中的多个点被调用。
由于寄存器是计算机中保存数据最快的地方,所以我们希望尽可能多地利用寄存器。
LEGv8软件在位过程调用分配寄存器时遵循以下约定:
X0~X7
:作为参数寄存器(8个),用于传递参数或返回结果。
LR(X30)
:作为返回地址寄存器(存放过程调用的返回地址),用于返回原始调用点。
除了分配寄存器外,LEGv8汇编语言还为过程调用提供了一条指令:该指令在跳转到某个地址的同时,将下一条指令的地址保存在寄存器LR(X30)中。这条分支和链接指令(branch-and-link instruction)BL
可简单表示为:
BL ProcedureAddress
指令名中的链接代表指向调用者的地址或链接,以允许过程返回到合适的地址。存储在寄存器LR中的“链接”称为返回地址,返回地址是必需的,因为同一过程可能在程序的不同地方被调用。
为了支持从过程调用返回,计算机(如LEGv8)使用了寄存器跳转( branch register)指令BR
,表示无条件跳转到寄存器所指定的地址:
BR LR
寄存器跳转指令跳转到存储在LR寄存器中的地址。因此,调用程序或称为调用者(caller),将参数值放在X0~X7中,然后使用BL X 跳转到过程X(有时被称为被调用者(callee))。被调用者执行运算,将结果放在相同的参数寄存器中,然后通过BR LR 指令将控制返回给调用者。
程序计数器(PC):存放正在执行指令的地址的寄存器。BL指令实际上将PC+4保存在寄存器LR中,从而链接到下一条指令的字节地址,为过程返回做好准备。
假设对于一个过程,编译器需要的寄存器超过了8个参数寄存器的数量。由于在任务完成后必须消除过程产生的踪迹,因此调用者原先使用的寄存器都必须恢复到过程调用前的状态。这种情况可以看作是需要将寄存器溢出(换出)到存储器的一个例子。
换出寄存器的最理想的数据结构是栈,一种后进先出的队列。 栈需要一个指针指向栈中最新分配的地址,以指示下一个过程放置换出寄存器的位置,或是寄存器旧址存放的位置。
按照历史惯例,栈从高地址向低地址“增长”。这意味着数据压栈时,栈指针值减小;而数据弹栈时,栈指针增加,(空闲栈)空间缩小。
例题
编译一个不调用其他过程的C过程
将2.2节的例子转化为一个C过程:
long long int leaf_example( long long int g,long long int h, long long int i,long long int j){
long long int f;
f=(g+h)-(i+j);
return f;
}
编译后的LEGv8汇编代码是什么?
答案
参数变量 g,h,i,j分别对应参数寄存器X0,X1,X2,X3,f对应X19.编译后的程序是以一个过程标号开始的:
leaf_example:
下一步是保存过程中使用的寄存器。过程实体中的C赋值语句使用了两个临时寄存器X9和X10.因此,需要保存三个寄存器:X19,X9和X10.我们将旧值压栈,即在栈中建立三个双字的空间(24个字节),并将三个寄存器的值存入:
SUBI SP,SP,#24 // 调整堆栈指针,预留空间
STUR X10,[SP,#16]//保存寄存器X10
STUR X9,[SP,#8]//保存寄存器X9
STUR X19,[SP,#0]//保存寄存器X19
从上面四句汇编可以看出,堆栈从高地址开始使用,一直到低地址。
接下来三条语句对应 函数体
ADD X9,X0,X1
ADD X10,X2,X3
SUB X19,X9,X10
为了返回f的值,我们将它复制到一个参数寄存器中:
ADD X0,X19,XZR // return f (X0=X19+0)
在返回前,还要通过“弹栈”恢复三个寄存器的旧值:
LDUR X19,[SP,#0]// 为调用者恢复X19的旧值
LDUR X9,[SP,#8]//为调用者恢复X9的旧值
LDUR X10,[SP,#16]//为调用者恢复X10的旧值
ADDI SP,SP,#24 //调整堆栈指针,删除三个
过程最后通过一条寄存器分支指令跳转到返回地址:
BR LR // branch back to calling routine
上例中,我们使用了临时寄存器(X9和X10),并假设它们的旧值必须保存和恢复。为了避免保存和恢复一个从未被使用过的寄存器(通常是临时寄存器),LEGv8软件将其中19个寄存器分为两组:
这一简单约定减少了寄存器的换出。在上面的例子中,因为调用者不期望在过程调用时保留寄存器X9和X10,所以我们可以去掉代码中的两次保存和两次载入操作。但仍然需要保存和恢复X19,因为被调用者只能假设调用者需要该值。
不调用其他过程的过程称为叶(leaf)过程。如果所有过程都是叶过程,那么情况就很简单,但实际并非如此。实际上过程嵌套比较多,如果没有一套机制,就容易相互冲突。
一种解决方法是将其他所有必须保留的寄存器压栈,就像把保存寄存器压栈一样。调用者将所有调用后需要的参数寄存器(X0~ X7)或临时寄存器( X9~ X17)压栈。被调用者将返回地址寄存器LR和被调用者使用的保存寄存器(X19~X25)压栈。栈指针SP随着压入栈中的寄存器个数进行调整。返回时,寄存器从存储器中恢复,栈指针也随之重新调整。
例题
编译一个递归的C过程,演示嵌套过程的链接
下面是一个计算阶乘的递归过程:
long long int fact(long long int n){
if(n<1) return (1);
else return (n*fact(n-1));
}
请写出对应的LEGv8汇编代码。
答案
参变量n对应参数寄存器X0.编译后的程序从过程标签开始,然后将两个寄存器保存在栈中,一个是返回地址LR,另一个是X0:
fact:
SUBI SP,SP,#16//调整堆栈指针,预留两个
STUR LR,[SP,#8]// 保存返回地址寄存器
STUR X0,[SP,#0] //保存参变量n
第一次调用fact时,STUR保存程序中调用fact 的地址。下面两条指令测试n是否小于1,如果n≥1则跳转到L1.
SUBIS ZXR,X0,#1 // TEST FOR n<1 (这里是测试X0和1的差值)
B.GE L1 //IF n≥ 1,go to L1
如果n小于1,fact将1 放入一个参数寄存器X1并返回,具体步骤是:在0上加1,它们的和存入X1,然后从栈中弹出两个已保存的值并跳转到返回地址:
ADDI X1,XZR,#1 // return 1
ADDI SP,SP,#16// pop 2 items off stack
BR LR //跳转到返回地址
在从栈中弹出两项之前,本应该加载(加载使用STUR)X0和LR。但是由于n小于1时,X0和LR没有变化,所以跳过了这些指令。
如果n不小于1,参数n减1,然后使用减1后的值再次调用fact:
L1: SUBI X0,X0,#1 //参数减一
BL fact //call fact(n-1)
BL指令(分支和链接指令):跳转到某一个地址的同时将下一条指令的地址保存到寄存器中(LEGv8中为寄存器LR,即X30).
BR指令(寄存器跳转指令):无条件跳转到寄存器所指定的地址
下一条指令是fact的返回位置。下面恢复旧的参数、旧的返回地址,以及栈指针:
LDUR X0,[SP,#0] // 从BL返回:恢复参数n
LDUR LR,[SP,#8] // 恢复 返回地址
ADDI SP,SP,#16 //调整堆栈指针 弹出2个位置
接下来,值寄存器X1得到旧参数X0和当前值寄存器的乘积
MUL X1,X0,X1 //return n*fact(n-1)
最后 ,fact再次跳转到返回地址:
BR LR // return to the caller
C语言中的一个变量通常对应存储器中的一个位置,其解释取决于类型和存储方式。典型的类型包括整型和字符型。C语言提供两种存储方式:动态地和静态的。动态变量位于过程中,当过程退出时失效。静态变量在进入和退出过程时始终存在。在所有过程之外声明的C变量,以及声明时使用关键字static的变量都视为静态的,其余的变量都视为动态的。为了简化对静态数据的访问,LEGv8编译器保留了一个寄存器,称为全局指针(global pointer),即GP。例如X27寄存器可以保留全局指针。
栈的另一点复杂性在于,栈还用来保存过程的局部变量,而这些变量可能不适用于寄存器,例如局部数组或结构体。栈中包含过程所保存的寄存器和局部变量的片段称为过程帧(procedure frame)或活动记录(activation record).
有些LEGv8编译器使用帧指针(Frame Pointer,FP)指向过程帧的第一个双字。在过程中栈指针(SP)可能会发生改变。 因此,在过程中的不同位置对存储器中局部变量的引用可能会具有不同的偏移量,这使得过程更加难以理解。另一种方案是,帧指针(FP)在一个过程中为本地存储器引用提供一个固定的基址寄存器。注意,无论是否使用显示的帧指针,活动记录都出现在栈中。
对定义再明确一下
过程帧:也称为活动记录,栈中包含过程所保存的寄存器以及局部变量的片段。
帧指针:指向给定过程中保存的寄存器和局部变量的值(指针)
上图表示过程调用前(a),调用时(b),调用后(c)栈的分配情况。帧指针(FP或者X29)指向该帧的第一个双字(一般是保存的参数寄存器 ,saved argument registers),栈指针(SP)指向栈顶。(本书中,栈顶在图片下方,因为栈从高地址到低地址)。栈进行调整,为所有的保存寄存器和驻留在内存的局部变量提供足够的空间。 由于栈指针(SP)可能会改变,所以对于程序员来说,虽然使用栈指针和少量的地址运算就可能完成对变量的引用,但使用固定的帧指针(FP)会变得更为简单。 如果在一个过程中栈中没有局部变量,则编译器可以不设置和不恢复帧指针以节省时间。使用帧指针时,在过程调用时使用SP中的地址初始化FP,而SP可以使用FP来恢复。
除了动态变量之外,C程序员还需要再内存中为静态变量和动态数据结构提供空间。下图给出了LEGv8在运行Linux操作系统时内存分配的约定。
栈从用户地址空间的高端(高地址)开始并向下增长。内存低端的第一部分是保留的,接着是LEGv8机器代码,通常称为代码段(text segment),即下图中的Text。代码段之上是静态数据段,是存储常量和其他静态变量的空间。数组通常具有固定长度,因而能与静态数据段很好地匹配。但类似于链表这样的数据结构通常会在生命周期内增长或缩短,这类数据结构对应的段通常称为堆(heap),一般在存储器中放在静态数据段之后。这种分配允许栈和堆相互增长(相反方向),从而在连个段此消彼长的过程中实现对内存的高效使用。
一些递归过程可以不通过递归而用迭代方式实现。通过消除递归时过程调用产生的相关开销,可以显著提高迭代性能。例如,考虑下面的求和过程:
long long int sum(long long int n,long long int acc){
if(n>0)
return sum(n-1,acc+n);
else
return acc;
}
考虑过程调用sum(3,0).该过程递归调用sum(2,3),sum(1,5),sum(0,6),结果6通过4次返回得到。这种求和的递归调用称为尾调用,这个尾递归的例子可以高效地实现(假设X0=n,X1=acc,结果在X2中):
sum: SUBS XZR,X0,XZR // COMPARE N TO 0
B.LE sum_exit //go to sum_exit if n≤0
ADD X1,X1,X0 // add n to acc
SUBI X0,X0,#1 // n-1
B sum // go to sum
sum_exit:
ADD X2,X1,XZR // return value acc
BR LR // return to caller