程序的机器级表示

Posted 清水寺扫地僧

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了程序的机器级表示相关的知识,希望对你有一定的参考价值。


1. 历史观点

Intel处理器的系列和架构演进:
Intel处理器系列俗称x86,此处引入IA32的概念,作为x86_64的32位前身,于1985年提出,是Intel机器语言之选。
8086(1978)->i386(1985)->Pentium(1993)->Core(2006)


2. 程序编码

对于使用机器级代码的机器级编程而言,有两种重要抽象:

  • 指令集体系结构或指令集架构(Instruction Set Architecture, ISA),来定义机器级程序的格式和行为;
  • 机器级程序使用的内存地址是虚拟地址,所提供的内存模型看起来像是一个非常大的字节数组;

机器执行的程序只是一个字节序列,是对一系列指令的编码。机器对产生这些指令的源代码几乎一无所知。在Linux上,汇编代码文件的后缀名是.s文件。对汇编文件会调用汇编器以产生目标代码文件,同时也存在将目标代码文件还原为汇编代码文件的反汇编器(其是基于机器代码文件中的字节序列来确定汇编代码的,而无需访问该程序的源代码或汇编代码。)。之后再对目标代码文件调用链接器,为其中的函数调用找到匹配的函数的可执行代码的位置。


3. 数据格式

数据格式方面,由于是16bit体系结构扩展至32bit,Intel用术语"字(word)“表示16bit数据类型。进而32bit称为"双字(double words)”,64bit称为"四字(quad words)",字节、字、双字、四字分别对应的汇编代码后缀为"b",“w”,“l”,“q”,特别的float类型对应的是"s"。


4. 访问信息/寄存器分类

一个x86_64的中央处理器单元(CPU)包含一组16个存储64位值的通用目的寄存器,以用来存储整数数据和指针。16个寄存器的命名有历史原因,其中有一重要的寄存器为%rsp(register stack pointer?),%rax(返回值寄存器,ret指令返回的即是%rax寄存器中的值),用作栈指针。此外对于寄存器,有一组标准的变成规范控制着如何使用寄存器来管理栈、传递返回值,以及存储局部和临时数据。以下表示的是16个存储寄存器分别的类别和寄存器演变过程:


5. 算术和逻辑操作

对于操作数(operand)指示符,也即是目的操作数的概念。其作用是指出执行一个操作中要使用的源数据值,以及放置结果的目的位置。各种不同的操作数的可能性分为三种:

  • 立即数(immediate):用来表示常数值;
  • 寄存器(register):表示某个寄存器的内容,16个寄存器的低位1字节、2字节、4字节或8字节中的一个作为一个操作数。访问寄存器比访问内存要快得多
  • 内存引用(memory reference):根据计算得到的地址(通常是有效的物理内存地址而不是虚拟内存地址)访问某个内存位置,在汇编中取出内存引用中的数值使用()表示取用,对应的是C语言当中的指针

常用的操作指示符如下(其中Imm表示的是立即数):
在这里插入图片描述

一般地,操作指令用法为:指令 源操作数 目的操作数。常见的操作指令如下(大多数操作都分成了指令类,这些指令类有各种带不同大小操作数的变种,leaq除外):

其中,leaq 指令能执行加法和有限形式的乘法。相应的,以上运算操作指令也有针对字节b、字w、双字l和四字q的操作变种,除了leaq指令。

移位操作,先给出移位量(立即数/存放在单字节寄存器%cl中,对应其它位数的%cx,%ecx,%rcx),第二项是给出的要移位的数。左移操作的SAL和SHL是等效的,而右移操作的SAR执行算术移位(填上符号位),SHR执行逻辑移位(填上0)。

在这里插入图片描述
其中除法操作使用参数寄存器%rdx用以运算和保存结果,%rax保存商,%rdx保存余数


6. 控制/指令执行顺序

