最初買《程式設計師的自我修養》這本書,只因為在京東買書差一些錢,不夠用優惠券。買回來以後的很長一段時間,我都以為這本書只是程式設計師用來調侃和自黑的。不過翻讀了第一章以後,我就發現自己錯的太離譜。我覺得即使一個不使用C/C++,甚至是寫解釋性語言(如JS等)的程式設計師,也有必要抽空讀一讀這本書。作為使用OC或Swift的iOS開發者,我認為這本書是必讀的。
所以這篇文章會簡單梳理一下《程式設計師的自我修養》這本書的脈絡結構,如果時間有限,又想快速閱讀這本書,可以先看看這篇文章。標註了頁號的地方表示詳細知識可以在給出的頁數獲取詳細的知識。為了簡化問題,有些地方會省略一些原文中的細節,一切為了保證讀者快速瞭解這本書。
對於不是專門從事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
檔案中的變數或函式。在編譯這個檔案時,我們無法在編譯期確定這個變數或函式的地址。只有在把所有目標檔案連結起來以後,才能確定。連結器主要負責地址重分配、符號名稱繫結和重定位。
從原始碼到程式的執行要做的遠遠不止編譯,很多時候我們說“把程式編譯一下”,是不準確的。不過編譯確實是整個流程中最複雜的部分。
軟體呼叫層次
我們把整個計算機呼叫結構分為四層:
- 最上層是應用層。不管是瀏覽器、遊戲,還是我們使用的各種開發工具,如Xcode,VS,彙編器自身等,都屬於這一範疇。
- 第二層是作業系統的執行庫。我們在程式裡呼叫系統API,比如檔案讀寫,就是呼叫了第二層提供的相應服務。這種呼叫通過作業系統的API完成,它溝通了應用層和作業系統的執行庫。這也就是為什麼不管是在Mac還是Windows上程式設計,我們都可以呼叫
printf()
或fread()
等函式。因為不同的作業系統的執行庫提供了不同底層的實現,但對應用層提供的API總是一樣的。 - 第三層是作業系統核心。作業系統的執行庫通過系統呼叫(System Call)呼叫系統核心提供的函式。比如
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函式執行完成後,執行與之前相反的操作,進行系統呼叫結束程式。