2018-2019-1 20189206 《深入理解计算机系统》
教材学习内容总结
计算机执行机器代码,用字节序列编码低级的的操作,包括处理数据、内存管理、读写存储设备上的数据,以及利用网络通信。GCC语言汇编器以汇编代码的形式输出,汇编代码是机器代码的文本表示,给出程序中的每一条指令。然后用GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。
用高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的
第三章 程序的机器级表示
程序编码
gcc main.c -o main
实际上gcc命令调用了一整套程序,将源码转换成可执行代码
- 预处理 插入#include命令指定的文件
- 编译 生成汇编文件
- 汇编 将汇编生成二进制代码
- 链接 目标代码文件与实现库合并
机器级编程
对于机器级编程来说,有两种抽象:
- 指令级体系结构或指令级架构 (instruction set architecture ISA)定义机器级程序的格式和行为
- 处理器状态
- 指令的格式
- 每条指令对状态的影响
- 机器级程序使用的内存地址是虚拟地址,提供的内存模型看上去是一个非常大的字节数组。
一些对C语言隐藏的处理器都是可见的:
- 程序计数器(PC) 给出将要执行的下一条指令在内存中的地址
- 整数寄存器文件 包含16个命名的位置,分别存储64位的值
- 条件码寄存器 保存着最近执行的算术或逻辑指令的状态信息
操作系统负责管理虚拟地址空间,将虚拟地址翻译成实际处理内存中的物理地址。
一些机器级代码和它的反汇编表示的特性值得注意:
- x86-64的指令长度从1~15字节不等
- 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码, 不会访问程序的源代码或汇编代码。
- 生成可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数
数据格式
用 “字” 表示16位数,“双字”表示32位数,“四字”称为64位数
访问信息
一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器用来存储整数数据和指针。
x86-64的寄存器都是64位,标号从%rax
到%rbp
,还增加了8个新寄存器,从%r8
到%r15
操作数指示符
多数指令会有一个或多个操作数,其类型包括三种:
- 立即数 表示常数值,用$后面跟一个整数表示
- 寄存器 表示某个寄存器的内容
- 内存引用 会根据计算出来的地址访问某个内存位置
数据传送指令
将数据从一个位置复制到另一个位置的指令。
源操作数指定的值是一个立即数,存储在寄存器或者内存中。目的操作数指定一个位置,可以是寄存器或者内存地址。
规定传送指令的两个操作数不能都指向内存位置
当将较小的源值复制到较大的目的时使用零扩展数据传送指令。
- MOVZ 把目的中剩余的字节填充为0
- MOVS 通过符号扩展来进行填充,把源操作的最高位进行复制
“指针”其实就是地址。
间接引用指针就是将该指针放入寄存器中,然后在内存引用中使用这个寄存器,就可以访问到这个指针指向的数据。
压入和弹出栈数据
程序栈存放在内存中的某个位置,向下增长,栈顶元素的地址就是所有栈中元素最低的。其中寄存器%rsp
指向栈顶元素的地址。
-
pushq 实现将数据压入栈
- 首先将栈顶指针地址减8
- 将数据写入栈顶地址指向的位置
-
popq 实现将数据弹出栈
- 先将栈顶指针指向的数据取出
- 栈顶指针地址加8
【说明】
地址减8 :
64位的操作系统中,一次可以存储64/8=8字节,即4字 8字节
pushq命令 将4字压入栈中,
算数和逻辑操作
每个指令类都对应有四种不同大小数据的指令。
加载有效地址
指令leaq(load effective address)命令实际上是movq指令的变形。
指令中第一个操作数是将有效地址写入到目的操作数,同时目的操作数必须是一个寄存器
特殊的算数操作
上图描述的是支持产生两个64位数字的全128位乘积以及整数除法的指令。
- 无符号全乘法 mulq s
- 补码乘法 imulq s
- 上面两种乘法要求参数必须存在%rax中,另一个作为指令的源操作数给出,乘积存放在%rdx(高64位)%rax(低64位)中
- 有符号除法 idivl s 将寄存器%rdx和%rax中的128位数作为被除数,除数在指令的操作数中给出,最后结果放在%rax中。
条件码
-
CF:进位标志 最近的操作使得最高位产生了进位
- 可以用来检测无符号操作的溢出
-
ZF:零标志 最近的操作得出结果为0
-
SF:符号标志 最近的操作得到的结果为负数
-
OF:溢出标志 最近的操作导致一个补码溢出——正溢出或负溢出
-
leaq指令 不改变任何条件码,用来进行地址计算
以上这些指令会设置条件码。
- CMP指令根据两个操作数之差来设置条件码,而不更新寄存器,除此之外和SUB指令的效果是一样的
- 如果两个操作数相等,会将ZF置1
- TEST指令的行为与AND一样,除了只设置条件码而不改变寄存器的值
访问条件码
条件码不会被直接读取,一般常使用的方法有三种:
SET指令:
- 可以根据条件码的某种组合,将一个字节设置为0或1
- 可以跳转到程序的某个其他部分
- 可以有条件地传送数据
一条SET指令的目的操作数是低位单字节寄存器元素之一,或者是一个字节的内存位置。指令会将这个字节设置为0或1。
跳转指令
jmp无条件跳转指令
- 直接跳转 跳转目标是作为指令的一部分编码
- .L1 就是一种直接跳转
- 间接跳转 跳转目标是从寄存器或内存位置读出的
- 写法是 * 后面跟一个操作数指示符
其他的指令都是有条件的,根据条件码的某种组合,或者跳转,或者执行代码序列中的下一条指令。
跳转指令的编码
- PC相对码 将目标指令的地址与紧跟在跳转指令之后的那条指令的地址之间的差作为编码
- 绝对地址 用4个字节直接指定目标
汇编器和链接器会选择适当的跳转目的编码。
条件控制来实现条件分支
对于if-else语句,汇编实现通常采用对then-statement和else-statement产生各自的代码块。
条件传送来实现条件分支
使用数据的条件转移,这种方法计算一个条件操作数的两种结果,然后根据条件是否满足从中选取一个。
处理器通过流水线获得高性能,在流水线中,一条指令要经过一系列的阶段,每个阶段执行所需操作的一小部分。通过重叠连续指令的步骤来获得高性能。
当机器遇到条件跳转时,只有当分支条件求值完成之后,才能决定分支的走向。处理器通过分支预测逻辑来猜测每条指令是否会执行。错误预测一个跳转处理器要求它丢掉为该跳转指令后所有的已做工作,然后从正确位置起始的指令去填充流水线。
条件传送与条件跳转不同,处理器无需预测结果就可以执行条件传送。处理器只是读取源值,检查条件码,然后更新或者保持目的寄存器不变。
v = test-expr ? then-expr : else-expr;
利用条件控制转移,可以表示为:
if(!test-expr)
goto false;
v = then-expr;
goto done;
fales:
v = else-expr;
done:
利用条件转移则可以表示为:
v = then-expr;
ve = else-expr;
t = test-expr;
if(!t)v = ve;
循环
do-while循环
语句格式
do
body-statement
while(test-expr);
可以被翻译成
loop:
body-statement
t = test-expr;
if(t)
goto loop;
while循环
语句格式
while (test-expr)
body-statement
区别于do-while循环,在第一次执行body-statement
之前,会对条件进行检验,循环有可能终止,采用两种不同的翻译方法。
跳转到中间
goto test;
loop:
body-statement
test:
t = test-expr;
if(t)
goto loop;
guarded-do
首先用条件分支,如果初始条件不成立就跳过循环,把代码换成do-while循环。
t = test-expr;
if(!t)
goto done;
loop:
body-statement;
t = test-expr;
if(t)
goto loop;
done:
for循环
语句格式
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;
guarded-do策略得到
init-expr;
t = test-expr;
if(!t)
goto done;
loop:
body-statement;
update-expr;
t = test-expr;
if(t)
goto loop;
done:
switch语句
跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当索引值i时程序应该采取的动作。当开关的情况比较多,值的跨度范围较小时,就会使用跳转表。
执行switch语句的关键时通过跳转表来访问代码位置。
采用间接跳转jmp *.L4(,%rsi,8)
即先找到.L4然后向后找8个字节。
运行时栈
x86-64过程需要的存储空间超出寄存器大小后,会在栈上分配空间,这个过程称为栈帧
以过程P调用Q,Q执行后返回P为例
P调用Q会将Q返回后下一条执行的P的代码作为P栈帧的一部分。Q则会扩展当前栈的边界,分配其栈帧所需要的空间。
转移控制
- call Q 将地址A压入栈中,并将PC设置为Q的起始地址。
- 地址A是返回地址,即跟在call指令之后的下一条指令
数据传送
过程Q执行结束返回P过程时,P的代码可以访问寄存器%rax中的返回值。
x86-64中,通过寄存器最多传递6个整型参数,寄存器的使用是有特殊顺序的,寄存器使用的名字则取决于要传递的数据类型的大小,会根据参数在参数列表中的顺序为它们分配寄存器。
寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。我们需要确保一个过程(调用者)调用另一个过程(被调用者)时,被调用者不会覆盖调用者稍后会使用的寄存器的值。
寄存器 %rbx %rbp 和 %r12 ~ %r15被划分为被调用者保存寄存器 当过程P调用过程Q时,Q必须保存这些寄存器的值,保证在Q返回到P时不变。
所有其他的寄存器,除了栈指针%rsp都被分类为调用者保存寄存器,任何函数都可以修改它们。过程P在某个此类寄存器中有局部变量时,调用Q,Q可以随意修改这个寄存器,保存数据是P的责任
指针运算
嵌套数组
对于T D[R][C] 即一个R行C列的二维数组
L是类型为T的字节为单位的大小,则元素D[i][j]的内存地址为:
将5*3二维数组A[i][j]复制到寄存器%eax中的汇编代码为:
A-%rid i-%rsi j-%rdx
leaq (%rsi,%rsi,2),%rax
leaq (%rdi,%rax,4),%rax
movl (%rax,%rdx,4),%eax
联合
允许以多种类型来引用一个对象。
union U3{
char c;
int i[2];
double v;
};
对于union U3 * 的指针p,p->c、p->i[10]和P->v引用的都是数据结构的起始位置。同时一个联合的大小总是等于它的最大字段的大小。
函数指针
int (*fp)(int ,int *);
代表fp是一个函数指针,形参为int 和int * ,同时该函数的返回值为int类型
int * f(int *);
代表f为一个函数,形参为int * ,同时返回值为int *类型
缓冲区溢出
通常在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超出了数组分配的空间。超出以后,返回指针的值以及更多的保存状态会被破坏。如果存储的返回地址为破坏了,那么ret指令可能会导致程序转跳到一个意想不到的位置。
对抗缓冲区溢出攻击
-
栈随机化
- 攻击者在代码中插入代码的指针,而栈随机化使得栈的位置在程序每次运行时都有所变化。
- 实现方式:程序开始时在栈上分配一段0~n字节之间的随机大小的空间,程序不使用这段空间,会导致程序执行后续栈的位置发生变化
-
地址空间布局随机化
-
栈破坏检测
- 在栈帧中任何局部缓冲区和栈状态之间存储一个特殊的金丝雀值,在恢复寄存器状态和从函数返回前,检查该值是否改变
- 在栈帧中任何局部缓冲区和栈状态之间存储一个特殊的金丝雀值,在恢复寄存器状态和从函数返回前,检查该值是否改变
-
限制可执行代码的区域
变长帧栈
%rbp作为帧指针(栈底指针),是一个被调用者保存寄存器,只有在栈帧长可变的情况下,才会使用%rbp栈基址指针。
浮点代码
AVX浮点体系结构允许数据存储在16个YMM寄存器中,它们的名字为%ymm0~%ymm15。每个YMM寄存器都是256位,对标量数据操作,只保存浮点数,只使用低32位或64位,使用%xmm来引用它们。
浮点传送指令
浮点和整数之间相互转化
过程中的浮点代码
XMM寄存器用来向函数传递浮点参数,以及从函数返回浮点值。
- XMM寄存器%xmm0~%xmm7最多可以传递8个浮点参数。
- 函数使用%xmm0来返回浮点值
- 所有XMM寄存器都是调用者保存