最初買《程序員的自我修養》這本書,只由於在京東買書差一些錢,不夠用優惠券。買回來之後的很長一段時間,我都覺得這本書只是程序員用來調侃和自黑的。不過翻讀了第一章之後,我就發現本身錯的太離譜。我以爲即便一個不使用C/C++,甚至是寫解釋性語言(如JS等)的程序員,也有必要抽空讀一讀這本書。做爲使用OC或Swift的iOS開發者,我認爲這本書是必讀的。c++
因此這篇文章會簡單梳理一下《程序員的自我修養》這本書的脈絡結構,若是時間有限,又想快速閱讀這本書,能夠先看看這篇文章。標註了頁號的地方表示詳細知識能夠在給出的頁數獲取詳細的知識。爲了簡化問題,有些地方會省略一些原文中的細節,一切爲了保證讀者快速瞭解這本書。程序員
對於不是專門從事C和底層開發的程序猿來講,我的認爲完整的看完本書的全部內容是不太現實,也不太必要的。這本書中有兩大部分的知識點對於新手來講很是有必要了解:算法
帶着這兩個問題去讀書,收穫會更大。在閱讀原書以前,這裏有幾個相關內容的總結,我儘量用簡單的語言介紹某些知識背景。即便不能徹底看懂,也有利於讀書時的理解。編程
程序最初的存在形式是源代碼,也就是若干個.c
文件。它要想變成一個可執行的程序,須要如下幾個步驟:數組
預編譯(P39):負責這一步工做的叫「預編譯器」。它主要負責處理全部的#define
宏定義;全部的預編譯指令,好比#if
、#endif
等。接下來會遞歸處理#include
指令,用被包含的文件替換這個預編譯指令。.c
文件通過預編譯,變爲.i
文件。瀏覽器
編譯(P42):這一步由編譯器負責,主要又由詞法分析、語法分析、語義分析、優化和生成彙編代碼五個部分:安全
(
但沒有)
,這一步就能發現錯誤2+4
就是一顆根節點爲+
,左右葉子節點分別爲2
和4
的語法樹。若是你只是寫2+
,在這一步就會報錯。2 * "3"
在這一步就會報錯2 + 3
會寫成t1 = 2 + 3
,同時也會把這樣在編譯期就能夠肯定的表達式進行優化.i
文件通過編譯,獲得彙編文件,後綴是.s
函數
彙編(P40):這一步由彙編器負責,將彙編語言轉換成機器能夠執行的語言(徹底由0和1組成).彙編文件通過彙編,變成目標文件,後綴爲.o
。工具
連接(P41):這一步是這本書的重點。以前的幾個步驟,都是以.c
文件爲基本單位,一個.c
源代碼文件最終被彙編,生成目標文件。這一步就是處理如何把多個目標文件連接起來。性能
考慮一個.c
文件中,用到了另外一個.c
文件中的變量或函數。在編譯這個文件時,咱們沒法在編譯期肯定這個變量或函數的地址。只有在把全部目標文件連接起來之後,才能肯定。連接器主要負責地址重分配、符號名稱綁定和重定位。
從源代碼到程序的運行要作的遠遠不止編譯,不少時候咱們說「把程序編譯一下」,是不許確的。不過編譯確實是整個流程中最複雜的部分。
咱們把整個計算機調用結構分爲四層:
printf()
或fread()
等函數。由於不一樣的操做系統的運行庫提供了不一樣底層的實現,但對應用層提供的API老是同樣的。fread
屬於API,它在Linux下會調用read()
這個系統調用,而在Windows下會調用ReadFile()
這個系統調用。應用程序能夠直接調用系統調用,可是這樣一來,咱們須要考慮各個操做系統下系統調用的不一樣,並且系統調用因爲更加底層,實現起來也就更加困難。最關鍵的是,系統調用是經過中斷來完成的,涉及到堆棧的保存與恢復,頻繁的系統調用會影響性能。這四層之間的關係以下圖所示:
在程序運行的過程當中,最重要的概念就是虛擬地址空間。所謂的虛擬地址空間,是指應用程序本身認爲,本身所處的地址空間。它區別於物理地址空間。後者是真實存在的,好比電腦有一根8G的內存條,物理地址空間就是0~8Gb。CPU的MMU負責把虛擬地址轉換成物理地址。
引入虛擬地址的第一個好處是,程序員再也不關心真實的物理內存空間是什麼樣的,理論上來講,程序員有幾乎無限大的虛擬內存空間可用,最後只要創建虛擬地址和物理地址的對應關係便可。另外一方面,操做系統屏蔽了物理內存空間的細節,進程沒法訪問到操做系統禁止訪問的物理地址,也不能訪問到別的進程的地址空間,這大大加強了程序安全性。
由虛擬地址空間引伸出來的分頁(Paging)技術,大大提升了內存的使用效率。要想運行一個程序,再也不須要把整個程序都放入內存中執行,咱們只要保證將要執行的頁在內存中便可,若是不存在則致使頁錯誤。
關於地址空間的理解很是重要,書中有不少關於內存、和地址的描述,須要咱們本身分析這是虛擬地址仍是物理地址。若是分析錯了,理解問題會比較麻煩。
咱們把foo
函數定義在另外一個文件中,而後在main.c
中調用這個函數,單獨編譯main.c
後代碼以下:
……
0000000000000024 callq 0x29
0000000000000029 xorl %ecx, %ecx
……
複製代碼
能夠看到,本該調用foo
函數的地方,咱們直接調用了下一條命令,可是當main.o
和foo.o
連接起來後,就變成了:
0000000100000f30 pushq %rbp
0000000100000f31 movq %rsp, %rbp
0000000100000f34 movl $0x7b, %eax
0000000100000f39 movl %edi, -0x4(%rbp)
0000000100000f3c movl %esi, -0x8(%rbp)
0000000100000f3f popq %rbp
//以上爲foo函數實現
……
0000000100000f74 callq 0x100000f30
0000000100000f79 xorl %ecx, %ecx
……
複製代碼
這時候foo
函數的位置就正確設置了。緣由在於在main.c
這個編譯模塊單獨編譯時,編譯器沒法肯定foo
的位置,只好臨時用下一條指令的位置代替一下。
連接器在連接過程當中,就是要對這樣的符號進行重定位。在重定位時,main.o
中有foo
函數通過修飾的符號名,一樣的符號名在foo.o
中也有,因而二者一拍即合,就這樣被連接器連在了一塊兒。0x29
這個臨時的調用地址被更新成了0x100000f30
。這個過程相似於拼圖遊戲,程序在連接時就是處理各類各樣相似的問題,當全部編譯模塊都按照符號名完整的連接起來時,程序也就能夠開始運行了。
書中花了很多篇幅介紹目標文件的組成結構,其中不少都是爲了重定位而準備的。一旦明白了重定位的原理和過程,在閱讀相關內容時就會輕鬆不少。
最後列出一部分知識點的簡要歸納和他們在書中的位置,方便讀者參考:
###靜態連接部分
這一部分主要是討論多個.c
文件怎麼經過靜態連接,獲得一個靜態庫。
P58
目標文件中分爲若干個段,好比.text段存放代碼,.data段存放存放已初始化的全局變量和局部靜態變量,.bss段存放未初始化的全局變量和局部靜態變量,除此之外目標文件還有不少其餘的段。
P70
Linux下的目標文件還有一個ELF文件頭,用於彙總這個目標文件的各類信息,其中包括了ELF魔數、機器字節長度、數據存儲方式、版本、運行平臺、ABI版本,重定位類型、硬件平臺及版本、入口地址、段表位置、段的數量等。
P74
段表實際上是一個數組,其中每個元素都是結構體。結構體裏面有段的名稱、類型、加載地址、相對於文件頭的偏移量,段的大小,連接信息等。
P79
目標文件中還有一個重定位表。須要重定位的信息都記錄在這個表裏面。.text段中全部須要重定位的信息,都放在.rel.text段中。
P81
在連接時,咱們把函數名和變量都稱爲符號。每個函數、變量都有本身獨特的符號名,這樣在連接時才能把它們對應起來。不一樣的語言有本身的符號修飾規則。UNIX下的C,編譯出來的符號名前面加「_」,如函數foo在編譯以後的結果爲_foo。
P86
C++的namespace就是用來避免符號名衝突。C++有一套本身的符號名修飾規則,能夠經過c++filt命令還原被修飾過的符號名(demangle)。一旦瞭解了符號名的修飾規則,在寫iOS時遇到undefined symbol
或duplicate symbol name
的報錯,就很是好檢查了。
P92
符號分爲強符號,和弱符號。強符號不可名稱重複,弱符號(未初始化的全局變量)能夠有符號名相同。對符號名的引用分爲強引用和弱引用,強引用表示若是找不到符號定義會報錯,弱引用不報錯,默認爲0或某個特殊值。
P99
連接過程通常分爲兩步,首先地址分配,而後符號解析並重定位。
因爲不一樣的目標文件,可能含有相同的段,因此在連接過程當中,咱們能夠合併類似段,這就是地址分配。
合併完成後,全部符號的位置均可以惟一肯定,此時能夠就開始重定位工做了。連接完成後,咱們就獲得了靜態庫。
P118
靜態庫能夠看作一組目標文件的集合,同一個靜態庫中的不一樣目標文件可能相互依賴,不一樣的靜態庫也能夠相互依賴。
P127
連接控制腳本控制連接器的運行,將目標文件和庫文件轉化爲可執行文件。連接控制腳本由連接腳本語言寫成。能夠認爲的控制程序入口,某幾個段合併,某幾個段捨棄等
這一部分主要是討論通過連接後,可執行文件如何裝載到內存中
P153
有兩種典型的動態裝載方法:覆蓋裝入和頁映射。覆蓋裝入容許互不依賴的兩個模塊共同享有同一塊內存,在使用中互相替換。速度較慢,用時間換空間。咱們經常使用的方案是頁映射,把程序虛擬的內存空間分紅多個頁,由專門的頁裝載管理器負責管理虛擬頁和物理內存中頁的對應關係。
P157
建立進程三步驟:首先進程本身的建立物理空間。設置好虛擬空間中各個頁到物理空間裏的頁的映射關係(這一步可能在頁錯誤以後發生)、而後創建虛擬空間與可執行文件的映射關係。Linux下,目標文件的每一個段都有本身在虛擬內存中的位置,這叫虛擬內存區域(VMA, Virtual Memory Area),表示它裝載在虛擬內存中的地址,最後指令寄存器設置爲可執行文件入口。
P159
進程建立後,只有物理頁與虛擬頁的對應關係,可是真正的指令和數據尚未放入物理頁中,物理頁的內存處於未分配狀態。一旦訪問到這個物理頁,就會發生頁錯誤。
發生頁錯誤時,操做系統馬上根據物理內存的頁與虛擬內存的頁的對應關係,找到這個頁對應的虛擬內存,而後再查詢每一個段的VMA,就能夠找這個頁面在可執行文件中的偏移量。這時候操做系統先爲物理頁分配內存空間,而後把可執行文件中的數據和指令寫入物理頁,最後創建物理頁和虛擬頁聯繫便可。而後進程從發生頁錯誤的地方從新執行。
P169
可執行文件有不少Section,它們的大小各不相同,但有些小於頁的大小,致使了空間浪費(不能連續存儲不一樣的section是由於可能會有兩個權限不一樣的section在同一個頁中)。因爲操做系統不關心每一個Section的具體做用,可是關心它們的讀寫權限(是否可讀、可寫、可執行),因此每每把具備權限的Section合併成一個Segment
P172:
進程運行後,操做系統會初始化進程的堆棧,其中存放了環境變量和命令行參數。這些參數被傳給main函數(argc和argv兩個參數對應參數數量和參數數組)
P181
動態連接把程序按模塊拆分紅若干個相對獨立的部分,模塊之間的連接推遲到運行時。ELF的動態連接文件成爲「動態共享對象(DSO)」,後綴爲「.so」。動態連接的過程由動態連接器完成。動態連接能夠節約內存(多個進程共享內存中的某一個模塊)、方便升級(靜態連接的每個模塊都會影響整個可執行文件)。
P188:
因爲動態共享對象會被多個程序使用,致使它在虛擬地址空間中的位置難以肯定。不一樣模塊的目標裝載地址若是有相同的,那麼同時導入這兩個模塊就會出問題。若是都不同也不行,由於可能存在的模塊太多了。沒有那麼多內存。因此動態共享對象須要在裝載時重定位。
P191:
裝載時重定位會致使沒法在多個進程間共享,目前採用的方案是地址無關代碼技術。動態對象中的地址引用分爲模塊內部和外部,指令引用和數據引用,兩兩組合成四種。對於模塊內部的指令或數據引用,採用相對偏移調用的方法。
P195:
把地址相關須要重定位的部分放到數據段中,同時創建全局偏移表(GOT)。用.got和.got.plt表分別處理數據和函數引用。
P200:
當函數第一次被用到的時候才重定位,從而提升程序運行速度。這種方法被稱爲延遲綁定(Lazy Binding)。Linux維護一個PLT(Procedure Linkage Table)來保存符號名和真實地址之間的對應關係
P208:
動態連接中有兩個重定位表.rel.dyn和.rel.plt分別對應.rel.text和.rel.data。前者對數據引用(.got)進行修正,後者對函數引用(.got.plt)進行修正。
P214:
動態連接器是一個特殊共享對象,它不依賴於任何動態共享文件,且本身的重定位工做由本身完成。經過一段被稱爲自舉(Bootstrap)的特殊代碼,不用到任何靜態或所有變量,完成這項工做
P286:
i386處理器下,棧頂有esp寄存器定位,因爲棧向下生長,壓棧使得棧頂地址減少
P287:
棧保存了函數調用所須要的維護信息,被稱爲堆棧幀(Stack Frame)或活動記錄,包含了函數的返回地址和函數,臨時變量以及保存的上下文。ebp是幀指針指向活動記錄的某一個固定位置。
P294:
函數的調用方和被調用方要遵照同一個「調用慣例」。默認的cdecl慣例要求函數參數以從右到左的順序入棧,由函數調用方負責參數的出棧。
P301:
函數返回值的獲取:若是是四個字節,放在eax中。4-8字節的返回值經過eax(低位)和edx(高位)聯合存儲。查過8字節的返回值,把返回值在棧中存放的地址放到eax中。
P306:
棧上的數據在函數返回時就會被釋放,全局地、動態的申請內存的方式是利用堆。若是由操做系統管理堆,因爲老是進行系統調用,性能開銷比較大,因此通常由應用程序「批發」一大塊內存空間,而後本身進行內存管理。
P311:
堆並不老是向上生長(如Windows的HeapCreate系列),調用malloc有可能產生系統調用(取決於進程預申請的空間是否足夠),堆內存在進程結束後被操做系統回收,堆內存在虛擬地址空間中連續,在物理空間中可能不連續
P314:
堆分配三種算法:空閒鏈表(簡單,記錄長度的字節容易被數組越界破壞)、位圖(速度快(容易命中cache),穩定性好(不容易數組越界),易管理,會產生碎片,位圖有可能過大)、對象池(針對固定大小的分配空間)
P319:
建立進程後,操做系統把控制權交給運行庫的某個入口函數,而後開始堆的構造,啓動I/O,建立線程,進行全局變量構造等。而後調用main函數,main函數執行完成後,執行與以前相反的操做,進行系統調用結束進程。