HelloWorld是怎么跑出来的?

基本上从学C语言的第一天起,我们就会知道一个C程序,需要经过编译、链接,才能得到可执行文件,然后,才能跑起来。在我们用IDE或者类似于gcc 1.c这样的命令的时候,有很多步骤被省略了。现在我想回过来看看它们,加深下理解。

C语言从头到尾分为了预处理、编译、链接这三个过程。链接的结果可以是一个exe文件,即Windows系统上我们可以执行的程序。当然,链接器的输出也可以是一个dll、一个静态库lib。在最后,当我们双击这个exe文件的时候,操作系统的加载器就会运作——它把exe载入到内存里面,然后告诉操作系统,要运行它,接着,我们就看到了屏幕上的"Hello, World":

过程

1. 预处理

预处理器是负责处理预处理命令的,也就是所有#打头的命令。比如我们下面的C程序:

#define max(a, b)  ((a) > (b) ? (a) : (b))
int main()
{
    max(1, 2);
    max("str", ;);
    return 0;
}

可以看到,它包含了一个宏定义,下面我使用预处理器对它进行处理——

int main ( )
{
( ( 1 ) > ( 2 ) ? ( 1 ) : ( 2 ) ) ;
( ( "str" ) > ( ; ) ? ( "str" ) : ( ; ) ) ;
return 0 ;
}

可以看到,预处理器忠实的展开了我们的宏。它没有做任何的检查(显然,第二个max的写法一定是错的)。预处理器输出的文件一般是*.i,通常我们是见不到它的。同样的,针对#include等#打头的预处理也会进行处理。比如我们下面有一个1.h和一个1.c文件:

// 1.h
struct MyStruct
{
    int  a;
};
#define PROCESS  int a
//-------------------
// 下面是另一个文件
// 1.c
#include "1.h"
#include "1.h"
int main()
{
    return 0;
}

预处理器——



struct MyStruct
{
int a ;
} ;


struct MyStruct
{
int a ;
} ;

int main ( )
{
return 0 ;
}

可以看到,预处理器简单的把1.h的内容复制到我们#include的位置。PROCESS宏因为没有在任何位置使用被丢掉了。同样的,我们一般所编写的#include <stdio.h>也是简单的把stdio.h的内容复制到我们#include的位置。在运行预处理器之后,输出的文件通常不会像上面展示的那么干净,它还会包含一些其它的#开头的指令——它们表明了当前文件的行号、文件名等信息。例如在编译器显示错误提示的时候,这些信息会一并显示出来,它有助于我们快速定位错误。除此之外,还会留下一些编译过程使用的预处理指令,如内存对齐的说明,它会告诉编译器如何处理结构体的内容。

需要注意的是,你会发现输出的注释没了。但是,预处理器不一定要处理注释,编译器依然可以忽略它们。

接下来,预处理器输出送往编译器。

2. 编译

编译过程负责把一种语言转变为另外一种语言。通常而言,它会把我们的源程序(比如C程序),变成机器码,后者是机器能够认识的语言。编译过程会检查我们输入的程序的语法语义上的问题,例如我们之前编写的那个错误的max宏,它将会在这里被报告错误。

扫描二维码关注公众号,回复: 11457865 查看本文章

简而言之,我们送入了下面的一个程序:

int main()
{
    int a, b;
    a = 0;
    b = 2;
    a + b;
    return a;
}

紧接着,语法分析器将对它进行操作——对于语法分析器而言,它所见到的输入是一个个的单词。例如我们上面的那段C程序,语法分析器会这样看待它:

语法分析器的视角

可以看到输入的那一串字符串(即我们的源代码)被划分为一个个的小组,这些小组就是单词。负责划分这些单词的组件被称作词法分析器(扫描器),当它遇到错误时,它会告诉语法分析器停止分析,不然,语法分析器会不断请求词法分析器给出一个单词,直到你的程序结束,或者因为某种原因导致了语法分析停止。

当然,语法分析器遇到错误就会报告语法错误。请注意这里的语法错误——它是上下文无关的,也就是说,它只保证a + b这种写法是正确的,但是不会考虑a是int,而b是一个struct的情况。这个工作会交给其它组件完成。但是,语法分析器会报告这种错误:int a,,,,,b。很显然,它中间有太多逗号了,这不需要去积累任何有关于这个程序的知识就可以得到(即上下文无关)

语法分析器同时还在引导另外的一个组件运作——翻译(即所谓的语法制导转换)。这个组件会按照语法分析器的指令,将我们的源代码暂时转成另外一种形式。同时,这个组件将检查大部分的类型错误(例如a+b,a是一个int而b是一个struct数据)、为局部变量分配数据、检查变量、函数作用域、函数声明与调用是否吻合......该组件的工作非常多,在这里,上下文相关(即a + b这种写法,要考虑到a是怎么声明的,b是什么情况,能不能运行"+")的问题被一并分析,如果我们的变量没有声明,或者其它不单纯是一条语句上的问题,那么这个组件将会报告错误。

或许你发现了,翻译组件自上而下的积累有关于程序的信息,而且语法分析器绝不再看一眼程序,因而,在C语言中要求了先声明后使用——不然它无法积累有关信息。当然,你可能会怀疑JavaScript这类语言为什么不需要在乎这个。或许你也发现了,我们描述的这个编译过程是单遍的——语法分析器扫描一遍程序即马上进入下一环节。对于JavaScript这类语言,语法分析器或多或少都会第二遍来查看源程序。

