X86-64寄存器和栈帧--牛掰降解汇编函数寄存器相关操作
X86-64寄存器和棧幀
概要
說到x86-64,總不免要說說AMD的牛逼,x86-64是x86系列中集大成者,繼承了向后兼容的優(yōu)良傳統(tǒng),最早由AMD公司提出,代號AMD64;正是由于能向后兼容,AMD公司打了一場漂亮翻身戰(zhàn)。導(dǎo)致Intel不得不轉(zhuǎn)而生產(chǎn)兼容AMD64的CPU。這是IT行業(yè)以弱勝強(qiáng)的經(jīng)典戰(zhàn)役。不過,大家為了名稱延續(xù)性,更習(xí)慣稱這種系統(tǒng)結(jié)構(gòu)為x86-64。
X86-64在向后兼容的同時,更主要的是注入了全新的特性,特別的:x86-64有兩種工作模式,32位OS既可以跑在傳統(tǒng)模式中,把CPU當(dāng)成i386來用;又可以跑在64位的兼容模式中,更加神奇的是,可以在32位的OS上跑64位的應(yīng)用程序。有這種好事,用戶肯定買賬啦。
值得一提的是,X86-64開創(chuàng)了編譯器的新紀(jì)元,在之前的時代里,Intel CPU的晶體管數(shù)量一直以摩爾定律在指數(shù)發(fā)展,各種新奇功能層出不窮,比如:條件數(shù)據(jù)傳送指令cmovg,SSE指令等。但是GCC只能保守地假設(shè)目標(biāo)機(jī)器的CPU是1985年的i386,額。。。這樣編譯出來的代碼效率可想而知,雖然GCC額外提供了大量優(yōu)化選項,但是這對應(yīng)用程序開發(fā)者提出了很高的要求,會者寥寥。X86-64的出現(xiàn),給GCC提供了一個絕好的機(jī)會,在新的x86-64機(jī)器上,放棄保守的假設(shè),進(jìn)而充分利用x86-64的各種特性,比如:在過程調(diào)用中,通過寄存器來傳遞參數(shù),而不是傳統(tǒng)的堆棧。又如:盡量使用條件傳送指令,而不是控制跳轉(zhuǎn)指令。
寄存器簡介
先明確一點,本文關(guān)注的是通用寄存器(后簡稱寄存器)。既然是通用的,使用并沒有限制;后面介紹寄存器使用規(guī)則或者慣例,只是GCC(G++)遵守的規(guī)則。因為我們想對GCC編譯的C(C++)程序進(jìn)行分析,所以了解這些規(guī)則就很有幫助。
在體系結(jié)構(gòu)教科書中,寄存器通常被說成寄存器文件,其實就是CPU上的一塊存儲區(qū)域,不過更喜歡使用標(biāo)識符來表示,而不是地址而已。
X86-64中,所有寄存器都是64位,相對32位的x86來說,標(biāo)識符發(fā)生了變化,比如:從原來的%ebp變成了%rbp。為了向后兼容性,%ebp依然可以使用,不過指向了%rbp的低32位。
X86-64寄存器的變化,不僅體現(xiàn)在位數(shù)上,更加體現(xiàn)在寄存器數(shù)量上。新增加寄存器%r8到%r15。加上x86的原有8個,一共16個寄存器。
剛剛說到,寄存器集成在CPU上,存取速度比存儲器快好幾個數(shù)量級,寄存器多了,GCC就可以更多的使用寄存器,替換之前的存儲器堆棧使用,從而大大提升性能。
讓寄存器為己所用,就得了解它們的用途,這些用途都涉及函數(shù)調(diào)用,X86-64有16個64位寄存器,分別是:
%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。
其中:
- %rax 作為函數(shù)返回值使用。
- %rsp 棧指針寄存器,指向棧頂
- %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函數(shù)參數(shù),依次對應(yīng)第1參數(shù),第2參數(shù)。。。
- %rbx,%rbp,%r12,%r13,%14,%15 用作數(shù)據(jù)存儲,遵循被調(diào)用者使用規(guī)則,簡單說就是隨便用,調(diào)用子函數(shù)之前要備份它,以防他被修改
- %r10,%r11 用作數(shù)據(jù)存儲,遵循調(diào)用者使用規(guī)則,簡單說就是使用之前要先保存原值
?
棧幀
棧幀結(jié)構(gòu)
? ? ? ? C語言屬于面向過程語言,他最大特點就是把一個程序分解成若干過程(函數(shù)),比如:入口函數(shù)是main,然后調(diào)用各個子函數(shù)。在對應(yīng)機(jī)器語言中,GCC把過程轉(zhuǎn)化成棧幀(frame),簡單的說,每個棧幀對應(yīng)一個過程。X86-32典型棧幀結(jié)構(gòu)中,由%ebp指向棧幀開始,%esp指向棧頂。函數(shù)進(jìn)入和返回
函數(shù)的進(jìn)入和退出,通過指令call和ret來完成,給一個例子
?
#include
#include </code>
?
int foo ( int x )
{
? ? int array[] = {1,3,5};
? ? return array[x];
}??????/* -----? end of function foo? ----- */
?
int main ( int argc, char *argv[] )
{
? ? int i = 1;
? ? int j = foo(i);
? ? fprintf(stdout, "i=%d,j=%d\n", i, j);
? ? return EXIT_SUCCESS;
}?????????????? /* ----------? end of function main? ---------- */
命令行中調(diào)用gcc,生成匯編語言:
?
Shell > gcc –S –o test.s test.c
?Main函數(shù)第40行的指令Callfoo其實干了兩件事情:
- Pushl %rip //保存下一條指令(第41行的代碼地址)的地址,用于函數(shù)返回繼續(xù)執(zhí)行
- Jmp foo //跳轉(zhuǎn)到函數(shù)foo
Foo函數(shù)第19行的指令ret 相當(dāng)于:
- popl %rip //恢復(fù)指令指針寄存器
棧幀的建立和撤銷
還是上一個例子,看看棧幀如何建立和撤銷。
說題外話,以”點”做為前綴的指令都是用來指導(dǎo)匯編器的命令。無意于程序理解,統(tǒng)統(tǒng)忽視之,比如第31行。
棧幀中,最重要的是幀指針%ebp和棧指針%esp,有了這兩個指針,我們就可以刻畫一個完整的棧幀。
函數(shù)main的第30~32行,描述了如何保存上一個棧幀的幀指針,并設(shè)置當(dāng)前的指針。
第49行的leave指令相當(dāng)于:
?
Movq %rbp %rsp //撤銷棧空間,回滾%rsp。
Popq %rbp //恢復(fù)上一個棧幀的%rbp。
?
同一件事情會有很多的做法,GCC會綜合考慮,并作出選擇。選擇leave指令,極有可能因為該指令需要存儲空間少,需要時鐘周期也少。
你會發(fā)現(xiàn),在所有的函數(shù)中,幾乎都是同樣的套路,我們通過gdb觀察一下進(jìn)入foo函數(shù)之前main的棧幀,進(jìn)入foo函數(shù)的棧幀,退出foo的棧幀情況。
?
Shell> gcc -g -o testtest.c
Shell> gdb --args test
Gdb > break main
Gdb > run
?
進(jìn)入foo函數(shù)之前:
?
你會發(fā)現(xiàn)rbp-rsp=0×20,這個是由代碼第11行造成的。
進(jìn)入foo函數(shù)的棧幀:
?
回到main函數(shù)的棧幀,rbp和rsp恢復(fù)成進(jìn)入foo之前的狀態(tài),就好像什么都沒發(fā)生一樣。
可有可無的幀指針
你剛剛搞清楚幀指針,是不是很期待要馬上派上用場,這樣你可能要大失所望,因為大部分的程序,都加了優(yōu)化編譯選項:-O2,這幾乎是普遍的選擇。在這種優(yōu)化級別,甚至更低的優(yōu)化級別-O1,都已經(jīng)去除了幀指針,也就是%ebp中再也不是保存幀指針,而且另作他途。
在x86-32時代,當(dāng)前棧幀總是從保存%ebp開始,空間由運行時決定,通過不斷push和pop改變當(dāng)前棧幀空間;x86-64開始,GCC有了新的選擇,優(yōu)化編譯選項-O1,可以讓GCC不再使用棧幀指針,下面引用 gcc manual 一段話 :
?
-O also turns on -fomit-frame-pointer on machines where doing so does not interfere with debugging.
?
這樣一來,所有空間在函數(shù)開始處就預(yù)分配好,不需要棧幀指針;通過%rsp的偏移就可以訪問所有的局部變量。說了這么多,還是看看例子吧。同一個例子, 加上-O1選項:
?
Shell>: gcc –O1 –S –o test.s test.c
?
分析main函數(shù),GCC分析發(fā)現(xiàn)棧幀只需要8個字節(jié),于是進(jìn)入main之后第一條指令就分配了空間(第23行):
Subq $8, %rsp
然后在返回上一棧幀之前,回收了空間(第34行):
Addq $8, %rsp
等等,為啥main函數(shù)中并沒有對分配空間的引用呢?這是因為GCC考慮到棧幀對齊需求,故意做出的安排。再來看foo函數(shù),這里你可以看到%rsp是如何引用棧空間的。等等,不是需要先預(yù)分配空間嗎?這里為啥沒有預(yù)分配,直接引用棧頂之外的地址?這就要涉及x86-64引入的牛逼特性了。
?
訪問棧頂之外
通過readelf查看可執(zhí)行程序的header信息:
?
紅色區(qū)域部分指出了x86-64遵循ABI規(guī)則的版本,它定義了一些規(guī)范,遵循ABI的具體實現(xiàn)應(yīng)該滿足這些規(guī)范,其中,他就規(guī)定了程序可以使用棧頂之外128字節(jié)的地址。
這說起來很簡單,具體實現(xiàn)可有大學(xué)問,這超出了本文的范圍,具體大家參考虛擬存儲器。別的不提,接著上例,我們發(fā)現(xiàn)GCC利用了這個特性,干脆就不給foo函數(shù)分配棧幀空間了,而是直接使用棧幀之外的空間。@恨少說這就相當(dāng)于內(nèi)聯(lián)函數(shù)唄,我要說:這就是編譯優(yōu)化的力量。
寄存器保存慣例
過程調(diào)用中,調(diào)用者棧幀需要寄存器暫存數(shù)據(jù),被調(diào)用者棧幀也需要寄存器暫存數(shù)據(jù)。如果調(diào)用者使用了%rbx,那被調(diào)用者就需要在使用之前把%rbx保存起來,然后在返回調(diào)用者棧幀之前,恢復(fù)%rbx。遵循該使用規(guī)則的寄存器就是被調(diào)用者保存寄存器,對于調(diào)用者來說,%rbx就是非易失的。
反過來,調(diào)用者使用%r10存儲局部變量,為了能在子函數(shù)調(diào)用后還能使用%r10,調(diào)用者把%r10先保存起來,然后在子函數(shù)返回之后,再恢復(fù)%r10。遵循該使用規(guī)則的寄存器就是調(diào)用者保存寄存器,對于調(diào)用者來說,%r10就是易失的,舉個例子:
#include <stdio.h>
#include <stdlib.h>
?
void sfact_helper ( long int x, long int * resultp)
{
??? if (x<=1)
???????*resultp = 1;
??? else {
???????long int nresult;
???????sfact_helper(x-1,&nresult);
???????*resultp = x * nresult;
??? }
}??????/* -----? end of function foo? ----- */
?
long int
sfact ( long int x )
{
??? long int result;
???sfact_helper(x, &result);
??? return result;
}??????/* -----? end of function sfact? ----- */
?
int
main ( int argc, char *argv[] )
{
??? int sum = sfact(10);
???fprintf(stdout, "sum=%d\n", sum);
??? return EXIT_SUCCESS;
}?????????????? /* ----------? end of function main? ---------- */
?
命令行中調(diào)用gcc,生成匯編語言:
?
Shell>: gcc –O1 –S –o test2.s test2.c
?
在函數(shù)sfact_helper中,用到了寄存器%rbx和%rbp,在覆蓋之前,GCC選擇了先保存他們的值,代碼6~9說明該行為。在函數(shù)返回之前,GCC依次恢復(fù)了他們,就如代碼27-28展示的那樣。
看這段代碼你可能會困惑?為什么%rbx在函數(shù)進(jìn)入的時候,指向的是-16(%rsp),而在退出的時候,變成了32(%rsp) 。上文不是介紹過一個重要的特性嗎?訪問棧幀之外的空間,這是GCC不用先分配空間再使用;而是先使用棧空間,然后在適當(dāng)?shù)臅r機(jī)分配。第11行代碼展示了空間分配,之后棧指針發(fā)生變化,所以同一個地址的引用偏移也相應(yīng)做出調(diào)整。
X86時代,參數(shù)傳遞是通過入棧實現(xiàn)的,相對CPU來說,存儲器訪問太慢;這樣函數(shù)調(diào)用的效率就不高,在x86-64時代,寄存器數(shù)量多了,GCC就可以利用多達(dá)6個寄存器來存儲參數(shù),多于6個的參數(shù),依然還是通過入棧實現(xiàn)。了解這些對我們寫代碼很有幫助,起碼有兩點啟示:
- 盡量使用6個以下的參數(shù)列表,不要讓GCC為難啊。
- 傳遞大對象,盡量使用指針或者引用,鑒于寄存器只有64位,而且只能存儲整形數(shù)值,寄存器存不下大對象
讓我們具體看看參數(shù)是如何傳遞的:
?
#include <stdio.h>
#include <stdlib.h>
?
int foo ( int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7 )
{
??? int array[] = {100,200,300,400,500,600,700};
??? int sum = array[arg1]+ array[arg7];
??? return sum;
}??????/* -----? end of function foo? ----- */
?
??? int
main ( int argc, char *argv[] )
{
??? int i = 1;
??? int j = foo(0,1,2, 3, 4, 5,6);
???fprintf(stdout, "i=%d,j=%d\n", i, j);
??? return EXIT_SUCCESS;
} ??????????????/* ----------? end of function main? ---------- */
?
?
命令行中調(diào)用gcc,生成匯編語言:
?
Shell>: gcc –O1 –S –o test1.s test1.c
?
?
Main函數(shù)中,代碼31~37準(zhǔn)備函數(shù)foo的參數(shù),從參數(shù)7開始,存儲在棧上,%rsp指向的位置;參數(shù)6存儲在寄存器%r9d;參數(shù)5存儲在寄存器%r8d;參數(shù)4對應(yīng)于%ecx;參數(shù)3對應(yīng)于%edx;參數(shù)2對應(yīng)于%esi;參數(shù)1對應(yīng)于%edi。
Foo函數(shù)中,代碼14-15,分別取出參數(shù)7和參數(shù)1,參與運算。這里數(shù)組引用,用到了最經(jīng)典的尋址方式,-40(%rsp,%rdi,4)=%rsp + %rdi *4 + (-40);其中%rsp用作數(shù)組基地址;%rdi用作了數(shù)組的下標(biāo);數(shù)字4表示sizeof(int)=4。
結(jié)構(gòu)體傳參
應(yīng)@桂南要求,再加一節(jié),相信大家也很想知道結(jié)構(gòu)體是如何存儲,如何引用的,如果作為參數(shù),會如何傳遞,如果作為返回值,又會如何返回。
看下面的例子:
?
#include <stdio.h>
#include <stdlib.h>
?
struct demo_s {
??? char var8;
??? int? var32;
??? long var64;
};
?
struct demo_s foo (struct demo_s d)
{
??? d.var8=8;
??? d.var32=32;
??? d.var64=64;
??? return d;
}??????/* -----? end of function foo? ----- */
?
??? int
main ( int argc, char *argv[] )
{
??? struct demo_s d, result;
???result = foo (d);
???fprintf(stdout, "demo: %d, %d, %ld\n", result.var8,result.var32, result.var64);
??? return EXIT_SUCCESS;
}?????????????? /* ----------? end of function main? ---------- */
?
我們?nèi)笔【幾g選項,加了優(yōu)化編譯的選項可以留給大家思考。
?
?
Shell>gcc ?-S -o test.s test.c
?
上面的代碼加了一些注釋,方便大家理解,
問題1:結(jié)構(gòu)體如何傳遞?它被分成了兩個部分,var8和var32合并成8個字節(jié)的大小,放在寄存器%rdi中,var64放在寄存器的%rsi中。也就是結(jié)構(gòu)體分解了。
問題2:結(jié)構(gòu)體如何存儲? 注意看foo函數(shù)的第15~17行注意到,結(jié)構(gòu)體的引用變成了一個偏移量訪問。這和數(shù)組很像,只不過他的元素大小可變。
問題3:結(jié)構(gòu)體如何返回,原本%rax充當(dāng)了返回值的角色,現(xiàn)在添加了返回值2:%rdx。同樣,GCC用兩個寄存器來表示結(jié)構(gòu)體。
恩, 即使在缺省情況下,GCC依然是想盡辦法使用寄存器。隨著結(jié)構(gòu)變的越來越大,寄存器不夠用了,那就只能使用棧了。
總結(jié)
了解寄存器和棧幀的關(guān)系,對于gdb調(diào)試很有幫助;過些日子,一定找個合適的例子和大家分享一下。
參考
1. 深入理解計算機(jī)體系結(jié)構(gòu)
2. x86系列匯編語言程序設(shè)計
總結(jié)
以上是生活随笔為你收集整理的X86-64寄存器和栈帧--牛掰降解汇编函数寄存器相关操作的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 在Cisco交换机上实现隔离访问
- 下一篇: Seliux简介