前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >你知道.c是如何变成.exe的吗

你知道.c是如何变成.exe的吗

作者头像
帝旭科技
发布2022-11-23 19:15:51
8650
发布2022-11-23 19:15:51
举报
文章被收录于专栏:帝讯博客帝讯博客

文章目录[隐藏]

前言

今天我们要来探究的内容是一个或者多个源文件(.c)是如何变成一个可执行程序(.exe)的,博主将在Linux环境gcc编译器中进行分步演示,让你深入理解程序环境。

程序的翻译环境和执行环境

在ANSI C的任何一种实现中,存在两个不同的环境。

第1种是翻译环境,在这个环境中源代码被转换为可执行的机器指令。 第2种是执行环境,它用于实际执行代码

我们来简单的看下示意图:

845d9e6ebbbc4ca996d97dd8005b3e2b
845d9e6ebbbc4ca996d97dd8005b3e2b

一. 程序的翻译环境

我们通常把一个或多个源文件(.c)形成一个(.exe)可执行程序叫做翻译环境,在这个环境中它其实就是将源代码转换为可执行的机器指令。 我们来简单看下形成过程,首先我们创建了一个源文件,并没有编译运行这个程序。

4dd619af1ad04564b4333b763cc1526c
4dd619af1ad04564b4333b763cc1526c

接下来我们运行一下这段程序,我们在源文件目录下发现了Debug文件,点击进入我们看到了.obj目标文件等一些其他文件:

505f4c4ac682495a92b805bc4d3aacdd
505f4c4ac682495a92b805bc4d3aacdd

我们返回上一目录,点击进入Debug文件在里面我们发现了.exe可执行程序。

c749d9ecc49841788a63952388b22f49
c749d9ecc49841788a63952388b22f49

那如果是多个源文件组合在一起,程序运行之后它又会产生几个.obj目标文件.exe可执行程序呢?请看下图例子:

2d8df09b28d54152a85446010178bd67
2d8df09b28d54152a85446010178bd67

相信大家都知道这两个源文件组合运行起来能得出正确答案,那么它到底生成了几个.obj目标文件.exe可执行程序呢?下面我们一起来观察一下目录。

7f4aa4e4f1a94745b5d71270829daf20
7f4aa4e4f1a94745b5d71270829daf20

我们发现目录下出现了两个.obj目标文件,而只生成了一个可执行程序。由此,我们是不是能初步的得出一个小结论:每个源文件经过编译过程都会形成各自的.obj目标文件,但.exe可执行程序只有一个。

下面这幅图就是整个翻译环境中的各个过程了:

19ae83d299c046578f36d0f3e32ec720
19ae83d299c046578f36d0f3e32ec720

翻译环境可分为两个过程:翻译+链接。这里我们的编译器执行编译操作,链接器执行链接操作。

  • 组成一个程序的每个源文件通过编译过程分别转换成目标代码(object code)。
  • 每个目标文件由链接器(linker)捆绑在一起,形成一个单一而完整的可执行程序。
  • 链接器同时也会引入标准C函数库中任何被该程序所用到的函数,而且它可以搜索程序员个人的程序库,将其需要的函数也链接到程序中。

1. 编译

关于上述的翻译环境我们只是讲了一个大概,并没有进行深入的分析。 编译分为:预处理、翻译、汇编三个阶段。

a15b8846c14c4dfeb3135b2d7a3abc92
a15b8846c14c4dfeb3135b2d7a3abc92

下面我将带大家在Linux环境gcc编译器中进行深入的分析每一步的过程,有些读者可能没有学习过Linux环境中的一些命令操作,这没有关系你只要保证自己能听懂就OK。

1.1 预处理

首先我们创建一个test.c的源文件,它的代码显示如下:

5a61927c12e448eebe6710e8b32be232
5a61927c12e448eebe6710e8b32be232

而且当前目录下只有一个test.c源文件,ls可以显示到当前目录下有什么文件

