《深入BREW開發》——第二章 軟件基礎

 第二章 軟件基礎

       我們正在向我們的軟件王國進發,千萬別急,在這條路上“枯燥”是我們最大的敵人,不知有多少人在它的面前臣服,但願您不是其中之一。或許您覺得應該獲得一些鼓勵,寫一些代碼,能夠看見一些諸如“Hello, World!”之類的信息。非常幸運,從這裏開始您將能夠看見它們了,我會將部分內容使用源程序的方式向您講解。在這本書裏,我將使用Visual Studio .Net2003的開發環境來執行這些測試程序。建立測試工程的步驟如下:
       1、首先打開Visual Studio .Net2003開發環境,選擇新建項目。如圖2.1,選擇項目類型Visual C++項目/Win32控制檯項目,選擇路徑,填寫測試程序名稱是Test1,點擊確定按鈕。
       2、在Win32應用程序嚮導中接受默認設置,點擊完成按鈕。如圖2.2。

圖2.1 建立測試程序
       這樣就完成了一個測試用的應用程序,在以後的測試程序中我將不再重複這個步驟。由於我們主要是在C語言的基礎上講解,因此在這裏創建的是Win32控制檯應用程序,它使用Windows的命令窗口顯示輸出結果。不過請注意,雖然它使用Windows的命令窗口,但是它是一個Win32的應用程序,而不是DOS應用程序。
       在Windows環境下,應用程序分爲控制檯(Console)應用程序和窗口應用程序。控制檯應用程序不顯示窗口,其表現形式類似於DOS環境下的應用程序,但是由於它可以調用Windows的API來實現,所以它是一個Windows的應用程序而不是DOS應用程序。同時控制檯應用程序可以使用標準C/C++的庫,這樣Windows下的控制檯應用程序就和DOS環境下的C/C++十分的相似了。
       在這一部分我們將通過實例來演示一些C語言中較比令人“眩暈”的話題,期望能夠通過這些實例讓您弄明白這些問題的本質。由於本書是建立在您已經有一定的C語言基礎之上的,因此主要是針對C語言中一些較難理解的概念進行講解,畢竟我們不是一本專門講解C語言的書籍。在本部分主要講解的內容是指針、結構體、預處理和函數,因爲在我們接下來的行程中必須要充分的理解它們才能繼續前進。

圖2.2 Win32應用程序嚮導
2.1 重溫C語言的指針
       指針是一個精靈,以至於在我們剛剛接觸它的時候有點不知所措,甚至有些人懷着敬畏的心情而決心遠離它!不過,當我們掌握了它的時候,就會發現它能讓我們隨心所欲。儘管我們仍然面臨着使用不當所帶來的巨大風險,但是我還是會義無反顧的告訴您——一定要使用它。之所以我會在這裏向您發出這樣的號召,絕對不是因爲我對指針的個人情感,我和指針也是非親非故,只不過是因爲透過它我們不但可以編寫出靈活的程序,而且可以窺探到程序的真正世界——二進制世界的祕密。這就讓我們開始擁抱它吧!
2.1.1指針的本質
       指針的本質是存儲它所指向存儲空間地址的變量,下面將通過一個測試程序來開始征服它的旅程。打開測試工程Test1,在Test1.cpp中添加如下代碼:
#include "stdafx.h"
 
int _tmain(int argc, _TCHAR* argv[])
{
       int *pointer;
       int nNumber = 100;
      
       pointer = &nNumber;
       printf("&pointer = 0x%x pointer = 0x%x *pointer = %d /n",&pointer,pointer,*pointer);
return 0;
}


在這段代碼中,我們定義了一個int型指針pointer和一個int變量nNumber,然後讓pointer指向nNumber。編譯、運行生成可執行文件,可以看到輸出的&pointer、pointer和*pointer的值如下:
&pointer = 0x12fed4
pointer = 0x12fec8
*pointer = 100


*pointer = 100是我們知道的,也就是指針指向的內存地址的內容,我們在程序中將pointer指針指向nNumber的地址,那麼與nNumber的值相等就不足爲奇了。pointer = 1244872這個值就是指針指向的內存地址,是當前函數運行中存儲nNumber的地址。&pointer就是這個指針變量的地址,由於我們定義了一個指針變量,因此,在函數運行時將會爲這個變量分配一個存儲空間,因此這個值是有意義的,而且它與pointer的值十分相近。我們可以這樣理解指針,它是一個變量(因爲指針也需要空間存儲它所指向的地址),這個變量的值就是一個內存空間的地址。示意圖如下:
100


0x12fec8
0x12fec8


0x12fed4
pointer
nNumber


