1. 程式人生 > >程式設計師的自我修養-連結裝載與庫筆記

程式設計師的自我修養-連結裝載與庫筆記

花了近一個禮拜的時間算是把這本書看完了大部分的內容,因為工作接觸的是linux有關windows的部分沒有去看。個人覺得如果要做底層的話,這本書不得不看,看完之後雖然沒有全部理解,但是對於我之前的知識體系結構有了很大的一個補充。現在就要記錄下書中一些重難點,可以以後去回顧,將基礎知識打紮實。
為了協調I/O裝置與匯流排之間的速度,也為了能夠讓CPU能夠和I/O裝置進行通訊,一般每個裝置都會有一個相應的I/O控制器。
CPU採用倍頻的方式與系統匯流排進行通訊。
為了協調CPU,記憶體和高速的圖形裝置,人們專門設計了一個高速的北橋晶片,以便他們之間能夠高速地交換資料。
PC硬體模型是CPU,記憶體,以及I/O的基本結構。
理論上,增加CPU的數量就可以提高運算速度,並且理想情況下,速度的提高與CPU的數量成正比。
多核和SMP在快取共享等方面有細微的差別,使得程式在優化上可以有針對性地處理。
除了硬體和應用程式,其他都是所謂的中間層,每個中間層都是對它下面的那層的包裝和擴充套件,正是這些中間層的存在,使得應用程式和硬體之間保持相對的獨立。
最近流行的虛擬機器技術是在硬體和作業系統之間增加了一層的虛擬層,使得一個計算機上可以同時執行多個作業系統,這也是層次結構帶來的好處,在儘可能少改變甚至不改變
其他層的情況下,新增加一個層次可以提供前所未有的功能。
從整個層次結構來看,開發工具和應用程式是屬於同一個層次的,因為它們都使用一個介面,那就是作業系統應用程式程式設計介面。應用程式介面的提供者是執行庫,什麼樣的運
行庫提供什麼樣的API。
Linux使用0x80號中斷作為系統呼叫介面,Windows使用0x2E號中斷作為系統呼叫介面。
多工系統:作業系統接管了所有的硬體資源,並且本身執行在一個受硬體保護的級別。所有的應用程式都以程序的方式執行咋比作業系統許可權更低的級別,每個程序都有自己
獨立的地址空間,使得程序之間的地址空間相互隔離。
在Windows系統,系統硬體被抽象成了GDI,聲音和多媒體裝置裝置被抽象成了DirectX物件,磁碟被抽象成了普通檔案系統。
硬碟基本儲存單位為扇區,每個扇區一般為512位元組。
整個硬碟中所有的扇區從0開始編號,一直到最後一個扇區,這個扇區編號叫做邏輯扇區號。
程式在編寫時,它訪問資料和指令跳轉時的目標地址很多都是固定的,這涉及程式的重定位問題。
分段對記憶體區域的對映還是按照程式為單位,如果記憶體不足,被換入換出到磁碟的都是整個程式,這樣勢必會造成大量的磁碟訪問操作,從而嚴重影響速度,這種方法還是顯得
粗糙,粒度比較大。
分頁的基本方法是把地址空間人為分為固定大小的頁,每一頁的大小由硬體決定,或硬體支援多種大小的頁,由作業系統選擇決定頁的大小。
在同一時刻只能選擇一種大小,所以對整個系統來說,頁就是固定大小的。
把程序的虛擬地址空間按頁分割,把常用的資料和內碼表裝載到記憶體中,把不常用的程式碼和資料儲存在磁碟中,把需要用到的時候再把它從磁盤裡取出來即可。
當程序需要用到不在記憶體中的頁時,硬體會捕獲到這個訊息,就是所謂的頁錯誤,然後作業系統接管程序,負責將磁碟中讀出來並且裝入記憶體,然後將記憶體中的頁建立對映關係。
一個標準的執行緒由執行緒ID,當前指令指標,暫存器集合和堆疊組成,通常意義上,一個程序由一個到多個執行緒組成,各個執行緒之間共享程式的記憶體空間(包括程式碼段,資料段,堆等)以及一些程序級的資源(如開啟檔案和訊號)。
相對於多程序應用,多執行緒在資料共享方面效率要高的多。
執行緒訪問非常自由,它可以訪問程序記憶體裡的所有資料,甚至包括其他執行緒的堆疊但是實際運用中執行緒也擁有自己的私有儲存空間。
不論是在多處理器的計算機上還是在單處理器的計算機上,執行緒總是“ 併發”執行的。
不斷在處理器上切換不同的執行緒的行為稱為執行緒排程。
處於執行中執行緒擁有一段可以執行的時間,這段時間稱為時間片。
在優先順序排程的環境下,執行緒的優先順序一般由三種方式:1 使用者指定優先順序 2 根據進入等待狀態的頻繁程度提升或降低優先順序 3長時間得不到執行而被提升優先順序
在不可搶佔的排程模型下,執行緒必須主動進入就緒狀態,而不是靠時間片用盡來被強制進入。
Linux將所有的執行實都成為任務,,每一個任務在概念上都類似於一個單執行緒的程序,具有記憶體空間,執行實體,檔案資源等。
fork和exec通常用於產生新任務,而如果要產生新執行緒,則可以使用clone
臨界區是比互斥量更加嚴格的同步手段。
臨界區和互斥量與訊號量的區別在於,互斥量和訊號量在系統的任何系統的任何程序裡都是可見的,除此之外,臨界區具有和互斥量相同的性質。
對於同一個鎖,讀寫鎖有兩種獲取方式,共享的或獨佔的。
一個函式被重入,表示這個函式沒有執行完成,由於外部因素或內部呼叫,又一次進入該函式執行。
一個函式要成為可重入的,必須有如下幾個特點:
1 不使用任何靜態或區域性的非const變數
2 不返回任何靜態或區域性的非const變數的指標
3 僅依賴於呼叫方提供的函式
4 不依賴任何單個資源的鎖
5 不呼叫任何不可重入函式
volatile基本可以做到兩件事情:
1 阻止編譯器為了提高速度將一個變數快取到暫存器內而不寫回
2 組織編譯器調整操作volatile變數的指令順序
CPU的亂序執行能力讓我們對多執行緒的安全保障的努力變得異常困難。
使用者態執行緒並不一定在作業系統核心裡對應同等數量的核心執行緒。一個核心裡的執行緒在使用者態不一定有對應的執行緒存在。
預編譯過程主要處理那些原始碼檔案中的以#開始的預編譯指令。在該階段會保留所有的#pragma編譯器指令,因為編譯器必須要使用它們。
現在的版本GCC把預編譯和編譯兩個步驟合併成一個步驟,使用一個叫做cc1的程式來完成這兩步驟。
實際上gcc這個命令只是後臺程式的包裝,它會根據不同的引數要求去呼叫預編譯編譯程式cc1,彙編器as,聯結器ld。
編譯過程一般分為6步,掃描,語法分析,語義分析,原始碼分析,程式碼生成和目的碼優化。
lex程式可以實現詞法掃描,會按照使用者之前描述好的詞法規則將輸入的字串分割成一個個記號。
詞法分析器將對由掃描器產生的記號進行語法分析,從而產生語法樹。整個分析過程採用了上下文無關語法的分析手段。詞法分析也有一個現成的工具yacc(yet another compiler compiler)。
編譯器所能分析的語義是靜態語義,所謂靜態語義是指在編譯器可以確定的語義。
經過語義分析階段以後,整個語法樹的表示式都被標識了型別,如果有些型別需要做隱式轉換,語義分析程式會在語法樹中插入相應的轉換節點。
中間程式碼使得編譯器可以被分為前端和後端。編譯器前端負責產生機器無關的中間程式碼,編譯器後端將中間程式碼轉換成目標機器程式碼。這樣對於一些可以跨平臺的編譯器而言,
他們可以針對不同的平臺使用同一個前端和針對不同機器平臺的數個後端。編譯器後端主要包括程式碼生成器和目的碼優化器。
至今沒有一個編譯器能夠完整支援C++語言標準所規定的所有語言特性。
定義其他模組的全域性變數和函式在最終執行時的絕對地址都要在最終連結的時候才能確定。
重新計算各個目標的地址過程被叫做重定位。
符號這個概念隨著組合語言的普及迅速被使用,它用來表示一個地址,這個地址可以是一段子程式的起始地址,也可以是一個變數的起始地址。
基於符號的模組化的一個直接結果是連結過程在整個程式開發中變得十分重要和突出。
連線過程主要包括了地址和空間分配,符號決議和重定位等步驟。
最常見的庫是執行庫,它是支援程式執行的基本函式的集合。
重定位所做的就是給程式中每個這樣的絕對地址引用的位置打補丁,使它們指向正確的地址。
目標檔案從結構上講,是已經編譯後的可執行檔案格式,只是還沒有經過連結的過程,其中可能有些符號或有些地址還沒有被調整,其實它本身就是按照可執行檔案格式儲存的。
當程式意外終止時,系統可以將該程序地址空間的內容及終止時的一些其他資訊轉儲到核心轉儲檔案(core dump file)。
COFF的主要貢獻是在目標檔案裡面引入了“段”機制,不同的目標檔案可以擁有不同數量及不同型別的“段”,另外它還定義了除錯資料格式。
目標檔案包括了連結時所需要的一些資訊,比如符號表,除錯資訊,字串等。一般目標檔案將這些資訊按不同的屬性,以“節”section的形式儲存,有時候也叫"段"segment.
ELF檔案的開頭是一個“檔案頭”,它描述了整個檔案的檔案屬性,包括檔案是否可執行,是靜態連結還是動態連結及入口地址,目標硬體,目標作業系統等資訊,檔案頭還包括
一個段表,段表其實是一個描述檔案中各個段的陣列。
總體來說,程式原始碼被編譯以後主要分成兩種段:程式指令和程式資料。程式碼段屬於程式指令,而資料段和.bss段屬於程式資料。
當程式被裝載以後,資料和指令分別被對映到兩個虛擬區域。
現代CPU的快取一般都能被設計成資料快取和指令快取分離。
objdump -h 表示把ELF檔案的各個段的基本資訊打印出來。-s 將所有段的內容以十六進位制的方式打印出來 -d 將所有包含指令的段反彙編。
".rodata"段存放的是隻讀資料,一般是程式裡面的只讀變數(如const修飾的變數和字串常量)。這樣系統在載入的時候可以將“。rodata”段的屬性對映成只讀,這樣對於
這個段的任何修改都會作為非法操作處理。
有些編譯器會將全域性的未初始化變數存放在目標檔案的.bss段,有些則不存放,只是預留一個未定義的全域性變數符號,等到最終連結成可執行檔案的時候再在.bss段分配空間.
size用來檢視ELF檔案的程式碼段,資料段和BSS段的資料長度。
一個ELF檔案也可以擁有幾個相同段名的段。
在全域性變數或函式之前加上“__attribute__((section("name")))”屬性就可以把相應的變數或函式放到以“name”作為段名的段中。
ELF檔案中與段有關的重要結構是段表,該表描述了ELF檔案包含的所有段的資訊,比如每個段的段名,段的長度,在檔案中的偏移,讀寫許可權及段的其他屬性。
ELF檔案的檔案頭中定義了ELF魔數,檔案機器位元組長度,資料儲存方式,版本,執行平臺,ABI版本,ELF重定位型別,硬體平臺,硬體平臺版本,入口地址,程式頭入口和長度,段表的位置和長度及段的數量。
魔數用來確認檔案的型別,作業系統在載入可執行檔案的時候會確認魔數是否正確,如果不正確會拒絕載入。
ELF檔案的段結構是由段表決定的,編譯器,聯結器和裝載器都是依靠段表來定位和訪問各個段的屬性的。
事實上段的名字對於編譯器,聯結器是有意義的,但是對於作業系統來說並沒有實質的意義,對於作業系統來說,一個段該如何處理取決於它的屬性和許可權,即由段的型別和段
的標誌為這兩個成員決定。
重定位的資訊都記錄在ELF檔案的重定位表裡,對於每個必須要重定位的程式碼段或資料段,都會有一個相應的重定位表。
連結過程的本質就是要把多個不同的目標檔案之間相互粘在一起,實際上就是目標檔案之間對地址的引用,即對函式和變數的地址的引用。
在連結中,將函式和變數統稱為符號,函式名或變數名就是符號名。
整個連結過程是基於符號才能正確完成,每個目標檔案都會有一個相應的符號表,這個表裡記錄了目標檔案中所用到的所有符號。每個定義的符號有一個對應的值,叫做符號值
。對於變數和函式來說,符號值就是他們的地址。
ELF檔案中的符號表往往是檔案中的一個段,段名叫做".symtab"。
只有使用ld連結器生產最終可執行檔案的時候特殊符號才會存在。(__exectable_start,  __etext, __edata, _end)。
GCC編譯器可以通過引數選項"-fleading-underscore"或“-fno-leading-underscore”來開啟和關閉是否在C語言符號前加上下劃線。
函式簽名包含了一個函式的資訊,包括函式名,它的引數型別,它所在的類和名稱空間及其他資訊,函式簽名用於識別不同的函式。
簽名和名稱修飾機制不光被使用到函式上,C++中的全域性變數和靜態變數也有同樣的機制。名字修飾機制也被用來防止靜態變數的名字衝突。
不同的編譯器廠商的名稱修飾方法可能不同,所以不同的編譯器對於同一個函式簽名可能對應不同的修飾後名稱。由於不同的編譯器採用不同的名字修飾方法,必然會導致由不
同編譯器編譯產生的目標檔案無法正常相互連結,這是導致不同編譯器之間不能互操作的主要原因之一。
對於C/C++語言來說,編譯器預設函式和初始化了的全域性變數為強符號,未初始化的全域性變數為弱符號。也可以通過GCC的"__attribute((weak))"來定義任何一個強符號為弱符號。注意強符號和弱符號都是針對定義來說的,不是針對符號的引用。
在處理弱引用時,如果該符號有定義,則連結器將該符號的引用決議,如果該符號未被定義,則連結器對於該引用不報錯。
弱引用和弱符號主要用於庫的連線過程。?????
可執行檔案中的程式碼段和資料段都是由輸入的目標檔案中合併而來的。
“.bss”段在目標檔案和可執行檔案中並不佔用檔案的空間,但是它在裝載時佔用地址空間。所以聯結器在合併各個段的同時,也將“.bss”段合併,並且分配虛擬空間。

