本文內容總結自:《深入理解計算機系統》第三版
歷史
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 | 大於(有符號) | |
jge Label | jnl | 大於等於(有符號) | |
jl Label | jnge | 小於(有符號) | |
jle Label | 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)值,這個值是在程序每次運行時隨機產生的。在恢復寄存器狀態和從函數返回之前,程序檢查這個金絲雀值是否被更改,如果是的,那么程序異常終止。