Li Jiaheng's blog
4039 字
20 分钟
汇编语言初步(1) 数据类型、运算、条件、循环
WARNING

这是《深入理解计算机系统》(CS)的笔记,彼时还未上CO,OS等计算机系统基础课,存在理解不到位与错误之处.

汇编语言初步,并且从机器代码看我们的程序。
使用ATT汇编代码格式(而不是Intel)

寄存器名称#

%xxx可能表示寄存器名称。特殊地,%rip 表示程序计数器,这里面包含了下一个要执行的指令。%rax,%rbx都表示寄存器。%在反汇编中往往缺省。
命名、用处、只操作低位几字节见P120,不同命名可能是同一寄存器的低位。

不同的数据用到的寄存器位数不一样,对应的寄存器虽然是同一个器件,但是识别名称不一样。如%rax是第一个寄存器64位时的名称,而%eax是同一个寄存器只用32位时的名称。8、16、32、64都有单独的名称

值得注意的是,任意为寄存器进行低32位操作的指令,都会直接把高32位置0.

数据类型(Intel x86-64)#

C声明Intel字节汇编代码后缀
char字节1b
short2w
int双字4l
long四字8q
指针四字8q
float单精度4s
double双精度8l

P119 《深入理解计算机系统》
可见机器代码中,不分辨整型和指针。整型和浮点数使用完全不同的指令集和寄存器。

数据访问#

操作数#

这些数值是被运算、移动的对象。
从不同来源读取数字:常量 寄存器 主存
常量 Imm,反汇编中为$+数字
寄存器r 为寄存器名称,指令r表示R[r],从寄存器 r 中取出数字。
主存Imm(r1,r2,s) 表示 M[Imm+R[r1]+R[r2] * s],在主存中访问中括号中地址值的数值。Imm可无,括号及其中所有可无(只剩Imm),r1可无,s可无。反正Imm,r1,r2,s四个任意排列组合。
注意s一定只能是1,2,4,8中的一个,或者缺省。

可见C语言的指针ptr就是那个主存地址。如果一个指针存储在寄存器%rcx中,则(%rcx)就能访问到ptr指向的数据。

数据传送#

1)MOV S D : 将源数据S复制到目标位置D中。如果S位数低于D所能存储的位数(64bits),除了复制 l 即双字int时把高位置0,其它时候不变高位。

mov_描述
movb传送字节char
movw传送字short
movl传送双字int,注意高位清零
movq传送四字long
movabsq传送绝对四字long(?),只能把立即数送到寄存器

例如:movq (%rdi), %rax 把地址为R[%rdi]的主存的值放入寄存器%rax

2) MOVZ S R: 把源数据S送到R寄存器 处。如果S位数低,对S进行高位0扩展,然后再移动
格式:movz_ _ 两个下划线都是类型(字),前一个是S,后一个是R的类型。前一个的类型必须短于后一个类型,注意没有movzlq,因为移动双字高位清零,默认为0扩展!
例如:movbq,表示把字节数据0扩展为四字数据,赋值到目标寄存器。
用于unsigned

3)MOVS S R: 把源数据S送到指定寄存器 处。对S进行符号位扩展。符号位为0,高位全为0,符号位为1,高位全为1.
格式:movs_ _ 意义同上。
用于signed
这一族里面有一个特殊的 cltq 指令,它没有操作数,默认读出%rax寄存器的低32位,进行符号位扩展到64位,仍在%rax中。

任何数据传送指令都不能直接把主存中的数据存到主存中去,一定要先从主存到寄存器,再从寄存器到主存。即S D 不能同时是主存的操作数
强制类型转换中,涉及到数据长度变化有涉及到符号变化的,先在自己的数据类型下扩充长度,再用另一种解释解读数据(即二进制数实际上不变) 如int a=-1, 二进制0xffffffff,全1,(unsigned long long)a会变成 2^64-1,即也是全1。只有从机器代码看,只有数据在本类型下的扩充才有对应的机器指令,即movs movz,同样长度的数据类型转换(符号变换)只是在软件上换了一种解读方式,二进制数字形式没变。
windows下long 是48位而非64位。

入栈出栈#

pushq popq
程序中的栈由 %rsp 维持。%rsp只存储栈顶Top的指针,实际的是栈在主存的数组。栈是向下生长的,也就是栈顶地址是最小的。%rsp中是地址,一定是四字q
pushq S 把源数据S入栈。 R[%rsp] <—R[%rsp]-8; (%rsp) <— S先更新栈顶指针,在把数据放进指针所指。Stack[++top]=S;
popq S 把栈顶数据放到目标位置D中。D <—(%rsp); R[%rsp] <—R[%rsp]+8; 类比于D=Stack[top—];
规定栈向低地址生长,可以访问到栈中任意元素。8*k(%rsp),表示访问从栈顶往上推,第i个数据。
这个栈的用处,在 汇编语言初步(2) 中详细介绍

