1.1 全局變量引發(fā)的故事
1.1.1 剖析賦值語句機器碼
我們從下面這段簡單地為全局變量賦值語句的反匯編開始:
int gi; void main(int argc, char* argv[]) { gi = 12; }
使用VS 2008作為調(diào)試環(huán)境。將光標放在“gi=12; ”語句行上,然后按F9鍵,在該行上設(shè)置斷點(如果繼續(xù)按F9鍵,將消除該斷點),出現(xiàn)如圖1.1所示的標志 。

圖1.1
斷點(break point):當程序運行時,經(jīng)過該點所在語句,程序?qū)和_\行。可在此時觀察程序的各種狀態(tài),如變量值、內(nèi)存值、寄存器,甚至可以修改這些相關(guān)狀態(tài)。可以在程序運行前設(shè)置斷點,也可以在運行中設(shè)置斷點。
然后保證程序為Debug模式(默認模式),如圖1.2中橢圓圈定部分。因為在Debug模式下可進行代碼的調(diào)試跟蹤。VS 2008的Release版本可調(diào)試,因為生成了調(diào)試信息,而VC 6.0必須手動配置,否則無法調(diào)試跟蹤。
按F5鍵,進入調(diào)試運行方式(按Ctrl+F5組合鍵,為非調(diào)試運行方式,設(shè)置的斷點無效)。程序在斷點處暫停,見圖1.3。

圖1.2

圖1.3
選擇菜單的“調(diào)試→窗口→反匯編”命令(見圖1.4),進行反匯編(disassemble)(快捷鍵Ctrl+Alt+D),反匯編的結(jié)果見DM1-1。

圖1.4
DM1-1 1 gi = 12; 2 0041138E mov dword ptr [gi (417140h)],0ch 3 }
第2行是第1行對應(yīng)的匯編碼,左邊的數(shù)字0041138E是十六進制數(shù)表示的賦值指令mov的存放地址,右邊是該條指令的匯編表示形式。(說明:內(nèi)存地址和其中的值均采用十六進制數(shù)表示,在反匯編中一般省略末尾的H或h。全書同。)學(xué)習(xí)過匯編的讀者會奇怪,全局變量的名字gi怎么會出現(xiàn)在匯編指令中呢?匯編語言沒有變量名。因為在VC環(huán)境中,為了易理解,開發(fā)環(huán)境特意將操作對應(yīng)的符號名也顯示在語句中。為了看到純正的匯編語句,我們選擇暫時將符號名從反匯編語句中去除。在代碼窗口中單擊右鍵,在彈出的快捷菜單中選擇“顯示符號名”,取消其選中狀態(tài)(如果需要顯示符號名,則再次單擊),見圖1.5。此時,反匯編結(jié)果見DM1-2,mov指令中沒有C語言的變量名gi了,是把0ch(h代表十六進制,0c即十進制數(shù)12)賦值給內(nèi)存。該內(nèi)存地址為指令方括號中的十六進制數(shù),即00417140h。

圖1.5
DM1-2 gi = 12; 0041138E mov dword ptr ds:[00417140h],0ch }
從這里開始,我們就開始探索式學(xué)習(xí)了。“問題”是探索式學(xué)習(xí)的關(guān)鍵。我們必須學(xué)會存疑發(fā)問,然后尋找解決方法求證。讓我們來發(fā)問吧:
計算機的信息存儲在內(nèi)存中,mov指令將數(shù)據(jù)存放在指定地址的內(nèi)存中。C語言的全局變量本質(zhì)上是一塊內(nèi)存,所以對它的存取也是通過地址進行的。
“mov DS:[地址值],存儲值”指令將存儲值存入括號中的地址指向的內(nèi)存中。DS是數(shù)據(jù)段寄存器,在x86尋址中是段寄存器和段內(nèi)偏移合在一起定位的(這有點像用街道名和門牌號一起定位的方式)。在Windows和Linux系統(tǒng)中,所有段寄存器都指向一個位置,所以相當于只有一個段,段寄存器可以不用考慮。
① 內(nèi)存00417140h真的是gi的地址嗎?
② mov指令真的放在地址為0041138eh內(nèi)存中嗎?
先來解決第一個問題。打印是我們最熟悉的基本方法。修改源代碼如下:
gi=12; printf(“gi address=%x\n”, &gi);
這樣太麻煩了,如果每次想看不同值,豈非要不斷修改程序?這是不能容忍的!因此我們利用調(diào)試環(huán)境的一個能力,用監(jiān)視窗口來觀察程序狀態(tài),如變量和表達式。選擇如圖1.6所示的菜單命令,激活一個監(jiān)視(watch)窗口。

