1. 程式人生 > >[讀書筆記]程序的機器級表示

[讀書筆記]程序的機器級表示

可能 比較 破解 ima 一段 匯編指令 禁止程序運行 cti 機制

匯編指令

x86-64 CPU有16個寄存器,每個寄存器都能存儲一個64位(即8個bytes)的值,每個寄存器的名字都以%r開頭,並且不同的寄存器有約定上的不同的用途。下圖以%rax為例,這個寄存器專門用於存放返回值,其中的“子部分”%rax、%ax、%al也能作為指令的operand(操作數),但是他們分別能表示的數據長度各不相同(比如%al代表%rax中低位的8個bits),而且必須和mov指令的最後一個字母匹配,當指令的目標操作數的bytes不滿8個bytes的時候,約定是:若目標操作數為1個byte或者2個bytes(如下圖中的%ax和%al)則就保留剩余bytes不變;而如果是4個byte(如下圖中的%eax,並且對應的指令的suffix為l,比如movl、xorl)就把高位的四個bytes置為零。寄存器也可以存放包括浮點數的任何數據類型。

技術分享圖片

匯編指令的操作數有幾種格式:

  • immediate(立即數),代表了一個常量值,ATT格式中以$開頭,比如$-577$0x1F
  • register(寄存器),代表一個寄存器中存放的內容,下圖中用R[ra]表示(將所有寄存器看作一個以寄存器標識為索引的數組R)。
  • memory(內存),有幾種不同的addressing mode(尋址模式),其中最為一般化的就是M[Imm + R[rb]+ R[ri]*s]這個形式(下表中最下面一行),其他的都是這種形式的特殊情況。M[Addr]表示內存中地址從Addr開始的跨越一個或多個bytes長度的一個值(將內存看作一個很大的元素為byte的數組M,以地址為索引)。在x86-64中,即使操作數是1個、2個或4個bytes,memory reference總是以quad word register(就是%rax什麽的)表示,比如movw %dx, (%rax)

技術分享圖片

MOV類指令就是把一個src(源)裏的數值復制到一個指定的dst(目標位置),寄存器或者某個內存地址(根據寄存器的名稱或者mov指令的最後一個字母來判斷dst(目標)中的多少個bytes會被覆蓋)。x86-64有一些規定,比如:src和dst不能同時是內存地址;movq的src只能是一個能代表32bit two‘s complement的立即數,然後被sign extend(就是用符號位(最高位)填充滿所有高位)到64位後再復制到dst;而movabsq的src可以是任意的64位立即數,而dst只能是寄存器。

MOVZ和MOVS都是把“小的src”復制到“大的dst”,MOVZ是zero-extend(高位補零),而MOVS是sign extend,其中也有一些特殊規定。這兩個指令可以很好地實現高級語言中的各種基本類型的cast(強制轉換)。

【旁註】由於歷史原因,Intel使用“word”來指16位的數據類型。像moveb,movew,movel和moveq最後一個字母表示operand(操作數)的大小,b就是一個字節,w是一個word,l是兩個word(l是long的縮寫,表示“long word”),q是四個word(quad word)。在浮點數的情況下,single precision是moves,double precision是movel(雖然和整型的movel名字相同,但是不會產生歧義,因為浮點數代碼的上下文完全不一樣)。

根據約定,我們把stack向下畫,x86-64中最低的地址算stack的頂部。%rsp寄存器中存放的是stack指針,指向棧頂元素。push和pop指令如下,push需要指定src,pop需要指定dst。

技術分享圖片

算數和邏輯指令例如:INC DADD S,DAND S,DSAL k,D(左移)等等。

另外還有一個特殊的指令:leaq S,D,其中leaq的lea是load effective address(但壓根不會去引用內存,常常用來做一些算術運算),它的第一個操作數就像MOV指令中的“memory reference”,但並不會進行“dereference”(解引用),而是就是指這個寄存器中的內容,比如leaq (%rdx), %rax只不過是把%rdx中的內容復制到%rax罷了,所以經常被用來做一些算術運算(no memory access occurs),比如leaq 7(%rdx,%rdx,4), %rax就是把%rax設為5倍的%rdx + 7。移位運算的shift ammount(第一個操作數)要麽是立即數,要麽只能是%cl這個單字節的寄存器。

