C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹

sewain發表於2021-01-17

這是道哥的第014篇原創



一、前言

如果問C語言中最重要、威力最大的概念是什麼,答案必將是指標!威力大,意味著使用方便、高效,同時也意味著語法複雜、容易出錯。指標用的好,可以極大的提高程式碼執行效率、節約系統資源;如果用的不好,程式中將會充滿陷阱、漏洞

這篇文章,我們就來聊聊指標。從最底層的記憶體儲存空間開始,一直到應用層的各種指標使用技巧,循序漸進、抽絲剝繭,以最直白的語言進行講解,讓你一次看過癮。

說明:為了方便講解和理解,文中配圖的記憶體空間的地址是隨便寫的,在實際計算機中是要遵循地址對齊方式的。

二、變數與指標的本質

1. 記憶體地址

我們編寫一個程式原始檔之後,編譯得到的二進位制可執行檔案存放在電腦的硬碟上,此時它是一個靜態的檔案,一般稱之為程式

當這個程式被啟動的時候,作業系統將會做下面幾件事情:

  1. 把程式的內容(程式碼段、資料段)從硬碟複製到記憶體中;
  2. 建立一個資料結構PCB(程式控制塊),來描述這個程式的各種資訊(例如:使用的資源,開啟的檔案描述符...);
  3. 在程式碼段中定位到入口函式的地址,讓CPU從這個地址開始執行。

當程式開始被執行時,就變成一個動態的狀態,一般稱之為程式

記憶體分為:實體記憶體和虛擬記憶體。作業系統對實體記憶體進行管理、包裝,我們開發者面對的是作業系統提供的虛擬記憶體。
這2個概念不妨礙文章的理解,因此就統一稱之為記憶體。

在我們的程式中,通過一個變數名來定義變數、使用變數。變數本身是一個確確實實存在的東西,變數名是一個抽象的概念,用來代表這個變數。就比如:我是一個實實在在的人,是客觀存在與這個地球上的,道哥是我給自己起的一個名字,這個名字是任意取得,只要自己覺得好聽就行,如果我願意還可以起名叫:鳥哥、龍哥等等。

那麼,我們定義一個變數之後,這個變數放在哪裡呢?那就是記憶體的資料區。記憶體是一個很大的儲存區域,被作業系統劃分為一個一個的小空間,作業系統通過地址來管理記憶體。

記憶體中的最小儲存單位是位元組(8個bit),一個記憶體的完整空間就是由這一個一個的位元組連續組成的。在上圖中,每一個小格子代表一個位元組,但是好像大家在書籍中沒有這麼來畫記憶體模型的,更常見的是下面這樣的畫法:

也就是把連續的4個位元組的空間畫在一起,這樣就便於表述和理解,特別是深入到程式碼對齊相關知識時更容易理解。(我認為根本原因應該是:大家都這麼畫,已經看順眼了~~)

2. 32位與64位系統

我們平時所說的計算機是32位、64位,指的是計算機的CPU中暫存器的最大儲存長度,如果暫存器中最大儲存32bit的資料,就稱之為32位系統。

在計算機中,資料一般都是在硬碟、記憶體和暫存器之間進行來回存取。CPU通過3種匯流排把各組成部分聯絡在一起:地址匯流排、資料匯流排和控制匯流排。地址匯流排的寬度決定了CPU的定址能力,也就是CPU能達到的最大地址範圍

剛才說了,記憶體是通過地址來管理的,那麼CPU想從記憶體中的某個地址空間上存取一個資料,那麼CPU就需要在地址匯流排上輸出這個儲存單元的地址。假如地址匯流排的寬度是8位,能表示的最大地址空間就是256個位元組,能找到記憶體中最大的儲存單元是255這個格子(從0開始)。即使記憶體條的實際空間是2G位元組,CPU也沒法使用後面的記憶體地址空間。如果地址匯流排的寬度是32位,那麼能表示的最大地址就是2的32次方,也就是4G位元組的空間。

【注意】:這裡只是描述地址匯流排的概念,實際的計算機中地址計算方式要複雜的多,比如:虛擬記憶體中採用分段、分頁、偏移量來定位實際的實體記憶體,在分頁中還有大頁、小頁之分,感興趣的同學可以自己查一下相關資料。

3. 變數

