汇编

程序编码

假设一个 C 程序,有两个文件 p1.c 和 p2.c。我们用 Unix 命令行来编译这些代码:

gcc -Og -o p p1.c p2.c

命令 gcc 就是 GCC C 编译器。编译选项 -Og 告诉编译器使用会生成符合原始 C 代码整体结构得机器代码优化等级。使用较高级别优化代码会严重变形,以至于产生的机器代码和初始源代码之间的关系非常难以理解。
实际上 gcc 命令调用了一整套的程序,将源代码转化成可执行代码。

  1. 首先,C 预处理器扩展源代码,插入所有用 #include 命令指定的文件,并扩展所有用 #define 声明指定的宏。
  2. 其次,编译器产生两个源文件的汇编代码,名字分别为 p1.s 和 p2.s。
  3. 接下来,汇编器会将汇编代码转化成二级制目标文件 p1.o 和 p2.o。目标文件是机器代码中的一种形式,它包含所有指定的二进制表示,但是还没有填入全局值得地址。
  4. 最后,链接器将两个目标文件与实现库函数的代码合并,并产生最终的可执行代码文件 p。

机器级代码

计算机系统使用提供不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。对于机器级编程来说,其中两种抽象尤为重要。
第一种是由指令体系结构或指令集架构(ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数 ISA ,包括 x86-64,将程序描述成好像每条指令都是按照顺序执行的,一条指令结束后,下一条再开始。处理器的硬件远比描述的精细复杂,它们并发地执行许多指令,但是可以采取措施保证整体行为与 ISA 指定的顺序完全一致。第二种抽象是,机器级程序使用的内存是虚拟地址,提供的内存模型看上去是一个非常大的数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
在整个编译过程中,编译器会完成大部分的工作,将把用 C 语言提供的相对比较抽象的执行模型表示的程序转换成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示。能够理解汇编代码以及它与原始 C 代码的联系,是理解计算机如何执行程序的关键一步。
x86-64 的机器代码和原始的 C 代码差别非常大。一些通过对 C 语言程序员隐藏的处理器状态都是可见的:

  1. 程序计数器(PC)给出将要执行的下一条指令在内存中的地址。
  2. 整数寄存器文件包含 16 个命名的位置,分别存储 64 位的值。这些寄存器可以存储地址或整数数据。有的寄存器可以被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
  3. 条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现 if 或 while 语句。
  4. 一组向量寄存器可以存放一个或多个整数或浮点值。

程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时的栈,以及用户分配的内存块。正如前面所提到的,程序内存用虚拟地址来寻址。在任意给定的时刻,只有有限的一部分虚拟地址被认为是合法的。例如,x86-64 的虚拟地址是由 64 位的字来表示的。较为典型的程序只会访问几兆字节或几千兆字节的数据。操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址。
一条机器指令只能执行一个非常基本的操作,例如,将存放在寄存器中的两个数字相加、在存储器和寄存器之间传送数据,或是条件分支转移到新的指令地址。编译器必须产生这些指令序列,从而实现程序结构。

代码示例

假设我们写一个 C 语言代码文件 mstore.c,包含如下的函数定义:

ong mult2(long,long);

void multstore(long x, long y, long *dest){
    long t = mult2(x,y);
    *dest = t;
}

在命令行上使用 -S 选项,就能看到 C 语言编译器产生的汇编代码:

gcc -Og -S mstore.c

代码中每个缩进去的行都对应于一条机器指令。比如,pushq 指令表示应该将寄存器 %rbx 的内容压入程序栈中。
如果我们使用 -c 选项,GCC 就会编译并汇编该代码:

gcc -Og -c mstore.c

就会产生目标代码文件 mstore.o,它是二进制格式的,所以无法查看。所以我们能够知道,机器执行的程序只是一个字节序列,它对应一系列指令的编码。机器对产生这些指令的源码几乎一无所知。
要查看机器代码文件的内容,有一类称为反汇编器的程序非常有用。这些程序根据机器代码产生一种类似于汇编汇编代码的格式。比如 Linux 中的 objdump

pwnki@LAPTOP-KETPO6R7:~/course$ objdump -d mstore.o

mstore.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <multstore>:
   0:   53                      push   %rbx
   1:   48 89 d3                mov    %rdx,%rbx
   4:   e8 00 00 00 00          callq  9 <multstore+0x9>
   9:   48 89 03                mov    %rax,(%rbx)
   c:   5b                      pop    %rbx
   d:   c3                      retq

在左边,我们看到前面给出的字节顺序排列的 14 个十六进制字节值,它们分成了若干组,每组有 1 ~ 5 个字节。每组都是一条指令,右边是等价的汇编语言。

数据格式

由于是从 16 位体系结构扩展成 32 位的,Intel 用术语“字(word)”表示 16 位数据类型。因此,称 32 位数为 “双字(double words)”,称 64 位数为“四字(quad word)”。
C语言数据类型在 x86-64 中的大小:

C类型声明 Intel 数据类型 汇编代码后缀 大小(字节)
char 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char* 四字 q 4
float 单精度 s 4
double 双精度 1 8

访问信息

一个 x86-64 的中央处理单元(CPU)包含一组 16 个存储 64 位值的通用目的寄存器。这些寄存器用来存储整数数据和指针。

63 31 15 7 功能
%rax %eax %ax %al 返回值
%rbx %ebx %bx %bl 被调用者保存
%rcx %ecx %cx %cl 第四个参数
%rdx %edx %dx %dl 第三个参数
%rsi %esi %si %sil 第二个参数
%rbp %ebp %bp %bpl 被调用者保存
%rsp %esp %sp %spl 栈指针
%r8 %r8d %8w %8b 第五个参数
%r9 %r9d %9w %9b 第六个参数
%r10 %r10d %10w %10b 调用者保存
%r11 %r11d %11w %11b 调用者保存
%r12 %r12d %12w %12b 被调用者保存
%r13 %r13d %13w %13b 被调用者保存
%r14 %r14d %14w %14b 被调用者保存
%r15 %r15d %15w %15b 被调用者保存

操作数指示符

大多数指令有一个或多个操作数。指示出一个操作要使用的源数据值,以及放置结果的目的位置。x86-64 支持多种操作数格式。

类型 格式 操作数值 名称
立即数 $Imm Imm 立即数寻址
寄存器 ra R[ra] 寄存器寻址
存储器 Imm M[Imm] 绝对寻址
存储器 (ra) M[R[ra]] 间接寻址
存储器 Imm(rb) M[Imm+R[rb]] (基址+偏移量)寻址
存储器 (rb,ri) M[R[rb]+R[ri]] 变址寻址
存储器 Imm(rb,ri) M[Imm+R[rb]+R[rt]] 变址寻址
存储器 (,ri,s) M[Imm+R[ri]*s] 比例变址寻址
存储器 Imm(,ri,s) M[Imm+R[ri]*s] 比例变址寻址
存储器 (rb,ri,s) M[R[rb]+R[ri]*s] 比例变址寻址
存储器 Imm(rb,ri,s) M[Imm+R[rb]+r[ri]*s] 比例变址寻址

源数据的值可以以常数的形式给出,或是从寄存器或内存中读出。结果可以存放在寄存器或内存中。因此,各种不同的操作数可以被被分为三种类型:

  1. 立即数(immediate),用来表示常数值。在 ATT 格式的汇编代码中,立即数的书写方式是‘$’后面跟一个用标准 C 表示法表示的整数,比如,$0x1F。不同的指令允许的立即数值范围不同,汇编器会自动选择最紧凑的方式进行数值编码。
  2. 寄存器(register),它表示某个寄存器的内容,16 个寄存器的第位 1 字节、2 字节、4 字节或 8 字节中的一个作为操作数,这些字节数分别对应于 8 位、16 位、32 位或 64 位。我们用符号 ra 来表示任意寄存器 a,用引用 R[ra] 来表示它的值,这是将寄存器集合看成一个数组 R,用寄存器标识符作为索引。
  3. 第三类操作数是内存引用,它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。因为将内存看成一个很大的字节数组,我们用符号 Mb[addr] 表示对存储在内存中从地址 Addr 开始的 b 个字节值的引用。为了简便,我们通常省去下表 b。

如上表所示,有多种不同的寻址模式,允许不同形式的内存引用。表中底部用语法 Imm(rb,ri,s) 表示的是最常用的形式。这样的引用分为四个组成部分:一个立即数偏移 Imm,一个基址寄存器 rb,一个变址寄存器 ri 和一个比例因子 s,这里 s 必须是 1、2、4 或者 8。基址和变址寄存器都必须是 64 位寄存器。有效地址被计算为 Imm + R[rb] + R[ri] * s。引用数组元素时,会用到这种通用的形式。其他形式都是这种通用形式的特殊情况,只是省略了某些部分。

数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令。

MOV

MOV 指令是嘴贱大的数据攒传送指令。源操作数指定的值是一个立即数,存储在寄存器或内存中。目的操作数指定一个位置,要么是一个寄存器或者,要么是一个内存地址。
MOV 指令源和目的类型的五种可能的组合:

1      movl $0x4050,%eax      立即数---寄存器
2      movw %bp,%sp           寄存器---寄存器 
3      movb (%rdi,%rcx),%al   内存---寄存器
4      movb $-17,(%rsp)       立即数---内存
5      movq %rax,-12(%rbp)    寄存器---内存

代码示例

long exchange(long *xp,long y)
{
    long x = *xp;
    *xp = y;
    return x;
}

压入和弹出栈数据

push 跟 pop 指令可以将数据压入程序栈中,以及从程序栈中弹出数据。

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

算术和逻辑操作

指令 效果 描述
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
SUB S,D D <- D-S
IMULS,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 S
AND S,D D <- D>>Ak 算数右移
SHR k,D D <- D>>Lk 逻辑右移

它们可以分为四类:

  1. 加载有效地址
  2. 一元操作
  3. 二元操作
  4. 移位

加载有效地地址

加载有效地址指令 leaq 实际上是 movq 指令的变形。它的指令形式是从内存读取数据到寄存器,但实际上它根本没有引用内存。它的第一个操作数看上去是内存引用,但该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。
代码示例:

long scale(long x,long y,long z){
    long t = x + 4 * y + 12 * z;
    return t;
}

一元操作和二元操作

在以上表格中:
一元操作只有一个操作数,即使源又是目的。这个操作数可以是一个寄存器,也可以是一个内存位置。
二元操作中,第二个操作数即是源又是目的。

移位操作

最后一组操作是移位操作,先给出移位量,,然后第二项给出的是要移位的数。算数移位是填符号位,逻辑移位是填上 0 。

控制

到目前为止,哦我们值考虑了直线代码的行为,也就是指令一条接着一条顺序地执行。C 语言里的某些结构,比如条件语句、循环语句和分支语句,要求有条件的执行,根据数据测试的结果来决定操作执行的顺序。机器代码提供了两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试的结果来改变控制流或数据流。

条件码

除了整数寄存器,CPU 还维护者一组单个位的条件码寄存器,它们描述了最近的算术或者逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:

  1. CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
  2. ZF:零标志。最近的操作得出的结果为 0。
  3. SF:符号标志。最近的操作得到的结果为负数。
  4. OF:溢出标志。最近的操作导致一个补码溢出————正溢出或负溢出。

leaq 指令改变任何条件码,因为它是用来进行地址计算的。除此之外,之前表中列出的所有指令都会设置条件码。
除了之前说到的指令,还有一些指令它们只设置条件码而不改变任何其他寄存器。

指令 基于 描述
CMP S1,S2 S2-S1 比较
TEST S1&S2 测试

访问条件码

条件码通常不会直接读取,常用的使用方法有三种:

  1. 可以根据条件码的某种组合,将一个字节设置为 0 或者 1。
  2. 可以条件跳转到程序的某个其他的部分。
  3. 可以有条件地传送数据。

第一种情况:

指令 同义名 效果 设置条件
sete D setz D <- ZF 相等/零
setne D setnz D <- ~ZF 不等/非零
setg D setnle D <- (SF^OF)&ZF 大于(有符号>)
setge D setnl D <- ~(SF^OF) 大于等于(有符号>=)

跳转指令

正常执行的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置。在汇编代码中,这些跳转的目的地通常用于标号(label)指明。

movq      $0,%rax
jmp       .L1
movq      (%rax),%rdx
.L1:
popq      %rdx 

指令 jmp .L1 会导致程序跳过 movq 指令,而从 popq 指令开始继续执行。

指令 同义名 跳转条件 描述
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 jbae CF 低于(无符号<)
jbe Label jna CF|ZF 低于或相等(无符号<=)

除了 jmp,其他跳转指令都是有条件的————它们根据条件码的某种组合,或者跳转,或继续执行代码序列中下一条指令。

用条件控制来事项条件分支

将条件表达式和语句从 C 语言翻译成机器代码,最常用的方式是结合有条件和无条件跳转。
代码示例:

long lt_cnt = 0;
long ge_cnt = 0;

long absdiff_se(long x,long y)
{
    long result;
    if(x < y){
        lt_cnt++;
        result = y - x;
    }
    else{
        ge_cnt++;
        result = x- y;
    }
    return result;
}

用条件传送来实现条件分支

实现条件操作的传统方法时通过使用控制的条件转移。当条件满足时,程序沿着一条执行路径执行,而当条件不满足时,就走另一条路径。这种机制简单而通用,但是在现代处理器上,它可能会非常低效。
一种代替的策略就是是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后再根据条件是否满足从中选取一个。只有在一些受限制的情况中,这种策略才可行,但是如果可行,可以用一条简单的条件传送指令来实现它,条件传送指令更符合现代处理器的性能特性。
代码示例:

long absdiff(long x, long y)
{
      long result;
      if (x < y)
            result = y - x;
      else
            result = x - y;
      return result;
}

产生的汇编代码:

movq      %rsi,%rax
subq      %rdi,%rax
movq      %rdi,%rdx
subq      %rsi,%rdx
cmpq      %rsi,%rdi      Compare x:y
cmovge    %rdx,%rax      IF >=, rval = eval
ret

对应的 C 代码:

long cmovdiff(long x, long y)
{
      long rval = y - x;
      long eval = x - y;
      long ntest = x >= y;
      if(ntest) rval = eval;
      return rval;
}

循环

C 语言提供了多种循环结构,即 do-while、while 和 for。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。GCC 和其他汇编器产生的循环代码主要基于两种基本的循环模式。

do while

代码示例:

long fact_do(long n)
{
    long result = 1;
    do{
        result *= n;
        n = n - 1;
    }while(n > 1)
    return result;
}   

等效 C 代码:

long fact_do_goto(long n)
{
      long result = 1;
      n = n - 1;
      if(n > 1)
            goto loop;
      return result;
}

switch 语句

代码示例:

void switch_eg(long x, long n, long *dest)
{
    long val = x;
    switch(n){

        case 100:
            val *= 13;
            break;
        
        case 102:
            val += 10;
        
        case 103:
            val += 11;
        
        case 104:
        case 106:
            val *= val;
            break;
        
        default:
            val = 0;
    }
    *dest = val;
}

内容来源

《深入理解计算机系统》

posted @ 2021-01-30 20:12  PwnKi  阅读(390)  评论(0编辑  收藏  举报