連結器為目標檔案分配地址和空間有兩個含義:第一個是在輸出的可執行檔案中的空間;第二個是在裝載後的虛擬地址中的虛擬地址空間。

整個連結過程分兩步:1 空間與地址的分配 掃描所有的輸入目標檔案,獲得他們的各個段的長度,屬性和位置,並且將輸入目標檔案中的符號表中所有的符號定義和符號引用收集起來,統一放到一個全域性符號表。這一步中,連結器將能夠獲得所有輸入目標檔案的段長度,並且將它們合併,計算輸出檔案中各個段合併後的長度和位置,並建立對映關係。2 符號解析與重定位 使用上面第一步中收集的所有資訊,讀取輸入檔案中段的資料,重定位資訊,並且進行符號解析與重定位,調整程式碼中的地址等。第二步是連結過程的核心,特別是重定位過程。

ld -e main 表示將main函式作為程式入口,ld連結器預設的程式入口為_start.

在第一步的掃描和空間分配階段,連結器按照一定的空間分配方法進行分配,這時候輸入檔案中的各個段在連結後的虛擬地址就已經確定了。

事實上在ELF檔案中,有一個叫重定位表的結構專門用來儲存與重定位相關的資訊,而一個重定位表往往是ELF檔案中的一個段,所以其實重定位表也可以叫做重定位段。

