1. 程式人生 > >深入理解計算機系統----程式的機器級表示

深入理解計算機系統----程式的機器級表示

轉載地址 https://www.jianshu.com/p/c60a9c2131c3

目  錄

精通細節是理解更深和更基本概念的先決條件,這一章節首先講解了C程式碼、彙編程式碼與機器程式碼的關係,再次重申了彙編的承上啟下的重要作用。接著從IA32的細節一步步講起,如何儲存資料、如何訪問資料、如何完成運算、如何進行跳轉,在瞭解了這些細節以後告訴你我們常用的分支語句、迴圈語句是怎麼完成了。在如何呼叫函式的部分,花費的篇幅較大,詳細的講解了棧幀結構,也讓我們更好的瞭解了遞迴的過程。(其他方面還對陣列、結構、聯合有所講解,難度不大)通過對編譯器產生的彙編程式碼表示,我們瞭解了編譯器和它的優化能力,知道了編譯器為我們完成了哪些工作。

[本章內容]

※c語言、彙編程式碼以及機器程式碼之間的關係;

※介紹IA32的細節;

※講解過程的實現,包括如何維護執行棧來支援過程間資料和控制傳遞;

※理解儲存器訪問越界問題,以及緩衝區溢位攻擊問題;

※IA32擴充套件到64位(x86-64)

[筆記]

一、c語言程式碼、彙編程式碼、機器程式碼之間的關係

在第一章開始的部分我們就已經講解過這三者的關係大概順序是:1]C前處理器擴充套件原始碼,展開所以的#include命名的指定檔案;2]編譯器產生彙編程式碼(.s);3]彙編器將彙編程式碼轉化成二進位制目標檔案(.o).二進位制目標檔案是很難閱讀懂的,我們使用imac下的otool工具翻譯如下:

圖1:從左到右分別是:c語言原始碼、彙編原始碼和機器目的碼

圖2:使用命令:tool -tV

我們從圖1中可以看出,彙編程式碼起到了承上啟下的作用,目前已經不再要求程式設計師手寫彙編程式碼,但是理解彙編程式碼以及與它的源C程式碼之間的聯絡,是理解程式如何執行的關鍵一步,因為編譯器隱藏了太多的細節如:程式計數器、暫存器(整數、條件碼、浮點)等。

二、IA32指令的細節

1)區分位元組與字:Intel使用術語“字”表示16位資料型別而“位元組”代表的是8個位的資料。如果不習慣理解的話,可以做一個比喻“位元組”相當於“字截”所以少了被截斷了的嘛

2)訪問資訊

前4個暫存器可以獨立訪問低位位元組

傳送指令:move  源--> 目的地(兩個運算元不能同時指向儲存器,需要暫存器週轉)

指標:就是地址,間接引用指標就是將指標放入一個暫存器中,然後在儲存器中使用這個暫存器

棧資料的基本理解:

地址向上增大,push向下壓棧

push指令相當於:sub $4, %esp 然後move %ebp, (%esp)

pop指令相當於:move (%esp), %eax 然後 add $4, %esp

棧的資料結構是向低地址方向增長的,無論如何esp都是指向棧頂頂

3)算數和邏輯操作

其實講的就是加減乘除、與或非這一系列的指令:

載入有效地址指令:leal S, D ==>  (&S-->D) 將有效地址寫入到目的運算元中去

彙編程式碼與C語言原始碼中的順序可能不同:

leal和sall組合實現了z*48

4)改變執行順序

a.機器機制

我們這裡用到的是條件碼暫存器,常見的有:

4個常用的條件碼暫存器

可以通過cmp和test設定條件碼暫存器:

比較和測試 只修改條件碼暫存器

通過set指令訪問條件碼,用處是設定值 or 跳轉 or 傳送資料:

注:set指令字尾是表示不同的運算元

跳轉指令(對於理解連結非常重要):

根據條件的不同進行條件,用於改變程式的執行順序

                          直接跳轉用:‘.’                                                間接跳轉用:'*'

理解跳轉指令的目標編碼:

0xd並不是目標地址,而是0xd+0xa

