深入理解計算機系統_第三章_程序的機器級表示

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

文章導讀

計算機執行機器代碼,用字節序列編碼低級的操作,包括處理數據、管理內存、讀寫存儲設備上的數據,以及利用網絡通信。編譯器基於編程語言的規則、目標機器的指令集和操作系統遵循的慣例,經過一系列的階段生成機器代碼。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位機器允許的有限地址空間奮戰。

歷史觀點

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”,我們用它指代整個系列。

這些年來,許多公司生產出了與Intel處理器兼容的處理器,能夠運行完全相同的機器級程序。其中,領頭的是AMD。數年來,AMD在技術上緊跟Intel,執行的市場策略是:生產性能稍低但是價格更便宜的處理器。2002年,AMD的處理器變得更加有競爭力,它們率先突破了可商用微處理器的1GHz的時鐘速度屏障,並且引入了廣泛採用的IA32的63位擴展 x86-64。雖然我們講的是Intel處理器,但是對於其競爭對手生產的與之兼容的處理器來說,這些表述也成立。

對於由GCC編譯器產生的、在Linux操作系統平臺上運行的程序,感興趣的人大多不關心x86的複雜性。最初的8086提供的內存模型和它在80286中的擴展,到i386的時候就都已經過時了。原來的x87浮點指令到引入了SSE2以後就過時了。雖然在x86-64程序中,我們能看到歷史發展的痕跡,但x86中許多最晦澀難懂的特性已經不會出現了。

程序編碼

假設一個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 指定的)。可執行代碼是我們要考慮的機器代碼的第二種形式,也就是處理器執行的代碼格式。

機器級代碼

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

在整個編譯過程中,編譯器會完成大部分的工作,將把用C語言提供的相對比較抽象的執行模型表示的程序轉化成處理器執行的非常基本的指令。彙編代碼表示非常接近於機器代碼。與機器代碼的二進制格式相比,彙編代碼的主要特點是它用可讀性更好的文本格式表示。能夠理解彙編代碼以及它與原始C代碼的聯繫,是理解計算機如何執行程序的關鍵一步。

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

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

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

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

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

代碼示例

例如如下的一個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字節,使得就存儲器系統性能而言,能更好地放置下一個代碼塊。

關於格式的註解

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),所以只應該在想要的特定只能以此種方式才能訪問到時才使用它。

數據格式

由於是從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字節雙精度浮點數。這不會產生歧義,因爲浮點數使用的是一組完全不同的指令和寄存器。

訪問信息

一個x86-64 的中央處理單元(CPU)包含一組16個存儲64位值得通用目的寄存器。這些寄存器用來存儲整數數據和指針。下圖顯示了這16個寄存器。它們的名字都以 %r 開頭,不過後面還跟着一些不同的命名規則的名字,這是由於指令集歷史演化造成的。最初的 8086 中有8個16位寄存器,即途中的 &ax 到 &bp。每個寄存器都有特殊的用途,它們的名字就反映了這些不同的用途。擴展到IA32架構時,這些寄存器也擴展成32位寄存器,標號從 %eax 到 %ebp。擴展到x86-64後,原來的8個寄存器擴展成64位,標號從 %rax 到 %rbp。除此之外,還增加了8個新的寄存器,它們的標號是按照新的明明規則制定的:%r8 到 %r15。
在這裏插入圖片描述
如圖中嵌套的方框標明的,指令可以對這16個寄存器的低位字節中存放的不同大小的數據進行操作。字節級操作可以訪問最低的字節,16位操作可以訪問最低的2個字節,32位操作可以訪問最低的4個字節,而64位操作可以訪問整個寄存器。
後面的章節中,我們會展現很多指令,複製和生成1字節、2字節、4字節 和 8字節。當這些指令以寄存器作爲目標時,對於生成小於8字節結果的指令,寄存器中剩下的字節會怎麼樣,對此有兩條規則:生成 1字節 和 2字節數字的指令會保持剩下的字節不變;生成4字節數字的指令會把高位4個字節置0。後面這條規則是作爲從 IA32 到 x86-64 的擴展的一部分而採用的。
像圖中右邊解釋說明的那樣,在常見的程序裏不同的寄存器扮演不同的角色。其中最特別的是棧指針 %rsp ,用來指明運行時棧的結束位置。有些程序會明確地讀寫這個寄存器。另外15個寄存器的用法更靈活。少量指令會使用某些特定的寄存器。更重要的是,有一組標準的編程規範控制着如何使用寄存器來管理棧、傳遞函數參數、從函數的返回值,以及存儲局部和臨時數據。我們會在描述過程的實現時,講述這些管理。

操作數指示符

大多數指令有一個或多個操作數(operand), 指示出執行一個操作中要使用的源數據值,以及放置結果的目的位置。x86-64 支持多種操作數格式。源數據值可以以常數形式給出,或從寄存器或內存中讀出。結果可以存放在寄存器或內存中。因此,各種不同的操作數的可能性被分爲三種類型:
第一種,立即數( immediate),用來表示常數值。在ATT格式的彙編代碼中,立即數的書寫方式是 ‘C’ 後面跟一個用標準C表示法表示的整數,比如,-577 或 $0x1F。不同的指令允許的立即數取值範圍不同,彙編器會自動選擇最緊湊的方式進行數值編碼。
第二種,寄存器(register)它作爲某個寄存器的內容,16個寄存器的低位1字節、2字節、4字節或8字節作爲一個操作數,這些字節數分別對應於8位、16位、32位和64位。在下圖中,我們用符號 ra來表示任意寄存器a,用引用R[ra]來表示它的值,這是講寄存器集合看成一個數字R,用寄存器標識符作爲索引。
第三種,內存引用,它會根據計算出來的地址 (通常稱爲有效地址)訪問某個內存位置。因爲將內存看成一個很大的字節數組,我們用符號 Mb[ADDr]表示對存儲在內存中從地址ADDr開始的 b個字節值得引用。爲了簡便,通常省去下標b。
如下圖,有多種不同的尋址模式,允許不同形式的內存引用。表中底部用語法Imm(rb,ri,s)表示的是最常用的形式。它有四個 組成部分:一個立即數偏移Imm,一個基址寄存器rb,一個變址寄存器ri 和一個比例因子 s,這裏s 必須是1、2、4或8.基址和變址寄存器都必須是64位寄存器。有效地址被計算爲 Imm + R[rb] + R[ri] * s 。引用數組元素時,會用到這種通用形式。其他形式都是這種通用形式的特殊情況,只是省略了某些部分。正如我們將看到的,當引用數組和結構元素時,比較複雜的尋址模式是很有用的。
在這裏插入圖片描述
爲了加深理解,馬上來看一個例子:
在這裏插入圖片描述
在這裏插入圖片描述

數據傳送指令

最頻繁使用的指令是將數據從一個位置複製到另一個位置的指令 。操作數表示的通用性使得一條簡單的數據傳送指令能夠完成在許多機器中要好幾條不同指令才能完成的功能。我們會介紹多種不同的數據傳送指令,它們或者源和目的類型不同,或者執行的轉換不同,或者具有的一些副作用不同。在講述中,把許多不同的指令劃分爲指令類,每一類中的指令執行相同的操作,只不過操作數大小不同。
下圖列出的是最簡單形式的數據傳送指令——MOV類。這些指令把數據從源位置複製到目的位置,不做任何變化。MOV類有四條指令租場:movb、movw、movl 和 movq 。這些指令都執行同樣的操作;主要區別在於它們操作的數據大小不同 :分別是1、2、4和8字節。
在這裏插入圖片描述

源操作數指定的值是一個立即數,存儲在寄存器中或者內存中。目的操作數指定一個位置,要麼是一個寄存器或者,要麼一個內存地址。x86-64 加了一條限制,傳送指令的兩個操作數不能都指向內存位置。將一個值從一個內存位置複製到另一個內存位置需要兩條指令——第一條指令將源值加載到寄存器中,第二條將該寄存器值寫入目的位置。這些指令的寄存器操作數可以使16個寄存器有標號部分中的任意一個,寄存器部分的大小必須與指令最後一個字符(‘b’、‘w’、‘l’、‘q’)指定的大小匹配。大多數情況中,MOV指令只會更新目的操作數指定的那些寄存器字節或內存位置。唯一地例外是 movl 指令以寄存器作爲目的時,它會把該寄存器的高位4字節設置爲0。造成這個例外的原因是 x86-64 採用的慣例,即任何爲寄存器生成32位值的指令都會把該寄存器的高位部分置爲0。
下面的MOV指令示例給出了源和目的的類型的物種可能的組合。記住,第一個是源操作數,第二個是目的操作數:
在這裏插入圖片描述
圖中記錄的最後一條指令是處理64位立即數數據的。常規的 movq 指令只能以表示爲32位補碼數字的立即數作爲源操作數,然後把這個值符號擴展得到64位的值,放到目的位置。 movabsq 指令能夠以任意64位立即數作爲源操作數,並且只能以寄存器作爲目的。

下圖記錄的是兩類數據移動指令,在將較小的源值複製到較大的目的時使用。所有這些指令都把數據從源(在寄存器或內存中)複製到目的寄存器。MOVZ類中的指令把目的中剩餘的字節填充爲0,而MOVS類中的指令通過符號擴展來填充,把源操作數的最高位進行復制。可以觀察到,每條指令名字的最後兩個字符都是大小指示符:第一個字符指定源的大小,而第二個指明目的大小。這兩個類中每個都有三條指令,包括了所有的源大小爲1個和2個字節、目的大小爲2個和4個的情況,當然只考慮目的大於源的情況。
在這裏插入圖片描述

注意上圖中並沒有一條明確的指令把4字節源值零擴展到8字節目的。這樣的指令邏輯上應該被命名爲 moxzlq ,但是並沒有這樣的指令。不過,這樣的數據傳送可以用以寄存器爲目的的movl指令來實現。這一技術利用的屬性是,生成4字節值並以寄存器作爲目的的指令會把高4字節置爲0。對於64位的目標,所有三種源類型都有對應的符號擴展傳送,而只有兩種較小的源類型有零擴展傳送。
圖中還給出了cltq指令。這條指令沒有操作數:它總是以寄存器 %eax 作爲源,%rax作爲符號擴展結果的目的。它的效果與指令 movslq %eax, %rax完全一致,不過編碼更緊湊。

兩個數據傳送的例子:
在這裏插入圖片描述
(下圖中3行修改爲 movb %dl,%rax,原書打印錯了)
在這裏插入圖片描述

練習題:這個練習題要回去看上面的各個寄存器的字節數
在這裏插入圖片描述
在這裏插入圖片描述
[看答案介紹內存引用總是用四字長寄存器給出,選擇數據傳送指令的時候就看另一個操作數好了。]

在這裏插入圖片描述
在這裏插入圖片描述

數據傳送示例

作爲一個使用數據傳送指令的代碼示例,考慮下圖中所示的數據交換函數,既有C代碼,也有GCC產生的彙編代碼。
在這裏插入圖片描述
如上圖所示,函數exchange由三條指令實現:兩個數據傳送(movq),加上一條返回函數被調用點的指令(ret)。我們會在之後講函數調用和返回的細節。在此之前,知道函數參數通過寄存器傳遞給函數就足夠了。我們對彙編代碼添加註釋來加以說明。函數通過把值存儲在寄存器 %rax 或該寄存器的某個低位部分中返回。
當過程開始執行時,過程參數 xp 和 y 分別存儲在寄存器 %rdi 和 % rsi中。然後,指令2從內存中讀出x,把它存放到寄存器 %rax 中,直接實現了C程序中的操作 x = *xp。稍後,用寄存器 %rax 從這個函數返回一個值,因而返回值就是 x。指令3將 y 寫入到寄存器 %rdi 中的 xp 指向的內存位置,直接實現了操作 *xp = y。這個例子說明如何用 MOV 指令從內存中讀值到寄存器(第2行),如何從寄存器寫到內存(第3行)。
關於這段彙編代碼有兩點值得注意。首先,我們看到C語言中所謂的“指針”其實就是地址。間接引用指針就是將該指針放在一個寄存器中,然後在內存引用中使用這個寄存器。其次,像 x 這樣的局部變量通常保存在寄存器中,而不是內存中。訪問寄存器比訪問內存要快得多。
在這裏插入圖片描述

我把每種數據類型的佔用的字節數再貼一下:
在這裏插入圖片描述

在這裏插入圖片描述

  • 指針的一些示例
    函數 exchange 提供了一個關於C語言中指針使用的很好說明。參數 xp 是一個指向 long 類型的整數的指針,而 y 是一個 long類型的整數。語句 long x = *xp ,表示我們將讀存儲在 xp 所指位置中的值,並將它存放到名字爲 x 的局部變量中。這個讀操作稱爲指針的間接引用(pointer dereferencing),C操作符 * 執行指針的間接引用。 語句 xp = y, 正好相反——它將參數 y 的值寫到 xp 所指的位置。這也是指針間接引用的一種形式(所以有操作符 ‘‘),但是它表明的是一個寫程序,因爲它在賦值語句的左邊。
    下面是調用 exchange 的一個實際例子:
    long a = 4;
    long b = exchange( &a, 3);
    printf( “a = %ld, b = %ld\n”, a, b)
    這段代碼會打印出:
    a = 3,b = 4
    C操作符 & (稱爲“取值”操作符)創建一個指針,在本例中,該指針指向保存局部變量 a 的位置。然後,函數 exchange 將用 3 覆蓋存儲在 a 中的值,但是返回原來的值 4 作爲函數的值。注意如何將指針傳遞給 exchange,它能修改存在某個遠處位置的數據。
    在這裏插入圖片描述
    在這裏插入圖片描述

壓入和彈出棧數據

最後兩個數據傳送操作可以將數據壓入程序棧中,以及從程序棧中彈出數據,如下圖。正如我們將看到的,棧在處理過程調用中起到至關重要的作用。棧是一種數據結構,可以添加或者刪除值,不過要尊村“後進先出”的原則。通過 push 操作把數據壓入棧中,通過 pop 操作刪除數據;它具有一個屬性:彈出的值永遠是最近被壓入而且仍然在棧中的值。棧可以實現位一個數組,總是從數組的一段插入和刪除元素。這一端被稱爲棧頂。在x86-64中,程序棧存放在內存中某個區域。如下下圖,棧向下增長,這樣一來,棧頂元素的地址是所有棧中元素地址中最低的。(根據慣例,我們的棧是倒過來畫的,棧頂在圖的底部。)棧指針 %rsp 保存着棧頂元素的地址。
在這裏插入圖片描述

pushq 指令的功能是把數據壓入到棧上,而 popq指令是彈出數據。這些指令都只有一個操作數——壓入的數據源和彈出的數據目的。
將一個四字值壓入棧中, 首先要將棧指針減8,然後將值寫到新的棧頂地址,因此,指令 pushq %rbp 的行爲等價於下面兩條指令:
在這裏插入圖片描述
它們之間的區別是在機器代碼中 pushq 指令編碼爲 1個字節,而上面那兩條指令一共需要8個字節。下圖中前兩欄給出的是,當 %rsp 爲 0x108,%rax 爲 0x123時,執行指令 pushq %rax 的效果。首先 %rsp 會減 8,得到 0x100,然後會將 0x123 存放到內存地址 0x100處。

在這裏插入圖片描述
彈出一個四字的操作包括從棧頂位置讀出數據,然後將棧指針加8,。因此 popq %rax 等價於下面兩條指令:
在這裏插入圖片描述
上圖的第三欄說明的是,在執行完 pushq 後立即執行指令 popq %rdx 的效果。先從內存中讀出值 0x123,再寫到寄存器 %rdx中,然後,寄存器 %rsp 的值將增加回到 0x108。如圖所示,值 0x123 仍然會保持在內存的 0x100 中,直到被覆蓋(例如被另一條入棧操作覆蓋)。無論如何, % rsp 指向的地址總是棧頂。
因爲棧和程序代碼以及其他形式的程序數據都是放在同一內存中,所以程序可以用標準的內存尋址方法訪問棧內的任意位置。例如,假設棧頂元素時四字,指令 movq 8(%rsp),%rdx 會將第二個四字從棧中複製到寄存器 % rdx。

