2016年國慶假期終于把此書過完税产,整理筆記和體會(huì)于此。
關(guān)于書名
書名源于俄羅斯的演員斯坦尼斯拉夫斯基創(chuàng)作的《演員的自我修養(yǎng)》邻奠,作者為了寫這本書前前后后修改了三十年之久呜象,臨終前才同意不在修改子姜,拿去出版遥赚。使用這個(gè)書名一方面書單內(nèi)容的確不是介紹一門新的編程語言或是展示一些實(shí)用的編程技術(shù)愧薛,而是介紹程序運(yùn)行背后的機(jī)制和由來费奸,可以看做是程序員的一種“修養(yǎng)”换况;另一方面是向斯坦尼斯拉夫斯基致敬,向他對作品精益求精的精神致敬节值。 -- 本書的序言三·余甲子
本書的組織
本書分為4大部分匿乃,分別如下佛嬉。
第一部分 簡介
第1章 溫故而知新
介紹基本的背景知識陨享,包括硬件、操作系統(tǒng)诲侮、線程等恨旱。
第二部分 靜態(tài)連接
第2章 編譯和鏈接
介紹編譯和鏈接的基本概念和步驟。
第3章 目標(biāo)文件里有什么
介紹 COFF 目標(biāo)文件格式和源代碼編譯后如何在目標(biāo)文件中存儲(chǔ)。
第4章 靜態(tài)鏈接
介紹靜態(tài)鏈接與靜態(tài)鏈接庫的過程和步驟南用。
第5章 Windows PE/COFF
介紹Windows平臺(tái)下的目標(biāo)文件和可支持文件格式
第三部分 裝載與動(dòng)態(tài)鏈接
第6章 可執(zhí)行文件的裝載過程
介紹進(jìn)程的概念、進(jìn)程地址空間的分布和可執(zhí)行文件映射裝載過程拇涤。
第7章 動(dòng)態(tài)鏈接
以Linux下的.so共享庫為基礎(chǔ)詳細(xì)分析了動(dòng)態(tài)鏈接的過程。
第8章 Linux共享庫的組織
介紹Linux下共享文件的分布和組織
第9章 Windows下的動(dòng)態(tài)鏈接
介紹Windows系統(tǒng)下的DLL動(dòng)態(tài)鏈接機(jī)制
第四部分 庫與運(yùn)行時(shí)
第10章 內(nèi)存
主要介紹堆與棧誉结,堆的分配算法,函數(shù)調(diào)用棧分布惩坑。
第11章 運(yùn)行庫
主要介紹運(yùn)行庫的概念掉盅、C/C++運(yùn)行庫、Glibc 和 MSVC CRT以舒、運(yùn)行庫如何實(shí)現(xiàn)C++全局構(gòu)造和析構(gòu)以及fread()庫函數(shù)為例對運(yùn)行庫進(jìn)行剖析趾痘。
第12章 系統(tǒng)調(diào)用與API
主要介紹Linux和Windows的系統(tǒng)調(diào)用以及Windows的API。
第13章 運(yùn)行庫的實(shí)現(xiàn)
主要實(shí)現(xiàn)了一個(gè)支持堆稀轨、基本文件操作扼脐、格式化字符串、基本輸入輸出奋刽、C++new/delete瓦侮、C++string、C++全局構(gòu)造和析構(gòu)的Mini CRT佣谐。
重點(diǎn)閱讀章節(jié)
- 第1章 溫故而知新
- 第2章 編譯和鏈接
- 第3章 目標(biāo)文件里有什么
- 第4章 靜態(tài)鏈接
- 第6章 可執(zhí)行文件的裝載過程
- 第7章 動(dòng)態(tài)鏈接
- 第10章 內(nèi)存
讀書筆記
溫故而知新
正如第一章的標(biāo)題一樣肚吏,溫故而知新,本章主要講述了計(jì)算機(jī)硬件和軟件的歷史發(fā)展背景狭魂。主要有幾點(diǎn):
- CPU的頻率目前的“天花板”是4GHz罚攀,從2004年后就不再按照摩爾定律增長,因?yàn)镃PU的制造工藝沒有本質(zhì)的突破雌澄。
- 理論上講斋泄,增加CPU的數(shù)量就可以提高運(yùn)算速度,并且理想情況下镐牺,速度的提高與CPU的數(shù)量成正比炫掐。但實(shí)際上并非如此,因?yàn)槲覀兊某绦虿欢寄芊纸獬扇舾蓚€(gè)完全不相干的子問題睬涧。就如一個(gè)女人可以花10個(gè)月生出一個(gè)孩子募胃,但是10個(gè)女人并不能在一個(gè)月生出一個(gè)孩子。
在第二節(jié)中畦浓,書中講計(jì)算機(jī)系統(tǒng)軟件的體系結(jié)構(gòu)痹束,有一句至理名言:“計(jì)算機(jī)科學(xué)領(lǐng)域的任何問題都可以通過增加一個(gè)間接的中間層來解決”,洋文是:“Any problem in conputer science can be solved by anther layer of indirection.”
看下計(jì)算機(jī)軟件體系結(jié)構(gòu)圖讶请,理解下這句話的魅力祷嘶。
每個(gè)層次之間都須要相互通信,既然須要通信就必須有一個(gè)通信的協(xié)議,我們一般稱為接口(Inerface)抹蚀,接口的下面那層是接口的提供者剿牺,又它定義接口;接口的上面那層是接口的使用者环壤,它使用該接口來實(shí)習(xí)所需要的功能。在層次體系中钞诡,接口是被精心設(shè)計(jì)過的郑现,盡量保持穩(wěn)定不變,那么理論上層次之間只要遵循這個(gè)接口荧降,任何一個(gè)層都可以被修改或被替換接箫。除了硬件和應(yīng)用程序,其他都是所謂的中間層朵诫,每個(gè)中間層都是對它下面那層的包裝和擴(kuò)展辛友。
書中歸納了功能,操作系統(tǒng)做什么剪返?
- 提供抽象接口废累;
- 管理硬件資源;
書中以不要讓CPU打盹這樣一個(gè)標(biāo)題脱盲,引出了CPU發(fā)展過程中的幾種程序協(xié)作模式:
- 多道程序邑滨;
- 分時(shí)系統(tǒng);
- 多任務(wù)系統(tǒng)钱反;
關(guān)于內(nèi)存不夠掖看,書中提出了一個(gè)問題:如何將計(jì)算機(jī)上有限的物理內(nèi)存分配給多個(gè)程序使用?使用簡單的內(nèi)存分配策略面哥,遇到的幾個(gè)問題:
- 地址空間不隔離哎壳;
- 內(nèi)存使用率低;
- 程序運(yùn)行的地址不穩(wěn)定尚卫;
解決這個(gè)幾個(gè)問題的思路就是我們使用前文提到過的法寶:增加中間層归榕,即使用一種間接的地址訪問方法。整個(gè)想法是這樣的焕毫,我們把程序給出的地址看做是一種虛擬地址(Virtual Address)蹲坷,然后通過某些映射的方法,將這個(gè)虛擬地址轉(zhuǎn)換成實(shí)際的物理地址邑飒。這樣只要我們能夠妥善地控制這個(gè)虛擬地址到物理地址的映射過程循签,就可以保證任意一個(gè)程序所能夠訪問的物理內(nèi)存區(qū)域跟另外一個(gè)程序互相不重疊,已達(dá)到地址空間隔離的效果疙咸。
虛擬地址是為了解決上面的那三個(gè)問題县匠,虛擬地址的發(fā)展過中,有兩種思路來解決:
- 分段;把一段與程序所需要的內(nèi)存空間大小的虛擬空間映射到某個(gè)地址空間乞旦。這個(gè)方案解決了第一個(gè)和第三個(gè)問題贼穆,但是沒有解決第二個(gè)問題,內(nèi)存的使用效率兰粉。
- 分頁故痊;分頁的基本方法是吧地址空間人為的等分成固定大小的頁,每一頁的大小又硬件決定玖姑,或硬件支持多種大小的頁愕秫,由操作系統(tǒng)選擇決定頁的大小。
虛擬內(nèi)存的實(shí)現(xiàn)需要依靠硬件的支持焰络,對于不同的CPU來說是不同的戴甩,但是幾乎所有的硬件都采用了一個(gè)MMU(Memory Management Unit)的部件來進(jìn)行頁映射,流程如:CPU->Virtual Address->MMU->Physical Address->Physical Memory
闪彼,一般MMU都集成在CPU內(nèi)部了甜孤,不會(huì)以單獨(dú)的部件存在。
讀到這的時(shí)候畏腕,我想起了看過的一片博客:alloc缴川、init你弄懂50%了嗎?
分頁的思想,很像字節(jié)對齊郊尝,apple的文檔Tips for Allocating Memory是這樣描述的:
When allocating any small blocks of memory, remember that the granularity for blocks allocated by the malloc library is 16 bytes. Thus, the smallest block of memory you can allocate is 16 bytes and any blocks larger than that are a multiple of 16. For example, if you call malloc and ask for 4 bytes, it returns a block whose size is 16 bytes; if you request 24 bytes, it returns a block whose size is 32 bytes. Because of this granularity, you should design your data structures carefully and try to make them multiples of 16 bytes whenever possible.
意思就是:
當(dāng)我們分配一塊內(nèi)存的時(shí)候二跋,假設(shè)需要的內(nèi)存小于16個(gè)字節(jié),操作系統(tǒng)會(huì)直接分配16個(gè)字節(jié)流昏;加入需要的內(nèi)存大于16個(gè)字節(jié)扎即,操作系統(tǒng)會(huì)分配a*16個(gè)字節(jié)。舉個(gè)栗子况凉,如果你調(diào)用malloc并且需要4個(gè)字節(jié)谚鄙,系統(tǒng)會(huì)給你一塊16個(gè)字節(jié)的內(nèi)存塊;如果你調(diào)用malloc并且需要24個(gè)字節(jié)刁绒,系統(tǒng)會(huì)給你一塊32個(gè)字節(jié)的內(nèi)存塊闷营。
第一章中還講了一些線程的基礎(chǔ)知識。什么是線程知市?
線程(Thread)傻盟,有時(shí)被稱為輕量級進(jìn)程(Lightweight Process,LWP),是程序執(zhí)行流程的最小單元嫂丙。
進(jìn)程內(nèi)的線程如圖:
多個(gè)線程可以互相不干擾地并并發(fā)執(zhí)行娘赴,并共享進(jìn)程的全局變量和堆的數(shù)據(jù),使用多線程的原因有以下幾點(diǎn):
- 某個(gè)操作可能會(huì)陷入長時(shí)間等待跟啤,等待線程會(huì)進(jìn)入睡眠狀態(tài)诽表,無法繼續(xù)執(zhí)行唉锌。多線程執(zhí)行可以有效利用等待的時(shí)間。典型的例子是等待網(wǎng)絡(luò)響應(yīng)竿奏,這可能要花費(fèi)數(shù)秒甚至數(shù)十秒袄简。
- 某個(gè)操作(常常是計(jì)算)會(huì)消耗大量時(shí)間,如果只有一個(gè)線程泛啸,程序和用戶之間的交互會(huì)中斷绿语。多線程可以讓一個(gè)線程負(fù)責(zé)交互,另一個(gè)線程負(fù)責(zé)計(jì)算候址。
- 程序邏輯本身就要求并發(fā)操作汞舱,例如一個(gè)多端下載軟件。
- 多CPU或多核計(jì)算機(jī)宗雇,本身具備同時(shí)執(zhí)行多個(gè)線程的能力,因此單線程程序無法全面的發(fā)揮計(jì)算機(jī)的全部能力莹规。
- 相對應(yīng)多進(jìn)程應(yīng)用赔蒲,多線程在數(shù)據(jù)共享方面效率要高很多。
線程的訪問權(quán)限
線程的訪問非常自由良漱,它可以訪問進(jìn)程內(nèi)存里的所有數(shù)據(jù),甚至包括其他線程的堆棧,但是實(shí)際運(yùn)用中冗疮,線程也擁有自己的私有存儲(chǔ)空間托嚣,包括以下幾個(gè)方面:
- 棧(盡管并非完全無法被其他線程訪問,但一般情況下仍然可以認(rèn)為是私有的數(shù)據(jù))
- 線程局部存儲(chǔ)(Thread Local Stroage,TLS)患久。線程局部存儲(chǔ)是某些操作系統(tǒng)為線程單獨(dú)提供的私有空間椅寺,但通常只具有有限的容量。
- 寄存器(包括PC寄存器)蒋失,寄存器是執(zhí)行流的基本數(shù)據(jù)返帕,因此為此線程所有。

線程的調(diào)度與優(yōu)先級
還是先說明一下并行和并發(fā)的區(qū)別
總線程數(shù) <= CPU數(shù)量:并行運(yùn)行
總線程數(shù) > CPU數(shù)量:并發(fā)運(yùn)行
并發(fā)是一種模擬出來的狀態(tài)篙挽,操作系統(tǒng)會(huì)讓這些多線程程序輪流執(zhí)行荆萤,這的一個(gè)不斷在處理器上切換不同的線程的行為稱之為線程調(diào)度(Thread Schedule),在線程調(diào)度中铣卡,線程通常擁有至少三種狀態(tài)链韭,分別是:
- 運(yùn)行(Running):此時(shí)線程正在執(zhí)行;
- 就緒(Ready):此時(shí)線程可以立刻執(zhí)行煮落,但CPU已經(jīng)被占用敞峭。
- 等待(Waiting):此時(shí)線程正在等待某一事件(通常是I/O或同步)發(fā)生,無法執(zhí)行州邢。
處于運(yùn)行中的線程擁有一段可以執(zhí)行的時(shí)間儡陨,這段時(shí)間稱之為時(shí)間片(Time Slice)褪子。

