1. 程式人生 > >深入理解計算機系統_第一部分_第三章_程式的機器級表示

深入理解計算機系統_第一部分_第三章_程式的機器級表示

深入,並且廣泛
				-沉默犀牛

文章導讀

計算機執行機器程式碼,用位元組序列編碼低階的操作,包括處理資料、管理記憶體、讀寫儲存裝置上的資料,以及利用網路通訊。編譯器基於程式語言的規則、目標機器的指令集和作業系統遵循的慣例,經過一系列的階段生成機器程式碼。GCC C語言編譯器以彙編程式碼的形式產生輸出,彙編程式碼是機器程式碼的文字表示,給出程式中的每一條指令,然後GCC呼叫彙編器連結器,根據彙編程式碼生成可執行的機器程式碼。在本章中,我們會近距離觀察機器程式碼,以及人類可讀的表示——彙編程式碼。
當我們有高階語言程式設計的時候,機器遮蔽了程式的機器級的實現。而使用匯編語言程式設計的時候,程式設計師就必須制定程式用來執行計算的低階指令。通常,使用現代的優化編譯器產生的程式碼至少與一個熟練的組合語言程式設計師手工編寫的程式碼一樣有效[原文說的是有效,是不是意味著不那麼高效?]。最大的優點是,用高階語言編寫的程式可以在很多不同的機器上編譯和執行,而彙編程式碼則是與特定機器密切相關的。
那麼為什麼我們還要花時間學習機器程式碼呢?對於優秀程式設計師來說,能夠閱讀和理解彙編程式碼

仍是一項很重要的技能。通過閱讀彙編程式碼,我們能夠理解編譯器的優化能力,並分析程式碼中隱含的低效率。[第五章會講到],試圖最大化一段關鍵程式碼效能的程式設計師,通常會嘗試原始碼的各種形式,每次編譯並檢查產生的彙編程式碼,從而瞭解程式將要執行的效率如何。有的時候,高階語言提供的抽象層會隱藏我們想要了解的程式的執行時行為。例如,用執行緒包寫併發程式時,瞭解不同的執行緒是如何共享程式資料或保持資料私有的,以及準確知道如何在哪裡訪問共享資料,都是很重要的。這些資訊在機器程式碼是可見的。程式遭到攻擊的許多方式中,都涉及程式儲存執行時控制資訊的方式的細節。許多攻擊利用了系統程式中的漏洞重寫資訊,從而獲得了系統的控制權。瞭解這些漏洞如何出現,以及如何防禦它們,需要具備程式機器級表示的知識。程式設計師學習彙編程式碼的需求隨著時間的推移也發生了變化,開始時要求程式設計師能夠直接用匯編語言寫程式,現在則要求能夠閱讀和理解編譯器產生的程式碼。
在本章中,我們將詳細學習一種特別的組合語言,瞭解如何將C程式編譯成這種形式的機器程式碼。閱讀編譯器產生的彙編程式碼,必須瞭解典型的編譯器在將C程式結構變換成機器程式碼時所作的轉換。相對於C程式碼表示的計算操作,優化編譯器能夠重新排列執行順序,消除不必要的計算,用快速操作替換慢速操作,甚至將遞迴計算變換成迭代計算。原始碼與對應的彙編程式碼的關係通常不大容易理解——就像要拼出的拼圖與盒子上圖片的設計有點不太一樣。這是一種逆向工程(reverse engineering)
——通過研究系統和逆向工作,來試圖瞭解系統的建立過程。

本書中的表述基於x86-64,這是現在膝上型電腦和桌上型電腦中最常見處理器的機器語言,也是驅動大型資料中心和超級計算機的最常見處理器的機器語言。
我們在技術講解之前,先快速瀏覽C語言
、彙編程式碼以及機器程式碼之間的關係。然後介紹x86-64的細節,從資料的表示和處理以及控制的實現開始。瞭解如何實現C語言中的控制結構,如if、while、switch語句。之後,我們會講到過程的實現,包括程式如何維護一個執行棧來支援過程間資料和控制的傳遞,以及區域性變數的儲存。接著,我們會考慮機器級如何實現像資料、結構和聯合這樣的資料結構。有了這些機器級程式設計的背景知識,我們會討論記憶體訪問越界的問題,以及系統容易遭受緩衝區溢位攻擊的問題。在這一部分的結尾,我們會給出一些用GDB偵錯程式檢查機器級執行時行為的技巧。本章的最後展示了包含浮點資料和操作的程式碼的機器程式表示。

計算機工業已經完成從32位到64位機器的過度。32位機器只能使用大概4GB(2的32次方)的隨機訪問儲存器。儲存器價格急劇下降,而我們隊計算的需求和資料的大小持續增加,超越這個限制既經濟上可行又有技術上的需要。當前的64位機器能夠使用多達256TB(2的48次方)的記憶體空間,而且很容易就能擴充套件至16EB(2的64次方)。[原來64位機器不是直接就可以使用16EB。。。]。
我們的表述集中於現代作業系統為目標,編譯C或類似程式語言時,生成的機器及程式型別。x86-64有一些特性是為了支援遺留下來的微處理器早期程式設計風格,在此,我們不試圖去描述這些特性,那時候大部分程式碼都是手工編寫的,而程式設計師還在努力與16位機器允許的有限地址空間奮戰。

1 歷史觀點

Intel處理器系列俗稱 x86,開始,它是第一代單晶片、16位微處理器之一。下面列舉Intel處理器的模型,以及他們的一些關鍵特性,特別是影響機器級程式設計的特性。我們用實現這些處理器所需要的電晶體數量來說明演變過程的複雜性。其中 K表示1000,M表示 1 000 000,而G表示 1 000 000 000。
8086(1978年,29K個電晶體)[我學習微機的書就是基於8086的啊,懷念]。它是第一代單晶片、16位微處理器之一。8088是8086的一個變種,在8086上增加了一個8位外部匯流排,構成了最初的IBM個人計算機的心臟。最初的機器型號有 32768位元組的記憶體和兩個軟碟機(沒有硬碟驅動器)。從體系結構上來說,這些機器只有 655360位元組的地址空間——地址線只有20位長(可定址範圍為1048576位元組),而作業系統保留了393216位元組自用。1980年,Intel提出了8087浮點協處理器(45K個電晶體),它與一個8086或8088處理器一同執行,執行浮點指令。8087建立了 x86系列的浮點模型,通常稱為“x87”
80286(1982年,134K個電晶體)。增加了更多的定址模式(現在已經廢棄了),構成了IBM PC-AT個人計算機的基礎,這種計算機是 MS Windows最初的使用平臺。
i386(1985年,257K個電晶體)。將體系結構擴充套件到32位。增加了平坦定址模式(flat addressing model),Linux和最近版本的 Windows作業系統都是使用的這種定址。這是Intel系列中第一臺全面支援Unix作業系統的機器。
i486(1989年,1.2M個電晶體)。改善了效能,同時將浮點單元整合到了處理器晶片上,但是指令集沒有明顯的改變。
Pentium(1993年,3.1M個電晶體)。改善了效能,不過只對指令集進行了小的擴充套件。
PentiumPro(1995年,5.5M個電晶體)。引入了全新的處理器設計,在內部被稱為P6微體系結構。指令集中增加了一類“條件傳送(conditional move)”指令。
Pentium/MMX(1997年,4.5M個電晶體)。在Pentium處理器中增加了一類新的處理整數向量的指令。每個資料大小可以是1、2或4位元組。每個向量總長64位。
Pentium II(1997年,7M個電晶體)。P6微體系結構的延伸。
Pentium III(1997年,8.2M個電晶體)。引入了SSE,這是一類處理整數或浮點數向量的指令。每個資料可以是1、2或4位元組,打包成128位向量。由於晶片上包括了二級快取記憶體,這種晶片後來的版本最多使用了 24M 個電晶體。
Pentium 4(2000年,42M個電晶體)。SSE擴充套件到SSE2,增加了新的資料型別(包括雙精度浮點數),以及針對這些格式的 144 條新指令。有了這些擴充套件,編譯器可以使用SEE指令(而不是x87指令),來編譯浮點程式碼。
Pentium 4E(2004年,125M個電晶體)。增加了超執行緒(hyperthreading),這種技術可以在一個處理器上同時執行兩個程式;還增加了EM64T,它是Intel對AMD提出的對IA32的64位擴充套件的實現,我們稱之為x86-64。
Core 2(2006年,291M個電晶體)。迴歸到類似於 P6 的微體系結構。Intel的第一個多核微處理器,即多處理器實現在一個晶片上。但不支援超執行緒
Core i7,Nehalem(2008年,781M個電晶體)。既支援超執行緒,也有多核,最初的版本支援每個核上執行兩個程式,每個晶片上最多四個核。
Core i7, Sandy Bridge(2011年,1.17G個電晶體)。引入了AVX,這是對SSE的擴充套件,支援把資料封裝近256位向量。
Core i7 , Haswell(2013年,1.4G個電晶體)。將 AVX擴充套件至AVX2,增加了更多指令和指令格式。
[這些處理器的改革一起羅列到這裡,真的是符合摩爾定律啊,不知道以後會變得怎樣呢]
每個後繼處理器的設計都是向後相容的——較早版本上編譯的程式碼可以在較新的處理器上執行。正如我們看到的那樣,為了保持這種進化傳統,指令集中有許多非常奇怪的東西。Intel處理器系列有好幾個名字,包括 IA32 ,也就是“Intel 32位體系結構(Intel Architecture 32-bit)”,以及最近的Intel64,即IA32的64位擴充套件,我們也稱為x84-64。最常用的名字是“x86”,我們用它指代整個系列,也反映了知道i486處理器命名的慣例。
這些年來,許多公司生產出了與Intel處理器相容的處理器,能夠執行完全相同的機器級程式。其中,領頭的是AMD。數年來,AMD在技術上緊跟Intel,執行的市場策略是:生產效能稍低但是價格更便宜的處理器。2002年,AMD的處理器變得更加有競爭力,它們率先突破了可商用微處理器的1GHz的時鐘速度屏障,並且引入了廣泛採用的IA32的63位擴充套件 x86-64。雖然我們講的是Intel處理器,但是對於其競爭對手生產的與之相容的處理器來說,這些表述也成立。
對於由GCC編譯器產生的、在Linux作業系統平臺上執行的程式,感興趣的人大多不關心x86的複雜性。最初的8086提供的記憶體模型和它在80286中的擴充套件,到i386的時候就都已經過時了。原來的x87浮點指令到引入了SSE2以後就過時了。雖然在x86-64程式中,我們能看到歷史發展的痕跡,但x86中許多最晦澀難懂的特性已經不會出現了。

2 程式編碼

假設一個C程式,有兩個檔案p1.c和p2.c。我們有Unix命令列編譯這些程式碼:
linux> gcc -Og -o p p1.c p2.c
命令 gcc指的就是GCC C編譯器。因為這是Linux上預設的編譯器,我們也可以簡單地用 cc 來啟動它。編譯選項 -Og 告訴編譯器使用會生成符合原始C程式碼整體結構的機器程式碼的優化等級。使用較高級別優化產生的程式碼會嚴重變形,以至於產生的機器程式碼和初始原始碼之間的關係非常難以理解。因此我們會使用 -Og 優化作為學習工具,然後當我們增加優化級別時,再看會發生什麼。實際中,從得到的程式的效能考慮,較高級別的優化(例如,以選項 -O1 或 -O2指定)被認為是較好的選擇。
實際上gcc命令呼叫了一整套的程式,將原始碼轉化成可執行程式碼。首先,C前處理器擴充套件原始碼,插入所有用 #include 命令指定的檔案,並擴充套件所有用 #define 宣告指定的巨集。其次,編譯器產生兩個原始檔的彙編程式碼,名字分別是p1.s 和 p2.s。接下來,彙編器會將彙編程式碼轉化成二進位制目的碼檔案 p1.o 和 p2.o。目的碼是機器程式碼的一種形式,它包含所有指令的二進位制表示,但是還沒有填入全域性值的地址。最後, 連結器將兩個目的碼檔案與實現庫函式(例如 printf)的程式碼合併,併產生最終的可執行程式碼檔案p(由命令列指示符 -o p 指定的)。可執行程式碼是我們要考慮的機器程式碼的第二種形式,也就是處理器執行的程式碼格式。