算術和邏輯操作

下圖列出了x86-64的一些整數和邏輯操作。大多數操作都分成了指令類。這些指令類有各種帶不同大小操作數的變種(只有 leaq 沒有其他大小的變種)。例如,指令類 ADD 由四條加法指令組成:addb、addw、addl 和 addq,分別是字節加法、字加法、雙字加法 和 四字加法。事實上,給出的每個指令類都有對這四種不同大小數據的指令。這些操作被分爲四組:加載有效地址、一元操作、二元操作 和 移位。二元操作有兩個操作數,而一元操作有一個操作數。這些操作數的描述方法與 上面所講的一樣。
在這裏插入圖片描述

加載有效地址

加載有效地址(load effective address)指令 leaq 實際上是 movq 指令的變形。它的指令形式是從內存讀取到寄存器,但實際上它根本就沒有引用內存。它的第一個操作數看上去是一個內存引用,但該指令並不是從指定的位置讀入數據,而是將有效地址寫入到目的操作數。在上圖中,我們用C語言的地址操作符 &S 說明這種計算。這條指令可以爲後面的內存引用產生指針。另外,它還可以簡潔地描述普通的算術操作。例如,如果寄存器 %rdx 的值爲 x ,那麼指令 leaq 7(%rdx ,%rdx,4), %rax 將寄存器 %rax 的值設置爲 5x+7 。編譯器經常發現 leaq 的一些靈活用法,根本就與有效地址計算無關。目的操作數必須是一個寄存器。
爲了說明 leaq 在編譯出的代碼中的使用,看下面的C程序:
在這裏插入圖片描述
編譯時,該函數的算術運算以三條 leaq 指令實現,就像右邊註釋說明的的那樣:
在這裏插入圖片描述

leaq指令能執行加法和有限形式的乘法,在編譯如上簡單的算術表達式時,是很有用的。

練習:
在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述
在這裏插入圖片描述

一元和二元操作

第二組中的操作是一元操作,只有一個操作數,既是源又是目的。這個操作數可以是一個寄存器,課可以是一個內存位置。比如說,指令 incq (%rsp)會使得棧頂的 8 字節元素加1。這種語法讓人想起C語言中的加1運算符(++)和減1運算符(–)。
第三組是二元操作,其中,第二個操作數既是源又是目的。這種語法讓人想起C語言中的賦值運算符,例如 x -= y 。不過要注意,源操作數是第一個,目的操作數是第二個,對於不可交換操作來說,這看上去很奇特。例如,指令 subq %rax,%rdx 使寄存器 %rdx的值減去 %rax中的值。(將指令解讀成“從 %rdx 中減去 %rax”)。第一個操作數可以是立即數、寄存器或是內存位置。第二個操作數可以是寄存器或是內存位置。注意,當第二個操作數位內存地址時,處理器必須從內存讀出值,執行操作,再把結果寫回內存。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

移位操作

最後一組是移位操作,先給出移位量,然後第二項給出的是要移位的數。可以進行算術和邏輯右移。移位量可以是一個立即數,或者放在單字節寄存器 %cl 中。(這些指令很特別,因爲只允許以這個特定的寄存器爲操作數)。原則上說,1個字節的移位量使得移位量的編碼範圍可以達到 2的8次方 - 1 = 255。x86-64 中,移位操作對 w 位長的數據值進行操作,移位量是由 %cl 寄存器的低 m 位決定的,這裏2的m次方 = w。高位會被忽略。所以,例如當寄存器 %cl 的十六進制值位 0xFF 時,指令 salb 會移7位,salw 會移動15位,sall會移31位,而salq會移63位。
[salb 說明移位的數是 8 位,即m = 3,0xFF 的低3位是 111,就是 7位,同理, sall 移位的數是32位, m = 5 ,低5位是 11111,就是 31位。]
左移指令有兩個名字:SAL 和 SHL 。兩者的效果是一樣的,都是將右邊填上0.右移指令不同,SAR 執行算術移位(填上符號位),而SHR 執行邏輯移位(填上0)。移位操作的目的操作數可以是一個寄存器或是一個內存位置。
在這裏插入圖片描述
在這裏插入圖片描述

討論

我們上面看到的大多數指令,既可以用於無符號運算,也可以用於補碼運算。
只有右移操作要求區分有符號和無符號數。這個特性使得補碼運算成爲實現有符號整數運算的一種比較好的方法的原因之一。
下圖給出了一個執行算術操作的函數示例,以及它的彙編代碼。參數 x、y和z初始時分貝存放在內存 %rdi、%rsi 和 %rdx中。彙編代碼指令和C源代碼行對應很緊密。第2行計算 x^y的值。指令3和4用 leaq 和移位指令的組合來實現 z * 48。第5行計算 t1 和 0x0F0F0F0F 的 AND值。第6行計算最後的減法。 由於減法的目的寄存器是 %rax ,函數會返回這個值。
在這裏插入圖片描述
在上圖的彙編代碼中,寄存器 %rax 中的值先後對應於程序值 3 * z、z * 48 和 t4(作爲返回值)。通常,編譯器產生的代碼中,會用一個寄存器存放多個程序值,還會在寄存器之間傳送程序值。
在這裏插入圖片描述
在這裏插入圖片描述

特殊的算術操作

正如之前看到的,兩個64位有符號或無符號整數相乘得到的乘積需要 128 位來表示。x86-64指令集對 128位(16字節)數的操作提供有限的支持。延續字(2字節)、雙字(4字節)和四字(8字節)的命名慣例,Intel 把16字節的數稱爲 八字(oct word)。下圖描述的是支持產生兩個64位數字的全128位乘積以及整數除法的指令。
在這裏插入圖片描述
imulq 指令有兩種不同的形式。其中一種,是IMUL 指令類中的一種。這種形式的 imulq 指令是一個 “雙操作數” 乘法指令。它從兩個 64 位操作數產生一個 64位乘積(當將乘積截斷位 64 位時,無符號乘 和 補碼乘 的位級行爲是一樣的。)
此外,x86-64 指令集還提供了兩條不同的 “單操作數”乘法指令,以計算兩個64位值得全 128位乘積——一個是無符號乘法(mulq),另一個是補碼乘法(imulq)。這兩條指令都要求一個參數必須在寄存器 %rax 中,而另一個作爲指令的源操作數給出。然後乘積存放在寄存器 %rdx (高64位)和 %rax(低64位)中。雖然 imulq 這個名字可以用於兩個不同的乘法操作,但是彙編語言可以聽過計算操作數的數目,分辨出用哪條指令。
下面這段C代碼是一個示例,說明了如何從兩個無符號 64 位數字 x 和 y 生成 128位的乘積:
在這裏插入圖片描述

在這個程序中,我們顯式地把 x 和 y 聲明爲 64 位的數字,使用文件 inttypes.h
中聲明的定義,這是對標準C擴展的一部分。不幸的是,這個標準沒有提供 128位的值。所以我們只好依賴GCC提供的 128位整數支持,用名字__int128來聲明。代碼用 typedef 聲明定義了一個整數類型 uint128_t,沿用的 inttypes.h 中其他數據類型的命名規律。這段代碼指明得到的乘積應該存放在指針 dest 指向的16字節處。
在這裏插入圖片描述

可以觀察到,存儲乘積需要兩個 movq 指令:一個存儲低8個字節,一個存儲高8個字節。由於生成這段代碼針對的是小端法機器,所以高位字節存儲在大地之,正如地址8(%rdi)表明的那樣。
前面的算術表沒有列出除法或取模操作。這些操作是由單操作數除法指令來提供的,類似於單操作數乘法指令。有符號除法指令 idivl 將寄存器 %rdx 和 %rax 中的128位數作爲被除數,而除數作爲指令的操作數給出。指令將商存儲在寄存器 %rax 中,將餘數存儲在寄存器 %rdx 中。
對於大多數 64位除法應用來說,除數也常常是一個64位的值。這個值應該存放在 %rax 中,%rdx 的位應該設置爲全0(無符號運算)或者 %rax 的符號位(有符號運算)。後面這個操作可以用指令 cqto 來完成。這條指令不需要操作數——它隱含的讀出 %rax 的符號位,並將它複製在 %rdx 的所有位。
我們用下面這個C函數來說明 x86-64 如何實現除法,它計算了兩個 64 位有符號的商和餘數:
在這裏插入圖片描述
該函數編譯得到如下彙編代碼:
在這裏插入圖片描述

在上述代碼中,必須首先把參數 qp 的地址保存到另一個寄存器中,因爲除法操作要使用參數寄存器 %rdx。接下來,準備被除數,複製並符號擴展 x。除法之後,寄存器 %rax 中的商被保存在 qp,而寄存器 %rdx 中的餘數被保存在 rp。
無符號除法使用 divq 指令。通常,寄存器 %rdx 會事先設置爲0。
在這裏插入圖片描述
在這裏插入圖片描述

控制

到目前爲止,我們只考慮了直線代碼的行爲,也就是指令一條一條的順序執行。C語言中的某些結構,比如條件語句、循環語句 和 分支語句,要求有條件的執行,根據數據測試的結果來決定操作執行的順序。機器代碼提供兩種基本的低級機制來實現由條件的行爲:測試數據值,然後根據測試的結果來改變控制流或者數據流。
與數據相關的控制流是實現由條件行爲的更一般和常見的方法,所以我們現在介紹它。通常,C語言中的語句和機器代碼中的指令都是按照它們在程序中出現的次序,順序執行的。用 jump 指令可以改變一組機器代碼指令的執行順序,jump 指令指定控制應該被傳遞到程序的某個其他部分,可能是依賴於某個測試的結果。編譯器必須產生構建在這種低級機制基礎之上的指令序列,來實現C語言的控制結構。
本文會先涉及實現條件操作的兩種方式,然後描述表達循環 和 switch 語句的方法。

條形碼

出了整數寄存器,CPU還維護着一組單個位的條件碼(condition code)寄存器,它們描述了最近的算術或邏輯操作的屬性。 可以檢測這些寄存器來執行條件分支指令。最常用的條件碼有:
CF:進位標誌。最近的操作使最高位產生了進位。可以來檢查無符號操作的溢出。
ZF:零標誌。最近的操作得出的結果爲0。
SF:符號標誌。最近的操作得到的結果爲負數。
OF:溢出標誌。最近的操作導致一個補碼溢出——正溢出或負溢出。

比如說,假設我們有一條 ADD 指令完成等價於 C 表達式 t = a + b 的功能,這裏變量 a、b 和 t 都是整型的。然後,根據下面的C表達式來設置條形碼:
在這裏插入圖片描述

leaq 指令不改變任何條件碼,因爲它是用來進行地址計算的。對於邏輯操作,例如 XOR,進位標誌和溢出標誌會設置成0。對於移位操作,進位標誌將設置爲最後一個被溢出的位,而溢出標誌設置爲0。INC 和 DEC 指令會設置溢出和零標誌,但是不會改變進位標誌,至於原因,我們就不在這裏深入探討了。
有兩類指令只設置條件碼而不改變任何其他寄存器;如下圖,CMP指令根據兩個操作數之差來設置條件碼。除了只設置條件碼而不更新目的寄存器之外,CMP指令與SUB指令的行爲是一樣的。在ATT格式中,列出操作數的順序是相反的,這使代碼有點難度。如果兩個操作數相等,這些指令會將零標誌設置爲1,而其他的標誌可以用來確定兩個操作數之間的大小關係。TEST 指令的行爲與 AND指令 一樣,出了它們只設置條件碼而不改變目的寄存器的值。典型的用法是,兩個操作數是一樣的(例如,testq %rax,%rax 用來檢查 %rax是負數、零還是正數),或其中的一個操作數是一個掩碼,用來指示哪些位應該被測試。
在這裏插入圖片描述

訪問條件碼

條件碼通常不會直接讀取,常用的使用方法有三種:

  1. 根據條件碼的某種組合,將一個字節設置爲0或者1
  2. 可以條件跳轉到程序的某個其他的部分
  3. 可以有條件地傳送數據
    對於第一種情況,下圖中描述的指令根據條件碼的某種組合,將一個字節設置爲0或者1。我們將這一整類指令稱爲 SET 指令;它們之間的區別就在於它們考慮的條件碼的組合是什麼,這些指令名字的不同後綴指明瞭它們所考慮的條件碼的組合。這些指令的後綴表示不同的條件而不是操作數大小,瞭解這一點很重要。例如,指令 setl 和 setb 表示“小於時設置(set less)”和“低於時設置(set below)”,而不是“設置長字(set long word)”和 “設置字節(set byte)”。
    在這裏插入圖片描述
    一條 SET 指令的目的操作數是低位單字節寄存器元素之一,或是一個字節的內存位置,指令會將這個字節設置成0或者1.爲了得到一個 32 位或64位結果,我們必須對高位清零。一個計算C語言表達式 a < b的典型指令序列如下圖所示,這裏 a和 b 都是 long 類型:
    在這裏插入圖片描述
    注意cmpq指令的比較順序。雖然參數列出的順序先是 b 再是 a,實際上比較的是 a 和 b。還要記得,movzbl 執行不僅會把 %eax 的高3個字節清零,還會把整個寄存器 %rax 的高4個字節都清零。
    某些底層的機器指令可能有多個名字,我們稱之爲“同義名(synonym)”。比如說,setg(表示“設置大於”)和 setnle(表示“設置不小於等於”)指的就是同一條機器指令。
    雖然雖有的算術和邏操作都會設置條件碼,但是各個SET命令的描述都適用的情況是:執行比較指令,根據計算 t = a - b 設置條件碼。
    來看sete的情況,即“當相等時設置(set when equal)”指令。當 a = b時,會得到t = 0,因此零標誌置位就表示相等。
    測試一個有符號數比較,在執行 t = a - b 時用 setl,即“當小於時設置(set when less)”指令。當OF被設置爲1,當且僅當SF被設置爲0,有 a < b。所以 OF 和 SF 的異或提供了 a < b是否爲真的測試。
    測試一個無符號數比較,在執行t = a - b時,CMP指令會設置進位標誌,因而無符號比較實用的是 進位標誌和零標誌的組合。
    注意到機器代碼如何區分有符號和無符號值是很重要的。同C語言不同,機器代碼不會將每個程序值都和一個數據類型聯繫起來。相反,大多數情況下,機器代碼對於有符號和無符號兩種情況都使用一樣的指令,這是因爲許多算術運算對無符號和補碼算術都有一樣的位級行爲。有些情況需要用不同的指令來處理有符號和無符號操作,例如,使用不同版本的右移、除法和乘法指令,以及不同的條件碼組合。
    在這裏插入圖片描述

在這裏插入圖片描述

在這裏插入圖片描述
在這裏插入圖片描述

跳轉指令

正常執行的情況下,指令按照它們出現的順序一條一條地執行。跳轉(jump)指令會導致執行切換到程序中一個全新的位置。在彙編代碼中,這些跳轉的目的地通常有一個標號(label)指明。
在產生目標代碼文件時,彙編器會確定所有帶標號指令的地址,並將跳轉目標( 目的指定的地址)編碼爲跳轉指令的一部分。
下圖列舉了不同的跳轉指令。jmp指令是無條件跳轉。它可以是直接跳轉,即跳轉目標是作爲指令的一部分編碼的;也可以是間接跳轉,即跳轉目標目標時從寄存器或內存位置中讀出的。彙編語言中,直接跳轉是給出一個標號作爲跳轉目標的,舉個例子:
jmp *%rax (用寄存器 %rax 中的值作爲跳轉目標)
jmp *(% rax) (以 %rax中的值作爲地址,從內存中讀出跳轉目標)
在這裏插入圖片描述

上表中所示的其他跳轉指令都是有條件的——它們根據條件碼的某種組合,或者跳轉,或者繼續執行代碼序列中下一條指令。這些指令的名字和跳轉條件與 SET 指令的名字和設置條件是相匹配的。同 SET 指令一樣,一些底層的機器指令有多個名字。條件轉移只能是直接挑戰。

跳轉指令的編碼

雖然我們不關心機器代碼格式的細節,但是理解跳轉指令的目標如何編碼,對之後研究鏈接非常重要。此外,它也能幫助理解反彙編器的輸出。在彙編代碼中,跳轉目標用符號標號書寫。彙編器,以及後來的鏈接器,會產生跳轉目標的適當編碼。跳轉指令有幾種不同的編碼,但是最常用都是 PC 相對的(PC - relative)。也就是,它們會將目標指令的地址與緊跟在跳轉指令後面那條指令的地址之間的差作爲編碼。這些地址偏移量可以編碼爲1、2或4個字節。第二種編碼方式是給出“絕對”地址,用4個字節直接指定目標。彙編器和鏈接器會選擇適當的跳轉目的編碼。

下面是一個 PC相對尋址 的例子,這個函數的彙編由編譯文件 branch.c 產生。它包含兩個跳轉:第2行的 jmp 指令前向跳轉到更高的地址,而第7行的 jg 指令後向跳轉到較低的地址。
在這裏插入圖片描述

右邊反彙編器產生的註釋中,第2行中跳轉指令的跳轉目標指明爲 0x8,第5行中的跳轉指令的跳轉目標是 0x5(反彙編器以十六進制格式給出所有的數字)。不過,觀察指令的字節編碼,會看到第一條跳轉指令的目標編碼(在第二個字節中)位 0x03。把它加上 0x5 就是下一條指令的地址,就得到跳轉目標地址 0x8,也就是第4行指令的地址。

類似,第二個跳轉指令的目標用單字節、補碼錶示編碼爲 0xf8,將這個數加上 0xd,即第6行指令的地址,我們得到 0x5,即 第3行指令的地址。

這些例子說明,當執行PC相對尋址時,程序計數器的值是跳轉指令後面的那條指令的地址,而不是跳轉指令本身的地址。這種管理可以追溯到早起,當時的處理器會將更新程序計數器作爲執行一條指令的第一步。

【總結一下,跳轉目標地址 = (跳轉指令)下一條指令的地址 + 跳轉指令的目標編碼位(在第二個字節中,並且是十六進制的補碼錶示)】

下面是鏈接後的程序反彙編版本:
在這裏插入圖片描述
這些指令被重定位到不同的地址,但是第2行和第5行中跳轉目標的代碼並沒有變。通過使用與 PC相對的跳轉目標編碼,指令編碼很簡潔,而且目標代碼可以不做改變就移到內存中不同的位置。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
【這裏的D答案,0xffffff73 + 0x004005ed = 0x100400560 截斷後爲 0x00400560】

用條件控制來實現條件分支

將條件表達式和語句從C語言翻譯成機器代碼,最常用的方式是結合有條件和無條件跳轉。(另一種方式在下一節會看到,有些條件可以用數據的條件轉移實現,而不是用控制的條件轉移來實現)。例如,下圖a給出了一個計算兩數之差絕對值的函數的C代碼。這個函數有一個副作用,會增加兩個計數器,編碼爲全局變量 It_cnt 和 ge_cnt 之一。GCC產生的彙編代碼如下圖c所示。把這個機器代碼再轉換成C語言,我們稱之爲函數 gotodiff_se (下圖b)。它使用了C語言中的 goto 語句,這個語句類似於彙編代碼中的無條件跳轉。使用 goto語句通常認爲是一種不好的編程風格,因爲它會使代碼非常難以閱讀和調試。本文中使用 goto 語句,是爲了構造描述彙編代碼程序控制流的C程序。我們稱這樣的編程風格爲“goto 代碼”。

在 goto 代碼中,第5行中的 goto x_ge_y 語句會導致跳轉到第9行中的標號 x_ge_y出(當x >= y時會進行跳轉),從這一點繼續執行,完成函數 adsdiff_se 的 else 部分並返回。另一方面,如果測試 x >= y 失敗,程序會計算 adbdiff_se 的 if 部分指定的步驟並返回。

彙編代碼的實現(下圖c)首先比較了兩個操作數嗎,設置條件碼。如果比較的結果表明 x >= y 。那麼它就會跳轉到第8行 ,增加全局變量 ge_cnt,計算 x - y 作爲返回值並返回。由此我們可以看到 adsdiff_se 對應彙編代碼的控制流非常類似於 gotodiff_se 的goto代碼。
在這裏插入圖片描述
在這裏插入圖片描述

C語言中的 if-else 語句的通用形式魔板如下:
在這裏插入圖片描述
對於這種通用形式,彙編實現通常會使用下面這種形式,這裏,用C語法來描述控制流:
在這裏插入圖片描述
也就是,彙編器位 then-statement 和 else-statement 產生各自的代碼塊。它會插圖條件和無條件分支,以保證能執行正確的代碼塊。
在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

【小提示:可能你沒能一次記住跳轉指令的條件,本題目中的 jge 是 “大於等於”則跳轉】
在這裏插入圖片描述
在這裏插入圖片描述

用條件傳送來實現條件分支

實現條件操作的傳統方法是通過使用控制的條件轉移。當條件滿足時,程序沿着一條執行路徑執行,當條件不滿足時,就走另外一條路徑。這種機制簡單通用,但是再現代處理器上,它可能會非常低效。

一種替代的策略是使用數據的條件轉移。這種方法計算一個條件操作的兩種結果,然後再根據條件是否滿足從中選取一個。只有在一些受限制的情況中,這種策略纔可行,但是如果可行,就可以用一條簡單的條件傳送指令來實現它,條件傳送指令更符合現代處理器的性能特性。

【看起來只有計算兩種結果的成本不高的時候纔有效】

下圖a給出了一個可以用條件傳送編譯的示例代碼。這個函數計算參數 x 和 y 差的絕對值,和前面的例子一樣。不過在前面的例子中,分支裏有副作用,會修改 lt_cnt 和 ge_cnt 的值,而這個版本只是簡單地計算函數要返回的值。

GCC爲該函數產生的彙編代碼如圖c所示,它與圖b中所示的C函數 cmovdiff 有相似的形式。研究這個C版本,我們可以看到它既計算了 y - x ,也計算了 x - y,分別命名爲 rval 和 eval。然後再測試最後返回結果。
在這裏插入圖片描述
在這裏插入圖片描述

爲了理解爲什麼基於條件數據傳送的代碼會比基於條件控制轉移的代碼性能好,我們先了解一些關於現代處理器如何運行的知識。處理器通過使用流水線(pipelining)來獲得高性能,在流水線中,一條指令的處理要經過一系列的階段,每個階段執行所需操作的一小部分(例如,從內存取指令、確定指令類型、從內存讀數據、執行算術運算、向內存寫數據,以及更新程序計數器)。這種方法通過重疊連續指令的步驟來獲得高性能,例如,在取一條指令的同事,執行它前面一條指令的算術運算。要做到這一點,要求能夠事先確定要執行的指令序列,這樣才能保持流水線中充滿了待執行的指令。當機器遇到條件轉移時,只有當分支條件求值完成後,才能決定分支往哪裏走。處理器採用非常精密的分支 預測邏輯來猜測每條跳轉指令是否會執行。只要它的猜測還比較可靠(現代微處理器設計試圖達到 90% 以上的成功率),指令流水線中就會充滿着指令。另一方面,錯誤預測一個跳轉,要求處理器丟掉它爲該跳轉指令後所有指令已做的工作,然後再開始從正確位置處起始指令去填充流水線。正如我們會看到的,這樣一個錯誤預測會招致很嚴重的懲罰,浪費大約15-30個時鐘週期,導致程序性能嚴重下降。

作爲一個示例,我們在Intel Haswell 處理器上運行 adsdiff 函數,用兩種方法來實現條件操作。在一個典型的應用中, x < y的結果非常地不可預測,因此即使是最精密的分支預測硬件也只能有大約 50% 的概率猜對。對此,兩個代碼序列中的計算執行都只需要一個時鐘週期。因此,分支預測錯誤除法主導着這個函數的性能。對於包含條件跳轉的 x86-64 代碼,我們發現當分支行爲模式很容易預測時,每次調用函數需要大約 8 個時鐘週期;而分支行爲模式隨機的時候,每次大約 17.5 個時鐘週期。由此我們可以推斷出分支預測錯誤的處罰是大約 19 個時鐘週期。這就意味着函數需要的時間範圍大約在 8 到 27 個週期之間,這依賴於分支預測是否準確。

另一方面,無論測試的數據是什麼,編譯出來使用條件傳送的代碼所需的時間都是大約 8 個時鐘週期。控制流不依賴於數據,這使得處理器更容易保持流水線是滿的。

下圖中例舉了 x86-64 上一些可用的條件傳送指令。每條指令都有兩個操作數:源寄存器 或者 內存地址 S,和目的寄存器 R。源值可以從內存或者源寄存器中讀取,但是隻有在指定的條件滿足時,纔會被複制到目的寄存器中。

在這裏插入圖片描述

爲了理解如何通過條件數據傳輸來實現條件操作,考慮下面的條件表達式和賦值的通用形式:
在這裏插入圖片描述
用條件控制轉移的標準方法來編譯這個表達式會得到如下:
在這裏插入圖片描述
這段代碼包含兩個代碼序列:then-expr求值,else-expr求值。條件跳轉和無條件跳轉結合起來使用是爲了保證只有一個序列執行。
基於條件傳送的代碼,會對 then-expr 和 else-expr 都求值,最終值的選擇基於對 test-expr 的求值。
在這裏插入圖片描述
這個序列中的最後一條語句是用條件傳送實現的——只有當測試條件 t 滿足時, vt 的值纔會被複制到 v 中。

不是所有的條件表達式都可以用條件傳送來編譯。最重要的是,無論結果如何,我們給出的抽象代碼會對 then-expr 和 else-expr 都求值。如果這兩個表達式中的任意一個可能產生錯誤條件或者副作用,就會導致非法的行爲。
看看這個例子:
在這裏插入圖片描述
乍一看,很適合被編譯成使用條件傳送,當指針位空時將結果設置爲0,
在這裏插入圖片描述
不過,這個實現是非法的,因爲即使當測試爲假的時,movq指令對 xp 的間接引用還是發生了,導致一個間接引用空指針的錯誤。

使用條件傳送也不總是會提高代碼的效率。例如,如果 then-expr 或者 else-expr 的求值需要大量的計算,那麼當相對應的條件不滿足時,這些工作就白費了。編譯器必須考慮浪費的計算和由於分支預測錯誤所造成的性能處罰之間的相對性能。說實話,編譯器並不具有足夠的信息來做出可靠的決定。例如,它們不知道分支會多好地遵循可預測的模型。對GCC的實驗表明,只有當兩個表達式都很容易計算式,它纔會使用條件傳送。根據經驗,即使許多分支預測錯誤的開銷會超過更復雜的計算,GCC還是會使用條件控制轉移。

所以總的來說,條件數據傳送提供了一種條件控制轉移來實現條件操作的代替策略。它們只能用於非常受限制的情況,但是這些情況還是相當常見的,而且與現代處理器的運行方式更契合。
在這裏插入圖片描述
在這裏插入圖片描述
【小提示:cmovns 的傳送條件爲 非負數(通過判斷 SF 的值),之前的 testq 會影響 SF 的值】
在這裏插入圖片描述
【我不知道爲啥 “負數要加偏移量” 】

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

循環

C語言提供了多種循環結構,即 do-while 和 for。彙編中沒有相應的指令存在,可以用條件測試和跳轉組合起來實現循環的效果。GCC和其他彙編器產生的循環代碼主要基於兩種基本的循環模式。我們會循序漸進地研究循環的翻譯,從 do-while 開始,然後研究具有更復雜實現的循環,並覆蓋這兩種模式。

  1. do-while 循環
    do-while 語句的通用形式如下:
    在這裏插入圖片描述
    這個循環的效果就是重複執行 body-statement,對 test-expr 求值,如果求值的結果爲非零,就繼續循環。可以看到,body-statement 至少會執行一次。

這種通用形式可以被翻譯成如下所示的條件個 goto 語句:
在這裏插入圖片描述
也就是說,每次循環,程序會執行循環體裏的語句,然後執行測試表達式。如果測試爲真,就回去再執行一次循環。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

圖a 給出了一個函數的實現,用do-while循環來計算函數參數的階乘,寫出n!。這個函數只計算 n > 0 時 n的階乘的值。
圖b所示的 goto 代碼展示瞭如何把循環變成低級的測試和條件跳轉的組合。result 初始化之後,程序開始循環。首先執行循環體,包括更新變量 result 和 n。然後測試 n > 1,如果是真,跳轉到循環開始處。圖c 所示的彙編代碼就是 goto 代碼的原型。條件跳轉指令 jg 是實現循環的關鍵指令,它決定了是需要基礎重複還是退出循環。
在這裏插入圖片描述
在這裏插入圖片描述

逆向工程像圖c 中那樣的彙編代碼,需要確定哪個寄存器對應的是哪個程序值。本例中,這個對應關係很容易確定:我們知道 n 在寄存器 %rdi 中傳遞給函數。可以看到寄存器 %rax 初始化爲 1 。(注意,雖然指令的目的寄存器是 %eax,它實際上還會把 %rax 的高4字節設置爲0)。還可以看到這個寄存器還會在第4行被乘法改變值。此外,% rax 用來返回函數值,所以通常會用來存放需要返回的程序值。因此我們斷定 %rax 對應程序值 result。

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

  • 逆向工程循環
    理解產生的彙編代碼與原始源代碼之間的關係,關鍵是找到程序值和寄存器之間的映射關係。對於上圖的循環來說,這個任務非常簡單,但是對於更復雜的程序來說,就可能是更具挑戰性的任務。C語言編譯器常常會重組計算,因此有些C代碼中的變量帶機器代碼中沒有對應的值;而有時,機器代碼中又會引入源代碼不存在的新值。此外,編譯器還常常試圖將多個程序值映射到一個寄存器上,來最小化寄存器的使用率。

我們描述 fact_do 的過程對於逆向工程循環來說,是一個通用的策略。看看在循環之前如何初始化寄存器,在循環中如何更新和測試寄存器,以及在循環之後又如何使用寄存器。這些步驟中的每一步都提供了一個線索,組合起來就可以解開謎團。做好準備,你會看到令人驚奇的變換,其中有些情況很明顯是編譯器能夠優化代碼,而有些情況很難解釋編譯器爲什麼要選用那些奇怪的策略。GCC常常做的一些變換,非但不能帶來性能好處,反而甚至可能降低代碼性能。

  1. while 循環
    while 語句的通用形式如下:
    在這裏插入圖片描述

與 do-while 不同之處在於,在第一次執行 body-statement 之前,它會對 test-expr 求值,循環有可能就中止了。有很多方法將while 循環翻譯成機器代碼,GCC在代碼生成和使用其中的兩種方法。這兩種方法使用同樣的循環結構,與 do-while 一樣,不過它們實現初始測試的方法不同。

第一種翻譯方法,我們稱之爲跳轉到中間(jump to middle),它執行一個無條件跳轉跳到循環結尾處的測試,以此來執行初始的測試,如下所示:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

作爲一個示例,圖a 給出了使用 while 循環的階乘函數的實現。這個函數能夠精確地計算 0! = 1。它旁邊的函數 fact_while_jm_goto 是GCC帶優化命令行選項 -Og 時產生的彙編代碼的C語言翻譯。比較 face_while 和 face_do 的代碼,可以看到它們非常相似,區別僅在於循環前的 goto test 語句使得程序在修改 result 或 n 的值之前,先執行對 n 的測試。圖c 給出的是實際產生的彙編代碼。
在這裏插入圖片描述
在這裏插入圖片描述

第二種翻譯方法,我們稱之爲 guarded-do ,首先用條件分支,如果初始條件不成立就跳過循環,把代碼變換爲 do-while 循環。當使用較高優化等級編譯時,例如使用命令行選項 -O1 ,GCC會採用這種策略。可以用如下模板來表達這種方法,把通用的 while 循環格式翻譯成 do-while 循環:
在這裏插入圖片描述
翻譯成 goto 代碼如下:
在這裏插入圖片描述

利用這種實現策略,編譯器常常可以優化初始的測試,例如認爲測試條件總是滿足。