線程調(diào)度的方式,主要是以下兩種:
- 優(yōu)先級調(diào)度(Priority Schedule)
- 輪轉(zhuǎn)法(Round Robin)
線程的優(yōu)先級改變一般有三種情況:
- 用戶指定優(yōu)先級
- 根據(jù)進(jìn)入等待狀態(tài)的頻繁程度提升或降低優(yōu)先級
- 長時(shí)間得不到執(zhí)行而被提升優(yōu)先級
線程在用盡時(shí)間片之后會(huì)被強(qiáng)制剝奪繼續(xù)執(zhí)行的權(quán)利骗村,而進(jìn)入就緒狀態(tài)嫌褪,這個(gè)過程叫做搶占(Preemption),即之后執(zhí)行別的線程搶占了當(dāng)前線程胚股。
線程安全
我們把單指令的操作稱之為原子的笼痛,因?yàn)闊o論如何,單條指令的執(zhí)行是不會(huì)被打斷了琅拌。
為了避免多個(gè)線程同時(shí)讀寫一個(gè)數(shù)據(jù)而產(chǎn)生不可預(yù)料的后果缨伊,我們需要將各個(gè)線程對同一個(gè)數(shù)據(jù)的訪問同步(Synchronization)。所謂同步进宝,既是指在一個(gè)線程訪問數(shù)據(jù)未結(jié)束的時(shí)候刻坊,其他線程不得對同一個(gè)數(shù)據(jù)進(jìn)行訪問。如此党晋,對數(shù)據(jù)的訪問被原子化了谭胚。
同步的最常見方法是使用鎖(Lock)。鎖是一種非強(qiáng)制機(jī)制未玻。每一個(gè)線程在訪問數(shù)據(jù)或資源之前首先試圖獲仍侄(Acqurie),并在訪問結(jié)束之后釋放(Release)鎖扳剿。在鎖已經(jīng)被占用的時(shí)候試圖獲取鎖時(shí)旁趟,線程會(huì)等待,直到鎖重新可用庇绽。
這里總結(jié)下 iOS 中常用的幾種鎖:
-
@synchronized
NSObject *obj = [[NSObject alloc] init]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ @synchronized(obj) { NSLog(@"需要線程同步的操作1 開始"); sleep(3); NSLog(@"需要線程同步的操作1 結(jié)束"); } }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ sleep(1); @synchronized(obj) { NSLog(@"需要線程同步的操作2"); } });
@synchronized(obj)指令使用的obj為該鎖的唯一標(biāo)識锡搜,只有當(dāng)標(biāo)識相同時(shí),才為滿足互斥敛劝,如果線程2中的@synchronized(obj)改為@synchronized(self),剛線程2就不會(huì)被阻塞余爆,@synchronized指令實(shí)現(xiàn)鎖的優(yōu)點(diǎn)就是我們不需要在代碼中顯式的創(chuàng)建鎖對象,便可以實(shí)現(xiàn)鎖的機(jī)制夸盟,但作為一種預(yù)防措施蛾方,@synchronized塊會(huì)隱式的添加一個(gè)異常處理例程來保護(hù)代碼,該處理例程會(huì)在異常拋出的時(shí)候自動(dòng)的釋放互斥鎖上陕。所以如果不想讓隱式的異常處理例程帶來額外的開銷桩砰,你可以考慮使用鎖對象。
上面結(jié)果的執(zhí)行結(jié)果為:
需要線程同步的操作1 開始 需要線程同步的操作1 結(jié)束 需要線程同步的操作2
-
NSLock
NSLock *lock = [[NSLock alloc] init]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ //[lock lock]; [lock lockBeforeDate:[NSDate date]]; NSLog(@"需要線程同步的操作1 開始"); sleep(2); NSLog(@"需要線程同步的操作1 結(jié)束"); [lock unlock]; }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ sleep(1); if ([lock tryLock]) {//嘗試獲取鎖释簿,如果獲取不到返回NO亚隅,不會(huì)阻塞該線程 NSLog(@"鎖可用的操作"); [lock unlock]; }else{ NSLog(@"鎖不可用的操作"); } NSDate *date = [[NSDate alloc] initWithTimeIntervalSinceNow:3]; if ([lock lockBeforeDate:date]) {//嘗試在未來的3s內(nèi)獲取鎖,并阻塞該線程庶溶,如果3s內(nèi)獲取不到恢復(fù)線程, 返回NO,不會(huì)阻塞該線程 NSLog(@"沒有超時(shí)煮纵,獲得鎖"); [lock unlock]; }else{ NSLog(@"超時(shí)懂鸵,沒有獲得鎖"); } });
NSLock是Cocoa提供給我們最基本的鎖對象,這也是我們經(jīng)常所使用的行疏,除lock和unlock方法外匆光,NSLock還提供了tryLock和lockBeforeDate:兩個(gè)方法,前一個(gè)方法會(huì)嘗試加鎖酿联,如果鎖不可用(已經(jīng)被鎖住)终息,剛并不會(huì)阻塞線程,并返回NO贞让。lockBeforeDate:方法會(huì)在所指定Date之前嘗試加鎖周崭,如果在指定時(shí)間之前都不能加鎖,則返回NO喳张。
上面代碼的執(zhí)行結(jié)果為:需要線程同步的操作1 開始 鎖不可用的操作 需要線程同步的操作1 結(jié)束 沒有超時(shí)续镇,獲得鎖
-
NSRecursiveLock 遞歸鎖
//NSLock *lock = [[NSLock alloc] init]; NSRecursiveLock *lock = [[NSRecursiveLock alloc] init]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ static void (^RecursiveMethod)(int); RecursiveMethod = ^(int value) { [lock lock]; if (value > 0) { NSLog(@"value = %d", value); sleep(1); RecursiveMethod(value - 1); } [lock unlock]; }; RecursiveMethod(5); });
NSRecursiveLock實(shí)際上定義的是一個(gè)遞歸鎖,這個(gè)鎖可以被同一線程多次請求销部,而不會(huì)引起死鎖磨取。這主要是用在循環(huán)或遞歸操作中。
這段代碼是一個(gè)典型的死鎖情況柴墩。在我們的線程中,RecursiveMethod是遞歸調(diào)用的凫岖。所以每次進(jìn)入這個(gè)block時(shí)江咳,都會(huì)去加一次鎖,而從第二次開始哥放,由于鎖已經(jīng)被使用了且沒有解鎖歼指,所以它需要等待鎖被解除,這樣就導(dǎo)致了死鎖甥雕,線程被阻塞住了踩身。調(diào)試器中會(huì)輸出如下信息:
value = 5 -[NSLock lock]: deadlock (<NSLock: 0x7fd811d28810> '(null)') Break on _NSLockError() to debug.
在這種情況下,我們就可以使用NSRecursiveLock社露。它可以允許同一線程多次加鎖挟阻,而不會(huì)造成死鎖。遞歸鎖會(huì)跟蹤它被lock的次數(shù)峭弟。每次成功的lock都必須平衡調(diào)用unlock操作附鸽。只有所有達(dá)到這種平衡,鎖最后才能被釋放瞒瘸,以供其它線程使用坷备。
如果我們將NSLock代替為NSRecursiveLock,上面代碼則會(huì)正確執(zhí)行情臭。
value = 5 value = 4 value = 3 value = 2 value = 1
-
NSConditionLock 條件鎖
NSMutableArray *products = [NSMutableArray array]; NSInteger HAS_DATA = 1; NSInteger NO_DATA = 0; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ while (1) { [lock lockWhenCondition:NO_DATA]; [products addObject:[[NSObject alloc] init]]; NSLog(@"produce a product,總量:%zi",products.count); [lock unlockWithCondition:HAS_DATA]; sleep(1); } }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ while (1) { NSLog(@"wait for product"); [lock lockWhenCondition:HAS_DATA]; [products removeObjectAtIndex:0]; NSLog(@"custome a product"); [lock unlockWithCondition:NO_DATA]; } });
當(dāng)我們在使用多線程的時(shí)候省撑,有時(shí)一把只會(huì)lock和unlock的鎖未必就能完全滿足我們的使用赌蔑。因?yàn)槠胀ǖ逆i只能關(guān)心鎖與不鎖,而不在乎用什么鑰匙才能開鎖竟秫,而我們在處理資源共享的時(shí)候娃惯,多數(shù)情況是只有滿足一定條件的情況下才能打開這把鎖:
在線程1中的加鎖使用了lock,所以是不需要條件的鸿摇,所以順利的就鎖住了石景,但在unlock的使用了一個(gè)整型的條件,它可以開啟其它線程中正在等待這把鑰匙的臨界地拙吉,而線程2則需要一把被標(biāo)識為2的鑰匙潮孽,所以當(dāng)線程1循環(huán)到最后一次的時(shí)候,才最終打開了線程2中的阻塞筷黔。但即便如此往史,NSConditionLock也跟其它的鎖一樣,是需要lock與unlock對應(yīng)的佛舱,只是lock,lockWhenCondition:與unlock椎例,unlockWithCondition:是可以隨意組合的,當(dāng)然這是與你的需求相關(guān)的请祖。
上面代碼執(zhí)行結(jié)果如下:
wait for product produce a product,總量:1 custome a product wait for product produce a product,總量:1 custome a product wait for product produce a product,總量:1 custome a product
-
NSCondition
NSCondition *condition = [[NSCondition alloc] init]; NSMutableArray *products = [NSMutableArray array]; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ while (1) { [condition lock]; if ([products count] == 0) { NSLog(@"wait for product"); [condition wait]; } [products removeObjectAtIndex:0]; NSLog(@"custome a product"); [condition unlock]; } }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ while (1) { [condition lock]; [products addObject:[[NSObject alloc] init]]; NSLog(@"produce a product,總量:%zi",products.count); [condition signal]; [condition unlock]; sleep(1); } });
一種最基本的條件鎖订歪。手動(dòng)控制線程wait和signal。
[condition lock];一般用于多線程同時(shí)訪問肆捕、修改同一個(gè)數(shù)據(jù)源刷晋,保證在同一時(shí)間內(nèi)數(shù)據(jù)源只被訪問、修改一次慎陵,其他線程的命令需要在lock 外等待眼虱,只到unlock ,才可訪問
[condition unlock];與lock 同時(shí)使用
[condition wait];讓當(dāng)前線程處于等待狀態(tài)
condition signal];CPU發(fā)信號告訴線程不用在等待席纽,可以繼續(xù)執(zhí)行
上面代碼執(zhí)行結(jié)果如下:
wait for product produce a product,總量:1 custome a product wait for product produce a product,總量:1 custome a product wait for product produce a product,總量:1 custome a product
-
pthread_mutex
__block pthread_mutex_t theLock; pthread_mutex_init(&theLock, NULL); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ pthread_mutex_lock(&theLock); NSLog(@"需要線程同步的操作1 開始"); sleep(3); NSLog(@"需要線程同步的操作1 結(jié)束"); pthread_mutex_unlock(&theLock); }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ sleep(1); pthread_mutex_lock(&theLock); NSLog(@"需要線程同步的操作2"); pthread_mutex_unlock(&theLock); });
c語言定義下多線程加鎖方式捏悬。
- pthread_mutex_init(pthread_mutex_t mutex,const pthread_mutexattr_t attr);
初始化鎖變量mutex。attr為鎖屬性润梯,NULL值為默認(rèn)屬性过牙。 - pthread_mutex_lock(pthread_mutex_t mutex);加鎖
- pthread_mutex_tylock(*pthread_mutex_t *mutex);加鎖,但是與2不一樣的是當(dāng)鎖已經(jīng)在使用的時(shí)候纺铭,返回為EBUSY抒和,而不是掛起等待。
- pthread_mutex_unlock(pthread_mutex_t *mutex);釋放鎖
- pthread_mutex_destroy(pthread_mutex_t* mutex);使用完后釋放
代碼執(zhí)行操作結(jié)果如下:
需要線程同步的操作1 開始 需要線程同步的操作1 結(jié)束 需要線程同步的操作2
- pthread_mutex_init(pthread_mutex_t mutex,const pthread_mutexattr_t attr);
-
pthread_mutex(recursive)
__block pthread_mutex_t theLock; //pthread_mutex_init(&theLock, NULL); pthread_mutexattr_t attr; pthread_mutexattr_init(&attr); pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE); pthread_mutex_init(&lock, &attr); pthread_mutexattr_destroy(&attr); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ static void (^RecursiveMethod)(int); RecursiveMethod = ^(int value) { pthread_mutex_lock(&theLock); if (value > 0) { NSLog(@"value = %d", value); sleep(1); RecursiveMethod(value - 1); } pthread_mutex_unlock(&theLock); }; RecursiveMethod(5); });
這是pthread_mutex為了防止在遞歸的情況下出現(xiàn)死鎖而出現(xiàn)的遞歸鎖彤蔽。作用和NSRecursiveLock遞歸鎖類似摧莽。
如果使用pthread_mutex_init(&theLock, NULL);初始化鎖的話,上面的代碼會(huì)出現(xiàn)死鎖現(xiàn)象顿痪。如果使用遞歸鎖的形式镊辕,則沒有問題油够。
-
OSSpinLock
__block OSSpinLock theLock = OS_SPINLOCK_INIT; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ OSSpinLockLock(&theLock); NSLog(@"需要線程同步的操作1 開始"); sleep(3); NSLog(@"需要線程同步的操作1 結(jié)束"); OSSpinLockUnlock(&theLock); }); dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ OSSpinLockLock(&theLock); sleep(1); NSLog(@"需要線程同步的操作2"); OSSpinLockUnlock(&theLock); });
OSSpinLock 自旋鎖,性能最高的鎖征懈。原理很簡單石咬,就是一直 do while 忙等。它的缺點(diǎn)是當(dāng)?shù)却龝r(shí)會(huì)消耗大量 CPU 資源卖哎,所以它不適用于較長時(shí)間的任務(wù)鬼悠。 不過YY在自己的博客不再安全的 OSSpinLock 中說明了OSSpinLock已經(jīng)不再安全。
關(guān)于同步亏娜,還有一種二元信號量(Binary Semaphore)是最簡單的一種鎖焕窝,它只有兩種狀態(tài),占用與非占用维贺。它適合只能被唯一一個(gè)線程單獨(dú)訪問的資源它掂。當(dāng)二元信號量處于非占用狀態(tài)時(shí),第一個(gè)試圖獲取該二元信號量的線程會(huì)獲得該鎖溯泣,并將二元信號量置為占用狀態(tài)虐秋,此后其他所有試圖獲取該二元信號量的線程將會(huì)等待,指導(dǎo)改鎖被釋放垃沦。
對于允許多個(gè)線程并發(fā)訪問的資源客给,多元信號量簡稱信號量(Semaphore),它是一個(gè)很好的選擇肢簿。一個(gè)初始值為N的信號量允許N個(gè)線程并發(fā)訪問起愈。線程訪問資源的時(shí)候首先獲取信號量,進(jìn)行如下操作:
- 將信號量的值減1
- 如果信號量的值小于0译仗,則進(jìn)入等待狀態(tài),否則繼續(xù)執(zhí)行官觅。
訪問完資源之后纵菌,線程釋放信號量,進(jìn)行如下操作:
- 將信號量的值加1
- 如果信號量的值小于1休涤,喚醒一個(gè)等待中的線程咱圆。
iOS 中信號量的相關(guān)用法為 dispatch_semaphore
dispatch_semaphore_t signal = dispatch_semaphore_create(1);
dispatch_time_t overTime = dispatch_time(DISPATCH_TIME_NOW, 3 * NSEC_PER_SEC);
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
dispatch_semaphore_wait(signal, overTime);
NSLog(@"需要線程同步的操作1 開始");
sleep(2);
NSLog(@"需要線程同步的操作1 結(jié)束");
dispatch_semaphore_signal(signal);
});
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
sleep(1);
dispatch_semaphore_wait(signal, overTime);
NSLog(@"需要線程同步的操作2");
dispatch_semaphore_signal(signal);
});
dispatch_semaphore是GCD用來同步的一種方式,與他相關(guān)的共有三個(gè)函數(shù)功氨,分別是dispatch_semaphore_create序苏,dispatch_semaphore_signal,dispatch_semaphore_wait捷凄。
-
dispatch_semaphore_create
dispatch_semaphore_t dispatch_semaphore_create(long value);
傳入的參數(shù)為long忱详,輸出一個(gè)dispatch_semaphore_t類型且值為value的信號量。
值得注意的是跺涤,這里的傳入的參數(shù)value必須大于或等于0匈睁,否則dispatch_semaphore_create會(huì)返回NULL监透。
```
-
dispatch_semaphore_signal
long dispatch_semaphore_signal(dispatch_semaphore_t dsema)
這個(gè)函數(shù)會(huì)使傳入的信號量dsema的值加1;
```
-
dispatch_semaphore_wait
long dispatch_semaphore_wait(dispatch_semaphore_t dsema, dispatch_time_t timeout)航唆; 這個(gè)函數(shù)會(huì)使傳入的信號量dsema的值減1胀蛮;這個(gè)函數(shù)的作用是這樣的,如果dsema信號量的值大于0糯钙,該函數(shù)所處線程就繼續(xù)執(zhí)行下面的語句粪狼,并且將信號量的值減1;如果desema的值為0任岸,那么這個(gè)函數(shù)就阻塞當(dāng)前線程等待timeout(注意timeout的類型為dispatch_time_t再榄,不能直接傳入整形或float型數(shù)),如果等待的期間desema的值被dispatch_semaphore_signal函數(shù)加1了演闭,且該函數(shù)(即dispatch_semaphore_wait)所處線程獲得了信號量不跟,那么就繼續(xù)向下執(zhí)行并將信號量減1。如果等待期間沒有獲取到信號量或者信號量的值一直為0米碰,那么等到timeout時(shí)窝革,其所處線程自動(dòng)執(zhí)行其后語句。
dispatch_semaphore 是信號量吕座,但當(dāng)信號總量設(shè)為 1 時(shí)也可以當(dāng)作鎖來虐译。在沒有等待情況出現(xiàn)時(shí),它的性能比 pthread_mutex 還要高吴趴,但一旦有等待情況出現(xiàn)時(shí)漆诽,性能就會(huì)下降許多。相對于 OSSpinLock 來說锣枝,它的優(yōu)勢在于等待時(shí)不會(huì)消耗 CPU 資源厢拭。
如上的代碼,如果超時(shí)時(shí)間overTime設(shè)置成>2撇叁,可完成同步操作供鸠。如果overTime<2的話,在線程1還沒有執(zhí)行完成的情況下陨闹,此時(shí)超時(shí)了楞捂,將自動(dòng)執(zhí)行下面的代碼。
上面代碼的執(zhí)行結(jié)果為:
需要線程同步的操作1 開始
需要線程同步的操作1 結(jié)束
需要線程同步的操作2
如果把超時(shí)時(shí)間設(shè)置為<2s的時(shí)候趋厉,執(zhí)行的結(jié)果就是:
需要線程同步的操作1 開始
需要線程同步的操作2
需要線程同步的操作1 結(jié)束
YY 關(guān)于這幾種鎖的性能測試(定性分析)結(jié)果如下圖:
多線程內(nèi)部情況
線程的并發(fā)執(zhí)行是由多處理器或操作系統(tǒng)調(diào)度來實(shí)現(xiàn)的寨闹。但實(shí)際情況要更為復(fù)雜一些:大多數(shù)操作系統(tǒng),包括windows和Linux君账,都在內(nèi)核里提供線程的支持繁堡,內(nèi)核線程也是由多處理器或調(diào)度來實(shí)現(xiàn)并發(fā)。然后用戶實(shí)際使用的線程并不是內(nèi)核線程,而是存在于用戶態(tài)的用戶線程帖蔓。用戶線程并不一定在操作系統(tǒng)內(nèi)核里對應(yīng)同等數(shù)量的內(nèi)核線程伟姐,例如某些輕量級的線程庫病线,對用戶來說如果有三個(gè)線程同時(shí)在執(zhí)行,對內(nèi)核來說很可能只有一個(gè)線程。
用戶態(tài)和內(nèi)核態(tài)的三種線程模型如下:
-
一對一模型

這樣用戶線程就具有了和內(nèi)核線程一致的優(yōu)點(diǎn)菩鲜,線程之間的并發(fā)是真正的并發(fā)惭蹂,一個(gè)線程因?yàn)槟撤N原因阻塞時(shí)葵礼,其他線程執(zhí)行不會(huì)受到影響萨醒。一般直接使用API或系統(tǒng)創(chuàng)建的線程均為一對一的線程。
一對一線程有兩個(gè)缺點(diǎn):
- 由于許多操作系統(tǒng)限制了內(nèi)核線程的數(shù)量写妥,因此一對一線程會(huì)讓用戶的線程數(shù)量受到限制拳球。
- 許多操作系統(tǒng)內(nèi)核線程調(diào)度時(shí),上下文切換的開銷較大珍特,導(dǎo)致用戶線程的執(zhí)行效率下降祝峻。
多對一模型

多對一模型將多個(gè)用戶線程映射到一個(gè)內(nèi)核線程上,線程之間的切換由用戶態(tài)的代碼來進(jìn)行扎筒,因此相對于一對一模型莱找,多對一模型的線程切換要快許多。
多對一模型的一大問題是嗜桌,如果其中一個(gè)用戶線程阻塞奥溺,那么所有的線程都將無法執(zhí)行,因?yàn)榇藭r(shí)內(nèi)核里的線程也隨之阻塞了骨宠。另外浮定,在多處理器系統(tǒng)上,處理器的增多對多對一模型的線程性能也不會(huì)有明顯的幫助层亿。但同時(shí)桦卒,多對一模型得到的好處是高效的上下文切換和幾乎無限制的線程數(shù)量。多對多模型

多對多模型結(jié)合了多對一模型和一對一模型的特點(diǎn)匿又,將多個(gè)用戶線程映射到少數(shù)但不止一個(gè)內(nèi)核線程上方灾。在多對多模型中,一個(gè)用戶線程阻塞并不會(huì)使得所有用戶線程阻塞琳省,因?yàn)榇藭r(shí)還有別的線程可以被調(diào)度來執(zhí)行。另外躲撰,多對多模型對用戶線程的數(shù)量也沒什么限制针贬,在多處理器其他上,多對多模型的線程也能得到一定性能的提升拢蛋,不過提升的幅度不如一對一模型高桦他。
編譯和鏈接
對于平常的應(yīng)用開發(fā),我們很少關(guān)注編譯和鏈接的過程,因?yàn)橥ǔ5拈_發(fā)環(huán)境都是流程的集成開發(fā)環(huán)境(IDE)快压。IDE 一般都將編譯和鏈接的過程一步執(zhí)行完成圆仔,通常這種編譯和鏈接合并到一起的過程稱為構(gòu)建(Build) 即使使用命令行來編譯一個(gè)源碼文件,簡單的一句"gcc hello.c"命令就包含了非常復(fù)雜的過程蔫劣。
IDE和編譯器提供的默認(rèn)配置坪郭、編譯和鏈接參數(shù)對于大部分的應(yīng)用程序開發(fā)而言已經(jīng)足夠使用了。但是在這樣的開發(fā)過程中脉幢,我們往往會(huì)被這些復(fù)雜的集成工具提供強(qiáng)大的功能所迷惑歪沃,很多系統(tǒng)軟件的運(yùn)行機(jī)制與機(jī)理被掩蓋,其程序的很多莫名其妙的錯(cuò)誤讓我們無所適從嫌松,而對程序運(yùn)行時(shí)種種性能瓶頸我們素手無策沪曙。如果能夠深入了解這些機(jī)制,那么解決這些問題就能夠游刃有余萎羔,收放自如了液走。
#include "hello.h"
int main()
{
printf("Hello World\n");
return 0;
}
在Linux下,我們使用GCC來編譯程序時(shí)贾陷,只須使用最簡單的命令
gcc hello.c
./a.out
Hello World
上述過程可以分解為4步:
- 預(yù)處理(Prepressing)
- 編譯(Compilation)
- 匯編(Assembly)
- 鏈接(Linking)

