【C\C++語言入門篇】-- 深入函數

作者: masefee  來源: CSDN  發布時間: 2010-11-02 15:31  閱讀: 895 次  推薦: 1   原文鏈接   [收藏]  
摘要:概況的介紹了C的函數

  從第一篇Helloworld開始到現在,就沒有脫離函數。那就是我們的main函數。main函數也是一個普通的函數,只不過通常把它作為我們寫的程序的入口。也就是說我們就當它最先執行。那這樣一來為什么說它又是一個普通的函數呢?原因是我們可以通過寫代碼改變這個入口。讓我們的程序一開始不執行main函數而先執行我們自定義的函數。具體怎么實現不是本篇的內容,大家知道有這么回事便可。記得main函數并不是一個特殊的函數,它只是被認為的定為程序的入口函數而已。

  那么,什么是函數?通俗的理解,它就是一段代碼塊,被我們將零散的語句集中在一起而用于支持某個功能。比如我們的strcpy也是一個函數,這個函數的作用是字符串拷貝。它里面有很多語句。這些語句被用一個函數的形式集中在一起而已。說到這里又不得不強調一點,那就是我們在接觸一個新的東西的時候盡量往其本質想,這樣便不會感到抽象和陌生。就比如函數,我們就理解它就是一個代碼塊集中管理的方案。一個函數名,參數列表加返回值用大括號將代碼括起來就成了函數。雖然是括起來了,但是函數可以說是不存在的。當編譯器將我們的CC++代碼編譯成匯編語言的時候,每個函數都只是一段代碼,什么函數名,參數列表,返回值將不再清晰可見。那就是一段集中在一塊兒的代碼。我們也就這么理解。至于為什么在CC++語法上函數要有名字、參數、返回值。這點是可以理解的。因為是高級語言嘛,這樣一來代碼的模塊性將很強。總不可能這樣寫喲:

  有函數:

 
void fun(void )
{

int a = 0;
}

void fun1(void )
{

int b = 0;
}

int main(void )

{

if (...)
fun();

else
fun1();
return 0;
}
//沒函數:

int main(void )
{

if (...)
goto fun;
else
goto fun1;
return 0;
fun:

int a = 0;
return 0;
fun1:

int b = 0;
return 0;
}

  上面的代碼顯而易見,函數的基本作用就得以體現了。模塊化方便管理與維護。知道了函數的概念及其本質后,我們再看具體的一些用法。首先從返回值上面說。

 
void fun();

int* fun();

struct A fun();

int fun();

char fun();

  看到上面不同返回值的函數。有指針,有類型,有空,有字符,還有結構體。C語言只能返回一個值,如果想返回多個,就只能用地址的形式返回給調用者了。其它花騷的辦法原理也都差不多。這里不一一說明。函數的返回值在CC++層面上都是用return關鍵字進行返回的。返回值是為了能夠被需要一些結果的調用者獲得這個結果值。返回值在很多時候非常重要。void類型就不用返回,如果想在函數中間某處就返回的話可以這樣:

 
void fun()

{


int a = 0;

return; //執行完a = 0就直接返回了,這里不返回任何值,只是充當一個結束此函數的作用。

a = 10;

}

  函數一旦return后,不管是否有返回值,都立即結束。因此,return通常用來返回值也用來終結函數。上面諸多返回值類型,我們只需要看兩個就夠了,一是返回指針,而是返回結構體。我們將一一追究起本質和一些注意事項。首先看返回指針:

 
int* fun(void )
{

int a = 100;
return &a;
}

  看上面這個程序,返回指針的形式很簡單。直接return a變量的地址。在外層調用的時候可以:

 
int* p = fun();

  這樣便把p指向了函數返回的地址上。假如我們要返回一個數組,在前面我們講了指針和數組。我們又知道不可能有 int[] fun(void )或者 int[ 3 ] fun(void )這樣的函數定義。那么我們便聯想到了指針和數組的共性,我們是否可以返回一個數組的首地址?然后再調用者取得這個首地址。在我們知道數組大小的情況下就能挨個訪問這個數組的每一個元素。有的人又會問,假如不知道大小了怎么辦呢?不知道大小基本是不可能的。你的大小是否可以使用宏定義或者全局變量呢?我們為何要往死胡同里面鉆呢?對吧。因此,就有如下代碼:

 
int* fun(void )

{

int a[ 3 ] = { 1, 2, 3 };
return a;
}

  在外層:

 
