第3章 程序的机器级表示
GCC C语言编译器以汇编代码的形式产生输出,汇编代码是机器代码的文本表示。然后GCC调用汇编器和链接器,根据汇编代码生成可执行的机器代码。
高级语言编写的程序可以在很多不同的机器上编译和执行,而汇编代码则是与特定机器密切相关的。
3.1历史观点
每个后继处理器的设计都是后向兼容的,较早版本上编译的代码可以在较新的处理器上运行。
Intel处理器系列有好几个名字,如IA32,以及最新的Intel64,即IA32的64位扩展,也称为x86-64。最常用的名字是x86,我们用它指代整个系列。
3.2程序编码
3.2.1机器级代码
程序的行为看上去是顺序执行的,但处理器的硬件远比描述的精细复杂,它们并发执行许多指令,但是可以保证行为一致。
寄存器:
●程序计数器:称为PC,在x86-64中用%rip表示,给出将要执行的下一条指令在内存中的地址。
●整数寄存器:16个命名的位置,存储64位的值,可以存储地址或整数数据。
●条件码寄存器:保存最近执行的算术或逻辑指令的状态信息
●一组向量寄存器:存放一个或多个整数或浮点数值
机器代码只是简单地将内存看成一个很大的、按字节寻址的数组。汇编代码不区分整数和指针。
程序内存包含:程序的可执行机器代码、操作系统需要的信息、运行时栈、用户分配的内存块(如malloc)。程序内存用虚拟地址来寻址,操作系统负责将虚拟地址翻译成实际处理器内存中的物理地址。
3.2.2代码示例
机器执行的程序只是字节序列,是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。
反汇编器:根据机器代码产生汇编代码的格式,如OBJDUMP
x86-64指令不常用或操作数较多的指令所需字节数较多
3.3数据格式
字:16位数据类型,一字(w)等于两个字节(b)。双字(l),四字(q)
3.4访问信息
规则:生成1字节和2字节数字的指令会保持剩下的字节不变;生成4字节数字的指令会把高四位4个字节置0.
3.4.1操作数指示符
操作数:1.立即数$ 2.寄存器 ra3.内存引用() Imm(rb,ri,s)=Imm+rb+ri*s
3.4.2数据传送指令
MOV S D将立即数传送到寄存器或者内存地址,两个操作数不能都指向内存位置
3.4.3数据传送示例
间接引用指针就是将该指针放在寄存器中,在内存引用中使用这个寄存器,像局部变量通常保存在寄存器中,访问寄存器比访问内存快得多。
3.4.4压入和弹出栈数据
栈在处理过程调用中起到至关重要的作用,存放在内存中的某个区域,“栈顶”元素的地址是所有栈中元素地址中最低的,栈顶在图的底部。%rsp中保存着栈顶元素的值。
pushq把数据压入栈中,将四字值压入栈中,首先要将栈指针减8,再将值写到新的栈顶位置。popq弹出数据。可以用偏移量访问栈内任意位置。
3.5算术和逻辑操作
3.5.1加载有效地址
leaq实际是movq指令的变形。它的指令形式是从内存读数据到寄存器,但实际上根本没有引用内存。它将有效地址写入目的操作数。
%rdx中值为x,leaq 7(%rdx,%rdx.4),%rax将%rax的值设置为5x+7
3.6控制
机器代码测试数据值,然后根据测试的结果改变控制流或数据流
3.6.1条件码
条件码寄存器
3.6.2访问条件码
条件码通常不会直接读取,常用的使用方法有3种:
1.根据条件码的某种组合,将一个字节设置为0或1
2.条件跳转到程序的某个其他的部分
3.有条件的传输数据
如SET指令,根据条件码的某种组合,将字节设置为0或1
3.6.3跳转指令
jmp指令,根据条件码的某种组合,或者跳转或者继续执行代码序列中下一条指令。
3.6.4跳转指令的编码
两种方法:1.相对地址,用差作为编码 2.用绝对地址编码
3.6.5用条件控制来实现条件分支
汇编器为不同的分支产生不同的代码块,插入条件和无条件分支,保证执行正确的代码块
3.6.6用条件传送来实现条件分支
沿着一条执行路径执行,条件不满足时走另一条路径,这种机制简单通用,但很低效。可以计算出条件操作的两种结果,然后根据条件是否满足从中选取一个。
处理器通过流水线(重叠连续指令)获得高性能,所以需要保持流水线中充满待执行的指令,处理器采用分支预测逻辑来猜测每条跳转指令是否会执行,如果预测错误会丢掉已做的工作,降低程序性能。
3.6.7循环
循环都可以用简单的策略来翻译,产生包含一个或多个条件分支的代码。
3.6.8 switch语句
使用跳转表使实现更加高效,跳转表是一个数组,表项i是一个代码段的地址。这个代码段实现当开关索引为i时程序应该采取的动作。
3.7过程
假设过程P调用过程Q,Q执行后返回到P,包含下面的机制
●传递控制:进入Q时,程序计数器必须为Q代码的起始地址,返回时,程序计数器设置为P中调用Q后面指令的地址
●传递数据:P向Q传递参数,Q向P返回地址
●分配和释放内存:调用Q需要为局部变量分配空间,返回前需要释放
3.7.1运行时栈
过程P调用Q时,会把返回地址压入栈中,当Q返回时从那个位置继续执行。过程P可以传递最多6个整数值(指针和整数),如果需要更多参数,P可以在调用Q前在栈帧中存储好这些参数,Q通过栈访问。
3.7.2转移控制
call Q调用Q,该指令把地址A压入栈中,并将PC设置为Q的起始地址
ret从栈中弹出A,并把PC设置为A
3.7.3数据传送
3.7.4栈上的局部存储
有时候局部数据必须放在内存中而不是寄存器中,过程减小栈指针在栈上分配空间,分配的结果作为栈帧的一部分,标号为局部变量:
1.寄存器满了
2.需要对该变量使用&,需要产生一个地址
3.某些局部变量是数组或结构,需要通过数组或结构引用访问到。
3.7.5寄存器中的局部存储空间
寄存器组是唯一被所有过程共享的资源。
●被调用者保存寄存器:过程P调用Q时,Q必须保存该寄存器的值,要么不改变它,要么存入栈中返回时再弹出旧值。
●调用者保存寄存器:P调用Q,P保存好寄存器的值,Q可以随意修改
3.7.6递归过程
过程调用在栈中都有自己的私有空间,因此多个未完成调用的局部变量不会相互影响
3.8 数组分配与访问
C语言的一个不同寻常的特点就是可以产生指向数组中元素的指针,并对这些指针进行运算。
3.8.1基本原则
3.8.2指针运算
p为指向T类型的指针,T类型大小为L,p值为x,那么p+i的值为x+Li
数组引用A[i]=(A+i)
3.8.3嵌套的数组
二维数组可以看成是元素为数组的一维数组
3.8.4定长数组
当程序要用常数作为数组的维度或者缓冲区的大小时,最好通过#define声明将这个常数与一个名字联系起来
3.8.5变长数组
历史上C语言只支持大小在编译时就能确定的多维数组,程序员需要变长数组时需要用malloc这样的函数为这些数组分配空间。
3.9异质的数据结构
3.9.1 结构 struct
产生访问结构内部的指针,只需将结构的地址加上该字段的偏移量
3.9.2联合
3.9.3数据对齐
对齐限制:K字节类型对象的地址必须是K的倍数。可以提高内存系统的性能,因为CPU读取内存是一块块读取的
3.10在机器级程序中将控制与数据结合起来
3.10.1理解指针
●每个指针都有一个类型
如char **cpp,cpp指向的对象自身就是一个指向char类型对象的指针
void*为通用指针,如malloc返回通用指针,通过类型转换变为有类型的指针。
●每个指针都有一个值,指针只是存放地址的变量
这个值是某个指定类型的对象的地址,特殊的NULL(0)表示指针没有指向任何地方。
●指针用&创建
●数组与指针紧密联系
●指针类型转换时,值不变
●指针也可以指向函数,函数指针的值是该函数机器代码表示中第一条指令的地址。
3.10.2应用:使用GDB调试器
3.10.3内存越界引用和缓冲区溢出
C对数组引用不进行任何边界检查,而且局部变量和状态信息都存放在栈中,对越界的数组元素的写操作会破坏存储在栈中的状态信息。
缓冲区溢出:如在栈中分配某个字符数组保存字符串,但是字符串的长度超出了为数组分配的空间,很多库函数如strcpy都不需要告诉它们缓冲区大小,容易溢出。
缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数,称为攻击代码。
3.10.4对抗缓冲区溢出攻击
1.栈随机化
攻击者插入代码要知道栈地址,所以可以使用如alloca在栈上分配指定字节数量的空间。程序不使用这段空间,但是它会导致程序执行后序的栈位置变化,分配的范围n要足够大,才能变化大,也要足够小,不至于浪费空间。
确定栈地址的方法:声明局部变量,打印它的地址。
2.栈破坏检测
在局部缓冲区和栈状态间存储一个金丝雀值,也称为哨兵值,可以通过检查这个金丝雀值,确定栈状态是否被破坏。
3.限制可执行代码区域
以前读和执行访问控制合并成一个1位的标志,可读就可执行,现在将它们分开。
3.10.5 支持变长栈帧
使用寄存器%rbp作为栈指针(基指针base pointer),用相对于%rbp固定偏移量的局部变量来访问变长数组元素