Compile & Build

一三學術長 AaW

  • Why we need to learn how to properly build our program
  • GCC & compiler pipeline
  • 專案結構與檔案拆分 
  • Makefile
  • CMake
  • 實作時間
  • 開發環境管理

Today's Topics

我開始備這門課的時間是瑞典時間半夜

精神狀態很差

有錯誤請幫我主動提出

Why Compile & Build

這種東西有什麼好教的 --- cjtsai

為什麼我想講這堂課

  • 你們暑訓、開學後第一步驟就是幫學弟妹安裝寫程式的環境
  • 對於初學者來說,環境在幹嘛其實很難好好理解
    • 又要安裝 mingw、msys2、vscode、code-runner
    • 要學一堆怪 g++ 指令,不然就要用醜死的 code::blocks 或 DevC++
  • 當擺脫純競賽的時候,程式一定會要連結其他函式庫,例如圖形化介面等等,這些東西很難安裝,也很難使用,更難編譯和執行
  • 完全靠 GPT 的話,當出事的時候,你找不到問題點在哪一個環節

這門課要教什麼

  • 我希望多說一點傳統 C 語言當中,不同的程式和函式庫是如何結合、編譯的
  • 希望帶給你們整理環境、專案目錄的核心原則
  • 講一些常見的 make 工具,讓你看 Github Readme 時知道在幹嘛
  • 好好的學會如何在 Linux 下開發程式,並且用對的工具整理環境
  • 學會並理解環境變數是啥
  • 理解 Windows 的開發環境有多垃圾 (但還是會教你們一點啦)

先來問問大家都怎麼撰寫、編譯 C++ 程式的

IDE

Editor           +           Compiler

  • DevC++
  • Code::blocks
  • Xcode
  • Visual Studio
  • Clion
  • NotePad++
  • vscode
  • sublime
  • Atom
  • Vim
  • Emacs
  • Word
  • gcc / g++
  • clang

OS

  • Windows
  • macOS
  • Linux
  • freeBSD

Header and Library

一般來說,我們在打競賽或是 APCS 寫的 C / C++ 程式,都只會有一個檔案

main.c

main.cpp

#include <stdio.h>

int main(void) {
    printf("Hello, World!\n");
    return 0;
}
#include <iostream>

int main() {
    std::cout << "Hello, World!" << std::endl;
    return 0;
}

有人可以出來完整解釋整個 hello world 嗎?

只有 main.c ,仍然不足以執行!

只有 main.c ,仍然不足以執行!

#include <stdio.h>

來看看這句

  • 這句引用了 stdio.h
  • 因為有引用 stdio.h,我們才有 printf 可以用
  • 在 stdio.h 裡面定義了 printf!

main.c

stdio.h

int printf(const char* restrict format, ...);
#include <stdio.h>

int main(void) {
    printf("Hello, World!\n");
    return 0;
}

只有 main.c ,仍然不足以執行!

#include <stdio.h>

來看看這句

  • 這句引用了 stdio.h
  • 因為有引用 stdio.h,我們才有 printf 可以用
  • 在 stdio.h 裡面定義了 printf!

main.c

int printf(const char* restrict format, ...);

int main(void) {
    printf("Hello, World!\n");
    return 0;
}

標頭檔 (header)

  • 副檔名 .h或是.hpp,幾乎沒有差別
  • 一個把所有提前宣告的東西一起寫完的地方
  • 用 #include 的方式把它 include 進來
  • 例如:
    • #include <iostream>
    • #include "person.h"
  • ​其中,用<>的話代表是系統標頭檔
    • ​例如電腦/usr/bin/include中會有一個iostream.h
  • ​用 "" 的則是你自己寫的標頭檔,要放在同一個目錄底下,或是用 gcc 的特殊參數 -I

但這樣直接編譯,「理論上」仍然不足以執行!

main.c

int printf(const char* restrict format, ...);

int main(void) {
    printf("Hello, World!\n");
    return 0;
}

為何?

誰幫你實作了 printf?

系統另一個地方寫好了 printf 的實作,變成一個 library !

換個例子

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

int main() {
    int a = 10;
    int b = 5;
    int c = add(a, b);
    return 0;
}

main.c

換個例子

#include "add.h"

int main() {
    int a = 10;
    int b = 5;
    int c = add(a, b);
    return 0;
}

main.c

add.h

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

換個例子

#include "add.h"

int main() {
    int a = 10;
    int b = 5;
    int c = add(a, b);
    return 0;
}

main.c

add.h

int add(int, int);

add.c

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

How to compile?

gcc main.c add.c

How to compile?

gcc main.c add.c

但是實務上不推薦這樣做

Why?

  • 一個改了之後要重新編譯多個檔案
  • 系統/別人開發好的函式庫,每次都要編譯會非常慢

So How?

  • 將原始碼先個別編譯好,之後直接連結!
  • 變成 library (函式庫)
  • 本身就是二進位模式,編譯時可以直接和object files 連結,以減少重新編譯時間

靜態vs動態函式庫

動態共享連結函式庫

(Shared library)

  • 在程式開始執行時才載入的
  • 具有三個優點
    1. 減少執行檔大小
    2. 更新函式庫時無須重新編譯其他程式
    3. 可在程式執行時修改函式庫
    4. 可以共用函式庫

靜態函式庫

(Static library)

  • 檔案名稱以 lib 開頭,而副檔名則為 .a。
  • 在程式變為執行檔時便載入完成
  • 具有三個優點
    • 較高的執行速度
    • 只要保證使用者有程式對應的函式庫,便能執行
    • 避免因為程式找不到.dll而無法執行(dll地獄)

Compile pipeline

我不會編譯,反正 atom 亂按一通就好 --- RepKiroNcA

沒有說過

我們都會編譯,但你真的知道編譯是什麼嗎

  • 編譯是將高級程式語言寫成的原始碼轉換為低階語言(如機器碼)的過程
  • 我們平常認為的「編譯」其實不只是包含編譯本身
  • 編譯器四大步驟:
    • 預處理 (Preprocess)
    • 編譯 (compile)
    • 組譯 (assemble)
    • 連結 (link)

