C++的可移植性和跨平臺開發
概述
今天聊聊C++的可移植性問題。如果你平時使用C++進行開發,並且你對C++的可移植性問題不是非常清楚,那麽我建議你看看這個系列。即使你目前沒有跨平臺開發的需要,了解可移植性方面的知識對你還是很有幫助的。
C++的可移植性這個話題很大,包括了編譯器、操作系統、硬件體系等很多方面,每一個方面都有很多內容。鑒於本人能力、精力都有限,只能介紹每一個方面最容易碰到的問題,供大夥兒參考。
後面我會分別從編譯器、C++語法、操作系統、第三方庫、輔助工具、開發流程等方面進行介紹。
編譯器
在跨平臺的開發過程中,很多問題都和編譯器有關。因此我們先來聊聊編譯器相關的問題。
編譯器的選擇
首先,GCC是優先要考慮支持的,因為幾乎所有操作系統平臺都有GCC可用。它基本上成了一個通用的編譯器了。如果你的代碼在A平臺的GCC能夠編譯通過,之後拿到B平臺用類似版本的GCC編譯,一般也不會有太大問題。因此GCC是肯定要考慮支持的。
其次,要考慮是否支持本地編譯器。所謂本地編譯器就是操作系統廠商自產的編譯器。例如:相對於Windows的本地編譯器就是Visual C++。相對於Solaris的本地編譯器就是SUN的CC。如果你對性能比較敏感或者想用到某些本地編譯器的高級功能,可能就得考慮在支持GCC的同時也支持本地編譯器。
編譯警告
編譯器是程序員的朋友,很多潛在的問題(包括可移植性),編譯器都是可以發現並給出警告的,如果你平時註意這些警告信息,可以減少很多麻煩。因此我強烈建議:
1把編譯器的警告級別調高;
2不要輕易忽略編譯器的警告信息。
交叉編譯器
交叉編譯器的定義參見“維基百科”。通俗地說,就是在A平臺上編譯出運行在B平臺上的二進制程序。假設你要開發的應用是運行在Solaris上,但是你手頭沒有能夠運行Solaris的SPARC機器,這時候交叉編譯器就可以派上用場了。一般情況下都使用GCC來制作一個交叉編譯器,限於篇幅,這裏就不深入聊了。有興趣的同學可以參見“這裏”。
異常處理
上一個帖子“語法”由於篇幅有限,沒來得及聊異常,現在把和異常相關的部分單獨拿出來說一下。
小心new分配內存失敗
早期的老式編譯器生成的代碼,如果new失敗會返回空指針。我當年用的Borland C++ 3.1似乎就是這樣的,現在這種編譯器應該不多見了。如果你目前用的編譯器還有這種行為,那你就慘了。你可以考慮重載new操作符來拋出 bad_alloc異常,便於進行異常處理。
稍微新式一點的編譯器,就不是僅僅返回空指針了。當new操作符發現內存告急,按照標準的規定(參見C++ 03標準18.4.2章節),它應該去調用new_handler函數(原型為typedef void (*new_handler)();)。標準建議new_handler函數幹如下三件事:
1、設法去多搞點內存來;
2、拋出bad_alloc異常;
3、調用abort()或者exit()退出進程。
由於new_handler函數是可以被重新設置的(通過調用set_new_handler),所以上述的行為它都可能有。
綜上所述,new分配內存失敗,有可能三種可能:
1、返回空指針;
2、拋出異常;
3、進程立即終止。
如果你希望你的代碼具有較好的移植性,你就得把這三種情況都考慮到。
慎用異常規格
異常規格在我看來不是一個好東西,不信可以去看看《C++ Coding Standards - 101 Rules, Guidelines & Best Practices》的第75條。(具體有哪些壞處以後專門開一個C++異常和錯誤處理的帖子來聊)言歸正傳,按照標準(參見03標準18.6.2章節),如果一個函數拋到外面的異常沒有包含在該函數的異常規範中,那麽應該調用unexcepted()。但是並非所有編譯器生成的代碼都遵守標準(比如某些版本的VC編譯器)。如果你的需要支持的編譯器在異常規範上的行為不一致,那就得考慮去掉異常規範聲明。
不要跨模塊拋出異常
此處說的模塊是指動態庫。如果你的程序包含有多個動態庫,不要把異常拋到模塊的導出函數之外。畢竟現在C++還沒有ABI標準(估計將來也未必會有),跨模塊拋出異常會有很多不可預料的行為。
不要使用結構化異常處理(SEH)
如果你從來沒有聽說過SEH,那就當我沒說,跳過這段。如果你以前習慣於用SEH,在你打算寫跨平臺代碼之前,要改掉這個習慣。包含有SEH的代碼只能在Windows平臺上編譯通過,肯定無法跨平臺的。
關於catch(...)
照理說,catch(...)語句只能夠捕獲C++的異常類型,對於訪問違例、除零錯等非C++異常是無能為力的。但是某些情況下(比如某些VC編譯器),諸如訪問違例、除零錯也可以被catch(...)捕獲。所以,你如果希望代碼移植性好,就不能在程序邏輯中依賴上述catch(...)的行為。
硬件體系相關
這次聊的話題主要是和硬件體系有關的。比如你的程序需要支持不同類型的CPU(x86、SPARC、PowerPC),或者是同種類型不同字長的CPU(比如x86和x86-64),這時候你就需要關心一下硬件體系的問題。
基本類型的大小
C++中基本類型的大小(占用的字節數)會隨著CPU字長的變化而變化。所以,假如你要表示一個int占用的字節數,千萬不要直接寫“4”(順便說一下,直接寫“4”還犯了Magic Number的大忌,詳見這裏),而應該寫“sizeof(int)”;反過來,如果你要定義一個大小必須為4字節的有符號整數,也不要直接用int,要用預先typedef好的定長類型(比如boost庫的int32_t、ACE庫的ACE_INT32、等)。
差點忘了,指針的大小也有上述的問題,也要小心。
字節序
如果你沒聽說過“字節序”這玩意兒,請看“維基百科”。通俗地打個比方,在一個大尾序的機器上有一個4字節的整數0x01020304,通過網絡或者文件傳到一臺小尾序的機器上就會變成0x04030201;據說還有一種中尾序的機器(不過我沒接觸過),上述整數會變成0x02010403。
如果你編寫的應用程序中涉及網絡通訊,一定要在記得進行主機序和網絡序的翻譯;如果涉及跨機器傳輸二進制文件,也要記得進行類似的轉換。
內存對齊
如果你不曉得“內存對齊”是什麽東東,請看“維基百科”。簡單來說,出於CPU處理上的性能考慮,結構體中的數據不是緊挨著的,而是要空開一些間隔。這樣的話,結構體中每個數據的地址正好都是某個字長的整數倍。
由於C++標準中沒有定義內存對齊的細節,因此,你的代碼也不能依賴對齊的細節。凡是計算結構體大小的地方,都老老實實寫上sizeof()。
有些編譯器支持#pragma pack預處理語句(可以用來修改對齊字長),不過這種語法不是所有編譯器都支持,要慎用。
移位操作
對於有符號整數的右移操作,有些系統默認使用算數右移(最高的符號位不變),有些默認使用邏輯右移(最高的符號位補0)。所以,不要對有符號整數進行右移操作。順便說一下,即使沒有移植性問題,代碼中也盡量少用移位運算符。那些企圖用移位運算來提高性能的同學更要註意了,這麽幹不但可讀性很差,而且吃力不討好。只要不太弱智的編譯器,都會自動幫你搞定這種優化,無須程序員操心。
操作系統
上一個帖子提到了“硬件體系”相關的話題,今天來說說和操作系統相關的話題。C++跨平臺開發中和OS相關的瑣事挺多,所以今天會啰嗦比較長的篇幅,請列位看官見諒 :-)
為了不繞口,以下把Linux和各種Unix統稱為Posix系統。
文件系統(FileSystem以下簡稱FS)
剛開始搞跨平臺開發的新手,多半都會碰上和FS相關的問題。所以先來聊一下FS。歸納下來,開發中容易碰上的FS差異主要有如下幾個:目錄分隔符的差異;大小寫敏感的差異;路徑中禁用字符的差異。
為了應對上述差異,你要註意如下幾點:
1、文件和目錄命名要規範
在給文件和目錄命名時,盡量只使用字母和數字。不要在同一個目錄下放兩個名稱相似(名稱中只有大小寫不同,例如foo.cpp與Foo.cpp)的文件。不要使用某些OS的保留字(例如aux、con、nul、prn)作文件名或目錄名。
補充一下,剛才說的命名,包括了源代碼文件、二進制文件和運行時創建的其它文件。
2、#include語句要規範
當你寫#include語句時,要註意使用正斜線“/”(比較通用)而不要使用反斜線“\”(僅在Windows可用)。#include語句中的文件和目錄名要和實際名稱保持大小寫完全一致。
3、代碼中涉及FS操作,盡量使用現成的庫
已經有很多成熟的、用於FS的第三方庫(比如boost::filesystem)。如果你的代碼涉及到FS的操作(比如目錄遍歷),盡量使用這些第三方庫,可以幫你省不少事情。
★文本文件的回車CR/換行LF
由於幾個知名的操作系統對回車/換行的處理不一致,導致了這個煩人的問題。目前的局面是:Windows同時使用CR和LF;Linux和大部分的Unix使用LF;蘋果的Mac系列使用CR。
對於源代碼管理,好在很多版本管理軟件(比如CVS、SVN)都會智能地處理這個問題,讓你從代碼庫取回本地的源碼能適應本地的格式。
如果你的程序需要在運行時處理文本文件,要留意本文方式打開和二進制方式打開的區別。另外,如果涉及跨不同系統傳輸文本文件,要考慮進行適當的處理。
★文件搜索路徑(包括搜索可執行文件和動態庫)
在Windows下,如果要執行文件或者加載動態庫,一般會搜索當前目錄;而Posix系統則不盡然。所以如果你的應用涉及到啟動進程或加載動態庫,就要小心這個差異。
★環境變量
對於上述提到的搜索路徑問題,有些同學想通過修改PATH和LD_LIBRARY_PATH來引入當前路徑。假如使用這種方法,建議你只修改進程級的環境變量,不要修改系統級的環境變量(修改系統級有可能影響到同機的其它軟件,產生副作用)。
★動態庫
如果你的應用程序使用動態庫,強烈建議動態庫導出標準C風格的函數(盡量不要導出類)。如果在Posix系統中加載動態庫,切記慎用RTLD_GLOBAL標誌位。這個標誌位會Enable全局符號表,有可能會導致多個動態庫之間的符號名沖突(一旦碰到這種事,會出現匪夷所思的運行時錯誤,極難調試)。
★服務/看守進程
如果你不清楚服務和看守進程的概念,請看維基百科(這裏和這裏)。為了敘述方便,以下統稱服務。
由於C++開發的模塊大部分是後臺模塊,經常會碰到服務的問題。編寫服務需要調用好幾個系統相關的API,導致了與操作系統的緊密耦合,很難用一套代碼搞定。因此比較好的辦法是抽象出一個通用的服務外殼,然後把業務邏輯代碼作為動態庫掛載到它下面。這樣的話,至少保證了業務邏輯的代碼只需要一套;服務外殼的代碼雖然需要兩套(一個用於Windows、一個用於Posix),但他們是業務無關的,可以很方便地重用。
★默認棧大小
不同的操作系統,棧的默認大小差別很大,從幾十KB(據說Symbian只有12K,真摳門)到幾MB不等。因此你事先要打聽一下目標系統的默認棧大小,如果碰上像Symbian這樣摳門的,可以考慮用編譯器選項調大。當然,養成“不在棧上定義大數組/大對象”的好習慣也很重要,否則再大的棧也會被撐爆的。
多線程
最近一個多月寫的帖子比較雜,導致本系列又好久沒更新了。結果又有網友在評論中催我了,搞得我有點囧。今天趕緊把多線程篇補上。上次聊操作系統 的時候,由於和OS有關的話題比較瑣碎,雜七雜八說了一大堆。當時一看篇幅有點長,就把多進程和多線程的部分給留到後面了。
★編譯器
◇關於C運行庫選項
先來說一個很基本的問題:關於C運行庫(後面簡稱CRT:C Run-Time)的設置。本來不想聊這麽低級的問題,但周圍有好幾個人都在這個地方吃過虧,所以還是講一下。
大部分C++編譯器都會自帶有CRT(可能還不止一個)。某些編譯器自帶的CRT可能會根據線程的支持分為單線程CRT和多線程CRT兩類。當你要進行多線程開發的時候,別忘了確保相關的C++工程項目使用的是多線程的CRT。否則會死得很難看。
尤其當你使用Visual C++創建工程項目,更加要小心。如果新建的工程項目是不含MFC的(包括Console工程和Win32工程),那工程的默認設置會是使用“單線程CRT”,如下圖所示:
◇關於優化選項
“優化選項”是另一個很關鍵的編譯器相關話題。有些編譯器提供號稱很牛X的優化選項,但是某些優化選項可能會有潛在的風險。編譯器可能自作主張打亂執行指令的順序,從而導致出乎意料的線程競態問題(Race Condition,詳細解釋看“這裏 ”)。劉未鵬同學在“C++多線程內存模型 ”裏舉了幾個典型的例子,大夥兒可以去瞧一瞧。
建議只使用編譯器常規的速度優化選項即可。其它那些花哨的優化選項,增加的效果未必明顯,但是潛在的風險不小。實在不值得冒險。
以GCC為例:建議用-O2 選項即可(其實-O2 是一堆選項的集合),沒必要冒險用-O3 (除非你有很充足的理由)。除了-O2 和-O3 之外,GCC還有一大坨(估計有上百個)其它的優化選項。如果你企圖用當中的某個選項,一定要先把它的特性、可能的副作用都摸清楚,否則將來死都不知道怎麽死的。
★線程庫的選擇
由於當前的C++ 03標準幾乎沒有涉及線程相關的內容(即使將來C++ 0x包含了線程的標準庫,編譯器廠商的支持在短期內也未必全面),所以在未來很長的一段時間,跨平臺的多線程支持還是要依賴第三方庫。所以線程庫的選擇是大大滴重要。下面大致介紹一下幾個知名的跨平臺線程庫。
◇ACE
先說一下ACE這個歷史悠久的庫。如果你之前從未接觸過它,先看“這裏 ”掃盲。從ACE的全稱(Adaptive Communication Environment)來看,它應該是以“通訊”為主業。不過ACE對“多線程”這個副業的支持還是非常全面的,比如互斥鎖(ACE_Mutex)、條件變量(ACE_Condition)、信號量(ACE_Semaphore)、柵欄(ACE_Barrier)、原子操作(ACE_Atomic_Op)等等。對某些類型比如ACE_Mutex還細分為線程讀寫鎖(ACE_RW_Thread_Mutex)、線程遞歸鎖(ACE_Recursive_Thread_Mutex)等等。
除了支持很全面,ACE還有另一個很明顯的優點,就是對各種操作系統平臺及其自帶的編譯器支持很好。包括一些老式的編譯器(比如VC6),它也能夠支持(此處所說的支持 ,不光是能編譯通過,而且要能穩定運行)。這個優點對於跨平臺開發那是相當相當滴明顯。
那缺點捏?由於ACE開工的年頭很早(大概是上世紀九十年代中期),那會兒很多C++的老特性都還沒出來(更別提新特性了),所以感覺ACE整個的風格比較老氣,遠不如boost那麽時髦前衛。
◇boost::thread
boost::thread正好和ACE形成鮮明對照。這玩意貌似從boost 1.32版本開始引入,年頭比ACE短。不過得益於boost裏一幫大牛的支持,發展還是蠻快的。到目前的boost 1.38版本,也能夠支持許多特性了(不過似乎沒ACE多)。鑒於很多C++標準委員會的成員雲集在boost社區中,隨著時間的推移,boost::thread終將成為C++線程的明日之星,前途無量啊!
boost::thread的缺點就是支持的編譯器不夠多,尤其是一些老式 編譯器(很多boost的子庫都有此問題,多半因為用了一些高級的模板語法)。這對於跨平臺而言一個比較明顯的問題。
◇wxWidgets 和QT
wxWidgets和QT都是GUI界面庫,但是它們也都內置和對線程的支持。wxWidgets線程的簡介可以看“這裏 ”,關於QT線程的簡介可以看“這裏 ”。這兩個庫對線程的支持差不多,都提供了諸如mutex、condition、semaphore等常用的機制。不過特性沒有ACE豐富。
◇如何權衡
對於開發GUI軟件並已經用上了wxWidgets或者QT,那你可以直接用它們內置的線程庫(前提是你只用到基本的線程功能)。由於它們內置的線程庫,特性稍嫌單薄。萬一你需要某高級的線程功能,那得考慮替換成boost::thread或ACE。
至於boost::thread和ACE的取舍,主要得看軟件的需求了。如果你要支持的平臺挺多挺雜,那建議選用ACE,以免碰上編譯器不支持的問題。如果你只需要支持少數幾個主流的平臺(比如Windows、Linux、Mac),那建議用boost::thread。畢竟主流操作系統上的編譯器,對boost的支持還是蠻好的。
★編程上的註意事項
其實多線程開發,需要註意的地方挺多的,我只能大致列幾個印象比較深的註意事項。
◇關於volatile
說到多線程編程可能碰到的陷阱,那就不得不提到volatile 關鍵字。如果你對它還不甚了解,先看“這裏 ”掃盲一下。由於C++ 98和C++ 03標準都沒有定義多線程的內存模型,而標準中也就volatile 和線程沾點兒邊。結果導致C++社區中有相當多的口水都集中在volatile 身上(其中有不少C++大牛的口水)。有鑒於此,我這裏就不再多啰嗦了。推薦幾個大牛的文章:Andrei Alexandrescu 的文章“這裏 ”、還有Hans Boehm的文章“這裏 ”和“這裏 ”。大夥兒自個兒去拜讀一下。
◇關於原子操作
有些同學光知道多個線程的競爭寫 需要加鎖,卻不知道多個讀 單個寫 也需要保護。比如有某個整數int nCount = 0x01020304;在並發狀態下,一個寫線程去修改它的值nCount = 0x05060708;另一個讀線程去獲取該值。那麽讀線程有沒有可能讀取到一個“壞”的(比如0x05060304)數據捏?
數據是否壞掉,取決於對nCount的讀和寫是否屬於原子操作。而這就依賴於很多硬件相關的因素了(包括CPU的類型、CPU的字長、內存對齊的字節數等)。在某些情況下,確實可能出現數據壞掉。
由於我們討論的是跨平臺的開發,天曉得將來你的代碼會在啥樣的硬件環境下執行。所以在處理類似問題的時候,還是要用第三方庫提供的原子操作類/函數(比如ACE的Atomic_Op)來確保安全。
◇關於對象的析構
在之前的系列帖子“C++對象是怎麽死的? ”裏面,已經分別介紹了Win32平臺和Posix平臺下線程的非自然死亡問題。
由於上述幾個跨平臺的線程庫底層還是要調用操作系統自帶的線程API,所以大夥兒還是要盡最大努力確保所有線程都能夠自然死亡。
C++的可移植性和跨平臺開發