1.2 理解指針和指針強制轉換
1.2.1 指針和它丟失的類型信息
我們在學習C語言的時候,最難把握的是指針,特別是遇到指針強制轉換時。通過反匯編,我們能夠直接透視其本質,重新建立起清晰的認知。按照猜測實證的原則,首先應該對指針有一個簡單的猜想,然后通過實驗求證。
指針存儲了內存的地址,同時指針是有類型的,如int *、float *,那么,一個自然的猜想就是指針變量應該存儲這兩方面的信息:地址和指針類型。比如,就像下面的結構體:
struct pointer{ long address; //地址 int type; //指針的類型 }
先做一個簡單的實驗,打印sizeof(int *)。出乎意料,這個值是4!再打印sizeof(float *),還是4。在1.1節中對全局變量賦值及mov指令的分析中,我們已經了解,全局變量的地址是4字節,即下面代碼中的00417140h。
gi = 12; 0041138E mov dword ptr ds:[00417140h],0ch
既然4字節是存儲內存地址用的,反過來就說明指針并沒有存儲類型信息的地方。那么,為什么指針要有類型?為什么還要有指針類型強制轉換?而且不轉換,不同類型的指針無法賦值(如int*不能直接賦值給float *,會產生編譯錯誤)。要實驗就用反匯編直指其根本。反匯編一段簡單代碼,見DM1-6。
DM1-6 int gi; int *pi; void main() { … pi = &gi; 00411452 mov dword ptr ds:[00417164h],417168h * pi = 12; 0041145C mov eax, dword ptr ds:[00417164h] 00411461 mov dword ptr [eax],0ch … }
如果還覺得不清晰,右鍵單擊代碼,在彈出的快捷菜單中選取“顯示符號名”,見DM1-7。
DM1-7 pi = &gi; 00411452 mov dword ptr[pi(417164h)], offset gi(417168h) * pi = 12; 0041145C mov eax, dword ptr [pi(417164h)] 00411461 mov dword ptr [eax],0ch
從反匯編結果可知,在第1條mov指令中, 417168h是gi的地址,而417164h是pi的地址(可通過監視窗體驗證)。“pi = &gi;”就是通過一條mov指令將gi的地址放入一個4字節的變量pi中。指針確實就只有4字節(417164h指向的4字節)存儲了地址信息,且沒有存儲指針類型(即int *)的代碼。因為“pi = &gi;”只有一條mov指令與它對應,難道指針變量真的沒有類型?如果沒有類型,為什么C語言還有int *、float *這些指針類型?
在匯編語言中沒有指針的概念,只有地址。為什么C語言包裝了一個指針的概念?從表面看,指針除了存儲地址信息,還有類型區別。(思考問題往往要從源頭去想,想想它的用途和存在價值。)想想數據指針的用途,我們不是拿地址來玩,實際上要用它來訪問內存。是否“訪問內存,只要地址就可以了”?(一定要想清楚細節,每個細節。)把整數1存儲到地址0x12345678中,需要從這個地址開始寫幾字節?1字節、2字節還是4字節?在CPU中,它們都是1的合法表示,對應到C語言的數據類型分別是char、short和int。那么,到底要寫幾字節?實際上,該信息應該反映到賦值語句中。“* pi = 12;”語句的反匯編如下:
mov eax, dword ptr [pi(417164h)] //將pi中存儲的gi的地址存入eax mov dword ptr [eax], 0ch //將12(0ch)存儲到eax指向的地址
在第2條賦值語句中,除了要賦的值(0ch)、被賦值的地址(在eax中)外,還有一個符號我們之前未注意——dword。是它回答了我們的問題:“寫幾字節?”dword表明寫4字節。到此我們發現,其實指針的類型信息決定了賦值/讀取時寫/讀多少字節。
讀/寫多少字節的信息不是存放在指針變量中,而是放到了與該地址相關的賦值指令中,mov指令中的dword指明了這個信息。
C語言之所以要包裝出指針的概念,是在匯編地址的內涵上增加了另一層含義,即讀/寫多少字節。不同類型指針,訪問字節數不同。int *訪問4字節,short *訪問2字節。這樣就方便我們操控一個地址,否則如果只有地址信息,每次訪問它還要附加說明訪問的字節數。這時,我們也能理解指針加/減1不是加/減1字節,而是加/減長度為該指針指向類型的長度的字節數。比如,int *指針加1是加4字節,short*指針則是加2字節。我們也能理解,void *類型的指針為什么無法進行加、減運算。因為它只是匯編語言中的地址,沒有類型信息,加、減的時候不知道加/減多少字節。
為了進一步確認“指針類型信息放入了賦值指令中”這一結論,我們再做一個實驗:對short指針指向的地址賦值,見DM1-8。
DM1-8 short gi; short *pi; void main() { … pi = &gi; 00413762 mov dword ptr [pi(417164h)], offset gi(417168h) * pi = 12; 0041376C mov eax, 0ch 00413771 mov ecx, dword ptr [pi(417164h)] 00413777 mov word ptr [ecx], ax … }
“pi = &gi;”的反匯編與前面是一樣的,只有一條mov指令將gi的地址放入pi中,并沒有指針類型信息的相關操作。
注意“*pi = 12;”對應的3條指令:
① mov eax, 0ch:將12放入eax中, eax為4字節,12存放在eax的低2字節即ax中。
② mov ecx, dword ptr [pi (417164h)]:將pi存儲的地址即gi的地址放入ecx中(pi的地址是417164h,[417164h]中存儲的是gi的地址)。
③ mov word ptr [ecx], ax:將eax的低2字節存儲的內容(就是要賦值的12)存入ecx指向的地址(即gi的地址)中。“word”表明了如果向gi所在地址存儲,將寫入2字節(前面對int *指向地址的寫操作涉及的mov指令是dword,即double word,為4字節)。我們再次看到,指針類型信息short *體現在賦值指令mov中,而不是存放在指針變量中,指針變量只存放了地址。
還不夠?我們繼續這個實驗,將gi改為char類型,將pi改為char *類型。通過反匯編,則最后將12賦值給*pi:
mov byte ptr [eax], 0ch
byte指明了這個賦值語句是寫1字節,因為pi為char *類型。
至此,我們已經可以得出結論:
C語言的指針類型包含兩方面信息:一是地址,存放在指針變量中;二是類型信息,關乎讀寫的長度,沒有存儲在指針變量中,位于用該指針讀寫時的mov指令中,不同的讀寫長度對應的mov指令不同。
看清了這一點,我們就要進一步直面許多人都有點發虛的指針強制轉換,以及相關的轉換安全性問題。
1.2.2 指針強制轉換
問題,還是從問題開始我們的猜測和實證之旅,看如下代碼:
int *pi; short * ps; … pi = ps;
問題:
① 為什么,類型不同的指針變量不能賦值,如果將一個short *指針變量ps賦值給int *變量pi (pi = ps),編譯時會報錯?
② 為什么,可以強制轉換后賦值“pi = (int *)ps;”,強制轉換到底發生了什么?
對于第一個問題,1.2.1節的分析已可給出答案。如果允許將ps指向的地址賦值給pi,那么將來生成“*pi = 2;”賦值語句的指令時,必然生成mov dword指令,即寫4字節。而pi指向的是ps指向的變量,該變量為short,只有2字節,那么賦值4字節必然導致越界。所以,編譯器為了幫助讀者避免越界錯誤,便產生了編譯錯誤。
對于第二個問題,我們只有去看看匯編才知道真相。還是先猜想:這種強制轉換有類型信息的轉換嗎?根據1.2.1節的結論,指針變量本身不存儲類型信息,只存儲地址信息,那么,似乎強制轉換只是形式上的,“pi = (int *)ps;”應該只是將ps的值存入pi而已。所謂類型轉換的效果,應該還是體現在對該地址指向內存的存取上吧?猜想到此,開始我們的實驗。
在代碼DM1-9中,整數i的地址賦值給了int *、short*和char *(即pi、ps、pc)三種類型的指針變量。其中,對ps和pc的賦值進行了指針強制轉換,最后3行是用它們對同一地址進行賦值操作。
DM1-9 int i; int *pi; short *ps; char * pc; void main(int argc, char* argv[]) { pi = &i; ps = (short *)&i; pc = (char *)&i; *pi = 0x1234; *ps = 0x1234; *pc = 0x12; }
先看看主函數中的前3句對指針變量的賦值語句的反匯編:
pi = &i; 0041138e mov dword ptr [pi(417148h)], offset i (41714ch) ps = (short *)&i; 00411398 mov dword ptr[ps(417144h)],offset i(41714ch) pc = (char *)&i; 004113a2 mov dword ptr[pc(417140h)],offset i(41714ch)
這3條mov指令都是將41714ch即i的地址賦值給相關變量。只有賦值地址的3條指令,沒有產生任何與類型相關的指令。可知,在指針變量賦值上,強制轉換只是編譯器的一個善意提醒,沒有產生實際的指令。
再看后面3條表面上與強制轉換無關的賦值語句的反匯編:
*pi = 0x1234;

箭頭所示的3條指令是同一地址在3種指針身份下的對應mov指令。注意其中的黑體部分,對int * pi指向地址的賦值語句是mov dword,對short * ps指向地址的賦值語句是mov word,對char *pc指向地址的賦值語句是mov byte。指針強制轉換的影響不是在轉換的時候發生,而是在用轉換后的身份去訪問內存時體現到了指令中。
那么,什么情況下轉換是安全的呢?就要看用這個轉換后的身份去訪問內存是否安全。簡單地說有以下原則:
如果轉換后指針指向的數據類型大小小于原數據類型大小,那么用該轉化后的指針訪問就不會越過原數據的內存,是安全的,否則危險,要越界。
對上例而言,“ps = (short *)&i;”強制轉換后,用ps來訪問內存是2字節,而i本身是4字節,所以不會越界,是安全的。而下面代碼就是危險的:
short s; int * p; p = (int *)&s;
因為p指向的是short變量s,大小為2字節,而p為整數指針,用它訪問指向的內存將生成訪問4字節的指令,訪問會越界。
我們清晰了指針強制轉換的本質,也了解了安全性原則,來看下面的例子(我們真地能運用這些原則了嗎?):
int * pi; short si = 12; pi = (int *) &si; printf(“%d, %x”, * pi, * pi);
打印的結果是什么?是“12, 0c”?筆者發現,即使講完了以上分析后馬上問學生該問題,也幾乎無人發現其問題。(在了解原則的情況下,還需要多多練習,多多懷疑,讓猜測和實證將知識深深植入到思想和靈魂中,成為“活生生”的一部分。程序是手藝,而手藝是熟練和思想的交點。)來看這段代碼的反匯編結果:
-859045876 cccc000c
哪里有錯?這次我們沒有頭緒,沒有猜測。(沒關系,調試吧,觀察吧,或許觀察能夠給我們線索。)在“pi = (int *) &si;”語句上設置斷點,然后用內存窗體查看si的值,看它是否為12。先在監視窗體中輸入“&si”,獲得si的地址0x0012FF54,然后在內存窗體中輸入這個值,見圖1.22。

圖1.22
第1字節是0c(0c就是12),第2字節是00。(也對,在小端機的內存中0c 00代表0x000c,即12。)12還可以用4字節表示,圖中0x0012FF54開始的4字節0c 00 cc cc,它代表的整數是0xcccc000c。(這個值就是打印結果的十六進制表示。找到問題了。)將si的地址強制轉換為int *類型,然后賦值給pi(即pi = (int *) &si;),那么*pi會訪問4字節。這時越界了,將si后的2字節納入了范圍,即cc cc,它們與0c 00合在一起正好與結果吻合。(我們違反了強制轉換的原則,越界了。)
- TypeScript Blueprints
- C# 2012程序設計實踐教程 (清華電腦學堂)
- 無代碼編程:用云表搭建企業數字化管理平臺
- Developing Middleware in Java EE 8
- 三維圖形化C++趣味編程
- Java程序設計與計算思維
- Object-Oriented JavaScript(Second Edition)
- Windows Server 2012 Unified Remote Access Planning and Deployment
- PLC編程及應用實戰
- C語言程序設計案例精粹
- Salesforce Reporting and Dashboards
- Python Machine Learning Blueprints:Intuitive data projects you can relate to
- Web Developer's Reference Guide
- Unity Android Game Development by Example Beginner's Guide
- 分布式數據庫HBase案例教程