x86汇编

x86汇编

本文使用AT&T风格的汇编代码展示。

数据格式

由于x86是从16位机发展过来的,Intel就用术语“字(word)”表示16位数据类型。由此衍生出“双字(double word)”,“四字(quad word)”。具体如下表。

Intel数据类型 汇编代码后缀 大小(字节) 备注
字节 b 1
w 2
双字 l 4
四字 q 8 x86-64语法
单精度 s 4
双精度 l 8

寻址模式

寻址模式可以总结为\(Imm(r_b,r_i,s)\)这种形式,由四部分组成:一个立即数偏移地址\(Imm\),一个基址寄存器\(r_b\),一个变址寄存器\(r_i\),一个比例因子\(s\),这里\(s\)必须为1,2,4或者8。计算出的有效地址为\(Imm+R[r_b]+R[r_i]*s\)

下表就是上述形式的各种用法。

格式 操作数值 操作对象 名称
\(\$Imm\) \(Imm\) 立即数 立即数寻址
\(r_a\) \(R[r_a]\) 寄存器 寄存器寻址
\(Imm\) \(M[Imm]\) 内存 绝对寻址
\((r_a)\) \(M[R[r_a]]\) 内存 间接寻址
\(Imm(r_b)\) \(M[Imm+R[r_b]]\) 内存 基址+偏移量寻址
\((r_b,r_i)\) \(M[R[r_b]+R[r_i]]\) 内存 变址寻址
\(Imm(r_b,r_i)\) \(M[Imm+R[r_b]+R[r_i]]\) 内存 变址寻址
\((,r_i,s)\) \(M[R[r_i]*s]\) 内存 比例变址寻址
\(Imm(,r_i,s)\) \(M[Imm+R[r_i]*s]\) 内存 比例变址寻址
\((r_b,r_i,s)\) \(M[R[r_b]+R[r_i]*s]\) 内存 比例变址寻址
\(Imm(r_b,r_i,s)\) \(M[Imm+R[r_b]+R[r_i]*s]\) 内存 比例变址寻址

数据传送指令

MOV类

MOV指令的源操作数可以是一个立即数,寄存器或者内存地址。目的操作数只能是寄存器或者内存地址。并且MOV指令不支持内存到内存的移动,必须先从内存移动到寄存器,再从寄存器移动到内存。

指令 效果 描述
MOV S,D D<-S 传送
movb 传送字节
movw 传送字
movl 传送双字
movq 传送四字
movabsq I,R R<-I 传送绝对四字

movq只允许最大32位的立即数作为源数据,然后高位用符号扩展传送到64位目的,当需要64位立即数时,得使用专门的movabsq指令。

movabsq 0x0011223344556677, %rax
movq %rax, %rbx

一般情况,MOV指令只会更新指定大小的数据,但movl指令例外,当其目的地为寄存器时,它会将寄存器的高4位字节置为0,造成这个例外的原因是x86-64采用的惯例,即任何为寄存器生成32位值的指令会将该寄存器的高位置为0。

movabsq $0x0011223344556677, %rax  // %rax = 0x0011223344556677
movb $-1, %al                      // %rax = 0x00112233445566ff
movw $-1, %ax                      // %rax = 0x001122334455ffff
movl $-1, %eax                     // %rax = 0x00000000ffffffff
movq $-1, %rax                     // %rax = 0xffffffffffffffff

MOVZ和MOVS类

在将较小的源值复制到较大的目的时,使用MOVZ指令对高位字节做0扩展,使用MOVS指令对高位字节做符号位扩展。目的地址必须是寄存器。

零扩展。

指令 效果 描述
MOVZ S,R R<-零扩展(S) 以0扩展进行传送
movzbw 将做了0扩展的字节传送到字
movzbl 将做了0扩展的字节传送到双字
movzwl 将做了0扩展的字传送到双字
movzbq 将做了0扩展的字节传送到四字
movzwq 将做了0扩展的字传送到四字

符号位扩展。

指令 效果 描述
MOVS S,R R<-符号扩展(S) 以符号扩展进行传送
movsbw 做了符号扩展的字节传送到字
movsbl 做了符号扩展的字节传送到双字
movswl 将做了符号扩展的字传送到双字
movsbq 做了符号扩展的字节传送到四字
movswq 做了符号扩展的字传送到四字
movslq 做了符号扩展的双字传送到四字
cltq %rax<-符号扩展(%eax %eax符号扩展到%rax

压入和弹出栈指令

指令 效果 描述
pushq S R[%rsp]<-R[%rsp]-8
M[R[%rsp]]<-S
将四字压入栈
popq D D<-M[R[%rsp]]
R[%rsp]<-R[%rsp]+8
将四字弹出栈

%rsp寄存器记录的就是栈顶指针,这里栈顶指针是往低地址增长的。

算术和逻辑操作指令

指令 效果 描述
leaq S,D D<-&S 加载有效地址
INC D D<-D+1 自增1
DEC D D<-D-1 自减1
NEG D D<--D 取负
NOT D D<-~D 取反
ADD S,D D<-D+S 加上S到D
SUB S,D D<-D-S 从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
SAL k,D D<-D<< k 左移
SHL k,D D<-D<< k 左移(等同于SAL)
SAR k,D D<-D>>k 算术右移(符号位扩展)
SHR k,D D<-D>>k 逻辑右移(零位扩展)

加载有效地址指令

加载有效地址(load effective address)指令leaq实际上是movq指令的变形。leaq指令直接计算源操作数对应的地址,然后不对地址做解引用,直接复制给目的,编译器经常用来做一些计算表达式的活。

movq $1, %rax                // %rax = 1
leaq 7(%rax, %rax, 4), %rdx  // %rdx = 5%rax+7 = 12

位移指令

位移指令中先给出位移量,再给出要位移的目的操作数。位移量可以是一个立即数,也可以是%cl寄存器。

特殊算术操作指令

指令 效果 描述
imulq S R[%rdx]:R[%rax]<-S*R[%rax] 有符号全乘法
mulq S R[%rdx]:R[%rax]<-S*R[%rax] 无符号全乘法
clto R[%rdx]:R[%rax]<-符号扩展(R[%rax]) 转换为8字
idivq S R[%rdx]<-R[%rdx]:R[%rax] mod S
R[%rax]<-R[%rdx]:R[%rax] / S
有符号除法
divq S R[%rdx]<-R[%rdx]:R[%rax] mod S
R[%rax]<-R[%rdx]:R[%rax] / S
无符号除法

控制相关指令

条件码

CPU维护着一组单个位的条件码寄存器。它们描述了最近的算术或者逻辑运算属性。可以通过检测这些条件码寄存器来执行条件分支指令。常见的条件码有。

  • CF:进位标志,最近的操作使最高位产生了进位,可以用来判断无符号操作的溢出。
  • ZF:零标志,最近的操作得出的结果为0。
  • SF:符号标志,最近的操作得出的结果为负数。
  • OF:溢出标志,最近的操作导致一个补码溢出——正溢出或者负溢出。

设置指令

有两类指令,比较指令和测试指令,它们都只设置条件码,不改变任何其它寄存器。

指令 基于 描述
\(CMP\quad S_1,S_2\) \(S_2-S_1\) 比较
cmpb 比较字节
cmpw 比较字
cmpl 比较双字
cmpq 比较四字
\(TEST\quad S_1,S_2\) \(S_1\) & \(S_2\) 测试
testb 测试字节
testw 测试字
testl 测试双字
testq 测试四字

TEST的典型用法是两个操作数一样,比如testq %rax, %rax来判断%rax是正数还是0还是负数。或者其中的一个操作数是掩码,用来指示哪些位应该被测试。

一条SET指令的目的操作数是低位单字节寄存器。或是一个字节的内存位置。

指令 同义词 效果 描述
sete D setz D<-ZF 相等/零
setne D setnz D<-~ZF 不相等/不为零
sets D D<-SF 负数
setns D D<-~SF 非负数
setg D setnle D<-(SF^OF)&ZF 大于(有符号)
setge D setnl D<-~(SF^OF) 大于等于(有符号)
setl D setnge D<-SF^OF 小于(有符号)
setle D setng D<-(SF^OF)|ZF 小于等于(有符号)
seta D setnbe D<-CF&ZF 超过(无符号)
setae D setnb D<-~CF 超过或相等(无符号)
setb D setnae D<-CF 低于(无符号)
setbe D setna D<-CF|ZF 低于或相等(无符号)

SET指令可以和CMP指令搭配使用。

// int comp(data_t a, data_t b)
// a in %rdi, b in %rsi
comp:
    cmpq %rsi, %rdi     // 比较a和b
    setg %al            // 判断a>b
    movzbl %al, %eax
    ret

跳转指令

正常情况下,指令按照出现的顺序一条一条地执行。jmp指令可以将执行切换到程序的另一个位置(相当于高级语言中的goto)。

jmp指令通常有两种跳转格式,一个是目的地址用一个标号(LABEL)指明叫做直接跳转,一个是目的地址用寄存器或内存中保存的值指明叫做间接跳转。

// 直接跳转
    jmp .L1
    movq %rax, %rbx
.L1:
    popq %rdx

// 间接跳转,前面需要加一个星号*
jmp *%rax
指令 同义词 跳转条件 描述
jmp LABEL 1 直接跳转
jmp *Operand 1 间接跳转
je LABEL jz ZF 相等/零时跳转
jne LABEL jnz ~ZF 不相等/非零时跳转
js LABEL SF 负数时跳转
jns LABEL ~SF 非负时跳转
jg LABEL jnle (SF^OF)&ZF 大于时(有符号)跳转
jge LABEL jnl ~(SF^OF) 大于等于时(有符号)跳转
jl LABEL jnge SF^OF 小于时(有符号)跳转
jle LABEL jng (SF^OF)|ZF 小于等于时(有符号)跳转
ja LABEL jnbe CF&ZF 超过时(无符号)跳转
jae LABEL jnb ~CF 超过相等时(无符号)跳转
jb LABEL jnae CF 低于时(无符号)跳转
jbe LABEL jna CF|ZF 低于相等时(无符号)跳转

在汇编代码中,跳转目标用标号(LABEL)书写,后续的汇编器和链接器会产生适当的编码。通常是程序指针相对的(PC-relative)的,它们会将目标指令的地址和紧跟在跳转指令后面的那条指令的地址之间的差值作为编码。这种相对值编码可以编码为1,2或4个字节。

第二种编码方式就是直接写出“绝对”地址,用四个字节直接指定目标。

用条件控制实现分支语句(if)

C语言中if-else的通用形式如下。

if (test-expr)
    then-statement
else 
    else-statement

汇编通常会用下面的格式进行翻译,下面的ifgoto语法在汇编中可以用CMP指令和JMP指令来实现。

    t = test-expr;
    if (!t)
        goto false;
    then-statement
    goto done;
false:
    else-statement
done:

条件传送指令实现三元运算符

指令 同义词 传送条件 描述
cmove S,R cmovz ZF 相等或零时传送
cmovne S,R cmovnz ~ZF 不相等或非零时传送
cmovs S,R SF 负数时传送
cmovns S,R ~SF 非负数时传送
cmovg S,R cmovnle (SF^OF)&ZF 大于时(有符号)传送
cmovge S,R cmovnl ~(SF^OF) 大于等于时(有符号)传送
cmovl S,R cmovnge SF^OF 小于时(有符号)传送
cmovle S,R cmovng (SF^OF)|ZF 小于等于时(有符号)传送
cmova S,R cmovnbe CF&ZF 超过时(无符号)传送
cmovae S,R cmovnb ~CF 超过相等时(无符号)传送
cmovb S,R cmovnae CF 低于时(无符号)传送
cmovbe S,R cmovna CF|ZF 低于相等时(无符号)传送

同条件跳转不同,条件传送不需要分支预测,所以性能更高。

三元运算符可以通过条件跳转和条件传送实现。

// 三元运算符
v = test-expr ? then-expr : else-expr;

// 条件跳转实现
    t = test-expr;
    if (!t)
        goto false;
    v = then-expr;
    goto done;
false:
    v = else-expr;
done:

// 条件传送实现
v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;

但是要用条件传送那肯定是有限制的。当计算表示比较复杂,或者计算表达式有副作用(比如自增,取地址等),那么用条件传送反而不好。对GCC的实验表明,只有两个表达式都很好计算的时候,比如简单的加减法,那么会翻译为条件传送的形式。

用条件控制实现循环语句

C语言中do-while循环形式如下。

do
    body-statement
while (test-expr);

翻译为汇编格式如下。

loop:
    body-statement
    t = test-expr;
    if (t)
        goto loop;

while循环形式如下。

while (test-expr)
    body-statement

翻译为汇编格式如下。

goto test;
loop:
    body-statement
test:
    t = test-expr;
    if (t)
        goto loop;

for循环形式如下,其实和while的形式是一样的。

for (init-expr; test-expr; update-expr)
    body-statement

// 等于while
init-expr;
while (test-expr)
{
    body-statement
    update-expr;
}

用条件控制实现switch

C语言中的switch语句支持整型常量作为跳转分支的值,当分支数量比较多,并且分支的值范围比较小时,可以用跳转表来优化程序的性能。

跳转表是一个数组,里面存放了各个分支的入口代码地址,这样直接就可以根据传入的值跳转到对应的分支入口。

// 原版C语言switch代码
int ans = 0;

switch (n) {
case 100 :
    ans = 0;
    break;

case 102 :
    ans = 2;
    //fall through

case 103 :
    ans = 3;
    break;

case 104 :
    break;

default :
    ans = 0;
}

//翻译为跳转表的C代码
//跳转表
static void *jt[6] = {
    &&loc_A, &&loc_def, &&loc_B,
    &&loc_C, &&loc_D, &&loc_def
};

//对分支对应的值做简单的坐标变换
unsigned index = n - 100;

int ans = 0;

//default分支
if (index > 5) {
    goto loc_def;
}

//进行分支跳转
goto &jt[index];

//case 100
loc_A :
    ans = 0;
    goto done;

//case 102
loc_B :
    ans = 2;
    //fall through

loc_C :
    ans = 3;
    goto done;

//case 104
loc_D :
    ans = 6;
    goto done;

loc_def :
    ans = 0;

done :

对于其他情况,翻译为if-elseif-else那种形式就可以了。

汇编实现函数

实现函数主要有三点。

  • 转移控制:将执行流程从函数Caller跳转到函数Callee,执行完后又跳转回函数Caller。这个主要依靠callret指令实现。
  • 参数传递:通过寄存器最多可以传递6个参数(x86-64),多余的参数可以放在函数Caller的栈帧上,也就是在执行call之前,先把参数push到内存里面。
  • 数据返回:一般是通过%rax寄存器返回数据。
指令 描述
call LABEL 过程调用
call *Operand 过程调用
ret 从过程调用中返回

当执行call指令后,会将紧跟在call指令后面的那条指令的地址压入栈。然后跳转到被调用函数Callee。

而对于ret指令,则是相反的顺序,先从当前栈顶弹出之前保存的调用者函数Caller中下一条指令的地址,然后再跳转到该地址继续执行(所以得出在退出一个函数之前要先把函数栈都退完,让栈顶指针%rsp指向回去的地址)。

x86-64中,可以通过寄存器传递最多6个整型数据。寄存器的使用顺序是有规范的。

参数大小 参数顺序
64 %rdi %rsi %rdx %rcx %r8 %r9
32 %edi %esi %edx %ecx %r8d %r9d
16 %di %si %dx %cx %r8w %r9w
8 %dil %sli %dl %cl %r8b %r9b

如果要传递的参数超过6个,那就需要通过栈来传递了,通过栈传递时,所有数据的大小都要向8字节对齐,顺序小的参数越靠近栈顶(第7个参数在栈顶)。

依照惯例,寄存器%rbx%rbp以及%r12~%r15这些寄存器都为被调用者Callee保存的寄存器,被调用这Callee得保证自己返回的时候这些寄存器还得和原来一样。

这里%rbp还比较常见,叫做基址寄存器,通常用来保存一个栈帧的基址,比如一个栈帧是变长的话(函数里面有变长数组,变长参数等),那么要对栈内的局部变量寻址那么还是用%rbp比较方便,只需要在函数开头设置movq %rsp, %rbp即可。

参考资料

  • 深入理解计算机系统第三章
posted @ 2022-09-28 17:15  HachikoT  阅读(719)  评论(0编辑  收藏  举报