再來看個例子,下圖給出了所示階乘函數同樣的C代碼,不過給出的是GCC使用命令行選項 -O1 時的編譯。圖c給出的是實際生成的彙編代碼,圖b 是這個彙編代碼更易讀的C語言表示。根據 goto 代碼,可以看到如果對於 n 的初始值有 n <= 1,那麼將跳過該循環。該循環本身的基本結構與該函數 do-while 版本產生的結構一樣。不過,一個有趣的特性是,循環測試從原始C代碼的 n > 1 變成了 n ≠ 1。編譯器知道只有當 n > 1時纔會進去循環,所以將 n 減 1 意味着 n > 1 或者 n = 1。因此,測試 n ≠ 1 就等價於測試 n <= 1。

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

  1. for 循環
    for 循環的通用形式如下:
    在這裏插入圖片描述

C語言標準說明,這樣一個循環的行爲與下面這段使用 while 循環的代碼的行爲一樣:
在這裏插入圖片描述

程序首先對初始表達式 init-expr 求值,然後進入循環;在循環中它先對測試條件 test-expr 求值,如果測試結果爲假就會推出,否則執行循環體 body-statement ;最後最更新表達式 update-expr 求值。

GCC爲for循環產生的代碼是while循環的兩種翻譯之一,這取決於優化的等級,也就是,跳轉到中間策略會得到如下 goto 代碼:
在這裏插入圖片描述
在這裏插入圖片描述

而 guarded-do 策略得到:
在這裏插入圖片描述

作爲一個示例,考慮用 for 循環寫的階乘函數:
在這裏插入圖片描述

如上述代碼所示,用 for 循環編寫階乘函數最自然的方式就是將從 2 一直到 n 的因子乘起來,因此,這個函數與我們使用 while
或者 do-while 循環的代碼很不一樣。

這段代碼中的 for 循環的不同組成部分如下:
在這裏插入圖片描述

用這些部分替換前面給出的模板中相應的位置,就把 for 循環轉換成了 while 循環,得到如下代碼:

在這裏插入圖片描述

對 while 循環進行跳轉到中間變換,得到如下 goto 代碼:
在這裏插入圖片描述

確實,仔細看使用命令行選項 -Og 的GCC產生的彙編代碼,會發現它非常接近於以下模板:
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

綜上,C語言中三種形式的所有的循環——do-while 、while 和 for ——都可以用一種簡單的策略來翻譯,產生包含一個或多個條件分支的代碼。控制的條件轉移提供了將循環翻譯成機器代碼的基本機制。

switch 語句

switch 語句可以根據一個整數索引值進行多重分支(multiway branching)。在處理具有多種可能結果的測試時,這種語句特別有用。它們不僅提高了C語言的可讀性,而且通過使用跳轉表(jump table)這種數據結構使得實現更加高效。跳轉表是一個數組,表項 i 是一個代碼段的地址,這個代碼段實現當開關索引值等於 i 時程序應該採取的動作。程序代碼有ongoing開關索引值來執行一個跳轉表內的數組引用,確定跳轉指令的目標。和使用一組很長的 if-else 語句相比,使用跳轉表的優點是執行開關語句的時間與開關情況的數量無關。GCC根據開關情況的數量和開關情況值得稀疏程度來翻譯開關語句。當開關情況數量比較多(例如 4 個以上),並且值的範圍跨度比較小時,就會使用跳轉表。

下圖a 是一個C語言 switch 語句的示例。這個例子有些有意思的特徵,包括情況表號(case table)跨過一個不連續的區域(對於101 和 105 沒有標號),有些情況有多個標號(104 和 106),而有些情況則會落入其他情況之中(102),因爲對應該情況的代碼段沒有以 break 語句結尾。
在這裏插入圖片描述
下圖是編譯 switch_eg時產生的彙編代碼。這段代碼的行爲用C語言來描述就是上圖b中的過程 switch_eg_impl。這段代碼使用了GCC提供的對跳轉表的支持,這是對C語言的擴展。數組 jt 包含 7 個表項,每個都是一個代碼塊的地址。這些位置由代碼中的標號定義,在 jt 的表項中由代碼指針指明,由標號加上’&&'前綴組成。(回想運算符 & 創建一個指向數據值的指針。在做這個擴展時,GCC的作者們創造了一個新的運算符 && ,這個運算符創建一個指向代碼位置的指針。)
在這裏插入圖片描述

原始的C語言有針對值 100、102-104 和 106 的情況,但是開關變量 n 可以是任意整數。編譯器首先將 n 減去 100,把取值範圍移到 0 - 6 之間,創建一個新的程序變量,在我們的C版本中稱爲 index。補碼錶示的負數會映射成無符號表示的大正數,利用這一事實,將 index 看作 無符號值,從而進一步簡化了分支的可能性。因此可以通過測試 index 是否大於 6 來判定 index 是否在 0 - 6 的範圍之外。在C和彙編代碼中,根據 index 的值,有五個不同的跳轉位置:loc_A(在彙編代碼中表示爲.L3)、loc_B(.L5)、loc_C(.L6)、loc_D(.L7) 和 loc_def(.L8),最後一個是默認的目的地址。每個標號都標識一個實現某個情況分支的代碼塊。在C和彙編代碼中,程序都是講 index 和 6 做比較,如果大於 6 就跳轉到默認的代碼處。

執行 switch 語句的關鍵步驟是通過跳轉表來訪問代碼位置。在 C 代碼中是第 16 行,一條 goto 語句引用了跳轉表 jt。GCC支持計算 goto(computed goto),是對C語言的擴展。在我們的彙編代碼版本中,類似地操作是在第 5 行,jmp 指令的操作數有前綴 ‘ * ’,表明這是一個間接跳轉,操作數指定一個內存位置,索引由寄存器 %rsi 給出,這個寄存器保存着 index 的值。

C 代碼將跳轉表聲明爲一個有 7 個元素的數組,每個元素都是一個指向代碼位置的指針。這些元素跨越 index 的值 0-6,對應於 n 的值 100 - 106。可以觀察到,跳轉表對重複情況的處理就是簡單地對錶項 4 和 6 用同樣的代碼表號(loc_D),而對於缺失的情況的處理就是對錶項 1 和 5 使用默認情況的標號(loc_def)。

在彙編代碼中,跳轉表用以下聲明表示,我們添加了一些註釋:
在這裏插入圖片描述

這些聲明表明,在叫做“.rodata(只讀數據,Read-Only Data)”的目標代碼文件的段中,應該有一組 7 個“四”字(8個字節),每個字的值都是與指定的彙編代碼標號(例如.L3)相關聯的指令地址。標號.L4標記出這個分配地址的起始。與這個標號相對應的地址會作爲間接跳轉(第5行)的基地址。

不同的代碼塊(C標號 loc_A 和 loc_D 和 loc_def)實現了 switch 語句的不同分支。它們中的大多數只是簡單地計算了 val 的值,然後跳轉到函數的結尾。類似地,彙編代碼塊計算了寄存器 % rdi 的值,並且跳轉到函數結尾處由標號.L2指示的位置。只有情況標號 102 的代碼不是這種模式的,正好說明在原始 C代碼中情況 102 會落到情況 103 中。具體處理如下:以標號.L5起始的彙編代碼塊中,在快結尾處沒有 jmp 指令,這樣代碼就會繼續執行下一個塊。類似的,C版本 switch_rg_impl 中以標號loc_B 起始的塊的結尾處也 goto 語句。

檢查所有這些代碼需要很仔細的研究,但是關鍵是領會使用跳轉表是一種非常有效的實現多重分支的方法。在我們的例子中,程序可以只用一次跳轉表引用就分支到 5 個不同的位置。甚至當 switch 語句有上百種情況的時候,也可以只用一次跳轉表訪問去處理。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

過程

過程是軟件中一種很重要的抽象。 它提供了一種封裝代碼的方式,用一組指定的參數和一個可選的返回值實現了某種功能。然後,可以在程序中不同的地方調用這個函數。設計良好的軟件用過程作爲抽象機制,隱藏某個行爲的具體實現,同時又提供清晰簡潔地接口定義,說明要計算的是哪些值,過程會對程序狀態產生什麼樣的影響。不同編程語言中,過程的形式多樣:函數(function)、方法(method)、子例程(subroutine)、處理函數(handler)等等,但是它們有一些共有的特性。

要提供對過程的機器級支持,必須要處理許多不同的屬性。爲了討論方便,假設過程 P 調用過程 Q,Q執行後會返回到 P。這些動作包括一個或多個機制:
傳遞控制。在進入過程 Q 的時候,程PC必須被設置爲 Q 的代碼的起始地址,然後在返回時,要把PC設置爲 P 中調用 Q後面那條指令的地址。
傳遞數據。P 必須能夠想 Q 提供一個或多個參數,Q 必須能夠向 P 返回一個值。
分配和釋放內存 。在開始時,Q 可能需要爲局部變量分配空間,而在返回前,又必須釋放這些存儲空間。
x86-64 的過程實現包括一組特殊的指令和一些對機器資源(例如寄存器和程序內存)使用的約定規則。人們花了大量的力氣來儘量減少過程調用的開銷。所以,它遵循了被認爲是最低要求策略的方法,只實現上述機制中每個過程所必須的那些。接下來,我們一步步地構建起不同的機制,先描述控制,再描述數據傳遞,最後是內存管理。

運行時棧

C語言過程調用機制的一個關鍵特性(大多數其他語言也是如此)在於使用了棧數據結構提供的後進先出的內存管理原則。在過程 P 調用過程 Q 的例子中,可以看到當 Q 在執行時,P 以及所有在向上追溯到 P 的調用鏈中的過程,都是暫時被掛 起的。當 Q 運行時,它只需要爲局部變量分配新的存儲空間,或者設置到另一個過程的調用。另一方面,當 Q 返回時,任何它所分配的局部存儲空間都可以被釋放。因此,程序可以用棧來管理它的過程所需要的存儲空間,棧和程序寄存器存放着傳遞控制和數據、分配內存所需要的信息。當 P 調用 Q 時,控制和數據信息添加到棧尾。當 P 返回時,這些信息會釋放掉。

x86-64 的棧向低地址方向增長,而棧指針 %rsp 指向棧頂元素。可以用 pushq 和 popq 指令將數據存入棧中或是從棧中取出。將棧指針減少一個適當的量可以爲沒有指定初始值的數據在棧上分配空間。類似地,可以通過增加棧指針來釋放空間。

當 x86-64 過程需要的存儲空間超過寄存器能夠存放的大小時,就會在棧上分配空間。這個部分稱爲過程的棧幀(stack fram)。下圖給出了運行時棧的通用結構,包括把它劃分爲棧幀。當前正在執行的過程的幀總是在棧頂。當過程 P 調用過程 Q 時,會把返回地址壓入棧中,指明當 Q 返回時,要從 P 程序的哪個位置繼續執行。我們把這個返回地址當做 P 的棧幀的一部分,因爲它存放的是與 P 相關的狀態。 Q 的代碼會擴展當前棧的邊界,分配它的棧幀所需的空間。在這個空間中,它可以保存寄存器的值,分配局部變量空間,爲它調用的過程設置參數。大多數過程的棧幀都是定長的,在過程的開始就分配好了。但是有些過程需要邊長的幀,這個問題會在之後討論。通過寄存器,過程 P 可以傳遞最多 6 個整數值(也就是指針和整數),但是如果 Q 需要更多的參數,P 可以在調用 Q 之前在自己的棧幀裏存儲好這些參數。
在這裏插入圖片描述

爲了提高空間和時間效率,x86-64 過程只分配自己所需要的棧幀部分。例如,許多過程有 6 個或者更少的參數,那麼所有的參數都可以通過寄存器傳遞。因此,上圖中畫出的某些棧幀部分可以忽略。實際上,許多函數根本不需要棧幀。當所有的局部變量都可以保存在寄存器中,而且該函數不會調用任何其他函數時,就可以這樣處理。例如,上面例舉過得所有函數都不需要棧幀。

轉移控制

將控制從函數 P 轉移到函數 Q 只需要簡單地把PC設置爲 Q 的代碼的起始位置。不過,當稍後從 Q 返回的時候,處理器必須記錄好它需要繼續 P 的執行的代碼位置。在 x86-64 機器中,這個信息是用指令 call Q 調用過程 Q 來記錄的。該指令會把地址 A 壓入棧中,並將 PC 設置爲 Q 的起始地址。壓入的地址 A 被稱爲返回地址,是緊跟在 call 指令後面的那條指令的地址。對應的指令 ret 會從棧中彈出地址 A,並把 PC 設置爲 A。

下表給出的是 call 和 ret 指令的一般形式:
在這裏插入圖片描述

call 指令有一個目標,即指明被調用過程起始的指令地址。同跳轉一樣,調用可以是直接的,也可以是間接的。在彙編代碼中,直接調用的目標時一個標號,而間接調用的目標時 * 後面跟一個操作數指示符。

在main 函數中,地址爲 0x400563 的 call 指令調用函數 multstore。此時如下圖a的狀態,指令了棧指針 %rsp 和 PC %rip 的值。 call 的效果是將返回地址 0x400568 壓入棧中,並調到函數 multstore 的第一條指令,地址爲 0x0400540。函數 multstore 繼續執行,知道遇到地址 0x40054d 處的 ret 指令。這條指令從棧中彈出值 0x400568,然後跳轉到這個地址,就在 call 指令之後,繼續 main 函數的執行。

在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述

在來看一個更詳細說明在過程間傳遞控制的例子,下圖給出了兩個函數 top 和 leaf 的反彙編代碼,以及 main 函數中調用 top 處的代碼。每條指令都以標號標出:L1 ~ L2(leadf中),T1 ~ T4(main中)和 M1 ~ M2(main中)。
在這裏插入圖片描述

下圖給出了這段代碼執行的詳細過程,main調用 top(100),然後 top 調用 leaf(95)。函數 leaf 向 top 返回 97,然後 top 向 main 返回 194。前面三列描述了被執行的指令,包括指令標號、地址和指令類型。後面四列給出了在該指令執行前程序的狀態,包括寄存器 %rdi、%rax 和 %rsp的內容,以及位於棧頂的值。這張表的內容說明了運行時棧在管理支持過程調用和返回所需存儲空間中的重要作用。
在這裏插入圖片描述

leaf 的指令 L1 將 %rax 設置爲 97,也就是要返回的值。然後指令 L2 返回,它從棧中彈出 0x400054e。通過將 PC 設置爲這個彈出的值,控制轉移回 top 的T3指令。程序成功完成對 leaf 的調用,返回到 top。

指令 T3 將 %rax 設置爲 194,也就是要從 top 返回的值。然後指令 T4 返回,它從棧中彈出 0x400560,因此將PC設置爲 main 的M2 指令。程序成功完成對 top 的調用,返回到main。可以看到,此時棧指針也恢復成了 0x7fffffffe820,即調用 top 之前的值。

可以看到,這種把返回地址壓入棧的簡單的機制能夠讓函數在稍後返回到程序中正確的點。C語言標準的調用/返回機制剛好與棧提供的後進先出的內存管理方法吻合。

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

數據傳送

當調用一個過程時,出了要把控制傳遞給它並在過程返回時再傳遞回來之外,過程調用還可能包括把數據作爲參數傳遞,從而過程返回還有可能包括返回一個值。x86-64 中,大部分過程間的數據傳送是通過寄存器實現的。例如,我們已經看到之前的函數示例,參數在寄存器 %rdi 、%rsi 和其他寄存器中傳遞。當過程 P 調用過程 Q 時,P的代碼必須首先把參數複製到適當的寄存器中。類似地,當 Q 返回到 P 時,P的代碼可以訪問寄存器 %rax 中的返回值。本節中,更詳細地探討這些規則。

x86-64 中,可以通過寄存器最多傳遞 6 個整型(例如整數和指針)參數。寄存器的使用是有特殊順序的,寄存器使用的名字取決於要傳遞的數據類型的大小,如下表所示,會根據參數在參數列表中的順序爲它們分配寄存器。可以通過 64 位寄存器適當的部分訪問小於 64 位的參數。例如,如果第一個參數是 32 位的,那麼可以用 %edi 來訪問它。
在這裏插入圖片描述

