看雪學(xué)院 4天前 本文為看雪論壇優(yōu)秀文章 看雪論壇作者ID:有毒 一、x86_64寄存器 在分析函數(shù)調(diào)用時(shí),必須要對(duì)CPU的寄存器熟悉。在所有的體系架構(gòu)中,每個(gè)寄存器都有建議的使用方法,編譯器在對(duì)代碼進(jìn)行編譯時(shí),也通常按照體系架構(gòu)建議的寄存器的使用方法進(jìn)行編譯。 在x86_64體系架構(gòu)中,總共有16個(gè)64位通用寄存器,各寄存器及用途如下所示: 對(duì)上圖中的寄存器做簡(jiǎn)單說(shuō)明:
(備注:用棧在進(jìn)行參數(shù)傳遞時(shí),即便參數(shù)<8字節(jié),也要對(duì)齊放在8字節(jié)的空間中)
二、函數(shù)調(diào)用時(shí)的棧幀 >>>> 1. 函數(shù)調(diào)用 在對(duì)子函數(shù)進(jìn)行調(diào)用時(shí),棧幀情況如下: (注意此處棧幀增長(zhǎng)方向從上到下)
以上過(guò)程由編譯器自動(dòng)完成。需要注意的是,父函數(shù)中進(jìn)行參數(shù)壓棧時(shí),順序?yàn)閺挠业阶?,但并不是固定,要看編譯器的具體實(shí)現(xiàn)(gcc使用的是從右到左)。 >>>> 2. 函數(shù)返回 函數(shù)返回時(shí),我們需要的數(shù)據(jù)是函數(shù)的返回值(%rax),然后將棧結(jié)構(gòu)恢復(fù)到函數(shù)調(diào)用之前的狀態(tài),最后跳轉(zhuǎn)到父函數(shù)的返回地址繼續(xù)執(zhí)行。需要執(zhí)行以下兩條指令:
為了便于棧幀恢復(fù),x86_64 架構(gòu)中提供了 leave 指令來(lái)實(shí)現(xiàn)上述兩條命令的功能。執(zhí)行 leave 后,前面圖中函數(shù)調(diào)用的棧幀結(jié)構(gòu)如下: 調(diào)用 leave 后,%rsp 指向返回地址;ret 指令,從棧頂彈出數(shù)據(jù),并跳轉(zhuǎn)到此數(shù)據(jù)指向的地址處。在leave 執(zhí)行后,%rsp 指向返回地址,因而 ret 的作用就是把 %rsp 上移一個(gè)位置,并跳轉(zhuǎn)到返回地址執(zhí)行。 所以,leave 指令用于恢復(fù)父函數(shù)的棧幀,ret 用于跳轉(zhuǎn)到返回地址處,leave 和ret 配合共同完成了子函數(shù)的返回。當(dāng)執(zhí)行完成 ret 后,%rsp 指向的是父棧幀的結(jié)尾處,父棧幀尾部存儲(chǔ)的調(diào)用參數(shù)由編譯器自動(dòng)釋放。 三、函數(shù)調(diào)用示例 程序源代碼如下:
上面程序生成的和子函數(shù)調(diào)用相關(guān)的匯編程序如下:
首先看 main 函數(shù)的前三條匯編語(yǔ)句:
保存父函數(shù)棧幀,之后創(chuàng)建main 函數(shù)的棧幀并且分配了48 Byte 的空間。執(zhí)行完成后,main 函數(shù)的棧幀如下圖所示: 繼續(xù)往后走,可以看到對(duì)k=i+j的處理過(guò)程:
需要注意的是,傳統(tǒng)的棧空間的利用操作是使用一個(gè)??臻g,進(jìn)行一次push操作。 但是我們的代碼里沒(méi)有這樣進(jìn)行,而是利用之前分配的48字節(jié)的空間,以空間的縮減進(jìn)行分配,本質(zhì)上是和push操作是一樣的。最終的計(jì)算結(jié)果保存在%eax中。 后續(xù)進(jìn)行add函數(shù)調(diào)用,add的返回值將會(huì)放在%eax中,當(dāng)前%eax中保存的是k值。 所以需要首先保存%eax中的值,然后在add函數(shù)中進(jìn)行調(diào)用,最后再恢復(fù)%eax的值。%eax 是 Caller Save的,所以由父函數(shù)main函數(shù)來(lái)進(jìn)行保存(movl %eax,-12(%rbp))。 再往后,開(kāi)始參數(shù)入棧,前6個(gè)參數(shù)依次保存到對(duì)應(yīng)的寄存器中,最后兩個(gè)參數(shù)從右到左壓入棧中。 進(jìn)入 add 函數(shù)后之后的操作如下:
首先創(chuàng)建新棧幀,然后進(jìn)行參數(shù)入棧。 在參數(shù)入棧時(shí),我們看到并未使用 push 之類(lèi)的指令,也沒(méi)有調(diào)整 %esp 指針的值,而是使用了 -N(%rbp) 這樣的指令來(lái)使用新的??臻g。這種使用”基地址+偏移量“ 來(lái)使用棧的方式和直接使用 %esp 指向棧頂?shù)姆绞狡鋵?shí)是一樣的。 當(dāng)add函數(shù)返回后,返回結(jié)果存儲(chǔ)在%eax 中,%rbp 和 %rsp 調(diào)整為指向 main 的棧幀,之后執(zhí)行main 函數(shù)中的如下指令:
add 函數(shù)返回時(shí),把返回值保存到了 %eax 中,使用完返回值后,會(huì)恢復(fù) caller save 寄存器 %eax的值,這時(shí)main 棧幀恢復(fù)到調(diào)用add函數(shù)之前的狀態(tài)。 需要注意的是,在調(diào)用 add 之前,main 中執(zhí)行了一條 subq 48, %rsp 的指令,這主要是因?yàn)閙ain函數(shù)并未再調(diào)用其他函數(shù),結(jié)尾處的leave、ret兩條指令直接覆蓋了%rsp的值從而回到了父棧幀中。 如果先調(diào)整 main 棧幀的 %rsp 值,之后 leave 再覆蓋 %rsp 的值,相當(dāng)于調(diào)整是多余的。因而省略main 中 add返回之后的 %rsp 的調(diào)整,而使用 leave 直接覆蓋%rsp更為合理。 之前對(duì)這一塊不是很懂,看了大佬的文章后就明白了~ x86-64 下函數(shù)調(diào)用及棧幀原理一蓑一笠一扁舟,一丈絲綸一寸鉤。 —— 題秋江獨(dú)釣圖 緣起在 C/C++ 程序中,函數(shù)調(diào)用是十分常見(jiàn)的操作。那么,這一操作的底層原理是怎樣的?編譯器幫我們做了哪些操作?CPU 中各寄存器及內(nèi)存堆棧在函數(shù)調(diào)用時(shí)是如何被使用的?棧幀的創(chuàng)建和恢復(fù)是如何完成的?針對(duì)上述問(wèn)題,本本文進(jìn)行了探索和研究。 通用寄存器使用慣例函數(shù)調(diào)用時(shí),在硬件層面我們需要關(guān)注的通常是cpu 的通用寄存器。在所有 cpu 體系架構(gòu)中,每個(gè)寄存器通常都是有建議的使用方法的,而編譯器也通常依照CPU架構(gòu)的建議來(lái)使用這些寄存器,因而我們可以認(rèn)為這些建議是強(qiáng)制性的。 對(duì)于 x86-64 架構(gòu),共有16個(gè)64位通用寄存器,各寄存器及用途如下圖所示: 從上圖中,我們可以得到如下結(jié)論:
這里還要區(qū)分一下 “Caller Save” 和 ”Callee Save” 寄存器,即寄存器的值是由”調(diào)用者保存“ 還是由 ”被調(diào)用者保存“。當(dāng)產(chǎn)生函數(shù)調(diào)用時(shí),子函數(shù)內(nèi)通常也會(huì)使用到通用寄存器,那么這些寄存器中之前保存的調(diào)用者(父函數(shù))的值就會(huì)被覆蓋。為了避免數(shù)據(jù)覆蓋而導(dǎo)致從子函數(shù)返回時(shí)寄存器中的數(shù)據(jù)不可恢復(fù),CPU 體系結(jié)構(gòu)中就規(guī)定了通用寄存器的保存方式。 如果一個(gè)寄存器被標(biāo)識(shí)為”Caller Save”, 那么在進(jìn)行子函數(shù)調(diào)用前,就需要由調(diào)用者提前保存好這些寄存器的值,保存方法通常是把寄存器的值壓入堆棧中,調(diào)用者保存完成后,在被調(diào)用者(子函數(shù))中就可以隨意覆蓋這些寄存器的值了。如果一個(gè)寄存被標(biāo)識(shí)為“Callee Save”,那么在函數(shù)調(diào)用時(shí),調(diào)用者就不必保存這些寄存器的值而直接進(jìn)行子函數(shù)調(diào)用,進(jìn)入子函數(shù)后,子函數(shù)在覆蓋這些寄存器之前,需要先保存這些寄存器的值,即這些寄存器的值是由被調(diào)用者來(lái)保存和恢復(fù)的。 函數(shù)的調(diào)用子函數(shù)調(diào)用時(shí),調(diào)用者與被調(diào)用者的棧幀結(jié)構(gòu)如下圖所示: 在子函數(shù)調(diào)用時(shí),執(zhí)行的操作有:父函數(shù)將調(diào)用參數(shù)從后向前壓棧 -> 將返回地址壓棧保存 -> 跳轉(zhuǎn)到子函數(shù)起始地址執(zhí)行 -> 子函數(shù)將父函數(shù)棧幀起始地址(%rpb) 壓棧 -> 將 %rbp 的值設(shè)置為當(dāng)前 %rsp 的值,即將 %rbp 指向子函數(shù)棧幀的起始地址。 上述過(guò)程中,保存返回地址和跳轉(zhuǎn)到子函數(shù)處執(zhí)行由 call 一條指令完成,在call 指令執(zhí)行完成時(shí),已經(jīng)進(jìn)入了子程序中,因而將上一棧幀%rbp 壓棧的操作,需要由子程序來(lái)完成。函數(shù)調(diào)用時(shí)在匯編層面的指令序列如下:
保存返回地址和保存上一棧幀的%rbp 都是為了函數(shù)返回時(shí),恢復(fù)父函數(shù)的棧幀結(jié)構(gòu)。在使用高級(jí)語(yǔ)言進(jìn)行函數(shù)調(diào)用時(shí),由編譯器自動(dòng)完成上述整個(gè)流程。對(duì)于”Caller Save” 和 “Callee Save” 寄存器的保存和恢復(fù),也都是由編譯器自動(dòng)完成的。 需要注意的是,父函數(shù)中進(jìn)行參數(shù)壓棧時(shí),順序是從后向前進(jìn)行的。但是,這一行為并不是固定的,是依賴(lài)于編譯器的具體實(shí)現(xiàn)的,在gcc 中,使用的是從后向前的壓棧方式,這種方式便于支持類(lèi)似于 printf(“%d, %d”, i, j) 這樣的使用變長(zhǎng)參數(shù)的函數(shù)調(diào)用。 函數(shù)的返回函數(shù)返回時(shí),我們只需要得到函數(shù)的返回值(保存在 %rax 中),之后就需要將棧的結(jié)構(gòu)恢復(fù)到函數(shù)調(diào)用之差的狀態(tài),并跳轉(zhuǎn)到父函數(shù)的返回地址處繼續(xù)執(zhí)行。由于函數(shù)調(diào)用時(shí)已經(jīng)保存了返回地址和父函數(shù)棧幀的起始地址,要恢復(fù)到子函數(shù)調(diào)用之前的父棧幀,我們只需要執(zhí)行以下兩條指令:
為了便于棧幀恢復(fù),x86-64 架構(gòu)中提供了 leave 指令來(lái)實(shí)現(xiàn)上述兩條命令的功能。執(zhí)行 leave 后,前面圖中函數(shù)調(diào)用的棧幀結(jié)構(gòu)如下: 可以看出,調(diào)用 leave 后,%rsp 指向的正好是返回地址,x86-64 提供的 ret 指令,其作用就是從當(dāng)前 %rsp 指向的位置(即棧頂)彈出數(shù)據(jù),并跳轉(zhuǎn)到此數(shù)據(jù)代表的地址處,在leave 執(zhí)行后,%rsp 指向的正好是返回地址,因而 ret 的作用就是把 %rsp 上移一個(gè)位置,并跳轉(zhuǎn)到返回地址執(zhí)行??梢钥闯?,leave 指令用于恢復(fù)父函數(shù)的棧幀,ret 用于跳轉(zhuǎn)到返回地址處,leave 和ret 配合共同完成了子函數(shù)的返回。當(dāng)執(zhí)行完成 ret 后,%rsp 指向的是父棧幀的結(jié)尾處,父棧幀尾部存儲(chǔ)的調(diào)用參數(shù)由編譯器自動(dòng)釋放。 函數(shù)調(diào)用示例為了更深入的了解函數(shù)調(diào)用原理,我們可以使用一個(gè)程序示例來(lái)觀察函數(shù)的調(diào)用和返回。程序如下:
在main 函數(shù)中,首先進(jìn)行了一個(gè) k=i+j 的加法,這是為了觀察 Caller Save 效果。因?yàn)榧臃〞?huì)用到 %rax,而下面 add 函數(shù)的返回值也會(huì)使用 %rax。由于 %rax 是 Caller Save 寄存器,在調(diào)用 add 子函數(shù)之前,程序應(yīng)該先保存 %rax 的值。 add 函數(shù)使用了 8 個(gè)參數(shù),這是為了觀察當(dāng)函數(shù)參數(shù)多于6個(gè)時(shí)程序的行為,前6個(gè)參數(shù)會(huì)保存到寄存器中,多于6個(gè)的參數(shù)會(huì)保存到堆棧中。但是,由于在子程序中可能會(huì)取參數(shù)的地址,而保存在寄存器中的前6個(gè)參數(shù)是沒(méi)有內(nèi)存地址的,因而我們可以猜測(cè),保存在寄存器中的前6個(gè)參數(shù),在子程序中也會(huì)被壓入到堆棧中,這樣才能取到這6個(gè)參數(shù)的內(nèi)存地址。上面程序生成的和子函數(shù)調(diào)用相關(guān)的匯編程序如下:
在匯編程序中,如果使用的是64位通用寄存器的低32位,則寄存器以 ”e“ 開(kāi)頭,比如 %eax,%ebx 等,對(duì)于 %r8-%r15,其低32 位是在64位寄存后加 “d” 來(lái)表示,比如 %r8d, %r15d。如果操作數(shù)是32 位的,則指令以 ”l“ 結(jié)尾,例如 movl $11, %esi,指令和寄存器都是32位的格式。如果操作數(shù)是64 位的,則指令以 q 結(jié)尾,例如 “movq %rsp, %rbp”。由于示例程序中的操作數(shù)全部在32位的表示范圍內(nèi),因而上面的加法和移動(dòng)指令全部是用的32位指令和操作數(shù),只有在創(chuàng)建棧幀時(shí)為了地址對(duì)齊才使用的是64位指令及操作數(shù)。 首先看 main 函數(shù)的前三條匯編語(yǔ)句:
這三條語(yǔ)句保存了父函數(shù)的棧幀(注意main函數(shù)也有父函數(shù)),之后創(chuàng)建了main 函數(shù)的棧幀并且在棧幀中分配了48Byte 的空位,這三條語(yǔ)句執(zhí)行完成后,main 函數(shù)的棧幀如下圖所示: 之后,main 函數(shù)中就進(jìn)行了 k=i+j 的加法和 add 參數(shù)的處理:
在進(jìn)行 k=i+j 加法時(shí),使用 main ??臻g的方式較為特別。并不是按照我們通常認(rèn)為的每使用一個(gè)??臻g就會(huì)進(jìn)行一次push 操作,而是使用之前預(yù)先分配的 48 個(gè)空位,并且用 -N(%rbp) 即從 %rbp 指向的位置向下計(jì)數(shù)的方式來(lái)使用空位的,本質(zhì)上這和每次進(jìn)行 push 操作是一樣的,最后計(jì)算 i+j 得到的結(jié)果 k 保存在了 %eax 中。之后就需要準(zhǔn)備調(diào)用 add 函數(shù)了。 我們知道,add 函數(shù)的返回值會(huì)保存在 %eax 中,即 %eax 一定會(huì)被子函數(shù) add 覆蓋,而現(xiàn)在 %eax 中保存的是 k 的值。在 C 程序中可以看到,在調(diào)用完成 add 后,我們又使用了 k 的值,因而在調(diào)用 add 中覆蓋%eax 之前,需要保存 %eax 值,在add 使用完%eax 后,需要恢復(fù) %eax 值(即k 的值),由于 %eax 是 Caller Save的,應(yīng)該由父函數(shù)main來(lái)保存 %eax 的值,因而上面匯編中有一句 “movl %eax, -12(%rbp)” 就是在調(diào)用 add 函數(shù)之前來(lái)保存 %eax 的值的。 對(duì)于8個(gè)參數(shù),可以看出,最后兩個(gè)參數(shù)是從后向前壓入了棧中,前6個(gè)參數(shù)全部保存到了對(duì)應(yīng)的參數(shù)寄存器中,與本文開(kāi)始描述的一致。 進(jìn)入 add 之后的操作如下:
add 中最前面兩條指令實(shí)現(xiàn)了新棧幀的創(chuàng)建。之后把在寄存器中的函數(shù)調(diào)用參數(shù)壓入了棧中。在本文前面提到過(guò),由于子程序中可能會(huì)用到參數(shù)的內(nèi)存地址,這些參數(shù)放在寄存器中是無(wú)法取地址的,這里把參數(shù)壓棧,正好印證了我們之前的猜想。 在參數(shù)壓棧時(shí),我們看到并未使用 push 之類(lèi)的指令,也沒(méi)有調(diào)整 %esp 指針的值,而是使用了 -N(%rbp) 這樣的指令來(lái)使用新的棧空間。這種使用”基地址+偏移量“ 來(lái)使用棧的方式和直接使用 %esp 指向棧頂?shù)姆绞狡鋵?shí)是一樣的。 這里有兩個(gè)和編譯器具體實(shí)現(xiàn)相關(guān)的問(wèn)題:一是上面程序中,-8(%rbp) 和 -12(%rbp) 地址并未被使用到,這兩個(gè)地址之前的地址 -4(%rbp) 和之后的 -16(%rsp) 都被使用到了,這可能是由于編譯器具體的實(shí)現(xiàn)方式來(lái)決定的。另外一個(gè)就是如下兩條指令:
先是把 %eax 的值賦值給的 -4(%rbp),之后又逆向賦值了一次,猜測(cè)可能是編譯器為了通用性才如此操作的。以上兩個(gè)問(wèn)題需要后續(xù)進(jìn)一步研究。 當(dāng)add函數(shù)返回后,返回結(jié)果會(huì)存儲(chǔ)在%eax 中,%rbp 和 %rsp 會(huì)調(diào)整為指向 main 的棧幀,之后會(huì)執(zhí)行main 函數(shù)中的如下指令:
可以看出,當(dāng) add 函數(shù)返回時(shí),把返回值保存到了 %eax 中,使用完返回值后,會(huì)恢復(fù) caller save 寄存器 %eax的值,這時(shí)main 棧幀與調(diào)用 add 之前完全一樣。 需要注意的是,在調(diào)用 add 之前,main 中執(zhí)行了一條 subq 48, %rsp 這樣的指令,原因就在于調(diào)用 add 之后,main 中并未調(diào)用其他函數(shù),而是執(zhí)行了兩條賦值語(yǔ)句后就直接從main返回了。 main 結(jié)尾處的 leave、ret 兩條指令會(huì)直接覆蓋 %rsp 的值從而回到 main 的父棧幀中。如果先調(diào)整 main 棧幀的 %rsp 值,之后 leave 再覆蓋 %rsp 的值,相當(dāng)于調(diào)整是多余的。因而省略main 中 add返回之后的 %rsp 的調(diào)整,而使用 leave 直接覆蓋%rsp更為合理。 結(jié)語(yǔ)本文從匯編層面介紹了X86-64 架構(gòu)下函數(shù)調(diào)用時(shí)棧幀的切換原理,了解這些底層細(xì)節(jié)對(duì)于理解程序的運(yùn)行情況是十分有益的。并且在當(dāng)前許多程序中,為了實(shí)現(xiàn)程序的高效運(yùn)行,都使用了匯編語(yǔ)言,在了解了函數(shù)棧幀切換原理后,對(duì)于理解這些匯編也是非常有幫助的。 在下一篇文章中,將會(huì)詳細(xì)介紹 libco 庫(kù)中用匯編語(yǔ)言實(shí)現(xiàn)的協(xié)程上下文的切換,本文可以作為理解協(xié)程上下文切換的基礎(chǔ)。 The End. 我就是我,疾馳中的企鵝。 我就是我,不一樣的焰火。 編輯于 2017-06-10 |
|
來(lái)自: 金剛光 > 《待分類(lèi)》