jle跳轉指令中的0d並不是目標地址,而真正的地址是通過計算0d+0a來確定的,這樣做的優點是:通過使用與機器相關目標使得程式碼簡潔,可以使目的碼移到儲存器中而不是簡單的地址,執行的是程式計數器與目的碼的加法。

b 翻譯條件分支

通過將C程式碼翻譯成不良好的goto語句可以方便我們理解彙編程式碼的執行方式。彙編程式通過條件測試和跳轉來實現迴圈,我們常見的迴圈語句其實都是翻譯成了do-while形式的:

do-while迴圈

while迴圈會先轉成do-while形式:

while迴圈

for迴圈也是一樣的道理,先轉成do-while形式:

for迴圈

switch語句:使用一個數組作為跳轉表

著重理解跳轉的建立與使用

在switch語句的彙編程式碼中,我們使用的是一個數組jt來表示所以可能的7種情況,使用n-100將範圍縮小到了0-6區間。其中102到103中間沒有break,榿木的擴充套件C程式碼中也巧妙的實現了這樣的效果。

c 條件傳送指令

先計算出可能的多種不同結果

如條件語句:x < y ? y-x : x-y ; 就會先計算兩種結果y-x和x-y的值,然後再判斷x,y的大小

現代計算機CPU使用流水線方式實現效能的最優化:

舉個例子,你去一家快餐店點餐,想要吃一個雞蛋、一碗稀飯和一個包子,如果都是現場馬上給你做的話,味道肯定最好,但花費的時間我估計你不會再去吃第二次了。然而真實的情況是,服務員將你的要求傳達下去,賓果~~很快的時間就準備好了你需要的食物。這是因為快餐店已經從很早開始就將大家喜歡吃的都做好了,所以的結果(雞蛋、稀飯、包子)都已經提前準備好了,這時候只需要根據你的需要(x,y的大小)馬上就能給你上菜了。

在這個過程中,我們提前處理了一部分指令,如製作包子過程中的和麵、包肉、上蒸籠我們成功的預測了90%的人早餐喜歡吃包子。就大大的節約了時間。記住所以的結果都提前準備好了的。

三、如何呼叫函式

我們如果要呼叫一個函式,實現將資料和控制程式碼從一個部分到另一個部分的跳轉。我們如何來分配執行函式的變數空間,並在返回的時候釋放空間,將返回值返回。用什麼樣的資料結構實現這一系列的操作:

單個過程分配一個棧幀結構

幀指標與棧指標的不公之處:ebp放與引數與返回地址的最下方,方便計算引數的偏移位置;而esp一直在棧頂,可以通過push將資料壓入,通過pop取出,增加指標來釋放空間。

1) 轉移控制

常用轉移控制指令

其中call先將返回地址入棧,然後跳轉到函式開始的地方執行。(返回地址是開始呼叫者執行call的後面那條指令的地址)當遇到ret指令的時候,彈出返回地址,並跳轉到該處繼續執行呼叫者剩餘部分。

2) 暫存器使用慣例

1] eax edx ecx 呼叫者儲存,可以被呼叫者使用。

   舉個例子:這裡的呼叫者就像很有票子的王健林一樣,兒子王思聰可以無償的使用王健林的票子

2] ebx esi edi 被呼叫者儲存,在使用前被呼叫者要把這裡面的值儲存好,用完之後還回去

  舉個例子:這裡就像有我有一輛豪車,可以把車子借給朋友使用,但是一定要把鑰匙儲存好,用完了之後還回來

3)遞迴的過程

我們可以理解,遞迴的呼叫其實與其他函式的呼叫是一樣的,因為計算機使用的是棧幀結構,為每個單獨的呼叫建立了一個棧幀,每次呼叫都有私有的狀態資訊。

每個呼叫都有獨立的棧幀結構

五、陣列的分配與訪問

1)基本原則

陣列的宣告就不用多說了,來看看宣告過後陣列的具體位置

Xa代表的是起始地址

彙編程式碼使用move指令來簡化訪問:

movl (%edx , %ecx, 4), %eax

假設E是一個int型別的陣列,我們要計算E[i]的值,在此,E的地址放於edx中,而i放於ecx中,我們通過上面的指令就完成了Xe + 4i來讀取其中的值,放在了eax中去。