從普通程式設計師的角度看,符號的解析佔據了連結過程的主要內容。

重定位的過程中,每個重定位的入口都是對一個符號的引用,那麼當連結器必須要對某個符號的引用進行重定位時,他就要確定這個符號的目標地址,這時候連結器就會去查詢由所有輸入目標檔案的符號表組成的全域性符號表,找到對應的符號後進行重定位。

絕對定址修正和相對定址修正的區別就是絕對定址修正後的地址為該符號的實際地址,相對定址修正後的地址為符號距離被修正位置的地址差。

目前的連結器本身並不支援符號的型別,即變數型別對連結器來說是透明的。

在目標檔案中,編譯器為什麼不直接把未初始化的全域性變數也當作未初始化的區域性靜態變數一樣處理,為它在BSS段分配空間,而是將其標記為一個COMMON型別的變數?因為當連結器讀取所有輸入目標檔案以後,任何一個弱符號的最終大小都可以確定了,所以它可以在最終輸出檔案的BSS段為其分配空間。

C++的一些語言特性使之必須由編譯器和連結器共同支援才能完成工作。

C++編譯器在很多時候會產生重複的程式碼,比如模板,外部行內函數和虛擬函式表都有可能在不同的編譯單元裡生成相同的程式碼。

GCC的編譯選項“-ffunction-sections”和"-fdata-sections",作用就是將每個函式或者變數分別保持到獨立的段中。

如果要使兩個編譯器編譯出來的目標檔案能夠互相連結,那麼這兩個目標檔案必須滿足下面的條件:採用同樣的目標檔案格式,擁有同樣的符號修飾標準,變數的記憶體分佈方式相同,函式的呼叫方式相同等等,也就是所謂的ABI要相同。

C++一直為人詬病的一大原因是它的二進位制相容性不好,或者說比起C語言來更為不易。

gcc -verbose 表示將整個編譯連結過程的中間步驟打印出來。

GCC呼叫collect2程式來完成最後的連結。collect2可以看作是ld連結器的一個包裝,它會呼叫ld連結器來完成對目標檔案的連結,然後再對連結結果進行一些處理,主要是收集所有與初始化有關的資訊並且構造初始化的結構。

GCC編譯器提供了很多內建函式,它會把一些常用的C庫函式替換成編譯器的內建函式以達到優化的功能。

簡單來講,控制連結過程無非是控制輸入段如何變成控制輸出段,比如哪些輸入段要合併一個輸出段,哪些輸入段要丟棄,指定要輸出段的名字,裝載地址,屬性等等。

在預設情況下,ld連結器在產生可執行檔案時會產生3個段。對於可執行檔案來說,符號表和字串表是可選的,但是段名字串表為使用者儲存段名,所以他是必不可少的。

連結指令碼由一系列語句組成,語句分兩種,一種是命令語句,另外一種是賦值語句。

SECTIONS負責指定連結過程的段轉換過程,這也是連結的最核心和最複雜的部分。

入口地址即程序執行的第一條使用者空間的指令在程序地址空間的地址。

