位元詩人 [C 語言] 程式設計教學:撰寫跨平台 C 專案

C 語言專案
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

在先前的文章中,我們以概念為主,介紹撰寫跨平台 C 程式相關的議題。在本文中,我們延續這個議題,但會著重實際的工具使用。讀者可以將本文和先前的文章對照著看,對於撰寫跨平台 C 程式會更了解。

參考實例:libjwt

libjwt 是以 C 語言實作的 JWT 函式庫。雖然 C 語言不是用來製作網頁程式的主流語言,C 網頁程式在嵌入式裝置仍然有其市場,所以會出現 libjwt 這種函式庫。

libjwt 以 CMake 和 Make 並行的策略來管理專案,可涵蓋大部分的系統。

選擇工具鍵

在本文中,我們分別以命令列環境和 Makefile 為範例,展示編譯函式庫的流程。目標系統為 Windows, macOS, GNU/Linux,應可涵蓋大部分的使用情境。

在 Unix 上編譯函式庫

編譯靜態函式庫

假定函式庫 mylib 有三個 C 程式碼 a.cb.cc.c 。我們要將 C 原始碼編譯成靜態函式庫。

先將 C 程式碼編譯成目的檔:

$ gcc -c a.c
$ gcc -c b.c
$ gcc -c c.c

然後把目的檔編譯成靜態函式庫:

$ ar rcs libmylib.a a.o b.o c.o

要注意二進位檔的 lib 前綴是必需的,若無此前綴,會造成函式庫無法使用。

由於編譯 C 程式碼是重覆的動作,可以用 Make 的規則來寫:

%.o: %.c
    $(CC) -c $< $(CFLAGS)

將上述過程寫成 Makefile 如下:

OBJS=a.o b.o c.o
TARGET=libmylib.a

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    $(AR) rcs $(TARGET) $(OBJS)

%.o: %.c
    $(CC) -c $< $(CFLAGS)

clean:
    $(RM) $(OBJS) $(TARGET)

當我們要編譯 libmylib.a 時,會先編譯相依的目的檔。在編譯目的檔時,會自動套用相同的規則,我們就不需要重覆寫相同的指令。

編譯動態函式庫

承接上一小節的假想範例,我們現在改編譯動態函式庫。

先將原始碼編譯成目的檔:

$ gcc -fPIC -c a.c
$ gcc -fPIC -c b.c
$ gcc -fPIC -c c.c

注意我們在這裡額外加上參數 -fPIC,這時為了編譯動態函式庫而加的。由於靜態函式庫和動態函式庫的目的檔相異,兩者無法同時編譯,要先編完其中一個,清掉目的檔,再編另一個。

接著,將目的檔編成動態函式庫:

$ gcc -shared -o libmylib.so a.o b.o c.o

.so 是 Unix 上的動態函式庫的副檔名。同樣地,二進位檔的 lib 前綴是必需的,不可省略。

將上述過程寫成 Makefile 如下:

OBJS=a.o b.o c.o
TARGET=libmylib.so

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    $(CC) -shared -o $(TARGET) $(OBJS)

%.o: %.c
    $(CC) -fPIC -c $< $(CFLAGS)

clean:
    $(RM) $(OBJS) $(TARGET)

在 macOS 上編譯函式庫

編譯靜態函式庫

承接上一節的假想範例,我們現在要編譯靜態函式庫。

先將原始碼編譯成目的檔:

$ clang -c a.c
$ clang -c b.c
$ clang -c c.c

在 macOS 上會使用 libtool 生成靜態函式庫:

$ libtool -static -o libmylib.a a.o b.o c.o

將上述過程寫成 Makefile 如下:

OBJS=a.o b.o c.o
TARGET=libmylib.a

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    libtool -static -o $(TARGET) $(OBJS)

%.o: %.c
    $(CC) -c $< $(CFLAGS)

clean:
    $(RM) $(OBJS) $(TARGET)

編譯動態函式庫

承上,現在在 macOS 上編譯動態函式庫。

先將 C 程式碼編譯成目的檔:

$ clang -fPIC -c a.c
$ clang -fPIC -c b.c
$ clang -fPIC -c c.c

再將目的檔編為動態函式庫:

$ clang -shared -o libmylib.dylib a.o b.o c.o

