程序移植與宏定義

 由於操作系統的差異,同一種操作系統本身版本的差異,目前C++標準庫提供的功能仍然有限以及C++編譯器產品不是完全兼容等問題,使得我們在移植大型應用程序的時候往往會出現很多難以解決的問題,如何合理的避免他們提高C++程序的移植性,本文作者從源代碼的組織安排等方面提出了一些實用的建議。

 

當我們編寫服務器端的軟件產品時,我們往往需要爲同一個軟件產品推出多種不同平臺版本。這是因爲目前還沒有哪個服務器操作系統可以一統天下。有不少服務器運行Windows 操作系統,但運行Linux和各種UNIX操作系統的服務器也很多,而且各種UNIX操作系統之間又有細微的差別。另外,在一些大企業(特別是大銀行)中,運行關鍵業務的服務器往往是IBM 的大型機,它們的操作系統又會和一般的UNIX 有一些不同。

此外,軟件依賴的中間件,調用的函數庫,要求的編譯器,都可看作平臺的一部分。上述內容的任意組合會造成大量的可能性。如果平臺移植性做得不好,那麼很可能軟件在你的開發環境能正常運行,但拿到客戶的環境中會出現各種奇奇怪怪的問題。

或許你會說,這些都不是問題,用Java來寫程序不就一切OK了?不幸的是,有時候一些遺產代碼是用C寫的,或者你必須依賴的某個關鍵函數庫只提供了C API,經過評估又發現用Java重寫,或者通過JNI以及其他可能的跨語言調用機制去封裝這些遺產代碼或者CAPI的工作量太大。那麼這時候C++往往是更合適的選擇。

用Java寫程序可以跨平臺的一大原因是Java有一個無所不包的標準庫,而C++的標準庫只提供了最基本的一些功能。要用C++寫比較大的程序幾乎一定會調用到標準庫之外的API,而這些API未必可以跨平臺。所以,編寫易於移植的C++程序要注意的第一點是:如果能有選擇,那麼儘可能地使用跨平臺的API。

比如,同樣是對文件操作,Win 32 API 和UNIX操作系統提供的文件操作函數各不相同,選哪個呢?都不合適,最好還是依賴標準庫,fstream或者fopen/fclose都可以。要創建線程並進行線程間同步,Win 32 API 和UNIX的做法又不一樣。有沒有跨平臺的解決方案呢?有的,pthreads是跨平臺的。如果你的系統需要有對字符串進行操作,是用MFC提供的CString還是標準庫中的string呢?顯然應該選後者,因爲MFC 不是跨平臺的。

那麼,如果你不得不用到的某些API 沒有跨平臺的實現,只有各個平臺自己的實現,怎麼辦呢?舉個例子,在Windows平臺,加載動態庫是調用LoadLibrary;在UNIX平臺,加載動態庫是調用dlopen。似乎沒有什麼跨平臺的實現。那麼我們怎麼辦?可不可以在每處要加載動態庫的地方都這麼寫?

#ifdef WIN32
HMODULE h = LoadLibrary(“libraryname”);
#elif defined(UNIX)
int h = dlopen(“libraryname”, RTLD_LAZY);
#endif

不少軟件就是這麼做的。但這樣做很糟糕,因爲把平臺相關代碼同其他的平臺獨立代碼混在了一起,而且代碼中會散佈很多的#ifdef,影響閱讀;而且如果稍後需要把代碼移植到另一個平臺,那麼可能需要修改每一處加載動態庫的地方,增加一個#elif defined(?),工作量會比較大。

推薦的做法是:自己封裝一個跨平臺的實現,在平臺獨立代碼中只調用這個跨平臺的API,把平臺相關性隔離出去。當然,這層封裝應該是很薄的,應該只需要用一兩行的inline 函數以及幾個typedef 即可。這樣做的指導思想是,通過封裝來增加間接層次,從而把平臺獨立代碼和平臺相關代碼分離。

下面來看一下,這樣做是不是可以了呢?

在main.cpp 中(假設我們需要在這個文件中加載動態庫)這樣寫:

#include “platform_specific.hpp”

int main() {

handle_t h = MyLoadLibrary("libraryname");

// 之後使用動態庫,然後卸載

}

 

在platform_specific.hpp 中這樣寫:

#ifdef WIN32

typedef HMODULE /* WIN32 handle type */ handle_t;

inline handle_t MyLoadLibrary(const string& libname)

{

return LoadLibrary(libname.c_str());

}

#elif defined(UNIX)
typedef int /* UNIX handle type */ handle_t;
inline handle_t MyLoadLibrary(const string& libname)
{
return dlopen(libname.c_str(), RTLD_LAZY)
}
#endif

這樣確實做到了“把平臺獨立代碼和平臺相關代碼分離”,main.cpp中是平臺獨立代碼,platform_specific.hpp中是平臺相關代碼,兩者分離開來了。移植到新的平臺時不需要對main.cpp做任何修改,只需要修改platform_specific.hpp中的MyLoadLibrary 的實現,而且只要改這一處就可以了。但這樣做的一個問題是,platform_specific.hpp會變得非常混亂,充滿了#ifdef。想象一下,除了MyOpenLibrary,可能還會有MyCloseLibrary,MyBindSymbol,等等,所有自己封裝的跨平臺API(也就是實現中需要寫#ifdef(某種OS)的API)都在裏面了。這個文件會變得難以維護,而且很可能是多個人在維護(每個人負責一個不同的平臺),修改會非常頻繁(特別是如果幾個平臺的版本同步開發的話)。有沒有更好的做法呢?

不妨這樣做:在platform_specific.hpp中,只放這些內容:

#ifdef WIN32

#include “win32_specific.hpp”

#endif

#ifdef UNIX

#include “unix_spefic.hpp”

#endif

 

而把平臺相關的實現部分放在各個平臺自己的頭文件中去。比如,win32_speific.hpp 是這樣的:

typedef HMODULE /* WIN32 handle type */ handle_t;

inline handle_t MyLoadLibrary(const string& libname)

{

return LoadLibrary(libname.c_str());

}

在unix_specific.hpp 是這樣的:

typedef int /* UNIX handle type */ handle_t;

inline handle_t MyLoadLibrary(const string& libname)
{
return dlopen(libname.c_str(), RTLD_LAZY)\
}


這樣就極大地減少了# i f d e f 的數目。除了在platform_specific.hpp中會出現#ifdef(需要支持幾個平臺就有幾個),其他所有文件中都不再需要。而且也分離了關注焦點:負責實現平臺獨立功能的人就專注於編寫和維護main.cpp,而負責移植到各個平臺的人就編寫和維護各自平臺的os_specific.hpp。不會造成多人修改同一個文件的衝突,平臺獨立代碼和平臺相關代碼也得到了很好的分離。

有兩點值得注意:
第一點,platform_specific.hpp中沒有用到#elif,而是用了獨立的#ifdef #endif 塊。這樣做的目的是爲了支持下面這樣的拓撲結構:

#ifdef WIN32

#include “win32_specific.hpp”

#endif

#ifdef WINCE

#include “wince_specific.hpp”

#endif

#ifdef UNIX

#include “unix_spefic.hpp”

#endif

#ifdef SOLARIS

#include “solaris_specific.hpp”

#endif

#ifdef AIX

#include “aix_specific.hpp”

#endif

 

WIN32和WINCE不衝突,WINCE是特殊的WIN32;Solaris和AIX是兩種特殊的UNIX,和UNIX也不衝突。如果用了#elif就無法同時#include,但用上面這種拓撲結構就可以做到,而且可以把各個UNIX平臺都一樣的東西實現在unix_specific.hpp中,而把Solaris和AIX有差異的東西實現在solaris_specific.hpp和aix_specific.hpp 中,實現進一步的平臺細分。

第二點,win32_specific.hpp、unix_specific.hpp等只能用來封裝平臺相關的API,不能包含過多的平臺獨立邏輯。

下面舉一個反例:

在unix_specific.hpp 中:

int main()

{

// 做平臺無關的事情

int h = dlopen(“library”, RTLD_LAZY);

// 繼續做平臺無關的事情

}

在win32_specific.hpp 中:

int main()

{

// 做平臺無關的事情

HMODULE h = LoadLibrary(“library”);

// 繼續做平臺無關的事情

}

 

這樣做是很不好的。有一部分平臺無關代碼會被拷貝粘貼,重複出現在了兩個地方。拷貝粘貼是編程之大忌。所以一定要注意,那些封裝函數只能是很簡單的只有一兩行的inline 函數,而且不能出現平臺獨立的代碼。

採用這種源文件拓撲結構,可以極大地提高軟件的可移植性,而且給編寫第一個平臺版本帶來的麻煩也不大。如果你的開發策略是各個平臺同步開發,那麼這樣做可以讓各個平臺以及跨平臺模塊的開發者毫不衝突地工作於不同的源代碼文件;如果你的開發策略是先全力發佈一個平臺的版本,然後移植到另一個平臺,那麼用這樣的源代碼結構同樣可以給你帶來極大的好處:假設第一個版本是Windows 的,稍候發佈Linux 版本,那麼一開始只有main.

cpp(在這裏代表所有的平臺獨立代碼)和win32_specific.hpp。移植的時候只要照着win32_specific.hpp的實現,編寫一個linux_specific.hpp 即可。

維護起來也很省心,以後出升級版本或者出patch/servicepack,都只需要在一棵代碼樹上工作,而沒有很多合併修改分支的煩惱。而且還有一個好處是,如果一個bug只在某個平臺出現而在其他平臺沒有,那麼找bug基本上只要在那個平臺對應的os_specific.hpp中看即可,這是分離關注焦點帶來的好處。

正如我前面說過的,平臺除了指操作系統,也可以指更廣泛的概念,比如中間件或者你依賴的某個第三方庫。只要你對平臺的依賴是局部性的,而非全局性(比如對框架的依賴),那麼這種方法都可適用。我在這裏選擇了用#ifdef和#include配合來選擇性地包含和編譯平臺相關代碼。這是通用性最好也最省事的做法,C和C++都支持,所有平臺上的編譯器都支持。當然,還有其他的辦法,比如配合使用namespace 定義、using namespace導入語句、模板的實例化(把操作系統類型作爲一個模板參數),也能做到。對預編譯器和#號深惡痛絕

的朋友不妨可以試試。

這樣的文件結構也可以用於makefile。編譯時用make -e OS=YOURTARGETOS [其他參數]來選擇性地爲某個平臺進行構建。其中makefile 應包含這樣的內容:

include $(ROOT)/buildenv/default.inc #平臺獨立的構建信息

include $(ROOT)/buildenv/$(OS).inc #平臺相關的構建信息,比如不同平臺

#上不同編譯器的參數定義

 

因爲包含次序在後的宏定義可以覆蓋前面的,所以default.inc中還可以爲各平臺的編譯器提供缺省值(比如把編譯器缺省定義成cc,有的平臺可以覆蓋成gcc或者xlC等等;優化參數在default.inc中缺省定義成-O3,在支持更高優化程度的平臺.inc中覆蓋成-O5,諸如此類)。宏除了覆蓋的話,也可以連接。關於makefile的寫法在此限於篇幅就不詳述了。事實上還有自動工具(autoconf、autoheader、automake 等)同GNU make配套,可以生成平臺相關的文件並進行平臺相關構建(具體用法可以通過Google查找文檔),但我覺得很多情況下殺雞不需要用牛刀除了整體結構,還有很多細節需要注意。比如文件路徑分隔符“/”和“\”的不同(boost::path很好地封裝了這個不同),這個操作系統的文件系統是否區分大小寫,Big Endian和Little Endian的區分,不同平臺上字長的不同,以及不同平臺/編譯器的缺省對齊方式的不同,等等。另外,要注意一些C++ 編譯器提供的API 其實擴展了ANSI或者ISO 的標準,比如SGI STL 中的hash_map、hash_set 和rope,還有某些C庫提供的snprintf之類函數,這些API 其實不是跨平臺的,應避免使用(比如S/390 上的C 庫就不帶snprintf 函數,絕大部分STL實現都沒有hash_map、hash_set 和rope)。不過如果你覺得使用它們會帶來很大方便,也可以用,只是你不得不在不支持這些API的平臺的os_specific.hpp 中自己實現snprintf 或者hash_map、rope等等。篇幅所限,這些細節就不展開說了。

 

最後,必須提到,軟件應儘可能地具有良好的邏輯和物理設計,這一點非常重要。移植到一個不同的平臺,本質上是對軟件做修改。設計得越好的軟件修改起來越容易。糟糕的設計會導致軟件邏輯不清、代碼都糾纏在一起,做一點點改動都會牽一髮而動全身。這樣的軟件是很難移植的。而設計得好的軟件,對局部做改動不會影響到其餘部分,而且一個改動只需要做一次,不需要做全局的查找且替換還擔心遺漏一處就造成bug,這樣的軟件移植起來會很省心。


----------------------------------------------------------------------------------------
編寫可移植C/C++程序的要點

1.分層設計,隔離平臺相關的代碼。就像可測試性一樣,可移植性也要從設計抓起。一般來說,最上層和最下層都不具有良好的可移植性。最上層是GUI,大多數GUI都不是跨平臺的,如Win32 SDK和MFC。最下層是操作系統A ...

1.分層設計,隔離平臺相關的代碼。就像可測試性一樣,可移植性也要從設計抓起。一般來說,最上層和最下層都不具有良好的可移植性。最上層是GUI,大多數GUI都不是跨平臺的,如Win32 SDK和MFC。最下層是操作系統API,大多部分操作系統API都是專用的。

  如果這兩層的代碼散佈在整個軟件中,那麼這個軟件的可植性將非常的差,這是不言自明的。那麼如何避免這種情況呢?當然是分層設計了:

  最底層採用Adapter模式,把不同操作系統的API封裝成一套統一的接口。至於封裝成類還是封裝成函數,要看你採用的C還是C++寫的程序了。這看起來很簡單,其實不盡然(看完整篇文章後你會明白的),它將耗去你大量的時間去編寫代碼,去測試它們。採用現存的程序庫,是明智的做法,有很多這樣的庫,比如,C庫有glib(GNOME的基礎類),C++庫有ACE(ADAPTIVE CommunicationEnvironment)等等,在開發第一個平臺時就採用這些庫,可以大大減少移植的工作量。

  最上層採用MVC模型,分離界面表現與內部邏輯代碼。把大部分代碼放到內部邏輯裏面,界面僅僅是顯示和接收輸入,即使要換一套GUI,工作量也不大。這同時也是提高可測試性的手段之一,當然還有其它一些附加好處。所以即使你採用QT或者GTK+等跨平臺的GUI設計軟件界面,分離界面表現與內部邏輯也是非常有用的。

  若做到了以上兩點,程序的可移植性基本上有保障了,其它的只是技術細節問題。

  2.事先熟悉各目標平臺,合理抽象底層功能。這一點是建立在分層設計之上的,大多數底層函數,像線程、同步機制和IPC機制等等,不同平臺提供的函數,幾乎是一一對應的,封裝這些函數很簡單,實現Adapter的工作幾乎只是體力活。然而,對於一些比較特殊的應用,如圖形組件本身,就拿GTK+來說吧,基於XWindow的功能和基於Win32的功能,兩者差巨大,除了窗口、事件等基本概念外,幾乎沒有什麼相同的,如果不事先了解各個平臺的特性,在設計時就精心考慮的話,抽象出來的抽口在另外一個平臺幾乎無法實現。

  3.儘量使用標準C/C++函數。大多數平臺都會實現POSIX(Portable Operating SystemInterface)規定的函數,但這些函數較原生(Native)函數來說,性能上的表現可能較次一些,用起來也不如原生函數方便。但是,最好不要貪圖這種便宜而使用原生函數函數,否則搬起的石頭最終會軋到自己的腳。比如,文件操作就用fopen之類的函數,而不要用CreateFile之類的函數等。

  4.儘量不要使用C/C++新標準裏出現的特性。並不是所有的編譯器都支持這些特性,像VC就不支持C99裏面要求的可變參數的宏,VC對一些模板特性的支持也不全面。爲了安全起見,這方面不要太激進了。

  5.儘量不要使用C/C++標準裏沒有明確規定的特性。比如你有多個動態庫,每個動態庫都有全局對象,而且這些全局對象的構造還有依賴關係,那你遲早會遇到麻煩的,這些全局對象構造的先後順序在標準裏是沒有規定的。在一個平臺上運行正確,在另外一個平臺上可能莫明其妙的死機,最終還是要對程序作大量修改。


