在軟硬件接口中,cpu幫我們做了什么事?
我們常說,cpu就是計(jì)算機(jī)的大腦。cpu的全程是Central Processing Unit,中文是中央處理器。
上一節(jié)說了,從硬件的角度來看,cpu就是一個(gè)超大規(guī)模集成電路,通過電路實(shí)現(xiàn)了加法、乘法乃至各種各樣的處理邏輯。
如果我們從軟件工程師的角度來講,cpu就是一個(gè)執(zhí)行各種計(jì)算機(jī)指令(Instruction Code)的邏輯機(jī)器。這里的計(jì)算機(jī)指令,就好比一門CPU能夠聽得懂的語言,我們也可以把它叫做機(jī)器語言(Machine Language)。
不同的cpu能夠聽懂的語言不太一樣。比如,我們的個(gè)人電腦用的是Inter的CPU,蘋果手機(jī)用的是ARM的CPU。這兩者能聽懂的語言就不太一樣。類似這樣兩種CPU各自支持的語言,就是兩組不同的計(jì)算機(jī)指令集,英文叫Instruction Set。這里面的Set,其實(shí)就是數(shù)學(xué)上的集合,代表不同的單詞、語法。
所以,如果我們在自己電腦上寫一個(gè)程序,然后把這個(gè)程序復(fù)制一下,裝到自己的手機(jī)上,肯定是沒有辦法正常運(yùn)行的,因?yàn)閮烧哒Z言不通。而一臺(tái)電腦上的程序,簡單復(fù)制一下到另外一臺(tái)電腦上,通過就能正常運(yùn)行,因?yàn)檫@兩臺(tái)CPU有著相同的指令集,也就是說,它們的語言相同的。
一個(gè)計(jì)算機(jī)程序,不可能只有一條指令,而是由成千上萬條指令組成。但是CPU里不能一直放著所有指令,所以計(jì)算機(jī)程序平時(shí)是存儲(chǔ)在存儲(chǔ)器中的。這種程序指令存儲(chǔ)在存儲(chǔ)器里面的計(jì)算機(jī),我們就叫做存儲(chǔ)程序型計(jì)算機(jī)(Stored-program Computer)。
說到這里,你可能要問了,難道還有不是存儲(chǔ)程序型的計(jì)算機(jī)么?其實(shí),在沒有現(xiàn)代計(jì)算機(jī)之前,有著聰明才智的工程師,早就發(fā)明了一種叫Plugboard Computer的計(jì)算設(shè)備。我把它直譯成“插線板計(jì)算機(jī)”。在一個(gè)布滿各種插口和插座的板子上,工程師用不同的電線來連接不同的插口和插座,從而來完成各種計(jì)算任務(wù)。下面這個(gè)圖就是一臺(tái)IBM的Plugboard,看起來是不是有一股滿滿的蒸汽朋克范兒?

從編譯到匯編,代碼怎么變成機(jī)器碼?
了解計(jì)算機(jī)指令和計(jì)算機(jī)指令集,接下來我們來看看,平時(shí)編寫的代碼,到底是怎么變成一條條計(jì)算機(jī)指令,最后被CPU執(zhí)行的呢?我們拿一小段真是的C語言程序來看看
// test.c
int main()
{
int a = 1;
int b = 2;
a = a + b;
}
這是一段再簡單不過的C語言程序,即便不了解C語言,應(yīng)該也可以看懂了。我們給兩個(gè)變量a、b分別賦值1、2,然后再將a、b量變量中的值加在一起,重新賦值給了a這個(gè)變量。
要讓這段程序在一個(gè)Linux操作系統(tǒng)上跑起來,我們需要把整個(gè)程序翻譯成一個(gè)匯編語言(ASM,Assembly Language)的程序,這個(gè)過程我們一般叫編譯(Compile)成匯編代碼。
針對匯編代碼,我們可以再用匯編器(Assembler)翻譯成機(jī)器碼(Machine Code)。這些機(jī)器碼由0和1組成的機(jī)器語言表示。這一條條機(jī)器碼,就是一條條計(jì)算機(jī)指令。這樣一串串的16進(jìn)制數(shù)字,就是我們CPU能夠真正認(rèn)識(shí)的計(jì)算機(jī)指令。
在一個(gè)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語言代碼,有時(shí)候只對應(yīng)一條機(jī)器碼和匯編代碼,有時(shí)候則是對應(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
這時(shí)候你可能又要問了,我們實(shí)際在用GCC(GUC編譯器套裝,GNU Compiler Cpllectipon)編譯器的時(shí)候,可以直接把代碼編譯成機(jī)器碼呀,為什么還需要匯編代碼呢?原因很簡單,你看著那一串?dāng)?shù)字表示的機(jī)器碼,是不是摸不著頭腦?但是即使你沒學(xué)過匯編代碼,看的時(shí)候也能“猜”出一些這些代碼的含義。
因?yàn)閰R編代碼其實(shí)就是“給程序員看的機(jī)器碼”,也正因?yàn)檫@樣,機(jī)器碼和匯編代碼是一一對應(yīng)的。我們?nèi)祟惡苋菀子涀dd、mov這些用英文表示的指令,而8b 45 f8這樣的指令,由于很難一下子看明白是在干什么,所以會(huì)難以記憶。盡管早年互聯(lián)網(wǎng)上到處流傳,大神程序員拿著小刀在光盤上刻出操作系統(tǒng)的梗,但是要讓你用打孔卡來寫個(gè)程序,估計(jì)浪費(fèi)的卡片比用的卡片要多的多。

從高級(jí)語言到匯編語言,再到機(jī)器碼,就是一個(gè)日常開發(fā)程序,最終變成了CPU可以執(zhí)行的計(jì)算機(jī)指令的過程。
解析指令和機(jī)器碼
了解這個(gè)過程,下面我們放大局部,來看看這一行行的匯編代碼和機(jī)器指令,到底是什么意思。
我們就從平時(shí)用的電腦、手機(jī)這些設(shè)備來說起。這些設(shè)備的CPU到底有哪些指令呢?這個(gè)還真有不少,我們?nèi)粘S玫腎nter CPU,有2000條左右的CPU指令,實(shí)在是太多了,但是,常用的指令可以分成五大類/
第一類是算術(shù)類指令。我們的加減乘除,在CPU層面,都會(huì)變成一條條算術(shù)類指令。
第二類是數(shù)據(jù)傳輸類指令。給變量賦值、在內(nèi)存里讀寫數(shù)據(jù),用的都是數(shù)據(jù)傳輸類指令。
第三類是邏輯類指令,邏輯上的與或非,都是這一類指令。
第四類是條件分支類指令,日常我們寫的if/else,其實(shí)都是條件分支類指令。
最后一類是無條件跳轉(zhuǎn)指令,寫一些大一點(diǎn)的程序,我們常常需要寫一些函數(shù)或者方法。在調(diào)用函數(shù)的時(shí)候,其實(shí)就是發(fā)起了一個(gè)無條件跳轉(zhuǎn)指令。

下面我們來看看,匯編器是怎么把對應(yīng)的匯編代碼,翻譯成為機(jī)器碼的。
我們說過,不同的CPU有不同的指令集,也就對應(yīng)著不同的匯編語言和不同的機(jī)器碼。為了方便快速理解這個(gè)機(jī)器碼的計(jì)算方式,我們選用最簡單的MIPS指令集,來看看機(jī)器碼是如何生產(chǎn)的。
MIPS是一組由MIPS技術(shù)公司在80年代中期設(shè)計(jì)出來的CPU指令集。

MIPS的指令是一個(gè)32位的整數(shù),高6位叫操作碼(Opcode),也就是代表這條指令具體是一條什么樣的指令,剩下的26位有三種格式,分別是R、I和J。
R指令是一般來做算術(shù)和邏輯操作,里面有讀取和寫入數(shù)據(jù)的寄存器的地址。如果是邏輯位移,后面還有位移操作和的位移量,而最后的功能碼,則是在前面的操作碼不夠的時(shí)候,擴(kuò)展操作碼表示對應(yīng)的具體指令的。
I指令,則通常使用在數(shù)據(jù)傳輸、條件分支,以及在運(yùn)算的時(shí)候使用的并非變量還是常數(shù)的時(shí)候。這個(gè)時(shí)候,沒有了位移量和操作碼,也沒有了第三個(gè)寄存器,而是把這三個(gè)部分直接合并成一個(gè)地址值或者一個(gè)常數(shù)。
J指令就是一個(gè)跳轉(zhuǎn)指令,高6位之外的26位都是一個(gè)跳轉(zhuǎn)后的地址。
add $t0,$s2,$s1
我以一個(gè)簡單的加法算術(shù)指令add t0,s1,s2為例,給你解釋。為了方便,我們下面都用十進(jìn)制來表示對應(yīng)的代碼。
對應(yīng)的MIPS指令里的opcode是0,rs代表第一個(gè)寄存器s1的地址是17,rt代表第二個(gè)寄存器s2的四肢是18,rd代表目標(biāo)的臨時(shí)寄存器t0的地址,是8。因?yàn)椴皇俏灰撇僮鳎晕灰屏渴?.這些數(shù)字拼在一起,就變成了一個(gè)MIPS的加法指令。
為了讀起來方便,我們一般把對應(yīng)的二進(jìn)制數(shù),用十六進(jìn)制標(biāo)識(shí)出來,也就是0x02324020。這個(gè)數(shù)字也就是這條指令對應(yīng)的機(jī)器碼。

CPU是如何執(zhí)行指令的?
那我們的Inter CPU來說,里面差不多有幾百億個(gè)晶體管。實(shí)際上,一條條計(jì)算機(jī)指令執(zhí)行起來非常復(fù)雜,好在CPU在軟件層面已經(jīng)為我們做好了封裝。對于我們這些做軟件的程序員來說,我們只要知道,寫好的代碼變成了指令之后,是一條一條順序執(zhí)行的就可以了。
我們先不管幾百億的晶體管的背后是怎么通過電路運(yùn)轉(zhuǎn)起來的,邏輯上,我們可以認(rèn)為,CPU其實(shí)就是由一堆寄存器組成。而寄存器就是CPU內(nèi)部,由多個(gè)觸發(fā)器(Flip-Flop)或者鎖存器(Latches)組成的簡單電路。
觸發(fā)器和鎖存器,其實(shí)就是兩種不同原理的數(shù)字電路組成的邏輯門。
N個(gè)觸發(fā)器或者鎖存器,就可以組成N位(bit)的寄存器,能夠保存N位的數(shù)據(jù)。比方說,我們用64位Intel服務(wù)器,寄存器就是64位。

一個(gè)CPU里面會(huì)有很多中不同功能的寄存器,這里介紹三種比較特殊的。
一個(gè)是PC寄存器(Program Counter Register),也叫指令地址寄存器(Instruction Address Register)。顧名思義,它就是用來存放下一條需要執(zhí)行的計(jì)算機(jī)指令的內(nèi)存地址。
第二個(gè)是指令寄存器(Instruction Register),用來存放當(dāng)前正在執(zhí)行的指令。
第三個(gè)是條件寄存器(Status Register),用里面的一個(gè)一個(gè)標(biāo)記位(Flag),存放CPU進(jìn)行算術(shù)或者邏輯計(jì)算的結(jié)果。
除了這些特殊的寄存器,CPU里面還有更多用來存儲(chǔ)數(shù)據(jù)和內(nèi)存地址的寄存器。這樣的寄存器通常一類里面不止一個(gè)。我們通常根據(jù)存放的數(shù)據(jù)內(nèi)容來給它們命名,比如整數(shù)寄存器、浮點(diǎn)數(shù)寄存器、向量寄存器和地址寄存器等等。有些寄存器既可以存放數(shù)據(jù),又能存放地址,我們就叫它通用寄存器。