注意在 macOS 上動態函式庫的副檔名為 .dylib 。前綴的 lib 同樣不能省略。

將上述過程寫成 Makefile 如下:

OBJS=a.o b.o c.o
TARGET=libmylib.dylib

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    $(CC) -shared -o $(TARGET) $(OBJS)

%.o: %.c
    $(CC) -fPIC -c $< $(CFLAGS)

clean:
    $(RM) $(OBJS) $(TARGET)

在 Windows 上編譯函式庫

Windows 上有兩套 C 編譯器,分別是 Visual C++ (MSVC) 和 MinGW (GCC)。由於 ABI 不相容,通常會固定用同一套編譯器編譯所有的 C 程式碼,而不會交叉使用。這篇文章有更詳細的介紹,有興趣的讀者可以看一下。

編譯適用於 MinGW 的靜態函式庫

MinGW (GCC) 的參數和 Unix 上的 GCC 相容,使用起來不會太難。

延續先前的假想範例,現在要編譯適用於 MinGW 的靜態函式庫。

先將 C 程式碼編譯為目的檔:

C:\> gcc -c a.c
C:\> gcc -c b.c
C:\> gcc -c c.c

再將目的檔轉為靜態函式庫:

C:\> ar rcs libmylib.a a.o b.o c.o

將上述過程寫成 Makefile 如下:

OBJS=a.o b.o c.o
TARGET=libmylib.a

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    $(AR) rcs $(TARGET) $(OBJS)

%.o: %.c
    $(CC) -c $< $(CFLAGS)

clean:
    $(RM) $(OBJS) $(TARGET)

由於 MinGW 是 GCC 的移植品,使用的開發工具雷同,故 Makefile 也會相同。

編譯適用於 MinGW 的動態函式庫

承上,現在要編譯適用於 MinGW 的動態函式庫。

先將 C 原始碼編譯成目的檔:

C:\> gcc -fPIC -c a.c
C:\> gcc -fPIC -c b.c
C:\> gcc -fPIC -c c.c

再將目的檔編譯成動態函式庫:

C:\> gcc -shared -o libmylib.dll a.o b.o c.o

注意在 Windows 上,動態函式庫的副檔名變成 .dll ,而非 .so

將上述過程寫成 Makefile 如下:

OBJS=a.o b.o c.o
TARGET=libmylib.dll

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    $(CC) -shared -o $(TARGET) $(OBJS)

%.o: %.c
    $(CC) -fPIC -c $< $(CFLAGS)

clean:
    $(RM) $(OBJS) $(TARGET)

基本上 Makefile 也是相同的,只是動態函式庫的檔名改了。

編譯適用於 MSVC 的靜態函式庫

Visual C++ (MSVC) 的使用方式和 GCC 不同,注意一下使用方式。由於我們使用 GNU Make 管理專案,要會從終端機使用 Visual C++,不依賴 Visual Studio 的 IDE 選單。

我們繼續使用同一個例子,現在要編譯靜態函式庫。

使用 Visual C++ 將 C 程式碼編譯成目的檔:

C:\> cl.exe /c a.c /DWIN32 /D_WINDOWS /MT
C:\> cl.exe /c b.c /DWIN32 /D_WINDOWS /MT
C:\> cl.exe /c c.c /DWIN32 /D_WINDOWS /MT

WIN32_WINDOWS 是編譯傳統 Windows 桌面程式時會用到的變數。但微軟官方文件沒特別說明這兩個變數的意義。

所謂的傳統 Windows 桌面程式是指用 C 或 C++ 所寫的 Windows 程式。相對來說,現代 Windows 程式則是指用 C# 或 Visual Basic.NET 所寫的 Windows 程式。兩者的差別在於傳統型程式是機械碼 (native code),而現代型程式則是位元碼 (bytecode)。

/MT 代表使用靜態連結到 Windows C 執行期函式庫。會在編譯靜態函式庫時使用。

接著將目的檔編譯成靜態函式庫:

C:\> lib /out:mylib.lib a.obj b.obj c.obj

注意目的檔及靜態函式庫的副檔名皆和 GCC 相異。MSVC 的目的檔的副檔名為 .obj ,而靜態函式庫的副檔名是 .lib 。此外,MSVC 的靜態函式庫不使用 lib 前綴。

