程序的机器级表示
本章学习内容:汇编代码
高级语言屏蔽了程序的机器级实现。
用高级语言编写的程序可以在不同的机器上运行,汇编代码则于特定机器密切相关
学习汇编代码能理解编译器优化能力,并分析代码中隐含的低效率
此外,高级语言提供的抽象层会隐藏我们想要了解的程序的运行时行为
此外,很多攻击都涉及到程序存储运行时控制信息的方式的细节
逆向工程:通过研究系统和逆向工作,试图了解系统的创建过程
IA32:x86-64的32位前身(机器可以向后兼容IA32程序)
- C语言、汇编代码以及机器代码之间的关系
- x86-64的细节
- C语言的控制结构的实现
- 过程的实现
- 数组、结构、联合等数据结构的实现
- 内存越界问题
- 系统易遭受缓冲区溢出攻击的问题
- GDB调试器检查机器级程序运行时的行为技巧
历史观点
Intel处理器俗称x86,最开始的时候是单芯片
每个后继处理器都是向后兼容的:较早版本的代码可以在较新的机器上运行
-
摩尔定律
晶体管数量以每年大约37%的速率增加=>每26个月翻倍
摩尔定律:1965年,摩尔预测在未来10年,芯片上的晶体管数量每年都会翻倍。在超过50年的事件里,半导体工业能使得晶体管数目每18个月翻倍
这些年出现了很多与Intel处理器兼容的处理器,例如AMD
经过了数十年的发展,曾经的晦涩难懂的特性已经不再会出现了
数据格式
byte:8位
word:16位
double word:32位
quad word:64位
标准int为双字,64位的指针为四字
x86-64指令集包括对完整的针对字节、字、双字的指令
C | Intel数字精度 | 汇编代码后缀 | 大小(字节) |
---|---|---|---|
char | 字节 | b | 1 |
short | 字 | w | 2 |
int | 双字 | l(long word) | 4 |
long | 四字 | q | 8 |
char * | 四字 | q | 8 |
float | 单精度 | s | 4 |
double | 双精度 | l | 8 |
大多数GCC生成的汇编代码指令带有一个字符的后缀,表明操作数的大小
例如:数据传送指令有四种:
- movb
- movw
- movl
- movq
浮点数的双精度也是使用l
代表的,但是并不会引起歧义,因为浮点数使用一组完全不同的指令与寄存器
假设有两个C程序,使用以下命令进行编译:gcc -Og -o p p1.c p2.c
- gcc:GCC编译器
- -Og:生成复合原始C代码整体结构的机器代码的优化等级(使用高等级编译产生的代码会严重变形,以至于产生的机器代码与源代码之间的关系难以理解)
编译流程(见第一章):
.c -> .i -> .s -> .o -> 可执行
.o:目标代码文件,是机器代码的一种形式,包含所有指令的二进制表示,但是未填入全局值的地址
机器级代码
计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节
两种最重要的抽象:
- 指令集体系结构或指令集架构(ISA)定义机器级别程序的格式与行为。它定义了处理器状态、指令的格式以及每条指令对状态的影响。大多数ISA将程序的运行描述得好像是一条一条执行的,但是事实上它们是并发执行的
- 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去像一个很大的字节数组
存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来的
汇编代码表示非常接近机器代码,且比起二进制格式,文本格式可读性更好
一些通常对C语言程序员隐藏的处理器状态在机器代码中都是可见的
- 程序计数器(PC):在x86-64中使用
%rip
表示 - 整数寄存器文件:包含16个命名的位置,可以存储指针或整数数据,也可以用来记录某些重要的程序状态。其它寄存器用来保存临时数据,例如过程参数、局部变量、函数返回值
- 条件码寄存器:最近执行的算术或逻辑指令的状态信息。实现数据流中的条件变化。
- 一组向量寄存器:存放一个或多个整数或浮点数值
机器代码只是简单地将内存看成一个很大的、按字节寻址的数组,C语言的各种结构在机器代码中用一组连续的字节表示,不区分有符号或者无符号的整数、指针。
程序内存:
- 可执行机器代码
- 操作系统需要的信息
- 管理过程调用和返回的栈
- 用户分配的内存块
在任意的给定的时刻,只有一部分虚拟地址被认为是合法的。
一条机器指令只执行一个非常基本的操作。
代码示例
在命令行上使用-S
选项就能看到C语言编译器产生的汇编代码
- helloWorld的编译结果:
.file "hello.c"
.text
.def __main; .scl 2; .type 32; .endef
.section .rdata,"dr"
.LC0:
.ascii "Hello, world!\0"
.text
.globl main
.def main; .scl 2; .type 32; .endef
.seh_proc main
main:
subq $40, %rsp
.seh_stackalloc 40
.seh_endprologue
call __main
leaq .LC0(%rip), %rcx
call puts
movl $0, %eax
addq $40, %rsp
ret
.seh_endproc
.ident "GCC: (x86_64-win32-seh-rev0, Built by MinGW-W64 project) 8.1.0"
.def puts; .scl 2; .type 32; .endef
每一个缩进去的行都相当于一条机器指令
机器执行的只是一个字节序列,它对于产生这些序列的源代码一无所知
-
反汇编器:用于查看机器代码的文件内容
在Linux中objdump可以作为反汇编器使用linux> objdump -d hello.o
-
使用反汇编器反汇编hello.o的结果
0000000000000000 <main>:
0: 48 83 ec 08 sub $0x8,%rsp
4: 48 8d 35 00 00 00 00 lea 0x0(%rip),%rsi # b <main+0xb>
b: bf 01 00 00 00 mov $0x1,%edi
10: b8 00 00 00 00 mov $0x0,%eax
15: e8 00 00 00 00 callq 1a <main+0x1a>
1a: b8 00 00 00 00 mov $0x0,%eax
1f: 48 83 c4 08 add $0x8,%rsp
23: c3 retq
-
每组都是一个指令。右边是等价的汇编语言
-
机器代码与反汇编表现的特性:
- x86-64的指令长度从1到15个字节不等,常用的指令以及操作数较少的指令的字节数较少
- (没看懂)设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如只有
pushq %rbx
是以字节53开头的 - 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码的。
- 反汇编器使用的指令命名规则与GCC生成的汇编代码使用的有细微差别。它省略了很多指令结尾的q。
- 生成可执行的代码需要对一组目标代码文件运行链接器
进行链接后文件变大了,因为它不仅包含着两个过程的代码,还包含着用来启动和终止程序的代码以及与操作系统交互的代码
关于格式的注解
GCC产生的汇编代码一方面包含一些我们不关心的信息,另一方面不提供任何程序的描述
所有以.
开头的都是指导汇编器与链接器工作的伪指令,可以忽略
对于一些应用程序,程序员必须用汇编代码来访问机器的低级特性
一种方法是用汇编代码编写整个函数
另一种方法是利用GCC的支持,直接在C程序中嵌入汇编代码
ATT与Intel汇编代码格式
ATT是GCC和一些其它工具的默认格式
其它工具,包括Microsoft的工具,使用Intel格式
GCC可以产生Intel格式的代码:linux> gcc -Og -S -masm=intel mstore.c
Intel与ATT在格式方面的不同:
- Intel代码省略了指示大小的后缀
- Intel代码省略了寄存器名字前的
%
号 - Intel代码使用不同的方式描述内存中的位置:
使用QWORD PTR [rbx]
而不是(%rbx)
- 在带有多个操作数时,列出的操作数顺序相反
将C程序与汇编代码结合起来(找一个时间进行试验)
有一些机器特性是C程序访问不到的
例如:每次x86-64处理器执行算术或者逻辑运算时,若得到的计算结果的低8位中有偶数个1,则将一个名为PF(parity flag)的1位条件码设为1,否则设为0
而在C语言中,需要得到这些信息至少需要7次移位、掩码或者异或计算
在程序中插入汇编代码指令就可以轻松完成该任务
在C程序中插入汇编代码:
- 编写完整的函数,放入一个独立的汇编代码文件,让汇编器与链接器将其与C语言代码合并
- 使用GCC内联汇编的特性,使用asm伪指令在C程序中包含简短的汇编代码(好处:减少了与机器相关的代码量)