C++ 自分用新手筆記2 : CMake基礎 I — 源代碼加頭文件、無外置庫編譯


#引言

    CMake 是學習C++的一大課題,C++與其他高階語言的最大差異不在於Syntax,而在於編譯方式。
現今主流的程式語言,無論是編譯語言還是直譯語言,運行的過程都十分簡單,例如C#的編譯只需要一句dotnet build便完成了編譯,而python也只需要一句python main.py就搞定了。

    而相反的C++的編譯步驟卻十分繁複,首先要把源代碼都編譯一遍,然後再把編譯出來的物件再連結至執行檔,才可以完成完成編譯,一個比較複雜的專案一次編譯的指令可能都要用到上百個字以上。而為了方便處理這些複雜編譯過程Make出現了,為了方便生成Make檔CMake出現了。

      儘管現在也有像Scon、XMake等工具,但CMake仍然有着一定程度的領導地位,大多數的開源專案也在使用CMake來作為主要的編譯工具。

#編譯器使用—專案結構

    首先,在學習CMake前,在最小程度下認識C++一般編譯器的操作方式對了解CMake的使用有十分的關鍵助力·

 


上圖是一個簡單的示範用專案的資料夾結構,在根資料夾下有源代碼(src)資料夾以及頭文件(headers)資料夾,再加上一個用以避免源碼污染的編譯用(bin)資料夾。

而src中分別有兩個源代碼檔,main.cpp以及foo.cpp。
因為main.cpp並不需要頭文件,因此headers中只有一個foo.hpp,為foo.cpp的頭文件。
(要留意資料夾結構沒有絕對的正解,自己/團隊習慣便可)

 

上圖是各個檔案的依賴關係。

 

main.cpp
main.cpp包括(include)foo.hpp,其用途為程式碼的Entry Point,
在代碼的最後會列印foo.hpp內函數的運算結果。

foo.cpp
foo.cpp包括了foo.hpp,而foo.cpp的用途為定義add_two_numbers函數內容。
foo.cpp以及main.cpp都不被任何檔案包括。(正常情況下.cpp的源代碼檔都不會被include)


 
foo.hpp
foo.hpp為foo.cpp的頭文件,其用途為定義add_two_numbers函數。
在文件最上處會有加上#progma once的標識。

 

 

 

有了一個專案後,便可以開始嘗試把它編譯成可執行檔。

#編譯器使用—編譯指令

    在此主要使用Windows 10平台上的Mingw64 g++ 13.2.0來進行編譯,但就算使用的是其他編譯器用法上也不會有太大的差異。

第一步,用cd bin進入bin資料夾,以避免編譯過程中生成的檔案污染到根資料夾以及源代碼,使資料夾內容太過雜亂。

 


 

第二步,用g++ -c 指令把main.cpp以及foo.cpp等源代碼檔案編案編譯成.o檔案。
( 請留意只有源代碼檔案需要編譯,頭文件則並不需要 )


等三步,用g++ 指令把.o檔案組合成可執行檔。

最後,運行可執行檔便並檢查結果。


 

以上便是在不使用CMake的前提下進行多文件C++編譯的步驟,我相信大多數人初學者都已經從入門到放棄了,當然上述的步驟可以簡化成一個較長的g++指令,但別忘了以上範例也只有兩個源代碼文件罷了,而且也沒使用任何庫(Library),如果是一個由幾十個源代碼文件,外加幾個庫組成的專案,其編譯過程能有多複雜,簡直難以想像。

至於為什麼要介紹這個比較繁複的方編譯方式,而不是用單一句指令的方式呢?
原因在於這種編譯更符合C++代碼編譯的過程與邏輯,對入門者了解C++編譯更有幫助。

上圖是一個圖文版的編譯過程解釋,總結而言,

1. 把一組頭文件和源代碼視為一個「物件」,並把每一個「物件」都組合並編譯成.o檔案。
2. 再把各個「物件」組合成一個程式。