ld有很多種方法可以設定程序入口地址1 ld命令列的-e選項 2 連結指令碼的ENTRY命令 3 如果定義了_start符號,使用_start符號值 4 如果存在.text段,使用.text段的第一個位元組的地址 5使用值0

現在GCC(更具體的講是GNU彙編器GAS),連結器ld,偵錯程式GDB及binutils的其他工具都通過BFD(binary file descriptor library)庫來處理目標檔案,而不是直接操作目標檔案。

每個程式被執行起來後,將擁有自己獨立的虛擬地址空間,這個虛擬地址空間的大小有計算機的硬體平臺決定,具體說是CPU的位數決定。

一般來說,C語言指標大小的位數與虛擬空間的位數相同。

從原則上講,我們的程序最多可以使用3GB的虛擬空間,也就是說整個程序在執行的時候,所有程式碼資料包括C語言malloc等方法申請的虛擬空間之和不可以超過3GB。

將程式最常用的部分駐留在記憶體中,而將一些不太常用的資料存放在磁盤裡,就是動態裝入的原理。

將記憶體和所有磁碟中的資料和指令按照頁為單位劃分成若干頁,以後所有的裝載和操作的單位就是頁。

從作業系統來看,一個程序最關鍵的特徵是它擁有獨立的虛擬地址空間,使得它有別於其他程序。

建立一個程序:1 建立一個獨立的虛擬地址空間(一個虛擬空間由一組頁對映函式將虛擬空間的各個頁對映至相應的物理空間,那麼建立以一個虛擬空間實際上並不是建立空間而是很粗昂見對映函式所需要的相應的資料結構。在i386的linux下,建立虛擬地址空間實際上只是分配一個頁目錄就可以了,甚至不設定頁對映關係,這些對映關係等到後面程式發生頁錯誤的時候再進行設定)

 2× 讀取可執行檔案頭,並建立虛擬空間與可執行檔案的對映關係,這種對映關係只是儲存在作業系統內部的一個數據結構,linux將程序虛擬空間中的一個段叫做虛擬記憶體區域(VMA),作業系統建立程序後,會在程序相應的資料結構中設定有一個.text段的VMA 

3 將CPU的指令暫存器設定成可執行檔案的入口地址,啟動執行。上面的步驟執行完成之後,其實可執行檔案的真正指令和資料都沒有被裝入記憶體中。作業系統只是通過可執行檔案頭部的資訊建立起可執行檔案和程序虛存之間的對映關係而已。

ELF檔案被對映時,是以作業系統的頁長度作為單位的,那麼每個段在對映時的長度應該都是系統頁長度的整數倍

作業系統只關心一些跟裝載有關的問題,最主要是段的許可權(可讀,可寫,可執行)。

ELF可執行檔案引入了一個概念叫做"segment",一個segment包含一個或多個屬性類似的section。

從連結的角度看,ELF檔案是按section儲存的,從裝載的角度看,ELF檔案可以按照segment劃分的。

正如描述section屬性的結構叫做段表,描述segment的結構叫做程式頭,它描述了ELF檔案該如何被作業系統對映到程序的虛擬空間。

ELF可執行檔案中有一個專門的資料結構叫做程式頭表用來儲存segment的資訊,因為ELF檔案不需要被裝載,所以他沒有程式頭表,而ELF的可執行檔案和共享庫檔案都有。

在作業系統裡面,VMA除了被用來對映可執行檔案中的各個segment以外,它還可以有其他的作用,作業系統通過使用VMA來對程序的地址空間進行管理。

很多情況下,一個程序中的棧和堆分別有對應的一個VMA。

可執行檔案最終是要被作業系統裝載執行的,這個裝載的過程一般是通過虛擬記憶體的頁對映機制完成的。在對映過程中,頁是對映的最小單位。

程序啟動以後,程式的庫部分會把堆疊裡的初始化資訊中的引數資訊傳遞給main函式。

linux系統裝載ELF並且執行:1 在使用者層面,bash程序會呼叫fork()系統呼叫建立一個新的程序,然後新的程序呼叫execve系統呼叫執行指定的ELF檔案,進入execve系統呼叫之後,linux核心就開始進行真正的裝載工作。

每種可執行檔案的格式的開頭幾個位元組都是很特殊的,特別是開頭4個位元組,常常被稱為魔數,通過對魔數的判斷可以確定檔案的格式和型別。

對於靜態連結誒ELF可執行檔案,這個程式入口就是ELF檔案的檔案頭中e_entry所指的地址,對於動態連結的ELF可執行檔案,程式入口就是動態連結器。

動態連結的一個特點就是程式在執行時可以動態地選擇載入各種程式模組。

動態連結涉及執行時的連結及多個檔案的裝載,必需要有作業系統的支援,因為動態連結的情況下,程序的虛擬地址空間的分佈會比靜態連結情況下更為複雜,還有一些儲存管理,記憶體共享,程序執行緒等機制在動態連結下也會有一些微妙的變化。

當程式被裝載的時候,系統的動態連結器會將程式所需要的所有動態連結庫裝載到程序地址空間,並且將程式中所有為決議的符號繫結到相應的動態連結庫中,並進行重定位工作。

ld-2.6.so實際上是linux下的動態連結器。動態連結器與普通共享物件一樣被對映到了程序的地址空間,在系統執行程式之前,首先會把控制權交給動態連結器,由它完成所有的動態連線工作以後再把控制權交給程式,然後開始執行。

共享物件的最終裝載地址在編譯時是不確定的,而是在裝載時,裝載器根據當前地址空間的空閒情況,動態分配一塊足夠大小的虛擬地址空間的相應的共享物件。

共享物件在編譯時不能假設自己在程序虛擬地址空間中的位置。與此不同的是,可執行檔案基本可以確定自己在程序虛擬空間中的起始位置,因為可執行檔案往往是第一個被載入的檔案,它可以選擇一個固定空閒的地址。

動態連線模組被裝載對映至虛擬地址空間後,指令部分是在多個程序之間共享的,同時指令被重定位之後對於每個程序來講是不同的,當然,動態連結庫中的可修改資料部分對於不同的程序來說有多個副本,所以他們可以採用裝載時重定位的方法來解決。

對於現代的系統來講,模組內部的跳轉,函式呼叫都可以是相對地址呼叫,或者基於暫存器的相對呼叫,所以對於這種指令是不需要重定位的。

