Jun's Blog

CSAPP第三章笔记Part 1

· Jun

程序的执行

虽然我们日常使用的编程语言多种多样,但对于计算机来说,其唯一能理解的无非就是二进制,即0和1而已。 CPU的工作流程基本可以看作为控制器从计数器(PC)取出下一条指令并执行,同时更新程序计数器的值。

C语言的编译过程

对于C语言这种编译型语言来说,我们需要用编译器将高级代码翻译成二进制代码。 它的基本流程如下:

首先,预处理器插入所有#include指定的文件,展开#define定义的宏。

1
gcc -E prog.c

接着,由编译器将源文件编译为汇编代码。

1
gcc -c prog.c

再然后,由汇编器将汇编代码转换为二进制目标代码。

1
gcc -S prog.c

最后,由链接器进行链接,如库函数等,并产生最终的可执行文件。

1
ld

机器代码

我们这里所研究的主要是x86汇编代码。汇编指令与目标平台息息相关,不同的CPU架构有着不同的指令集。 基本抽象模型:

  • 将程序的行为描述为按顺序执行。
  • 将内存看为一个字节数组。

寄存器

数据格式

C声明 Intel数据类型 代码后缀 大小
char 字节 b 1
short w 2
int 双字 l 4
long 四字 q 8
char* 四字 q 8
float 单精度 s 4
double 双精度 l 8

一个x86-64的CPU有16个通用目的寄存器。最初的8086有8个16位的寄存器,从%ax%sp。当拓展到32位时,这些寄存器也拓展为32位寄存器,标号为%eax%esp。而拓展到64位后,标号为%rax%rsp。另外,还增加了8个新的寄存器,从%r8%r15。注意这些指令是向后兼容的,原来的16位寄存器或32位寄存器实际上成为了现在寄存器的低位。

当我们只访问寄存器的低位时,遵循两条规则:

  • 生成1字节或者2字节的指令会保持剩下的字节不变
  • 生成4字节的指令会把高4字节置为0

内存引用

x86有多种寻址模式,允许不同形式的内存引用。其通用的公式可以表达为:

1
Imm(rb,ri,s)

其中Imm为立即数,表示一个偏移量,rb则为基址寄存器,ri为变址寄存器,s为比例因子。

有效地址被计算为:

1
Imm + rb + ri * s

访问数据

mov是使用最为频繁的指令之一。它的主要作用是把一个位置的数值转移到另一位置。

1
mov src, dst

其中src可能的情况为:

  • 立即数,如$0x4321
  • 寄存器,如%rax
  • 内存,如(%rdx,%rcx)

其中dst可能的情况为:

  • 寄存器
  • 内存

值得注意的是,我们不可以直接在两个内存地址间直接传输数据。我们只能先将数据拷贝到寄存器中, 然后再从寄存器中将数据传送到内存中。

位拓展

当我们将较小的值传送到较大的值时,如将16位的值传送到32位中,我们便需要对高位进行处理。

  • movz 将剩余字节填充为0
  • movs 将设关于字节填充为最高位

注意没有将4字节0拓展为8字节的指令,但根据我们的特殊规则,生成4字节的指令会把高4字节置为0。

压栈和出栈

栈可以被理解为一个向下增长的数组,栈顶的地址是最低的。它遵循后进先出(LIFO)的原则,即被弹出的值永远是最近被压入而且仍然在栈中的值。

在16个寄存器中,%rsp(stack pointer)专门负责保存栈顶的值。

push

push的作用是把数据压到栈上,只有一个操作数。它的具体行为是:先减去栈指针的值,然后将压入数据的值写入栈顶。

1
2
3
4
pushl $0x1234
// 等价于
sub $0x4, %rsp
movl $0x1234 (%rsp)

pop

pop的作用是把数据从栈上弹出,只有一个操作数。他的具体行为是:把栈顶数据写入操作数,然后增加栈指针的值。

1
2
3
4
popq %rax
// 等价于
movq (%rsp), %rax
addq $8, %rsp

控制

条件码

除了上面所提到的整数寄存器,CPU还维护着一组单个位的条件码寄存器。他们描述了最近的算术或逻辑操作的属性。

条件码 描述
CF 进位标志
ZF 零标志
SF 结果为负数
OF 补码溢出

跳转

正常情况下,指令会按照顺序执行。而跳转指令会导致执行切换到程序中一个全新的位置。

指令 描述
jmp Label 直接跳转
jmp *Operand 间接跳转
je 相等
jne 不相等
js 负数
jns 非负数
jg 大于
jge 大于或等于
jl 小于
jle 小于或等于
ja 超过
jae 超过或等于
jb 低于
jbe 低于或相等
跳转指令的编码

跳转指令有多种编码形式,其中最常用的都是PC相对的。也就是说, 它们会将目标指令的地址与紧跟在跳转指令后面的那条指令的差值作为编码。

举个例子(p140):

1
2
3
4
5
6
7
8
	movq %rdi, %rax
	jmp .L2
.L3:
	sarq %rax
.L2:
	testq %rax, %rax
	jg .L3
	ret

反汇编如下:

1
2
3
4
5
6
0: 48 89 f8 mov %rdi, %rax
3: eb 03    jmp 8 <loop+0x8>
5: 48 d1 f8 sar %rax
8: 48 85 c0 test %rax, %rax
b: 7f f8    jg 5 <loop+0x5>
d: f3 c3    retq

可以看到,第一条跳转指令的编码为eb 03,它的目标编码为0x3。此跳转指令下的指令地址为5,所以跳转目标地址为3 + 5 = 8。 类似地,第二条跳转指令的编码为7f f8,它的目标编码为f8,**注意是补码,表示十进制的-8。此跳转指令下的地址为0xd(13),所以跳转目标地址为-8 + 13 = 5

这些例子说明,在执行PC相对寻址时,程序计数器(%rip)的值不是跳转地址本身的值,而是跳转指令下面的那条指令的地址。