實(shí)際上,一個(gè)程序執(zhí)行的時(shí)候,CPU會(huì)根據(jù)PC寄存器里的地址,從內(nèi)存里面把需要執(zhí)行的指令讀取到指令寄存器里面執(zhí)行,然后根據(jù)指令長度自增,開始順序讀取下一條指令,可以看到,一個(gè)程序的一條條指令,在內(nèi)存里面是連續(xù)保存的,也會(huì)一條條順序加載。
而有些特殊指令,比如J類指令,也就是跳轉(zhuǎn)指令,會(huì)修改PC寄存器里面的地址值。這樣,下一條要執(zhí)行的指令就不是從內(nèi)存里面順序加載的了。事實(shí)上,這些跳轉(zhuǎn)指令的存在,也是我們可以在寫程序的時(shí)候,使用if...else條件語句和while/for循環(huán)語句的原因。
從if...else來看程序的執(zhí)行和跳轉(zhuǎn)
我們現(xiàn)在就來看一個(gè)包含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)了一個(gè)隨機(jī)數(shù)r,r要么是0,要么是1。當(dāng)r是0的時(shí)候,我們把之前定義的變量a設(shè)成1,不然就設(shè)成2.
$ gcc -g -c test.c
$ objdump -d -M intel -S test.o
我們把這個(gè)程序編譯成匯編代碼,你可以忽略前后無關(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指令比較了前后兩個(gè)操作數(shù)的值,這里的DWORD PTR代表操作的數(shù)據(jù)類型是32位整數(shù),而[rbp-0x4]則是一個(gè)寄存器的地址。所以,第一個(gè)操作數(shù)就是從寄存器里拿到變量r的值。第二個(gè)操作數(shù)0x0就是我們設(shè)定的常量0的16進(jìn)制表示。cmp指令的比較結(jié)果,會(huì)存入到條件碼寄存器當(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)、符號(hào)標(biāo)志(SF,Sign Flag)以及溢出標(biāo)志(OF,Overflow Flag),用在不同的判斷條件下。
cmp指令執(zhí)行完成之后,PC寄存器會(huì)自動(dòng)自增,開始執(zhí)行下一條jne的指令。
跟著的jne指令,是jump if not equal的意思,它會(huì)查看對應(yīng)的零標(biāo)志位。如果為0,會(huì)跳轉(zhuǎn)到后面跟著的操作數(shù)4a的位置。這個(gè)4a,對應(yīng)這里匯編代碼的行號(hào),也就是上面設(shè)置的else條件里的第一條指令。當(dāng)跳轉(zhuǎn)發(fā)生的時(shí)候,PC寄存器就不再是自增變成下一條指令的地址,而是被直接設(shè)置成這里的4a這個(gè)地址。這個(gè)時(shí)候,CPU再把4a地址里的指令加載到指令寄存器中來執(zhí)行。
跳轉(zhuǎn)到執(zhí)行地址為4a的指令,實(shí)際是一條mov指令,第一個(gè)操作數(shù)和前面的cmp指令一樣,是另一個(gè)32為整型的寄存器地址,以及對應(yīng)的2的16進(jìn)制值0x2。mov指令把2設(shè)置到對應(yīng)的寄存器里去,相當(dāng)于一個(gè)賦值操作。然后,PC寄存器里的值繼續(xù)自增,執(zhí)行下一條mov指令。
這條mov指令的第一個(gè)操作數(shù)eax,代表累加寄存器,第二個(gè)操作數(shù)0x0則是16進(jìn)制的0的表示。這條指令其實(shí)沒有實(shí)際的作用。它的作用是一個(gè)占位符。我們回過頭去看前面的if條件,如果滿足的話,再賦值的mov指令執(zhí)行完成之后,有一個(gè)jmp的無條件跳轉(zhuǎn)指令。跳轉(zhuǎn)的地址就是這一行的地址51。我們的main函數(shù)沒有設(shè)定返回值,而mov eax, 0x0其實(shí)就是給main函數(shù)生成了一個(gè)默認(rèn)的為0的返回值到累加器里面。if條件里面的內(nèi)容執(zhí)行完成之后也會(huì)跳轉(zhuǎn)到這里。和else里的內(nèi)容結(jié)束之后的位置是一樣的。

如何通過if...else和goto來實(shí)現(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)。整個(gè)程序,對應(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這個(gè)地址上的cmp比較指令,和緊接著的jle條件跳轉(zhuǎn)指令來實(shí)現(xiàn)的。主要的差別在于,這里的jle跳轉(zhuǎn)的地址,在這條指令之前的地址14,而非if...else編譯出來的跳轉(zhuǎn)指令之后。往前跳轉(zhuǎn)使得條件滿足的時(shí)候,PC寄存器會(huì)把指令地址設(shè)置到之前執(zhí)行過的指令位置,從新執(zhí)行之前執(zhí)行過的指令,直到條件不滿足,順序往下執(zhí)行jle之后的指令,整個(gè)循環(huán)才結(jié)束。

可以看到,對應(yīng)的循環(huán)也是用1e這個(gè)地址上的cmp比較指令,和緊接著的jle條件跳轉(zhuǎn)指令來實(shí)現(xiàn)的。主要的差別在于,這里的jle跳轉(zhuǎn)的地址,在這條指令之前的地址14,而非if...else編譯出來的跳轉(zhuǎn)指令之后。往前跳轉(zhuǎn)使得條件滿足的時(shí)候,PC寄存器會(huì)把指令地址設(shè)置到之前執(zhí)行過的指令位置重新執(zhí)行之前執(zhí)行過的指令,直到條件不滿足,順序往下執(zhí)行jle之后的指令,整個(gè)循環(huán)才結(jié)束。

其實(shí),你有沒有覺得,jle和jmp指令,有點(diǎn)像程序語言里面的goto命令,直接指定了一個(gè)特定條件下的跳轉(zhuǎn)位置。雖然我們在用高級(jí)語言開發(fā)程序的時(shí)候反對使用goto,但是實(shí)際在機(jī)器指令層面,無論是if...else也好,還是for/while也好,都是用和goto相同的跳轉(zhuǎn)到特定指令位置的方式來實(shí)現(xiàn)的。
為什么我們需要程序棧?
從一個(gè)簡單的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);
}
這個(gè)程序定義了一個(gè)函數(shù)add,接收兩個(gè)參數(shù)a和b,返回值就是a + b。而main函數(shù)里則定義了兩個(gè)變量x和y,然后通過調(diào)用這個(gè)add函數(shù),來計(jì)算u = x + y,最后把u的數(shù)值打印出來。
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
我們把這個(gè)程序編譯之后,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é)束的時(shí)候,又執(zhí)行了一條pop和一條ret指令。這四條指令的執(zhí)行,其實(shí)就是在進(jìn)行我們接下來要講壓棧(push)和出棧(Pop)操作。
你有沒有發(fā)現(xiàn),函數(shù)調(diào)用和上一節(jié)我們講的if...else和for/while循環(huán)有點(diǎn)像。他們兩個(gè)都是在原來順序執(zhí)行的指令過程里,執(zhí)行了一個(gè)內(nèi)存地址的跳轉(zhuǎn)指令,讓指令從原來順序執(zhí)行的過程里跳開,從新的跳轉(zhuǎn)后的位置開始執(zhí)行。
為什么我們需要程序棧?
從一個(gè)非常簡單的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);
}
這個(gè)程序定義了一個(gè)簡單的函數(shù)add,接收兩個(gè)參數(shù)a和b,返回值就是a + b。而main函數(shù)里則定義了兩個(gè)變量x和y,然后通過調(diào)用這個(gè)add函數(shù),來計(jì)算u = x + y,最后把u的數(shù)值打印出來。
$ gcc -g -c function_example.c
$ objdump -d -M intel -S function_example.o
我們把這個(gè)程序編譯之后,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é)束的時(shí)候,又執(zhí)行了一條pop和一條ret指令。這四條指令的執(zhí)行,其實(shí)就是在進(jìn)行我們接下來要講的壓棧(push)和出棧(pop)操作。
你有沒有發(fā)現(xiàn),函數(shù)調(diào)用和上一節(jié)我們講的if...else和for/while循環(huán)有點(diǎn)像。它們兩個(gè)都是在原來順序執(zhí)行的指令過程里,執(zhí)行了一個(gè)內(nèi)存地址的跳轉(zhuǎn)指令,讓指令從原來順序執(zhí)行的過程里跳開,從新的跳轉(zhuǎn)后的位置開始執(zhí)行。
但是,這兩個(gè)跳轉(zhuǎn)有個(gè)區(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),最終還是要回來的。
那我們有沒有一個(gè)可以不跳轉(zhuǎn)到原來開始的地方,來實(shí)現(xiàn)函數(shù)的調(diào)用呢?直覺是似乎有這么一個(gè)解決辦法。你可以把調(diào)用的函數(shù)指令,直接插入在調(diào)用函數(shù)的地方,替換掉對應(yīng)的call指令,然后在編譯器編譯代碼的時(shí)候,直接就把函數(shù)調(diào)用變成對應(yīng)的指令替換掉。
不過,仔細(xì)琢磨一下,你會(huì)發(fā)現(xiàn)這個(gè)方法有些問題。如果函數(shù)A調(diào)用了函數(shù)B,然后函數(shù)B再調(diào)用函數(shù)A,我們就得面臨在A里面插入B的指令,然后在B里面插入A的指令,這樣就會(huì)產(chǎn)生無窮無盡的替換。就好像兩面鏡子面對面放在一塊,任何一面鏡子里面都會(huì)看到無窮多面鏡子。

