目录
1.预处理
2.编译
3.汇编
3.1 机器码的格式
3.2 编码过程
4.链接
5.取指
6.译码
7.执行
8.访存
9.写回
10.更新PC
今天来谈谈,一句代码,是如何从被编辑到被执行的。以下为本人在阅读相关书籍资料后的初步理解,仅供参考,如有错误,还望各位老师不吝指教,多谢!
我要运行一个程序,我首先得写下来吧!比如说我现在在IDE中写下了以下程序:
#include "stdafx.h"
#include "iostream"
int main()
{
int x = 0;
int y;
y = x + 1;
return 0;
}
这个程序应当是非常简单,仅次于“哈喽我的”了,现在,这堆代码就存在于磁盘上了,也就是我们电脑中的ABCDEFG盘等等。这个时候的程序,是以文本形式存放在磁盘上的,什么叫文本形式呢?即是将程序中的每个字符以其ASCII码值进行存储在文件中,借用《CSAPP》书中的“哈喽我的”程序存储形式,如下所示:
这样实际上文件中是按字节序列存储的一系列整数值,而每一个字节的整数值都对应一个字符。
那么当我运行这段程序后,会发生什么呢?
1.预处理
在这个阶段主要实现以下几个功能:
将所有的"#define"删除,并且展开所有的宏定义;
处理所有条件预编译指令,比如"#if"、"#ifdef"、"#elif"、"#else"、"#endif";
处理"#include"预编译指令,将被包含的文件插入到该预处理指令位置。这是一个递归过程,也就是说被包含的文件可能还 包含其他文件;
删除所有的注释;
添加行号和文件名标识,以便编译时编译器产生调试用的行号信息及用于编译时产生编译错误或警告时能够显示行号;
保留所有的#pragma编译器指令,因为编译器需要使用它们;
预处理后,通常以.i作为文件扩展名,此时的程序依然为文本形式。
2.编译
简单来说,编译过程就是把预处理完的文件进行一系列词法分析、语法分析、语义分析及优化后生产相应的汇编代码文件。编译后的文件通常以.s作为文件扩展名,此时的程序为汇编语言构成的文本形式。如下所示(仅截取一部分):
图中,红色框标注的就是经过编译后形成的汇编代码,也可以看出这段汇编代码就是在对函数栈帧进行操作的。尽管已经翻译为了汇编语言,但是电脑又不懂英语,它怎么知道“push”、"mov"是什么呢?不用着急,接下来,就进入最最重要的环节,来将汇编代码翻译成电脑看得懂的东西——汇编。
3.汇编
汇编阶段会将第二步中生成的汇编语言翻译为一串二进制序列,这些0和1就是最终和CPU打交道的数字。
汇编是编译器完成的。在分析汇编阶段之前,不得不说一下指令集。我所理解的指令集有两种,一种是汇编指令集,一种是CPU指令集。其中汇编指令集是将汇编代码翻译为一串二进制代码,不同的汇编代码应当对应不同的二进制代码(机器码),而如何将不同的汇编代码翻译成相对应的二进制代码,这就需要根据汇编指令集来翻译了。而对于CPU指令集,当CPU识别到一串二进制代码(机器码)后,CPU会根据CPU指令集来决定自己要干什么,然后相应的操作寄存器、访问内存等等。。。常常提到的“x86、x64、x86-64、IA-32、IA-64"其实也就是CPU指令集了,它们规定了CPU在接收到什么样的机器码后必须做出什么样的行为。而汇编这个阶段,当然需要用到的就是汇编指令集了。
汇编实际上就是一个对汇编代码进行二进制编码成机器码的过程,由于编码出机器码最终是与CPU沟通的,并且我还没能够掌因此,与其根据汇编代码来分析机器码,不如从机器码来进行译码分析。以下以IA-32指令体系为例。
3.1 机器码的格式
机器码的格式如图所示。
可见,一条指令由: 指令前缀(Instruction Prefixes) + 操作码(Opcode) + ModR/M + SIB + 偏移(displacement) + 立即数(Immediate data) 几部分组成,一条指令至少需要有Opcode,其它几部分,在不同指令中可能存在可能不存在。
①Instruction Prefixes(指令前缀)
在x86中,指令前缀有四类:
lock/repeat prefix :F0/F2,F3 (F0用于LOCK指令中,保证在当前指令执行期间,对内存进行独占式访问;F2、F3为重复前缀,主要用于串操作。其中F2主要用于REPNE/REPNZ,F3主要用于REP/REPE/REPZ;
segment override prefixes :2E,36,3E,26,64,65 (忽略段的前缀)
operand-size override prefix :66 ,操作数大小切换,如果默认位数为32位,则经切换后操作数为16位;反之亦然
address-size override prefix :67 ,地址大小切换,同上。
② Opcode(操作码)
操作码就指定了要执行什么操作,比如执行MOV、ADD、SUB等等。操作码可以是单字节、双字节或者是三字节的,我们常见的一些指令大多都是单字节指令。单字节操作符如表所示:
需要注意的是,表中,行代表了单字节操作码的高四位值,列代表了单字节操作码的低四位值,在该8位中,第0位还表示字操作标志位,称为w位;第1位表示指示操作数的传送方向,称为d位,只有在源操作数和目的操作数用Mod码表示不清楚时才会考虑d。当d=0时,在ModR/M码中,reg字段为源操作数,r/m和mod字段为目的操作数;当d=1时,r/m和mod字段为源操作数,reg字段为目的操作数。w=0时为字节操作指令,w=1是字操作指令(一个字为两个字节)。
x86架构下,通用寄存器主要有8个:
8位 |
al |
bl |
cl |
dl |
sil |
dil |
bpl |
spl |
16位 |
ax |
bx |
cx |
dx |
si |
di |
bp |
sp |
32位 |
eax |
ebx |
ecx |
edx |
esi |
edi |
ebp |
esp |
对于表中加深颜色的地方还需查Grp表:
③ModR/M(寻址模式)
ModR/M为单字节(若有),R表示Register寄存器,M表示Memory存储器(内存)。整个8位的划分形式为2:3:3,其中0~2位为R/M,为目的操作数的地址;3~5位既可以作为操作码的补充,也可以作为源操作数的地址;6~7位用来说明目的操作数的地址类型。ModR/M可进行寄存器寻址r,寄存器间接寻址[r]以及寄存器基址寻址[r+disp],寻址模式表如图所示。
需要注意的是,在ModR/m码中,mod字段和r/m字段为一组,reg字段为另一组,当二者谁表示源操作数谁表示目的操作数关系不明确时,这时候就由操作码的d位决定的,那什么时候关系很明确呢?比如说lea指令是加载有效地址,目的操作数必须是寄存器,源操作数一般为内存中取数再偏移,此时就只能是mod+r/m字段来表示源操作数了。图中如[EDX]所示意为间接寄存器寻址,实际寻址目的在内存中,无括号意为寄存器寻址。其中还有disp用于表示地址偏移量,disp8表示地址偏移8位,disp32表示地址偏移32位。
除此之外,在图中目的操作数还有[--][--]等的情况,当指令寻址方式为基址+变址寻址时,这种情况下ModR/M不能完成准确的寻址,此时就需要用到SIB来补充寻址。
④SIB(补充寻址)
ModR/M无法进行基址+变址寻址,SIB则是对ModR/M寻址方式的一种补充。SIB也是单字节(若有),和ModR/M相似,整个字节也被划分为2:3:3,其中0~2位为Base,即基址;3~5位为index,即索引;6~7位为Scale,寻址方式为Base+index*2^Scale,如图所示。
⑤Displacement(偏移量)
⑥Immediate(立即数)
对上述所有表中部分重要标识符作出解释:(参考Inter白皮书Appendix A Opcode Map章节A.2)
标识符 |
描述 |
E |
操作数为通用寄存器或内存地址。如果是内存地址,则该地址由SBI(scaling factor、base register、index register)以及偏移量displacement计算得出 |
G |
操作数为通用寄存器 |
O |
操作数为内存地址,没有ModR/M码以及SIB码,偏移量由实际地址大小决定,可以为Word型或doubleWord型 |
b |
指定操作数为Byte型(8位),不管操作数实际大小 |
c |
操作数大小由实际操作数决定,可以为Byte型或Word型(16位) |
d |
指定操作数大小为DoubleWord型(32位) |
p |
操作数为指针,大小由实际操作数决定,可以为32位、48位或80位 |
q |
指定操作数为QuarWord型(64bits),不管实际操作数大小 |
v |
操作数大小由实际操作数决定,可以为Word、doubleWord或quadword型(64位下) |
w |
操作数大小为word型,不管操作数实际大小 |
z |
如果操作数实际大小为16位,那么就取为Word型;如果操作数实际大小为32或者64位,那么就取为doubleword型 |
好了,把几个关键的东西说了一下,多半是不大清楚的,现在我们来实际应用一下。
3.2 编码过程
再来贴一下经编译后的汇编程序:
int main()
{
push ebp
mov ebp,esp
sub esp,0D8h
push ebx
push esi
push edi
lea edi,[ebp+FFFFFF28h]
mov ecx,36h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
int x = 0;
mov dword ptr [ebp-8],0
int y;
y = x + 1;
mov eax,dword ptr [ebp-8]
add eax,1
mov dword ptr [ebp-14h],eax
return 0;
xor eax,eax
}
我们以前面一部分程序为例,简述编码过程(部分)如下:
- push ebp。操作指令为push,操作数为ebp,对应操作码表中第5行第5列,编码55(rbp为ebp的32位延伸);
- mov ebp,esp。操作指令为mov,源操作数为esp寄存器,目的操作数为ebp寄存器,并且由于操作数都是32位,因此操作码为89或8B,实际上这两种操作码都是可以的,89的操作码d位为0,而8B的操作码d位为1,对于89来说,当寻找ModR/M码时,由于d=0,因此reg字段应当寻找源操作数esp,r/m和mod字段寻找目的操作数ebp,最终找到Mod码为E5,这种情况下的指令编码为89 E5;而对于8B来说,其d位为1,因此reg字段应当寻找目的操作数ebp,r/m和mod字段寻找源操作数esp,最终找到Mod码为EC,这种情况下的指令编码为8B EC。因此,mov ebp,esp的机器码为89 E5或8B EC。
- sub esp,0D8h 。操作指令为sub,源操作数为立即数0D8h,目的操作数为esp寄存器。可见在Opcode表中没有适合的,因此从扩展表中去找sub指令,发现其对应操作码为80~83,只能取81(目的操作数为32位且源操作数大于1个字节),并且sub对应的reg字段值为101,由此可查Mod码为EC,后面再加上0D8h的小端表示,最终得到机器码为81 EC D8 00 00 00;
因此,前三句指令对应的机器码应当是55 89 E5 81 EC D8 00 00 00或55 8B EC 81 EC D8 00 00 00,我们看看在内存中实际存放的是什么呢?
由此可见是与我们分析的是相匹配的。当然编码的过程没有这么简单,这里是比较浅显的理解了一下。整个编码的过程应当是编译器来完成的,可想而知,这这个阶段中使用到的汇编指令集和CPU指令集应当是相匹配的,不同的编译器可以把源程序编译成各式各样的汇编语言,也可以有不同的汇编指令集,但是汇编指令集一定要和CPU指令集在意义上达成一致,换句话说,汇编指令集应当是CPU指令集的另一种表现方式而已。装在电脑上的CPU指令集是不会变的,在这台电脑上的不同的编译器对于同一句话翻译而来的机器码不说一定要相同,但是至少不能有明显的差距吧!总不可能同样一句“std::cout<<"哈喽我的”<<std::endl”,分别在gcc++下编译和在clang下编译后一个输出“哈喽我的”,另一个输出“我的哈喽”吧?
那么不同编译器汇编得到的机器码就一定相同吗?那也不一定,比如说:同样一句int x=0;int y=x;我们来比较一下这句话在x86-64 gcc++8.2编译器下的汇编结果和在x86-64 clang 7.0.0编译器下的汇编结果:
可以发现,两种编译器的到的机器码是不同的,但是二者操作码中变的也只是rbp的偏移量fc和f8而已,但是这两种机器码虽然影响了变量x的地址,但是CPU对这种影响并不敏感,也能正确地访问x。
4.链接
由于我们的程序难以避免的会调用库函数,就像最简单的printf("hello world");我们自己写的程序中并没有printf函数的定义,那么printf在编译器看来就仅仅只是个符号而已,就需要让编译器找到printf函数的定义,而这个函数的定义在哪呢?当然就在某个标准函数库中了,为了让程序能够正常运行,让编译器能够正常识别出printf函数并且找到其定义,那么就这时候就需要链接器将printf函数所在库与当前程序链接起来,这样就可以正常使用printf函数了。这也是为什么常常会出现某个库函数未定义的情况,这往往都是没有正确链接到库的原因。
最终链接后生成的文件就是可执行目标文件,以二进制形式保存。
通过上面4个步骤,编写的程序成为了二进制形式的可执行目标文件,在此之前CPU都没有参与,而在真正运行程序之前,还需要将可执行目标文件加载到内存中,这个加载过程比较复杂,这里省略掉,直接进入程序的运行部分,在这部分CPU起着极为重要的作用。
5.取指
取指即是从内存中取出指令的阶段。CPU中的程序计数器PC中存放着下一条指令的地址(也就是将要被执行的指令在内存中的地址)。
在取指阶段中,CPU先将PC中的指令地址发送到存储地址寄存器MAR中(位于内存中);然后向内存发起读命令,根据MAR中的指令地址从内存中相应地址处取出指令,然后将其送至存储数据存储器MDR中(位于内存中),接着再将MDR中的指令送至指令寄存器IR中,CPU的读事务结束。此时指令寄存器IR中就存放着下一条指令的机器码。取指阶段到这里应该结束了。
值得注意的一点是,此时内存中同时存放有数据和指令,CPU其实也无法通过自己取出来的二进制来辨别到底是指令还是数据,CPU实际上是根据时间段不同来辨别的,在取指阶段,CPU从内存中取出的就是指令,如果是在取操作数阶段,那么CPU从内存中取出的自然就是数据了。
(看到网上一些资料说的在取指阶段还会进行PC计数器的增量,但个人认为在进行译码之前CPU也不知道这个指令到底有多长,只有通过指令译码知道了指令的长度后才能确定下一条指令的地址,PC才能进行增量。因为还需要补充一点的是,CPU在进行取指的时候也不知道该取多长,因此往往都是从指令地址开始连续取多个字节的数据,实际的指令就包含在里面)
6.译码
在目标指令被取到指令寄存器后,CPU就需要对指令进行分析,来确定指令是要干嘛的,这是就需要译码了,译码的过程实际上和编码是差不多的,只不过编码是将汇编语言转换为机器语言,而译码则是将机器语言转换为计算机硬件最终需要进行的动作。不过,译码后的结果应当与前面汇编语言的意义是相互对应匹配的。
在译码阶段,CPU按照前面所说的指令格式来翻译指令,识别出指令所对应的具体类别(指令实现什么功能?)和操作数的类别(操作数如何寻址?取寄存器还是取内存?),
7.执行
知道了指令要干嘛,这个时候就可以按照指令来执行了。在执行阶段,算术逻辑单元ALU要么根据指令要求直接进行相应操作,计算内存引用的地址(如果需要的话),要么增加或减少栈指针。
8.访存
顾名思义,在访存阶段可以对内存进行相应的读写。根据指令类别,如果需要对内存进行访问,或对内存进行写入,那么就需要进行这一步。(个人认为访存阶段可能会发生在执行阶段,二者不会有一定的先后顺序)
9.写回
写回阶段就是把执行指令阶段的运行结果数据“写回”到某种存储形式。结果数据经常被写到CPU的内部寄存器中,以便被后续的指令快速地存取。在有些情况下,结果数据也可被写入相对较慢、但较廉价且容量较大的主存。许多指令还会改变程序状态字寄存器中标志位的状态,这些标志位标识着不同的操作结果,可被用来影响程序的动作。
10.更新PC
在前面的译码阶段中,根据指令类别就可以知道操作数占多少位,从而推断出指令的实际长度,这时,前面取指阶段所说的PC计数器才能真正的根据指令长度进行偏移来得到下一条指令的地址。
然后根据PC继续取下一条指令,重复6-10阶段。被访问过的数据和指令会被放在数据Cache和指令Cache中,以便下次访问到同样的数据或指令时快速读取。
参考资料:CSAPP、Intel白皮书卷2