🌟《C++从零开始》 系列,开始更新中…

作为一名C/C++程序员,g++/Makefile/CMake等相关工具是必备的基础。但之前使用中一直存在一些困惑,因此参考了一些资料[1][2][3],动笔写了这篇文章,希望可以帮助自己或大家:

  • 对编译处理过程有个基本认知;
  • 能初步使用编译工具g++/Makefile/CMake;
  • 能初步使用CMake编译大型项目。

才疏学浅,若有错误不吝指正。

g++

在下文中,我们将多次利用了g++编译代码。为了方便后续学习Makefile和CMake,我们先进行简单总结。

以hello.cpp为例。

1
2
3
4
5
6
7
8
#include <iostream>
using namespace std;

int main()
{
cout << "Hello, World!" <<endl;
return 0;
}

快速入门

程序 g++ 是将 gcc 默认语言设为 C++ 的一个特殊的版本,链接时它自动使用 C++ 标准库而不用 C 标准库。

当然,用 gcc 来编译链接 C++ 程序是可行的,如下例所示:

1
$ gcc hello.cpp -lstdc++ -o hello.out

不过我们还是主要熟悉g++基本用法来编译C++代码。

  1. g++最简单的编译方式

    1
    $ g++ hello.cpp

    由于命令行中未指定可执行程序的文件名,编译器采用默认的 a.out。程序可以这样来运行:

    1
    2
    $ ./a.out
    Hello, world!
  2. 指定可执行程序文件名

    我们使用 -o 选项指定可执行程序的文件名,以下实例生成一个 名为hello.out 的可执行文件:

    1
    $ g++ hello.cpp -o hello.out

    执行 hello.out:

    1
    2
    $ ./hello.out
    Hello, world!
  3. 多个 C++ 代码文件

    如 a.cpp、b.cpp,编译命令如下:

    1
    $ g++ a.cpp cpp、b.cpp -o test.out

    生成一个 test.out可执行文件。

g++ 有些系统默认是使用 C++98,我们可以指定使用 C++11 来编译 hello.cpp 文件:

1
g++ -g -Wall -std=c++11 hello.cpp -o hello.out

g++ 常用命令选项

选项 解释
-ansi 只支持 ANSI 标准的 C 语法。这一选项将禁止 GNU C 的某些特色, 例如 asm 或 typeof 关键词。
-c 只编译并生成目标文件。
-DMACRO 以字符串"1"定义 MACRO 宏。
-DMACRO=DEFN 以字符串"DEFN"定义 MACRO 宏。
-E 只运行 C 预编译器。
-g 生成调试信息。GNU 调试器可利用该信息。
-IDIRECTORY 指定额外的头文件搜索路径DIRECTORY。
-LDIRECTORY 指定额外的函数库搜索路径DIRECTORY。
-lLIBRARY 连接时搜索指定的函数库LIBRARY。
-m486 针对 486 进行代码优化。
-o FILE 生成指定的输出文件。用在生成可执行文件时。
-O0 不进行优化处理。
-O 或 -O1 优化生成代码。
-O2 进一步优化。
-O3 比 -O2 更进一步优化,包括 inline 函数。
-shared 生成共享目标文件。通常用在建立共享库时。
-static 禁止使用共享连接。
-UMACRO 取消对 MACRO 宏的定义。
-w 不生成任何警告信息。
-Wall 生成所有警告信息。

编译过程初探

现在让我们从一个简单的例子,来一步步探讨下编译过程。

准备的hello.cpp程序如下:

1
2
3
4
5
6
7
8
#include <iostream>
using namespace std;

int main()
{
cout << "Hello, World!" <<endl;
return 0;
}

我们对它进行简单的编译&输出:

1
g++  hello.cpp -o hello.out  # 文件名可包含指定路径

可以看到,当前路径生成了hello.out文件,这是一个可执行的二进制文件。

hello.out本质是什么 ?

一个程序(比如hello.out)本质是由数据段、代码段、.bss段组成。

下图展示了一个虚拟进程(程序)内存空间运行时分布布局(下图.bss段和数据段合并了):

  • 注意到此时还多了堆&栈用来给程序运行时进行空间分配

  • 高地址的1GB(Linux下如此,Windows默认2GB)空间分配给内核,也称为内核空间;剩下的3GB分给用户,也称用户空间

你确定你理解内存分配吗?
  • 栈(Stack):存储代码中调用函数、定义局部变量(但不包含static修饰的变量)、保存的上下文等;

  • 文件映射区域 : 栈和堆中间那个空白区域。动态库、共享内存等映射物理空间的内存,一般是 mmap 函数所分配的虚拟地址空间。

  • 堆(Heap):存储那些生存期与函数调用无关的数据,如动态分配的内存。

  • .bss段:全称Block Started by Symbol,也就是未被初始化的全局变量、静态变量的内容的一块内存区域。

  • 数据段(.data):保存全局变量、常量、静态变量的内容的一块内存区域,区别.bss段在于变量已经被初始化。比如:

  • 代码段(.text & .init): .text 用于存放整个程序中的代码; .init 用于存放系统中用来初始化启动你的程序的一段代码 。

