在軟體開發的世界裡,建構系統扮演着至關重要的角色,它不僅決定了項目的建構效率,還直接影響到團隊協作的流暢度。對于許多 C++ 開發者而言,CMake 因其強大的功能和廣泛的相容性成為了建構自動化流程的首選工具。
原文連結:https://journal.hexmos.com/cmake-survial-guide/
聲明:未經允許,禁止轉載。
作者 | Shrijith Venkatramana翻譯 | 鄭麗媛出品 | CSDN(ID:CSDNnews)
最近我一直在用 C++ 處理一些程式設計挑戰,其中管理 C++ 項目的一個重要方面就是依賴管理。
如今,我們在很多程式設計生态系統中享受着即時包管理器的便利:
● 在 Node.js/JavaScript 中使用 npm
● 在 Rust 中使用 cargo
● 在 Python 中使用 pip
而在 C++ 中,盡管有像 Conan 這樣的包管理器,但處理實際項目時,你通常會發現 CMake 是繞不開的選擇。是以如果你想在 C++ 生态系統中工作,學習如何使用 CMake 就不是可選項,而是必修課。
CMake 到底是什麼,為什麼要學它?
CMake 是一個跨平台的建構系統生成器。跨平台這一點非常重要,因為 CMake 能夠在一定程度上抽象出不同平台之間的差異。
例如在類 Unix 系統上,CMake 會生成 makefile 檔案,然後用這些檔案來建構項目。而在 Windows 系統中,CMake 會生成 Visual Studio 項目檔案,随後用于建構項目。
需要注意的是,不同平台通常都有各自的編譯和調試工具鍊:Unix 使用 gcc,macOS 使用clang 等等。
在 C++ 生态系統中,另一個重要方面是能同時處理可執行檔案和庫。
可執行檔案可基于以下不同因素:
● 目标 CPU 架構
● 目标作業系統
● 其他因素
對于庫來說,連結方式也有不同的選擇(連結是指在代碼中使用另一個代碼庫的功能,而無需了解其具體實作):
● 靜态連結
● 動态連結
我曾在一些内部原型項目中,需要調用底層作業系統 API 來執行某些任務,唯一可行的高效方法就是基于一些 C++ 庫來進行建構。
CMake 是如何工作的:三個階段
1. 配置階段
CMake 會讀取所有的 CMakeLists.txt 檔案,并建立一個中間結構來确定後續步驟(如列出源檔案、收集要連結的庫等)。
2. 生成階段
基于配置階段的中間輸出,CMake 會生成特定平台的建構檔案(如在 Unix 系統上生成 makefiles 等)。
3. 建構階段
使用特定平台的工具(如 make 或 ninja)來建構可執行檔案或庫檔案。
一個簡單的 CMake 項目示例(Hello World!)
假設你有一個用于計算數字平方根的 C++ 源檔案。
tutorial.cxx
// A simple program that computes the square root of a number #include <cmath> #include <cstdlib> // TODO 5: Remove this line #include <iostream> #include <string> // TODO 11: Include TutorialConfig.h int main(int argc, char* argv[]) { if (argc < 2) { // TODO 12: Create a print statement using Tutorial_VERSION_MAJOR // and Tutorial_VERSION_MINOR std::cout << "Usage: " << argv[0] << " number" << std::endl; return 1; } // convert input to double // TODO 4: Replace atof(argv[1]) with std::stod(argv[1]) const double inputValue = atof(argv[1]); // calculate square root const double outputValue = sqrt(inputValue); std::cout << "The square root of " << inputValue << " is " << outputValue << std::endl; return 0; }
CMakeLists.txt
project(Tutorial) add_executable(tutorial tutorial.cxx)
上述兩行是生成一個可執行檔案所需的最少指令。理論上,我們還應該指定 CMake 的最低版本号,省略 CMake 會預設使用某個版本(暫時跳過這部分)。
嚴格來說,project 指令并非必需,但我們還是保留它。是以最重要的代碼行是:
add_executable(tutorial tutorial.cxx)
這行代碼指定了目标二進制檔案 tutorial 以及源檔案 tutorial.cxx。
如何建構
以下是一組用于建構項目和測試二進制檔案的指令,稍後會詳細解釋:
mkdir build cd build/ cmake .. ls -l # inspect generated build files cmake --build . ./tutorial 10 # test the binary
從上面的步驟可以看到,整個建構過程大約涉及 5-6 個步驟。
首先,在 CMake 中,我們應該将建構相關的内容與源代碼分開,是以先建立一個建構目錄:
mkdir build
然後我們可以在建構目錄中進行所有與建構相關的操作:
cd build
從這一步開始,我們将執行多個建構相關的任務。
先是生成配置檔案:
cmake ..
在這一步中,CMake 會生成平台特定的配置檔案。在我的 Ubuntu 系統中,我看到了生成的makefile,這些檔案相當冗長,但目前我不需要擔心它們。
接下來,我根據新生成的檔案觸發建構:
cmake --build .
這一步使用生成的建構檔案,生成目标二進制檔案 tutorial。
最後,我可以通過以下指令驗證二進制檔案是否如預期運作:
./tutorial 16
我得到了預期的答案,這說明建構過程運作正常!
在 C++ 項目中注入變量
CMake 通過 Config.h.in 提供了一種機制,允許你在 CMakeLists.txt 中指定變量,這些變量可以在你的 .cpp 檔案中使用。
下面是一個示例,我們在 CMakeLists.txt 中定義了項目的版本号,并在程式中使用。
Config.h.in
在這個檔案中,來自 CMakeLists.txt 的變量将以 @VAR_NAME@ 的形式出現。
#pragma once #define PROJECT_VERSION_MAJOR @PROJECT_VERSION_MAJOR@ #define PROJECT_VERSION_MINOR @PROJECT_VERSION_MINOR@ #define AUTHOR_NAME "@AUTHOR_NAME@"
CMakeLists.txt
cmake_minimum_required(VERSION 3.10) project(Tutorial) # Define configuration variables set(PROJECT_VERSION_MAJOR 1) set(PROJECT_VERSION_MINOR 0) set(AUTHOR_NAME "Jith") # Configure the header file configure_file(Config.h.in Config.h) # Add the executable add_executable(tutorial tutorial.cxx) # Include the directory where the generated header file is located target_include_directories(tutorial PRIVATE "${CMAKE_BINARY_DIR}")
請注意,我們添加了 cmake_minimum_required 來指定所需的最低 CMake 版本,這是編寫 CMakeLists.txt 檔案時的一個良好習慣。
然後,我們使用多個 set() 語句來定義所需的變量名。接着,指定配置檔案 Config.h.in,通過該檔案來使用上述設定的變量。
最後,CMake 會在變量占位被填充後生成頭檔案,這些動态生成的頭檔案需要被包含到項目中。
在我們的示例中,Config.h 檔案将被放置在 ${CMAKE_BINARY_DIR} 目錄中,是以我們隻需指定該路徑即可。
你可能會對以下這一行的 PRIVATE 标簽感到好奇:
target_include_directories(tutorial PRIVATE "${CMAKE_BINARY_DIR}")
了解 CMake 的兩個關鍵概念:可見性修飾符和目标
在 CMake 中,有三個可見性修飾符:PRIVATE、PUBLIC、INTERFACE。
這些修飾符可以在指令中使用,例如:target_include_directories 和 target_link_libraries 等。
這些修飾符是在目标(Targets)的上下文中指定的。目标是 CMake 中的一種抽象概念,表示某種類型的輸出:
● 可執行目标(通過 add_executable)生成二進制檔案
● 庫目标(通過 add_library)生成庫檔案
● 自定義目标(通過 add_custom_target)通過腳本等生成任意檔案
所有上述的目标都會産生具體的檔案或工件作為輸出。庫目标的一個特殊情況是接口目标(Interface Target)。接口目标的定義如下:
add_library(my_interface_lib INTERFACE) target_include_directories(my_interface_lib INTERFACE include/)
在這裡,my_interface_lib 并不會立即生成任何檔案。但在後續階段,一些具體的目标可能會依賴于 my_interface_lib。這意味着,接口目标中指定的 include 目錄也會被依賴。是以,INTERFACE 庫可以看作是建構依賴關系樹的一種便利機制。
了解了目标和依賴的概念之後,我們就回到可見性修飾符的概念。
PRIVATE 可見性
1target_include_directories(tutorial PRIVATE "${CMAKE_BINARY_DIR}")
PRIVATE 表示目标 tutorial 将使用指定的包含目錄。但如果在後續階段其他目标連結到 tutorial,包含目錄将不會傳遞給那些依賴項。
PUBLIC 可見性
1target_include_directories(tutorial PUBLIC "${CMAKE_BINARY_DIR}")
使用 PUBLIC 修飾符意味着目标 tutorial 需要使用該包含目錄,并且任何依賴于 tutorial 的其他目标也會繼承這個包含目錄。
INTERFACE 可見性
1target_include_directories(tutorial INTERFACE "${CMAKE_BINARY_DIR}")
INTERFACE 修飾符表示 tutorial 本身不需要該包含目錄,但任何依賴于 tutorial 的其他目标會繼承這個包含目錄。
簡單總結,可見性修飾符的工作原理如下:
● PRIVATE:源檔案和依賴關系隻傳遞給目前目标;
● PUBLIC:源檔案和依賴關系傳遞給目前目标及其依賴的目标;
INTERFACE:源檔案和依賴關系不傳遞給目前目标,但會傳遞給依賴于它的目标。
将項目建構劃分為庫和目錄
随着項目規模不斷增長,通常需要子產品化來組織項目并管理複雜性。
在 CMake 中,可以用子目錄來指定獨立的子產品及其自定義的建構流程。我們可以擁有一個主 CMake 配置,它能觸發多個庫(子目錄)的建構,最後将所有子產品連結在一起。
這是一個經過簡化後的示例。我們将建立一個名為 MathFunctions 的子產品/庫,它将建構為一個靜态庫(在 Unix 系統上生成 MathFunctions.a),最後再把它連結到我們的主程式中。
首先是源檔案部分(代碼較為簡單):
MathFunctions.h
#pragma once namespace mathfunctions { double sqrt(double x); }
MathFunctions.cxx
#include "MathFunctions.h" #include "mysqrt.h" namespace mathfunctions { double sqrt(double x) { return detail::mysqrt(x); } }
mysqrt.h
#pragma once namespace mathfunctions { namespace detail { double mysqrt(double x); } }
mysqrt.cxx
#include "mysqrt.h" #include <iostream> namespace mathfunctions { namespace detail { // a hack square root calculation using simple operations double mysqrt(double x) { if (x <= 0) { return 0; } double result = x; // do ten iterations 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 << "Computing sqrt of " << x << " to be " << result << std::endl; } return result; } } }
以上這些代碼片段,引入了一個名為 mathfunctions 的命名空間,其中包含了一個自定義的 sqrt 函數實作。這樣我們就可以在項目中定義自己的平方根函數,而不會與其他版本的 sqrt 沖突。
接下來,如何将該檔案夾建構為 Unix 二進制檔案?我們需要為該子產品/庫建立一個自定義的 CMake 子配置:
MathFunctions/CMakeLists.txt
add_library(MathFunctions MathFunctions.cxx mysqrt.cxx)
通過這條簡單的 add_library 指令,我們指定了需要編譯的 .cxx 檔案來生成庫檔案。
但這還不夠,解決方案的核心在于如何将這個子目錄或庫連結到我們的主項目中:
tutorial.cxx(使用庫/子產品版本)
#include "Config.h" #include "MathFunctions.h" #include <cmath> #include <cstdlib> #include <iostream> #include <string> int main(int argc, char* argv[]) { std::cout << "Project Version: " << PROJECT_VERSION_MAJOR << "." << PROJECT_VERSION_MINOR << std::endl; std::cout << "Author: " << AUTHOR_NAME << std::endl; if (argc < 2) { std::cout << "Usage: " << argv[0] << " number" << std::endl; return 1; } const double inputValue = atof(argv[1]); // use library function 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
cmake_minimum_required(VERSION 3.10) project(Tutorial) # Define configuration variables set(PROJECT_VERSION_MAJOR 1) set(PROJECT_VERSION_MINOR 0) set(AUTHOR_NAME "Jith") # Configure the header file configure_file(Config.h.in Config.h) add_subdirectory(MathFunctions) add_executable(tutorial tutorial.cxx) target_include_directories(tutorial PUBLIC "${PROJECT_BINARY_DIR}" "${PROJECT_SOURCE_DIR}/MathFunctions") target_link_libraries(tutorial PUBLIC MathFunctions)
這裡有幾條新指令:
● add_subdirectory 指定了一個子目錄建構,CMake 将負責處理該子目錄中的建構任務。
● target_include_directories 告訴 CMake MathFunctions 檔案夾的路徑,這樣我們可以在 tutorial.cxx 中直接引用 MathFunctions.h。
● target_link_libraries 将 MathFunctions 庫連結到主程式 tutorial 中。
當我在 Linux 上建構這個項目時,我看到 build/MathFunctions 目錄下生成了 libMathFunctions.a 檔案,這是一個靜态連結的庫檔案,它已經成為主程式的一部分。
現在,我們還可以随意移動生成的 tutorial 可執行檔案,它将繼續正常運作,因為 libMathFunctions.a 已經被靜态連結進主程式中。
下一步是什麼?
學習 CMake 的基本工作原理和如何用它完成一些基本任務确實很有意思。
CMake 解決了我現在在 C++ 打包方面遇到的大部分問題。同時,探索 Conan 和 vcpkg 以簡化 C++ 中的依賴管理也是一件有趣的事情。未來有機會的話,我應該會進一步了解和嘗試這些工具。