我們在C程式中使用變數來“代表”一個資料,使用函式名來“代表”一個函式,變數名和函式名是程式設計師使用的助記符。變數和函式最終是要放到記憶體中才能被CPU使用的,而記憶體中所有的資訊(程式碼和資料)都是以二進位制的形式來儲存的,計算機根據就不會從格式上來區分哪些是程式碼、哪些是資料。CPU在訪問記憶體的時候需要的是地址,而不是變數名、函式名

問題來了:在程式程式碼中使用變數名來指代變數,而變數在記憶體中是根據地址來存放的,這二者之間如何對映(關聯)起來的?

答案是:編譯器!編譯器在編譯文字格式的C程式檔案時,會根據目標執行平臺(就是編譯出的二進位制程式執行在哪裡?是x86平臺的電腦?還是ARM平臺的開發板?)來安排程式中的各種地址,例如:載入到記憶體中的地址、程式碼段的入口地址等等,同時編譯器也會把程式中的所有變數名,轉成該變數在記憶體中的儲存地址

變數有2個重要屬性:變數的型別和變數的值

示例:程式碼中定義了一個變數

int a = 20;

型別是int型,值是20。這個變數在記憶體中的儲存模型為:

我們在程式碼中使用變數名a,在程式執行的時候就表示使用0x11223344地址所對應的那個儲存單元中的資料。因此,可以理解為變數名a就等價於這個地址0x11223344。換句話說,如果我們可以提前知道編譯器把變數a安排在地址0x11223344這個單元格中,我們就可以在程式中直接用這個地址值來操作這個變數。

在上圖中,變數a的值為20,在記憶體中佔據了4個格子的空間,也就是4個位元組。為什麼是4個位元組呢?在C標準中並沒有規定每種資料型別的變數一定要佔用幾個位元組,這是與具體的機器、編譯器有關。

比如:32位的編譯器中:

char: 1個位元組;
short int: 2個位元組;
int: 4個位元組;
long: 4個位元組。

比如:64位的編譯器中:

char: 1個位元組;
short int: 2個位元組;
int: 4個位元組;
long: 8個位元組。

為了方便描述,下面都以32位為例,也就是int型變數在記憶體中佔據4個位元組。

另外,0x11223344,0x11223345,0x11223346,0x11223347這連續的、從低地址到高地址的4個位元組用來儲存變數a的數值20。在圖示中,使用十六進位制來表示,十進位制數值20轉成16進位制就是:0x00000014,所以從開始地址依次存放0x00、0x00、0x00、0x14這4個位元組(儲存順序涉及到大小端的問題,不影響文字理解)。

根據這個圖示,如果在程式中想知道變數a儲存在記憶體中的什麼位置,可以使用取地址操作符&,如下:

printf("&a = 0x%x \n", &a);

這句話將會列印出:&a = 0x11223344

考慮一下,在32位系統中:指標變數佔用幾個位元組?

4. 指標變數

指標變數可以分2個層次來理解:

  1. 指標變數首先是一個變數,所以它擁有變數的所有屬性:型別和值。它的型別就是指標,它的值是其他變數的地址。 既然是一個變數,那麼在記憶體中就需要為這個變數分配一個儲存空間。在這個儲存空間中,存放著其他變數的地址。
  2. 指標變數所指向的資料型別,這是在定義指標變數的時候就確定的。例如:int *p; 意味著指標指向的是一個int型的資料。

首先回答一下剛才那個問題,在32位系統中,一個指標變數在記憶體中佔據4個位元組的空間。因為CPU對記憶體空間定址時,使用的是32位地址空間(4個位元組),也就是用4個位元組就能儲存一個記憶體單元的地址。而指標變數中的值儲存的就是地址,所以需要4個位元組的空間來儲存一個指標變數的值。

示例:

int a = 20;
int *pa;
pa = &a;
printf("value = %d \n", *pa);

在記憶體中的儲存模型如下:

對於指標變數pa來說,首先它是一個變數,因此在記憶體中需要有一個空間來儲存這個變數,這個空間的地址就是0x11223348;

其次,這個記憶體空間中儲存的內容是變數a的地址,而a的地址為0x11223344,所以指標變數pa的地址空間中,就儲存了0x11223344這個值

這裡對兩個操作符&和*進行說明:

&:取地址操作符,用來獲取一個變數的地址。上面程式碼中&a就是用來獲取變數a在記憶體中的儲存地址,也就是0x11223344。
*:這個操作符用在2個場景中:定義一個指標的時候,獲取一個指標所指向的變數值的時候。

  1. int pa; 這個語句中的表示定義的變數pa是一個指標,前面的int表示pa這個指標指向的是一個int型別的變數。不過此時我們沒有給pa進行賦值,也就是說此刻pa對應的儲存單元中的4個位元組裡的值是沒有初始化的,可能是0x00000000,也可能是其他任意的數字,不確定;
  2. printf語句中的*表示獲取pa指向的那個int型別變數的值,學名叫解引用,我們只要記住是獲取指向的變數的值就可以了。

5. 操作指標變數

對指標變數的操作包括3個方面:

  1. 操作指標變數自身的值;
  2. 獲取指標變數所指向的資料;
  3. 以什麼樣資料型別來使用/解釋指標變數所指向的內容。
5.1 指標變數自身的值

int a = 20;這個語句是定義變數a,在隨後的程式碼中,只要寫下a就表示要操作變數a中儲存的值,操作有兩種:讀和寫。

printf("a = %d \n", a); 這個語句就是要讀取變數a中的值,當然是20;
a = 100;這個語句就是要把一個數值100寫入到變數a中。

同樣的道理,int *pa;語句是用來定義指標變數pa,在隨後的程式碼中,只要寫下pa就表示要操作變數pa中的值

printf("pa = %d \n", pa); 這個語句就是要讀取指標變數pa中的值,當然是0x11223344;
pa = &a;這個語句就是要把新的值寫入到指標變數pa中。再次強調一下,指標變數中儲存的是地址,如果我們可以提前知道變數a的地址是 0x11223344,那麼我們也可以這樣來賦值:pa = 0x11223344;

思考一下,如果執行這個語句printf("&pa =0x%x \n", &pa);,列印結果會是什麼?

上面已經說過,操作符&是用來取地址的,那麼&pa就表示獲取指標變數pa的地址,上面的記憶體模型中顯示指標變數pa是儲存在0x11223348這個地址中的,因此列印結果就是:&pa = 0x11223348

5.2 獲取指標變數所指向的資料

指標變數所指向的資料型別是在定義的時候就明確的,也就是說指標pa指向的資料型別就是int型,因此在執行printf("value = %d \n", *pa);語句時,首先知道pa是一個指標,其中儲存了一個地址(0x11223344),然後通過操作符*來獲取這個地址(0x11223344)對應的那個儲存空間中的值;又因為在定義pa時,已經指定了它指向的值是一個int型,所以我們就知道了地址0x11223344中儲存的就是一個int型別的資料。

5.3 以什麼樣的資料型別來使用/解釋指標變數所指向的內容

如下程式碼:

int a = 30000;
int *pa = &a;
printf("value = %d \n", *pa);

根據以上的描述,我們知道printf的列印結果會是value = 30000,十進位制的30000轉成十六進位制是0x00007530,記憶體模型如下:

現在我們做這樣一個測試:

char *pc = 0x11223344;
printf("value = %d \n", *pc);

指標變數pc在定義的時候指明:它指向的資料型別是char型,pc變數中儲存的地址是0x11223344。當使用*pc獲取指向的資料時,將會按照char型格式來讀取0x11223344地址處的資料,因此將會列印value = 0(在計算機中,ASCII碼是用等價的數字來儲存的)。

這個例子中說明了一個重要的概念:在記憶體中一切都是數字,如何來操作(解釋)一個記憶體地址中的資料,完全是由我們的程式碼來告訴編譯器的。剛才這個例子中,雖然0x11223344這個地址開始的4個位元組的空間中,儲存的是整型變數a的值,但是我們讓pc指標按照char型資料來使用/解釋這個地址處的內容,這是完全合法的。

以上內容,就是指標最根本的心法了。把這個心法整明白了,剩下的就是多見識、多練習的問題了。

三、指標的幾個相關概念

1. const屬性

const識別符號用來表示一個物件的不可變的性質,例如定義:

const int b = 20;

在後面的程式碼中就不能改變變數b的值了,b中的值永遠是20。同樣的,如果用const來修飾一個指標變數:

int a = 20;
int b = 20;
int * const p = &a;

記憶體模型如下:

這裡的const用來修飾指標變數p,根據const的性質可以得出結論:p在定義為變數a的地址之後,就固定了,不能再被改變了,也就是說指標變數pa中就只能儲存變數a的地址0x11223344。如果在後面的程式碼中寫p = &b;,編譯時就會報錯,因為p是不可改變的,不能再被設定為變數b的地址。

但是,指標變數p所指向的那個變數a的值是可以改變的,即:*p = 21;這個語句是合法的,因為指標p的值沒有改變(仍然是變數c的地址0x11223344),改變的是變數c中儲存的值。

與下面的程式碼區分一下:

int a = 20;
int b = 20;
const int *p = &a;
p = &b;

這裡的const沒有放在p的旁邊,而是放在了型別int的旁邊,這就說明const符號不是用來修飾p的,而是用來修飾p所指向的那個變數的。所以,如果我們寫p = &b;把變數b的地址賦值給指標p,就是合法的,因為p的值可以被改變。

但是這個語句*p = 21就是非法了,因為定義語句中的const就限制了通過指標p獲取的資料,不能被改變,只能被用來讀取。這個性質常常被用在函式引數上,例如下面的程式碼,用來計算一塊資料的CRC校驗,這個函式只需要讀取原始資料,不需要(也不可以)改變原始資料,因此就需要在形參指標上使用const修飾符:

short int getDataCRC(const char *pData, int len)
{
    short int crc = 0x0000;
    // 計算CRC
    return crc;
}

2. void型指標

關鍵字void並不是一個真正的資料型別,它體現的是一種抽象,指明不是任何一種型別,一般有2種使用場景:

  1. 函式的返回值和形參;
  2. 定義指標時不明確規定所指資料的型別,也就意味著可以指向任意型別。

指標變數也是一種變數,變數之間可以相互賦值,那麼指標變數之間也可以相互賦值,例如:

int a = 20;
int b = a;
int *p1 = &a;
int *p2 = p1;

變數a賦值給變數b,指標p1賦值給指標p2,注意到它們的型別必須是相同的:a和b都是int型,p1和p2都是指向int型,所以可以相互賦值。那麼如果資料型別不同呢?必須進行強制型別轉換。例如:

int a = 20;
int *p1 = &a;
char *p2 = (char *)p1;

記憶體模型如下:

p1指標指向的是int型資料,現在想把它的值(0x11223344)賦值給p2,但是由於在定義p2指標時規定它指向的資料型別是char型,因此需要把指標p1進行強制型別轉換,也就是把地址0x11223344處的資料按照char型資料來看待,然後才可以賦值給p2指標。

如果我們使用void *p2來定義p2指標,那麼在賦值時就不需要進行強制型別轉換了,例如:

int a = 20;
int *p1 = &a;
void *p2 = p1;

指標p2是void*型,意味著可以把任意型別的指標賦值給p2,但是不能反過來操作,也就是不能把void*型指標直接賦值給其他確定型別的指標,而必須要強制轉換成被賦值指標所指向的資料型別,如下程式碼,必須把p2指標強制轉換成int*型之後,再賦值給p3指標:

int a = 20;
int *p1 = &a;
void *p2 = p1;
int *p3 = (int *)p2;

我們來看一個系統函式:

void* memcpy(void* dest, const void* src, size_t len);

第一個引數型別是void*,這正體現了系統對記憶體操作的真正意義:它並不關心使用者傳來的指標具體指向什麼資料型別,只是把資料挨個儲存到這個地址對應的空間中。

第二個引數同樣如此,此外還新增了const修飾符,這樣就說明了memcpy函式只會從src指標處讀取資料,而不會修改資料

3. 空指標和野指標

一個指標必須指向一個有意義的地址之後,才可以對指標進行操作。如果指標中儲存的地址值是一個隨機值,或者是一個已經失效的值,此時操作指標就非常危險了,一般把這樣的指標稱作野指標,C程式碼中很多指標相關的bug就來源於此。

3.1 空指標:不指向任何東西的指標

在定義一個指標變數之後,如果沒有賦值,那麼這個指標變數中儲存的就是一個隨機值,有可能指向記憶體中的任何一個地址空間,此時萬萬不可以對這個指標進行寫操作,因為它有可能指向記憶體中的程式碼段區域、也可能指向記憶體中作業系統所在的區域。

一般會將一個指標變數賦值為NULL來表示一個空指標,而C語言中,NULL實質是 ((void*)0) , 在C++中,NULL實質是0。在標準庫標頭檔案stdlib.h中,有如下定義:

#ifdef __cplusplus
     #define NULL    0
#else    
     #define NULL    ((void *)0)
#endif
3.2 野指標:地址已經失效的指標

我們都知道,函式中的區域性變數儲存在棧區,通過malloc申請的記憶體空間位於堆區,如下程式碼:

int *p = (int *)malloc(4);
*p = 20;

記憶體模型為:

在堆區申請了4個位元組的空間,然後強制型別轉換為int*型之後,賦值給指標變數p,然後通過*p設定這個地址中的值為14,這是合法的。如果在釋放了p指標指向的空間之後,再使用*p來操作這段地址,那就是非常危險了,因為這個地址空間可能已經被作業系統分配給其他程式碼使用,如果對這個地址裡的資料強行操作,程式立刻崩潰的話,將會是我們最大的幸運!

int *p = (int *)malloc(4);
*p = 20;
free(p);
// 在free之後就不可以再操作p指標中的資料了。
p = NULL;  // 最好加上這一句。

四、指向不同資料型別的指標

1. 數值型指標

通過上面的介紹,指向數值型變數的指標已經很明白了,需要注意的就是指標所指向的資料型別

2. 字串指標

字串在記憶體中的表示有2種:

  1. 用一個陣列來表示,例如:char name1[8] = "zhangsan";
  2. 用一個char *指標來表示,例如:char *name2 = "zhangsan";

name1在記憶體中佔據8個位元組,其中儲存了8個字元的ASCII碼值;name2在記憶體中佔據9個位元組,因為除了儲存8個字元的ASCII碼值,在最後一個字元'n'的後面還額外儲存了一個'\0',用來標識字串結束

對於字串來說,使用指標來操作是非常方便的,例如:變數字串name2:

char *name2 = "zhangsan";
char *p = name2;
while (*p != '\0')
{
    printf("%c ", *p);
    p = p + 1;
}

在while的判斷條件中,檢查p指標指向的字元是否為結束符'\0'。在迴圈體重,列印出當前指向的字元之後,對指標比那裡進行自增操作,因為指標p所指向的資料型別是char,每個char在記憶體中佔據一個位元組,因此指標p在自增1之後,就指向下一個儲存空間。

也可以把迴圈體中的2條語句寫成1條語句:

printf("%c ", *p++);

假如一個指標指向的資料型別為int型,那麼執行p = p + 1;之後,指標p中儲存的地址值將會增加4,因為一個int型資料在記憶體中佔據4個位元組的空間,如下所示:

思考一個問題:void*型指標能夠遞增嗎?如下測試程式碼:

int a[3] = {1, 2, 3};
void *p = a;
printf("1: p = 0x%x \n", p);
p = p + 1;
printf("2: p = 0x%x \n", p);

列印結果如下:

1: p = 0x733748c0 
2: p = 0x733748c1 

說明void*型指標在自增時,是按照一個位元組的跨度來計算的。

3. 指標陣列與陣列指標

這2個說法經常會混淆,至少我是如此,先看下這2條語句:

int *p1[3];   // 指標陣列
int (*p2)[3]; // 陣列指標
3.1 指標陣列

第1條語句中:中括號[]的優先順序高,因此與p1先結合,表示一個陣列,這個陣列中有3個元素,這3個元素都是指標,它們指向的是int型資料。可以這樣來理解:如果有這個定義char p[3],很容易理解這是一個有3個char型元素的陣列,那麼把char換成int*,意味著陣列裡的元素型別是int*型(指向int型資料的指標)。記憶體模型如下(注意:三個指標指向的地址並不一定是連續的):

如果向指標陣列中的元素賦值,需要逐個把變數的地址賦值給指標元素:

int a = 1, b = 2, c = 3;
char *p1[3];
p1[0] = &a;
p1[1] = &b;
p1[2] = &c;
3.2 陣列指標

第2條語句中:小括號讓p2與*結合,表示p2是一個指標,這個指標指向了一個陣列,陣列中有3個元素,每一個元素的型別是int型。可以這樣來理解:如果有這個定義int p[3],很容易理解這是一個有3個char型元素的陣列,那麼把陣列名p換成是*p2,也就是p2是一個指標,指向了這個陣列。記憶體模型如下(注意:指標指向的地址是一個陣列,其中的3個元素是連續放在記憶體中的):