如果一個函數有大於6個整型參數,超過6個的部分就要通過棧來傳遞。假設過程 P 調用過程 Q,有n個整型參數,且 n > 6。那麼 P 的代碼分配的棧幀必須要能夠容納 7 到 n 號參數的存儲空間,要把參數 1 ~ 6 複製到對應的寄存器,把參數 7 ~ n 放到棧上,而參數 7 位於棧頂。通過棧傳遞參數時,所有的數據大小都向 8 的倍數對齊。參數到位以後,程序就可以執行 call 指令將控制轉移到過程 Q 了。過程 Q 可以聽過寄存器訪問參數,有必要的話可以通過棧訪問。相應的,如果 Q 也調用了某個有超過 6 個參數的函數,它也需要在自己的棧幀中爲超過 6 個部分的參數分配空間,還記得之前描述棧的圖中的“參數構造區”嗎?

作爲參數傳遞的示例,考慮下圖a 所示的C函數 proc。這個函數有 8 個參數,包括字節數不同的整數(8、4、2 和 1)和不同類型的指針,每個都是 8 字節的。
在這裏插入圖片描述
在這裏插入圖片描述

上圖b 中給出 proc 生成的彙編代碼。前面 6 個參數通過寄存器傳遞,後面 2 個通過棧專遞,就像下圖畫出來的那樣。可以看到,作爲過程調用的一部分,返回地址被壓入棧中。因而這兩個參數位於相對於棧指針距離爲 8 和 16 的位置。在這段代碼中,可以看到根據操作數的大小,使用了 ADD 指令的不同版本:a1(long)使用 addq,a2(int)使用 addl,a3(short)使用 addw,而 a4(char)使用 addb。注意第 6 行的 movl 指令從內存讀入 4 字節,而後面的 addb 指令只使用其中的低位一字節。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

棧上的局部存儲

到目前爲止,我們看到的大多數示例都不需要超出寄存器大小的本地存儲區域。不過有些時候,局部數據必須存放在內存中,常見的情況包括:

  • 寄存器不足夠存放所有的本地數據
  • 對一個局部變量使用地址運算符 ‘ & ’,因此必須能夠爲它產生一個地址
  • 某些局部變量是數組或結構,因此必須能夠通過數組或結構引用被訪問到。在描述數據和結構分配時,會討論這個問題。
    一般來說,過程通過減小棧指針在棧上分配空間。分配的結果作爲棧幀的一部分,標號爲“局部變量”。

來看一個處理地址運算符的例子,下圖a 中給出的兩個函數。函數 swap_add 交換指針 xp 和 yp 指向的兩個值,並返回這兩個值的和。函數 caller 創建到局部變量 arg1 和 arg2 的指針,把它們傳遞給 swap _add。下圖b展示了 caller 是如何用棧幀來實現這些局部變量的。caller 的代碼開始的時候把棧指針減掉 16,實際上這就是在棧上分配了 16 個字節。S表示棧指針的值,可以看到這段代碼計算 &arg2 爲 S + 8(第5行),而 &arg1 爲 S。因此可以推斷局部變量 arg1 和 arg2 存放在棧幀中相對於棧指針偏移量爲 0 和 8 的地方。當對 swap_add 的調用完成後,caller 的代碼會從棧上取出這兩個值(第8-9行),計算它們的差,再乘以 swap_add 在寄存器 %rax 中返回的值(第10行)。最後,該函數把棧指針加16,釋放棧幀(第11行)。通過這個例子可以看到,運行時棧提供了一種簡單的、在需要時分配、函數完成時釋放局部存儲的機制。

在這裏插入圖片描述
在這裏插入圖片描述

函數 call_proc 是一個更復雜的例子,說明 x86-64 棧行爲的一些特性。儘管這個例子有點長,但還是值得研究。它給出了一個必須在棧上分配局部變量存儲空間的函數,同時還要向有 8 個參數的函數 proc 傳遞至。該函數創建一個棧幀。
在這裏插入圖片描述
在這裏插入圖片描述

看看 call_proc 的彙編代碼可以看到,代碼中一大部分(2 ~ 15)是爲調用 proc 做準備。其中包括爲局部變量和函數參數建立棧幀,將函數參數加載至寄存器。如下圖,在棧上分配局部變量 x1 ~ x4,它們具有不同的大小:24 ~ 31(x1)、20 ~ 23(x2)、18 ~ 19(x3)和17(x4)。用 leaq 指令生成到這些位置的指針,(第7、10、12、14行)。參數 7 (值爲4)和 8(指向 x4 的位置的指針)存放在棧中相對於棧指針偏移量爲 0 和 8 的地方。

當調用過程 proc 時,程序會開始執行上圖b中的代碼,參數7 和 8 現在位於相對於棧指針偏移量 8 和 16 的地方,因爲返回地址時已經被壓入棧中了。
在這裏插入圖片描述
當程序返回 call_proc 時,代碼會去除 4 個局部變量(第17 ~ 20行),並執行最終的計算,在程序結束前,把棧指針加 32 ,釋放這個棧幀。

寄存器中的局部存儲空間

寄存器組是唯一被所有過程共享的資源。雖然在給定時刻只有一個過程是活動的,我們仍然必須確保當一個過程(調用者)調用另一個過程(被調用者)時,被調用者不會覆蓋調用者稍後會使用的寄存器值。爲此,x86-64 採用了一組統一的寄存器使用慣例,所有的過程(包括程序庫)都必須遵循。

根據慣例,寄存器 %rbx、%rbp 和 %r12 ~ %r15 被劃分爲被調用者保存寄存器。當過程 P 調用過程 Q 時,Q 必須保存這些寄存器的值,保證它們的值在 Q 返回到 P 時與 Q 被調用時是一樣的。過程 Q 保存一個寄存器的值不變,要麼就是根本不去改變它,要麼就是把原始值壓入棧中,改變寄存器的值,然後在返回前從棧中彈出舊值。壓入寄存器的值會在棧幀中創建標號爲“保存的寄存器”的一部分,有了這條慣例,P 的代碼就能安全地把值存在被調用者保存寄存器中(當然,要先把之前的值保存到棧上),調用 Q,然後繼續使用寄存器中的值,不同擔心值被破壞。

所有其他的寄存器,除了棧指針 %rsp ,都分類爲調用者保存寄存器。這就意味着任何函數都能修改它們。可以這樣來理解“調用者保存”這個名字:過程 P 在某個此類寄存器中有局部數據,然後調用過程 Q。因爲 Q 可以隨意修改這個寄存器,所以在調用之前首先保存好這個數據是P(調用者)的責任。

看個例子,下圖a中的函數 P。它兩次調用 Q。在第一次調用中,必須保存 x 的值以備後面使用。類似地,在第二次調用中,也必須保存 Q(y) 的值。圖b中,可以看到GCC生成的代碼使用了兩個被調用者保存寄存器:%rbp 保存 x 和 %rbx 保存計算出來的Q(y)的值。在函數的開頭,把這兩個寄存器的值保存到棧中(第2 ~ 3 行)。在第一次調用 Q 之前,把參數 x 複製到 %rbp(第5行)。在第二次調用 Q 之前,把這次調用的結果複製到 %rbx (第8行)。在函數的結尾,(第13 ~ 14行),把它們從棧中彈出,恢復這兩個被調用者保存寄存器的值。注意它們的彈出順序與壓入順序相反,說明了棧的後進先出規則。
在這裏插入圖片描述
在這裏插入圖片描述

練習題:
在這裏插入圖片描述
在這裏插入圖片描述

遞歸過程

前面已經描述的寄存器和棧的慣例使得 x86-64 過程能夠遞歸地調用它們自身。每個過程調用在棧中都有它自己的私有空間,因此多個未完成調用的局部變量不會相互影響。此外,棧的原則很自然地就提供了適當的策略,當過程被調用時分配局部存儲,當返回時釋放存儲。

下圖給出了遞歸地階乘函數的C代碼和生成的彙編代碼。可以看到彙編代碼使用寄存器 %rbx 來保存參數 n,先把已有的值保存在棧上(第2行),隨後在返回前恢復該值(第11行)。根據棧的使用特性和寄存器保存規則,可以保證當遞歸調用 rfact( n - 1 )返回時(第9行),(1)該次調用的結果會保存在寄存器 %rax 中,(2)參數 n 的值仍然在寄存器 %rbx 中。把這兩個值相乘就能得到期望的結果。

在這裏插入圖片描述

從這個例子中我們可以看到,遞歸調用一個函數本身與調用其他函數是一樣的 。棧桂策提供了一種機制,每次函數調用都有它自己私有的狀態信息(保存的返回位置和被調用者寄存器保存的值)存儲空間。如果需要,它還可以提供局部變量的存儲。棧分配和釋放的規則很自然地就與函數調用-返回的順序匹配。這種實現函數調用和返回的方法甚至對更復雜的情況也使用,包括相互遞歸調用(例如,P 調用 Q,Q 再調用 P)。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

數組分配和訪問

C語言中的數組是一種將標量數據聚集成更大數據類型的方式。C語言實現數組的方式非常簡單,因此很容易翻譯成機器代碼。C語言的一個不同尋常的特點就是可以產生指向數組中元素的指針,並針對這些指針進行運算。在機器代碼中,這些指針會被翻譯成地址計算。

優化編譯器非常善於簡化數組索引所使用的地址計算。不過這使得 C 代碼和它到機器代碼的翻譯之間的對應關係有些難以理解。

基本原則

對於數據類型 T 和整型常數 N,聲明如下:
T A[N];

起始位置表示爲 xa。這個聲明有兩個效果,首先,它在內存中分配了一個 L * N 字節的連續區域,這裏 L 是數據類型 T 的大小(單位爲字節)。其次,它引入了標識符 A,可以用 A 來作爲指向數組開頭的指針,這個指針的值就是 xa。可以用 0 ~ N-1 的整數索引來訪問該數組元素。數組元素 i 會被存放在地址爲 sa + L * i 的地方。

作爲示例,看看下面的聲明:
在這裏插入圖片描述
這些聲明會產生帶下列參數的數組:
在這裏插入圖片描述

數組 A 由 12 個單字節(char)元素組成。數組 C 由 6 個整數組成,每個需要 8 個字節。 B 和 D 都是指針數組,因此每個數組元素都是 8 個字節。

x86-64 的內存引用指令可以用來簡化數組訪問。例如,假設 E 是一個 int 型的數組,而我們想計算 E[i],在此,E 的地址存放在寄存器 % rdx 中,而 i 存放在寄存器 % rcx中。然後,指令 movl (%rdx,%rcx,4),%eax 會執行地址計算 xe + 4 * i ,讀這個內存位置的值,並將結果存放到寄存器 %eax 中。允許的伸縮因子1、2、4 和 8覆蓋了所有基本簡單數據類型的大小。
在這裏插入圖片描述
在這裏插入圖片描述

指針運算

C 語言允許對指針進行運算,而計算出來的值會根據該指針引用的數據類型的大小進行伸縮。也就是說,如果 p 是一個指向類型爲 T 的數據的指針,p 的值爲 xp,那麼表達式 p + i 的值爲 xp + L* i,這裏 L 是數據類型 T 的大小。

單操作數操作符‘ & ’和‘ * ’可以產生指針和間接引用指針。也就是,對於一個表示某個對象的表達式 Expr ,&Expr 是給出該對象地址的一個指針。對於一個表示地址的表達式 AExpr,&AExpr 給出該地址處的值。因此,表達式 Expr 與 * &Expr是等價的。可以對 數組和指針應用數組下標操作。數組引用 A[i] 等同於表達式 *(A + i)。它計算第 i 個數組元素的地址,然後訪問這個內存位置。

擴展一下之前的例子,假設整形數組 E 的起始地址和整數索引 i 分別存放在寄存器 %rdx 和 %rcx 中。下面是一些與 E 有關的表達式。我們還給出了每個表達式的彙編代碼實現,結果存放在寄存器 %eax(如果是數據)或寄存器 %rax(如果是指針)中。
在這裏插入圖片描述
(上圖第二行彙編代碼改爲 movl (%rdx),%eax)

在這些例子中,可以看到返回數組值的操作類型爲 int,因此設計 4 字節操作(例如 movl)和寄存器(例如 %eax)。那些返回指針的操作類型爲 int *,因此涉及 8 字節操作(例如 leaq)和寄存器(例如 %rax)。最後一個例子表明可以計算同一個數據結構中的兩個指針之差,結果的數據類型爲 long,值等於兩個地址之差除以該數據類型的大小。
在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述

嵌套的數組

當我們創建數組的數組時(多維數組),數組分配和引用的一般原則也是成立的。例如,聲明 int A[5][3]; 等價於下面的聲明:
在這裏插入圖片描述

數據類型 row3_t 被定義爲一個 3 個整數的數組,數組 A 包含 5 個這樣的元素,每個元素需要 12 個字節來存儲 3個整數。整個數組的大小就是 12 * 5 = 60字節。

數組 A 還可以被看成一個 5行3列 的二維數組,用 A[0][0] 到 A[4][2]來引用。數組元素在內存中按照“行優先”的順序排列,意味着第 0 行的所有元素,可以寫作 A[0],後面跟着第 1 行的所有元素(A [1]),以此類推,如下圖。

在這裏插入圖片描述

這種排列順序是嵌套聲明的結果。將 A 看作一個有 5 個元素的數組,每個元素都是 3 個 int 的數組,首先 A[0],然後是 A[1],以此類推。

要訪問多維數組的元素,編譯器會移數組起始爲基地址(可能需要經過伸縮)偏移量爲索引。產生計算期望的元素的偏移量,然後使用某種 MOV 指令。通常來說,對於一個聲明如下的數組:
T D[R][C];

它的數組元素 D[i][j]內存地址爲:
在這裏插入圖片描述

這裏, L 是數據類型 T 以自己爲單位的大小。作爲一個示例,考慮前面定義的 5 * 3 的整形數組 A。假設 xa、i 和 j 分別在寄存器 %rdi、%rsi 和 %rdx 中。然後,可以用下面的代碼將數組元素 A[i][j] 複製到寄存器 %eax 中:
在這裏插入圖片描述

正如可以看到的那樣,這段代碼計算元素的地址爲 xa + 12i + 4j = xa + 4(3i + j),使用了 x86-64 地址運算的伸縮和加法特性。
在這裏插入圖片描述
在這裏插入圖片描述

定長數組

C語言編譯器能夠優化定長多維數組上的操作代碼。這裏我們展示優化等級設置爲 -o1 時GCC採用的一些優化。假設我們用如下方式將數據類型 fix_matrix 聲明爲 16 * 16 的整形數組:
在這裏插入圖片描述

