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

C 語言函式庫
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

雖然 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 特有的修飾詞 dllexportdllimport

當我們想要編譯 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 函式庫的預備知識。

關於作者

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

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