帶你一步一步理解 C 語言指針!

作者:李山文的博客

鏈接:https://lishanwen.cn/index.php/2021/05/20/cpointer/

一直覺得 C 語言較其他語言最偉大的地方就是 C 語言中的指針,有些人認爲指針很簡單,而有些人認爲指針很難,當然這裏的對簡單和難並不是等價於對指針的理解程度。爲此作者在這裏對 C 語言中的指針進行全面的總結,從底層的內存分析,徹底讓讀者明白指針的本質。

小編認爲 C 指針應該和 C 語言中的變量放在一起,因爲 C 指針本質上還是一個變量,但現在大部分教材將其單獨拿出來講解,這也使得很多初學者認爲指針是一個和變量毫無相關的概念。

一、指針變量

首先讀者要明白指針是一個變量,爲此作者寫了如下代碼來驗證之:

#include "stdio.h"

int main(int argc, char **argv)
{
    unsigned int a = 10;
    unsigned int *p = NULL;
    p = &a;
    printf("a=%d\n",a);
    printf("&a=%d\n",&a);
    *p = 20;
    printf("a=%d\n",a);
    return 0;
}

運行後可以看到 a 的值被更改了,上面的例子可以清楚的明白指針實質上是一個放置變量地址的特殊變量,其本質仍然是變量。

既然指針是變量,那必然會有變量類型,因此這裏必須對變量類型做解釋。在 C 語言中,所有的變量都有變量類型,整型、浮現型、字符型、指針類型、結構體、聯合體、枚舉等,這些都是變量類型。變量類型的出現是內存管理的必然結果,相信讀者知道,所有的變量都是保存在計算機的內存中,既然是放到計算機的內存中,那必然會佔用一定的空間,問題來了,一個變量會佔用多少空間呢,或者說應該分出多少內存空間來放置該變量呢?

爲了規定這個,類型由此誕生了,對於 32 位編譯器來說,int 類型佔用 4 個字節,即 32 位,long 類型佔用 8 字節,即 64 位。這裏簡單說了類型主要是爲後面引出指針這個特殊性,在計算機中,將要運行的程序都保存在內存中,所有的程序中的變量其實就是對內存的操作。計算機的內存結構較爲簡單,這裏不詳細談論內存的物理結構,只談論內存模型。將計算機的內存可以想象爲一個房子,房子裏面居住着人,每一個房間對應着計算機的內存地址,內存中的數據就相當於房子裏的人。

既然指針也是一個變量,那個指針也應該被存放在內存中,對於 32 位編譯器來說,其尋址空間爲 2^32=4GB,爲了能夠都操作所有內存(實際上普通用戶不可能操作所有內存),指針變量存放也要用 32 位數即 4 個字節。這樣就有指針的地址 & p,指針和變量的關係可以用如下圖表示:

從上圖可以看到&p是指針的地址,用來存放指針p,而指針p來存放變量a的地址,也就是&a,還有一個 * p 在 C 語言中是解引,意思是告訴編譯器取出該地址存放的內容。

上面提到過關於指針類型的問題,針對 32 位編譯器而言,既然任何指針都只佔用 4 個字節,那爲何還需要引入指針類型呢?僅僅是爲了約束相同類型的變量麼?實際上這裏不得不提到指針操作,先思考如下兩個操作:

上面兩個操作的意思是不同的,先說下第一種:p+1 操作,如下圖所示:

對於不同類型指針而言,其p+1所指向的地址不同,這個遞增取決於指針類型所佔的內存大小,而對於((unsigned int)p)+1,該意思是將地址 p 所指向的地址的值直接轉換爲數字,然後+1,這樣無論 p 是何種類型的指針,其結果都是指針所指的地址後一個地址。

從上述可以看到,指針的存在使得程序員可以相當輕鬆的操作內存,這也使得當前有些人認爲指針相當危險,這一觀點表現在 C# 和 Java 語言中,然而實際上用好指針可以極大的提高效率。下面深入一點來通過指針對內存進行操作,現在我們需要對內存 6422216 中填入一個數據 125,我們可以如下操作:

unsigned int *p=(unsigned int*)(6422216);
*p=125;

當然,上面的代碼使用了一個指針,實際上 C 語言中可以直接利用解引操作對內存進行更方便的賦值,下面說下解**引操作 ***。

二、解引用

所謂解引操作,實際上是對一個地址操作,比如現在想將變量 a 進行賦值,一般操作是 a=125,現在我們用解引操作來完成,操作如下:

*(&a)=125;

上面可以看到解引操作符爲*,這個操作符對於指針有兩個不同的意義,當在申明的時候是申明一個指針,而當在使用 p 指針時是解引操作,解引操作右邊是一個地址,這樣解引操作的意思就是該地址內存中的數據。這樣我們對內存 6422216 中填入一個數據 125 就可以使用如下操作

*(unsigned int*)(6422216)=125;

上面需要將 6422216 數值強制轉換爲一個地址,這個是告訴編譯器該數值是一個地址。值得注意的是上面的所有內存地址不能隨便指定,必須是計算機已經分配的內存,否則計算機會認爲指針越界而被操作系統殺死即程序提前終止。

三、結構體指針

結構體指針和普通變量指針一樣,結構體指針只佔 4 個字節(32 位編譯器), 只不過結構體指針可以很容易的訪問結構體類型中的任何成員,這就是指針的成員運算符 ->。

上圖中p是一個結構體指針,p 指向的是一個結構體的首地址,而p->a可以用來訪問結構體中的成員 a,當然p->a*(p)是相同的。

四、強制類型轉換

爲何要在這裏提強制類型轉換呢,上面的測試代碼可以看到編譯器會報很多警告,意思是告訴程序員數據類型不匹配,雖然並不影響程序的正確運行,但是很多警告總會讓人感到難受。因此爲了告訴編譯器代碼這裏沒有問題,程序員可以使用強制類型轉換來將一段內存轉換爲需要的數據類型,例如下面有一個數組 a,現在將其強制轉換爲一個結構體類型 stu:

#include <stdio.h>

typedef struct STUDENT
{
    int      name;
    int    gender;
}stu;

int a[100]={10,20,30,40,50};

int main(int argc, char **argv)
{
    stu *student;
    student=(stu*)a;
    printf("student->name=%d\n",student->name);
    printf("student->gender=%d\n",student->gender);
    return 0;
}

上面的程序運行結果如下:

可以看到 a[100] 被強制轉換爲 stu 結構體類型,當然不使用強制類型轉換也是可以的,只是編譯器會報警報。

上圖爲程序的示意圖,圖中數組a[100]的前 12 個字節被強制轉換爲了一個 struct stu 類型,上面僅對數組進行了說明,其它數據類型也是一樣的,本質上都是一段內存空間。

五、void 指針

爲何在這裏單獨提到空指針類型呢?,主要是因爲該指針類型很特殊。void 類型很容易讓人想到是空的意思,但對於指針而言,其並不是指空,而是指不確定。在很多時候指針在申明的時候可能並不知道是什麼類型或者該指針指向的數據類型有多種再或者程序員僅僅是想通過一個指針來操作一段內存空間。

這個時候可以將指針申明爲 void 類型。但是問題來了,由於 void 類型原因,對於確定的數據類型解引時,編譯器會根據類型所佔的空間來解引相應的數據,例如 int *_p,那麼 *_p 就會被編譯器解引爲 p 指針的地址的 4 個字節的空間大小。但對於空指針類型來說,編譯器如何知道其要解引的內存大小呢?先看一段代碼:

#include <stdio.h>

int main(int argc, char **argv)
{
    int a=10;
    void *p;
    p=&a;
    printf("p=%d\n",*p);
    return 0;
}

編譯上面的程序會發現,編譯器報錯,無法正常編譯。

這說明編譯器確實是在解引時無法確定*p的大小,因此這裏必須告訴編譯器 p 的類型或者 * p 的大小,如何告訴呢?很簡單,用強制類型轉換即可,如下:

*(int*)p

這樣上面的程序就可以寫爲如下:

#include <stdio.h>

int main(int argc, char **argv)
{
    int a=10;
    void *p;
    p=&a;
    printf("p=%d\n",*(int*)p);
    return 0;
}

編譯運行後:

可以看到結果確實是正確的,也和預期的想法一致。由於 void 指針沒有空間大小屬性,因此 void 指針也沒有 ++ 操作。

六、函數指針

函數指針使用

函數指針在 Linux 內核中用的非常多,而且在設計操作系統的時候也會用到,因此這裏將詳細講解函數指針。既然函數指針也是指針,那函數指針也佔用 4 個字節(32 位編譯器)。下面以一個簡單的例子說明:

#include <stdio.h>

int  add(int a,int b)
{
    return a+b;
}

int main(int argc, char **argv)
{
    int (*p)(int,int);
    p=add;
    printf("add(10,20)=%d\n",(*p)(10,20));
    return 0;
}

程序運行結果如下:

可以看到,函數指針的申明爲:

函數指針的解引操作與普通的指針有點不一樣,對於普通的指針而言,解引只需要根據類型來取出數據即可,但函數指針是要調用一個函數,其解引不可能是將數據取出,實際上函數指針的解引本質上是執行函數的過程,只是這個執行函數是使用的 call 指令並不是之前的函數,而是函數指針的值,即函數的地址。其實執行函數的過程本質上也是利用 call 指令來調用函數的地址,因此函數指針本質上就是保存函數執行過程的首地址。函數指針的調用如下:

爲了確認函數指針本質上是傳遞給 call 指令一個函數的地址,下面用一個簡單例子說明:

上面是編譯後的彙編指令,可以看到,使用函數指針來調用函數時,其彙編指令多瞭如下:

0x4015e3    mov    DWORD PTR [esp+0xc],0x4015c0
0x4015eb    mov    eax,DWORD PTR [esp+0xc]
0x4015ef    call   eax

分析:第一行 mov 指令將立即數 0x4015c0 賦值給寄存器 esp+0xc 的地址內存中,然後將寄存器 esp+0xc 地址的值賦值給寄存器 eax(累加器),然後調用 call 指令,此時 pc 指針將會指向 add 函數,而 0x4015c0 正好是函數 add 的首地址,這樣就完成了函數的調用。

細心的讀者是否發現一個有趣的現象,上述過程中函數指針的值和參數一樣是被放在棧幀中,這樣看起來就是一個參數傳遞的過程,因此可以看到,函數指針最終還是以參數傳遞的形式傳遞給被調用的函數,而這個傳遞的值正好是函數的首地址。

從上面可以看到函數指針並不是和一般的指針一樣可以操作內存,因此作者覺得函數指針可以看作是函數的引用申明。

函數指針應用

在 linux 驅動面向對象編程思想中用的最多,利用函數指針來實現封裝,下面以一個簡單的例子說明:

#include <stdio.h>

typedef struct TFT_DISPLAY
{
    int   pix_width;
    int   pix_height;
    int   color_width;
    void (*init)(void);
    void (*fill_screen)(int color);
    void (*tft_test)(void);

}tft_display;

static void init(void)
{
    printf("the display is initialed\n");
}

static void fill_screen(int color)
{
    printf("the display screen set 0x%x\n",color);

}

tft_display mydisplay=
{
    .pix_width=320,
    .pix_height=240,
    .color_width=24,
    .init=init,
    .fill_screen=fill_screen,
};

int main(int argc, char **argv)
{

    mydisplay.init();
    mydisplay.fill_screen(0xfff);
    return 0;
}

上面的例子將一個 tft_display 封裝成一個對象,上面的結構體成員中最後一個沒有初始化,這在 Linux 中用的非常多,最常見的是 file_operations 結構體,該結構體一般來說只需要初始化常見的函數,不需要全部初始化。上面代碼中採用的結構體初始化方式也是在 Linux 中最常用的一種方式,這種方式的好處在於無需按照結構體的順序一對一。

回調函數

有時候會遇到這樣一種情況,當上層人員將一個功能交給下層程序員完成時,上層程序員和下層程序員同步工作,這個時候該功能函數並未完成,這個時候上層程序員可以定義一個 API 來交給下層程序員,而上層程序員只要關心該 API 就可以了而無需關心具體實現,具體實現交給下層程序員完成即可(這裏的上層和下層程序員不指等級關係,而是項目的分工關係)。

這種情況下就會用到回調函數(Callback Function),現在假設程序員 A 需要一個 FFT 算法,這個時候程序員 A 將 FFT 算法交給程序員 B 來完成,現在來讓實現這個過程:

#include <stdio.h>

int  InputData[100]={0};
int OutputData[100]={0};

void FFT_Function(int *inputData,int *outputData,int num)
{
    while(num--)
    {

    }
}

void TaskA_CallBack(void (*fft)(int*,int*,int))
{

    (*fft)(InputData,OutputData,100);
}

int main(int argc, char **argv)
{

    TaskA_CallBack(FFT_Function);
    return 0;
}

上面的代碼中TaskA_CallBack是回調函數,該函數的形參爲一個函數指針,而FFT_Function是一個被調用函數。可以看到回調函數中申明的函數指針必須和被調用函數的類型完全相同。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/kSM2d66QowxVgVMrAjtAyA