条件码:CPU在整数寄存器之外,还维护一组单个位的条件码(condition code)寄存器,描述了最近的算术或逻辑操作的属性。可通过检测条件寄存器来执行条件分支指令。常用的条件码有:

  • CF: 进位标志。最近的操作使最高位产生了进位。可用来检査无符号操作的溢出;
  • ZF: 零标志。最近的操作得出的结果是否为0,1表示是,0表示不是;
  • SF: 符号标志。最近的操作得到的结果为负数;
  • OF: 溢出标志。最近的操作导致一个补码溢出—— 正溢出或负溢出。

算术与逻辑运算当中的操作指令除了leaq指令不会改变任何条件码外(因为其用来进行地址计算),其余指令都会根据结果设置条件码。

除了以上的算术和逻辑运算指令,还有两类指令TESTCMP指令,他们只设置条件码而不改变任何其它寄存器。

条件选择:对于条件码的访问,一般不会直接读取,常用的使用方法有三种:

  • ①根据条件码的某种组合,将一个字节设置为0或1。将这一整类指令称为SET指令;它们之间的区别就在于它们考虑的条件码的组合是什么,这些指令名字的不同后缀指明了它们所考虑的条件码的组合。所设置的条件码字节作为跳转的依据;
  • ②可以条件跳转到程序的某个其它的部分jmp指令(jump)会导致执行切换到程序中一个全新的位置,在汇编代码当中,跳转的全新位置通常用一个标号label指明。跳转又分为直接跳转(给出标号作为跳转目标)和间接跳转();汇编当中的if-else语句的控制流与C语言当中的goto语句的实现相近(为then-statement和else-statement产生各自的代码块。它会插入条件和无条件分支,以保证能执行正确的代码块)。没有下边的读取方法高效
  • ③可以有条件的传送数据。实现条件操作的传统方法是通过使用控制的条件转移,但是该方法在现代处理器上效率低下。该种方式计算一个条件操作的两种结果,再根据条件是否满足从中选取一个结果传送给返回值/目的寄存器。只有在受限制的情况下,该策略才可行。

但是上面的②③两者在编译器的具体应用取决于浪费的计算和由于分支预测错误所造成的性能处罚之间的相对性能而做出的提高相对性能的选择。

对于switch/开关语句,其是根据一个整数索引值进行多重分支(multiway branching)。使用跳转表(jump table)数据结构使得改语句的实现更加高效,跳转表是一个数组,表项i是一个代码段地址,该代码段实现当开关索引值等于i时程序应采取的动作,其优点是执行开关语句的时间与开关情况的数量无关。实际实现中也是使用到了goto语句(GCC支持),GCC同时会根据开关情况(case)的数量和开关情况值的稀疏程度来翻译开关语句为汇编语言。

循环:汇编中没有对应do-while、while和for的相关指令,所以使用条件测试和跳转组合起来以实现循环的效果。

do-while 实现
while 实现(jump to middle方式)
for 实现(guarded-do方式)
其中,while和for的实现又分为jump to middle(图片中的,先进行test-expr,若符合跳转到loop标签处)和guarded-do(先进行test-expr计算,再转换为do-while)两种。在上图中分别列举了具体的实现方法。

7. 程序的运行过程

对于过程,是软件中一种很重要的抽象,是一种用一组指定的参数和一个可选的返回值实现某种功能的封装代码的方式,常见的有函数(function)、方法(method)、子例程(subroutine)和处理函数(handler)等。

假设过程P调用过程Q,Q执行后返回到P。这些动作/运行过程包括以下一个或多个机制

  • 传递控制:进入Q时,PC必须被设置为Q代码的起始地址,在返回时,PC须设置为P中调用Q后面的那条指令的地址(对应运行时栈图中的返回地址内容),使用call Q指令来记录P的调用位置,即返回地址;
  • 传递数据:P必须能够为Q提供一个或多个参数,Q必须能够向P返回一个值。x86-64中,可通过寄存器最多传递6个整形(如整数和指针)参数,所使用寄存器名称按照所要传递的数据类型大小可分为如下图中的名称。如果一个函数有大于6 个整型参数,超出6 个的部分就要通过栈来传递,而参数7 位于栈顶;
  • 内存管理:在开始时,Q可能需为局部变量分配空间,在返回前,又必须释放这些空间,这些关于内存的分配和释放操作是不可或缺的。
    在这里插入图片描述

