概述
今天聊聊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++的可移植性和跨平台開發(長文)的詳細內容。更多資訊請關注PHP中文網其他相關文章!