在軟硬件接口中每窖,cpu幫我們做了什么事帮掉?
我們常說,cpu就是計算機(jī)的大腦窒典。cpu的全程是Central Processing Unit蟆炊,中文是中央處理器。
上一節(jié)說了瀑志,從硬件的角度來看涩搓,cpu就是一個超大規(guī)模集成電路,通過電路實現(xiàn)了加法劈猪、乘法乃至各種各樣的處理邏輯睦番。
如果我們從軟件工程師的角度來講辣之,cpu就是一個執(zhí)行各種計算機(jī)指令(Instruction Code)的邏輯機(jī)器疮方。這里的計算機(jī)指令悍募,就好比一門CPU能夠聽得懂的語言,我們也可以把它叫做機(jī)器語言(Machine Language)常侦。
不同的cpu能夠聽懂的語言不太一樣浇冰。比如,我們的個人電腦用的是Inter的CPU聋亡,蘋果手機(jī)用的是ARM的CPU肘习。這兩者能聽懂的語言就不太一樣。類似這樣兩種CPU各自支持的語言坡倔,就是兩組不同的計算機(jī)指令集井厌,英文叫Instruction Set。這里面的Set致讥,其實就是數(shù)學(xué)上的集合仅仆,代表不同的單詞、語法垢袱。
所以墓拜,如果我們在自己電腦上寫一個程序,然后把這個程序復(fù)制一下请契,裝到自己的手機(jī)上咳榜,肯定是沒有辦法正常運(yùn)行的,因為兩者語言不通爽锥。而一臺電腦上的程序涌韩,簡單復(fù)制一下到另外一臺電腦上,通過就能正常運(yùn)行氯夷,因為這兩臺CPU有著相同的指令集臣樱,也就是說,它們的語言相同的。
一個計算機(jī)程序雇毫,不可能只有一條指令玄捕,而是由成千上萬條指令組成。但是CPU里不能一直放著所有指令棚放,所以計算機(jī)程序平時是存儲在存儲器中的枚粘。這種程序指令存儲在存儲器里面的計算機(jī),我們就叫做存儲程序型計算機(jī)(Stored-program Computer)飘蚯。
說到這里馍迄,你可能要問了,難道還有不是存儲程序型的計算機(jī)么局骤?其實攀圈,在沒有現(xiàn)代計算機(jī)之前,有著聰明才智的工程師庄涡,早就發(fā)明了一種叫Plugboard Computer的計算設(shè)備。我把它直譯成“插線板計算機(jī)”搬设。在一個布滿各種插口和插座的板子上穴店,工程師用不同的電線來連接不同的插口和插座,從而來完成各種計算任務(wù)拿穴。下面這個圖就是一臺IBM的Plugboard泣洞,看起來是不是有一股滿滿的蒸汽朋克范兒?
從編譯到匯編默色,代碼怎么變成機(jī)器碼球凰?
了解計算機(jī)指令和計算機(jī)指令集,接下來我們來看看腿宰,平時編寫的代碼呕诉,到底是怎么變成一條條計算機(jī)指令,最后被CPU執(zhí)行的呢吃度?我們拿一小段真是的C語言程序來看看
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
這是一段再簡單不過的C語言程序甩挫,即便不了解C語言,應(yīng)該也可以看懂了椿每。我們給兩個變量a伊者、b分別賦值1、2间护,然后再將a亦渗、b量變量中的值加在一起,重新賦值給了a這個變量汁尺。
要讓這段程序在一個Linux操作系統(tǒng)上跑起來法精,我們需要把整個程序翻譯成一個匯編語言(ASM,Assembly Language)的程序,這個過程我們一般叫編譯(Compile)成匯編代碼亿虽。
針對匯編代碼菱涤,我們可以再用匯編器(Assembler)翻譯成機(jī)器碼(Machine Code)。這些機(jī)器碼由0和1組成的機(jī)器語言表示洛勉。這一條條機(jī)器碼粘秆,就是一條條計算機(jī)指令。這樣一串串的16進(jìn)制數(shù)字收毫,就是我們CPU能夠真正認(rèn)識的計算機(jī)指令攻走。
在一個Linux操作系統(tǒng)上,我們可以簡單地使用gcc和objdump這兩條命令此再,把對應(yīng)的匯編代碼和機(jī)器碼都打印出來昔搂。
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
可以看到,左側(cè)有一堆數(shù)字输拇,這些就是一條條機(jī)器碼摘符;右邊有一系列的push、mov策吠、add逛裤、pop等,這些就是杜英的匯編代碼猴抹。一行C語言代碼带族,有時候只對應(yīng)一條機(jī)器碼和匯編代碼,有時候則是對應(yīng)兩條機(jī)器碼和匯編代碼蟀给。匯編代碼和機(jī)器碼之間是一一對應(yīng)的蝙砌。
test.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
int main()
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
int a = 1;
4: c7 45 fc 01 00 00 00 mov DWORD PTR [rbp-0x4],0x1
int b = 2;
b: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
a = a + b;
12: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
15: 01 45 fc add DWORD PTR [rbp-0x4],eax
}
18: 5d pop rbp
19: c3 ret
這時候你可能又要問了,我們實際在用GCC(GUC編譯器套裝跋理,GNU Compiler Cpllectipon)編譯器的時候择克,可以直接把代碼編譯成機(jī)器碼呀,為什么還需要匯編代碼呢前普?原因很簡單祠饺,你看著那一串?dāng)?shù)字表示的機(jī)器碼,是不是摸不著頭腦汁政?但是即使你沒學(xué)過匯編代碼道偷,看的時候也能“猜”出一些這些代碼的含義。
因為匯編代碼其實就是“給程序員看的機(jī)器碼”记劈,也正因為這樣勺鸦,機(jī)器碼和匯編代碼是一一對應(yīng)的。我們?nèi)祟惡苋菀子涀dd目木、mov這些用英文表示的指令换途,而8b 45 f8這樣的指令懊渡,由于很難一下子看明白是在干什么,所以會難以記憶军拟。盡管早年互聯(lián)網(wǎng)上到處流傳剃执,大神程序員拿著小刀在光盤上刻出操作系統(tǒng)的梗,但是要讓你用打孔卡來寫個程序懈息,估計浪費(fèi)的卡片比用的卡片要多的多肾档。
從高級語言到匯編語言,再到機(jī)器碼辫继,就是一個日常開發(fā)程序怒见,最終變成了CPU可以執(zhí)行的計算機(jī)指令的過程。
解析指令和機(jī)器碼
了解這個過程姑宽,下面我們放大局部遣耍,來看看這一行行的匯編代碼和機(jī)器指令,到底是什么意思炮车。
我們就從平時用的電腦舵变、手機(jī)這些設(shè)備來說起。這些設(shè)備的CPU到底有哪些指令呢瘦穆?這個還真有不少纪隙,我們?nèi)粘S玫腎nter CPU,有2000條左右的CPU指令难审,實在是太多了瘫拣,但是亿絮,常用的指令可以分成五大類/
第一類是算術(shù)類指令告喊。我們的加減乘除,在CPU層面派昧,都會變成一條條算術(shù)類指令黔姜。
第二類是數(shù)據(jù)傳輸類指令。給變量賦值蒂萎、在內(nèi)存里讀寫數(shù)據(jù)秆吵,用的都是數(shù)據(jù)傳輸類指令。
第三類是邏輯類指令五慈,邏輯上的與或非纳寂,都是這一類指令。
第四類是條件分支類指令泻拦,日常我們寫的if/else毙芜,其實都是條件分支類指令。
最后一類是無條件跳轉(zhuǎn)指令争拐,寫一些大一點的程序腋粥,我們常常需要寫一些函數(shù)或者方法。在調(diào)用函數(shù)的時候,其實就是發(fā)起了一個無條件跳轉(zhuǎn)指令隘冲。
下面我們來看看闹瞧,匯編器是怎么把對應(yīng)的匯編代碼,翻譯成為機(jī)器碼的展辞。
我們說過奥邮,不同的CPU有不同的指令集,也就對應(yīng)著不同的匯編語言和不同的機(jī)器碼纵竖。為了方便快速理解這個機(jī)器碼的計算方式漠烧,我們選用最簡單的MIPS指令集,來看看機(jī)器碼是如何生產(chǎn)的靡砌。
MIPS是一組由MIPS技術(shù)公司在80年代中期設(shè)計出來的CPU指令集已脓。
MIPS的指令是一個32位的整數(shù),高6位叫操作碼(Opcode)通殃,也就是代表這條指令具體是一條什么樣的指令度液,剩下的26位有三種格式,分別是R画舌、I和J堕担。
R指令是一般來做算術(shù)和邏輯操作,里面有讀取和寫入數(shù)據(jù)的寄存器的地址曲聂。如果是邏輯位移霹购,后面還有位移操作和的位移量,而最后的功能碼朋腋,則是在前面的操作碼不夠的時候齐疙,擴(kuò)展操作碼表示對應(yīng)的具體指令的。
I指令旭咽,則通常使用在數(shù)據(jù)傳輸、條件分支穷绵,以及在運(yùn)算的時候使用的并非變量還是常數(shù)的時候轿塔。這個時候,沒有了位移量和操作碼仲墨,也沒有了第三個寄存器勾缭,而是把這三個部分直接合并成一個地址值或者一個常數(shù)。
J指令就是一個跳轉(zhuǎn)指令目养,高6位之外的26位都是一個跳轉(zhuǎn)后的地址俩由。
add $t0,$s2,$s1
我以一個簡單的加法算術(shù)指令add t0,s1混稽,s2為例采驻,給你解釋审胚。為了方便,我們下面都用十進(jìn)制來表示對應(yīng)的代碼礼旅。
對應(yīng)的MIPS指令里的opcode是0膳叨,rs代表第一個寄存器s1的地址是17,rt代表第二個寄存器s2的四肢是18痘系,rd代表目標(biāo)的臨時寄存器t0的地址菲嘴,是8。因為不是位移操作汰翠,所以位移量是0.這些數(shù)字拼在一起龄坪,就變成了一個MIPS的加法指令。
為了讀起來方便复唤,我們一般把對應(yīng)的二進(jìn)制數(shù)健田,用十六進(jìn)制標(biāo)識出來,也就是0x02324020佛纫。這個數(shù)字也就是這條指令對應(yīng)的機(jī)器碼妓局。
CPU是如何執(zhí)行指令的?
那我們的Inter CPU來說呈宇,里面差不多有幾百億個晶體管好爬。實際上,一條條計算機(jī)指令執(zhí)行起來非常復(fù)雜甥啄,好在CPU在軟件層面已經(jīng)為我們做好了封裝存炮。對于我們這些做軟件的程序員來說,我們只要知道蜈漓,寫好的代碼變成了指令之后穆桂,是一條一條順序執(zhí)行的就可以了。
我們先不管幾百億的晶體管的背后是怎么通過電路運(yùn)轉(zhuǎn)起來的迎变,邏輯上充尉,我們可以認(rèn)為飘言,CPU其實就是由一堆寄存器組成衣形。而寄存器就是CPU內(nèi)部,由多個觸發(fā)器(Flip-Flop)或者鎖存器(Latches)組成的簡單電路姿鸿。
觸發(fā)器和鎖存器谆吴,其實就是兩種不同原理的數(shù)字電路組成的邏輯門。
N個觸發(fā)器或者鎖存器苛预,就可以組成N位(bit)的寄存器句狼,能夠保存N位的數(shù)據(jù)。比方說热某,我們用64位Intel服務(wù)器腻菇,寄存器就是64位胳螟。
一個CPU里面會有很多中不同功能的寄存器,這里介紹三種比較特殊的筹吐。
一個是PC寄存器(Program Counter Register)糖耸,也叫指令地址寄存器(Instruction Address Register)。顧名思義丘薛,它就是用來存放下一條需要執(zhí)行的計算機(jī)指令的內(nèi)存地址嘉竟。
第二個是指令寄存器(Instruction Register),用來存放當(dāng)前正在執(zhí)行的指令洋侨。
第三個是條件寄存器(Status Register)舍扰,用里面的一個一個標(biāo)記位(Flag),存放CPU進(jìn)行算術(shù)或者邏輯計算的結(jié)果希坚。
除了這些特殊的寄存器边苹,CPU里面還有更多用來存儲數(shù)據(jù)和內(nèi)存地址的寄存器。這樣的寄存器通常一類里面不止一個裁僧。我們通常根據(jù)存放的數(shù)據(jù)內(nèi)容來給它們命名勾给,比如整數(shù)寄存器、浮點數(shù)寄存器锅知、向量寄存器和地址寄存器等等播急。有些寄存器既可以存放數(shù)據(jù),又能存放地址售睹,我們就叫它通用寄存器桩警。
實際上,一個程序執(zhí)行的時候昌妹,CPU會根據(jù)PC寄存器里的地址捶枢,從內(nèi)存里面把需要執(zhí)行的指令讀取到指令寄存器里面執(zhí)行,然后根據(jù)指令長度自增飞崖,開始順序讀取下一條指令烂叔,可以看到,一個程序的一條條指令固歪,在內(nèi)存里面是連續(xù)保存的蒜鸡,也會一條條順序加載。
而有些特殊指令牢裳,比如J類指令逢防,也就是跳轉(zhuǎn)指令,會修改PC寄存器里面的地址值蒲讯。這樣忘朝,下一條要執(zhí)行的指令就不是從內(nèi)存里面順序加載的了。事實上判帮,這些跳轉(zhuǎn)指令的存在局嘁,也是我們可以在寫程序的時候溉箕,使用if...else條件語句和while/for循環(huán)語句的原因。
從if...else來看程序的執(zhí)行和跳轉(zhuǎn)
我們現(xiàn)在就來看一個包含if...else的簡單程序悦昵。
// test.c
#include <time.h>
#include <stdlib.h>
int main()
{
srand(time(NULL));
int r = rand() % 2;
int a = 10;
if (r == 0)
{
a = 1;
} else {
a = 2;
}
我們用rand生產(chǎn)了一個隨機(jī)數(shù)r约巷,r要么是0,要么是1旱捧。當(dāng)r是0的時候独郎,我們把之前定義的變量a設(shè)成1,不然就設(shè)成2.
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
我們把這個程序編譯成匯編代碼,你可以忽略前后無關(guān)的代碼,只關(guān)注這里的if...else條件判斷語句除破。對應(yīng)的匯編代碼是這樣的:
if (r == 0)
3b: 83 7d fc 00 cmp DWORD PTR [rbp-0x4],0x0
3f: 75 09 jne 4a <main+0x4a>
{
a = 1;
41: c7 45 f8 01 00 00 00 mov DWORD PTR [rbp-0x8],0x1
48: eb 07 jmp 51 <main+0x51>
}
else
{
a = 2;
4a: c7 45 f8 02 00 00 00 mov DWORD PTR [rbp-0x8],0x2
51: b8 00 00 00 00 mov eax,0x0
}
可以看到挚赊,這里對于r==0的條件判斷,被編譯成了cmp和jne這兩條指令。
cmp指令比較了前后兩個操作數(shù)的值,這里的DWORD PTR代表操作的數(shù)據(jù)類型是32位整數(shù),而[rbp-0x4]則是一個寄存器的地址疲迂。所以,第一個操作數(shù)就是從寄存器里拿到變量r的值莫湘。第二個操作數(shù)0x0就是我們設(shè)定的常量0的16進(jìn)制表示尤蒿。cmp指令的比較結(jié)果,會存入到條件碼寄存器當(dāng)中去幅垮。
在這里腰池,如果比較的結(jié)果是True,也就是r == 0忙芒,就把零標(biāo)志條件碼(對應(yīng)的條件碼是ZF示弓,Zero Flag)設(shè)置位1。除了零標(biāo)志之外呵萨,Inter的CPU下還有進(jìn)位標(biāo)志(CF奏属,Carry Flag)、符號標(biāo)志(SF潮峦,Sign Flag)以及溢出標(biāo)志(OF囱皿,Overflow Flag),用在不同的判斷條件下跑杭。
cmp指令執(zhí)行完成之后铆帽,PC寄存器會自動自增咆耿,開始執(zhí)行下一條jne的指令德谅。
跟著的jne指令,是jump if not equal的意思萨螺,它會查看對應(yīng)的零標(biāo)志位窄做。如果為0愧驱,會跳轉(zhuǎn)到后面跟著的操作數(shù)4a的位置。這個4a椭盏,對應(yīng)這里匯編代碼的行號组砚,也就是上面設(shè)置的else條件里的第一條指令。當(dāng)跳轉(zhuǎn)發(fā)生的時候掏颊,PC寄存器就不再是自增變成下一條指令的地址糟红,而是被直接設(shè)置成這里的4a這個地址。這個時候乌叶,CPU再把4a地址里的指令加載到指令寄存器中來執(zhí)行盆偿。
跳轉(zhuǎn)到執(zhí)行地址為4a的指令,實際是一條mov指令准浴,第一個操作數(shù)和前面的cmp指令一樣事扭,是另一個32為整型的寄存器地址,以及對應(yīng)的2的16進(jìn)制值0x2乐横。mov指令把2設(shè)置到對應(yīng)的寄存器里去求橄,相當(dāng)于一個賦值操作。然后葡公,PC寄存器里的值繼續(xù)自增罐农,執(zhí)行下一條mov指令。
這條mov指令的第一個操作數(shù)eax催什,代表累加寄存器啃匿,第二個操作數(shù)0x0則是16進(jìn)制的0的表示。這條指令其實沒有實際的作用蛆楞。它的作用是一個占位符溯乒。我們回過頭去看前面的if條件,如果滿足的話豹爹,再賦值的mov指令執(zhí)行完成之后裆悄,有一個jmp的無條件跳轉(zhuǎn)指令。跳轉(zhuǎn)的地址就是這一行的地址51臂聋。我們的main函數(shù)沒有設(shè)定返回值光稼,而mov eax, 0x0其實就是給main函數(shù)生成了一個默認(rèn)的為0的返回值到累加器里面孩等。if條件里面的內(nèi)容執(zhí)行完成之后也會跳轉(zhuǎn)到這里艾君。和else里的內(nèi)容結(jié)束之后的位置是一樣的。
如何通過if...else和goto來實現(xiàn)循環(huán)肄方?
int main()
{
int a = 0;
for (int i = 0; i < 3; i++)
{
a += i;
}
}
我們再看一段簡單的利用for循環(huán)的程序冰垄。我們循環(huán)自增變量i三次,三次之后权她,i >= 3虹茶,跳出循環(huán)逝薪。整個程序,對應(yīng)的Intel匯編代碼就是這樣的:
for (int i = 0; i < 3; i++)
b: c7 45 f8 00 00 00 00 mov DWORD PTR [rbp-0x8],0x0
12: eb 0a jmp 1e <main+0x1e>
{
a += i;
14: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
17: 01 45 fc add DWORD PTR [rbp-0x4],eax
for (int i = 0; i < 3; i++)
1a: 83 45 f8 01 add DWORD PTR [rbp-0x8],0x1
1e: 83 7d f8 02 cmp DWORD PTR [rbp-0x8],0x2
22: 7e f0 jle 14 <main+0x14>
24: b8 00 00 00 00 mov eax,0x0
}
可以看到蝴罪,對應(yīng)的循環(huán)也是用1e這個地址上的cmp比較指令董济,和緊接著的jle條件跳轉(zhuǎn)指令來實現(xiàn)的。主要的差別在于要门,這里的jle跳轉(zhuǎn)的地址虏肾,在這條指令之前的地址14,而非if...else編譯出來的跳轉(zhuǎn)指令之后欢搜。往前跳轉(zhuǎn)使得條件滿足的時候询微,PC寄存器會把指令地址設(shè)置到之前執(zhí)行過的指令位置,從新執(zhí)行之前執(zhí)行過的指令狂巢,直到條件不滿足撑毛,順序往下執(zhí)行jle之后的指令,整個循環(huán)才結(jié)束唧领。
可以看到藻雌,對應(yīng)的循環(huán)也是用1e這個地址上的cmp比較指令,和緊接著的jle條件跳轉(zhuǎn)指令來實現(xiàn)的斩个。主要的差別在于胯杭,這里的jle跳轉(zhuǎn)的地址,在這條指令之前的地址14受啥,而非if...else編譯出來的跳轉(zhuǎn)指令之后做个。往前跳轉(zhuǎn)使得條件滿足的時候,PC寄存器會把指令地址設(shè)置到之前執(zhí)行過的指令位置重新執(zhí)行之前執(zhí)行過的指令滚局,直到條件不滿足居暖,順序往下執(zhí)行jle之后的指令,整個循環(huán)才結(jié)束太闺。
其實,你有沒有覺得嘁圈,jle和jmp指令省骂,有點像程序語言里面的goto命令,直接指定了一個特定條件下的跳轉(zhuǎn)位置最住。雖然我們在用高級語言開發(fā)程序的時候反對使用goto钞澳,但是實際在機(jī)器指令層面,無論是if...else也好涨缚,還是for/while也好轧粟,都是用和goto相同的跳轉(zhuǎn)到特定指令位置的方式來實現(xiàn)的。
為什么我們需要程序棧?
從一個簡單的c程序function_example.c
// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
這個程序定義了一個函數(shù)add逃延,接收兩個參數(shù)a和b览妖,返回值就是a + b轧拄。而main函數(shù)里則定義了兩個變量x和y揽祥,然后通過調(diào)用這個add函數(shù),來計算u = x + y檩电,最后把u的數(shù)值打印出來拄丰。
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
我們把這個程序編譯之后,objdump出來俐末。我們來看一看對應(yīng)的匯編代碼料按。
int static add(int a, int b)
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 <main>:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 <add>
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
可以看出來,在這段代碼里卓箫,main函數(shù)和上一節(jié)我們將的程序執(zhí)行區(qū)別并不大载矿,它主要是把jump指令換成了函數(shù)調(diào)用的call指令。call指令后面跟著的烹卒,仍然是跳轉(zhuǎn)后的程序地址闷盔。
我們來看add函數(shù)÷眉保可以看到逢勾,add函數(shù)編譯之后,代碼限制性了一條push指令和一條mov指令藐吮;在函數(shù)執(zhí)行結(jié)束的時候溺拱,又執(zhí)行了一條pop和一條ret指令。這四條指令的執(zhí)行谣辞,其實就是在進(jìn)行我們接下來要講壓棧(push)和出棧(Pop)操作迫摔。
你有沒有發(fā)現(xiàn),函數(shù)調(diào)用和上一節(jié)我們講的if...else和for/while循環(huán)有點像泥从。他們兩個都是在原來順序執(zhí)行的指令過程里攒菠,執(zhí)行了一個內(nèi)存地址的跳轉(zhuǎn)指令,讓指令從原來順序執(zhí)行的過程里跳開歉闰,從新的跳轉(zhuǎn)后的位置開始執(zhí)行辖众。
為什么我們需要程序棧?
從一個非常簡單的c程序function_example.c看起
// function_example.c
#include <stdio.h>
int static add(int a, int b)
{
return a+b;
}
int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}
這個程序定義了一個簡單的函數(shù)add和敬,接收兩個參數(shù)a和b凹炸,返回值就是a + b。而main函數(shù)里則定義了兩個變量x和y昼弟,然后通過調(diào)用這個add函數(shù)啤它,來計算u = x + y,最后把u的數(shù)值打印出來。
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
我們把這個程序編譯之后变骡,objdump出來离赫,來看一看對應(yīng)的匯編代碼。
int static add(int a, int b)
{
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 <main>:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 <add>
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret
可以看出來塌碌,在這段代碼里渊胸,main函數(shù)和上一節(jié)我們講的程序執(zhí)行區(qū)別并不大,它主要是把jump指令換成了函數(shù)調(diào)用的call指令台妆。call指令后面跟著的翎猛,仍然是跳轉(zhuǎn)后的程序地址。
我們來看add函數(shù)接剩,可以看到切厘,add函數(shù)編譯之后,代碼限制性了一條push指令和一條mov指令懊缺;在函數(shù)執(zhí)行結(jié)束的時候疫稿,又執(zhí)行了一條pop和一條ret指令。這四條指令的執(zhí)行鹃两,其實就是在進(jìn)行我們接下來要講的壓棧(push)和出棧(pop)操作遗座。
你有沒有發(fā)現(xiàn),函數(shù)調(diào)用和上一節(jié)我們講的if...else和for/while循環(huán)有點像怔毛。它們兩個都是在原來順序執(zhí)行的指令過程里员萍,執(zhí)行了一個內(nèi)存地址的跳轉(zhuǎn)指令,讓指令從原來順序執(zhí)行的過程里跳開拣度,從新的跳轉(zhuǎn)后的位置開始執(zhí)行碎绎。
但是,這兩個跳轉(zhuǎn)有個區(qū)別抗果,if...else和for/while的跳轉(zhuǎn)筋帖,是跳轉(zhuǎn)走了就不再回來了,就在跳轉(zhuǎn)后的新地址開始順序地執(zhí)行指令冤馏,就好像徐志摩在《再別康橋》里面寫的:我揮一揮衣袖日麸,不帶走一片云彩。繼續(xù)進(jìn)行新的生活了逮光。而函數(shù)調(diào)用的跳轉(zhuǎn)代箭,在對應(yīng)函數(shù)的指令執(zhí)行完了之后,還要再回到函數(shù)調(diào)用的地方涕刚,繼續(xù)執(zhí)行call之后的指令嗡综,就好像賀知章再《回鄉(xiāng)偶書》里面寫的那樣,少小離家老大回杜漠,鄉(xiāng)音未改鬢毛衰极景。不管走多遠(yuǎn)察净,最終還是要回來的。
那我們有沒有一個可以不跳轉(zhuǎn)到原來開始的地方盼樟,來實現(xiàn)函數(shù)的調(diào)用呢氢卡?直覺是似乎有這么一個解決辦法。你可以把調(diào)用的函數(shù)指令晨缴,直接插入在調(diào)用函數(shù)的地方译秦,替換掉對應(yīng)的call指令,然后在編譯器編譯代碼的時候喜庞,直接就把函數(shù)調(diào)用變成對應(yīng)的指令替換掉诀浪。
不過棋返,仔細(xì)琢磨一下延都,你會發(fā)現(xiàn)這個方法有些問題。如果函數(shù)A調(diào)用了函數(shù)B睛竣,然后函數(shù)B再調(diào)用函數(shù)A晰房,我們就得面臨在A里面插入B的指令,然后在B里面插入A的指令射沟,這樣就會產(chǎn)生無窮無盡的替換殊者。就好像兩面鏡子面對面放在一塊,任何一面鏡子里面都會看到無窮多面鏡子验夯。
看來猖吴,把被調(diào)用函數(shù)的指令直接插入在調(diào)用出的方法行不通。那我們就換一個思路挥转,能不能把后面要跳回來執(zhí)行的指令地址記錄下來呢海蔽?就像前面講的PC寄存器一樣,我們可以專門設(shè)立一個“程序調(diào)用寄存器”绑谣,來存儲接下來要跳轉(zhuǎn)回來執(zhí)行的指令地址党窜。等到函數(shù)調(diào)用結(jié)束,從這個寄存器里取出地址借宵,再跳轉(zhuǎn)到這個記錄的地址幌衣,繼續(xù)執(zhí)行就好了。
但是在多層函數(shù)調(diào)用里壤玫,簡單值記錄一個地址也是不夠的豁护。我們在調(diào)用函數(shù)A之后,A還可以調(diào)用函數(shù)B欲间,B還能調(diào)用函數(shù)C楚里。這一層又一層的調(diào)用并沒有數(shù)量上的限制。在所有函數(shù)調(diào)用返回之前括改,每一次調(diào)用的返回地址都要記錄下來腻豌,但是我們CPU里的寄存器數(shù)量并不多家坎,向我們一般使用的Inter i7CPU只有16個64位寄存器,調(diào)用的層數(shù)一多就寸不下了吝梅。
最終虱疏,計算機(jī)科學(xué)家們想到了一個比單獨記錄跳轉(zhuǎn)回來的地址更完善的辦法。我們在內(nèi)存里面開辟一段空間苏携,用棧這個后進(jìn)先出的數(shù)據(jù)結(jié)構(gòu)做瞪。棧就像一個乒乓球桶,每次程序調(diào)用函數(shù)之前右冻,我們都把返回后的地址寫在一個乒乓球上装蓬,然后塞進(jìn)這個球桶。這個操作其實就是我們常說的壓棧纱扭。如果函數(shù)執(zhí)行完了牍帚,我們就從球桶里取出最上面的那個乒乓球,很顯然乳蛾,這就是出棧暗赶。
拿到出棧的乒乓球,找到上面的地址肃叶,把程序跳轉(zhuǎn)過去蹂随,就返回了函數(shù)調(diào)用后的下一條指令了。如果函數(shù)A在執(zhí)行完成之前又調(diào)用了函數(shù)B因惭,那么在取出乒乓球之前岳锁,我們需要往球桶里賽一個乒乓球。而我們從球桶最上面拿乒乓球的時候蹦魔,拿的也一定是最近一次的激率,也就是最下面一層的函數(shù)調(diào)用完成后的地址。乒乓球桶的底部版姑,就是棧底柱搜,最上面的乒乓球所在的位置,就是棧頂剥险。
在真實的程序里聪蘸,壓棧的不只有函數(shù)調(diào)用完成后的返回地址。比如函數(shù)A在調(diào)用B的時候表制,需要傳輸一些參數(shù)數(shù)據(jù)健爬,這些參數(shù)數(shù)據(jù)在寄存器不夠用的時候也會被壓入棧中。整個函數(shù)A所占用的所有內(nèi)存空間么介,就是函數(shù)A的棧幀娜遵。Frame在中文里也有“相框”的意思,所以壤短,每次到這里设拟,我都有種感覺慨仿,整個函數(shù)A所需要的內(nèi)存空間就像是被這么一個“相框”給框起來,放在了棧里面纳胧。
而實際的程序棧布局镰吆,頂和底與我們的乒乓球桶相比是倒過來。底在最上面跑慕,頂在最下面万皿,這樣的布局是因為棧底的內(nèi)存地址是在一開始就固定的。而一層層壓棧之后核行,棧頂?shù)膬?nèi)存地址實在逐漸變小而不是變大牢硅。
對應(yīng)上面函數(shù)add的匯編代碼,我們來仔細(xì)看看芝雪,main函數(shù)調(diào)用add函數(shù)時减余,add函數(shù)入口在01行,add函數(shù)結(jié)束之后在1213行绵脯。
我們在調(diào)用第34行的call指令時佳励,會把當(dāng)前的PC寄存器里的下一條指令的地址壓棧休里,保留函數(shù)調(diào)用結(jié)束后要執(zhí)行的指令地址蛆挫。而add函數(shù)的第0行,push rbp這個指令妙黍,就是在進(jìn)行壓棧悴侵。這里的rbp又叫棧幀指針,是一個存放了當(dāng)前棧幀位置的寄存器拭嫁。push rbp就把之前調(diào)用函數(shù)可免,也就是main函數(shù)的棧幀的棧底地址,壓入棧頂做粤。
接著浇借,第1行的一條命令mov rbp,rsp里怕品,則是把rsp這個棧指針的值復(fù)制到rbp里妇垢,而rsp始終會指向棧頂。這個命令意味著肉康,rbp這個棧幀指針指向的地址闯估,變成當(dāng)前最新的棧頂,也就是add函數(shù)的棧幀的棧底地址了背亥。
而在函數(shù)add執(zhí)行完成之后饼问,又會分別調(diào)用第12行的pop rbp來將當(dāng)前的棧頂出棧慌烧,這部分操作維護(hù)好了我們整個棧幀。然后刚夺,我們可以調(diào)用第13行的ret指令献丑,這個時候同時要把call調(diào)用的時候壓入的PC寄存器里的下一條指令出棧,更新到PC寄存器中侠姑,將程序的控制權(quán)回到出棧后的棧頂阳距。
如何構(gòu)造一個stack overflow?
通過引入棧结借,我們可以看到筐摘,無論有多少層的函數(shù)調(diào)用,或者在函數(shù)A里調(diào)用函數(shù)B船老,再再函數(shù)B里調(diào)用函數(shù)A咖熟,這樣的遞歸調(diào)用,哦我們都只需要通過維護(hù)rbp和rsp柳畔,這兩個維護(hù)棧頂坐在地址的寄存器馍管,就能管理好不同函數(shù)之間的跳轉(zhuǎn)。不過薪韩,棧的大小也是有限的确沸。如果函數(shù)調(diào)用層數(shù)太多,我們往棧里壓入它存不下的內(nèi)容俘陷,程序在執(zhí)行的過程中就會遇到棧溢出的錯誤罗捎,這就是大名鼎鼎的“stack overflow”。
要狗仔要給棧溢出的錯誤并不困難拉盾,最簡單的辦法桨菜,就是我們上面說的Infiinite Mirror Effect的方式,讓函數(shù)A調(diào)用自己捉偏,并且不設(shè)計任何終止條件倒得。這樣一個無限遞歸的程序,在不斷地壓棧過程中夭禽,將整個椣疾簦空間填滿,并最終遇上stack overflow讹躯。
int a()
{
return a();
}
int main()
{
a();
return 0;
}
除了無限遞歸菩彬,遞歸層數(shù)過深,在検癯牛空間里面創(chuàng)建非常棧內(nèi)存的變量(比如一個巨大的數(shù)組)挤巡,這些情況都可能給你帶來stack overflow。相信你理解了棧在程序運(yùn)行的過程里面是怎么回事酷麦,未來在遇到stackoverflow這個錯誤的時候矿卑,不會完全沒有方向。
如何利用函數(shù)內(nèi)聯(lián)進(jìn)行性能優(yōu)化沃饶?
上面我們提到一個方法母廷,把要給實際調(diào)用的函數(shù)產(chǎn)生的指令轻黑,直接插入到的位置,來替換杜英的函數(shù)調(diào)用指令琴昆。盡管這個通用的函數(shù)調(diào)用方案氓鄙,被我們否定了,但是如果被調(diào)用的函數(shù)里业舍,沒有調(diào)用其它函數(shù)抖拦,這個方法是可以行得通的。
事實上舷暮,這就是一個常見的編譯器進(jìn)行自動優(yōu)化的場景态罪,我們通常叫函數(shù)內(nèi)聯(lián)(Inline)。我們只要在GCC編譯的時候下面,加上對應(yīng)的一個讓編譯器自動優(yōu)化的參數(shù)-O复颈,編譯器就會在可行的情況下,進(jìn)行這樣的指令替換沥割。
我們來看一段代碼:
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
int static add(int a, int b)
{
return a+b;
}
int main()
{
srand(time(NULL));
int x = rand() % 5
int y = rand() % 10;
int u = add(x, y)
printf("u = %d\n", u)
}
為了避免編譯器優(yōu)化掉太多的代碼耗啦,我小小修改了一下function_example.c,讓參數(shù)x和y都變成了机杜,通過隨機(jī)數(shù)生成帜讲,并在代碼的最后加上將u通過printf打印出來的語句。
$ gcc -g -c -O function_example_inline.c
$ objdump -d -M intel -S function_example_inline.o
上面的function_example.c的編譯出來的匯編代碼叉庐,沒有把a(bǔ)dd函數(shù)單獨編譯成一段指令順序舒帮,而是在調(diào)用u = add(x, y)的時候,直接替換成了一個add指令陡叠。
return a+b;
4c: 01 de add esi,ebx
除了依靠編譯器的自動優(yōu)化,你還可以在定義函數(shù)的地方肢执,加上inline的關(guān)鍵字枉阵,來提示編譯器對函數(shù)進(jìn)行內(nèi)聯(lián)。
內(nèi)聯(lián)帶來的優(yōu)化是预茄,CPU需要執(zhí)行的指令數(shù)變少了兴溜,根據(jù)地址跳轉(zhuǎn)的過程不需要了,壓棧和出棧的過程也不用了耻陕。
不過內(nèi)聯(lián)并不是沒有代價拙徽,內(nèi)聯(lián)意味著,我們把可以復(fù)用的程序指令在調(diào)用它的地方完全展開了诗宣。如果一個函數(shù)在很多地方都被調(diào)用了膘怕,那就會展開很多次,整個程序占用的空間就會變大了召庞。
這樣沒有調(diào)用其它函數(shù)岛心,只會被調(diào)用的函數(shù)来破,我們一般稱之為葉子函數(shù)(或葉子過程)。
編譯忘古、鏈接和裝載:拆解程序執(zhí)行
寫好c語言代碼徘禁,可以通過編譯器編譯成匯編代碼,然后匯編代碼再通過匯編器變成cpu可以理解的機(jī)器碼髓堪,于是cpu就可以執(zhí)行這些機(jī)器碼了送朱。
我們通過gcc生成的文件和objdump獲取到的匯編指令都有些小小的問題,我們先把a(bǔ)dd函數(shù)實例干旁,拆分成兩個add_lib.c和link_examplie.c骤菠。
// add_lib.c
int add(int a, int b)
{
return a+b;
}
// link_example.c
#include <stdio.h>
int main()
{
int a = 10;
int b = 5;
int c = add(a, b);
printf("c = %d\n", c);
}
我們通過gcc來編譯這兩個文件,然后通過objdump命令看看它們的匯編代碼疤孕。
$ gcc -g -c add_lib.c link_example.c
$ objdump -d -M intel -S add_lib.o
$ objdump -d -M intel -S link_example.o
add_lib.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <add>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
12: 5d pop rbp
13: c3 ret
link_example.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000 <main>:
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 48 83 ec 10 sub rsp,0x10
8: c7 45 fc 0a 00 00 00 mov DWORD PTR [rbp-0x4],0xa
f: c7 45 f8 05 00 00 00 mov DWORD PTR [rbp-0x8],0x5
16: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
19: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
1c: 89 d6 mov esi,edx
1e: 89 c7 mov edi,eax
20: b8 00 00 00 00 mov eax,0x0
25: e8 00 00 00 00 call 2a <main+0x2a>
2a: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
2d: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
30: 89 c6 mov esi,eax
32: 48 8d 3d 00 00 00 00 lea rdi,[rip+0x0] # 39 <main+0x39>
39: b8 00 00 00 00 mov eax,0x0
3e: e8 00 00 00 00 call 43 <main+0x43>
43: b8 00 00 00 00 mov eax,0x0
48: c9 leave
49: c3 ret
既然代碼已經(jīng)被編譯成了指令商乎,運(yùn)行./link_examle.o,沒有執(zhí)行權(quán)限祭阀,我們遇到一個Permisssion denied錯誤鹉戚。即使通過chmod命令賦予link_example.o文件可執(zhí)行權(quán)限,運(yùn)行./link_example.o仍然只會得到一條can not execute binary file:Exec format error的錯誤专控。
我們再仔細(xì)看一下onjdump出來的兩個文件的代碼抹凳,會發(fā)現(xiàn)兩個程序都是從0開始的。如果地址是一樣的伦腐,程序如果需要通過call指令調(diào)用函數(shù)的話赢底,它怎么知道應(yīng)該跳轉(zhuǎn)到哪一個文件里呢?
這么說吧柏蘑,無論是這里的運(yùn)行報錯幸冻,還是objdump出來的匯編代碼里面的重復(fù)地址,都是因為add_lib.o以及l(fā)ink_example.o并不是一個可執(zhí)行文件(Executable Program)咳焚,而是目標(biāo)文件(Object File)洽损。只有通過鏈接器(Linker)把多個目標(biāo)文件以及調(diào)用的各種函數(shù)庫鏈接起來,我們才能得到一個可執(zhí)行文件革半。
我們通過gcc的-o參數(shù)碑定,可以生成對應(yīng)的可執(zhí)行文件,對應(yīng)執(zhí)行之后又官,就可以得到這個簡單的加法調(diào)用函數(shù)的結(jié)果延刘。
$ gcc -o link-example add_lib.o link_example.o
$ ./link_example
c = 15
實際上,c語言代碼-匯編代碼-機(jī)器碼 這個過程六敬,再我們的計算機(jī)上進(jìn)行的時候是由兩部分組成的碘赖。
第一個部分由編譯(Compile)、匯編(Assemble)以及鏈接(Link)三個階段組成。在這三個階段完成之后崖疤,我們就生成了一個可執(zhí)行文件秘车。
第二部分,我們通過裝載器(Loader)把可執(zhí)行文件裝載(Load)到內(nèi)存中劫哼。cpu從內(nèi)存中讀取指令和數(shù)據(jù)叮趴,來開始真正執(zhí)行程序。
ELF格式和鏈接:理解鏈接過程
程序最終是通過裝載器變成指令和數(shù)據(jù)的权烧,所以其實我們生成的可執(zhí)行代碼也不僅僅是一條條的指令眯亦。我們還是通過objdump指令,把可執(zhí)行文件的內(nèi)容拿出來看看般码。
link_example: file format elf64-x86-64
Disassembly of section .init:
...
Disassembly of section .plt:
...
Disassembly of section .plt.got:
...
Disassembly of section .text:
...
6b0: 55 push rbp
6b1: 48 89 e5 mov rbp,rsp
6b4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
6b7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
6ba: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
6bd: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
6c0: 01 d0 add eax,edx
6c2: 5d pop rbp
6c3: c3 ret
00000000000006c4 <main>:
6c4: 55 push rbp
6c5: 48 89 e5 mov rbp,rsp
6c8: 48 83 ec 10 sub rsp,0x10
6cc: c7 45 fc 0a 00 00 00 mov DWORD PTR [rbp-0x4],0xa
6d3: c7 45 f8 05 00 00 00 mov DWORD PTR [rbp-0x8],0x5
6da: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
6dd: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
6e0: 89 d6 mov esi,edx
6e2: 89 c7 mov edi,eax
6e4: b8 00 00 00 00 mov eax,0x0
6e9: e8 c2 ff ff ff call 6b0 <add>
6ee: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
6f1: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
6f4: 89 c6 mov esi,eax
6f6: 48 8d 3d 97 00 00 00 lea rdi,[rip+0x97] # 794 <_IO_stdin_used+0x4>
6fd: b8 00 00 00 00 mov eax,0x0
702: e8 59 fe ff ff call 560 <printf@plt>
707: b8 00 00 00 00 mov eax,0x0
70c: c9 leave
70d: c3 ret
70e: 66 90 xchg ax,ax
...
Disassembly of section .fini:
...
你會發(fā)現(xiàn)妻率,可執(zhí)行代碼dump出來的內(nèi)容,和之前的目標(biāo)代碼長得差不多板祝,但是長了很多宫静。因為在linux下,可執(zhí)行文件和目標(biāo)文件所使用的都是一種叫ELF(Execuatable and Linkable File Format)的文件格式券时,中文名字叫可執(zhí)行與可鏈接文件格式孤里,這里面不僅存放了編譯成的匯編指令,還保留了很多別的數(shù)據(jù)橘洞。
比如我們過去的所有objdump出來的代碼里捌袜,你都可以看到對應(yīng)的函數(shù)名稱,像add炸枣、main等等虏等,乃至你自己定義的全局可以訪問的變量名稱,都存放在這個ELF格式文件里适肠。這些名字和它們對應(yīng)的地址霍衫,在ELF文件里面,存儲在一個叫做符號表(Symbols Table)的位置里迂猴。符號表相當(dāng)于一個地址簿慕淡,把名字和地址關(guān)聯(lián)了起來。
我們先只關(guān)注和我們add以及main函數(shù)相關(guān)的部分沸毁。你會發(fā)現(xiàn),這里面傻寂,main函數(shù)里調(diào)用add的跳轉(zhuǎn)地址息尺,不再是下一條指令的地址了,而是add函數(shù)的入口地址了疾掰,這就是ELF格式和鏈接器的功勞搂誉。
ELF文件格式把各種信息,分成一個一個的Section保存起來静檬。ELF有一個基本的文件頭(File Header)炭懊,用來表示這個文件的基本屬性并级,比如是否是可執(zhí)行文件,對應(yīng)的CPU侮腹、操作系統(tǒng)等等嘲碧。除了這些基本屬性之外,大部分程序還有這么一些Section:
- 首先是.text Section父阻,也叫做代碼段或者指令段(Code Section)愈涩,用來保存程序的代碼和指令;
- 接著是.data Section加矛,也叫數(shù)據(jù)段(Data Section)履婉,用來保存程序里面設(shè)置好的初始化數(shù)據(jù)信息;
- 然后就是.rel.text Section斟览,叫做重定位表(Relocation Table)毁腿。重定位表里,保留的是當(dāng)前文件里面苛茂,哪些跳轉(zhuǎn)地址其實是我們不知道的已烤。比如上面的link_example.o里面。我們在main函數(shù)里面調(diào)用了add和printf這兩部分函數(shù)味悄,但是鏈接發(fā)生之前草戈,我們并不知道該跳轉(zhuǎn)到哪里,這些信息就會存儲在重定位表里侍瑟。
- 最后是.sybtab Section唐片,叫做符號表(Symbol Table)。符號表保留了我們所說的當(dāng)前文件里面定義的函數(shù)名成和對應(yīng)地址的地址簿涨颜。
鏈接器會掃描所有輸入的目標(biāo)文件费韭,然后把所有符號表里的信息收集起來,構(gòu)成一個全局的符號表庭瑰。然后再根據(jù)重定位表星持,把所有不確定要跳轉(zhuǎn)地址的代碼,根據(jù)符號表里面存儲的地址弹灭,進(jìn)行一次修正督暂。最后,把所有的目標(biāo)文件的對應(yīng)段進(jìn)行一次合并穷吮,變成了最終的可執(zhí)行代碼逻翁。這也是為什么,可執(zhí)行文件里面的函數(shù)調(diào)用的地址都是正確的捡鱼。
在鏈接器把程序變成可執(zhí)行文件之后八回,要裝載器去執(zhí)行程序就容易多了。裝載器不再需要考慮地址跳轉(zhuǎn)的問題,只需要解析ELF文件缠诅,把對應(yīng)的指令和數(shù)據(jù)溶浴,加載到內(nèi)存里面供CPU執(zhí)行就可以了。
程序裝載面臨的跳轉(zhuǎn)
上一將管引,我們看到了如果通過鏈接器士败,把多個文件合并成一個最終可執(zhí)行文件。在運(yùn)行這些可執(zhí)行文件的時候汉匙,我們其實是通過一個裝載器拱烁,解析ELF或者PE格式的可執(zhí)行文件。裝載器會把對應(yīng)的指令和數(shù)據(jù)加載到內(nèi)存里面來噩翠,讓cpu去執(zhí)行戏自。
說起來只是裝載到內(nèi)存里面這一句話的事兒,實際上裝載器需要滿足兩個要求伤锚。
第一擅笔,可執(zhí)行程序加載后占用的內(nèi)存空間應(yīng)該是連續(xù)的。執(zhí)行指令的時候屯援,程序計數(shù)器是順序地一條一條指令執(zhí)行下去猛们。這也就意味著,這一條條指令需要連續(xù)地存儲在一起狞洋。
第二弯淘,我們需要同時加載很多個程序,并且不能讓程序自己規(guī)定在內(nèi)存中加載的位置吉懊。雖然編譯出來的指令里已經(jīng)有了對應(yīng)的各種各樣的內(nèi)存地址庐橙,但是實際加載的時候,我們其實沒有辦法確保借嗽,這個程序一定加載在哪一段內(nèi)存地址上态鳖。因為我們現(xiàn)在的計算機(jī)通常會同時運(yùn)行很多個程序,可能你想要的內(nèi)存地址已經(jīng)被其它加載了的程序占用了恶导。
要滿足這兩個基本的要求浆竭,我們很容易想到一個辦法。那就是我們可以在內(nèi)存里面惨寿,找到一段連續(xù)的內(nèi)存空間邦泄,然后分配給裝載的程序,然后把這段連續(xù)的內(nèi)存空間地址裂垦,和整個程序指令里指定的內(nèi)存地址做一個映射虎韵。
我們把指令里用到的內(nèi)存地址叫做虛擬內(nèi)存地址(Virtual Memory Address),實際在內(nèi)存硬件里面的空間地址缸废,我們叫物理內(nèi)存地址(Physical Memory Address)。
程序里有指令和各種內(nèi)存地址,我們只需要關(guān)心虛擬內(nèi)存地址就可以了企量。對于任何一個程序來說测萎,它看到的都是同樣的內(nèi)存地址。我們維護(hù)了一個虛擬內(nèi)存到物理內(nèi)存的映射表届巩,這樣實際程序指令執(zhí)行的時候硅瞧,會通過虛擬內(nèi)存地址,找到對應(yīng)的物理內(nèi)存地址恕汇,然后執(zhí)行腕唧。因為是連續(xù)的內(nèi)存地址空間,所以我們只需要維護(hù)映射關(guān)系的起始地址和對應(yīng)的空間大小就可以了瘾英。
內(nèi)存分段
這種找出一段連續(xù)的物理內(nèi)存和虛擬內(nèi)存地址進(jìn)行映射的方法枣接,我們叫分段(Segmentation)。這里的段缺谴,就是系統(tǒng)分配出來的那個連續(xù)的內(nèi)存空間但惶。
分段的辦法很好,解決了程序本身不需要關(guān)心具體的物理內(nèi)存地址的問題湿蛔。但它也有一些不足之處膀曾,第一個就是內(nèi)存碎片(Memory Fragmentation)的問題。
我們來看這樣一個例子阳啥。我現(xiàn)在手頭的這臺電腦添谊,有1GB的內(nèi)存,我們先啟動一個圖形渲染程序察迟,占用了512MB的內(nèi)存卷拘,接著啟動要給Chrome瀏覽器,占用了128MB內(nèi)存改鲫,再啟動一個Python程序截歉,占用了256MB內(nèi)存。這個時候關(guān)掉Chrome诬辈,于是空閑內(nèi)存還有1024 - 512 - 256 = 256MB攻礼。按理來說礁扮,我們有足夠的空間再去裝載一個200MB的程序。但是瞬沦,這256MB的內(nèi)存空間不是連續(xù)的太伊,而是被分成了兩段128MB的內(nèi)存。因此逛钻,實際情況是僚焦,我們的程序沒有辦法加載進(jìn)來。
當(dāng)然曙痘,這個我們也有辦法解決芳悲。解決的辦法叫內(nèi)存交換(Memory Swapping)立肘。
我們可以把Python程序占用的那256MB內(nèi)存寫到硬盤上,然后再從硬盤上讀回來到內(nèi)存里芭概。不過讀回來的時候赛不,我們不再把它加載到原來的位置,而是緊緊跟在那已經(jīng)被占用了的512MB內(nèi)存后面罢洲。這樣,我們就有了連續(xù)的256MB內(nèi)存空間文黎,就可以去加載一個新的200MB的程序惹苗。如果你自己安裝過Linux操作系統(tǒng),你應(yīng)該遇到過分配一個swap硬盤分區(qū)的問題耸峭。這塊分出來的磁盤空間桩蓉,起始就是專門給Linux操作系統(tǒng)進(jìn)行內(nèi)存交換用的。
虛擬內(nèi)存劳闹、分段院究,再加上內(nèi)存交換,看起來似乎已經(jīng)解決了計算機(jī)同時裝載運(yùn)行很多個程序的問題本涕。不過业汰,這三者的組合仍然會遇到一個性能瓶頸。硬盤的訪問速度要比內(nèi)存慢的很多菩颖,而每一次內(nèi)存交換样漆,我們都需要把一大段連續(xù)的內(nèi)存的內(nèi)存數(shù)據(jù)寫到硬盤上。所以晦闰,如果內(nèi)存交換的時候放祟,交換的是一個很占用內(nèi)存空間的程序,這樣整個機(jī)器都會顯得卡頓呻右。
內(nèi)存分頁
既然問題出在內(nèi)存碎片和內(nèi)存交換的空間太大上跪妥,那么解決問題的辦法就是,少出現(xiàn)一些內(nèi)存碎片声滥。另外眉撵,當(dāng)需要進(jìn)行內(nèi)存交換的時候,讓需要交換寫入或者從磁盤裝載的數(shù)更少一點醒串,這樣就可以解決這個問題执桌。這個辦法,在現(xiàn)在計算機(jī)的內(nèi)存管理里面芜赌,就叫做內(nèi)存分頁(Paging)仰挣。
和分段這樣分配一整段連續(xù)的空間給到程序相比,分頁是把整個物理內(nèi)存空間切成一段段固定尺寸的大小缠沈。而對應(yīng)的程序所需要占用的虛擬內(nèi)存空間膘壶,也會同樣切成一段段固定尺寸的大小错蝴。這樣一個連續(xù)并且尺寸固定的內(nèi)存空間,我們叫頁(page)颓芭。從虛擬內(nèi)存到物理內(nèi)存的映射顷锰,不再是拿整段連續(xù)的內(nèi)存的物理地址,而是按照一個一個頁來的亡问。頁的尺寸一般遠(yuǎn)遠(yuǎn)小于整個程序的大小官紫。在Linux下,我們通常只設(shè)置成4KB州藕。你可以通過命令看看Linux系統(tǒng)設(shè)置的頁的大小束世。
$ getconf PAGE_SIZE
由于內(nèi)存空間都是預(yù)先劃分好的,也就沒有了不能使用的碎片床玻,而只有被釋放出來的很多4kb的頁毁涉。即使內(nèi)存空間不夠,需要讓現(xiàn)有的锈死、正在運(yùn)行的其它程序贫堰,通過內(nèi)存交換釋放出一些內(nèi)存的頁出來,一次性寫入磁盤的頁只有少數(shù)的一個頁或者幾個頁待牵,不會花太多時間其屏,讓整個機(jī)器被內(nèi)存交換的過程給卡住。
更進(jìn)一步地洲敢,分頁的方式使得我們在加載程序的時候漫玄,不再需要一次性都把程序加載到物理內(nèi)存中。我們完全可以在進(jìn)行虛擬內(nèi)存和物理內(nèi)存的頁之間的映射之后压彭,并不真的把頁加載到物理內(nèi)存里睦优,而是只在程序運(yùn)行中,需要用到對應(yīng)虛擬內(nèi)存頁里面的指令和數(shù)據(jù)時壮不,再加載到物理內(nèi)存里面去汗盘。
實際上,我們的操作系統(tǒng)询一,的確是這么做的隐孽。當(dāng)要讀取特定的頁,卻發(fā)現(xiàn)數(shù)據(jù)并沒有加載到物理內(nèi)存里的時候健蕊,就會觸發(fā)一個來自于cpu的缺頁錯誤(page Fault)菱阵。我們的操作系統(tǒng)會捕捉到這個錯誤,然后將對應(yīng)的頁缩功,從存放再硬盤上的虛擬內(nèi)存里讀取出來晴及,加載到物理內(nèi)存里。這種方式嫡锌,使得我們可以運(yùn)行那些遠(yuǎn)大于我們實際物理內(nèi)存的程序虑稼。同時琳钉,這樣一來,任何程序都不要一次性加載完所有指令和數(shù)據(jù),只需要加載當(dāng)前需要用到的就行了。
通過虛擬內(nèi)存茶袒、內(nèi)存交換和內(nèi)存分頁這三個技術(shù)的組合,我們最終得到了一個讓程序不需要考慮實際的物理內(nèi)存地址及皂、大小和當(dāng)前分配空間的解決方案。這些技術(shù)和方法茸塞,對于我們程序的編寫躲庄、編譯和鏈接的過程都是透明的。這也是我們在計算機(jī)的軟硬件開發(fā)中常用的一種方法钾虐,就是加入一個間接層。
通過引入虛擬內(nèi)存笋庄、頁映射和內(nèi)存交換效扫,我們的程序本身,就不再需要考慮對應(yīng)的真實的內(nèi)存地址直砂、程序加載菌仁、內(nèi)存管理等問題了。任何一個程序静暂,都只需要把內(nèi)存當(dāng)成是一塊完整而連續(xù)的空間來直接使用济丘。
我們之前講過,程序的鏈接洽蛀,是把對應(yīng)的不同文件內(nèi)的代碼段摹迷,合并到一起,成為最后的可執(zhí)行文件郊供。這個鏈接的方式峡碉,讓我們在寫代碼的時候做到了“復(fù)用”。同樣的功能代碼只要寫一次驮审,然后提供給很多不同的程序進(jìn)行鏈接就行了鲫寄。
這么說來,“鏈接”其實有點兒像日常生活中的標(biāo)準(zhǔn)化疯淫、模塊化生產(chǎn)地来。我們有一個可以生產(chǎn)標(biāo)準(zhǔn)螺帽的生產(chǎn)線,就可以生產(chǎn)很多個不同的螺帽熙掺。只要需要螺帽未斑,我們都可以通過鏈接的方式,去復(fù)制一個出來适掰,放到需要的地方去颂碧,大到汽車荠列,小到信箱。
但是载城,如果我們有很多個程序都要通過裝載器裝載到內(nèi)存里面肌似,那里面鏈接好的同樣的功能代碼,也都需要再裝載一遍诉瓦,再占一遍內(nèi)存空間川队。這就好比,假設(shè)每個人都有騎自行車的需要睬澡,那我們給每個人都生產(chǎn)一輛自行車帶在身邊固额,固然大家都有自行車了,但是馬路上肯多會特別擁擠煞聪。
鏈接可以分動斗躏、靜,共享運(yùn)行省內(nèi)存
上一節(jié)解決了程序裝載到內(nèi)存的時候昔脯,講了很多方法啄糙。說起來,最根本的問題其實就是內(nèi)存空間不夠用云稚。如果我們能夠讓同樣功能的代碼隧饼,在不同的程序里面,不需要各占一份內(nèi)存空間静陈,那該有多好把嘌恪!就好比鲸拥,現(xiàn)在馬路上的共享單車拐格,我們并不需要給每個人都造一輛自行車,只要馬路上有這些單車崩泡,誰需要的時候禁荒,直接通過手機(jī)掃碼,都可以解鎖騎行角撞。
這個思路就引入一種新的鏈接方法呛伴,叫做動態(tài)鏈接(Dynamic Link)。相應(yīng)的谒所,我們之前說的合并代碼的方法热康,就是靜態(tài)鏈接(Static Link)。
在動態(tài)鏈接的過程中劣领,我們想要“鏈接”的姐军,不是存儲在硬盤上的目標(biāo)文件代碼,而實加載到內(nèi)存中的共享庫(Shared Libraries)。顧名思義奕锌,這里的共享庫重在“共享”這兩個字著觉。
這個加載到內(nèi)存中的共享庫會被很多個程序的指令調(diào)用到。在Windows下惊暴,這些共享庫文件就是.dll文件饼丘,也就是Dynamic-Link Libary(DLL,動態(tài)鏈接庫)辽话。在Linux下肄鸽,這些共享庫文件就是.so文件,也就是Shared Object(動態(tài)鏈接庫)油啤。這兩大操作系統(tǒng)下的文件名后綴典徘,一個用了“動態(tài)鏈接”的意思,另一個用了“共享”的意思益咬,正好覆蓋了兩方面的含義逮诲。
地址無關(guān)很重要,相對地址解煩惱
不過幽告,要想要在程序運(yùn)行的時候共享代碼汛骂,也有一定的要求,就是這些機(jī)器碼必須是“地址無關(guān)”的评腺。也就是說,我們編譯的共享庫文件的指令代碼淑掌,是地址無關(guān)碼(Position-Independent Code)蒿讥。換句話說就是,這段代碼抛腕,無論加載在哪個內(nèi)存地址芋绸,都能夠正常執(zhí)行。如果不是這樣的代碼担敌,就是地址相關(guān)的代碼摔敛。
如果還不明白,我再舉個例子全封。如果我們有一個騎自行車的程序马昙,要“前進(jìn)500米,左轉(zhuǎn)進(jìn)入天安門廣場刹悴,再前進(jìn)500米”行楞。它在500米之后要到天安門廣場了,這就是地址相關(guān)的土匀。如果程序是“前進(jìn)500米子房,左轉(zhuǎn),再前進(jìn)500米”,無論你在哪里都可以汽車走這1000米证杭,沒有具體地點的限制田度,這就是地址無關(guān)的。
你可以想想解愤,大部分函數(shù)其實都可以做到地址無關(guān)镇饺,因為它們都接受特定的輸入,進(jìn)行確定操作琢歇,然后給出返回結(jié)果就好了兰怠。無論是實現(xiàn)一個向量加法,還是實現(xiàn)一個打印函數(shù)李茫,這些代碼邏輯和輸入的數(shù)據(jù)在內(nèi)存里面的位置并不重要揭保。
而常見的地址相關(guān)的代碼,比如絕對地址代碼(Absolute Code)魄宏、利用重定位表的代碼等等秸侣,都是地址相關(guān)的代碼。你回想一下之前的重定位表宠互。在程序鏈接的時候味榛,我們就把函數(shù)調(diào)用后要跳轉(zhuǎn)的地址確定下來了,這意味著予跌,如果這個函數(shù)加載到一個不同的內(nèi)存地址搏色,跳轉(zhuǎn)就會失敗。
對于所有動態(tài)鏈接共享庫的程序來講券册,雖然我們的共享庫用的是同一段物理內(nèi)存地址频轿,但是在不同的應(yīng)用程序里,它所在的虛擬內(nèi)存地址是不同的烁焙。我們沒辦法航邢、也不應(yīng)該要求動態(tài)鏈接同一個共享庫的不同程序,必須把這個共享庫所使用的虛擬內(nèi)存地址變成一致骄蝇。如果這樣的話膳殷,我們寫的程序就必須明確地知道內(nèi)部的內(nèi)存地址分配。
那么問題來了九火,我們要怎么樣才能做到赚窃,動態(tài)共享庫編譯出來的代碼指令,都是地址無關(guān)碼呢吃既?
動態(tài)代碼庫內(nèi)部的變量和函數(shù)調(diào)用都很容易解決考榨,我們只需要使用相對地址(Relative Address)就好了。各種指令中使用到的內(nèi)存地址鹦倚,給出的不是一個絕對的地址空間河质,而實一個相對于當(dāng)前指令偏移量的內(nèi)存地址。因為整個共享庫是放在一段連續(xù)的虛擬內(nèi)存地址中的,無論裝載到哪一段地址掀鹅,不同指令之間的相對地址都是不變的散休。
PLT和GOT,動態(tài)鏈接的解決方案
要實現(xiàn)動態(tài)鏈接共享庫乐尊,并不困難戚丸,和前面的靜態(tài)鏈接里的符號表和重定向表類似,還是和前面一樣扔嵌,我們還是拿出一小段代碼來看看限府。