【读薄 CSAPP】贰 机器指令与程序优化
文章目录
学习目标
- 了解基本的 CPU 架构,以及 CPU 如何进行指令计算
- 掌握基本的汇编语法,常见的寻址方式和运算方式
- 理解如何通过条件代码进行流程控制
- 理解过程调用和递归的底层实现机制
- 理解数据在内存中的存储方式
- 了解程序在内存中的组织形式以及缓冲区溢出漏洞攻击
- 利用代码执行的机制来优化已有代码
这一讲的内容比较多,考虑到现在接触汇编的机会着实不多,部分涉及具体细节的内容有删减,详情请参考书本。
基础知识
从 8086 到 Core i7
1978 年,Intel 发布了第一款 x86 指令集的微处理器——Intel 8086[1],以此拉开了 Intel x86 系列发展的序幕。8086 是 16 位微处理器,主要为 IBM PC 和 DOS 设计,有 1MB 的地址空间。八年后的 1985,第一个 32 位 Intel 处理器(IA32) 386 诞生。2004 年,奔腾(Pentium) 4E 成为了第一个 64 位处理器(x86-64)。后来随着摩尔定律在单个核心上达到极限,2006 年 Core 2 成为了第一个多核 Intel 处理器。
多核心的处理器大概长这样:
随着时代和科技的发展,处理器除了支持最基本的运算指令集外,还增加了支持多媒体操作处理和更高效执行条件操作的指令,这部分内容涉及到分支预测[2](又是一个很有意思但是没时间写的话题),因为篇幅关系不再赘述,简单来说就是研究人员为了让处理器效率提升采用了各种各样的方法,可能只是为了 1% 的提升。
除了增加处理器本身的功能外,另外的趋势是集成,比如说 2015 年的 Core i7 Broadwell,可以从下图看到处理器芯片中加入了原来主板才有的许多部件,如 PCIe, SATA, DDR3 等等。
顺带说一下千年老二 AMD,主打性价比,研发的 Opteron 系列是 Pentium 4 的强劲对手,并且开发了自己的 64 位拓展 x86-64。
Intel 在 64 位处理器的发展并不算顺风顺水,2001 年本打算使用全新的架构快速从 IA32 转换到 IA64,但是糟糕的性能反倒给了 AMD 机会。后者在 2003 年发布的 x86-64(现在叫 AMD64) 架构明显更厉害,搞得 Intel 疲于应战,最后在 2004 年搞出来一个叫 EM64T 的东西,其实几乎和 AMD64 一样。现在除了某些低端的处理器,几乎都支持 x86-64,也是这一讲主要介绍的内容。
从 C 到机器代码
机器代码就是处理器能够直接执行的字节层面上的程序,但是对于人类来说基本上是不可读的,所以把字节按照具体含义进行『翻译』,就成了人类可读的汇编代码。注意这里的用词是『翻译』而不是『编译』,可以认为汇编代码就是机器代码的可读形式。
机器代码和 C 代码应用两套完全不同的逻辑,机器代码是纯粹从『执行』的方式来进行思考的,而 C 的话则因为较多的抽象有了『程序设计』这个概念。相信读完这一节之后,你就会意识到为什么 C 语言的出现,可以称得上计算机学科的『第二次工业革命』。
一门新语言绝非只是一套语法规则,而是一系列配套的工具加上语法规则。C 语言代码最终成为机器可执行的程序,会像流水线上的产品一样接受各项处理:
- C 语言代码(da.c, wang.c)经过编译器的处理(
gcc -0g -S
)成为汇编代码(da.s, wang.s) - 汇编代码(da.s, wang.s)经过汇编器的处理(
gcc
或as
)成为对象程序(da.o, wang.o) - 对象程序(da.o, wang.o)以及所需静态库(lib.a)经过链接器的处理(
gcc
或ld
)最终成为计算机可执行的程序
我们直接来看一段代码及其经过编译生成的汇编代码,可能会有些难以理解,这是正常的,因为还没有介绍处理器具体执行指令的机制。这里我们先有一个感性的认识即可。
// 代码文件: sum.c
long plus(long x, long y);
void sumstore(long x, long y, long *dest)
{
long t = plus(x, y);
*dest = t;
}
对应的汇编代码
sumstore:
pushq %rbx
movq %rbx, %rbx
call plus
movq %rax, (%rbx)
popq %rbx
ret
比较一下我们就发现,C 语言代码被处理成了有统一格式的汇编代码,在汇编代码中,第一个字符串叫做操作符,后面可能跟着 1/2/3 个以逗号分隔的操作数,为什么是以这样的形式呢?这就要从处理器的运算方式讲起了,先来看看处理器是如何配合内存进行计算的:
- 程序计数器(PC, Program counter) - 存着下一条指令的地址,在 x86-64 中称为 RIP
- 寄存器(Register) - 用来存储数据以便操作
- 条件代码(Codition codes) - 通常保存最近的算术或逻辑操作的信息,用来做条件跳转
这里需要注意,处理器能够执行的操作其实是非常有限的,简单来说只有三种:存取数据、计算和传输控制。存取数据是在内存和寄存器之间传输数据,进行计算则是对寄存器或者内存中的数据执行算术运算,传输控制主要指非条件跳转和条件分支。这也就是为什么汇编代码有固定的 指令 操作数1 (,操作数2 ,操作数3)
这样的形式了。
我们拿前面程序中的两条指令来具体说明一下从 C 到汇编再到机器代码的变化:
// C 代码
*dest = t;
// 对应的汇编代码
movq %rax, (%rbx)
// 对应的对象代码
0x40059e: 46 89 03
C 代码的意思很简单,就是把值 t
存储到指针 dest
指向的内存中。对应到汇编代码,就是把 8字节(也就是四个字, Quad words)移动到内存中(这也就是为什叫做 movq
)。t
的值保存在寄存器 %rax 中,dest
指向的地址保存在 %rbx 中,而 *dest
是取地址操作,对应于在内存中找到对应的值,也就是 M[%rbx]
,在汇编代码中用小括号表示取地址,即 (%rbx)
。最后转换成 3 个字节的指令,并保存在 0x40059e
这个地址中。
汇编入门
前面我们简要了解了一下程序执行的基本过程,也对汇编有了一点点认识,这一节我们从寄存器的相关知识讲起,介绍汇编的基本知识。这部分内容虽然在实际编程中几乎用不到,但是对于后面内容的理解非常重要。
x86-64 架构中的整型寄存器如下图所示(暂时不考虑浮点数的部分)
仔细看看寄存器的分布,我们可以发现有不同的颜色以及不同的寄存器名称,黄色部分是 16 位寄存器,也就是 16 位处理器 8086 的设计,然后绿色部分是 32 位寄存器(这里我是按照比例画的),给 32 位处理器使用,而蓝色部分是为 64 位处理器设计的。这样的设计保证了令人震惊的向下兼容性,几十年前的 x86 代码现在仍然可以运行!
前六个寄存器(%rax, %rbx, %rcx, %rdx, %rsi, %rdi)称为通用寄存器,有其『特定』的用途:
- %rax(%eax) 用于做累加
- %rcx(%ecx) 用于计数
- %rdx(%edx) 用于保存数据
- %rbx(%ebx) 用于做内存查找的基础地址
- %rsi(%esi) 用于保存源索引值
- %rdi(%edi) 用于保存目标索引值
而 %rsp(%esp) 和 %rbp(%ebp) 则是作为栈指针和基指针来使用的。下面我们通过 movq
这个指令来了解操作数的三种基本类型:立即数(Imm)、寄存器值(Reg)和内存值(Mem)。
对于 movq
指令来说,需要源操作数和目标操作数,源操作数可以是立即数、寄存器值或内存值的任意一种,但目标操作数只能是寄存器值或内存值。指令的具体格式可以这样写 movq [Imm|Reg|Mem], [Reg|Mem]
,第一个是源操作数,第二个是目标操作数,例如:
movq Imm, Reg
->mov $0x5, %rax
->temp = 0x5;
movq Imm, Mem
->mov $0x5, (%rax)
->*p = 0x5;
movq Reg, Reg
->mov %rax, %rdx
->temp2 = temp1;
movq Reg, Mem
->mov %rax, (%rdx)
->*p = temp;
movq Mem, Reg
->mov (%rax), %rdx
->temp = *p;
这里有一种情况是不存在的,没有 movq Mem, Mem
这个方式,也就是说,我们没有办法用一条指令完成内存间的数据交换。
上面的例子中有些操作数是带括号的,括号的意思就是寻址,这也分两种情况:
- 普通模式,®,相当于
Mem[Reg[R]]
,也就是说寄存器 R 指定内存地址,类似于 C 语言中的指针,语法为:movq (%rcx), %rax
也就是说以 %rcx 寄存器中存储的地址去内存里找对应的数据,存到寄存器 %rax 中 - 移位模式,D®,相当于
Mem[Reg[R]+D]
,寄存器 R 给出起始的内存地址,然后 D 是偏移量,语法为:movq 8(%rbp),%rdx
也就是说以 %rbp 寄存器中存储的地址再加上 8 个偏移量去内存里找对应的数据,存到寄存器 %rdx 中
因为寻址这个内容比较重要,所以多说两句,不然之后接触指针会比较吃力。对于寻址来说,比较通用的格式是 D(Rb, Ri, S)
-> Mem[Reg[Rb]+S*Reg[Ri]+D]
,其中:
D
- 常数偏移量Rb
- 基寄存器Ri
- 索引寄存器,不能是 %rspS
- 系数
除此之外,还有如下三种特殊情况
(Rb, Ri)
->Mem[Reg[Rb]+Reg[Ri]]
D(Rb, Ri)
->Mem[Reg[Rb]+Reg[Ri]+D]
(Rb, Ri, S)
->Mem[Reg[Rb]+S*Reg[Ri]]
我们通过具体的例子来巩固一下,这里假设 %rdx 中的存着 0xf000
,%rcx 中存着 0x0100
,那么
0x8(%rdx)
=0xf000
+0x8
=0xf008
(%rdx, %rcx)
=0xf000
+0x100
=0xf100
(%rdx, %rcx, 4)
=0xf000
+4*0x100
=0xf400
0x80(, %rdx, 2)
=2*0xf000
+0x80
=0x1e080
了解了寻址之后,我们来看看运算指令,这里以 leaq
指令为例子。具体格式为 leaq Src, Dst
,其中 Src
是地址的表达式,然后把计算的值存入 Dst
指定的寄存器,也就是说,无须内存引用就可以计算,类似于 p = &x[i];
。我们来看一个具体的例子,假设一个 C 函数是:
long m12(long x)
{
return x * 12;
}
对应的汇编代码为:
leaq (%rdi, %rdi, 2), %rax # t <- x+x*2
salq $2, %rax # return t << 2
可以看到是直接对 %rdi 寄存器中存的数据(地址)进行运算,然后赋值给 %rax。最后给出一些常见的算术运算指令,注意参数的顺序,而且对于有符号和无符号数都是一样的,更多的信息可以参考 Int