X86-64寄存器和棧幀--牛掰降解匯編函數寄存器相關操作


X86-64寄存器和棧幀

概要

說到x86-64,總不免要說說AMD的牛逼,x86-64是x86系列中集大成者,繼承了向后兼容的優良傳統,最早由AMD公司提出,代號AMD64;正是由於能向后兼容,AMD公司打了一場漂亮翻身戰。導致Intel不得不轉而生產兼容AMD64的CPU。這是IT行業以弱勝強的經典戰役。不過,大家為了名稱延續性,更習慣稱這種系統結構為x86-64。

X86-64在向后兼容的同時,更主要的是注入了全新的特性,特別的:x86-64有兩種工作模式,32位OS既可以跑在傳統模式中,把CPU當成i386來用;又可以跑在64位的兼容模式中,更加神奇的是,可以在32位的OS上跑64位的應用程序。有這種好事,用戶肯定買賬啦。

值得一提的是,X86-64開創了編譯器的新紀元,在之前的時代里,Intel CPU的晶體管數量一直以摩爾定律在指數發展,各種新奇功能層出不窮,比如:條件數據傳送指令cmovg,SSE指令等。但是GCC只能保守地假設目標機器的CPU是1985年的i386,額。。。這樣編譯出來的代碼效率可想而知,雖然GCC額外提供了大量優化選項,但是這對應用程序開發者提出了很高的要求,會者寥寥。X86-64的出現,給GCC提供了一個絕好的機會,在新的x86-64機器上,放棄保守的假設,進而充分利用x86-64的各種特性,比如:在過程調用中,通過寄存器來傳遞參數,而不是傳統的堆棧。又如:盡量使用條件傳送指令,而不是控制跳轉指令。

寄存器簡介

先明確一點,本文關注的是通用寄存器(后簡稱寄存器)。既然是通用的,使用並沒有限制;后面介紹寄存器使用規則或者慣例,只是GCC(G++)遵守的規則。因為我們想對GCC編譯的C(C++)程序進行分析,所以了解這些規則就很有幫助。

在體系結構教科書中,寄存器通常被說成寄存器文件,其實就是CPU上的一塊存儲區域,不過更喜歡使用標識符來表示,而不是地址而已。

X86-64中,所有寄存器都是64位,相對32位的x86來說,標識符發生了變化,比如:從原來的%ebp變成了%rbp。為了向后兼容性,%ebp依然可以使用,不過指向了%rbp的低32位。

X86-64寄存器的變化,不僅體現在位數上,更加體現在寄存器數量上。新增加寄存器%r8到%r15。加上x86的原有8個,一共16個寄存器。
剛剛說到,寄存器集成在CPU上,存取速度比存儲器快好幾個數量級,寄存器多了,GCC就可以更多的使用寄存器,替換之前的存儲器堆棧使用,從而大大提升性能。

讓寄存器為己所用,就得了解它們的用途,這些用途都涉及函數調用,X86-64有16個64位寄存器,分別是:

%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。

其中:

  • %rax 作為函數返回值使用。
  • %rsp 棧指針寄存器,指向棧頂
  • %rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函數參數,依次對應第1參數,第2參數。。。
  • %rbx,%rbp,%r12,%r13,%14,%15 用作數據存儲,遵循被調用者使用規則,簡單說就是隨便用,調用子函數之前要備份它,以防他被修改
  • %r10,%r11 用作數據存儲,遵循調用者使用規則,簡單說就是使用之前要先保存原值

 

棧幀

棧幀結構

        C語言屬於面向過程語言,他最大特點就是把一個程序分解成若干過程(函數),比如:入口函數是main,然后調用各個子函數。在對應機器語言中,GCC把過程轉化成棧幀(frame),簡單的說,每個棧幀對應一個過程。X86-32典型棧幀結構中,由%ebp指向棧幀開始,%esp指向棧頂。


函數進入和返回

函數的進入和退出,通過指令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  ---------- */


命令行中調用gcc,生成匯編語言:

 

Shell > gcc –S –o test.s test.c


 Main函數第40行的指令Callfoo其實干了兩件事情:

  • Pushl %rip //保存下一條指令(第41行的代碼地址)的地址,用於函數返回繼續執行
  • Jmp foo //跳轉到函數foo