回来神来,让我们继续执行一下试试:

image-20211208140718480

我们的代码被正确执行了。但这整个过程的细节被隐藏了,如果不了解清楚对我们以后的学习工作阻碍很大。

正式开始接触细节前,我们先大致了解下上述编译过程(四步):

C/C++程序编译的过程| 码农家园

  1. 预处理:资源进行等价替换,生成预编译文件.i文件);
  2. 编译 :生成汇编代码.s文件);
  3. 汇编 :将汇编代码最终生成机器代码.o文件);
  4. 链接:动态或静态链接外部函数/库(lib)/变量,生成可执行的二进制(hex)文件/静态库(.a)文件/动态库(.so)文件

现在让我们来逐步分析。

预处理

预处理的主要作用:通过内建功能对预处理指令进行等价文本替换

一般地,C/C++ 程序的源代码中包含以 # 开头的各种编译指令,被称为预处理指令。根据ANSI C 定义,主要包括:文件包含、宏定义、条件编译和特殊控制等4大类[7]

  • 文件包含:例如常用的预处理指令 #include <iostream> ,预编译阶段会使用系统目录下iostream文件中的全部内容,替换 #include <iostream>

    #include "xxx.h" ,表示使用当前目录下xxx.h文件,<> 是在系统目录下查找。

  • 宏定义展开及处理: 预处理阶段会将定义的常量符号进行等价替换,e.g. #define A 100 , 所有的宏定义符号A都会被替换成100。还会将一些内置的宏展开,比如用于显示文件全路径的__FILE__

  • 条件编译处理: 如 #ifdef,#ifndef,#else,#elif,#endif等,这些条件编译指令的引入,使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预处理时会将那些不必要的代码过滤掉,防止文件重复包含等。

  • 其它:特殊控制处理…

特别的,预处理过程还会发生:

  • 添加行号和文件名标识: 比如在文件hello.i中就有类似 # 2 "main.c" 2 的内容,以便于编译时编译器产生调试用的行号信息,编译时产生编译错误或警告时能够显示行号;
  • 清理注释内容等。

在这一步,我们亲眼瞧瞧预处理的等价文本替换究竟做了什么:

1
g++ -E hello.cpp > hello.i  # 输出文件重定向到hello.i中

可以看到:

  1. 文件包含:我们之前引入的头文件 #include <iostream>预处理后会将#include <iostream> 代码替换为iostream文件的内容,插入到hello.i

    文件过长,以下是部分截图:

    image-20211208235953948

    特别的,iostream文件本身也#include了头文件,同样会被替换,也就是进行大杂烩嵌套拼接。

    在这里插入图片描述
  2. 其它,条件编译处理添加行号和标识等也一并可以(左图)观察到。

编译

编译过程是整个程序构建的核心部分,也是最复杂的部分之一,其工作就是把预处理完生成的 .i 文件进行一系列的词法分析、语法分析、语义分析以及代码优化,最终产生相应的汇编代码文件,也就是 .s 文件。

1
g++ -S hello.cpp -o hello.s  # 该命令包含等价替换过程

打开当前目录下hello.s ,入目即是熟悉的汇编天书:

image-20211208150625682

汇编

相对来说比较简单,每个汇编语句都有相对应的机器指令,只需根据汇编代码语法和机器指令的对照表翻译过来就可以了。

有了上述汇编代码后,我们便可以将其转换为机器码(.o文件,即object file)。

1
g++ -c hello.cpp -o hello.o  # -c 表示不进行链接,只生成目标文件

image-20211208151441286

但是在这一步还不能直接执行,会报错:

image-20211208151833947

这是因为我们还没有链接其它相应的文件,因此会报错。我们来试试链接再生成可执行代码:

1
g++ hello.o -o hello.out

然后执行:./hello.out

image-20211208160023661

假装惊喜的发现(是的就是这么戏精),文件确实已经被成功执行了。

那么,链接过程中究竟发生了什么?为什么一定要链接后才能执行

链接

链接过程究竟做了什么?

链接就是进行符号解析和重定位的过程[4]

  • 符号解析:每个符号对应于一个函数、一个全局变量或一个静态变量,符号解析的目的是将每个符号引用与一个符号定义关联起来。
  • 重定位:链接器通过把每个符号定义与一个内存位置关联起来,然后修改所有对这些符号的引用,使得它们指向这个内存位置。

