C++构建的基础知识

简介

C++项目代码会有一些规范的目录结构,比如 https://github.com/hattonl/cpp-project-structure

这篇博客的目的是总结一下C++项目构建的基础知识,以下面这个最简单的目录结构作为示例。这是一个非常简单的结构,hello.cpp里定义了hello函数,输出Hello, World!,我们把它编译成静态库。main.cpp调用了hello函数。项目的目的是编译出一个main.cpp对应的可执行文件,放到deploy/bin目录里。虽然这个项目非常简单,但是里面还是涉及到了一些的背景知识,即使想从黑盒的角度在某个层级自洽地理解他们,也需要花费一些精力,这个博客算是一些琐碎资料的总结。

1
2
3
4
5
6
7
8
9
10
11
12
13
project/
├── src/ # 源代码文件
│ ├── hello.cpp # 实现文件
│ ├── main.cpp # 主程序
│ └── CMakeLists.txt # src 目录下的 CMake 配置文件
├── include/ # 头文件目录
│ └── hello.h # 头文件
├── deploy/ # 用于存放最终部署和交付的文件
│ ├── bin/ # 可执行文件目录
│ ├── lib/ # 静态库或动态库文件目录
│ └── include/ # 用于存放公共头文件
├── build/ # 存放 CMake 构建过程产生的中间文件
└── CMakeLists.txt # 根目录的 CMake 配置文件
1
2
3
4
5
6
7
8
// src/main.cpp

#include "hello.h"

int main() {
hello();
return 0;
}
1
2
3
4
5
6
7
8
// include/hello.h

#ifndef HELLO_H
#define HELLO_H

void hello();

#endif // HELLO_H
1
2
3
4
5
6
7
8
// src/hello.cpp

#include <iostream>
#include "hello.h"

void hello() {
std::cout << "Hello, World!" << std::endl;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# CMakeLists.txt

cmake_minimum_required(VERSION 3.10)
project(HelloWorld)

# 设置 C++ 标准
set(CMAKE_CXX_STANDARD 11)

# 设置构建输出的目录
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/deploy/lib) # 静态库
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/deploy/lib) # 动态库
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/deploy/bin) # 可执行文件

# 添加子目录
add_subdirectory(src)

# 安装头文件到 deploy/include
install(DIRECTORY include/ DESTINATION deploy/include)
1
2
3
4
5
6
7
8
9
10
11
12
13
# src/CMakeLists.txt

# 包含头文件路径
include_directories(${PROJECT_SOURCE_DIR}/include)

# 编译静态库
add_library(hello STATIC hello.cpp)

# 创建可执行文件
add_executable(HelloWorld main.cpp)

# 将静态库链接到可执行文件
target_link_libraries(HelloWorld hello)

手动构建

1
2
3
4
5
6
7
8
9
10
11
# 编译hello.cpp
g++ -I./include -c src/hello.cpp -o build/hello.o

# 创建静态库
ar rcs deploy/lib/libhello.a build/hello.o

# 编译main.cpp并链接
g++ -I./include src/main.cpp deploy/lib/libhello.a -o deploy/bin/HelloWorld

# 运行程序
./deploy/bin/HelloWorld

用cmake构建

1
2
3
4
5
6
7
8
9
10
11
# 进入build目录
cd build

# 生成Ninja构建系统
cmake -G Ninja ..

# 使用Ninja构建项目
ninja

# 运行程序
./deploy/bin/HelloWorld

中间和目标文件

头文件

为什么需要头文件?

在编译阶段,源文件不会与其他源文件产生关系,编译器一次只处理一个源文件。在这种情况下,一个源文件就没法直接调用另一个源文件的函数或类了。于是头文件就出现了,头文件声明了函数的参数、返回值等,有了这些信息,编译器就可以正常编译程序了。就像拼图版一样,虽然中间缺了一块(调用其他源文件的函数),缺的具体的拼图内容不知道是什么,但是知道了这块拼图的形状(头文件中的函数定义),其他部分可以正常编译。到最终的链接环节,再把缺的这块的具体内容补上(链接目标文件或库文件补充具体函数实现)。

头文件保护

从源文件到可执行文件的构建过程,可以分解成4个步骤:预处理、编译、汇编、链接。预处理过程主要处理源代码文件中以#开始的预编译指令,比如#include预编译指令是将被包含的文件直接插入到该预编译指令的位置,这个过程是递归进行的,即被包含的文件可能还包含其他文件。这就可能会带来头文件的重复包含和定义,因此在C++中经常会用#ifndef, #define, #endif三个宏定义的组合来防止这种重复。当预处理程序识别到#ifndef时,会检查后方的头文件标识是否已经被定义过。如果是第一次被定义,则通过#define定义这个头文件并包含这个文件内的内容;如果这个头文件标识已经被定义过了,则忽略这个文件以避免重复包含。

头文件包含

C++中有""<>两种包含头文件的方式,它们之间的主要区别是编译器搜索头文件的路径顺序。#include "header.h"一般用于包含项目中自定义的头文件。编译器首先在包含当前源文件的目录中搜索该头文件,如果在当前目录中未找到,则继续在标准库的包含路径中搜索。#include <header.h>用于包含标准库或第三方库的头文件,编译器直接在标准库的包含路径中搜索该头文件,如果未找到,继续在其他指定的包含路径中查找。

目标文件

目标文件是编译器将源代码编译后生成的中间文件,以.o为扩展名。目标文件是单一的,是源代码的直接编译结果,可以直接参与链接,生成最终的可执行文件。目标文件包含当前源文件中所有已实现的函数和变量的机器代码,如果代码引用了外部函数,目标文件会留下一个未解析的符号,即函数的声明,但没有定义(实现)。在链接阶段,连接器负责将目标文件(.obj)和库文件(.lib)合并成可执行文件(.exe),把缺失的拼图块填到拼图版中。编译阶段,编译器会在.h文件中查找函数或类的声明;链接阶段,链接器会查找在目标文件或者库文件中查找函数或类的定义,此时就与头文件无关了。

链接器的主要任务包括:

  1. 符号解析:找到所有引用的符号(函数、变量)的定义。
  2. 重定位:将各个目标文件的代码和数据整合到内存地址中。
  3. 合并库:将静态库中的代码复制到可执行文件中,动态库(DLL)中只记录引用,运行时加载。

目标文件中的未解析符号(如函数声明)必须在链接阶段中所指定的目标文件或库文件中找到它的定义,否则链接器就会报错”无法解析的外部符号“,这也是一个常见错误,主要的原因就是在链接时没有指定目标文件或库文件。

库文件

保留多个目标文件以生成可执行文件,不便于他人重用。于是我们可以把多个目标文件整合成一个库文件,这样链接成可执行程序的时候只需要链接一个库文件就可以了。库文件分成两种,静态库和动态库。静态库在链接时直接被嵌入到可执行文件中,最后输出的可执行文件运行时不依赖该静态库,生成的可执行文件较大。而动态库则是在可执行文件运行时动态加载,像个插件一样在运行时现插到可执行文件中,此时生成的可执行文件就相应较小,可执行文件的运行依赖于动态库。

静态库的创建非常简单,在将源文件编译成目标文件之后,直接通过ar归档工具生成静态库:

1
ar rcs libhello.a hello.o

ar 是用于创建静态库的工具,它会将多个目标文件(.o 文件)打包成一个归档文件(如 .a)。ar 命令有多个选项,rcs 是常用的一组选项,具体含义如下:

r(replace):替换库中已经存在的目标文件。如果静态库中已经存在相同名称的目标文件,r 会将旧的目标文件替换成新的。

c(create):如果静态库不存在,c 会创建一个新的静态库。

s(index):生成索引表,优化静态库的查找过程。索引表会包含所有目标文件的符号信息,使得链接器可以更快速地查找到需要的符号。

如果要把多个目标文件放入同一个静态库,在ar命令后面列出多个.o文件即可:

1
ar rcs libmylibrary.a hello.o world.o utils.o

生成静态库本身并不涉及链接过程,当将静态库链接到主程序的时刻,链接器会提取静态库中的目标文件,才会将他们链接到最终的可执行文件中。换言之,链接一个静态库和链接一系列 .o 文件是一样的,只是静态库提供了一种更便捷的方式来管理和链接这些目标文件。

动态库稍微复杂一点,动态库生成涉及到了链接的过程。在静态库的情况中,所有的目标文件(.o)都会在链接阶段被合并进最终的可执行文件中。静态库中的代码和数据在最终的可执行文件中有一个确定的内存地址,这个地址在程序运行时不会改变,直到程序退出。具体来说,静态库的符号在链接阶段被解析并分配了内存地址,一旦程序开始运行,这些代码和数据就会在内存中被加载到固定位置。而动态库的符号地址是在运行时由操作系统的动态链接器(或加载器)确定的。在程序运行时,操作系统的动态链接器会将动态库加载到内存中。由于动态库是位置无关的,它们可以被加载到内存中的任何位置,操作系统会根据当前系统的内存布局和可用空间来决定具体的加载地址。在程序运行时,操作系统会将动态库中的符号地址(如函数、变量的地址)进行重定位,使得这些符号能够指向正确的内存位置。这样,无论动态库被加载到内存的哪个位置,程序都能正确地访问库中的函数和数据。

创建动态库的过程分成两步,第一步是编译源文件,并生成位置无关代码:

1
g++ -c src/hello.cpp -Iinclude -fPIC -o hello.o

-fPIC:生成位置无关的代码(Position Independent Code),动态库需要这种代码以支持加载到不同内存地址。

第二步就是生成动态库了,-shared 告诉链接器生成动态库:

1
2
3
g++ -shared hello.o -o libhello.dll # Windows
g++ -shared hello.o -o libhello.dylib # macOS
g++ -shared hello.o -o libhello.so # Linux

链接到动态库和链接到静态库是一样的:

1
g++ main.o -L. -lhello -o main

最后生成的可执行文件在运行时,操作系统会自动查找动态库的路径并加载。不同的平台动态库的查找路径有所区别:

操作系统 优先查找路径 相关环境变量/配置
Linux 1. 当前目录 2. 环境变量 LD_LIBRARY_PATH 3. 默认系统路径 /lib/usr/lib 等 4. rpathrunpath LD_LIBRARY_PATH
macOS 1. 当前目录 2. 环境变量 DYLD_LIBRARY_PATH 3. 默认系统路径 /usr/lib 4. rpath DYLD_LIBRARY_PATH
Windows 1. 当前目录 2. 系统目录(System32SysWOW64) 3. PATH 环境变量 4. 注册表路径 PATH

第三方包

从上面的讨论也可以知道什么是C++的第三方包了。解释型语言(如 Python)的包通常就是一堆 .py 文件,这些文件可以直接被解释器动态加载并运行。只要在程序中导入import这些包的文件,解释器会根据需要自动解析这些文件并运行对应的功能。而C++ 这种编译型语言,包实际上大致可以分为两部分:

  1. 头文件(.h.hpp 文件):提供接口声明,使得编译器在编译阶段可以知道包的函数和类的定义,我们自己的源文件可以调用包里的函数或类完成编译。
  2. 库文件(.lib, .a, .dll, .so 等):包含函数或类的实际实现代码,链接器在链接阶段会将这些库文件整合到程序中。

使用时,需要先安装头文件和库文件(就是把他们下载下来放到某个文件夹里),确保编译器和链接器能找到它们。程序编译时,头文件用于编译阶段,库文件用于链接阶段。当然,这是对于我们想用第三方包来编译链接开发我们自己的程序来说的。我们也可能只是使用者,这个时候我们就只需要下载这个包编译出来的可执行文件以及其依赖的动态库就好了。

关于MPI

