1. 程式人生 > >《深入理解計算機系統》閱讀筆記--程序的機器級表示(上)

《深入理解計算機系統》閱讀筆記--程序的機器級表示(上)

還要 所有 執行文件 命令 不同的 指向 local 變量 section

一、為什麽要學習和了解匯編

編譯器基於編程語言的規則,目標機器的指令集和操作系統遵循的慣例,經過一系列的階段生成機器代碼。GCC c語言編譯器以匯編代碼的形式產生輸出,匯編代碼是機器代碼的文本表示,給出程序中的每一條指令。然後GCC調用匯編和鏈接器,根據匯編代碼生成可執行的機器代碼。這一章節其實就是來更加深入的認識和理解匯編代碼

現在我們更多接觸的都是一些高級語言,如JAVA,GO,Python,其實用這些語言的時候,更大程度上,已經屏蔽了一些程序的細節,即機器級的實現。但是如果是用匯編語言,程序員就必須制定程序用來執行計算的低級指令。

那麽為什麽我們還要學習和了解匯編呢? 雖然現在編譯器已經替我們做了生成匯編代碼的大部分工作,但是作為程序員,如果我們能夠閱讀和理解匯編代碼將是一個非常重要的技能,好處是:

能夠理解編譯器的優化能力分析代碼中隱含的低效率

如我們通過線程包寫並發程序時,了解不同線程是如何共享程序數據或保持數據私有的,以及準確知道如何在哪裏訪問共享數據,這些在機器代碼都是可見的

二、歷史

Inter的處理器系統俗稱x86,第一代處理器是8086,一個單芯片,16位微處理器,主要為 IBM PC 和 DOS 設計,有 1MB 的地址空間。八年後的 1985,第一個 32 位 Intel 處理器(IA32) 386 誕生。2004 年,奔騰(Pentium) 4E 成為了第一個 64 位處理器(x86-64)。2006 年 Core 2 成為了第一個多核 Intel 處理器。

三、程序編碼

假如我們有一個c程序,有兩個文件p1.c 和p2.c 我們通常編譯的時候是通過如下命令:

gcc -0g -o p p1.c p2.c

GCC是linux上默認的編譯器,-0g 告訴編譯器使用會生成符合原始C代碼整體結構的機器代碼來優化等級。

GCC命令調用了一整套的程序,將源代碼轉換為可執行代碼:

C預處理器擴展源代碼,插入所有用#include 命令指定的文件,並擴展所有用#define聲明制定的宏。

編譯器產生兩個源文件的匯編代碼,名字分別為p1.s 和p2.s

匯編器會將匯編代碼轉換為二進制目標文件p1.o 和p2.o

鏈接器將兩個目標代碼文件與實現庫函數的代碼合並,並最終生成可執行文件p

對於機器級編程,有兩個重要的抽象:

由指令集體系結構或指令集架構(Instruction Set Architecture, ISA)來定義機器級程序的格式和行為,它定義了處理器狀態,指令的格式,以及每條指令對狀態的影響。

機器級程序使用的內存地址是虛擬地址,提供的內存模型看上去是一個非常大的數組。

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

程序計數器(PC,在x86-64中用%rip表示)給出將要執行的下一條指令在內存中的地址

整數寄存器文件包含16個命令的位置,分別存儲64位的值,這些寄存器可以存儲地址或者整數數據。有的寄存器用於記錄某些重要的程序狀態,而其他的寄存器用來保存臨時數據。

條件碼寄存器保存著最近執行的算術或邏輯指令的狀態信息。用來實現控制或數據流中的條件變化

一組向量寄存器可以存放一個或多個整數或者浮點數值

程序內存包含:程序的可執行機器代碼,操作系統需要一些信息,用來管理過程調用和返回的運行時棧,以及用戶分配的內存塊

先看一個代碼編譯實例:

long mult2(long,long);

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

通過gcc -Og -S mstore.c, 我們就得到了一個mstore.s

mstore.s的內容如下:

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

通過gcc -Og -c mstore.c 我們可以得到它的二進制格式,其實這個我們無法直接查看,要查看機器代碼文件的內容,可以通過objdump查看,如下所示:

root@localhost  /app/c_codes  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 

上述一些關於機器代碼和它的反匯編表示的特性值得註意:

x86-64的指令長度從1-15個不等

設計指令格式的方式是,從某個給定位置開始,可以將字節唯一地解碼成機器指令,如上述中,只有指令pushq %rbx 是以字節值53開頭

反匯編器只是基於機器代碼文件中的字節序列來確定匯編碼,不需要訪問該程序的源代碼或匯編代碼

