程序的機器級表示(一)


程序編碼

假設一個C程序,有兩個文件p1.c和p2.c。我們用Unix命令行編譯這些代碼:

linux> gcc -Og-o p p1.c p2.c

  

命令gcc就是GCC編譯器,這是Linux默認的編譯器。編譯選項-Og告訴編譯器使用會生成符合原始C代碼整體結構的機器代碼的優化等級,使用較高級別的優化產生的代碼會嚴重變形,以至於產生的機器代碼和初始源代碼之間的關系難以理解。

實際上,gcc命令調用了一整套程序,將源代碼轉換為可執行代碼。首先,C預處理器擴展源代碼,插入所有用#include命令指定的文件,並擴展所有用#define聲明指定的宏。其次,編譯器產生兩個源文件的匯編代碼,名字分別為p1.s和p2.s。接下來,匯編器會將匯編代碼轉換為二進制目標代碼文件p1.o和p2.o。目標代碼是機器代碼的一種形式,它包含所有指令的二進制表示,但是還沒填入全局值的地址。最后,鏈接器將兩個目標代碼文件與實現函數(如printf)的代碼合並,並產生最終的可執行文件p(由-o p指定的)。

機器級代碼

對機器級編程來說,其中兩種抽象尤為重要。第一種是由指令集體系結構或指令集架構(Instruction Set Architecture,ISA)來定義機器級程序的格式和行為,它定義了處理器狀態、指令的格式,以及每條指令對狀態的影響。大多數ISA,包括x86-64,將程序的行為描述成好像每條指令都是按順序執行的,一條指令結束后,下一條指令再開始。處理器的硬件遠比描述的精細復雜,它們並發的執行許多指令,但是可以采取措施保證整體行為與ISA指定的順序執行的行為完全一致。第二種抽象是,機器級程序使用的內存地址是虛擬地址,提供的內存模型看上去是一個非常大的字節數組。存儲器系統的實際實現是將多個硬件存儲器和操作系統軟件組合起來。

在整個編譯的過程中,編譯器會完成大部分的工作,把用C語言提供的比較抽象的執行模型表示的程序轉化成處理器執行的非常基本的指令。匯編代碼表示非常接近機器代碼。與機器代碼的二進制格式相比,匯編代碼的主要特點是它用可讀性更好的文本格式表示。

x86-64的機器代碼和原始的C代碼差別非常大,一些通常對C語言程序員隱藏的處理狀態都是可見的:

  • 程序計數器(通常稱為PC,在x86-64中用%rip表示)給出將要執行的下一條指令在內存中的地址。
  • 整數寄存器文件包含16個命名的位置,分別存儲64位的值。這些寄存器可以存儲地址(對應C語言中的指針)或整數數據。有的寄存器被用來記錄某些重要的程序狀態,而其他寄存器用來保存臨時數據,例如過程的參數和局部變量,以及函數的返回值。
  • 條件碼寄存器保存着最近執行的算數或邏輯指令的狀態信息。它們用來實現控制或數據流中的條件變化,比如說用來實現if和while語句。
  • 一組向量寄存器可以存放一個或多個整數或浮點數值。

雖然C語言提供了一種模型,可以在內存中聲明和分配各種數據類型的對象,但是機器代碼只是將內存看成一個很大的、按字節尋址的數組。C語言的聚合數據類型,例如數組和結構,在機器代碼中用一組連續的字節來表示。即使是對標量數據類型,匯編代碼也不區分有符號或無符號整數,不區分各種類型的指針,甚至不區分指針和整數。

程序內存包含:程序的可執行機器代碼,操作系統需要一些信息,用來管理過程調用和返回的運行時棧,以及用戶分配的內存塊(比如說用malloc庫函數分配的)。正如前面提到的,程序內存用虛擬地址來尋址。在任意給定的時刻,只有有限的一部分虛擬地址被認為是合法的。例如,x86-64的虛擬地址是由64位的字來表示的。在目前的實現中,這些地址的高16位必須設置為0,所以一個地址實際上能指定的是248或64TB范圍內的一個字節。較為典型的程序只會訪問幾兆字節或幾千兆字節的數據。操作系統負責管理虛擬地址空間,將虛擬地址翻譯成實際處理器內存中的物理地址。

