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 | 字节 | 1 | b |
short | 字 | 2 | w |
int | 双字 | 4 | l |
long | 四字 | 8 | q |
指针 | 四字 | 8 | q |
float | 单精度 | 4 | s |
double | 双精度 | 8 | l |
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 D | D中数字+1 |
dec D | D中数字-1 |
neg D | D=-D,对D中数取相反数 |
not D | D=~D,对D中数取反 |
D可以是寄存器也可以是主存。
3)双操作数
S D:把D中数和S进行运算后放到D中。S可以是Imm,R,M,D可以是R,M,任意组合。
指令 | 效果 |
---|---|
add S D | D=S+D |
sub S D | D=D-S |
imul S D | D=D*S |
xor S D | D=D^S,异或 |
or S D | D=D|S |
and S D | D=D&S |
注意这个imul,它有对应的b w l q子族,而imulq+双操作数对应结果截取64位,有不同的全乘法得到128位版本。
3)移位操作
XXX k D: 把D中数字移动K位,仍在D中。D可以是主存也可以是寄存器。
指令 | 效果 |
---|---|
sal k D | D<<k |
shl k D | D<<k,效果同上 |
sar k D | D>>k,算术右移,复制符号位 |
shr k D | D>>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可未必按顺序命名。
跳跃表的形成类似编译器对数组结构的实现与维护。