Foo函數第19行的指令ret 相當於:

  • popl %rip //恢復指令指針寄存器

棧幀的建立和撤銷

還是上一個例子,看看棧幀如何建立和撤銷。

說題外話,以”點”做為前綴的指令都是用來指導匯編器的命令。無意於程序理解,統統忽視之,比如第31行。

棧幀中,最重要的是幀指針%ebp和棧指針%esp,有了這兩個指針,我們就可以刻畫一個完整的棧幀。

函數main的第30~32行,描述了如何保存上一個棧幀的幀指針,並設置當前的指針。
第49行的leave指令相當於:

 

Movq %rbp %rsp //撤銷棧空間,回滾%rsp。

Popq %rbp //恢復上一個棧幀的%rbp。

 

同一件事情會有很多的做法,GCC會綜合考慮,並作出選擇。選擇leave指令,極有可能因為該指令需要存儲空間少,需要時鍾周期也少。

你會發現,在所有的函數中,幾乎都是同樣的套路,我們通過gdb觀察一下進入foo函數之前main的棧幀,進入foo函數的棧幀,退出foo的棧幀情況。

 

Shell> gcc -g -o testtest.c

Shell> gdb --args test

Gdb > break main

Gdb > run

 

進入foo函數之前:

 

你會發現rbp-rsp=0×20,這個是由代碼第11行造成的。
進入foo函數的棧幀:

 

回到main函數的棧幀,rbp和rsp恢復成進入foo之前的狀態,就好像什么都沒發生一樣。

可有可無的幀指針

你剛剛搞清楚幀指針,是不是很期待要馬上派上用場,這樣你可能要大失所望,因為大部分的程序,都加了優化編譯選項:-O2,這幾乎是普遍的選擇。在這種優化級別,甚至更低的優化級別-O1,都已經去除了幀指針,也就是%ebp中再也不是保存幀指針,而且另作他途。

在x86-32時代,當前棧幀總是從保存%ebp開始,空間由運行時決定,通過不斷push和pop改變當前棧幀空間;x86-64開始,GCC有了新的選擇,優化編譯選項-O1,可以讓GCC不再使用棧幀指針,下面引用 gcc manual 一段話 :

 

-O also turns on -fomit-frame-pointer on machines where doing so does not interfere with debugging.

 

這樣一來,所有空間在函數開始處就預分配好,不需要棧幀指針;通過%rsp的偏移就可以訪問所有的局部變量。說了這么多,還是看看例子吧。同一個例子, 加上-O1選項:

 

Shell>: gcc –O1 –S –o test.s test.c

 

分析main函數,GCC分析發現棧幀只需要8個字節,於是進入main之后第一條指令就分配了空間(第23行):

Subq $8, %rsp

然后在返回上一棧幀之前,回收了空間(第34行):

Addq $8, %rsp

等等,為啥main函數中並沒有對分配空間的引用呢?這是因為GCC考慮到棧幀對齊需求,故意做出的安排。再來看foo函數,這里你可以看到%rsp是如何引用棧空間的。等等,不是需要先預分配空間嗎?這里為啥沒有預分配,直接引用棧頂之外的地址?這就要涉及x86-64引入的牛逼特性了。

 

訪問棧頂之外

通過readelf查看可執行程序的header信息:

 

紅色區域部分指出了x86-64遵循ABI規則的版本,它定義了一些規范,遵循ABI的具體實現應該滿足這些規范,其中,他就規定了程序可以使用棧頂之外128字節的地址。

這說起來很簡單,具體實現可有大學問,這超出了本文的范圍,具體大家參考虛擬存儲器。別的不提,接着上例,我們發現GCC利用了這個特性,干脆就不給foo函數分配棧幀空間了,而是直接使用棧幀之外的空間。@恨少說這就相當於內聯函數唄,我要說:這就是編譯優化的力量。

寄存器保存慣例