2.1 機器級程式碼

如之前說過的那樣,計算機系統使用了多種不同形式的抽象,利用更簡單的抽象模型來隱藏實現的細節。對於機器級程式設計來說,其中兩種抽象尤為重要。第一種是由指令集體系結構或指令集架構(Instruction Set Architecture, ISA)來定義機器級程式的格式和行為,它定義了處理器狀態、指令的格式,以及每條指令對狀態的影響。大多數ISA,包括x86-64,將程式的行為描述成好像每條指令都是按照順序執行的,一條指令結束後,下一條再開始。處理器的硬體遠比描述的精細複雜,它們併發地執行許多指令,但是可以採取措施保證整體行為與ISA指定的順序執行的行為完全一致。第二種抽象是,機器級程式使用的記憶體地址是虛擬地址,提供的記憶體模型看上去是一個非常大的位元組陣列。儲存器系統的實際實現是將多個硬體儲存器和作業系統軟體組合起來。
在整個編譯過程中,編譯器會完成大部分的工作,將把用C語言提供的相對比較抽象的執行模型表示的程式轉化成處理器執行的非常基本的指令。彙編程式碼表示非常接近於機器程式碼。與機器程式碼的二進位制格式相比,彙編程式碼的主要特點是它用可讀性更好的文字格式表示。能夠理解彙編程式碼以及它與原始C程式碼的聯絡,是理解計算機如何執行程式的關鍵一步。
x86-64的機器程式碼和原始的C程式碼差別非常大。一些通常對C語言程式設計師隱藏的處理器狀態都是可見的:

  • 程式計數器(成為“PC”,在x86-64中用%rip表示)給出將要執行的下一條指令在記憶體中的地址。
  • 整數暫存器檔案 包含16個命名的位置,分別儲存64位的值。這些暫存器可以儲存地址(對應於C語言的指標)或整數資料。有的暫存器被用來記錄某些重要的程式狀態,而其他的暫存器用來儲存臨時資料,例如過程的引數和區域性變數,以及函式的返回值。
  • 條件碼暫存器儲存著最近執行的算術或邏輯指令的狀態資訊。它們用來實現控制或資料流中的條件變化,比如說用來實現if 和 while 語句。
  • 一組向量暫存器可以存放一個或U盾謳歌整數或浮點數值。
    雖然C語言提供給了一種模型,可以在記憶體中宣告的分配各種資料型別的物件,但是機器程式碼只是簡單地將記憶體看成一個很大的、按位元組定址的資料。C語言中的聚合資料型別,例如陣列和結構,在機器程式碼中用一組連續的位元組來表示。即使是對標量資料型別,彙編程式碼也不區分有符號或無符號整數,不區分各種型別的指標,甚至於不區分指標和整數。
    程式記憶體包含:程式的可執行機器程式碼,作業系統需要的一些資訊,用來管理過程呼叫和返回的執行時棧,以及使用者分配的記憶體塊(比如說用malloc庫函式分配的)。正如前面提到的,程式記憶體用虛擬地址來定址。在任意給定的時刻,只有有限的一部分虛擬地址被認為是合法的。例如,x86-64的虛擬地址是由64位的字來表示的。在目前的實現中,這些地址的高16位必須設定為0,所以一個地址實際上能夠指定的是2的48次方或64TB範圍內的一個位元組。較為典型的程式只會訪問幾兆位元組或幾千兆位元組的資料。作業系統負責管理
    虛擬地址空間,將虛擬地址翻譯成實際處理器記憶體中的實體地址。
    一條機器指令只執行一個非常基本的操作。例如,將存放在暫存器中的兩個數字相加,在儲存器和暫存器之間傳送資料,或是條件分支轉移到新的指令地址。編譯器必須產生這些指令的序列,從而實現(像算術表示式求值、迴圈或過程呼叫和返回這樣的)程式結構。