圖1.6
選擇一個空行,雙擊“名稱”列,輸入“&gi”,回車,則在“值”列中顯示其值 0x00417140 (與尾部加h一樣,頭部加0x也是十六進制數(shù)的一種表示方法),見圖1.7。證明前面mov指令中的地址確實是gi的地址。

圖1.7
我們暫時將第二個問題放一放,進一步探索mov指令,來看它的機器碼。在代碼窗體中單擊右鍵,在彈出的快捷菜單中選擇“顯示代碼字節(jié)”(見圖1.8),則反匯編的結(jié)果如下:
gi = 12; 0041138E c7 05 40 71 41 00 0c 00 00 00 mov dword ptr ds:[00417140h], 0ch

圖1.8
mov指令的機器碼處于指令存放地址和匯編指令中間,即黑體顯示部分。
現(xiàn)在來分析這個機器碼中包含的與mov指令相關(guān)的信息。首先還是猜測,該指令應(yīng)該包含了3方面的信息:要賦的值12,要賦值的內(nèi)存地址00417140h,該指令是mov指令。按照猜測,我們在給出的機器碼c7 05 40 71 41 00 0c 00 00 00中來實證。
我們需要觀察、分析和猜測。賦值的12,即0ch,就被包含其中,從右邊數(shù)第4字節(jié)。賦值的地址00417140h好像沒有,但是其中包含每個單獨的字節(jié),00、41、71、40。從右往左看,則右數(shù)第5~8字節(jié)分別是00 41 71 40。我們因此猜測上面的機器碼分成如下3塊:
| c7 05 (代表mov指令) | 40 71 41 00 (地址00417140h)| 0c 00 00 00(代表值12)|
套用前面對地址的分析,第3塊從右往左看就是0000000c,即12。而int類型在32位機上就是4字節(jié),所以這條指令中用4字節(jié)表示了12。
這樣我們似乎發(fā)現(xiàn)了一個規(guī)律,計算機上表示的整數(shù)在內(nèi)存中是按字節(jié)倒序存儲的。0000000c在內(nèi)存中就是0c 00 00 00,00417140h在內(nèi)存中就是40 71 41 00。
下面來驗證。將“gi = 12;”改成“gi=0x12345678;”,再反匯編:
gi = 0x12345678; 0041138E c7 05 40 71 41 00 78 56 34 12 mov dword ptr ds:[00417140h],12345678h
其中,機器碼c7 05 40 71 41 00 78 56 34 12的后4字節(jié)“78 56 34 12”應(yīng)該是被賦的值,倒序排列為“12345678”。可知,我們的猜測是正確的。
這里引出“整數(shù)在計算機上的表示方式”的知識。在內(nèi)存中如何存儲整數(shù)只是一種規(guī)范,分為大端機和小端機兩種方式。
小端機 大端機
小端機:整數(shù)邏輯上的最低字節(jié)放在內(nèi)存的最低地址,次低字節(jié)放在內(nèi)存的次低地址,依次存放。比如, 0x12345678放到內(nèi)存中就是78 56 34 12。Intel的x86系列CPU是小端機。
大端機:與小端機剛好相反。比如,0x12345678放到內(nèi)存中就是12 34 56 78。PowerPC、SUN的SPARC、Motorola 6800是大端機。
下面來看mov指令的效果。要看賦值效果,我們需要用另一個調(diào)試利器——內(nèi)存觀察窗體。在VC 2008中選擇如圖1.9所示的菜單命令,激活內(nèi)存窗體,見圖1.10。

圖1.9
在“地址”欄中輸入地址,其值為十六進制數(shù)表示(以0x開頭),然后回車。下方為內(nèi)存面板區(qū),其中顯示內(nèi)存的值,左邊是內(nèi)存地址,地址從左到右、從上到下增加,顯示單位為字節(jié)。
首先執(zhí)行程序,在語句“gi = 12;”斷點處停下。在“地址”欄中輸入要觀察的gi的地址0x00417140,回車,結(jié)果見圖1.11。

圖1.10
單步執(zhí)行“gi = 12;”,再觀察gi所在內(nèi)存的值,見圖1.12。

圖1.11