1. 預處理 (Preprocessing)

  • 展開巨集(#define
  • 處理條件編譯(#ifdef
  • #include 的頭檔內容直接插入來源檔。
  • 目標:形成單一完整的檔案,以利後續進行

指令:

gcc -E main.c -o main.i

# 新增 include 檔案搜尋路徑 (#include "header.h" 這種)
gcc -E main.c -o main.i -I ./path/to/include

# 定義 Macro
gcc -E main.c -o main.i -DHAHA # 相當於在程式裡面加上 #define HAHA

2. 編譯 (Compile)

  • 將 c 語言檔案轉為組合語言
  • 包含眾多步驟:Parsing、最佳化、等等
  • -std=<c89|c99|c11|c++98|c++11|c++17> 可以指定語言標準

指令:

gcc -S main.c -o main.s
gcc -S main.c -o main.s -g  # -g 代表產生除錯符號
gcc -S main.c -o main.s -O2 # -O1 -O2 -O3 分別為不同優化,-O0 不優化

3. 組譯 (Assembling)

  • 把組語(.s)轉成「目標檔」(object file,通常是 .o 或 .obj),也就是二進位的機器碼塊。

  • object file 的檔案格式為 「elf file」

指令:

gcc -c main.c -o main.o  # from .c

as main.s -o main.o      # from .s

3.5 封裝 (Archieving)

  • 目標:製作靜態函式庫

  • 在 Unix/Linux 下最常用的工具是 ar,可以把多個 .o 檔案打包成一個 .a 檔

指令:

ar rcs libadd.a add.o

要注意的是,在 linux 裡面,靜態函式庫一定要是 lib 開頭,.a 結尾,中間為函式庫名稱

4. 連結 (Linking)

  • 把多個目標檔(.o)和函式庫合併,解析跨檔符號依賴,最終產生可執行檔或動態函式庫。

指令:

# -l<name>:指定連結函式庫(lib<name>.so / .a)
# -L<path>:指定函式庫搜尋路徑
# -static: 強制全部用靜態連結,執行時完全不需動態函式庫
gcc main.o add.o -o myapp -ladd -L./lib

# 連結成動態函式庫
gcc -fPIC -shared add.c -o libadddy.so
gcc main.o add.o -o myapp -ladddy -L./lib

# 檢查執行檔的動態函式庫依賴
ldd myapp

# 執行依賴動態函式庫的程式
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
./run_shared

縱整 gcc 的各種 flags

  • -I

  • -D

  • -E

預處理階段

連結階段

編譯階段

其他

  • -S

  • -c

  • -std

  • -g

  • -l

  • -L

  • -shared

  • -o

# 1. 靜態函式庫範例
gcc -Wall -O2 -std=c99 -c add.c -o add.o
ar rcs libadd.a add.o

# 2. 連結可執行檔
gcc main.c -L. -ladd -o run_static

# 3. 動態函式庫範例
gcc -fPIC -shared add.c -o libadd.so
gcc main.c -L. -ladd -o run_shared

# 4. 高階最佳化測試
gcc main.c -O0 -o app_O0
gcc main.c -O3 -march=native -o app_O3_native

# 比較執行時間
time ./app_O0
time ./app_O3_native

Try It Out

Make

我們現在已經學會所有 C / C++ 編譯的方法了

理論上可以完全編譯出任何程式了

嗎?

gcc 一次只能編譯一個檔案

當檔案一多,會變得很麻煩

所以我們要有自動的工具,能夠用簡易的指令,按照預先定義好的規則,去編譯

最常用 / 古老的工具稱為

GNU Make

他的設計理念:依賴項

我們先來創一個新的專案

  • add 和 sub 定義了加法和減法函數
  • utils 定義了 print_result,可以列印一個算式
  • plugin 裡面有一個一些函數,會呼叫 math 的各種公式進行運算,然後用 print_result 輸出
  • main.c 會呼叫 plugin 和 math 的程式

何謂依賴?

a 的程式會用到 b

→ a 依賴 b

我們先來創一個新的專案

  • add 和 sub 定義了加法和減法函數

  • utils 定義了 print_result,可以列印一個算式

  • plugin 裡面有一個一些函數,會呼叫 math 的各種公式進行運算,然後用 print_result 輸出

  • main.c 會呼叫 plugin 和 math 的程式

誰依賴誰?

  • add.c, sub.c, utils.c 不依賴別人

  • plugin.c 依賴 add.c, sub.c, utils.c

  • main.c 依賴 add.c, sub.c, utils.c, plugin.c

  • 我依賴蔡政廷之前的簡報

看完蔡政廷簡報,我們知道 makefile 怎麼寫了

現在來試著寫剛剛那個專案的

我希望將 add 和 sub 合併成 math 靜態函式庫

utils 也是靜態函式庫,而 plugin 弄成動態函式庫

我需要生成哪些東西?

5 個 object file, each for 1 c file

兩個靜態函式庫、一個動態函式庫

一個最終執行檔

5 個 object file, each for 1 c file

兩個靜態函式庫、一個動態函式庫

一個最終執行檔

.PHONY: all clean

all: myapp

# 編譯 object files
math/add.o: 

math/sub.o: 

utils.o: 

plugin.o:

main.o:

# 靜態函式庫
libmath.a: 

libutils.a: 

# 動態函式庫
libplugin.so: 

# 最終執行檔
myapp: 

# 清理所有產出
clean:
	 

來按照以上框架一步一步完成它吧

實作時間!

完整答案

.PHONY: all clean

all: myapp

# 編譯 object files
math/add.o: math/add.c math/add.h
	 gcc -Wall -std=c99 -O2 -c math/add.c -o math/add.o -I.

math/sub.o: math/sub.c math/sub.h
	 gcc -Wall -std=c99 -O2 -c math/sub.c -o math/sub.o -I.

utils.o: utils.c utils.h
	 gcc -Wall -std=c99 -O2 -c utils.c -o utils.o

plugin.o: plugin.c plugin.h math/add.h utils.h
	 gcc -Wall -std=c99 -O2 -fPIC -c plugin.c -o plugin.o

main.o: main.c math/add.h math/sub.h utils.h plugin.h
	 gcc -Wall -std=c99 -O2 -c main.c -o main.o

# 靜態函式庫
libmath.a: math/add.o math/sub.o
	 ar rcs libmath.a math/add.o math/sub.o

libutils.a: utils.o
	 ar rcs libutils.a utils.o

# 動態函式庫
libplugin.so: plugin.o libmath.a libutils.a
	 gcc -shared -o libplugin.so plugin.o libmath.a libutils.a

# 最終執行檔
myapp: libmath.a libutils.a libplugin.so main.o
	 gcc -Wall -std=c99 -O2 -o myapp main.o -L. -lplugin -lutils -lmath

# 清理所有產出
clean:
	 rm -f math/*.o *.o *.a *.so myapp

更多 make 技巧

CMake

這什麼垃圾東西 --- cjtsai

Make 很棒,但是有一個致命的缺點

還記得他的全名嗎?

GNU Make

看到 GNU ,就知道這東西是為了 GNU/Linux 開發的

所以跟 gcc 一樣,windows 上面原生沒有

對的,所以 windows 要用 gcc 或是 make,都需要另外裝 mingw 這種怪怪方式

那有沒有一種 Make,能夠針對不同平台,用不同方式建置呢

CMake!

我懶得偷了

來看個 U 質的影片

(抱歉是簡中,但這我找到最好的說明了)

如何編譯

最常用:在 linux 底下,變成 makefile

mkdir build && cd build
cmake ..
make

常用語法整理

# 1. 指定 CMake 最低版本 & 專案名稱
cmake_minimum_required(VERSION 3.10)
project(MyApp LANGUAGES C)

# 2. 設定 C 標準
set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)

# 3. 全域編譯旗標:啟用所有警告並最佳化到 O2
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -Wall -O2")

# 4. 結構化多子目錄
add_subdirectory(foo_dir)
add_subdirectory(bar_dir)

# 5. 定義函式庫
## 靜態庫
add_library(mylib STATIC lib.c)
## 動態庫 
add_library(plugin SHARED plugin.c)

# 6. 定義可執行檔
## myapp 為執行檔名稱,後面列出要編譯的原始檔
add_executable(myapp main.c foo.c)

# 7. 設定 include 路徑
target_include_directories(myapp
  PRIVATE
    ${CMAKE_SOURCE_DIR}/include
)

# 8. 連結函式庫
target_link_libraries(myapp
  PRIVATE
    mylib
    plugin
)

常用變數整理

  • CMAKE_SOURCE_DIR:
    指向最上層(root)CMakeLists.txt 所在的原始碼目錄絕對路徑。

  • CMAKE_BINARY_DIR
    指向頂層 build 目錄(也就是你執行 cmake 時所在的資料夾)。

  • CMAKE_CURRENT_SOURCE_DIR
    指向正在處理的那一層 CMakeLists.txt 所在的原始碼資料夾。通常搭配 add_subdirectory() 後在子目錄裡使用,用以組合相對路徑。

  • CMAKE_CURRENT_BINARY_DIR
    指向與 CMAKE_CURRENT_SOURCE_DIR 相對應的 build 目錄(子目錄對應的 build 資料夾),方便在子目錄中輸出檔案。

  • CMAKE_INSTALL_PREFIX
    設定 make install 時安裝的根目錄(預設通常是 /usr/local)。

  • CMAKE_PREFIX_PATH
    用於輔助 find_package() 搜尋第三方庫的根路徑;指定後 CMake 會在這些路徑底下找「include」和「lib」。

實作時間!

  • 嘗試整理剛剛範例用的程式從 Makefile 改成 cmake
  • 答案在下一頁,不要偷看
cmake_minimum_required(VERSION 3.10)
project(MyApp C)

set(CMAKE_C_STANDARD 99)
set(CMAKE_C_STANDARD_REQUIRED ON)

# 把 include/ 加到搜尋路徑
include_directories(${PROJECT_SOURCE_DIR}/include)

# math 模組 → 靜態庫 libmath.a
add_library(math STATIC
    src/math/add.c
    src/math/sub.c
)

# utils 模組 → 靜態庫 libutils.a
add_library(utils STATIC
    src/utils.c
)

# plugin 模組 → 共享庫 libplugin.so,link math + utils
add_library(plugin SHARED
    src/plugin.c
)
target_link_libraries(plugin PRIVATE math utils)

# 最終執行檔 myapp,link plugin + utils + math
add_executable(myapp
    src/main.c
)
target_link_libraries(myapp PRIVATE plugin utils math)

專案結構

在實際讓你們開始實作 CMake 之前,想要先講講 C 的專案結構

C / C++ 的專案結構很自由,但是大部分大專案都是用以下三種架構的變形

定義一下名詞:

Module 指的是在大專案當中,可以獨立編譯的小部分(可以想成一個 library 之類的)

1. Source files 和 header files 依照 module 放在一起

  • 優點:最精簡的結構
  • 缺點:source 和 header 容易混在一起,檔案多的時候還是容易很亂
  • 例子:Tensorflow

2. Source files 和 header files 分開,再依照 module 放在一起

  • 優點:方便區分專案中公開與非公開的部分
  • 使用函式庫的只需要看 include 資料夾就知道介面
  • 缺點:東西多的時候開發很麻煩,只適合小專案
  • 例子:spdlog

3. 在 module 底下分成 include 和 src

  • 適合 module 多且複雜的大型專案,開發者可以專注於個別的 module 即可。
  • 例子:OpenCV

環境與套件管理

這裡要來補一些前面挖的坑

到目前為止我們已經可以

  1. 理解標頭檔跟 library 在幹嘛
  2. 編譯出一個好程式
  3. 連結動態、靜態函式庫
  4. 用一個好的結構整理我們的程式
  5. 用 make 或是 CMake 編譯程式

但是,在實際開發專案的時候,還會有別的問題要處理

還沒學會如何別人程式!

我是說 整合

根據前面的我們可以知道,如果我要使用別人的函式庫,在編譯時重點有三個

  1. 要知道他的 header 路徑在哪裡 (才能用 -I 參數)
  2. 要知道他的 Library 路徑在哪裡 (才能用 -L 參數)
  3. 要知道他的 Library 名字(才能用 -l 參數)

前面的坑

gcc 怎麼找到系統的預設函式的 library 和 header 在哪裡呢?

在 Linux 底下,系統 library 和 header 通常放在 /usr 路徑下

裡面有幾個比較需要知道的

  • bin:一堆執行檔

  • lib:一堆函式庫
  • include:一堆標頭檔
  • local:和 /usr 一樣的結構,但是是給使用者手動放東西的

所以在 Unix 世界當中,有個很簡單的法則

當你要安裝一個執行檔時,只要把它放進 /usr/bin 或是 /usr/local/bin 裡面就好

(雖然一般來說我們不會直接放進去,會用 symbolic link 的,但這不是我們今天要談的事)

但如果我今天想要將某個資料夾底下的所有檔案都可以直接呼叫怎麼辦呢?

例如在 mac 上,我用 brew 裝的東西都會在 /opt/homebrew/bin 裡面

所以實際上,系統會搜尋的路徑不單單只有 /usr/bin 或是 /usr/local/bin,而是一個列表

環境變數

  • shell 是一種語言:所以可以宣告變數
  • 有幾個系統預先存在的全域變數,用來規範大部分程式的運作,就稱為環境變數
  • 常見的環境變數:
    • PATH:一個列表,裡面每個路徑都是系統要執行一個指令時,會去裡面翻執行檔

怎麼新增路徑進去呢?

  • 一次性指令:export PATH=/path/to/bin:$PATH
  • 但重新開一個 session 就會消失
  • 多次:把 export 寫在 ~/.bashrc 或 ~/.zshrc 當中

所以到底

  gcc 怎麼找到系統的預設函式的 library 和 header 在哪裡呢? 

有幾個跟 $PATH 類似的變數

還有幾個寫死在 gcc 裡面的路徑

看到 gcc 搜尋路徑:

gcc -v -E -x c /dev/null

環境變數

  • CPATH
  • LIBRARY_PATH
  • LD_LIBRARY_PATH

有了這些之後,我們就知道怎麼引用別人函式庫了

  1. 下載或是編譯出 header 和 library
  2. 放到 /usr 目錄下,或是把編譯完的資料夾放入環境變數
  3. 開心編譯

怎麼聽起來還是那麼麻煩啊?

有沒有簡單一點的做法?

有...但不多

  1. 系統套件管理器(例如 apt)
  2. brew 這種其他另外裝的套件管理器
  3. vcpkg、conan(但都不太好用)
  4. 將原始碼用 cmake 整合進專案內

vcpkg 一點基本的使用

(因為我也不會)

1. 手動編譯並安裝

git clone https://github.com/microsoft/vcpkg.git ~/vcpkg
cd ~/vcpkg
./bootstrap-vcpkg.sh

2. 在 .bashrc 或是 .zshrc 加入這幾行

export VCPKG_ROOT="$HOME/vcpkg"
export PATH="$VCPKG_ROOT:$PATH"
export CMAKE_TOOLCHAIN_FILE="$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake"

3. 用 vcpkg install 來安裝東西,就可以正常用 cmake 編譯了

來嘗試安裝 fmt 並成功執行以下程式

#include <fmt/core.h>
int main() {
    fmt::print("Hello, vcpkg + CMake!\n");
    return 0;
}

實作時間

來試著寫一個小專案吧

要求:

  1. 使用 CMake 建置
  2. 專案當中要包含至少一個靜態函式庫、一個動態函式庫、一個執行檔
  3. 需要安裝並使用至少一項以下這幾個工具,並在 readme 中寫下步驟
    1. spdlog
    2. fmt
    3. nlohmann/json
    4. boost
    5. SFML
  4. 專案要整理乾淨、整潔、推到 github 上
  5. 週五成發

下課!

希望你們還沒睡著 XD

Made with Slides.com