菜鸟笔记
提升您的技术认知

C++编译知识笔记(一)——基本知识

文章目录

    • 一、编译的基本步骤
      • 1.1 预处理阶段
      • 1.2 编译阶段
      • 1.3 汇编阶段
      • 1.4 链接阶段
    • 二、核心常用基本概念
      • 2.1 .o目标文件
      • 2.2 符号
      • 2.3 静态链接库
      • 2.4 动态链接库
    • 三、链接和加载
      • 3.1 .o文件和静态库的链接
      • 3.2 动态库的链接
    • 四、编译的关键参数—各种路径
      • 4.1 头文件搜索路径
      • 4.2 编译时库文件搜索路径
      • 4.3 运行时库文件搜索路径
        • 4.3.1 LD_PRELOAD环境变量
        • 4.3.2 RPATH
        • 4.3.3 LD_LIBRARY_PATH环境变量

作为一个C++工程师,编译可以说是日常工作内容中最常见的一个部分了,但实际情况是,这是一个很容易开发人员忽略的部分,绝大部分开发者对于这个环节并不了解,在日常迭代的过程中似乎也影响不大,但在引入一些新的依赖,或者要编译一个开源项目的时候,如果对编译不了解的话,碰到一些编译时和运行时的奇奇怪怪的问题往往会让人崩溃,因此还是有必要系统地学习一下,所以开一个新坑来聊一聊编译,也当作自己的学习笔记,本篇是个开头,主要是一些基本但有非常重要的知识。

一、编译的基本步骤

我们平常所说的编译一般指从源码到可执行文件或者库的过程,在实际的工程应用中,我们往往会使用诸如cmake、bazel等高级构建工具来进行编译相关操作,底层实际上是调用gcc等编译器的相关功能,这个过程其实包含了预处理、编译、汇编、链接四个阶段,以下以《一个程序员的自我修养》里用到的一个linux平台的简单源码用gcc进行编译为实例介绍这几个阶段。

//hello.cpp
#include <stdio.h>
int main() {
  
    printf("Hello World\n");
    return 0;
}

1.1 预处理阶段

(1)将#include 的头文件展开:#include本质上是代码的拷贝动作,所有inlclude的头文件的内容在展开阶段均会被直接复制过来。
(2)将#define语句指定的值转换为变量。
(3)将宏定义转换为具体代码。
(4)根据#if #elif 和#endif指定的位置包含或排除特定部分的代码。

对应的典型gcc命令如下:

$ gcc -E hello.cpp -o hello.i

1.2 编译阶段

编译阶段则是基于预处理阶段得到的.i文件进行一系列的词法分析、语法分析、语义分析和相关优化后生成汇编文件。

对应的典型gcc命令如下:

$ gcc -S hello.i -o hello.s

1.3 汇编阶段

该阶段则是将汇编文件转换为机器可以执行的指令,也就是机器码,示例程序在汇编阶段会生成目标文件hello.o,这个目标文件从结构上来讲,已经是编译后的可执行文件格式,只是在经过链接之前,缺少一些外部调用的依赖等,还不能直接运行。

对应的gcc命令如下:

$ gcc -c hello.s -o hello.o

或者使用linux内建的as命令:

$ as hello.s -o hello.o

1.4 链接阶段

链接由链接器完成,在linux下面通常是ld,在实际能运行的程序中,即便简单如上述示例程序,要想在操作系统上运行起来,实际上还是需要有其他依赖,也就是需要依赖自身代码之外的函数、变量等,简单来说,链接就是将这些依赖连接到用户程序中需要用到的地方。比如示例hello.cpp文件中调用了printf函数,目标文件hello.o中引用的printf符号还未解析,同时也缺少系统运行库libc和相关的启动文件,只有这些都链接在一起,才能产出最终的可执行程序,本质上来说链接就是将多个不同的目标文件连接到一起,而链接的直接依据就是符号,下面会详细介绍下符号的概念。

以上就是编译的基本阶段,这里再多说两句,我们说到要执行一个编译操作,相应的产出无非是三种,可执行文件,静态链接库或动态链接库。
1.可执行文件:可以直接执行,如果链接了动态库需要结合动态库一起运行。
2.静态链接库.a:静态库本质上是各种.o文件的打包,我们平常编译生成静态库实际上就是将源码编译成.o之后打包得到一个.a,供其他模块编译使用。
3.动态链接库.so:各目标文件链接处理得到的库,供其他模块编译和运行使用。

二、核心常用基本概念

2.1 .o目标文件

.o目标文件是汇编阶段过后的产物,在gcc编译中,一个cpp文件对应一个编译单元,即对应一个.o目标文件,在linux下,.o是ELF格式的,这里不展开说明目标文件的具体格式,但我们需要知道,目标文件里面除了包含了编译后的机器指令代码、数据,还包括及符号表等链接需要的信息,它从格式上已经非常接近可执行文件,只需要再进行链接处理即可成为最终的可执行文件或者库。

2.2 符号

看过编译相关资料就会发现,符号这个名词会经常出现,它也是链接过程中的核心元素,可以说链接就是根据符号来的。在链接中,目标文件之间的连接实际上是目标文件直接对地址的引用,也就是对函数和变量的地址的引用,以上面的示例程序为例,我们生成的hello.o就引用了其他.o文件中定义的printf,除了函数,变量也是类似,每个函数或变量都有自己独特的名字,才能避免链接过程中不同变量和函数的混淆,编译器有一套生成规则,具体的可以用nm命令去查看符号。在链接中,我们将函数和变量统称为符号(Symbol),函数名或者变量名就是符号名(Symbol Name)。
每个目标文件都有相应的符号表,记录了目标文件所用到的所有的符号,每个定义的符号由一个对应的值,叫做符号值,对于变量和函数来说,符号值就是他们的地址。

2.3 静态链接库

前面讲到了,一个源码文件会生成一个目标文件,一个程序往往会有很多的依赖,比如系统调用相关的,以C语言运行库glibc为例,其中包含了输入输出、文件操作、时间管理、内存管理等内容。glibc由上千个c语言源文件组成,编译后会有相同数量的目标文件。如果把这些零散的文件直接提供给使用者,会造成文件传输、组织和管理的不便。把这些目标文件压缩在一起,对其进行编号和索引,以便查找和检索,方便使用,这便形成了libc.a,因此静态库是.o目标文件的集合,供编译时按需静态链接使用,编译完毕后不再需要相关文件。

2.4 动态链接库

.o目标文件经过了链接处理便成为了动态库,linux下一般为so文件,从操作系统的角度而言,动态库包含了指令和数据,从文件结构上更接近于应用程序,给动态库增加入口函数,动态库也可以像应用程序一样正常运行。生成动态库需要指定-shared和-fPIC 参数,前者是指定生成动态库,后者是生成地址无关代码因此可以动态加载,以后单独聊一聊,这里有个概念就行。动态链接库在程序运行的时候才会被真正加载和使用,编译期主要是起到了一个检查的作用,因此编译和运行的时候都需要相关so文件。

三、链接和加载

前面介绍了链接的基本概念,而链接的对象包括源码文件生成的目标文件,静态库和动态库,针对不同类型的链接对象,所发生的链接行为是不一样的,

3.1 .o文件和静态库的链接

链接.o文件和静态库本质上是一样的,因为静态库只是对o文件的简单打包
一个源码文件中引用其他文件(cpp生成的.o,.a或.so)中定义的函数、变量,在链接阶段需要定位这些外部的函数、变量。对静态库或.o文件,链接器会将.o目标文件中用到的外部符号其所在的.o文件一并保存到最终编译产出中。静态库是按需链接,链接的粒度是.o文件,并不是指定的.a文件,只有存在被引用符号的.o 文件才会被写入最终的编译产出。

3.2 动态库的链接

对so动态库而言,在编译阶段链接器并不会执行真正的链接动作,只是检查代码中所需的符号能否在动态库中找到,找不到就报错,找到便将so文件的元信息记录到编译产出中,直到所有的符号都被找到并被记录到了编译产出中,编译阶段的链接相关工作就算完成了,真正意义上的链接在程序运行时由动态加载器来加载相关so完成。

四、编译的关键参数—各种路径

4.1 头文件搜索路径

作为一个c/c++语言的coder,#include "xxx.h"恐怕是最熟悉的语句之一了,但背后编译器寻找头文件的细节大部分人其实并不清楚,只知道凭着感觉写能work就行,而且我们会看到,有的include会带路径,有的就是一个文件名,有的时候不同依赖的同名头文件还会发生冲突,因此理解编译器是如何搜索头文件是十分重要的,也就是要弄清楚编译器的头文件搜索路径,编译器会搜索所有的路径去找include的头文件。
头文件可以分为两种,一种是标准库的头文件,比如stdio.h,编译器的默认搜索路径会包含这些文件存放的路径,直接inlclude就行,可以通过cc1plus –v查看包含哪些路径,这里不展开说明。还有一种就是自己编写或者依赖的第三方的头文件,这个时候就需要添加额外的头文件搜索路径了,对于gcc,通过-I参数来指定,比如

gcc -c –Imodule1/a/include –Imodel2 -o src/hello.o src/hello.cpp

这个语句中,我们制定了两个额外的相对搜索路径,module1/a/include和model2/b/include,编译器在查找头文件时,会按搜索路径加上include语句里的内容去查找,比如,如果有以下include代码:

#include "foo.h"
#include "b/include/bob.h"

分别对应的搜索路径如下:

#include "foo.h"
module1/a/include/foo.h
model2/foo.h

#include " b/include/bob.h"
module1/a/include/b/include/bob.h 
model2/b/include/bob.h

gcc会按搜索路径的先后顺序遍历查找,找到第一个存在的文件路径便停止查找,因此如果有同名的头文件,搜索路径比较杂并且include语句没有包含路径信息的话有可能会出现找错的情况,一个比较好的方法是搜索路径不要太深入,在include语句里保留部分目录信息,这样不但不容易找错开发者自己看着也更直观。

4.2 编译时库文件搜索路径

对于依赖了库文件的编译,自然也需要根据路径去找库文件,我们知道有静态库和动态库两种,无论哪种库都需要在编译阶段用到。
和头文件搜索路径类似,gcc默认的库文件搜索路径下存放了C++标准库,操作系统为了方便大家使用,也会将相关的库文件放到默认的搜索路径下,许多开源软件也会将库文件的安装目录默认设置为/lib或/usr/lib,这样不管在程序编译阶段还是在程序运行阶段,gcc都可以找到需要的库文件,无需再额外指定搜索路径。
对于不在默认的库文件搜索路径下的库文件,则需要手动指定,gcc通过-L指定库文件搜索路径,根据-l指定库文件
比如:

gcc -o hello hello.cpp -L /usr/local/libtest/lib -ltest

4.3 运行时库文件搜索路径

对于静态库,编译阶段用完后就了事了,库中所需的所有符号对应的.o文件都被写入到应用程序中,程序在运行时不需要进行额外的链接动作。但动态库在运行时也是需要的,我们经常也在运行时碰到一些动态库找不到或者版本不一致之类的问题,因此很有必要了解动态库在运行时的搜索方式,

简单来说,linux动态库搜索路径的先后顺序可以认为依次为:
(1)LD_RELOAD
(2)RPATH
(3)LD_LIBRARY_PATH

4.3.1 LD_PRELOAD环境变量

LD_PRELOAD是Linux系统的一个环境变量,它可以影响程序的运行时的链接(Runtime linker),通过它可以定义在程序运行前优先加载的动态链接库,也就是它指定的有最高的搜索优先级。该方案一般仅用于极为特殊的情况,例如测试与诊断紧急补丁。
设置LD_PRELOAD环境变量如下:

export LD_PRELOAD=/home/work/test/libs/libtest.so:$LD_PRELOAD

4.3.2 RPATH

RPATH是一个链接选项,指定RPATH是比较推荐的一种指定动态库搜索路径的一种方式,具体到实现上又有以下两种方式:
1.在编译命令中使用-R参数在编译阶段指定,该命令会将RPATH直接写入应用程序,编译阶段指定不会对其他程序产生影响,可以减少服务运维的难度和成本,应该优先考虑此方式,如下:

$ gcc -Wl,-R/home/work/test/libs -ltest

2.指定LD_RUN_PATH环境变量,该环境变量既能影响编译时搜索路径的写入,也能动态影响程序启动时动态库的搜索。
由于设置LD_RUN_PATH会影响整个shell session的环境,避免对其他程序启动产生影响的典型用法:
(1)采用独立的启动脚本,在里面修改LD_RUN_PATH环境变量。
(2)启动前修改,启动后还原,如下:

#!/bin/bash
OLD_LD_RUN_PATH=$LD_RUN_PATH
export LD_RUN_PATH=/home/work/test/libs:$LD_RUN_PATH
./hello
export LD_RUN_PATH=$OLD_LD_RUN_PATH

对于写入程序中的RPATH可以用readelf命令来查看

$ readelf -d curl | grep RPATH

4.3.3 LD_LIBRARY_PATH环境变量

LD_LIBRARY_PATH 估计大家都不陌生,平常在一些找不到so的库的处理方法的文章中也经常能看到,在没有设置LD_RUN_PATH的时候,LD_LIBRARY_PATH路径的优先级是最高的,设置方法和LD_RUN_PATH类似,但是只会影响运行时的动态库搜索,这里不再赘述。