运行时栈:利用了栈数据结构后进先出的内存管理规则。对于一个程序而言,其x86-64过程需要的存储空间超出寄存器所能存放的大小(6个参数寄存器)时,就会在栈上分配空间(用以存储第7个开始的参数)。即将%rsp寄存器的值减小一个适当的量,这个部分称为该过程的栈帧(stack frame)。当前正在执行的过程的帧总是在栈顶。当正在执行的过程执行完毕,栈指针寄存器也会增加对应的量,回收栈内存。

栈上的局部存储:大多时候,过程无需超出寄存器大小的本地存储区域,但有些时候,局部数据必须存放在内存中。比如:

  • 寄存器不足够存放所有的本地数据(已提到,多于6个参数的情形);
  • 对一个局部变量使用地址运算符&,因此必须能够为它产生一个地址(即程序中有局部(相对于运行程序)指针变量);
  • 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到(将在数组或结构分配中提到);

所分配的结果作为栈帧的一部分,标记为“局部变量”,如上图所示。

寄存器中的局部存储空间:寄存器组是唯一被所有过程共享的资源。须保证被调用者不会覆盖调用者稍后会使用的寄存器值。因此有如下惯例需遵循:

  • 被调用者保存寄存器:当过程P 调用过程Q 时,Q 必须保存这些寄存器的值,保证它们的值在Q 返回到P 时与Q 被调用时是一样的;
  • 调用者保存寄存器:所有除栈指针和被调用者保存寄存器外的寄存器。过程P 在某个此类寄存器中有局部数据,然后调用过程Q。因为Q 可以随意修改这个寄存器,所以在调用之前首先保存好这个数据是P(调用者)的责任。

递归过程:递归调用一个函数本身与调用其他函数是一样的。栈规则提供了一种机制,每次函数调用都有它自己私有的状态信息(保存的返回位置和被调用者保存寄存器的值)存储空间。如果需要,它还可以提供局部变量的存储。栈分配和释放的规则很自然地与函数调用-返回的顺序匹配。这种实现函数调用和返回的方法甚至对更复杂的情况也适用,包括相互递归调用(例如,过程P 调用Q, Q 再调用P)。


8. 数组分配和访问

数组的存在将标量数据聚集在一起形成更大数据类型。C语言中,可以产生执行数组中元素的指针,即生成数组的索引,并对这些指针做*,++,–等运算,在机器代码中,这些指针会翻译成地址计算。其中,优化编译器非常善于简化数组索引所使用的地址计算。

C语言中,单操作数操作符&*可以产生指针和间接引用指针。也即数组引用A[i]等价于表达式*(A+i),它计算第i个数组元素的地址,然后访问这个内存位置。

对于嵌套数组int A[5][3],可以被看成一个5 行3 列的二维数组,用A[0][0]A[4][2]来引用。数组元素在内存中按照“行优先”的顺序排列,意味着第0行的所有元素,可以写作A[0]…,则将A 看作一个有5 个元素的数组,每个元素都是3 个int 的数组。要访问多维数组的元素,编译器会以数组起始A为基地址,(可能需要经过伸缩的)偏移量为索引,产生计算期望的元素的偏移量( & A [ i ] [ j ] = x A + L ( C ∗ i + j ) \\&A[i] [j] = x_A +L(C * i + j) &A[i][j]=xA+L(Ci+j) ),然后使用某种MOV(b/w/l/q) 指令。

对于定长数组int A[N][N],当我们计算矩阵A和B的乘积矩阵C的元素C[i][j]时,有如下代码:

/* Compute i,k of fixed matrix product 未优化版本,直接进行乘和加*/
int fix_prod_ele (fix_matrix A, fix_matrix B, long i, long k){
	long j;
	int result = 0;
	
	for (j = 0; j < N; j++)
		result += A[i][j] * B[j][k];
	
	return result;
}