在前面我們說到取地址操作符&,用來獲得一個變數的地址。凡事都有特殊情況,對於獲取地址來說,下面幾種情況不需要使用&操作符

  1. 字串字面量作為右值時,就代表這個字串在記憶體中的首地址;
  2. 陣列名就代表這個陣列的地址,也等於這個陣列的第一個元素的地址;
  3. 函式名就代表這個函式的地址。

因此,對於一下程式碼,三個printf語句的列印結果是相同的:

int a[3] = {1, 2, 3};
int (*p2)[3] = a;
printf("0x%x \n", a);
printf("0x%x \n", &a);
printf("0x%x \n", p2);

思考一下,如果對這裡的p2指標執行p2 = p2 + 1;操作,p2中的值將會增加多少

答案是12個位元組。因為p2指向的是一個陣列,這個陣列中包含3個元素,每個元素佔據4個位元組,那麼這個陣列在記憶體中一共佔據12個位元組,因此p2在加1之後,就跳過12個位元組。

4. 二維陣列和指標

一維陣列在記憶體中是連續分佈的多個記憶體單元組成的,而二維陣列在記憶體中也是連續分佈的多個記憶體單元組成的,從記憶體角度來看,一維陣列和二維陣列沒有本質差別。

和一維陣列類似,二維陣列的陣列名表示二維陣列的第一維陣列中首元素的首地址,用程式碼來說明:

int a[3][3] = {{1,2,3}, {4,5,6}, {7,8,9}}; // 二維陣列
int (*p0)[3] = NULL;   // p0是一個指標,指向一個陣列
int (*p1)[3] = NULL;   // p1是一個指標,指向一個陣列
int (*p2)[3] = NULL;   // p2是一個指標,指向一個陣列
p0 = a[0];
p1 = a[1];
p2 = a[2];
printf("0: %d %d %d \n", *(*p0 + 0), *(*p0 + 1), *(*p0 + 2));
printf("1: %d %d %d \n", *(*p1 + 0), *(*p1 + 1), *(*p1 + 2));
printf("2: %d %d %d \n", *(*p2 + 0), *(*p2 + 1), *(*p2 + 2));

列印結果是:

0: 1 2 3 
1: 4 5 6 
2: 7 8 9

我們拿第一個printf語句來分析:p0是一個指標,指向一個陣列,陣列中包含3個元素,每個元素在記憶體中佔據4個位元組。現在我們想獲取這個陣列中的資料,如果直接對p0執行加1操作,那麼p0將會跨過12個位元組(就等於p1中的值了),因此需要使用解引用操作符*,把p0轉為指向int型的指標,然後再執行加1操作,就可以得到陣列中的int型資料了。

5. 結構體指標

C語言中的基本資料型別是預定義的,結構體是使用者定義的,在指標的使用上可以進行類比,唯一有區別的就是在結構體指標中,需要使用->箭頭操作符來獲取結構體中的成員變數,例如:

typedef struct 
{
    int age;
    char name[8];
} Student;

Student s;
s.age = 20;
strcpy(s.name, "lisi");
Student *p = &s;
printf("age = %d, name = %s \n", p->age, p->name);

看起來似乎沒有什麼技術含量,如果是結構體陣列呢? 例如:

Student s[3];
Student *p = &s;
printf("size of Student = %d \n", sizeof(Student));
printf("1: 0x%x, 0x%x \n", s, p);
p++;
printf("2: 0x%x \n", p);

列印結果是:

size of Student = 12 
1: 0x4c02ac00, 0x4c02ac00 
2: 0x4c02ac0c

在執行p++操作後,p需要跨過的空間是一個結構體變數在記憶體中佔據的大小(12個位元組),所以此時p就指向了陣列中第2個元素的首地址,記憶體模型如下:

6. 函式指標

每一個函式在經過編譯之後,都變成一個包含多條指令的集合,在程式被載入到記憶體之後,這個指令集合被放在程式碼區,我們在程式中使用函式名就代表了這個指令集合的開始地址

函式指標,本質上仍然是一個指標,只不過這個指標變數中儲存的是一個函式的地址。函式最重要特性是什麼?可以被呼叫!因此,當定義了一個函式指標並把一個函式地址賦值給這個指標時,就可以通過這個函式指標來呼叫函式。

如下示例程式碼:

int add(int x,int y)
{
    return x+y;
}

int main()
{
    int a = 1, b = 2;
    int (*p)(int, int);
    p = add;
    printf("%d + %d = %d\n", a, b, p(a, b));
}

