程序員C語言快速上手——工程篇(十三)

C語言工程構建

爲什麼需要編譯腳本?

當C語言工程很大,源碼非常多時,如果還去使用GCC命令編譯程序,幾乎是不現實的。這時候,可以通過編寫shell腳本去執行編譯命令,當然這並不是一種好的方式。在Linux上我們可以寫shell腳本,在Windows上則可以編寫bat腳本

本篇以如下源碼作爲示例工程,需要編譯一個main.exe程序出來

add.c

int add(int a, int b){
    return a+b;
}

sub.c

int sub(int a, int b){
    return a-b;
}

mul.c

int mul(int a, int b){
    return a*b;
}

div.c

int div(int a, int b){
    return a*b;
}

calc.h

int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
int div(int a, int b);

main.c

#include <stdio.h>
#include "calc.h"

int main(){
    printf("1+2=%d\n",add(1,2));
    printf("18-9=%d\n",sub(18,9));
    return 0;
}

shell腳本(bat腳本)

由於在Windows平臺,使用MinGW環境,這裏編寫的是bat腳本,創建一個名爲build的文件(文件名任意),修改其擴展名爲build.bat,使用文本編輯器編輯該文件(Linux平臺上,則保存擴展名build.sh

gcc add.c sub.c mul.c div.c main.c -o main.exe

可以看到,只需要執行build.bat就能編譯生成main.exe,這比每次手敲命令方便太多了。如果有多個源碼文件,只需要寫入腳本中,通過執行腳本完成編譯。

Makefile 腳本

Makefile 腳本文件是GNU make 工具的輸入文件,它也包含一套自己的語法規則,它也能幫助C語言實現編譯和鏈接。既然可以通過命令行腳本(shell)完成編譯工作,爲什麼還需要Makefile腳本文件呢?

雖然命令行腳本也能幫助編譯鏈接,但是它的能力還太弱,它每次都會將所有文件重新編譯,例如有幾百個源文件,我僅僅只修改了其中一個源文件,那麼重新編譯時,這幾百個源文件也都會重新編譯,這樣每次編譯一下都會耗費大量時間。而make 工具會自動根據修改情況完成源文件的對應.o文件的更新、庫文件的更新以及最終的可執行程序的更新,它實際上是通過比較對應文件的最後修改時間,來決定哪些文件需要更新、那些文件不需要更新。

現在將命令行腳本改寫爲Makefile腳本,在源碼目錄下創建一個名爲Makefile的文件(亦可以寫作makefile),注意,它沒有拓展名,編輯如下內容:

# 編譯一個main.exe 程序
main.exe: main.o add.o sub.o mul.o div.o
	gcc main.o add.o sub.o mul.o -o main.exe

main.o: main.c calc.h
	gcc -c main.c
	
add.o: add.c
	gcc -c add.c

sub.o: sub.c
	gcc -c sub.c

mul.o: mul.c
	gcc -c mul.c

div.o: div.c
	gcc -c div.c

# 僞目標,刪除所有.o文件
clean:
	rm *.o

cd到當前目錄,執行輸入make命令,即可快速編譯生成main.exe程序,當我們需要清理整個工程時,即全部重新編譯時,可以輸入make clean命令,即可刪除當前目錄下的所有.o文件。

基本語法規則

注意,#號開頭的行表示註釋

語法結構如下

target1 target2 target3...: prerequisite1 prerequisite2 prerequisite3...
	command1
	command2
	command3
  • target
    表示目標。通常有三種情況:可以是一個目標文件(.o文件);可以是一個可執行文件;可以是一個標籤,標籤被稱爲僞目標

  • prerequisite
    表示條件。實際上表達的是一種依賴關係,即要生成前面的target,所需要依賴的文件或是另一個目標

  • command
    表示需要執行的命令。即要生成這個目標,對應執行的命令

需要注意,在冒號的左邊,可以是一個或多個目標,而在冒號的右邊,則可以是零個或多個依賴條件。目標頂格寫,而command前面則必須有一個製表符(即Tab鍵)

要想寫Makefile文件,必須對C語言的編譯鏈接階段有基本的瞭解,總的來說,就是將.c源碼文件編譯爲.o目標文件,然後將.o文件鏈接爲可執行程序,而Makefile腳本正是將這個依賴關係反過來描述,即一個可執行程序需要依賴哪些.o文件,每一個.o文件又依賴於哪些.c.h文件。

簡化版本

除了上面那種標準版本,我們還可以利用make工具的自動推導能力,省略對目標文件的條件依賴描述,包括編譯命令。

# 編譯一個main.exe 程序
main.exe: main.o add.o sub.o mul.o div.o
	gcc main.o add.o sub.o mul.o -o main.exe

main.o: calc.h
add.o:
sub.o: 
mul.o:
div.o:

# 僞目標,刪除所有.o文件和可執行文件
clean:
	rm *.o main.exe

另一種風格

# 編譯一個main.exe 程序
main.exe: main.o add.o sub.o mul.o div.o
	gcc main.o add.o sub.o mul.o -o main.exe

main.o: calc.h

# 另一種風格,寫在同一行
add.o sub.o mul.o div.o:

# 僞目標,刪除所有.o文件和可執行文件
clean:
	rm *.o main.exe

在make工具中,它能夠自動完成對.c文件的編譯並生成對應的.o文件。它默認執行命令cc -c來編譯.c源文件,以main.o爲例,它會默認執行cc -c main.c -o main.o。但是要注意,我們如果在Windows上執行以上簡化版的make,則會報錯,這是因爲在Linux系統中,cc命令會默認的鏈接到gcc命令上,執行cc命令就是在執行gcc命令,而我們Windows系統中是沒有cc命令的。解決辦法非常簡單粗暴,就是進入gcc.exe所在目錄,將gcc.exe再複製一份,並更名爲cc.exe即可。

僞目標
僞目標就是一個標籤,它本身既不是目標文件也不是可執行文件,例如上面例子中的clean,我們可以通過僞目標定義一些命令,然後在make中去執行。

上面例子中的僞目標在定義上存在一些問題,假如源碼目錄下真的存在一個名爲clean的文件,則會與當前的僞目標衝突。將一個目標聲明爲僞目標需要將它作爲特殊目標.PHONY的依賴,這樣定義的僞目標就不會和源碼目錄下的文件名衝突。

正確的定義僞目標

.PHONY: clean
clean:
	rm *.o main.exe

再看一個例子

# 定義一個僞目標print,它執行命令行的echo命令輸出hello,world
.PHONY: print
print:
	echo "hello,world"

然後在命令行執行make print,就會輸入出被執行的完整命令,以及命令執行的結果

我們可以根據自己的需要在Makefile中定義自己的僞目標,通常會定義cleaninstall這些僞目標,install一般定義拷貝命令,將生成的可執行程序拷貝到應用安裝目錄下。在Linux平臺下,通常是將C語言的源代碼和Makefile腳本一同發佈出去,用戶只需要在源碼目錄下分別執行命令makemake install即完成了程序的編譯和安裝,可以看到,有了make工具後,讓開源的C程序的編譯使用過程變得非常簡單。

補充說明

實際上完整的Makefile 語法體系是非常複雜靈活的,學習完整Makefile語法不亞於學習一門新的編程語言,而且許多語法功能並不是常用的,另一方面,在大型的複雜工程中,自己手寫Makefile是極爲不明智的選擇。make工具是一個比較古老的工具,已經有一些工具可以幫助我們自動生成Makefile文件,例如Linux上的Autoconf,當然,現在更好的工具是cmake,它可以自動生成跨平臺編譯腳本,而且還能用於Android端的NDK開發,是最被推薦的構建工具。

CMake工具

它首先允許開發者編寫一種平臺無關的 CMakeLists.txt 文件來定製整個編譯流程,然後再根據目標用戶的平臺進一步生成所需的本地化 Makefile 或工程文件,如Linux 下的 Makefile文件 或 Windows 的 Visual Studio 工程文件。

簡單說,以前我們編寫的C語言編譯腳本是不能跨平臺編譯的,例如上面示例中編寫的 Makefile ,它只能在GCC環境下編譯,通常是Linux系統上,而在Windows下的Visual Studio裏面就沒法用,得重新改造,如果是一個大型項目,那就是災難。現在我們用CMake工具編寫構建腳本,就與平臺無關了,它會自動生成對應平臺的構建方案,再也不用程序員去操心了。更準確的說,CMake工具真正厲害的地方並不只是跨平臺,而是跨編譯環境。

安裝

進入cmake官網下載頁 下載zip包或安裝器,安裝後,將cmake的bin目錄加入PATH環境變量中,命令行輸入cmake --version檢查環境是否配置成功

簡單示例

以上面的代碼爲例,在源碼目錄下創建 CMakeLists.txt 文件

# CMake最低版本號要求
cmake_minimum_required (VERSION 2.8)
# 配置項目名
project (ch1)
# 指定生成目標,main2爲生成的可執行程序名,後面是源碼列表
add_executable (main2 add.c sub.c mul.c div.c main.c)

當前面目錄下執行以下命令,注意.不能掉

cmake .

在我們的目錄下自動生成了一個 Visual Studio 工程,因爲我本地安裝了Visual Studio開發環境。可以雙擊打開ch1.sln文件或main2.vcxproj文件,這裏會打開Visual Studio IDE,就能直接在IDE裏面編譯了。

這裏,如果我想生成MinGW開發環境的Makefile,則只需要加一個-G參數,來指定一個明確的編譯環境,從而生成對應的構建腳本。

cmake -G "MinGW Makefiles"

要注意,以上命令直接在CMD命令行執行可能會報錯,它需要一個sh環境,這裏有兩種解決辦法

  • sh.exe所在目錄加入到環境變量中,它位於MinGW根目錄下的git\bin下,修改環境變量後,打開新的命令行窗口然後再執行以上命令
  • 第二種就是偷懶的做法,如果你本地安裝了git工具,則直接鼠標右鍵,選擇Git Bash Here打開一個bash來執行以上命令

命令執行完畢,本地目錄下就會自動生成一個Makefile文件,然後執行make命令即可編譯。我們如果打開這個Makefile文件,會發現看不懂,裏面內容比較複雜。

到這裏我們已經學會了cmake構建的簡單流程,接下來只需要學習一下 CMakeList.txt文件的編寫規則

基礎規則

CMakeLists.txt文件由命令、註釋和空格組成,其中命令是不區分大小寫的#開頭的行表示註釋。命令由命令名稱、小括號和參數組成,參數之間使用空格進行間隔。例如add_executable (main2 add.c sub.c mul.c div.c main.c)

外部構建

在上面的示例中,執行cmake命令會在源碼工程的目錄下生成很多無法自動刪除的中間文件或臨時文件,這就弄亂了源碼工程的目錄,如果要發佈源碼,還得手動一個個去刪除這些文件,這顯然不是一種好的構建方式,這種方式被稱爲內部構建,相應的,我們需要使用外部構建的方式來解決問題。

在源碼工程的根目錄下創建一個build文件夾,然後在命令行裏cdbuild下,執行cmake ..
cmake -G "MinGW Makefiles" ..命令,此時會將所有的中間文件生成到build目錄中,包括Makefile,然後執行make編譯。當我們需要刪除臨時文件時,只需要刪除build目錄即可,不會對源碼工程造成任何影響。

定義變量

源文件較多時,可以定義一個變量來保存,後續只需要引用該變量即可,如下,定義src_list來保存源文件列表,引用是使用${}包裹.

定義變量使用set命令,取消命令可使用unset命令

# 定義變量 src_list
set (src_list add.c sub.c mul.c div.c main.c)
# 打印日誌
message (STATUS "源文件列表:${src_list}")

# 引用變量
add_executable (main2 ${src_list})

message命令是用來打印日誌的,它的第一個參數是mode,可省略,常用值如下

mode 簡述
(none) 重要信息
STATUS 附帶消息
WARNING CMake警告,繼續處理
AUTHOR_WARNING CMake警告(dev),繼續處理
SEND_ERROR CMake錯誤,繼續處理,但會跳過生成
FATAL_ERROR CMake錯誤,停止處理和生成

內置變量

cmake中已經內置了一些變量,我們可以直接使用,也可使用set命令去修改

  • CMAKE_SOURCE_DIRPROJECT_SOURCE_DIR 表示工程的根目錄
  • CMAKE_BINARY_DIRPROJECT_BINARY_DIR 表示編譯目錄。如果是內部構建,則編譯目錄與工程根目錄相同,如果是外部構建,則表示外部構建創建的編譯目錄,如上例中的build目錄
  • CMAKE_CURRENT_SOURCE_DIR 表示當前處理的CMakeLists.txt所在文件夾的路徑
  • CMAKE_CURRENT_LIST_FILE 當前CMakeLists.txt文件的完整路徑
  • CMAKE_C_COMPILERCMAKE_CXX_COMPILER 分別表示C和C++編譯器的路徑
  • PROJECT_NAME 該變量可獲取project命令配置的項目名

可以使用message命令打印這些內置變量的值

cmake_minimum_required (VERSION 2.8)

project (ch1)

message (${CMAKE_SOURCE_DIR})
message (${PROJECT_SOURCE_DIR})
message (${CMAKE_BINARY_DIR})
message (${PROJECT_BINARY_DIR})
message (${CMAKE_CURRENT_SOURCE_DIR})
message (${CMAKE_CURRENT_LIST_FILE})
message (${CMAKE_C_COMPILER})
message (${CMAKE_CXX_COMPILER})
message (${PROJECT_NAME})
  • EXECUTABLE_OUTPUT_PATH 設置該變量可修改可執行程序的生成路徑
  • LIBRARY_OUTPUT_PATH 設置該變量可修改庫文件生成路徑
# build/bin/
SET(EXECUTABLE_OUTPUT_PATH ${PROJECT_BINARY_DIR}/bin)
# build/lib/
SET(LIBRARY_OUTPUT_PATH ${PROJECT_BINARY_DIR}/lib)
  • BUILD_SHARED_LIBS 指定默認生成的庫的類型

命令

CMakeLists.txt文件基本上就是由命令和參數組成的,例如之前的setmessage這些,下面就瞭解一下常用的命令

  • add_executable
    使用給定的源文件,生成一個可執行程序
  • add_library
    使用給定的源文件,生成一個庫(靜態庫或共享庫)
  • add_subdirectory
    添加一個子目錄,該子目錄也必須包含一個CMakeLists.txt文件
  • include_directories
    添加頭文件路徑
  • add_definitions
    添加編譯參數
  • target_link_libraries
    鏈接指定的庫
  • find_library
    查找指定的庫,並將庫文件路徑保存到一個變量
  • set_target_properties
    設置目標的一些屬性,從而改變構建方式
  • link_directories
    添加庫的搜索路徑
  • aux_source_directory
    查找指定路徑下的所有源文件

綜合實例
調整上面示例工程的結構,在工程跟目錄下創建四個文件夾,分別是buildcalcincludesrc,具體工程結構如下所示

  ch1
    |
    +--- build/
    |
    +--- calc/
          |
          +--- add.c
          |
          +--- div.c
          |
          +--- mul.c
          |
          +--- sub.c
          |
          +--- CMakeLists.txt
    |
    +--- include/
          |
          +--- calc.h
    |
    +--- src/
          |
          +--- main.c
    |
    +--- CMakeLists.txt

calc目錄作爲一個子項目,用於編譯一個libcalc.a靜態庫,主工程源碼在src下,且需鏈接靜態庫。

子項目calc下需要一個CMakeLists.txt文件,內容如下

cmake_minimum_required (VERSION 2.8)
# 創建靜態庫calc,其生成的文件名爲libcalc.a
add_library (calc STATIC add.c sub.c mul.c div.c)

工程根目錄下也需要CMakeLists.txt文件,內容如下

cmake_minimum_required (VERSION 2.8)
# 配置項目名
project (ch1)

# 添加一個子文件夾 calc,這裏寫的相對路徑
add_subdirectory (calc)

# 定義變量 SRCS_DIR, 指向src目錄的絕對路徑
set (SRCS_DIR "${PROJECT_SOURCE_DIR}/src")

# 添加頭文件目錄,即添加工程根目錄下的include目錄
include_directories ("${PROJECT_SOURCE_DIR}/include")

# 添加庫的搜索路徑,即libcalc.a所在的目錄(build/calc/libcalc.a)
link_directories ("${PROJECT_BINARY_DIR}/calc")

# 用於生成可執行文件 main.exe
add_executable (main "${SRCS_DIR}/main.c")

# 爲main程序指定鏈接靜態庫calc
target_link_libraries(main calc)

首先執行cmake -G "MinGW Makefiles" ..命令自動生成Makefile文件,然後執行make命令進行編譯,完成後build目錄下即生成main.exe

當鏈接已經編譯好的庫時,推薦使用find_library來查找庫,因爲link_directories命令傳入相對路徑時,會直接將相對路徑傳給編譯器,導致出現找不到問題。

find_library命令原型如下,第一個參數爲變量,第二個參數爲庫名稱,最後面可以填入多個路徑
find_library(<VAR> name1 [path1 path2 ...])

# 在指定的目錄下查找名爲calc的庫,
# 並將庫文件的絕對路徑保存到變量STATIC_LIB中
find_library(STATIC_LIB calc "${PROJECT_BINARY_DIR}/calc")
message (${STATIC_LIB})

# 爲main程序指定鏈接靜態庫calc
target_link_libraries(main ${STATIC_LIB})

靜態庫與動態庫
使用add_library命令默認生成靜態庫,如add_library (calc add.c sub.c mul.c div.c),亦可加上參數STATIC顯式指定,如需生成動態庫,則添加參數SHARED,如add_library (calc SHARED add.c sub.c mul.c div.c),此外,還可以通過設置變量BUILD_SHARED_LIBS來修改默認行爲,當該變量爲真時,默認會生成動態庫,如

# 使用option命令定義選項
option(BUILD_SHARED_LIBS "build shared or static libraries" ON)

自動獲取源碼列表
當我們工程的源碼非常多時,一個個去手寫源碼列表是非常麻煩的,以上述calc目錄下的CMakeLists.txt文件爲例,這時可以使用aux_source_directory命令

cmake_minimum_required (VERSION 2.8)
# 獲取當前目錄下的源文件路徑列表,並保存到變量SRC_LIST中
aux_source_directory (. SRC_LIST)

# 打印
message (STATUS ${SRC_LIST})
add_library (calc STATIC ${SRC_LIST})

該命令原型如下,第一個參數爲搜索的路徑,第二個參數爲變量

aux_source_directory(<dir> <variable>)

這個命令只能識別源碼文件,不能識別其他文件,比如.h文件就不能掃描出來,因此存在一定缺陷,想知道能識別哪些拓展名的源文件,可打印兩個內置變量獲取

message (STATUS ${CMAKE_C_SOURCE_FILE_EXTENSIONS})
message (STATUS ${CMAKE_CXX_SOURCE_FILE_EXTENSIONS})

遞歸獲取文件列表
aux_source_directory命令只能獲取源碼文件列表,且無法遞歸獲取給定路徑下的嵌套子文件夾下的各種源文件,這時可以使用file命令,結合GLOB_RECURSE參數,對指定的文件拓展名進行遞歸獲取。

# 遞歸遍歷當前目錄下的所有.c .cpp後綴名的文件,並將結果列表保存到SRC_LIST變量中
FILE(GLOB_RECURSE SRC_LIST *.c *.cpp)
# 打印
message (STATUS ${SRC_LIST})
add_library (calc STATIC ${SRC_LIST})

原型如下

file(GLOB_RECURSE 
     variable 
     [RELATIVE path] 
     [FOLLOW_SYMLINKS] 
     [globbing expressions]...)

如不需遞歸,可將GLOB_RECURSE改爲GLOB

指定庫的輸出名稱

add_library (calc STATIC ${SRC_LIST})
# 將生成 libcalculate.a
set_target_properties(calc PROPERTIES OUTPUT_NAME "calculate")

定義宏與條件編譯
可使用add_definitions命令,傳入-D加上宏名稱來定義宏,以下定義宏USER_PRO

# 定義宏 USER_PRO
add_definitions(-DUSER_PRO)

# 等價於 #define VER 1 、#define Foo 2
add_definitions(-DVER=1 -DFoo=2)

配合使用option命令,實現條件編譯

project(test)

option(USER_PRO "option for user" OFF)
if (USER_PRO)
add_definitions(-DUSER_PRO)
endif()

option命令原型:

 option(<option_variable> "描述選項的幫助性文字" [initial value])

add_definitions命令主要用來添加編譯參數,add_compile_options命令也具有相同的功能,示例如下

add_compile_options(-std=c99 -Wall)
add_definitions(-std=c99 -Wall)

指定構建環境

前面已經學會了-G參數指定構建環境,那麼到底可以指定哪些構建環境呢?這裏根據官方文檔,整理一下-G後面可以跟哪些值。

生成 Makefile文件

以下是不同環境下的Makefile文件

  • Borland Makefiles
  • MSYS Makefiles
  • MinGW Makefiles
  • NMake Makefiles
  • NMake Makefiles JOM
  • Unix Makefiles
  • Watcom WMake

生成 Visual Studio工程

  • Visual Studio 6
  • Visual Studio 7
  • Visual Studio 7 .NET 2003
  • Visual Studio 8 2005
  • Visual Studio 9 2008
  • Visual Studio 10 2010
  • Visual Studio 11 2012
  • Visual Studio 12 2013
  • Visual Studio 14 2015
  • Visual Studio 15 2017
  • Visual Studio 16 2019

其他環境

  • Green Hills MULTI
  • Xcode
  • CodeBlocks
  • CodeLite
  • Eclipse CDT4
  • Kate
  • Sublime Text 2

補充

  • Ninja

這裏重點說一下Ninja,當前的官方文檔中沒有寫Ninja,實際上CMake從2.8.9版本開始可以支持Ninja構建

Ninja 是一個注重速度的小型構建系統。它與其他構建系統在兩個主要方面不同:它被設計爲使其輸入文件由更高級別的構建系統生成,並且被設計爲儘可能快地運行構建。

簡單說,它被設計出來是爲了替代make工具以及Makefile文件的,它與make工具的顯著區別是,Makefile是設計出來給人手寫的,而Ninjabuild.ninja設計出來是給其它程序生成的。Makefile是一個DSL,Ninja則只是一種配置文件。 Makefile支持分支、循環等流程控制,而Ninja僅支持一些固定形式的配置。

兩者的對應關係
ninja對應makebuild.ninja文件對應於Makefile文件

安裝
下載鏈接 下載對應版本的ninja工具,解壓後配置PATH環境變量,輸入ninja --version檢查環境

生成 build.ninja文件

 cmake -G "Ninja" ..

編譯

ninja

歡迎關注我的公衆號:編程之路從0到1

編程之路從0到1

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章