3.1 程序編碼
1.計算機系統使用了多種不同形式的抽象,對於機器級編程來說,兩種抽象尤為重要:
- 指令集體系結構(ISA):定義了處理器狀態、指令的格式,以及每條指令對狀態的影響
- 機器級程序使用的存儲器地址是虛擬地址:提供的存儲器模型看上去是一個非常大的字節數組
2.反匯編器使用的指令命名規則與GCC生成的匯編代碼使用的有區別。反匯編省略了指令結尾的q,給call和ret指令添加了q后綴。
3.可執行程序反匯編和對.c反匯編產生的代碼有差別。對於可執行文件的反匯編,鏈接器將代碼的地址移到了一段不同的地址范圍,鏈接器的任務之一就是為函數調用找到匹配的函數的可執行代碼的位置。
3.2 數據格式
GCC生成的匯編代碼指令都有一個字符的后綴,表明操作數的大小、。后綴l可以表示4字節整數和8字節雙精度浮點數,但是並沒有歧義,因為浮點數使用的是一組完全不同的指令和寄存器。
3.3 訪問信息
1.x86-64的CPU包含一組16個存儲64位值的通用目的寄存器,用來存儲整數數據和指針。
2.不同操作數可能被分為三種類型,分別為立即數(表示常數)、寄存器(表示某個寄存器的內容)、內存引用(根據計算出來的地址訪問某個內存位置)。
3.傳送指令兩個操作數不能都指向內存位置。MOV指令只會更新目的操作數指定的那些寄存器字節或內存位置。唯一的例外是movl指令以寄存器作為目的時,會把寄存器的高位4字節設置為0。movq指令只能以表示為32位補碼數字的立即數作為源操作數,然后把這個值符號擴展得到64位的值,放到目的位置。
4.MOVZ類中的指令把目的中剩余的字節填充為0,MOVS類中的指令通過符號擴展來填充,把源操作的最高位進行復制。它們均以寄存器或內存地址作為源,以寄存器作為目的。
把4字節源值零擴展到8字節邏輯上應該是movzlq,但並沒有這樣的指令。可以使用movl來實現(movl指令會把寄存器的高位4字節設置為0)。
3.4 算術和邏輯操作:
如果寄存器%eax的值為x,那么指令leal 3(%edx, %edx, 2),%eax
將設置%eax的值為2x+3。
移位量可以是一個立即數,或者放在單字節寄存器%cl中。左移指令有SAL和SHL,兩者效果一樣,都是將右邊填上0,而右移指令不同,SAR執行算術移位(填上符號位),而SHR執行邏輯移位(填上0)。
無符號數乘法(mulq)和補碼乘法(imulq)要求一個參數必須在%rax中,另一個作為指令的源操作數給出。乘積存放在%rdx(高64位)和%rax(低64位);有符號除法idivl 將寄存器 %rdx(高32位)和 %rax(低32位)中的64位數作為被除數,而除數作為指令的操作數給出。指令將商存儲在%rax中,將余數存儲於%rdx中。
3.5 控制
1.條件碼寄存器描述了最近算術或邏輯運算的屬性,可以檢測這些寄存器來執行條件分支指令:
-
CF:進位標志。可用來檢查無符號操作的溢出。如:(unsigned)t < (unsigned)
-
ZF:零標志。如:(t == 0)
-
SF:符號標志。如:(t < 0)
-
OF:溢出標志,最近的操作導致了補碼溢出。如:(a<0==b<0)&&(t<0!=a<0)
2.leaq 指令不會設置條件碼,除過前面提到的指令外,CMP(和SUB行為一樣)和TEST(和ADD行為一樣)指令會設置條件碼,但不改變任何其他寄存器。testq %rax %rax
用來檢查 %rax 是零、正數還是負數。
3.條件碼通常不會直接讀取,通常使用的方法有三種:
- 可以根據條件碼的某種組合,將一個字節設置為0或者1。
- 可以條件跳轉到程序的某個其他部分。
- 可以有條件的傳送數據
SET指令時條件碼的組合,執行比較指令,根據計算t=a-b設置條件碼。有符號比較測試基於SF、OF和ZF的組合,無符號比較測試基於CF和ZF。
4.jump 指令有三種跳轉方式:直接跳轉、間接跳轉(‘*’后跟一個操作數指示符)、其他條件跳轉(根據條件碼的某個組合,或者跳轉,或者繼續執行代碼序列中的下一條指令)。
常用的PC相對的對於跳轉指令的編碼會將目標指令的地址與緊跟在跳轉指令后面那條指令的地址之間的差作為編碼。
5.匯編中沒有do-while、while和for相應的指令存在,可以用條件測試和跳轉組合起來實現循環的效果。大多數匯編器中都要先將其他形式的循環轉換成do-while格式。
do-while的通用形式可以翻譯成如下所示的條件和goto語句:
loop:
body-statement
t=test-expr;
if(t)
goto loop;
while循環第一種翻譯方式跳轉到中間:
goto test;
loop:
body-statement
test:
t=test-expr;
if(t)
goto loop;
第二種翻譯方式為首先用條件分支,如果初始條件不成立就跳過循環,轉化為do-while循環:
t=test-expr;
if(!t)
goto done;
loop:
body-statement
t=test-expr;
if(t)
goto loop;
done:
for循環可以很容易轉換成while循環,進而轉換成do-while形式:
init-expr;
t=test-expr;
if(!t)
goto done;
loop:
body-statement
update-expr;
t=test-expr;
if(t)
goto loop;
done:
switch語句的跳轉表是一個數組,表項i是一個代碼段的地址,這個代碼段實現當開關索引值等於i時程序應該采取的動作。
3.6 過程
1.過程提供了一種封裝代碼的方式,用一組指定的參數和一個可選的返回值實現了某種功能。然后,可以在程序中不同的地方調用這個函數。過程機制的構建需要實現傳遞控制、傳遞數據、分配和釋放內存。
2.過程P可以傳遞最多6個整數值,如果Q需要更多參數,P可以在調用Q前在自己的棧幀里存儲好這些參數。寄存器最多傳輸6個小於等於64位的數據,並通過%rax返回數據。如果一個函數有大於6個整型參數,超出6個的部分就通過保存在調用者的棧幀來傳遞。
3.%rbx、%rbp和%r12~%15被調用者保存,在使用前被調用者要把這里面的值保存好,保證其值在返回時和調用時是一樣的(這里就像有我有一輛豪車,可以把車子借給朋友使用,但是一定要把鑰匙保存好,用完了之后還回來),這讓我想到了之前看過的匯編代碼在被調用函數的第一步都是 push %ebp.
4.所有其他寄存器,除了%rsp為調用者保存,意味着任何函數都能修改它們,則在調用前首先保存好這個數據是調用者的責任。(這里的調用者就像很有票子的王健林一樣,兒子王思聰可以無償的使用王健林的票子)
參考《深入理解計算機系統》| 程序的機器級表示。
5.遞歸的調用其實與其他函數的調用是一樣的,因為每個過程調用在棧中都有私有的空間,多個未完成調用的局部變量不會相互影響。
3.7 數據分配和訪問
1.設 xA 表示起始位置,則訪問數組元素 A[i] 的位置在 xA+ L*i,L為數據類型的大小(單位為字節)。數組元素的訪問一般借助存儲器引用指令。如計算 int 型的 E[i]: E 的地址存放在 %rdx 中,而 i 存放在 %rcx 中。movl (%rdx,%rcx,4),%eax
表示計算地址 xE+4i,並讀取這個存儲器位置的值,將結果放到 %eax 中。
2.如果 P 是一個執行類型 T 的數據的指針,P 的值為 xP,那么表達式 P+i 的值為 xP + L*i,L 是數據類型T的大小。假設整型數組 E 的起始地址和整數索引 i 分別存放在 %rdx 和 %rcx 中,下面是一些與 E 有關的表達式,可以明顯看出 leal 和 movl 的區別(前者產生地址,后者引用內存):
3.數組的嵌套,也就是數組的數組。對於數組 int A[5][3],可以將 A 看成是一個有 5 個元素的數組,而每個元素都是 3 個 int 類型的數組。計算D[R][C](int 型)的地址:
&D[i][j] = xD + L(C * i + j)
由於每組有 C 個數據,所以跳過一組就要乘以C,跳過I組就 C*i 個,再加上偏移的 j 就是所求地址。
3.8 異質的數據結構
1.結構:所有的組成部分在存儲器中連續存放,指向結構的指針指向結構的第一個字節。
2.聯合:允許以多種類型來引用一個對象,總大小等於它最大字段的大小,而指向一個聯合的指針,引用的是數據結構的起始位置。
3.x86-64系統對齊要求為:對於任何需要K字節的標量數據類型的起始地址必須是K的倍數。匯編中.align 8
要求后面的數據起始位置是8的倍數。結構體的對齊除了要滿足每個字段的對齊要求,還需要考慮整體的結構滿足怎樣的對齊要求。
如:
struct test {
int i;
int j;
char c;
};
我們能保證起始地址4字節對齊要求,但struct s2 d[4]
就不能滿足 d 的每個元素的對齊要求,因為這些元素的地址分別為xd,xd+9,xd+18和xd+27,所以為s2分配12個字節。
3.9 在機器級程序中將控制與數據結合起來
1.void * 表示通用指針,malloc函數返回一個通用指針,然后轉換成一個有類型的指針。
2.指針從一個類型轉為另外一個類型,只是伸縮因子變化,不改變它的值。如 p 是一個 char * 類型的指針,值為p,(int * )p + 7
計算為 p+28 ,而(int * )(p + 7)
計算為 p+7。
3.C對數組引用不做邊界檢查,同時局部變量和狀態信息(寄存器值和返回指針等)都存放在棧中,這使得越界的數組寫操作會破壞存儲在棧中的狀態信息。常見的狀態破壞稱為緩沖區溢出。
棧是向低地址增長的,數組緩沖區是向高地址增長的。故上圖所示 buf[8] 在輸入超過 8 個時就會覆蓋棧上存儲的某些信息。如果破壞了存儲的返回地址,那么ret指令會使程序跳轉到完全意想不到的地方(如跳轉到攻擊代碼)。使用gets或strcpy、strcat、sprintf等能導致存儲溢出的函數(不需要告訴它們目標緩沖區的大小就產生一個字節序列),都不是好的編程習慣。
4 對抗緩沖區溢出攻擊的方法:
- 棧隨機化:使得棧的位置在程序每次運行時都有變化。實現的方式是程序開始時,在棧上分配一段0--n字節之間的隨機大小空間
- 棧破壞檢測:在棧中任何局部緩沖區與棧狀態之間存儲一個特殊的金絲雀值。這個金絲雀值是在程序每次運行時隨機產生的,因此,攻擊者沒有簡單的辦法知道它是什么。在恢復寄存器狀態和從函數返回之前,程序檢查這個金絲雀值是否被該函數的某個操作或者函數調用的某個操作改變了。如果是,那么程序異常終止
- 限制可執行代碼區域:限制那些能夠存放可執行代碼的存儲器區域
3.10 浮點代碼
浮點數操作和整數操作很類似,指令命名上有區別,故此部分簡述。
1.AVX浮點體系結構允許數據存儲在16個YMM寄存器中,每個YMM寄存器是256位。對標量數據(單個數據)操作時,寄存器只保存浮點數,而且只使用低32位(float)或64位(double)。
2.浮點傳送指令:
3.浮點轉換指令:
4.%xmm0~%xmm7最多可以傳遞8個浮點參數,額外參數通過棧傳遞。%xmm0返回浮點數。XMM寄存器都是調用者保存,被吊用着不用保存就覆蓋這些寄存器中的任意一個。當函數包含指針、整數和浮點數混合的參數時,指針和整數通過通用寄存器傳遞,浮點值通過XMM寄存器傳遞。
5.浮點運算操作(第一個源操作數S1可以是XMM寄存器或內存位置,第二個源操作數和目的操作數必須是XMM寄存器):
6.AVX浮點操作不能以立即數值作為操作數,編譯器需要為所有常量分配和初始化存儲空間(從標號為 .LC2 的內存位置讀出 1.8,從標號為 .LC3 的內存位置讀出 32.0):
7.位級操作:
8.浮點比較操作(S2必須在XMM寄存器中):
浮點比較指令會設置ZF、CF、奇偶標志位PF(當浮點操作數中任一個時NaN會設置該位):
3.11 問題及解決
B中最大為long,所以以8字節對齊,我想當然地將 i 后填充4,c和d后填充7,總共為32字節。看了答案是16字節后,我意識到 i、c、d “拼”一起依然小於8字節,所以應該是在它們后填充2字節,總共就為16字節。如果像我那樣做的話就太浪費存儲空間了。
E中8字節對齊,P3結構體數組中第二、三個元素c[2]、c[3] 2字節還能和P2結構體的i、c、d “拼”,為什么答案 t 的起始位置為24了,像是沒把它們拼一起,直接在c[3]后擴充6字節?最后想了想結構體填充的規則,如果拼一起 t 的起始位置為 18,不是8的倍數。
另外有一問題未解決,習題3.9中在一片movq、salq、sarq中出現了movl,感覺有點奇怪,雖然只有最低位的字節指示着移位量的解釋能接受,那在這里使用 movq 和 movl 有什么區別?是效率上的區別嗎?書中還有很多地方出現 q、l、b、w “混用”的例子,什么時候該用什么時候不該用呢?