为什么一定要进行符号解析和重定位

比如我们上一步生成的可执行文件hello.o 执行出错,就是因为符号std::cout没有进行解析和重定位

在前面预处理阶段,我们知道 #include<iostream> 会被替换为头文件iostream中的内容。

但头文件iostream中的符号,如cout只是被定义,并没有实现:

1
extern ostream cout;		/// Linked to standard output

具体是在libstdc++.so.6中被实现的。我们必须要让编译器找到libstdc++.so,也就是通过链接,然后将cout符号解析重定向libstdc++.so中。这样,cout才可以被正常执行。

准备代码片段

为了更清楚的说明整个过程,我们不妨换一个例子,不使用系统库文件(预处理后的文件太复杂)。

  • Main.cpp

    1
    2
    3
    4
    5
    6
    7
    8
    extern int shared;
    extern void swap(int*,int*);

    int main()
    {
    int a = 100;
    swap(&a, &shared);
    }
  • Libtest.cpp

    1
    2
    3
    4
    5
    6
    int shared = 1;

    void swap(int *a, int *b)
    {
    *a ^= *b ^= *a ^= *b;
    }

可以看到:Libtest.cpp不引用任何外部变量符号,但Main.cpp文件会引用Libtest.cpp中的shared变量swap函数

那么,Main.cpp中外部符号即shared和swap,怎么样才能被正确解析到Libtest.cpp中

链接前置知识

下面有不理解的地方,建议阅读:ELF学习–重定位文件

继续讲解前,我们还需补充几个基本概念[5]

  • 符号和外部符号

    • 在链接中,我们将函数和变量统称为符号Symbol);
    • 在本目标文件中使用,而又没有在本目标文件中定义的全局符号,称为外部符号External Symbol)。
  • 重定位表

    由于外部符号在编译后并不能确定其位置地址(链接重定位后才能确定)。所以需要这么一个文件:将需要重定位的外部符号进行标记

    比如,编译后Main.o 文件符号表:

    1
    2
    g++ -c Main.cpp -o Main.o # -c参数表示不进行链接
    objdump -r Main.o
    • 可见,sharedswap()为外部符号被标记记录,显然,这些符号是需要被解析重定向的。

      image-20211227201402682

    但是Libtest.o中没有外部符号,因此其重定位表为空。

    1
    2
    g++ -c Libtest.cpp -o Libtest.o
    objdump -r Libtest.o

    重定位表为空。

    image-20211227215524264

  • 符号表

    目标文件使用符号表Symbol Table)来记录本目标文件中的全局符号的信息。

    e.g. 自定义的全局符号地址,这样别的文件中引用了该自定义的全局符号,就可以查找其真实地址。

    • Main.o符号表

      1
      readelf -s Main.o
      • Main.o定义了全局符号main,使用到了外部符号sharedswap

        image-20211227201514728

        UND 即表示未定义需要重定义。

    • Libtest.o的符号表

      1
      readelf -s Libtest.o

      Libtest.o定义了符号sharedswap,没有使用到外部符号

      image-20211227201608874

静态链接过程

静态链接的主要目的:1)将多个目标文件合并,2)并处理各目标文件用到的外部符号(e.g. main.cpp 中的 swap和shared),对外部符号重定位( 调整地址到真正定义实现的地方,e.g.,swap→Libtest.o),最后生成可独立运行的可执行文件。

现在我们进行静态链接:

1
2
3
4
5
6
g++ -static Main.cpp Libtest.cpp -o main.out

# 等价于
g++ -c Main.cpp -o Main.o
g++ -c Libtest.cpp -o Libtest.o
g++ -static Main.o Libtest.o -o main.out # 目标文件(可执行文件)静态链接

静态链接一般采用两步链接Two-pass Linking)的方法,下面以链接 Main.cpp 和 Libtest.cpp为例具体说明。

第一步,空间与地址分配。

扫描所有的编译生成的可重定向文件(Main.o和Libtest.o)并合并,同时获得其以下信息:

  1. 全局符号表:包含所有的符号定义和符号引用;

    符号名 状态 所在目标文件
    main 定义 Main
    shared 引用 Main
    swap 引用 Mian
    shared 定义 Lib
    swap 定义 Lib
  2. 段信息:各个段的长度、属性和位置。

第二步,符号解析与重定位。

  1. 查看全局符号表,发现shared需要重定位;

  2. 全局符号表发现Libtest.o定义了shared

  3. 查看Libtest.o的符号表以及第一步的段信息,确定shared的地址;

  4. 再查看Main.o的重定位表,找到所有shared需要重定位的地址,修改为shared的真实地址;

    Main.o和Libtest.o被合并,必须要查看重定位表,知道哪些是属于Main的share,进行重定位。

  5. 继续查看全局符号表,发现swap 需要重定位,过程同上;

  6. 直至所有的符号引用都被修改为真实地址,结束。

