1. 程式人生 > >六星經典CSAPP-筆記(3)程式的機器級表示

六星經典CSAPP-筆記(3)程式的機器級表示

1.前言IA32機器碼以及彙編程式碼都與原始的C程式碼有很大不同,因為一些狀態對於C程式設計師來說是隱藏的。例如包含下一條要執行程式碼的記憶體位置的程式指標(program counter or PC)以及8個暫存器。還要注意的一點是:彙編程式碼的ATT格式Intel格式。ATT格式是GCC和objdump等工具的預設格式,在CSAPP中一律使用這種格式。而Intel格式則通常會在Intel的IA32架構文件以及微軟的Windows技術文件中碰到。兩者的主要區別有:
  • Intel格式忽略指令中暗示運算元長度的字尾,例如mov而不是ATT格式的movl
  • Intel格式忽略暫存器名稱前的%,例如esp
    而不是ATT格式的%esp
  • Intel格式用不同的方式描述記憶體位置,例如DWORD PTR [ebp+8]而不是ATT格式的8(%ebp)
  • Intel格式指令的運算元順序與ATT格式的完全相反,ATT格式總是最後一個運算元是目標,例如movl %eax, (%edx)。
此外,作為16位處理器架構的遺留產物,如今的指令依舊用word指2個位元組16位,而用double word指4個位元組。所以指令中通常使用B、W、L表示運算元是1、2、4個位元組的指令,例如資料移動指令的三個版本movb、movw、movl。這一章通過學習程式的機器級底層表示,學會閱讀底層程式碼。為什麼逆向工程很難?因為原始碼與編譯後的程式碼往往不是一一對應的。編譯器會引入原始碼中不存在的新變數,同時為了節約暫存器的使用,編譯器也經常將多個值對映到一個暫存器
。對於迴圈來說,通過觀察暫存器是如何在迴圈前初始化,在迴圈內的更新和條件檢測以及迴圈後的使用,能夠得到一些線索。2.暫存器與定址第一章的筆記中我們看到,程式執行的很大一部分時間都是在將資料挪來挪去的。所以處理器支援只使用暫存器的1、2、4個位元組,同時並且支援多種定址方式。如下圖右半邊的表格中所示,這樣我們就可以靈活地從記憶體中載入資料到暫存器,或者將暫存器中的值儲存到記憶體。
雖然看起來有些眼花繚亂,但實際上最基本的形式就是最後一種:Imm(Eb, Ei, s)=Imm+R[Eb]+R[Ei]*s (R[X]指暫存器X的值)。一共四個引數控制定址,看起來有些過於靈活,那就讓我們想象一下它的應用場景。先不考慮Imm,那麼最典型的應用就是訪問陣列中某個資料項。假如陣列為int x[4],則此時Eb就是陣列的首地址,相當於x,而Ei就是要訪問資料項的下標,而s就是陣列中資料型別的長度。例如我們要訪問x[3],那麼就相當於(x, 3, sizeof(int))=x+3*4。用C語言來寫就是*(x+3),因為C語言自動按照指標的型別長度進行移動(編譯器自動生成正確的程式碼),所以我們並不用自己計算偏移量乘以sizeof(int),但這都是後話了。那再加上Imm又能有何種應用場景,其實很簡單,就是訪問struct中
陣列中某一項
。如下圖所示,直接一條指令就能訪問到結構中的陣列中的某一項。
3.常用指令下面是一些最常見的彙編指令及其含義:
  • mov:資料移動。IA32強加了一條限制:一條移動指令的兩個運算元不能都是記憶體地址。所以從一個記憶體位置拷貝資料到另一個記憶體位置是需要兩條指令的。
  • leal:載入地址。效果就是mov Imm(%a, %b, s), %x會將%x賦值為Imm+%a+s*%b,而不是M[Imm+%a+s*%b],所以有兩個很有用的場景:1)拷貝地址。例如int *x=a彙編為mov (%eax), %edx,那麼int x=&a彙編為leal (%eax), %edx。所以leal不會真的將a的值(即(%eax))儲存到x(即%edx),而只是將a的地址(其實就是%eax)儲存到x。2)簡單算術運算。第二個很自然會想到的應用就是使用leal一條指令壓縮簡單的算術運算,例如leal 7(%edx, %edx, 4)=5x+7。
  • jmp:直接跳轉到標籤,或間接跳轉到暫存器中指定的地址。對於直接跳轉,在組合語言中通常就是符號化的標籤表示。但之後彙編器或連結器要對其進行編碼,最常見的編碼方式就是PC相對地址。即用1、2、4位元組的偏移量表示跳轉目標地址與jmp指令緊接著的下一條指令的地址,如下圖所示。但為什麼是緊接著jmp指令的下一條指令的地址而不是jmp這一條的?其實也是有歷史原因的,因為早期的處理器實現是先更新PC計數器作為第一步,然後再執行當前指令的。所以指令在執行的時候,其實PC已經指向下一條指令了,因此跳轉的偏移量也就要相對下一條指令來說了。

