深入理解计算机系统--机器的程序基表示


程序编码

0. 一些cmd命令

GCC编译的命令

  • 预处理: g++ -E example.cpp -o example.i
  • 编译: g++ -S example.i -o example.s
  • 汇编: g++ -c example.s -o example.o
  • 链接: g++ example.o -o example

反汇编

  • objdump -d example.o 会直接在命令行中输出

1. 机器级代码

两个重要的抽象

  1. 指令集架构(ISA)来定义机器级程序的格式和行为,定义了处理器状态、指令的格式,以及每条指令对状态的影响。大多数ISA将程序的行为描述成按顺序逐条执行。
  2. 机器级程序使用的内存地址是虚拟地址,提供的内存模型看起来是一个非常大的字节数组。

x86-64机器代码中对C程序员隐藏的处理器状态

  • 程序计数器 x86-64中%rip表示
  • 整数寄存器 16个命名位置,分别存储64位值
  • 条件码寄存器 保存最近执行的算术或逻辑指令的状态信息
  • 向量寄存器 存放一个或多个整数或浮点数值

程序内存包括:

  1. 程序的可执行机器代码
  2. 操作系统需要的一些信息
  3. 用来管理过程调用和返回的运行时栈
  4. 用户分配的内存块

2. 数据格式

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

3. 访问信息

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

63~0 31~0 15~0 7~0
返回值 %rax %eax %ax %al
被调用者保存 %rbx %ebx %bx %bl
第4个参数 %rcx %ecx %cx %cl
第3个参数 %rdx %edx %dx %dl
第2个参数 %rsi %esi %si %sil
第1个参数 %rdi %edi %di %dil
被调用者保存 %rbp %ebp %bp %bpl
栈指针 %rsp %esp %sp %spl
第5个参数 %r8 %r8d %r8w %r8b
第6个参数 %r9 %r9d %r9w %r9b
调用者保存 %r10 %r10d %r10w %r10b
调用者保存 %r11 %r11d %r11w %r11b
被调用者保存 %r12 %r12d %r12w %r12b
被调用者保存 %r13 %r13d %r13w %r13b
被调用者保存 %r14 %r14d %r14w %r14b
被调用者保存 %r15 %r15d %r15w %r15b

两条规则:

  1. 生成小于8字节结果的命令会保持剩余字节不变
  2. 生成4字节数字的指令会把高位4个字节置为0

操作数指示符

大多数指令有一个或多个操作数(operand),指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。各种不同的操作数的可能性被分为三类:

  1. 立即值(immediate) ATT格式中是 & 后跟上一个标准C表示法表示的整数
  2. 寄存器 表示某个寄存器的内容
  3. 内存引用 根据计算出来的地址访问某个内存位置

数据传送指令 mov

MOV类的指令将数据从源位置复制到目的位置,不作任何变化。

  • 特殊:movabsq R <- S 传送绝对的四字
    能够将任意64位立即数作为源操作数,并且只能以寄存器作为目的
  • 内存引用的寄存器必须为四字,如:
    • movb $0xF (%ebx)
      false
    • movb $0xF (%rbx)
      true

源操作数指定值是一个立即数,存储在寄存器或内存中,目的操作数指定一个位置,是一个寄存器或内存地址。x86-64做了一条限制:
传送指令(mov)的两个操作数不能都指向内存地址。

指令操作的寄存器的大小必须与指令最后一个字符(b,w,l,q)指定的大小匹配

MOVZ类中 把剩余字节填充为0
MOVS类中 通过符号扩展填充

  • cltp: 把 %eax 符号扩展到 %rax

压入(push)和弹出(pop)栈数据

栈指针%esp保存着栈顶元素的地址

  • pushq指令: 把数据压入到栈上
    1. R[%rsp] <-- R[%rsp] - 8 栈指针减八
    2. M[R[%rsp]] <--将值写入新的栈顶地址
  • popq指令: 弹出数据
    1. D <-- M[R[%rsp]]
    2. R[%rsp] <-- R[%rsp] + 8

4. 算术与逻辑操作

指令 效果 描述
leaq S, D D <- &S 加载有效地址
INC D
DEC D
NEG D
NOT D
D <- D + 1
D <- D - 1
D <- -D
D <- ~D
加1
减1
取负
取补
ADD S, D D <- D + S
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
SAL k, D D <- D << k 左移
SHL k, D D <- D << k 左移(等同于SAL)
SAL k, D D <- D >>A k 算术右移
SHR k, D D <- D >>L k 逻辑右移

给出的每个指令类都有针对四种不同大小数据的指令


加载有效地址

leaq指令实际上是movq指令的变形,其指令形式是从内存读数据到寄存器,但实际上根本没有引用内存,该指令并不是从指定的位置读入数据,而是将有效地址写入到目的操作数。

leaq指令会有一些灵活的用法:

%rdx的值为x,%rcx的值为y,
那么指令leaq 7( %rdx, %rcx, 4),
%rax即为设置寄存器%rax的值为x+4y+7


一元和二元操作

  • 一元操作:
    只有一个操作数,既是源又是目的,该操作数可以使一个寄存器,也可以是一个内存位置
  • 二元操作:
    • 第一个操作数是源,第二个操作数是目的
    • 第一个操作数可以是立即数、寄存器或内存位置
    • 第二个操作数寄存器或内存位置
    • 第二个操作数为内存位置时,处理器必须从内存读值,执行操作,再把结果写回内存

移位操作

  • 第一项为移位量,第二位为要移位的数
  • 移位量可以是一个立即数,或者存放在寄存器%cl中(只允许该寄存器为操作数)

特殊算术操作

两个64位有符号或无符号整数相乘得到的乘积需要128位表示,下列特殊操作提供了有符号和无符号数的全128位乘法和除法。一对寄存器%rdx(高64位)和%rax(低64位)组成一个128位的八字

指令 效果 描述
imulq S R[%rdx]:R[%rax] <- S * R[%rax] 有符号全乘法
mulq S R[%rdx]:R[%rax] <- S * R[%rax] 无符号全乘法
cqto R[%rdx]:R[%rax] <- 符号扩展R[%rax] 转换为八字
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
无符号除法

5. 控制

机器代码提供了两种基本的低级机制来实现有条件的行为:测试数据值,然后根据测试结果来改变控制流数据流。与数据相关的控制流是实现条件行为的更常见的方法。
jump指令可以改变一组机器代码的执行顺序,jump指令指定控制应该被传递到程序的某个其他部分。


条件码

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

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

在上节表中的指令除leaq(因为该指令用于进行地址运算)外都会设置条件码。
此外两类指令只设置条件码而不改变其他任何寄存器:

指令 基于 描述
CMP S1, S2 S2 - S1 比较
TEST S1, S2 S1 & S2 测试
  • CMP指令根据两个操作数之差设置条件码。
  • TEST指令ADD相似,不过不过只改变条件寄存器

访问条件码

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

  1. 根据条件码的某种组合,将一个字节设置为0或1
    SET指令类
  2. 条件跳转到程序的某个其他地方
  3. 有条件地传送数据

SET指令

跳转指令

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

...
  jmp .L1
...
.L1:
  popq %rdx

上述代码片段中jum .L1会导致程序跳过其与popq %rdx之间的程序,直接从popq %rdx执行。

jmp指令是无条件跳转,它可以是

  • 直接跳转,即跳转目标是作为指令的一部分编码jmp .L1
  • 间接跳转,跳转目标从寄存器jmp *%rax或内存位置jmp *(%rax)中读出

跳转指令的编码

在汇编代码中,跳转目标用符号标号书写。汇编器以及后来的链接器会产生跳转目标的适当编码。

  • 跳转指令最常用的编码是PC相对的(PC-relative)。即他们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码

    /*汇编代码*/
     1:       movq    %rdi, %rax
     2:       jmp     .L2
     3:   .L3:
     4:       sarq    %rax
     5:   .L2:
     6:       testq   %rax, %rax
     7:      jg  .L3
     8:       rep; ret
    /*汇编后反汇编*/
     0:   48 89 f8                mov    %rdi,%rax
     3:   eb 03                   jmp    8 <.text+0x8>
     5:   48 d1 f8                sar    %rax
     8:   48 85 c0                test   %rax,%rax
     b:   7f f8                   jg     5 <.text+0x5>
     d:   f3 c3                   repz retq
     f:   90                      nop
    

    第一条指令的目标编码为0x03,把它加上0x05,也就是紧跟着下一条指令的地址,就得到跳转目标地址0x8。对第二条跳转指令同理。

    /*链接后的程序反汇编版本*/  
    4004d0:   48 89 f8                mov    %rdi,%rax
    4004d3:   eb 03                   jmp    8 <.text+0x8>
    4004d5:   48 d1 f8                sar    %rax
    4004d8:   48 85 c0                test   %rax,%rax
    4004db:   7f f8                   jg     5 <.text+0x5>
    4004dd:   f3 c3                   repz retq
    4004df:   90                      nop
    
  • 第二种编码方式是给出绝对地址,用4个字节直接指定目标

用条件控制实现条件分支

实现条件操作的传统方法是通过使用控制的条件转移,当条件满足时程序沿着一条执行路径执行,不满足时就沿着另一条路径。
例子见 Demos/ConditionControl
这种机制简单通用,但在现代处理器上可能会非常低效。

用条件传送实现条件分支

这种方法计算了一个条件操作的的两种结果,然后再根据条件是否满足从中选取一个。
只有在一些受限的条件中这种策略才可行,但如果可行就可以用一条简单的条件传送指令来实现。这种方式更符合现代处理器的性能。