看來,把被調(diào)用函數(shù)的指令直接插入在調(diào)用出的方法行不通。那我們就換一個(gè)思路,能不能把后面要跳回來執(zhí)行的指令地址記錄下來呢?就像前面講的PC寄存器一樣,我們可以專門設(shè)立一個(gè)“程序調(diào)用寄存器”,來存儲(chǔ)接下來要跳轉(zhuǎn)回來執(zhí)行的指令地址。等到函數(shù)調(diào)用結(jié)束,從這個(gè)寄存器里取出地址,再跳轉(zhuǎn)到這個(gè)記錄的地址,繼續(xù)執(zhí)行就好了。
但是在多層函數(shù)調(diào)用里,簡單值記錄一個(gè)地址也是不夠的。我們在調(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個(gè)64位寄存器,調(diào)用的層數(shù)一多就寸不下了。
最終,計(jì)算機(jī)科學(xué)家們想到了一個(gè)比單獨(dú)記錄跳轉(zhuǎn)回來的地址更完善的辦法。我們在內(nèi)存里面開辟一段空間,用棧這個(gè)后進(jìn)先出的數(shù)據(jù)結(jié)構(gòu)。棧就像一個(gè)乒乓球桶,每次程序調(diào)用函數(shù)之前,我們都把返回后的地址寫在一個(gè)乒乓球上,然后塞進(jìn)這個(gè)球桶。這個(gè)操作其實(shí)就是我們常說的壓棧。如果函數(shù)執(zhí)行完了,我們就從球桶里取出最上面的那個(gè)乒乓球,很顯然,這就是出棧。
拿到出棧的乒乓球,找到上面的地址,把程序跳轉(zhuǎn)過去,就返回了函數(shù)調(diào)用后的下一條指令了。如果函數(shù)A在執(zhí)行完成之前又調(diào)用了函數(shù)B,那么在取出乒乓球之前,我們需要往球桶里賽一個(gè)乒乓球。而我們從球桶最上面拿乒乓球的時(shí)候,拿的也一定是最近一次的,也就是最下面一層的函數(shù)調(diào)用完成后的地址。乒乓球桶的底部,就是棧底,最上面的乒乓球所在的位置,就是棧頂。

在真實(shí)的程序里,壓棧的不只有函數(shù)調(diào)用完成后的返回地址。比如函數(shù)A在調(diào)用B的時(shí)候,需要傳輸一些參數(shù)數(shù)據(jù),這些參數(shù)數(shù)據(jù)在寄存器不夠用的時(shí)候也會(huì)被壓入棧中。整個(gè)函數(shù)A所占用的所有內(nèi)存空間,就是函數(shù)A的棧幀。Frame在中文里也有“相框”的意思,所以,每次到這里,我都有種感覺,整個(gè)函數(shù)A所需要的內(nèi)存空間就像是被這么一個(gè)“相框”給框起來,放在了棧里面。
而實(shí)際的程序棧布局,頂和底與我們的乒乓球桶相比是倒過來。底在最上面,頂在最下面,這樣的布局是因?yàn)闂5椎膬?nèi)存地址是在一開始就固定的。而一層層壓棧之后,棧頂?shù)膬?nèi)存地址實(shí)在逐漸變小而不是變大。

