C/C++程序编译与链接(一) 编译与链接的概念


python,js等语言,编译,链接过程对程序员来说是完全透明的,代码写完就可以直接跑起来,错误会在运行过程中发现。

而对C++来说,代码写完到程序运行中间还隔了一大步,就是编译与链接。它们对程序员来说并不是透明的,需要根据需求来控制编译,链接的过程,控制可执行文件的生成。

大部分C++程序员都遇到过诸如:符号未定义,重复的符号定义,动态库依赖问题,依赖库生成的规则不同导致的符号冲突,如果不了解编译与链接的基本知识和规则,解决起来还是挺麻烦的,只能头疼医头,脚痛医脚。

这将是一系列文章,介绍编译,链接的基本概念及流程,加载器的规则,相关工具的使用。

本篇是介绍编译与链接的基本概念和流程。

首先编译器负责编译与链接,将一系列源文件生成可运行的程序。

常用的编译器包括GCC,Vistual Studio,分别对应了Linux和Windows平台(当然GCC也可以运行于Windows下)。

编译器生成可执行文件的分为两步:编译与链接,编译将源文件编译成目标文件;链接器将目标文件及程序依赖的库链接成可执行文件,Linux中的连接器是ld程序。

编译

编译的目标就是将源文件变成目标文件,包括预处理,语言分析,汇编,生成目标文件

预处理

预处理就是替换原文件中宏。

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

在C/C++程序中,宏会被大量使用,它可以用于区分不同平台的条件编译,也可以用于简化代码。

对宏的处理是在编译的第一个阶段,经过预处理器的处理,源文件中的宏被替换成定义内容,它的规则如下:

  • 将#include关键字标示的含有定义的文件包含到源代码文件中。
  • 将#define语句指定的值转换成常量。
  • 在代码中调用宏的位置将宏定义转换成代码。
  • 根据#if,#elif和#endif指令的位置包含或排除特定部分的代码。

在gcc中,我们可以用如下命令生成宏被替换后的代码。
gcc -E -P <input file> -o <output preprocessed file>.i

这个命令只是告诉gcc只做预处理,并没有编译,如果我们遇到一个比较复杂的宏拿不准解析后的具体代码,可以将宏单独摘出来放到文件中,也不必在意语法是否正确。通过此命令可以看到被解析成的具体代码。

比如下面的代码 :

#define _SUM_(a,b) (a) + (b)
void function()
{
    
    
    _SUM_(2,3);
}

gcc -E -P sum.cpp -o sum.i 处理后,变为:

void function()
{
    
    
    (2) + (3);
}

跨平台中代码中最常用的方式,就是通过#if #define宏来区分代码,在预处理阶段满足宏条件的代码将会保留。

语言分析阶段

在这个阶段中,编译器会将C/C++代码换成更易于处理的形式(删去注释和不必要的空格,以及从文本中提取符号等操作)。通过词法分析,就可以得到这种优化和精简后的源代码形式,词法分析的目的在于检查程序是否满足编程语言的语法规则。编译器会在检查到不满足语法规则错误的时候报告错误或发出警告。编译错误会导致编译过程中断。
可以更加细分为三个阶段:

  • 词法分析
  • 语法分析
  • 语义分析

如果编译报错就是属于这个阶段。

汇编阶段

当源代码经过校验,其中不包含任何语法错误后,编译器才会执行汇编阶段。在这个阶段中,编译器会将标准的语言集合转换成特定CPU指令集。不同的CPU包含不同的功能需求,通常会包含不同的指令集,寄存器和中断。这就是为什么不同的处理器要有不同的编译器对其支持。

gcc 可以通过下面的命令将源代码转换成对应的ASCII编码的文本文件

**gcc -S -masm=intel function.c -o function.s**

-masm=intel表示生成intel平台汇编代码。

优化阶段

当源代码文件生成最初版本的汇编代码后,优化过程就开始了,这可以将程序的寄存器使用率最小化。此外,通过分析能够预测出实际上不需要执行的部分代码,并将其删去。

目标文件

编译阶段的产物就是目标文件,目标文件是二进制文件,它们有标准格式,能让操作系统认识它。在Visual Studio(windows)下目标文件是以.obj结尾的文件,在GCC下目标文件是以.o结尾的问题。