將上述過程寫成 Makefile 如下:

CFLAGS=/DWIN32 /D_WINDOWS /MT
OBJS=a.obj b.obj c.obj
TARGET=mylib.lib

RM=del /q /f

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    lib /out:$(TARGET) $(OBJS)

%.obj: %.c
    $(CC) /c $< $(CFLAGS)

clean:
    $(RM) $(OBJS) $(TARGET)

由於 MSVC 的用法和 GCC 有差異,所以 Makefile 寫起來也相異。

由於 GNU Make 在設計時,是以 GCC 為考量,故 $(CC) 會自動對應到 GCC,但不會對應到 Visual C++。使用 GNU Make 搭配 MSVC 時,改用以下指令:

C:\> mingw32-make CC=cl

這時候命令列上的 CC 會帶入 Makefile 中的 $(CC) 變數,就可以用 Visual C++ 編譯程式。

編譯適用於 MSVC 的動態函式庫

承接上一節,現在要編譯適用 MSVC 的動態函式庫。

C:\> cl.exe /c a.c /DWIN32 /D_WINDOWS /MD
C:\> cl.exe /c b.c /DWIN32 /D_WINDOWS /MD
C:\> cl.exe /c c.c /DWIN32 /D_WINDOWS /MD

/MD 代表使用動態連結到 Windows C 執行期函式庫。會在編譯動態函式庫時使用。

接著將目的檔編譯成動態函式庫:

C:\> link /dll /OUT:mylib.dll a.obj b.obj c.obj

注意在 MSVC 的動態函式庫中不需 lib 前綴。

將上述過程寫成 Makefile 如下:

CFLAGS=/DWIN32 /D_WINDOWS /MD
OBJS=a.obj b.obj c.obj
TARGET=mylib.lib

RM=del /q /f

.PHONY: all clean

all: $(TARGET)

$(TARGET): $(OBJS)
    link /dll /OUT:$(TARGET) $(OBJS)

%.obj: %.c
    $(CC) /c $< $(CFLAGS)

clean:
    $(RM) $(OBJS) $(TARGET)

使用 MSVC 製作動態函式庫時,除了要注意 Visual C++ 的使用方式外,還要加上額外的修飾詞。我們會於下一節說明。

撰寫適用於 MSVC 動態函式庫的 C 程式碼

在製作 MSVC 的動態函式庫時,預設函式不輸出。對於要輸出或輸入的函式,要在函式宣告加上額外的修飾詞 dllexportdllimport 等。

在編譯動態函式庫時,要加上 dllexport 修飾詞。我們沿用先前提到的 libjwt 中的例子:

__declspec(dllexport) int jwt_new(jwt_t **jwt);

而在編譯使用動態函式庫的外部程式時,要加上 dllimport 修飾詞。如下例:

__declspec(dllimport) int jwt_new(jwt_t **jwt);

dllexportdllimport 等修飾詞是 MSVC 特有的,無法跨平台。所以,我們要用小技巧來避開這項問題。這裡節錄 libjwt 的標頭檔:

#ifdef _MSC_VER

    #define DEPRECATED(func) __declspec(deprecated) func

    #define alloca _alloca
    #define strcasecmp _stricmp
    #define strdup _strdup

    #ifdef JWT_DLL_CONFIG
        #ifdef JWT_BUILD_SHARED_LIBRARY
            #define JWT_EXPORT __declspec(dllexport)
        #else
            #define JWT_EXPORT __declspec(dllimport)
        #endif
    #else
        #define JWT_EXPORT
    #endif

#else

    #define DEPRECATED(func) func __attribute__ ((deprecated))
    #define JWT_EXPORT

#endif

然後在函式宣告前加上 JWT_EXPORT

JWT_EXPORT int jwt_new(jwt_t **jwt);

當我們在編譯給 MSVC 的動態函式庫時,在命令列參數加上 /DJWT_BUILD_SHARED_LIBRARY。這時候的 JWT_EXPORT 等同於 __declspec(dllexport),效果是輸出函式宣告。

當我們在編譯使用 MSVC 動態函式庫的外部程式時,不要宣告編譯期變數 JWT_BUILD_SHARED_LIBRARY。這時候的 JWT_EXPORT 等同於 __declspec(dllimport),效果是輸入函式宣告。

