C語言指標詳解
前言
這不是我第一次寫關於C指標的文章了,只是因為指標對於C來說太重要,而且隨著自己程式設計經歷越多,對指標的理解越多,因此有了本文。然而,想要全面理解指標,除了要對C語言有熟練的掌握外,還要有計算機硬體以及作業系統等方方面面的基本知識。所以我想通過一篇文章來儘可能的講解指標,以對得起這個文章的標題吧。
為什麼需要指標?
指標解決了一些程式設計中基本的問題。
第一,指標的使用使得不同區域的程式碼可以輕易的共享記憶體資料。當然你也可以通過資料的複製達到相同的效果,但是這樣往往效率不太好,因為諸如結構體等大型資料,佔用的位元組數多,複製很消耗效能。但使用指標就可以很好的避免這個問題,因為任何型別的指標佔用的位元組數都是一樣的(根據平臺不同,有4位元組或者8位元組或者其他可能)。
第二,指標使得一些複雜的連結性的資料結構的構建成為可能,比如連結串列,鏈式二叉樹等等。
第三,有些操作必須使用指標。如操作申請的堆記憶體。還有:C語言中的一切函式呼叫中,值傳遞都是“按值傳遞”的,如果我們要在函式中修改被傳遞過來的物件,就必須通過這個物件的指標來完成。
指標是什麼?
我們指知道:C語言中的陣列是指 一類 型別,陣列具體區分為 int 型別陣列,double型別陣列,char陣列 等等。同樣指標 這個概念也泛指 一類 資料型別,int指標型別,double指標型別,char指標型別等等。
通常,我們用int型別儲存一些整型的資料,如 int num = 97 , 我們也會用char來儲存字元: char ch = 'a'。
我們也必須知道:任何程式資料載入記憶體後,在記憶體都有他們的地址,這就是指標。而為了儲存一個數據在記憶體中的地址,我們就需要指標變數。
因此:指標是程式資料在記憶體中的地址,而指標變數是用來儲存這些地址的變數。
為什麼程式中的資料會有自己的地址?
弄清這個問題我們需要從作業系統的角度去認知記憶體。
電腦維修師傅眼中的記憶體是這樣的:記憶體在物理上是由一組DRAM晶片組成的。
而作為一個程式設計師,我們不需要了解記憶體的物理結構,作業系統將RAM等硬體和軟體結合起來,給程式設計師提供的一種對記憶體使用的抽象。,這種抽象機制使得程式使用的是虛擬儲存器,而不是直接操作和使用真實存在的物理儲存器。所有的虛擬地址形成的集合就是虛擬地址空間。
在程式設計師眼中的記憶體應該是下面這樣的。
也就是說,記憶體是一個很大的,線性的位元組陣列(平坦定址)。每一個位元組都是固定的大小,由8個二進位制位組成。最關鍵的是,每一個位元組都有一個唯一的編號,編號從0開始,一直到最後一個位元組。如上圖中,這是一個256M的記憶體,他一共有256x1024x1024 = 268435456個位元組,那麼它的地址範圍就是 0 ~268435455 。
由於記憶體中的每一個位元組都有一個唯一的編號,因此,在程式中使用的變數,常量,甚至數函式等資料,當他們被載入到記憶體中後,都有自己唯一的一個編號,這個編號就是這個資料的地址。指標就是這樣形成的。
下面用程式碼說明
#include <stdio.h> int main(void) { char ch = 'a'; int num = 97; printf("ch 的地址:%p\n",&ch); //ch 的地址:0028FF47 printf("num的地址:%p\n",&num); //num的地址:0028FF40 return 0; }
指標的值實質是記憶體單元(即位元組)的編號,所以指標 單獨從數值上看,也是整數,他們一般用16進製表示。指標的值(虛擬地址值)使用一個機器字的大小來儲存,也就是說,對於一個機器字為w位的電腦而言,它的虛擬地址空間是0~2w- 1 ,程式最多能訪問2w個位元組。這就是為什麼xp這種32位系統最大支援4GB記憶體的原因了。
我們可以大致畫出變數ch和num在記憶體模型中的儲存。(假設 char佔1個位元組,int佔4位元組)
變數和記憶體
為了簡單起見,這裡就用上面例子中的 int num = 97 這個區域性變數來分析變數在記憶體中的儲存模型。
已知:num的型別是int,佔用了4個位元組的記憶體空間,其值是97,地址是0028FF40。我們從以下幾個方面去分析。
1、記憶體的資料
記憶體的資料就是變數的值對應的二進位制,一切都是二進位制。97的二進位制是 : 00000000 00000000 00000000 0110000 , 但使用的小端模式儲存時,低位資料存放在低地址,所以圖中畫的時候是倒過來的。
2、記憶體資料的型別
記憶體的資料型別決定了這個資料佔用的位元組數,以及計算機將如何解釋這些位元組。num的型別是int,因此將被解釋為 一個整數。
3、記憶體資料的名稱
記憶體的名稱就是變數名。實質上,記憶體資料都是以地址來標識的,根本沒有記憶體的名稱這個說法,這只是高階語言提供的抽象機制 ,方便我們操作記憶體資料。而且在C語言中,並不是所有的記憶體資料都有名稱,例如使用malloc申請的堆記憶體就沒有。
4、記憶體資料的地址
如果一個型別佔用的位元組數大於1,則其變數的地址就是地址值最小的那個位元組的地址。因此num的地址是 0028FF40。 記憶體的地址用於標識這個記憶體塊。
5、記憶體資料的生命週期
num是main函式中的區域性變數,因此當main函式被啟動時,它被分配於棧記憶體上,當main執行結束時,消亡。
如果一個數據一直佔用著他的記憶體,那麼我們就說他是“活著的”,如果他佔用的記憶體被回收了,則這個資料就“消亡了”。C語言中的程式資料會按照他們定義的位置,資料的種類,修飾的關鍵字等因素,決定他們的生命週期特性。實質上我們程式使用的記憶體會被邏輯上劃分為: 棧區,堆區,靜態資料區,方法區。不同的區域的資料有不同的生命週期。
無論以後計算機硬體如何發展,記憶體容量都是有限的,因此清楚理解程式中每一個程式資料的生命週期是非常重要的。
我會在以後的文章中再對C語言的記憶體管理做出介紹,敬請期待。
指標變數 和 指向關係
用來儲存 指標 的變數,就是指標變數。如果指標變數p1儲存了變數 num的地址,則就說:p1指向了變數num,也可以說p1指向了num所在的記憶體塊 ,這種指向關係,在圖中一般用 箭頭表示。
上圖中,指標變數p1指向了num所在的記憶體塊 ,即從地址0028FF40開始的4個byte 的記憶體塊。
定義指標變數
C語言中,定義變數時,在變數名 前 寫一個 * 星號,這個變數就變成了對應變數型別的指標變數。必要時要加( ) 來避免優先順序的問題。
引申:C語言中,定義變數時,在定義的最前面寫上typedef ,那麼這個變數名就成了一種型別,即這個型別的同義詞。
int a ; //int型別變數 a int *a ; //int* 變數a int arr[3]; //arr是包含3個int元素的陣列 int (* arr )[3]; //arr是一個指向包含3個int元素的陣列的指標變數 //-----------------各種型別的指標------------------------------ int* p_int; //指向int型別變數的指標 double* p_double; //指向idouble型別變數的指標 struct Student *p_struct; //結構體型別的指標 int(*p_func)(int,int); //指向返回型別為int,有2個int形參的函式的指標 int(*p_arr)[3]; //指向含有3個int元素的陣列的指標 int** p_pointer; //指向 一個整形變數指標的指標
取地址
既然有了指標變數,那就得讓他儲存其它變數的地址,使用& 運算子取得一個變數的地址。
int add(int a , int b) { return a + b; } int main(void) { int num = 97; float score = 10.00F; int arr[3] = {1,2,3}; //----------------------- int* p_num = # float* p_score = &score; int (*p_arr)[3] = &arr; int (*fp_add)(int ,int ) = add; //p_add是指向函式add的函式指標 return 0; }
特殊的情況,他們並不一定需要使用&取地址:
- 陣列名的值就是這個陣列的第一個元素的地址。
- 函式名的值就是這個函式的地址。
- 字串字面值常量作為右值時,就是這個字串對應的字元陣列的名稱,也就是這個字串在記憶體中的地址。
int add(int a , int b){ return a + b; } int main(void) { int arr[3] = {1,2,3}; //----------------------- int* p_first = arr; int (*fp_add)(int ,int ) = add; const char* msg = "Hello world"; return 0; }
解地址
我們需要一個數據的指標變數幹什麼?當然使用通過它來操作(讀/寫)它指向的資料啦。對一個指標解地址,就可以取到這個記憶體資料,解地址 的寫法,就是在指標的前面加一個*號。
解指標的實質是:從指標指向的記憶體塊中取出這個記憶體資料。
int main(void) { int age = 19; int*p_age = &age; *p_age = 20; //通過指標修改指向的記憶體資料 printf("age = %d\n",*p_age); //通過指標讀取指向的記憶體資料 printf("age = %d\n",age); return 0; }
指標之間的賦值
指標賦值和int變數賦值一樣,就是將地址的值拷貝給另外一個。指標之間的賦值是一種淺拷貝,是在多個程式設計單元之間共享記憶體資料的高效的方法。
int* p1 = & num; int* p3 = p1; //通過指標 p1 、 p3 都可以對記憶體資料 num 進行讀寫,如果2個函式分別使用了p1 和p3,那麼這2個函式就共享了資料num。
空指標
指向空,或者說不指向任何東西。在C語言中,我們讓指標變數賦值為NULL表示一個空指標,而C語言中,NULL實質是 ((void*)0) , 在C++中,NULL實質是0。
換種說法:任何程式資料都不會儲存在地址為0的記憶體塊中,它是被作業系統預留的記憶體塊。
下面程式碼摘自 stdlib.h
#ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif
壞指標
指標變數的值是NULL,或者未知的地址值,或者是當前應用程式不可訪問的地址值,這樣的指標就是壞指標,不能對他們做解指標操作,否則程式會出現執行時錯誤,導致程式意外終止。
任何一個指標變數在做 解地址操作前,都必須保證它指向的是有效的,可用的記憶體塊,否則就會出錯。壞指標是造成C語言Bug的最頻繁的原因之一。
下面的程式碼就是錯誤的示例。
void opp() { int*p = NULL; *p = 10; //Oops! 不能對NULL解地址 } void foo() { int*p; *p = 10; //Oops! 不能對一個未知的地址解地址 } void bar() { int*p = (int*)1000; *p =10; //Oops! 不能對一個可能不屬於本程式的記憶體的地址的指標解地址 }
指標的2個重要屬性
指標也是一種資料,指標變數也是一種變數,因此指標 這種資料也符合前面 變數和記憶體 主題中的特性。 這裡我只想強調2個屬性: 指標的型別,指標的值。
int main(void) { int num = 97; int *p1 = # char* p2 = (char*)(&num); printf("%d\n",*p1); //輸出 97 putchar(*p2); //輸出 a return 0; }
指標的值:很好理解,如上面的num 變數 ,其地址的值就是0028FF40 ,因此 p1的值就是0028FF40。資料的地址用於在記憶體中定位和標識這個資料,因為任何2個記憶體不重疊的不同資料的地址都是不同的。
指標的型別:指標的型別決定了這個指標指向的記憶體的位元組數並如何解釋這些位元組資訊。一般指標變數的型別要和它指向的資料的型別匹配。
由於num的地址是0028FF40,因此p1 和 p2的值都是0028FF40
*p1 : 將從地址0028FF40 開始解析,因為p1是int型別指標,int佔4位元組,因此向後連續取4個位元組,並將這4個位元組的二進位制資料解析為一個整數 97。
*p2 : 將從地址0028FF40 開始解析,因為p2是char型別指標,char佔1位元組,因此向後連續取1個位元組,並將這1個位元組的二進位制資料解析為一個字元,即'a'。
同樣的地址,因為指標的型別不同,對它指向的記憶體的解釋就不同,得到的就是不同的資料。
void*型別指標
由於void是空型別,因此void*型別的指標只儲存了指標的值,而丟失了型別資訊,我們不知道他指向的資料是什麼型別的,只指定這個資料在記憶體中的起始地址,如果想要完整的提取指向的資料,程式設計師就必須對這個指標做出正確的型別轉換,然後再解指標。因為,編譯器不允許直接對void*型別的指標做解指標操作。
結構體和指標
結構體指標有特殊的語法: -> 符號
如果p是一個結構體指標,則可以使用 p ->【成員】 的方法訪問結構體的成員
typedef struct { char name[31]; int age; float score; }Student; int main(void) { Student stu = {"Bob" , 19, 98.0}; Student*ps = &stu; ps->age = 20; ps->score = 99.0; printf("name:%s age:%d\n",ps->name,ps->age); return 0; }
陣列和指標
1、陣列名作為右值的時候,就是第一個元素的地址。
int main(void) { int arr[3] = {1,2,3}; int*p_first = arr; printf("%d\n",*p_first); //1 return 0; }
2、指向陣列元素的指標 支援 遞增 遞減 運算。(實質上所有指標都支援遞增遞減 運算 ,但只有在陣列中使用才是有意義的)
int main(void) { int arr[3] = {1,2,3}; int*p = arr; for(;p!=arr+3;p++){ printf("%d\n",*p); } return 0; }
3、p= p+1 意思是,讓p指向原來指向的記憶體塊的下一個相鄰的相同型別的記憶體塊。
同一個陣列中,元素的指標之間可以做減法運算,此時,指標之差等於下標之差。
4、p[n] == *(p+n)
p[n][m] == *( *(p+n)+ m )
5、當對陣列名使用sizeof時,返回的是整個陣列佔用的記憶體位元組數。當把陣列名賦值給一個指標後,再對指標使用sizeof運算子,返回的是指標的大小。
這就是為什麼我麼將一個數組傳遞給一個函式時,需要另外用一個引數傳遞陣列元素個數的原因了。
int main(void) { int arr[3] = {1,2,3}; int*p = arr; printf("sizeof(arr)=%d\n",sizeof(arr)); //sizeof(arr)=12 printf("sizeof(p)=%d\n",sizeof(p)); //sizeof(p)=4 return 0; }
函式和指標
函式的引數和指標
C語言中,實參傳遞給形參,是按值傳遞的,也就是說,函式中的形參是實參的拷貝份,形參和實參只是在值上面一樣,而不是同一個記憶體資料物件。這就意味著:這種資料傳遞是單向的,即從呼叫者傳遞給被調函式,而被調函式無法修改傳遞的引數達到回傳的效果。
void change(int a) { a++; //在函式中改變的只是這個函式的區域性變數a,而隨著函式執行結束,a被銷燬。age還是原來的age,紋絲不動。 } int main(void) { int age = 19; change(age); printf("age = %d\n",age); // age = 19 return 0; }
有時候我們可以使用函式的返回值來回傳資料,在簡單的情況下是可以的,但是如果返回值有其它用途(例如返回函式的執行狀態量),或者要回傳的資料不止一個,返回值就解決不了了。
傳遞變數的指標可以輕鬆解決上述問題。
void change(int* pa) { (*pa)++; //因為傳遞的是age的地址,因此pa指向記憶體資料age。當在函式中對指標pa解地址時, //會直接去記憶體中找到age這個資料,然後把它增1。 } int main(void) { int age = 19; change(&age); printf("age = %d\n",age); // age = 20 return 0; }
再來一個老生常談的,用函式交換2個變數的值的例子:
#include<stdio.h> void swap_bad(int a,int b); void swap_ok(int*pa,int*pb); int main() { int a = 5; int b = 3; swap_bad(a,b); //Can`t swap; swap_ok(&a,&b); //OK return 0; } //錯誤的寫法 void swap_bad(int a,int b) { int t; t=a; a=b; b=t; } //正確的寫法:通過指標 void swap_ok(int*pa,int*pb) { int t; t=*pa; *pa=*pb; *pb=t; }
有的時候,我們通過指標傳遞資料給函式不是為了在函式中改變他指向的物件,相反,我們防止這個目標資料被改變。傳遞指標只是為了避免拷貝大型資料。
考慮一個結構體型別Student。我們通過show函式輸出Student變數的資料。
typedef struct { char name[31]; int age; float score; }Student; //列印Student變數資訊 void show(const Student * ps) { printf("name:%s , age:%d , score:%.2f\n",ps->name,ps->age,ps->score); }
我們只是在show函式中取讀Student變數的資訊,而不會去修改它,為了防止意外修改,我們使用了常量指標去約束。另外我們為什麼要使用指標而不是直接傳遞Student變數呢?
從定義的結構看出,Student變數的大小至少是39個位元組,那麼通過函式直接傳遞變數,實參賦值資料給形參需要拷貝至少39個位元組的資料,極不高效。而傳遞變數的指標卻快很多,因為在同一個平臺下,無論什麼型別的指標大小都是固定的:X86指標4位元組,X64指標8位元組,遠遠比一個Student結構體變數小。
函式的指標
每一個函式本身也是一種程式資料,一個函式包含了多條執行語句,它被編譯後,實質上是多條機器指令的合集。在程式載入到記憶體後,函式的機器指令存放在一個特定的邏輯區域:程式碼區。既然是存放在記憶體中,那麼函式也是有自己的指標的。
C語言中,函式名作為右值時,就是這個函式的指標。
void echo(const char *msg) { printf("%s",msg); } int main(void) { void(*p)(const char*) = echo; //函式指標變數指向echo這個函式 p("Hello "); //通過函式的指標p呼叫函式,等價於echo("Hello ") echo("World\n"); return 0; }
const 和 指標
const到底修飾誰?誰才是不變的?
下面是我總結的經驗,分享一下。
如果const 後面是一個型別,則跳過最近的原子型別,修飾後面的資料。(原子型別是不可再分割的型別,如int, short , char,以及typedef包裝後的型別)
如果const後面就是一個數據,則直接修飾這個資料。
int main() { int a = 1; int const *p1 = &a; //const後面是*p1,實質是資料a,則修飾*p1,通過p1不能修改a的值 const int*p2 = &a; //const後面是int型別,則跳過int ,修飾*p2, 效果同上 int* const p3 = NULL; //const後面是資料p3。也就是指標p3本身是const . const int* const p4 = &a; // 通過p4不能改變a 的值,同時p4本身也是 const int const* const p5 = &a; //效果同上 return 0; }
typedef int* pint_t; //將 int* 型別 包裝為 pint_t,則pint_t 現在是一個完整的原子型別 int main() { int a = 1; const pint_t p1 = &a; //同樣,const跳過型別pint_t,修飾p1,指標p1本身是const pint_t const p2 = &a; //const 直接修飾p,同上 return 0; }
深拷貝和淺拷貝
如果2個程式單元(例如2個函式)是通過拷貝 他們所共享的資料的 指標來工作的,這就是淺拷貝,因為真正要訪問的資料並沒有被拷貝。如果被訪問的資料被拷貝了,在每個單元中都有自己的一份,對目標資料的操作相互 不受影響,則叫做深拷貝。
附加知識
指標和引用這個2個名詞的區別。他們本質上來說是同樣的東西。指標常用在C語言中,而引用,則用於諸如Java,C#等 在語言層面封裝了對指標的直接操作的程式語言中。
大端模式和小端模式
1) Little-Endian就是低位位元組排放在記憶體的低地址端,高位位元組排放在記憶體的高地址端。個人PC常用,Intel X86處理器是小端模式。
2) B i g-Endian就是高位位元組排放在記憶體的低地址端,低位位元組排放在記憶體的高地址端。
採用大端方式 進行資料存放符合人類的正常思維,而採用小端方式進行資料存放利於計算機處理。有些機器同時支援大端和小端模式,通過配置來設定實際的端模式。
假如 short型別佔用2個位元組,且儲存的地址為0x30。
short a = 1;
如下圖:
//測試機器使用的是否為小端模式。是,則返回true,否則返回false //這個方法判別的依據就是:C語言中一個物件的地址就是這個物件佔用的位元組中,地址值最小的那個位元組的地址。 bool isSmallIndain() { unsigned int val = 'A'; unsigned char* p = (unsigned char*)&val; //C/C++:對於多位元組資料,取地址是取的資料物件的第一個位元組的地址,也就是資料的低地址 return *p == 'A'; }