對應(yīng)上面函數(shù)add的匯編代碼,我們來仔細(xì)看看,main函數(shù)調(diào)用add函數(shù)時(shí),add函數(shù)入口在01行,add函數(shù)結(jié)束之后在1213行。
我們在調(diào)用第34行的call指令時(shí),會(huì)把當(dāng)前的PC寄存器里的下一條指令的地址壓棧,保留函數(shù)調(diào)用結(jié)束后要執(zhí)行的指令地址。而add函數(shù)的第0行,push rbp這個(gè)指令,就是在進(jìn)行壓棧。這里的rbp又叫棧幀指針,是一個(gè)存放了當(dāng)前棧幀位置的寄存器。push rbp就把之前調(diào)用函數(shù),也就是main函數(shù)的棧幀的棧底地址,壓入棧頂。
接著,第1行的一條命令mov rbp,rsp里,則是把rsp這個(gè)棧指針的值復(fù)制到rbp里,而rsp始終會(huì)指向棧頂。這個(gè)命令意味著,rbp這個(gè)棧幀指針指向的地址,變成當(dāng)前最新的棧頂,也就是add函數(shù)的棧幀的棧底地址了。
而在函數(shù)add執(zhí)行完成之后,又會(huì)分別調(diào)用第12行的pop rbp來將當(dāng)前的棧頂出棧,這部分操作維護(hù)好了我們整個(gè)棧幀。然后,我們可以調(diào)用第13行的ret指令,這個(gè)時(shí)候同時(shí)要把call調(diào)用的時(shí)候壓入的PC寄存器里的下一條指令出棧,更新到PC寄存器中,將程序的控制權(quán)回到出棧后的棧頂。
如何構(gòu)造一個(gè)stack overflow?
通過引入棧,我們可以看到,無論有多少層的函數(shù)調(diào)用,或者在函數(shù)A里調(diào)用函數(shù)B,再再函數(shù)B里調(diào)用函數(shù)A,這樣的遞歸調(diào)用,哦我們都只需要通過維護(hù)rbp和rsp,這兩個(gè)維護(hù)棧頂坐在地址的寄存器,就能管理好不同函數(shù)之間的跳轉(zhuǎn)。不過,棧的大小也是有限的。如果函數(shù)調(diào)用層數(shù)太多,我們往棧里壓入它存不下的內(nèi)容,程序在執(zhí)行的過程中就會(huì)遇到棧溢出的錯(cuò)誤,這就是大名鼎鼎的“stack overflow”。
要狗仔要給棧溢出的錯(cuò)誤并不困難,最簡單的辦法,就是我們上面說的Infiinite Mirror Effect的方式,讓函數(shù)A調(diào)用自己,并且不設(shè)計(jì)任何終止條件。這樣一個(gè)無限遞歸的程序,在不斷地壓棧過程中,將整個(gè)??臻g填滿,并最終遇上stack overflow。
int a()
{
return a();
}
int main()
{
a();
return 0;
}
除了無限遞歸,遞歸層數(shù)過深,在??臻g里面創(chuàng)建非常棧內(nèi)存的變量(比如一個(gè)巨大的數(shù)組),這些情況都可能給你帶來stack overflow。相信你理解了棧在程序運(yùn)行的過程里面是怎么回事,未來在遇到stackoverflow這個(gè)錯(cuò)誤的時(shí)候,不會(huì)完全沒有方向。
如何利用函數(shù)內(nèi)聯(lián)進(jìn)行性能優(yōu)化?
上面我們提到一個(gè)方法,把要給實(shí)際調(diào)用的函數(shù)產(chǎn)生的指令,直接插入到的位置,來替換杜英的函數(shù)調(diào)用指令。盡管這個(gè)通用的函數(shù)調(diào)用方案,被我們否定了,但是如果被調(diào)用的函數(shù)里,沒有調(diào)用其它函數(shù),這個(gè)方法是可以行得通的。
事實(shí)上,這就是一個(gè)常見的編譯器進(jìn)行自動(dòng)優(yōu)化的場景,我們通常叫函數(shù)內(nèi)聯(lián)(Inline)。我們只要在GCC編譯的時(shí)候,加上對應(yīng)的一個(gè)讓編譯器自動(dòng)優(yōu)化的參數(shù)-O,編譯器就會(huì)在可行的情況下,進(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ù)單獨(dú)編譯成一段指令順序,而是在調(diào)用u = add(x, y)的時(shí)候,直接替換成了一個(gè)add指令。
return a+b;
4c: 01 de add esi,ebx
除了依靠編譯器的自動(dòng)優(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)并不是沒有代價(jià),內(nèi)聯(lián)意味著,我們把可以復(fù)用的程序指令在調(diào)用它的地方完全展開了。如果一個(gè)函數(shù)在很多地方都被調(diào)用了,那就會(huì)展開很多次,整個(gè)程序占用的空間就會(huì)變大了。

這樣沒有調(diào)用其它函數(shù),只會(huì)被調(diào)用的函數(shù),我們一般稱之為葉子函數(shù)(或葉子過程)。
編譯、鏈接和裝載:拆解程序執(zhí)行
寫好c語言代碼,可以通過編譯器編譯成匯編代碼,然后匯編代碼再通過匯編器變成cpu可以理解的機(jī)器碼,于是cpu就可以執(zhí)行這些機(jī)器碼了。
我們通過gcc生成的文件和objdump獲取到的匯編指令都有些小小的問題,我們先把a(bǔ)dd函數(shù)實(shí)例,拆分成兩個(gè)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來編譯這兩個(gè)文件,然后通過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)限,我們遇到一個(gè)Permisssion denied錯(cuò)誤。即使通過chmod命令賦予link_example.o文件可執(zhí)行權(quán)限,運(yùn)行./link_example.o仍然只會(huì)得到一條can not execute binary file:Exec format error的錯(cuò)誤。
我們再仔細(xì)看一下onjdump出來的兩個(gè)文件的代碼,會(huì)發(fā)現(xiàn)兩個(gè)程序都是從0開始的。如果地址是一樣的,程序如果需要通過call指令調(diào)用函數(shù)的話,它怎么知道應(yīng)該跳轉(zhuǎn)到哪一個(gè)文件里呢?
這么說吧,無論是這里的運(yùn)行報(bào)錯(cuò),還是objdump出來的匯編代碼里面的重復(fù)地址,都是因?yàn)閍dd_lib.o以及l(fā)ink_example.o并不是一個(gè)可執(zhí)行文件(Executable Program),而是目標(biāo)文件(Object File)。只有通過鏈接器(Linker)把多個(gè)目標(biāo)文件以及調(diào)用的各種函數(shù)庫鏈接起來,我們才能得到一個(gè)可執(zhí)行文件。
我們通過gcc的-o參數(shù),可以生成對應(yīng)的可執(zhí)行文件,對應(yīng)執(zhí)行之后,就可以得到這個(gè)簡單的加法調(diào)用函數(shù)的結(jié)果。
$ gcc -o link-example add_lib.o link_example.o
$ ./link_example
c = 15
實(shí)際上,c語言代碼-匯編代碼-機(jī)器碼 這個(gè)過程,再我們的計(jì)算機(jī)上進(jìn)行的時(shí)候是由兩部分組成的。
第一個(gè)部分由編譯(Compile)、匯編(Assemble)以及鏈接(Link)三個(gè)階段組成。在這三個(gè)階段完成之后,我們就生成了一個(gè)可執(zhí)行文件。
第二部分,我們通過裝載器(Loader)把可執(zhí)行文件裝載(Load)到內(nèi)存中。cpu從內(nèi)存中讀取指令和數(shù)據(jù),來開始真正執(zhí)行程序。

