读书笔记:CSAPP Chapter3 Machine-Level Representation of Programs
chapter 3
程序的机器级表示
在第一章1.2节中,我们了解过编译系统(compaliation system),也叫编译器;编译器是基于编程语言的规则、目标机器的指令集和操作系统来执行一系列的阶段(见1.2节)来生产机器代码(用字节序列编码的低级操作)的。
GCC C语言以汇编代码(本章的学习重点)的形式产生输出,汇编代码是机器代码的文本表示,给出程序的每一条指令;然后GCC再调用汇编器和链接器,根据汇编代码生成可执行文件(可执行的机器代码)。

通常来说,因为高级语言很成熟,所以在高级语言上执行操作,相对于一般的汇编语言程序员来说,效率更高,可靠性也更高。
那为什么我们还要学习汇编语言?
因为通过阅读汇编代码,我们可以分析代码中隐含的低效率,并了解程序将要运行的效率如何。另外高级语言会隐藏了解程序的运行时行为,这些行为可以帮助了解程序为什么会容易受到攻击等等好处。
3.1 历史观点
1.每个后继处理器的设计都是后向兼容的;
2.IA32(Intel Architecture 32-bit):Intel 32位体系结构
3.Intel64:IA32的64位扩展,也就是我们说的x86,用它代指示整个系列
4.许多公司生产了和Intel处理器兼容的处理器,能够运行完全相同的机器级程序(如AMD)。
5.对于在GCC上编译,Linux上运行的程序,大多都不关心x86的复杂性
3.2 程序编码
假设一个C程序,有两个文件p1.c和p2.c。我们使用Unix命令行编译这些代码:
|
|
命令解释:
gcc:GCC C编译器 Linux上默认的编译器
-Og:指优化等级:-Og表示编译器使用会生成符合原始C代码整体结构的机器代码,但优化等级低 ;-O1表示使用一级优化 -O2表示使用二级优化
-o p:制定最终生成的可执行文件为p
编译过程:

3.2.1 机器级代码
在1.9.3中我们了解到计算机系统使用不同形式的抽象:
对于机器级编程来说,两种重要的抽象最为重要:
1.指令集体系结构(也叫做指令集架构 Instruction Set Architecture):用ISA来定义机器级程序的格式和行为。
每条指令看似是顺序执行的,但其实是并发执行多条指令,但采取措施使得看上去是顺序执行
2.机器级程序使用内存地址是虚拟的:虚拟内存看上去是一个很大的字节数组,其实是有多个硬件存储器和操作系统软件组合起来的,操作系统负责管理虚拟地址空间,将虚拟地址空间翻译成实际处理器内存中的物理地址。
汇编语言和机器代码的区别:
汇编代码有更好可读性的文本格式
x86-64的机器代码和原始C语言的区别—处理器的很多隐藏状态都是可见的:
1.程序计数器(Program counter——在x86-64中用%rip表示):给出将要执行的下一条指令在内存中的地址
2.整数寄存器文件(integer register file):存储地址(C语言的指针)或整数数据(对应3.4的通用目的寄存器)
3.条件码寄存器(condition code register):保存最近执行的算术或逻辑指令的状态信息,用来实现控制和数据流中的条件变化(如if while)。
4.一组向量寄存器(a set of vector registers):存放一个或多个整数或浮点数值。
机器代码是不区别数据类型(如有无符号数、不同类型指针),只是把内存看作一个很大的 、按字寻址的数组。
程序内存(虚拟内存-如下图)包含:可执行机器代码、操作系统的一些信息、用户分配的内存块;而且程序内存用虚拟地址来寻址,在任意给定的时刻,只有有限的虚拟地址认为是合法的,这也是为什么64位机器实际上一个地址只能制定2^48范围内的一个字节,高16位必须设置为0,操作系统负责管理虚拟地址空间,将虚拟地址空间翻译成实际处理器内存中的物理地址。
3.2.2 代码实例
C语言代码文件mstore.c如下所示:
|
|
1.通过-S命令将mstore.c生成一个mstore.s汇编文件:
|
|
|
|
2.通过-c命令将mstore.c生成一个mstore.o汇编文件:
|
|
mstore.o中包含如下一段14字节的序列,也包含其他内容
|
|
因为是包含,我们可以通过如下代码在mstore.o文件上运行GUN调试工具GDB
通过如下代码找到mstore的二进制目标文件(Executable Object Program)
|
|
x:告诉GDB显示(第一个x)从函数multstore(multstore)处地址开始的14(14)个16进制格式(第二个x)表示的字节(b)–括号中对应上面代码中每个字符的位置
3.反汇编器程序
机器执行程序时,只是读取一个字节序列,他是对一系列指令的编码,机器对产生这些编码的源代码(C程序代码、汇编指令代码)一无所知。
我们可以通过反汇编器(disassembler)使机器代码生成一种类型于汇编代码的格式(但还是和汇编代码有所区别)
|
|
objdump:object dump程序
-d:运行该程序 可以对.o文件和可执行文件进行抽取。
运行结果如下所示(蓝色字体为后来加的注释):
按照前面给出的14个字节顺序,将他们分为若干组表示不同的指令。
1:表示mstore程序在虚拟内存中的地址(因为此时还不是可执行文件,所以地址是假的用0000000000000000表示)
2.表示0x53储存在0位置,0x53表示操作push %rbx
3.表示0x4889d3储存在1位置,0x48 89 d3表示操作mov %rdx,%rbx
4.因为上一个指令占3个字节,所以offset从4开始
5.6.7以此类推
关于机器代码和反汇编表示的一些说明:
1.x86-64的指令长度从1到15个字节不等 常用指令和操作数较少时,指令较短。
2.从某个字节开头可以唯一的解释为某个指令 如53开头是push 48开头的是mov等
3.反汇编器只需要通过机器代码文件等字节序列就可以确定汇编代码
4.反汇编器生成的指令和GCC生成的指令有略微不同,如很多指令省略了q结尾(如push),某些指令又加上了q结尾(如ret)
q表示的是大小指示符
到目前为止,生成的都还只是.o文件(可重定向的目标程序),如果需要生成可执行目标文件,则文件中必须含有一个main函数。
|
|
|
|
通过运行下列指令,生成可执行文件prog.(这和之前3.2中运行的编译命令一致)
prog中不仅包含了上面两个函数的代码,还包含了与操作系统交互、用来启动和终止程序的代码。
|
|
通过反汇编器,抽取出包含multstore的代码:
|
|
其中multstore部分为:
之前在mstore中反汇编抽取到为:
这和我们之前在mstore.c中对multstore抽取的代码几乎一样,主要的区别在于:
1.Linker将代码的地址移植到了一段不同地址范围=(起始地址(0x400540)+offset)
2.链接器填上了函数mult2需要使用到的地址。
3.多了最后两行,这两条指令对程序没有影响,目的是为了使函数的代码变为16字节,方便更好的放置下一个代码块。
3.2.3 关于格式的注解
我们使用GCC命令行生成如下mstore.s文件:
|
|
通过观察我们发现,mstore.s中包含了很多.开头的文件,这些行都是指导汇编器和Linker工作的伪指令。
我们3.2.3说过,x86-64的机器代码和原始C语言的区别是处理器的很多隐藏状态在机器代码时才是都是可见的,有时候,程序员想访问这些低级特性有两种方法:
1.用汇编代码编写整个函数,形成一个独立的汇编代码文件,再让汇编器和Linker把它和C语言书写的其他程序代码合并起来;
2.使用GCC的内联汇编特性,用asm伪指令可以在c语言中包含简短的汇编代码
3.3 数据格式
word:16bits

