1. 程式人生 > >C語言重點——指標篇(一文讓你完全搞懂指標)| 從記憶體理解指標 | 指標完全解析

C語言重點——指標篇(一文讓你完全搞懂指標)| 從記憶體理解指標 | 指標完全解析

> 有乾貨、更有故事,微信搜尋【**程式設計指北**】關注這個不一樣的程式設計師,等你來撩~ **注:這篇文章好好看完一定會讓你掌握好指標的本質** C語言最核心的知識就是指標,所以,這一篇的文章主題是「指標與記憶體模型」 說到指標,就不可能脫離開記憶體,學會指標的人分為兩種,一種是不瞭解記憶體模型,另外一種則是瞭解。 不瞭解的對指標的理解就停留在“指標就是變數的地址”這句話,會比較害怕使用指標,特別是各種高階操作。 而瞭解記憶體模型的則可以把指標用得爐火純青,各種 byte 隨意操作,讓人直呼 666。 ### 一、記憶體本質 程式設計的本質其實就是操控資料,資料存放在記憶體中。 因此,如果能更好地理解記憶體的模型,以及 C 如何管理記憶體,就能對程式的工作原理洞若觀火,從而使程式設計能力更上一層樓。 大家真的別認為這是空話,我大一整年都不敢用 C 寫上千行的程式也很抗拒寫 C。 因為一旦上千行,經常出現各種莫名其妙的記憶體錯誤,一不小心就發生了 coredump...... 而且還無從排查,分析不出原因。 相比之下,那時候最喜歡 Java,在 Java 裡隨便怎麼寫都不會發生類似的異常,頂多偶爾來個 **NullPointerException**,也是比較好排查的。 直到後來對記憶體和指標有了更加深刻的認識,才慢慢會用 C 寫上千行的專案,也很少會再有記憶體問題了。(過於自信 「指標儲存的是變數的記憶體地址」這句話應該任何講 C 語言的書都會提到吧。 所以,要想徹底理解指標,首先要理解 C 語言中變數的儲存本質,也就是記憶體。 #### 1.1 記憶體編址 計算機的記憶體是一塊用於儲存資料的空間,由一系列連續的儲存單元組成,就像下面這樣, ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk6p1iowcxj30t20acmz8.jpg) 每一個單元格都表示 1 個 Bit,一個 bit 在 EE 專業的同學看來就是高低電位,而在 CS 同學看來就是 0、1 兩種狀態。 由於 1 個 bit 只能表示兩個狀態,所以大佬們規定 8個 bit 為一組,命名為 byte。 並且將 byte 作為記憶體定址的最小單元,也就是給每個 byte 一個編號,這個編號就叫記憶體的**地址**。 ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk6pcjje44j30qe09ggnr.jpg) 這就相當於,我們給小區裡的每個單元、每個住戶都分配一個門牌號: 301、302、403、404、501...... 在生活中,我們需要保證門牌號唯一,這樣就能通過門牌號很精準的定位到一家人。 同樣,在計算機中,我們也要保證給每一個 byte 的編號都是唯一的,這樣才能夠保證每個編號都能訪問到唯一確定的 byte。 #### 1.2 記憶體地址空間 上面我們說給記憶體中每個 byte 唯一的編號,那麼這個編號的範圍就決定了計算機可定址記憶體的範圍。 所有編號連起來就叫做記憶體的地址空間,這和大家平時常說的電腦是 32 位還是 64 位有關。 早期 Intel 8086、8088 的 CPU 就是隻支援 16 位地址空間,**暫存器**和**地址匯流排**都是 16 位,這意味著最多對 ```2^16 = 64 Kb``` 的記憶體編號定址。 這點記憶體空間顯然不夠用,後來,80286 在 8086 的基礎上將**地址匯流排**和**地址暫存器**擴充套件到了20 位,也被叫做 A20 地址匯流排。 當時在寫 mini os 的時候,還需要通過 BIOS 中斷去啟動 A20 地址匯流排的開關。 但是,現在的計算機一般都是 32 位起步了,32 位意味著可定址的記憶體範圍是 ```2^32 byte = 4GB```。 所以,如果你的電腦是 32 位的,那麼你裝超過 4G 的記憶體條也是無法充分利用起來的。 好了,這就是記憶體和記憶體編址。 #### 1.3 變數的本質 有了記憶體,接下來我們需要考慮,int、double 這些變數是如何儲存在 0、1 單元格的。 在 C 語言中我們會這樣定義變數: ```c int a = 999; char c = 'c'; ``` 當你寫下一個變數定義的時候,實際上是向記憶體申請了一塊空間來存放你的變數。 我們都知道 int 型別佔 4 個位元組,並且在計算機中數字都是用補碼(不瞭解補碼的記得去百度)表示的。 ```999``` 換算成補碼就是:```0000 0011 1110 0111``` 這裡有 4 個byte,所以需要四個單元格來儲存: ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk73z5ahpjj30s00aimzc.jpg) 有沒有注意到,我們把高位的位元組放在了低地址的地方。 那能不能反過來呢? 當然,這就引出了**大端和小端。** 像上面這種將高位位元組放在記憶體低地址的方式叫做**大端** 反之,將低位位元組放在記憶體低地址的方式就叫做**小端**: ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk74584w6tj30rs0b840p.jpg) 上面只說明瞭 int 型的變數如何儲存在記憶體,而 float、char 等型別實際上也是一樣的,都需要先轉換為補碼。 對於多位元組的變數型別,還需要按照大端或者小端的格式,依次將位元組寫入到記憶體單元。 記住上面這兩張圖,這就是程式語言中所有變數的在記憶體中的樣子,不管是 int、char、指標、陣列、結構體、物件... 都是這樣放在記憶體的。 ### 二、指標是什麼東西? #### 2.1 變數放在哪? 上面我說,定義一個變數實際就是向計算機申請了一塊記憶體來存放。 那如果我們要想知道變數到底放在哪了呢? 可以通過運算子```&```來取得變數實際的地址,這個值就是變數所佔記憶體塊的起始地址。 (PS: 實際上這個地址是虛擬地址,並不是真正實體記憶體上的地址 我們可以把這個地址打印出來: ```c printf("%x", &a); ``` 大概會是像這樣的一串數字:```0x7ffcad3b8f3c``` #### 2.2 指標本質 上面說,我們可以通過```&```符號獲取變數的記憶體地址,那獲取之後如何來表示這是一個**地址**,而不是一個普通的值呢? **也就是在 C 語言中如何表示地址這個概念呢?** 對,就是指標,你可以這樣: ```c int *pa = &a; ``` pa 中儲存的就是變數 ```a``` 的地址,也叫做指向 ```a``` 的指標。 在這裡我想談幾個看起來有點無聊的話題: > 為什麼我們需要指標?直接用變數名不行嗎? 當然可以,但是變數名是有侷限的。 > 變數名的本質是什麼? 是變數地址的符號化,變數是為了讓我們程式設計時更加方便,對人友好,可計算機可不認識什麼變數 ```a```,它只知道地址和指令。 所以當你去檢視 C 語言編譯後的彙編程式碼,就會發現變數名消失了,取而代之的是一串串抽象的地址。 你可以認為,編譯器會自動維護一個對映,將我們程式中的變數名轉換為變數所對應的地址,然後再對這個地址去進行讀寫。 也就是有這樣一個對映表存在,將變數名自動轉化為地址: ```c a | 0x7ffcad3b8f3c c | 0x7ffcad3b8f2c h | 0x7ffcad3b8f4c .... ``` 說的好! 可是我還是不知道指標存在的必要性,那麼問題來了,看下面程式碼: ```c 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 指標,這個型別影響了指標本身儲存的資訊嗎? > 這個型別會在什麼時候發揮作用? #### 2.3 解引用 上面的問題,就是為了引出指標解引用的。 ```pa```中儲存的是```a```變數的記憶體地址,那如何通過地址去獲取```a```的值呢? 這個操作就叫做**解引用**,在 C 語言中通過運算子 ```*```就可以拿到一個指標所指地址的內容了。 比如```*pa```就能獲得```a```的值。 我們說指標儲存的是變數記憶體的首地址,那編譯器怎麼知道該從首地址開始取多少個位元組呢? 這就是指標型別發揮作用的時候,編譯器會根據指標的所指元素的型別去判斷應該取多少個位元組。 如果是 int 型的指標,那麼編譯器就會產生提取四個位元組的指令,char 則只提取一個位元組,以此類推。 下面是指標記憶體示意圖: ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk8awo5rq8j30xq0fcadf.jpg) ```pa``` 指標首先是一個變數,它本身也佔據一塊記憶體,這塊記憶體裡存放的就是 ```a``` 變數的首地址。 當解引用的時候,就會從這個首地址連續劃出 4 個 byte,然後按照 int 型別的編碼方式解釋。 #### 2.4 活學活用 別看這個地方很簡單,但卻是深刻理解指標的關鍵。 舉兩個例子來詳細說明: 比如: ```c float f = 1.0; short c = *(short*)&f; ``` 你能解釋清楚上面過程,對於 ```f``` 變數,在記憶體層面發生了什麼變化嗎? 或者 ```c``` 的值是多少?1 ? 實際上,從記憶體層面來說,```f``` 什麼都沒變。 如圖: ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk8b66uofoj30sc0dg0vd.jpg) 假設這是``` f``` 在記憶體中的位模式,這個過程實際上就是把 ```f``` 的前兩個 byte 取出來然後按照 short 的方式解釋,然後賦值給 ```c```。 詳細過程如下: 1. ```&f```取得``` f``` 的首地址 2. ```(short*)&f``` 上面第二步什麼都沒做,這個表示式只是說 : “噢,我認為```f```這個地址放的是一個 short 型別的變數” 最後當去解引用的時候```*(short*)&f```時,編譯器會取出前面兩個位元組,並且按照 short 的編碼方式去解釋,並將解釋出的值賦給 ```c``` 變數。 這個過程 ```f ```的位模式沒有發生任何改變,變的只是解釋這些位的方式。 當然,這裡最後的值肯定不是 1,至於是什麼,大家可以去真正算一下。 那反過來,這樣呢? ```c short c = 1; float f = *(float*)&c; ``` 如圖: ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk8babigguj30se0dm771.jpg) 具體過程和上述一樣,但上面肯定不會報錯,這裡卻不一定。 為什麼? ```(float*)&c```會讓我們從```c``` 的首地址開始取四個位元組,然後按照 float 的編碼方式去解釋。 但是``` c ```是 short 型別只佔兩個位元組,那肯定會訪問到相鄰後面兩個位元組,這時候就發生了記憶體訪問越界。 當然,如果只是讀,大概率是沒問題的。 但是,有時候需要向這個區域寫入新的值,比如: ```c *(float*)&c = 1.0; ``` 那麼就可能發生 coredump,也就是訪存失敗。 另外,就算是不會 coredump,這種也會破壞這塊記憶體原有的值,因為很可能這是是其它變數的記憶體空間,而我們去覆蓋了人家的內容,肯定會導致隱藏的 bug。 如果你理解了上面這些內容,那麼使用指標一定會更加的自如。 #### 2.6 看個小問題 講到這裡,我們來看一個問題,這是一位群友問的,這是他的需求: ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk8bj1ximsj30ie0aajtn.jpg) 這是他寫的程式碼: ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk8bk5n64oj30a508xdi7.jpg) 他把 double 寫進檔案再讀出來,然後發現列印的值對不上。 而關鍵的地方就在於這裡: ``` char buffer[4]; ... printf("%f %x\n", *buffer, *buffer); ``` 他可能認為 ```buffer``` 是一個指標(準確說是陣列),對指標解引用就該拿到裡面的值,而裡面的值他認為是從檔案讀出來的 4 個byte,也就是之前的 float 變數。 注意,這一切都是他認為的,實際上編譯器會認為: “哦,```buffer``` 是 char型別的指標,那我取第一個位元組出來就好了”。 然後把第一個位元組的值傳遞給了 printf 函式,printf 函式會發現,```%f``` 要求接收的是一個 float 浮點數,那就會自動把第一個位元組的值轉換為一個浮點數打印出來。 這就是整個過程。 錯誤關鍵就是,這個同學誤認為,任何指標解引用都是拿到裡面“我們認為的那個值”,實際上編譯器並不知道,編譯器只會傻傻的按照指標的型別去解釋。 所以這裡改成: ```c printf("%f %x\n", *(float*)buffer, *(float*)buffer); ``` 相當於明確的告訴編譯器: “```buffer ```指向的這個地方,我放的是一個 float,你給我按照 float 去解釋” ### 三、 結構體和指標 結構體內包含多個成員,這些成員之間在記憶體中是如何存放的呢? 比如: ```c struct fraction { int num; // 整數部分 int denom; // 小數部分 }; struct fraction fp; fp.num = 10; fp.denom = 2; ``` 這是一個定點小數結構體,它在記憶體佔 8 個位元組(這裡不考慮記憶體對齊),兩個成員域是這樣儲存的: ![image-20201030214416842](https://tva1.sinaimg.cn/large/0081Kckwgy1gk7p2vyuxzj30m00d2tb8.jpg) 我們把 10 放在了結構體中基地址偏移為 0 的域,2 放在了偏移為 4 的域。 接下來我們做一個正常人永遠不會做的操作: ```c ((fraction*)(&fp.denom))->num = 5; ((fraction*)(&fp.denom))->denom = 12; printf("%d\n", fp.denom); // 輸出多少? ``` 上面這個究竟會輸出多少呢?自己先思考下噢~ 接下來我分析下這個過程發生了什麼: ![](https://tva1.sinaimg.cn/large/0081Kckwgy1gk7pkqjcmtj30v00d4acz.jpg) 首先,```&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``` 能把我搞崩潰,我估計很多同學現在就是這種情況