int* pArray = fun();
a
= pArray[ 0 ];

  是不是很方便。所以我們在使用指針和數組的時候要靈活。C語言雖然不能返回多個值,但是我們有辦法實現這個功能。寫到這里,大家知道了返回指針,欣喜若狂。運行之。結果意料之外的事情發生了。為什么返回的指針、數組元素亂了?數據錯誤了?

  這里就牽涉到一個注意事項了。我們上面寫的這兩個返回指針的函數,都是有問題的。說它是錯誤的也完全不過分。

  為什么這么說呢?大家仔細觀察,我返回的數組和返回的a的地址都是屬于臨時變量的地址。語法上這兩個函數確實沒有問題,錯誤的原因就在于我返回了臨時數據的內存地址。所謂臨時變量,也就是生命周期比較短,這里的數組和a在函數結束后生命便終結了。所以稱之為臨時變量。既然生命終結了,那么這塊內存將會被重新利用。就會被任意代碼或者操作重新賦值。這里就是所謂的棧內存。這里的棧不是數據結構里面的那個棧。這里通常指存放臨時變量的內存空間。一般很小,默認是1MB,也有2MB的。這個可以自己設置。這里就不多說了。假如有這樣一段代碼:

 
int* fun(void )
{

int a = 100;
return &a;
}

int* fun1(void )
{

int b = 200;
return &b;
}


int main(void )
{

int* p;
int* p1;
int aa, bb;

p
= fun();
p1
= fun1();
aa
= *p;
bb
= *p1;
return 0;
}

  在我的機器上,這兩個函數fun和fun1由于代碼基本相似。我故意構造了一個能夠體現棧內存被修改的錯誤。在這個程序結束后,aa和bb的值都是200。為什么?原因很簡單,我們在調用了fun函數后,p指向的棧內存比如是0x0012ffd4,當調用了fun1后,因為fun1跟fun區別很小,臨時變量b所在的棧內存地址剛好也被指定到了0x0012ffd4這個內存地址上。p1也便指向了這個內存地址。所以這里aa和bb必然是相同的值了。為什么是200原因也很簡單,臨時變量b把0x0012ffd4這個內存地址下的值賦值成了200,便覆蓋了之前的100。

  那么,如果我要改變這兩個函數,讓它們不會出錯該怎么辦呢?如下:

 
int* fun(void )
{

int* a = ( int*)malloc( sizeof( int ));

*a = 100;


return a;
}

  這樣的話就不存在被覆蓋了,大家知道這里使用的malloc函數申請的空間,此函數申請的空間將不在棧空間上,而是在堆內存中。我們不手工調用free函數,這個內存值將永遠存在。知道程序結束被回收。當然這樣做的的話,在外層獲得了這個a指針,在使用完后。記得把它free調。不然將造成內存泄露(一直申請,用完不釋放,內存被占用逐漸耗盡。)。

  問題一:寫出正確的返回數組的函數fun1。

  在了解了指針返回后,可能有的朋友會提問假如我要返回二級指針該怎么寫呢?我這里只說一句,二級指針也是指針,沒有什么特別的。跟以及指針同樣一個道理返回,記得一點指針變量也可以是臨時變量。具體還不清楚的話建議看看前面兩篇關于指針的文章。好了,返回指針說完了,再來說返回結構體。

  大家由于看了上面的返回指針,心里可能就會在猜想了。結構體以一組成員的集合,跟數組類似。我們要返回的時候,是不是也必須得用指針的方式返回首地址呢?或者還有其它方法?先看程序:

 
struct A
{

int a;
int b;
int c;
};
 
struct A fun(void )
{

struct A a = { 1, 2, 3 };
return a;
}


int main(void )
{

struct A ret = fun();

return 0;
}

  有這樣一段代碼,我們的目的是想返回臨時的結構體變量a的值給main函數里面的臨時變量ret。這里我故意強調了臨時變量這個詞。希望不要引起大家的誤解。這里雖然a是一個臨時變量,但是我返回變量a到ret中,并不是指向。而是拷貝。意思就是說將臨時變量a的3個成員值拷貝到ret變量的對應的成員里。跟指針是有區別的。我們前面說了C語言是不能返回多個值的,要返回就用指針。那么這里我沒有用指針很明顯的返回了3個值1、2、3.這是為什么呢?答案可能在這里講不是怎么適合,我先說在這里,能理解就理解。不能理解就記住結構體變量返回能實現返回多個值,就把結構體變量當著是一個值,不要想到它的成員。那么其本質上來說,結構體變量是怎么返回3個值的呢?

  原因在于,這里C語言默認幫我們做了很多事情,在后臺其實還是返回的只是一個地址,也就是結構體變量a的首地址,這個首地址不是存儲在我們定義的變量上的,而是通過CPU寄存器傳遞的。然后將寄存器指向的那個內存地址的值賦值給ret變量的成員a,然后再寄存器所指向的地址+偏移(這里是4,都是int型)就是b所在的內存地址,然后將b的值取出來賦值給ret中的b。c也是一個道理。這樣就把值傳遞過來了。我們可以理解為編譯器編譯后,程序會在內存中構建一個臨時的結構體。把函數要返回的結構體變量里面的值都復制到這個臨時的結構體里。我們是看不到這個結構體的。在函數執行完成后將這個臨時結構體的值賦值給我們的接收變量。這里可能有點不好理解,什么是臨時結構體,我之前不是一直強調本質嗎。結構體就是一塊連續的內存空間,我們這里A結構體占用12個字節,因此我可以隨便在內存的某個地方構建一個12字節的空間。放置這個結構體的3個成員的值。所以這里叫臨時結構體。

  說到這里,又得提醒一點了。這里我們要返回多個結構體變量的話,同樣也可以采用指針。原理跟上面基本類型指針返回一個道理。也存在臨時棧內存的問題。返回指針(返回地址)跟返回值(拷貝)大家要區分清楚。

好了。返回值我們就說完了。下面說參數。

  參數可以有多個,還可以有不定參數,比如我們常用的printf函數就是不定參數。也就是動態的參數個數哈。固定的參數個數多個和一個是一樣的道理,我在這里只列舉一個參數的情況或者兩個參數的情況。

 
void fun( int* p );
void fun( int a );
void fun(void );
void fun( int* p, int size );

  上面我沒有寫返回值,返回值不用說了。在了解參數之前我們先看一個例子:

 
void fun( int var )
{
var
= 100;
}

int main(void )
{

int a = 1;
fun( a );

return 0;
}

  在這個程序中,我們調用了fun函數,試圖去改變a的值。但是出乎意料的是,在調用了fun函數后a的值改變。這是為什么?可能很多初學的讀者一直很納悶。或者就死記硬背這樣不會改變a的值。我們在研究一個東西只有知道了本質才能記得更牢,而且不用記都會一直明白。那么我們先說說a沒有被改變的原因。

  也許大家都聽說過值傳遞,地址傳遞,引用傳遞。引用傳遞我們在本篇不說,那牽涉到C++的相關概念了。以后我們在講引用的時候再說。那么先說說什么叫值傳遞。

  我們通過上面的內容了解到了棧內存,也就是臨時數據存放的地方。函數內部的臨時變量都是放在這里面的。這里傳參數,又不得不明白一點就是。不管我們傳的是指針還是值。程序在調用函數之前都會先將參數壓入函數內部的棧空間里。意思就是說函數會把這些參數當著函數內部的臨時變量來處理。這里將參數壓入我們函數內部所在的棧空間里的過程叫傳遞,壓入的地方(內存地址)里的值通常稱為參數的副本。這里別想到游戲里面下FB哈,總結出來的意思就是說,我們在跟函數傳參數的時候會將參數一個一個壓入到函數內部所在的棧內存中。這里的壓入也可以理解成向棧內存里面寫值。

  上面的fun( a ),首先是將a的值壓入到棧內存,比如0x0012ffec這個內存里。這個內存地址下面的值就是1,也就是通常所說的a的副本(克隆體)。然后執行到函數內部的var = 100; 這里的var所取值的內存地址就是0x0012ffec,也就是傳進來的參數的那個內存地址。這一切都是編譯器給安排好的。然后我們將這個0x0012ffec內存地址里面的值賦值為100。好了,var變成了100。之后函數fun便執行完畢了。到這里大家可能已經知道為什么a的值不會改變了。原因就是函數內部只知道去改變0x0012ffec這個內存地址里面的值,而改變了這個值并不會影響到a,因為a又屬于main函數的局部變量,a所在的內存地址并不是0x0012ffec。0x0012ffec這個地址之所以能夠將a的值傳進函數是因為在壓參數的時候是將a的值1拷貝到0x0012ffec內存里。注意這里是拷貝。

  那么,到這里我們想了想,要是我們想改變a的值怎么辦呢?如下:

 
void fun( int*var )
{

*var = 100;
}

int main(void )
{

int a = 1;
fun(
&a );
return 0;
}

  用指針就可以將a 的值改變。大家又疑惑了。為什么這里指針就能改變呢?原因跟上面一樣,首先我們傳入的是a的內存地址,比如是0x0012ffff,將這個地址傳給了函數,通過我們上面知道,雖然是傳的地址,可它還是將這個地址當著值壓入函數內部棧空間,比如壓到了0x0012eeee這個內存里。注意每個函數都有自己獨立的那塊棧空間提供給自己用,用完就丟棄。所以這里壓入后的內存地址跟變量a本身的內存地址不可能相同。然后我們再看fun函數,它是一個指針取值操作然后再賦值為100。看看流程,首先var我們知道它的內存地址就是0x0012eeee(上面說的編譯器安排的),而這個內存地址里面的值就是0x0012ffff這個內存地址。var是一個指針,在前面指針篇我們知道var有它自己的內存地址(這里就是0x0012eeee),它自己又保存了它所指向的內存地址(這里就是0x0012ffff)。這里這個內存地址也就是傳進來的a變量的地址,我們在間接訪問(*var)時,實際就是操作的a變量本身。因此這里將會直接指向a的地址將其值改變為100。

  這個例子在我沒有打招呼的情況下我們已經就講了地址傳遞的方法。地址傳遞就是將一個變量的地址傳遞給函數,函數內部在訪問壓入的這個參數時,讀寫的是外部變量的地址值。因此可以改變傳入參數的值。

  問題二:假如上面的程序中a是一個指針,我們將a傳進函數fun,然后在fun函數里改變指針的指向(指針的值)。外面的a指針是否會改變?為什么?(提示:原理跟上面一樣,必要時用二級指針進行地址傳遞)

  說到數組,我們又不得不想到如果我們想傳一個一維數組到函數內部,供函數取值或者寫值。又該怎么做?

 
void fun( int* a )
{
a[
0 ] = 100;
a[
1 ] = 100;
a[
2 ] = 100;
}

int main(void )
{

int array[ 3 ] = { 1, 2, 3 };
fun( array);

return 0;
}

  以上代碼中,我們的意圖是想將array的值改成100。我們的目的達到了,結果一切正常。為什么呢?可能有的讀者已經被上面的值傳遞和地址傳遞給弄混了。在這里我們不用多想,就應該知道這里傳入的是array數組的首地址,在函數內部會將這個地址里面的值進行修改,然后加上偏移逐個修改。這里也是通過地址直接操作的。原理跟上面一樣我就不多說了。這里的fun函數是我知道array數組有3個元素的情況下,假如不知道,那么我們就該再添加一個數組元素個數的參數。這樣既安全又得體。比如:

  void fun( int* pArray, int size ); fun( array, 3 );

  這樣函數內部就不會怕讀寫越界了。

  問題三:怎么傳二維數組到函數內部?下面我就來舉一個越界帶來的可怕后果之一:

 
void fun(void )
{
printf(
"I'm Come In!!!\n");
}

int main(void )
{

int array[ 1 ] = { 1 };
array[
3 ] = ( unsigned int )fun;
return 0;
}

  就上面一個簡單的程序,已經詮釋了一個經典的緩沖區溢出攻擊基本原理了。先解釋下程序,這里定義了一個數組array,它是有一個元素,下面的一句    array[ 3 ] = ( unsigned int )fun; 我這里是故意將fun函數的地址越界賦值給array數組后面的第3個內存地址里。占用4個字節。這樣做的目的,大家運行了便知道,神奇般的在我沒有調用fun函數的情況下進入了fun函數并輸出了I‘m Come In!!!字符串。可能很多人就傻了,為什么會這樣?我這里并沒有調用。

  原因很簡單,我們每個函數在執行完以后都會跳轉回來,回到調用此函數的下一條語句繼續往下執行,函數之所以能跳轉回來是因為我們在調用函數的時候就已經將要返回到的代碼地址給保存到函數棧內存中了。我這里將數組寫越界的目的就是為了將這個返回地址值改變成我的目標函數fun函數的地址(函數也是有首地址的)。這里強制類型轉換fun函數首地址為無符號整數覆蓋掉main函數的返回地址。這樣在main函數返回時便會跳轉到fun函數并執行該函數。輸出字符串。我們可以聯想一下,假如這個fun函數是我們的黑客想操作一些事情的函數,那將是非常危險的。這里就是經典的“緩沖區溢出攻擊”的基本原理。

  假如我這里不是array數組,而是一個字符數組,我們在strcpy的時候沒有檢查長度,黑客通過修改函數傳入的字符串參數,讓其拷貝越界,覆蓋掉返回地址,覆蓋的內容就是黑客自己實現的函數的地址。我們程序將神不知鬼不覺的調用它的函數。當然上面我寫的這個在執行輸出后,fun函數在返回時,由于不是正常調用,他的返回地址沒有誰給他壓入,將返回到錯誤的地址最后崩潰掉。這里我沒有處理堆棧平衡和返回地址。處理之后將不會崩潰,跟正常流程一樣順利。

  上面說了越界緩沖區溢出亂調函數,也是為了引入函數指針,上面的例子我們初識函數也是有自己的地址的。既然有地址,那么指針必然就成立。既然是指針,又是普通函數,那么我隨便怎么轉換該指針都沒有問題。這也是CC++的魅力所在。我上面就輕輕松松轉換成了無符號整數然后覆蓋了返回地址。是不是很方便?那么我們再看看正規的函數指針定義:

 