8ea3b19ff3b94d2fa066123e71f10c5f
8ea3b19ff3b94d2fa066123e71f10c5f

接下来输入gcc -E test.c -o test.i这个指令,它代表的意思是预处理完成之后就停下来,预处理之后产生的结果都放在test.i文件中。这个文件我们可以随便取名,但是为了编码规范我们写成一般的这种形式,比如什么阶段生成什么后缀的文件名,这里就不做过多的赘述了。 执行完上述指令我们查看到当前目录下出现了test.i文件。

69f0d0a93e384b8a889c140f1ffc13d2
69f0d0a93e384b8a889c140f1ffc13d2
1.1.1 头文件包含

接下来输入vim test.i进入test.i文件查看里面的内容,我们发现里面的代码有800多行,那么这里面放的究竟是什么代码呢?

24f179cc1b794a94a3823ae09d3beed9
24f179cc1b794a94a3823ae09d3beed9

我们在命令模式下输入G跳转至文本末尾,我们看到的情况是这样的

dd1a7fa82a6a4bad9ce09f5acdb2fc7b
dd1a7fa82a6a4bad9ce09f5acdb2fc7b

接下来我们在命令模式下按下Shift + : ,输入内容vs test.c,此时我们来对比两者一下,你发现什么问题了吗?

6ef3b3583efc4cffba5ade0ea65cd98c
6ef3b3583efc4cffba5ade0ea65cd98c

我们发现test.i中头文件不见了,但却出现了大量代码,你觉得是什么原因呢?我们想是不是源文件经过预处理将头文件stdio.h的内容全部包含进来了吗?下面我们来证明这个事实。

我们输入vim /usr/include/stdio.h进入到Linux环境中stdio.h头文件中,我们发现有900多行的代码包含在内

9bc59135d4e6405481d66798edc1e0ba
9bc59135d4e6405481d66798edc1e0ba

接下来我们对比test.istdio.h,发现它们两者之间有些内容确实是一样的,但可能由于其他原因我们观察到的可能不是完全一致,这里我们就不刨根挖底了,我们只需知道test.i里面的这些内容确实就是stdio.h中的就行了。

501d6dd9eacf4ed4a377ce40f5c0b98d
501d6dd9eacf4ed4a377ce40f5c0b98d

从这里我们就可以得出一个结论:预处理会将头文件中的内容拷贝进源文件,#include的本质就是把头文件中相关内容直接拷贝至源文件。

那么我就有一个疑问了,我们的stdio.h文件中都有900多行的代码,而你的test.i加上源代码都只有800多行,那么为什么会出现这种情况呢?先把这个问题放一放我们继续分析下面的过程。

1.1.2 注释删除

我将这份代码稍微改动一下,添加几行注释,在test.i里面观察与test.c的变化。

c70345c7934d485fa2c15314d876d26d
c70345c7934d485fa2c15314d876d26d

此时我们的test.c文件已经改变,所以接下来我们重新进行gcc -E test.c -o test.i生成test.i文件, 我们发现在预处理过后,test.c里面的注释都被空格替换了。

7dd106132622433bae06ff99e33b30c8
7dd106132622433bae06ff99e33b30c8

由此我们得出结论:实际编译器是不关心注释的,所以在预处理阶段是要被删除的;注释只是写给程序员或者其他人员看的,并不参与到程序运算当中。

1.1.3 预处理指令的文本替换

这里以#define为例,我们将代码再稍加修改一下,通过对比我们发现#define对文本进行了直接宏替换,并且预处理完之后就消失了。

19b8baa37a774d8b819bbf5b2cb72783
19b8baa37a774d8b819bbf5b2cb72783

那么回到上面那个问题,你知道为什么stdio.h文件的代码行数比test.i中代码数要多了吗🙈🙈

综上:

  • 预处理过程实质上是处理“#”,将#include包含的头文件直接拷贝到.i文件当中;
  • 将代码中没用的注释部分删除;
  • 将#define定义的宏进行替换、执行条件编译等预处理指令