我们可以反编译一下最后的可执行文件,看看是否如上所示已经全部重定义完成:

1
objdump -d main.out > tmp.txt

例如,主函数中调用的swap函数的地址被修正为40050d。

image-20211227204728883

其它信息合并

在静态链接下,链接器还会将各个目标文件的代码段和数据段【合并拷贝】到可执行文件,因此静态链接下可执行文件当中包含了所依赖的所有代码和数据

  • 在本例中,Main.o和Libtest.o中的代码段和数据段被合并拷贝到可执行文件中,然后进行解析重定位。

  • 下图进行了图解展示合并过程。

    img

看到这里,相信你已经明白,在静态链接中外部符号:

  • 为什么要被解析重定位:外部符号能被定位到真正实现的地方;
  • 如何被解析重定位:通过符号表实现。

同时,静态链接还会将需要的目标文件进行合并,因此体积比较大。

动态链接过程

为什么需要动态链接

试试想想以下两种糟糕的情况:

  • 空间浪费: 假设你是个腾讯技术专家,你写的代码Libtest.cpp性能挺好,于是开源出来生成一个静态库给其它人也用用。

    1
    2
    gcc -c Libtest.cpp -o libtest.o
    ar cqs libtest.a libtest.o

    github反应不错,你的大作很受欢迎,基本机器上每个程序都调用了你的库。但是由于每个程序都静态链接你的静态库libtest.a,导致每个程序都会【拷贝】Libtest.a中的代码,造成了很大的空间浪费。

  • 更新困难: 不幸的是,你不但技术精湛头发稀少同时精力旺盛,经常对你的大作libtest.a进行更新。这样你每更新一次,为了跟上你技术专家的步伐,所有的程序都要重新编译一次,来静态链接拷贝你的最新代码。情况严重的话,这可能收到一些礼貌的问候。

聪明的你,自然想到号召大家使用动态链接

  • 对那些组成程序的目标文件,比如你的libtest,不进行直接链接,而只是将必要信息写入了可执行文件,等到程序要运行时才进行链接。这样他们只用下载你大作libtest.so最新的版本,可执行文件运行时就会自动(动态)链接新版本,从而不用重新编译了。

于是你开心地开始尝试下动态链接:

1
2
3
4
5
6
7
# 生成动态库
# 生成的动态库的名字必须是lib+名字.so
g++ -shared -o libtest.so Libtest.cpp
# 保存在/usr/lib64/下
mv libtest.so /usr/lib64/
# 动态链接你的大作libtest.so,可以直接使用-ltest来引用
g++ Main.cpp -L/usr/lib64/ -ltest -o main.out

得到可执行文件main.out

但是main.out仅包含了libtest.so 的相关符号信息,并没有将 libtest.somain.out合并。只有当我们执行 ./main.out ,此时才会动态加载Libtest.so

前面提到的hello.out中的std::cout 也是动态链接的:

  1. 编译生成了可执行文件hello.out ,但此时hello.out只包含了cout符号信息;
  2. 执行可执行文件hello.out时 ,根据cout符号信息加载libstdc++.so.6 动态库。

从上也回答本节开头的问题:动态链接生成的可执行文件体积小,避免了空间浪费,同时灵活性强。这也就是使用动态链接的主要原因。

那么,动态链接的需要的动态库,和静态链接需要的静态库又是什么呢?

静态库和动态库

Windows下的静态库和动态库分别为.lib.dll 结尾的文件,本节中仅以在Linux系统中说明相关概念。

Linux 下的库有两种[8]静态库(.a)和共享库(动态库,.so) ,都采用以下方式进行链接:

1
2
3
4
# 【例】Main.cpp动态链接libtest.so,静态链接需加上-static参数
# -L:指定搜素路径,:可分隔多个路径
# -l:指定库名,前缀"lib"和后缀".a" 或".so"省略
g++ Main.cpp -L/usr/lib64/ -ltest -o main.out
  • 静态库

    • 特点:编译过程中已经被载入可执行程序,因此体积较大;

    • 命名:.a为后缀,lib为前缀, 例如 libtest.a

    • 生成:先生成.o 文件,再用ar工具可生成;

      1
      2
      g++ -c Libtest.cpp -o libtest.o
      ar cqs libtest.a libtest.o
    • 链接路径

      1. 参数-L:ld会去找gcc/g++命令中的参数-L指定的路径;
      2. 环境变量:gcc的环境变量LIBRARY_PATH,它指定程序静态链接库文件搜索路径;
      3. 默认库:再找默认库目录 /lib/usr/lib/usr/local/lib
  • 动态库

    • 特点:可执行程序运行时才载入内存的,在编译过程中仅简单的引用,因此代码体积较小;

    • 命名:.so为后缀,lib为前缀,通常还会加上版本号, 例如 libtest.a.0.1 ;

    • 生成g++工具可生成;

      1
      2
      g++ -c Libtest.cpp -o libtest.o
      g++ -shared libtest.o -o libtest.so.1.0
    • 链接路径

      1. 参数-L:ld会去找gcc/g++命令中的参数-L指定的路径;
      2. 环境变量:gcc的环境变量LD_LIBRARY_PATH,它指定程序动态链接库文件搜索路径;
      3. 配置文件:配置文件 /etc/ld.so.conf 中指定动态库路径;
      4. 默认库:再找默认库目录 /lib/usr/lib