一個模組前面一般是若干個頁的程式碼,後面緊跟著若干頁的資料,這些頁之間的相對位置是固定的,那麼只需要相對於當前指令加上固定的偏移量就可以訪問模組內部資料了。

其他模組的全域性變數的地址是跟模組裝載地址有關的。ELF的做法是在資料段裡棉建立一個指向這些變數的指標陣列,也被稱為編劇偏移表(GOT),當代碼需要引用該全域性變數時,可以通過GOT中相對應的項間接引用,當然GOT中每個地址對應於哪個變數是由編譯器決定的。

GOT本身是放在資料段的,所以它可以在模組裝載時被修改,並且每個程序都可以有獨立的副本,相互不受影響。

如何區分一個動態共享物件是否為PIC:“readelf -d *.so | grep TEXTREL”PIC的DSO是不會包含任何程式碼段重定位表的,TEXTREL表示程式碼段重定位表地址。

多程序共享全域性變數被叫做“共享資料段”,而多個執行緒訪問不同的全域性變數副本叫做“執行緒私有儲存(TLS)”。

對於共享物件來說,如果資料段中有絕對地址引用,那麼編譯器和連結器就會產生一個重定位表。

如果程式碼不是地址無關的,它就不能被多個程序之間共享,於是也就失去了節省記憶體的特點。但是裝載時中定位的共享物件的執行速度要比使用地址無關程式碼的共享物件塊,因為他省去了地址無關程式碼中每次要訪問全域性資料和函式時需要做一次計算當前地址以及間接地址定址的過程。

動態連結的可執行檔案中存在".got"這樣的段。

動態連線比靜態連結慢的主要雲隱是動態連結下對於全域性和靜態的資料訪問都要進行復雜的GOT定位,然後間接定址;對於嗎模組鍵的嗲用也要先定位GOT,然後再進行間接跳轉,如此一來,程式的執行速度必定會減慢。

ELF採用了一種叫做延遲繫結的做法,基本思想就是當函式第一次被用到時才進行繫結(符號查詢,重定位等)如果沒有用到則不進行繫結。所以程式開始執行時,模組鍵的函式呼叫都沒有進行繫結,而是需要用到時才由動態連結器來負責繫結。

ELF將GOT拆分成了兩個表叫做".got"和".got.plt"。其中,“.got”用來儲存全域性變數引用的地址,“.got.plt”用來儲存函式引用的地址,也就是說,所有對於外部函式的引用全部被分離出來放到了".got.plt"中,另外“.got.plt”還有一個特殊的地方就是他的前三項是有特殊意義的。所以前三項是被系統佔據的,從第四項開始才真正是存放匯入函式地址的地方。

PLT在ELF檔案中以獨立的段存放,段名通常叫做".plt“,因為他本身是一些地址無關的程式碼,所以可以跟程式碼段等一起合併成一個可讀可執行的"segment"被裝入記憶體。

靜態連結裝載:作業系統會讀取可執行檔案的頭部,檢查檔案的合法性,然後從頭部中的program header中讀取每個segment的虛擬地址,檔案地址和屬性,並將他們對映到程序虛擬空間的對應位置。

在linux下,動態連結器ld.so實際上是一個共享物件,作業系統同樣通過對映的方式將他載入到程序的地址空間中。作業系統在載入完動態連結之後,就將控制權交給動態連結器的入口地址(和可執行檔案一樣,共享物件也有入口地址)。當動態連結器得到控制權之後,他開始執行一系列自身的初始化操作,然後根據當前的環境引數,開始對可執行檔案進行動態連結工作。當所有動態連結完成工作以後,動態連結器會將控制權轉交到可執行檔案的入口地址,程式開始正式執行。

動態連結器的位置既不是由系統配置指定,也不是由環境引數決定,而是由ELF可執行檔案決定。在動態連結的ELF可執行檔案中,有一個專門的段叫做".interp"。

在linux中,作業系統在對可執行檔案愛你的進行載入的時候,它回去尋找裝載該可執行檔案所需要相應的動態連結器,即".interp"段指定的路徑的共享物件。

動態連結檔案ELF中最重要的結構應該是".dynamic"段,這個段裡面儲存了動態連結器所需要的基本資訊,比如依賴哪些共享物件,動態連結符號表的位置,動態連結器重定位表的位置,共享物件初始化程式碼的地址等,所以".dynamic"段可以看成是動態連結下ELF檔案的檔案頭。

linux-gate.so.1不存在於檔案西戎中,實際上是一個核心虛擬共享物件,涉及到linux的系統呼叫和核心。

很多時候動態連結的模組同時擁有".dynsym"和".symtab"兩個表,".symtab"中往往儲存了所有符號,包括".dynsym"中的符號。

為了加快符號的查詢過程,往往還有輔助的符號雜湊表".hash".

動態連結下,無論是可執行檔案或共享物件,一旦它依賴於其他共享物件,也就說有匯入的符號時,那麼它的程式碼或資料中就會有對於匯入符號的引用。

PIC模式的共享物件也需要重定位,對於使用PIC技術的可執行檔案或共享物件來說,雖然他們的程式碼段不需要重定位(因為地址無關),但是資料段還包含了絕對地址的引用,因為程式碼段中絕對地址相關的部分被分離了出來,變成了GOT,而GOT實際上是資料段的一部分。

目標檔案的重定位是在靜態連結完成的,而共享物件的重定位是在裝載時完成的。

共享物件的資料段是沒有辦法做到地址無關的,他可能會包含絕對地址的引用,對於這種絕對地址的引用,我們必須在裝載時將其重定位。

動態連結基本上分為3步:先是啟動動態連結器本身,然後裝載所有需要的共享物件,最後是重定位和初始化。

動態連結器本身不可以依賴於其他任何物件,其次是動態連結本身所需要的全域性和靜態變數的重定位工作由它本身完成。(動態連結器必須在啟動時有一段非常精巧的程式碼可以完成這項艱鉅的工作同時又不能用到全域性和靜態變數,這種具有一定限制條件的啟動程式碼被稱為自舉)。

動態連結器入口地址即是子句程式碼的入口,檔作業系統將程序控制權交給動態連結器時,動態連結器的自舉程式碼即開始執行。自舉程式碼首先會找到它自己的GOT。而GOT的第一個入口儲存的就是“.dynamic”段的偏移地址,由此找到了動態連結器本身的".dynamic"段。通過".dynamic"中的資訊,自舉程式碼便可以獲得動態聯結器本身的重定位表和符號表等,從而得到動態連結器本身的重定位入口,先將他們全部重定位。從這一步開始,動態連結器程式碼中才可以開始使用自己的全域性變數和靜態變數。

完成基本的自舉以後,動態連結器將可執行檔案和連結器本身的符號表都合併到一個符號表當中。我們可以稱他為全域性符號表。然後連結器開始尋找可執行檔案所依賴的共享物件。".dynamic"段中有一種型別的入口是DT_NEEDED,他所指出的是該可執行檔案或共享物件所依賴的共享物件。由此,連結器可以列出可執行檔案的所有共享物件,並將這些共享物件的名字放入到一個裝載集合中。然後連線誒其開始從結合中取一個所需要的共享物件的名字,找到相應的檔案後開啟該檔案,讀取相應的ELF檔案頭和“.dynamic”段,然後將他想in個的程式碼段和資料段對映到程序空間中。如果這個ELF共享物件還依賴其他共享物件,那麼將所依賴的共享物件的名字放到裝載集合中。如此迴圈直到所有依賴的共享物件都被裝載進來為止。

“-XLinker -rpath”表示連結器在當前路徑尋找共享物件。

當一個符號需要被加入全域性符號表時,如果相同的符號名已經存在,則後加入的符號被忽略。

動態連結器是一個非常特殊的共享物件,他不僅是一個共享物件,還是個可執行程式,可以直接在命令列下執行。

共享庫和可執行檔案實際上沒有上呢麼區別,除了檔案頭的標誌位和副檔名不同之外,其他都是一樣的。

動態連結器本身是靜態連結的,它不能依賴於其他的共享物件,動態連結器本身是用來幫助其他ELFwenjian解決共享物件依賴問題的。

一般的共享物件不需要進行任何修改就可以進行執行時裝載,這種共享物件往往被叫做動態裝載庫。

動態庫和共享物件之間的主要區別是共享物件是由動態連結器在程式啟動之前負責裝載和連結的,這一系列步驟都是由動態連結器自動完成的,對於程式本身是透明的,而動態庫的裝載是通過一系列由動態聯結器提供的API,具體的講有4個函式:開啟動態庫,查詢符號,錯誤處理,以及關閉動態庫,程式可以通過這幾個API對動態庫進行操作。

符號不僅僅是函式和變數,有時還是常量,比如表示編譯單元檔名的符號等,這一般由編譯器和連結器產生,而且對外是不可見,但他們的確存在於模組的符號表中。

共享庫的ABI跟程式語言有著很大的關係,不同的語言對於介面的相容性要求不同。

因為C++標準對於C++的ABI沒有做出規定,所以不同的編譯器甚至同一個編譯器的不同版本對於C++的一些特性的實現都有著各自的方案,而且互不相容,比如虛擬函式表,模組例項化,多重繼承等等。

程式中必須包含被依賴的共享庫的名字和主版本號,因為不同主機板本號之間的共享庫是完全不相容的。

在linux系統中,系統會為每個共享庫在他所在的目錄建立一個跟“SO-NAME”相同的並且指向它的軟連線。

建立以SO-NAME為名字的軟連結目的是,使得所有依賴某個共享庫的模組,在編譯,連結和執行時,都使用共享庫的SO-NAME,而不使用詳細的版本號。

GCC允許使用一個叫做“.symver”的彙編巨集指令來指定符號的版本,這個彙編指令可以被用在GAS彙編中,也可以在GCC的C/C++原始碼中以嵌入彙編指令的模式使用。

在linux下,當我們使用ld連結一個共享庫時,可以使用“--version-script”引數;如果使用GCC,則可以使用"-Xlinker"引數加“--version-script”,相當於把“--version-script”傳遞給連結器。

目前大多數包括Linux在內的開源作業系統都遵守一個叫做FHS的標準,這個標準規定了一個系統中的系統檔案應該如何存放,包括各個目錄的結構,組織和作用,這有助於促進各個開源作業系統之間的相容性。

任何一個動態連結的模組所依賴的模組路徑儲存在“.dynamic”段裡面。由DT_NEED型別的項表示。動態連結器對於模組的查詢有一定的規則:如果DT_NEED裡面儲存的是絕對路徑,那麼動態連結器就按照這個路徑去查詢;如果DT_NEED裡面儲存的hi相對路徑,那麼動態連結器會在/lib,/usr/lib和/etc/ld.so.conf配置檔案指定的目錄中查詢共享庫。

linux系統中有一個叫做ldconfig的程式,這個程式的作用是為共享庫目錄下的各個共享庫建立,刪除或更新相應的SO-NAME(即相應的符號連結),這樣每個共享庫的SO-NAME就能夠指向正確的共享庫檔案,並且這個程式還會將這些SO-NAME收集起來,集中存放到/etc/ld.so.cache檔案裡面,並建立一個SO-NAME的快取。當動態連結器要查詢共享庫時,它可以直接從/etc/ld.so.config裡面查詢。而/etc/ld.so.cache的結構是經過特殊設計的、非常適合查詢,所以這個設計大大加快了共享庫的查詢過程。

如果不適用-soname來指定共享庫二等SO-NAME,那麼該共享庫預設就沒有SO_NAME,即使用ldconfig更新SO-NAME二等軟連線時,對該共享庫也沒有效果。

使用LD_LIBRARY_PATH可以指定共享庫的查詢路徑,還可以使用連結器的"-rpath"選項指定連結產生的目標程式的共享庫查詢路徑。

在共享模組中反向引用主模組中的符號時,只有那些在連結時被共享模組引用到的符號才會被匯出。

ld連結器提供了一個"-export-dynamic"的引數,這個引數表示連結器在乘勝可執行檔案時,將所有全域性符號匯出到動態符號表。

GCC提供了一種共享庫的建構函式函式,只要在函式宣告時加上"__attribute__((constructor))"的屬性,即指定該函式為共享庫建構函式,擁有這種屬性的函式會在共享庫載入時被執行,即在程式的main函式之前執行。但是如果使用了這種建構函式,那麼必須使用系統預設的標準執行庫和啟動檔案,即不可以使用GCC的"-nostartfiles"或"-nostdllib"這兩個引數。

實際上,共享庫還可以是符合一定格式的連結指令碼檔案。

在平坦的記憶體模型中,整個記憶體是一個同一的地址空間,使用者可以使用一個32位的指標訪問任意記憶體位置。

棧通常在使用者空間的最高地址處分配,通常有數兆位元組的大小。

在某些時候,堆也可能沒有固定統一的儲存區域。堆一般比棧大很多,可以有幾十至數百兆位元組的容量。

沒有棧就沒有函式,沒有區域性變數。

棧儲存了一個函式呼叫所需要的維護的資訊,這常常被稱為堆疊幀或活動記錄(函式的返回地址和引數,臨時變數,儲存的上下文)。

之所以要儲存一些暫存器,在於編譯器可能要求某些暫存器在呼叫前後保持不變,那麼函式就可以在呼叫開始時將這些暫存器的值壓入棧中,在結束後再取出。

GCC編譯器有一個引數叫做-fnomit-frame-pointer可以取消幀指標,即不使用任何幀指標,但是這樣就無法準確定位函式的呼叫軌跡。

在C語言裡,存在著多個呼叫慣例,而預設的呼叫慣例是cdecl。任何一個沒有顯式指定呼叫慣例的函式都預設是cdecl慣例。

如果返回值型別的尺寸太大,C語言在函式返回時會使用一個臨時的棧上記憶體區域作為中轉,如果返回值物件會被拷貝兩次。因而不到萬不得已,不要輕易返回大尺寸的物件。

返回物件的拷貝情況完全不具備可移植性,不同的編譯器產生的結果可能不同。

函式傳遞大尺寸的返回值所使用的方法並不是可移植的,不同的編譯器,不同的平臺,不同的呼叫慣例甚至不同的編譯引數都有權利採用不同的實現方法。

系統呼叫的效能開銷是很大的,當程式對堆的操作比較頻繁時。這樣做的結果是會嚴重影響程式的效能的。比較好的做法就是程式向作業系統申請一塊適當大小的堆空間,然後由程式自己管理這塊空間,而具體來講,管理堆空間分配的往往是程式的執行庫。

linux提供了兩種堆空間分配的方式,即兩個系統呼叫,一個是brk(),另外一個是mmap()。

brk()的作用實際上是設定程序資料段的結束地址,即它可以擴大或者縮小資料段(Linux下資料段和BSS合併在一起統稱為資料段)。

mmap的作用是向作業系統申請一段虛擬地址空間,當然這塊虛擬地址空間可以對映到某個檔案,當他不講地址空間對映到某個檔案時,又稱這塊空間為匿名空間,匿名空間就可以拿來作為堆空間。他是系統虛擬空間申請函式,他申請的空間的其實地址和大小都必須是系統頁大小的整數倍,對於位元組數很小的請求如果也使用mmap的話,無疑會浪費大量的空間。

mmap申請匿名空間時,系統會為它在記憶體或交換空間中預留地址,但是申請的空間大小不能超出空閒記憶體空閒交換空間的綜合。

堆的分配演算法對於glibc來說,對於小於64位元組的空間申請是採用類似於物件池的方法,而對於大於512位元組的空間申請採用的是最佳適配演算法,對於大於64位元組而小於512位元組的,會根據情況採取上述方法中的最佳折中策略;對於大於128KB的申請,會使用mmap機制直接向作業系統申請空間。

atexit接受一個函式指標作為引數,並保證在程式正常退出(指從main裡返回或呼叫exit函式)時,這個函式指標指向的函式被呼叫。

程式的入口點實際上是一個程式二等初始化和結束的部分,他往往是執行庫的一部分。

glibc的程式入口為_start(這個入口是由ld連結器預設的連結指令碼所指定),_start由彙編實現,並且和平臺相關。在呼叫_start前,裝載器會把使用者的引數和環境變數壓入棧中。

環境變數是存在於系統中的一些公用資料,任何程式都可以訪問。C語言可以使用getenv這個函式來獲取環境變數資訊。

_exit的作用僅僅是呼叫了exit這個系統呼叫。也就是說_exit呼叫後,程序就會直接結束。程式正常結束有兩種情況,一種是main函式的正常退出,一種是程式中exit退出。在_libc_start_main裡可以看到,即使main返回了,exit也會被呼叫。exit是程序正常退出的必經之路,因此把呼叫用atexit註冊的函式的任務交給exit來完成可以說萬無一失。

在linux裡,程序必須使用exit系統呼叫結束。一旦exit被呼叫,程式的執行就會終止。因此實際上,exit末尾的hlt不會執行,從而_libc_start_main永遠不會返回,以至於_start末尾的hlt指令也不會執行。_exit裡的hlt指令是為了檢測exit系統呼叫是否成功。

無論是Linux還是Windows,檔案控制代碼總是和核心的檔案物件相關聯的,但如何關聯細節使用者並不可見。核心通過控制代碼來計算出核心裡檔案物件的地址,但此能力並不對使用者開放。

在核心中,每一個程序都有一個私有的“代開檔案表”,這個表是一個指標陣列,每一個元素都只想一個核心的開啟檔案物件。而fd,就是這個表的下標。

首先I/O初始化函式需要在使用者空間中建立stdin,stout,sterr及其對應的FILE結構,使得程式進入main之後可以直接使用printf,scanf等函式。

一個C語言執行庫大致包含了如下功能:

1 啟動和退出:包括入口函式及入口函式所以來的其他函式等 2標準函式:由C語言標準規定的C語言標準庫所擁有的函式實現 3 I/O:I/O功能的封裝和實現 4 堆:對的封裝和實現 5 語言實現:語言中的一些特殊功能的實現 6除錯

在GCC編譯器下 ,變長引數巨集可以使用"##"巨集字元連線操作實現。

非區域性跳轉即使在C語言裡也是一個備受爭議的機制。使用非區域性跳轉,可以實現從一個函式體內向另一個事先登記過的函式體內跳轉,而不用擔心堆疊混亂。

執行庫是平臺相關的,因為它與作業系統化結合的分廠緊密。C語言的執行從某種程度上來講是C語言的程式和不同作業系統之間的抽象層,它將不同的作業系統API抽象成相同的庫函式。

glibc事實上是標準C語言執行庫的超集,他們各自對C標準庫進行了一些擴充套件。

