第3章 程序的机器级表示
参考资料
- 《深入理解计算机系统》学习笔记整理(CSAPP 学习笔记) (opens new window)
- 汇编指令学习 (opens new window)
- 第三章3.2-3.3的笔记-P115-P128 (opens new window)
程序的机器级表示
编译器基于编程语言的规则、操作系统的惯例、目标机器的指令集生成机器代码。
汇编代码是机器代码的一种形式,它是机器代码的文本表示。
高级代码可移植性好,而汇编代码与特定机器密切相关。
能够阅读汇编代码:
- 好处:可以理解编译器的优化能力,并分析代码中隐含的低效率
- 条件:了解编译器将高级语言转换为机器代码的转换方式。
程序编码
汇编器产生的目标代码是机器代码的一种形式,它包含二进制形式表示的所有指令,但还没有填入全局值的地址。
机器级代码
影响机器级程序的两种抽象:
指令集架构:定义了处理器状态、指令的格式、指令对状态的影响。
虚拟地址:机器代码将内存看成一个按字节寻址的数组。
对机器代码可见的处理器状态:
- 程序计数器:给出下一条指令在内存中的地址
- 整数寄存器文件:保存临时数据或重要的程序状态
- 条件码寄存器:保存最近执行的算术或逻辑指令的状态信息。
- 一组向量寄存器:保存一个或多个整数或浮点数值
虽然C 语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是机器代码只是简单地将内存看成一个很大的、按字节寻址的数据。
在机器代码中用一组连续的字节来表示。 汇编代码不区分有符号数和无符号数,不区分指针的不同类型,不区分指针和整数。 一条机器指令只执行一个非常基本的操作。
代码示例
假设C语言代码文件mstore.c,包含如下的函数定义:
long mult2(long,long)
void multstore(long x,long y,long *dest){
long t = mult2(x,y)
*dest = t;
}
2
3
4
5
6
使用"-S"选项,可以看到C语言编译器生成汇编代码:
[root@VM-0-10-centos csapp]# gcc -Og -S mstore.c
mstore.s的简化内容如下:
multstore:
pushq %rbx
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
ret
2
3
4
5
6
7
使用"-c"选项,GCC会编译并汇编该代码,生成目标代码文件mstore.o:
[root@VM-0-10-centos csapp]# gcc -Og -c mstore.c
使用反汇编器可以根据机器代码产生汇编代码(objdump):
[root@VM-0-10-centos csapp]# objdump -d mstore.o
mstore.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <multstore>:
0: 53 push %rbx
1: 48 89 d3 mov %rdx,%rbx
4: e8 00 00 00 00 callq 9 <multstore+0x9>
9: 48 89 03 mov %rax,(%rbx)
c: 5b pop %rbx
d: c3 retq
2
3
4
5
6
7
8
9
10
11
12
13
14
机器代码与反汇编表示的特性:
x86-64 的指令长度范围为 1~15 字节。常用指令和操作数少的指令所需字节少。
从十六进制字节值到汇编指令,格式为:某个数字唯一地对应某个汇编指令,比如 mov 指令以 48 开头,push指令以53开头。指令结尾的 'q' 是大小指示符,大多数情况下可以省略。
从源程序转换来的可执行目标文件中,除了程序过程的代码,还包含启动和终止程序的代码,与操作系统交互的代码。
链接器的任务之一就是为函数调用找到匹配的函数的可执行代码的位置。
关于格式的注解
mstore.s的完整内容如下:
.file "mstore.c"
.text
.globl multstore
.type multstore, @function
multstore:
.LFB0:
.cfi_startproc
pushq %rbx
.cfi_def_cfa_offset 16
.cfi_offset 3, -16
movq %rdx, %rbx
call mult2
movq %rax, (%rbx)
popq %rbx
.cfi_def_cfa_offset 8
ret
.cfi_endproc
.LFE0:
.size multstore, .-multstore
.ident "GCC: (GNU) 9.1.0"
.section .note.GNU-stack,"",@progbits
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
在汇编代码中,以 ‘.’ (点) 开头的行是指导汇编器和链接器工作的伪指令。
数据格式
字节:byte,8位;字:word,16位;双字:double words,32位;四字:quad words,64位。 对应的指令后缀:movb, movw, movl, movq。 这里说的都是整数,浮点数使用一组完全不同的指令和寄存器。
访问信息
一个x86-64 位的中央处理单元(CPU )中包含一组 16 个存储 64 位值的通用目的寄存器,用来存储整数和指针。
- 16 个寄存器标号为 rax~rbp,r8~r15
- 16 个寄存器的低位部分都可以作为字节、字、双字、四字来单独访问。分别表示为 al, ax, eax, rax。
备注:调用参数超过6个,就需要在栈上申请空间存储参数。
低位操作的规则:
- 将寄存器作为目标位置时,生成1字节和2字节的指令会保持剩下的字节不变
- 生成4字节的指令会把高位4字节置为 0.
16个寄存器的作用
rax:返回值
rsp:栈指针
rdi, rsi, rdx, rcx, r8, r9:第 1 到第 6 个参数
rbx, rbp, r12~r15:被调用者保存
r10, r11:调用者保存
操作数指示符
指令的操作数有三种类型:
立即数:书写方式是$后面跟一个用标准C表示法表示的整数,例如$-20或$0x10
寄存器:它表示某个寄存器的内容,用寄存器标识符作为索引,例如R[ra]
内存引用: 根据计算出来的地址访问某个内存位置,最常用的寻址方式:Imm(rb, ri, s):Imm + rb + ri*s,s 为比例因子,只能是 1,2,4,8 中的某一个
操作数格式如下:
加深理解:
假设下面的值存放在指明的内存地址和寄存器中:
答案:
%rax对应0x100, 0x104对应0xAB, $0x108对应0x108, (%rax)对应0xFF, 4(%rax)对应0xAB, 9(%rax,%rdx)对应0x11
260(%rcx,%rdx)对应0x13, 0xFC(,%rcx,4)对应0xFF, (%rax,%rdx,4)对应0x11
讲解一下260(%rcx,%rdx),因为260=0x104,所以操作数是0x104+0x1+0x3=0x108地址对应的值,是0x13
2
3
4
5
数据传送指令
最简单形式的数据传送指令——MOV类,将数据从源位置复制到目的位置,不做任何变化。
mov 类有 5 种:
- movb, movw, movl:传送字节、字、双字
- movq:传送四字。如果源操作数是立即数,只能是双字,然后符号扩展到四字(假的四字)
- movabsq:传送绝对的四字。只能以立即数作为源操作数,以寄存器为目的。可以传送任意 64 位立即数。
规则:
movq 用来传送寄存器和内存引用中的四字,movabsq 用来传送四字的立即数
mov 类的源操作数和目的操作数不能同时为内存,即不能将值从内存复制到内存。
mov 指令中寄存器的大小必须与 mov 的后缀字符大小匹配。movb $-17, %al
movz类
- movz 系列和 movs 系列可以把较小的源值复制到较大的目的,目的都是寄存器。
- movz 将目的寄存器剩余字节做零扩展,movs 做符号扩展
- movz类:movzbw, movzbl, movzbq, movzwl, movzwq(movzbw 即从字节复制到字,其他类似)
- movs类:movsbw, movsbl, movsbq, movswl, movswq, movslq, cltq
- cltq:没有操作数,将 eax 符号扩展到 rax,等价于 movslq %eax,%rax
数据传送示例
局部变量通常保存在寄存器中。 函数返回指令 ret 返回的值为寄存器 rax 中的值
压入和弹出栈数据
栈向下增长,栈顶的地址是栈中元素地址中最低的。栈指针 rsp 保存栈顶元素的地址。 出入栈指令:
- pushq rax:压栈,栈指针减 8 并将 rax 中的值写入新的栈顶地址,等价于:subq $8, (rsp) ; movq rax,(rsp)。
- popq rax:出栈,栈指针加 8 并将出栈的值写入 rax 中,等价于:movq (rsp),rax ; add $8,(rasp)
- 使用 mov 指令和标准的内存寻址方法可以访问栈内的任意位置,而非仅限于栈顶。
算术和逻辑操作
x86-64 的每个指令类都有对应四种不同大小数据的指令。
算术和逻辑操作共有四组:
加载有效地址
leaq 实际上是 movq 指令的变形。操作是从内存读数据地址到寄存器。
leaq 在实际应用中常常不用来取地址,而用来计算加法和有限形式的乘法
leaq 7(%rdx, %rdx, 4), %rax;//将设置寄存器%rax的值为5x + 7
进一步,举例说明:
一元和二元操作
一元操作中的操作数既是源又是目的。 二元操作中的第二个操作数既是源又是目的。 因为不能从内存到内存,因此当第二个操作数是内存地址时,要先从内存读出值,执行操作后再把结果写回去。
移位操作
移位操作的移位量可以是一个立即数或放在单字节寄存器 %cl 中。 当移位量大于目的数的长度时,只取移位量低字节中的值(小于目的数长度)来作为真实的移位量。
特殊的算术操作
两个 64 位数的乘积需要 128 位来表示,x86-64指令集可以有限的支持对 128 位数的操作,包括乘法和除法,Intel把16字节的数称为八字(oct word)。(乘积存放在寄存器%rdx(高64位)和%rax(低64位)中)
128 位数需要两个寄存器来存储,移动时也需要两个 movq 指令来移动。
这种情况对于有符号数和无符号数采用了不同的指令。
支持产生两个64位数字的全128位乘积以及整数除法的指令:
控制
条件语句、循环语句、分支语句都要求有条件的执行。
机器代码提供两种低级机制来实现有条件的行为:
- 测试数据值,然后根据测试的结果来改变控制流或数据流
- 使用 jump 指令进行跳转
条件码
条件码寄存器都是单个位的,是不同于整数寄存器的另一组寄存器。
条件码描述了最近的算术或逻辑操作的属性,可以通过检测这些寄存器来执行条件分支指令。
常用条件码:
- CF:进位标志。最近的操作使最高位产生了进位。可以用来检查无符号数的溢出
- ZF:零标志。最近的操作的结果为 0
- SF:符号标志。最近的操作的结果为负数。
- OF:溢出标志。最近的操作导致了补码溢出——正溢出或负溢出
除了 leaq 指令外,其余的所有算术和逻辑指令都会根据运算结果设置条件码。
此外还有两类特殊的指令,他们只设置条件码不更新目的寄存器:
- cmp s1, s2:除了不更新目的寄存器外与 sub 指令的行为相同
- test s1, s2:除了不更新目的寄存器外与 and 指令的行为相同
访问条件码
条件码一般不直接读取,常用的使用方法有 3 种:
- 根据条件码的某种组合,使用 set 指令类将一个字节设置为 0 或 1。
- 可以条件跳转到程序的某个其他部分
- 有条件地传送数据
set 指令类
set 指令的目的操作数是低位单字节寄存器元素或一个字节的内存位置。set 会将该字节设置为 0 或 1
set 指令类的后缀指明了所考虑的条件码的组合,如 setl (set less) 表示“小于时设置”。
set指令集合如下:
注意到上图中,set 指令对于大于、小于的比较分为了有符号和无符号两类。
大多数时候,机器代码对无符号和有符号两种情况使用一样的指令。
使用不同指令来处理无符号和有符号操作的情况:
- 不同的条件码组合:
- 不同版本的右移:sar 和 shr
- 不同的乘法和除法指令
汇编语言中数据本身不区分有符号和无符号,通过不同的指令来区分有符号操作和无符号操作。
跳转指令
跳转指令会导致执行切换到程序中一个全新的位置,这些跳转的目的地通常用一个标号指明。
示例代码:
movq $0,%rax
jmp .L1 ;
movq (%rax),%rdx
.L1:
popq %rdx
2
3
4
5
jmp 可以是直接跳转,即操作数为标号。也可以间接跳转,即操作数是寄存器或内存引用,这种情况下跳转到寄存器中存储的地址处。
跳转指令分为有条件跳转和无条件跳转,只有 jmp 是无条件跳转。有条件跳转都只能是直接跳转。
有条件跳转类似 set 指令系列,根据条件码寄存器的值来判断是否进行跳转。
jump的指令集合如下:
跳转指令的编码
跳转指令的机器编码(就是纯粹数字表示的机器语言)有几种方式,其中两种如下:
PC 相对跳转:使用目标地址与跳转指令之后下一条指令的地址之间的差来编码。可以用 1、2 或 4 个字节来编码。
绝对地址编码:使用目标的绝对地址。用 4 个字节直接指出。
汇编器和链接器会自己选择适当的编码方式。
用条件控制来实现条件分支
汇编代码层面的条件控制类似于 c 语言的 goto 语句。
汇编语言使用条件码和条件跳转来起到和 c 语言中 if 相似的作用
用条件传送来实现条件分支
实现条件操作的传统方法是通过使用控制的条件转移。当条件满足时,程序沿着一条执行路径执行,而当条件不满足时,就走另外一条路径。这种机制简单而通用,但是在现代处理器上,它可能会非常低效。
一种替代的策略是使用数据的条件转移。这种方法计算一个条件操作的两种结果,然后根据条件是否满足从中选取一个。
为了理解为什么基于条件数据传送的代码会比条件控制转移的代码性能要好?
—— 处理器通过使用流水线来获得高性能,在流水线中,一条指令的处理要经过一系列的阶段,每个阶段执行所需操作的一小部分(例如,从内存取指令、确定指令类型、从内存读数据、执行算术运算、向内存写数据,以及更新程序计数器)。这种方法通过重叠连续指令的步骤来获取高性能。
当机器遇到条件跳转,只有当分支条件求值完成后,才能决定分支往哪边走。(分支预测错误会带来性能的严重下降)
在一个典型的应用中,x < y 的结果非常地不可预测,仅有50%概率,从而导致每次调用的平均时钟周期会变大。
条件传送指令集如下:
循环
C 语言提供了多种循环结构,即 do-while、while 和 for,汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。
如通用 do-while 形式
do
body-statement
while(test-expr)
2
3
可以通过下面组合完成
loop:
body-statement
t = test-expr;
if (t)
goto loop;
2
3
4
5
switch
switch 通过一个整数索引值进行多重分支,处理具有多种可能结果的测试时特别有用:
- 不仅提高了代码的可读性,
- 通过跳转表使得实现更加高效。
- 使用跳转表的优点是执行开关语句的时间与开关数量无关。
跳转表是一个数组,表项 i 是一个代码段的地址,当开关索引等于 i 时进行此部分代码段的操作。
过程
过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。
然后,可以在程序中不同的地方调用这个函数。设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会对程序状态产生什么样的影响。
不同编程语言中,过程的形式多种多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等等。
假设过程P调用过程Q,Q执行完后返回到P:
- 传递控制。在进入过程Q的时候,程序计数器必须被设置为Q的代码的起始地址,然后在返回时,要把程序计数器设置为p中调用Q后面那条指令的地址。
- 传递数据。P必须能够向Q提供一个或多个参数,Q必须能够向p返回一个值。
- 分配和释放内存:在开始时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间
运行时栈
程序可以用栈来管理它的过程所需要的存储空间,栈和程序寄存器存放着传递控制和数据、分配内存所需要的信息。
Q栈帧:Q的代码可以保存寄存器的值,分配局部变量空间
P中定义的变量要放在P的栈帧里。如果调用Q,把这些值再复制到寄存器中。
P最多传递六个整数值,如果多了,可以在调用Q之前把参数放在自己的栈帧里
转移控制
将控制从函数P转移到函数Q只需要简单地把程序计数器PC设置为Q的代码的起始位置。不过,当稍后从Q返回的时候,处理器必须记录好它需要继续P的执行的代码位置。
在X86-64机器中,这个信息是用 call Q 调用过程Q来记录的。该指令会把地址A压入栈中,并将PC设置为Q的起始地址。压入的地址A被称为返回地址,是紧跟在call指令后面的那条指令的地址。对应的指令ret会从栈中弹出地址A,并把PC设置为A。
下面给出的是call和ret指令的一般形式:
数据传送
当调用一个过程时,除了要把控制传递给它并在过程返回时再传递回来之外,过程调用还可能包括把数据作为参数传递,而从过程返回还有可能包括返回一个值。
在x86-64中,大部分过程间的数据传送是通过寄存器实现的,例如当过程P调用过程Q时,P的代码要把参数复制到适当的寄存器,多于6个放在自己栈帧里。类似地,当Q返回到P时,P的代码可以访问寄存器%rax中的返回值。
栈上的局部存储
大部分过程示例都不需要超出寄存器大小的本地存储区域。不过有些时候,局部数据必须存放在内存中,常见的情况包括:
- 寄存器不足存放所有的本地数据
- 对一个局部变量使用地址运算符&,因此必须为它产生一个地址
- 某些局部变量时数组或结构,因此必须能够通过数组或结构引用被访问到。
示例如下:
当P调用Q传递参数的时候,使用leaq语句,传递的是%rsp代表的地址
寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。虽然在给定时刻只有一个过程是活动的,但是我们必须确保:调用者调用被调用者时,被调用者不会覆盖稍后调用者会使用的寄存器。
被调用者保护寄存器,%rbx,%rbp,%r12~15, 实现方法:要么不去改变那个寄存器,要么把原始值压入栈中,改变寄存器,最后弹出原始值。
递归过程
每个过程调用在栈中都有它自己的私有空间,因此多个未完成调用的局部变量不变相互影响。此外,栈的原则很自然地就提供了适当的策略,当过程被调用时分配局部存储,当返回时释放存储。
递归的阶乘函数示例如下:
数据的分配和访问
对于数组 T A[N], L 为数据类型 T 的大小,首先它在存储器中分配一个 L * N 字节的连续区域,用 Xa 指向数组开头的指针,数组元素 i 会被存放在地址为 Xa + L* i 的地方。
一维数组:
- %edx: 数组的起始地址
- %eax: 数组元素下标值
- 要访问的数据地址为: 4*%eax + %edx
- 内存寻址方式: (%edx,%eax,4)
二维数组
T D [R] [C];
- 数据类型 T , R 行, C 列
- 假设类型 T 的元素需要 L字节 数组大小 R * C * L bytes
- 排列方式: 以行为主序, 在内存中是连续分配的
访问数组: Xd+(i * C + j )* L
异质的数据结构
结构
C 语言中用 struct 声明创建一个数据类型,将可能不同类型的对象聚合在一个对象中,结构的各个组成部分用名字来引用。
类似于数组,结构的所有组成部分都存放在存储器的一段连续区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段的偏移。机器代码不包含关于字段声明或字段名字的信息。
struct字节对齐示例如下:
b之所以要字节填补7个字节,是因为c是8字节。
联合
允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的存储器块。
一个联合的总的大小等于它最大字段的大小。
数据对齐
许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4或8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。
对齐原则如下:
在机器级程序中将控制与数据结合起来
理解指针
指针是C语言的核心特殊,以一种统一的方式,对不同数据结构中的元素产生引用。
指针的原则:
- 每个指针都对应一个类型,表明指针指向那一类对象。不过指针类型不是机器代码中的一部分;它指示 C 语言提供的一种抽象,帮助程序员避免寻址错误。
- 每个指针都有一个值。是某个指定类型对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。
- 指针用 & 运算符创建。机器代码常常用 leaq 来计算存储器引用的地址。
- ‘*’操作符用于间接引用指针。
- 数组与指针紧密联系。一个数组的名称可以像一个指针变量一样引用。数组引用a[3]与指针运算和间接引用*(a + 3)有一样的效果。
- 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
- 指针也可以指向函数。
int fun(int x, int y)
int (*fp)(int,int)
fp = fun
2
3
使用GDB调试器
GNU的调试器GDB提供了许多有用的特性,支持机器级程序的运行时评估和分析。
使用以下命令启动GDB
linux> gdb prog
内存越界引用和缓冲区溢出
内存越界访问: c对于数组指针引用不进行任何边界检查,且局部变量和状态信息都存放在栈中。此两种情况结合在一起可能导致严重的程序错误,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令,就会出现严重的错误。
缓冲区溢出: 在栈中分配某个字符数组来保存一个字符串,但是字符串的长度超过了为数组分配的空间。
程序示例:
//库函数gets()的实现
char *gets(char *s)
{
int c;
char *dest=s;
//从标准输入读入一行,在遇到一个回车换行字符或某个错误情况时停止
while((c=getchar())!='\n' && c!=EOF)
{
*dest++=c;
}
//将字符串复制到参数s指明的位置后,在字符串的末尾加上NULL字符
if(c==EOF && dest==s)
{
retuen NULL;
}
*dest++='\0';
return s;
}
//从标准行输入中读入一行,再将其送回到标准输出
void echo()
{
char buf[8];//设置8字节的缓冲区,任何长度超过7个字符的字符串都会导致写越界
gets(buf);
puts(buf);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
gets()函数的问题是无法确定是否为保存整个字符串分配了足够的空间。
echo的汇编代码:
void echo()
echo:
subq $24,%rsp
movq %rsp,%rdi
call gets
movq %rsp,%rdi
call puts
addq $24,%rsp
ret
2
3
4
5
6
7
8
9
该程序在栈上分配了24个字节,字符数组buf位于栈顶,%rep被复制到%rdi作为调用gets和puts的参数。调用的参数和存储的返回指针之间的16个字节未被使用,根据用户输入字符大小,可得到一下表格:
如果存储的返回地址的值被破坏了,那么ret指令会导致程序跳转到一个意想不到的位置,会出现缓冲区漏洞。有时会使程序执行它本来不愿意执行的函数,从而对计算机网络系统进行攻击。
对抗缓冲区溢出攻击
对抗这种攻击有几种常用方法:
1.栈随机化,即程序开始时,在栈上随机分配一段0-n字节间的随机大小的空间(可用alloca实现),程序不使用这段空间,这样,通过浪费一段空间,可以使程序每次执行时后续的栈位置发生变化。然而这种方式仍有着被破解的可能,攻击者可以在攻击代码前放许多nop指令,这些指令唯一的作用就是指向下一条指令,假设本来栈随机化后栈空间地址的变化范围达到了223个字节,本来要精确地将返回地址改到攻击代码入口的对应的地址需要“精确投放”,即要尝试枚举223种可能,现在攻击代码加上这一堆nop指令,假设达到了28=256个字节,代表只要返回地址指向这些指令中的任何一条,都会导致最后进入攻击代码,因此只要枚举215种可能就行了,因此栈随机化不大保险。
2.栈破坏检测,基本思路是在栈的局部缓冲区插入一个哨兵值(金丝雀值),它在程序每次运行时随机产生(比如可以从内存中某个地方取得),在函数返回以及恢复寄存器的值之前,程序会先检测哨兵值是否被改变,若改变了则程序异常终止。
3.限制可执行代码区域,即限制只有保存编译器产生的代码的那部分内存才是可执行的,其他内存区域被限制为只允许读和写。
支持可变栈帧
当声明一个局部变长数组时,编译器无法一开始就确定栈帧的大小,要为之分配多少内存空间,因此需要用变长栈帧。
下面看一个实例,比较难:
变长数组意味着在编译时无法确认栈帧的大小。