圖2.3 指針示意圖
在上面的代碼中,&pointer是一個指向指針的指針,這樣的稱呼實在是過於繁瑣了,我們就稱它爲“二重指針”吧。順延的,如果是指向(指針的指針)的指針我們就叫它“三重指針”。在C語言中,二重指針是一個非常有用的東西,很多高階的C語言應用都會使用到它。二重指針的主要作用是做爲參數爲一個指針變量賦值,在後面的章節中我們還會經常使用到它。
對上面的圖2.3作一個說明,pointer和nNumber分別位於兩個不同的內存區域,nNumber中存儲的值是100,這是程序中指定的;pointer內存儲的值是0x12fec8,這個值正好是nNumber所在的內存地址;圖左邊的0x12fed4是存儲pointer值的指針變量的地址,我們可以通過定義一個二重指針獲得它的內容,在本程序中我們通過&pointer來獲得它的內容。
從本質上來說,指針、二重指針、三重指針等等,都是一樣的,從代碼的層次(我們將在“編譯器基礎”部分詳細講解軟件的層次問題)來講都是一個“地址”型的變量,只不過使用的時候會由編譯器做一下合法性的檢查,目的是爲了防止程序出現錯誤;在二進制層次來說它們就更加沒有分別了,都是一個存儲內容的容器。打個比方,現在有兩個盒子,一個規定放置籃球,另一個規定放置足球。體現在C語言中這個“規定”就是定義了兩個變量,一個是int型的,另一個是指針型的;這兩個盒子就是兩塊存儲空間。在現實生活中“規定”是由人來制定的,在程序中就是由C語言定義的。那麼我違反規定放置籃球的盒子我放置足球,放置足球的盒子我放置籃球。這是沒什麼問題的,不過會發生錯誤,因爲會讓一場足球比賽變成了踢籃球的比賽,讓一場籃球比賽變成了足球投籃的比賽。同樣的對於指針和變量的關係也是,它們的內容在二進制層次是可以互換的。但是我們在這裏要考慮兩件事情,一是這個錯誤發生的前提條件,這個錯誤要發生必須是在球的管理者不知道錯的情況下。如果球的管理者知道哪個盒子放了籃球哪個盒子放了足球也不會出錯。體現在程序上,就是增加強制類型轉換來告訴編譯器“我知道我要在int變量中放置一個指針的值”。否則編譯器將檢查到這個錯誤並報錯。二是要考慮“盒子”容量的問題,因爲足球比籃球小,所以互換的時候不能讓籃球撐壞了足球盒子。在程序中也就是32位的指針變量不能放到char型變量中去存儲,因爲這樣會丟掉地址信息。
綜上所述,雖然變量的二進制本質是一樣的,但是在代碼層次要精確控制變量的類型來避免錯誤的發生。指針也是一個變量,一個32位的指針變量也可以存儲在一個4字節的無符號整型變量裏,前提是我們要知道我們是採用這種方式來存儲它們的。
最後使用一個例子來做個演示,新建工程Test2,輸入如下代碼:
#include "stdafx.h"
 
int _tmain(int argc, _TCHAR* argv[])
{
       int nNumber = 100;
       int *pointer = &nNumber;
       unsigned int   dwBox;
       unsigned short wBox;
   
       // 將指針的值分別賦給無符號整型變量
       dwBox =(unsigned int)pointer;
       wBox =(unsigned short)pointer; // 地址內容將被裁減!!!
      
       // 輸出Box變量的值,注意wBox與dwBox之間的不同
       printf("wBox = 0x%x dwBox = 0x%x/n",wBox,dwBox);
 
       // 輸出Box變量指向地址的值,其中*((int *)dwBox)相當於*pointer
       // 此時不能夠使用*((int *)wBox)輸出值,因爲它的地址是經過裁減的
       printf("*dwBox = %d/n",*((int *)dwBox));
       return 0;
}


     編譯後運行,輸出如下結果:
       wBox = 0xfed4 dwBox = 0x12fed4
       *dwBox = 100
請仔細體會上面的代碼,它真正揭示了變量與指針之間微妙的關係,還有類型轉換時所發生的數據裁減。
2.1.2指針的增減
關於指針的增減是一個比較容易讓人迷惑的問題,指針本身也是一個變量,它裏面存儲的是一個地址,那麼這個變量的自增是將這個地址值加1嗎?自增操作和直接加1的操作是一樣的嗎?
爲了弄清這個迷惑,這裏我們在Test2工程中增加一個Test2-1的項目(請設置Test2-1爲啓動項目),然後輸入如下代碼:
#include "stdafx.h"
 
typedef struct _Test2{
       char Test2[100];
}Test2;
 
int _tmain(int argc, _TCHAR* argv[])
{
       int   nValue = 1;
       char cValue = 'A';
       Test2 sValue;
       int   *intPtr   = &nValue;
       char *charPtr      = &cValue;
       Test2 *structPtr= &sValue;
 
       printf("Int %d Char %d Struct %d/n",(int)intPtr,(int)charPtr,(int)structPtr);
 
       intPtr++;
       charPtr++;
       structPtr++;
       printf("Int %d Char %d Struct %d/n",(int)intPtr,(int)charPtr,(int)structPtr);
 
       intPtr   += 1;
       charPtr += 1;
       structPtr+= 1;
       printf("Int %d Char %d Struct %d/n",(int)intPtr,(int)charPtr,(int)structPtr);
       return 0;
}


       編譯運行輸出的結果如下:
       Int 1244884 Char 1244875 Struct 1244764
       Int 1244888 Char 1244876 Struct 1244864
       Int 1244892 Char 1244877 Struct 1244964
       從這個結果中,我們可以看出,指針的加減是與指針所指的變量類型有關的,它所增加的步數是所指變量的大小,而且指針的+1和++操作是一樣的。那麼void型的沒有類型的指針怎麼處理呢?答案是不處理,編譯器會通知我們“未知大小”的錯誤而終止程序的生成。
2.2 重溫C語言的結構
       結構是C語言中組織不同數據類型的一種方式,它將不同的數據類型組織到一個相鄰的地址空間內。每個定義的結構體變量就是一個多種數據的存儲空間。在這裏我們主要講述幾個相對“高階”問題,之所以在這裏加上引號,是因爲對於某些傳說中的高手來說,這不過是小菜一碟。
2.2.1結構體變量賦值
       我們已經習慣了爲結構體變量中的每個成員賦值,那麼我們可以在兩個結構體變更之間直接使用“=”號賦值嗎? 答案是肯定的,因爲編譯器支持。例如定義一個表示矩形的結構體:
       typedef struct _Rectangle{
              int x;       // 左上角x座標
              int y;       // 左上角y座標
              int dx;     // 矩形寬度
              int dy;     // 矩形高度
       } Rectangle;
      
       定義兩個矩形結構體變量並賦值:
       Rectangle Rect1, Rect2;
       Rect1.x = 100;
       Rect1.y = 100;
       Rect1.dx = 100;
       Rect1.dy =100;
      
       Rect2 = Rect1;
      
       上面的賦值在C語言中是支持的,編譯器會將Rect2 = Rect1中的值轉化成內存拷貝的CPU指令來實現賦值操作。可以想象,對於簡單的變量賦值,CPU只需要執行一個MOV指令就可以完成了,因此對於包含多個簡單變量的結構體來說,使用多個循環的MOV指令就在情理之中了(在早期16位CPU中,如果對一個32位的int變量執行賦值操作都需要兩條MOV指令)。在使用這種賦值方法的時候需要注意的是,在這個結構體變量中最好不要有指針變量,因爲指針變量可能在變量1中指向一個分配的內存區域,當變量2通過賦值操作獲得了這個指針值的時候,有可能這個指針已經釋放了,這樣就導致了空指針情況的發生,後果是使用這個指針的時候將會導致程序崩潰。舉例說明如下:
       1、定義一個包含指針類型的結構體:
 
typedef struct _TestStruct{
       int nMember;
       int *Ptr;
}TestStruct;
 


2、定義兩個這種類型的變量並採用如下使用方法:
 
TestStruct Struct1, Struct2;
Struct1.nMember = 100;
Struct1.Ptr = (int *)malloc(15*sizeof(int)); // 分配15個int變量的空間
 
// 結構體賦值
Struct2 = Struct1; //此時Struct2.Ptr與 Struct1.Ptr的值相等
 
if(Struct1.Ptr)
{
       free(Struct1.Ptr);
       Strict1.Ptr = NULL;
}
 
// 這裏有很複雜的處理,其中包含了malloc等操作
 
Struct2.Ptr[0] = 2; // 錯誤的賦值操作,因爲此時Struct2.Ptr所指向的內容已經被釋放了。
 


對於程序來說,修改一個已經釋放了空間的內存地址內容是十分危險的。當然,如果程序只有上面那麼簡單的話也不會出現什麼嚴重的問題,頂多只是非法使用了一塊內存區域;但是,如果中間含有複雜的處理,Struct2.Ptr[0] = 2將修改程序其他部分使用的內存區域,那麼這樣就可能會有莫名其妙的死機之類的事情發生了。由於其發生問題的時間不固定,因此這類問題調試起來也十分的困難。
2.2.2結構體嵌套
       在一個結構體中可以聲明另一個結構體,形成結構體嵌套,如果將內部嵌套的子結構體變量放在父結構體的頂部,那麼兩個結構體之間還可以進行類型互換。這個特性爲實現C語言的數據封裝提供了一種方法。例如定義如下結構體:
 
typedef struct _Point{
              int x;
              int y;
       }Point;
      
typedef struct _Rectangle{
       Point LeftTop;
       int   dx;
       int   dy;
}Rectangle;
 


由於結構體中Rectangle嵌套了結構體Point,因此如果定義變量Rectangle Rect1則Rect1可以轉化成Point使用。例如:
Rectangle Rect1 = {{100, 100}, 100, 100};
Rectangle *pRect = &Rect1;
Point *pPoint = (Point *)&Rect1;


如果需要訪問這個矩形的左上角的x座標值可以有兩種方法:pRect->LeftTop.x或者pPoint->x。Rectangle結構體的內存模式如圖2.4所示:
x
y
dx
dy
Rectangle
Point


圖2.4 結構體內存模型
從圖2.4可以看出Point是嵌入在Rectangle中的,兩個結構體的頂端地址是一樣的,因此他們之間的指針可以互換,並且可以正常操作。
2.3 重溫C語言的預處理
       在編譯器編譯源文件之前,會首先通過預處理器來處理源程序中的預處理選項。大名鼎鼎的宏也是預處理的一種,預處理器採用直接替換的方式來處理宏,也就是說將宏定義的內容替換到源文件中之後纔開始編譯,在每一個調用宏的地方就有一個宏的替換體。例如定義如下宏:
       #define MAX(a, b) (a > b ? a : b)
       在程序中使用一次MAX(a, b)對應的預處理器就把(a > b ? a : b)替換到相應的位置,結果是增加了可執行程序的大小(因爲有n個重複的代碼段)。
如果定義的需要多行的宏,則使用“/”做爲行與行之間的連接符,請看下面的宏定義:
       #define INTI_RECT_VALUE( a, b )          /
{                                             /
           a = 0;                               /
           b = 0;                                     /
}
注意最後一行就不再使用“/”了。
除了宏之外還有代碼的選擇編譯也是預處理器的主要功能之一。在一個大型的軟件項目中,有許多功能需要根據不同的硬件平臺或者軟件用途來進行選擇,例如一個軟件的中文版和英文版,在發佈的時候就需要使用定義的中文或英文標籤來決定。
C語言的預編譯器使用的關鍵詞和功能描述如下表:
關鍵詞 功能描述
#define  用來進行宏和符號或常量的定義。
#undef 取消通過#define定義過的符號。
#if 用來判斷預處理條件,需要#endif做爲結束標記。相對應的#ifdef 和#if defined用來判斷符號是否定義;#ifndef 和#if !defined()是判斷符號是否未定義。
#else #ifdef、#ifndef的條件分支語句
#endif #ifdef、#ifndef的條件結束語句,只要有#if就需要有#endif
#error 無條件的向預處理器報錯。通常用在#if…#endif之間,用來判斷是否符合編譯條件。例如:
#ifndef ENABLE_COMPILE
#error Disable compile
#endif
#include 用來包含文件。通常是用來包含頭文件,但實際上它什麼文件都可以包含。它直接將文件的內容引入到當前的包含文件中,這些包含都是由預處理器完成的。
#pragma 指定編譯器的參數,這個和具體的編譯器有關。例如有些編譯器支持startup和exit pragmas,允許用戶指定在程序開始和結束時執行的函數。
#pragma startup load_data
#pragma exit close_files
__FILE__ 預處理常量,代表當前編譯的文件名。例如可以使用如下代碼輸出當前的文件名:printf(“This file name is %s”,__FILE__);
__LINE__ 預處理常量,代表當前編譯的行數。例如可以使用如下代碼輸出當前的行數:printf(“Current line is %d”,__LINE__);
__DATE__ 預處理常量,代表當前編譯的日期。例如可以使用如下代碼輸出當前的編譯日期:printf(“Current compile date is %s”,__DATE__);
__TIME__ 預處理常量,代表當前編譯的時間。例如可以使用如下代碼輸出當前的編譯時間:printf(“Current compile time is %s”,__TIME__);


在使用預處理的時候需要注意兩件事情:
1、在定義宏或常量時候儘可能的使用括號。這是因爲預處理器是將宏和常量採用直接替換的方式,如果不是用括號則有可能產生錯誤的程序處理。看下面的代碼:
#define DISPLAY1_HEIGHT 320
#define DISPLAY2_HEIGHT 240
#define DISPLAY_SUM DISPLAY1_HEIGHT+ DISPLAY2_HEIGHT

if(DISPLAY_SUM*2 >200)
{
       …
}

上面的判斷語句的真實想法是如果顯示高度之和的2倍大於200則進行相應的處理。而實際上進行預處理後上面的代碼變成了if(DISPLAY1_HEIGHT+ DISPLAY2_HEIGHT*2)也就是if(320+240*2),這與我們期望的值相差太遠了。因此在定義的時候最好加上括號,這樣就不會出問題了。
2、在使用宏的時候必須不能出現參數變化。請看下面的代碼:
#define SQUARE( a ) ((a) * (a))
 
int a = 2;
int b;
b = SQUARE( a++ ); // 結果:a = 4,即執行了兩次增1。
 
正確的用法是:
b = SQUARE( a );
a++; // 結果:a = 3,即只執行了一次增1。
2.4 重溫C語言的函數
       理論上我們可以只使用一個main函數,因爲不管多少程序我們都可以就寫在一個main裏面。但是您能設想有一個10萬行的main函數嗎?所以我們不得不把程序分成各個模塊,不但要分成函數,而且還要將不同類型的函數放在不同的文件中。就我的經驗而言,通常程序多於200行的時候應該考慮分成函數,而一個文件中的總共代碼最好不要超過5000行。多了就不利於調試和閱讀。當然,這些內容只是爲了增強代碼的可讀性和易管理性的問題,但是您也要知道,軟件在達到一定規模後就不單單是技術問題了,還有管理的問題。
2.4.1形參和實參
直到現在我還我還記得在我初學C語言的時候形參和實參給我帶來的困惑,我期望能夠用非常簡單明瞭的語言來描述它們,讓我們來試試吧!
形參:在函數定義的時候使用的參數叫做形參。
實參:在使用函數的時候傳遞的參數叫做實參。
舉例說明:
int add(int a, int b) // 這裏是函數的定義,a,b都屬於形參
{
       // 這裏全部都是函數的實現
       return(a+b);
}
       上面的函數展示了一個函數的各個部分——定義和實現,在函數定義裏的a和b都是形參,在函數的實現體內,也就是a+b,中的a和b是實參的替身。這裏是最容易讓人困惑的地方,看一個例子吧,假設有一個函數calc需要調用add函數來實現加法的運算功能:
       int calc(int calctype, int p1, int p2) // calctype、p1和p2都是形參
{
       if(calctype == 1)
{
       return add(p1, p2);    // 在這裏的p1和p2是形參還是實參?
}
return 0;
}
根據我們對形參和實參的定義,p1和p2對於calc函數來說是形參,但是對於add函數來說是實參。哦,恍然大悟了沒有?對於判斷到底是實參還是形參是有參考座標的。同樣的此時在函數add內部a+b等同於p1+p2,因此說在函數的實現體內形參(形式上的參數)是調用時實參(實際上的參數)的替身。相當於形參爲實參佔了一個位子,使用函數的時候實參就坐在了這個位子上。
如果您理解了上面的內容,那麼在使用函數的時候就再也沒有必要糾纏於實參和形參了,因爲它們合起來完成了一個參數的傳遞過程。調用函數的時候,按照形參的類型傳遞相應的變量就可以了。形參和實參只是一個概念上的區分,在實際的二進制層次並沒有這個概念,因此我們不要過分拘泥於此,理解了就好。
2.4.2宏與函數的比較
       對於一個初級的程序員來說,什麼時候使用宏,什麼時候使用函數還是有些暈的,這裏我也順帶的提一下吧。從前面可以知道,當預處理器遇到宏的引用時,都是將它用宏語句進行替換。因此,在每一個宏引用的地方都會增加相應的宏代碼,結果就是使得編譯後的代碼加大。而函數則是直接調用函數的代碼,不會增加編譯後的代碼大小。不過使用函數的缺點是在函數調用的時候會增加一些額外的處理,這使得執行函數的時間比相應的宏代碼的執行時間要長一些。因此如果在需要高效率的時候就應該使用宏,如果需要程序變得更小則使用函數。關於函數的額外處理部分的信息將在下一節編譯器基礎中進行介紹,到那個時候我們就會對函數是如何執行的問題有一個全面的瞭解了。
