CSI-IV:程序的機器級表示-反彙編基礎


前言

       看到標題,可能你就失去了繼續閱讀的興趣,反彙編?跟我的工作有關?彙編還記的?反正寫程序用不到?的確,你可能幾年都沒碰過彙編,對於彙編指令記得更是寥寥無幾,又或者你的工作永遠也不會用到彙編了,所以很少去關注。但我想學習彙編並不是要求我們去從事跟其相關的領域,而是給了我們另一個從機器角度去看待程序的方式。這樣,當下次處理我們程序中的指針就能更加清晰和隨意,對於程序的棧幀結構和讀寫操作更加明瞭,以此爲程序的優化提供可靠的依據。或者對於類似JAVA虛擬機的內部運行機制也能夠瞭如指掌。甚至對於某些簡單漏洞攻擊或木馬病毒也能通過反彙編的方式明白其中的奧祕。當然,你也可以據此根據程序的需求,對程序進行彙編級的重構來最大限度的提高執行效率,當然這可能會犧牲程序本身的可移植性。

       在本篇中我不準備講解關於彙編語言的基礎指令,因爲對於不同機器的處理器芯片其指令系統可能是不相同的,但其原理大都相同,比如寄存器、指令的尋址方式,存取方式等等。我將通過編寫簡單的C程序並結合其彙編代碼來講解本章的內容。在本篇中,我使用MS的VS開發工具來查看相關的代碼,因此熟悉和了解X86指令系統是有必要的。下面就進入正題:

1.   程序編碼

        在第CSI-I一篇中,我們知道了程序經過編譯階段將文本文件.i編譯成.s彙編文件。這個彙編文件就是對我們程序的低層翻譯。而無論你是學習C或者JAVA,對這裏的瞭解對你會有一定的幫助。

首先我們看一段代碼和其對應的彙編指令:

Int accum = 0 ;

Int sum (intx,int y)

{

       Int t = x+y;

          Accum = t;

          Return t;

}

這段程序計算兩個數的和並返回給一個全局變量accum。下面是調試生成對應的彙編指令。我們從先從地址爲0215139E的指令往後看,首先通過mov指令取到x的值,然後通過add指令相加存放到eax寄存器,該寄存器一般用來存放函數的返回值。之後再存放到t臨時變量和全局變量的存儲區中,結果通過eax返回。

                           

接下來,我們繼續查看在該段代碼中存儲區域的值。

0x0125139E 

編碼  機器碼   指令

1:8b 45 08  mov    eax ,dword ptr[x]

2:03 45 0c  add   eax,dworkptr[y]

3:89 45 f8  mov   dwordptr[t],eax

4:8b 45 f8  mov   eax,dword ptr[t]

5:a3 38 71 25 01   mov      dword ptr[accum(1257138h)],eax

6:8b 45 f8        mov      eax,dworkptr[t]

        根據兩條指令的存儲地址,我們可以得到上一條指令的長度。從而取到指令的對應的機器指令。彙編代碼對於機器來說是透明存在的,微處理器只能夠識別彙編指令對應的機器碼,而這種從機器碼到彙編的轉變是我們爲了方便記憶和理解而做的人爲的翻譯。所以,基於這樣的想法,纔有了後來的高級語言。學過彙編語言我們知道,一條指令是由操作碼和操作數構成的,操作碼代表指令的所具有的功能,而操作數代表執行該操作所需要的數據,而一般的數據來源就是寄存器或者存儲器。比如一條mov指令可以將一個寄存器A的數據存放到寄存器B中,或者將寄存器C中的數據存放到存儲器M的位置,這中數據的不同存取方式就構成了指令系統的尋址方式。

         從上面的指令可以作出推測,1,4,6的屬於同一種尋址方式,對應的MOV操作碼爲8B,而3對應的另一種尋址方式的MOV操作碼爲89。這段指令中的dword ptr[]其實是寄存器間接尋址。可以看到在第1,2條指令中的最後一個字節08,0C是函數sum的兩個參數x,y的在棧中基於寄存器ebp的偏移地址,這個是由棧幀結構決定的,而第二個字節45分別就應該代表了寄存器eax和ebp寄存器,表示該指令所操作的寄存器。

         第5條指令佔去了5個字節,其中a3代表了指令的操作碼,後面的四個字節代表的應該是全局變量accum相對ebp的偏移地址1257138h。而指令中給出的是38 71 25 01 這是由於機器是小端存儲方式,即高位地址存儲數據高位,低位地址存儲數據低位。所以a3所代表的指令,我們也可以看做是Mov指令的一種形式。