圖1.12
單步執(zhí)行
單步執(zhí)行:只執(zhí)行當前語句。根據(jù)單步執(zhí)行函數(shù)的方式,單步執(zhí)行分為兩種:step into(快捷鍵F11),跟蹤執(zhí)行進當前函數(shù);step over(快捷鍵F10),不跟蹤執(zhí)行進入當前函數(shù),直接到下一語句。
gi所在內(nèi)存0x00417140中的值從00變?yōu)?c。可知,賦值12產(chǎn)生了效果。但到底是修改了1字節(jié)還是4字節(jié)呢?我們需要證明。(雖然我們知道int是4字節(jié)。)怎么辦?我們運用內(nèi)存窗體的另外一個能力——修改內(nèi)存的值。
修改內(nèi)存值
修改內(nèi)存值:單擊要修改的字節(jié),然后直接輸入要修改的新值。
重新執(zhí)行程序,當斷點中斷時,將0x00417140指向的4字節(jié)全部修改為11,見圖1.13。然后單步執(zhí)行“gi = 12;”語句,我們發(fā)現(xiàn),總共修改了4字節(jié):0c 00 00 00。至此驗證了整數(shù)gi是4字節(jié)。最后我們還要看小端機的效果,將賦值語句修改為“gi = 0x12345678;”,單步執(zhí)行,并在內(nèi)存窗體中查看,其值確實是“78 56 34 12”。

圖1.13
1.1.2 修改賦值語句機器碼
現(xiàn)在來看之前提出的問題:
② mov指令真的放在內(nèi)存0041138eh地址中嗎?
怎樣來設(shè)計這個實驗?既然指令依然存放在內(nèi)存中,本質(zhì)也是內(nèi)存中的一些數(shù)據(jù),那么我們是否可以通過內(nèi)存窗體來觀察?對于“gi=12;”,該地址的值是c7 05 40 71 41 00 0c 00 00 00。在內(nèi)存窗體的“地址”欄中輸入0x0041138E,回車,結(jié)果見圖1.14,確實該內(nèi)存段存放的是mov指令的機器碼。

圖1.14
既然指令存儲在內(nèi)存中,并且可以修改內(nèi)存中的值,那么我們是否可以修改其值,從而達到修改指令的效果?還記得剛才驗證,最后4字節(jié)代表要賦的值,那我們來看修改其內(nèi)容之后的效果,如修改為“gi = 894567;”。首先,計算894567的十六進制值。可采用Windows系統(tǒng)自帶的“計算器”來求取,其十六進制數(shù)表示為0xda667。
十六進制數(shù)、二進制數(shù)與十進制數(shù)之間的轉(zhuǎn)換
打開Windows系統(tǒng)自帶的“計算器”,選擇“查看→科學(xué)型”菜單命令(Windows 7下是“查看→程序員”),就可以選擇十進制、二進制和十六進制了。比如,在十進制下輸入12,然后選擇十六進制,就變成了c。反向轉(zhuǎn)換也如此。
如果我們要修改內(nèi)存窗體中代表mov指令的字節(jié)串“c7 05 40 71 41 00 0c 00 00 00”的后4字節(jié),應(yīng)該怎樣將0xda667填進去?是“c7 05 40 71 41 00 00 0d a6 67”嗎?(想不清楚?沒關(guān)系,實驗一下。)調(diào)試運行并在斷點“gi = 12;”處停止,修改內(nèi)存窗體,見圖1.15。再按F10鍵,單步執(zhí)行,在監(jiān)視窗體中查看gi的值,見圖1.16。gi的值變?yōu)椤?738935552”,不是12,也不是我們希望的894567。那么,說明我們修改還是有部分正確,它確實修改了mov指令所賦值,只是值不對。可將該值輸入到計算器中看看其十六進制表示,也可用一點小技巧更方便觀察其十六進制值。

圖1.15

圖1.16
十六進制顯示
在監(jiān)視窗體中用十六進制查看結(jié)果:因為調(diào)試器用十六進制數(shù)顯示指針變量值,我們只需騙騙它,將被查看的變量強制轉(zhuǎn)換為void *。比如,我們要查看變量gi,它本身是int類型,輸入“(void *) gi”,就可用十六進制查看了。
用十六進制查看gi的結(jié)果,見圖1.17。

圖1.17
前面計算出的十六進制數(shù)應(yīng)該是0xda667,與0x67a60d00有什么關(guān)聯(lián)?把“0xda667”寫為“0x000da667”,并將這兩個數(shù)按字節(jié)分開,即