x86中有一些奇特的指令,比如 PUSH SP 指令将 SP 寄存器中的值 -2 后压入栈中,这时候不同的机器会有微妙的不同,有的先把SP-2,再入栈;有的先入栈原值,才在SP上-2。虽然很少见这样的指令,但也为可移植性带来了担忧。
pop的类似指令中没有这样的差异。

算数与逻辑运算#

64位及以下常规运算#

本大组运算中,除了 leaq 所有指令都在末尾加上 b w l q的子族,对应不同的数据类型。

1)leaq S D : &S —> D 加载有效地址
这个指令很特殊,S一定是一个访问主存格式的操作数,D一定是寄存器,它的原意是把S(这个主存)的地址放到寄存器D中。如:

leaq (%rsi) %rax  

(%rsi)对应地址是%rsi所存储的值的内存的值,但是leaq指令并不访问这个值,而直接把%rsi中存的这个地址放到寄存器%rax中。
显然不一定只限制为“地址”,直接把寄存器中的64位数全当成地址,可以实现有限的64位数加法和乘法。例如:

/* x in %rsi, y in %rdx */  
leaq 2(%rsi,rdx,4) %rdx  
/* 数值上等价于 y = 2 + x + 4y */  

即,leaq可以便利实现取地址运算符,和对直接存储在寄存器上的变量的有限的加减乘运算。

2)单操作数
对应x++,x—,x~=x,x-=x 单目运算。

指令效果
inc DD中数字+1
dec DD中数字-1
neg DD=-D,对D中数取相反数
not DD=~D,对D中数取反

D可以是寄存器也可以是主存。

3)双操作数
S D:把D中数和S进行运算后放到D中。S可以是Imm,R,M,D可以是R,M,任意组合。

指令效果
add S DD=S+D
sub S DD=D-S
imul S DD=D*S
xor S DD=D^S,异或
or S DD=D|S
and S DD=D&S

注意这个imul,它有对应的b w l q子族,而imulq+双操作数对应结果截取64位,有不同的全乘法得到128位版本。

3)移位操作
XXX k D: 把D中数字移动K位,仍在D中。D可以是主存也可以是寄存器。

指令效果
sal k DD<<k
shl k DD<<k,效果同上
sar k DD>>k,算术右移,复制符号位
shr k DD>>k,逻辑右移,左端补0

结果存储位128位的全乘法全除法#

算术左端的被乘数、被除数一定放在 %rax 中!乘法结果中,%rax存低四位,%rdx存高四位!除法中,%rax存商,%rdx存余数。
指令中的操作数是乘数和被乘数。

指令效果
imulq S有符号全乘法,区分imul S D
mulq S无符号全乘法
idivq S有符号除法
divq S无符号除法
cqto无操作数,把%rax中的数转换为128位,符号位扩展

控制与跳转#

它们是实现判断、循环等的基础。

条件码#

一个bit的条件码,反应了刚才进行的计算的一些特征,通过它们可以进行复杂的条件判断。

条件码作用
CF最近的计算最高位产生了进位
ZF最近的操作结果为0
SF符号,最近的操作结果为负数
OF溢出,最近的计算结果产生溢出

有两组指令对条件码进行修改:CMP TEST,它们的后缀为表示数据类型的 b w l q。
CMP S1 S2:计算S2-S1但不改变寄存器的值,只改变条件码。不同的结果、不同的数据类型会标识不同的条件码。
TEST S1 S2:计算 S1&S2, 其它同上。

访问条件码:SET族指令。
SET D 把访问结果放到D中去。注意SET族的后缀不是表示数据类型的b w l q!它有一大串后缀,表示不同的条件码排列组合并且进行不同的逻辑运算后得到的结果。如sete setne sets… 等。具体在《深入理解计算机系统》P137

跳转指令:JUMP族#

JUMP label :有条件或者无条件地跳转到 label所在的位置。

1)jump Label / *Operand
无条件跳转。其中Label是写在机器代码中的“常数”,*Operand可以是从存储中读出来的某个地址。前面加 * 表示。

2)j+后缀:有条件跳转
这里的后缀和SET含义一样,相应地访问条件码,进行相应计算,符合条件(为1)就跳转。
和SET后缀对应的条件码行为是一样的。
JUMP命令本身不会改变条件码的值,它会访问条件码(不需要额外SET)。它往往跟在CMP, TEST后面。
JUMP实现条件分支语句,成为条件控制方法,是标准、普适的方法。

JUMP实现条件控制,一般会判断错误的情况,若判断为错误,就跳到False对应的语句Label,而正确的是紧跟在判断后面。

/* 用 goto 表达编译器机器代码所做的事情,test_expr是条件 */  
	if(!test_expr)  //判断错误情况。  
		goto False;  
	{True statement}  //条件为真时,要做的事情。  
	goto done;    //跳去if-else后面的其它语句,下一个。  
False:  
	{False statement}  
done:  
	......  

一般情况下,都会是上面这个格式。

