x86_64 Linux 運行時棧的字節對齊


前言

C語言的過程調用機制(即函數之間的調用)的一個關鍵特性(起始大多數編程語言也是如此)都是使用了棧數據結構提供的后進先出的內存管理原則。每一個函數的棧空間被稱為棧幀,一個棧幀上包含了保存的寄存器、分配給局部變量的空間以及傳遞給要調用函數的參數等等。一個基本的棧結構如下圖所示:

但是,有一點需要引起注意的是,過程調用的參數是通過棧來傳遞的,並且分配的局部變量也在棧上,那么對於不同字節長度的參數或變量,是如何在棧上為它們分配空間的?這里所涉及的就是我們要探討的字節對齊。

本文示例用到的環境如下:

  • Ubuntu x86_64 GNU/Linux
  • gcc 7.4.0

數據對齊

許多計算機系統對基本數據類型的合法地址做了一些限制,要求某種類型對象的地址必須是某個值K的倍數,其中K具體如下圖。這種對齊限制簡化了形成處理器和內存系統之間接口的硬件設計。舉個實際的例子:比如我們在內存中讀取一個8字節長度的變量,那么這個變量所在的地址必須是8的倍數。如果這個變量所在的地址是8的倍數,那么就可以通過一次內存操作完成該變量的讀取。倘若這個變量所在的地址並不是8的倍數,那么可能就需要執行兩次內存讀取,因為該變量被放在兩個8字節的內存塊中了。

K 類型
1 char
2 short
4 int, float
8 long,double,char*

無論數據是否對齊,x86_64硬件都能正常工作,但是卻會降低系統的性能,所以我們的編譯器在編譯時一般會為我們實施數據對齊。

棧的字節對齊

棧的字節對齊,實際是指棧頂指針必須須是16字節的整數倍。棧對齊幫助在盡可能少的內存訪問周期內讀取數據,不對齊堆棧指針可能導致嚴重的性能下降。

上文我們說,即使數據沒有對齊,我們的程序也是可以執行的,只是效率有點低而已,但是某些型號的Intel和AMD處理器對於有些實現多媒體操作的SSE指令,如果數據沒有對齊的話,就無法正確執行。這些指令對16字節內存進行操作,在SSE單元和內存之間傳送數據的指令要求內存地址必須是16的倍數。

因此,任何針對x86_64處理器的編譯器和運行時系統都必須保證分配用來保存可能會被SSE寄存器讀或寫的數據結構的內存,都必須是16字節對齊的,這就形成了一種標准:

  • 任何內存分配函數(alloca, malloc, calloc或realloc)生成的塊起始地址都必須是16的倍數。
  • 大多數函數的棧幀的邊界都必須是16直接的倍數。

如上,在運行時棧中,不僅傳遞的參數和局部變量要滿足字節對齊,我們的棧指針(%rsp)也必須是16的倍數。

三個示例

我們用三個實際的例子來看一看為了實現數據對齊和棧字節對齊,棧空間的分配具體是怎樣的。

如下是CSAPP上的一個示例程序。

void proc(long  a1, long  *a1p,
          int   a2, int   *a2p,
          short a3, short *a3p,
          char  a4, char  *a4p) {
    *a1p += a1;
    *a2p += a2;
    *a3p += a3;
    *a4p += a4;
}

long call_proc()
{
    long  x1 = 1; int  x2 = 2;
    short x3 = 3; char x4 = 4;
    proc(x1, &x1, x2, &x2, x3, &x3, x4, x4);
    return (x1+x2)*(x3+x4);
}

使用如下命令進行編譯和反編譯:

$ gcc -Og -fno-stack-protector -c call_proc.c
$ objdump -d call_proc.o

其中-fno-stack-protector參數指示編譯器不添加棧保護者機制

生成的匯編代碼如下,這里我們僅看call_proc()中的棧空間分配

0000000000000015 <call_proc>:
  15:   48 83 ec 10             sub    $0x10,%rsp
  19:   48 c7 44 24 08 01 00    movq   $0x1,0x8(%rsp)
  20:   00 00 
  22:   c7 44 24 04 02 00 00    movl   $0x2,0x4(%rsp)
  29:   00 
  2a:   66 c7 44 24 02 03 00    movw   $0x3,0x2(%rsp)
  31:   c6 44 24 01 04          movb   $0x4,0x1(%rsp)
  36:   48 8d 4c 24 04          lea    0x4(%rsp),%rcx
  3b:   48 8d 74 24 08          lea    0x8(%rsp),%rsi
  40:   48 8d 44 24 01          lea    0x1(%rsp),%rax
  45:   50                      push   %rax
  46:   6a 04                   pushq  $0x4
  48:   4c 8d 4c 24 12          lea    0x12(%rsp),%r9
  4d:   41 b8 03 00 00 00       mov    $0x3,%r8d
  53:   ba 02 00 00 00          mov    $0x2,%edx
  58:   bf 01 00 00 00          mov    $0x1,%edi
  5d:   e8 00 00 00 00          callq  62 <call_proc+0x4d>
  ...