2.棧幀結構

IA32程序用程序棧支持過程調用。機器用棧來傳遞過程參數、存儲返回信息、保存寄存器用於以後恢復,以及本地存儲。爲單個過程函數分配的棧稱爲棧幀。棧幀的最頂端以兩個指針界定,寄存器ebp爲幀指針,而寄存器esp爲棧指針。當程序執行時,棧指針可以移動因此大多數的數據訪問都是基於棧指針的。

 

從圖中可以看到,棧是向低地址方向增長的。棧寄存器%esp總是指向棧頂。

假設過程P(調用者)調用過程Q(被調用者),則Q的參數放在P的棧幀中。另外,當P調用Q時,P中的返回地址被壓入棧中,形成P的棧幀的末尾。返回地址就是當程序從Q返回是應該繼續執行的地方。Q的棧幀從保存的幀指針ebp的值開始,後面保存的是其他寄存器的值。

在瞭解程序在執行過程中棧的變化情況,我們首先了解幾個彙編指令:

1.CALL指令,將返回地址入棧,並跳轉到被調用過程的起始處。返回地址是在程序中緊跟在call指令後面的那條指令的地址。

2.RET指令,從棧中彈出返回地址,並跳轉到對應的位置。

3.LEAVE指令,這條指令使棧做好返回的準備,相當於:

Mov  ebp,esp

Pop  ebp

 

下面看下程序sum再調用過程中的棧幀結構:

首先是調用者的棧幀結構,在這裏就是Main函數對應的棧幀結構。我們看到

在地址002B1404處,使用call指令來調用sum函數,並在之前的將參數a,b都push到棧中。在調用結束後地址002B1409處,add指令用來將sum的參數從自己的棧幀中彈出。

 

         接着我們繼續跟進地址2B1055即CALL指令所調用的地址處。

在此處又用jmp指令做了一次跳轉(應該是編譯器所爲),跳轉到地址2B1380處。在該地址處我們看到的是sum的棧幀結構。

 

         看完這兩個過程的棧幀結構,我們發現其有很大的相似之處,至少在開頭的前幾條指令是這樣的。先是保存了幀寄存器的值,然後將其值設爲棧指針的值,隨後開闢了棧幀所需要的空間,然後再保存了ebx,esi,edi寄存器的值,最後執行了幾條莫名的指令纔開始了函數所要做的計算過程。其實這麼做不無道理,都是爲了遵循寄存器的使用慣例:

         根據慣例,寄存器eax,edx和ecx被劃分爲調用者保存寄存器。即這些寄存器中的內容是由調用者保存的,因此被調用者可以隨意覆蓋。而另一些寄存器,edi,esi,ebx是被調用者保存寄存器,這就要求被調用者在使用之前先將其保存在自己的棧幀中,待使用完成後再從棧中恢復。此外根據慣例被調用者也必須對ebp,esp進行保存。

         那麼在那開頭的三條push指令之後的那幾條指令是用來幹什麼的?在後面講解緩衝區溢出時我們就會明白,這幾條指令是用來防止緩衝溢出的,屬於編譯器所做的特殊處理。

在瞭解了函數的棧幀結構後,我們再接着看下對於一個遞歸函數來說,它的棧幀結構到底是怎麼樣的,我們知道遞歸函數能夠對其本身進行重複調用,但調用過程到底是如何進行的呢?下面我們再看一個例子:

int rfact(int n)

{

        int result ;

if(n<=1)

result= 1;

else

result= n*rfact(n-1);

return result;

}

對於這個計算階乘的遞歸函數,我們得到的彙編程序如下:

 

我們可以看到對於遞歸過程,其棧幀結構沒有多大的變化,只是在執行過程中能夠調用其過程本身,每個調用在棧中都有其私有的空間,因此多個未完成調用的局部變量不會互相影響。這是由於棧本省的特性所提供的,當過程調用時分配局部存儲,當返回時釋放存儲。

 

根據上面的彙編代碼我們可以試着畫出該遞歸函數的棧幀結構圖:


 

可以看到,函數在每次調用自身的過程中,需要佔用一定的棧空間,所以如果遞歸函數不能夠正常的返回而無限調用自身,會不斷的消耗進程的棧空間,最終導致棧溢出。所以在寫遞歸調用時要異常的小心,要保證其出口的正確性。

         同時另一方面,從彙編代碼中可以大致看出,遞歸調用在執行過程中,需要進行頻繁的棧操作,每次調用自身都需要使棧做好準備,包括了被調用者寄存器的內容保存,返回地址以及釋放棧時的相應動作導致了遞歸調用的效率並不是很高。