預(yù)編譯
預(yù)編譯過程主要處理那些源碼文件中以"#"開頭的預(yù)編譯指令缘眶。主要規(guī)則如下:
- 將所有的 "#define" 刪除,并且展開所有的宏定義昵宇。
- 處理所有條件預(yù)編譯指令磅崭,比如:"#if","#ifdef","elif","#else","#endif"。
- 處理"#include"預(yù)編譯指令瓦哎,將被包含的文件插入到改預(yù)編譯指令的位置砸喻。注意,這個(gè)過程是遞歸進(jìn)行的蒋譬,也就是說被包含的文件可能還包含其他文件割岛。
- 刪除所有的注釋 "http://" 和 "/**/"。
- 添加行號和文件標(biāo)識犯助,比如 #2"helloc.c"2癣漆,以便于編譯時(shí)編譯器產(chǎn)生試用的行號信息及用于編譯時(shí)產(chǎn)生便于錯(cuò)誤或警告時(shí)能夠顯示的行號。
- 保留所有的 #pargma 編譯指令剂买,因?yàn)榫幾g器需要使用它們惠爽。
經(jīng)過預(yù)編譯后的 .i 文件不包含任何宏定義,因?yàn)樗械暮暌呀?jīng)被展開瞬哼,并且包含的文件也已經(jīng)被插入到 .i 文件中婚肆。所以當(dāng)我們無法判斷宏定義是否正確或頭文件包含是否正確時(shí),可以查看預(yù)編譯后的文件來確定問題坐慰。
第一步預(yù)編譯的過程相當(dāng)于如下命令:
gcc -E hello.c -o hello.i
編譯
編譯過程就是把預(yù)處理的文件進(jìn)行一系列詞法分析较性、語法分析、語義分析以及優(yōu)化后生產(chǎn)相應(yīng)的匯編代碼文件,這個(gè)過程往往是我們所說的整個(gè)程序構(gòu)建的核心部分赞咙,也是最復(fù)雜的部分之一责循。上面的編譯過程相當(dāng)于如下命令:
gcc -S hello.i -o hello.s
最直觀的角度,編譯器就是將高級語言翻譯成機(jī)器語言的一個(gè)工具攀操。高級語言使得程序員能夠更加關(guān)注程序邏輯的本身院仿,而盡量少考慮計(jì)算機(jī)本身的限制。高級編程語言的出現(xiàn)使得程序開發(fā)的效率大大提高崔赌,據(jù)研究意蛀,高級語言的開發(fā)效率是匯編語言和機(jī)器語言的5倍以上。
編譯過程一般可以分為6步:掃描健芭,語法分析县钥,語義分析,源代碼優(yōu)化慈迈,代碼生成和目標(biāo)代碼優(yōu)化若贮,整個(gè)過程如下:

匯編
匯編器是將匯編代碼轉(zhuǎn)變成機(jī)器可以執(zhí)行的指令,每一個(gè)匯編語句幾乎都對應(yīng)一條機(jī)器指令痒留。所以匯編器的匯編過程相對于編譯器來講是比較簡單谴麦,它沒有復(fù)雜的語法,也沒用語法伸头,也沒有語義匾效,也不需要做指令優(yōu)化,只是根據(jù)匯編指令和機(jī)器指令的對照表一一翻譯就可以了恤磷,“匯編”這個(gè)名字也源于此面哼。
上面的匯編過程可以調(diào)用匯編器as來完成:
as hello.s -o hello.o
或者
gcc -c hello.s -o hello.o
鏈接
鏈接通常是一個(gè)讓人比較費(fèi)解的過程,為什么匯編器不直接輸出可執(zhí)行文件而是輸出一個(gè)目標(biāo)文件呢扫步?鏈接過程到底包含了什么內(nèi)容魔策?為什么要鏈接?
書中簡單的回顧了下計(jì)算機(jī)發(fā)展的歷史河胎,簡單來講就是隨之軟件規(guī)模越來越大闯袒,每個(gè)程序被分成了多個(gè)模塊,這些模塊的拼接過程就叫:鏈接(Linking)游岳。
鏈接過程主要包括了:
- 地址和空間的分配(Address and Storage Alloction)
- 符號決議(Symbol Resolution)Ps:"決議"更傾向于靜態(tài)鏈接政敢,而"綁定"更傾向于動(dòng)態(tài)鏈接。
- 重定位(Relocation)
最基本的靜態(tài)鏈接過程如下圖:

每個(gè)模塊的源代碼文件(如.c)文件經(jīng)過編譯器編譯成目標(biāo)文件(Object File胚迫,一般擴(kuò)展名為.o 或.obj)喷户,目標(biāo)文件和庫一起鏈接形成最終的可執(zhí)行文件。
重定位的過程如下:
假設(shè)有個(gè)全局變量叫做var晌区,它在目標(biāo)文件A里面摩骨。我們在目標(biāo)文件B里面要訪問這個(gè)全局變量。由于在編譯目標(biāo)文件B的時(shí)候朗若,編譯器并不知道變量var的目標(biāo)地址恼五,所以編譯器在沒法確定的情況下,將目標(biāo)地址設(shè)置為0哭懈,等待鏈接器在目標(biāo)文件A和B連接起來的時(shí)候?qū)⑵湫拚致_@個(gè)地址修正的過程被叫做重定位,每個(gè)被修正的地方叫一個(gè)重定位入口遣总。
目標(biāo)文件
編譯器編譯源代碼后生成的文件叫做目標(biāo)文件睬罗。
現(xiàn)在PC平臺(tái)流行的可執(zhí)行文件(Executable)主要是Windows下的PE(Portable Executable)和Linux的ELF(Executable Linkabel Format),它們都是COFF(Common file format)格式的變種。
目標(biāo)文件就是源代碼編譯后但未進(jìn)行鏈接的那些中間文件(Widdows的.obj和Linux下的.o)旭斥。
從廣義上看容达,目標(biāo)文件與可執(zhí)行文件的格式其實(shí)幾乎一樣,所以我們可以統(tǒng)稱他們?yōu)镻E-COFF文件格式垂券。在Linux下花盐,我們可以將他們統(tǒng)稱為ELF文件。
動(dòng)態(tài)鏈接庫(DLL,Dynamic Linking Library)(Windows的.dll和Linux的.so)以及靜態(tài)鏈接庫(Static Linking Library)(Windows的.lib 和Linux的.a)文件都按照可執(zhí)行文件格式存儲(chǔ)菇爪。
靜態(tài)鏈接庫稍有不同算芯,它是把很多目標(biāo)文件的文件捆綁在一起形成一個(gè)文件,再加上一些索引凳宙,簡單的理解:一個(gè)包含有很多目標(biāo)文件的文件包熙揍。
ELF 文件類型 | 說明 | 實(shí)例 |
---|---|---|
可重定位文件(Relocation File) | 這類文件包含了代碼和數(shù)據(jù),可以被用來鏈接成可執(zhí)行文件或共享目標(biāo)文件氏涩,靜態(tài)鏈接庫也可以歸為這一類 | Linux的.o Windows的.obj |
可執(zhí)行文件(Executable File) | 這類文件包含了可以直接執(zhí)行的程序届囚,它的代表就是ELF可執(zhí)行文件,它們一般都是沒有擴(kuò)展名 | 比如/bin/bash 文件 Windows 的.exe |
共享目標(biāo)文件(Shared Object File) | 這種文件包含了代碼和數(shù)據(jù)削葱,可以在以下兩種情況下使用奖亚。一種是鏈接器可以使用這種文件跟其他可重定位和共享目標(biāo)文件鏈接,產(chǎn)生新的目標(biāo)文件析砸。第二種是動(dòng)態(tài)鏈接器可以將幾個(gè)這種共享目標(biāo)文件與可執(zhí)行文件結(jié)合昔字,作為進(jìn)程映像的一部分來運(yùn)行 | Linux的.so 如/lib/glibc-2.5.so Windows的DLL |
核心轉(zhuǎn)存文件(Core Dump File) | 當(dāng)進(jìn)程意外終止時(shí),系統(tǒng)可以將該進(jìn)程的地址空間的內(nèi)容以及終止時(shí)的一些其他信息轉(zhuǎn)儲(chǔ)到核心轉(zhuǎn)儲(chǔ)文件 | Linux下的 core dump |
目標(biāo)文件什么樣子
這種類型的圖在講block或者其他內(nèi)存問題時(shí)經(jīng)常能看到的首繁。
程序源代碼編譯后的機(jī)器指令經(jīng)常被放在代碼段(Code Section)作郭,代碼段常見的名字有".code",或".text"弦疮;全局和局部靜態(tài)變量數(shù)據(jù)經(jīng)常被放在數(shù)據(jù)段(Data Section)夹攒,數(shù)據(jù)段的一般名字都叫".data"。
看一個(gè)簡單的程序被編譯成目標(biāo)文件后的結(jié)構(gòu)胁塞。

其他段大家一看就明白咏尝。未初始化的全局變量和局部靜態(tài)變量一般被放在一個(gè)叫".bss"的段里(BSS - Block Started by Symbol)压语。未初始化的全局變量和局部靜態(tài)變量默認(rèn)值都為0,本來它們也可以被放在.data端里编检,但是因?yàn)樗鼈兌际?胎食,所有為它們在.data端分開空間并且存儲(chǔ)數(shù)據(jù)0是沒有必要的。程序運(yùn)行的時(shí)候它們的確是要站存儲(chǔ)空間的允懂,并且可執(zhí)行文件必須記錄所有未初始化的全局變量和局部靜態(tài)變量的大小總和厕怜,記為.bss段。所以.bss段只是未初始化的全局變量和局部靜態(tài)變量預(yù)留位置而已蕾总,它并沒有內(nèi)容粥航,所以它在文件中也不占據(jù)空間。
總體來說生百,程序源代被編譯以后主要分成兩種段:程序指令和程序數(shù)據(jù)递雀。代碼段屬于程序指令,而數(shù)據(jù)端和.bss段屬于程序數(shù)據(jù)蚀浆。
為什么要把程序的指令和數(shù)據(jù)的存放分開映之?
當(dāng)程序被裝載后,數(shù)據(jù)和指令分別被映射到了兩個(gè)虛存區(qū)域蜡坊。數(shù)據(jù)區(qū)域?qū)τ谶M(jìn)程來說是可讀寫的杠输,而指令區(qū)域?qū)τ谶M(jìn)程來說是只讀的,所以這兩個(gè)虛存區(qū)域的權(quán)限可以被分別設(shè)置成可讀寫和只讀秕衙。這樣可以防止程序指令被有意或無意的改寫蠢甲。
現(xiàn)代CPU有這極為強(qiáng)大的緩存體系,所以程序必須盡量提高緩存的命中率据忘。指令區(qū)和數(shù)據(jù)區(qū)的分離有利于提高程序的局部性○信#現(xiàn)代CPU的緩存一般都被設(shè)計(jì)成數(shù)據(jù)緩存和指令緩存分離,所以程序的指令和數(shù)據(jù)被分開存放對CPU的緩存命中率提高有好處勇吊。
最后一點(diǎn)也是最重要的一點(diǎn)曼追,就是當(dāng)系統(tǒng)中運(yùn)行著多個(gè)改程序的副本時(shí),它們的指令都是一樣的汉规,所有內(nèi)存中只須要保存一份改程序的指令部分礼殊。對于指令這種只讀的區(qū)域來說是這樣的,對于其他的只讀數(shù)據(jù)也是一樣针史。當(dāng)然每個(gè)副本進(jìn)程的數(shù)據(jù)區(qū)域是不一樣的晶伦,它們是進(jìn)程私有的。
除了.text啄枕、.data婚陪、.bss這三個(gè)最常用的段之外,ELF文件也可能含有其他段频祝,用來保存于程序相關(guān)的其他信息泌参。

ELF文件結(jié)構(gòu)
省去EFL一些繁瑣的結(jié)構(gòu)脆淹,把最重要的結(jié)構(gòu)提取出來,形成了下面的EFL文件的基本結(jié)構(gòu)圖沽一。

EFL目標(biāo)文件格式的還包含了一下內(nèi)容:
- EFL頭文件(EFL Header)
它包含描述了整個(gè)文件的基本屬性未辆,比如ELF文件版本、目標(biāo)機(jī)器型號锯玛、程序入口地址等。 - 各個(gè)段
- 段表(Sextion Header Tabel)
描述了EFL文件包含的所有段的信息兼蜈,比如每個(gè)段的段名攘残、段的長度、在文件中的偏移为狸、讀寫權(quán)限以及段的其他屬性歼郭。EFL文件的段結(jié)構(gòu)就是由段表決定的,編譯器辐棒、鏈接器和裝載器都是依靠段表來定位和訪問各個(gè)段的屬性的病曾。 - 重定位表
鏈接器在處理目標(biāo)文件時(shí),須要對目標(biāo)文件中的某些部位進(jìn)行重定位漾根,即代碼段和數(shù)據(jù)段中那些絕對地址的引用的位置泰涂。這些重定位信息都記錄在EFL文件的重定位表里,對于每個(gè)須要重定位的代碼段辐怕,都會(huì)有一個(gè)響應(yīng)的重定位表逼蒙。 - 字符串表
ELF 文件中用到了很多字符串,比如段名寄疏、變量名等是牢。因?yàn)樽址拈L度往往是不定的,所以用固定的結(jié)構(gòu)來標(biāo)示它比較困難陕截。一種很常見的做法是把字符串集中起來放到一個(gè)表驳棱,然后使用字符串在表中的偏移量來引用字符串。一般字符串表分別為字符串表(String Table)和段表字符串表(Section Header String Tabel)农曲。顧名思義社搅,字符串表是用來保存普通的字符串,段表字符串表是用來保存段表中用到的字符串乳规。
鏈接的接口--符號
鏈接的過程本質(zhì)就是要把多個(gè)不同的目標(biāo)文件之間相互"粘到一起"罚渐,或著說像一個(gè)玩具積木一樣,可以拼裝成一個(gè)整體驯妄。為了使不同目標(biāo)文件之間能夠相互粘合荷并,這些目標(biāo)文件之間必須有固定的規(guī)則才行,就像積木模塊必須有凹凸的部分才能夠拼合青扔。
在鏈接中目標(biāo)文件之間互相拼合實(shí)際上是目標(biāo)文件之間對地址的引用源织,即對函數(shù)和變量地址的引用翩伪。比如目標(biāo)文件B要用到目標(biāo)文件A中的函數(shù)“foo”,那么我們就稱目標(biāo)文件A定義(Define)了函數(shù)“foo”,稱目標(biāo)文件B引用(Reference)了目標(biāo)文件A中的函數(shù)“foo”燕少。這樣的概念同樣適用于變量挨下。每個(gè)函數(shù)或變量都有自己獨(dú)特的名字,才能避免鏈接過程中不同變量和函數(shù)之間的混淆轻姿。
在鏈接中,我們將函數(shù)和變量統(tǒng)稱為符號(Symbol)逻炊,函數(shù)名或變量名就是符號名(Symbol Name)互亮。
符號修飾和函數(shù)簽名
約在20世紀(jì)70年代以前,編譯器編譯源碼產(chǎn)生的目標(biāo)文件時(shí)余素,符號名與相應(yīng)的變量函數(shù)的名字是一樣的豹休。為了防止類似的符號名沖突,Unix下的C語言就規(guī)定桨吊,C語言源代碼文件中的所以全局變量和函數(shù)經(jīng)過編譯以后威根,相對于的符號名前加上下劃線“_”。當(dāng)然這也成為了歷史视乐。
簡單的例子洛搀,兩個(gè)相同名字的函數(shù)func(int)和func(double),盡管函數(shù)名稱相同,但是參數(shù)列表不同佑淀,這是C++里面函數(shù)重載的最簡單的一種情況姥卢,那么編譯器和鏈接器在鏈接過程中如何區(qū)分這兩個(gè)函數(shù)呢?為了支持C++這些復(fù)雜的特性渣聚,人們發(fā)明了符號修飾(Name Decoration)或符號改編(Name Mangling)的機(jī)制独榴。
兩個(gè)同名函數(shù)func,只不過它們的返回類型和參數(shù)及所在的名稱空間不同奕枝。引入了一個(gè)術(shù)語叫做函數(shù)簽名(Function Signature)棺榔,函數(shù)簽名包含了一個(gè)函數(shù)的信息,包括函數(shù)名隘道、參數(shù)症歇、它所在的類和名稱空間以及其他信息。在編譯器以及鏈接器處理符號時(shí)谭梗,使用了名稱修飾的方法忘晤,使得每個(gè)函數(shù)簽名對應(yīng)一個(gè)修飾后的名稱(Decorated Name)。
弱符號和強(qiáng)符號 | 弱引用和強(qiáng)引用
我們經(jīng)常在編程中碰到了一種情況叫符號重復(fù)定義激捏。多個(gè)目標(biāo)文件中含有相同名字全局符號的定義设塔,那么這些目標(biāo)文件鏈接的時(shí)候?qū)?huì)出現(xiàn)符號重復(fù)定義的錯(cuò)誤。
出現(xiàn)沖的符號的定義可以被稱為強(qiáng)符號(Strong Symbol)远舅。有些符號的定義可以被稱為弱符號(Weak Symbol)闰蛔。對于C/C++語言來說痕钢,編譯器默認(rèn)函數(shù)和初始化的全局變量為強(qiáng)符號,未初始化的全局變量為弱符號序六。我們也可以通過GCC的“attribute((weak))”來定義任何一盒強(qiáng)符號為弱符號任连。
注意:強(qiáng)符號和弱符號都是針對定義來說的,不是針對符號的引用例诀。例如下面這段程序:
extern int ext;
int weak ;
int strong = 1;
__attribute__((weak)) weak2 = 2;
int main()
{
return 0;
}
"weak"和"weak2"是弱符號(我測試了下随抠,weak2 改成weak 加了編譯屬性,不會(huì)引起符號重復(fù)定義報(bào)錯(cuò))繁涂,"strong"和"main"是強(qiáng)符號拱她,而"ext"既非強(qiáng)符號也非弱符號,因?yàn)樗且粋€(gè)外部變量引用爆土。針對強(qiáng)弱符號的概念,鏈接器會(huì)按如下規(guī)則處理與選擇被多次定義的符號:
- 規(guī)則1:不允許強(qiáng)符號被多次定義(即不同的目標(biāo)文件中不能有同名的強(qiáng)符號)诸蚕;如果有多個(gè)強(qiáng)符號定義步势,則鏈接器報(bào)符號重復(fù)定義的錯(cuò)誤;
- 規(guī)則2:如果一個(gè)符號在某個(gè)目標(biāo)文件中是強(qiáng)符號背犯,在其他文件中都是弱符號坏瘩,那么現(xiàn)在強(qiáng)符號。
- 規(guī)則3:如果一個(gè)符號在所有目標(biāo)文件中都是弱符號漠魏,那么選擇其中占用空間最大的一個(gè)倔矾。
弱引用和強(qiáng)引用。目前我們所看到的對外部目標(biāo)文件的符號引用在目標(biāo)文件被最終鏈接成執(zhí)行文件時(shí)柱锹,他們需要被正確的決議哪自,如果沒有找到該符號的定義,鏈接器就會(huì)報(bào)符號未定閱的錯(cuò)誤禁熏,這種被稱為強(qiáng)引用(Strong Reference)壤巷。與之相對應(yīng)的還有一種弱引用(Weak Reference),在處理弱引用時(shí)瞧毙,如果該符號有定義胧华,則鏈接器將該符號的引用決議;如果該符號未被定義宙彪,則鏈接器對于該引用不報(bào)錯(cuò)矩动。鏈接器處理強(qiáng)引用和弱引用的過程幾乎一樣,只是對于未定義的弱引用释漆,鏈接器不認(rèn)為它是一個(gè)錯(cuò)誤悲没。一般對于未定義的弱引用,鏈接器默認(rèn)其為0男图,或者一個(gè)特殊的值檀训,以便于程序代碼能夠識別柑潦。
使用“attribute((weak))”來聲明對一個(gè)外部函數(shù)的引用為弱引用,例子:
__attribute__((weakref)) void foo()
int main()
{
foo();
}
它可以編譯成一個(gè)可執(zhí)行文件峻凫,GCC并不會(huì)報(bào)鏈接錯(cuò)誤渗鬼。但是當(dāng)我們運(yùn)行這個(gè)可執(zhí)行文件時(shí),會(huì)發(fā)生運(yùn)行時(shí)錯(cuò)誤荧琼。因?yàn)楫?dāng)main函數(shù)試圖調(diào)用foo函數(shù)時(shí),foo函數(shù)的地址為0命锄,于是發(fā)生了非法地址訪問的錯(cuò)誤堰乔。改進(jìn)后:
__attribute__((weakref)) void foo()
int main()
{
if(foo) foo();
}
弱引用和弱符號主要用于庫的鏈接過程。比如庫中定義的弱符號可以被用戶定義強(qiáng)符號所覆蓋脐恩,從而使得程序可以使用自定義版本中的函數(shù)庫镐侯;或者程序可以對某些擴(kuò)展功能模塊的引用定義為弱引用,當(dāng)我們將擴(kuò)展模塊與程序鏈接在一起時(shí)候驶冒,功能模塊就可以正常使用苟翻;如果我們?nèi)サ袅四承┕δ苣K,那么程序也可以正常的鏈接骗污,只是缺少了響應(yīng)發(fā)功能崇猫,這使得程序的功能更加容易剪裁和組合。
調(diào)試信息
目標(biāo)文件里面還有可能保存的是調(diào)試信息需忿。幾乎所有的現(xiàn)代編譯器都支持源代碼級別的調(diào)試诅炉,比如我們可以在函數(shù)里面設(shè)置斷點(diǎn),可以監(jiān)聽變量變化屋厘,可以單步進(jìn)行等涕烧,前提是編譯器必須提前將源代碼與目標(biāo)代碼之間的關(guān)系等,比如目標(biāo)代碼中的地址對于源代碼中的哪一行汗洒、函數(shù)和變量的類型澈魄、結(jié)構(gòu)體的定義、字符串保存到目標(biāo)文件里面仲翎。設(shè)置有些高級的編譯器和調(diào)試器支持查看STL容器的內(nèi)容痹扇。想想xcode在調(diào)試時(shí)就支持查看各種容器的內(nèi)容,還有image圖像等溯香。
調(diào)試信息在目標(biāo)文件和可執(zhí)行文件中占很大的空間鲫构,往往比程序和數(shù)據(jù)本身大好幾倍,所以當(dāng)我們開發(fā)完程序并要將它發(fā)布的時(shí)候玫坛,須要吧這些對于用戶沒有用的調(diào)試信息去掉结笨,以節(jié)省大量空間。在Linux下,我們可以使用“strip”命令來去掉ELF文件中的調(diào)試信息炕吸;
strip foo
想想Xcode在build Configuration 的時(shí)候伐憾,也會(huì)選擇Debug 還是 release,選擇release時(shí)赫模,在運(yùn)行的時(shí)候树肃,程序crash了,就不會(huì)再xcode中提示crash原因和位置瀑罗。
靜態(tài)鏈接
由于鏈接形式的不同胸嘴,產(chǎn)生了靜態(tài)鏈接和動(dòng)態(tài)鏈接。當(dāng)我們有兩個(gè)目標(biāo)文件時(shí)斩祭,如何將它們鏈接起來形成一個(gè)可執(zhí)行文件劣像?這個(gè)過程中發(fā)生了什么?這基本就是鏈接的核心內(nèi)容摧玫。
整個(gè)鏈接過程分為兩步:
- 第一步:空間與地址的分配 掃描所有的輸入目標(biāo)文件耳奕,并獲得它們各個(gè)段的長度、屬性和位置诬像,并且將輸入目標(biāo)文件中的符號表中所有的符號定義和符號引用收集起來屋群,統(tǒng)一放到一個(gè)全局符號表。這一步中颅停,鏈接器將能夠獲得所有輸入目標(biāo)文件的段長度谓晌,并且將它們合并掠拳,計(jì)算出輸出文件中各個(gè)段合并后的長度與位置癞揉,并建立映射關(guān)系。
- 第二步:符號解析與重定位 使用上面第一步中收集到的所有信息溺欧,讀取輸入文件中斷的數(shù)據(jù)喊熟、重定位信息,并且進(jìn)行符號解析與重定位姐刁、調(diào)整代碼中的地址等芥牌。事實(shí)上第二步是鏈接過程的核心,特別是重定位過程聂使。
空間與地址的分配
鏈接器為目標(biāo)文件分配地址和空間壁拉,實(shí)際的空間分配策略是:相似段合并,如下圖所示:

鏈接器為目標(biāo)文件分配地址和空間柏靶,這句話中的“地址和空間”弃理,其實(shí)有兩個(gè)含義:
- 輸出的可執(zhí)行文件中的空間;
- 裝載后的虛擬地址中的虛擬空間屎蜓。
對于有實(shí)際數(shù)據(jù)的段痘昌,比如“.text”和“.data”來說,它們在文件中和虛擬地址中要分配空間,因?yàn)樗鼈冊谶@兩者中都存在辆苔;而對于“.bss”這樣的段來說算灸,分配空間的意義只局限于虛擬地址空間,因?yàn)樗谖募胁]有內(nèi)容驻啤。事實(shí)上菲驴,我們這里談到的空間分配只關(guān)注于虛擬地址的分配,以為這個(gè)關(guān)系到鏈接器后面關(guān)于地址計(jì)算的步驟街佑,而可執(zhí)行文件本身的空間分配與鏈接過程的關(guān)系并不是很大谢翎。
符號解析與重定位
在我們通常的觀念里,之所以要鏈接是因?yàn)槲覀兡繕?biāo)文件中用到的符號被定義在其他目標(biāo)文件中沐旨,所以要將它們鏈接起來森逮。
符號沒有被定義是我們平時(shí)在編寫程序的時(shí)候最常碰見的問題之一,就是鏈接時(shí)符號未定義磁携。導(dǎo)致整個(gè)問題的原因很多褒侧,最常見的一般都是鏈接時(shí)缺少了某個(gè)庫,或者輸入目標(biāo)文件路徑不正確或符號的聲明與定義不一樣谊迄。所以從普通程序員的角度看闷供,符號的解析占據(jù)了鏈接過程的主要內(nèi)容。
其實(shí)重定位的過程也伴隨著符號解析的過程统诺,每個(gè)目標(biāo)文件都可能定義一些符號歪脏,也可能引用到定義在其他目標(biāo)文件的符號。重定位的過程中粮呢,每個(gè)重定位的入口都對一個(gè)符號引用婿失,那么當(dāng)鏈接器須要對某個(gè)符號的引用進(jìn)行重定位是,它就要確定這個(gè)符號的目標(biāo)地址啄寡。這時(shí)候鏈接器就會(huì)去查找由所有輸入目標(biāo)文件的符號表組成的全局符號表豪硅,找到相應(yīng)的符號進(jìn)行重定位。
靜態(tài)庫鏈接
一個(gè)靜態(tài)庫可以簡單地看成一組目標(biāo)文件的集合挺物,即很多目標(biāo)文件經(jīng)過壓縮打包后形成的一個(gè)文件懒浮。比如我們在Linux中最常用的C語言靜態(tài)庫libc位于/usr/lib/libc.a它屬于glibc項(xiàng)目的一部分。
靜態(tài)庫的鏈接如下圖所示:

為什么的靜態(tài)運(yùn)行庫里面一個(gè)目標(biāo)文件包含一個(gè)函數(shù)识藤?比如lib.c里面printf.o只有printf()函數(shù)砚著,為什么要這樣組裝?
鏈接器在鏈接靜態(tài)庫的時(shí)候是以目標(biāo)文件為單位的痴昧。比如我們引用了靜態(tài)庫中的printf()函數(shù)稽穆,那么鏈接器就會(huì)把庫中包含printf()函數(shù)的那個(gè)目標(biāo)文件鏈接進(jìn)來,如果很多函數(shù)都放在一個(gè)目標(biāo)文件中剪个,很可能很多沒有的函數(shù)都被遺棄鏈接進(jìn)了輸出結(jié)果中秧骑,由于運(yùn)行庫有成百上千個(gè)函數(shù)版确,數(shù)量非常龐大,每個(gè)函數(shù)獨(dú)立地放在一個(gè)目標(biāo)文件中可以盡量減少空間的浪費(fèi)乎折,那些沒有被用到的目標(biāo)文件(函數(shù))就不要鏈接到最終的輸出文件中绒疗。
接下來書中也講了鏈接過程控制,這部分操作性比較強(qiáng)骂澄,我就不一一展開了吓蘑。
提示:
很多地方都提到了操作系統(tǒng)內(nèi)核。從本質(zhì)上講坟冲,它本身也是一個(gè)程序磨镶。比如Windows的內(nèi)核ntokrnl.exe就我我們平常看到的PE文件健提,它的位置位于\WINDOWS\system32\ntoskrnl.exe琳猫。很多人誤以為Windows操作系統(tǒng)內(nèi)核很龐大,由很多文件組成私痹。這是一個(gè)誤解脐嫂,其實(shí)真正的Windows內(nèi)核機(jī)是這個(gè)文件。
可執(zhí)行文件的裝載與進(jìn)程
介紹可執(zhí)行文件的裝載與進(jìn)程開始紊遵,有幾個(gè)問題账千。
- 什么是進(jìn)程的虛擬地址空間?
- 為什么進(jìn)程要有自己獨(dú)立的虛擬地址空間暗膜?
- 裝載的方式有哪些匀奏?
- 進(jìn)程虛擬地址的分布情況?比如代碼段学搜、數(shù)據(jù)段娃善、BSS段、堆恒水、棧分別在進(jìn)程地址空間中的怎么分布会放,它們的位置和長度如何決定饲齐?
進(jìn)程的虛擬地址空間
程序和進(jìn)程的區(qū)別什么钉凌?
程序(或者狹義上講的可執(zhí)行文件)是一個(gè)靜態(tài)的概念,它就是一些預(yù)先編譯好的指令和數(shù)據(jù)集合的一個(gè)文件捂人;進(jìn)程則是一個(gè)動(dòng)態(tài)的概念御雕,它是程序運(yùn)行時(shí)的一個(gè)過程,很多時(shí)候把動(dòng)態(tài)庫叫做運(yùn)行時(shí)(Runtime)也有一定的含義滥搭。
每個(gè)程序運(yùn)行起來以后酸纲,它將擁有自己獨(dú)立的虛擬地址空間(Virtual Address Space)這個(gè)虛擬地址空間的大小由計(jì)算機(jī)的硬件決定,具體地說是由CPU的位數(shù)決定瑟匆。硬件決定了地址空間的最大理論上限闽坡,即硬件的尋址空間大小,比如32位的硬件平臺(tái)決定了虛擬地址的地址為0到【2的32次方】-1,即0x000000000xFFFFFFFF,也就是我們常說的4GB虛擬空間大屑残帷外厂;而64位的硬件平臺(tái)具有64位尋址能力,它的虛擬地址空間達(dá)到了【2的64次方】字節(jié)代承,即0x00000000000000000xFFFFFFFFFFFFFFFF汁蝶,總共17 179 869 184 GB,這個(gè)尋址能力現(xiàn)在來看论悴,幾乎是無限的掖棉,但是歷史總是會(huì)嘲弄人,或許有一天我們會(huì)覺得64位的地址空間很小膀估,就像我們現(xiàn)在覺得32位地址不夠用一樣幔亥。
其實(shí)從程序的角度看,我們可以通過判斷C語言程序中指針的所占空間來計(jì)算虛擬地址的大小察纯。一般來說C語言指針大小的位數(shù)與虛擬空間的位數(shù)相同紫谷,如32位平臺(tái)下的指針為32位,即4字節(jié)捐寥;64位平臺(tái)下的指針為64為笤昨,即8字節(jié)。
那么32位平臺(tái)下的4GB虛擬空間,我們的程序是否可以任意使用呢?很遺憾沙合,不行横辆。因?yàn)槌绦蛟谶\(yùn)行的時(shí)候處于操作系統(tǒng)的監(jiān)管下,操作系統(tǒng)為了達(dá)到監(jiān)控程序運(yùn)行等一系列的目的哀澈,進(jìn)程的虛擬空間都在操作系統(tǒng)的掌控之中。進(jìn)程只能使用那些系統(tǒng)分配給進(jìn)程的地址,如果訪問了未經(jīng)運(yùn)行的空間拔稳,那么操作系統(tǒng)就會(huì)捕獲到這些訪問,將進(jìn)程這種訪問當(dāng)作非法操作锹雏,強(qiáng)制結(jié)束進(jìn)程巴比。我們經(jīng)常在Windows在碰到令人討厭的“進(jìn)程因非法操作需要關(guān)閉”或Linux下的“Segmentation fault”很多時(shí)候是因?yàn)檫M(jìn)程訪問了未經(jīng)允許的地址。
32位CPU下礁遵,程序使用的空間能不能超過4GB呢轻绞?這個(gè)問題其實(shí)應(yīng)該從兩個(gè)角度來看。
- 問題里的“空間”如果是指虛擬地址空間佣耐,那么答案是“否”政勃。因?yàn)?2位的CPU只能用32位的指針,它最大的尋址范圍是0到4GB兼砖。
- 如果問題里是“空間”指計(jì)算機(jī)的內(nèi)存空間奸远,那么答案是“是”既棺。Intel 自從1995年的Pentium Pro CPU開始采用36為的物理地址,也就是可以訪問高達(dá)64GB的物理內(nèi)存懒叛。
從硬件層面上來講援制,首先的32位地址線只能訪問最多4GB的物理內(nèi)存。但是自從擴(kuò)展至36位地址之后芍瑞,Intel修改了頁映射的方式晨仑,使得新的映射方式可以訪問到更多的物理內(nèi)存。Intel 把這個(gè)地址擴(kuò)展方式叫做PAE(Physical Address Extension)拆檬。當(dāng)然這只是一種補(bǔ)救32位地址空間不夠大時(shí)的非常規(guī)手段洪己,真正的解決方法還是應(yīng)該使用64位的處理器和操作系統(tǒng)。
裝載的方式
程序執(zhí)行時(shí)所需要的指令和數(shù)據(jù)必須在內(nèi)存中才能夠正常運(yùn)行竟贯,最簡單的辦法就是將程序運(yùn)行時(shí)所需要的指令和數(shù)據(jù)全部裝入內(nèi)存中答捕,這樣程序就可以順利運(yùn)行,這是最簡單的靜態(tài)裝入的辦法屑那。但是很多情況下程序需要的內(nèi)存數(shù)量大于物理內(nèi)存的數(shù)量拱镐,當(dāng)內(nèi)存的數(shù)量不夠時(shí),根本的解決辦法就是添加內(nèi)存持际。相對應(yīng)磁盤來說沃琅,內(nèi)存是昂貴且稀有的,這種情況自計(jì)算機(jī)磁盤誕以來一直如此蜘欲。所以人們想盡各種辦法益眉,希望能夠在不添加內(nèi)存的情況下讓更多的
的程序運(yùn)行起來,盡可能有效的利用內(nèi)存姥份。后來研究發(fā)現(xiàn)郭脂,程序運(yùn)行時(shí)是由局部性原理的,所以我們可以將程序最常用的部分留在內(nèi)存中澈歉,而將一些不太常用的數(shù)據(jù)存放在磁盤里展鸡,這就是動(dòng)態(tài)裝入的基本原理。
裝載的方式有哪些埃难?覆蓋裝入(overlay)和頁映射(paging)是兩種很典雅的動(dòng)態(tài)裝載方法莹弊,它們所采用的思想都差不多,原則上都是利用了程序的局部性原理凯砍。
動(dòng)態(tài)裝入的思想是程序用到了哪個(gè)模塊箱硕,就將那個(gè)模塊裝入內(nèi)存拴竹,如果不用就暫時(shí)不裝入悟衩,存放在磁盤中。
- 覆蓋裝入(overlay)
覆蓋裝入在沒有發(fā)明虛擬存儲(chǔ)之前使用比較廣泛栓拜,現(xiàn)在幾乎已經(jīng)被淘汰了座泳。覆蓋裝入的方法把挖掘內(nèi)存潛力的任務(wù)交給了程序員惠昔,程序員在編寫程序的時(shí)候會(huì)必須手工的將程序分割成若干塊,然后編寫一個(gè)輔助代碼來管理這些模塊何時(shí)應(yīng)該駐留在內(nèi)存挑势,何時(shí)應(yīng)該被替換掉镇防。這個(gè)小的輔助代碼就是所謂的覆蓋管理器(Overlay Manager)。 - 頁映射(paging)
頁映射是虛擬存儲(chǔ)機(jī)制的一部分潮饱。與覆蓋裝入的原理相似来氧,頁映射也不是一下子就把程序的所有數(shù)據(jù)和指令都裝入內(nèi)存,而是將內(nèi)存和所有的磁盤中的數(shù)據(jù)和指令按照“頁(Page)”為單位劃分成若干個(gè)頁香拉,以后所有裝載和操作的單位就是頁啦扬。
理解頁映射:
為了演示頁映射的基本機(jī)制,假設(shè)我們的32位機(jī)器有16KB的內(nèi)存凫碌,每個(gè)頁大小為4096字節(jié)扑毡,則共有4個(gè)頁。假設(shè)程序所有的指令和數(shù)據(jù)總和為32KB盛险,那么程序總共被分為了8個(gè)頁瞄摊。我們將它們編號為P0~P7。很明顯苦掘,16KB的內(nèi)存無法開始同時(shí)將32KB的程序裝入换帜,那么我們將按照動(dòng)態(tài)裝入的原理來進(jìn)行整個(gè)裝入的過程。如果程序剛開始執(zhí)行時(shí)的入口地址在P0鹤啡,這時(shí)裝載管理器發(fā)現(xiàn)程序P0不在內(nèi)存中膜赃,于是將內(nèi)存F0分配給P0,并且將P0的內(nèi)容裝入F0揉忘;運(yùn)行一段時(shí)間以后跳座,程序需要用到P5,于是裝載管理器將P5裝入F1泣矛;就這樣疲眷,當(dāng)程序用到P3和P6的時(shí)候,它們分別被裝入到了F2和F3您朽,它們的映射關(guān)系如下圖狂丝。

很明顯,如果這時(shí)候程序只需要P0哗总、P3几颜、P5和P6這個(gè)4個(gè)頁,那么程序就能一直運(yùn)行下去讯屈。但是問題很明顯蛋哭,如果這時(shí)候程序要訪問P4,那么裝載管理器必須做出抉擇涮母,它必須放棄目前正在使用的4個(gè)內(nèi)存頁中的其中一個(gè)來裝載P4谆趾。至于選擇哪個(gè)頁躁愿,我們有很多種算法可以選擇,比如可以選擇F0沪蓬,因?yàn)樗堑谝粋€(gè)被分配掉的內(nèi)存頁(這個(gè)算法我們可以稱之為FIFO彤钟,先進(jìn)先出);假設(shè)裝載管理器發(fā)現(xiàn)F2很少被訪問到跷叉,那么我們可以選擇F2(這種算法可以稱之為LUR逸雹,最少使用算法)。假設(shè)我們放棄P0云挟,那么這時(shí)候F0就裝入了P4峡眶。程序接著按照這樣的方式運(yùn)行。
其實(shí)例子中這個(gè)所謂的裝載管理器就是現(xiàn)代的操作系統(tǒng)植锉,更加準(zhǔn)確地講就是操作系統(tǒng)的存儲(chǔ)管理器辫樱。目前幾乎所有的主流操作系統(tǒng)都是按照這種方式裝載可執(zhí)行文件的。我們熟悉的Windows對PE文件的裝載及Linux對ELF文件的裝載都是這樣完成的俊庇。
進(jìn)程的建立
從操作系統(tǒng)的角度來看狮暑,一個(gè)進(jìn)程最關(guān)鍵的特征是它擁有獨(dú)立的虛擬地址空間,這使得它有別與其他進(jìn)程辉饱。很多時(shí)候一個(gè)程序被執(zhí)行同時(shí)都伴隨著一個(gè)新的進(jìn)程的創(chuàng)建搬男,那么我們就來看看這種最通常的情形:創(chuàng)建一個(gè)進(jìn)程,然后裝載響應(yīng)的可執(zhí)行文件并且執(zhí)行彭沼。在有虛擬存儲(chǔ)的情況下缔逛,上述過程最開始只需要做三件事情:
- 創(chuàng)建一個(gè)獨(dú)立的虛擬地址空間;
我們知道一個(gè)虛擬空間由一組頁映射函數(shù)將虛擬空間的各個(gè)頁映射至相應(yīng)的物理空間姓惑,那么創(chuàng)建一個(gè)虛擬空間實(shí)際上并不是創(chuàng)建空間而是創(chuàng)建映射函數(shù)所需要的相應(yīng)的數(shù)據(jù)結(jié)構(gòu)褐奴。 - 讀取可執(zhí)行文件頭,并且建立虛擬空間與可執(zhí)行的映射關(guān)系于毙;
上面那一步的頁映射關(guān)系函數(shù)是虛擬空間到物理內(nèi)存的映射關(guān)系敦冬,這一步所做的是虛擬空間與可執(zhí)行文件的映射關(guān)系。當(dāng)程序執(zhí)行發(fā)生頁錯(cuò)誤時(shí)唯沮,操作系統(tǒng)將從物理內(nèi)存中分配一個(gè)物理頁脖旱,然后將該“缺頁”從磁盤中讀取到內(nèi)存中,再設(shè)置缺頁的虛擬頁和物理頁的映射關(guān)系介蛉,這樣程序才得以正常運(yùn)行萌庆。但是很明顯的一點(diǎn)是,當(dāng)操作系統(tǒng)捕獲到缺頁錯(cuò)誤時(shí)币旧,它應(yīng)該知道程序當(dāng)前所需要的頁在可執(zhí)行文件中的哪一個(gè)位置践险。這就是虛擬空間與可執(zhí)行文件之間的映射關(guān)系。這一步是是整個(gè)裝載過程中最重要的一步,頁是傳統(tǒng)意義上“裝載”的過程捏境。
Linux 中進(jìn)程虛擬空間中的一個(gè)段叫做虛擬內(nèi)存區(qū)域(VMA于游,Virtual Memory Area),Windows中將這個(gè)叫做虛擬段(Virtual Secion)毁葱,其實(shí)它們都是同一個(gè)概念垫言。比如,操作系統(tǒng)創(chuàng)建進(jìn)程后倾剿,會(huì)在進(jìn)程相應(yīng)的數(shù)據(jù)結(jié)構(gòu)中設(shè)置一個(gè).text段的 VMA 筷频。VMA 是一個(gè)很重要的概念,它對于我們理解程序的裝載執(zhí)行和操作系統(tǒng)如何管理進(jìn)程的虛擬空間由非常重要的幫助前痘。 - 將CPU的指令寄存器設(shè)置成可執(zhí)行文件的入口地址凛捏,啟動(dòng)運(yùn)行。
第三步其實(shí)也是最簡單的一部芹缔,操作系統(tǒng)通過設(shè)置CPU的指令寄存器控制權(quán)轉(zhuǎn)交給進(jìn)程坯癣,由此進(jìn)程開始執(zhí)行。這一步看似簡單最欠,實(shí)際上是在操作系統(tǒng)層面上比較復(fù)雜示罗,它涉及內(nèi)核推展和用戶堆棧的切換、CPU運(yùn)行權(quán)限的切換芝硬。不過從進(jìn)程的角度看這一步可簡單地認(rèn)為操作系統(tǒng)執(zhí)行了一條跳轉(zhuǎn)指令蚜点,直接跳轉(zhuǎn)到可執(zhí)行文件的入口地址。
進(jìn)程虛存空間分布
在一個(gè)正常的進(jìn)程中拌阴,可執(zhí)行的文件中包含的往往不止代碼段绍绘,還有數(shù)據(jù)段、BSS等迟赃,所以映射到進(jìn)程虛擬空間的往往不止一個(gè)段陪拘。當(dāng)段的數(shù)量增多時(shí),就會(huì)產(chǎn)生空間浪費(fèi)的問纤壁。EFL文件被映射時(shí)藻丢,是系統(tǒng)的頁長度作為單位,那么每個(gè)段在映射時(shí)的長度應(yīng)該都是系統(tǒng)頁長度的整數(shù)倍摄乒;如果不是悠反,那么多余的部分也將占有一個(gè)頁。一個(gè)EFL文件中往往有幾十個(gè)段馍佑,那么內(nèi)存空間的浪費(fèi)是可想而知的斋否。
當(dāng)我們站在操作系統(tǒng)裝載可執(zhí)行文件的角度看問題時(shí),可以發(fā)現(xiàn)它實(shí)際上并不關(guān)心可執(zhí)行文件各個(gè)段所包含的實(shí)際內(nèi)容拭荤,操作系統(tǒng)只關(guān)心一些跟裝載相關(guān)的問題茵臭。最主要的是段的權(quán)限(可讀、可寫舅世、可執(zhí)行)旦委。ELF文件中奇徒,段的權(quán)限往往只有為數(shù)不多的幾種組合,基本上是三種:
- 以代碼段為代表的權(quán)限可讀可執(zhí)行的段缨硝。
- 以數(shù)據(jù)段和BSS段為代表的權(quán)限可讀可寫的段摩钙。
- 以只讀數(shù)據(jù)段為代表的權(quán)限為只讀段。
那么我們可以找到一個(gè)很簡單的方案就是:對于相同的權(quán)限的段查辩,把他們合并到一起當(dāng)作一個(gè)段來進(jìn)行映射胖笛。
段合并在一起看作是一個(gè)“Segment”,那么裝載的時(shí)候就可以將它們看作是一個(gè)整體一起映射宜岛,這樣做的好處是可以很明顯的減少內(nèi)部碎片长踊,從而節(jié)省了內(nèi)存空間。
“Segment”的概念實(shí)際上是從裝載的角度重新劃分了ELF是各個(gè)段萍倡。在目標(biāo)文件鏈接成可執(zhí)行文件的時(shí)候身弊,鏈接器會(huì)進(jìn)來把相同權(quán)限屬性的段分配在一個(gè)空間。比如可讀可執(zhí)行的段都放在一起列敲,這種段的典型是代碼段阱佛;可讀寫的段都放在一起,這種段的典型是數(shù)據(jù)段酿炸。
那我們在之前理解的.text和.data是不是這里說的"Segment"呢瘫絮?
看下圖: ELF可執(zhí)行文件與進(jìn)程虛擬空間的映射關(guān)系

在ELF文件中的這些段,我們稱之為"section"填硕。所以“Segment”和“Section”是從不同的角度來劃分同一個(gè)ELF文件麦萤。這個(gè)在ELF中被稱為不同的視圖(View),從“Section”的角度來看ELF文件就是鏈接視圖(Linking View),從“Segemnt”的角度來看就是執(zhí)行視圖(Execution View)扁眯。當(dāng)我們談ELF裝載時(shí)壮莹,“段”專門指“Segment”;而在其他情況下“段”指的是“Section”。
堆和棧
在操作系統(tǒng)中姻檀,VMA除了被用來映射可執(zhí)行文件中的各個(gè)“Segment”以外命满,它還可以有其他的作用,操作系統(tǒng)通過VMA來對進(jìn)程的地址空間進(jìn)行管理绣版。我們知道進(jìn)程在執(zhí)行的時(shí)候它還需要用到棧(Stack)胶台、堆(Heap)空間,事實(shí)上它們在進(jìn)程虛擬空間也是以VMA的形式存在的杂抽,很多情況下诈唬,一個(gè)進(jìn)程中中的棧和堆分別都有一個(gè)對應(yīng)的VMA,而且它們沒有映射到文件中缩麸,這種VMA叫做匿名虛擬內(nèi)存區(qū)域(Anonymous Virtual Memory Area)铸磅。
小結(jié)一下關(guān)于進(jìn)程虛擬地址空間的概念:操作系統(tǒng)通過給進(jìn)程空間劃分出一個(gè)個(gè)VMA來管理進(jìn)程的虛擬空間;基本原則上將相同權(quán)限屬性的、有相同映像文件的映射成一個(gè)VMA阅仔;一個(gè)進(jìn)程基本上可以分為如下VMA區(qū)域:
- 代碼VMA吹散,權(quán)限只讀、可執(zhí)行八酒;有映像文件空民;
- 數(shù)據(jù)VMA,權(quán)限可讀寫丘跌、可執(zhí)行袭景;有映像文件唁桩;
- 堆 VMA闭树,權(quán)限可讀寫,可執(zhí)行荒澡;無映像文件报辱,匿名,可向上擴(kuò)展单山。
- 棧 VMA碍现,權(quán)限可讀寫漂辐,不可執(zhí)行赦邻;無映像文件柒凉,匿名,可向下擴(kuò)展懈涛。
再讓我們來看一個(gè)常見的進(jìn)程虛擬空間是怎么樣的批钠,如下圖:

堆的最大申請數(shù)量
Linux下虛擬地址空間給進(jìn)程的本身是3GB(Windows默認(rèn)是2GB)宇植,那么程序真正可以用到的有多少呢?我們知道埋心,一般程序中使用malloc()函數(shù)進(jìn)行地址空間的申請指郁,那么malloc()到底最大可以申請多少內(nèi)存呢?用下面這個(gè)小程序可以測試malloc最大內(nèi)存的申請數(shù)量:
#include <stdio.h>
#include <stdlib.h>
unsigend maximum = 0;
int main(int argc, const char * argv[]) {
// insert code here...
unsigned blocksize[] = {1024 * 1024,1024,1};
int i,count;
for (i = 0; i < 3; i++){
for (count = 1;; count++) {
void *block = malloc(maximum + blocksize[i] * count);
if (block) {
maximum = maximum + blocksize[i] * count;
free(block);
}else{
break;
}
}
}
printf("maximum malloc size = %lf GB\n",maximum*1.0 / 1024.0 / 1024.0 / 1024.0);
return 0;
}
作者在Linux機(jī)器上拷呆,運(yùn)行上面這個(gè)程序的結(jié)果大概是2.9GB左右的空間闲坎;Windows下運(yùn)行這個(gè)乘車的結(jié)果大概是1.5GB疫粥。那么malloc的最大申請數(shù)量會(huì)受到哪些因素的影響?實(shí)際上腰懂,具體是數(shù)值會(huì)受到操作系統(tǒng)版本梗逮、程序本身大小、用到的動(dòng)態(tài)/共享庫數(shù)量大小绣溜、程序棧數(shù)量慷彤、大小等,甚至可能每次運(yùn)行的結(jié)果都會(huì)不同怖喻,因?yàn)橛行┎僮飨到y(tǒng)使用了一種叫做隨機(jī)地址空間分布的技術(shù)(主要是出于安全考慮底哗,防止程序受到惡意攻擊),進(jìn)程的堆空間變小锚沸。
段對齊
可執(zhí)行文件最終是要被操作系統(tǒng)裝載運(yùn)行的跋选,這個(gè)裝載的過程一般是通過虛擬內(nèi)存頁映射機(jī)制完成的。在映射的過程中咒吐,頁是映射的最小單位野建。一段物理內(nèi)存和進(jìn)程地址空間之間建立映射關(guān)系属划,這段內(nèi)存空間長度必須是頁內(nèi)存的整數(shù)倍。由于有著長度和起始地址的限制,對于可執(zhí)行文件來說募壕,它應(yīng)該盡量優(yōu)化自己的空間和地址的安排芭商,以節(jié)省空間。
為了解決這種問題须蜗,有些UNIX系統(tǒng)采用了一個(gè)很取巧的辦法硅确,就是讓那些各個(gè)段壤接部分共享一個(gè)物理頁面,然后將該物理頁面分別映射兩次明肮。這里解釋一下菱农,我剛剛看到這里的時(shí)候,也沒有明白是是什么意思柿估,我們想來看一下圖:

假設(shè)虛擬內(nèi)存SEG0 page和 虛擬內(nèi)存SEG1 page循未,都是未被分配滿的內(nèi)存頁,那SEG0和SEG1的接壤部分的那個(gè)物理頁秫舌,系統(tǒng)將它們映射兩份到虛擬地址空間的妖。如下圖:

因?yàn)槎蔚刂穼R的關(guān)系,各個(gè)段的虛擬的虛擬地址往往不是系統(tǒng)頁面長度的整數(shù)倍了足陨。
為什么要?jiǎng)討B(tài)鏈接
靜態(tài)鏈接使得不同程度程序開發(fā)者和部門能夠相對獨(dú)立的開發(fā)和測試自己的程序模塊嫂粟,從某種意義上來講大大促進(jìn)了程序開發(fā)的效率,原先限制程序的模塊也隨之?dāng)U大墨缘,但是慢慢地靜態(tài)鏈接的諸多缺點(diǎn)也逐步暴漏出來星虹,比如浪費(fèi)內(nèi)存和磁盤空間零抬、模塊更新困難等問題,使得人們不得不尋找一種更好的方式來組織程序的模塊宽涌。
內(nèi)存和磁盤空間
靜態(tài)鏈接這種方法的確很簡單媚值,原理上很容易理解,實(shí)踐上很難實(shí)現(xiàn)护糖,在操作系統(tǒng)和硬件不發(fā)達(dá)的早期褥芒,絕大部分系統(tǒng)采用這種方案。隨著計(jì)算機(jī)軟件的發(fā)展嫡良,這種方法的缺點(diǎn)很快就暴漏出來了锰扶,那就是靜態(tài)鏈接對于計(jì)算機(jī)內(nèi)存和磁盤的空間浪費(fèi)非常嚴(yán)重。特別是多進(jìn)程操作系統(tǒng)的情況下寝受,靜態(tài)鏈接極大的浪費(fèi)了內(nèi)存和空間坷牛,想象一下每個(gè)程序內(nèi)部除了都保留著print()函數(shù)、scanf()函數(shù)很澄、strlen()等這樣的公用函數(shù)庫京闰,還有數(shù)量相當(dāng)可觀的其他庫以及它們所需要的輔助數(shù)據(jù)結(jié)構(gòu)。
比如Program1和Program2分別包含Program1.o和Program2.o兩個(gè)模塊甩苛,在靜態(tài)鏈接的情況下因?yàn)镻rogram1和Program2都用到Lib.o這個(gè)模塊蹂楣,所以它們同時(shí)在鏈接輸出的可執(zhí)行文件Program1和Program2有兩個(gè)副本。當(dāng)我們同時(shí)運(yùn)行Program1和Program2時(shí)讯蒲,Lib.o在磁盤中和內(nèi)存中都有兩個(gè)副本痊土。當(dāng)系統(tǒng)中存在大量的類似Lib.o的多個(gè)程序共享目標(biāo)文件時(shí),其中很大一部分空間就被浪費(fèi)了墨林。在靜態(tài)鏈接中赁酝,C語言的靜態(tài)庫是很典型的浪費(fèi)空間的例子,還有其他數(shù)以千計(jì)的庫旭等,如果都需要靜態(tài)鏈接酌呆,那么空間浪費(fèi)無法想象。
程序開發(fā)和發(fā)布
空間浪費(fèi)是靜態(tài)鏈接的一個(gè)問題搔耕,另一個(gè)問題是靜態(tài)鏈接對程序的更新隙袁、部署和發(fā)布也會(huì)帶來很多麻煩。比如Program1所使用的Lib.o是由一個(gè)第三方廠商提供的度迂,當(dāng)該廠商更新了Lib.o的時(shí)候(比如修復(fù)了lib.o里面包含的一個(gè)bug),那么Program1的廠商就需要拿到最新版的Lib.o惭墓,然后將其與Program1.o鏈接后將新的Program1整個(gè)發(fā)布給用戶划咐。這樣做的缺點(diǎn)很明顯拴念,即一旦程序中有任何模塊更新,整個(gè)程序就要重新鏈接褐缠、發(fā)布給用戶政鼠。
動(dòng)態(tài)鏈接
要解決空間浪費(fèi)和更新困難這兩個(gè)問題的辦法就是把程序模塊互相分割開來,行程獨(dú)立的文件队魏,而不再將它他們靜態(tài)地鏈接在一起公般。簡單是將,就是不對哪些組成程序的目標(biāo)文件進(jìn)行鏈接胡桨,等到程序要運(yùn)行時(shí)才進(jìn)行鏈接官帘。也就是說,把鏈接這個(gè)過程推遲到了運(yùn)行時(shí)再進(jìn)行昧谊,這就是動(dòng)態(tài)鏈接(Dynamic Linking)的基本思想刽虹。
還是Program1和Program2為例涌哲,Program1和lib.o都加入了內(nèi)存稍刀,這時(shí)如果我們需要運(yùn)行Program2澳迫,那么系統(tǒng)只要加載Program2.o拢锹,而不需要重新加載Lib.o他巨,因?yàn)閮?nèi)存中已經(jīng)存在一份Lib.o的副本辈灼,系統(tǒng)做的只是將Program2.o和Lib.o鏈接起來榕莺。另外在內(nèi)存中共享一個(gè)目標(biāo)文件模塊的好處不僅僅是節(jié)省內(nèi)存邮辽,它還可以減少物理頁面的換入和換出,也可以增進(jìn)CPU緩存的命中率,因?yàn)椴煌M(jìn)程間的數(shù)據(jù)和指令都幾種在了同一個(gè)共享模塊上。
動(dòng)態(tài)鏈接的方案也可以使程序的升級變動(dòng)更加容易,當(dāng)我們要升級程序庫或程序共享的某個(gè)模塊時(shí),理論上只要簡單地將舊的目標(biāo)文件覆蓋點(diǎn),而無需將所有的程序再重新鏈接一遍,當(dāng)程序下一次運(yùn)行的時(shí)候灵寺,新版本的目標(biāo)文件會(huì)被自動(dòng)裝載到內(nèi)存并且鏈接起來叮称,程序就完成了升級的目標(biāo)。
程序可擴(kuò)展性和兼容性
動(dòng)態(tài)鏈接還有一個(gè)特點(diǎn)就是在程序運(yùn)行時(shí)可以動(dòng)態(tài)的選擇加載各種模塊程序,這個(gè)優(yōu)點(diǎn)就是后來被人名用來制作程序的插件(Plug-in)
動(dòng)態(tài)鏈接還可以加強(qiáng)程序的兼容性。一個(gè)程序在不同平臺(tái)運(yùn)行時(shí)可以動(dòng)態(tài)鏈接到有操作系統(tǒng)提供的動(dòng)態(tài)鏈接庫,這些動(dòng)態(tài)鏈接庫相當(dāng)于在程序和操作系統(tǒng)之間加了一個(gè)中間層,從而消除了程序?qū)Σ煌脚_(tái)之間依賴的差異性。
動(dòng)態(tài)鏈接也有諸多的問題令人煩惱和費(fèi)解。很常見的一個(gè)問題是问潭,當(dāng)程序所依賴的某個(gè)模塊更新后,由于新的模塊與舊的模塊之間接口不兼容谷炸,導(dǎo)致原有的程序無法運(yùn)行描孟。
動(dòng)態(tài)鏈接的基本實(shí)現(xiàn)
動(dòng)態(tài)鏈接的基本思想就是把程序模塊拆分成各個(gè)相對獨(dú)立的部分缠导,在程序運(yùn)行時(shí)才將它們鏈接在一起形成一個(gè)完整的程序嫡意,而不是像靜態(tài)鏈接一樣把所有的程序模塊都鏈接成一個(gè)單獨(dú)的可執(zhí)行的文件。
動(dòng)態(tài)鏈接涉及運(yùn)行時(shí)的鏈接及多個(gè)文件的裝載,必須要有操作系統(tǒng)的支持,因?yàn)閯?dòng)態(tài)鏈接的情況下,進(jìn)程的虛擬地址空間的分布會(huì)比靜態(tài)鏈接更為復(fù)雜,還有存儲(chǔ)管理、內(nèi)存共享借卧、進(jìn)程線程等機(jī)制在動(dòng)態(tài)鏈接下也會(huì)有微妙的變化滨达。在Linux系統(tǒng)中竹握,ELF動(dòng)態(tài)鏈接文件被稱為動(dòng)態(tài)共享對象,簡稱共享對象,一般以".so"為擴(kuò)展名的一些文件。
在Linux中,常用的C語言的運(yùn)行庫glibc,它的動(dòng)態(tài)鏈接形式的版本保存在"/lib"目錄下,文件名叫做"lib.so"。整個(gè)系統(tǒng)只保留一份C語言的動(dòng)態(tài)鏈接文件"lib.so"佃蚜,而所有C語言編寫的归露、動(dòng)態(tài)鏈接的程序都可以在運(yùn)行時(shí)使用它疆液。當(dāng)程序被裝載的時(shí)候掉缺,系統(tǒng)的動(dòng)態(tài)鏈接器會(huì)將程序中所需要的所有動(dòng)態(tài)鏈接庫(最基本的就是libc.so)裝載到進(jìn)程的地址空間筐高,并且將程序中所有未決議的符號綁定到相應(yīng)的動(dòng)態(tài)鏈接庫中冰单,并進(jìn)行重定位工作。
程序與libc.so 之間真正的鏈接工作是由動(dòng)態(tài)鏈接器完成的,動(dòng)態(tài)鏈接把鏈接這個(gè)過程從本來的程序裝載前被推遲到了裝載的時(shí)候。這樣的做法的確很靈活,但是程序每次都被裝載時(shí)都要進(jìn)行重新鏈接,是不是很慢豆拨?的確拾积,動(dòng)態(tài)鏈接會(huì)導(dǎo)致程序在性能的一些損失,但是對動(dòng)態(tài)鏈接的鏈接過程可以進(jìn)行優(yōu)化。據(jù)估算,動(dòng)態(tài)鏈接與靜態(tài)鏈接相比加袋,性能損失大約5%以下蚀之。這點(diǎn)性能用來換取程序在空間上的節(jié)省和程序構(gòu)建升級的靈活性锁右,是相當(dāng)值得的。
動(dòng)態(tài)鏈接過程

