C/C++ 项目构建过程
- 预处理:预处理器将 C/C++源码(cpp, hpp, c, h, inl 等)中的头文件嵌入进去(查找包含文件),展开由 define 定义的宏,移除注释并解析条件编译指令,最终生成一个不包含预处理代码的
.i文件:g++ -E main.cpp -o main.i。 - 汇编:词法、语法、语义的分析,优化并编译为汇编代码:
g++ -S main.i -o main.s或g++ -S main.cpp -o main.s。 - 编译:将汇编代码翻译成机器指令,打包成目标文件。使用
-c进行汇编得到.o文件,不是一个文本文件,并不能直接查看其内容。但是可以通过反汇编指令objdump -d main.o将机器码翻译回汇编代码进行查看。 - 链接:将所需的静态库打包并嵌入到可执行文件中,Linux 下是 ELF,Windows 下是 EXE。注意对于静态库,链接器是单项扫描。假设 main 依赖静态库 la,静态库 la 又依赖静态库 lb,那么链接的顺序是
g++ main.o la lb,当然了,现代链接器可能对这一点进行了优化,也就是不需要强调链接的顺序也能正确链接。在 Linux 系统中,静态库通常以.a作为后缀,并以lib开头,例如libxxx.a。如果有动态库的话,链接器会标记引用,记录下动态库的位置。 - 加载:运行可执行文件时,操作系统的加载器会解析可执行文件中的引用标记,在标准搜索路径中查找系统中的动态库(Linux 下的
.so、Windows 下的.lib、MacOS 下的.dylib,在编译时被编译为位置无关代码Position Independent Code)并将其加载到内存中的任意可用位置,实现多进程共享。
动态库与静态库
以 Linux 系统为例
(1)静态库构建过程
假设现在有三个文件 main.cpp, add.cpp, minus.cpp,我需要将后面两个编译成静态库,下面是命令行实现源文件的构建与加载运行过程:
g++ -c add.cpp minus.cpp main.cpp ar rcs libmath.a add.o minus.o g++ main.o libmath.a -o exec /exec
上面的命令过程是:先编译所有的 cpp 文件,每个 cpp 文件生成对应的目标文件 .o,然后使用 ar 命令将 add 和 minus 打包成数学静态库 libmath.a,接着将 main 和 libmath 链接为可执行文件 exec,最后直接运行可执行文件。
(2)动态库的构建
和上面一样的例子
g++ -c -fPIC add.cpp minus.cpp g++ -shared -o libmath.so add.o minus.o g++ -c main.cpp g++ main.o -L. -lmath -o exec
生成目标文件,PIC 告诉编译器:生成的代码不要依赖于被加载到内存中的绝对地址。接下来由目标文件创建动态库。第 4 行命令告诉链接器记录引用,-L. 告诉编译器在当前目录中搜索动态库,-lmath 是告诉编译器链接标准命名为 math 的动态库,标准命名 lib<标准>.so,math 前面的 l 表示跨平台的快捷方式。可以使用命令 ldd exec(list dynamic dependencies)来查看 exec 所需的动态库。
选择建议
- 当静态库更新时,整个项目需要重新编译并链接,对于一个大型的应用程序来说,该过程会非常漫长。而动态库是独立的,还能够多进程共享,还能以插件的方式动态扩展功能(在 Qt 开发中尤为常见)。
- 在不同平台,动态库有不同的后缀,而且命名规范更加严格。
- 动态库的链接失效、链接地狱(引用层级过深)都是缺点。
如果项目不是特别庞大的话,Windows 系统下还是推荐使用静态库,能避免很多麻烦。
如何使用 CMake 构建项目
手动使用命令行构建项目过于繁琐。CMake 是一个 Build Generator,只需要我们在每个目录下编写 CMakeLists.txt 即可。作为一个自动化构建工具,它会根据平台的不同生成对应的构建文件,比如 Linux 系统下的 Makefile。本节的重点在构建项目的流程,而不是 CMake 的语法。
现在我有一个较为复杂的项目,项目结构如下:
<...>/ ├── Demo/ │ ├── CMakeLists.txt (【1】顶层 CMakeLists) │ ├── main/ │ │ ├── main.cpp │ │ └── CMakeLists.txt (【2】main 的 CMakeLists) │ ├── math/ │ │ ├── add.cpp │ │ ├── add.h │ │ └── CMakeLists.txt (【3】math 的 CMakeLists) │ ├── logger/ │ │ ├── logger.cpp │ │ ├── logger.h │ │ └── CMakeLists.txt (【4】logger 的 CMakeLists) │ └── dependencies/ │ └── (比如:some_header.h) │ └── build/ (生成 VS 解决方案的地方)
math 编译为动态库,logger 编译为静态库,提供给主程序使用。Demo 文件夹下还有一个依赖文件夹 dependencies,不作为项目,仅作为主程序包含头文件。
Windows 平台上的 Visual Studio C/C++ 项目
首先确保电脑上安装了 CMake,并且配置了系统环境变量:使用 cmake --version 命令来检验。以 Visual Studio 2019 为例,使用 CMake 构建 vs 的解决方案。
接下来编写每层的 CMakeLists.txt:
1️⃣顶层 Demo/CMakeLists.txt
# CMake 最低版本要求,VS2019 建议 3.15 或更高
cmake_minimum_required(VERSION 3.15)
# 顶层项目名称
project(Demo CXX)
# CXX 表示 C++ 项目
# 设置输出目录
# 这可以确保 .exe 和 .dll 文件在同一个目录下,方便运行
# ${CMAKE_BINARY_DIR} 指向 'build' 目录
# 1. 可执行文件 (.exe)
# 输出到:build/bin/Debug/main_app.exe
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
# 2. 动态库 (.dll) 和 动态库的导入库 (.lib)
# 输出到:build/bin/Debug/math.dll 和 math.lib
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
# 3. 静态库 (.lib)
# 输出到:build/lib/Debug/logger.lib
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
# 包含子项目
# 最佳实践是先添加库,再添加依赖它们的可执行文件
add_subdirectory(logger)
add_subdirectory(math)
add_subdirectory(main)
2️⃣Demo/logger/CMakeLists.txt (静态库)
# 1. 定义静态库 (STATIC) 目标 "logger"
add_library(logger STATIC logger.cpp logger.h )
# 2. "发布" 头文件目录
# 任何链接到 "logger" 的项目,都将自动获得这个 include 路径
target_include_directories(logger PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
# CMAKE_CURRENT_SOURCE_DIR 指向 Demo/logger
)
3️⃣Demo/math/CMakeLists.txt (动态库)
# 1. 定义动态库 (SHARED) 目标 "math"
add_library(math SHARED add.cpp add.h )
# 2. "发布" 头文件目录
# 任何链接到 "math" 的项目,都将自动获得这个 include 路径
target_include_directories(math PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
# CMAKE_CURRENT_SOURCE_DIR 指向 Demo/math
)
# 3. (Windows 平台) 自动导出 DLL 符号
# 这是一个简便方法,可以避免在 .h/.cpp 中手动添加 __declspec(dllexport)
# CMake 会尝试导出所有全局符号
set_target_properties(math PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS TRUE )
4️⃣Demo/main/CMakeLists.txt (主程序)
# 1. 定义可执行文件目标
# 避免使用 "main" 与目录名混淆
add_executable(main_app main.cpp)
# 2. 添加 "dependencies" 文件夹到 include 路径
# ${CMAKE_SOURCE_DIR} 指向顶层 "Demo" 目录
target_include_directories(main_app PRIVATE ${CMAKE_SOURCE_DIR}/dependencies )
# 3. 链接 "logger" 和 "math" 库
# CMake 会自动处理:
# - 找到 logger.lib (静态库)
# - 找到 math.lib (动态库的导入库)
# - 将 Demo/logger 和 Demo/math 目录添加到 main_app 的 include 路径
# (因为它们在各自的 CMakeLists 中设置了 PUBLIC)
target_link_libraries(main_app PRIVATE logger math )
进入 build 目录,打开命令行执行命令
cmake -G "Visual Studio 16 2019"../Demo
其中,-G "Visual Studio 16 2019" 指定使用 VS2019 64 位(默认)生成器。../Demo 表示 build 上一级目录的 Demo 文件夹下的项目。如果顺利的话,可以看到命令行的构建结果。
若失败,可尝试打开命令行终端 "x64 Native Tools Command Prompt for VS 2019",或者配置系统环境变量,将 \\Microsoft_Visual_Studio\\2019\\Professional\\VC\\Tools\\MSVC\\14.29.30133\\bin\\Hostx64\\x64 配置进去,以确保 CMake 能找到 VS。
构建完成后进入 build 文件夹,打开 Demo.sln 解决方案即可用 VS 来 Debug 项目。或者在 build 的命令行下使用命令 cmake --build . 默认构建 Debug 版本,如果需要 release 版本则在命令后面添加 --config Release:
这个和打开 VS2019 并右键项目选择'生成解决方案'是一样的效果。
打开各项目的属性设置,可以发现 CMake 帮我们自动配置好了,不必再手动添加。
根据在顶层 CMakeLists.txt 中设置的路径,可以得到最终生成的产物位置:
- 可执行文件和 DLL:
build/bin/Debug/main_app.exebuild/bin/Debug/math.dll(因为它们在同一个目录,main_app.exe运行时可以立即找到math.dll)
- 静态库:
build/lib/Debug/logger.lib(在链接main_app.exe时,链接器会自动从这里找到它)
Linux 平台下的 C/C++ 项目构建
针对上述项目,在 Linux 平台下差不多。只需要修改动态库的 CMakeLists 设置 Demo/math/CMakeLists.txt:
add_library(math SHARED add.cpp add.h )
target_include_directories(math PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} )
# (跨平台) 仅在 Windows 上设置导出属性
if(WIN32)
set_target_properties(math PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS TRUE )
endif()
如果是跨平台的项目,则从 10 行开始可以像上面那样写,如果仅仅在 Linux 上构建,则可以删除。同时,在 Linux 上,库文件(.so 动态库和 .a 静态库)的标准约定是都放在 lib 目录下,而不是像 Windows 那样把 .dll 和 .exe 放在一起。因此顶层的 CMakeLists.txt 可以修改如下(可修改可不修改):
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
# Linux RPATH 设置
# RPATH (Runtime Path) 告诉可执行文件在哪里寻找 .so 动态库。
# $ORIGIN 是一个特殊变量,指向可执行文件所在的目录。
# # 这条设置会"烧录"一个相对路径到 main_app 中,告诉它:
# "去你 (main_app) 所在的目录的上一级(..),然后去 lib 目录找 .so"
# (即:build/bin/../lib -> build/lib)
# # 这样设置后,你可以直接在 build/bin 目录下运行 ./main_app,
# 而无需设置 LD_LIBRARY_PATH 环境变量。
if(UNIX AND NOT APPLE) # $ORIGIN 在链接时会被解析
set(CMAKE_INSTALL_RPATH "$ORIGIN/../lib")
set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE)
endif()
在 Linux 上默认使用 Makefiles(或 Ninja)。在 build 目录下构建 Release 或 Debug(用于 GDB 调试)版本需要明确指定:
cmake -DCMAKE_BUILD_TYPE=Release ../Demo
cmake -DCMAKE_BUILD_TYPE=Debug ../Demo
当然不指明也行:
cmake ../Demo
CMake 会在 build 目录中生成一个 Makefile,接下来要生成可执行文件,可以通过以下两种操作:
- 方案 1:使用命令:
make -j8
其中 -j8 表示 8 核心编译,加快编译速度,可要可不要。
- 方案 2:当然也可以直接使用 cmake:
cmake --build . -- -j8 # 末尾的 -- -j8 是把参数传给底层的 make,这一坨也是可选的
这样可以生成一个可执行文件,使用命令 ./bin/main_app 即可运行。如果 CMakeLists.txt 里面没有指明链接生成的文件,即 set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin),那么可执行文件默认生成在 build 下。

