深入理解计算机系统 -- 程序的机器级表示
1. 程序優化等級
假設有源文件 p1.c 和 p2.c,使用 gcc -Og -o p p1.c p2.c 編譯生成代碼,-Og 會告訴編譯器使用符合原始 C 代碼整體結構的機器代碼優化等級。(PS: -O0 所得到的匯編代碼實用價值極小,幾乎沒有什么用處,建議使用 -Og 或者 -O1(有的較早的編譯器可能不認識 -Og,這是 GCC 4.8 之后引入的內容),可讀性會更高)。
1.1 機器級代碼
對于機器級編程來說,計算機有兩種抽象至關重要,第一種是用指令集結構來定義機器級程序的格式和行為,它定義了處理器的狀態,指令的格式,以及每條指令對狀態的影響,大多數指令集結構將程序的行為描述的好像每條指令都是按順序執行的,實際情況要更加復雜一些,很多指令都是并發執行的。第二種抽象則是虛擬內存,所有機器級程序使用的內存地址均為虛擬內存,計算機提供了一個內存模型,看上去是一個很大的字節數組,具體的內容會在第九章講到。
在整個編譯過程中,編譯器會完成大部分的工作,將把用 C 語言提供的相對比較抽象的執行模型表示的程序轉換成處理器執行的非常基本的指令。匯編代碼十分接近于機器代碼,但可讀性比二進制的機器代碼顯然是高很多的,理解匯編代碼以及它與原始 C 代碼的關系,是理解計算機如何執行程序的關鍵一步。
對于我們來說,可見的處理器狀態主要有以下幾種:
程序計數器,(通常稱為“PC”,在 x86-64 中通常用 %rip 寄存器表示),主要存放下一條要執行的指令在內存中的地址。
整數寄存器,總共包含 16 個寄存器,分別存儲 64 位的值。這些寄存器可以來存放地址(如指針)或者整型數據。有的寄存器用來記錄某些重要的程序狀態,而其他的寄存器用來保存臨時數據,例如函數參數,局部變量以及函數返回值等。
條件碼寄存器,保存著最近執行的算術運算或邏輯指令的狀態信息。用來控制數據流中的條件變化,比如實現 if 和 while 等等語句。
向量寄存器,用來保存一個或多個整數或浮點數值。
C 語言提供了一種模型,可以在內存中分配和聲明各種數據類型的對象,但是實際上機器代碼只是簡單的將內存視為一個巨大的,按字節尋址的數組。C 語言的各種數據類型,如數組,結構體,在機器代碼中用連續的字節表示,對標量數據類型,匯編代碼也不區分有符號和無符號整數,不區分各種類型的指針,甚至不區分指針與整數。
程序內存包括: 可執行的機器代碼,操作系統需要的一些信息,用來管理過程調用和返回的運行時棧,以及用戶分配的內存塊(new, malloc)。程序內存用虛擬地址來尋址。在任意給定時刻,只有有限的一部分虛擬地址是認為合法的。例如,x86-64 的虛擬地址是由 64 位的字來表示的,在目前的實現中,這些地址的最高 16 位必須設置為 0 。所以一個地址實際上能夠指定的是 2^48 或 64 TB 范圍內的一個字節。較為典型的程序只會訪問幾兆字節或幾千兆字節的數據。操作系統負責管理虛擬地址空間,將虛擬地址翻譯成實際的處理器內存中的物理地址。
假如有源文件 main.c ,代碼如下:
long mult2(long, long);
void multstore(long x, long y, long *dest)
{
long t = mult2(x, y);
*dest = t;
}
我們想查看它生成的匯編代碼,可以使用 gcc -Og -S main.c ,即可產生一個 main.s 的匯編文件,此時可以直接用 vim 查看匯編代碼,可以看到諸如以下的匯編代碼:
.LFE34:
.size intlen, .-intlen
.globl multstore
.type multstore, @function
multstore:
.LFB35:
.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
這里就是 multstore 的匯編代碼,假如我們使用 gcc -Og -c main.c ,則會生成目標代碼文件 main.o ,這個文件是二進制格式的,是一個字節序列,它是對一系列指令的編碼。如果使用 vim 查看也只會是一堆亂碼,我們可以使用一類被稱為 反匯編器 的程序來查看這種文件的匯編代碼。比如 objdump -d main.o ,此時會輸出該文件的匯編代碼。如下所示:
0000000000000054 <multstore>:
54: 53 push %rbx
55: 48 89 d3 mov %rdx,%rbx
58: e8 00 00 00 00 callq 5d <multstore+0x9>
5d: 48 89 03 mov %rax,(%rbx)
60: 5b pop %rbx
61: c3 retq
在使用 vim 查看 .o 文件時看到的亂碼實際上就是上述代碼中的十六進制數字對應的 ASCII 碼,對于機器代碼和它的反匯編表示的特性有幾點值得注意:
x86-64 的指令長度從 1 到 15 個字節不等。常用的指令以及操作數較少的指令所需的字節數少,而那些不太常用或操作數較多的指令所需字節數較多。
設計指令格式的方式是,從某個給定位置開始,可以將字節唯一的解碼成機器指令。例如,只有指令 push %rbx 是以字節值 53 開頭的(更多關于指令編碼值相關的翻閱第四章)。
反匯編器只是根據機器代碼文件中的字節序列來確定匯編代碼。它不需要訪問該程序的源代碼或匯編代碼。
假如我們需要對多個源文件進行鏈接生成一個可執行文件,那么這些文件中必須含有 main 函數,我們可以使用 gcc -Og main.c hello.c 來生成一個可執行文件 a.out ,此時 a.out 包含了兩個文件的代碼,還包含了啟動和終止程序的代碼,以及用來與操作系統交互的代碼,我們反匯編 a.out 文件可以看到這些源文件的一些函數的匯編代碼,此外,.o 文件和 .out 文件的顯著區別之一就是,.o 文件的匯編代碼是從地址 0 開始的, 而 .out 文件的地址是從某個地址開始的,具體參閱 第七章 鏈接。
2 數據格式
由于是從 16 位體系結構拓展成 32 位的,Intel 用 ‘字(word)’ 來表示 16 位數據類型,因此 32 位稱為雙字(double words),稱 64 位為四字(quad words),指針存儲為 8 字節的四字,64位機器預期如此。x86-64 中,數據類型 long 實現為 64 位,允許表示的值范圍比較大。下圖表示 C 語言數據在 x86-64 中的大小,大多數 GCC 生成的匯編代碼都會有一個字符的后綴。
一個 x86-64 的中央處理單元(CPU)包含一組 16 個存儲 64 位值的通用目的寄存器。這些寄存器用來存儲整數數據和指針,下圖顯示了這 16 個寄存器,每個寄存器都有特殊的用途,如 %rsp 表示運行時棧頂的地址。
2.1 操作數指示符
大多數指令有一個或多個操作數,指示出執行一個操作中要使用的源數據值,以及防止結果的目的位置。主要有以下三類操作數立即數,寄存器以及內存。
立即數:立即數的格式主要為 '$' 后面加一個用標準 C 表示法表示的整數,比如 $-577 和 $0x1F。
寄存器:主要表示某個寄存器的內容,用 ra (如下圖第 3 行) 來表示任意寄存器 a ,用引用 R[ra] 來表示寄存器 a 里面存儲的值,可以將 R 看成寄存器數組,ra 看成索引標志某個寄存器。
內存引用:內存引用會根據計算出來的地址(通常稱為有效地址)訪問某個內存地址。因為將內存看成一個很大的字節數組,我們使用 Mb[Addr] 來表示存儲在內存中地址 Addr 開始的 b 個字節的引用, 為了簡便,通常省略 b , 即 M[Addr]
3 指令
3.1 數據傳送指令
最頻繁使用的指令是 數據傳送 指令,它將數據從一個位置復制到另一個位置,數據傳送指令有很多種,或者源和目的地址不同,或者執行的轉換不同,或者具有的一些副作用不同。下圖列出了最簡單形式的 mov 指令,可以看到有四條 mov 指令,后綴均不相同,表明每一條指令的操作數大小都不同。
源操作數指定的值是一個立即數,存儲在寄存器中或者內存中,目的操作數指定一個位置,寄存器或者內存地址,x86-64 加了一條限制,傳送指令的兩個操作數不能都是內存地址,將一個值從源地址傳送到目的地址需要兩條指令,將源地址的值加載到寄存器中,再將寄存器的值寫入到目標地址中。下圖展示了源操作數和目的操作數的 5 種可能組合。
此外還有 movz 指令以及 movs 指令,具體的描述如下所示,此外,可以觀察到,圖 3-6 中有 movslq 指令,但是圖 3-5 中沒有 movzlq 指令,這是因為 movzlq 指令所實現的效果可以利用 movl 指令來實現。 這一技術主要利用的屬性是,生成 4 字節值并以寄存器作為目的的指令會把高 4 字節置為 0 。
舉一個數據傳送的例子,有如下代碼:
long exchange(long *xp, long y)
{
long x = *xp;
*xp = y;
return x;
}
其匯編代碼如下:
exchange:
movq (%rdi), %rax
movq %rsi, (%rdi)
ret
首先,根據各個寄存器的作用描述圖可知,xp 存儲在 %rdi, y 存儲在 %rsi , 第一條 mov 指令將 %rdi 寄存器中的地址解引用賦值給 %rax 寄存器,對應著代碼 *xp 賦值給 x ,且 x 為返回值,%rax 寄存器的作用就是作為返回值。故 %rax 寄存器存放的為 x 的值。關于這段匯編代碼可知,C 語言中所謂的指針實質上就是地址,間接引用指針j就是將該指針放在一個寄存器中,然后在內存引用中使用這個寄存器。其次,像 x 這樣的局部變量通常是保存在寄存器中,而不是內存中,訪問寄存器比訪問內存要快的多。
3.2 壓棧和出棧
棧在處理過程調用中起著至關重要的作用,棧存儲著局部變量,函數參數以及函數返回地址等等,它可以添加和刪除值,但是需要遵循先進后出的原則,通過 push 壓數據入棧,pop 將數據彈出棧,它具有一個屬性,彈出的值永遠是最近剛入棧且仍然在棧頂的值,在 x86-64 中,程序的棧被存放在內存中某個區域,棧是向下生長的,也就意味著棧的地址會隨著入棧操作而變小。故而棧頂地址是所有棧中元素中地址最小的,%rsp 指針保存著棧頂地址。下圖是 push 和 pop 指令的效果和描述。
push 和 pop 都只有一個操作數,要壓入的數據源和要彈出的數據目的,將一個四字值壓入棧中,首先要將 %rsp 減 8 ,然后將新值寫到新的棧頂地址,因此 pushq %rbp 等價于 sub $8 , %rsp ; movq %rbp, (%rsp) 。兩者的區別主要在于,機器代碼中 pushq 指令編碼為一個字節,而上面兩條指令需要8 個字節。popq 指令與 pushq 指令類似,popq %rax 等價于 add $8, %rsp ; movq (%rsp), %rax 。此外,也可以利用 push 和 pop 來完成交換值的操作: push %rax ; mov %rbx , %rax ; pop %rbx 。
3.3 算術和邏輯操作
此處需要注意 leaq 和 movq 的區別在于,leaq 不解引用,即 movq M[addr] , %rax 會將 *addr 賦給 rax 寄存器,而 leaq M[addr] , %rax 會將 addr 賦給 rax 寄存器。編譯器經常使用 leaq 來進行一些靈活的算術運算。
3.4 移位操作
移位操作有左移以及右移,左移指令有兩個名字 SAL 和 SHL 。兩者效果是 一樣的,將右邊填上 0。而右移指令則不同 ,SAR 執行算術移位,即右移的時候左邊填充符號位的值,負數為 1 ,正數為 0 ,SHR 則執行邏輯右移,左邊填充 0 。此外,由于乘法的消耗較大,所以很多時候代碼中的乘法編譯器可能會轉換成 移位加上加減法,如 x * 3 可能轉換成 x << 1 + x 。
4 控制
在 C 語言中,有一些結構,如條件語句,循環語句和分支語句,要求有條件的執行,根據數據測試的結果來決定操作執行的順序,機器代碼提供兩種基本的低級機制來實現有條件的行為,測試數據值,然后根據測試的結果來改變控制流或者數據流。
4.1 條件碼
除了整數寄存器,CPU 還維護著一組單個位的條件碼寄存器,它們描述著最近的算術或者邏輯操作的屬性。可以檢測這些寄存器來執行條件分支指令。為了方便說明,假設有 t = a + b 。 然后再看執行過此等式后,常用的條件碼如何生效 :
CF : 進位標志。最近的操作使最高位產生了進位。可用來檢查無符號操作的溢出。如 (unsigned) t < (unsigned) a 。
ZF : 零標志。最近的操作得出的結果為 0 。 如 ( t == 0) 。
SF : 符號標志。最近的操作結果為負數。如 ( t < 0 ) 。
OF : 溢出標志。最近的操作導致一個補碼正/負溢出。如 ( a < 0 == b < 0 ) && ( t < 0 != a < 0) 。
在整數算術操作指令中,只有 leaq 指令不改變任何條件碼,因為它是用來地址計算的。除此之外其他的整數算術操作都會設置條件碼。對于邏輯操作,例如 XOR ,進位標志和溢出標志都被設置為 0 。對于移位操作,進位標志將設置為最后一個被移出的位,而溢出標志設置為 0 。除了這些整數算術指令之外,還有兩類指令只設置條件碼,不影響其他寄存器,即 TEST 指令和 CMP 指令 :
條件碼通常不會直接讀取,常用的使用方法有三種:
可以根據條件碼的組合,將一個字節設置為 0 或者 1。 如下圖中的 SET 指令。
可以條件跳轉到程序的某個其他的部分。
可以有條件的傳送數據
4.2 跳轉指令
正常情況下,指令是按序執行的,跳轉指令會導致執行切換到程序的另一個全新的位置。在匯編代碼中,這些位置目的地通常使用一個 Label 來表示,如下 :
movq $0 , %rax jmp .L1 movq (%rax), %rdx //跳過引用空指針 .L1 popq %rdx
jmp 指令有兩種跳轉方式,一種是直接跳轉,如上述代碼,直接跳轉到 label 的位置,另外一種則是間接跳轉,類似于解引用指針的方法,如 jmp *%rax ,或者 jmp *(%rax) 。jmp 指令具體的條件及描述如下 :
此外我們需要關注一下跳轉指令的編碼,因為這涉及到后續章節的內容,跳轉指令的格式大致為 (指令碼 + 目的地址), 主要有兩種地址編碼方式,一種是相對地址編碼,即目的地址的值為跳轉指令地址于目標指令地址的偏移量,另一種則是絕對地址編碼,顧名思義,即目的地址的值為目標指令的地址。
4.3 條件傳送指令
實現條件操作的傳統方法是通過使用條件控制轉移。根據條件的結果來決定程序的執行路徑,在現代的處理器上,這可能會非常低效,所以另一種替代的策略是使用數據的條件轉移,這種方法計算一個條件操作的兩種結果,然后再根據條件是否滿足從中選擇一個。但是這種策略只有在某些受限制的情況才可行,我們可以用一條簡單的條件傳送指令來實現它,這種條件傳送指令更符合現代處理器的特性。用如下代碼來進行說明:
// absdiff code
long absdiff(long x, long y)
{
long result;
if(x < y)
result = y - x;
else
result = x - y;
return result;
}
// cmovdiff code
long cmovdiff(long x, long y)
{
long rval = y - x;
long eval = x - y;
long ntest = x >= y;
if (ntest) rval = eval;
return rval;
}
// absdiff asm code
absdiff :
movq %rsi , %rax
subq %rdi , %rax // rval = y - x
movq %rdi , %rdx
subq %rsi , %rdx // eval = x - y
cmpq %rsi , %rdi // 比較 x 和 y
cmovge %rdx , %rax // if >= , rval = eval
ret
上述代碼中,cmovdiff 描述了 absdiff 匯編代碼所做的事情,可以看到,匯編代碼中并沒有做出 jmp 指令之類的跳轉操作,反而出現了一條 cmovge 指令,cmovge 指令判斷 cmpq 指令執行后的條件碼,通過指令后綴可以看到,ge 表示大于等于,即 x >= y 的時候,將已經計算好的 eval 存在了返回值寄存器 %rax 中,這段代碼是預先計算好了兩個結果,然后最后做一個簡單的條件判斷來決定返回值。為什么這樣的效率會更高呢?原因如下:
處理器通過流水線來獲得更高的性能,在取一條指令的同時,執行前一條指令的算術運算,要做到這一點,要求事先能夠確定要執行的指令序列,這樣才能保持流水線中充滿了待執行的指令,而當處理器遇到分支跳轉的情況時,需要等待分支條件求值完成之后才能繼續往前走,為了提高效率,處理器采用了十分精密的分支預測邏輯來猜測每條跳轉指令是否會執行,只要猜測可靠,也能保證流水線中充滿了待執行的指令,但是一旦猜錯,需要丟掉處理器為該跳轉指令所做的所有工作,相當于處理器白干了,然后再從正確的位置重新填充流水線,這個過程的消耗是十分大的(約浪費 15 ~ 30 個時鐘周期)。
基于條件傳送指令的代碼,其格式都大致如下:
v = then-expr; ve = else-expr; t = test-expr; if (!t) v = ve;
可以看到,這種格式會對 then-expr 和 else-expr 進行求值,這就會出現一個問題,考慮下面這種函數實現
long cread(long *xp)
{
return (xp ? *xp : 0);
}
cread:
movq (%rdi), %rax // v = *xp
testq %rdi , %rdi // test x
movl $0 , %edx // ve = 0
cmove %rdx, %rax // if x == 0 , v = ve
ret // return ve
該函數看上去很適合使用條件傳送指令的寫法,但是實際上,當 xp 為 空指針時,匯編代碼的第一句就會使程序產生segment fault ,因為條件傳送指令會對兩個分支的結果都進行計算,所以這里必須使用分支代碼的方法來編譯這段代碼(PS : 這里只是假設使用條件傳送指令的寫法,實際上編譯這個函數的時候可以看到其匯編代碼采用的是分支跳轉的方法去實現的)。此外,個人感覺編譯器在使用條件傳送指令方面是比較保守的,因為經過測試,寫了幾種個人認為很適合用條件傳送指令實現的函數,編譯器都采用了條件傳送的方法。
4.4 switch 語句
switch 開關語句可以根據一個整數索引值進行多重分支,在處理具有多種可能結果的測試時,這種語句是十分有用的,不僅提高了代碼的可讀性,而且通過使用 跳轉表 這種數據結構使實現更加高效。跳轉表是一個數組,表項 i 是一個代碼段的地址,這個代碼段實現當開關索引值等于 i 時程序應該執行的操作,程序代碼用開關索引值來執行一個跳轉表內的數組引用,確定跳轉指令的目標。和使用一組很長的 if-else 語句相比,跳轉表的優點是執行開關語句的時間與開關情況的數量無關。GCC 根據 case 的數量和以及索引值的稀疏程度來翻譯開關語句,當 case 數量比較多時,如 4 個以上的 case ,并且 這些 case 的索引值跨度比較小時,就會使用跳轉表。
上圖展示了一個 switch 的例子,以及翻譯成跳轉表格式之后的代碼,可以看到,原始的 C 代碼中,索引值的區間為 [100, 106],由于跳轉表是數組形式的,為了節約空間,跳轉表格式的代碼將范圍縮減至 [0, 6] ,這樣只需7個指針大小的空間就足夠了,然后根據參數 n - 100 來決定執行哪個 case。下面展示使用跳轉表結構的匯編代碼是怎樣的(PS:跳轉表結構的匯編代碼每個 case 的順序應該是與原始代碼一致的):
下圖是跳轉表的匯編聲明,這些聲明表示在 .rodata(read-only data)的目標代碼文件的段中,有一組 7 個四字(8個字節)的數據,每個字的值都是與指定的匯編代碼標號(如 L3 )相關練的指令地址。標號 L4 標記出這個分配地址的起始。與這個標號相對應的地址會作為間接跳轉(第 5 行)的基地址。(PS:標號只是一個符號,其后面的數字沒有任何意義,只是用來區分,L3 并不代表它是第 3 個case。)
5. 過程
過程是軟件中一種很重要的抽象,它提供了一種封裝代碼的方式,用一組指定的參數和一個可選的返回值實現了某種功能。然后可以在程序中不同的地方調用這個函數。要提供對過程的機器級支持,需要處理許多不同的屬性。為了討論方便,假設過程 P 調用過程 Q , Q 執行后返回到 P。這些動作包含以下機制:
傳遞控制:在進入過程 Q 的時候,程序計數器(PC,寄存器則為 %rip)必須被設置為 Q 的代碼的起始位置,然后在返回時,要把程序計數器設置為 P 中調用 Q 后面那條指令的地址。
傳遞數據:P 必須能夠向 Q 提供一個或者多個參數,Q 必須能夠向 P 返回一個值。
分配和釋放內存:在開始時,Q 可能需要為局部變量分配空間,而返回的時候也必須要釋放這些空間。
5.1 運行時棧
C 語言過程調用機制的一個關鍵特性在于使用了棧數據結構提供的后進先出的內存管理原則,大多數其他語言也是如此。一個典型的運行時棧結構如下圖所示:
當 x86-64 過程需要的存儲空間超出寄存器能夠存放的大小時,就會在棧上分配空間。這部分稱為過程的棧幀。當過程 P 調用過程 Q 的時候,會將返回地址壓入棧中,指明當 Q 返回時,要從 P 的哪個位置開始執行。這個返回地址屬于 P 的棧幀,通常來說,大多數過程的棧幀都是定長的,在過程開始就分配好了。但是有些過程需要變長的棧幀,如在一個函數中動態分配數組(動態分配數組一般通過 malloc 或者 new ,此時是從堆上分配內存,而 alloc 可以從棧上動態分配內存,且無需手動釋放),這個問題會在后面討論。通過寄存器,過程 P 可以傳遞最多 6 個整數值(指針和整數),如果需要更多的參數,則需要在 P 的棧幀中保存好這些參數。這里也延申出一個問題,非整數型參數是如何傳遞的呢?實際上也是通過調用者的棧幀去傳遞的。
5.2 轉移控制
將控制從函數 P 轉移到函數 Q 只需要簡單的把程序計數器(PC)設置為 Q 的代碼的起始位置。不過,當稍后從 Q 返回的時候,處理器必須記錄好它需要繼續 P 的執行代碼的位置。在 x86-64 機器中,這個信息是用指令 Call Q 調用過程 Q 來記錄的。該指令會把地址 A (即原先過程函數 P Call Q 指令的下一條指令地址),壓入棧中,并且設置 PC ,壓入的地址 A 稱為 返回地址。下表為 call 和 ret 指令的一般形式:
可以看到 call 指令有一個目標,即指明被調用過程起始的指令地址。同跳轉一樣,調用可以是直接的,也可以是間接的。call 指令的作用是將目標指令的地址壓入棧中,并且設置程序計數器,假設目標指令地址為 0x400000, 那么實際操作起來等同如下指令:
sub $0x8, %rsp mov %rip , %rsp mov *0x400000, %rip
而 ret 指令則等價于如下指令
mov %rsp , %rip add $0x8, %rsp
關于過程調用與返回更具體的例子可以參考以下代碼:
5.3 數據傳送
當調用過程時,除了控制傳遞之外,過程調用還可能將數據作為參數傳遞,而從過程返回還有可能包括返回一個值。x86-64 中,大部分過程間的數據傳送是通過寄存器實現的,可以通過寄存器最多傳遞 6 個整型(整數和指針)參數。寄存器的使用是有特殊順序的,具體的規則如下所示:
當函數參數大于 6 個整型參數時,超出部分需要通過棧來傳遞。假設過程 P 調用過程 Q , 有 n 個整型參數,且 n > 6。那么 P 的代碼分配的棧幀必須要能容納 7 到 n 號參數的存儲空間(此時意味著,棧上可能有大約兩份 7~n 號參數的變量),且參數 7 是位于棧頂,即參數是從右到左,依次入棧的。所有的數據大小都向 8 的倍數對齊。參數到位以后,程序就可以執行 call 指令進行控制轉移到過程 Q 了。過程 Q 可以通過寄存器訪問參數,有必要的話也可以通過棧訪問。(PS: 建議實際寫一個類似書中圖 3-29 的函數去進行測試,查看 -Og 優化級別的匯編代碼,有助于加深理解)
5.3 棧上局部存儲
有些時候,局部數據必須存放在內存中,常見的情況包括:
寄存器不夠存放所有的本地數據。
對一個局部變量使用地址運算符 “&” , 因此需要為它產生一個地址。
某些局部變量是數組或者結構,因此必須能夠通過數組或結構引用被訪問到。
一般來說,過程都是通過減小棧指針(%rsp)在棧上分配空間。分配的結果作為棧幀的一部分。寄存器組是唯一被所有過程共享的資源,雖然在給定時刻只有一個過程是活動的,我們仍然必須確保當一個過程(調用者)調用另一個過程(被調用者)時,被調用者不會覆蓋調用者稍后會使用的寄存器值。因此,x86-64 采用了一組統一的寄存器使用慣例,所有的過程都必須遵循。
依據慣例,寄存器 %rbx, %rbp 和 %r12 ~ %r15 被劃分為被調用者保存寄存器。當過程 P 調用過程 Q 時, Q 必須保存這些寄存器的值,保證它們的值在 Q 返回到 P 時與 Q 被調用時是一樣的。過程 Q 保存一個寄存器的值不變,要么就是根本不去改變它,要么就是把原始值壓入棧中(push),改變寄存器的值,然后在返回前從棧中彈出舊值(pop)。
然后其他的寄存器,除了 %rsp 棧指針 , 都分類為 調用者保存寄存器 。 這就意味著任意函數都能隨意修改它們。
6. 數組分配與訪問
C 語言中的數組是一種將標量數據聚集成更大數據類型的方式。 C 語言實現數組的方式非常簡單,因此很容易翻譯成機器代碼。 C 語言的一個不同尋常的特點是可以產生指向數組中元素的指針,并對這些指針進行運算。在機器代碼中,這些指針會被翻譯成地址計算。
6.1 指針運算
C 語言中允許對指針進行運算,而計算出來的值會根據該指針引用的數據類型的大小進行伸縮。也就是說,如果 p 是一個指向類型為 T 的數據的指針,p 的值為 xp ,那么表達式 p + i 等價于 xp + sizeof(T) * i 。現在擴展一下這個例子,假設整型數組 E 的起始地址和整數索引 i 分別存放在寄存器 %rdx 和 %rcx 中。下面是一些與 E 有關的表達式。此外給出了每個表達式的匯編代碼實現,結果存放在寄存器 %eax (結果是數據)或者 %rax (結果為指針)。
6.2 嵌套數組
當我們定義一個二維數組 int A[5][3] ,我們可以用 A[0][0] 到 A[4][2] 來引用。數組元素在內存中按照 “行優先” 的順序排列,所以可以得出下圖:
通常來說,對一個聲明為 T D[R][C] 的數組,他的數組元素 D[i][j] 的內存地址為 &D[i][j] = xD+ sizeof(T) * (C * i + j)。考慮前面定義的 5 * 3 的整型數組 A 。假設 xA , i 和 j 分別在寄存器 %rdi ,%rsi 和 %rdx 中。然后,可以用下面的代碼將數組元素 A[i][j] 復制到寄存器 %eax 中。可以看到,這段匯編代碼的計算公式為 xA+ 12 * i + 4 * j = xA+ 4 * (3 * i + j) 。
定義一個數組的時候,我們通常都是諸如 int A[5] 這樣的形式,實際上我們可以使用類似下面的代碼:
#define N 5 typedef int A[N]
這樣如果需要修改這個值,只需簡單修改 define 聲明即可。那么這里又引申出一個問題,為什么不使用 const 來定義 N 呢,因為 C 語言的 const 和 define 還是有些區別的,具體可以參考https://stackoverflow.com/questions/4024318/why-do-most-c-developers-use-define-instead-of-const。
7. 異質的數據結構
C 語言提供了兩種將不同對象組合到一起創建數據類型的機制,結構(struct)和 聯合(union)。結構可以將多個對象集合到一個單位中,而聯合允許使用幾種不同的類型來引用一個對象。
7. 1 結構
struct 可以把幾種不同的對象聚合到一個對象中,用名字來引用結構的各個組成部分。類似于數組的實現,結構的所有組成部分都存放在內存中一段連續的區域內,而指向結構的指針就是結構第一個字節的地址。編譯器維護關于每個結構類型的信息,指示每個字段的字節偏移。它以這些偏移作為內存引用指令中的位移,從而產生對結構元素的引用。
考慮這樣的結構聲明:
struct rec{
int i;
int j;
int a[2];
int *p;
};
這個結構的結構圖大致如下:
為了訪問結構的字段,編譯器的代碼要在結構的地址上加上適當的偏移,例如,假設 struct rec* 類型的變量 r 放在寄存器 %rdi 中。那么下面的代碼將元素 r->i 復制到元素 r->j :
// %rdi = r ,r 是指針 movl (%rdi), %eax // 獲取 r->i movl %eax, 4(%rdi) // r->j = r->i
再舉一個例子,r->p = &(r->a[r->i + r->j]) ,其匯編代碼如下:
// %rdi = r movl 4(%rdi), %eax // 獲取 r->j addl (%rdi), %eax // r->j = r->j + r->i cltq // 符號擴展%eax 到 64 位 leaq 8(%rdi, %rax, 4), %rax // 計算 &r->a[r->i + r->j] movq %rax, 16(%rdi) // 結果保存到 r->p
7. 2 聯合
聯合提供了一種方式,允許以多種類型來引用一個對象。聯合聲明的語法與結構的語法一樣,只不過語義相差比較大。它們是用不同的字段來引用相同的內存塊。考慮下面的聲明:
struct S3{
char c;
int i[2];
double v;
};
union U3{
char c;
int i[2];
double v;
}
編譯后,字段的偏移量,數據類型 S3 和 U3 的完整大小如下:
可以觀察得出,不管訪問 U3 的哪個字段,都是從起始位置開始,此外,一個聯合的總大小等于它最大字段的大小。假如我們實現知道對一個數據結構中的兩個不同字段的使用是互斥的,那么將兩個字段聲明為 union 可以減少分配的空間的總量。
聯合還可以用來訪問不同的數據類型的位模式,例如,假設我們使用簡單的強制類型轉換將一個 double 類型的 值 d 轉換為 unsigned long 類型的值 u , unsigned long u = (unsigned long) d; 值 u 會是 d 的整數表示。除了當 d = 0.0 之外,u 的位表示與 d 的很不一樣。再看下面這段代碼,從一個 double 產生一個 unsigned long 類型的值:
unsigned long double2bits(double d){
union{
double d;
unsigned long u;
} temp;
temp.d = d;
return temp.u;
}
此時得到的 u 會與 d 具有相同的位表示,但是值 u 就不會是值 d 的整數表示了。再舉另一個例子:
double uu2double(unsigned word0, unsigned word1){
union{
double d;
unsigned u[2];
}temp;
temp.u[0] = word0;
temp.u[1] = word1;
return temp.d;
}
假如在小端法機器上,參數 word0 是 d 的低位 4 個字節,而 word1 是高位 4 個字節,大端法機器上則相反。因為 temp 數組的賦值已經決定了它的兩個元素的地址了,word0 存放在起始位置,word1 順延起始地址存放,而小端法是解讀地址較小一端為低位字節。
8. 數據對齊
許多計算機系統對基本數據類型的合法地址做出了一些限制,要求某種類型對象的地址必須是某個值 K (通常是 2 ,4 或者 8 )的倍數。這種對齊限制簡化了形成處理器和內存系統之間接口的硬件涉及。例如,假設一個處理器總是從內存中取 8 個字節,則地址必須為 8 的倍數。如果我們能保證將所有的 double 類型數據的地址對齊成 8 的倍數,那么就可以用一個內存操作來讀或者寫值了。否則,我們可能需要執行兩次內存訪問,因為對象可能被放在兩個 8 字節內存塊中。
雖然無論數據是否對齊,x86-64 都能正常工作,但是對齊數據有利于提高內存系統的性能。對齊原則是任何 K 字節的基本對象的地址必須是 K 的倍數。可以看到這條原則會得到如下對齊:
對于結構體,編譯器可能需要在字段的分配中插入間隙,以保證每個元素都滿足對齊要求,以下面的代碼舉例:
struct S1{
int i;
char c;
int j;
};
假如在不對齊的情況下,那么這個結構體的大小為 9 字節,結構圖如下:
這樣的結構無法滿足字段 i 和 j 的 4 字節對齊要求。因此編譯器在字段 c 和 j 之間插入一個 3 字節的間隙:
此時, j 的偏移量為 8 ,整個結構體的大小擴大為 12 個字節,保證了這個結構體是對齊的。此外,編譯器需要保證 struct S1 * 類型的指針 p 都滿足 4 字節對齊。因為其首字段 i 是 int 類型,大小為 4 個字節,這樣能保證 p->i 總是滿足 4 字節對齊的。
假如我們改變結構體字段定義的順序成如下代碼:
struct S1{
int i;
int j;
char c;
};
那么此時的結構體無需插入在中間插入無用的間隙,只要保證結構體的起始位置滿足 4 字節的對齊要求,那么仍然能滿足對齊,但是假如定義了一個該結構體的數組。那么此時需要在結構末尾增加 3 個字節,如下圖所示:
因為分配 9 個字節無法滿足數組的每個元素的對齊要求,假如為 9 個字節,那么元素的地址分別為 x, x + 9 , x + 18 , x + 27。但是如果多分配了 3 個字節,那么此時數組元素的地址分別為 x, x + 12 , x + 24 , x + 36 ,在 x 的地址為 4 的倍數的情況下,顯然只有后者可以滿足對齊限制。
10. 緩沖區溢出攻擊
假設有如下代碼:
char *gets(char *s)
{
int c;
char *dest = s;
while((c = getchar()) != '
' && c !=EOF)
*dest++ = c;
if (c == EOF && dest == s)
return NULL;
return s;
}
void echo()
{
char buf[8];
gets(buf);
puts(buf);
}
可以看到,gets 函數從標準輸入中讀取一行,在遇到換行回車字符或者錯誤情況是停止。它將這個讀取的字符串復制到 s 指明的位置,并且在字符串結尾加上 null 字符。gets 的問題在于它無法確定是否有足夠的空間保存字符串,在 echo 函數中,我們分配了一個很小的空間區調用 gets 。下面是 echo 的匯編代碼以及 echo 的棧幀:
當我們輸入的字符數量過多時,就會破壞 echo 棧幀的結構,下圖時輸入字符的數量以及對應造成的破壞效果:
假如在棧可執行的情況下,我們可以在棧上注入攻擊代碼,然后通過覆蓋調用者的棧幀的返回地址,來達到調用我們的攻擊代碼的目的。如果說棧不可執行的情況下,我們也可以通過覆蓋返回地址,進行 ROP 攻擊,更多關于緩沖區攻擊的內容,建議完成本書對應的 attack lab , 會大有收獲。
那么如何多抗緩沖區溢出攻擊呢,主要有幾種機制:
棧隨機化:
為了注入攻擊代碼,攻擊者除了插入代碼,還需要插入指向這段代碼的指針,這個指針也是攻擊字符串的一部分,意味著這個指針是存放在棧上的,過去,棧的位置是相當固定的,因此指針的值可以直接寫死在字符串中。
棧隨機化使棧的位置每次運行都會變化,這意味著每次運行程序的時候,注入代碼的位置是會改變的,這樣攻擊者如果直接在字符串中寫入地址,是很難直接調用到注入的攻擊代碼的。
這個機制的實現方式是在程序開始時,在棧上分配一段 0 ~ n 字節之間的隨機大小的空間,可以使用 alloca 函數進行分配,然后不使用這塊空間,n 的設計也需要合理,才不至于浪費空間,又能保證棧地址變化難以預測。
但是這種機制仍有方法進行破解,通過在實際的攻擊代碼之前注入很長的一段 nop 指令,這條指令除了使程序計數器加一,使之指向下一條指令之外,沒有其他效果,這個 nop 指令序列被稱為 nop sled ,這樣我們覆蓋返回地址的時候,只要預測的地址剛好落在 nop sled 上,那么程序就會一直執行 nop 指令直到我們的攻擊代碼處。
假設我們建立一個 256(2^8) 字節的 nop sled ,那么枚舉 32768(2 的 15 次方)個起始地址,就能破解 n = 2^23 的隨機化(32 位 linux 的地址變化范圍大小約為 2^23 , 64 位為 2^32)。
棧破壞檢測:
計算機能檢測到棧何時被破壞,其思想是在棧幀任何局部緩沖區與棧狀態之間存儲一個特殊的 canary 值,如下圖所示:
由于這個值實在程序每次運行時隨機產生的,因此攻擊者沒有簡單的方法能夠獲取它,在恢復寄存器狀態和從函數返回之前,程序會檢查這個值是否被改變了,如果是,則程序異常終止。( PS:可以通過 “-fno-stack-protector” 來阻止這種代碼產生)
由于該值被存放在一個特殊的段中,標志為只讀,所以攻擊者不能覆蓋存儲的值,在返回的時候,函數會校驗棧上的這個值與它存放在特殊段中的值。這種棧保護機制很好的防止了緩沖區溢出攻擊破壞存儲在程序棧上的狀態。只會帶來很小的性能損失,而且 GCC 只會在函數中有局部 char 類型緩沖區的時候才插入這樣的代碼。
限制可執行代碼區域
虛擬的內存空間分為頁,典型的每頁具有 2048 或者 4096 個字節。許多系統允許控制三種訪問形式:讀(從內存中讀數據),寫(存儲數據到內存)和執行(將內存的內容看作機器級代碼)。之前 x86 體系將讀和執行訪問控制合并為一個 1 位的標志,這樣可讀的頁也是可執行的。而棧必須是可讀寫的,故棧上也是可執行的。而最近 AMD 和 intel 引入了不可執行的位,將讀與執行分開,因此棧可以被標記為可讀寫不可執行。這種機制可以讓注入的攻擊代碼無法被執行。
11. 變長棧幀
假設有如下代碼:
為了管理變長棧幀,x86-64 使用寄存器 %rbp 作為幀指針,使用幀指針時,上述代碼的棧幀結構如下:
可以看到代碼必須把 %rbp 之前的值先 push 到棧中,因為它是一個被調用者保存寄存器。然后在函數的整個執行過程中,都使得 %rbp 指向那個時刻棧的位置,然后用固定長度的局部變量相對于 %rbp 的偏移量來引用它們。
12. 浮點代碼
處理器的浮點體系結構包括以下幾個方面:
如何存儲和訪問浮點數值。通常時通過某種寄存器方式來完成。
對浮點數據操作的指令。
向函數傳遞浮點數參數和從函數返回浮點數結果的規則。
函數調用過程中保存寄存器的規則(調用者保存和被調用者保存)。
12.1 YMM 寄存器
12.2 浮點傳送和轉換操作
不論數據對齊與否,這些指令都能正確執行,但代碼優化規則建議 32 位內存數據滿足 4 字節對齊, 64 位數據滿足 8 字節對齊。
下面給出了浮點數和整數數據類型之間以及不同浮點格式之間進行轉換的指令集合。把浮點數轉換成整數時,指令會執行截斷,把值向 0 進行舍入。
圖 3-48 中的指令把整數轉換成浮點數。它使用的是三操作數格式,有兩個源和一個目的。第一個操作數讀自于內存或者一個通用目的寄存器。這里可以忽略第二個操作數,因為它的值只影響結果的高位字節。而我們的目標必須是 XMM 寄存器。在最常見的使用場景中,第二個源和目的操作數都是一樣的,如:
vcvtsi2sdq %rax, %xmm1, %xmm1
這條指令從 %rax 讀取一個長整數,然后把它轉換成數據類型 double ,并把結果存放到 %xmm1 的低字節中。
12.3 過程中的浮點代碼
在 x86-64 中,有如下規則:
XMM 寄存器 %xmm0 ~ %xmm7 最多可以傳遞 8 個浮點參數。按照參數列出的順序使用這些寄存器。可以通過棧傳遞額外的浮點參數。
函數使用寄存器 %xmm0 來返回浮點值。
所有的 XMM 寄存器都是調用者保存。
12.4 浮點運算操作
每條指令都有一個(S1)或者兩個(S1,S2)源操作數和一個目的操作數,第一個源操作數 S1 可以是一個 XMM 寄存器或者一個內存位置。第二個源操作數和目的操作數必須是 XMM 寄存器。
12.5 浮點位級操作
12.6 浮點比較操作
浮點比較指令會設置 3 個條件碼,ZF(零標志位),CF(進位標志位)和 PF(奇偶標志位)。奇偶標志位比較少見,它只有在最近的依次算術或邏輯運算產生的值的最低位字節是偶校驗的(即這個字節有偶數個 1)才會被設置。 條件碼的設置條件如下:
當任一操作數位 NaN 的時候,就會出現無序的情況,可以通過奇偶標志位發現這種情況。
12.7 浮點代碼小結
我們可以看到,浮點數的匯編代碼其實和整數的匯編代碼類似,它們都使用一組寄存器來保存和操作數據值,也都使用這些寄存器來傳遞函數參數。
13 小結
這一章主要了解了機器級別的編程。機器級程序和它們的匯編代碼表示,與 C 程序的差別很大。各種數據類型之間的差別很小。程序是以指令序列來表示的,每條指令都完成一個單獨的操作。部分程序狀態,如寄存器和運行時棧,對程序員來說時直接可見的,編譯器必須使用多條指令來產生和操作各種數據結構,以及實現像條件,循環和過程這樣的控制結構。這一章比較長,建議閱讀完這一章之后去做 attack lab 以及 bomb lab ,這里附上 lab 的網址:http://csapp.cs.cmu.edu/3e/labs.html,attack lab 可以加深對棧的理解,而 bomb lab 可以提高對匯編代碼的閱讀能力。在此建議使用 linux 環境來實現書中的代碼,因為書中的主要實驗環境也是在 linux 下,而且阿里云或者騰訊云都有學生優惠云服務器,一年 100 多,一個月不到 10塊錢,性價比十足,此外,我個人推薦使用 GDB 來調試程序,GDB 的 layout 指令可以以圖形界面同時查看程序的匯編代碼和寄存器。并且可以單步調試匯編代碼。關于這一章的筆記寫的比較長,如果有什么問題可以在評論中指出,謝謝~
總結
以上是生活随笔為你收集整理的深入理解计算机系统 -- 程序的机器级表示的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: C#(asp.net)读取yodao提供
- 下一篇: Android模拟器,ADB命令