15行(我們具體以代碼中給出的行號,其實這些數字應該是指令的起始位置,姑且就這樣叫吧)中先將%rsp減去0x10,為4個局部變量共分配了16個字節的空間,並且在45和46行,程序將%rax和$0x4入棧,聯系該函數的C語言程序和匯編程序中的具體操作,不難知,棧上的具體空間分配如下圖所示:

圖中,為了使棧字節對齊,4單獨占用了一個8字節的空間,並且棧中的每一個類型的變量,都符合數據對齊的要求。

如果我們的參數8占用的字節數減少,會不會減少棧空間的占用呢?我們將上面的C語言程序的稍微改一改,如下:

void proc(long  a1, long  *a1p,
          int   a2, int   *a2p,
          short a3, short *a3p,
          char  a4, char a5) {  // char *a4p改為了char a5
    *a1p += a1;
    *a2p += a2;
    *a3p += a3;
    a5 += a4;
}

long call_proc()
{
    long  x1 = 1; int  x2 = 2;
    short x3 = 3; char x4 = 4;
    proc(x1, &x1, x2, &x2, x3, &x3, x4, x4);  // 相應的改變了最后一個參數
    return (x1+x2)*(x3+x4);
}

call_proc()的匯編如下:

000000000000000a <call_proc>:
   a:   48 83 ec 10             sub    $0x10,%rsp
   e:   48 c7 44 24 08 01 00    movq   $0x1,0x8(%rsp)
  15:   00 00 
  17:   c7 44 24 04 02 00 00    movl   $0x2,0x4(%rsp)
  1e:   00 
  1f:   66 c7 44 24 02 03 00    movw   $0x3,0x2(%rsp)
  26:   48 8d 4c 24 04          lea    0x4(%rsp),%rcx
  2b:   48 8d 74 24 08          lea    0x8(%rsp),%rsi
  30:   6a 04                   pushq  $0x4
  32:   6a 04                   pushq  $0x4
  34:   4c 8d 4c 24 12          lea    0x12(%rsp),%r9
  39:   41 b8 03 00 00 00       mov    $0x3,%r8d
  3f:   ba 02 00 00 00          mov    $0x2,%edx
  44:   bf 01 00 00 00          mov    $0x1,%edi
  49:   e8 00 00 00 00          callq  4e <call_proc+0x44>
  ...

對照程序,棧的空間結構編程的如下如所示:

我們發現,棧空間的占用並沒有減少,為了能夠達到棧字節對齊的目的,參數8和參數7各占一個8字節的空間,該過程調用浪費了1 + 7 + 7 = 15字節的空間。但為了兼容性和效率,這是值得的。

我們再看另一個程序,當我們在棧中分配字符串時又是怎樣的呢?

void function(int a, int b, int c) {
       char buffer1[5];
       char buffer2[10];
       strcpy(buffer2, buffer1);
}

void main() {
        function(1,2,3);

使用gcc -fno-stack-protector -o foo foo.cobjdump -d foo進行編譯和反編譯后,function()的匯編代碼如下:

000000000000064a <function>:
 64a:	55                   	push   %rbp
 64b:	48 89 e5             	mov    %rsp,%rbp
 64e:	48 83 ec 20          	sub    $0x20,%rsp
 652:	89 7d ec             	mov    %edi,-0x14(%rbp)
 655:	89 75 e8             	mov    %esi,-0x18(%rbp)
 658:	89 55 e4             	mov    %edx,-0x1c(%rbp)
 65b:	48 8d 55 fb          	lea    -0x5(%rbp),%rdx
 65f:	48 8d 45 f1          	lea    -0xf(%rbp),%rax
 663:	48 89 d6             	mov    %rdx,%rsi
 666:	48 89 c7             	mov    %rax,%rdi
 669:	e8 b2 fe ff ff       	callq  520 <strcpy@plt>
 66e:	90                   	nop
 66f:	c9                   	leaveq 
 670:	c3                   	retq

該過程共在棧上分配了32個字節的空間,其中包括兩個字符串的空間和三個函數的參數的空間,這里需要提一下的是,盡管再x64下,函數的前6個參數直接用寄存器進行傳遞,但是有時候程序需要用到參數的地址,這個時候程序就不的不在棧上為參數分配內存並將參數拷貝到內存上,來滿足程序對參數地址的操作。

聯系程序,該過程的棧結構如下:

圖中,因為char類型的地址可以從任意地址開始(地址為1的倍數),所以buffer1和buffer2是連續分配的,而三個int型變量則分配在了兩個單獨的8字節空間中。

小結

以上,我們看到,為了滿足數據對齊和棧字節對齊的要求,或者說規范,編譯器不惜犧牲了部分內存,這使得程序提高了兼容性,也提高了程序的性能。


參考:


免責聲明!

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



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