程序的机器级表示
编译器
基于编程语言的规则、操作系统的惯例、目标机器的指令集生成机器代码。汇编代码
是机器代码的一种形式,它是机器代码的文本表示。高级代码
可移植性好,而汇编代码与特定机器密切相关。
下文讨论基于x86-64架构
3.2 程序编码
|
|
- -Og: 生成符合原始C代码整体结构的机器代码的优化等级。
- Step1:C预处理器 插入所有用
#include
命令指定的文件,并拓展#define
指定的宏- Step2:编译器产生两个源文件的汇编代码,分别为
p1.s
和p2.s
。- Step3:汇编器将汇编代码转化成二进制目标代码文件
p1.o
和p2.o
,是机器代码的一种形式,它包含二进制形式表示的所有指令,但还没有填入全局值的地址。- Step4:链接器将两个目标代码文件与实现库函数的代码合并,并产生最终的可执行代码文件
p
(-o
后指定的文件名)
3.2.1 机器级代码
两种抽象模型:
- 指令集架构/ISA:定义了处理器状态、指令的格式、指令对状态的影响。
- 虚拟地址:机器代码将内存看成一个按字节寻址的数组。
对机器代码可见的处理器状态:
- 程序计数器/PC:在x86-64中用
%rip
表示,给出将要执行的下一条指令在内存中的地址。 - 整数寄存器文件:16个命名位置,存储64位值,存储地址或整数数据,保存临时数据或重要的程序状态。
- 条件码寄存器:保存最近执行的算术或逻辑指令的状态信息,如
if
和while
语句。 - 向量寄存器:保存一个或多个整数或浮点数值。
C 语言中的数组和结构,在机器代码中用一组连续的字节来表示。 汇编代码不区分有符号数和无符号数,不区分指针的不同类型,不区分指针和整数。
一条机器指令只执行一个非常基本的操作。
- 程序计数器/PC:在x86-64中用
程序内存 包含程序的可执行机器代码,操作系统需要的一些信息,栈和堆(用户分配的内存块)。
使用虚拟地址来寻址,这些地址的高16位必须设0,操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理器内存中的物理地址
3.2.2 代码实例
|
|
使用命令行生成汇编文件mstore.s
|
|
汇编代码包含
|
|
使用命令行编译并汇编该代码产生二进制目标代码文件mstore.o
|
|
可以使用反汇编器查看机器代码的内容,在命令行中
|
|
结果如下:
|
|
机器代码与反汇编表示的特性:
- x86-64 的指令长度范围为1~15 字节。常用指令和操作数少的指令所需字节少。
- 从十六进制字节值到汇编指令,格式为:某个数字唯一地对应某个汇编指令,比如 mov 指令以 48 开头。
- 指令结尾的 ‘q’ 是大小指示符,大多数情况下可以省略。
从源程序转换来的可执行目标文件中,除了程序过程的代码,还包含启动和终止程序的代码,与操作系统交互的代码。
链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。
3.2.3 关于格式的注解
所有以.
开头的行都是指导汇编器和连接器工作的伪指令
3.3 数据格式
- 数据类型
- byte 字节 8位
- word 字 16位数据
- double word 双字 32位数据
- quad word 四字 64位数据
对应的指令后缀:
movb, movw, movl, movq
。
这里说的都是整数,浮点数使用一组完全不同的指令和寄存器。
3.4访问信息
16组64位值的通用目的寄存器,用来存储整数数据和指针。
|
|
低位操作的规则:
- 将寄存器作为目标位置时,生成字节和字的指令会保持剩下的字节不变
- 生成双字的指令会把高位四字节置为 0
3.4.1 操作数指示符
- 操作数类型
- 立即数:表示常数值,书写方式是
$
后跟整数,如$-577, $0x1F
- 寄存器:将16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为操作时,用$r_a$表示任意寄存器
a
,我们将寄存器集合看成一个数组R
,用$R[r_a]$表示寄存器$r_a$的值 - 内存引用:根据有效地址访问某个地址位置。我们将内存看成一个很大的字节数组,用符号$M_b[Addr]$表示对存储在内存中从地址
Addr
开始的b
个字节的引用。
- 立即数:表示常数值,书写方式是
- 寻址类型: $$Imm(r_b, r_i, s)$$ $Imm$-立即数偏移,$r_b$-基址寄存器,$r_i$-变址寄存器,$s$-比例因子,$s$必须是1,2,4,8。基址和变址寄存器必须是64位寄存器。有效地址被计算为$Imm+R[r_b]+R[r_i] \cdot s$。
3.4.2 数据传送指令
MOV类
把数据从原位置复制到目的位置,不做任何变化
|
|
源操作数是一个立即数,存储在寄存器中或内存中;目的操作数要么是一个寄存器要么是一个内存地址。x86-64传输指令的两个操作数不能都指向内存位置,需要将原值加载到寄存器中,再将寄存器写入目的位置。
这些指令的寄存器操作数可以是十六个寄存器有标号部分中的任意一个,寄存器部分大小必须与指令最后一个字符指定的大小匹配。
MOVZ类
movz 系列和 movs 系列可以把较小的源值复制到较大的目的,目的都是寄存器。
movz 将目的寄存器剩余字节做零扩展,movs 做符号扩展
movz类:movzbw, movzbl, movzbq, movzwl, movzwq(movzbw 即从字节复制到字,其他类似)
movs类:movsbw, movsbl, movsbq, movswl, movswq, movslq, cltq
- cltq:没有操作数,将 eax 符号扩展到 rax,等价于 movslq %eax,%rax
3.4.3 数据传送示例
|
|
|
|
这段编汇代码中有两点值得注意:
- “指针"其实就是地址。间接引用指针就是将指针放在一个寄存器中。
- 局部变量通常保存在寄存器中。
- 访问寄存器比访问内存要快得多。
- 强制类型转换是通过 mov 指令实现的。
3.4.4 压入和弹出栈数据🌟
- 栈:“后进先出"的数据结构,通过push操作把数据压入栈中,通过pop操作删除数据
- x86-64中的实现:
- x86-64中,栈是向下增长的,栈底位于较高的地址,栈顶的元素的地址是所有栈中元素地址最低的。
- 栈指针
%rsp
保存着栈顶的地址
指令1 2 3 4 5
pushq S R[%rsp] ← R[%rsp]-8; M[R[%rsp]] ← S popq D D ← M[R[%rsp]]; R[%rsp] ← R[%rsp]+8
pushq %rsp
的行为等价于指令1 2
subq $8,%rsp movq %rbp,(%rsp)
popq %rax
的行为等价于1 2
movq (%rsp),%rax addq $8,%rsp
- 栈的插入和弹出操作是针对指针而言的,在弹出后,值仍然会保存在原本的内存位置中,直到被覆盖。
- 栈和程序代码以及其他形式的程序数据都是放在同一内存中
算术和逻辑操作
- 除leaq外,其他指令都有带不同大小操作数的变种。
- 这些操作被分为四组:
加载有效地址、一元操作、二元操作和位移
。
|
|