本文首發於CSDN,同步到博客園
深入理解計算機系統第三章
3.1 程序的機器級表示
現有兩個源文件:
main.c
#include<stdio.h>
void mulstore(long, long, long*);
int main()
{
long d;
mulstore(2, 3, &d);
printf("2 * 3 --> %ld\n", d);
return 0;
}
long mult2(long a, long b) {
long s = a * b;
return s;
}
mstore.c
long mult2(long, long);
void mulstore(long x, long y, long* dest) {
long t = mult2(x, y);
*dest = t;
}
執行指令
gcc -Og -o prog main.c mstore.c
其中-o prog
表示將main.c
和mstore.c
編譯后得到的可執行文件的文件名設置為prog
,-Og
是用來告訴gcc
編譯器生成符合原始C代碼整體結構的機器代碼。實際項目中可能會使用-O1
或-O2
(人稱吸氧)等編譯優化
執行指令
gcc -Og -S mstore.c
將獲得mstore.c
對應的匯編文件mstore.s
這其中,以.
開頭的行都是指導匯編器和鏈接器工作的偽指令,在查看時可以忽略,得到如下匯編
其中pushq %rbx
表示將寄存器rbx
的值壓入程序棧進行保存。
這里引入寄存器的北京知識,在Intel x86-64的處理器中包含16個通用目的寄存器:
這16個寄存器用來存儲數據和指針。
其中分為調用者保存寄存器和被調用者保存寄存器
這里是func_A調用func_B,所以func_A是調用者,func_B是被調用者。因為func_B中修改了寄存器%rbx
,而func_A在調用func_B前后也使用了寄存器%rbx
,因此需要保證在調用func_B前后,func_A使用%rbx
的值應該是一致的。
第一種:在func_A調用func_B前,提前保存%rbx
的值,然后再調用結束后再將提前保存的值重恢復到%rbx
中,這稱為調用者保存。
第二種:在func_B調用%rbx
前,先保存%rbx
的值,在調用結束后,返回前,再恢復%rbx
的值,這稱為被調用者保存。
每個寄存器的保存策略不一定相同。
其中調用者寄存器:
%rbx, %rbp, %r12, %r13, %r14, %r15
被調用者寄存器:
%r10, %r11
%rax
%rdi, %rsi, %rdx, %rcx, %r8, %r9
pushq
就是用來保存% rbx
的值,在函數返回前,使用popq
來恢復%rbx
的值。
movq
就是將%rdx
的值復制到%rbx
中
這條指令執行后,%rbx
的內容和%rdx
的內容一致,都是dest
指針所指向的內存地址
根據寄存器用法定義
函數multistore的三個參數分別保存在%rdi,%rsi,%rdx
中
這里的pushq
, movq
的后綴q
都表示數據的大小。早期機器是16位,后來才擴展到32位。Intel用字(word)來表示16位的數據類型,32
位的數據類型稱為雙字,64位的數據類型稱為4字。
其中:
b -> byte
w -> word
l -> long word(double word)
q ->quad word
call mult2@PLT
的返回值保存到%rax
中。
movq %rax (%rbx)
是指將%rax
的值送到%rbx
所指向的內存地址處
ret
表示函數返回即return
對於從源文件生成機器代碼文件
執行gcc -Og -c mstore.c
,即可得到機器代碼文件mstore.o
借反匯編工具objdump
可以將機器代碼文件反匯編成匯編文件
objdump -d mstore.o
3.2 寄存器與數據傳送指令
寄存器
這是上述所說的保存寄存器
調用者保存寄存器(Callee Saved)
%rbx, %rbp, %r12, %r13, %r14, %r15
被調用者保存寄存器(Caller Saved)
%r10, %r11
%rax 保存函數返回值
%rdi, %rsi, %rdx, %rcx, %r8, %r9 傳遞函數參數
%rsp
用於保存程序棧結束位置
指令
操作碼
movq, addq, subq, xorq, ret
等
操作數
- 立即數(Immediate)
在AT&T格式的匯編中,立即數以$
符號開頭,后面跟一個整數,這個整數需要滿足標准C語言的定義,如$8
。 - 寄存器(Register)
在64位處理器上,64,32,16,8位的寄存器都可以作為操作數,如%rdx
寄存器帶了小括號的情況,表示內存引用,如(%rdx)
- 內存引用(Memory Reference)
將內存抽象成一個字節數組,當需要從內存中存取數據時,需要獲取目的地址的起始地址addr
和數據長度b
關於內存引用
有效地址是通過立即數與基址寄存器的值相加,再加上變址寄存器的值和比例因子的乘積,其中比例因子必須是1、2、4、8,這是因為只有這4種字節的基本數據類型。
舉個例子:有數組int a[32]
,首先你需要通過基址寄存器獲得基地址,如果你想查a[30]
這個元素,那么就需要變址寄存器存儲30
,此外,由於每個元素的大小都是int
大小,所以需要加上前30
個元素的地址才能得到a[30]
的地址,即比例因子。
mov指令
其中源操作數可以為立即數,寄存器和內存引用,而目的寄存器只能為寄存器或內存引用。同時x86-64位規定,源操作數和目的操作數不能同時為內存引用,因此如果需要將內存地址A的內容賦值給內存地址B,需要進行兩次mov
操作,第一次mov A register
,第二次mov register B
。
使用movb,movw, movl, movq
是與其寄存器的位數對應的,b -> 8, w ->16, l -> 32, q ->64
。
當movq
指令的源操作數是立即數時,該立即數只能是32位的補碼表示。對該數進行符號位擴展(Signed extended),將64位數傳送到目的位置。
當立即數是64位時,使用指令movabsq
,該指令的源操作數可以是任意的64位整數,目的操作數只能是寄存器。
當使用movl
指令且其目的操作數是寄存器時,會將該寄存器的高4字節設置為全0。x86-64位處理器規定,任何對寄存器生成的32位值的指令都會把該寄存器的高位部分設置為0
當源操作數的數位小於目的操作數,需要進行零擴展或符號位擴展
零擴展
z表示zero零擴展
b, w, l, q分別表示8,16,32,64位
movzbw
movzbl
movzbq
movzwl
movzwq
movl代替了movzlq的功能,所以不需要movzlq
符號位擴展
s表示signed 符號位擴展
b, w, l, q分別表示8,16,32,64位
movsbw
movsbl
movsbq
movswl
movswq
movslq
cltq = movslq %eax, %rax
3.3 棧與數據傳送指令
以執行加法操作a+b
為例
首先CPU執行數據傳送指令將a
和b
的值從內存讀到寄存器中
以Intel x86-64位處理器為例,寄存器%rax
的大小是64個比特位,即8個字節
變量a
是long
類型,占8個字節,%rax
的全部64位數據位都用來保存變量a
,表示為%rax
變量a
是int
類型,占4個字節,%rax
的低32位數據位保存變量a
,表示為%eax
變量a
是short
類型,占2個字節,%rax
的低16位數據位保存變量a
,表示為%ax
變量a
是char
類型,占1個字節,%rax
的低8位數據位保存變量a
,表示為%al
需要注意的是,%ax
的高8
位表示為%ah
圖源自:九曲闌干
main.c
#include<stdio.h>
long exchange(long *xp, long y);
int main()
{
long a = 4;
long b = exchange(&a, 3);
printf("a = %ld, b = %ld\n", a, b);
return 0;
}
exchange.c
long exchange(long* xp, long y) {
long x = *xp;
*xp = y;
return x;
}
執行指令
gcc -o main main.c exchange.c
運行得到
a = 3, b = 4
對於
exchange.c
long exchange(long* xp, long y) {
long x = *xp;
*xp = y;
return x;
}
執行
gcc -Og -S exchange.c
其匯編為
其中movq
是數據傳送指令,ret
是返回指令
根據寄存器使用,%rdi
存儲函數傳遞的第一個參數long *xp
,%rsi
存儲函數傳遞的第二個參數long y
-
movq (%rdi), %rax
表示從xp
指向的內存中讀取數值到寄存器%rax
,內存地址保存在寄存器%rdi
中,這條指令對應long x = *xp;
因為最后
exchange
函數返回變量x
的值,因此這里直接將數值x
保存到寄存器%rax
中。 -
movq %rsi, (%rdi)
表示將變量y
的值寫到內存中,變量y
存儲在寄存器%rsi
中,內存地址保存在寄存器%rdi
中,這條指令對應*xp = y;
除此之外,將值傳遞給參數的這部分數據傳送指令需要借助程序棧。
棧在內存中是從高地址到低地址的(User Stack),所以棧頂是所有棧元素地址中最低的。
當我們保存%rax
中的數據0x123
,可以使用pushq %rax
,而這個指令又可以分解為:
subq $8 %rsp
,表示將棧頂往低地址移動,然后指令movq %rax, (%rsp)
表示將%rax
中的數據存儲到%rsp
指向的內存地址處。
當使用popq %rbx
時,指令又可以分解為:movq (%rsp), %rbx
將%rsp
指向的內存地址處的數據保存到%rbx
中,然后指令addq $8 %rsp
將棧頂的地址往高地址移動進行彈出操作。但是直到再次壓棧訪問到該地址時,才會將該地址的數據覆蓋。
3.4 算術和邏輯運算指令
lea
指令即Load Effective Address
,即:將一個內存地址直接賦值給目的操作數,所以有些時候也被用來加法和有限的乘法運算
指令leaq 7(%rdx, %rdx, 4), %rax
若%rdx
存儲的值為x
,則有leaq 5x+7, %rax
這個指令將5x+7
直接賦值給%rax
,相當於將內存地址傳遞給了返回值寄存器%rax
scale.c
long scale(long x, long y, long z) {
long t = x + 4 * y + 12 * z;
return t;
}
通過指令
gcc -Og -S scale.c
得到
首先根據寄存器使用慣例,%rdi
存儲第一個傳遞參數,%rsi
傳遞第二個傳遞參數,%rdx
傳遞第三個傳遞參數
指令leaq (%rdi, %rsi, 4), %rax
是將%rdi+4%rsi
賦值給%rax
,對應到代碼就是計算x+4y
指令leaq(%rdx,%rdx,2), %rcx
是將3%rdx
賦值給%rcx
,即得到3z
指令leaq 0(,%rcx,4),%rdx
是將4%rcx
賦值給%rdx
,即得到12z
指令addq %rdx,%rax
是將%rax
的值和%rdx
的值加起來,再賦給%rax
,得到最終的x+4y+12z
最后ret
Unary Operations 一元操作
這組指令只有一個操作數,即該操作數既是源操作數,又是目的操作數
INC D D自增1
DEC D D自減1
NEG D D取負
NOT D D補碼取反
其中D可以是寄存器,也可以是內存地址
Binary Operations 二元操作
ADD S, D 將D+S的結果賦值給D 加法
SUB S, D 將D-S的結果賦值給D 減法
IMUL S, D 將D*S的結果賦值給D 乘法
XOR S, D 將D^S的結果賦值給D 異或
OR S, D 將D|S的結果賦值給D 或運算
AND S, D 將D&S的結果賦值給D 與運算
Shift Operations 移位操作
SAL k, D D = D << k 算術左移,填0
SHL k, D D = D << k 邏輯左移,填0
SAR k, D D = D >> k_A 算術右移,填符號位
SHR k, D D = D >> k_L 邏輯右移,填0
對於移位量k,可以是一個立即數,也可以是放在寄存器%cl
中的數。移位指令的k,如果是使用寄存器中的值,只允許%cl
寄存器的值。而對於寄存器%cl
,是一個8位寄存器,但是對於w位的數,\(2^m=w\),只由%cl
的低m位決定。
對於salb
,由%cl
的低3位決定
對於salw
,由%cl
的低4位決定
對於sall
,由%cl
的低5位決定
對於salq
,由%cl
的低6位決定
arith.c
long arith(long x, long y, long z) {
long t1 = x ^ y;
long t2 = z * 48;
long t3 = t1 & 0x0F0F0F0F;
long t4 = t2 - t3;
return t4;
}
執行
gcc -Og -S arith.c
根據寄存器使用慣例,
%rsi
存儲第一個傳遞參數x
,%rdi
存儲第二個傳遞參數y
,%rdx
存儲第三個傳遞參數z
直接來看
-
異或
long t1 = x ^ y;
對應匯編
xorq %rsi, %rdi 這里是將%rdi和%rsi中的值異或得到的結果存儲到%rdi中
-
乘法
long t2 = z * 48;
對應匯編
leaq (%rdx, %rdx, 2), %rdx --> 3%rdx = 3 * z movq %rdx, %rax salq $4, %rax --> (3 * z) << 4 = 3 * z * 16 = 48 * z
-
與運算
long t3 = t1 & 0x0F0F0F0F;
對應匯編
andl $252645135 %edi 這里由於立即數0x0F0F0F0F的高32位為0,所以只需要寄存器的低32位參與運算即可,所以只需要用%edi(%rdi的低32位)表示即可
-
減法
long t4 = t2 - t3;
對應匯編
subq %rdi, %rax 由於t2的值已經存儲在寄存器%rax中,t3的值存儲在寄存器%rdi中 所以兩個寄存器的值進行一個減法,最后將結果存儲在返回值寄存器%rax中即可
3.5 指令與條件碼
subq %rax, %rdx
這條指令是使用算術邏輯單元ALU來執行的,ALU從寄存器中讀取數據后,進行計算,並將計算結果返回到目的寄存器%rdx
如圖所示:
除了執行算術和邏輯運算指令,ALU還會根據該運算的結果去設置條件碼寄存器
條件碼寄存器(Condition Code Register)
由CPU維護的,長度是單個比特位的(因此取值只能為0或1),描述最近執行操作的屬性的寄存器
現有兩條指令
t1時刻: addq %rax, %rbx
t2時刻: subq %rcx, %rdx
t1時刻條件碼寄存器保存的就是指令addq %rax, %rbx
的執行結果的屬性,
t2時刻,條件碼寄存器中的內容將被指令subq %rcx, %rdx
所覆蓋
- CF --- Carry Flag 進位標志。當CPU最近執行的一條指令最高位產生了進位時,CF會被置為1,這里是針對無符號數的溢出。
- ZF --- Zero Flag 零標志。當最近的操作結果等於零時,ZF會被置為1。
- SF --- Sign Flag 符號標志。當最近的操作結果小於零時,SF會被置為1
- OF --- Overflow Flag 溢出標志。針對有符號數,當最近的操作導致正溢出或者負溢出時,OF會被置為1
算術和邏輯運算指令設置CCR
條件碼寄存器的值是由ALU在執行算術和邏輯運算指令時寫入的,主要是在執行3.4算術和邏輯運算指令時被寫入
對於不同指令制定了相應規則來設置條件碼寄存器
XOR S, D
指令會使得CF=0,OF=0
INC D
和DEC D
會設置OF
和ZF
,但不會改變CF
(這些是具體實現做出的具體操作)
cmpq
和testq
指令設置CCR
cmpq %rax, %rdx
和subq %rax, %rdx
類似,區別在於,在進行計算后,cmpq
指令不會將計算結果存放到目的寄存器中,而僅僅是設置條件碼寄存器
testq %rax, %rdx
和andq %rax, %rdx
類似,區別在於,在進行計算后,testq
指令只會設置條件碼寄存器
示例一
int comp(long a, long b) {
return (a == b);
}
對應匯編
comp:
//a in %rdi, b in %rsi
cmpq %rsi, %rdi
sete %al
movzbl %al, %eax
ret
其中
cmpq %rsi, %rdi
對應計算a-b的結果,當a=b,即a-b=0,會將ZF設置為1
sete %al
通常是不會直接讀條件碼寄存器,其中一種是根據條件碼的某種組合,通過set類指令,將一個字節設置為0或1。這里是根據ZF的值,對%al寄存器進行賦值,后綴e是equal的意思。
如果ZF=1,指令sete將%al設置為1,如果ZF=0,指令sete將%al設置為0,再使用mov指令對%al進行零擴展,這里由於是返回int,所以擴展成32位即可,即%eax
示例二
int comp(char a, char b) {
return (a < b);
}
對應匯編
comp:
//a in %rdi, b in %rsi
cmpq %rsi, %rdi
setl %al // l --> less
movzbl %al, %eax
ret
判斷小於的情況需要根據SF和OF的異或結果來判定,當SF^OF=1
,設置%al等於1,否則設置%al等於0
進行a-b計算時,當a-b<0,SF=1,當發生溢出時,OF=1
當不發生溢出時
- 如果a-b<0,SF=1,OF=0
- 如果a-b>=0,SF=0,OF=0
當發生溢出時
-
當發生正溢出時,必然是a>b的時候
正溢出結果為負,即SF=1,而發生了溢出,OF=1 -
當發生負溢出時,必然是a<b的時候
負溢出結果為正,即SF=0,而發生了溢出,OF=1
有符號數的表示范圍為:\([-2^{w-1},2^{w-1}-1]\)
正溢出的結果一定是負,正溢出最大是轉換成無符號數計算\((2^{w-1}-1)-(-2^{w-1})=2^w-1\),再解釋成有符號數就是\(-1\),所以正溢出的結果最大為\(-1\),即正溢出的結果永遠是負數
負溢出的結果一定是正,負溢出最小是轉換成無符號數計算\((-2^{w-1})-(2^{w-1}-1)=-2^w+1\),再解釋成有符號數就是\(1\),所以負溢出的結果最小為\(1\),即負溢出的結果永遠是正數
上述可以看到當a<b時,一定有SF^OF=1
關於其他計算也類似
3.6 跳轉指令和循環
跳轉指令
如下代碼
abs.c
long absdiff_se(long x, long y) {
long result;
if(x < y) {
result = y - x;
} else {
result = x - y;
}
return result;
}
對應匯編
absdiff_se:
cmpq %rsi, %rdi
jl .L4
movq %rdi, %rax
subq %rsi, %rax
ret
.L4:
movq %rsi, %rax
subq %rdi, %rax
ret
根據寄存器使用慣例:%rsi
存儲第一個傳遞參數的值,%rdi
存儲第二個傳遞參數的值
cmpq %rsi, %rdi
,即計算%rdi-%rsi
的值並設置條件碼寄存器,並且jl
根據SF
和OF
的異或值進行跳轉判斷
相關的Jump指令跳轉判斷
對於絕對值實現的另一種方式
abstemp.c
long cmovdiff_se(long x, long y) {
long rval = y - x;
long eval = x - y;
long ntest = x >= y;
if(ntest) rval = eval;
return rval;
}
對應匯編
cmovdiff_se:
movq %rsi, %rdx
subq %rdi, %rdx
movq %rdi, %rax
subq %rsi, %rax
cmpq %rdi, %rsi
jg .L3
.L1:
rep ret
.L3:
movq %rdx, %rax
jmp .L1
根據寄存器使用慣例:%rsi
存儲第一個傳遞參數的值,%rdi
存儲第二個傳遞參數的值
%rdx
通過movq
和subq
得到了x-y
的值
%rax
通過movq
和subq
得到了y-x
的值
通過cmpq
指令,通過%rsi-%rdi
的值對條件碼寄存器進行賦值
這里發現,我們給ntest賦的值是x>=y
的判斷bool值,但是編譯器做了優化,因為x=y時一定有x-y=y-x
, 所以這里並不是jge
而是jg
來跳轉
這里的rep ret
,應該是為了解決預測器出現的問題,解決辦法就是在ret
前添加rep
,而CPU會忽略該前綴,並修復預測器。
以上匯編中:
jg .L3
.L1:
rep ret
.L3:
movq %rdx, %rax
jmp .L1
更好的實現是cmov
相關指令
cmovge %rdx, %rax
ret
關於實現循環的指令
C語言中,沒有專門實現循環的指令,循環通常是通過條件測試和跳轉的結合來實現的
如下代碼
long fact_do(long n) {
long result = 1;
do {
result *= n;
n = n - 1;
} while(n > 1);
return result;
}
對應匯編代碼
fact_do:
// n in %rdi
movl $1, %eax // result = 1
.L2:
imulq %rdi, %rax // result = result * n
subq $1, %rdi // n -= 1
cmpq $1, %rdi // 用n-1的值去設置條件碼寄存器
jg .L2 // 以jmp greater 對應的條件碼寄存器操作值取判斷
rep ret
for和while的階乘實現
for.c
long fact_for(long n) {
long i;
long result = 1;
for(i = 2; i <= n; i++)
result *= i;
return result;
}
while.c
long fact_for_while(long n) {
long i = 2;
long result = 1;
while(i <= n) {
result *= i;
i++;
}
return result;
}
查看兩者的匯編
可以發現實際操作部分是完全一樣的。
以下待完成
關於switch語句的實現指令
switch語句通過跳轉表這種數據結構,使得實現更為高效
如下代碼
void switch_eg(long x, long n, long* dest) {
long val = x;
switch(n) {
case 0:
val *= 13;
break;
case 2:
val += 10;
case 3:
val += 11;
break;
case 4:
case 6:
val += 11;
break;
default:
val = 0;
}
*dest = val;
}
對應匯編
.LFB0:
// x in %rdi, n in %rsi, *dest in %rdx
cmpq $6, %rsi
ja .L8 // if n > 6, switch->default
-------------------------
leaq .L4(%rip), %rcx // 獲得這個函數數組基地址
movslq (%rcx,%rsi,4), %rax // 將內存引用(%rcx,%rsi,4)的值賦給%rax,%rsi相當於數組下標,所以這里相當於將這個函數數組中要取的數組元素的索引放到%rax中
addq %rcx, %rax //
jmp *%rax
-------------------------
//以上四行匯編對應的是跳轉到n對應的case上,因為switch語句是將每個case偏移成一個類似函數
//其中%rax保存的是跳轉函數地址,但是並不太理解為什么最后還要addq
.L4:
.long .L3-.L4 // case 0
.long .L8-.L4 // case 1
.long .L5-.L4 // case 2
.long .L6-.L4 // case 3
.long .L7-.L4 // case 4
.long .L8-.L4 // case 5
.long .L7-.L4 // case 6
.L3:
leaq (%rdi,%rdi,2), %rax // %rax = 3 * val
leaq (%rdi,%rax,4), %rdi // val = 4 * 3 * val + val = 13val
jmp .L2 // break
.L5:
addq $10, %rdi // val += 10
.L6:
addq $11, %rdi // val += 11
.L2:
movq %rdi, (%rdx) // *dest = val
ret // return
.L7:
addq $11, %rdi // val += 11
jmp .L2 // break
.L8:
movl $0, %edi // val = 0
jmp .L2 // break
關於這里被--------
所包括的匯編代碼,原版解釋
首先:這里的.L4
是指跳轉表的地址,而跳轉表中存儲的是每個case函數的偏移量,例如對於case函數.L3
,.L4
中存儲的是.L3-.L4
leaq, .L4(%rip), %rcx
這里的.L4(%rip)是一種特殊的尋址模式,指令的結果是基於給定相對地址的絕對地址,這樣獲得了.L4的絕對地址,存到%rcx中movslq (%rcx, %rsi, 4), %rax
這里相當於將具體的某個函數數組中的元素(例如:.L3-.L4
)保存到%rax中addq %rcx, %rax
是將%rax = %rax + %rcx = (.L3-.L4) + .L4=.L3
jmp *rax
跳轉到對應case函數處
這里的實現是:
C代碼將跳轉表聲明為一個長度為7的數組,每個元素都指向代碼未知的指針。
長度為7是因為要覆蓋case0~case6的情況。
對於重復情況case4和case6,使用了相同的標號。
對於缺失情況case1和case5,使用了默認情況的標號。
使用跳轉表的優點是執switch語句的時間和case數量無關,因此處理多重分治,switch的效率更高。
3.7 過程(函數調用)
函數調用包括傳遞控制(Passing Control),傳遞數據(Passing data)內存的分配與釋放(Allocating and deallocating memory)
long Q() {}
long P() { Q();}
以上述代碼為例,當函數Q正在執行時,函數P及其相關調用鏈上的函數都會被暫時掛起。
當函數執行所需要的內存空間超過寄存器能夠存放的大小時,就會借助棧的存儲空間,這部分存儲空間叫作函數的棧幀,對於函數P調用函數Q的例子,包括較早的幀(Earlier Frames),調用函數P的幀(Frame for calling function P),正在執行的函數Q的幀(Frame for executing function Q)
返回地址
當函數P調用函數Q時,會把函數Q返回后應該返回到繼續執行函數P的地址壓入棧中。
這個將地址壓入棧的操作是由調用指令call來實現的
這里以如下命令為例:
00000000000006da<main>:
6fb: e8 41 00 00 00 callq 741 <multstore>
700: 48 8b 14 24 mov (%rsp), %rdx
....
0000000000000741<multstore>
741: 53 push %rbx
742: 48 89 d3 mov %rdx, %rbx
這里執行時,先調用6fb
這條指令,將multstore
的地址寫入到%rip
程序指令寄存器中,同時也要將調用multstore后的返回地址(從multstore返回后的下一條指令的地址)壓入棧中。當multstore執行完畢,指令ret從棧中將返回地址彈出 ,寫入到%rip
中,函數返回,繼續執行main函數的剩余操作。
參數傳遞
前6個參數依次存放在
%rdi, %rsi, %rdx, %rcx, %r8, %r9
這里具體使用的寄存器是通過傳遞參數的字節大小來判斷的
當參數超過6,多出的參數就會通過棧來傳遞
通過棧傳遞參數時,所有的數據大小都是向8的整數對齊,這里的返回地址是處於棧頂位置,所以相應被保存在棧中的參數的位置都是對應棧頂位置+8的倍數。
函數調用中的局部變量
當代碼中對一個局部變量使用地址運算符時,需要對這個局部變量在棧上開辟相應的存儲空間,棧提供了內存的分配與回收機制。
函數調用中的寄存器
如下代碼
long P(long x, long y) {
long u = Q(y);
long v = Q(x);
return u + v;
}
long Q(long x) {}
對應P的匯編
P:
pushq %rbp
pushq %rbx
movq %rdi, %rbp
movq %rsi, %rdi
call Q
movq %rax, %rbx
movq %rbp, %rdi
call Q
addq %rbx, %rax
popq %rbx
popq %rbp
ret
在調用P時,%rdi
保存x,%rsi
保存y
而在P中調用Q時,%rdi
要用來保存y,所以這里需要提前保存P的傳遞參數x,這里使用了%rbp
保存了x的值,這里是先將兩個寄存器%rbp
和%rbx
的值壓棧,然后%rbp
保存參數x的值,%rbx
保存調用Q函數的結果值u。最后程序執行完畢,得到的返回值u+v存儲到%rax
中,最后將預先存儲到棧中的原%rbp
和%rbx
的值彈出重新保存到對應的寄存器中。
這里的原因是因為%rbp
和%rbx
都是被調用者存儲寄存器Caller Saved Register
函數調用之遞歸
如下代碼
long rfact(long n) {
long result;
if(n <= 1) {
result = 1;
} else {
result = n * rfact(n - 1);
}
return result;
}
對應匯編
rfact:
cmpq $1, %rdi
jg .L8
movl $1, %eax
ret
.L8:
pushq %rbx
movq %rdi, %rbx
leaq -1(%rdi), %rdi
call rfact
imulq %rbx, %rax
popq %rbx
ret
這里首先是比較,當n大於1時,跳轉到.L8處,否則直接將1賦值給返回值寄存器然后返回
對於.L8這里,實際還是上面的棧保存寄存器的值,然后將參數傳遞到%rbx
中保存,然后減1進行rfact的函數調用
函數調用結束后進行乘法,最后將棧保存的寄存器值彈出重新賦值給寄存器,然后返回。
遞歸可以看成特殊的普通函數調用。
當然這里的問題是,當n很大時,會造成棧上保存過多的值,造成棧內存空間不足而導致的棧溢出現象。
3.8 數組的分配和訪問
數組基本准則和指針運算
對於一個char類型的數組char A[8];
,數組的首地址是\(x_A\),那么數組A第i個元素A[i]的地址就是\(x_A+i\)
對於一個int類型的數組int B[4];
,數組的首地址是\(x_B\),那么數組B第i個元素B[i]的地址就是\(x_B+4\times i\)
如下代碼
#include<stdio.h>
int main()
{
int x = 10;
char* p = &x;
int* q = &x;
if(p == q) printf("Yes\n");
else printf("No\n");
p++;
q++;
if(p == q) printf("Yes\n");
else printf("No\n");
return 0;
}
執行指令gcc -Og pq.c
,並運行./a.out
,會發現是輸出Yes
和No
初始兩者都執行變量x的地址,兩個指針p和q分別執行加1操作后,因為兩個指針可以指向的數據類型的大小不同,其中char是1個字節,int是4個字節,所以對於指針對應進行加1操作后,p是在原地址的基礎上加1,而q是在原地址的基礎上加4,所以這里會使得兩者之后指向的地址不同。這里也可以發現這樣可以用來輸出一個數據類型的每個字節的二進制表達形式。
對於訪問一個數組int E[6];
的元素,可以采用E[i]或者*(E+i)來訪問
嵌套數組
對於一個數組int A[5][3];
其邏輯分布為:
A[0][0] A[0][1] A[0][2]
A[1][0] A[1][1] A[1][2]
A[2][0] A[2][1] A[2][2]
A[3][0] A[3][1] A[3][2]
A[4][0] A[4][1] A[4][2]
而物理上是按行優先的順序存儲每個元素的
A[0][0] A[0][1] A[0][2] ... A[4][0] A[4][1] A[4][2]
我的理解是將A看成一個一維數組,而這個一維數組的每個元素A[i]又是一個一維的長度為3的數組
數組的內存地址計算,這里A的首地址為\(x_A\),則\(A]i][j]\)的地址為:\(x_A+4(3\times i + j)\)
數組動態分配內存空間
在ISO C99的標准中,引入了變長數組的概念
即可以用變量來聲明數組大小
1.
int n = 10;
int a[n];
2.
int func(int n, int a[n]) {}
這里注意n在參數表中的位置必須在a之前
3.9 結構體和聯合體
結構體訪問
對於如下結構體
struct rec{
int i;
int j;
int a[2];
int* p;
};
其內存表示為:
i: 0~3
j: 4~7
a: 8~15
a[0]: 8~11
a[1]: 12~15
p: 16~23
其中根據匯編寄存器使用規則,%rdi
保存r,%rsi
保存訪問結構體中數組a的索引,那么這里如何訪問r中的每個元素呢?
movl (%rdi), %eax //將r->i的值取出放到%eax中
movl %eax, 4(%rdi) // 將%eax的值放到r->j中,這里的立即數偏移是根據變量在結構體中定義的順序確定的,因為int i占4個字節,所以立即數偏移4
leaq 8(%rdi, %rsi, 4), %rax // 這里是先獲得了數組a[index]的地址,存放到%rax中,這里是因為只能對寄存器進行括號訪問內存的格式
結構體對齊
struct S1 {
int i;
char c;
int j;
}
sizeof(S1) = 12
這里是進行了內存對齊操作
變量T必須在其字節數的倍數處開始存儲
如int存儲的起始地址必須是4的倍數,char存儲的起始地址必須是1的倍數,long long存儲的起始地址必須是8的倍數
那么對於S1這個結構體,int起始地址為0,char起始地址為4,起始地址都是其字節倍數,而int j的起始地址是5,並非4的倍數,所以為了內存對齊,這里需要保證j的起始存儲地址為4的倍數,即8,這樣S1的字節數就是12。
struct S2{
int i;
int j;
char c;
}
sizeof(S2) = 12
這里根據上述的要求,i的起始存儲地址為0,j的起始存儲地址為4,c的起始存儲地址為8,總字節數應該為9
但是存在S2是作為數組去存儲的,那么為了避免數組元素連續存儲時發生問題,所以最后的char c要添加字節數以保證內存對齊,即最后添加3個字節,最終為12字節。
總的來說,結構體對齊需要滿足,每個元素的起始存儲地址都是其字節數的倍數,同時最后的總字節數需要是元素字節數的最大值的倍數
聯合體
一個例子,對於二叉樹,有葉子結點和內部節點。內部節點不含數據,都有指向兩個孩子節點的指針,葉子節點都有兩個double類型的數據值
struct node_s {
struct node_s* left;
struct node_s* right;
double data[2];
};
sizeof(node_s) = 32
因為該二叉樹不是內部節點就是葉子節點
使用聯合體定義
union node_u {
struct {
union node_u* left;
union node_u* right;
} internal;
double data[2];
};
sizeof(node_u) = 16
存在的問題是沒辦法確定一個節點是內部節點還是葉子節點,添加一個枚舉類型用於確認節點類型
typedef enum{N_LEAF, N_INTERNAL} nodetype_t;
union node_t {
nodetype_t type;
union {
struct {
union node_t* left;
union node_t* right;
} internal;
double data[2];
}info;
};
sizeof(node_t) = 24
這里根據內存對齊直觀上應該是32,但是由於枚舉類型本質是int,占4個字節,同時因為機器字長為8,所以對齊到8即可,至於查看內存對齊的長度可以使用類似sizeof的alignof
聯合體的另一種使用是可以查看一個元素每個字節的存儲模式
對於一個double類型
union check_double {
double a;
unsigned char ch[8];
}
這時候就可以通過ch來查看a的每個字節存儲模式。
3.10 緩沖區溢出
運行時棧
棧幀中會保存程序執行所需要的重要信息,如返回地址以及保存的寄存器的值。
在C語言中並不會對數組越界進行檢查
- 此時若對越界數組進行寫操作,就會破壞存儲在棧中的狀態信息
- 當程序使用了被修改的返回地址時,就會導致嚴重的錯誤
緩沖區溢出
如下代碼
void echo() {
char buf[8];
gets(buf);
puts(buf);
}
gets
是用來從stdin讀入一行字符串的函數,在遇到回車換行或錯誤時停止,回車換行並不會被實際讀入。
gets將讀入的字符串復制到傳入的buf首地址處,並在字符串最后一個字符之后加上一個'\0'
字符表示結束。
gets函數自C++14或C11后已經被棄用,原因在於gets並不能確定是否有足夠大空間保存整個字符串,如果說輸入的字符串過長,超過了傳遞參數中申請的內存大小,很可能會覆蓋一些內存區域,導致安全問題
對抗緩沖區溢出攻擊
很多計算機病毒就依靠緩沖區溢出這點對計算機系統進行攻擊。
編譯器和操作系統采取很多機制來限制入侵者通過這種攻擊方式獲得系統控制權,如:
- 棧隨機化(Stack Randomization)
過去的程序棧地址很容易預測,棧隨機化的思想是棧的位置在程序每次運行時都可能變化,這樣棧地址就不容易被預測,在64位Linux系統上,棧的地址范圍為0x7fff0001b698~0x7ffffffaa4a8
。在Linux系統中,棧隨機化已經成為標准行為,屬於地址空間布局隨機化的一種,即Address-Space Layout Randomization, 簡稱ASLR。采用ASLR,每次運行時程序的不同部分都會被加載到內存的不同區域。int main() { int x; printf("%p", &x); return 0; }
- 棧破壞檢測(Stack Corruption Detection)
編譯器會在產生的匯編代碼中加入一種棧保護者的機制來檢測緩沖區越界,在緩沖區和棧保存的狀態值之間存儲一個特殊值,這個特殊值被稱為金絲雀值(canary),金絲雀值是每次程序運行時隨機產生的,因此很難獲取。在函數返回前,檢測金絲雀值是否被修改來判斷是否遭受攻擊。 - 限制可執行代碼區域(Limiting Executable Code Regions)
這種機制是消除攻擊者向系統中插入可執行代碼的能力,其中一種方法是限制哪些內存區域能夠存放可執行代碼。 引入不可執行位后,棧可以被標記為可讀和可寫但是不可執行,檢查頁是否可執行由硬件完成,效率沒有損失。