注:预处理阶段进行的都是文本操作。

1.2 翻译

上述代码经过预处理之后,接下来就是翻译阶段,我们输入gcc -S test.i -o test.s这个指令,翻译完成之后就停下来,结果保存在test.s中。 接下来我们输入vim test.s进入test.s文件进行观察。

f3904cfe3c6647848c0fc11e3a57cb87
f3904cfe3c6647848c0fc11e3a57cb87

我们能发现什么?虽然我们可能看不懂这些代码,可是你有没有发现它跟我们在Windows环境中一些基本的汇编指令很相似,例如:mov、push、call、jmp等,实际上这些就是汇编代码。 好了,那么此时我们就能得出一个结论:笼统的讲,翻译阶段就是把C语言代码翻译成汇编代码,而这个过程实际是经过以下几个步骤来完成转换的:语法分析、词法分析 、语意分析、符号汇总。

前三点很好理解,我们要将C语言代码翻译成汇编代码肯定是需要建立在C语言语法基础上才能准确的进行转换。下面我将这段代码进行修改故意的写错,看看到底能不能通过编译形成test.s文件。

dee70a8c3a4443de98a25e295132c1fe
dee70a8c3a4443de98a25e295132c1fe

接下来输入gcc -E test.c -o test.i看下能不能形成test.i文件

7f50a235fda14755a43cf1cd60a679d2
7f50a235fda14755a43cf1cd60a679d2

我们发现即使是在语法有问题的情况下经过预处理之后也形成了test.i文件,这说明什么?

说明预处理根本不进行语法语意等的分析与检查,它只需要干好自己应该做的事就可以了。

那么你明白了之前我们讲过为什么在一定程度上要少使用#define的指令吗? 因为一旦使用宏替换出现错误时,我们在调试时其实看到的已经是经过预处理过后的代码了,所以根本无法迅速判断错误出在哪,这也就增大了我们的维护成本。 接下来我们输入gcc -S test.i -o test.s,看能不能通过翻译形成test.s文件。

9a7c2703474547aa99cd0da1c0035acb
9a7c2703474547aa99cd0da1c0035acb

结果显而易见是不能通过编译的,在翻译阶段进行语法词义分析发现了错误故不能生成test.s文件。由此,我们要记住源代码是在翻译阶段进行语法语意等的分析的。

接下来我们调整回原来的代码来简单的谈谈符号汇总,首先将源文件和test.s文件进行对比。

c3d411e6ccaf432c9b86b9c993259c01
c3d411e6ccaf432c9b86b9c993259c01

我们发现其实在test.s中只有俩个是我们所熟悉的变量或者函数,全局变量g_valmain函数;其他的局部变量什么arr数组i什么的都不在test.s里面,那么符号汇总是干什么的呢?大概的我们想是将这些全局变量啊函数啊这些符号进行拆解之后全部汇总起来,最后进行链接操作的时候全部链接起来形成一个可执行程序,后续在讲到链接的时候我们再来谈论这个问题。

1.3 汇编

经过翻译阶段,编译器要执行的最后一步就是汇编了,我们输入gcc -c test.s -o test.o指令,接下来vim test.o进入文件观察现象。

aa26772394c648c9aa2e9e10079d73fd
aa26772394c648c9aa2e9e10079d73fd

我们发现test.o里面我们什么都看不懂,其实它就是二进制代码,Windows当中.obj与这个.o文件其实是一样的里面都是放的二进制代码。 那么我们就可以得出结论了:汇编过程实际上是将汇编代码转换成二进制代码生成一个目标文件。

其实汇编阶段还会形成一个符号表,这个符号表就是由翻译阶段进行符号汇总而来的,里面包含了符号的地址信息等,之后到了链接阶段链接器就从多个.o/.obj文件中选择性的从符号表中挑选所需要的符号信息来进行链接,最终形成一个可执行程序。

下图是在VS中创建的两个源文件,我对简单做了一下分析。