如果我们的程序通过了这步,那么它将会变成一种适合编译器本身运作的形式。这种形式被称作中间表示(IR)。之前这些组件则是编译器前端的部分。

中间表示会被送往优化器,优化器将会对代码进行一些等效的代换,消除一些无用的死代码,提高某种递归的效率,减少表达式冗余等等。特别的,优化器还会针对目标机器的体系结构进行优化,并且侧重于不同的优化目标,而不单纯是速度。例如在MSP430芯片上,优化器的目标之一是降低功耗,而在x86的电脑上,它的目标更多在于运行速度等。

优化器输出的中间表示不一定是我们编译器前端输出的了,它或许还会补充更多的细节,甚至用一个更适合后面步骤的中间表示。

最后,优化器输出被送往编译器后端,在这里,指令调度和生成组件会把我们的中间表示转换为机器码,并且按照一些顺序重排某些代码(例如为了避免CPU的流水线停顿会修改一些内存访问指令的位置),进行寄存器分配(注意我们的计算都是要送给CPU的,当然需要把操作数送往寄存器)。请注意,在这篇描述里面的编译器后端并没有输出汇编语言,事实上多数C语言编译器默认不会输出汇编。这或许和你学到的不一样——但是我们换个角度考虑,这套编译器的目标是机器码,我们当然可以构建一个输出汇编语言的编译器,然后再把汇编送给汇编器生成目标文件。

最终,编译器输出了我们的目标文件,在Windows上,这个文件通常是*.obj。我们来一张图总结一下吧:

编译器结构

我们的描述很简略的带过了编译器的运作。事实上,实际中的编译器或多或少都会和上面的过程有差距,但是总体思路是不变的。请不要局限编译器的输出是机器码,例如对于Java虚拟机,它可以输出用于JVM的字节码。

3. 链接

链接器往往被我们忽略,甚至我难以找到有关它的资料。

链接器的基本任务是为各个符号分配地址。这听起来有些抽象,它其实会把"调用printf"这种写法改写为"调用vcruntime库的第1024字节位置的函数"这样的描述。它的输入是数个目标文件,可以是*.obj这种格式(它被叫做COFF格式),也可以是Linux下更常见的ELF格式,还可能包含一些动态链接库的信息。为了说明这个,我们以下面的这个C程序为例:

#include <windows.h>
int main()
{
    // Sleep函数是Kernel32.dll这个动态链接库内部的
    // 换句话说, Sleep无法在这个文件的目标文件里面被表示
    // 它必须定位到Kernel32.dll去
    Sleep(1000); 
    return 0;
}

编译器输出的目标文件内部是机器码,为了说明链接过程,我找了一个输出汇编的C编译器,关于Sleep(1000);周围的汇编代码如下:

mov         esi,esp 
// 下面是调用Sleep的部分
push        3E8h       // 把0x3e8压栈, 0x3e8就是1000
call        0          // 请注意这里是0, 换句话说, 我们没有调用Sleep????
// 下面是其它的部分
cmp         esi,esp
xor         eax,eax

调用Sleep函数的位置不见了?

事实上,编译器无法知道Sleep的位置。C语言里面可不会用一个地址去描述函数。因此,编译器在处理这段源文件时,不知道Sleep放在哪,故保留了这个符号的地址。希望链接器处理它。目标文件里面会有一段区域告诉链接器有哪些符号需要操作。但是链接器怎么知道这个函数定位到哪去呢?从链接器的输入上看,它不单纯是一个目标文件,还包括各种各样的静态库数据、符号定义,链接器正是从这里得到的符号位置。在链接过程中,它通过其它的输入得到了Sleep函数的地址,并把它填入到call   0的那里。

等等...

Sleep,是动态链接库内部的吧?

你发现问题了。我承认我为了方(偷)便(懒)解释这个任务找了个不大恰当的例子。动态链接库的处理比你看到的还要复杂——事实上,上面描述的其实是跨文件调用函数和调用静态库的函数的情况。链接器会实际创建一个新的"Sleep"函数,将它填入0的位置。然后在这个虚假的Sleep函数位置填上去动态链接库里面查找Sleep地址的程序。

同样的,为了能让操作系统加载我们的exe,它还会填上一些必要的代码——创建进程、退出进程、定向储存空间等等。这些代码被称作胶连代码,它们包含一些api调用和几个跳转。

完成这些工作后,链接器可能还会做一件事——优化。这听着很奇怪,不过我们也可以想得到,编译器的大手只能触及一个文件,而链接器却可以触及整套程序。因此链接器的优化被称作全局优化(对应的,编译器的优化被叫做过程间优化,而对于某个函数的优化叫做过程内优化)

最后,链接器输出了最终的目标文件,它或许是一个可执行文件,也可能是其它的格式,例如可以烧录到单片机内部的格式等。

请不要局限链接器的输出。它不一定是一个可执行文件(exe这种格式)。

4. 加载

现在一切已经就绪,链接器的输出已经在你的磁盘上了。对于可执行文件,我们只需要双击它,操作系统的加载器就会过来加载这个程序,然后运行。

(当然加载器也做了非常多的事情)

现在,我们看到了屏幕上的那行Hello, World。

猜你喜欢

转载自blog.csdn.net/YanEast/article/details/104189353