/* Compute i,k of fixed matrix product */
int fix_prod_ele_opt(fix_matrix A, fix_matrix Bÿ long i, long k)
	int *Aptr = &A[i][0]; /* Points to elements in row i of A*/
	int *Bptr = &B[0][k]; /* Points to elements in column k of B*/
	int *Bend = &B[N][k]; /* Marks stopping point for Bptr*/
	int result = 0;
	do{ /* No need for initial test */
		result += *Aptr * *Bptr; /* Add next product to sum*/
		Aptr ++;                 /* Move Aptr to next column */
		Bptr += N;               /* Move Bptr to next row*/
	}while (Bptr != Bend);       /* Test for stopping point */
	return result;
}
/*优化内容:
它去掉了整数索引j, 并把所有的数组引用都转换成了指针间接引用,其中包括:
(1)生成一个指针,命名为Aptr,指向A 的行i 中连续的元素;
(2)生成一个指针,命名为Bptr, 指向B 的列k中连续的元素;
(3)生成一个指针,命名为Bend, 当需要终止该循环时,它会等于Bptr 的值。*/

对于变长数组,这是C语言当中的新特性(历史上只支持大小在编译时就能确定的多维数组),在ISO C99当中引入,允许数组的维度是表达式,在数组被分配时才计算出来。对于int A[n][k],参数n,k必须在参数A[n][k]之前就已经初始化才行。对于动态数组,必须用乘法指令对i(实际的变长数组第i个元素的内存地址值)伸缩k倍,而不能使用移位和加法的组合,很可能会招致不可避免的性能处罚。


9. 异质数据结构: struct、union

对于struct:C语言的struct 声明创建一个数据类型,将可能不同类型的对象聚合到一个对象中。用名字来引用结构的各个组成部分。类似于数组的实现,结构的所有组成部分都存放在内存中一段连续的区域内,而指向结构的指针就是结构第一个字节的地址。编译器维护关于每个结构类型的信息,指示每个字段(field)的字节偏移。它以这些偏移作为内存引用指令中的位移,从而产生对结构元素的引用。在由C语言转换为机器代码的过程中,结构的各个字段的选取完全是在编译时处理的,即机器代码不包含关于字段声明或字段名字的信息。

对于union:允许以多种类型来引用一个对象。联合声明的语法与结构的语法一样,但其用法①用于同一个数据结构中的若干不同字段是互斥的情况下,而不是结构体中共存的情况。union可以减少分配空间的总量,特别是对于由较多字段的数据结构,这样的节省很吸引人。用法②:用来访问不同数据类型的位模式。例如,假设我们使用简单的强制类型转换将一个double 类型的值d 转换为unsigned long 类型的值(需注意大小端序的问题)。

相同字段的struct和union的内存偏移如下:
在这里插入图片描述
对于S3当中的偏移量,涉及数据对齐(要求某种类型对象的地址必须是某个值K(通常是2、4 或8)的倍数,对于int*要求是4的倍数,double要求是8的倍数(详细见下表),所以会有i是4(4~12),v是16),对于U3中的偏移量结合用法①和数据对齐,可以得到大小为8(取字长最长的字段分配空间)。

对于数据对齐:许多计算机系统对基本数据类型的合法地址做出了一些限制,要求某种类型对象的地址必须是某个值K(通常是2、4 或8)的倍数。这种对齐限制简化了形成处理器和内存系统之间接口的硬件设计。无论数据是否对齐,X86-64 硬件都能正确工作。不过,Intel 还是建议要对齐数据以提高内存系统的性能。对齐原则是任何K 字节的基本对象的地址必须是K 的倍数。可以看到这条原则会得到如下对齐:

确保每种数据类型都是按照指定方式来组织和分配,即每种类型的对象都满足它的对齐限制,就可保证实施对齐。对于自定义数据结构(结构体,不涉及类的讨论,类大小见:C++类空间大小)有结构内偏移末尾填充两种方式实现数据对齐。


10. 在机器级程序中将控制与数据结合起来

