《深入理解计算机系统》读书笔记 第三章 程序的机器级表示。

3 程序的机器级结构

  • 通过阅读汇编代码:

    • 理解编译器的优化能力,并分析代码中隐含的低效率。
    • 如用线程包写并发程序时,了解不同线程如何共享程序数据或保持数据私有的,以及准确知道如何在哪里访问共享数据。
    • 许多攻击利用了系统程序中的漏洞重写信息,从而获得了系统的控制权。

3.1 历史观点

Intel处理器俗称x86。主要发展历程:

  • 8086(1978年)。第一代单芯片,16 位微处理器之一。其变种 8088 构成了最初 IBM 个人计算机的心脏,与微软开发了 MS-DOS 系统。
  • 80286(1982年)。MS Windows 最初的使用平台。
  • i386(1985年)。体系结构扩展到 32 位。Intel 系列中第一台全面支持 Unix 的机器。
  • i486(1989年)。改善了性能,集成了浮点单元,但指令级没有明显改变。
  • Pentium(1993年)。改善了性能。
  • PentiumPro(1995年)。全新的处理器设计,P6 微体系结构。
  • Pentium 4E(2004年)。增加了超线程(hyperthreading),使得可以在一个处理器上同时运行两个程序;还增加了 EM64T,是 Intel 对 AMD 提出的对 IA32 的 64 位 扩展的实现,我们称之为 x86-64。
  • Core 2(2006年)。回归 P6。Intel 第一个多核微处理器,但不支持超线程。
  • Core i7,Nehalem(2008年)。既支持超线程,也有多核。
  • Core i7,Sandy Bridge(2011年)。引入了 AVX,这是对 SSE 的扩展,支持把数据封装进 256 位的向量。
  • Core i7,Haswell(2013年)。将 AVX 扩展至 AVX2,增加了更多指令和指令格式。

这些处理器是后向兼容的:较早版本上编译的代码可以在较新的处理器上运行。

  • IA32:Intel 32位体系结构(Intel Architecture 32-bit)。
  • Intel64:IA32 的 64位扩展,也称为 x86-64。
  • x86:指代整个系列。

3.2 程序编码

1
gcc -0g -o p p1.c p2.c

3.2.1 机器级编码

两种抽象:

  • 指令集体系结构或指令级架构(Instruction Set Architecture, ISA):定义机器级程序的格式和行为、处理器状态和指令的格式、每条指令对状态的影响。
  • 机器级程序使用的内存地址是虚拟地址。

机器代码的一些在C语言中不可见的状态:

  • 程序计数器(PC,x86-64中用%rip表示):下一条指令在内存中的地址。
  • 整数寄存器文件:包含16个命名的位置。
  • 条件码寄存器:保存最近执行的算术或逻辑指令的状态信息,实现控制或数据流中的条件变化。
  • 一组向量寄存器:存放一个或多个整数或浮点的值。

  • 汇编代码不区分有符号无符号,不区分各种类型的指针,甚至不区分指针和整数。

  • x86-64 的虚拟地址是由 64 位的字来表示的,在目前的实现中,高 16 位地址必须设置为 0。(64TB)

3.2.2 代码示例

查看编译器产生的汇编代码(生成*.s):

1
gcc -0g -S *.c

编译并汇编代码(生成目标文件*.o):

1
gcc -0g -c *.c

使用 gdb 展示程序的二进制目标代码:

1
2
# 先用反汇编器确定该函数的代码长度是14字节。
(gdb) x/14xb func1

利用反汇编器查看机器代码内容:

1
objdump -d mstore.o

一些关于机器代码和反汇编表示的特性:

  • x86-64 的指令长度从 1 到 15 个字节不等。
  • 设计指令格式的方式:从某个给定位置开始,可以将字节唯一地解码成机器指令。
  • 反汇编器只基于代码文件中的字节序列来确定汇编代码,不需要访问源代码或汇编代码。
  • 反汇编器使用的指令命名规则和 GCC 生成的汇编代码有些细微差别。如:省略了很多指令结尾的q,也给一些指令添加了q

  • 连接器会为函数调用找到匹配的函数的可执行代码的位置。

  • 机器代码中在函数末尾处会填充字节使函数代码变为16字节,提升存储器系统性能。