一條機器指令只執行一個非常基本的操作。例如,將存放在寄存器中的兩個數字相加,在存儲器和寄存器之間傳送數據,或是條件分支轉移到新的指令地址。編譯器必須告訴這些指令的序列,從而實現像表達式求值、循環或過程調用和返回這樣的程序結構。

代碼示例

 

long mult2(long,long);

void multstore(long x, long y, long *dest) {
  long t = mult2(x, y);
  *dest = t;
}

  

在命令行上使用-S選項,就能看到C語言編譯器產生的匯編代碼:

# gcc -Og -S mstore.c 

  

這會使GCC運行編譯器,產生一個匯編文件mstore.c,但是不做其他進一步的工作。匯編代碼文件包含各種聲明,包括下面幾行:

multstore:
        pushq   %rbx
        movq    %rdx, %rbx
        call    mult2
        movq    %rax, (%rbx)
        popq    %rbx
        ret

  

上面的代碼中每個縮進去的行都對應於一條機器指令。比如pushq指令表示應該將寄存器%rbx的內容壓入程序棧中。這種代碼中已經除去了所有關於局部變量名或數據類型的信息。

如果我們使用-c命令行選項來編譯並匯編該代碼:

# gcc -Og -c mstore.c
# ll
total 637312
……
-rw-r--r--  1 root root      1368 Aug  7 14:59 mstore.o
……

    

這就會產生目標代碼文件mstore.o,它是二進制格式的,所以無法直接查看。1368字節的文件mstore.o中有一段14字節的序列,它的十六進制表示為:

53 48 89 d3 e8 00 00 00 00 48 89 03 5b c3

  

這就是上面列出的匯編指令對應的目標代表。從中可以得到一個信息,即機器執行的程序只是一個字節序列,它是對一系列指令的編碼。機器對產生這些指令的源代碼幾乎一無所知。

要查看機器代碼文件的內容,有一類稱為反匯編器的程序非常有用。這些程序根據機器代碼產生一種類似於匯編代碼的格式。在Linux系統中,帶'-d'命令行標志的程序OBJDUMP(表示“object dump”)可以充當這個角色:

# objdump -d mstore.o 

mstore.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <multstore>:
   0:   53                      push   %rbx
   1:   48 89 d3                mov    %rdx,%rbx
   4:   e8 00 00 00 00          callq  9 <multstore+0x9>
   9:   48 89 03                mov    %rax,(%rbx)
   c:   5b                      pop    %rbx
   d:   c3                      retq  

  

在左邊,我們看到按照前面給出的字節順序排列的14個十六進制字節值,它們分成若干組,每組有1~5個字節。每組都是一條指令,右邊是等價的匯編語言。

其中一些關於機器代碼和它的反匯編表示的特性值得注意:

  • x86-64的指令長度從1到15個字節不等。常用的指令以及操作數較少的指令所需的字節數較少,而那些不太常用或操作數較多的指令所需的字節數較多。
  • 設計指令格式的方式是,從某個給定的位置開始,可以將字節唯一地解碼成機器指令。例如,只有指令pushq %rbx是以字節值53開頭的。
  • 反匯編器只是基於機器代碼文件中的字節序列來確定匯編代碼。它不需要訪問該程序的源代碼或匯編代碼。
  • 反匯編器使用的指令命名規則與GCC生成的匯編代碼使用的有些細微的差別。在我們的示例中,它省略了很多指令結尾的'q'。這些后綴是大小指示符,在大多數情況下可以省略。相反,反匯編器給call和ret指令加上'q'后綴,同樣,省略這些后綴也沒問題。

生成可執行的代碼需要對一組目標代碼文件運行鏈接器,而這一組目標代碼文件中必須含有一個main函數。假設在文件main.c中有下面這樣的函數:

# include <stdio.h>

void multstore(long, long, long *);

int main() {
    long d;
    multstore(2, 3, &d);
    printf("2 * 3 --> %ld/n", d);
    return 0;
}

long mult2(long a, long b) {
    long s = a * b;
    return s;
}

  

然后,我們用如下方法生成可執行文件prog:

# gcc -Og -o prog main.c mstore.c 
# ll
total 637312
……
-rwxr-xr-x  1 root root      8616 Aug  7 15:55 prog

  

文件prog變成8616個字節,因為它不僅包含了兩個過程的代碼,還包含了用來啟動和終止程序的代碼,以及用來與操作系統交互的代碼。我們也可以反匯編prog文件:

# objdump -d prog 
……
0000000000400563 <mult2>:
  400563:       48 89 f8                mov    %rdi,%rax
  400566:       48 0f af c6             imul   %rsi,%rax
  40056a:       c3                      retq   
000000000040056b <multstore>:
  40056b:       53                      push   %rbx
  40056c:       48 89 d3                mov    %rdx,%rbx
  40056f:       e8 ef ff ff ff          callq  400563 <mult2>
  400574:       48 89 03                mov    %rax,(%rbx)
  400577:       5b                      pop    %rbx
  400578:       c3                      retq   
  400579:       0f 1f 80 00 00 00 00    nopl   0x0(%rax)
……

    

<multstore>這段代碼與mstore.c反匯編出來額代碼幾乎一模一樣。其中一個主要的區別是左邊列出的地址不同——鏈接器將這段代碼的地址移到額一段不同的地址范圍中。第二個不同之處在於鏈接器填上了callq指令調用函數mult2需要使用的地址(反匯編代碼第40056f行)。鏈接器的任務之一就是為函數調用找到匹配函數的可執行代碼的位置。最后一個區別是多了一行代碼(第400579行),這條指令對程序沒有影響,因為它們出現在返回指令的后面(第400578行)。

關於格式的注解

GCC產生的匯編代碼對我們來說有點難讀。一方面,它包含一些我們不需要關心的信息,另一方面,它不提供任何程序的描述或它是如何工作的描述。例如,假設我們用如下命令生成文件mstore.s。

# gcc -Os -S mstore.c 
# cat mstore.s 
        .file   "mstore.c"
        .text
        .globl  multstore
        .type   multstore, @function
multstore:
.LFB0:
        .cfi_startproc
        pushq   %rbx
        .cfi_def_cfa_offset 16
        .cfi_offset 3, -16
        movq    %rdx, %rbx
        call    mult2
        movq    %rax, (%rbx)
        popq    %rbx
        .cfi_def_cfa_offset 8
        ret
        .cfi_endproc
.LFE0:
        .size   multstore, .-multstore
        .ident  "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-16)"
        .section        .note.GNU-stack,"",@progbits

  

所有以'.'開頭的行都是指導匯編器和鏈接器工作的偽指令。我們通常可以忽略這些行。另一方面,也沒有關於指令的用途以及它們與源代碼之間關系的解釋說明。

為了更清楚地說明匯編代碼,我們用這樣一種格式來表示匯編代碼,它省略了大部分偽指令,包括行號和解釋性說明。對於我們的示例,帶解釋的匯編代碼如下:

數據格式

由於是從16位體系結構擴展成32位,Intel用術語“字(word)”表示十六位數據類型。因此,稱32位數為“雙字(double words)”,稱64位為“四字(quad words)”。圖3-1給出了C語言基本數據類型對應的x86-64表示。標准int值存儲為雙字(32位)。指針(在此用char *表示)存儲為8字節的四字,64位機器本來就預期如此。在x86-64中,數據類型long實現為64位,允許表示的值范圍較大。下面的代碼示例中大部分都使用了指針和long數據類型,所以都是四字操作。x86-64指令集同樣包括完整的針對字節、字和雙字的指令。

圖3-1   C語言數據類型在x86-64中的大小。在64位機器中,指針長8字節

浮點數主要有兩種形式:單精度(4字節)值,對應於C語言數據類型float;雙精度(8字節)值,對應於C語言數據類型double。

如圖所示,大多數GCC生成的匯編代碼指令都有一個字符的后綴,表明操作數的大小。例如,數據傳送指令有四個變種:movb(傳送字節)、movw(傳送字)、movl(傳送雙字)和movq(傳送四字)。后綴'l'用來表示雙字,因為32位數被看成是“長字(long word)”。注意,匯編代碼也使用后綴'l'來表示4字節整數和8字節雙精度浮點數。這不會產生歧義,因為浮點數使用的是一組完全不同的指令和寄存器。


免責聲明!

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



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