《深入理解计算机系统》第三章 程序的机器级表示学习
在本章的学习中,我们通过学习C语言提供的抽象层下面的东西,来了解机器及编程。通过让编译器产生机器级程序汇编代码表示,我们了解了编译器和它的优化能力。不论我们是在用C语言还是用JAVA或是其他的语言编程时,我们会忽视程序的机器级的实现。机器语言不需要被编译,可以直接被CPU执行,其执行速度十分快。但是机器语言的读写性与移植性较高级语言低。高级语言被编译后便成为了汇编语言,汇编语言十分接近机器语言。之后汇编代码会转化为机器语言。虽然现代的编译器能帮助我们将高级语言转化为汇编语言,解决了不少问题,但是对于一个严谨的程序员来说,需要做到能够阅读和理解汇编语言。比如C语言缺乏边界检查问题,使得许多程序容易出现缓冲区溢出,虽然最近的运行系统提供了安全保护,而且编译器帮助使得程序更安全,但是这已经使许多系统容易受到恶意入侵者的攻击。
一、历史观点
前段时间一直想换电脑,转了几家电脑店发现我是一个连i7处理器都不了解的电脑盲,为什么最新的电脑都换成了i7处理器?和之前的i5等等有什么区别?通过这一章的学习我对处理器也有了一个大概的了解。Intel处理器最的早是8086,它是十六位的微处理器,作为第一代单芯片,8086知名度是相当的高。之后又有80286、i386、i486、Pentium、PentiumPro、Pentium/MMX、PentiumⅡ、PentiumIII等等的一系列处理器的出现。目前最新的处理器是core i7处理器,既支持超线程,也有多核。引入AVX,并扩展至AVX2,增加了更多的指令和指令格式。每个时间上相继的处理器都是向后兼容的。Intel称其指令集为IA32,也就是Intel32位体系结构,也就是我们平常所说的x86。
二、程序编码
计算机系统使用了多种不同形式的抽象,利用更简单的抽象模型来隐藏实现的细节。
对于机器级编程来说,其中两种抽象尤为重要:
1、指令集体系结构或指令级框架:它定义了处理器状态、指令的格式,以及每条指令对状态的影响。
IA32将程序的行为描述成好像每条指令时按顺序执行的,一条指令结束后,下一条再开始。(实际上处理器并发地执行许多指令,但是可以采取措施保证整体行为与ISA指定的顺序执行完全一致)
2、机器级程序使用的存储器地址是虚拟地址:提供的存储器模型看上去是一个非常大的字节数组。存储器系统的实际实现是将多个硬件存储器和操作系统软件组合起来。
程序存储器(program memory)包含:程序的可执行机器代码、操作系统需要的一些信息、栈、堆。程序存储器用虚拟地址来寻址(此虚拟地址不是机器级虚拟地址)。操作系统负责管理虚拟地址空间(程序级虚拟地址),将虚拟地址翻译成实际处理器存储器中的物理地址(机器级虚拟地址)。
这一部分还讲到了预处理,编译,汇编,链接,在上学期的课程中有学过,没有那么陌生。用c语言写一个代码文件mstore.c:
在命令行上使用“-S”选项,就能看到C语言编译器产生的汇编代码:
Linux> gcc -Og -S mstore.c
通过ls查看可以发现,产生了一个汇编文件mstore.s,汇编代码文件包含各种声明,包括下面几行:
上面每个缩进去的行都对应于一条机器指令,比如,pushq指令表示应该将寄存器%rbx的内容压入程序栈中。这段代码中已经除去了所有关于局部变量名或数据类型的信息。下面我们使用“-c”命令,gcc会编译并汇编该代码:
Linux> gcc -Og -c mstore.c
产生了目标代码文件mstore.o,它是二进制格式的,所以无法直接查看。1368字节的文件mstore.o中有一段14字节的序列,它的十六进制表示为:
53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3
这就是上面列出的汇编指令对应的目标代码,机器执行的程序只是一个字节序列,它是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。
查看机器代码文件的内容,有一类称为反汇编器,带“-d”命令行:linux> objdump -d mstore.o, 结构如下:
左右两边实际是等价的,右边是给出的汇编语言。
三、数据格式
四、访问信息
这是IA32中央处理器所包含的一组八个存储单元的32位存储器。前六个是通用寄存器,对它们的使用没有限制。前三个寄存器(%eax,%ecx,%edx)的保存和恢复惯例不同于接下来的三个寄存器(%ebx,%esi,%edi)。最后两个寄存器保存着指向程序栈重要位置的指针,称为栈指针和帧指针。数据存放在寄存器中进行加减乘除等一些操作。原来的寄存器是16位的所以如图所示蓝色部分是0-15,之后寄存器进行了扩充,变成了32位的即0-31。
五、控制
对于C和汇编代码中的语句,默认的是按照语句或指令在程序中出现的顺序来执行的。
(1)条件码
CPU包含了一组单个位的条件码寄存器,它们描述了最近的算术或逻辑操作的属性。常见的条件码为:进位标志(CF)、零标志(ZF)、符号标志(SF)、溢出标志(OF)
(2)访问条件码
两种最常用的访问的条件码的方法不是直接读取它们,而是根据条件码的某个组合,设置一个整数寄存器或是执行一条分支指令。每个指令根据条件码的某个组合,将一个字节设置为0或者1。同一个机器指令可以有不同的名字。
(3)跳转指令
跳转指令会导致执行切换到程序中的一个全新的位置。这些跳转的目的地通常用一个标号指明。jmp指令是无条件跳转的,可以直接跳转也可以间接跳转。
(4)循环
do-while、while、 for
(5)switch语句
switch语句提供了根据一个整数索引值进行多重分支的能力。在处理具有多种可能结果的测试时,特别有用。它通过使用跳转表使代码更加高效。
六、过程
一个过程调用包括将数据(以过程参数和返回值的形式)和控制从代码的一部分传递到另一部分。另外,它还必须在进入时为过程的局部变量分配空间,并在退出时释放这些空间。
(1)栈帧结构
栈帧结构指的是为单个过程分配的那部分栈。栈帧的最顶端是以两个指针定界的,寄存器%ebp作为帧指针,寄存器%esp作为栈指针。栈指针是可以移动的,所以大多数信息的访问都是相对于帧指针的 。
(2)转移控制
call :过程调用
leave:为返回准备栈
ret:从过程调用中返回
(3)递归过程
七、异质的数据结构
异质结构是指不同数据类型的数组组合,比如C语言当中的结构(struct)与联合(union)。我通过了两个简单的c程序来区分了结构和联合。
首先分别写一个struct.c文件和union.c文件如下:
运行一下,发现结果不同:
这正是因为上面我们提到过的对齐的原因,只不过在struct中,对齐不是地址对齐也不是栈分配空间对齐,而是数据对齐。为了提高数据读取的速度,一般情况下会将数据以2的指数倍对齐,具体是2、4、8还是16,得根据具体的硬件设施以及操作系统来决定。这样做的好处是,处理器可以统一的一次性读取4(也可能是其它数值)个字节,而不再需要针对特殊的数据类型读取做特殊处理。
与结构体不同的是,联合会复用内存空间,以节省内存。它与结构体最大的区别就在于,对a、b、c赋值时,联合会覆盖掉之前的赋值,输出a,b,c中最大字节值,而结构体则不会,结构体可以同时保存a、b、c的值。
八、在机器级程序将控制与数据结合起来
强制类型转换的优先级高于加大,指针从一个类型转为另外一个类型,只改变它的类型,不改变它的值。如 p 是一个 char * 类型的指针,值为p,(int * )p + 7 计算为 p+28 ,而(int * )(p + 7)计算为 p+7。
缓冲区溢出
通常,在栈中分配某个字节数组来保存一个字符串,但是字符串的长度超出了为数组分配的空间。C对于数组引用不进行任何边界检查,而且局部变量和状态信息,都存在栈中。这样,对越界的数组元素的写操作会破坏存储在栈中的状态信息。当程序使用这个被破坏的状态,试图重新加载寄存器或执行ret指令时,就会出现很严重的错误。
void echo()
{
char buf[8] ;
gets(buf) ;
puts(buf) ;
}
由于栈是向地地址增长的,数组缓冲区是向高地址增长的。故,长一些的字符串会导致gets覆盖栈上存储的某些信息。
随着字符串变长,下面的信息会被破坏:
输入的字符数量 被破坏的状态
0---7 无
8---11 保存的%ebx的值
12---15 保存的%ebp的值
16---19 返回地址
20+ caller中保存的状态
如果破坏了存储%ebp的值,那么基址寄存器就不能正确地恢复,因此调用者就不能正确地引用它的局部变量或参数。
如果破坏了存储的返回地址,那么ret指令会使程序跳转到完全意想不到的地方。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。这是一种最常见的通过计算机网络攻击系统安全的方法。通常,输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码,另外还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret指令的效果就是跳转到攻击代码。
通常,使用gets或其他任何能导致存储溢出的函数,都不是好的编程习惯。不幸的是,很多常用库函数,包括strcpy、strcat、sprintf,都有一个属性——不需要告诉它们目标缓冲区的大小,就产生一个字节序列。
对抗缓冲区溢出攻击
1、栈随机化
为了在系统中插入攻击代码,攻击者不但要插入代码,还要插入指向这段代码的指针,这个指针也是攻击字符串的一部分。产生这个指针需要知道这个字符串放置的栈地址。在过去,程序的栈地址非常容易预测,在不同的机器之间,栈的位置是相当固定的。
栈随机化的思想使得栈的位置在程序每次运行时都有变化。因此,即使许多机器都运行相同的代码。它们的栈地址都是不同的。
实现的方式是:程序开始时,在栈上分配一段0--n字节之间的随机大小空间。程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。
在Linux系统中,栈随机化已经变成了标准行为。(在linux上每次运行相同的程序,其同一局部变量的地址都不相同)
2、栈破坏检测
在C语言中,没有可靠的方法来防止对数组的越界写,但是,我们能够在发生了越界写的时候,在没有造成任何有害结果之前,尝试检测到它。
最近的GCC版本在产生的代码中加入了一种栈保护者机制,用来检测缓冲区越界,其思想是在栈中任何局部缓冲区与栈状态之间存储一个特殊的金丝雀值。这个金丝雀值是在程序每次运行时随机产生的,因此,攻击者没有简单的办法知道它是什么。
在恢复寄存器状态和从函数返回之前,程序检查这个金丝雀值是否被该函数的某个操作或者函数调用的某个操作改变了。如果是,那么程序异常终止。
3、限制可执行代码区域
限制那些能够存放可执行代码的存储器区域。在典型的程序中,只有保存编译器产生的代码的那部分存储器才需要是可执行的,其他部分可以被限制为只允许读和写。
现在的64位处理器的内存保护引入了”NX”(不执行)位。有了这个特性,栈可以被标记为可读和可写,但是不可执行,检查页是否可执行由硬件来完成,效率上没有损失。