03-程序的機(jī)器級表示
在編譯結(jié)束、匯編開始之前,會生成.s程序,這個程序中存放的是代碼到匯編的匯編指令。然后再將.s文件通過匯編器生成.o二進(jìn)制文件。我們來做個實驗看看一個代碼編程匯編是什么樣子,然后二進(jìn)制.o文件通過objdump反匯編后是什么樣子(這里需要說明一下,objdump是一個反匯編工具。匯編器將匯編代碼翻譯成二進(jìn)制的機(jī)器代碼,機(jī)器代碼無法被查看,那么反匯編器就是將機(jī)器代碼翻譯成匯編代碼)
long mult2(long, long);
void mulstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}
如上實例代碼通過gcc指令編譯生成.s匯編代碼:
gcc -Og -S mstore.c
生成匯編.s代碼如下:

其中以.開頭的都是匯編鏈接相關(guān)的偽指令,我們將其忽略,剩余的代碼即為邏輯相關(guān):

pushq指令執(zhí)行的操作是,將寄存器rbx的值壓棧,并且棧頂指針由高地址向低地址偏移:

如上圖,pushq %rbx等價于:
subq $8, %rsp
movq %rbx, (%rsp)
首先給棧指針減去直接數(shù)8,即讓地址發(fā)生一個字偏移,然后將需要存放的值由rbx寄存器移動至rsp棧指針指向的區(qū)域。程序之所以將rbx寄存器的值壓棧,主要是進(jìn)行保存操作,以便于匯編代碼執(zhí)行完畢后恢復(fù)rbx的值,如上述代碼中的popq %rbx,將棧頂元素彈出。
關(guān)于匯編,需要補(bǔ)充說明一些內(nèi)容,在Inter x86-64的處理器中包含了16個通用目的的寄存器,這些寄存器用來存放整數(shù)數(shù)據(jù)和指針。分別如下:

可以看到16個寄存器名字均以%r開頭,在詳細(xì)介紹寄存器的功能之前,我們首先需搞清楚兩個概念:調(diào)用者保存寄存器和被調(diào)用者保存寄存器。
程序中經(jīng)常會有某個函數(shù)調(diào)用另一個函數(shù),那么發(fā)起調(diào)用的就是調(diào)用者,被調(diào)用的函數(shù)就是被調(diào)用者。

上圖理論上提到了有兩種保存方式,調(diào)用者保存和被調(diào)用者保存,通常由于寄存器的數(shù)量是有限的,我們在對某個函數(shù)進(jìn)行調(diào)用的時候,被調(diào)用的函數(shù)可能也會使用寄存器,因此寄存器在源調(diào)函數(shù)中的值可能會被覆蓋修改,所以我們需要保存相關(guān)值,保存方法如下:

對于具體使用調(diào)用者保存寄存器還是被調(diào)用者保存寄存器的方式,不同的寄存器有不同的措施,以下是使用調(diào)用者保存寄存器和被調(diào)用者保存寄存器策略的劃分:

實際上rbx寄存器只能使用被調(diào)用者保存寄存器的策略,回到剛才的代碼可以看到:

上面的代碼中,相對于main函數(shù)而言,mulstore函數(shù)是被調(diào)用者,當(dāng)進(jìn)入被調(diào)用者時,后續(xù)的一系列操作都將可能對rbx寄存器進(jìn)行修改,因此我們會首先將rbx寄存器的值入棧,然后再程序返回前通過popq %rbx將棧頂元素彈出至rbx寄存器中,達(dá)到恢復(fù)的目的。我們可以看到第二句執(zhí)行了movq指令,將寄存器rdx中的值存放至rbx中,那有同學(xué)就要問了,我們怎么知道我們要去操作rdx寄存器呢?為什么不能是rsi寄存器或者rax寄存器?如果有這樣的問題,說明對每個寄存器特定功能不了解,接下來我們先去了解幾個主要寄存器的功能:


可以看到rax一般用于存放要返回的值,rdi、rsi、rdx、rcx、r8、r9分別用于存放當(dāng)前函數(shù)的六個參數(shù)值,這七個寄存器均采用調(diào)用者保存寄存器策略,rsp寄存器在上面提到過,是棧頂指針寄存器。
指令通常都有后綴,如pushq中的q,代表著8字節(jié),更多匯編碼后綴如下圖所示:

繼續(xù)分析我們的代碼,根據(jù)上面不同寄存器具有不同功能得知,mulstore函數(shù)的三個參數(shù)分別保存至rdi、rsi、rdx中:

那么call則是調(diào)用mult2函數(shù),可以看到rbx寄存器外加了括號,這類似C語言中指針解引用,說明rbx存放的是地址,我們通過地址尋找到具體地址中存放的值,即代碼中dest指針指向的值,然后將rax寄存器的值移動至rbx中,rax存放著mult2函數(shù)的返回值。最終將rbx寄存器通過popq恢復(fù)。
一開始提到objdump反匯編操作,首先通過gcc -Og -c mstore.c生成mstore.o二進(jìn)制機(jī)器代碼文件,然后通過objdump -d mstore.o將二進(jìn)制機(jī)器代碼反匯編至匯編代碼,具體結(jié)果如圖,在我的CentOS 7.3中貼圖如下:


寄存器指令,大多數(shù)指令包含兩部分:操作碼和操作數(shù)。大多數(shù)指令具有一個或多個操作數(shù),ret返回指令則沒有操作數(shù)。在AT&T格式匯編中,立即數(shù)以$符號開頭,后跟一個C語言定義的整數(shù)。操作數(shù)是寄存器的情況,即使在64位的處理器上,不僅64位的寄存器可以作為操作數(shù),32位、16位甚至8位的寄存器都可以作為操作數(shù)。3寄存器帶小括號表示內(nèi)存引用。我們通常將內(nèi)存抽象成一個字節(jié)數(shù)組,當(dāng)需要從內(nèi)存中存取數(shù)據(jù)時,需要獲得目的數(shù)據(jù)的起始地址addr,以及數(shù)據(jù)長度b。為了簡便,通常會省略下標(biāo)b。

有效地址是通過立即數(shù)與基址寄存器的值相加,再加上變址寄存器與比例因子的乘積。

需要注意,比例因子s取值必須是1、2、4、8。實際上比例因子的取值是與源代碼中定義的數(shù)組類型的是相關(guān)的,編譯器會根據(jù)數(shù)組的類型來確定比例因子的數(shù)值,例如:定義char類型的數(shù)組,比例因子就是1,int類型,比例因子就是4,至于double類型比例因子就是8。
尋址方式

上圖中,RB是基址寄存器、RI是變址寄存器。
mov指令
mov含有兩個操作數(shù),一個是源操作數(shù),另一個是目的操作數(shù)。源操作數(shù)可以是立即數(shù)、寄存器、內(nèi)存引用等。目的操作數(shù)是用來存放源操作數(shù)內(nèi)容的,因此目的操作數(shù)可為寄存器或內(nèi)存引用等,目的操作數(shù)不能是立即數(shù)。我們在mov指令后經(jīng)常看到movq、movw、movb等的形式,代表移動的位數(shù),指令mov的后綴一定要和寄存器的大小進(jìn)行匹配,如果是32位寄存器,就需要movl;如果是64位寄存器,就需要movq:


需要注意,x86_64處理器有一條限制,mov指令的源操作數(shù)和目的操作數(shù)不能都是內(nèi)存的地址,當(dāng)需要將一個數(shù)從內(nèi)存的一個位置復(fù)制到另一個位 置時,需要兩條mov指令來完成;第一條指令將內(nèi)存源位置的數(shù)值加載到寄存器;第二條指令再將該寄存器的值寫入內(nèi)存的目的位置。

mov指令源操作數(shù)是立即數(shù)Imm時,立即數(shù)只能32位補(bǔ)碼,對該32位源操作數(shù)進(jìn)行符號位擴(kuò)展,傳送至64位目的位置(可以是寄存器也可以是內(nèi)存)。

如果要將64位補(bǔ)碼移動至寄存器(而不能是內(nèi)存)中,可以使用movabsq指令,該指令只能將64位補(bǔ)碼移動至寄存器中而非內(nèi)存,這里需要注意。
mov指令如何修改目的寄存器的內(nèi)容?
movabsq $0x0011223344556677, %rax,指的是將立即數(shù)存放至寄存器中,如下圖所示:

現(xiàn)在要將一個只有8位的立即數(shù)-1復(fù)制到寄存器al當(dāng)中,那么首先什么是al寄存器?其實在寄存器發(fā)展當(dāng)中,隨著寄存器位數(shù)的增加,很多寄存器的低位依然保留原來的名字,高位衍生出新的名字具體如下:

所以al寄存器實際上就是rax寄存器的低8位,那么回到剛才,將8位立即數(shù)-1復(fù)制到寄存器al中,使用movb指令:movb $-1, %al,寄存器低8位發(fā)生改變,那么為什么是全F呢,因為-1的補(bǔ)碼是全F,寄存器存放的是立即數(shù)的補(bǔ)碼:

那如果要將低16位的立即數(shù)-1復(fù)制到寄存器中,首先得復(fù)制ax寄存器,ax寄存器是rax寄存器的低16位,其次要用到命令movw,即movw $-1, %ax:

我們上面說到如果64位寄存器rax中,移入32位的立即數(shù),那么要進(jìn)行符號位擴(kuò)展,但是目前我們是將32位立即數(shù)移動至32位寄存器eax當(dāng)中,那么寄存器高4字節(jié)應(yīng)當(dāng)置0,這是x86_64處理器的規(guī)定,比如現(xiàn)在要將32位立即數(shù)-1復(fù)制到32位寄存器eax當(dāng)中,使用movl指令,movl $-1, %eax:

總結(jié)如下:

以上介紹了mov指令的位擴(kuò)展等操作,但是都基于一個前提那就是源操作數(shù)與目的操作數(shù)的數(shù)位相同。
當(dāng)源操作數(shù)位小于目的操作數(shù)的數(shù)位時,需要對目的操作數(shù)剩余字節(jié)進(jìn)行零擴(kuò)展或符號位擴(kuò)展,具體是哪種擴(kuò)展,與指令相關(guān)。零擴(kuò)展數(shù)據(jù)傳送指令有5條,指令后z是zero的縮寫;符號位擴(kuò)展傳送指令有6條,指令后s是sign的縮寫。接下來的第一個字母是源操作數(shù)大小,第二個字母表示目的操作數(shù)的大小,指令如圖所示:

可以看到符號擴(kuò)展比零擴(kuò)展多一條4字節(jié)到8字節(jié)的擴(kuò)展指令movslq,為何零擴(kuò)展無movzlq?是因為movl指令可實現(xiàn)該擴(kuò)展,即我們上面提到的,如movl $-1, %eax,當(dāng)?shù)?code>32位使用F填充后,高32位必須置0,即movl實現(xiàn)了類似于movzlq的功能,所以無需指令movzlq。同時需要說明符號位擴(kuò)展中的cltq指令,該指令的源操作數(shù)總是寄存器eax,目的操作數(shù)總是寄存器rax,cltq的效果等價于執(zhí)行了movslq %eax, %rax,即將eax高32位用符號位擴(kuò)展。
數(shù)據(jù)傳送
對一個執(zhí)行的程序而言,若要計算加法\(c = a+b\),那么需要將數(shù)據(jù)通過內(nèi)存總線和系統(tǒng)總線從內(nèi)存中寫入寄存器中,然后通過CPU內(nèi)部的邏輯運算單元ALU來計算a和b的加法,將返回值寫給rax、eax、ax、al等相關(guān)位數(shù)寄存器(具體使用哪個和數(shù)據(jù)類型字寬有關(guān)),需要再次說明,之所以ALU的結(jié)果放到rax中,因為rax這個特定寄存器的功能就是用來存放返回值的。


舉個例子,我們來看下如下代碼,分析其匯編執(zhí)行流程:

我們使用gcc -Og -S exchange.c單獨對exchange函數(shù)進(jìn)行匯編,生成匯編代碼主要指令如下:
exchange:
movq (%rdi), %rax
movq %rsi, (%rdi)
ret
根據(jù)之前的學(xué)習(xí),我們知道第一個參數(shù)存放的位置在rdi中,第二個參數(shù)存放的位置在rsi中(均為long四字類型)。所以xp指針指向的值的地址保存在rdi中,y的值存放在rsi中,函數(shù)exchange主要有三條指令實現(xiàn),包括兩條數(shù)據(jù)傳送指令和一條返回指令。