過程調用中,調用者棧幀需要寄存器暫存數據,被調用者棧幀也需要寄存器暫存數據。如果調用者使用了%rbx,那被調用者就需要在使用之前把%rbx保存起來,然后在返回調用者棧幀之前,恢復%rbx。遵循該使用規則的寄存器就是被調用者保存寄存器,對於調用者來說,%rbx就是非易失的。

反過來,調用者使用%r10存儲局部變量,為了能在子函數調用后還能使用%r10,調用者把%r10先保存起來,然后在子函數返回之后,再恢復%r10。遵循該使用規則的寄存器就是調用者保存寄存器,對於調用者來說,%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  ---------- */

 

命令行中調用gcc,生成匯編語言:

 

Shell>: gcc –O1 –S –o test2.s test2.c

 

在函數sfact_helper中,用到了寄存器%rbx和%rbp,在覆蓋之前,GCC選擇了先保存他們的值,代碼6~9說明該行為。在函數返回之前,GCC依次恢復了他們,就如代碼27-28展示的那樣。

看這段代碼你可能會困惑?為什么%rbx在函數進入的時候,指向的是-16(%rsp),而在退出的時候,變成了32(%rsp) 。上文不是介紹過一個重要的特性嗎?訪問棧幀之外的空間,這是GCC不用先分配空間再使用;而是先使用棧空間,然后在適當的時機分配。第11行代碼展示了空間分配,之后棧指針發生變化,所以同一個地址的引用偏移也相應做出調整。


X86時代,參數傳遞是通過入棧實現的,相對CPU來說,存儲器訪問太慢;這樣函數調用的效率就不高,在x86-64時代,寄存器數量多了,GCC就可以利用多達6個寄存器來存儲參數,多於6個的參數,依然還是通過入棧實現。了解這些對我們寫代碼很有幫助,起碼有兩點啟示:

  • 盡量使用6個以下的參數列表,不要讓GCC為難啊。
  • 傳遞大對象,盡量使用指針或者引用,鑒於寄存器只有64位,而且只能存儲整形數值,寄存器存不下大對象

讓我們具體看看參數是如何傳遞的:

 

#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  ---------- */

 

 

命令行中調用gcc,生成匯編語言:

 

Shell>: gcc –O1 –S –o test1.s test1.c

 

 

Main函數中,代碼31~37准備函數foo的參數,從參數7開始,存儲在棧上,%rsp指向的位置;參數6存儲在寄存器%r9d;參數5存儲在寄存器%r8d;參數4對應於%ecx;參數3對應於%edx;參數2對應於%esi;參數1對應於%edi。

Foo函數中,代碼14-15,分別取出參數7和參數1,參與運算。這里數組引用,用到了最經典的尋址方式,-40(%rsp,%rdi,4)=%rsp + %rdi *4 + (-40);其中%rsp用作數組基地址;%rdi用作了數組的下標;數字4表示sizeof(int)=4。


結構體傳參

應@桂南要求,再加一節,相信大家也很想知道結構體是如何存儲,如何引用的,如果作為參數,會如何傳遞,如果作為返回值,又會如何返回。

看下面的例子:

 

#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  ---------- */

 

我們缺省編譯選項,加了優化編譯的選項可以留給大家思考。

 

 

Shell>gcc  -S -o test.s test.c

 

上面的代碼加了一些注釋,方便大家理解,
問題1:結構體如何傳遞?它被分成了兩個部分,var8和var32合並成8個字節的大小,放在寄存器%rdi中,var64放在寄存器的%rsi中。也就是結構體分解了。
問題2:結構體如何存儲? 注意看foo函數的第15~17行注意到,結構體的引用變成了一個偏移量訪問。這和數組很像,只不過他的元素大小可變。

問題3:結構體如何返回,原本%rax充當了返回值的角色,現在添加了返回值2:%rdx。同樣,GCC用兩個寄存器來表示結構體。
恩, 即使在缺省情況下,GCC依然是想盡辦法使用寄存器。隨着結構變的越來越大,寄存器不夠用了,那就只能使用棧了。

總結

了解寄存器和棧幀的關系,對於gdb調試很有幫助;過些日子,一定找個合適的例子和大家分享一下。

參考

1. 深入理解計算機體系結構
2. x86系列匯編語言程序設計


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM