1. 溫故而知新
虛擬內(nèi)存可以解決地址連續(xù)帶來的不安全吱肌,分頁解決了內(nèi)存使用率(換入換出的消耗)問題。
當(dāng)機(jī)器的處理器數(shù)量大于線程數(shù)仰禽,那么就是真正的并發(fā)氮墨;如果線程數(shù)大于CPU數(shù),那么必然有至少一個處理器是運行多個線程的吐葵,這個時候就需要線程調(diào)度规揪,讓他們看起來在并行。
線程調(diào)度的方法們主要都帶著優(yōu)先級 + 輪轉(zhuǎn)的影子温峭,IO密集的比CPU密集的更容易提高優(yōu)先級猛铅,因為IO密集的用CPU時間少。
可重入的函數(shù)必須滿足以下三個條件:可以在執(zhí)行的過程中可以被打斷诚镰;被打斷之后奕坟,在該函數(shù)一次調(diào)用執(zhí)行完之前,可以再次被調(diào)用(或進(jìn)入清笨,reentered)月杉;再次調(diào)用執(zhí)行完之后,被打斷的上次調(diào)用可以繼續(xù)恢復(fù)執(zhí)行抠艾,并正確執(zhí)行苛萎。
重入發(fā)生的情況有兩種,一是多線程,二是函數(shù)自己調(diào)用自己腌歉⊥芾遥可重入的函數(shù)有以下特點:(其實就是沒有副作用)
鎖也不是絕對安全,編譯優(yōu)化可能會延遲寄存器寫入 or 調(diào)整不相干的指令執(zhí)行順序翘盖,導(dǎo)致意料之外的結(jié)果桂塞,volatile
可以阻止寄存器寫入延遲。
原來linux也有柵欄馍驯,防止在構(gòu)建對象的時候阁危,內(nèi)存地址返回先于對象構(gòu)建:
2. 靜態(tài)鏈接
Build = Compile + Link;一般是四個步驟:預(yù)處理 + 編譯 + 匯編 + 鏈接
編譯就是語法分析之類的汰瘫,生成語法樹以及優(yōu)化生成匯編代碼狂打;而匯編那一步是將匯編代碼轉(zhuǎn)為機(jī)器碼,輸出目標(biāo)文件混弥。(關(guān)于編譯歡迎看之前的編譯原理讀書筆記趴乡,這里不詳述啦)
比如array[index] = (index + 4) * (2 + 6)
經(jīng)過各種分析會變成下面醬紫的:
t2 = index + 4
t2 = t2 * 8
array[index] = t2
這個時候就面臨一個問題了,array和index的地址還米有確定蝗拿。如果他們定義在其他編譯單元呢晾捏?
鏈接主要分為:地址和空間分配、符號決議和重定位
例如兩個模塊main.c和func.c蛹磺,main.c中要使用func.c中的函數(shù)foo(),在main.c模塊中每一處調(diào)用foo的時候都要知道foo這個符號的地址粟瞬,但是模塊單獨編譯,編譯main.c的時候不知道foo的地址萤捆,編譯器暫時擱置裙品,到鏈接時再去確定。連接器在鏈接的時候會根據(jù)所引用的符號foo俗或,自動去相應(yīng)的func.o模塊中查找foo的地址市怎,然后將main.o模塊中所有引用到foo的指令重新修正,是得獲取真正的foo函數(shù)的地址辛慰。
如果沒有鏈接器区匠,每次func模塊重新編譯,都要手動修改main里面的foo的地址帅腌,這就非常難受了驰弄。
如全局變量var 在目標(biāo)文件A中,我們在目標(biāo)文件B 中需要訪問var速客,則編譯目標(biāo)文件B時由于不知道變量var 的目標(biāo)地址將其暫時設(shè)置為0戚篙,等鏈接器將目標(biāo)文件A和B 鏈接起來的時候再將var 的地址進(jìn)行修正,地址修正也叫重定位溺职,被修改的地方叫重定位入口岔擂。
3. 目標(biāo)文件里有什么
編譯及匯編之后生成的文件叫目標(biāo)文件位喂,只是沒有經(jīng)過link。
我們大概能猜到乱灵,目標(biāo)文件中的內(nèi)容至少有編譯后的機(jī)器指令代碼塑崖、數(shù)據(jù)。沒錯痛倚,除了這些內(nèi)容以外规婆,目標(biāo)文件中還包括了鏈接時所須要的一些信息,比如符號表状原、調(diào)試信息聋呢、字符串等苗踪。一般目標(biāo)文件將這些信息按不同的屬性颠区,以“節(jié)”的形式存儲,有時候也叫“段”通铲,在一般情況下毕莱,它們都表示一個一定長度的區(qū)域,基本上不加以區(qū)別颅夺,唯一的區(qū)別是在ELF的鏈接視圖和裝載視圖的時候朋截,后面會專門提到。在本書中吧黄,默認(rèn)情況下統(tǒng)一將它們稱為“段”部服。
假設(shè)上圖可執(zhí)行文件(目標(biāo)文件)的格式是ELF,ELF文件的開頭是一個“文件頭”拗慨,它描述了整個文件的文件屬性廓八,包括文件是否可執(zhí)行、是靜態(tài)鏈接還是動態(tài)鏈接及入口地址(如果是可執(zhí)行文件)赵抢、目標(biāo)硬件剧蹂、目標(biāo)操作系統(tǒng) 等信息,文件頭還包括一個段表(Section Table )烦却,段表其實是一個描述文件中各個段的數(shù)組宠叼。段表描述了文件中各個段在文件中的偏移位置及段的屬性等,從段表里面可以得到每個段的所有信息其爵。文件頭后面就是各個段的內(nèi)容冒冬,比如代碼段保存的就是程序的指令,數(shù)據(jù)段保存的就是程序的靜態(tài)變量等摩渺。
未初始化的數(shù)據(jù)其實也可以放到.data简烤,然后存?zhèn)€初始值0,但是這樣就很浪費证逻。所以.bss(Block Started by Symbol)段只是為未初始化的全局變量和局部靜態(tài)變量預(yù)留位置而已乐埠,它并沒有內(nèi)容抗斤,所以它在文件中也不占據(jù)空間。
很多人可能會有疑問:為什么要那么麻煩丈咐,把程序的指令和數(shù)據(jù)的存放分開?混雜地放在一個段里面不是更加簡單?其實數(shù)據(jù)和指令分段的好處有很多瑞眼。主要有如下幾個方面。
一方面是當(dāng)程序被裝載后棵逊,數(shù)據(jù)和指令分別被映射到兩個虛存區(qū)域 伤疙。 由于數(shù)據(jù)區(qū)域?qū)τ谶M(jìn)程 來說是可讀寫的,而指令區(qū)域?qū)τ谶M(jìn)程來說是只讀的辆影,所以這兩個虛存區(qū)域的權(quán)限可以被分別設(shè)置成可讀寫和只讀徒像。這樣可以防止程序的指令被有意或無意地改寫。
另外一方面是對于現(xiàn)代的CPU來說蛙讥,它們有著極為強(qiáng)大的緩存(Cache )體系锯蛀。 由于緩存在現(xiàn)代的計算機(jī)中地位非常重要,所以程序必須盡量提高緩存的命中率次慢。指令區(qū)和數(shù)據(jù)區(qū)的分離有利于提高程序的局部性∨缘樱現(xiàn)代CPU的緩存一般都被設(shè)計成數(shù)據(jù)緩存和指令緩存分離,所以程序的指令和數(shù)據(jù)被分開存放對CPU的緩存命中率提高有好處迫像。
第三個原因劈愚,其實也是最重要的原因,就是當(dāng)系統(tǒng)中運行著多個該程序的副本時闻妓,它們的指令都是一樣的菌羽,所以內(nèi)存中只須要保存一份改程序的指令部分。 對 于指令這種只讀的區(qū)域來說是這樣由缆,對于其他的只讀數(shù)據(jù)也一樣注祖,比如很多程序里面帶有的圖標(biāo)、圖片犁功、文本等資源也是屬于可以共享的氓轰。當(dāng)然每個副本進(jìn)程的數(shù)據(jù)區(qū)域是不一樣的,它們是進(jìn)程私有的浸卦。
ELF文件中用到了很多字符串署鸡,比如段名、變量名等限嫌。因為字符串的長度往往是不定的靴庆,所以用固定的結(jié)構(gòu)來表示比較困難,一種常見的做法是把字符串集中起來存放到一個表怒医,然后使用字符串在表中的偏移來引用字符串炉抒。
鏈接過程的本質(zhì)就是要把多個不同的目標(biāo)文件之間相互“粘”到一起。為了使不同目標(biāo)文件之間能夠相互粘合稚叹,這些目標(biāo)文件之間必須有固定的規(guī)則才行焰薄。在鏈接中拿诸,目標(biāo)文件之間相互拼合實際上是目標(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”野瘦。這兩個概念也同樣適用于變量描沟。每個函數(shù)或變量都有自己獨特的名字,才能避免鏈接過程中不同變量和函數(shù)之間的混淆鞭光。在鏈接中吏廉,我們將函數(shù)和變量統(tǒng)稱為符號(Symbol),函數(shù)名或變量名就是符號名(Symbol Name)惰许。
我們可以將符號看作是鏈接中的粘合劑席覆,整個鏈接過程正是基于符號才能夠正確完成。鏈接過程中很關(guān)鍵的一部分就是符號的管理啡省,每一個目標(biāo)文件都會有一個相應(yīng)的符號表(Symbol Table)娜睛,這個表里面記錄了目標(biāo)文件中所用到的所有符號。每個定義的符號有一個對應(yīng)的值卦睹,叫做符號值(Symbol Value),對于變量和函數(shù)來說方库,符號值就是它們的地址结序。
符號表的name由于避免重復(fù)以及函數(shù)重載之類的纵潦,會有很多生成規(guī)則徐鹤,和函數(shù)簽名很像需要唯一,不同編譯器生成的規(guī)則也不一樣邀层,所以不同編譯器生成的目標(biāo)文件之間的 link 是無法鏈接的返敬。
這里就解釋了為啥有的時候你build會報錯重復(fù)符號~ (這里是因為是強(qiáng)符號哦,強(qiáng)符號不能重復(fù)定義寥院,多個弱符號是木有問題的)
編譯里面的弱引用和iOS的弱引用其實不太一樣劲赠,編譯的弱引用是如果找不到這個符號不會報錯,但是強(qiáng)引用找不到會編譯不過秸谢,但弱引用運行時可能會crash凛澎,but它給外部提供了自定義的機(jī)會。(內(nèi)部僅聲明估蹄,外部去實現(xiàn))
4. 靜態(tài)鏈接
當(dāng)我們有多個目標(biāo)文件時塑煎,如何將它們鏈接起來形成一個可執(zhí)行文件呢?這就是鏈接的核心內(nèi)容:靜態(tài)鏈接臭蚁。
鏈接就是把幾個目標(biāo)文件合成一個可執(zhí)行文件最铁,那么要怎么合并呢讯赏?如何合并各個段呢?
方法1的問題主要是段太零散了冷尉,很多內(nèi)存碎片待逞,畢竟各段還要內(nèi)存對齊啥的属划。
在鏈接階段孽惰,鏈接器會為會為所有的目標(biāo)文件分配地址空間。
這里的地址空間需要區(qū)分兩種含義:
- 可執(zhí)行文件自身的空間:用于磁盤上靜態(tài)存儲可執(zhí)行文件的內(nèi)容
- 進(jìn)程虛擬地址空間:由程序運行時猴贰,系統(tǒng)加載可執(zhí)行文件的內(nèi)容而動態(tài)建立
鏈接器為可執(zhí)行文件中符號確定的地址即是最后程序運行時所使用的地址震束,在可執(zhí)行文件被裝載時會被一一映射到進(jìn)程的虛擬地址空間怜庸。典型的裝載數(shù)據(jù)包括代碼段和數(shù)據(jù)段中的數(shù)據(jù),一些特殊的段垢村,如.bss段在可執(zhí)行文件中不占用空間割疾,但是在可執(zhí)行文件裝載后的進(jìn)程虛擬地址空間中需要進(jìn)行空間分配。嘉栓。
現(xiàn)在的鏈接器空間分配策略基本上采用上述方式中的第二種宏榕,使用這種方法的鏈接器一般都采用一種叫兩步鏈接的方法。也就是整個鏈接過程分兩步侵佃。
空間與地址分配
掃描所有的輸入目標(biāo)文件麻昼,并且獲得它們各個段的長度、屬性和位置馋辈,并且將輸入目標(biāo)文件中的符號表中的所有符號定義和符號引用收集起來抚芦,統(tǒng)一放到一個全局符號表。這一步迈螟,鏈接器能夠獲得所有輸入目標(biāo)段長度叉抡,并且將它們合并,計算出輸出文件中的各個段合并后的長度與位置答毫,并建立映射關(guān)系符號解析與重定位
使用上面一步收集到的所有信息褥民,讀取輸入段的數(shù)據(jù)、重定位信息洗搂,并且進(jìn)行符號解析與重定位消返、調(diào)整代碼中的地址。事實上蚕脏,第二步是鏈接的核心侦副,特別是重定位的過程。
舉例我們把 a b 兩個文件合成可執(zhí)行文件ab:
在目標(biāo)文件里面的函數(shù)地址都是不固定的驼鞭,所以會用0x00之類的替代秦驯,再合成為可執(zhí)行文件以后,會修改這個地址為真實地址挣棕,這就是重定位译隘。
鏈接器怎么知道哪些指令是需要被調(diào)整的呢亲桥?這些指令哪些部分要被調(diào)整?怎么調(diào)整固耘?這些都需要重定位表來提供信息题篷。事實上在ELF文件中,有一個叫重定位表( Relocation Table)的結(jié)構(gòu)專門用來保存這些與重定位相關(guān)的信息厅目,我們在前面介紹ELF文件結(jié)構(gòu)時已經(jīng)提到過了重定位表番枚,它在ELF文件中往往是個或多個段。
比如代碼段 "text" 如有要被重定位的地方,那么會有一個相對應(yīng)叫 "rel.text"的段保存了代碼段的重定位表;如果代碼段 "data" 有要被重定位的地方,就會有一個相對應(yīng)叫 "rel.data" 的段保存了數(shù)據(jù)段的重定位表损敷。
重定位的過程中葫笼,每個重定位的入口都是對一個符號的引用,那么當(dāng)鏈接器須要對某個符號的引用進(jìn)行重定位時拗馒,它就要確定這個符號的目標(biāo)地址路星。這時候鏈接器就會去查找由所有輸入目標(biāo)文件的符號表組成的全局符號表,找到相應(yīng)的符號后進(jìn)行重定位诱桂。
visual C++提供了一個編譯選項叫函數(shù)級別鏈接洋丐,這個選項的作用就是讓所有的函數(shù)像前面的模板一樣,單獨保存在一個段里面挥等。當(dāng)鏈接器需要用到某個函數(shù)時友绝,它就將它合并到輸出文件中,對于那些滅有用到的函數(shù)則將他們拋棄触菜。這種做法很大程序上減小了輸出文件的長度九榔,減少了空間浪費。但是這個選項會減慢編譯和鏈接的過程涡相。
其實就是把段單位改小,然后用的時候就合并剩蟀,不用就不合并催蝗,這樣就可以減少最后生成的可執(zhí)行文件。因為如果你都混到一個段里面育特,是不好拆出來哪里不要哪里要的丙号,以 segment 為單位就比較容易。但目標(biāo)文件里面的段數(shù)會增加很多缰冤。
全局對象的構(gòu)造會在 main 之前執(zhí)行犬缨,以及它的析構(gòu)會在 main 之后執(zhí)行,他們分別處于單獨的段 .init 和 .fini 棉浸。
目標(biāo)文件格式怀薛、符號修飾標(biāo)準(zhǔn)、變量內(nèi)存分布方式迷郑、函數(shù)調(diào)用方式等這些跟二進(jìn)制可執(zhí)行代碼兼容性相關(guān)的內(nèi)容稱為ABI(Application Binary Interface)枝恋。和API的差別還挺大的创倔,API說的是源代碼級別的接口,ABI是二進(jìn)制層面的焚碌。
這其實也說明了畦攘,為啥很多jar包會區(qū)分系統(tǒng),也就是我們用的很多庫都會提供很多版本十电,讓你根據(jù)自己的系統(tǒng)下載知押。
靜態(tài)庫可以簡單看成一組目標(biāo)文件的集合。用壓縮程序?qū)⑦@些目標(biāo)文件壓縮到一起鹃骂,并進(jìn)行編號和索引台盯。如linux的usr/lib/libc.a
我們知道在一個 C 語言的運行庫,包含了很多跟系統(tǒng)功能相關(guān)的代碼偎漫,比如輸入輸出爷恳、文件操作、時間日期象踊、內(nèi)存管理等温亲。 glibe 本身是用 C 語言開發(fā)的,它由成百上千個 C 語言源代碼文件組成杯矩,也就是說栈虚,編譯完成以后有相同數(shù)量的目標(biāo)文件,比如輸入輸出有 pri ntf.o , scanf.o; 文件操作有 fread.o , fwrite.o等史隆。
把這些目標(biāo)文件零散的提供給庫的使用者魂务,很大程度上會造成文件傳輸、管理和組織方面的不便泌射,于是通常人們使用"ar"將這些目標(biāo)文件壓縮到一起粘姜,并且對其進(jìn)行編號和索引,以便于查找和檢索熔酷,這就形成了libc.a 這個靜態(tài)庫文件孤紧。
如果我想找一個符號在那個.o文件里可以醬紫:
很多靜態(tài)庫都是一個文件只放一個函數(shù),這個其實也是為了避免鏈接不需要的文件拒秘,這樣的話就可以當(dāng)你用到那個函數(shù)就link哪個文件啦号显。
你可以去修改鏈接的腳本,去除不需要的段躺酒,以得到最小的可執(zhí)行文件押蚤。
碎碎念:最近發(fā)現(xiàn)可能自己并沒有自己想象的那么喜歡編程,也許我并不適合羹应,所以三個月的停更想了也蠻多的揽碘,最近很想去英國留學(xué),等疫情過了再申請試試吧,現(xiàn)在這種無法感受到成長的日子實在是有點難熬钾菊。但還是會先堅持噠~ MBA和做XX博主也在考慮范圍內(nèi)帅矗,但以我的資質(zhì)吧,感覺難以維持生命吖~