無論你使用的哪種句式的指令來編譯都不會離這個邏輯太遠。
#CMake使用—專案結構
    而為了把其過程自動化(不是簡化)就需要使用CMake來協助編譯。
為了方便編譯,通常開發者會在根資料夾加入一個build資料夾,用途與上述手動編譯過程中的bin資料夾相同,名字不重要,目的只是為了防止CMake生成的檔案污染源代碼。
還需要加入一個CMakeLists.txt的文件檔,用以編寫CMake的指令,普通的文字檔,名字必需是CMakeLists.txt,如果無法執行的話可以留意一下是否有輸入錯誤。
新的資料夾結構如下 : 
#CMake使用—CMakeLists.txt編寫(基礎)

    利用CMake來編譯C++代碼背後的邏輯與直接使用編譯器基本是相同的。差異主要在使用CMake來進行編譯時需要一個CMakeLists.txt來命令CMake去操作編譯器以完成上述的編譯步驟,其實可以把CMake理解成為了編譯C++代碼而出現的程式語言。
#add_executable(project_name src_files_path)
首先,左圖是一個最最最簡單的CMakeLists寫法,add_executable的目的是把src\main.cpp與src\foo.cpp組合成名為main的可執行檔,main只是可執檔的名字,可以隨意修改。
而如果有更多文件需要編譯的話,只需要在括號內追加更多文件路徑便可。
(同樣的頭文件檔的內容已經被源代碼檔include了,因此並不需要加入至被編譯的對象)


                                                            
#CMake重要入門HINT : 
圖中的內容就已經是CMakeLists.txt的全部內容,一句,不需要更多。
在網路上找到的CMakeLists.txt的範例都非常複雜,又要定義版本,又要定義編譯器,
對新手而言十分不友善,但其實那些都是非必要的,對入門者而言先以圖中那樣簡單的編寫
方式,等到更熟識CMake的編寫後再慢慢加上版本等語句會更好。

 

 

 

 

 

 

 

#CMake終端機/cmd操作

接着,在cmd中用cd進入build資料夾以避免源代碼污染,
再用cmake .. 來讀取根資料夾的CMakeLists.txt,完成後會生成大量的CMake/Make檔案,
這也是為什麼cmake的指令需要在一個獨立的資料夾中進行,如果cmake的執行是在根資料夾的話,根資料夾會變得十分雜亂。
最後,用cmake –build . 來組合剛生成出來的CMake/Make檔案,便能編譯出最終的可執行檔。


