官术网_书友最值得收藏!

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合在一起正好與結果吻合。(我們違反了強制轉換的原則,越界了。)

主站蜘蛛池模板: 麦盖提县| 南京市| 红安县| 衡阳市| 明星| 义马市| 旌德县| 五指山市| 庄河市| 静乐县| 都江堰市| 平塘县| 嘉祥县| 兰考县| 汾阳市| 叙永县| 凤城市| 武威市| 黔西县| 沂源县| 澳门| 湄潭县| 五华县| 繁峙县| 赤壁市| 花莲县| 金门县| 宁南县| 静海县| 湖南省| 陆丰市| 灵武市| 梅州市| 乌鲁木齐县| 瑞丽市| 于田县| 手机| 永清县| 公主岭市| 仁化县| 电白县|