《第3章 程序的机器级表示》
汇编代码是机器代码的文本表示,是与特定机器密切相关的。用高级语言编写的程序可以在很多不同的机器上编译和执行。
3.2 程序编码
汇编代码表示非常接近于机器代码。与机器代码的二进制格式相比,汇编代码的主要特点是它用可读性更好的文本格式表示,能够理解汇编代码以及它与原始代码的联系,是理解九三级如何执行程序的关键一步。x96-64的机器代码和原始的C代码差别非常大。一些通常对C语言程序员隐藏的处理器状态都是可见的:
1.程序计数器(通常称为“PC”,在x86-64中用%rip表示)给出将要执行的下一条指令在内存中的地址。
2.整数寄存器文件包含16个命名的位置,分别存储64位的值。这些寄存器可以存储地址(对应于C语言的指针)或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
3.条件码寄存器保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现if和while语旬。
4.一组向量寄存器可以存放一个或多个整数或浮点数值。一些机器级代码和它的反汇编表示的特性值得注意:
1.x86-64的指令长度从1~15字节不等。
2.设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。
3.反汇编器只是基于机器代码文件中的字节序列来确定汇编代码, 不需要访问程序的源代码或汇编代码。
4.生成可执行的代码需要对一组目标代码文件运行链接器,而这一组目标代码文件中必须含有一个main函数。- 汇编代码中所有以
.
开头的行都是指导汇编器和链接器工作的伪指令。 我们的表述是ATT(根据“AT&T”命名的, AT&T是运营贝尔实验室多年的公 司)格式的汇编代码,这是GCC、 OBJDUMP和其他一些我们使用的工具的默认格式。 其他一些编程工具,包括Microsoft的工具,以及来自Intel的文档,其汇编代码都是Intel格式的。这两种格式在许多方面有所不同。比如下面的几点:
1.intel代码省略了指示大小的后缀。比如使用push和pop,而不是pushl和popl。
2.intel代码省略了寄存器前面的“%”符号,用的是rbx而不是%rbx。
3.intel代码用不同的方式来描述内存中的位置,例如是“QWORD PTR[rbx]”,而不是“(%rbx)”。
4.在带有多个操作数的指令情况下,列出操作数的顺序相反。当在两种格式之间进 行转换的时候,这一点非常令人困惑。
3.3 数据格式
由于是从16位体系结构扩展成32位的,Intel用术语“字(word)”表示16位数据类型。因此,称32位数为“双字(double words)”,称64位数为“四字(quad words)”。 在x86-64中,标准int值存储为双字(32位),指针(在此用char *表示)存储为8字节的四字。
3.4 访问信息
- 一个x86-64的中央处理单元(CPU)包含一组16个存储64位值的通用目的寄存器。 这些寄存器用来存储整数数据和指针。
- 操作数被分为三种类型:立即数、寄存器和内存引用。
- 多种不同的寻址模式
- 简单的数据传送指令
- 零扩展数据传送指令
- 符号扩展数据传送指令
压入和弹出栈数据
1.将一个四字值压人栈中,首先要将栈指针减8,然后将值写到新的栈顶地址。因此,指令
pushq %rbp
的行为等价于下面两条指令:
subq $8,%rsp
movq %rbp, (%rsp)
2.弹出一个四字的操作包括从栈顶位置读出数据,然后将栈指针加8。 因此,指令popq %rax
等价于下面两条指令:
movq (%rsp) ,%rax
addq $8,%rsp
3.5 算术和逻辑操作
- 可以分为四种::加载有效地址、一元操作、二元操作和移位。整数算术操作如下:
- 加载有效地址(load effective address)指令
leaq
命令实际上是movq
指令的变形。指令中第一个操作数是将有效地址写入到目的操作数(并不是内存引用),同时目的操作数必须是一个寄存器。 - 特殊的算术操作
3.6 控制
条件码
除了整数寄存器, CPU还维护着一组单个位的条件码(condition code)寄存器,它们描述了最近的算术或逻辑操作的属性。可以检测这些寄存器来执行条件分支指令。最常用的条件码有:1.CF:进位标志。最近的操作使最高位产生了进位。可用来检查无符号操作的溢出。
2.ZF:零标志。最近的操作得出的结果为0。
3.SF:符号标志。最近的操作得到的结果为负数。
4.OF:溢出标志。最近的操作导致一个补码溢出一正溢出或负溢出。
对于逻辑操作,例如xoR,进位标志和溢出标志会设置成0。对于移位操作,进位标志将设置为最后一个被移出的位,而溢出标志设置为0。INC和DEC指令会设置溢出和零标志,但是不会改变进位标志。条件码不会被直接读取,一般常使用的方法有三种:
1.可以根据条件码的某种组合,将一个字节设置为0或1
2.可以跳转到程序的某个其他部分
3.可以有条件地传送数据跳转指令
1.直接跳转:跳转目标是作为指令的一部分编码的。
2.间接跳转:跳转目标是从寄存器或内存位置读出的,写法是*
后面跟一个操作数指示符。
3.上图中jmp
是无条件跳转指令,它可以是直接跳转,也可以是间接跳转。
4.上图中其他指令都是有条件的,条件跳转只能是直接跳转。条件分支的实现
1.使用条件控制实现条件分支
C语言中的if-else
语句的通用形式模版如下:if (test-expr)> then-statement else else-statement
汇编通常会使用下面的形式,为
then-statement
和else-statement
产生各自的代码块。它会插入条件和无条件分支,以保证能执行正确的代码块。t = test-expr; if (!t) goto false; then-statement goto done; false: else-statement done:
2.使用条件传送实现条件分支
条件传送指令
- C语言提供了多种循环结构,即
do-While
、While
和for
。汇编中没有相应的指令存在,可以用条件测试和跳转组合起来实现循环的效果。 switch
(开关)语句可以根据一个整数索引值进行多重分支,不仅提高了C代码的可读性,而且通过跳转表这种数据结构使得实现更加高效。跳转表是一个数组,表项i是一个代码段的地址,这个代码段实现当索引值i时程序应该采取的动作。当开关的情况比较多,值的跨度范围较小时,就会使用跳转表。
3.7 过程
- 运行时栈
x86-64过程需要的存储空间超出寄存器能够存放的大小后,会在栈上分配空间。这个部分称为过程的栈帧(stack fram)。
以过程P调用Q,Q执行后返回P为例。P调用Q会将Q返回后下一条执行的P的代码作为P栈帧的一部分。Q的代码则会扩展当前栈的边界,分配其栈帧所需要的空间。
栈上的局部存储
有些时候,局部数据必须存放在内存中,常见的情况包括:1.寄存器不足够存放所有的本地数据。
2.对一个局部变量使用地址运算符“&”,因此能够为它产生一个地址。
3.某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。
一般来说,过程通过减小栈指针在栈上分配空间。分配的结果作为栈帧的一部分,标 号为“局部变量”。- 寄存器组是唯一被所有过程共享的资源
递归调用一个函数本身与调用其他函数是一样的。
3.8 数组分配和访问
- C语言中的数组是一种将标量数据聚集成更大数据类型的方式。C语言实现数组的方式非常简单,因此很容易翻译成机器代码。C语言的一个不同寻常的特点是可以产生指向数组中元素的指针,并对这些指针进行运算。在机器代码中,这些指针会被翻译成地址计算。优化编译器非常善于简化数组索引所使用的地址计算。
- 指针计算
- 变长数组
ISO C99引人了一种功能,允许数组的维度是表达式,在数组被分配的时候才计算出来。 在变长数组的C版本中,我们可以将一个数组声明如下:
int A[exp1][exp2];
它可以作为一个局部变量,也可以作为一个函数的参数,然后在遇到这个声明的时候,通过对表达式exp1
和exp2
求值来确定数组的维度。因此,例如要访问n×n的数组的元素i,j,我们可以写一个如下的函数:
int var_ele(long n, int A[n][n],long i, long j) {
return A[i][j];
}
参数n必须写在参数A[n][n]之前,这样函数就可以在遇到这个数组的时候计算出数组的维度。
对应的汇编代码如下:
/* int var_ele(long n, int A[n][n],long i, long j)
n in %rdi, A in %rsi, i in %rdx, j in %rcx */
var_ele :
imulq %rdx, %rdi
leaq (%rsi, %rdi, 4) , %rax
movl (%rax,%rcx,4) , %eax
ret
3.9 异质的数据结构
- 结构
C语言的struct声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中,用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。 - 联合
联合提供了一种方式,能够规避C语言的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差比较大。它们是用不同的字段来引用相同的内存块。 - 数据对齐
许多计算机系统对基本的数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2,4,8)的倍数。这种对齐限制简化了处理器和内存系统之间接口的硬件设计。无论数据是否对齐,x86-64的硬件都能正确工作。不过,intel还是建议要对齐数据以提高系统性能。 - 数据对齐让我想到了安装内存或者系统的过程中的对齐,以前不懂为何要这样做,现在通过这一节明白了数据对齐的重要性,可以提高系统性能,减少不必要的工作。
3.10 在机器级程序中将控制与数据结合起来
理解指针:
1.每个指针都对应一个类型.这个类型表明该指针指向的是哪一类对象。
2.每个指针都有一个值,这个值是某个指定类型的对象的地址。
3.指针用&
运算符创建.这个运算符可以应用到任何lvalue
类的C表达式上。lvalue
意指可以出现在赋值语句左边的表达式。
4.*
操作符用于间接引用指针,它的类型与该指针的类型一致。
5.数组与指针緊密联系,一个数组的名字可以像一个变量一样引用(但是不能修改)。数组引用与指针运算和间接引用有一样的效果。
6.将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值。
7.指针也可以指向函数。- GDB调试器常用命令
- 内存越界引用和缓冲区溢出
C对数组不进行任何边界检查,因此当对越界的数组元素进行写操作会破坏存储在栈中的信息。一种特别常见的状态破坏称为缓冲区溢出。缓冲区溢出一个很致命的使用就是让程序执行它本来不想执行函数,这是一种常见的通过计算机网络攻击系统安全的方法。输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码,另外还有一些字节会用一个指向攻击代码的指针覆盖返回区域。那么执行ret指令的效果就是跳转到攻击代码。 对抗缓冲区溢出可以采取以下方法:
1.栈随机化。使得栈的位置在程序每次运行时都有变化。
2.栈破坏检测。能够检测到栈何时已被破坏。最新的GCC版本加入了一种栈保护者机制来检测缓冲区越界。其思想是在任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值。
3.限制可执行代码区域。消除攻击者向系统中插入可执行代码的能力。
3.11 浮点代码
- 浮点传送指令
浮点转换指令
1.双操作数浮点转换指令
2.三操作数浮点转换指令
过程中的浮点代码
XMM寄存器用来向函数传递浮点参数,以及从函数返回浮点值。有如下规则:1.XMM寄存器%xmm0~%xmm7最多可以传递8个浮点参数。
2.函数使用%xmm0来返回浮点值。
3.所有XMM寄存器都是调用者保存。被调用者可以不用保存就覆盖这些寄存器中任意一个。浮点数运算操作
学习进度条
章节数(新增/累积) | 博客量(新增/累积) | |
---|---|---|
目标 | 共12章 | 共12篇 |
2018.10.21 | 1/1 | 1/1 |
2018.11.04 | 1/2 | 1/2 |
2018.12.08 | 1/3 | 1/3 |
第三章拖得时间有点久了,但是内容很多,还有很多内容没有深化理解,本书需要多遍阅读,细细体会。