#CMake使用—CMakeLists.txt編寫(稍微進階)
雖然CMakeLists.txt中只用一句add_executable就已經可以代替直接使用編譯器那些比較繁瑣的指令,但同樣的,如果專案有更多的文件不就要在add_excecutable中不停加入新的路徑嗎?
因此,使用add_executable之前我們還需要更多的指令來自動化CMakeLists.txt。
在新的CMakeLists.txt中,我們加入三種的CMake指令。
分別是 : 
    1. set(Var_Name Value)
            set()在CMake中的的用途為定義一個變數,和多數程式語言中的變數聲明相同,
            在括號的左方為變數的名字,右方為變數的值。
            使用該的變數的方式是${Var_Name},由$到}的整個字串會被直接替換成該變數的值。
    2. file(GLOB_RECURSE Var_Name “path1/*.ext” “path2/*.ext”)
file()是個CMake中常用的指令,但請留意雖然file()看起來十分像現今主流的程式語言中常見的函數(Function)或方法(Method),但在CMake的世界中,file()在內CMake中的不少指令其實更像一個類別(Class),一個類別中會有數個函數在其中,而選擇使用不同函數的方式是修改file()中第一個argument,不同的輸入會有不同的return、效果、其他arguments的需求,例如範例中的第一個argument為GLOB_RECURSE,其結構便會是file(GLOB_RECURSE […] …),如果第一個argument為MAKE_DIRECTORY,其結構又會變成file(MAKE_DIRECTORY …),而且會少去了賦值的效果。
而範例中的file(GLOB_RECURSE)的用途是把提供的路徑中所有合符條件的檔案之絕對路徑,並以 ; 為分界連成一個字串,並賦值予第二個argument的變數。
 
    3. message(“message text”)
message()的用途為在生成CMake/Make檔案的過程中於終端機或cmd中輸出括號中的文字內容。
 
當我們有了${ProjectName}以及${SourceFilesList}兩個變數後,便可以用以取代add_executable()中的可執行檔名稱以及而編譯的對象列表,之後我們便不需要在增加新的源代碼文件時再修改add_executable()中的arguments。

#CMake終端機/cmd操作 ii

接下來的需要的操作如之前的編譯方式相同便可,
唯有的差異在於第二句的cmake .. 加上–fresh的標識,
用以刷新先前已生成過的CMake/Make檔案,
以避免編譯所需的Cache更新失敗。
 
 
 

 

#結語
 
自此,CMakeLists.txt的編寫入門便大概如此,上文用到的幾句基本上已經大概夠一個小型的初學者專案使用,當然CMake的博大精深絕不止如此,接下來還有如何加入外置庫、加入外置頭文件、如何處理多平台的編譯等等的問題要處理,但那些內容以後再慢慢分享。
 
#Takeaway
 
    g++ : 
    g++ -c 用來編譯源代碼(.cpp)至.o檔
    g++ 沒有-c的話便會直接編譯成.exe檔
 
    CMakeLists.txt : 
    set(Var_Name Value) 用來定義變數
    ${Var_Name} 是使用變數的方式
    file(GLOB_RECURSE Var_Name “path1/*.ext” “path2/*.ext”) 用來自動取得文件路徑的字串
    add_executable() 用來指定哪些檔案會被組合編譯成.exe檔
    CMake
    cmake –fresh 用來讀取CMakeLists.txt的內容並準備編譯,會生成大量文件。
    cmake –build 用來進行編譯
#額外概念—頭文件
 
對入門者而言,頭文件的存在應該是非常難以理解的,畢竟入門者會接觸到的其他高階程式語言幾乎都不太有相同概念存在,也不太能分清什麼東西該寫進頭文件,什麼東西該寫進源文件。
其實C++也並不一定要用到頭文件,也不用把每一段代碼分開編譯成.o檔案,再組合在一起。
如果你每一個被include的文件都有加上#pragma once以避免重複被編譯,並且把原本應該分成.cpp以及.hpp的文件合在同一個文件中編寫,你也可以把C++中的include當成python中的import使用。
當你每需要特定的一段代碼或Function,就include它,那你也只需要編譯Entry Point的文件便可以了,以上文的範例來說,如果main.cpp直接include foo.cpp的話,並且foo.cpp的最上加上#pragma once,最後只需要用g++ main.cpp -o main.exe就可以完成編譯了。
但之所以大多專案都不會用這種方式來編寫C++的原因在於編譯時間太長了。當每個.cpp加.hpp的程式碼都已經編譯過一次,在修改某一檔案的內容後的第二次編譯時也只需要重新編譯被修改的部分再連結成可執行檔便可,但如果用把全部文件集合起來的編寫方式,那每次重新編譯就等同於要把所有每一段程式碼重跑一遍,在測試過程中會是多大災難。
說到這裡,頭文件的大概已真實身份應該也比較好理解了,頭文件單純只是來讓源代碼檔案了解有哪些新增的內容可以使用,類似於一個公告欄一樣,當頭文件被include之後源代碼裡的冒險者就可以到公告欄看有什麼任務可以接,但如果要進一步了解任務的內容就必須要找貼公告的依賴者談談,也就是要進行.o檔案之間的連結,因此在還未推進到Linking .o檔案的步驟前被include的頭文件是什麼內容都沒有的,真正使main.cpp能使用add_two_number函數的是後面的Linking而不是include,include頭文件只是為了讓第一步把.cpp編譯成.o檔案時不會被編譯器擋下來而已。

Published by