指针的理解:

 1. 每个指针都对应一个类型;
 2. 每个指针都有一个值;
 3. 指针用 & 运算符创建;
 4. * 操作符用于间接引用指针;
 5. 数组与指针紧密联系;
 6. 将指针从一种类型强制转换成另一种类型,只改变它的类型,而不改变它的值;
 7. 指针也可以指向函数,即函数指针的概念,见:
 [c++中的函数指针转函数参数](https://blog.csdn.net/yueguangmuyu/article/details/112279449)

内存越界引用和缓冲区溢出

C 对于数组引用不进行任何边界检查,而且局部变量和状态信息(例如保存的寄存器值和返回地址)都存放在栈中。若是局部变量的赋值等操作造成了越界访问,即对运行时栈中的状态信息进行了破坏,当程序使用这个被破坏的状态试图重新加载寄存器或执行ret指令时,就可能导致严重的程序错误。

常见的状态破坏是缓冲区溢出(buffer overflow)。缓冲区溢出的一个更加致命的使用就是让程序执行它本来不愿意执行的函数。通常,①输入给程序一个字符串,这个字符串包含一些可执行代码的字节编码,称为攻击代码(exploit code), ②还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret 指令的效果就是跳转到攻击代码。有两种攻击形式:

  • 攻击代码会使用系统调用启动一个shell 程序,给攻击者提供一组操作系统函数;
  • 攻击代码会执行一些未授权的任务,修复对栈的破坏,然后第二次执行ret 指令,(表面上)正常返回到调用者;

避免和对抗缓冲区溢出攻击,有以下三种方式:

  • 栈随机化:为解决安全单一化(security monoculture)问题引入,使得程序的栈地址难以预测,但是在32位系统上可穷举,在64位系统上花费较大代价仍可穷举。已是Linux的标准行为了;
  • 栈破坏检测:GCC在所产社工的代码中加入栈保护者(stack protector)机制,检测缓冲区越界。在栈帧中任何局部缓冲区和栈状态之间存储一个随机产生的特殊金丝雀/哨兵值(攻击者难以知道)。在恢复/加载寄存器前检测该值是否被非法改变,若是则程序异常中止;
  • 限制可执行代码区域:目的和思想是消除攻击者向系统中插入可执行代码的能力。

支持变长帧栈:为了管理变长栈帧,X86-64 代码使用寄存%rbp (被调用者保存寄存器)作为帧指针(frame pointer)(有时称为基指针(base pointer),这也是%rbp 中bp两个字母的由来)。在较早版本的x86 代码中,每个函数调用都使用了帧指针。而现在,只在栈帧长可变的情况下才使用。


11. 浮点代码


12. 总结及归纳

只分析了C 到X86-64 的映射,但是大多数内容对其他语言和机器组合来说也是类似的。

编译C++ 与编译C 就非常相似。实际上,C++ 的早期实现就只是简单地执行了从C++ 到C 的源到源的转换,并对结果运行C 编译器,产生目标代码。C++ 的对象用结构来表示,类似于C 的struct, C++的方法是用指向实现方法的代码的指针来表示的。

相比而言,Java 的实现方式完全不同。Java 的目标代码是一种特殊的二进制表示,称为Java 字节代码。这种代码可以看成是虚拟机的机器级程序。正如它的名字暗示的那样,这种机器并不是直接用硬件实现的,而是用软件解释器处理字节代码,模拟虚拟机的行为。

另外,有一种称为及时编译(just-in-time compilation)的方法,动态地将字节代码序列翻译成机器指令。当代码要执行多次时(例如在循环中), 这种方法执行起来更快。用字节代码作为程序的低级表示,优点是相同的代码可以在许多不同的机器上执行,而在本章谈到的机器代码只能在X86-64 机器上运行。

以上是关于程序的机器级表示的主要内容,如果未能解决你的问题,请参考以下文章

深入理解计算机系统(第二版)----之三:程序的机器级表示

程序的机器级表示

程序的机器级表示

深入理解计算机系统之程序的机器级表示部分学习笔记

机器级表示总结一,移位运算,控制指令

程序的机器级表示