本篇文章來自小北學長的公衆號,僅作學習使用,部份內容作了適當理解性修改和添加了博主的我的經歷。編程
注:這篇文章好好看完必定會讓你掌握好指針的本質!數組
看到標題有沒有想到什麼?安全
是的,這一篇的文章主題是「指針與內存模型」數據結構
說到指針,就不可能脫離開內存,學會指針的人分爲兩種,一種是不瞭解內存模型,另一種則是瞭解。編程語言
不瞭解的對指針的理解就停留在「指針就是變量的地址」這句話,會比較懼怕使用指針,特別是各類高級操做。函數
而瞭解內存模型的則能夠把指針用得爐火純青,各類 byte 隨意操做,讓人直呼 666。佈局
這篇看完,相信你會對指針有一個新的認識,坐等打臉😂學習
編程的本質其實就是更好的操控數據,而咱們的數據是存放在內存中。this
所以,若是能更好地理解內存的模型,以及 C 如何管理內存,就能對程序的工做原理洞若觀火,從而使編程能力更上一層樓。編碼
你們真的別認爲這是空話,我大一全年都不敢用 C 寫上千行的程序也很抗拒寫 C。
只有到最後課程要求用C寫一個地鐵管理系統和自主學習寫紅黑樹完整的寫了超過千行的代碼
由於一旦上千行,常常出現各類莫名其妙的內存錯誤,一不當心就發生了 coredump...... 並且還無從排查,分析不出緣由。
直到後來對內存和指針有了更加深入的認識,才慢慢會用 C\C++ 寫上千行的項目,也不多會再有內存問題了。
(過於自信
「指針存儲的是變量的內存地址」這句話應該任何講 C 語言的書都會提到吧。
因此,要想完全理解指針,首先要理解 C 語言中變量的存儲本質,也就是內存。
計算機的內存是一塊用於存儲數據的空間,由一系列連續的存儲單元組成,就像下面這樣,
每個單元格都表示 1 個 Bit,一個 bit 在 EE 專業的同窗看來就是高低電位,而在 CS 同窗看來就是 0、1 兩種狀態。
因爲 1 個 bit 只能表示兩個狀態,因此大佬們規定 8個 bit 爲一組,命名爲 byte。
而且將 byte 做爲內存尋址的最小單元,也就是給每一個 byte 一個編號,這個編號就叫內存的地址。
這就至關於,咱們給小區裏的每一個單元、每一個住戶都分配一個門牌號: 30一、30二、40三、40四、501......
在生活中,咱們須要保證門牌號惟一,這樣就能經過門牌號很精準的定位到一家人。
一樣,在計算機中,咱們也要保證給每個 byte 的編號都是惟一的,這樣纔可以保證每一個編號都能訪問到惟一肯定的 byte。
上面咱們說給內存中每一個 byte 惟一的編號,那麼這個編號的範圍就決定了計算機可尋址內存的範圍。
全部編號連起來就叫作內存的地址空間,這和你們平時常說的電腦是 32 位仍是 64 位有關。
早期 Intel 808六、8088 的 CPU 就是隻支持 16 位地址空間,寄存器和地址總線都是 16 位,這意味着最多對 2^16 = 64 Kb
的內存編號尋址。
這點內存空間顯然不夠用,後來,80286 在 8086 的基礎上將地址總線和地址寄存器擴展到了20 位,也被叫作 A20 地址總線。
若是是寫 mini os 的時候,還須要經過 BIOS 中斷去啓動 A20 地址總線的開關。
可是,如今的計算機通常都是 32 位起步了,32 位意味着可尋址的內存範圍是 2^32 byte = 4GB
。
因此,若是你的電腦是 32 位的,那麼你裝超過 4G 的內存條也是沒法充分利用起來的。
好了,這就是內存和內存編址。
有了內存,接下來咱們須要考慮,int、double 這些變量是如何存儲在 0、1 單元格的。
在 C 語言中咱們會這樣定義變量:
int a = 999; char c = 'c';
當你寫下一個變量定義的時候,其實是向內存申請了一塊空間來存放你的變量。
咱們都知道 int 類型佔 4 個字節,而且在計算機中數字都是用補碼(不瞭解補碼的記得去百度)表示的。
999 換算成補碼就是:0000 0011 1110 0111
c 這裏有 4 個byte,因此須要四個單元格來存儲:
有沒有注意到,咱們把高位的字節放在了低地址的地方。
那能不能反過來呢?
固然,這就引出了大端和小端。
像上面這種將高位字節放在內存低地址的方式叫作大端
反之,將低位字節放在內存低地址的方式就叫作小端:
上面只說明瞭 int 型的變量如何存儲在內存,而 float、char 等類型實際上也是同樣的,都須要先轉換爲補碼。
對於多字節的變量類型,還須要按照大端或者小端的格式,依次將字節寫入到內存單元。
記住上面這兩張圖,這就是編程語言中全部變量的在內存中的樣子,無論是 int、char、指針、數組、結構體、對象... 都是這樣放在內存的。
上面我說,定義一個變量實際就是向計算機申請了一塊內存來存放。
那若是咱們要想知道變量到底放在哪了呢?
能夠經過運算符&
來取得變量實際的地址,這個值就是變量所佔內存塊的起始地址。
(PS: 實際上這個地址是虛擬地址,並非真正物理內存上的地址
咱們能夠把這個地址打印出來:
printf("%x", &a);
大概會是像這樣的一串數字:0x7ffcad3b8f3c
上面說,咱們能夠經過&
符號獲取變量的內存地址,那獲取以後如何來表示這是一個地址,而不是一個普通的值呢?
也就是在 C 語言中如何表示地址這個概念呢?
對,就是指針,你能夠這樣:
int *pa = &a;
pa 中存儲的就是變量 a
的地址,也叫作指向 a
的指針。
在這裏我想談幾個看起來有點無聊的話題:
爲何咱們須要指針?直接用變量名不行嗎?
固然能夠,可是變量名是有侷限的。
變量名的本質是什麼?
是變量地址的符號化,變量是爲了讓咱們編程時更加方便,對人友好,可計算機可不認識什麼變量 a
,它只知道地址和指令。
因此當你去查看 C 語言編譯後的彙編代碼,就會發現變量名消失了,取而代之的是一串串抽象的地址。
你能夠認爲,編譯器會自動維護一個映射,將咱們程序中的變量名轉換爲變量所對應的地址,而後再對這個地址去進行讀寫。
也就是有這樣一個映射表存在,將變量名自動轉化爲地址:
a | 0x7ffcad3b8f3c c | 0x7ffcad3b8f2c h | 0x7ffcad3b8f4c ....
說的好!
但是我仍是不知道指針存在的必要性,那麼問題來了,看下面代碼:
int func(...) { ... }; int main() { int a; func(...); };
假設我有一個需求:
要求在
func
函數裏要可以修改main
函數裏的變量a
,這下咋整,在main
函數裏能夠直接經過變量名去讀寫a
所在內存。可是在
func
函數裏是看不見a
的呀。
你說能夠經過&
取地址符號,將 a
的地址傳遞進去:
int func(int address) { .... }; int main() { int a; func(&a); };
這樣在func
裏就能獲取到 a
的地址,進行讀寫了。
理論上這是徹底沒有問題的,可是問題在於:
編譯器該如何區分一個 int 裏你存的究竟是 int 類型的值,仍是另一個變量的地址(即指針)。
這若是徹底靠咱們編程人員去人腦記憶了,會引入複雜性,而且沒法經過編譯器檢測一些語法錯誤。
而經過int *
去定義一個指針變量,會很是明確:這就是另一個 int 型變量的地址。
編譯器也能夠經過類型檢查來排除一些編譯錯誤。
這就是指針存在的必要性。
實際上任何語言都有這個需求,只不過不少語言爲了安全性,給指針戴上了一層枷鎖,將指針包裝成了引用。
可能你們學習的時候都是天然而然的接受指針這個東西,可是仍是但願這段囉嗦的解釋對你有必定啓發。
同時,在這裏提點小問題:
既然指針的本質都是變量的內存首地址,即一個 int 類型的整數。
那爲何還要有各類類型呢?
好比 int 指針,float 指針,這個類型影響了指針自己存儲的信息嗎?
這個類型會在何時發揮做用?
上面的問題,就是爲了引出指針解引用的。
pa
中存儲的是a
變量的內存地址,那如何經過地址去獲取a
的值呢?
這個操做就叫作解引用,在 C 語言中經過運算符 *
就能夠拿到一個指針所指地址的內容了。
好比*pa
就能得到a
的值。
咱們說指針存儲的是變量內存的首地址,那編譯器怎麼知道該從首地址開始取多少個字節呢?
這就是指針類型發揮做用的時候,編譯器會根據指針的所指元素的類型去判斷應該取多少個字節。
若是是 int 型的指針,那麼編譯器就會產生提取四個字節的指令,char 則只提取一個字節,以此類推。
下面是指針內存示意圖:
pa
指針首先是一個變量,它自己也佔據一塊內存,這塊內存裏存放的就是 a
變量的首地址。
當解引用的時候,就會從這個首地址連續劃出 4 個 byte,而後按照 int 類型的編碼方式解釋。
別看這個地方很簡單,但倒是深入理解指針的關鍵。
舉兩個例子來詳細說明:
好比:
float f = 1.0; short c = *(short*)&f;
你能解釋清楚上面過程,對於 f
變量,在內存層面發生了什麼變化嗎?
或者 c
的值是多少?1 ?
實際上,從內存層面來講,f
什麼都沒變。
如圖:
假設這是f
在內存中的位模式,這個過程實際上就是把 f
的前兩個 byte 取出來而後按照 short 的方式解釋,而後賦值給 c
。
詳細過程以下:
&f
取得f
的首地址(short*)&f
上面第二步什麼都沒作,這個表達式只是說 :
「噢,我認爲f
這個地址放的是一個 short 類型的變量」
最後當去解引用的時候*(short*)&f
時,編譯器會取出前面兩個字節,而且按照 short 的編碼方式去解釋,並將解釋出的值賦給 c
變量。
這個過程 f
的位模式沒有發生任何改變,變的只是解釋這些位的方式。
固然,這裏最後的值確定不是 1,至因而什麼,你們能夠去真正算一下。
那反過來,這樣呢?
short c = 1; float f = *(float*)&c;
如圖:
具體過程和上述同樣,但上面確定不會報錯,這裏卻不必定。
爲何?
(float*)&c
會讓咱們從c
的首地址開始取四個字節,而後按照 float 的編碼方式去解釋。
可是c
是 short 類型只佔兩個字節,那確定會訪問到相鄰後面兩個字節,這時候就發生了內存訪問越界。
固然,若是隻是讀,大機率是沒問題的。
可是,有時候須要向這個區域寫入新的值,好比:
*(float*)&c = 1.0;
那麼就可能發生 coredump,也就是訪存失敗。
另外,就算是不會 coredump,這種也會破壞這塊內存原有的值,由於極可能這是是其它變量的內存空間,而咱們去覆蓋了人家的內容,確定會致使隱藏的 bug。
若是你理解了上面這些內容,那麼使用指針必定會更加的自如。
講到這裏,咱們來看一個問題,這是一位C語言交流羣的羣友問的,這是他的需求:
這是他寫的代碼:
他把 double 寫進文件再讀出來,而後發現打印的值對不上。
而關鍵的地方就在於這裏:
char buffer[4]; ... printf("%f %x\n", *buffer, *buffer);
他可能認爲 buffer
是一個指針(準確說是數組),對指針解引用就該拿到裏面的值,而裏面的值他認爲是從文件讀出來的 4 個byte,也就是以前的 float 變量。
注意,這一切都是他認爲的,實際上編譯器會認爲:
「哦,buffer
是 char類型的指針,那我取第一個字節出來就行了」。
而後把第一個字節的值傳遞給了 printf 函數,printf 函數會發現,%f
要求接收的是一個 float 浮點數,那就會自動把第一個字節的值轉換爲一個浮點數打印出來。
這就是整個過程。
錯誤關鍵就是,這個同窗誤認爲,任何指針解引用都是拿到裏面「咱們認爲的那個值」,實際上編譯器並不知道,編譯器只會傻傻的按照指針的類型去解釋。
因此這裏改爲:
printf("%f %x\n", *(float*)buffer, *(float*)buffer);
至關於明確的告訴編譯器:
「buffer
指向的這個地方,我放的是一個 float,你給我按照 float 去解釋」
結構體內包含多個成員,這些成員之間在內存中是如何存放的呢?
好比:
struct fraction { int num; // 整數部分 int denom; // 小數部分 }; struct fraction fp; fp.num = 10; fp.denom = 2;
這是一個定點小數結構體,它在內存佔 8 個字節(這裏不考慮內存對齊),兩個成員域是這樣存儲的:
image-20201030214416842
咱們把 10 放在告終構體中基地址偏移爲 0 的域,2 放在了偏移爲 4 的域。
接下來咱們作一個正常人永遠不會作的操做:
((fraction*)(&fp.denom))->num = 5; ((fraction*)(&fp.denom))->denom = 12; printf("%d\n", fp.denom); // 輸出多少?
上面這個究竟會輸出多少呢?本身先思考下噢~
接下來我分析下這個過程發生了什麼:
首先,&fp.denom
表示取結構體 fp 中 denom 域的首地址,而後以這個地址爲起始地址取 8 個字節,而且將它們看作一個 fraction 結構體。
在這個新結構體中,最上面四個字節變成了 denom 域,而 fp 的 denom 域至關於新結構體的 num 域。
所以:
((fraction*)(&fp.denom))->num = 5
實際上改變的是 fp.denom
,而
((fraction*)(&fp.denom))->denom = 12
則是將最上面四個字節賦值爲 12。
固然,往那四字節內存寫入值,結果是沒法預測的,可能會形成程序崩潰,由於也許那裏剛好存儲着函數調用棧幀的關鍵信息,也可能那裏沒有寫入權限。
你們初學 C 語言的不少 coredump 錯誤都是相似緣由形成的。
因此最後輸出的是 5。
爲何要講這種看起來莫名其妙的代碼?
就是爲了說明結構體的本質其實就是一堆的變量打包放在一塊兒,而訪問結構體中的域,就是經過結構體的起始地址,也叫基地址,而後加上域的偏移。
其實,C++、Java 中的對象也是這樣存儲的,無非是他們爲了實現某些面向對象的特性,會在數據成員之外,添加一些 Head 信息,好比C++ 的虛函數表。
實際上,咱們是徹底能夠用 C 語言去模仿的。
這就是爲何一直說 C 語言是基礎,你真正懂了 C 指針和內存,對於其它語言你也會很快的理解其對象模型以及內存佈局。
提及多級指針這個東西,我之前大一,最多理解到 2 級,再多真的會把我繞暈,常常也會寫錯代碼。
你要是給我寫個這個:int ******p
能把我搞崩潰,我估計不少同窗如今就是這種狀況🤣
其實,多級指針也沒那麼複雜,就是指針的指針的指針的指針......很是簡單。
今天就帶你們認識一下多級指針的本質。
首先,我要說一句話,沒有多級指針這種東西,指針就是指針,多級指針只是爲了咱們方便表達而取的邏輯概念。
首先看下生活中的快遞櫃:
這種你們都用過吧,豐巢或者超市儲物櫃都是這樣,每一個格子都有一個編號,咱們只須要拿到編號,而後就能找到對應的格子,取出裏面的東西。
這裏的格子就是內存單元,編號就是地址,格子裏放的東西就對應存儲在內存中的內容。
假設我把一本書,放在了 03 號格子,而後把 03 這個編號告訴你,你就能夠根據 03 去取到裏面的書。
那若是我把書放在 05 號格子,而後在 03 號格子只放一個小紙條,上面寫着:「書放在 05 號」。
你會怎麼作?
固然是打開 03 號格子,而後取出了紙條,根據上面內容去打開 05 號格子獲得書。
這裏的 03 號格子就叫指針,由於它裏面放的是指向其它格子的小紙條(地址)而不是具體的書。
明白了嗎?
那我若是把書放在 07 號格子,而後在 05 號格子 放一個紙條:「書放在 07號」,同時在03號格子放一個紙條「書放在 05號」
這裏的 03 號格子就叫二級指針,05 號格子就叫指針,而 07 號就是咱們日常用的變量。
依次,可類推出 N 級指針。
因此你明白了嗎?一樣的一塊內存,若是存放的是別的變量的地址,那麼就叫指針,存放的是實際內容,就叫變量。
int a; int *pa = &a; int **ppa = &pa; int ***pppa = &ppa;
上面這段代碼,pa
就叫一級指針,也就是平時常說的指針,ppa
就是二級指針。
內存示意圖以下:
無論幾級指針有兩個最核心的東西:
這就是我爲何多級指針是邏輯上的概念,實際上一塊內存要麼放實際內容,要麼放其它變量地址,就這麼簡單。
怎麼去解讀int **a
這種表達呢?
int ** a` 能夠把它分爲兩部分看,即`int*` 和 `*a`,後面 `*a` 中的`*`表示 `a` 是一個指針變量,前面的 `int*` 表示指針變量`a
只能存放 int*
型變量的地址。
對於二級指針甚至多級指針,咱們均可以把它拆成兩部分。
首先無論是多少級的指針變量,它首先是一個指針變量,指針變量就是一個*
,其他的*
表示的是這個指針變量只能存放什麼類型變量的地址。
好比int****a
表示指針變量 a
只能存放int***
型變量的地址。
數組是 C 自帶的基本數據結構,完全理解數組及其用法是開發高效應用程序的基礎。
數組和指針表示法緊密關聯,在合適的上下文中能夠互換。
以下:
int array[10] = {10, 9, 8, 7}; printf("%d\n", *array); // 輸出 10 printf("%d\n", array[0]); // 輸出 10 printf("%d\n", array[1]); // 輸出 9 printf("%d\n", *(array+1)); // 輸出 9 int *pa = array; printf("%d\n", *pa); // 輸出 10 printf("%d\n", pa[0]); // 輸出 10 printf("%d\n", pa[1]); // 輸出 9 printf("%d\n", *(pa+1)); // 輸出 9
在內存中,數組是一塊連續的內存空間:
第 0 個元素的地址稱爲數組的首地址,數組名實際就是指向數組首地址,當咱們經過array[1]
或者*(array + 1)
去訪問數組元素的時候。
實際上能夠看作 address[offset]
,address
爲起始地址,offset
爲偏移量,可是注意這裏的偏移量offset
不是直接和 address
相加,而是要乘以數組類型所佔字節數,也就是: address + sizeof(int) * offset
。
學過彙編的同窗,必定對這種方式不陌生,這是彙編中尋址方式的一種:基址變址尋址。
看完上面的代碼,不少同窗可能會認爲指針和數組徹底一致,能夠互換,這是徹底錯誤的。
儘管數組名字有時候能夠當作指針來用,但數組的名字不是指針。
最典型的地方就是在 sizeof
:
printf("%u", sizeof(array)); printf("%u", sizeof(pa));
第一個將會輸出 40,由於 array
包含有 10 個int類型的元素,而第二個在 32 位機器上將會輸出 4,也就是指針的長度。
爲何會這樣呢?
站在編譯器的角度講,變量名、數組名都是一種符號,它們都是有類型的,它們最終都要和數據綁定起來。
變量名用來指代一份數據,數組名用來指代一組數據(數據集合),它們都是有類型的,以便推斷出所指代的數據的長度。
對,數組也有類型,咱們能夠將 int、float、char 等理解爲基本類型,將數組理解爲由基本類型派生獲得的稍微複雜一些的類型,
數組的類型由元素的類型和數組的長度共同構成。而 sizeof
就是根據變量的類型來計算長度的,而且計算的過程是在編譯期,而不會在程序運行時。
編譯器在編譯過程當中會建立一張專門的表格用來保存變量名及其對應的數據類型、地址、做用域等信息。
sizeof
是一個操做符,不是函數,使用 sizeof
時能夠從這張表格中查詢到符號的長度。
因此,這裏對數組名使用sizeof
能夠查詢到數組實際的長度。
pa
僅僅是一個指向 int 類型的指針,編譯器根本不知道它指向的是一個整數,仍是一堆整數。
雖然在這裏它指向的是一個數組,但數組也只是一塊連續的內存,沒有開始和結束標誌,也沒有額外的信息來記錄數組到底多長。
因此對 pa
使用 sizeof
只能求得的是指針變量自己的長度。
也就是說,編譯器並無把 pa
和數組關聯起來,pa
僅僅是一個指針變量,無論它指向哪裏,sizeof
求得的永遠是它自己所佔用的字節數。
你們不要認爲二維數組在內存中就是按行、列這樣二維存儲的,實際上,無論二維、三維數組... 都是編譯器的語法糖。
存儲上和一維數組沒有本質區別,舉個例子:
int array[3][3] = {{1, 2,3}, {4, 5,6},{7, 8, 9}}; array[1][1] = 5;
或許你覺得在內存中 array
數組會像一個二維矩陣:
1 2 3 4 5 6 7 8 9
可實際上它是這樣的:
1 2 3 4 5 6 7 8 9
和一維數組沒有什麼區別,都是一維線性排列。
當咱們像 array[1][1]
這樣去訪問的時候,編譯器會怎麼去計算咱們真正所訪問元素的地址呢?
爲了更加通用化,假設數組定義是這樣的:
int array[n][m]
訪問: array[a][b]
那麼被訪問元素地址的計算方式就是: array + (m * a + b)
這個就是二維數組在內存中的本質,其實和一維數組是同樣的,只是語法糖包裝成一個二維的樣子。
想必你們必定看到過 void 的這些用法:
void func(); int func1(void);
在這些狀況下,void 表達的意思就是沒有返回值或者參數爲空。
可是對於 void 型指針卻表示通用指針,能夠用來存聽任何數據類型的引用。
下面的例子就 是一個 void 指針:
void *ptr;
void 指針最大的用處就是在 C 語言中實現泛型編程,由於任何指針均可以被賦給 void 指針,void 指針也能夠被轉換回原來的指針類型, 而且這個過程指針實際所指向的地址並不會發生變化。
好比:
int num; int *pi = # printf("address of pi: %p\n", pi); void* pv = pi; pi = (int*) pv; printf("address of pi: %p\n", pi);
這兩次輸出的值都會是同樣:
日常可能不多會這樣去轉換,可是當你用 C 寫大型軟件或者寫一些通用庫的時候,必定離不開 void 指針,這是 C 泛型的基石,好比 std 庫裏的 sort 函數申明是這樣的:
void qsort(void *base,int nelem,int width,int (*fcmp)(const void *,const void *));
全部關於具體元素類型的地方所有用 void 代替。
void 還能夠用來實現 C 語言中的多態,這是一個挺好玩的東西。
不過也有須要注意的:
好比:
int num; void *pv = (void*)# *pv = 4; // 錯誤
爲何?
由於解引用的本質就是編譯器根據指針所指的類型,而後從指針所指向的內存連續取 N 個字節,而後將這 N 個字節按照指針的類型去解釋。
好比 int *型指針,那麼這裏 N 就是 4,而後按照 int 的編碼方式去解釋數字。
可是 void,編譯器是不知道它到底指向的是 int、double、或者是一個結構體,因此編譯器無法對 void 型指針解引用。
不少人認爲 C 就只能面向過程編程,實際上利用指針和結構體,咱們同樣能夠在 C 中模擬出對象、繼承、多態等東西。
也能夠利用 void 指針實現泛型編程,也就是 Java、C++ 中的模板。
實際上用 C 實現面向對象、模板、繼承也是頗有趣的東西,當你知道了如何用 C 去實現這些東西,那你對 C++ 中的對象、Java 中的對象也會理解得更加透徹。
好比爲啥有 this
指針,或者 Python 中的 self
到底是個啥?
關於指針有趣的內容還有不少,這其實也只算是開了個頭,限於篇幅,之後有機會補齊如下內容:
malloc
和 free
const
修飾指針基本上涵蓋了 C 語言最核心的知識。