ELF格式和鏈接:理解鏈接過程
程序最終是通過裝載器變成指令和數(shù)據(jù)的,所以其實(shí)我們生成的可執(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:
...
你會(huì)發(fā)現(xiàn),可執(zhí)行代碼dump出來的內(nèi)容,和之前的目標(biāo)代碼長得差不多,但是長了很多。因?yàn)樵趌inux下,可執(zhí)行文件和目標(biāo)文件所使用的都是一種叫ELF(Execuatable and Linkable File Format)的文件格式,中文名字叫可執(zhí)行與可鏈接文件格式,這里面不僅存放了編譯成的匯編指令,還保留了很多別的數(shù)據(jù)。
比如我們過去的所有objdump出來的代碼里,你都可以看到對應(yīng)的函數(shù)名稱,像add、main等等,乃至你自己定義的全局可以訪問的變量名稱,都存放在這個(gè)ELF格式文件里。這些名字和它們對應(yīng)的地址,在ELF文件里面,存儲(chǔ)在一個(gè)叫做符號(hào)表(Symbols Table)的位置里。符號(hào)表相當(dāng)于一個(gè)地址簿,把名字和地址關(guān)聯(lián)了起來。
我們先只關(guān)注和我們add以及main函數(shù)相關(guān)的部分。你會(huì)發(fā)現(xiàn),這里面,main函數(shù)里調(diào)用add的跳轉(zhuǎn)地址,不再是下一條指令的地址了,而是add函數(shù)的入口地址了,這就是ELF格式和鏈接器的功勞。

ELF文件格式把各種信息,分成一個(gè)一個(gè)的Section保存起來。ELF有一個(gè)基本的文件頭(File Header),用來表示這個(gè)文件的基本屬性,比如是否是可執(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)地址其實(shí)是我們不知道的。比如上面的link_example.o里面。我們在main函數(shù)里面調(diào)用了add和printf這兩部分函數(shù),但是鏈接發(fā)生之前,我們并不知道該跳轉(zhuǎn)到哪里,這些信息就會(huì)存儲(chǔ)在重定位表里。
- 最后是.sybtab Section,叫做符號(hào)表(Symbol Table)。符號(hào)表保留了我們所說的當(dāng)前文件里面定義的函數(shù)名成和對應(yīng)地址的地址簿。
鏈接器會(huì)掃描所有輸入的目標(biāo)文件,然后把所有符號(hào)表里的信息收集起來,構(gòu)成一個(gè)全局的符號(hào)表。然后再根據(jù)重定位表,把所有不確定要跳轉(zhuǎn)地址的代碼,根據(jù)符號(hào)表里面存儲(chǔ)的地址,進(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)
上一將,我們看到了如果通過鏈接器,把多個(gè)文件合并成一個(gè)最終可執(zhí)行文件。在運(yùn)行這些可執(zhí)行文件的時(shí)候,我們其實(shí)是通過一個(gè)裝載器,解析ELF或者PE格式的可執(zhí)行文件。裝載器會(huì)把對應(yīng)的指令和數(shù)據(jù)加載到內(nèi)存里面來,讓cpu去執(zhí)行。
說起來只是裝載到內(nèi)存里面這一句話的事兒,實(shí)際上裝載器需要滿足兩個(gè)要求。
第一,可執(zhí)行程序加載后占用的內(nèi)存空間應(yīng)該是連續(xù)的。執(zhí)行指令的時(shí)候,程序計(jì)數(shù)器是順序地一條一條指令執(zhí)行下去。這也就意味著,這一條條指令需要連續(xù)地存儲(chǔ)在一起。
第二,我們需要同時(shí)加載很多個(gè)程序,并且不能讓程序自己規(guī)定在內(nèi)存中加載的位置。雖然編譯出來的指令里已經(jīng)有了對應(yīng)的各種各樣的內(nèi)存地址,但是實(shí)際加載的時(shí)候,我們其實(shí)沒有辦法確保,這個(gè)程序一定加載在哪一段內(nèi)存地址上。因?yàn)槲覀儸F(xiàn)在的計(jì)算機(jī)通常會(huì)同時(shí)運(yùn)行很多個(gè)程序,可能你想要的內(nèi)存地址已經(jīng)被其它加載了的程序占用了。
要滿足這兩個(gè)基本的要求,我們很容易想到一個(gè)辦法。那就是我們可以在內(nèi)存里面,找到一段連續(xù)的內(nèi)存空間,然后分配給裝載的程序,然后把這段連續(xù)的內(nèi)存空間地址,和整個(gè)程序指令里指定的內(nèi)存地址做一個(gè)映射。
我們把指令里用到的內(nèi)存地址叫做虛擬內(nèi)存地址(Virtual Memory Address),實(shí)際在內(nèi)存硬件里面的空間地址,我們叫物理內(nèi)存地址(Physical Memory Address)。
程序里有指令和各種內(nèi)存地址,我們只需要關(guān)心虛擬內(nèi)存地址就可以了。對于任何一個(gè)程序來說,它看到的都是同樣的內(nèi)存地址。我們維護(hù)了一個(gè)虛擬內(nèi)存到物理內(nèi)存的映射表,這樣實(shí)際程序指令執(zhí)行的時(shí)候,會(huì)通過虛擬內(nèi)存地址,找到對應(yīng)的物理內(nèi)存地址,然后執(zhí)行。因?yàn)槭沁B續(xù)的內(nèi)存地址空間,所以我們只需要維護(hù)映射關(guān)系的起始地址和對應(yīng)的空間大小就可以了。
內(nèi)存分段
這種找出一段連續(xù)的物理內(nèi)存和虛擬內(nèi)存地址進(jìn)行映射的方法,我們叫分段(Segmentation)。這里的段,就是系統(tǒng)分配出來的那個(gè)連續(xù)的內(nèi)存空間。