3)JUMP指令会亏损微处理器的“指令流水”,降低效率。
流水线:处理器执行一条指令要做的事:读取指令—从内存读数据—执行计算—将结果写入内存。如果没执行一条指令要等这四步执行完再下一条,会导致一定的空闲,降低速度(如计算时候读取指令的硬件不工作,读指令时计算单元不工作,浪费)。使用流水线,当处理器在处理本条指令的时候,同时就读取下一条指令,这样就减少浪费。这要求处理器明确知道要读取的下一条指令是什么,也就是指令必须是严格线性一条一条来的,这样指令永远是满的,更快。(也就是读取指令的硬件会时刻不停地把指令读到“指令队列“,执行单元从队列中拿指令,没有等待时间)

如果用JUMP跳转函数,处理器不知道下一条指令在哪里(可能会跳到别处),必须等判断指令进行完毕才能读取指令,降低了速度。
使用分支预测逻辑电路 猜测会进入到哪个分支,并且在执行完判断前根据猜测直接进入该分支进行流水操作。但预测总有个概率问题(最好的也不到90%,并且不同问题准度不同,如x<y就太随机了),如果预测错误,要抛弃处理器已经在所预测的分支中读取的指令,并返回正确的指令读取,亏损是巨大的。(十数个时间周期)。

于是有另一种条件分支机器编码策略:条件转移。当不同的条件下进行的操作较统一(且足够简单,是必须相当简单!如只有一个加法减法)时,机器代码首先计算所有分支下的结果,然后进行判断,对应的把判断为真的情况所计算的结果复制到结果寄存器中(比如要返回,则把正确结果复制到%rax),这样这个指令就是线性的,不用预测下一个指令在哪。
一般会把其中一个直接放到结果寄存器,判断是否需要复制更新。于是有CMOV指令

条件转移策略:CMOV指令#

它紧跟着用于判断的CMP TEST指令使用,后缀和前面SET JUMP族指令的后缀意义与种类一样。
cmov_ _ S R:当满足后缀所确定的条件时,将S中的值复制到R中去。
它用于条件转移的条件分支语句机器实现。一般,问号表达式就对应着这种策略,能很方便的应用条件转移

问号表达式的隐患:问号表达式强制编译器以条件转移(传送)方法进行机器代码汇编,条件传送的判断一步是在进行完所有分支的计算后,才在最后实行,可能导致错误,如:

long long *xp=XXXX;  
(xp) ? *xp : 0;  

这个语句提前判断xp是否是空指针,然后再访问。但问号表达式一定用条件转移方法实现,在判断xp是否是空指针前,已经对它进行了访问以试图计算出第一个分支的结果,导致错误!
如果你的判断是为了确定某个变量能否被正常引用(如越界、空指针等),请不要用问号表达式。

绝大多数情况,编译器仍使用可靠的条件控制方法。

实现循环#

本节中都使用思路类似的C伪代码表示汇编语言的实现。

do{ }while();#

......  
loop:  
	"body code";  
	if(test_expr)  
		goto loop;  

while(){}#

常规标准写法:

......  
	goto test;  
loop:  
	"body code";  
test:  
	if(test_expr)  
		goto loop  
循环外后续代码...  

guarded-do:
它常见于经过高级别优化的机器代码中,比如-O1,-O2等,编译器常常能在初始判断中找到简化优化的方法。

	if(!test_expr)  
		goto done;  //初始条件不成立,跳过此循环  
loop:  
	"body code";  
	if(t_expr)  
		goto loop;  
done:  
	......  

for( ; ; ){}#

转化为while。只用特别注意continue是跳到update语句。

switch(){}#

维护一个跳跃表。多数情况switch的实现过程是,将标签变量的值减去所有case中最小的那个,然后这个值就是跳跃表中的 “下标” ,根据这个下标无条件跳转到(jmp)跳跃表中规定好的位置,这里就是这个case下要执行的代码块的位置。有的case不在switch的case中显示出现,跳跃表中仍会让这个情况占用一行,它对应的标签就是指向了default对应的代码块。同样的,如果有的case被放在了一起,(如case 2: case 4:),以这两个数为下标的跳跃表中元素是同样的位置。
当遇到case中的break时,编译器给这个代码块一格无条件跳转 jmp 到switch{ }外面的语句块,表示结束。如果没有遇到这个break,机器会直接滑入下一个Label对应的语句块中执行,这就是C语言switch奇怪规则的来源。可见,汇编代码中各个代码块的顺序和switch中case对应的顺序一致。当然,它们的标签Label可未必按顺序命名。
跳跃表的形成类似编译器对数组结构的实现与维护。

汇编语言初步(1) 数据类型、运算、条件、循环
https://namisntimpot.github.io/posts/computersystem/汇编语言/汇编语言初步1数据类型运算条件循环/
作者
Li Jiaheng
发布于
2022-08-15
许可协议
CC BY-NC-SA 4.0