2.2 程式碼示例

例如如下的一個C語言程式碼檔案 mstore.c:
在這裡插入圖片描述
使用下面的編譯命令:
Linux> gcc -Og -S mstore.c
這會使GCC執行編譯器,產生一個彙編檔案mstore.s,但是不做其他進一步的工作。
彙編程式碼檔案包含以下幾行:
在這裡插入圖片描述
上面程式碼中每一個縮排都對應一條機器指令。比如,pushq指令表示應該將暫存器 %rbx 的內容壓入程式棧中。這段程式碼中已經除去了所有關於區域性變數名或資料型別的資訊。
如果我們使用如下命令列:
Linux> gcc -Og -c mstore.c
這就會產生目的碼檔案mstore.o,它是二進位制格式的,所以無法直接檢視。1368位元組的檔案mstore.o 中有一段14位元組的序列,它的十六進位制表示為:
在這裡插入圖片描述
這就是上面列出的彙編指令對應的目的碼。從中得到一個重要資訊,即機器執行的程式只是一個位元組序列,它是對一系列指令的編碼。機器對產生這些指令的原始碼幾乎一無所知。

要檢視機器程式碼檔案的內容,有一類稱為反彙編器(disassembler)的程式非常有用。這些程式根據機器程式碼產生一種類似於彙編程式碼的格式。在Linux系統中,帶‘-d’命令列標誌的程式OBJDUMP(表示“object dump”)可以充當這個角色:
linux> objdump -d mstore.o
結果如下:
在這裡插入圖片描述
左邊是前面給出的位元組順序排列的14個十六進位制位元組值,它們分成了若干組,每組有1 - 5個位元組。每組都是一條指令,右邊是等價的組合語言。

一些關於機器程式碼和它的反彙編表示的特性值得注意:

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

生成實際可執行的程式碼需要一組目的碼檔案執行連結器,而這一組目的碼檔案中必須含有一個main函式。假設main.c中有下面的函式:
在這裡插入圖片描述
用如下命令列生成可執行檔案 prog
linux> gcc -Og -o prog main.c mstore.c
檔案 prog 變成了8655個位元組,因為它不僅包含了兩個過程的程式碼,還包含了用來啟動和終止程式的程式碼,以及用來與作業系統互動的程式碼。我們可以反彙編 prog 檔案:
linux> objdump -d prog
在這裡插入圖片描述
這段程式碼與mstore.c反彙編產生的程式碼幾乎完全一樣。其中一個主要的區別是左邊列出的地址不同——連結器將這段程式碼的地址移到了一段不同的地址範圍中。第二個不同之處在於連結器填上了callq指令呼叫函式 mult2 需要使用的地址(第4行)。連結器的任務之一就是為函式呼叫找到匹配的函式的可執行程式碼的位置。最後一個區別是多了兩行程式碼(第8 、9行)。這兩條指令對程式沒影響,因為它們出現在返回指令後面。插入這些指令是為了使程式碼變為16位元組,使得就儲存器系統性能而言,能更好地放置下一個程式碼塊。

2.3 關於格式的註解

