1. 程式人生 > >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,2345,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系列組合語言程式設計


http://ju.outofmemory.cn/entry/769

相關推薦

X86-64

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

IA32x86-64的區別

IA32暫存器 一個IA32CPU包含一組8個儲存32位值的通用暫存器,這些暫存器用來儲存整數資料和指標: 31-0 15-0 15-8 7-0 使用慣例 %eax %ax %ah %al 呼叫者儲存 %ecx %cx %ch %cl 呼叫者

Operation System: 關於

對於Intel架構的處理器,從8位的暫存器(8位的CPU)一直演進到如今的64位(64位暫存器),為了承上啟下,到現在的64位的CPU還是可以運行當年8位CPU的程式,而如今的暫存器當中,依然是可以從64位中分出8位來應對8位的程式。 如今的暫存器如下圖所示:(圖片來源:

6432位的彙編的比較

64位暫存器分配的不同 區別有: 64位有16個暫存器,32位只有8個。但是32位前8個都有不同的命名,分別是e _ ,而64位前8個使用了r代替e,也就是r _。e開頭的暫存器命名依然可以直接運用於相應暫存器的低32位。而剩下的暫存器名則是從r8

【arm】arm32位arm64位架構、指令差異分析總結

Date: 2018.9.21 1、參考 2、前言   最近三個月的時間,都在進行解碼庫的arm架構彙編優化,包括arm32位彙編優化和arm64位彙編優化。在arm32位入門之後,只要掌握了兩種架構的暫存器和指令集差異之後,就可以很快上手編寫arm64

關於CPU快取記憶體的簡單介紹

關於CPU暫存器的簡單介紹 最近淺顯的學習了下這方面的知識,所以目前也想去總結一下 CPU除了控制器、運算器等器件還有一個重要的部件就是暫存器。其中暫存器的作用就是進行資料的臨時儲存。 CPU的運算速度是非常快的,為了效能CPU在內部開闢一小塊臨時

x86-64 下函式呼叫及原理

一蓑一笠一扁舟,一丈絲綸一寸鉤。 一曲高歌一樽酒,一人獨釣一江秋。 —— 題秋江獨釣圖 緣起 在 C/C++ 程式中,函式呼叫是十分常見的操作。那麼,這一操作的底層原理是怎樣的?編譯器幫我們做了哪些操作?CPU 中各暫存器及記憶體堆疊

跟我學彙編(三)實體地址的形成

一、通用暫存器 對於一個彙編程式設計師來說,CPU中主要部件是暫存器。暫存器是CPU中程式設計師可以用指令讀寫的部件。程式設計師通過改變各種暫存器的內容來實現對CPU的控制。 不同的CPU,暫存器的個數、結構是不同的。8086CPU 有14個暫存器,每個暫存

微機原理 七種定址方式

16位cpu 八個通用暫存器 指令指標IP 標誌暫存器FR 段暫存器 AX BX  CX DX BP SP SI DI 其中前四個又分為高八位和低八位 AX 累加器 BX 基址暫存器 CX 計數暫存器 DX 資料暫存器 BP 基址指標暫存器 SP 堆疊指標暫存器 

C++中有關volatile關鍵字的作用--阻止編譯器將其變數優化快取到(執行緒相關)(轉自百度)

       就象大家更熟悉的const一樣,volatile是一個型別修飾符(type specifier)。        它是被設計用來修飾被不同執行緒訪問和修改的變數 。        如果沒有volatile,基本上會導致這樣的結果:要麼無法編寫多執行緒

C語言的本質(29)——C語言與彙編之定址方式

x86的通用暫存器有eax、ebx、ecx、edx、edi、esi。這些暫存器在大多數指令中是可以任意選用的,比如movl指令可以把一個立即數傳送到eax中,也可傳送到ebx中。但也有一些指令規定只能用其中某些暫存器做某種用途,例如除法指令idivl要求被除數在eax暫存器中

gdb除錯祕籍(

GDB的常用除錯命令大家可以查閱gdb手冊就可以快速的上手了,在這兒就不給大家分享了,需要的可以到GDB的官網去下載手冊。這裡重點分享下GDB除錯中的一些暫存器和棧的相關知識用於解決下列gdb除錯時的問題: 優化的程式碼在printf或其它glibc函式處core 沒有

GDB記憶體

1. 檢視暫存器 (gdb) i r (gdb) i r a                     # 檢視所有暫存器(包括浮點、多媒體) (gdb) i r esp (gdb) i r pc 2. 檢視記憶體 (gdb) x /wx 0x80040000    # 以16進位制顯示指定地址處的資料 (gd

ARM的LCD控制原理(抖動演算法FRC)

控制器簡介        在複雜的PC機中,我們經常提到顯示卡這個東西,相信大家對顯示卡的原理都不陌生。LCD控制器就相當於嵌入式系統的顯示卡。它負責把視訊記憶體中的LCD圖形資料傳輸到LCD驅動器,併產生必須的LCD控制訊號。視訊記憶體與系統儲存器共用主存空間。這樣做有幾個好處:節約儲存器,提高空間利用率

2018/11/03-x86架構-《惡意程式碼分析實戰》

  棧用於函式的記憶體、區域性變數、流控制結構等被儲存在棧中。棧是一種用壓和彈操作來刻畫的資料結構,向棧中壓入一些東西,然後再把他們彈出來。它是一種先入後出(LIFO)的結構。   x86架構有對棧的內建支援。用於這種支援的暫存器包括ESP和EBP。其中,ESP是棧指標,包含了指向棧頂的記憶體地址。一些東西

解釋執行編譯執行的區別、基於基於的指令集區別

1. 解釋執行和編譯執行的區別 我們在學習java的時候,對class檔案都有個疑惑,虛擬機器是如何執行發方法中的位元組碼指令的呢?其實 虛擬機器的執行引擎在執行java程式碼的時候有解釋執行和編譯執行兩種選擇。通俗說來,解釋執行是通過直譯器執行,編譯執行即通

基於虛擬機器基於虛擬機器的比較

虛擬機器的概念    首先問一個基本的問題,作為一個虛擬機器,它最基本的要實現哪些功能?    他應該能夠模擬物理CPU對運算元的移進移出,理想狀態下,它應該包含如下概念:  (1)將原始碼編譯成VM指定的位元組碼。  (2)包含指令和運算元的資料結構(指令用於處理運算元作

1.4 x86 CPU地址空間分配訪問

1、基本概念 cpu地址空間和pci地址空間是兩個常用的比較容易混淆的概念,特別是其中不同系列的cpu的實現還各不相同:x86系列cpu地址空間和pci地址空間是重合的,即為同一空間;而非x86 cpu的cpu地址空間和pci地址空間為兩個獨立的空間。 也許

java 虛擬機器如何翻譯位元組碼 基於基於的區別

java 編譯 解釋執行 javac 編譯器 完成了程式程式碼經過詞法分析, 語法分析, 到抽象語法樹, 在遍歷語法樹生成線性的位元組碼指令流的過程 javac 最後是生成了.class 的位元組碼 最後位元組碼需要翻譯成機器語言才能執行

ADS1248 配置通道轉換

ADS1248是TI的一款 24位delta-sigma(ΔΣ) 、2KSPS、8通道(4通道差分)ADC晶片。TI官方有RTD設計方案,參考文件做了一板4通道3線PT100溫度採集。除錯ADS1248過程中遇到一些問題,記錄下來方便以後使用。 參考程式碼: