主頁 > 後端開發 > 深度閱讀:C語言指標,從底層原理到花式技巧,圖文+代碼透析

深度閱讀:C語言指標,從底層原理到花式技巧,圖文+代碼透析

2021-01-27 07:12:47 後端開發

以下文章來源于公眾號IOT物聯網小鎮 ,作者道哥

 

前言

 

如果問 C 語言中最重要、威力最大的概念是什么,答案必將是指標!

威力大,意味著使用方便、高效,同時也意味著語法復雜、容易出錯,指標用的好,可以極大的提高代碼執行效率、節約系統資源;如果用的不好,程式中將會充滿陷阱、漏洞,

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

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

 

變數與指標的本質

 

2.1 記憶體地址

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

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

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

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

記憶體分為:物理記憶體和虛擬記憶體,作業系統對物理記憶體進行管理、包裝,我們開發者面對的是作業系統提供的虛擬記憶體,

這 2 個概念不妨礙文章的理解,因此就統一稱之為記憶體,

在我們的程式中,通過一個變數名來定義變數、使用變數,

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

那么,我們定義一個變數之后,這個變數放在哪里呢?那就是記憶體的資料區,

記憶體是一個很大的存盤區域,被作業系統劃分為一個一個的小空間,作業系統通過地址來管理記憶體,

記憶體中的最小存盤單位是位元組(8 個 bit),一個記憶體的完整空間就是由這一個一個的位元組連續組成的,

在上圖中,每一個小格子代表一個位元組,但是好像大家在書籍中沒有這么來畫記憶體模型的,更常見的是下面這樣的畫法:

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

2.2 32 位與 64 位系統

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

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

剛才說了,記憶體是通過地址來管理的,那么 CPU 想從記憶體中的某個地址空間上存取一個資料,那么 CPU 就需要在地址總線上輸出這個存盤單元的地址,

假如地址總線的寬度是 8 位,能表示的最大地址空間就是 256 個位元組,能找到記憶體中最大的存盤單元是 255 這個格子(從 0 開始),即使記憶體條的實際空間是 2G 位元組,CPU 也沒法使用后面的記憶體地址空間,如果地址總線的寬度是 32 位,那么能表示的最大地址就是 2 的 32 次方,也就是 4G 位元組的空間,

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

2.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 位系統中:指標變數占用幾個位元組?

2.4 指標變數

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

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

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

示例:

int a = 20;

int *pa;

pa = &a;

printf("value = https://www.cnblogs.com/zm131417-/archive/2021/01/26/%d /n", *pa);

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

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

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

這里對兩個運算子&和*進行說明:

&:取地址運算子,用來獲取一個變數的地址,上面代碼中&a就是用來獲取變數 a 在記憶體中的存盤地址,也就是 0x11223344,

*:這個運算子用在 2 個場景中:定義一個指標的時候,獲取一個指標所指向的變數值的時候,

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

2.5 操作指標變數

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

  • 操作指標變數自身的值;
  • 獲取指標變數所指向的資料;
  • 以什么樣資料型別來使用/解釋指標變數所指向的內容,

指標變數自身的值

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,

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

指標變數所指向的資料型別是在定義的時候就明確的,也就是說指標 pa 指向的資料型別就是 int 型,因此在執行 printf("value = https://www.cnblogs.com/zm131417-/archive/2021/01/26/%d /n", *pa);陳述句時,首先知道 pa 是一個指標,其中存盤了一個地址(0x11223344),然后通過運算子*來獲取這個地址(0x11223344)對應的那個存盤空間中的值;又因為在定義 pa時,已經指定了它指向的值是一個 int 型,所以我們就知道了地址 0x11223344 中存盤的就是一個 int 型別的資料,

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

如下代碼:

int a = 30000;

int *pa = &a;

printf("value = https://www.cnblogs.com/zm131417-/archive/2021/01/26/%d /n", *pa);

根據以上的描述,我們知道 printf 的列印結果會是 value = https://www.cnblogs.com/zm131417-/archive/2021/01/26/30000,十進制的 30000 轉成十六進制是 0x00007530,記憶體模型如下:

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

char *pc = 0x11223344;

printf("value = https://www.cnblogs.com/zm131417-/archive/2021/01/26/%d /n", *pc);

指標變數 pc 在定義的時候指明:它指向的資料型別是 char 型,pc 變數中存盤的地址是 0x11223344,當使用*pc 獲取指向的資料時,將會按照 char 型格式來讀取 0x11223344 地址處的資料,因此將會列印 value = https://www.cnblogs.com/zm131417-/archive/2021/01/26/0(在計算機中,ASCII 碼是用等價的數字來存盤的),

這個例子中說明了一個重要的概念:在記憶體中一切都是數字,如何來操作(解釋)一個記憶體地址中的資料,完全是由我們的代碼來告訴編譯器的,

剛才這個例子中,雖然 0x11223344 這個地址開始的 4 個位元組的空間中,存盤的是整型變數 a 的值,但是我們讓 pc 指標按照 char 型資料來使用/解釋這個地址處的內容,這是完全合法的,

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

指標的幾個相關概念

 

3.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;

}

3.2 void 型指標

關鍵字 void 并不是一個真正的資料型別,它體現的是一種抽象,指明不是任何一種型別,一般有 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. 3 空指標和野指標

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

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

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

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

#ifdef __cplusplus

     #define NULL    0

#else    

     #define NULL    ((void *)0)

#endif

野指標:地址已經失效的指標

我們都知道,函式中的區域變數存盤在堆疊區,通過 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;  // 最好加上這一句,

指向不同資料型別的指標

 

4.1 數值型指標

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

4.2 字串指標

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

  • 用一個陣列來表示,例如:char name1[8] = "zhangsan";
  • 用一個 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* 型指標在自增時,是按照一個位元組的跨度來計算的,

4.3 指標陣列與陣列指標

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

int *p1[3];   // 指標陣列

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

指標陣列

第 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;

陣列指標

第 2 條陳述句中:小括號讓 p2 與 * 結合,表示 p2 是一個指標,這個指標指向了一個陣列,陣列中有 3 個元素,每一個元素的型別是 int 型,可以這樣來理解:如果有這個定義 int p[3],很容易理解這是一個有 3 個 char 型元素的陣列,那么把陣列名 p 換成是 *p2,也就是 p2 是一個指標,指向了這個陣列,記憶體模型如下(注意:指標指向的地址是一個陣列,其中的 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.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 型資料了,

4.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 個元素的首地址,記憶體模型如下:

4.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 點(學名叫:函式簽名):

  • 有 2 個 int 型的引數;
  • 有一個 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,也就是引數的個數、型別要相同,回傳值也要相同,

注意:這里有幾個小細節稍微了解一下:

  • 在賦值函式指標時,使用 p = &a;也是可以的;
  • 使用函式指標呼叫時,使用(*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 個元素;然后剩下的內容表示一個函式指標,那么就說明陣列中的元素型別是函式指標,也就是其他函式的地址,記憶體模型如下:

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

總結

 

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

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

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

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

 

如果你C/C++感興趣,想學編程,小編推薦一個C/C++技術交流群【點擊進入】!

涉及到了:編程入門、游戲編程、網路編程、Windows編程、Linux編程、Qt界面開發、黑客等等......

 

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/252924.html

標籤:其他

上一篇:Go遍歷struct,map,slice

下一篇:執行緒之間如何通信

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 【C++】Microsoft C++、C 和匯編程式檔案

    ......

    uj5u.com 2020-09-10 00:57:23 more
  • 例外宣告

    相比于斷言適用于排除邏輯上不可能存在的狀態,例外通常是用于邏輯上可能發生的錯誤。 例外宣告 Item 1:當函式不可能拋出例外或不能接受拋出例外時,使用noexcept 理由 如果不打算拋出例外的話,程式就會認為無法處理這種錯誤,并且應當盡早終止,如此可以有效地阻止例外的傳播與擴散。 示例 //不可 ......

    uj5u.com 2020-09-10 00:57:27 more
  • Codeforces 1400E Clear the Multiset(貪心 + 分治)

    鏈接:https://codeforces.com/problemset/problem/1400/E 來源:Codeforces 思路:給你一個陣列,現在你可以進行兩種操作,操作1:將一段沒有 0 的區間進行減一的操作,操作2:將 i 位置上的元素歸零。最終問:將這個陣列的全部元素歸零后操作的最少 ......

    uj5u.com 2020-09-10 00:57:30 more
  • UVA11610 【Reverse Prime】

    本人看到此題沒有翻譯,就附帶了一個自己的翻譯版本 思考 這一題,它的第一個要求是找出所有 $7$ 位反向質數及其質因數的個數。 我們應該需要質數篩篩選1~$10^{7}$的所有數,這里就不慢慢介紹了。但是,重讀題,我們突然發現反向質數都是 $7$ 位,而將它反過來后的數字卻是 $6$ 位數,這就說明 ......

    uj5u.com 2020-09-10 00:57:36 more
  • 統計區間素數數量

    1 #pragma GCC optimize(2) 2 #include <bits/stdc++.h> 3 using namespace std; 4 bool isprime[1000000010]; 5 vector<int> prime; 6 inline int getlist(int ......

    uj5u.com 2020-09-10 00:57:47 more
  • C/C++編程筆記:C++中的 const 變數詳解,教你正確認識const用法

    1、C中的const 1、區域const變數存放在堆疊區中,會分配記憶體(也就是說可以通過地址間接修改變數的值)。測驗代碼如下: 運行結果: 2、全域const變數存放在只讀資料段(不能通過地址修改,會發生寫入錯誤), 默認為外部聯編,可以給其他源檔案使用(需要用extern關鍵字修飾) 運行結果: ......

    uj5u.com 2020-09-10 00:58:04 more
  • 【C++犯錯記錄】VS2019 MFC添加資源不懂如何修改資源宏ID

    1. 首先在資源視圖中,添加資源 2. 點擊新添加的資源,復制自動生成的ID 3. 在解決方案資源管理器中找到Resource.h檔案,編輯,使用整個專案搜索和替換的方式快速替換 宏宣告 4. Ctrl+Shift+F 全域搜索,點擊查找全部,然后逐個替換 5. 為什么使用搜索替換而不使用屬性視窗直 ......

    uj5u.com 2020-09-10 00:59:11 more
  • 【C++犯錯記錄】VS2019 MFC不懂的批量添加資源

    1. 打開資源頭檔案Resource.h,在其中預先定義好宏 ID(不清楚其實ID值應該設定多少,可以先新建一個相同的資源項,再在這個資源的ID值的基礎上遞增即可) 2. 在資源視圖中選中專案資源,按F7編輯資源檔案,按 ID 型別 相對路徑的形式添加 資源。(別忘了先把檔案拷貝到專案中的res檔案 ......

    uj5u.com 2020-09-10 01:00:19 more
  • C/C++編程筆記:關于C++的參考型別,專供新手入門使用

    今天要講的是C++中我最喜歡的一個用法——參考,也叫別名。 參考就是給一個變數名取一個變數名,方便我們間接地使用這個變數。我們可以給一個變數創建N個參考,這N + 1個變數共享了同一塊記憶體區域。(參考型別的變數會占用記憶體空間,占用的記憶體空間的大小和指標型別的大小是相同的。雖然參考是一個物件的別名,但 ......

    uj5u.com 2020-09-10 01:00:22 more
  • 【C/C++編程筆記】從頭開始學習C ++:初學者完整指南

    眾所周知,C ++的學習曲線陡峭,但是花時間學習這種語言將為您的職業帶來奇跡,并使您與其他開發人員區分開。您會更輕松地學習新語言,形成真正的解決問題的技能,并在編程的基礎上打下堅實的基礎。 C ++將幫助您養成良好的編程習慣(即清晰一致的編碼風格,在撰寫代碼時注釋代碼,并限制類內部的可見性),并且由 ......

    uj5u.com 2020-09-10 01:00:41 more
最新发布
  • Rust中的智能指標:Box<T> Rc<T> Arc<T> Cell<T> RefCell<T> Weak

    Rust中的智能指標是什么 智能指標(smart pointers)是一類資料結構,是擁有資料所有權和額外功能的指標。是指標的進一步發展 指標(pointer)是一個包含記憶體地址的變數的通用概念。這個地址參考,或 ” 指向”(points at)一些其 他資料 。參考以 & 符號為標志并借用了他們所 ......

    uj5u.com 2023-04-20 07:24:10 more
  • Java的值傳遞和參考傳遞

    值傳遞不會改變本身,參考傳遞(如果傳遞的值需要實體化到堆里)如果發生修改了會改變本身。 1.基本資料型別都是值傳遞 package com.example.basic; public class Test { public static void main(String[] args) { int ......

    uj5u.com 2023-04-20 07:24:04 more
  • [2]SpinalHDL教程——Scala簡單入門

    第一個 Scala 程式 shell里面輸入 $ scala scala> 1 + 1 res0: Int = 2 scala> println("Hello World!") Hello World! 檔案形式 object HelloWorld { /* 這是我的第一個 Scala 程式 * 以 ......

    uj5u.com 2023-04-20 07:23:58 more
  • 理解函式指標和回呼函式

    理解 函式指標 指向函式的指標。比如: 理解函式指標的偽代碼 void (*p)(int type, char *data); // 定義一個函式指標p void func(int type, char *data); // 宣告一個函式func p = func; // 將指標p指向函式func ......

    uj5u.com 2023-04-20 07:23:52 more
  • Django筆記二十五之資料庫函式之日期函式

    本文首發于公眾號:Hunter后端 原文鏈接:Django筆記二十五之資料庫函式之日期函式 日期函式主要介紹兩個大類,Extract() 和 Trunc() Extract() 函式作用是提取日期,比如我們可以提取一個日期欄位的年份,月份,日等資料 Trunc() 的作用則是截取,比如 2022-0 ......

    uj5u.com 2023-04-20 07:23:45 more
  • 一天吃透JVM面試八股文

    什么是JVM? JVM,全稱Java Virtual Machine(Java虛擬機),是通過在實際的計算機上仿真模擬各種計算機功能來實作的。由一套位元組碼指令集、一組暫存器、一個堆疊、一個垃圾回收堆和一個存盤方法域等組成。JVM屏蔽了與作業系統平臺相關的資訊,使得Java程式只需要生成在Java虛擬機 ......

    uj5u.com 2023-04-20 07:23:31 more
  • 使用Java接入小程式訂閱訊息!

    更新完微信服務號的模板訊息之后,我又趕緊把微信小程式的訂閱訊息給實作了!之前我一直以為微信小程式也是要企業才能申請,沒想到小程式個人就能申請。 訊息推送平臺🔥推送下發【郵件】【短信】【微信服務號】【微信小程式】【企業微信】【釘釘】等訊息型別。 https://gitee.com/zhongfuch ......

    uj5u.com 2023-04-20 07:22:59 more
  • java -- 緩沖流、轉換流、序列化流

    緩沖流 緩沖流, 也叫高效流, 按照資料型別分類: 位元組緩沖流:BufferedInputStream,BufferedOutputStream 字符緩沖流:BufferedReader,BufferedWriter 緩沖流的基本原理,是在創建流物件時,會創建一個內置的默認大小的緩沖區陣列,通過緩沖 ......

    uj5u.com 2023-04-20 07:22:49 more
  • Java-SpringBoot-Range請求頭設定實作視頻分段傳輸

    老實說,人太懶了,現在基本都不喜歡寫筆記了,但是網上有關Range請求頭的文章都太水了 下面是抄的一段StackOverflow的代碼...自己大修改過的,寫的注釋挺全的,應該直接看得懂,就不解釋了 寫的不好...只是希望能給視頻網站開發的新手一點點幫助吧. 業務場景:視頻分段傳輸、視頻多段傳輸(理 ......

    uj5u.com 2023-04-20 07:22:42 more
  • Windows 10開發教程_編程入門自學教程_菜鳥教程-免費教程分享

    教程簡介 Windows 10開發入門教程 - 從簡單的步驟了解Windows 10開發,從基本到高級概念,包括簡介,UWP,第一個應用程式,商店,XAML控制元件,資料系結,XAML性能,自適應設計,自適應UI,自適應代碼,檔案管理,SQLite資料庫,應用程式到應用程式通信,應用程式本地化,應用程式 ......

    uj5u.com 2023-04-20 07:22:35 more