擊上方“一口Linux”,選擇“星標公眾號”

本文來源:https://ukabuer.me/blog/more-modern-cmake/
對于 C/C++的開發者而言,當涉及到復雜的第三方依賴時,工程的管理往往會變得十分棘手,尤其是還需要支持跨平臺開發時。CMake 做為跨平臺的編譯流程管理工具,為第三方依賴查找和引入,編譯系統創建,程序測試以及安裝都提供了成熟的解決方案。編寫一次 CMakeLists.txt 文件,執行同樣的命令,在不同系統上都可以完成可執行程序或者鏈接庫的創建。在熟悉 CMake 后,這種編譯體驗我認為勉強能趕上 Rust, Go 這些現代語言的一半,還有一半則是差在包管理上,這方面暫且不提。當然,如果只是做做算法題,完全不需要用到 CMake 這樣復雜的工具,簡單使用 gcc, clang 就可以滿足需求了。
CMake 和 C++一樣,隨著多年的發展,其設計也得到了許多改進,并且和舊版本相比產生了重要的差異,從而有了現代 CMake 的說法。傳統的 CMake 使用方式也沒有什么問題,但就和現代 C++一樣,現代的 CMake 使用方式在一些概念上更清晰,對開發者也更友好,更不容易出錯。
# 一個現代CMake工程的簡單例子
cmake_minimum_required(VERSION 3.12)
project(myproj)
find_package(Poco REQUIRED COMPONENTS Net Util)
add_executable(MyEXE)
target_source(MyEXE PRIVATE "main.cpp")
target_link_library(MyEXE PRIVATE Poco::Net Poco::Util)
target_compile_definition(MyEXE PRIVATE std_cxx_14)
一個 C/C++工程通常都是為了生成可執行程序或者鏈接庫,在現代 CMake 里他們被統稱為target,創建命令分別是add_library()和add_executable()。其中鏈接庫的類型又分為很多種,最常用的就是SHARED以及STATIC,在命令中加入關鍵詞進行聲明:add_library(MyLib SHARED),第一個參數為target的名稱,后續的配置都需要用到這個名字。
在CMakeLists.txt中可以有多個target,相關配置大多圍繞這些 target 進行。比如指定target的源文件:
target_source(MyLib PRVIATE "main.cpp" "func.cpp")
在 CMake 中,PRIVATE關鍵詞用于描述參數的“應用范圍”,此外還有INTERFACE和PUBLIC兩種可能的值,在下一小節會對他們進行詳細介紹,此處可以暫時無視。
將一個已有的項目改造為 CMake 工程時,通常會有較多的源文件,可以使用 CMake 的file命令進行遍歷拿到全部的源文件:
file(GLOB_RECURSE SRCS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
命令第一個參數GLOB_RECURSE表明遞歸的查找子文件夾,第二個參數SRCS則是存儲結果的變量名,第三個參數為目標文件的匹配模式,找到符合條件的 cpp 文件后,他們的路徑會以字符串數組的形式保存在 SRCS 變量中,使用方式如下:
target_source(MyLib PRIVATE ${SRCS})
除了源碼,配置target時通常還需要指定頭文件目錄:
target_include_directories(MyLib PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include/)
編譯時需要的語言特性:
target_compile_features(MyLib PRIVATE std_cxx_14)
以及編譯時的宏定義:
target_compile_definitions(MyLib PRIVATE LogLevel=3)
如果你有一些參數想直接傳給底層的編譯器(比如 gcc, clang, cl),可以使用
target_compile_options(MyLib PRIVATE -Werror -Wall -Wextra)
上面通過target_source這些target_*形式的命令進行的配置都是只對指定 target 有效的。而在傳統 CMake 中,這些配置通常都是以全局變量的形式定義,比如使用include_directories()、set_cxx_flags()等命令,傳統方式的問題是靈活度低,當存在多個 target 時無法進行分別配置,導致某個 target 的屬性意外遭到污染,因此現代 CMake 基于 target 的配置方式就和引入了 namespace 一樣,管理起來更省心。
軟件開發中依賴是十分常見的,C/C++通過 include 頭文件的方式引入依賴,在動態或靜態鏈接后可以調用依賴實現。一個可執行程序可能會依賴鏈接庫,鏈接庫也同樣可能依賴其他的鏈接庫。此時一個棘手的問題是,使用者如何知道使用這些外部依賴庫需要什么條件?比方說,其頭文件的代碼可能需要開啟編譯器 C++17 的支持、依賴存在許多動態鏈接庫時可能只需要鏈接其中的一小部分、有哪些間接依賴需要安裝、間接依賴的版本要求是什么……
對于這些問題,最簡單粗暴的解決方案即文字說明,依賴庫的作者可以在某個 README、網站、甚至在頭文件里說明使用要求,但這種方式效率顯然是很低下的。
CMake 提供的解決方案是,在對 target 進行配置時,可以規定配置的類型,分為 build specification 和 usage requirement 兩類,會影響配置的應用范圍。Build specification 類型的配置僅在編譯的時候需要滿足,通過PRIVATE關鍵字聲明;Usage requirement 類型的配置則是在使用時需要滿足,即在其他項目里,使用本項目已編譯好的 target 時需要滿足,這種類型的配置使用INTERFACE關鍵詞聲明。在實際工程中,有很多配置在編譯時以及被使用時都需要被滿足的,這種配置通過PUBLIC關鍵詞進行聲明。
下面來看一個例子,我們編寫了一個 library,在編譯時靜態鏈接了 Boost,在我們的實現文件中使用了 c++14 的特性,并用到了 Boost 的頭文件和函數。隨后我們對外發布了這個庫,其中有頭文件和預編譯好的動態鏈接庫。盡管我們的實現代碼里用了 C++14,但在對外提供的頭文件中只用到 C++03 的語法,也沒有引入任何 Boost 的代碼。這種情況下,當其他工程在使用我們的 library 時,其使用的編譯器不需要開啟 C++14 的支持,開發環境下也不需要安裝 Boost。我們 library 的 CMake 配置中可以這么寫:
target_compile_features(MyLib PRIVATE cxx_std_14)
target_link_libraries(MyLib PRIVATE Boost::Format)
此處用 PRIVATE 說明 c++14 的支持只在編譯時需要用到,Boost 庫的鏈接也僅在編譯時需要。但如果我們對外提供的頭文件中也使用了 C++14,那么就需要使用 PUBLIC 修飾,改為:
target_compile_features(MyLib PUBLIC cxx_std_14)
target_link_libraries(MyLib PRIVATE Boost::Format)
當 library 是 header-only 時,我們的工程是不需要單獨編譯的,因此也就沒有 build specification,通過INTERFACE修飾配置即可
target_compile_features(MyLib INTERFACE cxx_std_14)
需要注意的是,Usage requirement 類型的配置,即通過INTERFACE或是PUBLIC修飾的配置是會傳遞的,比如 LibA 依賴 LibB 后,會繼承 LibB 的 usage requirement,此后 LibC 依賴 LibB 時,LibA 和 libB 的 usage requirement 都會繼承下來,這在存在多級依賴時是非常有用的。
現在的一個問題是,我們寫好的這些 target, 還有他們的PRIVATE, INTERFACE以及PUBLIC屬性,使用者如何才能知道呢?
對于使用者而言,一大問題是如何找到依賴以及了解如何使用依賴。C/C++標準沒有規范庫的安裝位置和安裝形式,通過 CMake 提供的方案尋找依賴,不光可以定位到頭文件目錄和鏈接庫路徑,還能夠獲取到庫的 usage requirement。
在 CMake 中尋找第三方庫的命令為find_package,其背后的工作方式有兩種,一種基于 Config File 的查找,另一種則是基于 Find File 的查找。在執行find_package時,實際上 CMake 都是在找這兩類文件,找到后從中獲取關于庫的信息。
Config File 是依賴的開發者提供的 cmake 腳本,通常會隨預編譯好的二進制一起發布,供下游的使用者使用。在 Config file 里,會對庫里包含的 target 進行描述,說明版本信息以及頭文件路徑、鏈接庫路徑、編譯選項等 usage requirement。
CMake 對 Config file 的命名是有規定的,對于find_package(ABC)這樣一條命令,CMake 只會去尋找ABCConfig.cmake或是abc-config.cmake。CMake 默認尋找的路徑和平臺有關,在 Linux 下尋找路徑包括/usr/lib/cmake以及/usr/lib/local/cmake,在這兩個路徑下可以發現大量的 Config File,一般在安裝某個庫時,其自帶的 Config file 會被放到這里來。
在 Windows 下沒有安裝庫的規范,也因此沒有這樣的目錄,庫可能被安裝在各種奇奇怪怪的地方。此外,在 Linux 下,庫也可能沒有被安裝在上述這些默認位置,在這些情況下,CMake 也提供了解決方案,對于find_package(Abc)命令,如果 CMake 沒有找到 Config file,使用者可以提供Abc_DIR變量,CMake 會到Abc_DIR指向的路徑尋找 Config file。
Config file 看似十分美好,由開發者編寫 CMake 腳本,使用者只要能找到 Config file 即可獲取到庫的 usage requirement。但現實是,并不是所有的開發者都使用 CMake,很多庫并沒有提供供 CMake 使用的 Config file,但此時我們還可以使用 Find file。
對于find_package(ABC)命令,如果 CMake 沒有找到 Config file,他還會去試著尋找FindABC.cmake。Find file 在功能上和 Config file 相同,區別在于 Find file 是由其他人編寫的,而非庫的開發者。如果你使用的某個庫沒有提供 Config file,你可以去網上搜搜 Find file 或者自己寫一個,然后加入到你的 CMake 工程中。
一個好消息是 CMake 官方為我們寫好了很多 Find file,在CMake Documentation這一頁面可以看到,OpenGL,OpenMP,SDL 這些知名的庫官方都為我們寫好了 Find 腳本,因此直接調用 find_package 命令即可。但由于庫的安裝位置并不是固定的,這些 Find 腳本不一定能找到庫,此時根據 CMake 報錯的提示設置對應變量即可,通常是需要提供安裝路徑,這樣就可以通過 Find file 獲取到庫的 usage requirement。不論是 Config file 還是 Find file,其目的都不只是找到庫這么簡單,而是告訴 CMake 如何使用這個庫。
壞消息是有更大部分庫 CMake 官方也沒有提供 Find file,這時候就要自己寫了或者靠搜索了,寫好后放到本項目的目錄下,修改CMAKE_MODULE_PATH這個 CMAKE 變量:
list(INSERT CMAKE_MODULE_PATH 0 ${CMAKE_SOURCE_DIR}/cmake)
這樣${CMAKE_SOURCE_DIR}/cmake目錄下的 Find file 就可以被 CMake 找到了。
不過一個新的問題是,Config file 以及 Find file 究竟要怎么寫?
在 C/C++工程里,對于依賴,我們最基本的要求就是知道他們的鏈接庫路徑和頭文件目錄,通過 CMake 的find_library和find_path兩個命令就可以完成任務:
find_library(MPI_LIBRARY
NAMES mpi
HINTS "${CMAKE_PREFIX_PATH}/lib" ${MPI_LIB_PATH}
# 如果默認路徑沒找到libmpi.so,還會去MPI_LIB_PATH找,下游使用者可以設置這個變量值
)
find_path(MPI_INCLUDE_DIR
NAMES mpi.h
PATHS "${CMAKE_PREFIX_PATH}/include" ${MPI_INCLUDE_PATH}
# 如果默認路徑沒找到mpi.h,還會去MPI_INCLUDE_PATH找,下游使用者可以設置這個變量值
)
于是在早期 CMake 時代,依賴的開發者在 cmake 腳本里通過全局變量來聲明這兩個東西。比如名為 Abc 的庫,其開發者在他的 cmake 腳本里會創建Abc_INCLUDE_DIRS和Abc_LIBRARIES兩個變量供下游使用者使用。這種命令盡管不是官方強制要求的,但大家都遵守了這個習慣,到了今天,很多庫為了兼容舊 CMake 的使用方式,仍然提供這樣的全局變量。
在現代 CMake 中,cmake 腳本提供一個 target 顯然會更好,因為 target 具備屬性,我們不光是要找到庫,還需要了解庫的使用方式,使用 target 除了頭文件目錄和鏈接庫路徑,我們還可以拿到更多關于庫的信息。
因此現代 CMake 提供了一種特別的 target,Imported Target,創建命令為add_library(Abc STATIC IMPORTRED),用于表示在項目外部已經存在、無需編譯的依賴,命令的第二個參數用于說明類型,比如是靜態庫或動態庫等。對于 Imported Target 的名字,似乎開發者們都喜歡使用 namespace 的方式,比如Boost::Format、Boost::Asio等。同樣的,對于一個 CMake 腳本,可以有多個 Imported Target。
我們可以像對待普通 target 一樣,對 Imported Target 調用target_link_libraries等命令來說明他的 usage requirement。但其實還有另一種配置方式,上文提到過可以通過PRIVATE, INTERFACE, PUBLIC用于修飾 target 屬性,這實際上可看作是一種語法糖。在 CMake 中,target 的大多屬性都有對應的 private 以及 interface 兩個版本的變量。比如通過
target_include_directories命令配置頭文件目錄時,當使用PRIVATE修飾時,值被寫入 target 的 INCLUDE_DIRECTORIES變量;使用INTERFACE修飾時,值寫入
INTERFACE_INCLUDE_DIRECTORIES變量;而使用PUBLIC時,則會寫入兩個變量。在 CMake 中,我們可以不使用 target 命令,而是直接使用set_target_properties修改這些值的變量。
對于 Imported Target,當庫已經事先編譯好時,我們需要通過一個特殊的變量,IMPORTED_LOCATION,來指明動態鏈接庫的具體位置。這個變量就可以通過set_target_properties進行設置,在實際生產環境下,由于存在 Release 以及 Debug 環境的區別,IMPORTED_LOCATION實際上也存在多個版本,比如IMPORTED_LOCATION_RELEASE以及IMPORTED_LOCATION_DEBUG,都進行設置后,在對應的環境下,CMake 會根據這些變量為下游使用者選擇正確的鏈接庫。
# spdlog庫的Imported Target
set_target_properties(spdlog::spdlog PROPERTIES
IMPORTED_LINK_INTERFACE_LANGUAGES_RELEASE "CXX"
IMPORTED_LOCATION_RELEASE "${_IMPORT_PREFIX}/lib/spdlog/spdlog.lib"
)
使用 Imported Target 的另一個好處是,我們在引入一個依賴時只需要 link 其 Imported Target,不再需要手動加入其頭文件目錄了。因為依賴的頭文件目錄已經在其 target 的INTERFACE屬性里了,而INTERFACE屬性是可傳遞的,于是:
find_package(spdlog REQUIRED)
add_executable(MyEXE)
target_source(MyExe "main.cpp")
target_link_libraries(MyExe SPDLog::spdlog)
無需
target_include_directories,spdlog 的頭文件目錄自動會加進來。
回到find_package這個命令,這個命令可以指定很多參數,比如指定版本,指定具體的模塊等等。以 SFML 多媒體庫為例,其包含了 network 模塊,audio 模塊,graphic 模塊等等,但我很多時候只用到 graphic 模塊,那么其他的模塊對應的鏈接庫不需要被鏈接,于是 CMake 腳本可以這么寫
# 要求大版本號為2的SFML庫的graphic模塊
find_package(SFML 2 COMPONENTS graphics REQUIRED)
# SFML提供的target名字為sfml-graphics
target_link_libraries(MyEXE PRIVATE sfml-graphics)
對于find_package命令,這些版本、模塊等參數在 Config file 或是 Find file 中顯然是需要處理的,在版本不匹配,模塊不存在的情況下應該對下游使用者進行提示。這一方面 CMake 官方也為依賴開發者做了考慮,提供了
FindPackageHandleStandardArgs這個模塊,在 CMake 腳本中 include 此模塊后,就可以使用
find_package_handle_standard_args命令,來告知 CMake 如何獲取當前 package 的版本變量,如何知道是否找到了庫,比如下面針對 RapidJSON 的 cmake 腳本:
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(RapidJSON
REQUIRED_VARS RapidJSON_INCLUDE_DIR
VERSION_VAR RapidJSON_VERSION
)
這段腳本聲明了當前庫的版本值應該從RapidJSON_VERSION這個變量拿,而RapidJSON_INCLUDE_DIR這個變量可以用于表明有沒有找到庫。在執行這段腳本時,CMake 先去判斷RapidJSON_INCLUDE_DIR這個變量是否為空,如果為空說明沒找到庫,CMake 會直接對下游使用者報錯提示;如果此變量不為空,并且下游使用者在調用find_package時傳入了版本號,CMake 則會從RapidJSON_VERSION變量中取值進行對比,如果版本不滿足也報錯提示。
CMake 生成好編譯環境后,底層的 make, ninja, MSBuild 編譯命令都是不一樣的,但 CMake 提供了一個統一的方法進行編譯:
cmake --build .
使用--buildflag,CMake 就會調用底層的編譯命令,在跨平臺時十分方便。
對于 Visual Studio,其 Debug 和 Release 環境是基于 configuration 的,因此CMAKE_BUILD_TYPE變量無效,需要在 build 時指定:
cmake --build . --config Release
CMake 的缺陷是很明顯的,入門成本很高,其語法的設計也很糟糕,find_package這些函數不會返回結果,而是對全局變量或是 target 產生副作用,函數的行為不查閱文檔是很難預測的。并且在 CMake 中,變量,target,字符串的區分不明確,很容易讓人感到迷惑,不知道什么時候應該使用${}去讀取值。
此外,官方網站上的教程也十分落后,盡管可用,但并沒有使用現代 CMake 方式創建工程。推薦看本文最后給的資料而不是官網上的 Tutorial。
之后有空了再介紹 Config file 的具體創建方式、庫的 install 還有基于 ctest 的測試,不過希望在我更新之前就能有更好的替代工具誕生吧。
end
一口Linux
關注,回復【1024】海量Linux資料贈送
精彩文章合集
文章推薦
點擊“閱讀原文”查看更多分享,歡迎點分享、收藏、點贊、在看