6.儘量不要使用準標準函數。有些函數大多數平臺上都有,它們使用得太廣泛了,以至於大家都把它們當成標準了,比如atoi(把字符串轉換成整數)、strdup(克隆字符串)、alloca(在棧分配自動內存)等等。不怕一萬,就怕萬一,除非明白你在做什麼,否則還是別碰它們爲好。

  7.注意標準函數的細節。也許你不相信,即使是標準函數,拋開內部實現不論,就其外在表現的差異也有時令人驚訝。這裏略舉幾個例子:

  int accept(int s, struct sockaddr *addr, socklen_t *addrlen);addr/addrlen本來是輸出參數,如果是C++程序員,不管怎麼樣,你已經習慣於初始化所有的變量,不會有問題。如果是C程序員,就難說了,若沒有初始化它們,程序可能莫名其妙的crash,而你做夢也懷疑不到它頭它。這在Win32下沒問題,在Linux下才會出現。

  int snprintf(char *str, size_t size, const char *format, ……);第二個參數size,在Win32下不包括空字符在內,在Linux下包括空字符,這一個字符的差異,也可能讓你耗上幾個小時。

  int stat(const char *file_name, struct stat*buf);這個函數本身沒有問題,問題出在結構stat上,st_ctime在Win32下代表創建(create)時間,在Linux下代表最後修改(change)時間。

  FILE *fopen(const char *path, const char *mode);在讀取二進制文件,沒有什麼問題。在讀取文本文件可要小心,Win32下自動預處理,讀出來的內容與文件實際都長度不一樣,在Linux則沒有問題。

  8.小心數據標準數據類型。不少人已經吃過int類型由16位轉變成32位帶來的苦頭,這已經是陳年往事了,這裏且不談。你可知道char在有的系統上是有符號的,在有的系統是無符號的嗎?你可知道wchar_t在Win32下是16位的,在Linux下是32位的嗎?你可知道有符號的1bit的位域,取值是0和-1而不是0和1嗎?這些貌合神離的東東,端的是神出鬼沒,一不小心着了它的道。

  9.最好不要使用平臺獨有的特性。比如Win32下DLL可以提供一個DllMain函數,在特定的時間,操作系統的Loader會自動調用這個函數。這類功能很好用,但最好不要用,目標平臺可不能保證有這種功能。

  10.最好不要使用編譯器特有的特性。現代的編譯器都做很人性化,考慮得很周到,一些功能用起非常方便。像在VC裏,你要實現線程局部存儲,你都不調用TlsGetValue /Tls TlsSetValue之類的函數,在變量前加一個__declspec( thread)就行了,然而儘管在pthread裏有類似的功能,卻不能按這種方式實現,所以無法移植到Linux下。同樣gcc也有很多擴展,是在VC或者其它編譯器裏所沒有的。

  11.注意平臺的特性。比如:

  在Win32下的DLL裏面,除非明確指明爲export的函數外,其它函數對外都是不可見的。而在Linux下,所有的非static的全局變量和函數,對外全部是可見的。這要特別小心,同名函數引起的問題,讓你查上兩天也不爲過。

  目錄分隔符,在Win32下用‘\\’,在Linux下用‘/’。

  文本文件換行符,在Win32下用‘\r\n’,在Linux下用‘\n’,在MacOS下用‘\r’。

  字節順序(大端/小端),不同硬件平臺的字節順序可能不一樣。

  字節對齊,在有的平臺(如x86)上,字節不對齊,無非速度慢一點,而有的平臺(如arm)上,它完全用錯誤的方式去讀取數據,而且不會給你一點提示。若出問題,可能讓你一點頭緒都沒有。

  12.最好清楚不同平臺的資源限制。想必你還記得DOS下同時打開的文件個數限制在幾十個的情形吧,如今操作系統的功能已經強大多了,但是並非沒有限制。比如Linux下的共享內存默認的最大值是4M。若你對目標平臺常見的資源限制瞭然於胸,可能有很大的幫助,一些問題很容易定位

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章