在嵌入式裝置上使用 C++ 進行開發:概念篇

雖然我比較喜好 C 語言, 但從我轉換跑道進入嵌入式開發以來,卻常常壓抑不住想要把 C++ 程式碼放進產品專案裡面的想法! 當然我會有這樣的想法是有原因的。

我常想在嵌入式環境上使用 C++ 開發程式,哪怕只是其中一部份的程式碼也好! 這個想法常常涌上我的心頭,特別是當我埋首在複雜到快到天上的程式碼中的時候, 只是礙於一些行規而遲遲沒有付諸行動,現在想想真是不知道當時在堅持什麼啊! 總之,接下來就要分享一些我在這方面上面的想法。 而這篇文章也比較偏重關於想法、概念的東西,至於一些技術細節就留待之後再做詳解。

(一些技術細節請參考 「在嵌入式裝置上使用 C++ 進行開發:G++ 實做篇」 與 「C 呼叫 C++ 函式的方法」 )

嵌入式裝置上的困難

在說 C++ 之前,先來了解一下在嵌入式裝置上開發程式到底都有什麼困難? 這樣才能清楚的知道我們要面對的是什麼問題。

在一般的 PC 環境上,使用 C++ 開發程式並沒有什麼太多的問題,除非你是要和 Linus Torvalds 共事! 但在嵌入式裝置上,由於硬體尺寸、價格、耗電等各種要求使得裝置的硬體資源可能相當拮据, 而這成為了幾乎所有開發瓶頸的根本問題來源:

  • 記憶體容量低

    以目前來說,業界常見的嵌入式裝置裡能夠使用的記憶體大約在數十 MB 左右, 有些特別便宜小巧的單晶片甚至只有數百位元組。 因此記憶體的使用錙銖必較,像 Java 這種採用動態記憶體配置和垃圾蒐集機制的怪獸 便不太適合使用在這種場合。

  • 儲存空間容量低

    不像 PC 一般都把檔案存放在硬碟,嵌入式裝置可以用來儲存資料的裝置通常是像 SPI flash 或 EEPROM 之類,其容量通常約在十來 MB 到數百 KB 之間。 這代表你沒有空間亂塞一堆檔案,不能肆無忌憚打上龐大的程式庫, 也不能寫太多程式碼,否則編譯出來的程式會大到燒不進去!

  • 運算效能低

    目前常見嵌入裝置的 CPU 時脈約在數百 MHz 左右,運算速度不快。 我曾經玩過一個時脈大約 200 MHz 的設備,使用 OpenSSL 連接一個網站 直到 SSL 交握完成竟然要花費一分多鐘! 後來改用其他比較講究效能的程式庫才把連線時間降低到可接受的程度。

  • 軟體環境功能少

    比較大型的嵌入式裝置一般使用極度裁剪過的 Linux 核心, 所以可能會缺少一些平常覺得很普通的功能, 比如說網路功能、檔案系統支援、時區、以及其他平常可能蠻好用的一些工具程式等等。 當然這也是為了減少系統體積而做出的刪減需求,但確實在某些時候會讓開發除錯上比較不方便。 還有一些裝置連作業系統都放不下,所以什麼功能都要自己來, 光是要做出 TCP/IP 網路就可以先折騰好幾個三天三夜!

當然,我上面描述的嵌入式裝置是指大部份可用在商業產品上面的嵌入式裝置, 而不是像樹莓派這種擁有超高硬體資源的東西。 有些裝置如樹莓派等,雖名曰嵌入式裝置, 但其上之作業環境與軟硬體資源已經和一般桌上電腦沒有太多不同, 有些裝置甚至連 Windows 10 都可以安裝使用。 對於這樣效能強大的裝置,基本上完全沒有必要去糾結 C 和 C++ 的差異, 就算想要用 Java 還是 Python 之類的東西來開發應用,我想也都不是什麼問題了!

為什麼一般不喜歡在嵌入式裝置上使用 C++?

在產業界確實一般不常見、或者內規不允許在嵌入式裝置上寫 C++ 程式,大約有幾個原因:

  1. C++ 性能不佳

    「C++ 程式通常執行上的效能比 C 差得多、記憶體也用得兇。」

    這句話其實既對也不對! 反駁的人常常會說 C++ 幾乎完全相容於 C,C 能使用的寫法沒道理在 C++ 沒辦法寫, 那麼編譯出來的不管是效能、記憶體使用、或是程式大小等等不應該與 C 有別, 只有可能是寫程式的人功力的問題! 這些理由是確實的,也是為什麼我可以在嵌入式裝置寫 C++ 的最大原因。

    但另一方面,寫 C++ 不就是為了 C++ 一大堆語法上的方便、 以及豐富好用的標準程式庫元件比如像是種類眾多的容器嗎? 如果我使用 C++ 編譯器,卻寫的全是純 C 的東西,那又何必呢? 這樣我直接使用 C 編譯器不是更輕便? 因此我也讚同 C++ 在一般的應用場景下會比 C 來的肥又慢這種說法。

  2. 業界缺乏熟練 C++ 的專業人員

    雖然 C++ 也算歷史悠久、也不是太冷門的語言,但觀察臺灣在需要嵌入式開發的製造業裡面, 確實少見熟悉 C++ 與近代程設概念如物件導向和設計模式等的開發人員。 這些研發人員多是從電子電機、單晶片程式等一路學過來的人, 雖在電子軟硬體方面擁有難以取代的專業知識,卻顯少接受過純軟體知識概念上的教育, 導致普遍缺乏比較花俏現代的高階語言和其他軟體技能。 在這種環境下,有時並不是 C++ 真的不好,而是如果真的使用了 C++ 的話, 會讓一票人難以閱讀理解,後續維護上也會存在困難。

  3. 缺乏 C++ 編譯器

    有時候無法使用 C++ 並不是什麼其他的問題,而是開發環境就沒有 C++ 編譯器可以使用! 嵌入式開發環境千千萬萬種,並不是所有的方案都有 Linux & G++ 方案, 或者是 C++ 編譯器是選用工具且價格高,比如說 Keil 的工具等。 但是遇到這種狀況其實很好解決,因為你根本就沒有 C++ 可以用, 所以就不用糾結了,趕快回去想辦法用好 C 編譯器吧!

為什麼想要在嵌入式裝置上使用 C++

既然 C 語言可用且被廣泛使用,又為何想要在嵌入式裝置上面使用 C++ 程式呢? 簡單一句話總結就是現在電子裝置產品功能愈來愈複雜多樣的緣故!

因為產品需求愈來愈豐富多變,產品程式碼也就愈來愈複雜, 迫切需要導入物件導向等設計方法才能妥善應付現代商場所需要的變化, 而物件導向正是 C 語言的硬傷。 當然,並不是無法使用 C 語言寫作物件導向式程式, 最經典的案例大概就是 GTK+ 的程式碼,其使用各種技巧, 使得以 C 語言實做的 GTK+ 能擁有各種物件導向語言的特性。 然而實用層面上,簡單的類別封裝還好處理,我本人一直以來也都使用了這些技巧, 可是一碰到多型就完蛋了! 你看看那些用 C 語言模擬虛函式、模擬繼承、處理父類別轉子類別的轉型方法, 只能用一句話來形容,就是複雜又難以維持!

那麼與其用 C 語言來硬生生的模擬這些 C++ 特性 – 特別是多型的特性, 不只製做複雜,編寫完後其他人也難以理解維護,反而容易成為蟲子的溫床, 這時直接改用 C++ 來寫反而是更好的解決方案,並且效能還更好! 畢竟在物件導向特性橫豎都需要的情況下, 從應用程式碼手工模擬的方式要怎麼樣才能比從編譯器層面實做來的更好?

雖然討論的是嵌入式領域,但到這裡,我們還是要再進一步的限縮我們所討論的嵌入式裝置範圍。 我們要把那些計算資源極度匱乏的裝置排除在外, 比如那些記憶容量只有數百到數千位元組的單晶片裝置等。 因為在這些裝置上,即便用 C 語言來寫程式也是非常艱困的, 也許你更因該考慮在部份的程式碼上使用組合語言。 再者使用這種類型硬體的場合多半也不會拿來做什麼太過於複雜的事情,因此沒有需求。 而另一個極端,就是那些雖名為嵌入式卻擁有可與 PC 相比之計算能力的裝置,也已被我們早先排除, 因為在這種裝置上已經不需要去糾結 C 與 C++ 的差異了!也許你更應該考慮使用 Android + Java! 最終,這次主題所聚焦的嵌入式裝置, 大約就是那些記憶容量以 MB 計、CPU 時脈約在數百 MHz, 可能包含一些週邊設備如網路與串列埠的,常被使用在各種終端設備上的嵌入式裝置。

C++ 有什麼不同?

既然我考慮在通常使用 C 語言的地方使用 C++, 那麼就需要去分析 C++ 與 C 相比到底有哪些不同,這樣我們才能清楚到底要解決或迴避哪些問題? 因此下面的差異比較將會集中在那些會對於效能與空間造成影響的觀點層面。

首先我們先來看看,那些一般可能感覺上有差,然而實際上無關緊要的部份:

  • C++ 類別

    C++ 的類別(class)其實與 C 的結構(struct)是一模一樣的東西,對於編譯後的結果沒有任何影響。

  • C++ 成員權限控管

    C++ 類別內的權限控管關鍵字如 public、private、和 protected 等, 全都是編譯時期由編譯器來過濾的東西,所以依然沒有任何影響。

  • C++ 成員函式

    C++ 類別成員函式實際上也與 C 函式完全一樣, 函式對類別的綁定也是編譯時期的工作,不影響編譯後的成品。 唯一的不同在於成員函式多了一個參數,用來隱式傳遞物件實體指標, 然而使用 C 語言寫的函式也同樣需要傳遞結構指標,所以其實沒有太多差別。

  • C++ 同名函式重載

    C++ 的同名函式重載是在編譯時期由編譯器處理的, 編譯後的每個函式其實都會變成名稱不一樣的普通函式,所以沒有任何影響。

  • C++ 函式參數預設值

    C++ 支援的函式參數預設值一樣是編譯時期處理完畢的東西,所以沒有任何影響。

  • C++ 名稱空間

    C++ 的名稱空間亦為編譯時期由編譯器所控管的機制,所以沒有任何影響。

接下來再看看那些實際上真的有些不同,只是其效果不一定造成太大影響的部份:

  • 虛擬函式

    虛擬函式主要造成的影響是關於執行效能的部份, 虛擬函式的呼叫比起一般函式還多了一個虛擬函式表查詢工作,確實會讓函式的呼叫慢了點。 並且因為虛擬函式是執行時期的動態係結函式, 所以編譯器可能比較難在這上面去做比較有效的最佳化行為, 沒辦法事先進行函式展開、函式呼叫濃縮精減等處理。 但如果把必須使用物件導向與多型的程式設計做為前提的話, 比起手工自製的基於函式指標的虛擬函式表模擬來說,C++ 原生的機制顯然更有效率且更不易出錯!

  • 類別建構子與解構子

    C++ 類別支援建構與解構函式的呼叫, 好處是讓人在使用類別的時候,可以少費心在關於物件生命週期的維護上; 而壞處就是編譯器會到處在程式碼安插建構和解構呼叫,使得程式碼尺寸些許膨脹。 但在絕大部份的應用環境裡,這一點程式碼膨脹通常都應該還是在可接受的範圍內!

  • 類別繼承

    C++ 的類別繼承看起來和 C 的結構嵌套是一樣的,但若再加上建構子與解構子的效應那就不一樣了! 比如一個擁有十個父類別的類別,那麼它在建構或解構的時候, 就可能要呼叫十次的建構或解構函式! 當然,一個嵌套了十層的 C 結構很可能也會需要在它的生命週期維護上一層一層去做處理, 所以繼承與建構解構所造成的影響通常還是表現在程式碼膨脹上,而非效能表現上。

    然而多重繼承的情況會讓上述問題更加惡化,此外還附贈讓程式的行為變得更不穩定而難以預測掌握! 後面這部份嚴格上不能說是 C++ 的錯, 但是 C++ 那公認超級複雜的繼承相關規則與行為讓編譯器不容易做得妥善。 即便拋開編譯器的問題,產業界也少有人能夠完整掌握 C++ 的各種規則和例外, 綜合因素使得應用了多重繼承技術的程式碼容易出現一些意料之外的行為, 而這又不是嵌入式應用所樂見的!

    可是解決的方法也很簡單,不要使用多重繼承就好了! 至少到目前為止會複雜到需要動用多重繼承的應用多為 GUI 框架, 然而依據不可靠的經驗顯示,能夠大玩特玩 GUI 的裝置基本上硬體資源是相對充足得多的!

  • 運算子重載

    C++ 允許運算子重載,這會使得有一些情況下的運算子其實是函式呼叫,效能會差。 這嚴格說起來也不能算是運算子重載的錯,因為對於一些本就不單純的操作, 就算用 C 來寫大概也免不了需要函式呼叫,使用運算子重載只能算是語法糖而已, 並不能算是拉低效能的元兇。

    運算子重載真正可怕的地方在於他會隱藏複雜度, 使得一些像是 a + b 這種看上去很單純的操作其實可能背後一點也不單純, 但它的複雜性卻容易在語法糖的表面下被忽略掉,使得你的程式莫名其妙的慢了下來!

    結論是,運算子重載本身在程式編譯結果上並沒有壞的影響,使用得當的話可以讓你的程式更簡單; 它的壞處是表現在容易迷惑人心上面,讓你不自覺你的程式其實很複雜, 因此請小心使用!

終於來到重頭戲! 接下來列舉的就是那些真正成為關鍵因素的重大差異:

  • 模版

    模版的使用很容易造成程式碼的膨脹,因為它等於是到處在各個檔案裡進行程式碼展開, 尤其是如果 C++ 程式碼裡面使用了標準程式庫裡的各種模版的話,絕對會造成程式碼大量膨脹, 因此有必要禁止使用模版相關功能。

  • C++ 執行時庫

    C++ 執行時期程式庫的作用就和 C 執行時庫一樣,用來提供一些程式語言的基楚功能, 其實應該沒什麼問題,但差就差在 C++ 執行時庫通常體積龐大! 以我曾經手過的一臺機器為例,C++ 的程式庫檔案(libstdc++.so) 就有 4.9 MB 大, 而 C 的程式庫檔案(libgcc_s.so) 只有 1.3 MB, 因此若能夠移除 C++ 執行時庫的話,會給原來就很拮据的儲存空間帶來大福音!

    當然,移除了 C++ 執行時庫就代表了很多 C++ 的原生功能無法使用, 比如說標準程式庫的全部功能、以及一些如例外和 RTTI 等 C++ 執行期機制。 但是這些功能我們絕大部分可以用 C 標準程式庫的功能、或代用寫法來替代,所以問題不大, 只要改變寫作習慣即可。 畢竟在導入 C++ 之前,我們已經一直使用 C 相關的習慣用的很嫻熟了不是?

  • 例外機制

    C++、以及許多曾借鑑 C++ 的現代程式語言都存在例外拋接這種錯誤傳遞機制, 這種機制好用歸好用,但在嵌入式裝置上就會明顯的拖慢程式的效能, 並且例外傳遞機制通常需要依賴 C++ 執行時庫的支援。 因此需要禁用 C++ 的例外傳遞機制,改用 C 風格的傳統錯誤傳遞機制如 error code 等; 最多就是使用 setjmp/longjmp 來模擬, 不過要記得在 longjmp 的過程並不會自動解構需要解構的物件!

  • RTTI

    RTTI 是另一個 C++ 的基本功能,它實做了如父類別轉子類別這種時機所需要的型別檢查, 但顯然這種執行時期的檢查工作會拖慢程式效能,並且這種機制也會需要依賴 C++ 執行時庫。 然而停用 RTTI 機制的副作用不大,動態轉型依然還是可以繼續轉, 只是程式不會再幫我們檢查型別,需要依賴程設人員確保這些型態轉換是沒有問題的!

  • 記憶體碎片

    C++ 寫出來的程式常頻繁使用 heap 空間,衍生記憶體碎片化的問題, 尤其在記憶體資源缺乏的嵌入式裝置上更顯嚴重!

    雖然嚴格說起來,這並不是 C++ 的問題, 因為你完全可以在 C++ 程式裡按傳統的路子把東西建立在 stack 或 global 空間上, 但是寫 C++ 程式若不使用 heap 空間的話,使用 C++ 的意義就低很多很多了; 畢竟你會想要在嵌入式裝置上使用 C++,大概九成五就是想要使用 C++ 的多型多態特性是吧? 而多型物件若不是建立在 heap 上面的話,雖並不是寫不出來, 但你看看那寫出來的各種物件管理工作會長成什麼妖豔的樣子? 這就是為什麼我會說 C++ 程式在嵌入式裝置上無法避免記憶體碎片化的問題。

    當然緩解這問題的方法也不是沒有的! 除了儘量不進行不必要的 heap allocation、和加大記憶體空間之外, 也可以透過自行實做 heap 分配邏輯,將不同的記憶體需求配置在不同的記憶體區段上, 緩解碎片化的問題。

  • 與 C 程式互相呼叫

    既然您在思考在產品裡使用 C++ 的可能性,那通常表示您的產品目前都是使用 C 開發的吧? 那麼除非您打算全部翻掉改使用 C++ 重寫(想都知道不可能), 否則一定是想要把一部份的程式碼用 C++ 改寫對吧? 這樣就會需要面對 C 程式碼和 C++ 程式碼互相呼叫的問題了。

    C++ 呼叫 C 函式通常沒有什麼問題,畢竟 C++ 號稱是 C 的超集; 可是反過來,若 C++ 被用來改寫一些底層模組,那上層的 C 程式要怎麼呼叫它們呢? 這就會需要去製做一些 wrapper 函式、甚至可能需要建立一些 wrapper 物件!

解決方案總結

最後來簡單整理一下為了在嵌入式裝置上使用 C++ 編寫程式,建議進行哪些修改調整:

  1. 儘量減少、或最好不要使用模版的寫法。
  2. 不要使用多重繼承與虛擬繼承。
  3. 移除 C++ 執行時庫, 不要使用 C++ 標準函式庫的功能,改使用 C 標準函式庫或其他程式庫的功能來替代。
  4. 關閉編譯器對例外機制的支援,改用 error code 或 setjmp/longjmp 或其他傳統的錯誤傳遞機制。
  5. 關閉編譯器對 RTTI 的支援。

概念上就是這樣,然後就可以盡情的使用 C++ 在嵌入式裝置上奔馳了!

其實從另一個角度來解釋也是可以的: 我們的整份程式碼、整個專案依然固守著 C 語言的一切,只僅僅取用了 C++ 的類別和多型的特性, 好讓一些複雜的部份變得簡單!