美思 [C 語言] 程式設計教學:撰寫跨平台 C 程式

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

前言

在許多 C 語言教材中,會提到 C 語言是跨平台語言。然而,基礎教材只會碰觸到語法和標準函式庫的層面,這些層面已經是跨平台的。在越過語法和標準函式庫後,C 語言的跨平台並不是隨手可得的,而要經過一些額外的努力。

跨平台是困難的議題,我們也無法概括所有的情境,只能針對常見的原則去說明。筆者才疏學淺,如文章中有錯誤,還請讀者不吝指正。

為什麼要跨平台?

簡單地說,跨平台是為了省時、省力、省錢。當我們可以用同一份程式碼 (code base) 轉換成多種平台的電腦程式時,我們就省下了寶貴的開發時間。

C 語言原本是用來撰寫 Unix 作業系統的系統語言,所以 C 語言在語法上刻意和系統無關。藉由這個方式,讓 Unix 變成跨平台的作業系統。

Unix 大部分的程式碼是以 C 語言撰寫,只有少部分系統特有的部分仍然以組合語言來寫,所以要將 Unix 從某個硬體平台移植到另一個硬體平台不需要重寫整個程式碼,只要重寫組語的部分即可。

C 語言在原始碼層次跨平台

C 語言是編譯語言。C 編譯器會將 C 程式碼轉成機械碼 (native code),而非 Java 或 C# 中使用的位元碼 (bytecode)。所以,C 程式使用平台特有的格式,編譯好的 C 程式是無法跨平台的。那麼,C 語言如何跨平台呢?

簡單地說,C 語言在原始碼層次跨平台。C 程式跨平台的方式是將 (跨平台的) C 程式碼在各個平台重新編譯成該平台特有的電腦程式。只要某個平台 (CPU 架構 + 作業系統) 有 C 編譯器,就可以把 C 程式碼轉為電腦程式。

在新的平台上,還是得用組語從頭打造 C 編譯器。不過,寫 C 編譯器是系統商的工作,一般程式人不需要去煩惱這個議題。相對來說,在常見的平台上,都已經有相當成熟的 C 編譯器,我們只要專注在讓自己的 C 程式碼跨平台即可。

(選擇性) 寫 C++ 而非寫 C

雖然這篇文章談的是 C 語言,但我們可以用 C++ 寫程式,然後用 C++ 編譯器將 C++ 程式碼轉為電腦程式。本文所談的原則基本上是 C 和 C++ 共通的。

若我們所撰寫的 C++ 程式是 C++ 函式庫,可以為該函式庫額外寫 C API,之後就可以利用寫好的 C API 橋接其他高階語言。畢竟,外部程式使用 C API 的機會仍然大於直接使用 C++ API。

在所有的程式語言中,C++ 和 C 是最接近的。C++ 不僅是在語法上貼近 C,效能也是在同一個量級上。C++ 在設計時,即考慮到和 C 相容。相對來說,其他的系統語言雖然也可以寫 C API,但語法上不相容於 C。

選擇目標平台

對於 C 語言來說,在不同平台間的差異可能很大。所以,我們不能一廂情願地要讓自己的 C 程式碼自動跨越所有平台,而要預先選好目標平台。跨平台的目的是節約開發時間,所以,目標平台不用多,只要符合專案的需求即可。

對於桌面程式來說,主要的目標平台應該是 Windows 和 macOS。至於 GNU/Linux 的市佔率小,而且分裂成多種發行版,行有餘力才去支援即可。

對於伺服程式來說,主要的目標平台應該是 GNU/Linux,行有餘力才去支援 BSD、macOS 等非 GNU/Linux 的 Unix 系統。由於伺服程式會透過語言中立的通訊協定 (protocol) 來溝通,所以跨平台並非伺服程式最重要的議題。

對於行動程式來說,主要的目標平台應該是 iOS 和 Android。其他行動平台的市佔率很小,不會放在優先考量。當我們在寫行動程式時,UI 的部分仍然是用系統原生語言來寫,而 C 語言則是用來寫非 UI 的部分。

當我們在談行動平台的系統原生語言時,Android 上是指 Java 和 Kotlin,iOS 上則是指 Objective-C 和 Swift。

對於函式庫來說,最好能讓該函式庫跨越 Windows、macOS、GNU/Linux、BSD 等多種平台,以達到最大程度的程式碼重用性。

由此可知,當我們在談論跨平台程式時,目標平台並不總是指 Windows、macOS、GNU/Linux 等桌面系統,而要視程式類型而定。

從第一天起就讓 C 專案跨平台

當我們要寫跨平台 C 專案時,要從該專案建立的第一天就讓專案跨平台,而不是先在單一平台撰寫後,才移植到其他平台。

程式人在寫程式的當下,對於程式碼的實作細節相當熟悉,這時候要修改程式碼十分容易。反之,當我們把程式碼擺放數個月甚至數年時,通常已經忘了程式碼的實作方式。這時候要重新修改程式碼相當困難,而且可能會遺漏一些細節。

此外,當我們建立跨平台 C 專案時,會自然而然地使用 CMake 或 GNU Make 等跨平台的自動編譯軟體來管理專案,而不會用 IDE 來管理專案。反之,如果我們一開始先用 IDE 管理專案,之後得花數倍的力氣將專案重新用 CMake 或其他同質軟體來管理。

當我們在寫跨平台 C 專案時,還是會先在單一平台上開發,然後才拿到其他平台重新編譯和測試。例如,喜好 macOS 的開發者可以買較好的 Mac 主機做為主力開發機器,然後另外購買便宜的 Windows 主機做為編譯和測試的平台。反之亦然。

相對來說,我們不太需要特地為 GNU/Linux 或 BSD 購買單獨的實體機器,因為我們可以在虛擬機器或雲端環境中輕易地取得 GNU/Linux 或 BSD 工作環境。而且 GNU/Linux 或 BSD 的桌面環境比較弱勢,拿來當主力開發機器反而不方便。

只要在程式寫完的數小時至數天內立即進行異質平台的編譯和測試,我們對程式碼的印象仍然是清晰的,根據平台差異性修改程式碼並不會太困難。

使用標準 C 而非 C 編譯器特有的特性

C 編譯器有時會在標準 C 之外加入額外的特性,像是 GNU extension。C 編譯器加入編譯器特有的特性是為了讓寫程式更方便。但是,我們應該避免這些 C 編譯器特有的特性,因為這些標準 C 以外的特性會使得 C 專案的可攜性變差。

以 GNU extension 為例,雖然 GCC 是相當普遍的 C 編譯器,除非我們確知自己的 C 專案只會用 GCC 來編譯,否則我們不應在 C 程式碼中使用 GNU extension。另外一個例子是 Visual C++ 中以底線開頭的函式,如 _open() 函式。這些函式是 Visual C++ 特有的,不具可攜性,故不應在 C 程式碼中使用。

使用目標平台預設的 C 編譯器

當我們在目標平台上編譯及測試 C 專案時,應優先使用目標平台優先的 C 編譯器,尤其是函式庫專案。以下是常見的使用情境:

  • Windows 的 Visual C++
  • macOS 的 Clang
  • GNU/Linux 的 GCC

即使 Visual C++ 在 C 標準上相對落後,當我們把 Windows 視為目標平台時,還是應該優先使用 Visual C++,而不是預期專案使用者會去使用 MinGW 或 C++ Builder。因為 Visual C++ 在 Windows 仍然有一定的市佔率。

刻意地使用不同的 C 編譯器來編譯 C 專案,對於 C 專案來說是有益處的。當我們使用 C 編譯器編譯及測試專案時,就等同於讓 C 編譯器檢查一次該專案。當我們在開發早期就使用不同 C 編譯器來編譯專案,就可以及早發現專案中的可攜性問題。

善用跨平台函式庫解決常見的任務

在撰寫 C 程式時,總有一些具有共通性的子任務,像是 XML 或 JSON 文件的解析,這些常見的任務通常都會有一至多個可用的函式庫。即使我們有能力自己造輪子,我們還是應該優先使用現有的函式庫。因為這些函式庫等於是其他程式設計者測試過確認可行的方案,我們就不需要重新踩這項任務潛在的坑。

但是,對於所謂的中大型跨平台函式庫或是框架,就要謹慎選擇和使用。因為這些跨平台方案往往引入過多的外部程式碼,而我們通常只需要其中一小部分而已。例如,我們不需要為了使用剪貼簿 (clipboard) 去引入整個 GUI 函式庫。替代性的做法是使用 libclipboard 這類輕量級的函式庫。

如果沒有適合的跨平台函式庫時,可能就要自己做一個。我們會在後文介紹撰寫跨平台函式庫的相關議題。

使用 Unicode 處理文字

如果專案需要處理多國文字,應優先使用 Unicode,尤其是 UTF-8,而不是 Big5 等區域編碼。雖然 Unicode 無法處理中文罕見字或其他較少見的語言,Unicode 已經是目前受到認可的多國文字處理方案。一些新興編譯語言,包括 Golang 和 Rust,都在字串型態直接採用 UTF-8。

將核心功能寫成函式庫

如果我們在學 C 程式時,一開始就直接學 Windows API,很容易就寫出 UI 和程式邏輯混在一起的程式碼。因為 Windows API 的教學往往假定程式設計者的 C 程式只會在 Windows 上編譯和執行,不會去考慮跨平台的議題。

但是,我們在撰寫 C 專案時,應該在一開始就注意跨平台的議題。把和 UI 無關的部分獨立出來,以函式庫的方式來包裝。即使該函式庫不對外公開,日後我們要使用同一段程式邏輯時,就可以共用相同的程式碼,只要重新撰寫 UI 的部分即可。如果日後想橋接其他高階語言,也可以直接以動態函式庫的形式重覆使用同一段程式碼。

用 wrapper 隔離平台特有的 C 程式碼

在撰寫 C 程式時,總是有一些系統特有的 C API,這些 C API 在本質上就不是跨平台的,我們無法自動讓這些 C API 自動跨平台。

這時候,我們不應該直接在 C 程式中直接呼叫這些特定平台的 C API,而應該額外寫一些函式,把這些 C API 封裝起來。在函式內部使用條件編譯區隔特定平台的 C API,但在函式外部則用一致的界面來呼叫這些 C API。即使日後需要擴充到新的平台,只要修改條件編譯的部分即可,將需修改的地方降至最低。

這種用來封裝平台異質性的函式,一般通稱為 wrapper。

將使用者介面相關的 C 程式碼區隔開來

在撰寫程式時,使用者介面往往是異質性最高的。網頁程式、桌面程式、行動程式的使用者介面本質上就是相異的,硬要用相同的程式碼直接去轉換,就只能取得各個平台的交集,無法善用各個平台的特性。

由於不同平台間的使用者介面之間的差異過大,最簡單的方式反而是為不同平台各自寫一份程式碼,然後在編譯專案時只編譯和該平台相關的部分即可。所以本文會在先前的內容中建議將程式邏輯的部分分離出來,寫成函式庫,就是為了因應這樣的開發模式。

有些硬派的程式人會拒絕任何形式的跨平台使用者介面函式庫,但筆者認為這樣的函式庫仍有其市場。然而,考量平台間的異質性,應該對網頁程式、桌面程式、行動程式各用一套函式庫,而不是妄想用同一套函式庫通吃所有使用者介面。

Windows 特有的議題

MSVC (Visual C++) 程式中定義了數個非標準的主函式 (main function)。像是:

void main(void)
{
    /* Implement code here. */
}

這個主函式可以直接以標準 C 的主函式來取代,所以不應該在 C 程式中使用。

或者是為了使用 Unicode 所使用的主函式:

int _tmain(int argc, _TCHAR *argv[])
{
    /* Implement code here. */
}

以 Visual C++ 編譯此終端機程式時,開啟 UNICODE_UNICODE 編譯器常數,這時候 _TCHAR 會等同於 wchar_t (寬字元),就可以在 C 程式中自動使用寬字元。

或者是在 Windows 桌面程式使用的主函式:

int WINAPI WinMain(
    HINSTANCE hInstance,
    HINSTANCE hPrevInstance,
    PWSTR pCmdLine, int nCmdShow)
{
    /* Implement code here. */
}

由於這些主函式都是 MSVC 特有的,這些主函式都無法跨平台。如果想要使用這些主函式,最好為 MSVC 獨立寫一個主程式,並在使用 Visual C++ 編譯時指定編譯該程式。然後,另外以標準 C 寫另一個符合標準 C 的主程式。因為我們在看到 Windows 特有的主函式時,就會預期裡面的 C 程式碼是 Windows API 特有的。刻意使用條件編譯反而會讓主程式看起來雜亂且難以維護。

由於可知,將主程式及函式庫切開的撰碼模式相當重要。當我們下意識地將應用程式和核心功能切開時,就可以重覆實作主程式,但不至於重覆過多的程式碼。必要時,也可以為 Windows 特有的主程式多寫一些 wrapper 函式。

結語

在本文中,我們介紹了數個和撰寫跨平台 C 程式相關的議題。這些內容專注在原則和概念,而缺乏實際的程式碼。但讀者仍然可以透過本文的內容來了解如何撰寫跨平台 C 程式碼。在觀摩別人寫的跨平台 C 專案時,也可以藉由本文所提出的原則,觀察他人如何撰寫跨平台 C 程式。

關於作者

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

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