4.型別轉換時發生了什麼有符號轉成無符號整數時,我們期望著編譯器能將負數變成0,正數保留不變,長過最大長度的正數賦值成TMax。然而實際上相同長度的整數轉換其實只是簡單拷貝,什麼都不做。並且當同時需要長度轉換和型別轉換時,C語言首先進行長度轉換。長度轉換後兩個整數就都變成相同長度了,所以我們只需關注不同長度整數間的擴充套件和截斷是如何進行的:
  • 擴充套件:無符號進行零擴充套件,即用零填充高位。有符號進行符號擴充套件,即用最高位-符號位填充高位。
  • 截斷:簡單地扔掉高位位元組。對於小尾端來說,就是反過來,拷貝暫存器的高位如%al。

因為有符號整數在大部分機器上都是用反碼進行編碼的,對反碼進行有符號擴充套件是不會改變其值的,在第二章中有過證明。反碼就是這樣神奇!0有唯一表示,並且有符號擴充套件時值還不變!關鍵就在於:高位擴展出一個1後,-2w+2w-1=-2w-1,還是等於擴充套件前的原值。
5.邏輯運算為什麼要短路第二章筆記中曾說過位運算和邏輯運算的兩個區別,一是邏輯運算的眼中只有TRUE和FALSE,非0的不管是幾都會被看做TRUE。而第二個區別就是邏輯運算的短路效果。那為什麼邏輯運算會短路?因為邏輯運算是用jmp實現的。在組合語言中,逐一判斷條件表示式中的各個部分的真假,當某一部分判斷出結果就直接跳轉了。正因為邏輯運算是決定朝哪裡執行,而不像位運算得出一個最終結果,所以組合語言可以用跳轉實現,所以就產生了高階語言中短路的性質
6.區域性變數其實就在暫存器裡其實區域性變數是直接儲存在暫存器的,大部分情況下都會一直在暫存器中,而不會落地到記憶體。例如第7部分中的函式swap_add(),函式執行時棧幀(記憶體)實際上沒有儲存任何區域性變數。整個函式的區域性變數和邏輯都在暫存器和ALU中執行完成。在以下情況,區域性變數會被儲存在記憶體中(棧上):
  • 當沒有足夠的暫存器來儲存所有區域性變數時。畢竟暫存器只有八個。
  • 一些區域性變數是陣列或struct,因此必須通過指標訪問。
  • 當對區域性變數進行取地址&運算時,因此必須產生一個記憶體地址給它。
7.執行時的程式碼與棧下面來看一個函式呼叫的例子,深入學習程式碼底層是如何執行的。
caller()程式碼如下:
swap_add()程式碼如下:
編譯器生成的程式碼會遵守一定的規則,這樣在執行各種跳轉、函式呼叫時才不會發生資料覆蓋等問題,從而使程式正確的執行。
8.指標的本質也許之前也曾聽過,指標本質上就是一個記憶體地址。但之前沒有頓悟,現在通過研究底層知識來強化理解。從下圖可以看出,指標取值實際上是一種很自然的操作,因為大多數時候我們沒法在一個暫存器裡放下一個變量表示的全部資料,例如陣列或結構。如果暫存器能夠放下整個陣列和結構,那我們當然沒必要用指標了。所以很自然地,我們就會先載入資料的首地址的記憶體地址(就是指標!)到暫存器,然後再去訪問暫存器指向的記憶體位置。