程序的机器级表示

前提(汇编初识)

  1. 程序计数器(通常称为”PC”, 在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址.
  2. 整数寄存器文件包含16个命名的位置, 分别存储64位的值. 这些寄存器可以存储地址(对应于C语言的指针)或整数数据. 有的寄存器被用来记录某些重要的程序状态, 而其他的寄存器用来保持临时数据, 例如过程的参数核局部变量, 以及函数的返回值,
  3. 条件码寄存器保持着最近执行的算术或逻辑指令的状态消息. 它们用来实现控制或数据流中的条件变化, 比如用来实现if核while语句.

C语言提供了一种模型, 可以在内存中声明核分配各种数据类型的对象, 但是机器代码只是简单地将内存看成一个很大的, 按字节寻址的数组. C语言中的聚合数据类型, 例如数组和结构, 在机器代码中用一组连续的字节来表示. 即使是对标量数据类型, 汇编代码也不区分有符号或无符号整数, 不区分各种类型的指针, 甚至于不区分指针和整数

程序内存包含: 程序的可执行机器代码, 操作系统需要的一些信息, 用来管理过程调用和返回的运行时栈, 以及用户分配的内存块(比如说用malloc库函数分配的). 程序内存用虚拟内存来寻址. 在任意给定的时刻, 只有有限的一部分虚拟地址被认为是合法的.

一条机器指令执行一个非常基本的操作. 例如, 将存放在寄存器中的两个数字相加, 在存储器和寄存器之间传送数据, 或是条件分支转移到新的指令地址. 编译器必须产生这些指令的序列, 从而实现(像算术表达式求值, 循环或过程条用和返回这样的程序结构)

数据格式

根据Intel给定的定义:

  1. 8位数据类型称为字节(byte)
  2. 16位数据类型称为字(word)
  3. 32位数据类型称为双字(double words)
  4. 64位数据类型称为四字(quad words)

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

大多数GCC生成的汇编代码指令都有一个字符的后缀, 表明操作数的大小.

例如, 数据传送指令有四个变种: movb(传送字节), movw(传送字), movl(传送双字), movq(传送四字).

后缀’l’用来表示双字, 因为32位数被看成是”长字(long word)”.

汇编代码也使用后缀”l”来表示4字节整数和8字节双精度浮点数. 这并不会产生歧义, 因为浮点数使用的是一组完全不同的指令和寄存器.

访问信息

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

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

三个较为特殊的寄存器

操作数指示符

大多数指令有一个或多个操作数(operand), 指示出执行一个操作中要使用的源数据值, 以及放置结果的目的位置.

x86-64支持多种操作数格式

源数据值可以以常熟形式给出, 或是从寄存器或内存中读出. 结果可以存放在寄存器或内存中.

各种不同的操作数的可能性被分为三种类型:

  1. 立即数(immediate), 用来表示常数值. 在ATT格式的汇编代码中, 立即数的书写方式是’$’后面跟一个用标准C表示法表示的整数.比如$-577或$0x1F. 不同的指令允许的立即数值范围不同, 汇编器会自动选择最紧凑的方式进行数值编码.
  2. 寄存器(register), 它表示某个寄存器的内容, 16ge寄存器的低位1字节, 2字节, 4字节或8字节中的一个作为操作数, 这些字节数分别对应于8位, 16位, 32位或64位.
  3. 内存引用, 它会根据计算出来的地址(通常称为有效地址)访问某个内存位置.

寻址模式

咕咕咕

数据传送指令

最频繁使用的指令是将数据从一个位置复制到另一个位置的指令. 操作数表示的通用性使得一条简单的数据传送指令能够完成在许多机器要好几条不同指令才能完成的功能.

MOV类指令, 把数据复制到目的位置, 不做任何变化. 有四条指令形成movb, movw, movl, movq. 这些指令都执行同样的操作; 区别在于它们操作的数据大小不同: 分别是1字节, 2字节, 4字节和8字节.

源操作数指定的值是一个立即数, 存储在寄存器中或者内存中. 目的操作数指定一个位置, 要么是一个寄存器, 要么是一个内存地址.

X86-64加了一条限制, 传送指令的两个操作数不能都指向内存位置., 即一个值从一个内存位置复制到另一个内存位置需要两条指令—第一条指令将源值加载到寄存器中, 第二条从一个内存位置复制到另一个内存位置需要两条.

这些指令的寄存器操作数可以是16个寄存器有标号部分中的任意一个, 寄存器部分的大小必须与指令最后一个字符(‘b’, ‘w’, ‘l’或’q’)指定的大小匹配.

大多情况中, MOV指令只会更新目的操作数指定的那些寄存器字节或内存位置

唯一的例外是movl指令以寄存器作为目的时, 它会把该寄存器的高位4字节设置为0.

造成这个例外的原因是x86-64采用的惯例, 即任何为寄存器生成32位值的指令都会把该寄存器的高位部分置成0

扩展数据传送指令

用在较小的源值复制到较大的目的时使用. 所有这些指令都把数据从源(寄存器或内存中)复制到目的寄存器.

  1. MOVZ类中指令把目的中剩余的字节填充为0: movzbw, movzbl, movzwl, movzbq, movzwq.

    比如有符号类型变成无符号类型

  2. MOVS类中的指令通过符号扩展来填充, 把源操作的最高位进行复制(即用最高位的符号位填充): movsbw, movsbl, movswl, movsbq, movswq, movslq.

    例如无符号类型变成有符号类型
    每条指令名字的最后两个字符都是大小指示符: 第一个字符指定源的大小, 而第二个指明目的的大小.

控制流(Control Flow)

默认情况下, 所有代码都从上到下运行, 程序流的方向称为控制流(Contral-Flow). rip指针可以说是程序执行的跟踪器, 每条指令, 其增量1, 从而使控制流从上到下自然地流动.

条件码

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

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

  1. CMP指令根据两个操作数之差来设置条件码
  2. TEST指令的行为与AND(逻辑与)指令一样, 除了它们只设置条件码而不改变目的寄存器的值.

条件码的访问

条件码通常不会之间读取, 最常用的使用方法有三种:

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

    所有算术和逻辑操作都会设置条件码: 执行比较指令, 根据计算t=a-b设置条件码.

SET指令会根据条件码的某种组合, 将一个字节设置为0或1; 它们之间的区别就在于它们考虑的条件码的组合是什么, 这些指令名字的不同后缀指明了它们所考虑的条件码的组合

一条SET指令的目的操作的低位单字节寄存器元素之一,或是一个字节内存位置

跳转指令

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

指令 同义名 跳转条件 描述
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 Lable jnl ~(SF^OF) ZF
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 低于或相等(无符号<=)

表中所示的其他跳转指令都是有条件的—-它们根据条件码的某种组合, 或者跳转, 或者继续执行代码序列中下一条指令.

跳转指令的编码

跳转指令有好几种不同的编码, 但最常用都是PC相对的(PC-relative). 这些地址偏移量可以将目标指令的地址于紧跟在跳转指令后面那条指令的地址之间的差作为编码, 地址的偏移量可以为1, 2或4个字节. 第二种编码方法是给出”绝对”地址, 用4个字节直接指定目标汇编器何链接器会选择适当的跳转目标的编码.

过程

假设过程P调用过程Q, Q执行后返回到P. 这些动作包括下面一个或多个机制:

  • 传递控制. 在进入过程Q的时候, 程序计数器必须呗设置为Q的代码的起始地址, 然后在返回时, 要把程序计数器设置为P中调用Q后面那条指令的地址.
  • 传递数据. P必须能够向Q提供一个或多个参数, Q必须能够向P返回一个值.
  • 分配何释放内存. 在开始时, Q可能需要为局部变量分配空间, 而在返回前, 又必须释放这些存储空间.

栈帧

程序用栈管理它的过程所需要的存储空间, 栈何程序寄存器放着传递控制和数据, 分配内存所需要的消息. 当p调用Q时, 控制和数据消息添加到栈尾. 当P返回时, 这些消息会被释放掉.

当X86-64过程需要的存储空间超出寄存器能够存放的大小时, 就会在栈上分配空间. 这个部分称为过程的栈帧(stack frame).

大多数过程的栈帧都是定长的, 在过程的开始就分配好了. 但是有些过程需要变长的帧.

通过寄存器, 过程P可以传递最多6个整数值(也就是指针和整数), 但是如果Q需要更多的参数, P可以在调用Q之前在自己的栈帧里存储好这些参数.

为提高空间和时间效率, X86-64只分配自己所需要的栈帧部分.

转移控制

将控制从函数P转移到函数Q只需要简单地把程序计数器(PC)设置为Q的代码的初始位置. 不过, 当稍后从Q返回的时候, 处理器必须记录好它需要继续P的执行的代码位置.

在x86-64机器中, 这个信息是用指令call Q调用过程Q来记录的. 该指令会把地址A压入栈中, 并将PC设置为Q的起始地址. 压入的地址A被称为返回地址, 是紧跟在call指令后面的那条指令的地址. 对应的指令ret会从栈中弹出地址A, 并把PC设置为A.

数据传送

x86-64中, 大部分过程间的数据传送是通过寄存器实现的.

x86-64中. 可以通过寄存器最多传递6个整形(即整数和指针)参数. 寄存器的使用是有特殊顺序的, 寄存器使用的名字取决于要传递的数据类型的大小.

如果一个函数有大于6个整形参数, 超出6个的部分就要通过栈来传递.

栈上的局部存储

局部数据必须存放在内存的常见情况如下:

  • 寄存器不足够存放所有的本地数据
  • 对一个局部变量使用地址运算符’&’, 因此必须能够给为它产生一个地址.
  • 某些局部变量是数值或结构, 因此能够通过数组或结构引用被访问到.

一般来说, 过程通过减小栈指针在栈上分配空间. 分配的结果作为栈帧的一部分, 标号为”局部变量”

寄存器中的局部存储空间

寄存器组是唯一被所有过程共享的资源. 虽然在给定时刻只有一个是活动的, 我们依然确保当一个过程(调用者)调用另一个过程(被调用者)时, 被调用者不会覆盖调用者稍后会使用的寄存器值. 为此, x86-64采用了一组统一的寄存器使用惯例, 所有的过程(包括程序库)都必须遵循.

根据惯例, 寄存器%rbx, %rbp和%r12~r15被划分为被调用者保存寄存器. 当过程P调用过程Q时, Q必须保持这些寄存器的值, 要么就是根本不去改变它, 要么就是把原始值压入栈中, 改变寄存器的值, 然后在返回前从栈中弹出旧值. 压入寄存器的值会在栈帧中创建标号为”被保持的寄存器”的一部分.

所有其他的寄存器, 除了栈指针%rsp, 都分类为调用者保存寄存器

数组分配和访问.

对于指针, 其大小都是占用8个字节, 不管这个指针的类型是什么.

结构

类似于数组的的实现, 结构的所有组成部分都存放在内存中一段连续的区域内, 而指向结构的指针就是结构第一个字节的地址.

编译器维护关于每个结构类型的信息, 指示每个字段的字节偏移. 它以这些偏移作为内存引用指令中的位移, 从而产生对结构元素的引用.

数据对齐

  1. 对齐原则是任何K字节的基本对象的地址必须是K的倍数。
  2. 对于包含结构的代码, 编译器可能需要在字段的分配中插入间隙, 以保证每个结构元素都满足它的对齐要求. 而结构本身对它的起始地址也有一些对齐要求.

理解指针

  1. 每个指针都都对应一个类型. 这个类型表明该指针指向的是哪一类对象.

    指针类型不是机器代码中一部分, 它们是C语言提供的一种抽象

  2. 每个指针都有一个值. 这个值是某个指定类型的对象的地址. 特殊的NULL(0)值表示该指针没有指向任何地方.
  3. 指针用’&’运算符创建.
  4. *操作符用于间接引用指针, 其结果是一个值, 它的类型与该指针的类型一致,间接引用是用内存引用来实现的, 要么是存储到一个指定的地址, 要么是从指定的地址读取.
  5. 将指针从一种类型强制转换成另一种类型, 只改变它的类型, 而不改变它的值, 强制类型转换的一个效果是改变指针运算的伸缩.
  6. 指针也可以指向函数.

    函数指针的值是该函数机器代码表示中第一条指令的地址.

任何类型的函数指针其大小都是占用8个字节