crt0.o和crt1.o之間的區別是crt0.o為原始的,不支援".init"和".finit"的啟動程式碼,而crt1.o是改進過的後的,支援".init"和".finit"的版本。

為了保證最終輸出檔案中的".init"和".finit"的正確性,我們必須保證在連結時,crti.o必須在使用者目標檔案和系統庫之前,而crtn.o必須在使用者目標檔案和系統庫之後。

GCC提供了兩個引數"-nostaratfile"和"-nostdlib",分別用來取消預設的啟動檔案和C語言執行庫。

我們可以使用"__attribute__((section("init")))"將函式放到.init段裡面,但要注意的是普通函式放在".init"是會破壞他們的結構餓 ,因為函式的返回指令使得_init()函式會提前返回,必須使用匯編指令,不能讓編譯器產生"ret"指令。

C++這樣的語言的實現是跟編譯器密切相關的,而glibc知識一個C語言執行庫,他對C++的實現並不瞭解,而GCC是C++的真正實現者,他對C++的全域性構造和析構瞭如指掌。

執行緒的訪問非常自由,他可以訪問程序記憶體的所有資料,甚至包括其他執行緒的堆疊,但實際運用中執行緒也擁有自己的私有儲存空間。

對於C/C++標準庫來說,執行緒相關的部分是不屬於標準庫的內容的,他和網路,圖形影象一樣,屬於標準庫之外的系統相關庫。、

在多執行緒版本的執行庫中,執行緒不安全的函式內部都會自動地進行加鎖,包括malloc。printf等,而異常處理的錯誤也早早就解決了。

一旦一個全域性變數被定義成TLS型別的,那麼每個執行緒都會擁有這個變數的副本,任何執行緒對該變數的修改都不會影響其他執行緒中該變數的副本。

對於每個Windows程序來說,系統都會建立一個關於執行緒資訊的結構,叫做執行緒環境塊,這個結構裡面儲存的是執行緒的堆疊地址,執行緒ID等相關資訊,其中有一個域是一個TLS陣列。

對於隱式TLS,程式設計師無需關心TLS變數的申請,分配賦值和釋放,編譯器,執行庫還有作業系統已經將這一切悄悄處理了。

連結器在進行最終連結時,有一部分目標檔案來自於GCC,他們是那些與語言密切相關的支援函式。

為了實現在main函式前執行程式碼,必須在連結時進行特殊的處理。collect2這個程式就是用來實現這個功能的,它會收集所有輸入目標檔案中那些命名特殊的符號,這些特殊的符號表明他們是全域性建構函式在main前執行,collect2會生成臨時的.c檔案,將這些符號的地址收整合一個數組,然後放到這個.c檔案裡面,編譯後與其他目標檔案一起被連結到最終輸出檔案。

與任何系統級別的軟體一樣,真正複雜的並且有挑戰性的往往是軟體與外部通訊的部分,即IO部分。

因為系統呼叫的開銷是很大的,他要進行上下文切換,核心引數檢查,複製等,如果頻繁進行系統呼叫,將會嚴重影響程式和系統的效能。

所謂flush一個緩衝,即指對寫緩衝而言,將緩衝內的資料全部寫入實際的檔案,並將緩衝清空,這樣可以保證檔案處於最新的狀態。

系統呼叫是應用程式(執行庫也是應用程式的一部分)與作業系統核心之間的介面,它決定了應用程式是如何與核心打交道的。

執行時庫將不同的作業系統的系統呼叫包裝為統一固定的節誒口,使得同樣的程式碼,在不同的作業系統下都可以直接編譯,併產生一致的效果,這就是原始碼級別上的可移植性。

作業系統一般通過中斷來從使用者態切換到核心態。

系統呼叫號在執行int指令前會被放置在某個固定的暫存器裡,對應的中斷程式碼會缺德這個系統呼叫號,並且呼叫正確的函式。

C語言的大多數函式都以返回-1表示呼叫失敗,而將出錯資訊儲存在一個名為errno的全域性變數(在多執行緒庫中,errno儲存與TLS中)裡。

當用戶呼叫某個系統呼叫的時候,實際是執行另外一段彙編程式碼。

在Linux中,核心態和使用者態使用的是不同的棧,兩者各自負責各自的函式呼叫,互不干擾。

每個程序都有自己的核心棧。

aslinkage是一個巨集,定義為__attribute__((regparm(0)))這個擴充套件關鍵字的意義是讓這個函式值從棧上獲取引數。

linux用於支援新型系統呼叫的“虛擬”共享庫。linux-gateso.1並不在實際的檔案,它只是作業系統生成的一個虛擬動態共享庫(VDSO)。

呼叫sysenter之後,系統會直接跳轉到由某個暫存器指定的函式執行,並自動完成特權級轉換,堆疊切換等功能。

dd的作用為複製檔案,if引數代表輸入的檔案,而of引數代表輸出的檔案,/proc/self/mem總是等價於當前程序的記憶體快照。

作為一個商業作業系統,應用程式相容性是評價作業系統是否有競爭力最重要的指標之一。

程式執行的最初入口不是main函式,而是由執行庫為其提供的入口函式,它主要負責三部分工作:準備好程式執行環境和初始化環境,呼叫main函式執行程式主體,清理程式執行後的各種資源。

atexit()註冊回撥函式的機制主要是用來實現全域性物件的析構機制的。

brk系統呼叫可以設定程序的資料段邊界,而sbrk可以移動程序的資料段邊界,他們僅僅是分配了虛擬空間,這些空間一開始是不會提交的,(即不分配物理頁面)。

v[romtf是這些函式中真正實現字串格式化的函式。

通常C++的執行庫都是獨立於C語言執行庫的。

建構函式主要實現的是依靠特殊的段合併後形成建構函式陣列,而析構則依賴與atexit()函式。

對於GCC來說,須要定義".ctor"段的起始部分和結束部分,然後定義兩個函式指標分別指向他們。真正的構造部分則只要由一個迴圈將這兩個函式指標指向的所有函式都呼叫一遍即可。

所有的全域性物件的解構函式,不管是LInux還是Windows,都是通過atexit或其類似函式來註冊的,以達到在程式退出時執行的目的。

大端小端的區別就是大端規定MSB在儲存時存放在低地址,在傳輸時MSB放在流的開始;LSB儲存時放在高地址,在傳輸時放在流的末尾,小端則相反。