當我們在產出或使用 MSVC 靜態函式庫時,不需要修飾詞。不需要在命令列參數加上額外的變數。這時候 JWT_EXPORT 等同於空的宣告,和原本的 C 宣告同義。

除了 MSVC 動態函式庫的修飾詞外,GCC 或 Clang 也有自己的修飾詞。這是用來控制那些函式要對外輸出。但 GCC 的修飾詞沒那麼複雜,不需要用命令列參數切換。

C 函式庫在不同系統上的名稱

在本節中,我們統整先前數節的內容,將 C 函式庫在不同系統上的名稱統整起來。

假定函式庫名稱為 mylib ,在不同系統上的名稱如下:

library Unix macOS MinGW MSVC
static libmylib.a libmylib.a libmylib.a mylib.lib
dynamic libmylib.so libmylib.dylib libmylib.dll mylib.dll

註:MSVC 的動態函式庫包括 mylib.dll、mylib.lib、mylib.exp 等多個檔案。

在 Unix 家族系統以及 MinGW 中,名稱較為相似。而 MSVC 的名稱則差異較大。

撰寫 Makefile 的注意事項

GNU Make 原先是設計給 Unix 使用的,雖然也有 Windows 的移植品,但仍需要透過改寫 Makefile ,才能接軌 MSVC (Visual C++) 生態圈。

在 Windows 上,要注意差異性的來源,有些差異性來自於 Windows 系統,有些差異性來自於 MSVC。像是以下 Makefile 指令用來區隔 Windows 系統和 Unix 系統:

ifeq ($(OS),Windows_NT)
    detected_OS := Windows
else
    detected_OS := $(shell sh -c 'uname -s 2>/dev/null || echo not')
endif

接下來,我們就可以用 detected_OS 來判斷宿主系統是 Windows 或 Unix。

例如,在 Unix 上, Makefile 變數 RM 對應到 rm -f 指令。但在 Windows 上,這項設置是無效的。我們加以改寫如下:

ifeq ($(detected_OS),Windows)
    RM=del /q /f
endif

如果要判斷編譯器是否為 Visual C++,可以藉由 Makefile 變數 CC 來判斷。例如,以下 Makefile 片段用來編譯動態函式庫:

CL := cl icl

$(LIB_DYNAMIC): $(OBJS)
ifneq (,$(findstring $(CC),$(CL)))
    link /DLL /OUT:..$(SEP)$(DIST_DIR)$(SEP)$(LIB_DYNAMIC) $(OBJS)
else
    $(CC) -shared -o ..$(SEP)$(DIST_DIR)$(SEP)$(LIB_DYNAMIC) $(OBJS)
endif

當 C 編譯器為 Visual C++ (cl) 或 Windows 版本的 Intel C++ Compiler (icl) 時,使用 link 編譯動態函式庫。反之,則用 C 編譯器搭配 -shared 參數來編譯動態函式庫。會這樣寫是因為 Visual C++ 和 Windows 版本的 Intel C++ Compiler 共用相同的參數,故歸在同一指令區塊中。

當我們掌握了這些原則後,利用 GNU Make 寫跨平台 C 專案就不再是難事。

我們在先前的文章中,分別介紹了以 GNU Make 寫跨平台應用程式專案函式庫專案的方式,讀者可以參考一下。

結語

在本文中,我們藉由實際的範例專案來看跨平台 C 函式庫的撰寫方式。讀者可參考本範例專案或其他的跨平台函式庫,藉以學習撰寫跨平台 C 專案的方式。

在學習撰寫跨平台 C 或 C++ 專案時,先不要貪心,一次就要讀懂 GTK 這類大型的跨平台專案。反而可以從處理 HTML、XML、JSON、CSV 等檔案格式的小型函式庫開始閱讀。因為這類函式庫的目標明確、檔案格式為人熟知、函式庫規模也不會太大。比較能夠在較短的時間內讀完。

接著,就可以自己試著寫一些小型 C 或 C++ 專案,不論是要重造輪子還是寫新的函式庫都好。在撰寫專案的過程中,自然而然會發現一些問題,從中就可以增加自己的經驗值。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。