程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> 關於C語言 >> 程序移植與宏定義

程序移植與宏定義

編輯:關於C語言

 由於操作系統的差異,同一種操作系統本身版本的差異,目前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。若你對目標平台常見的資源限制了然於胸,可能有很大的幫助,一些問題很容易定位

作者“成長之路”

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved