前面一篇我們介紹了結構體,這篇終於能夠介紹函數了。為什麼這麼說呢?因為函數非常重要。就這麼簡單。嘿嘿!之所以在這時才講函數,是因為本篇將聯絡到前面的每一篇,這樣函數才能體現的透徹。那我們就迫不及待的切入正題。
從第一篇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個小時左右。。。。休息。。
【C/C++入門篇系列】
【C/C++語言入門篇】-- 序言
【C/C++語言入門篇】-- HelloWorld思考
【C/C++語言入門篇】-- 基礎資料型別 (Elementary Data Type)
【C/C++語言入門篇】-- 調試基礎
【C/C++語言入門篇】-- 深入指標
【C/C++語言入門篇】-- 數組與指標
【C/C++語言入門篇】-- 結構體
【C/C++語言入門篇】-- 深入函數
【C/C++語言入門篇】-- 位元運算
【C/C++語言入門篇】-- 剖析浮點數
【C/C++語言入門篇】-- 檔案操作