返回

编译与链接

编译和链接

  • 编译和链接是大家一定会用到但却很少被重视的步骤
  • 因为这两个步骤通常被IDE封装得很完美,我们一般都是在IDE中一键构建项目

  • 当我们一旦遇到链接相关的错误时,很多人会束手无策,接下来我简单介绍下编译和链接的整个过程

编译

  • 编译(compilation):编译就是将程序的源代码翻译成CPU能够直接运行的机器代码
1
2
3
4
5
6
7
8
//main.c
#include<stdio.h>
int add(int a,int b);
int main(){
    printf("Hello,World!\n");
    int result = add(5,5);
    return 0;
}
  • 比如我们写了一个源文件main.c,里面简单输出了一行文字,并且调用了一个函数add,而这个函数被定义在另一个源文件math.c
    • 这里我们就可以调用gcc -c来分别编译这两个源文件

注:

  • 常用的c/c++编译器除了gcc还有clang,msvc等
  • 编译永远都是以单个源文件为单位的
  • 在实际开发中,我们通常会将不同功能的代码分散到不同的源文件,一方面方便代码的阅读和维护,另一方面也提升了软件构建的速度

    • 比如我修改了一个源文件,那么只需要单独编译它这一个,而不需要浪费时间重新编译整个工程
  • 在编译完成后会生成两个扩展名为.o的文件,它们被称为目标文件(Object File),目标文件是一个二进制的文件,文件格式是ELF(Executable and Linkable Format),Linux下所有可执行文件的通用格式;相应的Windows使用的是另一种格式PE(Portable Executable),它们虽然互不兼容但在结构上非常相似,都是对二进制代码的一种封装

    • 我们可以在目标文件头部找到可执行文件的基本信息,比如支持的操作系统、机器类型等
    • 目标文件的头部后面是一系列的区块/段(Sections),里面有我们的机器代码还有程序的数据等
      • 比如.text区里面是之前编译好的机器代码,.data是数据区,里面保存了我们初始化的全局变量,局部静态变量等等

    注:目标文件虽然包含了编译之后的机器代码,但它并不能直接执行,操作系统也不允许你去执行它

  • 因为在编译的过程中我们用到了尚未定义的函数add,而我们主程序中的add只是一句函数声明,他被定义在另一个模块math.c

  • 这同样也包括我们用到的标准库中的printf ,如果我们去查看stdio.h头文件,其中的printf也只是一个函数的声明;换句话说,我们在编译main.c的过程中,编译器是完全不知道printfadd函数的存在的

    • 比如它们位于内存的哪个区块,代码长什么样,编译器都是不知道的
    • 编译器只能将这两个函数的跳转地址暂时先设为0,随后在链接的时候再去修正它
  • 比如我们看main.o这个目标文件中的内容

  • 这里的main是编译之后的主函数代码,左边是机器代码,右边是对应的反汇编
    • 可以看到这里的两个call指令,很明显它们对应之前调用的printfadd函数,但是可以发现,它们的跳转地址都被设为了0,而这里的0会在后面链接的时候被修正

  • 为了让链接器能够定位到这些需要被修正的地址,在目标文件的代码块中我们还可以找到一个重定位表(Relocation Table)
    • 比如在.text区块中,需要被重定位的两个函数printfadd,它们位于偏移量14和23的位置,后面是地址的类型和长度,这和我们之前看到的机器代码是一 一对应的

链接

  • 我们将另一个源文件math.c编译出来,最后连同main.o一起链接生成一个独立的可执行文件,我们用到的还是gcc命令gcc main.o math.o -o main,后面传入之前编译的这两个目标文件,随后在目录下可以找到生成的可执行文件main,而这个main就可以直接运行了

  • 所以链接(Linking)其实就是将编译之后的所有目标文件连同用到的一些静态库、运行时库组合拼装成一个独立的可执行文件,其中就包括我们之前提到的地址修正

  • 链接器会根据我们的目标文件或者静态库中的重定位表,找到哪些需要被重定位的函数、全局变量,从而修正它们的地址

  • 如果我们在链接的过程中,忘记提供必须的目标文件,比如这里的math.o,由于链接器找不到add函数的实现,于是报错“引用未定义”,或者有的编译器也叫它“符号未定义",意思就是我们用到了add,但链接器却无法找到它的定义,因此只能报错

构建工具

  • 但如果我们每次都手动编译再链接显然不够高效,实际开发也没有人这么做
  • 通常我们都是用各种各样的IDE或者项目构建工具帮我们自动化了

Makefile

  • 这里先介绍一种最简单的构建工具makefile(make),可能很多人对它的印象是很古老,但其实makefile除了软件构建之外还有许多功能
    • 比如自动生成文档
  • 现在许多现代的项目仍然还在使用make
    • Android OS的构建

  • makefile的核心是对“依赖”(Dependency)的管理
    • 比如要构建可执行文件main,只需要main.omath.o这两个文件,同时执行gcc main.o math.o -o main这条链接指令
    • 如果要构建main.o又需要main.c这个文件,同时执行gcc -c main.c这条编译指令

  • 可以发现,makefile其实就是再定义一颗依赖树
  • 我们要构建最上方的这个目标就需要提供下面这些节点的文件,然后这样层层地递归下去
  • 有了makefiile之后,我们可以调用make命令,后面跟上目标的名称main,它会自动根据我们的“依赖树”递归地去构建这个可执行文件

  • 由于第一次运行所有叶子节点都不存在,make会自动构建所有的依赖,包括其中的目标文件

  • 但如果我们再运行一次make,由于所有的文件都已经存在并且是最新的,make就不会再重复构建了

  • 此时,如果我们单独再修改一下main.c文件,由于main.c只会影响main.o,从而影响最后的可执行文件main,所以make只会去重新生成这两个相关的文件,从而避免了其他不必要的文件编译
  • 其实所有的现代化构建工具,都用到了相同的原理——对依赖的管理,只不过加入了一些更实用的功能,比如对脚本语言的支持、第三方库的管理等等

CMake

  • CMake是一款软件构建工具
  • 无论使用的是什么平台、什么编程语言,构建(Build)都是软件开发中必不可少的一个步骤
  • 如果项目只有一个源文件,我们当然可以用一行命令完成编译、链接的整个过程

  • 如果面对的是一个复杂的项目,其中包含不同的模块、组件

  • 每个组件由若干个源文件组成,里面还用到了不少第三方库
  • 这时如果我们再去手动编译链接,将会非常的低效
  • 软件构建所做的就是全自动完成代码编译、链接、打包的整个过程,并且还能管理不同组件、甚至第三方库的关联

  • 我们平时使用的IDE大多都内置了构建系统,只是我们没有留意
  • 每一个构建工具通常有各自擅长的领域
    • 如果在VS中做C++的开发,那么多半用到的是微软自己的MSBuild
    • 如果使用Android Studio写移动端的程序,那么多半用到的是Gradle
    • 当然还有一些更复杂、更万能的构建系统,比如Bazel、BUCK,它们试图用单个工具来完成各种语言在不同环境下的构建
    • 下面以CMake为例来专门介绍一下C和C++程序的构建
  • CMake是一个被广泛使用的、开源免费并且完全跨平台的项目构建工具
  • CMake可以在不同平台上编译运行软件,不再需要手动配置MakefileVS或者XCode工程,CMake会全自动的帮我们做到这一切
  • 由于CMake本身是不带编译工具的,所以在Windows上我们要提前安装MSBuild工具链或者直接安装VS,在Linux下则需要安装gcc或者clang等编译工具

  • CMake会根据我们所编写的构建规则,也就是CMakeLists文件,来自动生成目标平台下的原生工程文件,比如Windows下的VS工程或者Linux下的Makefile等等
  • 因此要顺利完成编译,C++工具链是必不可少的

单个文件

配置
  • 以只有单个源文件的例子来介绍CMake的基本用法
1
2
3
4
5
6
//main.cpp
#include <iostream>
int main(){
    std::cout << "你好兄弟" << std::endl;
    return 0;
}
1
2
3
4
//CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(Example)
add_executable(Example main.cpp)
  • 首先我们要做的第一件事是在项目的根目录下创建一个CMakeLists.txt文件
  • 对于一个最简单的、只有一个源文件的工程,这三行代码是必不可少的
    • 第一行指定了构建项目所需的最低CMake版本
    • 第二行指定了工程的名字,我们随后输出的可执行文件也会和它同一个名称
    • 第三行表示我们的项目需要构建一个可执行文件,并且它由main.cpp编译而成

  • 随后我们可以根据这个CMakeLists文件生成目标平台下的原生工程,这个过程在CMake中叫做"配置"(Configure)
    • 我们可以在菜单中找到CMake Configure命令,或者在VSCode打开项目时会自动提示你进行项目“配置”,
    • 我们只需要选择平台原生的C++构建工具然后等待配置完成即可
构建

  • 我们可以在VSCode中使用快捷键F7或者在菜单中运行CMake Build命令
  • 如果一切顺利的话,面板中会输出成功编译的可执行文件

注:配置cmake -S . -B build和构建cmake --build build操作都有对应的命令行指令,我们也可以在输出面板中找到它们;在通常情况下使用菜单或者图形界面自然更方便一些;但如果我们如果想在服务器上做持续集成(CI),进行自动化的编译和测试,命令行指令就格外有用了

多个文件

  • 用一个黑洞渲染的工程来讲解相对复杂一点的情况
  • 该工程包含多个源文件、图片资源还有一些第三方库,因此更加贴近于实际项目一些
  • CMakeList.txt文件中
    • find_package命令,它会在计算机中寻找符合要求的第三方库,我们需要确保计算机中事先安装好了它们,其次这些库也支持使用CMake构建,因为大多数常见的C++库都提供了CMake的支持;
      • 命令的第一个参数是库名,第二个参数REQUIRED表明该库是必须的,如果计算机中没有安装会直接报错
    • file GLOB命令,由于该项目由多个源文件组成,因此,使用该命令通过通配符匹配所有的C++源文件,并将它存放在变量SRC_FILES
    • add_executable命令用来构建一个可执行文件,第一个参数是工程文件的名字,这里是一个宏,会被自动替换成project命令指定的工程名,第二个参数是之前匹配的所有源文件
    • target_Link_libraries命令用来链接第三方库
    • target_compile_features命令用来打开对C++17的支持
    • add_custom_command命令,参数POST_BUILD也就是字面意思,代表编译之后要执行的操作,调用cmake命令,将根目录下的assets文件夹拷贝到输出路径下

第三方库的安装

  • 由于CMake只是一个构建工具,它并不包含库的安装和管理,如果我们的项目用到了第三方库,则需要确保计算机中事先安装好了它们
  • 常见安装方式是:直接下载库的源文件,然后手动构建并指定CMake库的路径,对于Linux和Mac,也可以直接通过包管理工具安装,不过缺点是每安装一个库,都需要执行许多繁琐的步骤,并且不同平台的配置过程也不太一样
  • 这里推荐一个微软的开源工具Vcpkg,它是一个跨平台的C++库管理工具,类似于Python的pip
  • 因此步骤简化为:先调用vcpkg install <库名>安装第三方库,然后在cmake构建的时候指定vcpkg工具链即可;如果使用的命令行,只需要额外传递一个参数CMAKE_TOOLCHAIN_FILE;如果使用的是VSCode插件,在配置文件中添加路径即可
  • 关于vcpkg的安装步骤,可以参考官方文档

总结

  • CMake是一个非常灵活但也非常复杂的工具,上面只对CMake这个工具做一个简单的使用介绍
  • CMake最好还是边用边学,比如亲自创建一个工程并引入一些外部关联,试着将项目顺利构建起来,如果遇到问题可以阅读官方文档或者在搜索引擎上寻找答案
  • CMake本身也是一种编程语言,所以可以使用它来实现基本的程序逻辑,比如循环、条件分支等等,其中最典型的应用是根据工程的配置来选择性编译部分源代码,比如有些功能只有在测试版本中才会开启
  • 和其他的构建工具一样,CMake的本质依然是定义各个目标之间的关联,比如项目由哪些可执行文件、动态库、资源文件组成,然后它们又是通过哪些源文件编译、用到了哪些外部关联等等
  • 除了以上讲到的较为基本的构建功能,CMake还允许添加单元测试、创建安装包、或者将工程拆分成若干个子目录更利于大型项目的管理

动态链接库

  • 动态链接:Dynamic Linking。将各个程序共享的代码提取出来,保存成一个独立的动态链接库,等到程序运行的时候再将它们加载到内存。这样不但可以节省空间,因为同一个模块在内存中只需要保留一份副本,可以被不同的进程所共享。另外由于动态库是独立的文件,因此我们可以很方便的安装和更新它们
  • 静态链接:将编译产生的所有目标文件连同各种库,合并形成一个独立的可执行文件,它不需要额外的依赖就可以直接运行。但静态链接最大的问题在于生成的文件体积大、并且相当耗费内存资源。随着软件复杂度的提升,我们的操作系统也越来越臃肿,不同的软件极有可能都包含了相同的功能的代码。例如libc.so是所有C语言程序都会用到的运行时库,它本身就高达2M左右,设想如果每个程序都重复包含这2M的代码,显然会浪费大量的硬盘空间。这个时候动态链接的优势就体现出来了。

创建动态链接库

1
2
3
4
5
//math.c
int add(int a, int b)
{
    return a + b;
}
1
2
//math.h
int add(int a, int b);
  • 这里的math.c中定义了一个函数add,我们希望将它编译成一个动态库;相应的math.h是它的头文件,里面只包含了函数的声明,主要方便其他模块的引用
1
gcc -shared -fPIC math.c -o libmath.so
  • math.c编译链接成一个动态库
  • -shared(Shared Library):表明这是一个共享库,或者也叫共享对象(Shared Object),它是Linux下对动态库的另一种称谓
  • -o:指定了要输出的文件名
  • .so:Shared Object,Linux下动态库的扩展名
  • .dll:Dynamic Link Library,Windows下动态库的扩展名

使用动态链接库

1
2
3
4
5
6
7
//main.c
#include <stdio.h>
#include "math.h"
int main(){
    printf("add(1, 2) returns %d\n", add(1,2));
    return 0;
}
  • 在主程序中包含这个math.h头文件,并且调用动态库中的add()函数,它和一般的函数调用没有区别
1
gcc main.c -lmath -L. -o main
  • 在编译主程序的时候,我们需要指定一个-l参数,它告诉编译器与之前创建的libmath.so进行动态链接

注:这里在指定动态库的时候,省略了前缀lib和扩展名.so,只保留math

  • -L:指定动态库所在的路径
  • 运行该命令,我们就得到了一个经过动态链接的主程序main

注:由于Linux默认只会去系统路径/user/local/lib下搜索动态库,这一点和windows不一样

  • 动态链接的一大优势是允许我们单独更新动态库本身,而不用重新编译其他的组件
  • 比如我们对math.c中的add函数稍微做一点修改,然后重新编译libmath这个动态库,主程序我们不用做任何修改,直接运行就可以看到刚才我们对动态库所作的更新

动态链接vs静态链接

  • 静态链接是将编译产生的所有目标文件连同各种库,合并形成一个独立的可执行文件。其中我们会去修正模块间函数的跳转地址,也被叫做重定位。而动态链接实际上将链接的整个过程推迟到了程序加载的时候。
    • 比如我们去运行一个程序,操作系统会首先将程序的数据、代码连同它用到的一系列动态库先加载到内存,并且这个过程是递归的。比如我们的程序会用到一系列的动态库,而这些动态库又有可能用到其他的动态库。其中每一个动态库的加载地址都是不固定的,操作系统会根据当前地址空间的使用情况,为它们动态分配一块内存。当动态库被加载到内存以后,一旦它的内存地址被确定,我们就可以去修正动态库中的那些函数跳转地址了,也就是重定位。这些地址在程序加载之前都只是一堆占位符。
  • 这里有一个问题:如果我们直接去修改代码段中的跳转地址,由于代码段的内容被修改,自然就不能被其他进程所共享了。因为我们需要在内存中保存多个不同的副本,这刚好与节约内存的目标就背道而驰了。
    • 为了解决这个问题,动态链接采用了一种聪明的做法:不再修改代码段,而是在数据段中专门预留一片区域用来存放函数的跳转地址。它也被叫做全局偏移表GOT(Global Offset Table),里面专门用来存放全局变量和函数的跳转地址。于是我们在调用函数的时候,会首先查表,然后根据表中的地址来进行跳转,这些地址会在动态库加载的时候修改为真正的地址,而查表的过程也很容易实现。由于全局偏移表与代码段的相对位置是固定的,我们完全可以利用CPU的相对寻址来实现,有了全局偏移表,我们不再需要修改代码段,因此代码可以被所有进程共享,而全局偏移表虽然在每一个进程中保留一份副本,但由于占用空间很小,所以完全没有问题。采用这种方式实现的动态链接也被叫做地址无关代码(Position Independent Code,PIC)。换句话说,我们的动态库不需要做任何修改,被加载到任意内存地址都能够正常运行,并且能够被所有进程共享。这也就是为什么上面使用动态链接库时,给编译器指定-fPIC参数的原因。
  • 另一方面由于动态链接在程序加载的时候需要对大量函数进行重定位,这一步显然是非常耗时的。为了进一步降低开销,我们的操作系统还做了一些其他的优化,比如延迟绑定,或者也叫程序链接表(PLT),准确来说延迟绑定使用到了PLT(Procedure Linkage Table)结构。与其在程序一开始就对所有函数进行重定位,不如将这个过程推迟到函数第一次被调用的时候。因为绝大多数动态库中的函数可能在程序运行期间一次都不会被使用到。
    • 它的大概思路是,GOT中的跳转地址默认会指向一段辅助代码,它也被叫做桩代码(stub),在我们第一次调用函数的时候,这段代码会负责查询真正函数的跳转地址,并且去更新GOT表。于是我们再次调用函数的时候,就会直接跳转到动态库中真正的函数实现。
  • 总而言之,动态链接将链接的整个过程,比如符号查询、地址的重定位从编译时推迟到了程序的运行时,它虽然牺牲了一定的性能和程序加载时间,但更有效的利用了磁盘空间和内存资源,也极大的方便的代码的更新和维护,更关键的是实现了二进制级别的代码复用。

参考资料:

Built with Hugo
Theme Stack designed by Jimmy