谈到这里,正好可以把MPI程序的开发和使用顺便厘清一下。MPI(Message Passing Interface,消息传递接口)是一个开放的标准,旨在提供一种跨平台、跨硬件的并行编程模型,因此可以根据不同的硬件平台、操作系统、网络通信协议、性能需求以及使用场景来定制不同的实现。在Windows平台上,常见的MPI实现有几种。Microsoft MPI(MSMPI)是微软官方提供的MPI实现;MPICH是开源的、跨平台的MPI实现;Intel MPI是Intel提供的MPI实现。那么到底什么叫作MPI的实现?

  1. 当我们只想运行一个用MPI开发的并行程序

    这时候我们需要两部分东西:MPI可执行文件MPI动态库

    MPI可执行文件:

    MPI可执行文件是MPI的启动前端,比如

    • mpiexec:启动和管理MPI程序的主要工具,负责在多个节点上分发任务。
    • mpirun:类似于mpiexec,是一些MPI实现(比如Open MPI)的别名。

    这些可执行文件本质上只是前端接口,具体的并行通信逻辑由动态库实现。

    MPI动态库:

    MPI的动态库实现了MPI标准规定的所有功能,包括点对点通信、集体通信、同步、非阻塞通信等。这些动态库负责处理程序的并行计算和数据传输。常见的MPI动态库文件包括:

    • msmpi.dll:Microsoft MPI的核心动态库。
    • libmpi.dlllibmpi.so:Open MPI和MPICH的动态库。
    • impi.dlllibimpi.so:Intel MPI的动态库。

    这些动态库通常包含了通过TCP/IP、共享内存、RDMA(远程直接内存访问)等方式实现进程间通信的底层实现。

    在运行时,mpiexec 或其他MPI启动器(如mpirun)会:

    1. 启动多个MPI进程,并在每个进程中加载相应的MPI动态库(如msmpi.dlllibmpi.so)。
    2. 配置进程间的通信通道,确保这些MPI进程能够在分布式或本地环境中相互通信。
    3. 将任务分发到不同的计算节点(如果是分布式计算)。
    4. 使用动态库中的功能实现消息传递、同步和计算。

    例如,当运行 mpiexec -n 4 my_program 时,mpiexec 会启动 4 个实例的 my_program,并在每个实例中加载MPI的动态库,这些动态库负责实现进程间的通信。只要有动态库和可执行文件,我们就可以顺利运行MPI程序了。

  2. 当我们想开发一个MPI程序

    与运行MPI程序时所需的可执行文件和动态库不同,开发MPI程序时,我们需要额外的一些工具,主要包括MPI头文件MPI库MPI编译工具

    • MPI头文件: 这些头文件包含了MPI程序开发所需要的函数声明、常量定义、数据类型和宏。MPI的核心头文件是mpi.h,开发者通过它来引用MPI提供的各种通信和同步接口。例如:

      1
      #include <mpi.h>

      这样就可以在程序中调用MPI的相关功能。

    • MPI库: 开发时所需的库文件主要是动态库,它们提供了MPI的底层通信功能。常见的MPI库有:

      • msmpi.dll:MS-MPI的动态库,在运行时加载。
      • libmpi.solibmpi.dll:Open MPI和MPICH的动态库。
      • libimpi.soimpi.dll:Intel MPI的动态库。

      这些库实现了MPI的核心通信功能,如点对点通信、集体通信等。编译时也可能依赖一些静态库(比如msmpi.lib)。它不包含实现代码,而是指示链接器在运行时加载相应的动态库。

    • MPI工具(如mpicxx: 为了简化MPI程序的编译和链接过程,MPI实现通常提供一系列工具。比如mpicxx是一个用于编译C++语言程序的MPI编译器。使用mpicxx时,开发者只需要提供源代码文件,mpicxx会自动处理头文件的引用和库文件的链接。例如,使用以下命令:

      1
      mpicxx -o my_program my_program.c

      这个命令会将MPI头文件自动加入编译路径,并将MPI动态库(如msmpi.dll)链接到生成的可执行文件中。开发者无需手动指定动态库或头文件的位置,简化了开发和编译过程。

    一旦开发和编译完成,开发者就可以使用MPI的启动工具(如mpiexecmpirun)来执行程序,MPI的动态库会在运行时自动加载,为程序提供必要的通信和同步支持。

  3. 以MSMPI为例

    比如当我们在下载MSMPI时,通常会获得两个主要的安装包:

    1. msmpisdk.msi:这是MS-MPI的开发工具包(SDK)。它包含了开发MPI程序所需的头文件、静态库、导入库和文档。开发者可以使用这些工具和文件将MPI集成到自己的程序中,并进行编译。这个包中的静态库(如msmpi.lib)通常用于链接MPI的动态库(如msmpi.dll)以实现MPI的功能。
    2. msmpisetup.exe:这是MS-MPI的运行时安装包。它包含了在程序执行时需要的动态库(如msmpi.dll)和启动工具(如mpiexec)。安装这个包后,你的系统将配置好MPI的运行时环境,确保能够顺利运行基于MS-MPI开发的并行程序。加入我们不自己开发MPI程序,只是想运行MPI程序,安装这个运行时包即可。
  4. 关于mpi4py

    mpi4py 是一个 Python 包,它提供了对 MPI 标准的 Python 接口,使得 Python 程序能够使用 MPI 进行并行计算。与传统的 C 或 C++ MPI 编程不同,使用 mpi4py 开发 MPI 程序时,不需要直接操作 C 的头文件(如 mpi.h),因为 mpi4py 是一个 动态链接 的 Python 库,它在运行时通过底层的 MPI 实现(如 MPICH、OpenMPI 或 MS-MPI)完成所有的并行通信任务。

    Python 程序通过 mpi4py 提供的接口调用 MPI 的函数,如 MPI_SendMPI_Recv 等。这些接口和 MPI 的标准接口类似,但是它们是为 Python 语言封装的。在 mpi4py 底层,Python 程序的调用被转发到底层的 MPI 实现(如 msmpi.dlllibmpi.so 等)中。mpi4py 会通过 Python 的 C API 和 Cython 将 Python 代码与底层的 MPI 动态库连接起来。这使得 Python 可以通过直接调用底层 MPI 的动态库来执行 MPI 的通信任务件。一旦请求被转发到底层 MPI 实现,MPI 库会完成实际的并行计算和通信任务。MPI 库通过不同的通信机制(如 TCP/IP、共享内存、RDMA 等)来在不同的进程之间传输数据。 mpi4py 本身不需要头文件,但它有一些运行时的前提条件:

    1. 必须安装支持的 MPI 动态库: 为了使 mpi4py 正常工作,必须安装并配置支持的 MPI 动态库,如 MPICH、OpenMPI 或 MS-MPI。mpi4py 需要通过这些动态库来实现实际的并行计算和通信。

    2. 使用 MPI 启动工具启动程序: 和传统的 MPI 程序一样,mpi4py 程序也需要通过 mpiexecmpirun 等工具来启动。mpiexec 会启动多个进程,并确保它们能够通过底层的 MPI 实现进行通信。

      例如:

      1
      mpiexec -n 4 python my_program.py

    在启动时,mpiexec 会启动 4 个 Python 进程,并让它们在底层的 MPI 动态库支持下进行并行计算和数据传输。换言之,使用mpi4py的需求和运行MPI程序的需求是一样的。

构建工具链

基本图像

构建主要包括编译和链接等系列过程,也就因此需要有编译器、链接器等程序来执行这些操作,不同的构建程序所依赖的库文件也不相同,也就因此有了不同的构建工具链。

MSVC+Windows SDK:在Windows上,代表性的工具链就是Microsoft Visual C++ (MSVC) + Windows SDK。MSVC包含了编译器cl.exe、链接器link.exe等等工具。Windows SDK包含了MSVC工具在编译链接等过程中需要使用的头文件(比如windows.h)、库文件(比如kernel32.libuser32.lib)以及一些其他工具,实现一些核心的系统功能。MSVC 完全依赖并使用 Windows SDK 提供的头文件和库文件。

在Windows上,可以通过Visual Studio Installer来安装MSVC+SDK。Visual Studio (VS) 是微软开发的集成开发环境(IDE),用于开发、调试和部署应用程序。当然我们也可以不用VS这个IDE,只使用 Visual Studio Installer 安装MSVC+SDK来配置必要的开发环境。

GCC(GNU Compiler Collection):GCC是Linux平台上提供的开源编译器工具链,提供了全面的语言支持和跨平台兼容性。比如其中常用的C语言编译器是gcc,C++(g++),Fortran(gfortran)等等,链接器是ld。GCC编译器套件的Windows实现是MinGW/MinGW-w64,包含了一系列的头文件(Win32API)、库和构建程序等。Windows上的GCC不依赖于微软的Windows SDK,而是依赖MinGW提供的替代实现。

Clang/LLVM:Clang/LLV是一个高度模块化和跨平台的编译器框架,支持多种架构。尤其,Clang是 macOS 平台的默认 C++ 编译器,集成在 Xcode 开发环境中,支持 Apple 生态的开发需求。其中Clang 编译器:负责 C/C++ 和 Objective-C 的编译。LLVM 后端:负责代码生成和优化。libc++ 标准库:苹果使用了 LLVM 提供的 libc++ 作为标准 C++ 库(取代了 GCC 的 libstdc++)Linker(链接器):使用的是基于 LLVM 的 LLD 或苹果自研的链接器 ld;调试器(LLDB):苹果基于 LLVM 的 LLDB 是默认调试工具,集成在 Xcode 中。

Intel oneAPI:Intel开发的用于科学计算、高性能计算和数据密集型应用的编译器套件,针对Intel硬件进行性能优化。如C/C++编译器(icx),用到的是Intel C++标准库、Math Kernel Library (MKL)、Integrated Performance Primitives (IPP)。

环境变量配置

不同的工具链在环境变量上的配置需求上也有很大的差异。比如在Windows平台上,MinGW的设计比较自包含,所有必要的资源(编译器、头文件、库文件等)都放在它的安装目录中,相对位置是固定的。因此只需要配置PATH变量,添加MinGW的bin目录,使得编译器可以被找到即可,编译器会在安装目录的预定义路径中查找头文件和库文件。

而MSVC不会将头文件和库文件的路径硬编码到编译器中,需要通过环境变量动态配置。在MSVC工具链中,必须配置PATHINCLUDELIB三个环境变量,因为MSVC的工具链依赖Windows SDK和其他独立库的路径配置。比如可能需要配置如下这些环境变量:

PATH:

  • \VS\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin:这是Visual Studio自带的CMake工具的路径,用于生成构建系统。
  • \VS\Common7\IDE\CommonExtensions\Microsoft\CMake\Ninja:这是Ninja构建工具的路径,用于高效地构建CMake项目。
  • \VS\VC\Tools\MSVC\14.39.33519\bin\Hostx64\x64:这是MSVC(Microsoft Visual C++)编译器的路径,cl.exelink.exe等工具位于此处。
  • \Windows Kits\10\bin\10.0.22621.0\x64:这是Windows SDK中的工具路径,包含rc.exemt.exe等工具。

LIB:

  • \Windows Kits\10\Lib\10.0.22621.0\um\x64:Windows SDK中用户模式库文件的路径,包括kernel32.libuser32.lib等基本系统库。
  • \VS\VC\Tools\MSVC\14.39.33519\lib\x64:MSVC编译器自带的库路径,包含C++标准库等其他依赖。
  • \Windows Kits\10\Lib\10.0.22621.0\ucrt\x64:Windows通用C运行时库(UCRT)的路径,这是现代Windows应用程序通常需要的库。

INCLUDE:

  • \VS\VC\Tools\MSVC\14.39.33519\include:C++标准库头文件。
  • \Windows Kits\10\Include\10.0.22621.0\ucrt;E:\Windows Kits\10\Include\10.0.22621.0\um;E:\Windows Kits\10\Include\10.0.22621.0\shared:Windows SDK头文件。

默认情况下,运行 Visual Studio 提供的环境脚本( vcvarsall.bat)可以自动设置这些变量。

在其他平台上,则按照对应平台的标准路径查询,也无需进行额外配置。

工具链 必需配置 默认行为 是否需要手动设置 INCLUDE 和 LIB
MinGW PATH 头文件和库文件路径固定在安装目录下,编译器自动查找
MSVC PATH, INCLUDE, LIB 环境变量动态配置,通过 vcvarsall.bat 自动完成,也可手动配置
GCC PATH 遵循 POSIX 路径约定,标准路径 /usr/include/usr/lib 下自动查找
Clang PATH 类似于 GCC,支持标准路径查找,必要时可通过命令行参数指定

编译选项

上面简单的编译链接过程涉及到了一些参数,不同的编译链接工具的语法有所区别,而且会有一些独特的选项。

对于GCC编译器,常用的基本编译选项包括:

选项 含义
-c 只编译源文件为目标文件(.o),不进行链接。
-o <file> 指定输出文件名(可用于目标文件或最终可执行文件)。
-I<directory> 添加头文件的搜索路径(包含路径)。
-L<directory> 添加库文件的搜索路径(链接路径)。
-l<library> 链接某个库(如 -lm 表示链接数学库 libm.a)。

这里有一些注意事项。对于-I-l-L,直接加后面的文件名,中间没有空格。另外,如果路径中包含空格,则需要用双引号包裹路径,避免产生解析错误。

1
2
gcc -I"C:\Program Files\MyLib\include" -L"C:\Program Files\MyLib\lib" -lMyLib
gcc -I/usr/local/include -L/usr/local/lib -lm

同时在链接库的时候,不需要显式地写出lib前缀和.a后缀,GCC默认会在指定的-L路径中查找以lib开头,以.a(静态库)或.so(动态库)结尾的文件。

1
gcc -L/usr/local/lib -lmy_library

等价于链接文件 /usr/local/lib/libmy_library.a/usr/local/lib/libmy_library.so。当然,也可以完整地指定文件名。

如果要配置多个头文件路径、多个库文件路径或者多个链接库,那么就多次使用-I等选项即可,GCC会按照顺序搜索。

1
gcc -I/path/to/headers1 -I/path/to/headers2 -I/path/to/headers3 main.c

对于MSVC,编译选项并不是用-,而是用/

MSVC 选项 说明
/c 仅编译源文件,不进行链接
/Fe:<file> 指定输出文件名
/I<directory> 添加头文件搜索路径
/LIBPATH:<directory> 添加库文件搜索路径

MSVC 的路径规则与 GCC 类似,路径中包含空格时需要双引号包裹,没有空格时可省略双引号。但对于库连接,MSVC 链接器不会自动添加 lib 前缀或 .lib 后缀,需要完整指定库文件名。此外,MSVC 直接指定要链接的库文件名,不需要使用类似 -l 风格的选项。

头文件和库文件的搜索顺序

不同的编译器搜索头文件和库文件的顺序是不一样的。

项目 GCC 查找顺序 MSVC 查找顺序
头文件 1. -I 选项指定的路径 2. 当前目录 3. 系统标准目录 4. CPLUS_INCLUDE_PATH 1. /I 选项指定的路径 2. 当前目录 3. 标准系统目录 4. INCLUDE 环境变量
静态库 1. -L 选项指定的路径 2. LIBRARY_PATH 环境变量(Linux) 3. 当前目录 4. 系统标准库目录 1. /LIBPATH 选项指定的路径 2. 当前目录 3. 系统库目录 4. LIB 环境变量
动态库 1. 当前目录 2. -L 选项指定的路径 3. LIBRARY_PATH 环境变量(Linux) 4. 系统标准库目录 1. 当前目录 2. /LIBPATH 选项指定的路径 3. System32 目录 4. PATH 环境变量

不过最简单最统一的方法,就是所有的都手动指定 -I-L 选项,明确地告诉编译器和链接器在哪里查找头文件和库文件。

运行时

虽然看起来,我们只用到了两个源文件,没有用到其他更多的文件了,但实际上背后发生的事情要更加复杂一些。一方面,我们用到了#include <iostream>标准库,我们最终链接出可执行文件需要把标准库的实现也链接到一起。另一方面,就算不显式include任何标准库,也有一些额外代码会被编译器插入到最后的可执行文件里,比如main函数在真正执行时需要大量运行时库的辅助。

v2-188fa171a5d9086a08a0414cb94acc05_720w

我们可以用g++ -v main.o hello.o -o main来显示这一链接过程查看完整的链接细节,会得到很多神奇的链接目标文件和链接库文件。这些东西,似乎统称为运行时库(Runtime Library)。运行时库提供了程序运行时所需的各种底层功能,包括内存管理、输入输出操作、异常处理、线程管理等。简而言之,运行时库提供了编译器生成的机器代码与操作系统之间的接口,使得程序能够正确运行。就像通过编译链接,我们各个拼图块已经拼成了一个完整的拼图,但还需要一个拼图底座或拼图框,运行时库就类似于这个拼图框一样。运行时库通常分为两类:

  • 静态运行时库(Static Runtime Library):在编译期间被链接到可执行文件中。它的代码被嵌入到最终的二进制文件里,程序启动时无需再加载任何外部文件。常见的静态运行时库有 libc.alibstdc++.a(对于 MinGW)等。
  • 动态运行时库(Dynamic Runtime Library):是编译后单独存在的共享库(如 .dll.so.dylib),程序在运行时动态加载这些库。这种方式可以减小程序的文件体积,多个程序可以共享同一份库文件。典型的动态运行时库包括 Windows 上的 msvcrt.dll(Microsoft C Runtime)和 Linux 上的 libc.so

不同的构建工具和工具链,使用的运行时库也有所不同。常见的工具链比如 MSVCMinGW,它们在构建过程中对运行时库的依赖有所不同。MSVC 的运行时库与操作系统有着更紧密的绑定。比如MCVS的静态运行时库包括libcmt.liblibvcruntime.lib等,链接时直接把 C++ 运行时库嵌入到目标文件中。动态运行时库是Windows上的共享库如msvcrt.dllvcruntime140.dll等,多个程序可以共享同一个 dll 文件,从而减少内存占用。动态库在程序启动时加载,程序运行时会依赖于这些 dll 文件。而MinGW 不依赖于 MSVC 的 msvcrt.dll,而是使用自己的运行时库。MinGW 的静态运行时库包括libmingw32.alibgcc.a,它们负责处理类似 mallocfree 之类的内存管理功能,并支持异常处理、线程管理等。动态运行时库例如 libgcc_s.dll,用于支持 GCC 编译的程序在运行时动态加载这些库。MinGW 的程序并不直接使用 msvcrt.dll,它使用自己的运行时库和标准库(如 libstdc++.alibmingw32.a

大多数情况下,编译器和链接器会自动选择适合的运行时库(静态或动态),并将其链接到最终生成的可执行文件中。比如,使用 g++ 编译时,默认会自动链接合适的运行时库(如 libstdc++libgcc)。对于 MSVC 编译器,默认会链接 msvcrt.dll 等运行时库。我们不需要手动去干预。主要需要注意的就是当使用MSVC工具链在项目中使用第三方库时,需要注意它们是否使用了与我们自己的程序相同类型的运行时库(静态或动态)。如果我们使用了 静态链接 的运行时库,并且第三方库也使用了静态链接,那么它们在编译时会把运行时库的代码嵌入到目标文件中,并在链接时一起生成一个最终的可执行文件;如果程序使用了 动态链接,那么程序和第三方库都必须使用相同的动态运行时库。另外如果程序和第三方库使用了不同版本的运行时库,也可能会发生问题,比如内存分配冲突、异常处理不兼容、以及符号冲突连接错误等。因此在整个项目中,要确保所有代码(包括自己编写的代码和第三方库)使用相同类型的运行时库(静态或动态)。可以通过编译选项来强制使用某种类型的运行时库。同时避免不同工具链混合使用,使用不同编译器或不同运行时库时,可能会出现不兼容的情况。例如,MSVC 编译的程序可能会依赖于 msvcrt.dll,而 MinGW 编译的程序使用 libgcc_s.dll,这两者不能混用。

在MSVC中,有以下个运行时库编译选项,MSVC会默认选择/MDd选项:

  1. /MD (Multi-threaded DLL):链接到多线程的动态运行时库。程序依赖于共享的运行时库(MSVCRT.dll 或 MSVCPRT.dll 等)。编译出的二进制文件更小,因为库中的代码在多个程序之间共享。适用于大多数 Windows 应用程序,特别是当希望减少最终应用程序的大小时。动态链接允许多个程序共享同一个库实例。
  2. /MT (Multi-threaded):链接到多线程的静态运行时库。程序独立于运行时库,所有库代码在编译时嵌入到二进制文件中。生成的二进制文件较大,但不需要依赖外部的 DLL。适用于希望生成独立可执行文件的应用程序,避免了外部 DLL 版本冲突的问题。
  3. /MDd (Multi-threaded DLL, Debug):链接到多线程的动态调试运行时库。类似于 /MD,但为调试版本提供额外的调试信息(如内存泄漏检测)。依赖调试版的 MSVCRTD.dll 和 MSVCPRTD.dll。用于调试模式下的开发,以帮助发现和修复内存相关的问题。
  4. /MTd (Multi-threaded, Debug)链接到多线程的静态调试运行时库。类似于 /MT,但为调试版本提供额外的调试信息。库代码嵌入到二进制文件中,适用于不依赖外部 DLL 的调试。用于调试模式下的独立可执行文件。

GCC(GNU Compiler Collection)在这方面相对简单一些,通常没有 MSVC 那样复杂的运行时库选择和配置。默认情况下,GCC 会使用 动态链接运行时库(类似于 MSVC 的 /MD 选项),即依赖操作系统提供的 C 标准库(libc,在 Linux 上通常是 glibc)。如果你没有特别指定,GCC 会自动链接到动态库。

自动化构建

自动化构建工具

上面我们都是用命令自己执行的每一步构建过程,但是对于稍微复杂或者大型的项目,复杂度就太高了而且容易出现错误。比如手动设置多个头文件或者库文件路径,执行一系列的链接操作,以及还要处理多个模块彼此之间的依赖关系。自动化构建工具的任务是将这个流程程序化、自动化,并管理复杂的依赖关系。常用的自动化构建工具有Make、MSBuild、Ninja。

Make:Make是最经典的构建工具之一,主要用于Unix/Linux环境。构建规则和依赖关系定义在Makefile中。基本语法是

1
2
target: dependencies
commands

target是目标要生成的文件,dependencies是目标文件的依赖文件,commands是生成目标文件的命令(编译命令等)。比如对于最上面这个简单的C++项目,可以编写如下的Makefile:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 定义变量
CC = g++
CFLAGS = -c -Iinclude
LDFLAGS =
TARGET = main
SRC = main.cpp src/hello.cpp
OBJ = $(SRC:.cpp=.o)

# 目标:最终可执行文件
$(TARGET): main.o hello.o
$(CC) main.o hello.o -o $(TARGET)

# 目标:编译 main.cpp
main.o: main.cpp
$(CC) $(CFLAGS) main.cpp -o main.o

# 目标:编译 src/hello.cpp
hello.o: src/hello.cpp
$(CC) $(CFLAGS) src/hello.cpp -o hello.o

# 清理生成文件
clean:
rm -f $(TARGET) $(OBJ)

执行make开始构建过程,make clean清理生成文件。但前面也看到了,MSVC和GCC的编译语法是不一样的,Make的规则语法是为GCC或Clang等工具链设计的。如果采用MSVC,则需要针对性地调整Makefile的内容。因此不建议在使用MSVC的时候用Make进行构建,在Windows上可以用MinGW+Make的构建方式。

MSBuild:MSBuild是Visual Studio提供的构建工具,主要用于构建Visual Studio的项目。MSBuild使用XML格式的项目文件,比如.csproj.vcxproj。MSBuild适用Windows平台的MSVC工具链,不过一般我们也不会手动创建项目文件, 而是通过Visual Studio自动创建。同时,相比于Make只会生成库文件、目标文件、可执行文件这些我们指定的内容,MSBuild有自己专属的一套生成文件和项目组织方式。比如当我们用Visual Studio构建一个C++项目时,通常会生成以下文件:解决方案文件(MySolution.sln)、项目文件(MyProject.vcxproj)、中间文件(.obj目标文件、.pch预编译头文件)、输出文件(.exe.dll等最终生成的可执行文件或动态库)。其中解决方案是一个容器,它包含多个项目(Project),每个项目可以对应一个可执行文件、动态库、静态库等。它的功能是组织项目之间的关系,管理多个项目的编译、链接和调试等流程。项目文件描述了单个模块的构建过程,包括源文件、头文件、库依赖等。它的作用和Makefile类似,可以定义编译器和链接器选项,配置构建输出类型等等。

Ninja:Ninja是一个高效、现代的构建工具,专为速度优化。Ninja使用各种编译器以及各个平台。Ninja配置文件(build.ninja)的基本结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 定义规则
rule compile
command = g++ -c -Iinclude $in -o $out

rule link
command = g++ $in -o $out

# 编译目标
build main.o: compile main.cpp
build hello.o: compile src/hello.cpp

# 链接目标
build main: link main.o hello.o

# 清理规则
rule clean
command = rm -f main main.o hello.o

build clean: clean

执行ninja构建,执行ninja -t clean清理。

CMake:构建之构建

Make、Ninja 和 MSBuild 这些工具本身并不直接执行构建,而是通过读取一类构建配置文件来调用编译器链接器等完成整个构建过程,CMake就是用来生成这些构建工具所需输入文件的工具。CMake 通过编写 CMakeLists.txt 配置文件,定义项目的构建规则。它能够根据不同平台和工具链,自动生成适合的构建文件(如 Makefile、Ninja 构建文件,或 MSBuild 的项目文件),从而简化了跨平台和多工具链的构建工作。CMake博大精深,在这个小项目里只用到了一些基本的语法:

  1. cmake_minimum_required(VERSION 3.10)

这行指定了 CMake 的最低版本要求。CMake 的语法和功能在不同版本间可能有所变化,因此通常会指定一个最低版本来确保兼容性。

1
cmake_minimum_required(VERSION 3.10)
  • 作用:确保 CMake 的版本不低于 3.10。如果用户的 CMake 版本低于这个要求,CMake 会给出错误提示。
  • 用法:这一行应该放在 CMakeLists.txt 的最开始。
  1. project(HelloWorld)

project() 指令用于定义一个 CMake 项目的名称,可以指定语言(如 C++、C 等)。

1
project(HelloWorld)
  • 作用:定义了这个项目的名称为 HelloWorld。通常用于标识项目,cmake会自动创建一些变量,CMake 使用这个名称来设置构建目录等。
  • 可以指定语言:例如 project(HelloWorld CXX) 来指定使用 C++。
  1. set(CMAKE_CXX_STANDARD 11)

set() 用于设置 CMake 变量的值。这个例子中,我们设置了 C++ 标准为 C++11。

1
set(CMAKE_CXX_STANDARD 11)
  • 作用:指定项目使用 C++11 标准进行编译。这影响了所有 C++ 代码的编译选项。
  • 常见的 C++ 标准:可以设置为 98, 11, 14, 17, 20 等。
  1. set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/deploy/lib)

set() 还可以用来指定输出目录。这里设置了静态库的输出目录。

1
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${PROJECT_SOURCE_DIR}/deploy/lib)
  • 作用:设置静态库文件的输出目录为 ${PROJECT_SOURCE_DIR}/deploy/lib
  • ${PROJECT_SOURCE_DIR}:这是 CMake 的一个内置变量,表示当前项目的根目录。
  1. add_subdirectory(src)