(這個例子說明了一個很好的編碼習慣。當程序要用一個常數作爲數組的維度或者緩衝區的大小時,最好通過 #define 聲明將這個常數與一個名字聯繫起來,然後在後面一直使用這個名字代替常數的數值)。下圖a中的代碼計算矩陣 A 和 B 乘積的元素 i,k,即 A 的行 i 和 B 的列 k 的內積。GCC產生的代碼(我們再反彙編成 C),如下圖b中函數 fix_prod_ele_opt 所示。這段代碼包含很多聰明的優化。它去掉了整數索引 j ,並把所有的數組引用都轉換成了指針間接引用,其中包 括(1)生成一個指針,命名爲 Aptr,指向 A 的行 i 中連續的元素;(2)生成一個指針,命名爲 Bptr,指向 B 的列 k 中連續的元素;(3)生成一個指針,命名爲 Bend,當需要終止該循環時,它會等於 Bptr 的值。Aptr 的初始值是 A 的行 i 的第一個元素的地址,由 C 表達式 &A[i][0] 給出。Bptr 的初始值是 B 的列 k 的第一個元素的地址,由 C 表達式 &B[0][k] 給出。Bend 的值是假象中 B 的列 j 的第(n + 1)個元素的地址,由 C 表達式 &B[N][k]給出。
在這裏插入圖片描述

在這裏插入圖片描述

下面給出的是 GCC 爲函數 fix_prod_ele 生成的這個循環的實際彙編代碼。我們看到 4 個寄存器的使用如下: %eax 保存 result,%rdi 保存 Aptr,%rcx 保存 Bptr,而 %rsi 保存 Bend。

在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

變長數組

歷史上,C語言只支持大小在編譯時就能確定的多維數組(對第一維可能有些例外)、程序員需要變長數組時不得不用 malloc 或 calloc 這樣的函數爲這些數組分配存儲空間,而且不得不顯式地編碼,用行優先索引將多維數組映射到一維數組。ISO C99 引入了一種功能,允許數組的維度是表達式,在數組被分配的時候才計算出來。

在變長數組的 C 版本中,我們可以將一個數組聲明如下:
int A[expr1][expr2];

它可以作爲一個局部變量,也可以作爲一個函數的參數,然後在遇到這個聲明的時候,通過對錶達式 expr1 和 expr2 求值來確定數組的維度。因此,例如要訪問 n * n 數組的元素 i ,j ,我們可以寫一個如下的函數:
在這裏插入圖片描述

參數n 必須在參數 A[n][n] 之前,這樣函數就可以在遇到這個數組的時候計算出數組的維度。

GCC爲這個引用函數產生的代碼如下所示:
在這裏插入圖片描述

正如註釋所示,這段代碼計算元素 i,j 的地址爲 xa + 4(n * i) +4j = xa + 4(n * i + j)。這個地址的計算類似於定長數組的地址計算,不同點在於(1)由於增加了參數 n,寄存器的使用變化了;(2)用了乘法指令來計算 n * i(第2行),而不是用 leaq 指令來計算 3i。因此引用變長數組只需要對定長數組做一點兒概括。動態的版本必須用乘法指令對 i 伸縮 n 倍,而不能用一系列的移位和加法。在一些處理器中,乘法會招致嚴重的性能處罰,但是再這種情況中無可避免。

在一個循環中引用變長數組時,編譯器常常可以利用訪問模式的規律性來優化索引的計算。例如,下圖a 給出的C代碼,它計算兩個 n * n 矩陣 A 和 B 乘積的元素 i, k。GCC產生的彙編代碼,我們再重新變爲 C代碼。這個代碼與固定大小數組的優化代碼風格不同,不過這更多的是編譯器選擇的結果,而不是兩個函數有什麼根本的不同造成的。圖b的代碼保留了循環變量 j。用以判斷循環是否結束和作爲 A 的行 i 的元素組成的數組的索引。
在這裏插入圖片描述
在這裏插入圖片描述

下面是 var_prod_ele 的循環的彙編代碼:
在這裏插入圖片描述
在這裏插入圖片描述

我們看到程序既使用了伸縮過得值 4n(寄存器 %r9)來增加Bptrt,也使用了 n 的值(寄存器 %rdi)來檢查循環的邊界。C 代碼中並沒有體現出需要這兩個值,但是由於指針運算的伸縮,才使用了這兩個值。

可以看到,如果允許使用優化, GCC能夠識別出程序訪問多維數組的元素的步長。然後生成的代碼會避免直接應用等式會導致的乘法。不論生成基於指針的代碼,還是基於數組的代碼,這些優化都能顯著提高程序的性能。

異質的數據結構

C 語言提供了兩種將不同類型的對象組合到一起創建數據類型的機制:結構(structure),用關鍵字 struct 來聲明,將多個對象集合到一個單位中;聯合(union),用關鍵詞 union 來聲明,允許用幾種不同的類型來引用一個對象。

結構

C 語言的 struct 聲明創建一個數據類型,將可能不同類型的對象聚合到一個對象中。用名字來引用結構的各個組成部分。類似於數組的實現,結構的所有組成部分都存放在內存中一段連續的區域內,而指向結構的指針就是結構第一個字節的地址。編譯器維護關於每個結構類型的信息,指示每個字段(field)的字節偏移。它以這些偏移作爲內存引用指令中的位移,從而產生對結構元素的引用。

  • 將一個對象表示爲 struct
    C 語言提供的 struct 數據類型的構造函數(constructor)與 C++ 和 Java 的對象最爲接近。它允許程序員在一個數據結構中保存關於某個實體的信息,並用名字來引用這些信息。
    例如,一個圖形程序可能會用到結構來表示一個長方形:
    在這裏插入圖片描述

可以聲明一個 struct rect 類型的變量r,並將它的字段值設置如下:
在這裏插入圖片描述

這裏表達式 r.llx 就會選擇結構 r 的 llx 字段。

另外,我們可以在一條語句中既聲明變量又初始化它的字段:
在這裏插入圖片描述

將指向結構的指針從一個地方傳遞到另一個地方,而不是複製它們,這是很常見的。例如,下面的函數計算長方形的面積,這裏,傳遞給函數的就是一個指向長方形 struct 的指針:
在這裏插入圖片描述

表達式(rp).width 間接引用了這個指針,並且選取所得結構的 width 字段。這裏必須要用括號,因爲編譯器會將表達式rp.width 解釋爲 *(rp.width),而這時非法的。間接引用和字段選取結合起來非常常見,以至於 C 語言提供了一種替代的表示法->。即 rp->width 等價於表達式(*rp).width。例如,我們可以寫一個函數。它將一個長方形順時針旋轉90度:
在這裏插入圖片描述

C++ 和 Java 的對象比 C 語言中的結構要複雜精細得多,因爲它們將一組可以被調用來執行計算的方法與一個對象聯繫起來。在C語言中,我們可以簡單地把這些方法寫成普通函數,就像上面所示的函數 area 和 rotate_left。

讓我們來看看這樣一個例子,考慮下面這樣的結構聲明:
在這裏插入圖片描述
這個結構包括 4 個字段:兩個 4 字節 int、一個由兩個類型爲 int 的元素組成的數組和一個 8 字節整型指針,總共是 24 個字節:在這裏插入圖片描述

可以觀察到,數組 a 是嵌入到這個結構中的。上圖中頂部的數字給出的是各個字段相對於結構開始處的字節偏移。

爲了訪問結構的字段,編譯器產生的代碼要將結構的地址加上適當的偏移。例如,假設 struct rec* 類型的變量 r 放在寄存器 %rdi 中。那麼下面的代碼將元素 r -> i 複製到元素 r -> j :
在這裏插入圖片描述
因爲字段 i 的偏移量爲 0,所以這個字段的地址就是 r 的值。爲了存儲到字段 j,代碼要將 r 的地址加上偏移量 4。

要產生一個指向結構內部對象的指針,我們只需將結構的地址加上該字段的偏移量。例如,只用加上偏移量 8 +4 * 1 = 12,就可以得到指針&(r -> a[1])。對於在寄存器 %rdi 中的指針 r 和在寄存器 %rsi 中的長整數變量 i,我們可以用一條指令產生指針&(r -> a[i])的值:
在這裏插入圖片描述

最後舉一個例子,下面的代碼實現的是語句:
在這裏插入圖片描述

開始時 r 在寄存器 %rdi 中:
在這裏插入圖片描述

綜上所述,結構的各個字段的選取完全是在編譯時處理的。機器代碼不包含關於字段聲明或字段名字的信息。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

9.2 聯合

聯合提供了一種方式,能夠規避 C 語言的類型系統,允許以多種類型來引用一個對象。聯合聲明的語法與結構的語法一樣,不過語義相差比較大。它們是用不同的字段來引用相同的內存塊。

考慮下面的聲明:
在這裏插入圖片描述
在一臺 x86-64 Linux 機器上編譯時,字段的偏移量、數據類型 S3 和 U3 的完整大小如下:
在這裏插入圖片描述
(稍後會解釋 S3 中 i 的偏移量爲什麼不是 1 而是 4,以及爲什麼 v 的偏移量是 16 而不是 9 或者 12)對於類型 union U3 * 的指針 p,p -> c、p -> i [0] 和 p -> v 引用的都是數據結構的起始位置。還可以觀察到,一個聯合的總的大小等於它最大字段的大小。

在一些上下文中,聯合十分有用。但是,它也會引起一些討厭的錯誤,因爲它們繞過了 C 語言類型提供提供的安全措施。一種應用情況是,我們事先知道對一個數據結構中兩個不同字段的使用是互斥的(就是用了一個,就不會用另一個),那麼將這兩個字段聲明爲聯合的一部分,而不是結構的一部分,會減少分配空間的總量。

例如,假設我們想實現一個二叉樹的數據結構,每個葉子節點都有兩個 double 類型的數據值,而每個內部節點都有指向兩個孩子節點的指針,但是沒有數據。如果聲明如下:
在這裏插入圖片描述
那麼每個節點需要 32 個字節,每種類型的節點都要浪費一半的字節。相反,如果我們如下聲明一個節點:
在這裏插入圖片描述
那麼,每個節點就只需要 16 個字節。如果 n 是一個指針,指向 union node_u * 類型的節點,我們用 n -> data[0] 和 n -> data[1] 來引用葉子節點的數據,而用 n -> internal.left 和 n -> internal.right 來引用內部節點的孩子。

不過,如果這樣編碼,就沒有辦法來確定一個給定的節點到底是葉子節點,還是內部節點。通常的方法是引入一個枚舉類型,定義這個聯合中可能的不同選擇,然後再創建一個結構,包含一個標籤字段和這個聯合:

在這裏插入圖片描述

這個結構總共需要 24 個字節:type 是 4 個字節,info.internal.left 和 info.internal.right 各要 8 個字節,或者是info.data 要 16 個字節。我們後面很快會談到,在字段 type 和聯合的元素之間需要 4 個字節的填充,所以整個結構大小爲 4 + 4 + 16 = 24。對於由較多字段的數據結構,這樣的節省會更加吸引人。

聯合還可以用來訪問不同數據類型的位模式。例如,假設我們使用簡單的強制類型轉換將一個 double 類型的值 d 轉換爲 unsigned long 類型的值 u:
在這裏插入圖片描述
值 u 會是 d 的整數表示。除了 d 的值爲 0.0 的情況以外,u 的位模式會與 d 的很不一樣。再看下面這個代碼,從一個 double 產生一個 unsigned long 類型的值:
在這裏插入圖片描述
在這段代碼中,我們以一種數據類型來存儲聯合中的參數,又以另一種數據類型來訪問它。結果會是 u 具有和 d 一樣的位模式,包括符號位字段、指數 和 尾數。u 的數值與 d 的數值沒有任何關係,除了 d 等於 0.0 的情況。

當用聯合來將各種不同大小的數據類型結合到一起時,字節順序問題就變得很重要了。例如,假設我們寫了一個過程,它以兩個 4 字節的 unsigned 的位模式,創建一個 8 字節的 double。
在這裏插入圖片描述
在 x86-64 這樣的小端法機器上,參數 word0 是 d 的低位4個字節,而 word1 是高位4個字節,在大端法機器上則相反。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述
【我很奇怪這個答案倒數第二行的用法算做上面說的"互斥"的情況,第二行 %rax 少寫了r ,第三行 addq 的立即數應該是 $a】

數據對齊

許多計算機系統對基本數據類型的合法地址做出了一些限制,要求某種類型對象的地址必須是某個值K(通常是2、4 或 8)的倍數。這種對齊限制簡化了形成處理器和內存系統之間接口的硬件設計。例如,假設一個處理器總是從內存中取 8 個字節,則地址必須爲 8 的倍數。如果我們能保證將所有的 double 類型數據的地址對齊成 8 的倍數,那麼就可以用一個內存操作來讀或者寫值了。否則,我們可能需要執行兩次內存訪問,因爲對象可能被分放在兩個 8 字節內存塊中。

無論數據是否對齊,x86-64 硬件都能正確工作。不過,Intel 還是建議要對齊數據以提高內存系統的性能。對齊原則是任何 K 字節的基本對象的地址必須是 K 的倍數。可以看到這條原則會得到如下對齊:在這裏插入圖片描述
確保每種數據類型都是按照指定方式來組織和分配,即每種類型的對象都母案組它的對齊限制,就可保證實施對齊。編譯器在彙編代碼中放入指令,指明全局數據所需的對齊。例如,在之前跳轉表的彙編代碼聲明在第 2 行包含下面這樣的命令: .align 8

這就保證了它後面的數據(在此,是跳轉表的開始)的起始地址是 8 的倍數。因爲每個表項長 8 個字節,後面的元素都會遵循 8 字節對齊的限制。

對於包含結構的代碼,編譯器可能需要在字段的分配中插入間隙,以保證每個結構元素都滿足它的對齊要求。而結構本身對它的起始地址也有一些對齊要求。

比如,考慮下面的結構聲明:
在這裏插入圖片描述
假設編譯器用最小的 9 字節分配,畫出來是這樣的:
在這裏插入圖片描述
它是不可能滿足字段i(偏移爲0)和j(偏移爲5)的4字節對齊要求的。取而代之地,編譯器在字段 c 和 j 之間插入一個 3 字節的間隙:
在這裏插入圖片描述

結果,j 的偏移量爲 8 ,而整個結構的大小爲 12 字節。此外,編譯器必須保證任何 struct S1 * 類型的指針 p 都滿足 4 字節對齊。用我們前面的符號,設指針 p 的值爲 xp。那麼,xp 必須是 4 的倍數。這就保證了 p -> i (地址xp)和p -> j(地址xp + 8 )都滿足它們的 4 字節對齊要求。

另外,編譯器結構的末尾可能需要一些填充,這樣結構數組中的每個元素都會滿足它的對齊要求。例如,考慮下面這個結構聲明:
在這裏插入圖片描述

如果我們將這個結構打包成 9 個字節,只要保證結構的起始地址滿足 4 字節對齊要求,我們仍然能夠保證滿足字段 i 和 j 的對齊要求。不過考慮下面的聲明:
在這裏插入圖片描述

分配 9 個字節,不可能滿足 d 的每個元素的對齊要求,因爲這些元素的地址分別是 xd、xd + 9、xd + 18、 xd + 27。相反,編譯器會爲結構 S2 分配 12 個字節,最後 3 個字節是浪費的空間:
在這裏插入圖片描述

這樣一來,d 的元素的地址分別爲 xd 、xd + 12、xd + 24 和 xd + 36。只要 xd 是 4 的倍數,所有的對齊限制就都可以滿足了。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

  • 強制對齊的情況
    對於大多數 x86-64 指令來說,保持數據對齊能夠提高效率,但是它不會影響程序的行爲。另一方面,如果數據沒有對齊,某些型號的 Intel 和 AMD 處理器對於有些實現多媒體操作的 SSE 指令,就無法正確執行。這些指令對 16 字節數據塊進行操作,在 SSE 單元和內存之間傳送數據的指令要求內存地址必須是 16 的倍數。任何試圖以不滿足對齊要求的地址來訪問內存都會導致異常,默認的行爲是程序終止。

因此,任何針對 x86-64 處理器的編譯器和運行時系統都必須保證分配用來保存可能會被 SSE 寄存器讀寫的數據結構的內存,都必須滿足 16 字節對齊。這個要求有兩個後果:

  1. 任何內存分配函數(alloca、malloc、calloc 或 realloc)生成的塊的起始地址都必須是 16 的倍數。
  2. 大多數函數的棧幀的邊界都必須是 16 字節的倍數。
    較勁版本的 x86-64 處理器實現了 AVX多媒體指令。出了提供 SSE 指令的超集,支持 AVX 的指令並沒有強制性的對齊要求。

在機器級程序中將控制與數據結合起來

到目前爲止,我們已經分別討論機器級代碼如何實現程序的控制部分和如何實現不同的數據結構。在本節中,我們會看看數據和控制如何交互。首先,深入審視一下指針,它是 C 編程語言中最重要的概念之一,但是許多程序員對它的理解都非常淺顯。我們複習符號調試器 GDB 的使用,用它自己檢查機器級程序的詳細運行。接下來,看看理解機器級程序如何幫我們研究緩衝區溢出,這是現實世界許多系統中一種很重要的安全漏洞。最後,查看機器級程序如何實現函數要求的棧空間大小在每次執行時都可能不同的情況。

理解指針

指針是 C 語言的一個核心特色。它們以一種統一方式,對不同數據結構中的元素產生引用。對於編程新手來說,指針總是會帶來很多的困惑,但是基本概念其實非常簡單。在此,重點介紹一些指針和它們映射到機器代碼的關鍵原則。

  • 每個指針都對應一個類型。這個類型表型該指針指向的是哪一類對象。以下面的指針聲明爲例:
    在這裏插入圖片描述

變量 ip 是一個指向 int 類型對象的指針,而 cpp 指針指向的對象自身就是一個指向 char 類型對象的指針。通常,如果對象類型爲 T,那麼指針的類型爲 T *。特殊的 void * 類型代表通用指針。比如說,malloc 函數返回一個通用指針,然後通過顯式強制類型轉換或者賦值操作那樣的隱式強制類型轉換,將它轉換成一個有類型的指針。指針類型不是機器代碼中的一部分:它們是 C 語言提供的一種抽象,幫助程序員避免尋址錯誤。

  • 每個指針都有一個值。這個值是某個指定類型的對象的地址。特殊的 NULL(0)值表示該指針沒有指向任何地方。
  • 指針用'&'運算符創建。這個運算符可以應用到任何 lvalue 類的 C 表達式上,lvaule 意指可以出現在賦值語句左邊的表達式。這樣的例子包括變量以及結構、聯合 和 數據的元素。我們已經看到,因爲 leaq 指令是設計用來計算內存引用的地址的,& 運算符的機器代碼實現常常用這條指令來計算表達式的值。
  • * 操作符用於間接引用指針。其結果是一個值,它的類型與該指針的類型一致。間接引用是內存引用來實現的,要麼是存儲到一個指定的地址,要麼是從指定的地址讀取。
  • 數組與指針緊密聯繫。一個數組的名字可以像一個指針變量一樣引用(但是不能修改)。數組引用(例如 a [ 3 ])與指針運算和間接引用(例如 * (a + 3))有一樣的效果。數組引用的指針運算都需要用對象大小對偏移量進行伸縮。當我們寫表達式 p + i,得到的地址計算爲 &p + L * i,這裏 L 是與 p 相關聯的數據類型的大小。
  • 將指針從一種類型強制轉換成另一種類型,只改變它的類型,而不改變它的值。強制類型轉換的一個效果是改變指針運算的伸縮。例如,如果 p 是一個 char * 類型的指針,它的值爲 &p,那麼表達式(int *)p + 7 計算爲&p + 28,而 (int *)(p + 7 )計算爲 &p + 7。(強制類型轉換的優先級高於加法。)
  • 指針也可以指向函數。這提供了很強大的存儲和向代碼傳遞引用的功能,這些引用可以被程序的某個其他部分調用。例如,如果我們有一個函數,用下面這個原型定義:
    int fun(int x,int *p);

然後,我們可以聲明一個指針 fp,將它賦值爲這個函數,代碼如下:
int (*fp)(int,int *);
fp = fun;

然後用這個指針來調用這個函數:
int y = 1;
int result = fp(3,&y);

函數指針的值是該函數機器代碼表示中第一條指令的地址。

  • 函數指針
    函數指針聲明的語法對程序員新手來說特別難以理解。對於一下聲明:
    int (*f)(int *);
    要從裏(從 “f” 開始)往外讀。因此,我們看到像“(*f)”表明的那樣,f 是一個指針;而“(*f)(int *)”表明 f 是一個指向函數的指針,這個函數以一個 int * 作爲參數。最後,我們看到,它是指向以 int * 爲參數並返回 int 的函數的指針。

*f 兩邊的括號是必須的,否則聲明變成
int *f(int *);
它會被解讀成
(int *)f(int *);
也就是說,它會被解釋成一個函數原型,聲明瞭一個函數 f,它以一個 int * 作爲參數並返回一個 int *。

應用:使用 GDB 調試器

GUN的調試器 GDB 提供了許多有用的特性,支持機器級程序的運行時評估和分析。對於本書中的示例和聯繫,我們試圖通過閱讀代碼,來推斷出程序的行爲。有了GDB,可以觀察正在運行的程序,同時又對程序的執行有相當的控制,這使得研究程序的行爲變爲可能。

下圖給出一些 GDB 命令的例子,幫助研究機器級 x86-64 程序。先運行 OBJ-DUMP 來獲得程序的反彙編版本,是很有好處的。我們的示例都基於對文件 prog 運行 GDB,程序的描述和反彙編在
2.3 節。我們用下面的命令來啓動 GDB:
linux> gdb prog

通常的方法是在程序中感興趣的地方附近設置斷點。斷點可以設置在函數入口後面,或是一個程序的地址處。程序在執行過程中遇到一個斷點時,程序會停下來,並將控制返回給用戶。在斷點處,我們能夠以各種方式查看各個寄存器和內存位置。我們也可以單步跟蹤程序,一次只執行幾條指令,或是前進到下一個斷點。

[插圖]
在這裏插入圖片描述

內存越界引用和緩衝區溢出

我們已經看到,C 對於數組引用不進行任何邊界檢查,而且局部變量和狀態信息(例如保存的寄存器值和返回地址)都存放在棧中。這兩種情況結合到一起就能導致嚴重的程序錯誤,對越界的數組元素的寫操作會存儲在棧中的狀態信息。當程序使用這個被破壞的狀態,試圖重新加載寄存器或執行 ret 指令時,就會出現很嚴重的錯誤。

一種特別常見的狀態破壞稱爲緩衝區溢出(buffer overflow)。通常,在棧中分配某個字符數組來保存一個字符串,但是字符串的長度超出了爲數組分配的空間。下面這個程序示例就說明了這個問題:
在這裏插入圖片描述

前面的代碼給出了庫函數 gets 的一個實現,用來說明這個函數的嚴重問題。它從標準輸入讀入一行,在遇到一個回車換行字符或某個錯誤情況時停止。它將這個字符串複製到參數 s 指明的位置,並在字符串結尾加上 null 字符。在函數 echo 中,我們只用了 gets 這個函數只是簡單地從標準輸入中讀入一行,再把它回送到標準輸出。

gets 的問題是它沒有辦法確定是否爲保存整個字符串分配了足夠的空間。在 echo 示例中,我們故意將緩衝區設置地非常小——只有 8 個字節長。任何長度超過 7 個字符的字符串都會導致寫越界。

檢查 GCC 爲 echo 產生的彙編代碼,看看棧是如何組織的:

在這裏插入圖片描述
下圖畫出了 echo 執行時棧的組織。該程序把棧指針減去了24,在棧上分配了 24 個字節。字符數組 buf 位於棧頂,可以看到,%rsp 被複制到 %rdi 作爲調用 gets 的 puts 的參數。這個調用的參數和存儲的返回指針之間的 16 字節是未被使用的。只要用戶輸入不超過 7 個字符,gets 返回的字符串(包括結尾的 null)就能夠放進 buf 分配的空間裏。

不過,長一些的字符串就會導致 gets 覆蓋棧上存儲的某些信息。隨着字符串變長,下面的信息會被破壞:

在這裏插入圖片描述

字符串到 23 個字符之前都沒有嚴重的後果,但是超過以後,返回指針的值以及更多可能的保存狀態會被破壞。如果存儲的返回地址的值被破壞了,那麼 ret 指令會導致程序跳轉到一個完全意想不到的位置。如果只看 C 代碼,根本就不可能看出會有上面這些行爲。只有通過研究機器代碼級別的程序才能理解像 gets 這樣的函數進行的內存越界寫的影響。

我們的 echo 代碼很簡單,但是有點太隨意了。更好一點的版本是使用 fgets 函數,它包括一個參數,限制待讀入的最大字節數。通常,使用 gets 或任何能導致存儲溢出的函數,都是不好的編程習慣。不幸的是,很多常用的庫函數,包括 strcpy、strcat 和 sprintf,都有一個屬性——不需要告訴它們目標緩衝區的大小,就產生一個字節序列。這樣的情況就會導致緩衝區溢出漏洞。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

【下面的反彙編可以看出來棧指針減少了16字節,所以畫圖的時候 %rsp 只在第二個空格處(一個空格 8 字節)。B答案返回地址修改爲 0x0400034。】

緩衝區溢出的一個更加致命的使用,就是讓程序執行它本來不願意執行的程序。這是一種最常見的通過計算機網絡攻擊系統安全的方法。通常,輸入給程序一個字符串,這個字符串包含一些可執行代碼的字節編碼,稱爲攻擊代碼*(exploit code),另外,還有一些字節會用一個指向攻擊代碼的指針覆蓋返回地址。那麼,執行 ret 指令的效果就是跳轉到攻擊代碼。

在一種攻擊形式中,攻擊代碼會使用系統調用啓動一個 shell 程序,給攻擊者提供一組操作系統函數。在另一種攻擊形式中,攻擊代碼會執行一些未授權的任務,修復對棧的破壞,然後第二次執行 ret 指令,(表面上)正常返回到調用者。

讓我們來看一個例子,在 1988年11月,著名的 Internet 蠕蟲病毒通過 Internet 以四種不同的方法獲取對許多計算機的訪問。一種是對 finger 守護進程 fingerd 的緩衝區溢出攻擊,fingerd 服務 FINGER 命令請求。通過以一個適當的字符串調用 FINGER,蠕蟲可以使遠程的守護進程緩衝區溢出並執行一段代碼,讓蠕蟲訪問遠程系統。一旦蠕蟲獲得了對系統的訪問,它就能自我複製,幾乎完全地消耗掉計算機上所有的計算資源。結果,在安全專家制定出如何消除這種蠕蟲的方法之前,成百上千的機器實際上都癱瘓了。這種蠕蟲的始作俑者最後被抓住並被起訴。時至今日,人們還是不斷地發現遭受緩衝區溢出攻擊的系統安全漏洞,這更加突顯了仔細編寫程序的必要性。任何到外部環境的接口都應該是“防彈的”,這樣,外部代理的行爲纔不會導致系統出現錯誤。

  • 蠕蟲和病毒
    蠕蟲和病毒都試圖在計算機中傳播它們自己的代碼段。蠕蟲(worm)可以自己運行,並且能夠將自己的等效副本傳播到其他機器。病毒(virus)能將自己添加到包括操作系統在內的其他程序中,但它不能獨立運行。在一些大衆媒體中,“病毒”用來指各種在系統間傳播攻擊代碼的策略,所以你可能會聽到人們把本來應該叫做“蠕蟲”的東西稱爲“病毒”。

對抗緩衝區溢出攻擊

緩衝區溢出攻擊的普遍發生給計算機系統造成了許多的麻煩。現代的編譯器和操作系統實現了很多機制,以避免遭受這樣的攻擊,限制入侵者通過緩衝區攻擊獲得系統控制的方式。在本節中,我們會介紹一些 Linux 上最新 GCC 版本所提供的機制。

1…棧隨機化
爲了在系統中插入攻擊代碼,攻擊者既要插入代碼,也要插入指向這段代碼的指針,這個指針也是攻擊字符串的一部分。產生這個指針需要知道這個字符串放置的棧地址。在過去,程序的棧地址非常容易預測。對於所有運行同樣程序和操作系統版本的系統來說,在不同的機器之間,棧的位置是相當固定的。因此,如果攻擊者可以確定一個常見的 Web 服務器所使用的棧空間,就可以設計一個在許多機器上都能實施的攻擊。以傳染病來打個比方,許多系統都容易受到一種病毒的攻擊,這種現象常被稱爲“安全單一化(security monoculture)”。

棧隨機化的思想使得棧的位置在程序每次運行時都有變化。因此,即使許多機器都運行同樣的代碼,它們的棧地址都是不同的。實現的方式是:程序開始時,在棧上分配一段 0 ~ n 字節的隨機大小空間,例如,使用分配函數 alloca 在棧上分配指定字節數量的空間。程序不使用這段空間,但是它會導致程序每次執行時後續的棧位置發生變化。分配的範圍 n 必須足夠大,才能獲得足夠多的棧地址變化。但是又要足夠小,不至於浪費程序太多的空間。

下面的代碼是一種確定“典型的”棧地址的方法:
在這裏插入圖片描述
這段代碼只是簡單地打印出 main 函數中局部變量的地址。在 32 位 Linux 上運行這段代碼 10000 次,這個地址的變化範圍爲 0xff7fc59c 到 0xffffd09c ,範圍大約爲 2的32次方。在更新一點的機器上運行 64 位Linux,這個地址的變化範圍約是 2的32次方。

在 Linux 系統中,棧隨機化已經變成了標準行爲。它是更大的一類技術中的一種,這類技術稱爲地址空間佈局隨機化(Address-Space Layout Randomization),或者簡稱 ASLR。採用 ASLR,每次運行時程序的不同部分,包括程序代碼、庫代碼、棧、全局變量 和 堆數據,都會被加載到內存的不同區域。這就意味着在一臺機器上運行一個程序,與在其他機器上運行同樣的程序,它們的地址映射大相徑庭。這樣才能夠對抗一些形式的攻擊。

然而,一個執着的攻擊者總是能夠用蠻力克服隨機化,他可以反覆地用不同的地址進行攻擊。一種常見的把戲就是在實際的攻擊代碼前插入很長一段的 nop 指令。執行這種指令對程序計數器加一,使之除了指向下一條指令之外,沒有任何的效果。只要攻擊者能夠猜中這段序列中的某個地址,程序就會經過這個序列,到達攻擊代碼。這個序列常用的術語是“空操作雪橇(nop sled)”,意思是程序會“滑過”這個序列。如果我們建立一個 256 個字節的 nop sled,那麼枚舉 2的15次方 個起始地址,就能破解 2的23次方 的隨機化,這對於一個頑固的攻擊者來說,是完全可行的。我們可以看到棧隨機化和其他一些 ASLR 技術能夠增加攻破一個系統的難度,因而大大降低了病毒或者蠕蟲的傳播速度,但是也不能提供完全的安全保障。

在這裏插入圖片描述
在這裏插入圖片描述

2.棧破壞檢測
計算機的第二道防線是能夠檢測到何時棧已經被破壞。我們在 echo 函數示例中看到,破壞通常發生在超越局部緩衝區的邊界時。在 C 語言中,沒有可靠的方法來防止對數組的越界寫。但是,我們能夠在發生了越界寫的時候,在造成任何有害結果之前,嘗試檢測到它。

最近的 GCC 版本在產生的代碼中加入了一種 棧保護者(stack protector) 機制,來檢測緩衝區越界。其思想是在棧幀中任何局部緩衝區與棧狀態之間存儲一個特殊的金絲雀值(canary),如下圖所示。這個金絲雀值,也稱爲哨兵值(guard value),是在程序每次運行時隨機產生的,因此,攻擊者沒有簡單的辦法能夠知道它是什麼。在恢復寄存器狀態和從函數返回之前,程序檢查這個金絲雀值是否被該函數的某個操作或者該函數調用的某個函數的某個操作改變了。如果是的,那麼程序異常中止。
在這裏插入圖片描述

最近的 GCC 版本會試着確定一個函數是否容易遭受棧溢出攻擊,並且自動插入這種溢出檢測。實際上,面對前面的棧溢出展示,我們其實用了命令行選項“ -fno-stack-protector”來阻止 GCC 啓用棧保護者。當不用這個選項來編譯 echo 函數時,也就是允許使用棧保護者,得到下面的彙編代碼:
在這裏插入圖片描述
在這裏插入圖片描述

這個版本的函數從內存中讀出一個值,再把它存放在棧中相對於 %rsp 偏移量爲 8 的地方。指令參數 %fs :40 指明金絲雀值是用段尋址(segmented addressing)從內存中讀入的,段尋址機制可以追溯到80286 的尋址,而在現代系統上運行的程序中已經很少見到了。將金絲雀值存放在一個特殊的段中,標誌位“只讀”,這樣攻擊者就不能覆蓋存儲的金絲雀值。在恢復寄存器狀態和返回錢,函數將存儲在棧位置處的值於金絲雀值做比較(通過第11行的 xorq 指令)。如果兩個數相同,xorq 指令就會得到 0,函數會按照正常的方式完成。非零的值表明棧上的金絲雀值被修改過,那麼代碼就會調用一個錯誤處理例程。

棧保護很好地防止了緩衝區溢出攻擊破壞存儲在程序棧上的狀態。它只會帶來很小的性能損失,特別是因爲 GCC 只在函數中有局部 char 類型緩衝區的時候才插入這樣的代碼。當然,也有其他一些方法會破壞一個正在執行的程序的狀態,但是降低棧的易受攻擊性能夠對抗許多常見的攻擊策略。

在這裏插入圖片描述
在這裏插入圖片描述

在這裏插入圖片描述

3.限制可執行代碼區域
最後一招是消除攻擊者向系統中插入可執行代碼的能力。一種方法是限制哪些內存區域能夠存放可執行代碼。在典型的程序中,只有保存編譯器產生的代碼的那部分內存才需要是可執行的。其他部分可以被限制爲只允許讀和寫。下面將會看到,虛擬內存空間在邏輯上被分成了頁(page),典型的每頁是 2048 或者 4096 個字節。硬件支持多種形式的內存保護,能夠指明用戶程序和操作系統所允許的訪問形式。許多系統允許控制三種訪問形式:讀(從內存讀數據)、寫(存儲數據到內存)和執行(將內存的內容看做機器及代碼)。以前,x86 體系結構將讀和執行訪問合併成一個 1 位的標誌,這樣任何被標記爲可讀的頁也都是可執行的。棧必須是既可讀又可寫的,因而棧上的字節也都是可執行的。已經實現的很多機制,能夠限制一些頁是可讀但是不可執行的,然而這些機制通常會帶來嚴重的性能損失。

最近,AMD 爲它的 64 位處理器的內存保護引入了“NX”(No-Execute,不執行)位,將讀和執行訪問模式分開,Intel 也跟進了。有了這個特性,棧可以被標記爲可讀和可寫,但是不可執行,而檢查頁是否可執行由硬件來完成,效率上沒有損失。

有些類型的程序要求動態產生和執行代碼的能力。例如,“即時(just-in-time)”編譯技術爲解釋語言(例如 java)編寫的程序動態地產生代碼,以提高執行性能。是否能夠將可執行代碼限制在由編譯器在創建原始程序時產生的那個部分中,取決於語言和操作系統。

我們講到的這些技術——隨機化、棧保護和限制哪部分內存可以存儲可執行代碼——是用於最小化程序緩衝區溢出攻擊漏洞的三種最常見的機制。它們都具有這樣的屬性,即不需要程序員做任何特殊的努力,帶來的性能代價都非常小,甚至沒有。單獨每一種機制都降低了漏洞的等級,而組合起來,它們變得更加有效。不幸的是,仍然有方法能夠供給計算機,因而蠕蟲和病毒繼續危害着許多機器的完整性。

支持變長棧幀

到目前爲止,我們已經檢查了各種函數的機器級代碼,但它們有一個共同點,記編譯器能夠預先確定需要爲棧幀分配多少空間。但是有些函數,需要的局部存儲是變長的。例如,當函數調用 alloca 時就會發生這種情況。alloca 是一個標準庫函數,可以在棧上分配任意字節數量的存儲。當代碼聲明一個局部變長數組時,也會發生這種情況。

雖然本節介紹的內容實際上是如何實現過程的一部分,但我們還是把它推遲到現在纔將,因爲它需要理解數組和對齊。

下圖a 的代碼給出了一個包含變長數組的例子。該函數聲明瞭 n 個指針的局部數組 p,這裏 n 由第一個參數給出。這要求在棧上分配 8n 個字節,這裏 n 的值每次調用該函數時都會不同。因此編譯器無法確定要給該函數的棧幀分配多少空間。此外,該程序還產生一個對局部變量 i 的地址引用,因此該變量必須存儲在棧中。在執行工程中,程序必須能夠訪問局部變量 i 和數組 p 中的元素。返回時,該函數必須釋放這個棧幀,並將棧指針設置爲存儲返回地址的位置。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

爲了管理變長棧幀,x86-64 代碼使用寄存器 %rbp 作爲幀指針(frame pointer)(有時稱爲基指針(base pointer),這也是 %rbp 中 bp 兩個字母的由來。)當使用幀指針時,棧幀的組織結構於下圖中函數 vframe 的情況一樣。
在這裏插入圖片描述

代碼必須把 %rbp 之前的值保存到棧中,因爲它是一個被調用者保護寄存器。然後在函數的整個執行過程中,都使得 %rbp 指向那個時刻棧的位置,然後用固定長度的局部變量(例如 i)相對於 %rbp 的偏移量來引用它們。

上圖b 是 GCC 爲函數 vframe 生成的部分代碼。在函數的開始,代碼建立棧幀,併爲數組 p 分配空間。首先把 %rbp 的當前值壓入棧中,將 %rbp 設置爲指向當前的棧位置(第2 - 3 行)。然後,在棧上分配 16 個字節,其中前 8 個字節用於存儲局部變量 i,而後 8 個字節是未被使用的。接着,爲數組 p 分配空間(第5 - 11 行)。當程序到第 11 行的時候,已經(1)在棧上分配了 8n 字節,並(2)在已分配的區域內放置好數組 p ,至少有 8n 字節可供其使用。

初始化循環的代碼展示瞭如何引用局部變量 i 和 p 的例子。第 13 行表明數組元素 p[ i ] 被設置爲 q。該指令用寄存器 %rcx 中的值作爲 p 的起始地址。我們可以看到修改局部變量 i(第 15 行)和讀局部變量(第 17 行)的例子。i 的地址是引用 -8(%rbp),也就是相對於棧指針偏移量爲 -8 的地方。

在函數的結尾,leave 指令將幀指針恢復到它之前的值(第 20 行)。這條指令不需要參數,等價於執行下面兩條指令:
在這裏插入圖片描述

也就是,首先把棧指針設置爲保存 %rbp 值的位置,然後把該值從棧中彈出到 %rbp。這個指令組合具有釋放整個棧幀的效果。

在較早版本的 x86 代碼中,每個函數調用都使用了幀指針。而現在,只在棧幀長可變的情況下才使用,就像函數 vframe 的情況一樣。歷史上,大多數編譯器在生成 IA32 代碼時會使用棧指針。最近的 GCC 版本放棄了這個慣例。可以看到把使用棧指針的代碼和不使用棧指針混在一起是可以的,只要所有的函數都把 %rbp 當做被調用者保存寄存器來處理即可。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述

【%rsp是 16 個字節,所以爲了對齊,s2(指針數組地址) 必須是 16 的倍數,因此,給 i 分配了 16 字,s2 和 s1 之間也要相隔 16 的倍數。而數組裏面的元素是 long 類型,long 類型佔了 8 個字節,所以 p 是以 8 的倍數對齊】

浮點代碼

處理器的浮點體系結構包括多個方面,會影響對數據操作的程序如何被映射到機器上,包括:

  • 如何存儲和訪問浮點數值。通常是通過某種寄存器方式來完成
  • 對浮點數據操作的指令
  • 向函數傳遞浮點數參數和從函數返回浮點數結果的規則
  • 函數調用過程中保存寄存器的規則——例如,一些寄存器被指定爲調用者保存,而其他的被指定爲被調用者保存。

簡要回顧歷史會對理解 x86-64 的浮點體系結構有所幫助。 1997 年出現了 Pentium/MMX,Intel 和 AMD 都引入了持續數代的媒體(media)指令,支持圖形和圖像處理。這些指令本意是允許多個操作以並行模式執行,稱爲單指令多數據
或 SIMD(讀作 sim-dee)。這種模式中,對多個不同的數據並行執行同一個操作。近年來,這些擴展有了長足的發展。名字經過了一系列大的修改,從 MMX 到 SSE(Streaming SIMD Extension,流式 SIMD 擴展),以及最新的 AVX(Advanced Vector Extension,高級向量擴展)。每一代中,都有一些不同的版本。每個擴展都是管理寄存器組中的數據,這些寄存器組在 MMX 中稱爲 “MM”寄存器,SSE 中稱爲“XMM”寄存器,而在 AVX中稱爲“YMM”寄存器;MM寄存器是 64 位的,XMM 是 128 位的,而 YMM 是256 位的。所以,每個 YMM 寄存器可以存放 8 個 32 位值,或 4 個 64 位值,這些值可以是整數,也可以是浮點數。

2000 年 Pentium 4 中引入了 SSE2,媒體指令開始包括那些對標量負點數據進行操作的指令,使用 XMM 或 YMM 寄存器的低3 32 位或 64 位中的單個值。這個標量模式提供了一組寄存器和指令,它們更類似於其他處理器支持浮點數的的方式。所有能夠執行 x86-64 代碼的處理器都支持 SSE2 或更高的版本。因此 x86-64 浮點數是基於 SSE 或 AVX 的,包括傳遞過程參數和返回值的規則。

我們的講述基於 AVX2,即 AVX 的第二個版本,它是在 2013 年 Core i7 Haswell 處理器中引入的。當給定命令行參數 -mavx2 時,GCC 會生成 AVX2 代碼。基於不同版本的 SSE 以及第一個版本的 AVX 的代碼從概念上來說是類似的,不過指令名和格式有所不同。我們只介紹用 GCC 編譯浮點程序時會出現的那些指令。其中大部分是標量 AVX 指令,我們也會說明對整個數據向量進行操作的指令出現的情況。後文中的網絡旁註 OPT:SIMD 更全面地說明了如何利用 SSE 和 AVX 的 SIMD 功能讀者可能希望參考 AMD 和 Intel 對每條指令的說明文檔。和整數操作一樣,注意我們表示中使用的 ATT 格式不同於這些文檔中使用的 Intel 格式。特別地,這兩種版本中列出指令操作數的順序是不同的。

下圖所示,AVX 浮點體系結構允許數據存儲在 16 個 YMM 寄存器中,它們的名字位 %ymm0 ~ %ymm15。每個 YMM 寄存器都是 256 位(32字節)。當對標量數據操作時,這些寄存器只保存浮點數,而且只使用低 32 位(對於 float)或 64 位(對於 double)。彙編代碼用寄存器的 SSE XMM 寄存器名字 %xmm0 ~ %xmm15 來引用它們,每個 XMM 寄存器都是對應的 YMM 寄存器的低 128 位(16 字節)。

在這裏插入圖片描述
在這裏插入圖片描述

浮點傳送和轉換操作

下圖給出了一組在內存和 XMM 寄存器之間以及從一個 XMM 寄存器到另一個不做任何轉換的傳送浮點數的指令。引用內存的指令是標量指令,意味着它們只對單個而不是一組封裝好的數據值進行操作。數據要麼保存在內存中(由表中的M32 和 M64 指明),要麼保存在 XMM 寄存器中(在表中用 X 表示)。無論數據對齊與否,這些指令都能正確執行,不過代碼優化則建議 32 位內存數據滿足 4 字節對齊, 64 位數據滿足 8 字節對齊。內存引用的指定方式與整數 MOV 指令一樣,包括偏移量、基址寄存器、變址寄存器 和 伸縮因子的所有可能的組合。

在這裏插入圖片描述

GCC 只用標量傳送操作從內存傳送數據到 XMM 寄存器或從 XMM 寄存器傳送數據到內存。對於在兩個 XMM 寄存器之間傳送的數據,GCC 會使用兩種指令之一,即用 vmovaps 傳送單精度數,用 vmovapd 傳送雙精度數據。對於這些情況,程序複製整個寄存器還是隻複製低位值既不會影響程序功能,也不會影響執行速度。所以使用這些指令還是針對標量數據的指令沒有差別。指令名字中的字母 a 表示 aligned(對齊的)。當用於讀寫內存時,如果地址不滿足 16 字節對齊,它們會導致異常。在兩個寄存器之間傳送數據,絕不會出現錯誤對齊的狀況。

下面是一個不同浮點傳送操作的例子,考慮一下C函數
在這裏插入圖片描述

與它相關聯的 x86-64 彙編代碼爲:
在這裏插入圖片描述

這個例子中可以看到它使用了 vmovaps 指令把數據從一個寄存器複製到另一個,使用了 vmovss 指令把數據從內存複製到 XMM 寄存器以及從 XMM 寄存器複製到內存。

下兩圖給出了在浮點數和整數數據類型之間以及不同浮點格式之間進行轉換的指令集合。這些都是對單個數據值進行操作的標量指令。在這裏插入圖片描述
上圖中的指令把一個從 XMM 寄存器或內存中讀出的浮點值進行轉換,並將結果寫入一個通用寄存器(例如 %rax、%ebx等)。把浮點值轉換成整數時,指令會執行截斷(truncation),把值向 0 進行舍入,這是 C 和大多數其他編程語言的要求。
在這裏插入圖片描述

上圖中的指令把整數轉換成浮點數。它們使用的是不太常見的三操作數格式,有兩個源和一個目的。第一個操作數從內存或一個通用目的寄存器中讀。這裏可以忽略第二個操作數,因爲它的值只會影響結果的高位字節。而我們的目標必須是 XMM 寄存器。在最常見的使用場景中,第二個源和目的操作數都是一樣的,就像下面這條指令:

vcvtsi2sdq %rax ,%xmm1,%xmm1

這條指令從寄存器 %rax 讀出一個長整數,把它轉換成數據類型 double,並把結果存放進 XMM 寄存器 %xmm1 的低字節中。

最後,要在兩種不同的浮點格式之間轉換,GCC 的當前版本生成的代碼需要單獨說明。假設 %xmm0 的低位 4 字節保存着一個單精度值,很容易就想到用下面這條指令:

vcvtss2sd %xmm0,%xmm0,%xmm0

把它轉換成一個雙精度值,並將結果存儲在寄存器 %xmm0 的低 8 字節。

【浮點數的這一節我實在沒興趣看,我先不看了,回頭如果用到這部分知識,再回來補充好了。嘻嘻】

小結

在本章中,我們窺視了 C 語言提供的抽象層下面的東西,以瞭解機器級編程。通過讓編譯器產生機器級程序的彙編代碼表示,我們瞭解了編譯器和它的優化能力,以及機器、數據類型和指令集。在第 5 章,我們會看到,當編寫能有效映射到機器上的程序時,瞭解編譯器的特性會有所幫助。我們還更完整地瞭解了程序如何將數據存儲在不同的內存區域中。在第 12 章會看到許多這樣的例子,應用程序元需要知道一個程序變量是在運行時棧中,是在某個動態分配的數據結構中,還是全局程序數據的一部分。理解程序如何映射到機器上,會讓理解這些存儲類型之間的區別容易一些。

機器級程序和它們的彙編代碼表示,與C程序的差別很大。各種數據類型之間的差別很小。程序時以指令序列來表示的,每條指令都完成一個單獨的操作。部分程序狀態,如寄存器和運行時棧,對程序員來說是直接可見的。本書僅提供了低級操作來支持數據處理和程序控制。編譯器必須使用多條指令來產生和操作各種數據結構,以及實現像條件、循環和過程這樣的控制結構。我們講述了 C 語言和如何編譯它的許多不同方面。我們看到 C 語言中缺乏邊界檢查,使得許多程序容易出現緩衝區溢出。

我們只分析了 C 到 x86-64 的映射,但是大多數內容對其他語言和機器組合來說也是類似的。例如,編譯 c ++ 和 編譯 C 就非常相似。實際上,C++ 的早期實現就只是簡單地執行了從 C++ 到 C 的源到源的轉換,並對結果運行 C 編譯器,產生目標代碼。C++ 的對象用結構來表示,類似於 C 的 struct。C++ 的方法是用指向實現方法的代碼的指針來表示的。相比而言,Java 的實現方式完全不同。Java 的目標代碼是一種特殊的二進制表示,稱爲Java字節代碼。這種代碼可以看成是虛擬機的機器級程序。這種機器並不是直接用硬件實現的,而是用軟件解釋器處理字節代碼,模擬虛擬機的行爲。另外,有一種稱爲及時編譯(just-in-time compilation)的方法,動態地將字節代碼序列翻譯成機器指令。當代碼要執行多次時(例如在循環中),這種方法執行起來更快。用字節代碼作爲程序的低級表示,優點是相同的代碼可以在許多不同的機器上執行,而在本章探討的機器代碼只能在 x86-64 機器上運行。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章