注意:
1.汇编代码的后缀“l“可能是双字也可能是双精度浮点数。
2.move指令有四个变种(movb(传送字节)、movw(传送字)、movl(传送双字)、movq(传送四字))。
movl一定表示传送双字,而不是传送双精度浮点数;因为浮点数使用的指令和寄存器和整型使用的不一样。
3.除了单精度浮点数和多精度浮点数,x86实现过一种80位浮点格式的全套浮点运算,用long double表示。
3.4 访问信息
一个x86-64的中央处理器单元爆了一组16歌储存64位值的通用目的寄存器(整数寄存器),这些寄存器用来存储整数数据和指针。整数寄存器如下图所示:
我们知道,历代的CPU有一个非常好的特性就是向后兼容,因此x86中%ax-sp(来自于8086)、%eax-%esp(来自IA32)、%rax-%rsp(对前一代的扩展)并增加了新的8个register(%r8-%r18)。
看上图我们需要了解的知识点:
1.嵌套的方框表明指令可以对这16个寄存器的地位字节进行访问和存取操作。
2.对于存放到寄存器中的数据有两条规则:
(1)生成一字节和两字节数据的指令会保持寄存器剩下的字节不变
(2)生成四位的数据会把寄存器的高4字节置为0
3.我们可以在3.7节看到如何使用寄存器来管理 栈、传递参数、从函数的返回值和存储器的局部及临时数据。
3.4.1 操作数指示符(Operand Specifiers)
什么是操作数?
用来指明指令中要操作使用的源数据值
源数据的值可以以三种形式出现:(立即数、寄存器值、存储器值)
注:
1.s为比例因子(scale factor),只能取1、2、4、8
2.基址寄存器rb(basic register)和变址寄存器ri(index register)都必须是64位((ra)此时的ra也相当于基址寄存器)。
基址寄存器和变址寄存器只在Imm(rb,ri,s)及其变种的情况下出现。
3.4.2数据传输指令
为什么说操作数时得一条简单的数据传送指令能够完成好多机器上要好几条不同的指令才能完成的任务?
因为通过操作数的存储方式可以将存储方式用简单的几个字符就能表示,方便数据传输指令进行传输。
将不用的指令化分为多种不用的类,接下来我们将介绍MOV类、MOVZ类和MOVS类
MOV类指令:
MOV类指令把数据从源位置复制到目的位置,不做任何变化(不用指令的区别是操作数据的大小不同)
注意点:
1.源操作数S:可以是一个立即数、存储在寄存器中的数或者存储在内存中的数,若S为寄存器中的数,则寄存器部分的大小必须与指令最后一个字符指定的大小相匹配。
2.目的操作数D:目的操作数指定的位置可以是寄存器或者内存
3.⚠️不能将一个值从内存复制到另一个内存中 如果要实现复制需要使用两条指令:将数据从内存->寄存器->内存
4.MOV指令通常只会更新目的操作数指定的那些寄存器字节或者位置,但唯一例外是movl指令以寄存器为目的时,会把寄存器的高4个字节设置为0(这符合3.4规则)
MOV指令的五种可能性:
5.movabsq:常规的movq指令只能将表示32位的补码数字扩展为64位(如何扩展见第二章)后再放到目的位置,而movabsq指令可以将任意的64位立即数值作为操作数,但只能以寄存器作为目的地。
MOVZ类指令:
使用情形:将较小但数据源值复制到较大但目的地时使用
注意点:
1.所有这些指令都把数据源从(寄存器或者内存)复制到目的寄存器中 ⚠️⚠️⚠️
2.MOVZ指令把剩余位用0填充
3.MOVZ指令的最后两个字符分别指明源和目的的大小
4.MOVZ中有1Byte->2Byte、1Byte->4Byte、2Byte->4Byte、1Byte->8Byte、2->8Byte
5.MOVZ中没有将4Byte->8Byte的指令,但此指令可以用movl指令实现,因为movl指令会把高4字节变为0
6.千万注意是无法以立即数作为源数据

MOVS指令
注意点:
1.所有这些指令都把数据源从(寄存器或者内存)复制到目的寄存器中 ⚠️⚠️⚠️
2.MOVZ指令把剩余位用符号位填充
3.MOVZ指令的最后两个字符分别指明源和目的的大小
4.MOVZ中有1Byte->2Byte、1Byte->4Byte、2Byte->4Byte、1Byte->8Byte、2Byte->8Byte、4Byte->8Byte
5.cltq总是以%eax作为源,%rax作为符号位扩展的目的地