分段的辦法很好,解決了程序本身不需要關(guān)心具體的物理內(nèi)存地址的問題。但它也有一些不足之處,第一個(gè)就是內(nèi)存碎片(Memory Fragmentation)的問題。
我們來看這樣一個(gè)例子。我現(xiàn)在手頭的這臺(tái)電腦,有1GB的內(nèi)存,我們先啟動(dòng)一個(gè)圖形渲染程序,占用了512MB的內(nèi)存,接著啟動(dòng)要給Chrome瀏覽器,占用了128MB內(nèi)存,再啟動(dòng)一個(gè)Python程序,占用了256MB內(nèi)存。這個(gè)時(shí)候關(guān)掉Chrome,于是空閑內(nèi)存還有1024 - 512 - 256 = 256MB。按理來說,我們有足夠的空間再去裝載一個(gè)200MB的程序。但是,這256MB的內(nèi)存空間不是連續(xù)的,而是被分成了兩段128MB的內(nèi)存。因此,實(shí)際情況是,我們的程序沒有辦法加載進(jìn)來。

當(dāng)然,這個(gè)我們也有辦法解決。解決的辦法叫內(nèi)存交換(Memory Swapping)。
我們可以把Python程序占用的那256MB內(nèi)存寫到硬盤上,然后再從硬盤上讀回來到內(nèi)存里。不過讀回來的時(shí)候,我們不再把它加載到原來的位置,而是緊緊跟在那已經(jīng)被占用了的512MB內(nèi)存后面。這樣,我們就有了連續(xù)的256MB內(nèi)存空間,就可以去加載一個(gè)新的200MB的程序。如果你自己安裝過Linux操作系統(tǒng),你應(yīng)該遇到過分配一個(gè)swap硬盤分區(qū)的問題。這塊分出來的磁盤空間,起始就是專門給Linux操作系統(tǒng)進(jìn)行內(nèi)存交換用的。
虛擬內(nèi)存、分段,再加上內(nèi)存交換,看起來似乎已經(jīng)解決了計(jì)算機(jī)同時(shí)裝載運(yùn)行很多個(gè)程序的問題。不過,這三者的組合仍然會(huì)遇到一個(gè)性能瓶頸。硬盤的訪問速度要比內(nèi)存慢的很多,而每一次內(nèi)存交換,我們都需要把一大段連續(xù)的內(nèi)存的內(nèi)存數(shù)據(jù)寫到硬盤上。所以,如果內(nèi)存交換的時(shí)候,交換的是一個(gè)很占用內(nèi)存空間的程序,這樣整個(gè)機(jī)器都會(huì)顯得卡頓。
內(nèi)存分頁
既然問題出在內(nèi)存碎片和內(nèi)存交換的空間太大上,那么解決問題的辦法就是,少出現(xiàn)一些內(nèi)存碎片。另外,當(dāng)需要進(jìn)行內(nèi)存交換的時(shí)候,讓需要交換寫入或者從磁盤裝載的數(shù)更少一點(diǎn),這樣就可以解決這個(gè)問題。這個(gè)辦法,在現(xiàn)在計(jì)算機(jī)的內(nèi)存管理里面,就叫做內(nèi)存分頁(Paging)。
和分段這樣分配一整段連續(xù)的空間給到程序相比,分頁是把整個(gè)物理內(nèi)存空間切成一段段固定尺寸的大小。而對應(yīng)的程序所需要占用的虛擬內(nèi)存空間,也會(huì)同樣切成一段段固定尺寸的大小。這樣一個(gè)連續(xù)并且尺寸固定的內(nèi)存空間,我們叫頁(page)。從虛擬內(nèi)存到物理內(nèi)存的映射,不再是拿整段連續(xù)的內(nèi)存的物理地址,而是按照一個(gè)一個(gè)頁來的。頁的尺寸一般遠(yuǎn)遠(yuǎn)小于整個(gè)程序的大小。在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ù)的一個(gè)頁或者幾個(gè)頁,不會(huì)花太多時(shí)間,讓整個(gè)機(jī)器被內(nèi)存交換的過程給卡住。

更進(jìn)一步地,分頁的方式使得我們在加載程序的時(shí)候,不再需要一次性都把程序加載到物理內(nèi)存中。我們完全可以在進(jìn)行虛擬內(nèi)存和物理內(nèi)存的頁之間的映射之后,并不真的把頁加載到物理內(nèi)存里,而是只在程序運(yùn)行中,需要用到對應(yīng)虛擬內(nèi)存頁里面的指令和數(shù)據(jù)時(shí),再加載到物理內(nèi)存里面去。
實(shí)際上,我們的操作系統(tǒng),的確是這么做的。當(dāng)要讀取特定的頁,卻發(fā)現(xiàn)數(shù)據(jù)并沒有加載到物理內(nèi)存里的時(shí)候,就會(huì)觸發(fā)一個(gè)來自于cpu的缺頁錯(cuò)誤(page Fault)。我們的操作系統(tǒng)會(huì)捕捉到這個(gè)錯(cuò)誤,然后將對應(yīng)的頁,從存放再硬盤上的虛擬內(nèi)存里讀取出來,加載到物理內(nèi)存里。這種方式,使得我們可以運(yùn)行那些遠(yuǎn)大于我們實(shí)際物理內(nèi)存的程序。同時(shí),這樣一來,任何程序都不要一次性加載完所有指令和數(shù)據(jù),只需要加載當(dāng)前需要用到的就行了。
通過虛擬內(nèi)存、內(nèi)存交換和內(nèi)存分頁這三個(gè)技術(shù)的組合,我們最終得到了一個(gè)讓程序不需要考慮實(shí)際的物理內(nèi)存地址、大小和當(dāng)前分配空間的解決方案。這些技術(shù)和方法,對于我們程序的編寫、編譯和鏈接的過程都是透明的。這也是我們在計(jì)算機(jī)的軟硬件開發(fā)中常用的一種方法,就是加入一個(gè)間接層。
通過引入虛擬內(nèi)存、頁映射和內(nèi)存交換,我們的程序本身,就不再需要考慮對應(yīng)的真實(shí)的內(nèi)存地址、程序加載、內(nèi)存管理等問題了。任何一個(gè)程序,都只需要把內(nèi)存當(dāng)成是一塊完整而連續(xù)的空間來直接使用。
我們之前講過,程序的鏈接,是把對應(yīng)的不同文件內(nèi)的代碼段,合并到一起,成為最后的可執(zhí)行文件。這個(gè)鏈接的方式,讓我們在寫代碼的時(shí)候做到了“復(fù)用”。同樣的功能代碼只要寫一次,然后提供給很多不同的程序進(jìn)行鏈接就行了。
這么說來,“鏈接”其實(shí)有點(diǎn)兒像日常生活中的標(biāo)準(zhǔn)化、模塊化生產(chǎn)。我們有一個(gè)可以生產(chǎn)標(biāo)準(zhǔn)螺帽的生產(chǎn)線,就可以生產(chǎn)很多個(gè)不同的螺帽。只要需要螺帽,我們都可以通過鏈接的方式,去復(fù)制一個(gè)出來,放到需要的地方去,大到汽車,小到信箱。
但是,如果我們有很多個(gè)程序都要通過裝載器裝載到內(nèi)存里面,那里面鏈接好的同樣的功能代碼,也都需要再裝載一遍,再占一遍內(nèi)存空間。這就好比,假設(shè)每個(gè)人都有騎自行車的需要,那我們給每個(gè)人都生產(chǎn)一輛自行車帶在身邊,固然大家都有自行車了,但是馬路上肯多會(huì)特別擁擠。

鏈接可以分動(dòng)、靜,共享運(yùn)行省內(nèi)存
上一節(jié)解決了程序裝載到內(nèi)存的時(shí)候,講了很多方法。說起來,最根本的問題其實(shí)就是內(nèi)存空間不夠用。如果我們能夠讓同樣功能的代碼,在不同的程序里面,不需要各占一份內(nèi)存空間,那該有多好啊!就好比,現(xiàn)在馬路上的共享單車,我們并不需要給每個(gè)人都造一輛自行車,只要馬路上有這些單車,誰需要的時(shí)候,直接通過手機(jī)掃碼,都可以解鎖騎行。
這個(gè)思路就引入一種新的鏈接方法,叫做動(dòng)態(tài)鏈接(Dynamic Link)。相應(yīng)的,我們之前說的合并代碼的方法,就是靜態(tài)鏈接(Static Link)。
在動(dòng)態(tài)鏈接的過程中,我們想要“鏈接”的,不是存儲(chǔ)在硬盤上的目標(biāo)文件代碼,而實(shí)加載到內(nèi)存中的共享庫(Shared Libraries)。顧名思義,這里的共享庫重在“共享”這兩個(gè)字。
這個(gè)加載到內(nèi)存中的共享庫會(huì)被很多個(gè)程序的指令調(diào)用到。在Windows下,這些共享庫文件就是.dll文件,也就是Dynamic-Link Libary(DLL,動(dòng)態(tài)鏈接庫)。在Linux下,這些共享庫文件就是.so文件,也就是Shared Object(動(dòng)態(tài)鏈接庫)。這兩大操作系統(tǒng)下的文件名后綴,一個(gè)用了“動(dòng)態(tài)鏈接”的意思,另一個(gè)用了“共享”的意思,正好覆蓋了兩方面的含義。

地址無關(guān)很重要,相對地址解煩惱
不過,要想要在程序運(yùn)行的時(shí)候共享代碼,也有一定的要求,就是這些機(jī)器碼必須是“地址無關(guān)”的。也就是說,我們編譯的共享庫文件的指令代碼,是地址無關(guān)碼(Position-Independent Code)。換句話說就是,這段代碼,無論加載在哪個(gè)內(nèi)存地址,都能夠正常執(zhí)行。如果不是這樣的代碼,就是地址相關(guān)的代碼。
如果還不明白,我再舉個(gè)例子。如果我們有一個(gè)騎自行車的程序,要“前進(jìn)500米,左轉(zhuǎn)進(jìn)入天安門廣場,再前進(jìn)500米”。它在500米之后要到天安門廣場了,這就是地址相關(guān)的。如果程序是“前進(jìn)500米,左轉(zhuǎn),再前進(jìn)500米”,無論你在哪里都可以汽車走這1000米,沒有具體地點(diǎn)的限制,這就是地址無關(guān)的。
你可以想想,大部分函數(shù)其實(shí)都可以做到地址無關(guān),因?yàn)樗鼈兌冀邮芴囟ǖ妮斎?,進(jìn)行確定操作,然后給出返回結(jié)果就好了。無論是實(shí)現(xiàn)一個(gè)向量加法,還是實(shí)現(xiàn)一個(gè)打印函數(shù),這些代碼邏輯和輸入的數(shù)據(jù)在內(nèi)存里面的位置并不重要。
而常見的地址相關(guān)的代碼,比如絕對地址代碼(Absolute Code)、利用重定位表的代碼等等,都是地址相關(guān)的代碼。你回想一下之前的重定位表。在程序鏈接的時(shí)候,我們就把函數(shù)調(diào)用后要跳轉(zhuǎn)的地址確定下來了,這意味著,如果這個(gè)函數(shù)加載到一個(gè)不同的內(nèi)存地址,跳轉(zhuǎn)就會(huì)失敗。

對于所有動(dòng)態(tài)鏈接共享庫的程序來講,雖然我們的共享庫用的是同一段物理內(nèi)存地址,但是在不同的應(yīng)用程序里,它所在的虛擬內(nèi)存地址是不同的。我們沒辦法、也不應(yīng)該要求動(dòng)態(tài)鏈接同一個(gè)共享庫的不同程序,必須把這個(gè)共享庫所使用的虛擬內(nèi)存地址變成一致。如果這樣的話,我們寫的程序就必須明確地知道內(nèi)部的內(nèi)存地址分配。
那么問題來了,我們要怎么樣才能做到,動(dòng)態(tài)共享庫編譯出來的代碼指令,都是地址無關(guān)碼呢?
動(dòng)態(tài)代碼庫內(nèi)部的變量和函數(shù)調(diào)用都很容易解決,我們只需要使用相對地址(Relative Address)就好了。各種指令中使用到的內(nèi)存地址,給出的不是一個(gè)絕對的地址空間,而實(shí)一個(gè)相對于當(dāng)前指令偏移量的內(nèi)存地址。因?yàn)檎麄€(gè)共享庫是放在一段連續(xù)的虛擬內(nèi)存地址中的,無論裝載到哪一段地址,不同指令之間的相對地址都是不變的。
PLT和GOT,動(dòng)態(tài)鏈接的解決方案
要實(shí)現(xiàn)動(dòng)態(tài)鏈接共享庫,并不困難,和前面的靜態(tài)鏈接里的符號(hào)表和重定向表類似,還是和前面一樣,我們還是拿出一小段代碼來看看。