编译入门:传说中的编译是在做什么

一、 编译的定义

编译程序(Compiler)是一种程序。它把用高级语言写的源程序作为数据接收,经过翻译转换,产生面向机器的代码作为输出。
这当中代码还可能要由汇编程序或装配程序作进一步加工,得出目标程序,交给计算机执行。

二、 Linux下的编译过程概述

根据编译的定义,编译就是把高级语言的源文件进行一系列处理,最终得到二进制代码的可执行文件(传说中的binary)。
整个编译过程在Linux系统下有4个抽象阶段(其他操作系统还没有研究过)。我们举一个例子来说明,如何把hello.c这个C源文件编译成名为hello的二进制文件。

#include <stdio.h>

int main()
{
    printf("happy new year!\n");
    return 0;
}

1预处理阶段 (.c -> .i)

此阶段主要完成#符号后面的各项内容到源文件的替换,例如头文件#include和宏定义#define, #ifdef等。
可以用gcc的参数-E来指示编译器只做预处理而不进行下面的3个步骤。例如:

gcc –E hello.c -o hello.i 

2. 编译阶段 (.i -> .s)

这个阶段编译器主要做词法分析、语法分析、语义分析等,在检查无错误后后,把代码翻译成汇编语言。 编译器将文本文件hello.i 翻译成文本文件hello.s, 它包含一个汇编语言程序,即一条低级机器语言指令。
可用gcc的参数-S来指示编译器只编译到汇编语言而不进行汇编和链接。 例如,

gcc -S hello.i -o hello.s

3. 汇编阶段 (.s -> .o)

汇编器as 将hello.s 翻译成机器语言,打包形成可重定位的目标文件hello.o 中(二进制文本形式)。

gcc -c hello.s -o hello.o

4. 链接阶段 (.o -> binary)

此阶段完成文件中调用的各种函数跟静态库和动态库的连接,并将它们一起打包合并形成目标文件,即可执行文件。本例中,printf函数存在于一个名为printf.o的单独预编译目标文件中。必须得将其并入到hello.o的程序中,链接器就是负责处理这两个的并入,结果得到hello文件,它就是一个可执行的目标文件。

gcc hello.o -o hello

其中,第1阶段和第2阶段由编译器完成,第3阶段由汇编器完成,第4阶段由链接器完成。
此外,以上四个过程,可以用一条命令一次执行:

gcc hello.c -o hello


三、 编译阶原理详述

编译阶段的原理细节讲起来特别复杂,而且在实际编程中基本用不到这么细,所以借用其他博客的内容一笔带过。
如果对编译原理感兴趣的话,可以搜一下编译原理的三大经典教材:龙书,虎书和鲸书

编译程序的工作过程一般可以分为5个阶段:

词法分析

词法分析的任务是:输入源程序,对构成源程序的字符串进行扫描和分解,识别出一个个单词(定义符、标识符、运算符、界符、常数)。

在词法分析阶段的工作中所依循的是语言的语法规则(或称构词规则)。
描述语法规则的有效工具是正规式和有限自动机。

语法分析

语法分析的任务是:在词法分析的基础上,根据语言的语法规则,把单词符号串分解成各类语法单元(语法范畴)(短语、子句、句子、程序段、程序),并确定整个输入串是否构成语法上正确的程序。

语法分析所依循的是语言的语法规则。
语法规则通常用上下文无关文法描述。
词法分析是一种线性分析,而语法分析是一种层次结构分析。

语义分析和中间代码的产生

这一阶段的任务是:对语法分析所识别出的各类语法范畴,分析其含义,并进行初步翻译(产生中间代码)。这一阶段通常包含两个方面的工作。

  1. 对每种语法范畴进行静态语义的检查,例如,变量是否定义、类型是否正确等等。
  2. 如果语义正确则进行中间代码的翻译。

这一阶段所依循的是语言的语义规则,通常使用属性文法描述语义规则。

优化

对于代码(主要是中间代码)进行加工变换,以期能够产生更为高效(省时间和空间)的目标代码 。
优化的主要方面有:公共子表达式的提取、循环优化、删除无用代码等等。

优化所依循的是程序的等价变换规则。

目标代码生成

这一阶段的任务是:把中间代码(经过优化处理之后的)变换成特定机器上的低级语言代码(绝对指令、可重定位指令、汇编指令)。

四、 Linux下的编译工具概述

原版的cc是unix的系统下的C编译器,商业软件。
Linux系统中的cc通常是符号链接,指向gcc。可以通过$ls –l /usr/bin/cc来简单察看,该变量是make程序的内建变量,默认指向gcc。cc符号链接和变量存在的意义在于源码的移植性,可以方便的用gcc来编译老的用cc编译的Unix软件,甚至连makefile都不用改在,而且也便于Linux程序在Unix下编译。
上古时期的gcc全称应该是GNU C Compiler,只能编译C。后来gcc扩展成了编译器套装(包含C、C++、Objective-C、Ada、Fortran、Java编译器),全称则是GNU Compiler Collection。
g++: C++编译器。
CC: makefile里面的宏定义,makefile里面的一个名字。

一些注意事项:

1. 后缀名为.c的源文件,gcc把它当做C程序,而g++把它当做C++程序。 后缀名为.cpp的源文件,两者都把它当做C++程序。

2. 编译阶段,g++会调用gcc。对于C++代码,两者是等价的。

3. gcc不能自动和C++程序使用的库链接,所以要用g++来完成链接。为了统一,常常用g++直接做编译和链接,所以会让人误以为只能用g++来编译C++代码。实际上gcc也可以编译C++代码。


五、 C和C++混合编译

时常在cpp的代码之中看到这样的代码:

#ifdef __cplusplus
extern "C" {
#endif
//一段代码
#ifdef __cplusplus
}
#endif

这样的代码到底是什么意思呢?首先,__cplusplus是cpp中的自定义宏,那么定义了这个宏的话表示这是一段cpp的代码,也就是说,上面的代码的含义是:如果这是一段cpp的代码,那么加入extern “C”{}来处理其中的代码。
要明白为何使用extern “C”,还得从cpp中对函数的重载处理开始说起。在c++中,为了支持重载机制,在编译生成的汇编码中,要对函数的名字进行一些处理,加入比如函数的返回类型等等.而在C中,只是简单的函数名字而已,不会加入其他的信息.也就是说:C++和C对产生的函数名字的处理是不一样的。
比如下面的一段简单的函数,我们看看加入和不加入extern “C”产生的汇编代码都有哪些变化:

int f(void)
{
    return 1;
}

在加入extern “C”之前和之后产生的汇编代码是:

.file "test.cxx"
.text
.align 2
.globl _f
.def _f; .scl 2; .type 32; .endef
_f:
pushl %ebp
movl %esp, %ebp
movl $1, %eax
popl %ebp
ret
但是不加入了extern "C"之后
.file "test.cxx"
.text
.align 2
.globl __Z1fv
.def __Z1fv; .scl 2; .type 32; .endef
__Z1fv:
pushl %ebp
movl %esp, %ebp
movl $1, %eax
popl %ebp
ret

两段汇编代码同样都是使用gcc -S命令产生的,所有的地方都是一样的,唯独是产生的函数名,一个是_f,一个是__Z1fv。  

明白了加入与不加入extern “C”之后对函数名称产生的影响,我们继续我们的讨论:为什么需要使用extern “C”呢?C++之父在设计C++之时,考虑到当时已经存在了大量的C代码,为了支持原来的C代码和已经写好C库,需要在C++中尽可能的支持C,而extern “C”就是其中的一个策略
试想这样的情况:一个库文件已经用C写好了而且运行得很良好,这个时候我们需要使用这个库文件,但是我们需要使用C++来写自己的新的代码。如果我们自己的新代码使用的是C++的方式链接这个C库文件的话,那么就会出现链接错误。
我们来看一段代码:首先,我们使用C的处理方式来写一个函数,也就是说假设这个函数当时是用C写成的:

//f1.c
extern "C"
{
    void f1()
    {
        return;
    }
}

编译命令是:

gcc -c f1.c -o f1.o

产生了一个叫f1.o的库文件。再写一段代码调用这个f1函数:

// test.cxx
//这个extern表示f1函数在别的地方定义,这样可以通过
//编译,但是链接的时候还是需要
//链接上原来的库文件.
extern void f1();
int main()
{
    f1();
    return 0;
}

通过

gcc -c test.cxx -o test.o 

产生一个叫test.o的文件。然后,我们使用

gcc test.o f1.o

来链接两个文件,可是出错了,错误的提示是:

test.o(.text + 0x1f):test.cxx: undefine reference to 'f1()'

也就是说,在编译test.cxx的时候编译器是使用C++的方式来处理f1()函数的,但是实际上链接的库文件却是用C的方式来处理函数的,所以就会出现链接过不去的错误:因为链接器找不到函数。

因此,为了在C++代码中调用用C写成的库文件,就需要用extern “C”来告诉编译器:这是一个用C写成的库文件,请用C的方式来链接它们。
比如,现在我们有了一个C库文件,它的头文件是f.h,产生的lib文件是f.lib,那么我们如果要在C++中使用这个库文件,我们需要这样写:

extern "C"
{
    #include "f.h"
}

回到上面的问题,如果要改正链接错误,我们需要这样子改写test.cxx:

extern "C"
{
    extern void f1();
}
int main()
{
    f1();
    return 0;
}

重新编译并且链接就可以过去了.

总结下,C和C++对函数的处理方式是不同的。extern “C”是使C++能够调用C写的库文件的一个手段,如果要对编译器提示使用C的方式来处理函数的话,那么就要使用extern “C”来说明。



参考文献:
编译原理简单介绍
程序编译的4个阶段
gcc编译的4个阶段
cc gcc g++的区别
Gcc的编译流程分为了四个步骤
C和C++混合编程(__cplusplus使用)

猜你喜欢

转载自www.cnblogs.com/li--chao/p/9229927.html