3.2.3 关于格式的注解

  • .开头的行都是指导汇编器和连接器工作的伪指令。
  • ATT 汇编代码格式(GCC、Objdump等工具的默认格式)和 Intel 汇编代码格式(Microsft 的工具和 Intel 的文档)的区别:

    • Intel 代码省略了指示大小的后缀。如q
    • Intel 代码省略了寄存器名字前面的%
    • Intel 代码用不同的方式来描述内存中的位置。如QWORD PTR [rbx]而不是(%rbx)
    • 多个操作数的指令下,列出的操作数顺序相反。
  • C程序中插入汇编代码的方法:

    • 编写完整的函数,放进一个独立的汇编代码文件中,让汇编器和链接器把他们合并。
    • 用 GCC 的内联汇编(inline assembly)特性,用 asm 伪指令在 C 程序中包含简短的汇编代码。

3.3 数据格式

  • 字节(byte):8位。后缀b
  • 字(word):16位。后缀w
  • 双字(DWORD):32位。后缀l
  • 四字(QWORD):64位。后缀q

  • 单精度浮点:4字节。
  • 双精度浮点:8字节。
  • long double:10字节(不推荐)。

3.4 访问信息

  • 8086:8个16位寄存器,从%ax%bp
  • IA32:8个32位寄存器,从%eax%ebp
  • x86-64:16个64位寄存器,从%rax%rbp%,从%r8%r15

  • 生成 1 字节和 2 字节数字的指令会保持剩下的字节不变。
  • 生成 4 字节数字的指令会把高位 4 个字节置为 0。

3.4.1 操作数指示符

  • 立即数(immediate),ATT 格式中,用$前缀加标准C表示法的整数。
  • 寄存器(register)。
  • 内存引用。有四个组成部分:立即数偏移、基址寄存器、变址寄存器、比例因子。

3.4.2 数据传送指令

MOV 指令类

指令 效果 描述
MOV S, D D <- S 传送
movb 传送字节
movw 传送字
movl 传送双字
movq 传送四字
movabsq I, R R <- I 传送绝对的四字
  • 源操作数指定的值是一个立即数,存储在寄存器或内存中。
  • 目的操作数指定一个位置,要么是一个寄存器,要么是一个内存地址。
  • x86-64 不允许两个操作数都指向内存地址。
  • 寄存器部分的大小必须与指令后缀匹配。
  • movl指令以寄存器作为目标时,会把高 4 位也置为 0。
  • movq只能以表示为32位补码数字的立即数作为源操作数,然后把这个值符号扩展得到 64 位的值,放到目的位置。
  • movabsq能够以任意 64 位立即数作为源操作数,并且只能以寄存器作为目的。

MOVZ 指令类

  • 将较小的源值复制到较大的目的时,用 0 填充。
指令 效果 描述
MOVZ S, R R <- 零扩展(S) 以零扩展进行传送
movzbw 字节 -> 字
movzbl 字节 -> 双字
movzwl 字 -> 双字
movzbq 字节 -> 四字
movzwq 字 -> 四字

MOVS 指令类

  • 将较小的源值复制到较大的目的时,用 符号扩展 填充。