2.4.3函數指針
       還記得指針嗎?什麼,忘了!對您豎起我的大拇指,恭喜您已經修成正果了,因爲在您的眼裏已經沒有了對指針特別的概念。還記得我們在介紹指針的時候說的嗎,指針就是一種變量。那麼函數呢?其實每個函數也是一樣,每個函數名就是一個函數的首地址,因此我們也可以定義一個指針變量來存儲它,這個就是函數指針——指向一個函數的指針。這一節將讓您從另一個深度上加深對指針的認識。
       函數指針在多任務的操作系統環境下是十分有用的。在多任務操作系統環境下,由於牽涉到多個任務之間的交互,因此通常使用函數指針的形式將一個函數做爲參數傳遞給另一個任務,以便於當另一個任務完成操作之後可以調用當前任務的函數通知狀態。這個由“另一個任務”調用的函數就是大名鼎鼎的回調(Callback)函數,這個回調函數的傳遞就是通過函數指針這個載體實現的。回調函數還常用在定時服務的程序裏,在設置一個定時器的時候我們會指定一個回調函數,用來在到達定時時間的時候調用以做出相應的處理。
       通常情況下使用如下方式定義一個函數指針類型:
       typedef int (*FunctionType)(int param1, int param2);
       FunctionType pFunction;


       使用的時候可以爲pFunction賦值,在這裏FunctionType的函數類型與我們上面的add函數的形式相同,因此我們可以改用下面的代碼來調用add函數:
int nResult;
       FunctionType pAddFunc = add;
      
       nResult = pAddFunc(1,2); // 返回的結果是3
       


       在這裏之所以使用typedef來定義一個類型是爲了保持在C語言環境下的函數調用一致。爲了更好的理解函數指針,我將使用一個void型的函數指針來傳遞這個函數。打開Visual Studio 2003 .Net,新建VC控制檯工程Test3(請參考前面Test1的創建過程),輸入如下代碼:
#include "stdafx.h"
 
typedef int (*FunctionType)(int param1, int param2);
 
int add(int a, int b)
{
       return(a+b);
}
 
int _tmain(int argc, _TCHAR* argv[])
{
       int nResult;
       void *pVoid = add; // 通過Void型的指針傳遞函數指針
      
       nResult = ((FunctionType)pVoid)(1,2);
 
       printf("Result is %d/n",nResult,0,0);
       return 0;
}


編譯鏈接運行,可以看見輸出結果是3。
從上面的例子可以看出,一個函數名其實就是一個地址,可以直接賦值給指針,並且通過指針類型的轉換來實現函數的調用。進一步,如果FunctionType類型與函數add之間的參數和返回值不一樣可以嗎?還是讓事實說話吧,將上面的程序修改成如下樣式:
#include "stdafx.h"
 
typedef int (*FunctionType)(int param1);
 
int add(int a, int b)
{
       return(a+b);
}
 
int _tmain(int argc, _TCHAR* argv[])
{
       int nResult;
       void *pVoid = add; // 通過Void型的指針傳遞函數指針
      
       nResult = ((FunctionType)pVoid)(1);
 
       printf("Result is %d/n",nResult,0,0);
       return 0;
}


編譯鏈接,可以通過。運行,輸出結果是2012749654,好大的數字!
從上面的代碼情況看出,程序是可以運行的,只不過輸出的結果不對。這是因爲add需要兩個參數,而我們調用的時候卻只有一個參數。那麼另一個參數是什麼呢?答案是一個隨機數。前面我們說過,函數中會通過形參爲實參留個“位子”,那麼現在有兩個“位子”卻只用了一個,那麼可以預見的,這個空的位子中就是一個隨機數了。
如果到現在爲止,您對這個例子還不是很明白的話,請您一定要再重新體會一下,因爲這部分的內容對於您理解程序會有很大的幫助。
2.4.4不要使用結構體或數組做爲函數的參數
在前面講解函數參數的時候我們曾經提到過,函數會通過聲明的形參爲實參留“位子”,那麼這是一個多大的“位子”呢?答案是和形參的數據類型的大小一樣,而且這個位子是存放在系統的堆棧中的。結果是參數的數據類型越大,需要的堆棧空間就越大,這對於內存空間很小的系統來說是很不利的。而且在進行實參傳遞的時候還需要將實參的內容拷貝到對應的“位子”中,因此就增加了很多的調用函數時的額外處理。(這裏稱呼成堆棧,實際上應該是“棧”,因爲我們目前習慣於稱呼“棧”爲堆棧。“堆”通常指的是一個使用malloc等函數分配的內存空間。)
基於上述原因,推薦不直接使用大的結構體或數組做爲函數的參數,請使用指針進行傳遞,這樣可以省掉很多調用函數的額外處理,提高函數的執行效率。同時佔用的堆棧空間比較小,減少了堆棧溢出的可能性,因此使用指針傳遞參數是一個多贏的方法。千萬不要因爲害怕指針而不使用它,做爲一名C語言的程序員是永遠也逃脫不了指針的,況且一旦掌握了它的精髓,您就會對它愛不釋手了。
2.4.5使用指針參數傳遞數據
指針做爲C語言的靈魂具有很強的技巧性和靈活性,而做爲參數傳遞數據則是它非常重要的應用之一。這裏的“傳遞”有兩層含義:傳入和傳出。
傳入就是調用函數的地方將數據傳遞給函數進行處理,例如一個數組排序的函數,需要通過指針將數組傳遞給函數使用。這裏以一個進行字母排序的函數爲例,代碼如下:
void CharSort(char *pString)
{
char *pCurrPtr;
char temp;
 
// 判斷參數的合法性
if(pString == NULL)
{
    return;
}
 
// 排序算法
for(;*pString!= ‘/0’; pString++)
{
    for(pCurrPtr = pString+1; *pCurrPtr != ‘/0’; pCurrPtr++)
    {
        // 將最小的放在當前位置
        if(*pString > *pCurrPtr)
        {
            // 交換數據
            temp = *pString;
            *pString = *pCurrPtr;
            *pCurrPtr = temp;
        }
    }
}
}


       這個函數是一個簡單的冒泡排序算法,使用這個函數需要傳遞一個char類型的數組。此時這個指針參數pString就包含了傳遞參數給函數使用的功能。正如上一節所講的一樣,這裏面使用指針來傳遞數據是十分高效的,只需要在函數棧空間內分配一個指針空間就好了。
       指針參數的另一個作用是可以傳出參數,返回函數的處理結果。通常情況下我們都是通過函數的返回值來返回處理結果的,但是如果遇到需要返回一個大數組或大數據結構的時候,使用返回值傳遞參數就顯得效率低下了。我們仍舊以上面的CharSort函數爲例,這個函數的參數其實就具有傳入和傳出數據兩種功能,它首先通過pString參數將排序的字符串傳遞給函數,然後函數的處理結果也是直接通過pString來寫入原存儲空間的。這個過程的示意圖如下:
CharSort函數
 
直接在pString所指向的Array空間內進行排序
Array[] = “asdfghjkl896”;
pString參數
Array[] = “689adfghjkls”;


圖2.5 指針參數的傳入傳出數據功能
       使用指針參數傳出數據在編寫C語言程序時是十分普遍的,除了上面的傳遞數組外,還可以傳遞單個數據,也可以通過二重指針傳遞指針值。總之,指針做爲參數使用是十分靈活多變的,各位讀者可以仔細體會,關鍵的是理解指針的本質意義——地址類型的變量。
2.5 C語言中幾個特殊的關鍵詞
       在這裏我只是簡單地介紹一下volatile、__packed和const的作用,省得我們在看到它們的時候不知所措。
2.5.1 volatile關鍵詞
       volatile的中文意思是“易揮發的”,它主要是給編譯器提個醒,告訴編譯器對於volatile變量不要輕易的進行優化,因爲在程序運行過程中這個值會被其他的任務或硬件改變。在編譯器中對於語句通常會做一些優化,例如有如下程序:
 
       bool bExit == FALSE;
       …
       for(;;)
       {
              …
              if(bExit)
              {
                     break;
              }
}
 


假設現在有另一個任務或線程通過bExit來控制程序的退出。如果此時變量不使用volatile關鍵字說明的話,編譯時就會對if(bExit)進行優化,不再在每一次for循環中判斷bExit了,這樣就會導致程序運行錯誤。因此,此時應使用volatile關鍵字說明bExit變量,這樣編譯器就不會做這樣的優化了。
2.5.2 __packed關鍵詞
__packed用來聲明結構體採用單字節偏移。並不是所有的編譯器都支持這個選項。使用__packed聲明的結構體會壓縮空間。例如有下面一個結構體:
struct _Test{
       int a;
       char b;
       char c;
       int d;
}Test;


如果不使用__packed聲明,在ARM編譯器中sizeof(Test)等於12(在ARM編譯器中是4字節偏移,int也是4字節變量)。加入__packed說明後,sizeof(Test)等於10,編譯器會壓縮Test結構體中b、c和d變量之間的padding字節。對比示意圖如下:
       int a;
(4 B)
char b;(1B)
 
char c;(1B)
 
       intd;
(4 B)
 
int a;
(4 B)
 
char b;(1B)
char c;(1B)
 
       int d;
(4 B)
 
Padding
(2B)
 
無__packed
有__packed
 


圖2.6 結構體內存映射
       從這個圖中可以看出,經過__packed說明之後的結構體,相對於沒有使用__packed說明的節省了2字節的padding存儲空間,實際上這給我們提供了一種緊湊數據的方法。
2.5.3 const關鍵詞
       使用const的好處在於它允許指定一種語意上的約束——某種數據不能被修改——編譯器具體來實施這種約束。通過const,我們可以告知編譯器和其他程序員某個值要保持不變。只要是這種情況,我們就要明確地使用const ,因爲這樣做就可以藉助編譯器的幫助確保這種約束不被破壞。
對指針來說,可以指定指針本身爲const,也可以指定指針所指的數據爲const,或二者同時指定爲const,還有,兩者都不指定爲const:
char *p            = "hello";    // 非const指針, 非const數據
const char *p       = "hello";    // 非const指針, const數據
char * const p       = "hello";    // const指針,非const數據
const char * const p = "hello";    // const指針, const數據


語法並非看起來那麼變化多端。一般來說,我們可以在頭腦裏畫一條垂直線穿過指針聲明中的星號(*)位置,如果const出現在線的左邊,指針指向的數據爲常量;如果const出現在線的右邊,指針本身爲常量;如果const在線的兩邊都出現,二者都是常量。標示爲const的數據,在編譯器中當作RO只讀數據處理。
       在指針所指爲常量的情況下,有些程序員喜歡把const放在類型名之前,有些則喜歡把const放在類型名之後、星號之前。所以,下面的函數取的是同種參數類型:
       void f1(const int *pw);      // f1取的是指向,widget常量對象的指針
       void f2(int const *pw);      // 同f2


2.6 地址對齊
       我們知道,在計算機系統中都是使用地址來管理數據,每一個數據實體都存儲在一定的地址空間內,如一個長度爲100的char型數組存儲在一個100字節的連續空間中。一個連續的地址空間中既有奇數的地址也有偶數的地址,這中間就存在了一個數據以什麼樣的地址做爲起始地址的問題。許多實際的計算機系統對基本類型數據在內存中存放的位置有限制,它們會要求這些數據的首地址的值是某個數(通常它爲4或8)的倍數,這就是所謂的內存對齊。而這個係數則被稱爲該數據類型的對齊模數(alignment modulus)。
       這種強制的要求一來簡化了處理器與內存之間傳輸系統的設計,二來可以提升讀取數據的速度。比如有這樣的一種處理器,它每次讀寫內存的時候都從某個8倍數的地址開始,一次讀出或寫入8個字節的數據,假如軟件能保證double類型的數據都從8倍數地址開始,那麼讀或寫一個double類型數據就只需要一次內存操作。否則,我們就可能需要兩次內存操作才能完成這個動作,因爲數據或許恰好橫跨在兩個符合對齊要求的8字節內存塊上。某些處理器甚至在數據不滿足地址對齊要求的情況還會出錯,
       地址對齊的問題主要表現在兩個方面:一是通過指針類型轉換訪問數據的情況;二是在結構體內部的數據對齊和訪問。接下來我們將就這兩個問題做一個闡述。
2.6.1指針的數據類型轉換
       C語言的指針是非常靈活的,我可以將任何一種數據類型的指針轉換成另一種數據類型的指針,例如,將一個(char *)轉換成一個(int *),或者將一個(int *)轉換成(char *)。這樣的指針類型轉換在C語言中十分的普遍,更有甚者,我們還可以定義一個無類型的指針(void *),可以不受限制的接受任何類型的指針,而編譯器也不會提示任何錯誤。這既是C語言靈活的標誌,但在其中也蘊藏着殺機!
       我們可以假設這樣的一種情況,我們從一個串口設備或者網絡設備上接收16個字節的數據,這些數據都是使用字節方式傳送的。此時我們規定,這些數據的第一個字節表示數據的類型,而其後的數據則表示真正的數據內容。此時我們會定義這樣的一個數組來接收數據:
    byte rsv_buf[16];


       現在,我們假設數據已經存儲在這個數組中了,同時假設這個數組在內存地址中的0x0至0xF的空間內。我們通過對這個數組第一個字節rsv_buf[0]的判斷,知道這裏面存儲的是一個word(半字,雙字節)型的數據。於是我們使用下面的方式獲得了數據:
    *((word *)&rsv_buf[1]);


       這裏面發生了什麼事情呢?我們先對rsv_buf[1]進行取址操作,然後轉換成word型的指針,然後通過一個取值運算符“*”來獲得這個word型指針中的數據。那麼此時&rsv_buf[1]到底是一個什麼樣的值呢?我們知道rsv_buf的首地址是0,那麼當然了&rsv_buf[1]的值就是1了。我們現在是要從一個奇地址中獲得一個雙字節的變量。
       這個時候地址對齊的問題出現了。當然對於一個支持這種訪問方式的CPU來說,不會出現任何問題,那麼對於不支持這種運行方式的CPU呢?一個錯誤產生了,或許系統崩潰了,或許獲取了錯誤的數據。不管怎樣這都將打破系統的正常運行,而且CPU不會爲我們提供任何可以更正錯誤的機會。
       由於我們並不能準確地知道我們所寫的程序將來會運行在什麼類型的CPU上面,也不知道它是否支持非地址對齊方式的數據訪問,因此我們應該儘可能的避免在程序中定義這樣的數據結構。即便不可避免的定義了這樣的數據結構,也要提供一種轉換的機制。比如,在本例中我們可以在定義一個rsv_data來轉存&rsv_buf[1]地址之後的數據,然後再進行類型轉換,這樣就不會出現問題了。當然,即便我們清楚的瞭解我們的程序將要運行的CPU支持非對齊方式的數據訪問,那麼也要儘可能的避免這種情況的發生,因爲它將影響我們的程序的執行效率,通常CPU執行這些代碼需要更多的指令週期。
2.6.2結構體內存佈局
       地址對齊的問題也表現在結構體中。C語言規定一種結構類型的大小是它所有字段的大小以及字段之間或字段尾部的填充區大小之和。填充區?是的,這就是爲了使結構體字段滿足地址對齊要求而額外分配給結構體的空間。C語言的標準規定結構體類型的對齊要求不能比它所有字段中要求最嚴格的那個寬鬆,可以更嚴格。我們可以看下面的例子:
typedef struct _Example1
{
    byte   a;
    dword  b;
} Example1;


       我們現在定義了一個結構體Example1,在其中有兩個成員變量a和b。假設這個結構體按照連續的方式佈局,那麼這個結構體將佔用3個字節的空間,a成員在結構體中的偏移是0,那麼結構體成員b的偏移則是1,而成員b是一個四字節的變量。這中間就存在了一個地址對齊的問題了。如果假設我們現在是編譯器,我們將如何安排這個結構體的內存結構呢?按照C語言的標準,這個結構體內成員變量對齊要求取成員地址對齊要求之大者的原則,這個結構體中的地址對齊的模數應該是sizeof(word) = 4。實現這種對齊的唯一方式就是在成員a和b之間增加3個字節填充區。增加填充區後的內存模式如下圖2.7所示。
a
padding…
b
0
1
2
3
4
5
6
7


圖2.7 Example1的結構體內存模型
       這個方案在a與b之間多分配了3個填充(padding)字節,這樣當整個結構體首地址滿足4字節的對齊要求時,b字段也一定能滿足dword型的4字節對齊規定。那麼sizeof(Example1)顯然就應該是8,而b字段相對於結構體首地址的偏移就是4。下面我們再來看一看將這兩個成員變量調換位置之後的情況。