add_subdirectory() 用来添加子目录,在这个例子中,我们让 CMake 进入 src 目录并执行 src/CMakeLists.txt

1
add_subdirectory(src)
  • 作用:将 src 目录中的 CMakeLists.txt 文件包含到构建过程中,CMake 会在该目录下继续寻找并构建目标。
  • 通常用于:组织较为复杂的项目结构,将项目分为多个模块或者子项目。
  1. install(DIRECTORY include/ DESTINATION deploy/include)

install() 是 CMake 的一个安装指令,用于指定文件或目录安装到特定的目标位置。这个例子表示将 include/ 目录下的文件安装到 deploy/include 目录。

1
install(DIRECTORY include/ DESTINATION deploy/include)
  • 作用:将头文件安装到指定的路径。在构建过程中,install 主要用于将文件复制到安装目录,在后期部署时会很有用,比如ninja install
  • 常见选项:
    • DIRECTORY:表示安装整个目录。
    • DESTINATION:指定目标安装路径。
  1. include_directories(${PROJECT_SOURCE_DIR}/include)

include_directories() 指令用于添加头文件的搜索路径。它告诉编译器在哪里查找头文件。

1
include_directories(${PROJECT_SOURCE_DIR}/include)
  • 作用:指定包含头文件的目录路径。在编译源代码时,编译器会去这个目录中查找 .h 文件。
  • 作用场景:当你的头文件不在默认的目录中时,需要通过这个指令告知 CMake 头文件的位置。
  1. add_library(hello STATIC hello.cpp)

