前言
在先前的文章中,我們以概念為主,介紹撰寫跨平台 C 程式相關的議題。在本文中,我們延續這個議題,但會著重實際的工具使用。讀者可以將本文和先前的文章對照著看,對於撰寫跨平台 C 程式會更了解。
參考實例:libjwt
libjwt 是以 C 語言實作的 JWT 函式庫。雖然 C 語言不是用來製作網頁程式的主流語言,C 網頁程式在嵌入式裝置仍然有其市場,所以會出現 libjwt 這種函式庫。
libjwt 以 CMake 和 Make 並行的策略來管理專案,可涵蓋大部分的系統。
選擇工具鍵
在本文中,我們分別以命令列環境和 Makefile 為範例,展示編譯函式庫的流程。目標系統為 Windows, macOS, GNU/Linux,應可涵蓋大部分的使用情境。
在 Unix 上編譯函式庫
編譯靜態函式庫
假定函式庫 mylib 有三個 C 程式碼 a.c 、 b.c 、 c.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 的動態函式庫時,預設函式不輸出。對於要輸出或輸入的函式,要在函式宣告加上額外的修飾詞 dllexport
、dllimport
等。
在編譯動態函式庫時,要加上 dllexport
修飾詞。我們沿用先前提到的 libjwt 中的例子:
__declspec(dllexport) int jwt_new(jwt_t **jwt);
而在編譯使用動態函式庫的外部程式時,要加上 dllimport
修飾詞。如下例:
__declspec(dllimport) int jwt_new(jwt_t **jwt);
但 dllexport
、dllimport
等修飾詞是 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++ 專案,不論是要重造輪子還是寫新的函式庫都好。在撰寫專案的過程中,自然而然會發現一些問題,從中就可以增加自己的經驗值。