此外,還有兩個數(shù)據(jù)傳送指令需要借助程序棧,程序棧本質(zhì)上是內(nèi)存中的一個區(qū)域。棧的增長方向是從高地址向低地址,因此,棧頂?shù)脑厥撬袟V性氐刂分凶畹偷?/strong>。根據(jù)慣例,棧是倒過來畫的,棧頂在圖的底部,棧底在頂部,rsp是棧頂寄存器。

例如現(xiàn)在我們需要保存寄存器rax內(nèi)存儲的數(shù)據(jù)0x123,可以使用pushq指令把數(shù)據(jù)壓入棧內(nèi)。若要將數(shù)據(jù)彈出,則使用popq指令,這些指令只有一個操作數(shù)(壓入的數(shù)據(jù)源和彈出的數(shù)據(jù)目的)。

我們首先來看下一個入棧的操作過程:
- 首先指向棧頂?shù)募拇嫫鞯?code>rsp進(jìn)行一個減法操作,例如壓棧之前,棧頂指針
rsp指向棧頂?shù)奈恢?,此處的?nèi)存地址0x108;壓棧的第一步就是寄存器rsp的值減8,此時指向的內(nèi)存地址是0x100。

- 然后將需要保存的數(shù)據(jù)復(fù)制到新的棧頂?shù)刂?,此時,內(nèi)存地址
0x100處將保存寄存器rax內(nèi)存儲的數(shù)據(jù)0x123。實際上pushq的指令等效于subq和movq這兩條指令。它們之間的區(qū)別是在于pushq這一條指令只需要一個字節(jié),而subq和movq這兩條指令需要8個字節(jié)。所以執(zhí)行subq %rax意味著執(zhí)行了兩個操作,首先是將棧頂?shù)刂窚p8,然后再將rax寄存器存放的值存放至棧頂指針rsp指向的位置。

說到底,push指令的本質(zhì)還是將數(shù)據(jù)寫入到內(nèi)存中,那么與之對應(yīng)的pop指令就是從內(nèi)存中讀取數(shù)據(jù),并且修改棧頂指針。例如圖中這條popq指令就是將棧頂保存的數(shù)據(jù)復(fù)制到寄存器rbx中。
那么pop操作也可分解為兩部分:
- 首先從棧頂?shù)奈恢米x出數(shù)據(jù),復(fù)制到寄存器
rbx(被調(diào)用者保存寄存器)。此時,棧頂指針rsp指向的內(nèi)存地址是0x100。

- 然后將棧頂指針加
8,pop后棧頂指針rsp指向的內(nèi)存地址是0x108。

因此pop操作也可以等效movq和addq這兩條指令。實際上pop指令是通過修改棧頂指針?biāo)赶虻膬?nèi)存地址來實現(xiàn)數(shù)據(jù)刪除的,此時,內(nèi)存地址0x100內(nèi)所保存的數(shù)據(jù)0x123仍然存在,直到下次push操作,此處保存的數(shù)值才會被覆蓋。

leaq指令
加載有效地址(load effective address)指令leaq實際上是movq指令的變形,它的指令形式是從內(nèi)存讀數(shù)據(jù)到寄存器當(dāng)中,但實際根本未引用內(nèi)存,它的第一個操作數(shù)看上去是內(nèi)存引用,但該指令并不是從指定位置讀入數(shù)據(jù),而是將有效地址寫入到目的操作數(shù)。


以上為leaq指令將有效地址值寫入寄存器中的過程,即加載有效地址。leaq不僅可以加載有效地址,還可表示加法和有限乘法運算,對下述代碼進(jìn)行編譯:

通過gcc -O1 -S a.c,得到的匯編代碼指令如下:
scale:
leaq (%rdi,%rsi,4), %rax
leaq (%rdx,%rdx,2), %rdx
leaq (%rax,%rdx,4), %rax
ret
x存放在rdi寄存器中,y存放在rsi寄存器中,z存放在rdx寄存器中,那么第一條指令將rdi+4*rsi放入rax中,即表示x+4y;第二條指令表示將3z寫入rdx寄存器;第三條指令則是將rax存放的x+4y與4rdx相加結(jié)果寫入rax,此時rax存放的是x+4y+12z的結(jié)果,最終rax寄存器作為返回值返回即可。

leaq指令能執(zhí)行加法和有限的乘法,在編譯如上簡單的算術(shù)表達(dá)式時,是很有用處的。

一元和二元操作、移位操作、特殊算術(shù)操作


移位操作:


我們根據(jù)具體代碼舉例說明移位操作,示例如下:

我們分析一下這個代碼第3行,對應(yīng)的匯編指令:

上圖rdx的值為z,rax通過第一條指令,存放3z的值,然后第二條指令左移4位等同于乘以\(2^4 = 16\),所以這兩條指令最終計算的是48z的值,存放至rax寄存器中。為什么編譯器不直接使用乘法指令來實現(xiàn)這個運算呢?主要是因為乘法指令的執(zhí)行需要更長的時間,因此編譯器在生成匯編指令時,會優(yōu)先考慮更高效的方式。
一元、二元操作、移位操作指令總結(jié):

特殊算數(shù)操作指令:

條件碼
關(guān)于條件碼寄存器的各個字段,之前博客也有介紹過(Link),主要以8086寄存器為例說明。
條件碼寄存器其實也稱為標(biāo)志寄存器,其具有三種作用:
- 用來存儲相關(guān)指令的某些執(zhí)行結(jié)果;
- 用來為CPU執(zhí)行相關(guān)指令提供行為依據(jù);
- 用來控制CPU的相關(guān)工作方式。


以下是8086相關(guān)標(biāo)志位:


條件碼寄存器(狀態(tài)寄存器)的值是由ALU在執(zhí)行算術(shù)和運算指令時寫入的,下圖中的這些算術(shù)和邏輯運算指令都會改變條件碼寄存器的內(nèi)容:

對于不同的指令也定義了相應(yīng)的規(guī)則來設(shè)置條件碼寄存器。例如:
- 邏輯操作指令
xor,進(jìn)位標(biāo)志(CF)和溢出標(biāo)志(OF)會置0; - 對于
inc加一指令和dec減一指令會設(shè)置溢出標(biāo)志(OF)和零標(biāo)志(ZF),但不會改變進(jìn)位標(biāo)志(CF)。


關(guān)于條件碼使用,我們舉例看下如下代碼以及其匯編指令:

gcc -Og -S a.c得到匯編指令為:
comp:
cmpq %rsi, %rdi
sete %al
movzbl %al, %eax
ret
上面已經(jīng)介紹了cmp指令,cmp指令是根據(jù)兩個操作數(shù)的差來設(shè)置條件碼寄存器。cmp指令和減法指令sub類似,也是根據(jù)兩個操作是的差來設(shè)置條件碼,二者不同的是cmp指令只是設(shè)置條件碼寄存器,并不會更新目的寄存器的值。
在這個例子中,指令sete根據(jù)需標(biāo)志(ZF)的值對寄存器al進(jìn)行賦值,后綴e是equal的縮寫。如果零標(biāo)志等于1,指令sete將寄存器al置為1;如果零標(biāo)志等于0,指令sete將寄存器al置為0。

然后mov指令對寄存器al進(jìn)行零擴(kuò)展,最后返回判斷結(jié)果,存放至rax寄存器中。
下面看一個復(fù)雜例子,代碼如下:

轉(zhuǎn)成匯編指令后:



判斷a<b是否為真,需要首先判斷a-b的值,a-b<0設(shè)置SF=1,反之SF=0,然后判斷是否正溢出或負(fù)溢出,溢出置OF=1,反之OF=0;計算SF^OF,若結(jié)果為1,則a<b為true;反之a<b為false。所以,綜上可發(fā)現(xiàn),根據(jù)符號標(biāo)志(SF)和溢出標(biāo)志(OF)的異或結(jié)果,可以對a小于b是否為真做出判斷。更多相關(guān)set指令如下:

跳轉(zhuǎn)指令
接下來看下跳轉(zhuǎn)指令相關(guān)代碼及匯編指令:

通過cmp指令首先設(shè)置x<y對應(yīng)的標(biāo)志寄存器的符號標(biāo)志(SF)和溢出標(biāo)志(OF),然后跳轉(zhuǎn)指令進(jìn)行相應(yīng)的位運算來判斷其布爾值的真假,以此來判斷是否發(fā)生跳轉(zhuǎn)至.L2處,位運算計算方法如下圖:

只不過,相比于代碼中的x < y,匯編指令通過jge判斷x是否大于等于y,ge是greater >和equal =的縮寫。對于代碼中的if-else語句,當(dāng)滿足條件時,程序洽著一條執(zhí)行路徑執(zhí)行,當(dāng)不滿足條件時,就走另外一條路徑。這種機(jī)制比較簡單和通用,但是在現(xiàn)代處理器上,它的執(zhí)行效率可能會比較低。針對這種情況,有一種替代的策略,就是使用數(shù)據(jù)的條件轉(zhuǎn)移來代替控制的條件轉(zhuǎn)移。還是針對兩個數(shù)差的絕對值問題,給出了另外一種實現(xiàn)方式,我們既要計算y-x的值,也要計算x-y的值,分別用兩個變量來記錄結(jié)果,然后再判斷x與y的大小,根據(jù)測試情況來判斷是否更新返回值。這兩種寫法看上去差別不大,但第二種效率更高。具體如下所示:

上圖c前面這幾條指令都是普通的數(shù)據(jù)傳送和減法操作。cmovge是根據(jù)條件碼的某種組合來進(jìn)行有條件的傳送數(shù)據(jù),當(dāng)滿足規(guī)定的條件時,將寄存器rdx內(nèi)的數(shù)據(jù)復(fù)制到寄存器rax內(nèi)。在這個例子中,只有當(dāng)x大于等于y時,才會執(zhí)行這一條指令。

更多傳送指令如下所示:

為什么基于條件傳送的代碼會比基于跳轉(zhuǎn)指令的代碼效率高呢?這里涉及到現(xiàn)代處理器通過流水線來獲得高性能。當(dāng)遇到條件跳轉(zhuǎn)時,處理器會根據(jù)分支預(yù)測器來猜測每條跳轉(zhuǎn)指令是否執(zhí)行,當(dāng)發(fā)生錯誤預(yù)測時,會浪費大量的時間,導(dǎo)致程序性能嚴(yán)重下降。
循環(huán)
do-while


while

對比一下for與while的匯編代碼:

可以發(fā)現(xiàn)除了這一句跳轉(zhuǎn)指令不同,其他部分都是一致的。這兩個匯編代碼是采用-Og選項產(chǎn)生的。綜上所述,三種形式的循環(huán)語句都是通過條件測試和跳轉(zhuǎn)指令來實現(xiàn)。以上則是三種循環(huán)的示例說明。
swich語句

對于上面的代碼,匯編代碼如下:

cmpq指令設(shè)置狀態(tài)寄存器,ja指令判斷是否超過6,超過的話跳轉(zhuǎn)至default對應(yīng)的L8程序段,case0、case6可通過跳轉(zhuǎn)表訪問不同分支。代碼跳轉(zhuǎn)表聲明為一個長度為7的數(shù)組,每個元素都是一個指向代碼位置的指針,具體關(guān)系如下圖所示:



在這個例子中,程序使用跳轉(zhuǎn)表來處理多重分支,甚至當(dāng)switch有上百種情況時,雖然跳轉(zhuǎn)表的長度會增加,但是程序的執(zhí)行只需要一次跳轉(zhuǎn)也能處理復(fù)雜分支的情況,與使用一組很長的if-else相比,使用跳轉(zhuǎn)表的優(yōu)點是執(zhí)行switch語句的時間與case的數(shù)量是無關(guān)的。因此在處理多重分支的時,與一組很長的if-else相比,switch的執(zhí)行效率要高。
程序調(diào)用過程相關(guān)知識
為了方便討論,以C語言代碼函數(shù)調(diào)用為例,假設(shè)函數(shù)P調(diào)用函數(shù)Q,函數(shù)Q執(zhí)行完畢后返回函數(shù)P,這一系列操作包括圖中一個或多個機(jī)制:

C語言過程調(diào)用機(jī)制的關(guān)鍵特性在于使用棧數(shù)據(jù)結(jié)構(gòu)提供FIFO內(nèi)存管理原則,在過程P調(diào)用過程Q的例子中,可以看到當(dāng)Q在執(zhí)行時,P以及所有在向上追溯到P的調(diào)用鏈中的過程都被暫時掛起。當(dāng)Q運行時,它只需要為局部變量分配新的存儲空間,或設(shè)置到另一個過程的調(diào)用。另一方面,當(dāng)Q返回時,任何它所分配的局部存儲空間都可被釋放。因此,程序可以用棧來管理它的過程所需要的存儲空間,棧和程序寄存器存放著傳遞控制和數(shù)據(jù)、分配內(nèi)存所需要的信息。當(dāng)P調(diào)用Q時,控制和數(shù)據(jù)信息添加到棧尾。當(dāng)P返回時,這些信息會被釋放掉。

函數(shù)P調(diào)用函數(shù)Q時,會把返回地址壓入棧中,該地址指明了當(dāng)函數(shù)Q執(zhí)行結(jié)束 返回時要從函數(shù)P的哪個位置繼續(xù)執(zhí)行。這個返回地址的壓棧操作并不是由指令push來執(zhí)行的,而是由函數(shù)調(diào)用call來實現(xiàn)的。具體以multstore代碼為例我們可以查看返回地址細(xì)節(jié):


編譯并使用命令objdump進(jìn)行反匯編,查看其具體調(diào)用情況:
gcc -Og -o prog main.c multstore.c
objdump -d prog
查看部分反匯編代碼:

上圖可知中4005a9: e8 26 00 00 00 callq 4005d4 <multstore>這一行,指令call不僅要將函數(shù)multstore的第一條指令的地址寫入到程序指令寄存器rip中,以此實現(xiàn)函數(shù)調(diào)用,同時還要將返回地址壓入棧中。

當(dāng)函數(shù)multstore調(diào)用完畢后,指令ret從棧中返回地址彈出,寫入程序指令寄存器rip中:

函數(shù)返回,繼續(xù)執(zhí)行上面反匯編代碼中main函數(shù)第7行相關(guān)的操作。以上整個過程就是函數(shù)調(diào)用與返回所涉及的操作。那么函數(shù)調(diào)用的參數(shù)傳遞是如何實現(xiàn)的呢?在一開始我們知道,函數(shù)傳遞參數(shù)分別通過6個寄存器可以實現(xiàn),但是如果傳遞的參數(shù)大于6個呢?超出的參數(shù)就會通過壓棧來實現(xiàn)存儲。


以下面代碼為例,探討參數(shù)傳遞過程:

代碼中函數(shù)有8個參數(shù),包括字節(jié)數(shù)不同的整數(shù)以及不同類型的指針,參數(shù)1到參數(shù)6是通過寄存器來傳遞,參數(shù)7和參數(shù)8是通過棧來傳遞。

這里補(bǔ)充說明:
通過棧來傳遞參數(shù)時,所有數(shù)據(jù)的大小都是向8的倍數(shù)對齊,雖然變量a4只占一個字節(jié),但是仍然為其分配了8個字節(jié)的存儲空間。由于返回地址占用了棧頂?shù)奈恢?,所以這兩個參數(shù)距離棧頂指針的距離分別為8和16。


棧局部存儲:
當(dāng)代碼中對一個局部變量使用地址運算符時,我們需要在棧上為這個局部變量開辟 相應(yīng)的存儲空間,接下來我們看一個與地址運算符相關(guān)的例子。


函數(shù)caller定義了兩個局部變量arg1和arg2,函數(shù)swap的功能是交換這兩個變量的值,最后返回二者之和。我們通過分析函數(shù)caller的匯編代碼來看一下地址運算符的處理方式:

subq $16, %rsp第一條減法指令將棧頂指針減去16,它表示的含義是在棧上分配16個字節(jié)的空間。
我們再來看一個較為復(fù)雜的棧上存放局部變量的例子,代碼如下:

根據(jù)上面的C代碼,我們來畫一下這個函數(shù)的棧幀。根據(jù)變量的類型可知x1占8個字節(jié),x2占4個字節(jié),x3占兩個字節(jié),x4占一個字節(jié),因此,這四個變量在棧幀中的空間分配如圖所示。

由于上面call_proc代碼中第6行調(diào)用的函數(shù)proc需要8個參數(shù),因此參數(shù)7和參數(shù)8需要通過棧幀來傳遞。注意,傳遞的參數(shù)需要8個字節(jié)對齊,而局部變量是不需要對齊的。

關(guān)于寄存器中的局部存儲空間,其實之前已經(jīng)提到過,在說明調(diào)用者保存寄存器和被調(diào)用者保存寄存器時已經(jīng)舉例,這里不再贅述。

遞歸程序調(diào)用過程
以斐波那契數(shù)列遞歸調(diào)用代碼為例:

數(shù)組、指針內(nèi)存訪問
數(shù)組在內(nèi)存中是一段連續(xù)的空間,至于其每個單元的地址間距,與其單個元素類型字長有關(guān)。指針在內(nèi)存中的加一跳轉(zhuǎn)與指針的類型有關(guān),若指針是char類型,每次加一僅跳轉(zhuǎn)一個地址單元,若指針是int類型,則每次加一跳轉(zhuǎn)四個地址單元,如下圖:

二維數(shù)組的存放如下圖所示:

所以可以看到,數(shù)組行號不變,按行遍歷效率要高于列號不變按列去遍歷。
結(jié)構(gòu)體對齊的計算不再說明,總結(jié)就是,結(jié)構(gòu)體元素類型的排列順序會影響其最終的結(jié)果,結(jié)構(gòu)體內(nèi)存對齊的設(shè)計也是為了提升尋址效率;相比于結(jié)構(gòu)體的內(nèi)存對齊,聯(lián)合體的設(shè)計更加巧妙,并且節(jié)約空間,聯(lián)合體中多個元素共享同一塊地址空間。關(guān)于內(nèi)存對齊的細(xì)節(jié),可此參考文章:[Link]
避免棧緩沖區(qū)溢出攻擊的方法措施
緩沖區(qū)溢出攻擊的普遍發(fā)生給計算機(jī)系統(tǒng)造成了很多麻煩,現(xiàn)代編譯器和操作系統(tǒng)實現(xiàn)了很多機(jī)制來盡量避免這種攻擊,限制入侵者通過緩沖區(qū)溢出攻擊獲得系統(tǒng)控制的方式,這里有一種避免緩沖區(qū)溢出的方法:棧隨機(jī)化。以下是書中對棧隨機(jī)化的介紹,避免安全單一化,每次程序執(zhí)行前,在棧上提前分配若干字節(jié)空間,后續(xù)程序棧地址就會發(fā)生改變,以此來達(dá)到隨機(jī)化的目的,避免入侵者確定??臻g具體位置。

第二種方法則是棧破壞檢測,若棧發(fā)生"下溢",則可以在將函數(shù)的返回地址壓棧的時候,加上一個隨機(jī)產(chǎn)生的整數(shù),如果出現(xiàn)了數(shù)組越界,那么這個整數(shù)將被修改,這樣在函數(shù)返回的時候,就可以通過檢測這個整數(shù)是否被修改,來判斷是否有"下溢"發(fā)生。這個隨機(jī)的整數(shù)被稱為"canary",它的原意是金絲雀,這種鳥對危險氣體的敏感度超過人類,所以過去煤礦工人往往會帶著金絲雀下井,如果金絲雀死了,礦工便知道井下有危險氣體,需要撤離。
那怎么加上這個canary呢,只需要在gcc編譯的時候,加入"-fstack-protector"選項即可。一個函數(shù)對應(yīng)一個stack frame,每個stack frame都需要一個canary,這會消耗掉一部分的??臻g。此外,由于每次函數(shù)返回時都需要檢測canary,代碼的整執(zhí)行時間也勢必會增加。

那么以上則是對csapp中程序機(jī)器級表示的相關(guān)總結(jié)。

浙公網(wǎng)安備 33010602011771號