Lib.c 被編譯成了Lib.so共享文件浦夷,Programl1.c被編譯成Program1.o之后,鏈接成可執(zhí)行文件Program1。圖中有一個(gè)步驟與靜態(tài)鏈接不一樣,那就是Program.o被連接這一步鏈接過程成可執(zhí)行文件的這一步,在靜態(tài)鏈接中會(huì)把Program1.o和Lib.o鏈接到一起,并且產(chǎn)生出輸出可執(zhí)行文件Program1。但是在這里,Lib.o沒有被鏈接進(jìn)來,鏈接的輸入目標(biāo)文件只有Program1.o(當(dāng)然還有C語音的運(yùn)行庫,這里暫時(shí)忽略)柳爽。但是娩脾,Lib.so也參與了鏈接過程,這是這么回事兒?
當(dāng)程序模塊Program1.o被編譯成Program1.o時(shí),編譯器還不知道 foobar() 函數(shù)的地址蘸朋。當(dāng)鏈接器將Program1.o鏈接成可執(zhí)行文件時(shí)炼彪,這個(gè)時(shí)候鏈接器必須確定Program1.o中所引用的 foobar() 函數(shù)的性質(zhì)冗疮。如果 foobar() 是定義與其他靜態(tài)目標(biāo)模塊中的函數(shù)湃密,那么鏈接器將會(huì)按照靜態(tài)鏈接的規(guī)則忿危,將Program1.o中的foobar()地址引用重定位努释;如果foobar()是一個(gè)定義在某個(gè)動(dòng)態(tài)共享對象中的函數(shù),那么鏈接器就會(huì)將這個(gè)符號的引用標(biāo)記為一個(gè)動(dòng)態(tài)鏈接的符號煞躬,不對它進(jìn)行地址重定位肛鹏,把這個(gè)過程留到裝載時(shí)再進(jìn)行。
那么問題又來了恩沛,鏈接器如何知道foobar的引用是一個(gè)靜態(tài)符號還是一個(gè)動(dòng)態(tài)符號娜汁?這實(shí)際上就是我們要用到Lib.so的原因般又。Lib.so保存了完整的符號信息(因?yàn)檫\(yùn)行時(shí)進(jìn)行動(dòng)態(tài)鏈接還須使用符號信息)怕膛,把Lib.so也作為鏈接的輸入文件之一撒璧,鏈接器在解析符號時(shí)就可以知道:foobar()一個(gè)定義在Lib.so的動(dòng)態(tài)符號,這樣鏈接器就可以對foobar()用作特殊的處理讯泣,使它成為一個(gè)對動(dòng)態(tài)符號的引用。
關(guān)于模塊
在靜態(tài)鏈接時(shí)绰播,整個(gè)程序最終只有一個(gè)可執(zhí)行文件滋将,它是一個(gè)不可以分割的整體;但是在動(dòng)態(tài)鏈接下,一個(gè)程序被分成了若干個(gè)文件趋观,有程序的主要部分,即可執(zhí)行文件(Program1)和程序所依賴的共享對象(Lib.so)第步,很多時(shí)候我們也把這些部分分稱為模塊桥温,即動(dòng)態(tài)鏈接下的可執(zhí)行文件和共享對象都可以看作是一個(gè)程序的模塊。
動(dòng)態(tài)鏈接程序運(yùn)行時(shí)的地址分布
共享對象的最終裝載地址在編譯時(shí)是不確定的扇苞,而是在裝載時(shí),裝載器根據(jù)當(dāng)前的地址空間的空閑情況,動(dòng)態(tài)分配一塊足夠大小的虛擬地址空間給響應(yīng)的共享對象跛锌。
地址無關(guān)代碼
我們知道重定位是根據(jù)符號來進(jìn)行重定位的,裝載時(shí)重定位是解決動(dòng)態(tài)模塊有絕對地址引用的辦法之一,但是它有一個(gè)很大的缺點(diǎn),是指令部分無法在多個(gè)進(jìn)程之間共享,這樣就失去了動(dòng)態(tài)鏈接節(jié)省內(nèi)存的一大優(yōu)勢篇亭。其實(shí)我們的的目的很簡單聪姿,希望程序模塊中共享的指令部分在裝載時(shí)不需要因?yàn)檠b載地址的改變而改變,所以事先的基本想法就是把指令中那些需要被修改的部分分離出來乙嘀,跟數(shù)據(jù)部分放在一起末购,這樣指令部分就可以保持不變,而數(shù)據(jù)部分可以做在每個(gè)進(jìn)程中擁有副本虎谢,這種方案就是目前被稱為地址無關(guān)代碼的技術(shù)(PIC盟榴,Position-independent Code)的技術(shù)。
我們先來分析模塊中各種類型地址的引用方式嘉冒,我們把共享對象模塊中的地址引用按照是否為跨模塊分為兩類:模塊內(nèi)部引用和模塊外部引用曹货;按照不同的引用方式又可以分為指令引用和數(shù)據(jù)訪問,這樣我們就得到了入下圖中的4中情況讳推。

- 第一種是模塊內(nèi)部的函數(shù)調(diào)用顶籽、跳轉(zhuǎn)等。
- 第二種是模塊外部的數(shù)據(jù)訪問银觅,比如模塊中定義的全局變量礼饱,靜態(tài)變量。
- 第三種是模塊外部的函數(shù)調(diào)用究驴、跳轉(zhuǎn)等镊绪。
- 第四種是模塊外部的數(shù)據(jù)訪問,比如其他模塊中定義的全局變量洒忧。
在編譯上面這段代碼時(shí)蝴韭,編譯器實(shí)際上不能確定變量b和函數(shù)ext()是模塊外部還是模塊內(nèi)部,因?yàn)樗鼈冇锌赡鼙欢x在同一個(gè)共享對象的其他目標(biāo)文件中熙侍。由于沒法確定榄鉴,編譯器只能把它們都當(dāng)作模塊外部的函數(shù)和變量來處理。
模塊內(nèi)部調(diào)用或跳轉(zhuǎn)
第一種類型應(yīng)該是最簡單的蛉抓,那就是模塊內(nèi)部調(diào)用庆尘。因?yàn)楸徽{(diào)用的函數(shù)與調(diào)用者處于同一個(gè)模塊,它們之間的相對位置是固定的巷送,所以這種情況比較簡單驶忌。對于現(xiàn)代系統(tǒng)來講,模塊內(nèi)部的跳轉(zhuǎn)笑跛、函數(shù)調(diào)用都是可以是相對地址調(diào)用付魔,或者是基于寄存器的相對調(diào)用,所以對于這種指令是不需要重定位的堡牡。模塊內(nèi)部數(shù)據(jù)訪問
接著來看看第二種類型抒抬,模塊內(nèi)部的數(shù)據(jù)訪問。很明顯晤柄,指令中不能直接包含數(shù)據(jù)的絕對地址擦剑,那么唯一的辦法就是尋址。我們知道芥颈,一個(gè)模塊前面一般是若干個(gè)頁的代碼惠勒,后面緊跟著若干個(gè)頁的數(shù)據(jù),這些頁之間的相對位置是固定的爬坑,也就是說纠屋,任何一條指令與它需要訪問的模塊內(nèi)部數(shù)據(jù)之間相對位置是固定的,那么只需要相對于當(dāng)前指令加上固定的偏移量就可以訪問模塊內(nèi)部數(shù)據(jù)了盾计。-
模塊間數(shù)據(jù)訪問
模塊間的數(shù)據(jù)訪問比模塊內(nèi)部稍微麻煩一點(diǎn)售担,因?yàn)槟K加的數(shù)據(jù)訪問目標(biāo)地址要等到裝載時(shí)才能決定赁遗,比如上面例子中的變量b,它被定義在其他模塊中族铆,并且該地址在裝載時(shí)才能確定岩四。我們前面提到要使得代碼地址無關(guān),基本思想就是把跟地址相關(guān)的部分放到數(shù)據(jù)段里面哥攘,很明顯剖煌,這些其他模塊的全局變量的地址是跟模塊裝載地址有關(guān)的。ELF的做法是在數(shù)據(jù)段里面建立一個(gè)指向這些變量的指針數(shù)組逝淹,也被稱為全局偏移表(Global Offset Table)耕姊,當(dāng)代碼需要引用改全局變量時(shí),可以通過GOT中的相對的項(xiàng)間接引用栅葡,它的基本機(jī)制如下圖:

當(dāng)指令中需要訪問變量b時(shí)茉兰,程序會(huì)先找到GOT,然后根據(jù)GOT中變量所對應(yīng)的項(xiàng)找到變量的目標(biāo)地址妥畏。每個(gè)變量都對應(yīng)一個(gè)4個(gè)字節(jié)的地址邦邦,鏈接器在裝載模塊的時(shí)候會(huì)查找每個(gè)變量所在的地址,然后填充GOT中的各個(gè)項(xiàng)醉蚁,以確保每個(gè)指針?biāo)赶虻牡刂氛_燃辖。由于GOT本身是放在數(shù)據(jù)段的,所以它可以在模塊裝載時(shí)被修改网棍,并且每個(gè)進(jìn)程都可以有獨(dú)立的副本黔龟,互相不受影響。那GOT是如何做到指令的地址無關(guān)性的呢滥玷?從第二種類型的數(shù)據(jù)訪問我們了解到氏身,模塊在編譯時(shí)可以確定模塊內(nèi)部變量相對于當(dāng)前指令的偏移量,那么我們也可以在編譯時(shí)確定GOT相對于當(dāng)前指令的偏移惑畴。確定GOT的位置跟上面的訪問變量a的方法基本一樣蛋欣,通過得到PC值然后加上一個(gè)偏移量,就可以得到GOT的位置如贷。然后我們根據(jù)變量地址在GOT中的偏移量就可以得到變量的地址陷虎,當(dāng)然GOT中的每個(gè)地址對于哪個(gè)變量是由編譯器決定的。
模塊間調(diào)用杠袱、跳轉(zhuǎn)
對于模塊間的調(diào)用和跳轉(zhuǎn)尚猿,我們也可以采用上面類型四的方法來解決。與上面的類型有所不同的是楣富,GOT中相應(yīng)的項(xiàng)保存的是目標(biāo)函數(shù)的地址凿掂,當(dāng)模塊需要調(diào)用目標(biāo)函數(shù)時(shí),可以通過GOT中的項(xiàng)進(jìn)行間接跳轉(zhuǎn)纹蝴,基本原理入下圖所示:

這種方法很簡單庄萎,但是存在一些性能問題踪少,實(shí)際上ELF采用了一種更為復(fù)雜和精巧的方法,在動(dòng)態(tài)鏈接優(yōu)化中進(jìn)行介紹糠涛。

