天天看點

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

一直覺得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;
}      
還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

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

既然指針是變量,那必然會有變量類型,是以這裡必須對變量類型做解釋。在C語言中,所有的變量都有變量類型,整型、浮現型、字元型、指針類型、結構體、聯合體、枚舉等,這些都是變量類型。

變量類型的出現是記憶體管理的必然結果,相信讀者知道,所有的變量都是儲存在計算機的記憶體中,既然是放到計算機的記憶體中,那必然會占用一定的空間,問題來了,一個變量會占用多少空間呢,或者說應該分出多少記憶體空間來放置該變量呢?

為了規定這個,類型由此誕生了,對于32位編譯器來說,int類型占用4個位元組,即32位,long類型占用8位元組,即64位。

這裡簡單說了類型主要是為後面引出指針這個特殊性,在計算機中,将要運作的程式都儲存在記憶體中,所有的程式中的變量其實就是對記憶體的操作。計算機的記憶體結構較為簡單,這裡不詳細談論記憶體的實體結構,隻談論記憶體模型。

将計算機的記憶體可以想象為一個房子,房子裡面居住着人,每一個房間對應着計算機的記憶體位址,記憶體中的資料就相當于房子裡的人。

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

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

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

從上圖可以看到​

​&p​

​​是指針的位址,用來存放指針​

​p​

​​,而指針​

​p​

​​來存放變量​

​a​

​​的位址,也就是​

​&a​

​,還有一個*p在C語言中是解引,意思是告訴編譯器取出該位址存放的内容。

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

上面提到過關于指針類型的問題,針對32位編譯器而言,既然任何指針都隻占用4個位元組,那為何還需要引入指針類型呢?僅僅是為了限制相同類型的變量麼?實際上這裡不得不提到指針操作,先思考如下兩個操作:

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

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

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

對于不同類型指針而言,其​

​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位編譯器),隻不過結構體指針可以很容易的通路結構體類型中的任何成員,這就是指針的成員運算符->。

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

上圖中​

​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;
}      

上面的程式運作結果如下:

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

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

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

上圖為程式的示意圖,圖中數組​

​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;
}      

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

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

這說明編譯器确實是在解引時無法确定​

​*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;
}      

編譯運作後:

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

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

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

六、函數指針

函數指針使用

函數指針在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;
}      

程式運作結果如下:

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

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

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

函數指針的解引操作與普通的指針有點不一樣,對于普通的指針而言,解引隻需要根據類型來取出資料即可,但函數指針是要調用一個函數,其解引不可能是将資料取出,實際上函數指針的解引本質上是執行函數的過程,隻是這個執行函數是使用的call指令并不是之前的函數,而是函數指針的值,即函數的位址。

其實執行函數的過程本質上也是利用call指令來調用函數的位址,是以函數指針本質上就是儲存函數執行過程的首位址。函數指針的調用如下:

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

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

還不懂 C/C++ 指針、結構體指針、指針函數、函數指針?趕緊看過來!

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

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就可以了而無需關心具體實作,具體實作交給下層程式員完成即可(這裡的上層和下層程式員不指等級關系,而是項目的分工關系)。

#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;
}