2)指標運算:對指標的運算其實際是按照相應的資料大小進行了伸縮

point + i = Xp + (資料大小)L * i

如何計算二維陣列的大小呢?

我們定義一個int D[5][3]的陣列,形如:

陣列D

如果我們要計算D[4,2]的地址,就可以使用

         D[i][j] = Xd + L(C * i + j) = D[0,0] + 4 * (3 * 4 + 2)

由於每組有3個數據,所以跳過一組就要乘以3,跳過4組就12個,再加上偏移的2,就是最後一個數據的地址了。

3)理解指標:陣列與指標關係密切

①指標用&符號創造、用*符號間接引用

②指標從一個型別 轉為另外一個型別,只是伸縮因子變化,不改變它的值

③指標可以指向函式:int (*f)(int *)從f開始由內往外閱讀,首先f代表的是一個指向函式的指標,這個函式的引數是int * 返回值是int

六、結構與聯合

1)結構:所有的組成部分在儲存器中連續存放,指向結構的指標指向結構的第一個位元組;結構的各個欄位的選取是在編譯時處理,機器程式碼不包含欄位的宣告或欄位名字的資訊。

2)聯合:一個聯合的總大小等於它最大欄位的大小,而指向一個聯合的指標,引用的是資料結構的起始位置。應用在:

a.如果兩個資料互斥,減少空間;

b.訪問不同資料的位模式;

使用不同的位模式訪問資料

用有符號資料儲存,而返回的確實無符號的資料。特別注意的是,如果用聯合將不同大小的資料組合到一起的時候要注意位元組的順序。

3)資料對齊:要求某個型別物件的資料地址必須是(2.4.8)的倍數

其中的.align 4要求陣列開始的位置為4的倍數,由於每單個數據的長度也是4的倍數,也就保證了後續的資料是4的倍數,資料對齊了。這種設計簡化了,處理器與儲存器之間介面的硬體設計。這種設計,編譯器甚至會在欄位中間、後面插入間隙,以保證每個結構滿足上述要求。如下圖所示:

結構的中間插入間隙,保證資料對齊

七、儲存器越界引用和緩衝區溢位

由於C對於陣列不進行邊界檢查,在棧幀結構中區域性變數和狀態資訊,特別是返回地址也是在棧中存放的,對越界資料的訪問和修改將破壞掉這些資料,當ret試圖返回的時候,錯誤的地址(甚至是被修改的惡意目的地地址)會帶來嚴重的安全隱患。

越界訪問

上圖兩段程式碼展示了一個get函式在只有8個位元組的空間中,存入了太多的資料,使得棧資料不斷被破壞的過程。

在計算機中儲存的 順序

被破壞的過程

常見的攻擊方式是覆蓋返回地址,使得程式跳轉到所插入的惡意程式碼部分。

對抗方式:

① 棧隨機化:在程式開始時,隨機分配一段0-n的空間,使得棧的位置每次執行都不同。棧地址隨機化,即使在一臺機器上運行同樣的程式,地址都是不同的。

② 棧破壞檢測:插入棧保護者,俗稱金絲雀的一段隨機大小

在陣列buf和儲存狀態之間放入一個特殊的金絲雀,程式碼檢查該值,確定棧狀態是否被改變

③ 限制可執行程式碼區域

八、x86-64:將IA32擴充套件到64位

1)資料型別的比較:

指標和長整數是64位

2)訪問資訊:

callee被呼叫者儲存,caller呼叫者儲存

注:pc相對定址-立即數+下條指令地址

3)算術指令:當大小不同的運算元混在一起的時候,必須進行正確的擴充套件

4)控制指令:增加了cmpq 、testq指令

增加cmpq與testq指令

a 過程

由於暫存器翻了一倍,64位中不需要棧幀來儲存引數,而是直接使用暫存器:

 

主要不同的地方

 

b 棧幀

以下原因會使用棧幀結構:

c 暫存器儲存慣例:

被呼叫者儲存:rbx   rbp  12  13  14  15號暫存器

呼叫者儲存: 10 11 號暫存器

d 資料結構:嚴格對齊要求