全过程总结

源码到执行全过程分析。

在linux中程序的加载(不是链接),涉及到linker和loader两个工具。linker主要涉及动态链接库的使用,loader主要涉及软件的加载。

img

上图是一个典型的编译将源文件main.cpp 生成可执行文件myProc.out 并执行的过程 :

1
2
g++ main.cpp -L/usr/lib -laaa  -lbbb -o myProc.out 
./myProc.out
  1. 链接前:依次按预处理→编译→汇编,生成可执行文件main.o

  2. 链接:分静态链接和动态链接。

    • 静态链接:对main.o 中未定义的符号进行解析重定位到静态库aaa.o 中,然后把需要的目标文件lib1.olib2.omain.o 的.text段、.data段、.bss段进行合并生成初步的可执行文件myProc.out
    • 动态链接:此时main.o 中依旧有部分符号没有被解析,它们的实现存于动态库bbb.so中 ,但动态链接仅将bbb.so 相关符号信息保存在myProc.out中。

    现在我们获取最终的可执行文件myProc.out

  3. 执行:执行可执行文件。

    myProc.out 由代码段(.text,只读可执行),数据段(.data,只读),.bss段组成,现在看看如何被加载到内存中。

    1
    ./myProc.out  # 开始执行程序,通过loader加载器加载程序
    • 内存映射。loader启动通过mmap系统调用,将代码段和数据段映射到虚拟内存中 ,不占物理内存;
    • 动态库加载。动态链接器将编译时指明依赖的动态链接库,映射到虚拟内存中;
    • 执行.text 中指令。程序开始运行,通常伴随着栈、堆空间分配等。

编译工具简介

在前面我们简单使用g++进行文件编译、执行。当然,主要还是偏“务虚”探讨了下编译的过程及原理。

接下来,我们将偏”务实“的介绍一下基本三大编译工具的使用 :g++/Makefile/CMake。

ROS课程讲义--2.1 Catkin编译系统_jinking01的专栏-CSDN博客

三者关系如上图所示。

  • gcc/g++:Linux编译器有gcc/g++,随着源文件的增加,直接用gcc/g++命令的方式效率较低,于是发明了Makefile来进行编译;
  • Makefile: Makefile描述了整个工程的编译、链接等规则,可以有效的减少大工程中需要编译和链接的文件,只编译和链接那些需要修改的文件。然而随着工程体量的增大,Makefile也不能满足需求,于是便出现了Cmake工具;
  • CMake:CMake是一个跨平台的编译(Build)工具,可以用简单的语句来描述所有平台的编译过程。早期的make需要程序员写Makefile文件进行编译,而现在CMake能够通过对CMakeLists.txt的编辑,轻松实现对复杂工程的组织

具体实操练习掌握。

Makefile

快速入门

Makefile基本格式如下:

1
2
<target> : <prerequisites> 
[tab] <commands>
  • target(目标) : 目标文件, 可以是 Object File, 也可以是可执行文件;

  • prerequisites(前置条件) : 生成target所需要的文件或者目标;

  • command(命令): make需要执行的命令(任意的shell命令),Makefile中的命令必须以 [tab],即四个空格 开头。

基本语法

先熟悉以下偏理论总结上的东西,实践时互相验证效果更好~

Makefile包含了五个重要的东西:显示规则、隐晦规则、变量定义、文件指示和注释

  • 显示规则: 即需要指明target和prerequisite文件
    • 一条规则可以包含多个target,这意味着其中每个target的prerequisite都是相同的;
    • 当其中的一个target被修改后,整个规则中的其他target文件都会被重新编译或执行。
  • 隐晦规则:make自动推导功能所执行的规则。
  • 变量和定义:Makefile中定义的变量,一般是字符串。
  • 文件指示:通常指以下
    1. Makefile中引用其他Makefile;
    2. 指定Makefile中有效部分;
    3. 定义一个多行命令。
  • 注释:只有行注释#
一起试试