我們發(fā)現(xiàn),這兩個值剛好按字節(jié)倒序排列了。(前面提到過Intel計算機是小端機。)因此,修改后的機器指令應(yīng)該是“c7 05 40 71 41 00 67 a6 0d 00”,而不是“c7 05 40 71 41 00 00 0d a6 67”。經(jīng)過這次修改且如前執(zhí)行并觀察,gi的結(jié)果真的變成了我們希望的值。
練習(xí):再定義一個全局變量,然后修改mov指令,將其地址部分修改為該變量地址,觀察執(zhí)行結(jié)果。
這樣我們就全面測試了前面關(guān)于該指令各部分含義的猜測。這是一個有意思的關(guān)于基礎(chǔ)知識學(xué)習(xí)的游戲,它的名字叫“猜測和實證”。希望大家從中感悟,找到自己的游戲方法,并運用到之后的學(xué)習(xí)中。在實證后是什么?就是“構(gòu)建”,見1.1.3節(jié)。
1.1.3 直接構(gòu)建新的賦值語句
既然我們發(fā)現(xiàn)了指令不過就是一些字節(jié)的組合,能否可以拋開C語言,自己構(gòu)造指令來執(zhí)行?我們完全可以分配一段內(nèi)存,然后將mov指令的機器碼填入。但是,如何讓CPU執(zhí)行我們的代碼?
選擇如圖1.18所示的菜單命令,可在VC 2008中激活寄存器窗體,見圖1.19,從中可以查看當前寄存器的值。
關(guān)于代碼的執(zhí)行
CPU中有一組寄存器,其中IP(Instruction Pointer)寄存器(又叫指令寄存器)與代碼執(zhí)行相關(guān),其大小為16位(bit)。CPU從該寄存器指向的內(nèi)存讀入指令,并執(zhí)行。執(zhí)行后,IP自動加上所執(zhí)行指令的長度,于是計算機就不停地重復(fù)取指和執(zhí)行過程。后來內(nèi)存變大,用16位存儲地址不夠了,于是將IP寄存器變成了32位的EIP(Extended Instruction Pointer)寄存器。總之,在Intel系統(tǒng)中,EIP寄存器指向哪里,CPU就將該地址作為將執(zhí)行指令的入口,即使錯誤地指向了數(shù)據(jù)區(qū)。
jmp指令可設(shè)定EIP寄存器,使CPU跳轉(zhuǎn)到設(shè)定地址執(zhí)行。JMP指令有多種形式,如“jmp [地址]”。

圖1.18

圖1.19
寄存器窗體:在VC中可以激活寄存器窗體,它顯示所有寄存器的當前值。如果單步執(zhí)行,寄存器的值與上次不同,將用紅色標注。
在“gi = 12;”語句的斷點處停下,查看其反匯編顯示的指令地址是否與EIP寄存器中的值相同。從圖1.20中兩個箭頭指示的地址可知,EIP的值與當前執(zhí)行的指令的地址相同,都是0041138e(十六進制數(shù),數(shù)字末尾省略了“h”)。按F10鍵,單步執(zhí)行,發(fā)現(xiàn)下一條指令的地址也是EIP寄存器中的值。

圖1.20
假定我們構(gòu)造了一段mov指令,需要用jmp語句跳轉(zhuǎn)到這段指令。這段指令執(zhí)行完畢后,EIP將指向下面的地址,即箭頭指向的內(nèi)存地址。如果不加處理,EIP所指內(nèi)存的值是不確定的。CPU就會將該不確定值解釋為對應(yīng)指令,這將導(dǎo)致不可預(yù)料的行為。因此,我們在mov指令后面應(yīng)該放一條指令,讓程序回到正常流程。jmp指令正好可達成該目的,見圖1.21。正常代碼用jmp指令跳到我們構(gòu)造的新代碼,在新代碼執(zhí)行了mov指令后,用jmp指令跳回正常代碼中jmp指令之后的代碼,即xxxx代表的代碼。