void fun(void )
{
printf(
"I'm Come In!!!\n");
}

int main(void )
{
typedef
void(*PFUN )(void ); //定義函數指針,這里使用typedef別名,PFUN就被聲明為void返回,無參數類型函數的指針
PFUN pfun = fun;
(
*pfun )();
pfun();
//兩種調用方式都是一樣的
return 0;
}

  上面大家已經知道了函數指針的定義了吧,語法很簡單。先定義一個函數指針pfun,將值賦值為fun函數的地址,函數名也代表函數指針,此指針就是指向的fun函數開始的代碼地址。這里是代碼地址。在我們的exe中,每一句代碼都是有自己的代碼地址的。這里的代碼值的是匯編每條指令。這里我們不追究,只需要知道函數也是有首地址的。可以賦值給函數指針乃至任何一個指針。只不過賦值給函數指針之后我們就可以像(*pfun )();   pfun();這樣調用它。跟函數調用沒有什么區別。假如你給我將fun函數賦值給一個void*指針p:

 
void* p = (void*)fun;
p();
// error

  這樣將是錯誤的,原因就不用說了吧。天下人都知道。函數指針也很靈活,同樣也可以由參數,有返回值。跟普通函數沒有上面區別。

  問題四:定義一個有參數,有返回值的函數指針,并調用它。將函數指針作為參數也是有必要了解的:

 
typedef void(*PFUN )(void );
void fun(void )
{
printf(
"I'm Come In!!!\n");
}

void call_func( PFUN pFun )
{
pFun();
}


int main(void )
{
call_func( fun );

return 0;
}

  上面的代碼,反映了將函數指針作為參數傳遞給一個函數,讓這個函數在另外一個地方被執行。這個過程通常稱為回調。fun可以稱為回調函數。我們將fun的函數指針傳遞給call_func,然后call_func再調用這個fun函數。原理大家清楚了吧。

  回調函數在大型的項目中使用得非常多,最直接的就是我們的WIN32的消息回調函數。我們需要注冊我們自己定義的函數給操作系統,這里的注冊其實就是操作系統提供了一個函數指針給我們。我們將提供的這個函數指針賦值為我們自定義的函數的指針。操作系統內部又在不斷的調用這個函數指針。因此我們就可以讓操作系統調用我們的自定義函數了。大家可以自己試著寫寫這樣的調用模型。比如一個函數指針的鏈表,里面存放了很多函數指針,我們遍歷調用這個鏈表里面的所有函數指針。這些指針我們都賦值成我們想要調用的函數。

  這里值得大家注意的是,使用函數指針的時候一定要小心,比如:

 
typedef int (*PFUN )(void );

void fun(void )
{
printf(
"I'm Come In!!!\n");
}


int call_func( PFUN pFun )
{

int a = pFun();
return a;
}

int main(void )
{

int ret = call_func(( PFUN )fun );
return 0;
}

  我將fun函數強制轉換成int返回類型的函數指針,然后調用。這樣執行完成后,ret的值將是廢棄的。不可預測的。原因很簡單,fun函數是沒有返回值的。這里的返回值具體會是讀取的哪兒的值我們就不在這里講解了,知道有這么回事就可以了。這里假如不強制轉換,編譯器也只是會給一個警告而已。這種用法是絕對錯誤的。所以我們在使用回調函數的時候一定要注意參數的函數指針是聲明的指向什么類型的函數。

  另外函數的可變參數這里就不講了,這不是重點,只是語法而已。大家通過查閱資料就可以明白了。好了,函數我們就介紹完了。大家好好理解。有點長。又寫了我5個小時左右。。。。休息。。

1
0
 
 
 

文章列表

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()