我们准了一段代码DisplayImage.cpp:使用c++和opencv对图片进行读取和显示。

为了方便阅读,代码已经尽量精简。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <opencv2/opencv.hpp>

int main(int argc, char** argv )
{
// 读取
Mat image = cv.imread( argv[1], 1 );
// 显示
cv.namedWindow("Display Image", WINDOW_AUTOSIZE );
cv.imshow("Display Image", image);
cv.waitKey(0);
return 0;
}

这里先给出已完成的Makefile文件:

1
2
3
4
5
6
7
8
9
10
export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:/home/royhuang/lib/pkgconfig

CXXFLAGS:=$(shell pkg-config --cflgs --libs opencv)

DispalyImage:DispalyImage.o
g++ DispalyImage.o -o DispalyImage
DispalyImage.o:DispalyImage.cpp
g++ -c DispalyImage.cpp -o DispalyImage.o
clean:
rm *o test

现在建议我们从下往上分析:

  1. 编写clean :删除所有的.o文件和可执行文件,避免过多的中间文件产生;

  2. 编写 DispalyImage.o:DispalyImage.cpp :根据之前的格式,target : prerequisites ,这个时候 targetDispalyImage.oprerequisitesDispalyImage.cpp

    下一行的g++命令,将cpp文件进行编译为object file(.o 文件)。

    1
    2
    DispalyImage.o:DispalyImage.cpp
    g++ -c DispalyImage.cpp -o DispalyImage.o
  3. 编写 DispalyImage:DispalyImage.o :在上一步我们得到了编译后的目标文件 DispalyImage.o 。现在我们可以build生成可执行文件DispalyImage。

    1
    2
    DispalyImage:DispalyImage.o
    g++ DispalyImage.o -o DispalyImage
  4. 应用OpenCV库和头文件

    1
    2
    3
    export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:/home/royhuang/lib/pkgconfig

    CXXFLAGS:=$(shell pkg-config --cflgs --libs opencv)
    • PKG_CONFIG_PATH :添加指定路径到环境变量。如上例,添加了路径/home/royhuang/lib/pkgconfig到环境变量 ,这样我们就可以直接在命令行中使用pkg-config命令 。

    • CXXFLAGS :指定文件(.h文件或lib文件)的路径,使得编译时可以找到相应头文件和库文件。

      在本例中,pkg-config命令可查看opencv的include头文件的路径:

      1
      2
      # --libs 参数可查看库文件
      shell pkg-config --cflgs opencv

      同时引入头文件和库文件:

      1
      pkg-config opencv --cflgs --libs opencv

有了makefile文件后,我们就可以make生成可执行文件DisplayImage了:

1
2
3
4
# 自动查找当前目录下叫“Makefile”或“makefile”的文件
make
# 显示图像
./DisplayImage ../01.jpg

从上也可总结出:Makefile 包含了所有的规则和目标,而 make 则是为了完成目标而去解释 Makefile 规则的工具

总的来说,Makefile的基本套路就是以上,熟练使用需要实际项目多练习下。

进阶学习

这里准备举一些较复杂的项目,怎么来编写Makefile文件。

但是一般较复杂的项目我现在一般用CMake,也是后文需要介绍的。因此这里复杂项目Makefile编写案例,暂时留白,后续补上。

当然,你可以先看看:Make 命令教程 - 阮一峰

CMake

早期的make需要程序员写Makefile文件,进行编译。而现在CMake能够通过对CmakeLists.txt的编辑,轻松实现对复杂工程的组织。

快速入门

首先,我们在Linux系统(CentOS)下安装下CMake:

1
sudo yum install cmake

一般使用CMake生成Makefile并编译的流程如下:

  1. 编写CMakeLists.txt,假定其路径为PATH
  2. 执行命令cmake PATH生成Makefile;
  3. 最后使用make进行编译。
一起试试

我们准备一个hello.cpp 文件,它所在的目录如下所示。

1
2
3
|-- build  # cmake生成的中间文件都放这
|-- hello.cpp
|-- CMakeLists.txt # 每个子目录下都要有CMakeLists.txt文件

文件内容很简单:

1
2
3
4
5
6
7
/* hello.cpp */ 
#include<iostream>
using namespace std;
int main()
{
cout<<"Hello Cpp!"<<endl;
}

我们编写的CMakeLists.txt,每一行代码解释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 命令格式:<命令名>(参数 参数值) ,中间参数空格隔开
# 设置项目名
project(helloDemo)

# 限定CMAKE最低版本
CMAKE_MINIMUM_REQUIRED(VERSION 2.6)

# 搜索当前目录所有源代码文件,并赋值给PROJECT_ROOT_SRCS
aux_source_directory(. PROJECT_ROOT_SRCS)

# 增加C++11特性
add_definitions(-std=c++11)

