探索c++底层编译原理

引言


C++的底层编译为了与C兼容,继承了C语言编译的大部分特点,所以在了解C++的底层编译原理之前,有必要好好聊聊C的编译模型。

众所周知C语言产生于贝尔实验室,当时的计算机资源相当有限,其内存无法完成地表示单个源文件的语法树,所以为了能够编译一些工程量较大的项目,Dennis Ritchie采用了分开编译源文件,链接形成可执行文件思想,让大文件的编译成为可能。

早期的C语言编译器并不是一个单独的程序(现在的编译器也很多是由单独功能的程序模块组成的编译工具链),Dennis RitchiePDP-11编写的C语言编译器是七个可执行文件组成的:cc/cpp/as/ld/c0/c1/c2。编译的步骤为:

  • 预编译:将#define,#Include这些的宏定义进行展开 cpp是预处理器

  • 编译:将源代码转化汇编代码 (由c0 c1 c2完成)

  • 汇编:as是汇编器,将汇编代码转化为目标文件,其外,该步骤还会产生一个符号表,记录每个文件符号的位置,如果当时无法知道该符号的位置,就先打上条目,由链接器去查找。

  • 链接:ld 是连接器,将多个源文件链接成可执行文件

符号表的生成:

每个文件的符号主要有三类:

  1. 在该文件定义的可以被其他文件引用的,如函数,全局变量

  2. 在该文件定义的不可以被其他文件引用的,如果static修饰的函数,局部变量

  3. 不在该文件定义的,从其他文件引用过来的,如extern 声明的变量,函数声明式.

前俩位符号,符号表可以直接找到该符号的位置,但是第三类符号需要在连接时由链接器定位其具体的位置。

C编译模型


由前言我们可以知道,C语言由于内存受限采用了分离编译的做法,为了对分离编译提供支持,C语言的编译模型具有以下特点。

  1. 隐式函数声明:在一个文件可以进行函数的声明而不实现该函数,在编译期间,编译器会为其生成目前代码(将函数入栈,记录函数的参数个数以及类型,返回值类型),但是其无法知道该函数的具体地址,所以生成的符号表会打个空白,等到链接时再去找到该函数。

  2. 单遍编译:C语言的编译是边扫描源文件,边生成目标代码,而且其由于内存的限制,C语言只能看到当前已经解析过的代码,无法看到之后的,而且不会保存所有已经扫描过的内容,会过目即忘。因此熟悉C语言的同学会发现C语言有以下的严格定义:

    • 变量必须先定义后使用,这样在后续使用该变量时,编译器才能知道清楚地知道变量位于哪个stack,位于其哪个位置,什么类型

    • C语言编译要求变量必须定义在函数的最开头,这是为了方便编译器分配stack空间

    • C语言的结构体必须先定义才能访问其成员,不然编译器无法知道结构体成员的类型以及偏移量

    • 遇到没定义过的函数采用隐式函数声明的方式

    • C语言不支持函数内部嵌套定义函数,多嵌套一次函数就需要多分配一个栈,内存占用会更多。

C++的编译模型


C++为了兼容C的编译模型,采用类似单遍编译的做法,回忆以下单边扫描的特点:从头到尾扫描,边扫描边生成。

这种方式存在着俩个问题:

  1. 函数重载:如何去找到最合适的那个函数,是使用当前已经找到的函数还是先保存当前去查看后面是否存在更加匹配的函数。

  2. 多值查找:C++存在着template这一复杂的函数声明,如何在编译时正确地找出其对应的符号,同时,当存在多个定义时,如果确保正确地找出对应的符号。

    C++实际上并不是单边编译,而是伪单边编译,其实际上会遍历2遍源代码进行编译,但是对外表现出单边编译。

    为了实现函数重载,C++编译器采用名字修饰(name mining 名字修饰) 的方法,即一个函数对应于一个符号,重载的函数会生成不同的函数符号。

    在C语言中通常一个符号在程序中只能有一处定义,不然就会造成重定义,但是C++并非如此,C++在编译时,并不知道某些符号是否由该文件产生,因此每个目标文件都会生成一份弱定义,由编译器去选择哪一份作为最终的定义。

    名字修饰

    简单样例

    考虑一个下面的C++程序中的两个f()的定义:

    int  f (void) { return 1; }
    int  f (int) { return 0; }
    void g (void) { int i = f(), j = f(0); }

    这些是不同的函数,除了函数名相同以外没有任何关系。如果不做任何改变直接把它们当成C代码,结果将导致一个错误——C语言不允许两个函数同名。所以,C++编译器将会把它们的类型信息编码成符号名,结果类似下面的的代码:

    int  __f_v (void) { return 1; }
    int  __f_i (int) { return 0; }
    void __g_v (void) { int i = __f_v(), j = __f_i(0); }

    注意g()也被名字修饰了,虽然没有任何名字冲突。名字修饰应用于C++的任何符号。

    从C++中链接时的C符号的处理

    最常见的C++惯常的做法:

    #ifdef __cplusplus 
    extern "C" {
    #endif
       /* ... */
    #ifdef __cplusplus
    }
    #endif

    这种写法用于确保下符号是未被C++编译器名字修饰过的——这种代码能使得C++编译器编译出的二进制目标代码中的链接符号是未经过C++名字修饰过的,就像C编译器一样。就像C语言定义是未名字修饰过的一样,C++编译器需要防止名字修饰这些标识符。

    例如,C标准字符串库<string.h>通常包含了类似这样子的

    #ifdef __cplusplus
    extern "C" {
    #endif

    void *memset (void *, int, size_t);
    char *strcat (char *, const char *);
    int   strcmp (const char *, const char *);
    char *strcpy (char *, const char *);

    #ifdef __cplusplus
    }
    #endif

    于是,例如这样的代码

    if (strcmp(argv[1], "-x") == 0) 
       strcpy(a, argv[2]);
    else
       memset (a, 0, sizeof(a));

    就能使用正确的、未经名字修饰过的strcmpmemset。如果没有使用extern "C",那么SunPro C++编译器会产生等价于下面的C代码:

    if (__1cGstrcmp6Fpkc1_i_(argv[1], "-x") == 0) 
       __1cGstrcpy6Fpcpkc_0_(a, argv[2]);
    else
       __1cGmemset6FpviI_0_ (a, 0, sizeof(a));

    而这些链接符号并不存在于C运行库中(例如 libc)。因此将导致链接错误。

gcc与g++

误区一:gcc只能编译c代码,g++只能编译c++代码 两者都可以,但是请注意: 1.后缀为.c的,gcc把它当作是C程序,而g++当作是c++程序;后缀为.cpp的,两者都会认为是c++程序,注意,虽然c++是c的超集,但是两者对语法的要求是有区别的

2.编译阶段,g++会调用gcc,对于c++代码,两者是等价的,但是因为gcc命令不能自动和C++程序使用的库联接,所以通常用g++来完成链接,为了统一起见,干脆编译/链接统统用g++了,这就给人一种错觉,好像cpp程序只能用g++似的。

误区二:gcc不会定义__cplusplus宏,而g++会 实际上,这个宏只是标志着编译器将会把代码按C还是C++语法来解释,如上所述,如果后缀为.c,并且采用gcc编译器,则该宏就是未定义的,否则,就是已定义。

误区三:编译只能用gcc,链接只能用g++ 严格来说,这句话不算错误,但是它混淆了概念,应该这样说:编译可以用gcc/g++,而链接可以用g++或者gcc -lstdc++。因为gcc命令不能自动和C++程序使用的库联接,所以通常使用g++来完成联接。但在编译阶段,g++会自动调用gcc,二者等价。

误区四:extern "C"与gcc/g++有关系 实际上并无关系,无论是gcc还是g++,用extern "c"时,都是以C的命名方式来为symbol命名,否则,都以c++方式命名

 

g++与gcc的区别参考 链接:https://www.zhihu.com/question/20940822/answer/69547180

链接方式

由前文我们知道,由于历史原因,程序需要进行编译、链接后才能执行,那么链接其实是有俩种方式的,静态链接与动态链接。

静态链接方式:在程序执行之前完成所有的组装工作,生成一个可执行的目标文件(EXE文件)。

动态链接方式:在程序已经为了执行被装入内存之后完成链接工作,并且在内存中一般只保留该编译单元的一份拷贝。

静态链接

静态链接的特点就是在程序运行前完成组装工作,链接完成后对静态库进行修改无法影响到之前的程序,所以静态库的修改需要再次编译才能改变程序,同时静态链接会将该链接库对应的符号重定位到目标文件中,所以多个引用该库的程序就具有多个该库的拷贝,内存占用更大。

//main.cpp文件
#include<iostream>
#include<string>
#include<vector>
//使用该预编译声明告诉编译器链接的库
#pragma comment(lib, "JenseLib.lib")
using namespace std;
void fnJenseLib();
int main() {
fnJenseLib();
getchar();
return 0;
}
// JenseLib.cpp : 定义静态库的函数。
//

#include "pch.h"
#include "framework.h"
#include<stdio.h>
// TODO: 这是一个库函数示例
void fnJenseLib()
{
printf("welcome to Jense Lib");
}

本文件是在vs2017,win10平台下进行编译,新键一个静态链接库项目,然后在里面生成JenseLib.cpp文件,编译完成后可以在debug文件夹或者release文件夹找到JenseLib.lib文件,如果是其他编译器应该也能找到该lib文件,只不过路径可能不一样。再来讨论一下到底是在debug文件夹还是在release文件中生成JenseLib.lib文件,这个如果用过vs的同学可能会比较清楚,如果是你编译选择的是debug模式,那么就会生成的debug文件夹,里面存放着目标文件(.exe,.lib)文件,如果选择release模式,就会生成release文件夹。debug跟release是俩种不同的编译模式,后面我们再介绍他们的区别。

总之,你的main.cpp所在文件目录必须包含.lib,不然会爆链接错误,找不到该库。

动态链接

(1)lib是编译时用到的,dll是运行时用到的。如果要完成源代码的编译,只需要lib;如果要使动态链接的程序运行起来,只需要dll。 (2)如果有dll文件,那么lib一般是一些索引信息,记录了dll中函数的入口和位置,dll中是函数的具体内容;如果只有lib文件,那么这个lib文件是静态编译出来的,索引和实现都在其中。使用静态编译的lib文件,在运行程序时不需要再挂动态库,缺点是导致应用程序比较大,而且失去了动态库的灵活性,发布新版本时要发布新的应用程序才行。

运行时库

其实,任何一个程序,它的背后都有一套庞大的代码在支撑着它,以使得该程序能够正常运行。这套代码至少包括入口函数、以及其所依赖的函数构成的函数集合。当然,它还包含了各种标准库函数的实现。

这个“支撑模块”就叫做运行时库(Runtime Library)。而C语言的运行库,即被称为C运行时库(CRT)。

CRT大致包括:启动与退出相关的代码(包括入口函数及入口函数所依赖的其他函数)、标准库函数(ANSI C标准规定的函数实现)、I/O相关、堆的封装实现、语言特殊功能的实现以及调试相关。其中标准库函数的实现占据了主要地位。标准库函数大家想必很熟悉了,而我们平时常用的printf,scanf函数就是标准库函数的成员。C语言标准库在不同的平台上实现了不同的版本,我们只要依赖其接口定义,就能保证程序在不同平台上的一致行为。C语言标准库有24个,囊括标准输入输出、文件操作、字符串操作、数学函数以及日期等等内容。大家有兴趣的可以自行搜索。

既然C语言提供了标准库函数供我们使用,那么以什么形式提供呢?源代码吗?当然不是了。下面我们引入静态链接库的概念。我们几乎每一次写程序都难免去使用库函数,那么每一次去编译岂不是太麻烦了。干嘛不把标准库函数提前编译好,需要的时候直接链接呢?我很负责任的说,我们就是这么做的。

那么,标准库以什么形式存在呢?一个目标文件?我们知道,链接的最小单位就是一个个目标文件,如果我们只用到一个printf函数,就需要和整个库链接的话岂不是太浪费资源了么?但是,如果把库函数分别定义在彼此独立的代码文件里,这样编译出来的可是一大堆目标文件,有点混乱吧?所以,编辑器系统提供了一种机制,将所有的编译出来的目标文件打包成一个单独的文件,叫做静态库(static library)。当链接器和静态库链接的时候,链接器会从这个打包的文件中“解压缩”出需要的部分目标文件进行链接。这样就解决了资源浪费的问题。

Linux/Unix系统下ANSI C的库名叫做libc.a,另外数学函数单独在libm.a库里。静态库采用一种称为存档(archive)的特殊文件格式来保存。其实就是一个目标文件的集合,文件头描述了每个成员目标文件的位置和大小。

 

 

猜你喜欢

转载自www.cnblogs.com/zhangshinan/p/12971792.html