4. 異質的數據結構

C語言提供了兩種結合不同類型的對象來創建數據類型的機制:結構(struct)和聯合(union).

結構是將不同類型的對象聚合到一個對象中,結構中各個組成部分用名字來引用。類似於數組的實現,結構的所有組成部分存儲在一段連續的區域內。

聯合提供了一種方式,能夠規避C語言的類型系統,允許以多種類型來引用一個對象。其組成部分的不同字段引用的是相同的存儲塊。

對於聯合,我們很清楚的知道其存儲大小就是最大字段的大小。而對於結構來說,由於涉及到數據對齊(alignment)有時候容易搞錯其真正所佔用的存儲大小。所以下面我們重點看下結構(struct)的存儲形式。

關於數據對齊,我們需要了解其存在的意義,許多計算機系統對於基本數據類型的合法地址做出了一些限制,要求某種類型對象的地址必須是某個K值(通常是2、4或者8)的倍數。這種對齊限制簡化了形成處理器和存儲器系統的硬件設計。例如,假設一個處理器總是從存儲器中取出8個字節,則地址必須爲8的倍數。如果我們能夠保證將所有的double類型數據的地址對齊成8的倍數,那麼就可以用一個存儲器操作來讀寫值了,否則,可能需要執行兩次存儲器訪問,因爲對象可能被放在兩個8字節存儲塊中。

可見,對齊數據可以提高存儲器系統的性能。Linux沿用的對齊策略是,2字節數據類型的地址必須是2的倍數,而較大的數據類型的地址必須是4的倍數。 Microsoft 對齊的要求更加嚴格,任何K字節基本對象的地址都必須是K的倍數,這一點從VC或者VS編譯器上的struct默認所佔用的存儲大小也是可以看出的。下面我們就看幾個簡單的例子來說明struct是如何進行內存分配的:

Struct A

{

           Double  d;

           Char    c;

           Int       n;

};

我們定義一個函數print打印一個結構A的對象值,並取到對應的彙編代碼如下:

可以很容易的看到,結構A中各成員在結構中的偏移值分別爲0,8,C。所以我們可以推測出結構的存儲形式爲:


即double 類型8字節,char類型成員1字節,外加3個對齊字節,int類型成員4個字節,總共16個字節。


看到這裏,我有必要解釋下這額外的3個字節,其實根據Microsoft的對齊要求,我們稍加推測就能明白其中的緣由。任意K字節的基本對象的地址必須是K的倍數,同樣對於結構中的成員也要滿足。在這個結構中:成員d的偏移爲0是doubel(8字節)類型的字節倍數,成員c偏移爲8同樣是char(1字節)類型的字節倍數,而成員n的偏移此時如果爲9(8+1)則不是int(4字節)類型的字節倍數。所以需要對其偏移值作出調整在前面增加3個字節來滿足對齊的要求。即double 類型8字節,char類型成員1字節,外加3個對齊字節,int類型成員4個字節,總共16個字節。


如果我們對結構A進行調整如下:

Struct A2

{

Char c;

Double d;

Int   n;

}

編譯器該結構的值爲24,所以我們可以想象的到其存儲形式爲:


我們可能對結果末尾的4個字節有些困惑,按照各成員在結構中的偏移位置來說這4個字節的確顯得多餘。而其實這4個字節是用來使結構的邊界對齊,這要求結構的大小必須是其內部佔最大空間的基本對象類型所佔用字節數的整數倍。而該結構最大的基本類型爲double 佔8個字節,所以需要添加末尾的4字節來使整個結構滿足對齊的要求。 

還有一種結構,其成員包含了另一種結構,在這種情況下,其內部結構成員的偏移規定爲該內部結構中最大的基礎對象類型所佔字節數的倍數開始的。如下:

Struct B

{

           Char c;

           A2   a;

           Int   n;

};

我們知道對於A2,其最大的基礎類型爲double佔8個字節,所以該內部結構A2在B中的偏移位置應該爲8的倍數,固結構大小應該爲8+24+4=36,但其還不滿足結構B的邊界要求(必須爲8的倍數).所以需要添加額外的4字節,所以結構的總大小應該爲40字節。

到這裏,本章的介紹就算完成了,當然這並不是所有的內容,還有很多基本的內容我沒有多做介紹,特別對於彙編指令、以及控制語句的彙編級代碼沒有多做介紹,還請有興趣的讀者自行學習。至於本篇所介紹的部分,如果任何疏忽及不正之處,煩請諒解指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章