# add_executable 从一组源文件编译出一个可执行文件
# 这里将${PROJECT_ROOT_SRCS}中文件编译,生成可执行文件hello.out
add_executable(hello.out ${PROJECT_ROOT_SRCS})

开始编译:

1
2
3
4
5
# 生成makefile等中间文件
# 生成的可执行文件 【如果要可以被调试】,还要带上参数:cmake -DCMAKE_BUILD_TYPE:STRING=Debug
cd ./build && cmake ..
# 生成可执行文件
make

最后执行刚刚生成的可执行文件:

1
./hello.out

image-20211209221810181

看到这里,相信你对CMake有了个基本的认知。在前面我们也知道,CMake通常是用来编译大型项目的。

那么,大型项目结构是什么样的?又如何进行编译呢

大型项目结构

主要参考:cpp-project-structure

这里假定项目名为 my_poject ,一个完整的大项目结构通常如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
my_poject  
├── deploy # 存放部署、交付的文件
│ └── bin # 项目生成的可执行文件
│ └── lib # 项目生成的库文件
│ └── include # 项目对外提供的头文件
├── build # 存放cmake产生的中间文件
│ └── release
│ └── debug
├── doc # 存放项目文档
├── src # 存放资源文件
│ └── pic
├── 3rdparty # 存放第三方库
│ └── lib # 库文件
│ └── include # 头文件
├── my_poject # 项目【代码源文件】
| └── module_1
│ ├── 1.cpp
│ ├── 1.h
│ ├── CMakeLists.txt
| └── module_2
│ ├── 2.cpp
│ ├── 2.h
│ ├── CMakeLists.txt
├── tools # 项目构建支持工具,如编译器
├── scripts # 脚本文件,如预处理脚本
├── config # 配置文件
│ └── xxx.yml
│ └── yyy.yml
├── test # 测试代码
├── LICENSE # 版权信息
├── CMakeLists.txt
├── build.sh # 构建项目的脚本
├── .gitignore
├── README.md # 项目说明文件
└── sample # 示例代码

编译复杂项目

现在我们举一个复杂点的,多层级项目如何用CMake进行编译。

整个目录结构如下(为方便,进行了精简):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|-- 3rdparty 
| |-- include
| `-- lib
|-- deploy
| |-- bin
| `-- lib
|-- build
|-- hello # 整个项目源码
| |-- module1
| | |-- people.cpp
| | |-- people.h
| | `-- CMakeLists.txt
| |-- module2
| | |-- bird.cpp
| | |-- bird.h
| | `-- CMakeLists.txt
| |-- hello.cpp
| |-- CMakeLists.txt
|-- LICENSE
|-- README.md
|-- src
| `-- video
| `-- 1577414323962.mp4

其中hello目录下各源文件如下:

  • moule1/people 相关源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /*people.cpp*/
    #include "people.h"

    void people_hello()
    {
    std::cout<<"people say : Hello Cpp!"<<std::endl;
    }

    /*people.h*/
    #include<iostream>

    void people_hello();
  • moule2/bird 相关源码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /*bird.cpp*/
    #include "bird.h"

    void bird_hello()
    {
    std::cout<<"bird say : Hello Cpp!"<<std::endl;
    }

    /*bird.h*/
    #include<iostream>

    void bird_hello();
  • hello/hello.cpp 源码

    1
    2
    3
    4
    5
    6
    7
    8
    #include "./module1/people.h"
    #include "./module2/bird.h"

    int main()
    {
    people_hello();
    bird_hello();
    }

