第3章 程序的机器级表示·三
关键词:汇编语言,越界引用,缓冲区溢出,C和指针,蠕虫和病毒
一个试图优化一段关键代码性能的程序员,通常会尝试源代码的各种形式,每次编译并检查产生出的汇编代码,从而了解程序将要运行的效率是如何的。
3.1 程序编码
1 机器级代码
在整个编译过程中,编译器会完成大部分的工作,将把用C提供的相对比较抽象的执行模型表示的程序转化成处理器执行的非常基本的指令。汇编代码表示非常接近于机器代码。与目标代码的二进制格式相比,汇编代码的主要特点是用可读性更好的文本格式表示的。能够理解汇编代码以及它是如何与原始的C代码相对应的,是理解计算机如何执行程序的关键一步。
汇编程序员看到的机器与C程序员看到的机器差别很大。一些通常对C程序员屏蔽的处理器状态时可见的:
(1)程序计数器(称为%eip)表示将要执行的下一条指令在存储器中的地址
(2)整数寄存器堆包含8个被命名的位置,分别存储32位的值。这些寄存器可以存储地址(对应于C的指针)或证书数据。有的寄存器用来记录某些重要的程序状态,而其它的寄存器用来保存临时数据,例如过程的变量。
(3)条件寄存器保存着最近执行的算术指令的状态信息。它们用来实现控制流中的条件变化,比如说用来实现if或while语句。
(4)浮点寄存器堆包含8个位置,用来存放浮点数据。
虽然C提供了一种模型,可以在存储器中声明和分配各种数据类型的对象,但是汇编代码只是简单地将存储器看成一个很大的、按字节寻址的数组。C中的聚集数据类型,例如束组合结构,在汇编代码中是用联系的子杰表示的。即便是对标量数据类型,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。
程序存储器(programming memory)包含程序的目标代码,操作系统需要的一些信息,用来管理过程调用和返回的运行时栈,以及用户分配的存储器块(比如用malloc
库函数分配的
)。
程序存储器是用虚拟地址来寻址的。在任意给定的时刻,只有有限的一部分,虚拟地址是合法的。操作系统负责管理虚拟地址空间,将虚拟地址转换成实际处理存储器中的物理地址。
一条机器指令只执行非常基本的操作。
2 汇编语言特性
(1)IA32指令长度从1-15个字节不等。指令编码被设计成常使用的指令以及操作数较少的指令所需的字字数少,而那些不太常用或操作数较多的指令所需字节数较多。
(2)指令格式是按照这样一种方式设计的,从某个给定位置开始,可以将字节唯一地译码成机器指令。
(3)反汇编器只是根据目标文件中的字节序列来确定汇编代码的。他们不需要访问程序的源代码或汇编代码。
(4)反汇编器使用的指令命名规则与GAS使用的有些细微的差别。在我们的示例中它省略了很多指令结尾的“1”。
(5)与code.s中的汇编代码相比,我们还发现多了一条nop指令。这条指令根本不会被执行。即使执行了也不会有任何影响(所以称之为nop,是“no operation”的简写,通常读作“no op”)。
3.2 访问信息
1 操作数
各种操作数的可能性被分为三种情况:
(1)立即数(immediate):也就是常数值。
(2)寄存器(register),它表示某个寄存器的内容。
(3)存储器引用,它会根据计算出来的地址访问某个存储器位置。
2 数据传送指令
最频繁使用的指令是执行数据传送的指令。最常用的传送指令是传送双字的movl指令。
算术和逻辑操作、一元和二元操作、移位操作、特殊的算术操作。
3.3 控制
1 最有用的条件码
CF:进位标志。最近的操作使最高位产生了进位,它可用来检查无符号操作数的溢出。
ZF:零标志。
最近的操作的出的结果为0。
SF:符号标志。
最近的操作得到的结果为负数。
OF:溢出标志。
最近的操作导致一个二进制补码溢出——正溢出或负溢出。
leal指令不改变任何条件码,因为它是用来进行地址计算的。
2 访问条件码
两种最常用的访问条件码的方法不是直接读取它们,而是根据条件码的某个组合,设置一个整数寄存器或是执行一条件分支指令。
movzbl用来清零三个高位字节。
3 跳转指令和它们的编码
在正常的情况下,指令按照它们出现的顺序一条一条地执行。跳转(jump)指令会导致执行切换到程序中一个全新的位置。
do-while,while,for,switch。
3.4 过程
一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。大多数机器,包括IA32,只提供简单的转移控制到过程和从过程中转移出控制的指令。数据传递、局部变量的分配和释放是通过操纵程序栈来实现的。
IA32程序用程序栈来支持过程调用。栈用来传递过程参数、存储返回信息、保存寄存器以供以后恢复之用,以及用于本地存储。为单个过程分配的那部分栈成为栈帧(stack frame)。栈帧的最顶端是以两个指针定界的,寄存器%ebp作为帧指针,而寄存器%esp作为栈指针。当程序执行时,栈指针是可以移动的,因此大多数信息的访问都是相对于帧指针的。
C的一个不同寻常的特点是可以对数组红的元素产生指针,并对这些指针进行运算。这些运算会在汇编代码中翻译成地址计算。
在C中,堆(一个可以用来存放数据结构的存储器池)中的存储分配是用的库函数malloc或calloc。它们的效果类似于C++和Java中的new操作。C和C++都要求程序显式地用free函数来释放已分配的空间。在Java中,释放是由运行时系统通过一个成为garbage collection(垃圾收集)的进程自动完成的。
3.5 异类的数据结构
C提供了两种将不同类型的对象结合到一起来创建数据类型的机制:
结构(structure),用关键词struct来声明,将多个对象集合到一个单位中。结构的各个组成部分是用名字来引用的。结构的实现类似于数组的实现,因为结构的所有组成部分都存放在存储器中连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器保存关于每个结构类型的信息,指示每个域的字节偏移。它以这些便宜作为存储器引用指令中的位移,从而产生对结构元素的引用。
联合(unite),用关键字union来声明。允许用几种不同的类型来引用一个对象。联合提供了一种方式,能够规避C的类型系统,允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,只不过语义相差较大。他们不是用不同的域来引用不同的存储器块,而是引用的同一存储器块。
3.6 指针
1指针的特点
(1)每个指针都有一个值,这个值是某个指定类型的对象的地址。特殊的NULL(0)值表示该指针没有指向任何地方。
(2)指针是用&运算符创建的。这个运算符可以用到任何lvalue类的C表达式上,也就是可以出现在赋值语句左边的表达式,这样的例子包括变量以及结构、联合和数组的元素。
(3)*操作符用于指针的间接引用。其结果是一个值,它的类型与该指针的类型相关。
(4)数组与指针是密切联系的。可以引用一个数组的名字(但是不能修改),就好像它是一个指针变量一样。数组引用于指针运算和间接引用有一样的效果。
指针也可以指向函数。这提供了一个很强大的存储和传递代码引用的功能。这些代码可以被程序的某个部分调用。
2 向函数传递参数
在C中,所有的参数都是传值的,但是我们可以通过显式地产生一个指向一个值的指针,并把该指针传递给过程,从而实现了引用参数的效果。
3.7 存储器的越界引用和缓冲区溢出
C对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如寄存器值返回指针)都存放在栈中。这两种情况结合到一起就能导致严重的程序错误,一个对越界的数组元素的写操作破坏了存储在栈中的状态信息。然后,当程序使用这个破坏的状态,试图重新加载寄存器执行ret指令,就会出现严重的错误。
一种特别常见的状态破坏称为缓冲区溢出(buffer overflow)。通常在栈中分配某个字节数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。
缓冲区溢出的一个更加致命的应用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为漏洞入侵编码(exploit code),另外还有一些字节会用一个指向缓冲区中那些可执行代码的指针覆盖掉返回指针。所以,执行ret指令的效果就是跳转到漏洞入
侵
代码。
蠕虫(worm)是这样一种程序,它能将一个完全有效的自己传播到其它机器。
病毒(virus)是
是这样一段代码,它能将自己添加到包括操作系统在内的其它程序中,但它不能独立运行。
参考文献
Bryant R E, 布赖恩特, O'Hallaron D, et al. 深入理解计算机系统[M]. 中国电力出版社, 2004.