從高階語言到彙編程式
本文內容總結自:《深入理解計算機系統》第三版
歷史
Intel 處理器系列俗稱 x86,經歷了一個長期的發展過程。
8086:第一代單晶片,16位微處理器。
80286:增加了更多的定址模式,現已廢棄。
i386:將體系結構擴充套件到32位,增加了平坦定址模式。
i486:改善了效能,將浮點單元整合到處理器晶片。
Pentium:改善了效能,對指令集進行了小的擴充套件。
Pentium 4E:增加了超執行緒,可以在一個處理器上同時執行兩個程式。
Core i7:支援超執行緒,多核。
每個後繼處理器的設計都是向後相容的。Intel 處理器系列有好多名字,包括IA32(英特爾32位體系結構,Intel Architecture 32-bit),以及最新的 Intel 64,常稱為,x86-64。
資料格式
由於從 16 位體系擴充套件成 32 位,Intel 用 “字” (word)表示16位資料型別,及 2 個位元組。
我們常說 int 型別為雙字,即,4 位元組。long 型別為四字,即,8 位元組。然而並非總是如此,我們應該根據系統使用的資料模型(Data Model),來判斷不同型別的資料佔據多少位元組。
現今所有 64 位的類 Unix 平臺均使用 LP64 資料模型,而 64 位 Windows 使用 LLP64 資料模型(摘自:https://www.cnblogs.com/lsgxeva/p/7614856.html)。如下圖所示,其中數字的單位是 bit。
Data Model | LP64 | LLP64 |
平臺 | Unix 和 Unix 類的系統 (Linux,Mac OS X) | Win64 |
char | 8 | 8 |
short | 16 | 16 |
int | 32 | 32 |
long | 64 | 32 |
long long | 64 | 64 |
pointer | 64 | 64 |
顧名思義,LP64 指 long 與 Pointer 型別都是 64 bit;LLP64 指long long 與 Pointer 型別都是 64 bit。
訪問資訊
一個 x86-64 的中央處理單元(cpu),包含一組 16 個儲存 64 位值的通用目的暫存器。這些暫存器用來儲存整數和指標
注:所有 16 個暫存器的低位部分都可以作為位元組、字、雙字和四字來訪問。
當生成不足 8 位元組的指令到這些暫存器時,有兩條寫入規則:
- 生成 1、2 位元組數字的指令保持暫存器中剩下的位元組不變
- 生成 4 位元組數字的指令會把高位 4 個位元組置零
定址方式
一條指令一般由操作碼、地址碼組成,地址碼由源運算元、目的運算元、下條指令地址組成。源運算元和目的運算元,分為三種類型:
- 立即數(immediate):表示常數值,一般用 ‘$’ + integer 表示
- 暫存器(register):表示暫存器中的內容,用 ra表示任意暫存器 a,用引用 R[ra] 表示暫存器 a 中的值
- 記憶體引用:根據有效地址,訪問對應的記憶體位置,用 M[addr] 表示儲存在 addr 中的值
型別 | 格式 | 運算元值 | 名稱 | 用途 |
立即數 | $imm | imm | 立即數定址 | 提供常量、設定初始值 |
暫存器 | ra | R[ra] | 暫存器定址 | 區域性變數變數賦值 |
儲存器 | imm | M[imm] | 絕對定址 | |
儲存器 | (ra) | M[R[ra]] | 間接定址 | 指標解引用 |
儲存器 | imm(ra) | M[imm+R[ra]] | 基址(+偏移量)定址 | |
儲存器 | (ra,rb) | M[R[ra]+R[rb]] | 變址定址 | |
儲存器 | imm(ra,rb) | M[imm+R[ra]+R[rb]] | 變址定址 | |
儲存器 | imm(ra,rb, s) | M[imm+R[ra]+R[rb]*s] | 比例變址定址 |
資料傳送指令
指令 | 效果 | 描述 |
MOV S, D | S→D | 把資料從源位置複製到目的位置 |
movb | 複製1個位元組 | |
movw | 複製字 | |
movl | 複製雙字 | |
movq | 複製四字 | |
movabsq | 複製絕對的四字 |
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;%rax 儲存返回值
- 第一行:讀 xp 中儲存的值 *xp,儲存到返回值
- 第二行:讀 y 中的值,放入 xp 指向的記憶體地
- 第三行:返回函式呼叫點
注意:指標就是運算元在記憶體中的地址,儲存在一個暫存器中,通過讀記憶體地址得到其對應的值。而 x 這樣的區域性變數一般儲存在暫存器中,而不是記憶體中,訪問暫存器要比訪問記憶體快得多。
壓入和彈出棧資料
指令 | 效果 | 描述 |
pushq S |
R[%rsp]←R[%rsp]-8; M[R[%rsp]]←S |
將四字壓入棧 |
popq D |
D←M[R[%rsp]]; R[%rsp]←R[%rsp]+8 |
將四字彈出棧 |
注意:棧的地址是從高地址到低地址增長的,也就是說,壓棧需要對棧頂指標 %rsp 做減操作,彈棧需要做加操作。
算數和邏輯操作
指令 | 效果 | 描述 |
leaq S, D | D←&S | 將有效地址寫入目的運算元 |
INC D | +1 | |
DEC D | -1 | |
NEG D | 取負 | |
NOT D | 按位取反 | |
ADD S, D | D←D+S | + |
SUB S, D | D←D-S | - |
IMUL S, D | * | |
XOR S, D | 異或 | |
OR S, D | 或 | |
AND S, D | 與 | |
SAL k, D | 左移 k 位(右邊填0) | |
SHL k, D | 左移 k 位(右邊填0) | |
SAR k, D | 算術右移(左邊填符號位) | |
SHR k, D | 邏輯右移(左邊填0) |
控制
通常,C 語言中的語句和機器程式碼中的指令都是按照它們在程式中出現的次序,順序執行的。用 jump 指令可以改變一組機器程式碼指令的執行順序,jump 指令指定控制應該被傳遞到程式的哪個部分。C 語言編譯器正是依靠這些指令序列,來實現自己的控制結構。
1 條件碼
除了整數暫存器,cpu 還維護一組單個位的條件碼(condition code)暫存器,它們描述了最近的算數或者邏輯操作的屬性。可以檢測這些暫存器來執行條件分支指令。常用的有:
- CF:進位標誌,最近的操作使最高位產生了進位。可用來檢查無符號操作的溢位
- ZF:零標誌,最近的操作得出結果為 0
- SF:符號標誌,最近的操作得到的結果為負數
- OF:溢位標誌,最近的操作導致一個補碼溢位——有符號數的正負溢位
2 跳轉指令
jmp 指令是無條件跳轉,可以是直接跳轉,如:jmp .getNum,跳轉到 .getNum 執行一組指令;也可以是間接跳轉,如:jmp *%rax,用 %rax 暫存器中的值作為跳轉目的。下表中其他的跳轉都是有條件的:
指令 | 同義名 | 跳轉條件 | 描述 |
jmp Label |
1 | 直接跳轉 | |
jmp *op | 1 | 間接跳轉 | |
je Label | jz | ZF | 相等 |
jne Label | jnz | ~ZF | 不相等 |
js Label | SF | 負數 | |
jns Label | ~SF | 非負數 | |
jg Label | jnle | 大於(有符號) | |
jgeLabel | jnl | 大於等於(有符號) | |
jl Label | jnge | 小於(有符號) | |
jleLabel | jng | 小於等於(有符號) | |
ja Label | jnbe | 大於(無符號) | |
jae Label | jnb | 大於等於(無符號) | |
jb Label | jnae | 小於(無符號) | |
jbe Label | jna | 小於等於(無符號) |
3 條件分支
將條件表示式和語句從 C 語言翻譯成機器程式碼,最常用的方式是結合有有條件和無條件跳轉。(需要區分資料的條件轉移和控制的條件轉移)
(1)控制的條件轉移
C 語言中的 if-else 語句(基於控制的條件轉移)的形式:
if (test-expr) then-statement else else-statement
彙編通常這樣實現上面的形式:
彙編器為then-statement 和then-statement 產生各自的程式碼塊,它會插入條件和無條件分支,以保證能執行正確的程式碼塊。
t = test-expr ;條件轉移 if (!t) goto false then-statement ;無條件轉移 goto done false: else-statement done: ...
(2)資料的條件轉移
注意:資料的條件轉移只在一些受限的情況中才可行。
控制的條件轉移實現的函式:
long diff(long x, long y) { long result; if (x < y) result = y-x; else result = x-y; return result; }
資料的條件轉移實現的函式:
long diff(long x, long y) { // 計算每個分支 long rval = y-x; long eval = x-y; bool test = (x>=y); if (test) rval = eval; return rval; }
為何基於資料的條件傳送會優於基於控制的條件轉移?
cpu 通過使用流水線來提高效能,在流水線中,一條指令的處理要經過一系列階段,每個階段執行所需操作的一小部分,如:從記憶體取指令、從記憶體讀資料、執行算術運算、向記憶體寫資料、更新程式計數器等。流水線的方法可能這麼做:在取一條指令的同時,執行它前面一條指令的算術運算。要做到這一點,必須要明確指令的執行序列,這樣才能保證流水線中充滿待執行的指令。
當機器遇到條件分支時,只有當分支條件求值完成後,才能決定分支往哪邊走。處理器使用分支預測邏輯來猜測每條跳轉指令是否會執行。如果猜對了的話,流水線中充滿指令;如果猜錯的話,cpu 要丟掉它在跳轉指令後所作的工作,再從正確的指令位置開始重新填充流水線,這會導致程式效能嚴重下降。
同控制的條件轉移不同,處理器無需預測測試結果就可以執行條件傳送,僅僅檢查條件碼就可以,然後要麼更新目的暫存器,要麼保持不變。
基於資料的條件轉移形式如下:
v = then-expr; ve = else-expr; t = test-expr; if (!t) v = ve;
這個 if 語句使用資料的條件傳送來實現。
缺陷:
無論如何,條件分支的正誤兩條分支都會被計算求值,如果計算量非常大,那造成的效能損失堪比預測錯誤。
注意:GCC 在多數情況下,使用控制的條件轉移,即使分支預測錯誤造成的效能損失大於複雜的計算。
4 迴圈
(1)do-while
C 形式:
do body-statement while (test-expt);
彙編形式:
loop: body-statement t = test-expr if (t) goto loop
(2) while 迴圈
while (test-expr) body-statement
彙編形式1(跳轉到中間):
goto test loop: body-statement test: t = test-expr if (t) goto loop
彙編形式2(guarded-do,當使用較高級別的優化編譯時,採用這種方法):
t = test-expr if (!t) goto done loop: body-statement t = test-expr if (t) goto loop done: ...
(3)for 迴圈
C 語言形式:
for (init-expr; test-expr; update-expr)
body-statement
C 語言標準使用等同 while 的彙編方法來實現它。等同於:
init-expr; while (test-expr){ update-expr; }
但是有一個特殊情況,存在 continue 的情況:
int sum = 0; for (int i = 0;i < 10;++i){ if (i & 1) continue; sum += i; }
如果使用 while 規則:
int sum = 0; int i = 0; while (i < 10) { if (i & 1) continue; sum += i; ++i; }
很明顯,如果 (i&1) 成立,那麼,while 迴圈將跳過 i 更新語句,造成無限迴圈,但是 for 迴圈每輪迭代都正常更新。
所以,當遇到 continue 時,編譯器會做額外的操作保證迴圈正確,使用 goto 代替 continue:
i = 0; while (i < 10) { if (i & 1) goto update; sum += i; update: ++i; }
(4)switch 語句
注意:開關語句僅僅可以根據整數索引值進行多重分支,不能使用一個例如:字串作為判斷條件。
如果有多種條件跳轉分支(例如 4 個以上),且索引值的範圍跨度較小,開關語句使用跳轉表(jump table)使得實現更加高效。
跳轉表是一個數組,陣列元素 i 儲存程式碼段的入口地址,該程式碼段實現當索引值等於 i 時程式的動作。和使用一組長的 if-else 語句相比,使用跳轉表的優點是執行開關語句的時間與條件分支的數量無關。
可以看到,索引值有 100,102,103,104,106,編譯器需要把它們對映到一個較小的連續空間,通過 -100,它們的取值區間就對映到了區間[0, 6]。那麼,根據 index 的值,如果 index < 0 或者 index > 6,顯然它屬於 default 情況,如果在範圍內,就可以跳轉到正確的程式碼段。
過程
過程即封裝的函式。它包括以下三個機制(以 P 呼叫 Q,隨後再返回 P 中執行為例):
- 傳遞控制:進入過程 Q 的時候,程式計數器的值被設定為 Q 的程式碼起始地址;返回到 P 時,PC 設定為呼叫 Q 語句後面的那條語句
- 傳遞資料:P 必須能夠向 Q 提供若干個引數,Q 必須能向 P 返回一個值(也可能不返回)
- 分配和釋放空間:開始時,Q 可能需要為區域性變數分配空間;返回時,必須釋放這些空間
執行時棧
C 語言過程呼叫機制的關鍵特性,在於使用了棧資料結構提供的後進先出的記憶體管理原則。
棧幀:當 x84-64 過程需要的儲存空間超出暫存器能夠存放的大小時,就會在棧上分配空間,這部分空間叫做棧幀(stack frame)。
棧頂:當前正在執行的過程總是在棧頂。
返回地址:當 P 呼叫 Q 時,會把返回地址壓入棧中,指明當 Q 返回時,要從 P 程式的哪個位置繼續執行。這個返回地址位於 P 的棧幀中。
Q 的程式碼分配自己的棧幀的空間,它可以儲存暫存器的值,分配區域性變數空間,為自己的呼叫設定引數等。
通過暫存器,P 最多可以傳遞 6 個整數值(指標和整數),如果超過了這個需求,P 在呼叫 Q 之前在自己的棧幀裡儲存好這些引數。
注意:許多引數少於 6 個的函式是不需要棧幀的,當這個函式所有的區域性變數都儲存在暫存器,且該函式不呼叫其他任何函式時,就可以不為它分配棧幀。
轉移控制
P 呼叫 Q:只需要把 Q 的程式碼的起始地址設定為 PC 的值即可。
Q 執行完畢返回 P:CPU 需要記錄繼續 P 的執行的程式碼位置。
指令 | 描述 |
call Label | 把呼叫指令的下一條指令的地址作為返回地址壓棧,把 PC 設定為 Q 的起始地址 |
call *Op | 同上 |
ret | 從棧中彈出返回地址,並把它賦值給 PC |
資料傳送
當 P 呼叫 Q 時,P 的程式碼必須先把引數複製到適當的暫存器中。類似的,當 Q 返回到 P 時,P 的程式碼可以發訪問暫存器 %rax 中的返回值。
如果一個函式有大於 6 個整型引數,超過 6 個的部分就要通過棧來傳遞。假設有 n 個引數,那麼 P 的棧幀為第 7-n 在棧上分配空間,引數 7 位於棧頂的位置。
這也就意味著,如果函式引數在棧上分配,那麼壓棧的順序是從後向前,讀引數的順序則是從前向後。
注意:以上是整型引數(包括指標)的情況,浮點型別也有對應的暫存器。
注意:通過棧傳遞引數時,所有的資料大小(位元組,而不是 bit)都必須向 8 的倍數對齊。
(1)棧幀中的區域性儲存空間
例子:
void proc ( long a1, long* a1p, int a2, int* a2p, short a3, short* a3p, char a4, char* a4p) { *a1p += a1; ... }
棧幀:
區域性資料必須存放在記憶體中的情況:
- 暫存器不足以存放所有的資料
- 對一個區域性變數使用 '&' 取地址,必須要為它產生一個地址
- 某些區域性變數是陣列或者結構體,必須能通過陣列或結構體引用被訪問到
例子:
long call_proc () { long x1 = 1; int x2 = 2; short x3 = 3; char x4 = 4; proc(x1, &x1, x2, &x2, x3, &x3, x4, &x4); return (x1+x2)*(x3-x4); }
對應的彙編程式碼:
由上圖可以看到,在棧上分配區域性變數 x1-x4,它們具有不同的大小。使用 leaq 指令生成指向這些位置的指標。這是為其分配棧幀,做呼叫前準備。
注意:需要區分棧幀上的區域性變數區(給區域性變數分配記憶體),以及引數構造區(用於傳參)。
我們看到,傳遞引數的時候,仍然使用 6 個暫存器儲存前 6 個引數,使用棧幀傳遞後 2 個引數。
注意:上圖是 call_proc 函式的棧幀,而不是 proc 的棧幀。
儲存順序:我們可以看到,區域性變數的儲存是按照順序,從高地址到低地址順序進行儲存的。
而引數在棧幀上的儲存恰好是反過來的,這會在實際執行過程中按引數順序來讀取。
對齊:由於區域性變數從高地址到低地址順序進行儲存,那麼區域性變數的對齊需要向低地址對齊,我們看到 16 這個地址空間是用於 padding 的,而不是 23 這個地址用於 padding。
而引數在棧幀上,(起始地址)必須以 8 的倍數(位元組)進行分配。佔記憶體不滿 8 位元組,就儲存在低位。
(2)暫存器中的區域性儲存空間
暫存器組是唯一被所有過程共享的資源(從上面我們看到棧幀是獨立分配的)。
必須確保一個函式(呼叫者),呼叫另一個函式(被呼叫者)時,被呼叫者不會覆蓋呼叫者隨後會使用的暫存器值。
被呼叫者儲存暫存器:暫存器 %r12-%r15, %rbx, %rbp 是被呼叫者儲存暫存器(除了 %rsp,所有其他的暫存器都是呼叫者儲存暫存器)。當 P 呼叫 Q 時,P 必須儲存這些暫存器的值,保證它們的值在 Q 返回到 P 時,與 Q 被呼叫時是一樣的。
關於被呼叫者儲存暫存器和呼叫者儲存暫存器的理解:
我們知道,任何函式都可以修改呼叫者暫存器,當函式 P 在某個此類暫存器中儲存了局部資料,然後呼叫函式 Q,而函式 Q 也可以修改這個暫存器,所以,在呼叫之前儲存好這個可能被修改的資料,是 P(呼叫者)的責任,呼叫者 P 通過被呼叫者儲存暫存器來儲存這些值。
那麼 Q 為什麼要儲存被呼叫者儲存暫存器的值,而不用儲存呼叫者儲存暫存器的值呢?
我們看到,呼叫者 P 通過被呼叫者儲存暫存器來保證自己的正確性,因此,在有了安全保障下,Q 可以隨心所欲的操作這些共享的呼叫者暫存器,而不必擔心造成問題;但是,Q 可能也要呼叫其他函式,因此,Q 也可能需要儲存自己當前的呼叫者儲存暫存器中的值,這就要覆蓋 P 的被呼叫者儲存暫存器中的值了,如果覆蓋了,那 P 談何恢復自己的呼叫者儲存暫存器的值呢?因此,Q 在呼叫其他函式前,先幫助 P 儲存被呼叫者儲存暫存器中的值,將之寫在自己的棧幀上,Q 呼叫完成後,通過棧幀恢復 P 被呼叫者儲存暫存器的值。也就是說,保證 P 能正常恢復,是 Q 的責任;保證 Q 能隨意使用呼叫者儲存暫存器,是 P 的責任。
例子:
第一次呼叫,必須儲存 x 的值,因為 Q 的第一引數使用 %rdi 傳遞,P 的第一引數也通過 %rdi 傳遞,P 先儲存此時的 %rdi,然後 Q 就可以使用 %rdi,並且不會影響後續。通過 movq %rdi, %rbp。
第二次呼叫,使用 %rbx 儲存 Q(y) 的返回值,隨後,通過 movq %rbp, %rdi 指令,又將 P 的一參暫存器中的值設定為 x。
在函式的結尾,對 %rbp 和 %rbx 進行彈棧操作,恢復這兩個被呼叫者儲存暫存器的值。它們的彈出順序與壓棧順序相反。
(3)遞迴呼叫
遞迴呼叫一個函式本身與呼叫其他函式是一樣的。棧規則提供了一種機制,每次函式呼叫都有自己私有的狀態資訊儲存空間(儲存的返回地址和被呼叫者儲存暫存器的值)。如果有需要,它還可以在自己的棧幀上為區域性變數分配儲存空間。而棧分配和釋放的順序自然地與函式呼叫-返回順序匹配。
陣列分配與訪問
對於資料型別 T 和整型常數 N:
T array[N];
- 在記憶體中分配一個 size*N 位元組的連續區域(size 代表資料型別 T 所佔位元組大小)
- 引入識別符號 array,可以用 A 作為指向陣列開頭的指標
陣列元素 i 放在記憶體地址為 addr+size*i 的地方(設陣列首地址 A 的值為 addr)
1 下標運算
記憶體引用指令可以簡化陣列訪問,假設 array 是 int 型陣列,我們要得到 array[i],而 array 的首地址放在暫存器 %rdx 中,i 放在暫存器 %rcx 中,那麼,取對應元素的指令為:
movl (%rdx, %rcx, 4), %eax
這條指令將 array[i] 的值放在暫存器 %eax 中。
等同於計算:
addr + 4*i
2 指標運算
C 語言允許對指標進行運算,計算出來的值會根據指標引用的資料型別的大小進行伸縮。
以上述陣列為例,假如 p 是int* 型別指標,且指向 array 首地址,那麼:
p+i = addr+i*4
3 多維陣列
首先介紹一下 typedef 宣告對於陣列如何使用:
int a[5][3];
等同於:
typedef int ary_dec[3]; // ary_dec 被定義為含有 3 個 int 元素的整型陣列 ary_dec[5]; // 5 個 int[3],等同於,int a[5][3]
對於多維陣列:
T D[R][C];
它的陣列元素 D[i][j] 的記憶體地址為(設 D 首地址為 addr,T 的資料型別大小為 size):
&D[i][j] = addr+(i*C+j)*size;
關於效率:
想想對於一個多維陣列 int a[N][N] 來說,遍歷陣列取 a[i][j] 的值,每次 a[i][j] 都對應著一次addr+(i*N+j)*4 的運算,而你如果使用 int* p = &a[0][0],那麼,*p++ 同樣可以遍歷陣列,每次你需要計算的是 p+4,只使用了一次加法操作,顯然這樣做效率更高。
GCC 對於定長多維陣列的優化(級別 O1)正是使用這種取消索引改用指標的方式。對於動態陣列,如果允許優化,GCC 同樣能識別指標的步長,進行類似於上述的優化。
結構體
使用 struct 宣告,將所有成員都存放在記憶體中一段連續的區域內,而指向結構體的指標就是結構體第一個成員第一個位元組的地址。編譯器負責維護每個結構體型別資訊,指示每個欄位(field)的位元組便宜,從而對成員進行正確的記憶體引用。
例子:
struct rec { int i; int j; int a[2]; int* p; };
它的記憶體佈局為:
為了訪問每個欄位,編譯器產生的程式碼要將結構的地址加上適當偏移,例如:對於一個 struct rec* 型別的變數 r,我們要把 r->i 複製到 r->j(假設 r 放在暫存器 %rdi 中):
movl (%rdi), %eax movl %eax, 4(%rdi)
欄位 i 的偏移為 0,欄位 j 的偏移為 4,我們對它們的取值方式分別為:(%rdi) 和 4(%rdi)。
聯合體
聯合允許多種型別引用一個物件,用不同的欄位來引用相同的記憶體塊。
例子:
struct U { char c; int i[2]; double v; };
union U { char c; int i[2]; double v; };
在 x86-64 機器上,欄位的偏移量,完整大小如下:
如何應用?
我們實現知道一個數據結構中的不同欄位的使用時互斥的,那麼將這兩個欄位宣告為聯合,而不是結構體,將會減少空間的分配。
例如:對於一棵特殊的二叉樹,它們的值都儲存在葉子節點上,對於非葉節點,它們不含值,只含指向左右孩子的指標。那麼,對於值的使用和對於左右孩子的使用顯然可以互斥,因為,是葉子節點就不會有左右孩子,是非葉節點,就不會有值。
enum NodeType{ LEAF, NO_LEAF }; strcut Node { NodeType type; union info { struct internal { struct node* left; struct node* right; }; double data[2]; }; };
佔用空間為 24 位元組(包括了 padding)。
相較於 struct 宣告:
struct node { struct node* left; struct node* right; double data[2]; };
佔用空間為 32 位元組。
識別字節順序:
union U { int a; // 地址 0-3 char c[2]; // 地址 0-1 };
我們宣告一個 U 型別的變數,並對 c 欄位進行賦值:
U u;
u.a = 0x00000001
U 型別的物件佔 4 位元組,如果使用小端儲存,那麼,此時 u 內容為:
00 00 00 01
如果使用大端儲存,那麼,此時 u 內容為:
01 00 00 00
因此,我們檢測 u.c[0] 和 u.c[1] 的值,就可以知道機器使用什麼位元組順序。
int res = u.c[0] | u.c[1]; // 若為小端儲存,結果為 1,若為大端儲存,結果為 0 cout << res << endl;
注意:不論大小端儲存,c 字元陣列是從低地址到高地址的。
記憶體越界引用與緩衝區溢位
記憶體越界引用:C 對於陣列引用不進行任何邊界檢查,而且區域性變數和狀態資訊(例如返回地址和儲存的暫存器的值)都存放在棧中。對越界陣列元素的寫操作會破壞儲存在棧中的狀態資訊,造成嚴重的錯誤。
緩衝區溢位:通常,在棧中分配某個字元陣列來儲存一個字串,但是字串的長度超出了為陣列分配的空間。
考慮下面這個程式:
如果我們通過 gets 輸入字串的長度超過了 7,就會產生一些未定義的結果。
echo() 的彙編程式碼:
可以看出,程式在棧上分配了 24 位元組,buf 位於棧頂的位置,那麼到返回地址之前,還有 16 位元組是未被使用的,因此,可能產生的破壞如下:
如果字串賦值超過 23 個字元,佔用了起始地址 24,那麼返回地址就會被破壞,導致程式跳轉到意想不到的位置。(實際上你使用這個程式執行是不會報錯的,GCC 有棧保護機制)
對抗緩衝區溢位攻擊:
棧隨機化:程式開始時,在棧上分配一段 0-n 位元組之間的隨機大小空間。程式不使用這段空間,但是它會導致程式每次執行時後續的棧的位置發生了變化。分配的範圍必須足夠大,這樣獲得足夠多的地址變化。但是又要足夠小,以節省棧空間。在 Linux 系統,這種技術稱為地址空間佈局隨機化(Address-Space Layout Randomization,ASLR),每次執行時,程式的不同部分,包括程式程式碼,庫程式碼,棧,全域性變數和堆資料,都會被載入到記憶體的不同區域。
棧破壞檢測:在棧幀中任何區域性緩衝區與狀態之間儲存一個特殊的金絲雀(canary)值,這個值是在程式每次執行時隨機產生的。在恢復暫存器狀態和從函式返回之前,程式檢查這個金絲雀值是否被更改,如果是的,那麼程式異常終止。