摘要:本章主要介绍了机器级代码,处理器的每个操作都对应着一组二进制值,但二进制可读性差,而汇编代码与二进制代码有着一一对应的关系,也就是处理器的操作可以用汇编指令来表示,因此本章主要通过汇编代码来介绍机器级表示。本章主要包括三个内容,第一是,程序的汇编代码表示。第二是,程序的帧栈结构,第三是,数组、结构体、联合体这些对象的数据存储空间分配方式与对齐方式。
关键词:汇编代码;帧栈结构;数据的存储方式;
目录:
1.汇编代码
1.1什么是汇编代码?
1.2如何看懂汇编代码?
1.3函数流程控制的汇编代码
2.函数的帧栈
2.1函数的帧栈的位置及其结构
2.2函数帧栈的创建与销毁
2.3如何利用函数的帧栈进行缓存区溢出攻击?
3.数据的存储方式
3.1数组、结构体、联合体的存储方式
3.2数据的对齐存储
1 汇编代码
1.1什么是汇编代码?
对于应用级程序员而言,我们确实很少需要接触汇编代码。但凡是学过C语言的都知道,我们的高级语言会先变成汇编语言,然后变成机器语言。高级语言我们都知道,那么什么是汇编语言呢?这三者之间有什么关系呢?为什么需要汇编语言呢?直接将高级语言变成机器语言不行吗?
高级语言是可读性最高的,可以让我们轻松的写出非常复杂的算法,然我们关注于某个算法或者逻辑的实现,而无需将太多的精力放在语言本身的语法上。编译器通过将高级语言转换成汇编语言,这种转化不是一种映射关系。由于机器能提供的操作有限,所以编译器要将高级语言所支持的一系列高级操作,变成机器级能实现的操作,因此可能高级语言一个语句,就得使用好几行汇编语句,因为汇编语言把操作变成了机器级了。而汇编语言与机器语言之间则是映射关系。相当于有个映射表,一个汇编语言的指令就可以对应一个机器级二进制指令,编译器根据这个映射表再把汇编语言转变成机器语言。机器语言也即二进制数据所表示的操作由处理器厂商定制,编译器可以根据自己的风格定义汇编语句来对应相关的机器级操作,因此汇编语言会因为编译器不同而不同,而没有统一的标准,就如同实现同样的功能可以C语言也可以用java,翻译同样的机器级语言,每个编译器有自己的一套汇编格式。
那既然汇编语言和机器级的代码只是个映射表的关系,那还搞个中介汇编代码干嘛。因为有些底层的程序员他要写汇编代码,汇编代码可读性比机器代码好太多,所以效率自然会高也不容易出错。
1.2 如何看懂汇编代码?
由于汇编代码表达的是机器级的操作,机器操作就是对数据进行操作,所以汇编代码最核心的两个方面就是:指令和操作数
指令表明了这一行要对数据做什么操作,操作数则表明了,指令操作的数据是谁。因此想要看懂汇编代码,很简单,只需两步,第一,你要懂该编译器汇编指令的意思,比如push、pop、add等等,然后你要理解操作数格式。
第一个指令的意思好说,背就行了。第二个操作数格式,稍微需要一些运算,一个指令就是一个操作,一个操作是一个动作,他往往有个承受的对象,总不能光一个指令就完了,那不是在自娱自乐吗,当然也有一个指令单独出现的,比如程序return对应的指令。那么操作数可能是常数,也可能是变量,还可能是对象例如数组,因此操作数分了三类,第一类立即数,也就是常数,第二类是寄存器,也就存储在寄存器中的操作数,第三类是存储器,即存储在存储器中。所以你需要明白这三类操作数的表示方法,即他们的地址是怎么表示的,因为很多情况下操作数不是常熟,是一些变量之间在做操作,变量就有地址,所以操作数很多都是以地址的形式给出的,而地址又分为寄存器的地址和存储器的地址。他们的地址格式都有明确的规定,因此你只需要熟记指令和操作数格式。看到汇编代码就没问题了。我们也只需要看懂就可以了。
1.3 函数流程控制的汇编代码
高级语言中,有很多控制语句,比如条件分支,循环,这些在汇编语句里面也有,思路都一样。熟记指令就OK了。
2 函数的帧栈
2.1 函数的帧栈的位置及其结构
函数是用来做什么的?实现某种功能的。函数包括两部分,一是数据,二是对数据的操作。对数据的操作也就是指令,而数据则是以地址的形式表示。一个函数的指令存储在专门用于存储指令的存储器中,然后不断取出来送入CPU中执行,那么数据呢?数据存在哪呢?如果函数比较小,变量也少,那么可以直接存在寄存器中,但一个函数往往变量是比较多,寄存器装不下,尤其是有数组的时候,它们都存储在存储器中。因此函数的数据也放在存储器中,并用栈这种数据结构来存储。
2.2 函数帧栈的创建与销毁
栈这种数据的特点就是先入的后出。由于我们之前就说过,我们把存储器看成是一个大的数组,我们要把一个函数的数据信息保存在这个数组中的某一段,那么我需要两个指针,第一,该段数组段的起始指针,也就是栈低指针,我们称其为帧指针;第二,该数组段的终点指针,也就是栈顶指针,我们称之为栈指针。当程序在执行某个函数的时候,帧指针指向栈低,所以除非函数结束返回,在此之前,帧指针都不会改变,而栈指针在栈顶,随着更多数据的入栈和出栈,栈顶指针是不断在移动的。所以对一个函数帧栈中数据的引用是以帧指针作为相对偏移量来引用的。函数的栈指针值和帧指针值都存在寄存器中。
在计算机中,栈低的指针要比栈顶的大,也就是当栈中元素增加时,栈顶指针朝着减小的方向移动。
一个函数执行容易理解。如果是函数的嵌套或者递归调用呢?
函数发生调用的时候,唯一要做的就是备份好调用者的数据,使得被调用结束返回时,能够还原所有信息。所以主要备份一下三类数据。
- 备份调用者返回地址。也就是被调用函数结束之后调用者接着要执行的指令,由于指令都是存在存储器中,所以该地址必须先备份好,一遍调用结束后知道接着该执行什么指令。此时入栈一个返回地址,栈指针向下移动。
- 备份调用者的帧指针。帧指针指明了一个函数数据存储开始的位置,其后所有的变量都以此作为参考进行索引,而栈指针和帧栈指针都保存在寄存器中,且寄存器只保存当前正在执行的函数的帧栈指针,因此被调用者必须先将帧指针备份到栈顶,因此帧指针接着返回地址入栈。
- 备份调用者寄存器中的数据。由于一个函数他的部分数据可能保存在寄存器中,为了避免被调用者执行时使用寄存器把数据覆盖了,因此被调用者还需要把这些寄存器中的相关数据备份到栈中。
不管调用多深,所有的函数的帧栈都只能存放在两个指定的寄存器中,如果发生调用,被调用者一定要备份调用者的帧指针。
备份好了这些调用者的数据之后,被调用才能开始利用他的帧栈开始存储数据,此时,寄存器中存储的已经是被调用者帧栈指针的值了。然后可以通过栈指针的移动来入栈存储相关的数据,用帧指针来引用数据了。随着调用的不断进行这个备份不断的发生,帧栈变得越来越长,所以递归调用次数很多的话,就会导致占用很大的存储空间了。但明确的是,不管你调用一个函数多少次,指令还是那几个,反复的指向,只是数据不同,所以数据的帧栈在不断的加深。而存储指令的空间与调用次数没有关系。
由于调用者的数据都备份好了,因此被调用者执行后,随着栈指针向栈低推移,备份的数据都回逐渐还原,然后知道移动到返回地址,被调用者就结束任务,他的帧栈都清空了。栈指针也回到了调用之前的位置,然后继续移动存储新的变量或者调用新的函数。
2.3 如何利用函数的帧栈进行缓存区溢出攻击?
当被调用者中存储了一个数组,而数据是有固定长度的。如果获取了数组的头指针,然后向数组内写数据,如果不限制他写入的个数,也就会发生数据越界,然后把返回值地址覆盖了(注意,栈低的指针是比栈顶大的,因此分配一个数组之后,数组的第一个元素靠近的是栈顶,最后一个元素靠近栈低)。被调用者的程序执行结束后,由于返回地址被改写了,他会跳到一个意向不到地方,产生错误。攻击者能够通过精心设计的代码,让你执行,然后返回到攻击代码地址,对你的系统实现攻击,这就是缓冲区溢出攻击的基本概念。
如何避免缓冲区溢出攻击呢?这是一个值得研究的课题。我们实验室就一个同学硕士的课题就是做的相关的研究。
3. 数据的存储方式
3.1数组、结构体、联合体的存储方式
对于一个普通常数或者基本类型的变量,基本上都是优先存在寄存器上。
但对于数组、结构体、联合体这种数据类型,则一般在存储器中存储。也就是存储在虚拟的大数组中。
数组:根据里面数组元素的字长,存到大数组中即可。如果是二维数组,则看出每个元素是个数组类型的数组即可。
结构体:根据结构体中各字段的字长,然后计算总字长存入大数组中。
联合体:以该联合体中最长字段的字长作为联合体的字长存入大数组中。
3.2数据的对齐存储
对于数组、结构体和联合体的存储方式,你可以像上述这样简单的理解,但实际存储的方式可能略有不同。主要体现在,系统会有数据对齐的要求。什么是数据对齐呢?你知道计算机存储体系是层次结构的。他每次都回从下一层取出数据块,放入上一层。而不是说你想取多少就取多少,他是以块为单位的。假设一个块8个字节,系统不会你想要大数组中哪个连续的八字节就给你取那八个字节,如果你要07的元素,然后你又要410元素,那不很多重复的被取到上一层中了,上一层空间本来就不大,所以不会这样做,如果一个块8字节,那么他值取起点是八的倍数的八个字节,这样保证不会有重复的。比如你要07和410之间的元素,他怎么做呢?他取07和815两个块给你。如果你一个八字节的浮点数,存储在4~10之间,怎么办,系统要为你取两个块,才能获得这个八字节的数组。所以如果你存储的数据都是和块对齐的,那么就可以提高存储器的读取速率,从而提供程序运行的性能,因此系统对数据是有对齐要求的。所以可能一个结构体对象实际只占10字节,但可能用了12字节来和系统进行对齐。通过牺牲空间换取时间性能。所以发现实际空间和你计算的空间不一样时,你不要惊讶,时数据对齐的要求造成的。