df7205634e8d47cba250e3c40d29048b
df7205634e8d47cba250e3c40d29048b

2. 链接

链接过程是由链接器来完成的,它又分为合并段表、符号表的合并和重定位。

2.1 合并段表

我们经过编译器翻译之后形成了一个或者多个目标文件,而每个目标文件它其实是有固定格式的,这个格式也叫做elf文件格式readelf是读取elf的一个工具,下面我们一起来看看目标文件test.oelf文件格式:

9e1bfc65aa084fe6bd6baffe78656e34
9e1bfc65aa084fe6bd6baffe78656e34

我们发现全局变量g_valmain函数以及库函数printf是我们所熟悉的,此时它们充当的就是符号,我们也就能跟翻译阶段进行符号汇总和汇编阶段形成符号表联系在一起了。

所谓段表就是它会把自己分成几个段一样的保存着不同的信息,之后在链接过程中会将对应的段表合并起来。这里举了一个简单的例子:

3e69e1b71b6c4ff0b6eab34e4c2ef358
3e69e1b71b6c4ff0b6eab34e4c2ef358

这样一推理,既然test.oelf文件格式,那么在链接之后形成的可执行程序是不是也为elf文件格式呢?我们一起来看下吧,输入命令gcc test.c产生了a.out这个默认的可执行程序,接下来我们用readelf工具进行读取查看。

1304d5d06ffb46dfaed365e8e9098790
1304d5d06ffb46dfaed365e8e9098790

同样的我们在可执行程序中发现了全局变量g_val和main函数的符号名。

2.2 符号表的合并和重定位

每个.o/.obj文件中都形成了各自的符号表,链接过程我们对它们进行合并,以下图为例我们发现Add函数冲突了,那么到底该用哪一个目标文件中的信息呢? 我们发现test.o/test.obj文件当中Add是无效的,因为我们只是对它进行了声明并没有定义,既然没有定义那就没有一个有效的地址,所以我们选择的是Add.o/Add.obj文件中的Add符号信息,这叫做重定位,最终我们的符号表再进行合并。

9f0478ce7f75454e8b7dc537b2f1332c
9f0478ce7f75454e8b7dc537b2f1332c

我们先来看下正确的例子,它得到了正确答案:

0c813b8ccebb469ca98718aeb40e1a63
0c813b8ccebb469ca98718aeb40e1a63

下面我将Add函数注释掉看看会发生什么情况

9921f3595fc24d66a660b11fbc2e05cd
9921f3595fc24d66a660b11fbc2e05cd

答案显然而知肯定是发生了链接错误,因为test.c当中的Add函数的地址是无效的,自然就不能找到且调用Add函数了。

如上例子整个过程如下:

c7a65750376c4de1b7a72cd5067bfd92
c7a65750376c4de1b7a72cd5067bfd92

好了,关于翻译环境就讲到这了,我们通过下图来简单的总结一下:

33ee38c01ee04d5e90d1ae7b072ed8a3
33ee38c01ee04d5e90d1ae7b072ed8a3

二. 程序的执行环境

运行环境不是我们今天的重点,我们就稍微了解一下就行了。

程序执行的过程: 1. 程序必须载入内存中。在有操作系统的环境中:一般这个由操作系统完成。在独立的环境中,程序的载入必须由手工安排,也可能是通过可执行代码置入只读内存来完成。 2. 程序的执行便开始。接着便调用main函数。 3. 开始执行程序代码。这个时候程序将使用一个运行时堆栈(stack),存储函数的局部变量和返>回地址。程序同时也可以使用静态(static)内存,存储于静态内存中的变量在程序的整个执行过>程一直保留他们的值。 4. 终止程序。正常终止main函数;也有可能是意外终止。

好了,今天的文章内容就到这儿了,如有疑问或文章有错误的地方请各位大佬及时指正🙈🙈

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
  • 程序的翻译环境和执行环境
    • 一. 程序的翻译环境
      • 1. 编译
      • 2. 链接
    • 二. 程序的执行环境
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档