CMake是一個構建工具,通過它可以很容易建立跨平臺的專案。通常使用它構建專案要分兩步,通過原始碼生成工程檔案,通過工程檔案構建目標產物(可能是動態庫,靜態庫,也可能是可執行程式)。使用CMake的一個主要優勢是在多平臺或者多人共同作業的專案中,開發人員可以根據自己的喜好來使選擇IDE,不用受其他人工程設定的影響,它有點像跨平臺的IDE,通過它設定好相關設定之後,可以在多個平臺無縫銜接,提高開發效率。
一個用CMake來管理的專案,其專案根目錄通常會包含一個CMakeLists.txt
的檔案,當然子目錄可能也有,這種情況我們稍後再說。我們先從最簡單的專案開始。以下就是一個最簡單的工程範例:
CMakeProject
| CMakeLists.txt
| main.cpp
這就是完整的可以跑起來的最小專案了。按照順序,我們來看看檔案裡的內容
CMakeLists.txt
# 設定版本號
cmake_minimum_required(VERSION 3.10)
# 設定專案名
project(CMakeProject)
# 設定產物和原始碼的關聯
add_executable(${CMAKE_PROJECT_NAME} main.cpp)
說明:
#
開始的是備註${變數名}
所以檔案中真正的有效內容就三行,
cmake_minimum_required(VERSION 3.10)
設定了CMake支援的最低版本,VERSION
是引數名,後面是版本號,可以根據自己的需要修改。 注意引數名和引數是以空白符分隔的,不是逗號, 不然會報錯。project(CMakeProject)
CMake中字串可以帶引號或者不帶,效果是一致的,這一行就是設定了專案名,如生成的Visual Studio的工程名就是依據這個名字來的。add_executable(${CMAKE_PROJECT_NAME} main.cpp)
main.cpp
則是用來生成產物的原始碼路徑,這就是CMake最靈活的地方。原始碼路徑可以是多樣的,查詢出來的,直接寫的,相對路徑,絕對路徑都可以。 多個原始碼的話就用空白符分隔,依次寫就行了。main.cpp
,我們想通過它來生成一個可執行的程式,內容也很簡單:#include <iostream>
int main()
{
std::cout<<"hello CMake"<<std::endl;
return 0;
}
準備工作已經做完,接下來我們就要使用CMake生成可執行檔案了。
第一步當然是要安裝CMake啦,這是下載地址!Download,根據自己的平臺選擇下載即可,安裝完成之後需要把它新增到環境變數中,便於我們在任何地方都能方便使用。
安裝了CMake以後,開啟命令列工具,進入到剛才建立的專案根目錄,也就是進入到存著CMakeLists.txt
和main.cpp
的目錄,下一步準備生成專案。
通常為了不影響和汙染當前的工作環境,我們會選擇新建一個目錄來存放生成的工程檔案,以下我主要以Windows平臺為主要平臺講解,其他平臺基本一致。
mkdir build #建立資料夾,儲存工程檔案;
cd build #切換cmake工作目錄;
cmake .. #生成專案檔案;
這三步執行完後,我們就可以在build資料夾下看到裡面已經生成了一個Visual Studio的工程,我們可以直接用Visual Studio開啟這個工程,按照我們的習慣執行編譯和偵錯。當然,假如想最快地生成可執行檔案,我還是推薦使用CMake。
使用CMake執行編譯,只需要在上一步的基礎上(也就是已經成功執行了上面的三個步驟)再執行一個命令cmake --build .
就可以了。這裡切記不能少第三個英文句號,它代表在當前的工作目錄中執行CMake的編譯。
假如上面的四步都一切順利的話,那麼,我們就可以在build/debug
目錄下看到以add_executable
的第一個引數命名的可執行檔案(這裡就是CMakeProject.exe
),雙擊或者把它拖到命令列就可以執行它了。
在前面的例子中,生成工程檔案,我們使用了兩個命令,其實,這裡可以直接用一個命令就可以完成——cmake build -S . -B build
。這個命令的意思是以當前路徑為工作路徑,以build
目錄為生成目錄,生成工程檔案,也就是不需要我們手動建立build
資料夾了。其中 -S
引數設定的是源路徑,-B
設定的是生成路徑。
另外,由於CMake沒有清理方法,所以每次修改CMake的設定(也就是新增或者刪除CMakeLists.txt
中的程式碼),需要重新生成工程檔案的時候,需要我們手動清理生成目錄,保證它是空目錄,假如不這樣做,那麼專案可能生成失敗或者新設定不起作用。假如只是修改了原始碼的內容的話,則不需要重新生成,直接進行第四步即可。
雖然上面的操作已經足夠簡單,但是考慮到長期的修改和驗證需要,還是太繁瑣枯燥了,尤其是要反覆切換工作目錄,還是比較煩人的。所以我推薦使用批次處理來完成這些操作。結合清理生成目錄和切換工作目錄這幾個步驟,最終的批次檔可能是這樣的
@echo off
rd /s /q build
mkdir build
cd build
cmake ..
cmake --build .
cd debug
CMakeProject
cd ../..
按順序依次解釋一下:
第一行是關閉了命令列的回顯功能,因為我們不希望它的回顯干擾到CMake的資訊輸出,以造成不必要的混亂,而且通常我們也只關心它最後有沒有完成工作而不是看它在幹什麼。
第二行則是用了Windows上的刪除資料夾命令(Linux,MacOS上對應的是rmdir),/s是設定它清除資料夾中所有的內容,包括子資料夾,不設定命令就會執行失敗,/q則是讓命令直接執行刪除,不需要我們手動確認,這個引數很重要,不然我們需要一個一個地確認刪除,完全失去了自動化的作用。然後後面的四句就是我們上面講的內容了,不再贅述。
一直來到倒數第二句,這裡我直接寫了可執行檔案的名字(需要替換為你自己的名字),為的就是直接在編譯完成之後執行可執行檔案,這對有些會生成檔案的應用來說很有用。
執行結束後,再將目錄切回到專案根目錄,這就是最後一行的作用,由於我們再編譯的時候已經切換了目錄到生成目錄了,而編譯的可執行檔案又是在生成目錄的子目錄中,所以回到根目錄,我們需要回退兩次,這是保證下次我們能勝利執行批次處理的關鍵。
把上面的內容儲存為bat結尾的檔案,然後下次就可以直接在命令列輸入bat檔名來一次性完成生成和構建了,簡直爽歪歪。
以上就是CMake專案我們所需要知道的了。當然實際專案遠比這個複雜得多,接下來我將以我踩過的坑為基礎,逐一增加專案的複雜度,慢慢形成對CMake的工作流程的理解。
在開始之前,我先講一講我對CMake專案或者說CMakeLists.txt
檔案的理解。我們不能單獨的以某一個設定為理解物件,我們需要對這些命令進行分類甚至提煉出它的核心工作模式。我是以c++檔案的編譯連結為線索梳理的。 我們都知道一個c++原始檔要想生成可執行程式碼,需要分三步
我們按照這個思路來理解CMake就簡單多了。假如CMake報錯,我們就可以根據報錯資訊定位到是哪個階段出了問題,進而快速找到解決辦法。另外我們也可以依據這些資訊對CMake的設定分類,我自己理解的粗略分類如下:
cmake_minimum_required
;file
,aux_source_directory
;find_libraray
;include_directories
;link_directories
;add_subdirectory
;add_executable
,add_library
;當然,這些只是很少的一部分,但是對我們理解和搜尋問題的解決思路提供了較好的方向。
很多時候,我們會引入第三方包來減少重複編碼的工作,通常這種程式碼我們需要放在其他目錄中,於是我新建了一個子目錄,用於模擬存放的第三方程式碼。對於這種情況,我們有兩種包含形式——子模組和子目錄。
先說簡單一些的子目錄吧。子目錄的意思就是將第三方程式碼看作我們程式碼的一部分,一起合併編譯,這種方式可以使我們的專案看起來更緊湊。如以下的專案結構
CMakeProject
| auto.bat
| CMakeLists.txt //修改
| main.cpp //修改
|
\---3rd //新增
lib.h
我新建了一個子資料夾,用來模擬第三方程式碼,現在我們把它引入到main.cpp
中,編譯,就會發現報錯了,資訊為fatal error C1083: 無法開啟包括檔案: 「lib.h」: No such file or directory,
這很正常。結合上面我舉的例子。這個報錯資訊是和標頭檔案相關的,檢視CMake檔案,我發現了CMake有個include_directories
的指令,它的意思就是新增檔案頭的目錄,以便讓CMake找到標頭檔案。於是,我在CMakeLists.txt
檔案中新增了include_directories(3rd)
,然後再次執行編譯,專案又正確跑起來了。來看看這時的main.cpp
#include <iostream>
#include <lib.h>
int main()
{
int a=1,b=1;
std::cout<<"hello CMake"<<std::endl;
std::cout<<"a + b = "<<sum(a,b)<<std::endl;
return 0;
}
注意:這裡的include_directories
和cpp中的include
是一一對應的,就是說,假如include_directories
裡面設定的目錄是.(當前目錄,CMake沒有把當前目錄新增到include
路徑),則對應cpp的include
要寫成3rd/lib.h
這種形式,簡單來說,就是include_directories
被設定為了include
的根目錄。
另一種情況就是子模組。
子模組的意思是,模組可以單獨編譯,單獨提供給其他庫使用,而不是和主專案共生的,適用於和主模組耦合不大的情況。為了滿足這個條件,我們修改剛才的目錄結構為下面這種
CMakeProject
| auto.bat
| CMakeLists.txt //修改
| main.cpp
|
\---3rd
CMakeLists.txt //新增
lib.cpp //新增
lib.h //修改
我把lib.h
中的函數改為宣告,實現放在了lib.cpp
檔案中。最大的變化是新建了3rd
目錄下的CMakeLists.txt
檔案,用它統一管理3rd
目錄下的所有原始檔(假如檔案很多的話,這裡是模擬),使用了add_library
把3rd
目錄下打包成了子模組。
project(sum)
add_library(${PROJECT_NAME} lib.cpp)
add_library
在名字和原始碼中間還可以指定構建型別,預設是STATIC
,也就是靜態庫,假如想構建動態庫需要手動指定為SHARED
(add_library(${PROJECT_NAME} SHARED lib.cpp)
)。
重要的改變來自主目錄下的CMakeLists.txt
# 設定版本號
cmake_minimum_required(VERSION 3.10)
# 設定專案名
project(CMakeProject)
# 指定3rd為include的查詢目錄
include_directories(3rd)
# 子模組
add_subdirectory(3rd)
# 設定產物和原始碼的關聯
add_executable(${PROJECT_NAME} main.cpp)
target_link_libraries(${PROJECT_NAME} sum)
新增了add_subdirectory
,它的作用是將指定目錄下的原始碼作為一個模組編譯,前提是這個目錄下要有CMakeLists.txt
檔案。另一個改變就是target_link_libraries
的新增,它的作用是將子模組連結進主模組,假如沒有這一句,在連結的時候會報錯error LNK2019: 無法解析的外部符號
。模組的名字需要和子模組中add_library
中第一個引數保持一致。
在前面的範例中,專案的複雜度表現在多目錄,多原始碼,而在使用CMake進行交叉編譯的過程中,專案的主要複雜度表現在環境設定。儘管CMake可以幾乎不修改CMakeLists.txt
的情況下,實現交叉編譯,但是對於新手,面對陌生的設定,往往會無從下手,企圖找到一鍵就完成設定的簡便方法。對於CMake,確實沒有這種快捷方法,但是,只要我們理解了交叉編譯就是正確設定屬性值的過程。 這一實質之後,問題就會變得明朗起來。所以,上面的問題就會轉化為我們熟悉的問題了——需要設定哪些屬性,這些屬性有哪些合適的值,這些值怎樣傳遞給CMake等等,這就是交叉編譯的全部了。正如之前提到的一樣,CMake有很多預設的變數,我們需要從這些預設變數中找到一些,設定一些值,然後讓CMake按照這些設定完成工作,這就是我們接下來需要做的事。下面我將以Windows交叉編譯Android為例說明這個過程。
在Windows平臺上,預設會使用Visual Studio作為C,C++的編譯器,這對於編譯Android的庫來說可能會報錯。所以在執行cmake
命令的時候,需要使用 -G "Unix Makefiles"
來改變這一行為。但這還不夠,因為CMake編譯是需要指定編譯器的。而Android上的C,C++編譯器通常以NDK的方式提供,所以,我們需要下載好NDK。在NDK中,會同時為我們提供兩種工具,一種就是編譯器,另一種就是android.toolchain.cmake
,這也是CMake命令構成的檔案,裡面為我們交叉編譯指定了很多預設值,能大大減輕我們的工作。
前面說了,交叉編譯就是改變CMake預設值,而改變這預設值的方式有兩種,我們要結合起來使用。一種是通過NDK提供的android.toolchain.cmake
檔案。 android.toolchain.cmake
中以設定了絕大部分的值,但是這些設定也是很靈活的,還有很大的設定空間。因此,根據使用者的需求不同,我們還需要在執行CMake命令時動態傳遞一些值,以使CMake能正確完成工作。這就是另一種方式——選項。傳遞選項會以-D
開頭,後面跟著某個CMake的預定義變數由於選項很多,而且大多比較複雜,所以,最好還是通過指令碼檔案來記錄並且修改。以下就是Windows平臺上編譯Android程式碼需要指定的幾個選項,我將逐個介紹這些必要的設定。
-DCMAKE_SYSTEM_NAME=Android
這個設定是告訴CMake需要生成Android平臺的庫,也就是執行交叉編譯。-DANDROID_ABI=x86
這個設定是告訴CMake生成庫適用的架構平臺。熟悉Android開發的讀者應該不會陌生,支援的值會根據NDK的變化而有所變化,如早期的armeabi
已經在 NDK r17中移除了,現在主流的還有四種armeabi-v7a
,arm64-v8a
,x86
,x86_64
.根據需要把值替換就行。-DANDROID_PLATFORM=android-28
,這個值其實不是特別必要,因為有預設值,但是為了可控,還是需要指定一個。它是用來確定庫支援的最低系統版本的。-DCMAKE_TOOLCHAIN_FILE=C:/Users/Leroene/AppData/Local/Android/Sdk/ndk/21.0.6113669/build/cmake/android.toolchain.cmake
,這是上面提到的預設檔案。需要注意的是,NDK中有多個以這個名字命名的檔案,假如指定錯誤,可能會導致CMake出錯,所以我的經驗就是,更改版本號(C:/Users/Leroene/AppData/Local/Android/Sdk/ndk/21.0.6113669
)及前面的路徑,後面的保持不變。-DCMAKE_MAKE_PROGRAM=C:/Users/Leroene/AppData/Local/Android/Sdk/ndk/21.0.6113669/prebuilt/windows-x86_64/bin/make
最後一個引數是指定make
程式的路徑,由於我們指定生成了make專案的程式碼,而Windows通常沒有make可執行檔案,所以我們需要讓CMake找到make檔案以完成編譯。這裡我的經驗也是保持後面的不變,修改前面的,並保持版本一致以避免BUG。-DCMAKE_BUILD_TYPE=Release
,指定構建型別,這應該很常見了。至此Windows交叉編譯Android庫的所有設定都講解完了。讓我們來看看它完整的例子
@echo off
rd /s /q build
mkdir build
cd build
cmake -G "Unix Makefiles" ^
-DCMAKE_TOOLCHAIN_FILE=C:/Users/Leroene/AppData/Local/Android/Sdk/ndk/21.0.6113669/build/cmake/android.toolchain.cmake ^
-DCMAKE_MAKE_PROGRAM=C:/Users/Leroene/AppData/Local/Android/Sdk/ndk/21.0.6113669/prebuilt/windows-x86_64/bin/make ^
-DANDROID_PLATFORM=android-28 ^
-DCMAKE_SYSTEM_NAME=Android ^
-DANDROID_ABI=x86 ^
-DCMAKE_BUILD_TYPE=Release ^
../3rd
cmake --build .
從上面可以看到,這些選項後面都跟著一個^
符號,這不是cmake的一部分,只是為了我們閱讀方便,特意書寫成這樣的,這是在Windows平臺上批次處理使用的命令換行符,它的作用就是告訴命令解析器,這個命令還沒有結束,接著往下面解析,該功能在Linux,MacOS上對應於\
。現在有了這些設定之後,該怎麼使用呢?其實也很簡單,只需要將這些命令儲存在android.bat
檔案中,在CMD中切換到當前目錄,執行這個檔案就能在build
目錄中找到以libsum.a
命名的靜態庫檔案了。下一步,我們試著用這個庫檔案執行在模擬器中。
在Android平臺中,也使用CMake來管理jni的專案,配合Gradle一起完成構建工作。這和普通的CMake專案最大的不同是,我們通常需要參照多個Android相關的庫,如log
,android
等.這些庫通常是由NDK提供的,我們仿照預設生成的CMakeLists.txt
檔案編寫就可。
接下來,為了描述方便,我們先來看一下現在的目錄結構(為了避免混亂,這裡只列出比較有代表性的檔案)
CMakeProject
│ android.bat
│ CMakeLists.txt
│ main.cpp
│
├─3rd
│ CMakeLists.txt
│ lib.cpp
│ lib.h
│
└─Android
│ build.gradle
│
├─app
│ │ build.gradle
│ │
│ ├─libs
│ └─src
│ ├─main
│ │ │ AndroidManifest.xml
│ │ │
│ │ ├─cpp
│ │ │ CMakeLists.txt
│ │ │ native-lib.cpp
│ │ │
│ │ ├─java
│ │ │ └─me
│ │ │ └─hongui
│ │ │ └─cmakesum
│ │ │ MainActivity.kt
│ │ │
│ │ ├─jniLibs
│ │ │ └─x86
│ │ │ libsum.a
在原來的目錄根目錄下新建了Android
子目錄,該目錄是一個Android C++工程,所以相比其他普通Android工程,它多了個cpp
目錄,後面我們主要的修改都是發生在該目錄下。
原來的根目錄,為了不增加複雜度,我們只作為生成靜態庫的功能存在,所以和上面的範例相比,沒有任何修改。
首先,我們回到根目錄。使用根目錄下的android.bat
批次處理生成Android上可用的靜態庫,也可以修改android.bat
檔案中的-DANDROID_ABI
選項的值,生成其他架構的靜態庫,但這需要和jniLibs
目錄下的目錄要一一對應,否則可能連結失敗。如我生成的libsum.a
檔案是x86
的架構。那麼就需要在jniLibs
目錄下新建x86
的目錄下,然後再把libsum.a
放到該目錄下。至此,靜態庫的構建工作就算結束了。
把靜態庫放到合適的位置後,我們需要設定app
目錄下的build.gradle
和cpp
目錄下的CMakeLists.txt
檔案,完成靜態庫的引入。
首先說build.gradle
,該檔案主要涉及到修改ABI的問題,因為不指定的話,Gradle預設生成的ABI可能找不到對應的靜態庫檔案來連結,從而導致連結失敗。該檔案主要的修改如下
android {
defaultConfig {
externalNativeBuild {
cmake {
cppFlags ""
abiFilters "x86"
}
}
}
}
也就是把abiFilters
的值指定為剛才構建的靜態庫相同的值。
而CMakeLists.txt
檔案就複雜一些了,它需要完成兩個工作,找到靜態庫和靜態庫的標頭檔案,連結靜態庫。
在文章的第二部分我們已經知道了讓CMake找到標頭檔案的include_directories
命令,把引數設定為3rd
目錄就行了。值得注意的是,CMake是以當前的CMakeLists.txt
檔案為工作目錄的,所以,要指定到3rd
檔案,我們需要一直回退目錄到根專案,最終就有了include_directories(../../../../../3rd)
這樣的設定。儘量使用相對路徑,可以在多人協同的情況下,不用修改設定。
下一步要讓CMake找到我們的靜態庫。說到庫,都是和add_library
相關的,不同的只是引數。使用原始碼新增庫的時候,我們需要指定庫的名稱和原始碼位置,而參照第三方庫,則是需要指定庫的名稱和型別,外加一個IMPORTED
的指示引數,告訴CMake這個庫是匯入的。所以就有了add_library(addSum STATIC IMPORTED)
這樣的設定。
但是,這裡我們只告訴了CMake庫的名字,庫儲存在哪裡,還不知道,所以我們還需要另一個命令告訴CMake庫的儲存位置。涉及到設定引數的,通常就是set_target_properties
命令了,可以多次呼叫這個命令設定多種設定。set_target_properties(addSum PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libsum.a)
,第一個引數和上一條的第一個引數是一一對應的,可以隨便取。其實add_library
相當於生成了一種目標產物,用第一個引數來指代這種產物,所以才讓我們的set_target_properties
找得到合適的目標設定屬性。第二個引數則是設定屬性的標準寫法,第三個代表屬性變數,第四個是屬性值,設定庫路徑的變數就是IMPORTED_LOCATION
,而值這裡就有個坑了,Android下的CMake限定值必須是絕對路徑,不能是相對路徑。而這與使用CMake的初衷背道而馳,幸好,我們有幾個預設值可以用,CMAKE_CURRENT_SOURCE_DIR
就是其中之一,它代表著當前這個CMakeLIsts.txt
檔案的絕對路徑,有了這個,再加上目錄的回退功能,我們就能找到任何合適的目錄了。至此,又出現了第二個問題,當有多個架構的靜態庫需要設定時,我們引入的目錄是不一樣的,而且會出現很多重複的設定。還好有ANDROID_ABI
的幫助,它指代了當前編譯的某個架構,隨著編譯的進行,這個值會被設定為合適的值,並且是和正在編譯的架構是一一對應的。所以,儘管它們有點奇怪,但是這給我帶來了靈活和簡單。
現在標頭檔案有了,庫也有了,但是C++的編譯是分成兩步的,目前為止,我們的工作只做完了編譯的事情,還沒涉及到連結的事情,當然,相比前面的設定,這就簡單多了,無疑就是在target_link_libraries
命令裡新增一個引數就可,如
target_link_libraries(
native-lib
${log-lib}
addSum
)
只需喲注意名字和add_library
時設定的名字一一對應就可。
經過漫長的等待,現在我們終於能在native-lib.cpp
檔案中引入addsum
的標頭檔案,並且使用裡面的函數完成工作了。我打算讓函數返回一個包含加法運算結果的字串。最終實現如下
#include <jni.h>
#include <string>
#include <lib.h>
extern "C" JNIEXPORT jstring JNICALL
Java_me_hongui_cmakesum_MainActivity_stringFromJNI(
JNIEnv* env,
jobject /* this */) {
std::string hello = std::to_string(sum(1,1));
return env->NewStringUTF(hello.c_str());
}
至此,點選工具列上的run
按鈕,我們終於可以在Android的模擬器上看到我們的靜態庫工作的成果啦。
其實除了參照靜態庫的方式之外,我們還可以直接通過設定CMakeLists.txt
檔案來參照原始碼,這樣可以隨時隨地對原始碼進行客製化,但是也降低了編譯速度,而且可能會增加CMakeLists.txt
的複雜度。所以我還是推薦直接使用靜態庫的方式。
CMake其實還有很多很多命令,我們這裡涉及到的只是很少的一部分。但是,我覺得理解CMake有這些內容差不多就可以了,後續有需要再針對性學習就行了。學習一門技術,切忌不能貪多,貪細。先要抓住主幹,理清脈絡,後面的細節就是水到渠成的事。對於CMake,我覺得就是以C++程式碼編譯為二進位制的過程為主幹就夠了。原始碼從哪裡來,標頭檔案在那裡,庫檔案在哪裡,怎麼組織編譯,參與連結的庫有哪些,生成什麼產物,還有一些完成這些工作的通用操作,複製檔案啊,目錄資訊啊等,這些操作的集合就構成了CMake的主體。另外,CMake其實只是一種構建工具,它本身不是編譯器和連結器,有些問題可能不僅僅會涉及到cmake,還可能會涉及到編譯器和聯結器。當然,這些都是後面深入瞭解之後才可能碰到的問題了。