typedef struct _Example2
{
    dword  a;
    byte   b;
} Example2;


       或許您會認爲Example2的內存佈局會比Example1的簡單,就是一個4字節的變量b加上一個1字節的變量,總共是5個字節長度。因爲Example2結構同樣要滿足4字節對齊規定,而此時a的地址與結構體的首地址相等,所以a和b都一定也是4字節對齊。嗯,分析的有道理,可是不全面。讓我們來考慮一下定義一個Example2類型的數組會出現什麼問題。C標準規定,任何類型(包括自定義結構類型)的數組所佔空間的大小一定等於一個單獨的該類型數據的大小乘以數組元素的個數。換句話說,數組各元素之間不會有空隙。按照上面的方案,一個Example2數組的佈局就是如圖2.8中所示的一樣。
b
a
0
1
2
3
4
5
6
8
a
b
7
9

10


圖2.8 Example2數組內存模型
       我們可以看到,此數組的第一個成員變量已經是四字節對齊了,可是第二個成員變量的起始地址卻是5開始的。這就不能滿足C語言的要求,因此在Example2類型的數組中,依然要增加填充區,如圖2.9所示。
b
padding…
a
0
1
2
3
4
5
6
7


圖2.9 Example2的結構體內存模型
       現在無論是定義一個單獨的Example2變量還是Example2數組,均能保證所有元素的所有字段都滿足對齊規定。那麼sizeof(Example2)仍然是8,而a的偏移爲0,b的偏移是4。現在我們已經掌握了結構體內存佈局的基本準則,嘗試分析一個稍微複雜點的類型吧。定義一個Example3的結構體:
typedef struct _Example3
{
    byte   a;
word   b;
dword c;
} Example3;


       這裏面有歧義的地方就是b這個變量採用什麼樣的對齊方式。在這個結構體中最大的偏移是4字節,那麼成員b應該是滿足4個字節的地址對齊方式嗎?實際情況是變量b只需要滿足它自身的對齊方式就可以了,也就是sizeof(word) = 2。因此現在我們可以得到圖2.10的內存佈局結構。
a
c
0
1
2
3
4
5
6
7

b


圖2.10 Example3的結構體內存模型
       那麼現在我們可以知道,在結構體內部成員變量地址對齊的要求就是滿足自身的需求,單字節的變量可以緊接着前面的成員,雙字節的要求偶數地址對齊,四字節的要求4字節對齊等等,可以依次類推,如果緊接着上一個成員的地址不符合要求就在中間添加填充字節。而對於整個結構體要求的地址對齊方式則取成員變量中要求地址對齊最大的那個。
       在實際開發中,我們可以通過指定編譯選項來更改編譯器的對齊規則(不同的編譯器有不同的設置方式,請參考相應的編譯器文檔)。例如我們可以指定字節對齊的方式是8,也可以指定是4,甚至還可以是1。在設置對齊規則的時候,採用的是參數與默認取二者之小的方式。例如我們通過編譯器參數設置結構體偏移量爲2,那麼對於Example1中的的內存佈局就會變成圖2.11中表示的那樣。
a

b
0
1
2
3
4
5


圖2.11 Example1的2字節偏移內存模型
       此時僅僅會增加一個字節的填充區。雖然結構體中b成員的偏移是4,但是由於我們設置了編譯器的偏移參數爲2,因此將會使用2作爲此結構體的最大偏移。如果此時我們將這個結構體的偏移設置爲1,那麼無論這個結構體的成員排列如何,都不會有任何的填充字節。此時的情況就類似於我們前面所講的編譯器__packed關鍵詞的意思了。
       在這種編譯器設置的字節對齊要求比結構體中變量自然要求小的情況下,將會出現訪問成員變量時的地址對齊問題。就像圖2.11中所表示的一樣,成員b是一個4字節的變量,但是它的首地址卻是2,不能被4整除的一個地址,我們在使用這樣的結構體成員的時候,會同指針的轉換的時候一樣出現地址對齊的問題嗎?
       如果我們不做任何事情,肯定會出現問題。不過請您放心,這些事情已經由編譯器爲我們做了。這是可以理解的,我們使用編譯器設置了結構體的字節對齊要求,不過出現問題當然要由編譯器負責了。在這種情況下使用結構體成員的時候,編譯器在編譯相應的代碼時,會額外的插入一些CPU指令來消除地址對齊問題的影響。典型的,在ARM C語言編譯器中,如果訪問一個使用__packed關鍵字聲明的結構體成員變量的時候(如使用pointer->b語句訪問b變量),每一個訪問成員的C語句都會變爲7個CPU指令,同時使用3個通用的CPU寄存器。這不但會影響程序執行效率,而且還會增加代碼的尺寸。所以,不到萬不得已,請不要使用這種方式的結構體。
2.7 小結
       噢,我要長長的鬆一口氣了,終於將一些C語言的基礎介紹給完了。在這一章裏,主要介紹了C語言的一些特性,其中大部分是讀者在使用中會遇到的令人迷惑的議題,如果我們能夠將這一章的內容透徹的理解了,那麼我們就基本上掌握了C語言本身的精髓(雖然還有很多高級的應用,不過那都不是C語言本身的了)。在這裏我要提醒那些初級的程序員朋友們,在掌握一門程序語言技術的同時,千萬不要忘記養成一個良好的編碼風格。因爲如果想成爲一名優秀的程序員不但要能夠寫出複雜的程序,而且要能夠將複雜的問題用容易閱讀的程序來實現。最好的程序是要大家都能看懂的,而不是隻有您一個人能看懂的程序。記住一個程序的KISS準則(此KISS非彼Kiss也):Keep It Simple and Stupid,也就是保持程序簡單性和傻瓜性,別人能容易看懂的程序纔是好程序。
思考題
1、指針和變量的相同點和區別的地方在哪裏?
2、函數的名稱可以賦值給一個int型的指針變量嗎?
3、如果沒有sizeof怎樣能夠獲得一個結構體的大小?


本文來自CSDN博客,轉載請標明出處:http://blog.csdn.net/Gemsea/archive/2007/01/26/1495198.aspx

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