前言
雖然 C 是跨平台語言,但卻不像 Java、Golang、Rust 等語言般可立即取得跨平台的特性,而要經過一些額外的努力。這是因為不同系統的系統 C API 不會完全相同,所以我們要用條件編譯等手法來處理平台異質性的議題。
本文會介紹一些和撰寫跨平台 C 函式庫相關的議題,供想要撰寫跨平台 C 程式碼的讀者參考。
參考實例:libclipboard 函式庫
libclipboard 是一套跨平台的剪貼簿 (clipboard) 函式庫,該函式庫以 facade 模式封裝了 Windows、macOS、GNU/Linux 等系統的剪貼簿功能。由於剪貼簿是系統特有的功能,在各系統會有相異的實作,所以適合用這個模式來封裝平台差異性。
大部分和剪貼簿相關的功能都是附屬在 GUI 函式庫的一部分,像是 GTK+ 或 SDL 都有剪貼簿相關的函式。但是,當我們寫了個命令列工具,而該工具想和桌面環境傳遞文字資料,這時候直接引入整個 GUI 函式庫就太肥大了。因此,會出現 libclipboard 這種輕量級的剪貼簿函式庫。
本文的重點在探討跨平台 C 函式庫相關的議題,不會深入解析 libclipboard 的程式碼,有興趣的讀者可以自己觀看該函式庫的原始碼。
選擇 C 語言標準
在撰寫 C 函式庫時,不應該任意地使用 C 語法特性,而要明確地考慮該函式庫所用的 C 標準。如果以最大的相容性為考量,仍應繼續使用 ANSI C (C89)。相對來說,如果只會用到 GCC 或 Clang,則可使用 C99 或 C11 等現代 C 語言的特性。
有一些 C 教材會考慮 K&R C 的相容性議題,但 K&R C 是 C 標準尚未建立時的非正規標準,現在的 C 編譯器不會刻意守在這個版本的 C 語言。所以,除非要考慮一些罕見的情境,不用刻意去支援這套非正規標準。
即使 GCC 是普遍的 C 編譯器,我們仍然不應該使用 GNU extension。在主流桌面系統中,至少還要考慮 Visual C++ 或 Clang。使用 GNU extension 會使 C 函式庫的可移植性變差。同樣地,我們也不應該使用其他 C 編譯器特有的 extension。
當我們使用 GCC 或 Clang 編譯 C 函式庫時,可以藉由參數鎖定特定的 C 標準,讓編譯器幫我們檢查 C 程式碼是否符合特定 C 標準。讀者可參考 GCC 的官方文件以了解這些參數的用法。
選擇函式庫的目標平台
C 程式碼有可能拿到不同平台上編譯,但實際運行時仍會轉為單一平台的機械碼。所以,考慮跨平台議題時,還是要考慮可能的目標平台,而不是一廂情願地想要讓 C 程式碼適用於所有的平台。
考慮目標平台時,需考慮目標 CPU 架構和作業系統。以下是一些可能的目標平台:
- x86 架構
- Windows
- macOS
- GNU/Linux
- Unix (FreeBSD, Solaris 等)
- ARM 架構
- Android
- iOS
- GNU/Linux (像 RaspBerry Pi 等嵌入式系統)
當我們用 C 寫 Android 或 iOS 程式時,UI 的部分仍用該平台的原生語言 (Java、Kotlin on Android 和Objective-C、Swift on iOS) 來寫,而 C 則用來寫和 UI 無關的部分。
選擇專案所用的自動編譯軟體
C 專案沒有官方的自動編譯軟體,而要依賴外部程式來自動化編譯 C 程式碼的過程。要考慮相依性的話,就不能依賴 IDE 來管理 C 專案,而要使用跨平台的自動編譯軟體。以下是常見的方案:
- Make
- Autotools
- CMake
如果只需在 Unix 系統上編譯,則三者皆可。如果要考慮 Windows,最好使用 CMake。像是前文提及的 libclipboard 函式庫就以 CMake 管理專案。
區分界面和實作
函式庫會包括外部界面和內部實作兩部分。以 C 語言來說,會使用標頭檔 (.h) 做為外部界面,而原始碼 (.c) 則是內部實作。標頭檔會存放宣告 (型態、巨集、函式等) 的部分,而原始碼則存放實作 (函式本體) 的部分。
並不是所有的標頭檔都要做為公開界面。我們仍然可以在原始碼中存放一些內部使用的標頭檔。以巢狀專案來說,放在 include 子目錄的標頭檔視為外部界面,放在 src 子目錄內的標頭檔則僅為內部使用。
隱藏不必要的資訊
當我們實作函式庫時,會儘量減少不必要的資訊暴露,以減少過度耦合。例如,我們在早期的界面中暴露某個結構體的屬性,而有外部程式直接使用這些屬性。當我們把結構體改用 opaque object 來宣告時,外部程式會因無法繼續使用該結構體的屬性而引發錯誤。
雖然 C 語言不直接支援封裝,但有一些有助於資訊隱藏的特性:
- opaque pointer
- static 變數
- static 函式
- 函式可視度 (function visibility)
我們會在物件導向 C 程式中,進一步展示這些特性的使用方式。
用前綴模擬命名空間
C 語言沒有命令空間或類似的特性,替代的方式就是直接在函式庫前端加上前綴。本文所提到的範例專案 libclipboard 函式庫使用 clipboard_
做為函式的前綴。而知名的 GUI 函式庫 GTK,則是使用 g_
做為函式的前綴。
前綴雖不是強制的特性,但是最好在公開宣告中加上前綴。由於 C 宣告是直接放入全域命名空間中,所以知名的函式庫都會有默契地加上前綴,以避免命名衝突。
將應用程式重構成函式庫
如果原本的 C 專案是應用程式,仍然可以經重構轉變成函式庫。這時候就要將專案進行一定的改寫,將使用者界面及核心功能切開來。輸出入的部分可能會和使用者界面放在一起,而核心功能則另外放在重構出來的函式。
經重構的 C 專案,會成為多產出的專案,同一專案可同時產出應用程式及函式庫。像是知名的內嵌語言 Lua 在編譯出來時同時會提供函式庫和命令列工具 lua
兩種輸出,就是一個兼具函式庫和應用程式的實例。
回傳錯誤訊息而非強制中止程式
C 語言沒有錯誤處理的機制,當函式發生錯誤時,會透過回傳狀態碼或是中止程式來應對。由於 C 的系統中止程式 exit() 不是例外物件,外部程式沒辦法攔截 exit()
函式。因此,不應任意地在函式庫中呼叫此函式。除了少數嚴重錯誤外,函式都應該以回傳狀態碼的方式來處理錯誤事件。
減少不必要的手動記憶體配置
手動配置記憶體是有可能失敗的動作,所以,在函式中應儘量減少手動記憶體配置的動作。像是標準函式庫的 strcpy() 或 strcat() 刻意地不在函式中手動配置記憶體,而把函式輸出透過第一個變數傳回。在這樣的函式中,外部程式可自行決定為 C 字串配置記憶體的方式。
減少外部相依性
發佈函式庫時,應儘量減少該函式庫的外部相依庫。當某個函式庫還得依賴多個外部函式庫時,由於建置過程較複雜,程式設計者對該函式庫的使用意願會大幅降低,不利於函式庫的推廣。
對於小型的外部相依程式碼,可以考慮直接在自己的專案中維護一份該程式碼的拷貝,函式庫使用者就不需處理相依性的議題。當然,這些外部相依程式碼久了有可能會和上游程式碼脫節,這時候函式庫維護者有責任對內部相依性進行必要的更新。
系統和 C 編譯器特有的巨集宣告
不同系統的系統 C API 不會完全相同,這時候可以用條件編譯來區隔平台特有的程式碼。例如,_WIN32
是 Windows 家族系統特有的巨集宣告。以下的程式碼可以用來撰寫 Windows 特有的程式碼:
#if _WIN32
/* Write Windows-specific code here. */
#endif
以下是一些系統特有的巨集宣告:
_WIN32
:Windows 系統 (32 位元和 64 位元)_WIN64
:Windows 系統 (64 位元)__APPLE__
:macOS 系統和 iOS 系統__linux__
:GNU/Linux 系統和 Android 系統__linux__ && !__ANDROID__
:GNU/Linux 系統,排除 Android 系統__unix__ || __unix || unix
:Unix 或類 Unix 系統,但不包含 macOS__ANDROID__
:Android 系統TARGET_OS_IPHONE
:iOS 系統__Fuchsia__
:Fuchsia 系統
這裡有一份更詳細的清單,如果需要支援一些較少見的系統,可以參考一下。
除此之外,C 編譯器也有一些特有的巨集宣告:
_MSC_VER
:Visual C++__GNUC__
:GCC__clang__
:Clang__EMSCRIPTEN__
:Emscripten (WebAssembly 和 asm.js)__MINGW32__
:MinGW (32 位元)__MINGW64__
:MinGW (64 位元)
在 C++ 中使用 C 函式庫
除了以 C 外部程式呼叫 C 函式庫外,也有可能以 C++ 外部程式呼叫 C 函式庫。但 C++ 編譯器在預設情形下會對函式宣告做 name mangling,造成 C 函式庫的宣告和實作無法匹配。
C++ 在實作時即考慮和 C 的相容性,所以 C++ 也有避開 name mangling 的手法。當我們對函式宣告加上 extern "C"
保留字時,就可以中止 name mangling 的動作。這時候的函式宣告會等同於在 C 語言中的行為。
由於在 C++ 程式中使用 C 函式庫是每個 C 函式庫都有可能碰到的議題,以下寫法幾乎成為 C 標頭檔的公式:
#ifdef __cplusplus
extern "C" {
#endif
/* C function declarations. */
#ifdef __cplusplus
}
#endif
至於巨集 (macro) 屬於前置處理器的部分,就不需要使用 extern "C"
區塊。
Windows 特有的議題
一般程式設計者用得到的系統,除了 Windows 家族系統外,大多是某種 Unix 或類 Unix 系統。Unix 家族系統大抵上會遵守 POSIX 標準,故系統間差異不會太大。而 POSIX 中就包括了一套標準的 C API。
所以,在撰寫跨平台的 C 程式碼時,應優先在 Unix 家族系統上撰寫。只有在需要關注 Windows 時,才把 C 程式碼拿到 Windows 上測試。
Visual C++ 的官方文件並沒有指明 Visual C++ 所支援的 C 標準。從網路上的討論來看,大概是 C89 和一部分的 C99。此外,還支援一些 C11 的函式及 Windows API 特有的版本,像是一些以底線開頭的函式。
比較安全的做法是用 C89 來撰寫 C 程式碼,並用條件編譯區隔非 C89 的部分。如果不想在 C 程式中加入 C11 或 Windows 特有的函式,可以在用 Visual C++ 編譯 C 程式碼時,加上 _CRT_SECURE_NO_WARNINGS
參數來停用 (不必要的) 安全性警告。
此外,由於 Visual C++ 無法藉由參數鎖定 C 標準版本,最好另外用 GCC 或 Clang 檢查自己的 C 程式碼是否符合 ANSI C 標準。
Windows 動態函式庫的修飾詞
在製作 Windows 動態函式庫 (DLL) 時,會用到 Windows 特有的修飾詞 dllexport
和 dllimport
。
當我們想要編譯 DLL 時,需要在函式宣告上新增 dllexport
修飾。如下例:
__declspec(dllexport) double add(double a, double b);
當我們想要使用外部 DLL 時,需要在函式宣告上新增 dllimport
修飾詞。如下例:
__declspec(dllimport) double add(double a, double b);
但是,對於相同的函式宣告,使用兩份標頭檔實在不太經濟。解決的方式是使用以下巨集:
#if _MSC_VER
#if defined(MYLIB_IMPORT_SYMBOLS)
#define MYLIB_PUBLIC __declspec(dllimport)
#elif defined(MYLIB_EXPORT_SYMBOLS)
#define MYLIB_PUBLIC __declspec(dllexport)
#else
#define MYLIB_PUBLIC
#endif
#else
#define MYLIB_PUBLIC
#endif
然後使用以下宣告:
MYLIB_PUBLIC double add(double a, double b);
我們在編譯程式碼時,只要切換巨集變數即可,就可以重覆使用同一份標頭檔。對於 Visual C++ 以外的 C 編譯器來說,MYLIB_PUBLIC
是空的變數,不影響函式宣告。
編譯程式牽涉到 C 編譯器的使用方式,限於文章篇幅,我們不在這篇文章中說明,而會放在後續的文章中。
結語
在本文中,我們介紹了數個和撰寫跨平台 C 函式庫相關的議題。這些內容應該可以做為一個起點,當成撰寫下一個跨平台 C 函式庫的預備知識。