現(xiàn)代CMake工具的設計理念和使用
點擊上方 “小白學視覺 ”,選擇加" 星標 "或“ 置頂 ”
重磅干貨,第一時間送達
鏈接:https://ukabuer.me/blog/more-modern-cmake/
對于 C/C++的開發(fā)者而言,當涉及到復雜的第三方依賴時,工程的管理往往會變得十分棘手,尤其是還需要支持跨平臺開發(fā)時。
CMake 做為跨平臺的編譯流程管理工具,為第三方依賴查找和引入,編譯系統(tǒng)創(chuàng)建,程序測試以及安裝都提供了成熟的解決方案。 編寫一次 CMakeLists.txt 文件,執(zhí)行同樣的命令,在不同系統(tǒng)上都可以完成可執(zhí)行程序或者鏈接庫的創(chuàng)建。在熟悉 CMake 后,這種編譯體驗我認為勉強能趕上 Rust, Go 這些現(xiàn)代語言的一半,還有一半則是差在包管理上,這方面暫且不提。當然,如果只是做做算法題,完全不需要用到 CMake 這樣復雜的工具,簡單使用 gcc, clang 就可以滿足需求了。
CMake 和 C++一樣,隨著多年的發(fā)展,其設計也得到了許多改進,并且和舊版本相比產生了重要的差異,從而有了現(xiàn)代 CMake 的說法。傳統(tǒng)的 CMake 使用方式也沒有什么問題,但就和現(xiàn)代 C++一樣,現(xiàn)代的 CMake 使用方式在一些概念上更清晰,對開發(fā)者也更友好,更不容易出錯。
# 一個現(xiàn)代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)
Target 和圍繞 Target 的配置
一個 C/C++工程通常都是為了生成可執(zhí)行程序或者鏈接庫,在現(xiàn)代 CMake 里他們被統(tǒng)稱為
target
,創(chuàng)建命令分別是
add_library()
和
add_executable()
。其中鏈接庫的類型又分為很多種,最常用的就是
SHARED
以及
STATIC
,在命令中加入關鍵詞進行聲明:
add_library(MyLib SHARED)
,第一個參數(shù)為
target
的名稱,后續(xù)的配置都需要用到這個名字。
在
CMakeLists.txt
中可以有多個
target
,相關配置大多圍繞這些 target 進行。比如指定
target
的源文件:
target_source(MyLib PRVIATE "main.cpp" "func.cpp")
在 CMake 中,
PRIVATE
關鍵詞用于描述參數(shù)的“應用范圍”,此外還有
INTERFACE
和
PUBLIC
兩種可能的值,在下一小節(jié)會對他們進行詳細介紹,此處可以暫時無視。
將一個已有的項目改造為 CMake 工程時,通常會有較多的源文件,可以使用 CMake 的
file
命令進行遍歷拿到全部的源文件:
file(GLOB_RECURSE SRCS ${CMAKE_CURRENT_SOURCE_DIR}/*.cpp)
命令第一個參數(shù)
GLOB_RECURSE
表明遞歸的查找子文件夾,第二個參數(shù)
SRCS
則是存儲結果的變量名,第三個參數(shù)為目標文件的匹配模式,找到符合條件的 cpp 文件后,他們的路徑會以字符串數(shù)組的形式保存在 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)
如果你有一些參數(shù)想直接傳給底層的編譯器(比如 gcc, clang, cl),可以使用
target_compile_options(MyLib PRIVATE -Werror -Wall -Wextra)
上面通過
target_source
這些
target_*
形式的命令進行的配置都是只對指定 target 有效的。而在傳統(tǒng) CMake 中,這些配置通常都是以全局變量的形式定義,比如使用
include_directories()
、
set_cxx_flags()
等命令,傳統(tǒng)方式的問題是靈活度低,當存在多個 target 時無法進行分別配置,導致某個 target 的屬性意外遭到污染,因此現(xiàn)代 CMake 基于 target 的配置方式就和引入了 namespace 一樣,管理起來更省心。
Build Specification 和 Usage Requirement
軟件開發(fā)中依賴是十分常見的,C/C++通過 include 頭文件的方式引入依賴,在動態(tài)或靜態(tài)鏈接后可以調用依賴實現(xiàn)。一個可執(zhí)行程序可能會依賴鏈接庫,鏈接庫也同樣可能依賴其他的鏈接庫。此時一個棘手的問題是,使用者如何知道使用這些外部依賴庫需要什么條件?比方說,其頭文件的代碼可能需要開啟編譯器 C++17 的支持、依賴存在許多動態(tài)鏈接庫時可能只需要鏈接其中的一小部分、有哪些間接依賴需要安裝、間接依賴的版本要求是什么……對于這些問題,最簡單粗暴的解決方案即文字說明,依賴庫的作者可以在某個 README、網(wǎng)站、甚至在頭文件里說明使用要求,但這種方式效率顯然是很低下的。
CMake 提供的解決方案是,在對 target 進行配置時,可以規(guī)定配置的類型,分為 build specification 和 usage requirement 兩類,會影響配置的應用范圍。Build specification 類型的配置僅在編譯的時候需要滿足,通過PRIVATE關鍵字聲明;Usage requirement 類型的配置則是在使用時需要滿足,即在其他項目里,使用本項目已編譯好的 target 時需要滿足,這種類型的配置使用INTERFACE關鍵詞聲明。在實際工程中,有很多配置在編譯時以及被使用時都需要被滿足的,這種配置通過PUBLIC關鍵詞進行聲明。
下面來看一個例子,我們編寫了一個 library,在編譯時靜態(tài)鏈接了 Boost,在我們的實現(xiàn)文件中使用了 c++14 的特性,并用到了 Boost 的頭文件和函數(shù)。隨后我們對外發(fā)布了這個庫,其中有頭文件和預編譯好的動態(tài)鏈接庫。盡管我們的實現(xiàn)代碼里用了 C++14,但在對外提供的頭文件中只用到 C++03 的語法,也沒有引入任何 Boost 的代碼。這種情況下,當其他工程在使用我們的 library 時,其使用的編譯器不需要開啟 C++14 的支持,開發(fā)環(huán)境下也不需要安裝 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 都會繼承下來,這在存在多級依賴時是非常有用的。
現(xiàn)在的一個問題是,我們寫好的這些 target, 還有他們的
PRIVATE
,?
INTERFACE
以及
PUBLIC
屬性,使用者如何才能知道呢?
尋找和使用鏈接庫
對于使用者而言,一大問題是如何找到依賴以及了解如何使用依賴。C/C++標準沒有規(guī)范庫的安裝位置和安裝形式,通過 CMake 提供的方案尋找依賴,不光可以定位到頭文件目錄和鏈接庫路徑,還能夠獲取到庫的 usage requirement。在 CMake 中尋找第三方庫的命令為find_package,其背后的工作方式有兩種,一種基于 Config File 的查找,另一種則是基于 Find File 的查找。在執(zhí)行find_package時,實際上 CMake 都是在找這兩類文件,找到后從中獲取關于庫的信息。
1.通過 Config file 找到依賴 Config File 是依賴的開發(fā)者提供的 cmake 腳本,通常會隨預編譯好的二進制一起發(fā)布,供下游的使用者使用。在 Config file 里,會對庫里包含的 target 進行描述,說明版本信息以及頭文件路徑、鏈接庫路徑、編譯選項等 usage requirement。
CMake 對 Config file 的命名是有規(guī)定的,對于find_package(ABC)這樣一條命令,CMake 只會去尋找ABCConfig.cmake或是abc-config.cmake。CMake 默認尋找的路徑和平臺有關,在 Linux 下尋找路徑包括/usr/lib/cmake以及/usr/lib/local/cmake,在這兩個路徑下可以發(fā)現(xiàn)大量的 Config File,一般在安裝某個庫時,其自帶的 Config file 會被放到這里來。
在 Windows 下沒有安裝庫的規(guī)范,也因此沒有這樣的目錄,庫可能被安裝在各種奇奇怪怪的地方。此外,在 Linux 下,庫也可能沒有被安裝在上述這些默認位置,在這些情況下,CMake 也提供了解決方案,對于find_package(Abc)命令,如果 CMake 沒有找到 Config file,使用者可以提供Abc_DIR變量,CMake 會到Abc_DIR指向的路徑尋找 Config file。
2.通過 Find file 找到依賴 Config file 看似十分美好,由開發(fā)者編寫 CMake 腳本,使用者只要能找到 Config file 即可獲取到庫的 usage requirement。但現(xiàn)實是,并不是所有的開發(fā)者都使用 CMake,很多庫并沒有提供供 CMake 使用的 Config file,但此時我們還可以使用 Find file。
對于find_package(ABC)命令,如果 CMake 沒有找到 Config file,他還會去試著尋找FindABC.cmake。Find file 在功能上和 Config file 相同,區(qū)別在于 Find file 是由其他人編寫的,而非庫的開發(fā)者。如果你使用的某個庫沒有提供 Config file,你可以去網(wǎng)上搜搜 Find file 或者自己寫一個,然后加入到你的 CMake 工程中。
一個好消息是 CMake 官方為我們寫好了很多 Find file,在CMake Documentation這一頁面可以看到,OpenGL,OpenMP,SDL 這些知名的庫官方都為我們寫好了 Find 腳本,因此直接調用 find_package 命令即可。但由于庫的安裝位置并不是固定的,這些 Find 腳本不一定能找到庫,此時根據(jù) 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 究竟要怎么寫?
Imported Target 在 C/C++工程里,對于依賴,我們最基本的要求就是知道他們的鏈接庫路徑和頭文件目錄,通過 CMake 的find_library和find_path兩個命令就可以完成任務:
find_library(MPI_LIBRARY
NAMES mpi
HINTS "${CMAKE_PREFIX_PATH}/lib" ${MPI_LIB_PATH}
# 如果默認路徑?jīng)]找到libmpi.so,還會去MPI_LIB_PATH找,下游使用者可以設置這個變量值
)
find_path(MPI_INCLUDE_DIR
NAMES mpi.h
PATHS "${CMAKE_PREFIX_PATH}/include" ${MPI_INCLUDE_PATH}
# 如果默認路徑?jīng)]找到mpi.h,還會去MPI_INCLUDE_PATH找,下游使用者可以設置這個變量值
)
于是在早期 CMake 時代,依賴的開發(fā)者在 cmake 腳本里通過全局變量來聲明這兩個東西。比如名為 Abc 的庫,其開發(fā)者在他的 cmake 腳本里會創(chuàng)建Abc_INCLUDE_DIRS和Abc_LIBRARIES兩個變量供下游使用者使用。這種命令盡管不是官方強制要求的,但大家都遵守了這個習慣,到了今天,很多庫為了兼容舊 CMake 的使用方式,仍然提供這樣的全局變量。
在現(xiàn)代 CMake 中,cmake 腳本提供一個 target 顯然會更好,因為 target 具備屬性,我們不光是要找到庫,還需要了解庫的使用方式,使用 target 除了頭文件目錄和鏈接庫路徑,我們還可以拿到更多關于庫的信息。
因此現(xiàn)代 CMake 提供了一種特別的 target,Imported Target,創(chuàng)建命令為add_library(Abc STATIC IMPORTRED),用于表示在項目外部已經(jīng)存在、無需編譯的依賴,命令的第二個參數(shù)用于說明類型,比如是靜態(tài)庫或動態(tài)庫等。對于 Imported Target 的名字,似乎開發(fā)者們都喜歡使用 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,當庫已經(jīng)事先編譯好時,我們需要通過一個特殊的變量,IMPORTED_LOCATION,來指明動態(tài)鏈接庫的具體位置。這個變量就可以通過set_target_properties進行設置,在實際生產環(huán)境下,由于存在 Release 以及 Debug 環(huán)境的區(qū)別,IMPORTED_LOCATION實際上也存在多個版本,比如IMPORTED_LOCATION_RELEASE以及IMPORTED_LOCATION_DEBUG,都進行設置后,在對應的環(huán)境下,CMake 會根據(jù)這些變量為下游使用者選擇正確的鏈接庫。
# 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,不再需要手動加入其頭文件目錄了。因為依賴的頭文件目錄已經(jīng)在其 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 的頭文件目錄自動會加進來。
3.find_package 的處理 回到find_package這個命令,這個命令可以指定很多參數(shù),比如指定版本,指定具體的模塊等等。以 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
命令,這些版本、模塊等參數(shù)在 Config file 或是 Find file 中顯然是需要處理的,在版本不匹配,模塊不存在的情況下應該對下游使用者進行提示。這一方面 CMake 官方也為依賴開發(fā)者做了考慮,提供了
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
這個變量可以用于表明有沒有找到庫。在執(zhí)行這段腳本時,CMake 先去判斷
RapidJSON_INCLUDE_DIR
這個變量是否為空,如果為空說明沒找到庫,CMake 會直接對下游使用者報錯提示;如果此變量不為空,并且下游使用者在調用
find_package
時傳入了版本號,CMake 則會從
RapidJSON_VERSION
變量中取值進行對比,如果版本不滿足也報錯提示。
使用 CMake 來編譯
CMake 生成好編譯環(huán)境后,底層的 make, ninja, MSBuild 編譯命令都是不一樣的,但 CMake 提供了一個統(tǒng)一的方法進行編譯:
cmake --build .
使用--buildflag,CMake 就會調用底層的編譯命令,在跨平臺時十分方便。
對于 Visual Studio,其 Debug 和 Release 環(huán)境是基于 configuration 的,因此CMAKE_BUILD_TYPE變量無效,需要在 build 時指定:
cmake --build . --config Release
CMake 的缺陷
CMake 的缺陷是很明顯的,入門成本很高,其語法的設計也很糟糕,find_package這些函數(shù)不會返回結果,而是對全局變量或是 target 產生副作用,函數(shù)的行為不查閱文檔是很難預測的。并且在 CMake 中,變量,target,字符串的區(qū)分不明確,很容易讓人感到迷惑,不知道什么時候應該使用${}去讀取值。此外,官方網(wǎng)站上的教程也十分落后,盡管可用,但并沒有使用現(xiàn)代 CMake 方式創(chuàng)建工程。推薦看本文最后給的資料而不是官網(wǎng)上的 Tutorial。
之后有空了再介紹 Config file 的具體創(chuàng)建方式、庫的 install 還有基于 ctest 的測試,不過希望在我更新之前就能有更好的替代工具誕生吧。
參考資料:
cmake-buildsystem cmake-packages It's Time To Do CMake Right
下載1:OpenCV-Contrib擴展模塊中文版教程
在「小白學視覺」公眾號后臺回復:
擴展模塊中文教程
,即可下載全網(wǎng)第一份OpenCV擴展模塊教程中文版,涵蓋擴展模塊安裝、SFM算法、立體視覺、目標跟蹤、生物視覺、超分辨率處理等二十多章內容。
下載2:Python視覺實戰(zhàn)項目52講
在「小白學視覺」公眾號后臺回復:Python視覺實戰(zhàn)項目,即可下載包括圖像分割、口罩檢測、車道線檢測、車輛計數(shù)、添加眼線、車牌識別、字符識別、情緒檢測、文本內容提取、面部識別等31個視覺實戰(zhàn)項目,助力快速學校計算機視覺。
下載3:OpenCV實戰(zhàn)項目20講
在「小白學視覺」公眾號后臺回復:OpenCV實戰(zhàn)項目20講
,
即可下載含有20個基于OpenCV實現(xiàn)20個實戰(zhàn)項目,實現(xiàn)OpenCV學習進階。
交流群
歡迎加入公眾號讀者群一起和同行交流,目前有SLAM、三維視覺、傳感器、自動駕駛、計算攝影、檢測、分割、識別、醫(yī)學影像、GAN、算法競賽等微信群(以后會逐漸細分), 請掃描下面微信號加群,備注:”昵稱+學校/公司+研究方向“,例如:”張三?+?上海交大?+?視覺SLAM“。請按照格式備注,否則不予通過。添加成功后會根據(jù)研究方向邀請進入相關微信群。請勿在群內發(fā)送廣告,否則會請出群,謝謝理解~