前文已經說過,函式的名字就代表函式的地址,所以函式名add就代表了這個加法函式在記憶體中的地址
int (*p)(int, int);這條語句就是用來定義一個函式指標,它指向一個函式,這個函式必須符合下面這2點(學名叫:函式簽名):

  1. 有2個int型的引數;
  2. 有一個int型的返回值。

程式碼中的add函式正好滿足這個要求,因此,可以把add賦值給函式指標p,此時p就指向了記憶體中這個函式儲存的地址,後面就可以用函式指標p來呼叫這個函式了

在示例程式碼中,函式指標p是直接定義的,那如果想定義2個函式指標,難道需要像下面這樣定義嗎?

int (*p)(int, int);
int (*p2)(int, int);

這裡的引數比較簡單,如果函式很複雜,這樣的定義方式豈不是要煩死?可以用typedef關鍵字來定義一個函式指標型別

typedef int (*pFunc)(int, int);

然後用這樣的方式pFunc p1, p2;來定義多個函式指標就方便多了。注意:只能把與函式指標型別具有相同簽名的函式賦值給p1和p2,也就是引數的個數、型別要相同,返回值也要相同

注意:這裡有幾個小細節稍微瞭解一下:

  1. 在賦值函式指標時,使用p = &a;也是可以的;
  2. 使用函式指標呼叫時,使用(*p)(a, b);也是可以的。

這裡沒有什麼特殊的原理需要講解,最終都是編譯器幫我們處理了這裡的細節,直接記住即可。

函式指標整明白之後,再和陣列結合在一起:函式指標陣列。示例程式碼如下:

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }
int divide(int a, int b) { return a / b; }

int main()
{
    int a = 4, b = 2;
    int (*p[4])(int, int);
    p[0] = add;
    p[1] = sub;
    p[2] = mul;
    p[3] = divide;
    printf("%d + %d = %d \n", a, b, p[0](a, b));
    printf("%d - %d = %d \n", a, b, p[1](a, b));
    printf("%d * %d = %d \n", a, b, p[2](a, b));
    printf("%d / %d = %d \n", a, b, p[3](a, b));
}

這條語句不太好理解:int (*p[4])(int, int);,先分析中間部分,識別符號p與中括號[]結合(優先順序高),所以p是一個陣列,陣列中有4個元素;然後剩下的內容表示一個函式指標,那麼就說明陣列中的元素型別是函式指標,也就是其他函式的地址,記憶體模型如下:

如果還是難以理解,那就回到指標的本質概念上:指標就是一個地址!這個地址中儲存的內容是什麼根本不重要,重要的是你告訴計算機這個內容是什麼。如果你告訴它:這個地址裡存放的內容是一個函式,那麼計算機就去呼叫這個函式。那麼你是如何告訴計算機的呢,就是在定義指標變數的時候,僅此而已!

五、總結

我已經把自己知道的所有指標相關的概念、語法、使用場景都作了講解,就像一個小酒館的掌櫃,把自己的美酒佳餚都呈現給你,但願你已經酒足飯飽!

如果以上的內容太多,一時無法消化,那麼下面的這兩句話就作為飯後甜點為您奉上,在以後的程式設計中,如果遇到指標相關的困惑,就想一想這兩句話,也許能讓你茅塞頓開

  1. 指標就是地址,地址就是指標。
  2. 指標就是指向記憶體中的一塊空間,至於如何來解釋/操作這塊空間,由這個指標的型別來決定。

另外還有一點囑咐,那就是學習任何一門程式語言,一定要弄清楚記憶體模型,記憶體模型,記憶體模型!

祝您好運!


【原創宣告】

作者:道哥(公眾號: IOT物聯網小鎮)
知乎:道哥
B站:道哥分享
掘金:道哥分享
CSDN:道哥分享

如果覺得文章不錯,請轉發、分享給您的朋友。


我會把十多年嵌入式開發中的專案實戰經驗進行總結、分享,相信不會讓你失望的!

長按下圖二維碼關注,每篇文章都有乾貨。


轉載:歡迎轉載,但未經作者同意,必須保留此段宣告,必須在文章中給出原文連線。




推薦閱讀

[1] 原來gdb的底層除錯原理這麼簡單
[2] 生產者和消費者模式中的雙緩衝技術
[3] 深入LUA指令碼語言,讓你徹底明白除錯原理
[4] 一步步分析-如何用C實現物件導向程式設計
[5] 關於加密、證書的那些事

相關文章