GCC產生的彙編程式碼對我們來說有點難度,一是因為,它包含一些我們不需要關心的資訊,二是因為,它不提供任何程式的描述或它是如何工作的描述。例如,假設我們用如下命令生成檔案 mstore.s。
linux> gcc -Og -S mstore.c
在這裡插入圖片描述
所有以‘.’開頭的都是指導彙編器和連結器工作的偽指令。我們通常可以忽略這些行。另一方面,也沒有關於指令的用途以及它們與原始碼之間關係的解釋說明。
為了更清楚地說明彙編程式碼,我們用這樣一種格式來表示彙編程式碼,它省略了大部分偽指令,但包括行數和解釋性說明。
在這裡插入圖片描述
通常我們只會給出與討論內容相關的程式碼行。每一行的左邊都有編號供引用,右邊是註釋,簡單地描述指令的效果以及它與原始C程式碼中的計算操作的關係。這是一種組合語言程式設計師寫程式碼的風格。

我們的表述是ATT格式的彙編程式碼,這是GCC、OBJDUMP和其他一些我們使用的工具的預設格式,此外還有Intel格式,它們在許多方面有所不同。

  • 把C程式和彙編程式碼結合起來
    雖然C編譯器在把程式中表達的計算轉換到機器程式碼方面表現出色,但是仍然有一些機器特性是C程式訪問不到的。例如,每次x86-64處理器執行算術或邏輯運算時,如果得到的運算結果的低8位中有偶數個1,那麼就會把一個名為PF的1位條件碼(condition code)標誌設定為1,否則就設定為0。這裡的PF表示“parity flag(奇偶標誌)”。在C語言中計算這個資訊需要至少7次移位、掩碼和異或運算。即使作為每次算術或邏輯運算的一部分,硬體都完成了這項計算,而C語言卻無法知道PF條件碼標誌的值。在程式中插入幾條彙編程式碼指令就能很容易地完成這項認為。
    在C程式中插入彙編程式碼有兩種方法,第一種是,我們可以編寫完成的函式,放進一個獨立的彙編程式碼檔案中,讓彙編器和連結器把它和C語言書寫的程式碼合併起來。第二種方法是,我們可以使用GCC的內聯彙編(inline assembly)特性,用asm偽指令可以在C程式中包含簡短的彙編程式碼。這種方法的好處是減少了與機器相關的程式碼量。
    當然,在C程式中包含彙編程式碼使得這些程式碼與某類特殊的機器相關(例如 x86-64),所以只應該在想要的特定只能以此種方式才能訪問到時才使用它。

3 資料格式

由於是從16位體系結構擴充套件成32位的,Intel用術語“字(Word)”表示16位資料型別。因此,稱32位數為“雙字(double words)”,稱63位數為“四字(quad words)”。下圖給出了C語言基本資料型別對應的x86-64表示。標準int值儲存為雙字(32位)。指標 (在此用 char * 表示)儲存為8位元組的四字,64位機器本來就預期如此。x86-64中,資料型別long實現位64字, 允許表示的值範圍較大。本章程式碼示例中的大部分都使用了指標和long資料型別,所以都是四字操作。x86-64 指令集同樣包括完整的針對位元組、字和雙字的指令。
在這裡插入圖片描述

浮點數主要有兩種形式:單精度(4位元組)值,對應於C語言資料型別float;雙精度(8位元組)值,對應於C語言資料型別 double。x86 家族的微處理器歷史上實現過對一種特殊的80位(10位元組)浮點格式進行全套的浮點運算。可以在C程式中用宣告 long double 來指定這種格式。不過我們不建議使用這種格式。它不能移植到其他型別的機器上,而且實現的硬體也不如單精度和雙精度算術運算的高效。
如上圖,大多數GCC生成的彙編程式碼指令都有一個字元的字尾,表明運算元的大小。例如,資料傳送指令有四個變種:movb(傳送位元組)、movw(傳送字)、movl(傳送雙字)和movq(傳送四字)。字尾‘1’用來表示雙字,因為32位數被看成是“長字(long Word)”。注意,彙編程式碼也使用字尾‘1’來表示4位元組整數和8位元組雙精度浮點數。這不會產生歧義,因為浮點數使用的是一組完全不同的指令和暫存器。