目录
前言
最近,我收到一位本站会员的请求,他找到了一个喜欢的C++开源项目,可是项目却没有它所用系统版本编译好的程序,他需要自己编译,为此一筹莫展向我求助。因此,我决定撰写本文,深入探讨C++的开发问题。
尽管现在有众多高级编程语言可选(如Python、Java、PHP、Go、Rust、Ruby等),但在某些涉及底层操作的开发场景中,C++依然是不可或缺的选择。而CMake,则是编译C++项目的实际构建工具,本文将详细解析它的方方面面。
对于C++项目管理而言,依赖管理是至关重要的一环。如今,我们已经被各种便捷的包管理器所“宠坏”:Node.js/JavaScript生态系统有npm,Rust有cargo,Python则有pip。在C++领域,虽然也有Conan这类包管理器,但如果您正在处理一些“野生项目”(即许多需要从头开始编写的基础功能,而这些功能在现有包管理器中……)
CMake 到底是什么?为什么它很重要?
CMake
是一个 跨平台 构建系统生成器。跨平台 方面至关重要,因为 CMake
可帮助您在一定程度上直接解决出特定于平台的差异。
例如,在基于 Unix 的系统上,CMake 会生成 makefile
,然后用于构建项目。在 Windows 系统上,CMake 会生成 Visual Studio
项目文件,然后用于构建项目。
请注意,不同的平台通常有自己的编译和调试工具包:Unix 使用 gcc
,macOS 使用 clang
,等等。
C++ 生态系统中的另一个重要方面是能够处理可执行文件和库。
可执行文件的类型有很多,具体取决于:
- 目标 CPU 架构
- 目标操作系统
- 其他因素
此外,对于库,有不同的链接方式(链接是指在代码中使用来自另一个代码库的功能,而不必了解其实现):
- 静态链接
- 动态链接
例如,你Windows系统上很多程序根目录中的
.dll
文件就是库链接。
假如我开发一些内部原型,需要调用底层操作系统 API 来执行某些任务。唯一可行的有效方法是基于一些 C++ 库进行构建。自从Linux内核用C重写后,当前所有系统底层代码都是C/C++(前段时间微软要用rust重构系统代码暂且不讨论,因为其还没开始呢),“开头所讲的高级编程语言”只有配合“C++”才能实现操作系统底层的一些东西,没C++,单独高级编程语言很难实现操作系统底层的功能(这里别和汇编语言搞混了,汇编目前只有开发硬件驱动才会用,就连Linux内核都C重写了,不在本文讨论范围)。
CMake 的工作原理:任何 CMake 项目中的三个阶段
- 配置:CMake 读取所有
CMakeLists.txt
文件并创建一个中间结构,该结构决定后续步骤(例如列出源文件、收集要链接的库等)。 - 生成:根据配置阶段的中间输出,CMake 生成特定于平台的构建文件(例如 Unix 上的 makefile 等)。
- 构建:生成的构建工件与特定于平台的工具(例如
make
或ninja
)一起使用,以创建可执行文件或库文件。
一个简单的基于 CMake 的项目(Hello World!)
假设您有一个用于计算平方根的 C++ 源文件。
tutorial.cxx
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 |
// 一个简单的程序,用于计算一个数的平方根 #include <cmath> #include <cstdlib> // TODO 5: 移除这一行 #include <iostream> #include <string> // TODO 11: 包含 TutorialConfig.h int main(int argc, char* argv[]) { if (argc < 2) { // TODO 12: 创建一个使用 Tutorial_VERSION_MAJOR 和 // Tutorial_VERSION_MINOR 的打印语句 std::cout << "Usage: " << argv[0] << " number" << std::endl; return 1; } // 将输入转换为 double 类型 // TODO 4: 用 std::stod(argv[1]) 替换 atof(argv[1]) const double inputValue = atof(argv[1]); // 计算平方根 const double outputValue = sqrt(inputValue); std::cout << "The square root of " << inputValue << " is " << outputValue << std::endl; return 0; } |
CMakeLists.txt
1 2 |
project(Tutorial) add_executable(tutorial tutorial.cxx) |
以上两行是我们需要提供的最少数量的指令/规则,以便获得可执行文件。
我们还应该指定 CMake 最低版本号,但如果我们忽略它,则会假定某些默认值(我们暂时跳过它)
从技术上讲,我们也不需要project
指令,但我们也会保留它。
所以这里最重要的一行是:
1 |
add_executable(tutorial tutorial.cxx) |
我们指定目标二进制文件tutorial
和源tutorial.cxx
。
如何构建
我将指定一组命令,这些命令可用于构建项目和测试二进制文件。一会儿我再进行解释。
1 2 3 4 5 6 |
mkdir build cd build/ cmake .. ls -l # inspect generated build files cmake --build . ./tutorial 10 # test the binary |
您可以看到,整体构建步骤涉及如上所列的 5-6 个步骤。
首先,在 CMake 中,我们应该将与构建相关的内容与源代码分开。因此,我们首先创建一个构建目录:
1 |
mkdir build |
这样,我们就可以在 build
文件夹中完成所有与构建相关的活动:
1 |
cd build |
从此时开始,我们将执行多个与构建相关的任务:
我们生成配置文件。
1 |
cmake .. |
在这一步中,CMake 会生成平台特定的配置文件。在 ubuntu 中,我看到生成了 makefiles
文件。这些文件相当冗长,但我现在不需要担心。
接下来,我根据新生成的文件触发编译:
1 |
cmake --build . |
该步骤使用构建文件来生成所需的二进制文件 tutorial
.
我可以用以下方法验证二进制文件是否按预期运行:
1 |
./tutorial 16 |
我得到了预期的答案4
,看来构建工作符合预期!
要让 CMake 运行,您必须了解两个关键概念:可见性说明符和目标
有三个可见性说明符:PRIVATE
PUBLIC
INTERFACE
可见性说明符可用于以下命令:target_include_directories
target_link_libraries
这些是在Targets的上下文中指定的。CMake 中的targets是某种输出的抽象表示:
- 可执行目标(通过)生成二进制文件
add_executable
- 库目标(通过)生成库文件
add_library
- 自定义目标(通过)通过脚本等生成任意文件
add_custom_target
以上所有都生成具体的文件或工件作为输出。库目标的一个特殊情况是接口目标。因此,接口目标的指定方式如下:
1 2 |
add_library(my_interface_lib INTERFACE) target_include_directories(my_interface_lib INTERFACE include/) |
这里,不会立即生成任何文件。但在后期,一些具体的目标可以依赖。这意味着,它指定的包含目标也会自动被依赖。因此,本质上,这个库是一种方便的机制,用于构建源的依赖关系树等。my_interface_lib
my_interface_lib
INTERFACE
因此,在理解了目标和依赖的概念后,我们可以回到可见性说明符的概念。
PRIVATE可见性如下所示:
1 |
target_include_directories(tutorial PRIVATE "${CMAKE_BINARY_DIR}") |
PRIVATE
表示目标将使用指定的包含目录。但是,在稍后阶段,我们链接其他内容,包含目录将不会传播tutorial
tutorial
PUBLIC 可见性如下所示:
1 |
target_include_directories(tutorial PUBLIC "${CMAKE_BINARY_DIR}") |
使用该说明符,我们的意思是目标将需要给定的包含目录,除此之外,任何其他可能依赖的目标也将传播它。PUBLIC
tutorial
tutorial
INTERFACE的可见性如下所示:
1 |
target_include_directories(tutorial INTERFACE "${CMAKE_BINARY_DIR}") |
使用说明符,我们的意思是目标将不需要给定的包含目录,但任何可能依赖于的目标都会将包含文件传播给它们。INTERFACE
tutorial
tutorial
因此,可见性说明符的工作方式总结如下:
- PRIVATE – 源仅传播到目标
- PUBLIC – 源传播到目标和从属目标
- INTERFACE – 源不传播到目标,但传播到从属目标
将项目构建分为库和目录
随着项目的发展,我们通常需要模块来组织项目和管理复杂性。
我们可以有子目录,在其中我们可以指定独立的模块及其自己的自定义构建过程。CMake
可以有一个主 CMake 配置,它可以触发许多库(子目录)构建并最终将所有模块链接在一起。
这是一个略微简化/修改的示例。我们将创建一个名为的模块/库,它将被构建为静态库(在 unix 中)。最后 – 我们将链接到我们的主要程序。MathFunctions
MathFunctions.a
我将首先介绍源文件(相当简单)
MathFunctions.h
1 2 3 4 5 |
#pragma once namespace mathfunctions { double sqrt(double x); } |
MathFunctions.cxx
1 2 3 4 5 6 7 8 9 |
#include "MathFunctions.h" #include "mysqrt.h" namespace mathfunctions { double sqrt(double x) { return detail::mysqrt(x); } } |
mysqrt.h
1 2 3 4 5 6 7 |
#pragma once namespace mathfunctions { namespace detail { double mysqrt(double x); } } |
mysqrt.cxx
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 |
#include "mysqrt.h" #include <iostream> namespace mathfunctions { namespace detail { // 使用简单运算的平方根计算方法 double mysqrt(double x) { if (x <= 0) { return 0; } double result = x; // 进行十次迭代 for (int i = 0; i < 10; ++i) { if (result <= 0) { result = 0.1; } double delta = x - (result * result); result = result + 0.5 * delta / result; std::cout << "计算 " << x << " 的平方根为 " << result << std::endl; } return result; } } } |
为了总结这些代码片段,我们引入了以下内容:
- 一个名为 的命名空间。命名空间是一种将相关代码归类到通用名称下的方法。可以将其视为函数、变量和其他元素的容器。这将作为主要消费者(我们稍后会看到)的“公共 API”。
mathfunctions
- 在这个命名空间内,我们定义了 (square root) 函数的自定义实现。这使我们能够创建我们版本的函数,而不会与程序中其他地方可能存在的任何其他版本发生冲突。
sqrt
sqrt
sqrt
现在,我们如何将此文件夹构建为 unix 二进制文件?我们有一个自定义的 CMake 子配置:
MathFunctions/CMakeLists.txt
1 |
add_library(MathFunctions MathFunctions.cxx mysqrt.cxx) |
本质上 – 我们用一行代码构建库并指定相关文件。add_library
.cxx
但我们还没有完成,解决方案的核心在于我们如何将这个子目录或库链接到我们的主要项目:
tutorial.cxx
(使用库/模块版本)
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 |
#include "Config.h" #include "MathFunctions.h" #include <cmath> #include <cstdlib> #include <iostream> #include <string> int main(int argc, char* argv[]) { std::cout << "项目版本: " << PROJECT_VERSION_MAJOR << "." << PROJECT_VERSION_MINOR << std::endl; std::cout << "作者: " << AUTHOR_NAME << std::endl; if (argc < 2) { std::cout << "用法: " << argv[0] << " number" << std::endl; return 1; } const double inputValue = atof(argv[1]); // 使用库函数 const double outputValue = mathfunctions::sqrt(inputValue); std::cout << "The square root of " << inputValue << " is " << outputValue << std::endl; return 0; } |
我们在最顶部导入,然后使用新可用的命名空间来调用该方法。MathFunctions.h
mathfunctions
sqrt
我们知道它位于子目录中,但我们直接引用它,机器并不像人那样思考,这怎么可能呢?MathFunctions.h
不卖关子了——答案就在修改后的主 CMake 配置文件中:
CMakeLists.txt
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
cmake_minimum_required(VERSION 3.10) project(Tutorial) # 定义配置变量 set(PROJECT_VERSION_MAJOR 1) set(PROJECT_VERSION_MINOR 0) set(AUTHOR_NAME "Jiaofu") # 配置头文件 configure_file(Config.h.in Config.h) # 添加子目录 add_subdirectory(MathFunctions) # 添加可执行文件 add_executable(tutorial tutorial.cxx) # 包含生成的头文件和 MathFunctions 目录 target_include_directories(tutorial PUBLIC "${PROJECT_BINARY_DIR}" "${PROJECT_SOURCE_DIR}/MathFunctions") # 链接 MathFunctions 库 target_link_libraries(tutorial PUBLIC MathFunctions) |
这里有几行新代码:
add_subdirectory
提到有一个子目录或子目录构建,CMake 必须处理它target_include_directories
指定MathFunctions
文件夹。这样,我们之前的问题就得到了回答——由于 CMake ,我们的tutorial.cxx
可以直接引用MathFunctions.h
- 最后,我们将库链接到主目标
target_link_libraries
MathFunctions
tutorial
当我在 Linux 中构建它时,我在中看到了一个新的工件。本质上,这是一个库对象文件,它将被静态链接(成为最终二进制文件的一部分)。/build/MathFunctions/libMathFunctions.a
我还有构建工件,它已经包含这个库。这意味着,例如,我可以将二进制文件移动到我想要的任何位置,并像往常一样运行它,它就会工作。目标文件已经是主二进制文件的一部分。tutorial
tutorial
libMathFunctions.a
最后
了解 CMake 的工作原理以及如何使用它完成基本工作很有趣。它解决了目前在 C++ 打包方面遇到的大多数问题。但同时也别忽略使用 Conan
和 vcpkg
等C++包管理器,以简化 C++ 中的依赖管理。
推荐学习C++书籍
系统化学习这门语言包括一切C++知识,这本书我大一暑假在家闲着没事,游戏玩厌了,倒腾Linux内核,老师推荐买的《C Primer Plus(第6版)中文版》。当时打折,《C++ Primer Plus:中文版(第六版)》这本我一起配套买的,很不错详细。
电子版下载(EPUB格式)
相关文章推荐:CPU工作原理——了解计算机CPU是如何工作的