x86-64還提供特殊的指令,就是能“不溢出”地計算兩個數的乘積(結果以兩個寄存器表示,%rdx中存放高位64位,%rax中存放低位64位),以及除法,如imulq Sdivq等等,這些指令都只有一個操作數,另一個參數必須事先存放在%rax中。這裏的乘法雖然也叫imulq(還有一個算術指令IMUL S,D),但只有一個operand,所以不會造成混淆。

除了常規寄存器,還有一些寄存器,它們保存的都是些1位的condition code(條件碼),這些condition code代表了最近的算術或邏輯運算造成的某些結果。最常用的condition code包括:

  • CF: Carry Flag(進位標誌)。 最近的操作使高位產生了進位,可用來檢查無符號數操作的溢出。
  • ZF: Zero Flag(零標誌)。最近的操作得出的結果為0。
  • SF: Sign Flag(符號標誌)。最近的操作得到的結果為負數。
  • OF: Overflow Flag(溢出標誌)。最近的操作造成了有符號數的溢出。

所有的算術和邏輯指令都會影響condition code。另外,CMP指令和SUB類似,但是不會改變dst中的值,只會改變上面這些condition code。同理,TEST和AND類似,而且經常用在“和自己與”,因為和自己與還是自己,所以可以知道某個數是負數還是零。

SET指令是根據condition code的某種組合,然後把某個byte置為0或1(必定存在一種組合可以確定兩個數的大小關系,所以SET指令可以理解為高級語言中的比較符號(大於、小於號什麽的)),比如sete(e代表equal,也可以叫setz)指令。SET指令經常跟在CMP後面使用,從而確定兩個數的大小關系。

這裏重要的啟示是,機器代碼並不知道某個值是個signed還是unsigned,還得靠人來通過不同的指令來“告訴”計算機,比如setl是“signed <”(告訴計算機這裏是個有符號數),而setb是“unsigned <”(告訴計算機這裏是個無符號數)

跳轉指令和SET指令類似,也是根據某些condition code的組合來決定是否跳轉,比如je Label就是如果ZF等於1就跳轉到Label,否則繼續順序執行。jmp是無條件跳轉,除了jmp到某個label,還可以用jmp *%rax表示以%rax中的內容為jump target(跳轉目標,即目標指令的地址),也可以jpm *(%rax),表示jump target為這個寄存器中的地址的內存位置中的內容(星號後面跟的是一個內存位置)。

在匯編代碼中,跳轉目標都是用L1或者L2這樣的標簽來表示。 assembler和linker都會以某種編碼生成合適的代碼來替換這些標簽。有幾種不同的編碼,但最常用的叫做PC relative,也就是算出當前指令(跳轉指令的下一條指令)的地址和跳轉目標的差,作為跳轉指令的操作數,然後等到CPU要執行的時候就會根據當前地址(program counter)和這個差算出jump target,這樣就相當於是指令間的地址都是相對的(有正有負),不管動態加載到內存時候的具體地址是多少,都不需要改變跳轉指令的jump target。

conditional move指令就是根據某個條件判斷要不要“move”的mov指令,有時候可以用它來優化if分支,因為CPU會對指令進行pipelining(流水線處理),即使有jump,CPU也會猜一下然後繼續不斷裝載指令,但萬一猜錯了if分支,那麽已經裝載的指令都要全部扔掉然後去裝載另一個分支的指令,而用了conditional move指令優化過的代碼則不會有這種風險,因為它會事先把兩種可能的結果都算出來,然後根據條件,只取其中一個結果就是了。很明顯,這種優化是有風險的,比如當算出任何一種結果有副作用或是很昂貴的時候。編譯器必須自己權衡並作出決定。

while、for和switch都是用條件跳轉指令來實現的。值得註意的是,switch的匯編實現可以利用一種叫jump table的數據結構來優化,這個jump table就是一個數組,裏面的元素對應每個“case代碼塊”的起始地址,這種優化方法有點類似於計數排序,條件是“各個case的數”分布必須在一定範圍內。所以只要根據“switch數”算出在jump table中對應的索引,然後就能直接得到jump target了(所以也就不必一次一次比較了)。

【旁註】匯編代碼有兩種格式: ATT格式(以AT&T公司命名)和Intel格式。以上的內容都是ATT格式。相比ATT格式,Intel格式的不同在於:

  • 省略了size designation suffix(指令名中用於指定數據長度的後綴),比如用mov代替movq。
  • 省略了寄存器名字前面的%符號,比如用rbx代替%rbx。
  • 用不同的格式來描述內存中的位置,比如用QWORD PTR [rbx],而不是(%rbx)。
  • 和ATT format有著相反的順序,即:move a b表示”把b move 到a“。

Procedures(過程)

call指令類似jmp指令,可以是:call Label,也可以是:call *Operand,這條指令會把下一條指令的地址push到棧中,然後把PC設為call指令的operand代表的地址。ret指令從棧中pop出一個地址然後將PC設為此地址。call和ret指令完成了過程的調用和返回。

下圖是P過程調用Q過程的示意圖:

技術分享圖片

每一個過程在棧中都有一塊屬於自己的區域,叫stack frame(棧幀),註意棧是“向下”畫的。圖中每個stack frame的各個區域不是必須的,而是只有當需要時才會分配,當一個過程中的所有的局部變量用寄存器保存就足夠了,並且不會再調用其他函數時,那麽其實它壓根就不需要stack frame。x86-64中,傳遞參數一般通過寄存器就足夠,但如果參數大於6個,就只能依賴於棧,上圖中P中的argument 7至argument n(以及Q中的Argument build area)就是用於分配第7至第n個參數的地方,Q可以通過stack棧頂指針加上一定的偏移量來訪問這些參數。Local variables的分配也同理,但是Q只能訪問P的argument build area和自己的local variables區域。在一個過程開始的時候,先讓棧頂指針向棧頂移動一定長度,即分配第7至n個參數以及local variables,但是在return之前,為了回收這些分配的空間,還必須讓棧頂指針向相反的方向移動同樣的長度,這樣以後再執行ret就可以保證pop出來的是正確的返回地址。所以,過程調用的匯編代碼常常是將這些局部變量需要在stack上分配和回收的長度“寫死”在代碼中。

另外,關於寄存器還有一些約定。某些寄存器不能被callee(被調用者)改變,這些寄存器叫作callee-saved registers(由被調用者“保證”它們的值不變)。也就是說,當P調用Q時,可以放心地把某些變量存到callee-saved registers中,而不用擔心Q會改變這些寄存器中的內容。當然,Q可以先把這些寄存器中的內容push到stack上,然後隨便用這些寄存器,只要在返回之前,從stack上把原來的值pop回相應的callee-saved register中就行。P自己本身就很可能也是一個callee,所以在使用callee-saved registers存放變量前,也會先在stack上保存其中原來的值。另一類寄存器叫作caller-saved registers,它們可以被任何函數改變,所以當P調用Q之前,必須把用到的caller-saved registers中的內容先保存到stack(圖中的saved registers區域)或callee-saved registers中,然後才能放心地去調用Q。

這樣的利用stack和寄存器約定的過程調用機制,也能很自然地支持函數的遞歸調用,和調用其他函數並沒有什麽區別。

數組的分配和訪問

數組可以理解為內存中的一塊L*N字節的連續區域,這裏的L為數組元素類型的大小,N為數組長度。而數組A(假設數組叫A)就代表指向數組第一個元素的指針,所以,數組中索引為i的個元素(在C中用A[i]表示)就存放在地址為A + L*i的地方。在C中,為了方便,p+i這個表達式的實際代表的值是xp + L*i,p是一個T類型的指針,i是一個整數,xp是p的值,L是T類型的大小。

假設有一個多維數組int A[5][3];,這裏的A是一個長度為5的數組,數組的元素類型為“長度為3的整型數組”,它在內存中的存儲順序是這樣的:

技術分享圖片

另外,關於多維數組中元素的訪問,編譯器可以做出一些優化,以簡化對數組元素的地址的計算。

Structure和Union

假設有一個struct聲明:

struct rec {
    int i;
    int j;
    int a[2];
    int *p;
};

那麽這樣的一個struct對象在內存中就是這樣的:

技術分享圖片

類似數組,如果想訪問struct對象中的某個字段,只要給這個“struct指針”(指向此struct開頭)加上對應的offset(偏移量)即可,比如:假設變量r的類型為struct rec *,它的值為pr ,那麽r->j的地址就為pr + 4,如果寫成匯編就是:movl 4(%rdi), %eax(r在%rdi中,指令將r->j放在%eax中),所以說,這個偏移量完全是在編譯時就已經決定的,然後“寫死”在匯編代碼中,而機器代碼對於字段聲明或是它們的類型一無所知。

union是C的另一個特性,主要用於某個對象有一些“互斥”的字段,然後可以節省空間,所以一個union的總的占用空間是其所有字段中所占空間最大的字段所占的空間。

數據對齊

為了簡化硬件上的設計,通常有這樣一個限制:CPU每一次的操作總是從內存中的一個地址為k的倍數的位置取得k個bytes。所以,如果我們能保證所有的原始類型(比如char,int這些)的數據的地址都是k的倍數,那麽每一次只需要一次CPU操作就能得到這個數據的全部。所以,Intel推薦我們對數據進行對齊,從而提高性能(雖然在大多數情況下,即使不做對齊也能正常工作)。這個對齊的規定是:任何k個bytes的原始類型的數據的地址都必須是k的倍數,比如int類型的數據的地址就應是4的倍數(另外,某些Intel和AMD處理器也規定大多數的函數的stack frame上的數據的地址需要是16的倍數 )。為了達到這個規定,通常會有一些空間上的浪費,比如這樣一個struct:

struct S1 {
    int i;
    char c;
    int j;
};

為了達到上述規定,可以在第二個字段c的後面補齊3個bytes(從而使j的地址滿足要求),看起來就像這樣:

技術分享圖片

指令.align 8就表示:接下來的數據的保證會從一個8的倍數的地址開始。

Buffer Overflow攻擊

由於C不會對數組的索引做檢查,所以完全可以使用超過數組長度的索引值來訪問那些不屬於數組的內存空間,比如修改saved registers,或修改函數的返回地址等等。Buffer overflow攻擊簡單來說就是:某個函數接受一個用戶輸入的字符串,放進一個預先分配好的char數組中,但沒有對用戶輸入的長度做任何檢查,所以一旦超過了預先分配的長度,就會造成stack狀態被破壞(比如覆蓋返回地址,讓程序跳轉到一段惡意代碼)。

通過編譯器防禦buffer overflow攻擊的手段包括:

  • 即使是同一個程序,每次運行它的時候,都先在stack上隨機分配一定的空間(不能太小,否則很容易被暴力破解),這段空間沒什麽用,只是為了讓每次的stack pointer都不那麽一樣,所以攻擊者沒那麽容易猜到惡意代碼實際所在的地址。
  • 在local buffer分配之前,先插入一段“guard value”(一個隨機值),在恢復saved registers和跳轉到返回地址之前,先比較這個guard value是否被改變了,如果被改變了就報錯。
  • 可以指定內存中的哪些部分是可執行的代碼,從而禁止程序運行某些非編譯器生成的代碼。

但最好的習慣還是應該在代碼中對任何用戶輸入進行校驗。

浮點數代碼

到現在為止所介紹的指令其實都是用於整數的,有一套專門用於浮點數的操作和運算的指令和寄存器,類似已經介紹過的那些用於整數的指令,包括傳送指令(類似MOV)、用於類型轉換的指令、用於算術運算或位運算(通常可以用於實現“絕對值”、“相反數”這些)的指令、用於比較的指令。由於立即數只能是整數,所以代碼中的“浮點數常量”在匯編代碼中都會被轉化為內存中的值。

[讀書筆記]程序的機器級表示