CSAPP 第三章 程序的机器级表示

3.2 程序编码

有两个文件p1.cp2.c,我们用Unix命令编译这些代码

1
gcc -Og -o p p1.c p2.c

gcc:指GCCC编译器

-Og:告诉编译器使用生成符合原始C语言整体结构的机器代码的优化等级,使用-O1,-O2会导致产出的机器代码变形,难以理解。

gcc命令调用了一整套的程序,将源代码转化成可执行代码。

首先,C预处理器扩展源代码,插入所有用#include命令指定的文件,并扩展所有用#define声明指定的宏。

其次,编译器产出两个源文件的汇编代码,名字为p1.sp2.s

接下来汇编器会将汇编代码转化成二进制目标文件p1.op2.o

main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <stdio.h>

void mulstore(long, long, long*);

int main(){
long d;
mulstore(2, 3, &d);
printf("2 * 3 --> %ld\n", d);
return 0;
}

long mult2(long a, long b){
long s = a * b;
return s;
}

mstore.c

1
2
3
4
5
6
long mult2(long,long);

void mulstore(long x, long y, long *dest){
long t = mult2(x, y);
*dest = t;
}

编译生成汇编文件mstore.s

1
gcc -Og -S mstore.c

使用vim打开

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
	.section	__TEXT,__text,regular,pure_instructions
.build_version macos, 12, 0 sdk_version 12, 3
.globl _mulstore ## -- Begin function mulstore
.p2align 4, 0x90
_mulstore: ## @mulstore
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
pushq %rbx
pushq %rax
.cfi_offset %rbx, -24
movq %rdx, %rbx
callq _mult2
movq %rax, (%rbx)
addq $8, %rsp
popq %rbx
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols

其中以.开头的行都是指导汇编器和链接器工作的伪指令,直接忽略

可以简化为:

1
2
3
4
5
6
7
mulstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
retq

pushq %rbx:将寄存器rbx的值压入程序栈进行保存

为什么程序一开始要保存rbx 的内容

我们先了解调用者保存寄存器和被调用者保存寄存器