Q&A
Q:如果一個(gè)共享對象的lib.so中定義了一個(gè)全局變量G秉馏,而進(jìn)程A和進(jìn)程B都使用了lib.so,那么當(dāng)進(jìn)程A改變了這個(gè)全局變量G的值時(shí),進(jìn)程B中的G會(huì)受到影響嗎脱羡?
A:不會(huì)。因?yàn)楫?dāng)lib.so被兩個(gè)進(jìn)程加載時(shí)免都,它的數(shù)據(jù)段部分在每個(gè)進(jìn)程中都有獨(dú)立的副本锉罐,從這個(gè)角度看绕娘,共享對象中的全局變量實(shí)際上和定義在程序內(nèi)部的全局變量沒有什么區(qū)別绢陌。任何一個(gè)進(jìn)程訪問的只是自己的那個(gè)副本挨下,而不會(huì)影響其他進(jìn)程。那么如果我們把這個(gè)問題的條件改成同一個(gè)進(jìn)程中的線程A和線程B脐湾,它們是否看得到對方對lib.so中全局變量G的修改呢臭笆?對于同一個(gè)進(jìn)程的兩個(gè)線程來說,它們訪問的是同一個(gè)進(jìn)程地址空間秤掌,也就是同一個(gè)lib.so的副本愁铺,所以它們對G的修改,對方都是看得見的闻鉴。
那么我們可不可以做到跟前面相反的情況呢茵乱?比如要求兩個(gè)進(jìn)程共享一個(gè)共享對象的副本或要求兩個(gè)線程訪問全局變量的不同副本报腔,這兩種需求都是存在的忌栅,比如多個(gè)進(jìn)行可以共享一個(gè)全局變量就可以用來實(shí)現(xiàn)進(jìn)程間的通信;而多個(gè)線程訪問全局變量的不同副本可以防止不同線程之間對全局變量的干擾挠他。實(shí)際上這兩種需求都是有相應(yīng)的解決方法的蚀苛,多進(jìn)程共享全局變量又被叫做"共享數(shù)據(jù)段"在验。而多個(gè)線程訪問不同的全局變量副本又被叫做"線程私有存儲(chǔ)"(Thread Local Stroage)。
影響動(dòng)態(tài)鏈接性能的主要問題
- 動(dòng)態(tài)鏈接下對全局和靜態(tài)的數(shù)據(jù)都有進(jìn)行GOT定位堵未,然后間接尋址腋舌;對于模塊間的調(diào)用也要先定位GOT,然后再進(jìn)行間接跳轉(zhuǎn)渗蟹,如此一來块饺,程序的運(yùn)行速度必然會(huì)減慢赞辩。;
- 動(dòng)態(tài)鏈接的鏈接工作在運(yùn)行時(shí)完成授艰,即程序開始執(zhí)行使辨嗽,動(dòng)態(tài)鏈接器都要進(jìn)行一次鏈接工作,動(dòng)態(tài)鏈接器會(huì)尋找并裝載所需要的共享對象淮腾,然后進(jìn)行符號查找重定位等工作糟需,這些工作勢必減慢程序的啟動(dòng)速度。
延遲綁定(PLT)
在動(dòng)態(tài)鏈接下谷朝,程序模塊之間包含了大量的函數(shù)引用(全局變量往往比較少洲押,因?yàn)榇罅咳肿兞繒?huì)導(dǎo)致模塊間耦合變大)。所以在程序開始執(zhí)行之前圆凰,動(dòng)態(tài)鏈接會(huì)消耗不少時(shí)間用于解決模塊之間的函數(shù)引用的符號查找以及重定位杈帐,這也是我們上面提到的減慢動(dòng)態(tài)鏈接的第二個(gè)原因。不過可以想象专钉,在一個(gè)程序運(yùn)行過程中挑童,可能很多函數(shù)在程序執(zhí)行完都不會(huì)被用到,比如一些錯(cuò)誤處理函數(shù)或是一些用戶很少用到的功能模塊等跃须,如果一開始就把所有的函數(shù)都鏈接好實(shí)際上是一種浪費(fèi)站叼。所以ELF采用了一種叫做延遲綁定(Lazy Binding)的做法,基本的思想就是當(dāng)函數(shù)第一次被用到才進(jìn)行綁定(符號查找菇民、重定位等)大年,如果沒有用到則不進(jìn)行綁定。這樣的做法可以大大加快程序的啟動(dòng)速度玉雾,特別有利于一些大量函數(shù)引用和大量模塊的程序翔试。ELF 使用PLT(Procedure Linkage Table)的方法來實(shí)現(xiàn),這種方法使用了一些很精巧的指令程序來完成复旬。
看到這里想到了iOS 中NSObject類的+load和+initialize這兩個(gè)方法垦缅。
在程序啟動(dòng)時(shí),Runtime會(huì)去加載所有的類驹碍。在這一時(shí)期壁涎,如果類或者類的分類實(shí)現(xiàn)了+load方法,則會(huì)去調(diào)用這個(gè)方法志秃。
而+initialize方法是在類或子類第一次接收消息之前會(huì)被調(diào)用怔球,這包括類的實(shí)例對象或者類對象。如果類一直沒有被用到浮还,則這個(gè)方法不會(huì)被調(diào)用竟坛。
基于這兩個(gè)方法的特殊性,我們可以將類使用時(shí)所需要的一些前置條件在這兩個(gè)方法中處理。不過担汤,如果可能涎跨,應(yīng)該盡量放在+initialize中。因?yàn)?load方法是在程序啟動(dòng)時(shí)調(diào)用崭歧,勢必會(huì)影響到程序的啟動(dòng)時(shí)間隅很。而+initialize方法可以說是懶加載調(diào)用,只有用到才會(huì)去執(zhí)行率碾。
動(dòng)態(tài)鏈接器
動(dòng)態(tài)鏈接情況下叔营,可執(zhí)行文件的裝載與靜態(tài)鏈接情況基本一樣。首先操作系統(tǒng)會(huì)讀取可執(zhí)行文件的頭部所宰,檢查文件的合法性审编,然后從頭部中的“Progeam Header”中讀取每個(gè)“Segment”的虛擬地址、文件地址和屬性歧匈,并將它們映射到虛擬空間的相應(yīng)位置,這些步驟跟前面的靜態(tài)鏈接情況裝載基本無異砰嘁。在靜態(tài)鏈接情況下件炉,操作系統(tǒng)接著就可以把控制權(quán)轉(zhuǎn)交給可執(zhí)行文件的入口地址,然后開始執(zhí)行矮湘。
但是在動(dòng)態(tài)鏈接的情況下斟冕,操作系統(tǒng)還不能在裝載完成可執(zhí)行文件之后就把控制權(quán)交給可執(zhí)行文件,因?yàn)槲覀冎揽蓤?zhí)行文件依賴于很多共享對象缅阳。這時(shí)候磕蛇,可執(zhí)行文件里對很多外部符號的引用還處于無效地址狀態(tài),即還沒有相應(yīng)的共享對象中的實(shí)際位置鏈接起來十办,所以在映射完可執(zhí)行文件之后秀撇,操作系統(tǒng)會(huì)先啟動(dòng)一個(gè)動(dòng)態(tài)鏈接器(Dynamaic Linker)。
在Linux下向族,動(dòng)態(tài)鏈接器ld.so實(shí)際上是一個(gè)共享對象呵燕,操作系統(tǒng)同樣通過映射的方式將它加載到進(jìn)程地址空間中。操作系統(tǒng)在加載完動(dòng)態(tài)鏈接器之后件相,就將控制權(quán)交給動(dòng)態(tài)鏈接的入口地址再扭。當(dāng)動(dòng)態(tài)鏈接器得到控制權(quán)之后,它開始執(zhí)行一系列自身的初始化操作夜矗,然后根據(jù)當(dāng)前的環(huán)境參數(shù)泛范,開始對可執(zhí)行文件進(jìn)行動(dòng)態(tài)鏈接工作。當(dāng)所有鏈接工作完成以后紊撕,動(dòng)態(tài)鏈接器會(huì)將控制權(quán)轉(zhuǎn)交給可執(zhí)行文件的入口地址罢荡,程序開始正式執(zhí)行。
關(guān)于動(dòng)態(tài)鏈接器本身的細(xì)節(jié)實(shí)雖然不再展開,但是作為一個(gè)非常有特點(diǎn)的柠傍,也很特殊的共享對象麸俘,關(guān)于動(dòng)態(tài)鏈接器的實(shí)現(xiàn)的幾個(gè)問題還是很值得思考的:
- 動(dòng)態(tài)鏈接器本身是動(dòng)態(tài)鏈接還是靜態(tài)鏈接的?
動(dòng)態(tài)鏈接器本身應(yīng)該是靜態(tài)鏈接的惧笛,它不是依賴于其他共享對象从媚,動(dòng)態(tài)鏈接器本身是用來幫助其他ELF文件解決共享對象的依賴問題,如果它也是依賴于其他共享對象患整,那么誰來幫它解決依賴問題拜效?所以它本身必須不依賴與其他共享對象。這一點(diǎn)可以使用 ldd 來判斷:
ldd /lib/ld-linux.so.2
staticall linked
- 動(dòng)態(tài)鏈接器本身必須是PIC的嗎各谚?
是不是PIC對于動(dòng)態(tài)鏈接器來說并不關(guān)鍵紧憾,動(dòng)態(tài)鏈接器可以是PIC的也可以不是,但往往使用PIC會(huì)更加簡單一些昌渤。一方面赴穗,如果不是PIC的話,會(huì)使得代碼段無法共享膀息,浪費(fèi)內(nèi)存般眉,另一方面也會(huì)是ld.so本身初始化更加復(fù)雜,因?yàn)樽耘e時(shí)還需要對代碼段進(jìn)行重定位潜支。實(shí)際上ld-linux.so.2是PIC的甸赃。 - 動(dòng)態(tài)鏈接器可以被當(dāng)做可執(zhí)行文件執(zhí)行,那么裝載地址應(yīng)該是多少冗酿?
ld.so的裝載地址跟一般的共享對象沒區(qū)別埠对,即0x00000000。這個(gè)裝載地址是一個(gè)無效的裝載地址裁替,作為一個(gè)共享庫项玛,內(nèi)核在裝載它時(shí)會(huì)為其選擇一個(gè)合適的裝載地址。
".interp" 段
那么操作系統(tǒng)中哪個(gè)才是動(dòng)態(tài)鏈接器呢弱判,它的位置由誰決定稍计?是不是所有的*NIX系統(tǒng)的動(dòng)態(tài)鏈接器都位于/lib/ld.so呢?實(shí)際上裕循,動(dòng)態(tài)鏈接器的位置既不是又系統(tǒng)配置指定臣嚣,也不是由環(huán)境參數(shù)決定,而是由ELF可執(zhí)行文件決定剥哑。在動(dòng)態(tài)鏈接的ELF可執(zhí)行文件中硅则,有一個(gè)專門的段叫做“.interp”段(“interp”是“interpreter”(解釋器)的縮寫)。
“.interp”的內(nèi)容很簡單株婴,里面保存的就是一個(gè)字符串怎虫,這個(gè)字符串就是可執(zhí)行文件所需要的動(dòng)態(tài)鏈接器的路徑暑认,在Linux下,可執(zhí)行文件所需要的動(dòng)態(tài)鏈接器的路徑幾乎都是“l(fā)ib/ld-linux.so.2”大审,其他*nix操作系統(tǒng)可能會(huì)有不同的路徑蘸际。
".dynamic"段
類似于“.interp”這樣的段,ELF中還有幾個(gè)段也是專門用與動(dòng)態(tài)鏈接的徒扶,比如“.dynamic”段和“.dynsym”段等
動(dòng)態(tài)鏈接ELF中最重要的結(jié)構(gòu)應(yīng)該是“.dynamic”段粮彤,這個(gè)段里面保存了動(dòng)態(tài)鏈接器所需要的基本信息,比如依賴那些共享對象姜骡、動(dòng)態(tài)鏈接符號表的位置导坟、動(dòng)態(tài)鏈接重定位表的位置、共享對象初始化代碼的地址等圈澈。
動(dòng)態(tài)符號表
為了完成動(dòng)態(tài)鏈接惫周,最關(guān)鍵的還是所依賴的符號和相關(guān)文件的信息。我們知道在靜態(tài)鏈接中康栈,有一個(gè)專門的段叫做符號表“.symbtab”(Symbol Table)递递,里面保存了所有關(guān)于該目標(biāo)的文件的符號的定義和引用。動(dòng)態(tài)鏈接的符號表示實(shí)際上它和靜態(tài)鏈接十分相似啥么,比如前面列子中的Program1程序依賴于Lib.so,引用到了里面的foobar()函數(shù)登舞。那么對于Progarml來說,我們往往稱Progarm1導(dǎo)入(Import)了foobar函數(shù)饥臂,foobar是Program1的導(dǎo)入函數(shù)(Import Function);而站在Lib.so的角度來看似踱,它實(shí)際上定義了foobar()函數(shù)隅熙,并且提供給其他模塊使用,我們往往稱Lib.so導(dǎo)出(Export)了foobar()函數(shù)核芽,foobar是Lib.so的導(dǎo)出函數(shù)(Export Function)囚戚。把這種導(dǎo)入導(dǎo)出關(guān)系放到靜態(tài)鏈接的情形下,我們可以把它們叫看作普通的函數(shù)定義和引用轧简。
為了表示動(dòng)態(tài)鏈接這些模塊之間的符號導(dǎo)入導(dǎo)出的關(guān)系驰坊,ELF專門有一個(gè)叫做動(dòng)態(tài)符號表(Dynamic Symbol Table)d段用來保存這些信息,這個(gè)段的段名通常叫做".dynsym"(Dynamic Symbol)哮独。與".symtab"不同的是拳芙,".dynsym"只保存了與動(dòng)態(tài)鏈接相關(guān)的符號,對于那些模塊內(nèi)部的符號皮璧,比如模塊私有變量則不保存舟扎。很多時(shí)候動(dòng)態(tài)鏈接的模塊同時(shí)擁有".dynsym"和".symtab"兩個(gè)表,".symtab"中往往包含了所有符號悴务,包括".dynsym"中的符號睹限。
與".symtab"類似,動(dòng)態(tài)符號表也需要一些輔助的表,比如用于保存符號的字符串表羡疗。靜態(tài)鏈接時(shí)叫做符號字符串表“symtab”(String Table)染服,在這里就是動(dòng)態(tài)符號字符串".dynstr"(Dynamic Stirng Table);由于動(dòng)態(tài)鏈接下,我們需要在程序運(yùn)行時(shí)查找符號叨恨,為了加快符號的查找過程柳刮,往往還有輔助的符號哈希表(“.hash”)。
動(dòng)態(tài)鏈接符號表的結(jié)構(gòu)與靜態(tài)鏈接符號表幾乎一樣特碳,我們可以簡單地將導(dǎo)入函數(shù)看作是對其他目標(biāo)文件中函數(shù)的引用诚亚;把導(dǎo)出函數(shù)看作是在本目標(biāo)文件定義的函數(shù)就可以了。
動(dòng)態(tài)鏈接重定位表
共享對象需要重定位的主要原因是導(dǎo)入符號的存在午乓。動(dòng)態(tài)鏈接下站宗,無論是可執(zhí)行文件或共享對象,一旦它依賴其他共享對象益愈,也就是說有導(dǎo)入的符號是梢灭,那么它的代碼或數(shù)據(jù)中就會(huì)有對于導(dǎo)入符號的引用。在編譯時(shí)這些導(dǎo)入符號的地址未知蒸其,在靜態(tài)鏈接中敏释,這些未知的地址引用在最終的鏈接時(shí)被修正。但是在動(dòng)態(tài)鏈接中摸袁,導(dǎo)入符號的地址在運(yùn)行時(shí)才確定钥顽,所以需要在運(yùn)行時(shí)將這些導(dǎo)入的引用修正,即需要重定位靠汁。
動(dòng)態(tài)鏈接的步驟
- 啟動(dòng)動(dòng)態(tài)鏈接器本身蜂大;
- 裝載所需要的共享對象;
- 重定位和初始化蝶怔;
顯示運(yùn)行時(shí)鏈接
支持動(dòng)態(tài)鏈接的系統(tǒng)問問都支持一種更加靈活的模塊加載方式奶浦,叫做顯示運(yùn)行時(shí)鏈接(Explicit Run-time Linking),有時(shí)候也叫做運(yùn)行時(shí)加載踢星。也就是讓程序自己在運(yùn)行時(shí)控制加載指定的模塊澳叉,并且可以在不需要的時(shí)候?qū)⒃撃K卸載。從前面的了解到的來看沐悦,如果動(dòng)態(tài)鏈接器可以在運(yùn)行時(shí)將共享模塊裝載進(jìn)內(nèi)存并且可以進(jìn)行重定位操作成洗,那么這種運(yùn)行時(shí)加載在理論上也是很容易實(shí)現(xiàn)的。而且一般的共享對象不需要然后修改就可以進(jìn)行運(yùn)行時(shí)裝載藏否,這種共享對象往往被叫動(dòng)態(tài)裝載庫(Dynamic Loading Library)泌枪,其實(shí)本質(zhì)上它跟一般共享對象沒什么區(qū)別,只是程序開發(fā)者使用它的角度不同秕岛。
這種運(yùn)行時(shí)加載使得程序的模塊組織更加靈活碌燕,可以用來實(shí)現(xiàn)一些諸如插件误证、驅(qū)動(dòng)等功能。當(dāng)程序需要用到某個(gè)插件或者驅(qū)動(dòng)的時(shí)候修壕,才講相應(yīng)的模塊裝載進(jìn)行愈捅,而不需要從一開始就講他們?nèi)垦b載進(jìn)來,從而減少了程序啟動(dòng)時(shí)間和內(nèi)存慈鸠。并且程序可以在運(yùn)行的時(shí)候重新加載某個(gè)模塊蓝谨,這樣使得程序本身不必重新啟動(dòng)而實(shí)現(xiàn)模塊的增加、刪除青团、更新等譬巫,這對于很多需要長期運(yùn)行的程序來說是很大的優(yōu)勢。最常見的例子是Web 服務(wù)器程序督笆,對于Web服務(wù)器程序來說芦昔,它需要根據(jù)配置來選擇不同的腳本解釋器。數(shù)據(jù)庫連接驅(qū)動(dòng)等娃肿,對于不同的腳本解釋器分別做成一個(gè)獨(dú)立的模塊咕缎,當(dāng)Web服務(wù)器需要某種腳本解釋器的時(shí)候可以將其加載進(jìn)來;這對于數(shù)據(jù)庫連接的驅(qū)動(dòng)程序也是一樣的原理料扰。另外對于一個(gè)可靠的Web服務(wù)器來說凭豪,長期的運(yùn)行是必要的保證,如果我們需要增加某種腳本解釋器晒杈,或者摸個(gè)腳本解釋器需要升級嫂伞,則可以通知Web服務(wù)器程序重新裝載該共享模塊以實(shí)現(xiàn)相應(yīng)的目的。
在Linux中拯钻,從文件本身的格式上來看帖努,動(dòng)態(tài)庫實(shí)際上跟一般的共享對象沒區(qū)別。主要的區(qū)別是共享對象是由動(dòng)態(tài)鏈接器在程序啟動(dòng)之前負(fù)責(zé)裝載和鏈接的说庭,這一系列步驟都是由動(dòng)態(tài)鏈接器自動(dòng)完成然磷,對于程序本身是透明的郑趁;而動(dòng)態(tài)庫的裝載則是通過一系列又動(dòng)態(tài)鏈接器提供API刊驴,具體講共有4個(gè)函數(shù):
-
打開動(dòng)態(tài)庫(dlopen())
dlopen()函數(shù)用來打開一個(gè)動(dòng)態(tài)庫,并將其加載到進(jìn)程的地址空間寡润,完成初始化過程捆憎,它的C原型定義為void * dlopen(const char *filename,int flag);
第一個(gè)參數(shù)是被加載的路徑,如果是路徑是絕對路徑(以“/”開始的路徑)梭纹,則該函數(shù)將會(huì)嘗試直接打開該動(dòng)態(tài)庫躲惰;如果是相對路徑,那么dlopen()會(huì)嘗試在以一定的順序去查找該動(dòng)態(tài)庫文件:
第二個(gè)參數(shù)flag表示函數(shù)符號的解析方式变抽。
- RTLD_LAZY:表示使用延遲綁定础拨,函數(shù)第一次被用到時(shí)才進(jìn)行綁定氮块,即PLT機(jī)制;
- RTLD_NOW:表示當(dāng)模塊被加載時(shí)即完成所有的函數(shù)綁定工作诡宗,如果有任何未定義的符號音樂綁定工作沒法完成滔蝉,那么dlopen()就返回錯(cuò)誤;
- RTLD_GLOBAL:可以跟上面兩者任意一個(gè)一起使用(通過常量的“或”操作),它表示將被加載的模塊的全局符號合并到進(jìn)程的全局符號中塔沃,使得以后加載的模塊可以使用這些符號蝠引。
dlopen的返回值是被加載的模塊的句柄,這個(gè)句柄在后面使用的dlsym或者dlclose時(shí)需要用到蛀柴。如果記載模塊失敗螃概,則返回NULL。如果模塊已經(jīng)通過dlopen被加載過了鸽疾,那么返回的是同一個(gè)句柄吊洼。另外如果被加載的模塊之間有依賴關(guān)系,比如模塊A依賴于模塊B肮韧,那么程序員需要手動(dòng)加載被依賴的模塊融蹂,比如先加載B,再加載A弄企。
-
查找符號(dlsym())
dlsym 函數(shù)基本是運(yùn)行時(shí)裝載的核心部分超燃,我們可以通過這個(gè)函數(shù)找到所需要的符號,它的定義如下:void * dlsym(void * handle, char * symbol);
定義非常簡潔拘领,兩個(gè)參數(shù)意乓,第一個(gè)參數(shù)是由dlopen()返回的動(dòng)態(tài)庫的句柄;
第二個(gè)參數(shù)即所需要查找的符號的名字约素,一個(gè)以“\0”結(jié)尾的C字符串届良。如果dlsym()找到了相應(yīng)的符號,則返回該符號的值圣猎,沒有找到相應(yīng)的符號士葫,則返回NULL。dlsym()返回的值對于不同類型的符號送悔,意義是不同的慢显。如果查找的符號是個(gè)常量,那么它返回的是該常量的值欠啤。這里有一個(gè)問題是:如果常量的值剛好是NULL或者0呢荚藻,我們?nèi)绾闻袛郿lsym()是否找到了該符號呢?這個(gè)問題就要用到下面介紹的dlerror()函數(shù)了洁段。如果符號找到了应狱,那么dlerror()返回NULL,如果沒找到祠丝,deerror就會(huì)返回相應(yīng)的錯(cuò)誤信息疾呻。
這里說一下符號優(yōu)先級除嘹,當(dāng)許多共享模塊中的符號同名沖突時(shí),先裝入的符號優(yōu)先岸蜗,我們把這種優(yōu)先級方式成為裝載序列(Load Ordering)憾赁。
當(dāng)我們使用dlsym()進(jìn)行符號的地址查找工作時(shí),這個(gè)函數(shù)是不是也是按照裝載序列的優(yōu)先進(jìn)行符號的查找呢散吵?實(shí)際的情況是龙考,dlsym()對符號的查找優(yōu)先級分為兩種類型。- 裝載序列:如果我們是全局符號表中進(jìn)行符號查找矾睦,即dlopen()時(shí)晦款,參數(shù)filename為NULL,那么由于全局符號使用的裝載序列枚冗,所以dlsym()使用的也是裝載序列缓溅。
- 依賴序列(Dependency Ordering):我們是對某個(gè)通過dlopen()打開的共享對象進(jìn)行符號查找的話,那么采用依賴序列赁温,它是以被dlopen()打開的那個(gè)共享對象為根節(jié)點(diǎn)坛怪,對它所有依賴的共享對象進(jìn)行廣度優(yōu)先遍歷,直到找到符號為止股囊。
錯(cuò)誤處理(dlerror())
每次我們調(diào)用dlopen()袜匿、dlsym()或dlclose以后移袍,我們都可以調(diào)用dlerror()函數(shù)來判斷上一次調(diào)用是否成功晾嘶。dlerror()返回類型是char*岁疼,如果返回NULL抵赢,則表示上一次調(diào)用成功;如果不是圈匆,則返回相應(yīng)的錯(cuò)誤信息扑毡。關(guān)閉動(dòng)態(tài)庫(dlclose())
dlclose()的作用跟dlopen()剛還相反探遵,它的作用是將一個(gè)已經(jīng)記載好的模塊卸載柳沙。系統(tǒng)會(huì)維持一個(gè)加載引用計(jì)數(shù)器每次使用dlopen()加載某個(gè)模塊時(shí)岩灭,相應(yīng)的計(jì)數(shù)器被加一;每次使用dlclose()卸載某個(gè)模塊是赂鲤,相應(yīng)的計(jì)數(shù)器減一噪径。只有當(dāng)計(jì)數(shù)器值減到0時(shí),模塊才被真正地卸載掉蛤袒。
程序可以通過這幾個(gè)API對動(dòng)態(tài)庫進(jìn)行操作熄云。這幾個(gè)API的實(shí)現(xiàn)是在/lib/libdl.so.2里面膨更,它們的聲明和相關(guān)常量被定義在系統(tǒng)標(biāo)準(zhǔn)頭文件<dlfcn.h>妙真。
程序的內(nèi)存布局
一般來講:應(yīng)用程序使用的內(nèi)存空間里有如下“默認(rèn)”的區(qū)域:
- 棧:棧用于維護(hù)函數(shù)調(diào)用的上下文,離開了棧函數(shù)調(diào)用就沒辦法實(shí)現(xiàn)荚守。棧通常在用戶空間的最高地址處分配珍德,通常有數(shù)兆字節(jié)的大辛钒恪;
- 堆:堆是用來容納應(yīng)用程序動(dòng)態(tài)分配的內(nèi)存區(qū)域锈候,當(dāng)程序使用malloc或new分配內(nèi)存時(shí)薄料,得到的內(nèi)存來自堆里。堆通常存在于棧的下方(低地址方向)泵琳,在某些時(shí)候摄职,堆也可能沒有固定統(tǒng)一的存儲(chǔ)區(qū)域。堆一般比棧大很多获列,可以有幾十至數(shù)百兆字節(jié)的容量谷市。
- 可執(zhí)行文件的映像:這里存儲(chǔ)著可執(zhí)行文件在內(nèi)存里的映像。由裝載器在裝載時(shí)將可執(zhí)行文件的內(nèi)存讀取或映像到這里击孩。
- 保留區(qū):保留區(qū)并不是一個(gè)單一的內(nèi)存區(qū)域迫悠,而是對內(nèi)存中受到保護(hù)而禁止訪問的內(nèi)存區(qū)域的總稱,例如巩梢,大多數(shù)操作系統(tǒng)里创泄,極小的地址通常都是不允許訪問的,如NULL括蝠。通常C語言將無效指針賦值為0也是出于這個(gè)考慮鞠抑,因?yàn)?地址上正常情況下不可能有有效的可訪問數(shù)據(jù)。
Linux下一個(gè)進(jìn)程里典型的內(nèi)存布局如下:

上圖中忌警,有一個(gè)沒有介紹的區(qū)域:“動(dòng)態(tài)鏈接庫映射區(qū)”碍拆,這個(gè)區(qū)域用于映射裝載的動(dòng)態(tài)鏈接庫。在Linux下慨蓝,如果可執(zhí)行文件依賴其他共享庫感混,那么系統(tǒng)就會(huì)為它從0x40000000開始的地址分配相應(yīng)的空間,并將共享庫裝載入到該空間礼烈。
圖中箭頭標(biāo)明了幾個(gè)大小可變的區(qū)的尺寸增長方向弧满,在這里可以清晰地看出棧向低地址增長,堆向高地址增長此熬。當(dāng)椡ノ兀或者堆現(xiàn)有的大小不夠用時(shí),它將按照圖中的增長方向擴(kuò)大自身的尺寸犀忱,直到預(yù)留空間被用完為止募谎。
Q&A
Q:寫程序時(shí)常常出現(xiàn)“段錯(cuò)誤(segment fault)”或“非法操作,改內(nèi)存地址不能read/write”的錯(cuò)誤阴汇,這是怎么回事兒数冬?
A:這是典型的非法指針解引用造成的錯(cuò)誤。當(dāng)指針指向一個(gè)不允許讀或?qū)懙膬?nèi)存地址時(shí)搀庶,而程序卻試圖利用指針來讀或?qū)懺摰刂返臅r(shí)候拐纱,就會(huì)出現(xiàn)這個(gè)錯(cuò)誤铜异。在Linux或Windows的內(nèi)存布局中,有些地址是始終不能讀寫的秸架,例如0地址揍庄。還有些地址是一開始不允許讀寫,應(yīng)用程序必須事先請求獲取這些地址的讀寫權(quán)东抹,或者某些地址一開始并沒有映射到實(shí)際的物理內(nèi)存蚂子,應(yīng)用程序必須事先請求將這些地址映射到實(shí)際的物理地址(commit),之后才能夠自由地讀寫這篇內(nèi)存缭黔。當(dāng)一個(gè)指針指向這些區(qū)域的時(shí)候缆镣,對它指向的內(nèi)存進(jìn)行讀寫就會(huì)引發(fā)錯(cuò)誤。造成這樣的最普遍的原因有兩種:
- 程序員將指針初始化為NULL试浙,之后卻沒有給它一個(gè)合理的值就開始使用指針董瞻。
- 程序員沒有初始化棧上的指針,指針的值一般會(huì)是隨機(jī)數(shù)田巴,之后就直接開始使用指針钠糊。
因此,如果你的程序出現(xiàn)了這樣的錯(cuò)誤壹哺,請著重檢查指針的使用情況抄伍。
棧
棧(stack)是現(xiàn)代計(jì)算機(jī)程序里最為重要的概念之一,幾乎每個(gè)程序都使用了棧管宵,沒有棧就沒有函數(shù)截珍,沒有局部變量,也就沒有我們?nèi)缃衲軌蚩匆姷乃杏?jì)算機(jī)語言箩朴。先了解一下傳統(tǒng)棧的定義:
在經(jīng)典的計(jì)算機(jī)科學(xué)中岗喉,棧被定義為一個(gè)特殊的容器,用戶可以將數(shù)據(jù)壓入棧中(入棧push),也可以將已經(jīng)壓入棧中的數(shù)據(jù)彈出(出棧炸庞,pop)钱床,但棧這個(gè)容器必須遵循一條規(guī)則:先入棧的數(shù)據(jù)后出棧(First In Last Out,FILO)。
在計(jì)算機(jī)系統(tǒng)中埠居,棧是一個(gè)具有以上屬性的動(dòng)態(tài)內(nèi)存區(qū)域查牌。程序可以將數(shù)據(jù)壓入棧中,也可以將數(shù)據(jù)從棧頂彈出滥壕。壓棧操作使得棧增大纸颜,而彈出操作使棧減小。
椧镩伲總是向下增長的胁孙。在i386下,棧頂由稱為esp的寄存器進(jìn)行定位。壓棧的操作棧頂?shù)牡刂窚p小浊洞,彈出的操作使得棧頂?shù)牡刂吩龃蟆?/p>
棧在程序運(yùn)行中具有舉足輕重的地位。最重要的胡岔,棧保存了一個(gè)函數(shù)調(diào)用所需要的維護(hù)信息法希,這常常稱為堆棧幀(Stack Frame)或活動(dòng)記錄(Activate Record)。堆棧幀一般包括如下幾個(gè)方面內(nèi)容:
- 函數(shù)的返回地址和參數(shù)靶瘸。
- 臨時(shí)變量:包括函數(shù)的非靜態(tài)局部變量以及編譯器自動(dòng)生成的其他臨時(shí)變量苫亦。
- 保存的上下文:包括在函數(shù)調(diào)用前后需要保持不變的寄存器。
棧的調(diào)用慣例
毫無疑問怨咪,函數(shù)的調(diào)用方和被調(diào)用方對于函數(shù)如何調(diào)用須要有一個(gè)明確的約定屋剑,只有雙方都準(zhǔn)守同樣的約定,函數(shù)才能被正確的調(diào)用诗眨,這樣的約定就稱為調(diào)用慣例(Call Convention)唉匾。一個(gè)調(diào)用慣例一般會(huì)規(guī)定如下幾個(gè)方面的內(nèi)容。
- 函數(shù)參數(shù)的傳遞順序和方式
函數(shù)參數(shù)的傳遞有很多種方式匠楚,最常見的一種是通過棧的傳遞巍膘。函數(shù)的調(diào)用方將參數(shù)壓入棧中,函數(shù)自己在從棧中將參數(shù)取出芋簿。對于有多個(gè)參數(shù)的函數(shù)峡懈,調(diào)用慣例要規(guī)定函數(shù)調(diào)用方將參數(shù)壓棧的順序:是從左至右,還是從右至左与斤。有些調(diào)用慣例還允許使用寄存器傳遞參數(shù)肪康,以提高性能。 - 棧的維護(hù)方式
在函數(shù)將參數(shù)壓棧之后撩穿,函數(shù)體會(huì)被調(diào)用磷支,此后需要將被壓入棧中的參數(shù)全部彈出,以使得棧在函數(shù)調(diào)用前后保持一致食寡。這個(gè)彈出的工作可以由函數(shù)的調(diào)用方來完成齐唆,也可以由函數(shù)本身來完成。 - 名字修飾(Name-mangling)的策略
為了鏈接的時(shí)候?qū)φ{(diào)用慣例進(jìn)行區(qū)分冻河,調(diào)用管理要對函數(shù)本身的名字進(jìn)行修飾箍邮。不同的調(diào)用管理有不同的名字修飾策略。
函數(shù)返回值傳遞
書中的例子和探討很長叨叙,這里我講一下我自己的理解
例如:
test(a,b)
{
return a + b;
}
int main()
{
int a = 1;
int b = 1;
int n = test(a,b);
return 0;
}
- 首先main函數(shù)在棧上額外開辟了一片锭弊,并將這塊空間的一部分作為傳遞返回值的臨時(shí)對象,這里稱為temp擂错。
- 將temp對象的地址作為隱藏參數(shù)傳遞給test函數(shù)味滞。
- test函數(shù)將數(shù)據(jù)拷貝給temp對象,并將temp對象的地址傳出。
- test返回之后剑鞍,main函數(shù)將temp對象的內(nèi)容拷貝給n昨凡。
當(dāng)然以上過程會(huì)有一些匯編代碼,這里省去了匯編代碼的解釋蚁署。
堆
光有棧對于面向過程的程序設(shè)計(jì)還遠(yuǎn)遠(yuǎn)不夠便脊,因?yàn)闂I系臄?shù)據(jù)函數(shù)返回的時(shí)候就會(huì)被釋放掉,所以無法將數(shù)據(jù)傳遞至函數(shù)外部光戈。而全局變量沒有辦法動(dòng)態(tài)產(chǎn)生哪痰。只能在編譯的時(shí)候定義,有很多情況下缺乏表現(xiàn)力久妆。在這種情況下晌杰,堆(Heap)是唯一的選擇。
堆是一塊巨大的內(nèi)存空間筷弦,常常占據(jù)整個(gè)虛擬空間的絕大部分肋演。在這片空間里,程序可以請求一塊連續(xù)內(nèi)存烂琴,并自由地使用惋啃,這塊內(nèi)存在程序主動(dòng)放棄之前都會(huì)一直保持有效。
如果每次程序申請或者釋放堆空間都需要系統(tǒng)調(diào)用监右,實(shí)際這這樣的做法是比較耗費(fèi)性能的边灭。所以程序向操作系統(tǒng)申請一塊適當(dāng)大小的堆空間,然后由程序自己管理這塊空間健盒。管理著堆的空間分配往往是程序的運(yùn)行庫绒瘦。
運(yùn)行庫相當(dāng)于是向操作系統(tǒng)“批發(fā)”了一塊較大的堆空間,然后“零售”給程序用扣癣。當(dāng)全部“售完”或程序有大量的內(nèi)存需求時(shí)惰帽,再根據(jù)實(shí)際需求向操作系統(tǒng)“進(jìn)貨”。當(dāng)然運(yùn)行庫向程序零售堆空間時(shí)父虑,必須管理它批發(fā)的堆空間该酗,不能把同一塊地址出售兩次,導(dǎo)致地址沖突士嚎。于是運(yùn)行庫需要一個(gè)算法來管理堆空間呜魄,這個(gè)算法就是堆的分配算法。
堆分配算法
如何管理一大塊連續(xù)的內(nèi)存空間莱衩,能夠按照需求分配爵嗅、釋放其中的空間,這就是堆分配的算法笨蚁。堆的分配算法有很多種睹晒,有很簡單的(比如下面介紹的這幾種算法)趟庄,也有很復(fù)雜的、適應(yīng)于某些高性能或者有其他特殊要求的場合伪很。
空閑鏈表
空閑鏈表(Free List)的方法實(shí)際上就是把堆中各個(gè)空閑的塊安裝鏈表的方式連接起來戚啥,當(dāng)用戶請求一塊空間時(shí),可以遍歷整個(gè)列表锉试,直到找到合適大小的塊并且將它拆分猫十;當(dāng)用戶釋放空間將它合并到空閑鏈表中。
我們首先需要一個(gè)數(shù)據(jù)結(jié)構(gòu)來登記空間里所有的空閑空間键痛,這樣才能知道程序請求空間的時(shí)候該分配給它那一塊內(nèi)存炫彩。這樣的結(jié)構(gòu)有很多種匾七,這里介紹最簡單的一種--空閑鏈表絮短。
空閑鏈表是這樣一種結(jié)構(gòu),在堆里的每一個(gè)空閑空間的開頭(或結(jié)尾)有一個(gè)頭(header)昨忆,頭結(jié)構(gòu)里記錄了上一個(gè)(prev)和下一個(gè)(next)空閑塊的地址丁频,也就是說,所有的空閑塊形成了一個(gè)鏈表邑贴。如下圖:

在這樣的結(jié)構(gòu)下如何分配空間呢席里?
首先在空閑鏈表里查找足夠容納請求大小的一個(gè)空閑塊,然后將這個(gè)塊分兩部分拢驾,一部分為程序請求的空間奖磁,另一部分為剩余下來的空閑空間。下面將鏈表里對應(yīng)的空閑塊的結(jié)構(gòu)更新為新的剩下的空閑塊繁疤,如果剩下的空閑塊大小為0咖为,則直接將這個(gè)結(jié)構(gòu)從鏈表里刪除。下圖演示了用戶請求一塊和空閑塊恰好相等的內(nèi)存空間后堆的狀態(tài)稠腊。

這樣的空閑鏈表實(shí)現(xiàn)盡管簡單躁染,但是在釋放空間的時(shí)候,給定一個(gè)已分配塊的指針架忌,堆無法確定這個(gè)塊的大小。一個(gè)簡單的解決方法是當(dāng)用戶請求k個(gè)字節(jié)空間的時(shí)候叹放,我們實(shí)際上分配k+4個(gè)字節(jié)饰恕,這4個(gè)字節(jié)用于存儲(chǔ)該分配的大小,即k+4井仰。這樣釋放該內(nèi)存的時(shí)候只要看這4個(gè)字節(jié)的值懂盐,就能知道該內(nèi)存的大小,然后將其插入到空閑鏈表里就可以了糕档。
當(dāng)然這僅僅是最簡單的一種分配策略莉恼,這樣的思路存在很多問題拌喉。例如,一旦鏈表被破壞俐银,或者記錄長度的那4字節(jié)被破壞尿背,整個(gè)堆就無法正常工作,而這些數(shù)據(jù)恰恰很容易被越界讀寫所接觸到捶惜。位圖
針對空閑鏈表的弊端田藐,另一種分配方式顯得更賤穩(wěn)健。這種方式稱為位圖(Bitmap)吱七。其核心思想是將整個(gè)堆劃分為大量的塊(block)汽久,每個(gè)塊的大小相同。當(dāng)用戶請求內(nèi)存的時(shí)候踊餐,總是分配整數(shù)個(gè)塊的空間給用戶景醇,第一個(gè)塊我們稱為已分配區(qū)域的頭(Head),其余的稱為已分配區(qū)域的主體(Body)吝岭。而我們可以使用一個(gè)整數(shù)數(shù)組來記錄塊的使用情況三痰,由于每個(gè)塊只有頭/主體/空閑三種狀態(tài),因此僅僅需要兩位即可表示一個(gè)塊窜管,因此稱為位圖散劫。
假設(shè)堆的大小為1MB,那么我們讓一個(gè)塊的大小為128字節(jié)幕帆,那么總共就有1M/128=8K個(gè)塊获搏,可以用8k/(32/2)=512個(gè)int來存儲(chǔ)。這有512個(gè)int數(shù)組就是一個(gè)位圖失乾,其中每兩個(gè)位代表一個(gè)塊常熙。當(dāng)用戶請求300字節(jié)的內(nèi)存時(shí),堆分配給用戶3個(gè)塊仗扬,并將位圖的相應(yīng)位置標(biāo)記為頭或軀體症概。下圖為一個(gè)這樣的堆的實(shí)例。

這個(gè)堆分配了3片內(nèi)存早芭,分別有2/4/1個(gè)塊彼城,用虛線框標(biāo)出。其對應(yīng)的位圖將是:
(HIGH)11 00 00 10 10 10 10 11 00 00 00 00 00 00 00 10 11 (LOW)
其中 11 表示 H(Head)退个,10表示主題(Body)募壕,00表示空閑(Free)。
這樣的實(shí)現(xiàn)方式有幾個(gè)優(yōu)點(diǎn):
- 速度快:由于整個(gè)堆的空閑信息存儲(chǔ)在一個(gè)數(shù)組內(nèi)语盈,因此訪問該數(shù)組是cache容易命中舱馅。
- 穩(wěn)定性好:為了避免用戶越界讀寫數(shù)據(jù)破壞,我們只須簡單的備份一下位圖即可刀荒。而且即使部分?jǐn)?shù)據(jù)被破壞代嗤,也不會(huì)導(dǎo)致整個(gè)堆無法工作棘钞。
- 塊不需要額外信息,易于管理干毅。
當(dāng)然缺點(diǎn)也是顯而易見的:
- 分配內(nèi)存的時(shí)候容易產(chǎn)生碎片宜猜。例如分配300個(gè)字節(jié),實(shí)際分配了3個(gè)塊即384個(gè)字節(jié)硝逢,浪費(fèi)了84個(gè)字節(jié)姨拥。
- 如果堆很大,或者設(shè)定的一個(gè)塊很星搿(這樣可以減少碎片)叫乌,那么這個(gè)位圖將會(huì)很大,可能失去cache命中率高的優(yōu)勢徽缚,而且也會(huì)浪費(fèi)一定的空間憨奸。針對這種情況,我們可以使用多級位圖猎拨。
-
對象池
以上介紹的堆管理方法是最為基本的兩種膀藐,實(shí)際上在一些場合屠阻,被分配對象的大小是較為固定的幾個(gè)值红省,這時(shí)候我們可以針對這樣的特征設(shè)計(jì)一個(gè)更為高效的堆算法,稱為對象池国觉。
對象池的思路很簡單吧恃,如果每一次分配的空間大小都一樣,那么久可以按照這個(gè)每次請求分配的大小作為一個(gè)單位麻诀,把整個(gè)堆空間劃分為大量的小塊痕寓,每次請求的時(shí)候只需要找到一個(gè)小塊就可以了。
對象池的管理方法可以采用空閑鏈表蝇闭,也可以采用位圖呻率,與它們的區(qū)別僅僅在于她假設(shè)了每次請求的都是一個(gè)固定的大小,因此實(shí)現(xiàn)起來很容易呻引。由于每次總是只請求一個(gè)單位的內(nèi)存礼仗,因此請求得到滿足的速度非常快逻悠,無須查找一個(gè)足夠大的空間元践。
實(shí)際上很多現(xiàn)實(shí)應(yīng)用中,堆的分配算法往往是采取多種算法符合而成的童谒。比如對于glibc來說单旁,它對小于64字節(jié)的空間申請是采用類似于對象池的方法;而對于大于512字節(jié)的空間申請采用的是最佳適配算法饥伊;對于大于64字節(jié)而小于512字節(jié)的象浑,它會(huì)根據(jù)情況采取上述方法中的最佳折中策略蔫饰;對于大于128KB的申請,它會(huì)使用mmap機(jī)制向操作系統(tǒng)申請空間愉豺。
結(jié)尾
本書前前后后小生讀了兩遍死嗦,第一遍讀的實(shí)體書,沒有做筆記粒氧;第二遍讀的電子版越除,邊看邊做筆記。書讀百遍其義自見外盯,第一邊看書讓我對整部書有了一個(gè)大致的了解摘盆,第二遍細(xì)讀和做筆記讓我理解了很多之前工作中不明白的地方。但仍還有很多不明白之處饱苟,還需在今后的職業(yè)生涯中慢慢消化孩擂,慢慢體會(huì)。