每个源文件都生成一个对应的目标文件(也称一个编译单元,汇编指令会在此阶段转换成对应机器指令的二进制值,并写入目标文件的特定位置)。

源文件中的方法和变量名就在目标文件中就称为符号。其实我们只要知道目标文件中包括各种不同类型(主要指作用域)的符号就足够了,有关目标文件中的绝大多数细节,我们都不用关心。大体上目标文件包括三种类型符合:

  • 导出符号,可以供其他编译单元(目标文件)使用。
  • 局部符号,仅供当前编译单元(目标文件)使用。
  • 未定义的符号,在其他编译单元中(目标文件),本质上是无法确定该符号的地址。

一个目标文件形象的如下图:

编译器视角的目标文件.png

我们在编译程序,我们通常需要关注其中的符号,因为我们遇到的编译的多数错误就是符号未定义或是符号重定义。可以通过nm命令查看目标文件中的符号,如下 function_test.cpp文件生成的目文件中的符号:

int addNum(int a,int b) {
    
    
    return a+b;
}

int subtractNum(int a,int b) {
    
    
    return a-b;
}

gcc -c function_test.cpp,生成function_test.o文件,通过nm 命令查看包含的符号

nm function_test.o

0000000000000014 T _Z11subtractNumii
0000000000000000 T _Z6addNumii

如果是以.c结尾的源文件文件,则符号名称如下:

0000000000000000 T addNum
0000000000000014 T subtractNum

这是因为对C++和C文件,gcc对符号的命名规则不同。

链接

经过编译阶段后,源文件变为了一个一个目标文件(一个个独立的编译单元)。链接阶段就是将这些目标文件生成可执行文件。

源文件中相互调用的方法,变量,在编译阶段并不知道它们具体的地址,可以说编译阶段视角只是局限在单个文件内,外部的方法和变量,并不知道它们在哪。那么目标文件中就是这些方法或变量的地址无法知道。但是完整的程序这些地址是一定需要的,那么这个建立地址联系的工作就是链接阶段完成,经常出现的符号未定义或符号重复定义就是这个阶段产生的错误。

链接器的输入是一系列目标文件,主要工作就是给这些目标文件中相互调用的符号生成正确的地址,再生成正确的可执行文件。它的工作类似于拼积木,如下:

拼接.png

它包括如下几个步骤:

  1. 重定位

链接过程的第一个阶段仅仅进行拼接,其过程是将分散在单独目标文件中不同类型的节拼接到程序内存映射节中。

  1. 解析引用
  • 检查拼接到程序内存映射中的节。
  • 找出哪些部分代码产生了外部调用。
  • 计算该引用的精确地址(在内存映射中的地址)
  • 最后,将机器指令中的伪地址替换成程序内存映射的实际地址,这样就完成了引用解析。

连接器区别于编译器,它并不关心编写的代码的任何细节相反,链接器关注的是目标文件的集合,并致力于将这些目标文件拼接成程序内存映射。

如下是链接器视角下的目标文件,根据它们的导出的符号(在源文件已经实现了/定义了的函数或变量)和它们需要的符号(调用/使用其它源文件中的函数或变量)进行拼接(确认符号地址)成完整的可执行文件。

链接器视角下的目标文件.png

结语

编译器本质上是将源代码中的高级语言翻译成低级语言,比如翻译成intel x86平台的汇编代码。上面简单的描述编译器的主要工作后,可以体会到编译器的复杂性,通常一个大型的C/C++项目有成千上万上个源文件,源文件间的调用关系错中复杂,编译器生成正确的汇编代码,要理解这些调用关系产生正确符号地址等,再生成正确的执行文件,还要讲究速度。这样简单的想想都很复杂。

平常我们认为C/C++复杂,认为它们是比较低级的语言,但是在它们背后,编译器还是做了大量的工作。

那么对python,java这样简洁,高效的语言。可想而知,它们背后的编译器系统更加强大,承担了更多工作。

所谓的哪有什么轻松,简单,只是别人在替你负重前行。

猜你喜欢

转载自blog.csdn.net/mo4776/article/details/129235751