假如函数A调用了函数B,所以函数A就称为调用者(Caller),函数B为被调用者(Callee

由于调用了函数B,寄存器rbx在函数中被修改了,逻辑上寄存器rbx的内容在调用函数B的前后应该保持一致,解决这个问题有两个策略,

第一,调用者保存:函数A在调用函数B之前,提前保存寄存器rbx的内容,执行完函数B之后恢复寄存器rbx原来存储的内容

第二,被调用者保存:函数B在使用寄存器rbx之前,先保存寄存器rbx的值,在函数B返回之前,先回复寄存器rbx原来存储的内容

popq %rbx:在函数返回之前,恢复寄存器rbx的内容

第二行:movq %rdx, %rbx:将寄存器rdx的内容复制到寄存器rbx

根据寄存器用法的定义

1
2
void mulstore(long x, long y, long *dest);
x->%rdi, y->%rsi, dest->%rdx

mov指令的后缀"q"表示数据的大小

Size of Data Type in x86-64

C 声明 Inter 数据类型 汇编代码后缀 大小(字节)
char Byte b 1
short Word w 2
int Double word l 4
long Quad word q 8
char * Quad word q 8
float Single precision(单精度) s 4
double Double precision(双精度) l 9

call mult2:对应函数调用,该函数的返回值会保存到寄存器rax

movq %rax, (%rbx):将寄存器rax的值送到内存中,内存的地址就存放在寄存器rbx

ret:就是函数返回

翻译为机器代码 mstore.o

1
gcc -Og -c mstore.c

由于该文件是二进制的无法直接查看,我们需要用到反汇编工具----objdump

1
objdump -d mstore.o
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
mstore.o:	file format mach-o 64-bit x86-64

Disassembly of section __TEXT,__text:

0000000000000000 <_mulstore>:
0: 55 pushq %rbp
1: 48 89 e5 movq %rsp, %rbp
4: 53 pushq %rbx
5: 50 pushq %rax
6: 48 89 d3 movq %rdx, %rbx
9: e8 00 00 00 00 callq 0xe <_mulstore+0xe>
e: 48 89 03 movq %rax, (%rbx)
11: 48 83 c4 08 addq $8, %rsp
15: 5b popq %rbx
16: 5d popq %rbp
17: c3 retq

3.3 数据格式

大多数指令有一个或多个操作数,指示出执行一个操作中要使用的源数据值,以及放置结果的目的位置。

各种不同的操作数的被分为三种类型:

  • 立即数:在ATT格式的汇编代码中是$后面跟一个用标准C表示法表示的整数
  • 寄存器:
  • 内存引用:\(M_b[addr]\)

内存引用:

Imm(\(r_b,r_i,s\)) ----> Imm + \(R[r_b] + R[r_i]*s\)

Imm ----> 立即数

\(r_b\)----> 基址寄存器

\(r_i\)---->变址寄存器

s ----> 比例因子【1,2,4,8】

编译器会根据数组类型来确定比例因子的数值

Mov指令

MOV 源操作数 目的操作数

源操作数可以为:立即数,寄存器,内存

目的操作数可以为:寄存器,内存,但是不能为立即数

MOV指令的源操作数和目的操作数不能都是内存的地址

当需要从内存一个地方复制到另一个地方时,需要用两条mov指令

mov memory , register

mov register, memory

MOV指令的后缀需要与寄存器的大小匹配

3.4 栈与数据传送

long:64 --> %rax

int:32 --> %eax

short:16 --> %ax

低8位 --> %al

数据传送示例:

main.c

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

long exchange(long*, long);

int main(){
long a = 4;
long b = exchange(&a, 3);
printf("a = %ld, b = %ld\n", a, b);
return 0;
}

exchange.c

1
2
3
4
5
long exchange(long *xp, long y){
long x = *xp;
*xp = y;
return x;
}

编译exchange.c为汇编代码

1
gcc -Og -S exchange.c

去除多余没用信息后

1
2
3
4
exchange:
movq (%rdi), %rax
movq %rsi, (%rdi)
retq

%rdi保存函数的第一个参数,%rsi保存函数的第二个参数

所以可以这样理解

1
2
3
4
5
exchange:
xp in %rdi, y in %rsi
movq (%rdi), %rax //Memory --> Registor long x = *xp
movq %rsi, (%rdi) //Register --> Memory *xp = y
retq

栈顶是栈中地址最低的。

入栈push:

先将地址减小,再入栈

1
2
3
pushq %rax
subq $8, %rsp
movq %rax, (%rsp)

pushq就等于上面两条指令

出栈pop:

先出栈,再把地址增加

1
2
3
popq %rbx
movq (%rsp), %rbx
addq $8, %rsp

3.5 算术和逻辑操作

加载有效地址leaq

leaq:它的指令形式是从内存读数据到寄存器

1
leaq S, D

例如:

1
leaq 7(%rdx, %rdx, 4), %rax

表示:

假设 %rdx 的值为x

有效地址 = 7 + %rdx + %rdx * 4 = 5x + 7

目的操作数必须是一个寄存器

leaq指令还可以执行加法和有限形式的乘法

scale.c

1
2
3
4
long scalse(long x, long y, long z){
long t = x + 4 * y + 12 * z;
return t;
}

编译后形成的汇编代码为:

1
2
3
4
5
scale:                                 
leaq (%rdi,%rsi,4), %rax
leaq (%rdx,%rdx,2), %rcx
leaq (%rax,%rcx,4), %rax
retq

理解:

1
2
3
4
5
6
scale:
x in %rdi, y in %rsi, z in %rdx
leaq (%rdi,%rsi,4), %rax --> %rdi + 4 * %rsi = x + 4 * y --> %rax
leaq (%rdx,%rdx,2), %rcx --> %rdx + 2 * %rdx = z + 2 * z --> %rcx
leaq (%rax,%rcx,4), %rax --> %rax + 4 * %rcx = (x + 4 * y) + 4 * (z + 2 * z)
retq

因为比例因子只能为[1,2,4,8],所以需要将12拆分

一元和二元操作

一元操作数:即是源操作数又是目的操作数

二元操作数:

例如:

1
ADD S, D

第二个操作数(D)即是源又是目的操作数,第一个操作数可以是立即数,寄存器或是内存位置

第二个操作数可以是寄存器或是内存位置,但不能是立即数

移位操作

左移:

SAL和SHL,两者的效果一样,都是将右边填上0

右移:

SAR执行算术运算(填上符号位)

SHR执行逻辑运算(填上0)

3.6 控制

3.6.1 条件码

最常用的条件码:

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

执行XOR时,ZF = 0, OF = 0

INC(+1),DEC(-1)时,只会改变OF,ZF,不会改变CF

CMP和TEST指令也可以设置条件码

1
cmpq %rax, %rdx

cmp指令是根据两个操作数的差来设置条件码寄存器,并不会更新目的寄存器的值

test指令设置条件码寄存器,并不会更新目的寄存器的值

条件码的使用

setlsetb表示“小于时设置(set less)"和"低于时设置(set below)"

comp.c

1
2
3
int comp(long a, long b){
return (a == b);
}

编译为汇编代码:

1
gcc -Og -S comp.c
1
2
3
4
5
6
7
8
9
10
comp:
(a in %rdi, b in %rsi)
comq %rsi, %rdi --> a - b,当 a = b 时,ZF = 1
sete %al
(
if ZF = 1, %al = 1
if ZF = 0, %al = 0

movzbl %al, %eax --> 对寄存器al进行零扩展
ret
1
2
3
int comp(char a, char b){
return (a < b);
}

汇编为:

1
2
3
4
5
comp:
comb %sil, %dil
setl %al
movzbl %al, %eax
ret

a < b --> SF ^ OF

t = a - b

Case1: a < b, t < 0, SF = 1, SF ^ OF = 1

Case2: a > b, t > 0, SF = 0, SF ^ OF = 0

Case3: a < b, a = -2, b = 127

a = 1111 1110

b = 0111 1111

t = a - b

a = 1111 1110

(-b) = 1000 0001(按位取反,末位+1)

a + (-b) = 1 0111 1111

t = 127 > 0, SF = 0, OF = 1, SF ^ OF = 1

综上:

对于有符号数的比较,采用的是SF和ZF的组合

对于无符号数的比较,采用的是CF和ZF的组合

3.6.2 访问条件码

常用的使用方式:

  • 可以根据条件码的某种组合,将一个字节设置为0或者1,2
  • 可以条件跳转到程序的某个其他的部分
  • 可以有条件地传送数据

3.6.3 跳转指令

jump.c

1
2
3
4
5
6
7
8
9
long absDiff_se(long x, long y){
long res;
if(x < y){
res = y - x;
}else{
res = x - y;
}
return res;
}
1
2
3
4
5
6
7
8
9
10
11
12
absDiff_se:
(x in %rdi, y in %rsi)
cmpq %rsi, %rdi --> set SF, OF
j1 .L4 --> SF ^ OF
movq %rdi, %rax
subq %rsi, %rax --> x - y
ret

.L4:
movq %rsi, %rax
subq %rdi, %rax --> y - x
ret

另一种实现方式,效率更高:

1
2
3
4
5
6
7
8
long comvdiff_se(long x, long y){
long rval = y - x;
long eval = x - y;
long ntest = x >= y;
if(ntest)
rval = eval;
return rval;
}
1
2
3
4
5
6
7
8
9
cmovdiff_se:
(x in %rdi, y in %rsi)
movq %rsi, %rdx
subq %rdi, %rdx --> rval = y - x
movq %rdi, %rax
subq %rsi, %rax --> eval = x - y
cmpq %rsi, %rdi
cmovge %rdx, %rax --> ~(SF ^ OF)
ret

为什么基于条件传送的代码会比基于跳转指令的代码效率高?

处理器会根据分支预测器来猜测每条跳转指令是否执行,

当发生错误预测时,会浪费大量的时间,导致程序性能严重下降(流水线相关)