撰寫跨平台 C 專案

    前言

    在先前的文章中,我們以概念為主,介紹撰寫跨平台 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 ,在不同系統上的名稱如下:

    libraryUnixmacOSMinGWMSVC
    staticlibmylib.alibmylib.alibmylib.amylib.lib
    dynamiclibmylib.solibmylib.dyliblibmylib.dllmylib.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++ 專案,不論是要重造輪子還是寫新的函式庫都好。在撰寫專案的過程中,自然而然會發現一些問題,從中就可以增加自己的經驗值。

    電子書籍

    如果讀者想要複習 C 程式設計相關的內容,可以參考以下書籍:

    現代 C 語言程式設計

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Yahoo
    【追蹤本站】
    Facebook Facebook Twitter