add_library() 指令用来定义一个库文件(可以是静态库或动态库)。在这里,我们创建了一个名为 hello 的静态库。

1
add_library(hello STATIC hello.cpp)
  • 作用:定义一个静态库 hello,其源文件为 hello.cpp
  • 常见参数:
    • STATIC:指定生成静态库。
    • SHARED:生成动态库(.so 或 .dll)。
    • OBJECT:生成对象文件。
  1. add_executable(HelloWorld main.cpp)

add_executable() 用于创建可执行文件。在这个例子中,我们创建了一个名为 HelloWorld 的可执行文件,源文件为 main.cpp

1
add_executable(HelloWorld main.cpp)
  • 作用:生成可执行文件 HelloWorld,使用的源文件是 main.cpp
  1. target_link_libraries(HelloWorld hello)

target_link_libraries() 用于将库链接到目标(可执行文件或其他库)。在这里,我们将静态库 hello 链接到可执行文件 HelloWorld

1
target_link_libraries(HelloWorld hello)
  • 作用:链接 hello 静态库到 HelloWorld 可执行文件,使得 HelloWorld 可以使用 hello 库中的功能。
  • 常见用法:
    • 链接第三方库:target_link_libraries(MyExecutable MyLibrary)

CMake 在配置项目时,会生成一个 CMakeCache.txt 文件,其中缓存了项目的配置选项、生成器信息等。如果想更改生成器或重新配置项目,需要删除这个缓存文件。CMake 支持多种生成器,如 Unix MakefilesNinjaVisual Studio 等。可以在运行 CMake 时通过 -G 选项指定生成器。

附录

macOS上的Homebrew包管理

在Win里,没有一个自带的包管理工具。对于macOS,也没有一个系统自带的包管理工具,但是开源的Homebrew已经成为了macOS的标准了。Intel芯片的Mac和M芯片的Mac下Homebrew的默认目录是不同的,但是Apple逐渐就全是M芯片了。Homebrew所谓的包管理,实际上和Anaconda也差不多,只不过它并不是解释器依赖的envs目录,而是在一个全局目录做包管理。Homebrew默认将包安装到/opt/homebrew目录下,这个文件包含了Homebrew的所有文件和软件包。Homebrew使用所谓的Formula(配方)来描述如何安装软件包。每个Formula是一个Ruby脚本,包含了从下载源代码、编译到安装软件包的完整过程。Formula存储在Github上的Homebrew仓库中,当运行brew install时,Homebrew会从这些仓库中获取相应的Formula。什么是包?就是一堆文件。

/opt/homebrew/Cellar(地下室、地窖)是Homebrew存储包的主要目录。每个软件包都会有一个子目录,并且按照版本号进一步分类。比如,安装的GoogleTest可能位于/opt/homebrew/Cellar/googletest/1.11.0。每个版本的目录包含了该软件包的所有文件,包括可执行文件、库文件、头文件等。/opt/homebrew/opt提供用于指向Cellar中某个软件包版本的符号链接。这使得我们总能够通过opt路径访问到当前的“激活”版本,而无需关心具体的版本号。

我们在安装Homebrew时,会把Homebrew的可执行文件路径添加到我们的$PATH路径中。Homebrew通过brew link命令,将其所安装的各大包的可执行文件都连接到这个路径,这样我们就可以通过命令行直接使用这些大包的可执行文件,而不需要手动把他们每个的bin路径都粘到$PATH中。此外,当安装某些库时,Homebrew可能会设置LDFLAGSCPPFLAGS环境变量,前一个用于指定连接时选项,后一个用于指定编译时选项。另外,当通过Homebrew安装某些软件包时,Homebrew还可能会从源码编译这些软件,这也是根据Formula的指示编译安装的。对于常用的软件包,Homebrew也会提供预编译的二进制包,称为bottles。这些包下载后直接解压即可使用,无须机器再编译了。Homebrew源码编译具体来说,工具链的选择以及编译出的库文件的位置取决于Homebrew的配置和包的具体Formula。Homebrew默认使用macOS提供的编译器工具链,即Xcode中的clangclang++ld,以及make和其他相关工具。Homebrew也允许自定义工具链,可以通过环境变量如CCCXXLD等来指定不同的编译器或链接器。某些Formula中可能还指定了特定的工具链或者编译器选项,以确保兼容性或性能。例如某些包可能强制使用clanggcc,以确保与其他库或工具的一致性。

编译出来的库文件通常会放在Homebrew的地窖目录下,对应于具体包的子目录中。同时,为了方便使用,Homebrew会在/opt/homebrew/opt目录下创建相应的符号链接,指向Cellar目录中的实际文件。通常,编译出来的库文件会以.a(静态库)或.dylib(动态库)形式存放在于lib子目录下。而头文件会放置在include子目录下,供其他项目引用。Homebrew也会把大包的头文件、编译出来的可执行文件(如果有)、以及库文件,分别放到Homebrew下的includebin、以及lib目录中。我们也可以指定Homebrew的安装选项,比如在brew install安装的时候,--build-from-source会强制Homebrew从源码编译,而不是使用预编译的bottle二进制包;--cc=gcc-11会强制Homebrew使用指定的编译器。不同编译器生成的代码可能会存在不兼容,如果用clang编译一个库文件,然后用gcc编译自己的项目并链接到这个库,就可能会遇到API不兼容的问题导致报错。因此,依赖库的编译和项目的编译最好都采用相同的工具链。

另外,sbin目录,在各种情景下,一般都是系统管理员的二进制文件目录,通常包含那些普通用户不需要直接执行的系统管理工具或守护进程(daemon)。这些工具可能需要管理员权限才能运行,因此通常位于sbin而非bin目录中。Homebrew安装的软件包如果包含系统管理工具,可能会把可执行文件放到sbin目录下而不是bin目录。例如,网络服务管理工具或系统配置工具等,通常会安装在sbin目录中,以避免普通用户无意中执行这些命令。