反匯編器使用指令命令規則與GCC生成的匯編代碼使用的有些區別,在上面的示例中,它省略了很多指令結尾的q,這些後綴是大小指示符,可以省略

四、數據格式

由於是從16位體系結構擴展成32位的,字(word)表示16位數據類型,因此,32位數為雙子,64位稱為四字

技術分享圖片

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

五、訪問信息

一個x86-64的中央處理單元包含一組16個存儲64位值的通用目的寄存器。這些寄存器用來存儲整數數據和指針如下圖:

技術分享圖片

這裏的名字都是以%r開頭 ,不過後面也包含了一些不同的命名規則,這是歷史演化造成的。

最早的8086中有8個16位的寄存器,即上圖中的%ax到%bp,當擴展到IA32架構時,這些寄存器也擴展成了32位寄存器,標號從%eax到%ebp,當擴展到x86-64後,原來的8個寄存器擴展為64位,標號從%rax到%rbp,除此之外還增加了8個新的寄存器,標號從%r8到%r15

操作數指示符

大多數指令有一個或多個操作數,指示出執行一個操作中要使用的源數據值,以及放置結果的目的位置。源數據可以以常數形式給出,或者從寄存器或內存中讀出,結果可以存放在寄存器或者內存中,因此各種不同的操作數的可能性被分為三種類型:

立即數:用來表示常數值。即$後面跟一個用標準C表示法表示的整數

寄存器:表示某個寄存器的內容,16個寄存器的低位1字節,2字節,4字節,或者8字節中的一個作為操作數分別對應於8位,16位,32位,或64位。

內存引用:根據計算出來的地址訪問某個內存位置

下圖是多種不同的尋址方式:

技術分享圖片

數據傳送指令

最頻繁使用的指令是將數據從一個位置復制到另一個位置的指令,最簡單形式的數據傳送指令是MOV類,MOV類由四條指令組成:movb,movw,movl和movq. b,w,l,q分別是1、2、4和8字節

技術分享圖片

源操作數指定的值是一個立即數,存儲在寄存器中或者內存中,目的操作數指定一個位置,要麽是一個內存地址。而在x86-64中增加一個限制,傳送指令的兩個操作數不能都指向內存位置。

技術分享圖片

上圖中記錄的是兩類數據移動指令,在將較小的源值賦值到較大的目的的時候使用,所有這些指令都把數據從源(在寄存器或內存中)復制到目的寄存器。MOVZ 類中的指令把目的中剩余的字節填充為0而MOVS類中的指令通過符號擴展來填充,把源操作的最高位進行復制

數據傳送的代碼示例

將下面代碼,通過gcc -Og -S exchange.c 生成匯編代碼

long exchange(long *xp,long y)
{
  long x = *xp;
  *xp = y;
  return x;
}

匯編代碼如下:

exchange:

movq (%rdi), %rax

movq %rsi, (%rdi)

ret

從上面的匯編代碼可以看出,函數exchange由三個指令實現:兩個數據傳送movq,加上一條返回函數被調用點的指令(ret).

過程描述為:

參數xp和y分別存儲在寄存器%rdi 和%rsi中

movq (%rdi), %rax :這個指令是從內存中讀x,把它放到寄存器%rax中,直接實現了c程序代碼中x = *xp。稍後用寄存器%rax 從這個函數返回一個值,因而返回值就是x

movq %rsi, (%rdi):這個指令將y寫入到寄存器%rdi 中的xp指向的內存位置,直接實現了代碼中*xp=y

關於這段匯編代碼有兩個地方需要註意:C語言中所謂指針其實就是地址。間接引用指針就是將該指針放在一個寄存器中,然後在內存引用中使用這個寄存器。 其次像x這樣的局部變量通常是保存在寄存器中,而不是內存中,訪問寄存器比訪問內存要快的多

壓入和彈出棧數據

最後兩個數據傳送操作可以將數據壓入程序棧中,以及從程序棧中彈出數據。

棧是一種數據結構,可以添加和刪除值,不過要遵循後進先出的原則,通過push操作將數據壓入棧中,通過pop刪除數據。

它具有一個屬性:彈出的值永遠是最近被壓入而且仍然在棧中的值。

pushq指令的功能是把數據壓入棧上,而popq是彈出數據,這些指令都只有一個操作數--壓入的數據源和彈出的數據目的

將一個四字值壓入棧中,首先要將棧指針減8,然後將值寫入到新的棧頂地址

因為棧和程序代碼以及其他形式的程序數據都是存放在同一個內存中,所以程序可以用標準的內存尋址方法訪問棧內的任意位置。

《深入理解計算機系統》閱讀筆記--程序的機器級表示(上)