最后给出各个目录下的CMakeLists.txt文件。

  • module1

    编译生成动态库libmodule1.so

    1
    2
    3
    4
    5
    6
    7
    8
    # 查找当前目录(module1)下的相关文件,并赋值给MODULE1_SRC
    aux_source_directory(. MODULE1_SRC)

    # 将指定的源文件(MODULE1_SRC)生成库文件libmodule1.so
    # 【注1】不需要写全libmodule1.so,只需写module即可,cmake会自动补全。
    # 【注2】SHARED参数指定生成动态库(.so文件),不加参数默认生成静态库(.a)文件

    add_library(module1 SHARED ${MODULE1_SRC})
  • module2

    编译生成动态库libmodule2.so ,基本同前。

    1
    2
    3
    4
    5
    6
    # 查找当前目录(module2)下的相关文件,并赋值给MODULE2_SRC
    aux_source_directory(. MODULE2_SRC)

    # 将指定的源文件(MODULE1_SRC)生成库文件libmodule1.so

    add_library(module2 SHARED ${MODULE2_SRC})
  • hello

    编译生成可执行文件hello.out ,然后链接libmodule1.solibmodule2.so

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    # 命令格式:<命令名>(参数 参数值) ,中间参数空格隔开
    # 设置项目名
    project(helloDemo)

    # 设置可执行文件保存路径
    set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/../deploy/bin)

    # 限定CMAKE最低版本
    CMAKE_MINIMUM_REQUIRED(VERSION 2.6)

    # 将module1和module2文件夹加入子目录,这样cmake就可以去其中查找编译
    # 【注1】没有这个会报错,ld:找不到 -lmoudle1 和 -lmoudle2
    # 【注2】这里只能用相对路径,不是hello项目下的路径,是指
    # build下的相对路径。
    # 因为最后make是在build路径下,libmoudule1.so和
    # libmoudule2.so是分别保存在build/moudle1 和build/module2下
    ADD_SUBDIRECTORY(./module1)
    ADD_SUBDIRECTORY(./module2)

    # 搜索当前目录所有源代码文件,并赋值给PROJECT_ROOT_SRCS
    aux_source_directory(. PROJECT_ROOT_SRCS)

    # 增加C++11特性
    add_definitions(-std=c++11)

    # 【编译】
    # add_executable 从一组源文件编译出一个可执行文件
    # 这里将${PROJECT_ROOT_SRCS}中文件编译,生成可执行文件hello.out
    add_executable(hello.out ${PROJECT_ROOT_SRCS})

    # 【链接】
    # 将目标文件与库文件进行链接,不显示指示文件后缀(如 module1.so),优先链接动态库
    TARGET_LINK_LIBRARIES(
    hello.out
    module2
    module1
    )

    特别的,如果你还引用了第三方库,还应该做如下修改。

    假设你引用的第三方库为ffmpeg ,相关头文件和库文件都放在3rdparty目录下。

    • 增加ffmpeg库文件和头文件搜索路径

      1
      2
      3
      4
      # 头文件搜索路径
      include_directories(${PROJECT_SOURCE_DIR}../3rdparty/inclue)
      # 库文件搜索路径
      link_directories(${PROJECT_SOURCE_DIR}../3rdparty/lib)
    • 链接ffmepeg相应库

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      # 链接ffmpeg库
      # 将目标文件与库文件进行链接
      TARGET_LINK_LIBRARIES(
      hello.out
      module2
      module1
      libavcodec.so # 显示指定动态库
      libavdevice.so
      libavfilter.so
      libavformat.so
      libavutil.so
      libpostproc.so
      libswresample.so
      libswscale.so
      )

准备好所有的文件后,我们开始进行cmake构建:

1
2
cd ./build
cmake ../hello && make

image-20211210122630662

bin 下执行:

1
../deploy/bin/hello.out 

image-20211210122942175

👨‍💻 CMake相关介绍到此完结。

写在最后

这篇博客主要介绍了编译的基本过程和原理,以及常用的编译工具(g++/Makefile/CMake)使用。

从构思大纲到最后初步完工大概用了五天,比最初预估的进度多花了一倍时间。最主要的原因就是中间我一直在删删改改,特别是写编译过程初探这一节:每写完一个版本,我就自己先看一遍再问自己:你真的能看明白吗?还是有些不理解的地方,就继续Google些资料看,直到把自己说服----至少文章逻辑上自恰了。同时也更深刻地体会到了:自己觉得懂了可能不是真的懂了,能把别人讲明白才可能算是懂了

C++环境相关介绍就先告一段落了,接下来准备整理一下C++基础相关知识(有事情做的感觉还不错😀 ),回复完论文评审意见后尽快开始更新。

更新记录

2022-01-24 :更新笔记

  1. 增加可执行文件执行过程分析

2021-12-20 :更新笔记

  1. 增加g++相关介绍

2021-12-10 :上传初稿

  1. 第一次更新,发布初稿

参考资料


  1. 1.g++,CMake和Makefile了解一下 : https://zhuanlan.zhihu.com/p/55027085
  2. 2.Linux下使用CMake编译C++:https://zhuanlan.zhihu.com/p/373256365
  3. 3.阮一峰--编译器的工作过程:http://www.ruanyifeng.com/blog/2014/11/compiler.html
  4. 4.符号解析:https://www.jianshu.com/p/2786533a34c9
  5. 5.计算机原理系列之七-链接过程分析:https://luomuxiaoxiao.com/?p=572
  6. 6.静态链接与动态链接在链接过程和文件结构上的区别:https://www.polarxiong.com/
  7. 7.gcc编译生成可执行文件的过程中发生了什么:https://blog.csdn.net/albertsh/article/details/89309107
  8. 8.C/C++中关于静态链接库(.a)、动态链接库(.so)的编译与使用:https://blog.csdn.net/qq_27825451/article/details/105700361
  9. 9.cpp_new_features:https://github.com/0voice/cpp_new_features/blob/main/