圖1.21
因為在構(gòu)造的新代碼中需要構(gòu)造jmp指令,所以先來看jmp指令的機器碼。到網(wǎng)上查找嗎?其實很多時候,使用反匯編調(diào)試,我們不需求助他人。只需構(gòu)造一段包含jmp指令的代碼,然后通過反匯編跟蹤查看機器碼即可,見DM1-3。(這就是基礎(chǔ)的力量,它可以生化萬物,支撐我們的學(xué)習(xí)探索。)
DM1-3 int i, gi; //工程見“code\第1章\asmjmp” void * address; { 1 _asm { 2 mov address, offset _lb1 3 jmp address 4 } 5 i = 2; 6 _lb1: 7 gi = 12; }
第2行將標簽_lb1即第6行語句的地址設(shè)定給變量address。(C語言中嵌入的匯編能夠識別高級語言中的符號,如變量address,這與純粹的匯編不同。)
第3行跳轉(zhuǎn)到address變量指向的地址,即執(zhí)行第6行語句。
下面來看關(guān)鍵代碼的反匯編:
jmp address 004113C8 ff 25 cc 74 41 00 jmp dword ptr ds:[004174cch]
在C語言程序中嵌入?yún)R編
嵌入?yún)R編有兩種方式:① _asm{ …. },在花括號中填寫多條匯編語句;② _asm一條匯編語句,如“_asm mov eax, 1”。例如,C語言中獲取標簽的地址值:
void * address; _lb1: gi = 12; _asm mov address, offset _lb1;
變量address中保存的是標簽_lb1所在的地址,即“gi=12;”語句的地址。
機器碼后4字節(jié)是cc 74 41 00,正好是jmp指令中地址004174cch的小端機表示。因此,我們能夠推斷出代表jmp指令的操作碼是ff 25。現(xiàn)在構(gòu)建機器碼(見DM1-4)并調(diào)用之,工程見“code\第1章\自己構(gòu)造mov和jmp代碼”。
DM1-4 void main() { 1 void * code = buildCode(); 2 _asm { 3 mov address, offset _lb1 4 } 5 gi = 12; 6 printf("gi=%d\n", gi); 7 _asm jmp code //執(zhí)行我們自己構(gòu)建的代碼 8 gi = 13; 9 _lb1: 10 printf("gi=%d\n", gi); //打印的結(jié)果為18,而不是13 11 getchar(); }
第1行,主函數(shù)調(diào)用buildCode獲取新構(gòu)建代碼的首地址。第2~4行將第10行代碼的地址賦值給address。第5~6行對gi賦值為12并打印。第7行跳轉(zhuǎn)到指針code指向的地址,即第1行分配的新代碼首址。新代碼將對gi賦值為18,并跳轉(zhuǎn)到address指向的地址,即第10行。第10行打印gi的新值18。
我們來分析buildCode函數(shù),它要構(gòu)建的匯編指令如下:
mov gi, 18; jmp address
而這兩條指令的機器碼如下:
mov指令

jmp指令

這兩條指令共16字節(jié),因此需要用malloc分配16字節(jié),然后將相關(guān)數(shù)據(jù)填入到其中,見DM1-5。

第1行,分配16字節(jié)存儲這兩條指令。第2行,pMov指向mov指令的開始。第3行,pJmp指向jmp指令頭,因為mov指令為10字節(jié),所以pJmp=code+10。第6~7行,將mov的機器碼填入,即c7 05。第8~9行,將gi地址的4字節(jié)賦值給機器碼第3字節(jié)開始。
第10行,將“18”這個要被賦值的4字節(jié)數(shù)設(shè)定給機器碼第7字節(jié)開始的內(nèi)存。第12~13行,設(shè)定jmp指令的機器碼,即ff 25。第14行,填寫address的地址,從jmp指令的第3字節(jié)開始,填寫4字節(jié)。第15行,將構(gòu)造出的代碼首址,即code,返回。
第9、10、14行填寫整數(shù)時,因為賦值指令采用小端機賦值,所以不需我們控制。
1.1.4 小結(jié)
通過本節(jié),我們了解了調(diào)試環(huán)境的反匯編、監(jiān)視窗口、內(nèi)存窗口、單步、斷點、mov指令、全局變量賦值的反匯編、大端機/小端機,對指令建立了形象的認知,并直接構(gòu)建了指令。
全局變量賦值引發(fā)的學(xué)習(xí)過程就是我們探索式學(xué)習(xí)很好的縮影,分為由淺及深的三階段:深入剖析 → 部分修改 → 全新構(gòu)建。我們要善于將自己的探索過程分解為遞進的階段,而這些階段又由諸多設(shè)問和實證組成、推進。通過這樣的鍛煉,我們能漸漸磨煉出自己的學(xué)習(xí)和探索能力。記住,設(shè)問和實證為心靈的自由插上了一對翱翔的翅膀。
- Visual C++程序設(shè)計學(xué)習(xí)筆記
- MATLAB圖像處理超級學(xué)習(xí)手冊
- Windows系統(tǒng)管理與服務(wù)配置
- Git高手之路
- 數(shù)據(jù)結(jié)構(gòu)習(xí)題精解(C語言實現(xiàn)+微課視頻)
- PLC編程及應(yīng)用實戰(zhàn)
- The DevOps 2.5 Toolkit
- 深入理解Android:Wi-Fi、NFC和GPS卷
- Extreme C
- 基于SpringBoot實現(xiàn):Java分布式中間件開發(fā)入門與實戰(zhàn)
- Scala for Machine Learning(Second Edition)
- 代替VBA!用Python輕松實現(xiàn)Excel編程
- JavaScript程序設(shè)計:基礎(chǔ)·PHP·XML
- Learning Kotlin by building Android Applications
- Daniel Arbuckle's Mastering Python