指令 效果 描述
MOVS S, R R <- 符号扩展(S) 以零扩展进行传送
movsbw 字节 -> 字
movsbl 字节 -> 双字
movswl 字 -> 双字
movsbq 字节 -> 四字
movswq 字 -> 四字
movslq 双字 -> 四字(movzlq 并不存在,因为用 movl 其实就能实现)
cltq %rax <- 符号扩展(%eax) 把 %eax 符号扩展到 %rax(即 movslq %eax,%rax

3.4.3 数据传送示例

  • 函数的参数通过寄存器传递给函数。
  • 函数通过把值存储在寄存器%rax或该寄存器某个低位部分中返回。
  • 间接引用指针就是将该指针放在一个寄存器中。
  • 局部变量通常是保存在寄存器中,而不是内存中。

3.4.4 压入和弹出栈数据

  • 栈向下增长,栈顶元素的地址是栈中所有元素地址中最低的。
  • %rsp保存着栈顶元素的地址。
  • 程序可以用标准的内存寻址方法访问栈内的任意位置。
指令 效果 描述
pushq S R[%rsp] <- R[%rsp] - 8 将四字压入栈
M[R[%rsp]] <- S
popq D D <- M[R[%rsp]] 将四字弹出栈
R[%rsp] <- R[%rsp] + 8

3.5 算数和逻辑操作

四组操作:

  • 加载有效地址:

    • leaq S,D,效果:D <- &S
  • 一元操作:

    • INC D:加 1。
    • DEC D:减 1。
    • NEG D:取负。
    • NOT D:取补。
  • 二元操作:

    • ADD S, D:加。
    • SUB S, D:减。
    • IMUL S, D:乘。
    • XOR S, D:异或。
    • OR S, D:或。
    • AND S, D:与。
  • 移位:

    • SAL k, D:左移。
    • SHL k, D:左移。
    • SAR k, D:算术右移。
    • SHR k, D:逻辑右移。

3.5.1 加载有效地址

  • leaq实际上是movq指令的变形。
  • 形式是从内存读数据到寄存器,但实际上根本没有引用内存,只是将有效地址写入到目的操作数。
  • 编译器经常发现leaq的一些灵活用法,比如可以简洁地描述普通的算术计算(加法和乘法),根本就与有效地址的计算无关。

3.5.2 一元和二元操作

  • 一元操作:既是源又是目的。
  • 二元操作:第二个操作数既是源又是目的,所以不能是立即数。如果第二个操作数是内存地址,处理器必须从内存读出值,执行操作,再把结果写回内存。

3.5.3 移位操作

  • 第一个操作数:移位量。第二个操作数:要移位的数。
  • 移位量可以是一个立即数,或者放在单字节寄存器%cl中。
  • 移位操作对w位长的数据值进行操作时,移位量是由%cl寄存器的低m位决定的,这里2^m = w。高位被忽略。如%cl0xFF

    • salb移动 7 位。
    • salw移动 15 位。
    • sall移动 31 位。
    • salq移动 63 位。

3.5.4 讨论

  • 除了右移操作,其他操作都无须区分有符号和无符号数。

3.5.5 特殊的算术操作

  • 两个 64 位整数相乘的积需要 128 位来表示。x86-64 指令集对其提供了有限的支持:8字(oct word)。
指令 效果 描述
imulq S R[%rdx]: R[%rax] <- S*R[%rax] 有符号全乘法
mulq S R[%rdx]: R[%rax] <- S*R[%rax] 无符号全乘法
clto R[%rdx]: R[%rax] <- 符号扩展(R[%rax]) 转换为八字
idivq S R[%rdx] <- R[%rdx]: R[%rax] mod S 有符号除法
R[%rdx] <- R[%rdx]: R[%rax] / S
divq S R[%rdx] <- R[%rdx]: R[%rax] mod S 无符号除法
R[%rdx] <- R[%rdx]: R[%rax] / S
  • imulq有两种形式:

    • 双操作数:两数相乘,产生一个64位乘积(截断,无符号乘和补码乘的位级行为是一样的,因此,没有imulq。)。
    • 单操作数:将%rax与操作数相乘,产生一个128位乘积,高位和低位分别存放在%rdx%rax中。(mulq同理。)
  • C 标准没有提供128位的值。可以借助 GCC 提供的__int128来声明128位整数。

  • 将结果从寄存器取出时,要注意机器的大小端。

  • idivq

    • 将寄存器%rdx(高64位)和%rax(低64位)中的128位数作为被除数,操作数作为除数,商存放在%rax,余数存放在%rdx
    • 除数应该放在%rax中,%rdx的位应该设置为0(无符号运算)或者%rax的符号位(有符号运算),使用cqto指令即可完成这个操作。
    • divq应该将%rdx事先设定为0。

3.6 控制

3.6.1 条件码(condition code)

除了整数寄存器,CPU 还维护着一组单个位的条件码寄存器。常用的条件码有:

  • CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
  • ZF:零标志。最近的操作得出的结果为 0。
  • SF:符号标志。最近的操作得出的结果为负值。
  • OF:溢出标志。最近的操作导致一个补码溢出——正溢出或负溢出。

除了leaq,上面列出的所有指令都会改变条件码。并且:

  • 对于逻辑操作,如XOR,进位标志和溢出标志会设置成0。
  • 对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置位0。
  • INCDEC指令会设置溢出和零标志,但是不会改变进位标志。

还有两类指令只设置条件码而不改变任何其他寄存器:

指令 基于 描述
CMP S1, S2 S2 - S1 比较
cmpb 比较字节
cmpw 比较字
cmpl 比较双字
cmpq 比较四字
TEST S1, S2 S1 & S2 测试
testb 测试字节
testw 测试字
testl 测试双字
testq 测试四字
  • 在 ATT 格式中,列出操作数的顺序是相反的。
  • 如果两个操作数相等:零标志置为 1。若不相等:根据其他标志判断。

3.6.2 访问条件码

  • 根据条件码的某种组合,将一个字节设置为 0 或者 1。(SET指令。)
  • 可以条件跳转到程序的某个其他的部分。
  • 可以有条件地传送数据。
指令 同义名 效果 设置条件
sete D setz D <- ZF 相等/零
setne D setnz D <- ~ZF 不等/非零
sets D D <- SF 负数
setns D D <- ~SF 非负数
setg D setnle D <- ~(SF^OF)&~ZF 大于(有符号)
setge D setnl D <- ~(SF^OF) 大于等于(有符号)
setl D setnge D <- SF^OF 小于(有符号)
setle D setng D <- (SF^OF) or ZF 小于等于(有符号)
seta D setnbe D <- ~CF & ~ZF 超过(无符号)
setae D setnb D <- ~CF 超过或等于(无符号)
setb D setnae D <- CF 低于(无符号)
setbe D setna D <- CF or ZF 低于或等于(无符号)
  • SET指令的后缀不是操作数的大小,而是不同的条件。
  • SET的目的操作数是低位单字节寄存器元素之一,或是一个字节的内存位置,指令会将这个字节设置为 0 或者 1。因此如果要得到 32 位或者 64 位的结果,须对高位清零。

  • 对于大多数情况,机器代码对于有符号和无符号两种情况都使用一样的指令。

  • 有些情况需要用不同的指令来处理有符号和无符号操作。例如:右移、乘除、不同的条件码组合。

3.6.3 跳转指令

  • jmp:无条件跳转指令。可以是:

    • 直接跳转:跳转目标是作为指令的一部分编码的。汇编中用标号,如.L1
    • 间接跳转:跳转目标8是从寄存器或内存位置中读出的。汇编中用*跟操作数指示符,如*%rax*(%rax)
  • 其他条件跳转都只能是直接跳转。

指令 同义名 跳转条件 描述
je D jz ZF 相等/零
jne D jnz ~ZF 不等/非零
js D SF 负数
jns D ~SF 非负数
jg D jnle ~(SF^OF)&~ZF 大于(有符号)
jge D jnl ~(SF^OF) 大于等于(有符号)
jl D jnge SF^OF 小于(有符号)
jle D jng (SF^OF) or ZF 小于等于(有符号)
ja D jnbe ~CF & ~ZF 超过(无符号)
jae D jnb ~CF 超过或等于(无符号)
jb D jnae CF 低于(无符号)
jbe D jna CF or ZF 低于或等于(无符号)

3.6.4 跳转指令的编码

跳转指令的编码分为:

  • PC相对的(PC-relative)。将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。这些地址偏移量可以编码为 1、2、4 个字节。程序计数器的值是跳转指令后面的那条指令的地址,而不是跳转指令本身的地址。
  • 绝对地址,用 4 个字节直接指定目标,汇编器和连接器会选择适当的跳转目的编码。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(汇编代码)
1 mov %rdi, %rax
2 jmp .L2
3 .L3:
4 sarq %rax
5 .L2:
6 testq %rax, %rax
7 jg .L3
8 req; ret

(机器代码的反汇编版本)
1 0: 48 89 f8 mov %rdi,%rax
2 3: eb 03 jmp 8 <loop+0x8>
3 5: 48 d1 f8 sar %rax
4 8: 48 85 c0 test %rax,%rax
5 b: 7f f8 jg 5 <loop+0x5>
6 d: f3 c3 repz retq

其中,反汇编第二行的8是由0x3 + 0x5得到,第五行的5是由0xf8 + 0xd得到。

注:AMD 建议用rep后面跟ret的组合来避免使ret成为条件跳转指令的目标。如果没有rep,当分支不跳转时,jg指令会继续到ret指令。(以后遇到reprepz就直接无视掉。)

3.6.5 用条件控制来实现条件分支

C语言中if-else到汇编的转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
(C代码)
if (test-expr)
then-statement
else
else-statement

(汇编代码)
t = test-expr;
if (!t)
goto false;
then-statement
goto done;
false:
else-statement
done:
...

3.6.6 用条件传送来实现条件分支

  • 利用控制实现条件转移虽然简单通用,但是可能非常低效。因为处理器通过使用“流水线”来获得高性能,而流水线需要事先确定要执行的指令序列,遇到条件分支时需要采用“分支预测逻辑”来猜测每条跳转指令是否会执行。若猜错,需要处理器丢掉这些已经做好的工作,浪费大约15~30个时钟周期。

  • 假设预测错误的概率是p,如果没有预测错误,执行代码的时间是T-OK,否则是T-MP,则执行代码的平均时间T-avg = T-OK + p * T-MP

指令 同义名 传送条件 描述
cmove S,R cmovz ZF 相等/零
cmovne S,R cmovnz ~ZF 不等/非零
cmovs S,R SF 负数
cmovns S,R ~SF 非负数
cmovg S,R cmovnle ~(SF^OF)&~ZF 大于(有符号)
cmovge S,R cmovnl ~(SF^OF) 大于等于(有符号)
cmovl S,R cmovnge SF^OF 小于(有符号)
cmovle S,R cmovng (SF^OF) or ZF 小于等于(有符号)
cmova S,R cmovnbe ~CF & ~ZF 超过(无符号)
cmovae S,R cmovnb ~CF 超过或等于(无符号)
cmovb S,R cmovnae CF 低于(无符号)
cmovbe S,R cmovna CF or ZF 低于或等于(无符号)
  • 两个操作数:源寄存器或内存地址 S,目的寄存器 R。
  • 只有在指定的条件满足时,才会被复制到目的寄存器中。
  • 源和目的的值可以时16位、32位、64位,不支持单字节的条件传送。汇编器可以从目标寄存器的名字推断出条件传送指令的操作数长度,所以对所有的操作数长度,都可以使用同一个的指令名字。

对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(C语句)
v = test-expr ? then-expr : else-expr;

(条件控制转移形式)
if (!test-expr)
goto false;
v = then-expr
goto done;
false:
v = else-expr;
done:

(条件传送形式)
v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;
  • 不是所有的条件表达式都可以用传送条件来编译。如果then-exprelse-expr可能产生错误条件或副作用,会导致非法的行为。(如给全局变量赋值、return p ? *p : 0;等)

  • 条件传送并不总是会提高代码的效率。编译器一般只在两个表达式都很容易计算且没有副作用时才会使用。

3.6.7 循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
(C语句)
do
body-statement
while (test-expr);

(汇编)
loop:
body-statement
t = test-expr;
if (t)
goto loop;

(C语句)
while (test-expr)
body-statement

(汇编方式一:跳转到中间)
goto test;
loop:
body-statement
test:
t = test-expr;
if (t)
goto loop;

(汇编方式二:guarded-do)
t = test-expr;
if (!t)
goto done;
loop:
body-statement
t = test-expr;
if (t)
goto loop;
done:

(C语句)
for(init-expr; test-expr; update-expr)
body-statement

(汇编一)
init-expr;
goto test;
loop:
body-statement
update-expr;
test:
t = test-expr;
if (t)
goto loop;

(汇编二)
init-expr;
t = test-expr;
if (!t)
goto done;
loop:
body-statement
update-expr;
t = test-expr;
if (t)
goto loop;
done:

3.6.8 switch 语句

  • 使用 跳转表(jump table) 提高效率,使得执行 switch 语句的时间与开关情况的数量无关。
  • 跳转表可以用 GCC 对 C 的扩展的方式表示:
1
2
3
4
5
6
// &&表示指向代码位置的指针。
static void *jt[7] = {
&&loc_A, &&loc_def, &&loc_B,
&&loc_C, &&loc_D, &&loc_def,
&&loc_D
};
  • 跳转表的汇编表示:
1
2
3
4
5
6
7
8
9
10
  .section      .rodata
.align 8
.L4:
.quad .L3 # Case 100: loc_A
.quad .L8 # Case 101: loc_def
.quad .L5 # Case 102: loc_B
.quad .L6 # Case 103: loc_C
.quad .L7 # Case 104: loc_D
.quad .L8 # Case 105: loc_def
.quad .L7 # Case 106: loc_D
  • switch语句的汇编表示:
1
2
3
4
5
6
7
8
9
10
11
12
13
switch_eg:
subq $100, %rsi # Compute index = n - 100
cmpq $6, %rsi # Compare index and 6
ja .L8 # If >, goto loc_def
jmp *.L4(,%rsi,8) # Goto *jt[index]
.L3:
...
.L5:
...
...
.L2:
movq %rdi, (%rdx)
ret
  • 完整代码见原书。

3.7 过程

  • 过程的形式:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等。
  • P 调用 Q,再返回 P。这些动作包括以下一个或多个机制:

    • 传递控制。(运行时栈)
    • 传递数据。
    • 分配和释放内存。

3.7.1 运行时栈

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

  • 当 P 调用 Q 时:

    • 把返回地址压入栈中(表示从 Q 返回时,从 P 的哪个位置继续执行)(算作 P 的栈帧的一部分)。
    • Q 会扩展当前栈的边界,在这个空间里,可以保存寄存器的值,分配局部变量空间,为它调用的过程设置参数。
  • 大多数过程的栈帧都是定长的,在过程的开始就分配好了。但也有变长的帧。

  • 最多可以通过寄存器传递 6 个整数值(指针和整数),如果 Q 需要更多的参数,则 P 可以在 Q 调用之前在自己的栈帧里存储好这些参数。
  • 当所有的局部变量都可以保存在寄存器中,且该函数不会调用任何其他函数,不需要栈帧。

3.7.2 转移控制

指令 描述
call Label 过程调用(直接调用)
call *Operand 过程调用(间接调用)
ret 从过程调用中返回
  • call Q 会把地址A(返回地址,是紧跟在call指令后的那条指令的地址)压入栈,并将 PC 设置为 Q 的起始地址。
  • ret 会从栈中弹出地址A,并将 PC 设置为 A。

3.7.3 数据传送

  • x86-64 中,可以通过寄存器最多传递6个整型参数。多余的部分就要通过栈来传递。
  • 寄存器的使用是有特殊顺序的,寄存器使用的名字取决于要传递的数据类型的大小。
  • 使用栈传递参数时,所有的数据大小都向8的倍数对齐。
操作数大小 1 2 3 4 5 6
64 %rdi %rsi %rdx %rcx %r8 %r9
32 %edi %esi %edx %ecx %r8d %r9d
16 %di %si %dx %cx %r8w %r9w
8 %dil %sil %dl %cl %r8b %r9b
  • 假设一个函数有两个参数需要放到栈中,分别是charchar*类型。则它的内存状态是:

3.7.4 栈上的局部存储

  • 局部数据必须存放在内存中的情况:

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

3.7.5 寄存器中的局部存储空间

  • %rbx%rbp%r12 ~ %r15被划分成 被调用者 保存寄存器。当 P 调用 Q 时,Q 必须保存这些寄存器的值,确保他们在调用接受后不变。措施有:

    • 根本不去改变它。
    • 把原始值压入栈中,返回前再弹出。
  • 其他所有寄存器,除了栈指针%rsp,都分类为 调用者 保存寄存器。任何函数都能修改他们。当 P 调用 Q 时,调用前保存好这个数据是 P 的责任。

3.7.6 递归过程

  • 栈机制使得对递归的情况同样适用。

3.8 数组分配和访问

3.8.1 基本原则

T A[N]

  • 数据类型:T
  • 整形常数:N
  • 标识符:A
  • 元素大小:L
  • 起始地址:x
  • 元素i的地址:x + i * L

3.8.2 指针运算

  • 可利用MOVleaq等指令进行地址的运算。

3.8.3 嵌套的数组

  • 对于T D[R][C]&D[i][j] = x + L(C · i + j)

3.8.4 定长数组

  • 编译器往往会对定长多维数组进行优化。

3.8.5 变长数组

  • C99 允许数组的维度是表达式。这会导致寄存器使用的变化,并且必须要使用乘法。
  • 在循环引用定长数组时,编译器会利用其规律性进行优化计算。

3.9 异数的数据结构

3.9.1 结构

  • 编译器负责计算每个字段的地址。

3.9.2 联合

  • 编译器负责计算每个字段的地址。

3.9.3 数据对齐

  • 许多计算机系统对基本数据类型的合法地址做了一些限制,比如必须是某个值(2、4 或 8)的倍数。
  • 无论数据是否对齐,x86-64 硬件都能正确工作。不过,Intel 还是建议对齐数据以提高内存系统的性能。对齐原则是任何 K 字节的基本对象的地址必须是 K 的倍数。
  • 编译器在汇编代码中放入命令,指明全局数据所需的对齐。例如.align 8
  • 对于结构,编译器可能需要在字段的分配中插入间隙,以保证每个结构单元满足对齐要求。包括结尾,以满足结构数组的对齐要求。

3.10 在机器级程序中将控制与数据结合起来

3.10.1 理解指针

  • 每个指针都对应一个类型。(不是机器代码的一部分,属于C语言提供的抽象)
  • 每个指针都有一个值。
  • 指针用&运算符创建。
  • *用于间接引用指针。
  • 数组和指针关系紧密。
  • 将指针类型转换,不会改变它的值。
  • 指针也可以指向函数。

3.10.2 应用:使用 GDB 调试器

3.10.3 内存越界引用和缓冲区溢出

  • 缓冲区溢出(buffer overflow)会破坏栈的状态,甚至让程序执行攻击代码(exploit code)。

3.10.4 对抗缓冲区溢出攻击

  • 栈随机化。

    • 过去,程序的栈地址非常容易预测,安全单一化(security monoculture)。
    • 栈随机化的思想是使得栈的位置在程序每次运行时都发生变化。如在程序开始时在栈上分配随机个字节大小的空间。
    • 这种策略已经变成了标准行为,它属于地址空间布局随机化(Address-Space Layout Randomization,ASLR)。
    • 不过攻击者依然可以用蛮力攻克,比如在攻击代码前插入很长一段空操作雪橇(nop sled),只要猜中这段序列中的任一地址即可。
  • 栈破坏检测

    • 栈保护者(stack protector)机制:在栈帧中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀(canary)值,或称哨兵值(guard value),是程序运行时随机产生的。在恢复寄存器状态和从函数返回之前,检查它是否改变了。
  • 限制可执行代码的区域

    • 虚拟内存空间在逻辑上被分成了页(page)。
    • 硬件支持多种形式的内存保护,能够指明用户程序和操作系统内核所允许的访问形式。
    • 许多系统都支持三种访问形式:读、写、执行。
    • 以前,x86 将读和执行合并在一起,为了限制一些页是可读但不可执行,会带来性能损失。

3.10.5 支持变长栈帧

  • 使用%rbp作为帧指针(frame pointer)或称基指针(base pointer)。

3.11 浮点代码

  • 处理器的浮点数体系结构包括以下几个方面:

    • 如何存储和访问浮点数值。(通常通过某种寄存器)
    • 对浮点数据操作的指令。
    • 向函数传递浮点参数和从函数返回浮点数结果的规则。
    • 函数调用过程中保存寄存器的规则。
  • 历史:

    • 1997,Pentium/MMX,媒体(media)指令,支持图形和图像处理,允许多个操作以并行模式执行,称为单指令多数据或 SIMD。(MM寄存器,64位)
    • SSE(Streaming SIMD Extension,流式 SIMD 扩展)。(XMM寄存器,128位)
    • AVX(Advanced Vector Extension,高级向量扩展)。(YMM寄存器,256位)
    • 2000,Pentium 4,SSE2,媒体指令开始包括对标量浮点数据进行操作的指令,他们更类似于其他处理器支持浮点数的方式。所有能够执行 x86-64 代码的处理器都支持 SSE2 或更高的版本,因此 x86-64 浮点数是基于 SSE 或 AVX 的,包括传递过程参数和返回值的规则。
    • 2013,Core i7 Haswell,AVX2。(指定命令行参数-mavx2)。我们讲述的版本。

  • 对标量数据操作时,这些寄存器只保存浮点数。并且只使用低32位(float)或64位(double)。

3.11.1 浮点传送和转换操作

  • 引用内存的指令是标量指令,意味着只对单个而不是一组封装好的数据值进行操作。
  • 数据要么保存在内存中(M32 或 M64),要么保存在XMM寄存器中(X)。
  • 无论数据对齐与否,都能正确执行。不过代码优化规则建议32位最好满足4字节对齐,64位最好满足8字节对齐。
指令 目的 描述
vmovss M32 X 传送单精度数
vmovss X M32 传送单精度数
vmovsd M64 X 传送双精度数
vmovsd X M64 传送双精度数
vmovaps X X 传送对齐的封装好的单精度数
vmovapd X X 传送对齐的封装好的双精度数
  • 程序复制整个寄存器还是只复制低位值既不会影响程序功能,也不会影响执行速度。
  • a表示aligned,如果地址不满足 16 字节对齐,会导致异常。在两个寄存器之间传送数据,绝不会出现错误对齐的状况。
指令 目的 描述
vcvttss2si X/M32 R32 用截断的方法把单精度数转换为整数
vcvttsd2si X/M64 R32 用截断的方法把双精度数转换为整数
vcvttss2siq X/M32 R64 用截断的方法把单精度数转换为四字整数
vcvttsd2siq X/M64 R64 用截断的方法把双精度数转换为四字整数
指令 源1 源2 目的 描述
vcvtsi2ss M32/R32 X X 整数 -> 单精度数
vcvtsi2sd M32/R32 X X 整数 -> 双精度数
vcvtsi2ssq M64/R64 X X 四字整数 -> 单精度数
vcvtsi2sdq M64/R64 X X 四字整数 -> 双精度数

3.12 小结

习题

  • 3.1 操作数的计算。
  • 3.2 MOV指令的选择。
  • 3.3 MOV指令的挑错。
  • 3.4 MOVMOVZMOVS的计算,实现C语言强制类型转换的指令。
  • 3.5 MOVMOVZMOVS的逆向推算。
  • 3.6 leaq的计算。
  • 3.7 leaq的逆向推算。
  • 3.8 一元和二元操作的计算。
  • 3.9 C语言移位运算的指令。
  • 3.10 算术操作的逆向推算。
  • 3.11 用xorq %rdx, %rdx实现赋 0 值。
  • 3.12 对比有符号除法和无符号除法产生的汇编。
  • 3.13 SET的逆向推算。
  • 3.14 TEST的逆向推算。
  • 3.15 跳转中PC相对寻址的计算。
  • 3.16 汇编跳转实现C语言中的短路运算。
  • 3.17 条件跳转。
  • 3.18 汇编中条件跳转逆向推算C语句。
  • 3.19 条件分支预测错误的概率计算。
  • 3.20 汇编中条件传送逆向推算C语句。
  • 3.21 汇编中条件传送逆向推算C语句。
  • 3.22 阶乘的可用范围。
  • 3.23 汇编do-while反推。
  • 3.24 汇编while反推。
  • 3.25 汇编while反推。
  • 3.26 汇编while反推 。计算有多少个1
  • 3.27
  • 3.28
  • 3.29
  • 3.30 switch反推。
  • 3.31 switch反推。
  • 3.32 函数调用的运行时栈。
  • 3.33 逆推参数类型。
  • 3.34 判断局部值保存在寄存器还是栈上。
  • 3.35 逆推递归函数。
  • 3.36 数组大小和地址的计算。
  • 3.37 指针运算与汇编代码。
  • 3.38 逆推二维数组。
  • 3.39 定长数组的优化。
  • 3.40 逆推定长数组的优化。

4 处理器体系结构

5 优化程序性能

6 存储器层次结构

7 链接

8 异常控制流

9 虚拟内存

10 系统级I/O

11 网络编程

12 并发编程