《CSAPP》程序的机器级表示(第三章)

程序的机器级表示

GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。
为什么要学习机器代码呢?对于程序员来说,能够阅读和理解汇编代码仍是一项很重要的技能。
逆向工程:通过研究系统和逆向工作,来试图了解系统的创建过程。

机器级代码

对于机器级编程来说,其中两种抽象尤为重要。

  • 第一种是由指令集体系结构或指令集架构(ISA)来定义机器级程序的格式和行为,它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
  • 第二种是机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。

一些通常对C语言程序员隐藏的处理器状态都是可见的:

  • 程序计数器(PC)给出将要执行的下一条指令在内存中的地址。
  • 整数寄存器,有16个,分别存储64位的值。这些寄存器可以存储地址或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据。
  • 条件码寄存器,保存着最近执行的算术或逻辑指令的状态信息。他们用来实现控制或数据流中的条件变化。
  • 一组向量寄存器可以存放一个或多个整数或浮点数值。

虽然C语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是简单的将内存看成一个很大的、按字节寻址的数组。

程序内存包含:程序的可执行机器代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的内存块。

代码示例

  1. 由源程序查看汇编代码,使用如下命令:
    Linux> gcc -0g -S 源程序名.c
    这会使GCC运行编译器,产生一个汇编文件 源程序名.s,但是不做其他进一步的工作。

  2. 使用-c命令行选项,GCC会编译并汇编该代码:
    Linux> gcc -0g -c 源程序名.c
    这就会产生目标代码文件 源程序名.o,他是二进制格式的,无法直接查看。
    例如:一段字节序列的十六进制表示:53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3。
    机器执行的程序只是一个字节序列,它是对一系列指令的编码,机器对产生这些指令的源代码几乎一无所知。

  3. 查看机器代码文件的内容,用反汇编器,根据机器代码产生一种类似于汇编代码的格式。
    使用命令:-d
    Linux> objdump -d 源程序名.o

关于格式的注解

用-S命令生成的汇编文件,有以.开头的行,所有以.开头的行都是指导汇编器和链接器工作的伪指令,我们通常可以忽略这些行。

数据格式

C语言数据类型在x86-64中的大小。在64位机器中,指针长8字节。

浮点数主要有两种形式:单精度值(4字节),对应于C语言数据类型float,双精度值(8字节),对应于C语言数据类型double。

访问信息

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

当这些指令以寄存器作为目标时,对于生成小于8字节结果的指令,寄存器中剩下的字节会怎么样,对此有两条规则:生成1字节和2字节数字的指令会保持剩下的字节不变,生成4字节数字的指令会把高位4个字节置0。

  1. 操作数指示符
    大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。
    各种不同的操作数的可能性被分为三种类型。
  • 立即数:用来表示常数值。在ATT格式的汇编代码中,立即数的书写方式是‘$’后面跟一个用标准C表示法表示的整数,比如,$-577或$0x1F。
  • 寄存器:它表示某个寄存器中的内容。16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作数,这些字节数分别对应于8位、16位、32位或64位。用符号ra来表示任意寄存器a,用引用R[ra]来表示它的值。
  • 内存引用:它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。用符号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。

  1. 数据传送指令
    最简单形式的数据传送指令——mov类。这些指令把数据从源位置复制到目的位置,不做任何变化。
    源操作数指的值是一个立即数,存储在寄存器中或者内存中,目的操作数指定一个位置,要么是一个寄存器或者是一个内存地址。
    x86-64加了一条限制,传送指令的两个操作数不能都指向内存位置。将一个值从内存位置复制到另一个内存位置需要两条指令:第一条指令将源值加载到寄存器中,第二条将该寄存器值写入目的位置。

  2. 压入和弹出栈数据
    栈在处理过程调用中起到至关重要的作用。
    栈可以实现为一个数组,总是从数组的一端插入和删除元素,这一端被称为栈顶。在x86-64中,程序栈存放在内存中某个区域,栈向下增长,栈顶元素的地址是所有栈中元素地址中最低的。栈底是高地址。栈指针%rsp保存着栈顶元素的地址。

pushq指令是把数据压入栈,popq指令是弹出数据,这些指令只有一个操作数——压入的数据源和弹出的数据目的。
将一个四字值压入栈,首先要将栈指针减8,然后将值写到新的栈顶地址。因此,指令pushq %rbp的行为等价于下面两条指令:
subq $8,%rsp
movq %rbp,(%rsp)
弹出一个四字的操作包括从栈顶位置读出数据,然后栈指针加8,因此,指令popq %rax等价于下面两条指令:
movq (%rsp),%rax
addq $8,%rsp

文章目录
  1. 1. 程序的机器级表示
    1. 1.1. 机器级代码
    2. 1.2. 代码示例
    3. 1.3. 关于格式的注解
    4. 1.4. 数据格式
    5. 1.5. 访问信息