程序是如何从编辑到执行的——我的初步理解

2023-11-07

目录

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

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

程序是如何从编辑到执行的——我的初步理解 的相关文章

  • [学习日志]伤害生效由谁来决定?

    伤害生效由谁来决定 普通攻击 使用动画事件 用动画事件是最普遍的一种方式 假如我的伤害生效是在动画结束之后呢 攻击动画片段只有2秒时长 要在3秒才对敌方造成伤害 那么动画事件就做不到了 计时器 把伤害生效是点交给计时器去操作 攻击的时候开始

随机推荐

  • 网络拓扑的分类

    一 按网络所覆盖的地理范围分类 1 局域网 LAN 局域网Local Area Network 简称 LAN 是一种私有网络 一般在一座建筑物内或建筑物附近 比如家庭 办公室或工厂 局域网络被广泛用来连接个人计算机和消费类电子设备 通过网络
  • swift 之AVFoundation自定义相机界面拍照、录像、保存到相册、合成视频

    1 自定义相机拍照 自定义相机 1 前置和后置摄像头 typedef NS ENUM NSInteger AVCaptureDevicePosition AVCaptureDevicePositionUnspecified 0 AVCapt
  • CVPR 2020

    Editing in Style Uncovering the Local Semantics of GANs 作者 Edo Collins Sabine S sstrunk School of Computer and Communica
  • 笔试题目1

    腾讯笔试 2 假设函数f1的时间复杂度O n 那么f1 f1的时间复杂度为 A O n B O n n C O n log n D 以上都不对 这个题目我的思路是A 讨论后答案应该是D 因为f1的返回值不确定 如果是个数组当然复杂度就不同了
  • linux基础——vim及bash的使用

    vim vim介绍 vim是linux里的文本编辑工具 是vi的升级版的软件 是程序员文本编辑器 用来写程序的工具 安装 yum install vim y 用法 命令模式 yy 复制当前行 5yy 向下复制5行 copy dd 删除 剪切
  • 使用vue里面el-upload,照片反显后,然后再上传照片,发现上传的照片只要最新上传的,之前反显的照片没有了,解决办法如下

    第一步 首先先将照片反显存到两个数组里面如图 第二步 照片上传成功回调里面就在查看添加照片这个数组接着push 就可以了 切记先反显的时候把之前数据添加到里面
  • R Plot添加中文及其他字体【showtext】

    文章目录 前言 R Packages showtext 安装 简单示例 加载字体 更多详见 前言 之前 一个群里的群友 想要在它画的图上加入中文 保存为PDF之后 中文字符也不变成乱码 当时随手推荐了一个R package showtext
  • Linux的shell编程(四)

    六 bash程序的调试 在编程过程中难免会出错 有的时候 调试程序比编写程序花费的时间还要多 shell程序同样如此 shell程序的调试主要是利用bash命令解释程序的选择项 调用bash的形式是 bash 选择项shell程序文件名 几
  • Conda安装失败:Solving environment: failed with initial frozen solve. Retrying with flexible solve.

    Conda安装包安装包出现错误 比如 conda install imutils 出现如下错误 Collecting package metadata current repodata json done Solving environme
  • linux qt通过ps获取进程

    在linux 当中 通过qt 程序获取进程的名称 来对后续进行操作 QProcess m process m process start ps ef if m process waitForFinished const QByteArray
  • 按钮卡片特效代码集锦

    css最好看最全的按钮卡片样式 动画效果大全 纯css样式打造的20款按钮特效和11款卡片合集 喜欢的可以收藏 备开发时使用 按钮代码
  • 机器学习模型评估指标

    在机器学习建模过程中 针对不同的问题 需采用不同的模型评估指标 主要分为两大类 分类 回归 一 分类 1 混淆矩阵 2 准确率 Accuracy 3 错误率 Error rate 4 精确率 Precision 5 召回率 Recall 6
  • 【6】测试用例设计-输入域+输出域+异常分析+错误出错法

    目录 输入域测试 输出域测试 异常分析 错误猜测 输入域测试 极端测试如学生成绩0分 1分 2分 情况很少这种 特殊值如 99 0 99 长时间输入内存溢出 内存泄露 输出域测试 异常分析 异常操作验证系统容错性 出现错误时 故障恢复的能力
  • 泛型和包装类

    1 泛型 1 1泛型的定义 泛型是程序设计语言的一种特性 允许程序员在强类型程序设计语言中编写代码时定义一些可变部分 那些部分在使用前必须作出指明 各种程序设计语言和其编译器 运行环境对泛型的支持均不一样 将类型参数化以达到代码复用提高软件
  • 构建seq2seq模型的常见问题

    1 seq2seq模型 输入是一个词向量 而不是词向量列表 对吧 是的 对于seq2seq模型 输入和输出都需要被转换成词向量形式 对于输入来说 通常会将一个句子转换成一个词向量序列 具体地 对于每个单词或者字符 都会将其对应成一个词向量
  • 广联达C++一面(坐等感谢信)

    括号中是我的回答 自我介绍 介绍一个最近做过的项目 不需要非得是C 相关的 回答了节点属性标注系统 在里面负责哪一部分 介绍了三个部分 说了自己负责的是统计和二阶段预测 主要是用主动探测的方法扩展节点信息 随机性检测是怎么做的 用的什么信息
  • Java EE学习笔记(1:Servlet & JSP)

    Servlet简介 Servlet技术规范是JavaEE技术规范中的一个重要组成部分 Servlet是一种独立于平台和协议的服务器端的Java应用程序 可以生成动态的Web页面 实际上 Servlet不仅仅是用于返回HTML的页面的 比如
  • Python_FontTools使用

    目录 Font Tools的使用 1 fontTools使用总结 2 加载字体文件 3 保存为xml文件 4 获取各节点名称 返回为列表 5 获取getGlyphOrder节点的name值 返回为列表 6 获取cmap节点code与name
  • 51单片机有几个通用io口_我在职高教单片机——02零基础学51单片机IO口(1)

    大家好 我是老王 职高老师一枚 一直从事单片机 计算机 电子技术基础等课程的教学 对于职高的学生层次 同行应该都懂的 老师在课堂上教学几乎是没什么成就感的 正是如此 才有了借助头条平台寻求认同感和成就感的想法 在这里 我准备陆续把自己花了很
  • 程序是如何从编辑到执行的——我的初步理解

    目录 1 预处理 2 编译 3 汇编 3 1 机器码的格式 3 2 编码过程 4 链接 5 取指 6 译码 7 执行 8 访存 9 写回 10 更新PC 今天来谈谈 一句代码 是如何从被编辑到被执行的 以下为本人在阅读相关书籍资料后的初步理