讀書筆記 - 《程式設計師的自我修養》

weixin_33806914發表於2016-11-24

一、溫故而知新

1. 記憶體不夠怎麼辦

  • 記憶體簡單分配策略的問題
    • 地址空間不隔離
    • 記憶體使用效率低
    • 程式執行的地址不確定
  • 關於隔離 : 分為 虛擬地址空間 和 實體地址空間
  • 分段 : 把一段程式所需要的記憶體空間大小對映到某個地址空間
  • 分頁 : 把地址空間人為地等分成固定大小的頁,每一頁大小由硬體決定,或硬體支援多種大小的頁,由作業系統決定頁的大小,目前幾乎所有的 PC 上的作業系統都使用 4KB 大小的頁。
    • 虛擬頁 VP
    • 物理頁 PP
    • 磁碟頁 DP

2. 執行緒

  • 執行緒基礎
    • 一個標準的執行緒由執行緒 ID當前指令指標(PC)暫存器集合堆疊組成
    • 執行緒私有
      • 棧 區域性變數
      • 函式的引數
      • 執行緒區域性儲存 TLS 資料
      • 暫存器
    • 執行緒之間共享
      • 全域性變數
      • 堆上的資料
      • 函式裡的靜態變數
      • 程式程式碼
      • 開啟的檔案
    • 執行緒排程中,執行緒通常擁有至少三種狀態
      • 執行
      • 就緒
      • 等待
  • 執行緒安全
    • 原子操作
    • 同步與鎖
    • 防止 CPU 過度優化
  • 多執行緒的內部情況——三種執行緒模型
    • 一對一模型 (使用者執行緒 核心執行緒)
    • 多對一模型
    • 多對多模型

二、編譯和連結

1. 被隱藏了的過程

  • 預編譯 : 主要處理那些原始碼檔案中的以 “#” 開始的預編譯指令,過程相當於 gcc -E hello.c -o hello.i
  • 編譯 : 把預處理完的檔案進行一系列詞法分析、語法分析、語義分析及優化後生產相應的彙編程式碼檔案,過程相當於 gcc -S hello.c -o hello.s,現在版本的 GCC 把預編譯和編譯兩個步驟合併成一個步驟,使用一個叫做 cc1 的程式來完成這兩個步驟
  • 彙編 : 將彙編程式碼轉變成機器可以執行的指令,每一個彙編語句都對應一條機器指令,過程相當於 gcc -c hello.s -o hello.o
  • 連結 : 將一大堆 .o 目標檔案連結起來才可以得到 a.out

2. 編譯器

  • 將高階語言翻譯成機器語言的一個工具
  • 編譯過程一般可分為 6 步:掃描、語法分析、語義分析、原始碼優化、程式碼生成、目的碼優化
    • 詞法分析 : 原始碼程式被輸入到掃描器,運用一種類似於有限狀態機的演算法輕鬆地將原始碼的字元序列分割成一系列的記號,記號一般分為:關鍵字、識別符號、字面量、特殊符號
    • 語法分析 : 對由掃描器產生的記號進行分析,從而產生語法樹(以表示式為節點的樹),整個分析過程採用了上下文無關語法
    • 語義分析 : 靜態語義和動態語義,經過語義分析階段後,整個語法樹的表示式都被標識了型別
    • 中間語言生成 : 原始碼優化器會在原始碼級別進行優化,將整個語法樹轉換成中間程式碼,使得編譯器可以被分為前端和後端,前端負責產生機器無關的中間程式碼,後端將中間程式碼轉換成目標機器程式碼。中間程式碼有很多型別,比較常見的有:三地址碼、P - 程式碼
    • 目的碼生成與優化 : 編譯器後端包括 程式碼生成器(將中間程式碼轉換成目標機器程式碼) 和 目的碼優化器(對目的碼進行優化,比如選擇合適的定址方式、使用位移來代替乘法運算、刪除多餘的指令等)

3. 靜態連結

  • 連結的主要內容 : 把各個模組之間相互引用的部分都處理好,使得各個模組之間能夠正確地連結
  • 過程主要包括 : 地址和空間分配、符號決議、重定位等步驟
  • 每個模組的原始碼檔案經過編譯器編譯成目標檔案,目標檔案和庫一起連結形成最終的可執行檔案
  • 最常見的庫就是執行時庫,它是 支援程式執行的基本函式的集合,庫其實是一組目標檔案的包
  • 重定位 : 編譯器沒法確定地址的情況下,先將指令的目標地址置為 0,等待連結器將目標檔案連結起來的時候再將其修正

三、目標檔案

1. 目標檔案的格式

  • 目標檔案就是原始碼編譯後但未進行連結的那種中間檔案,它跟可執行檔案的格式幾乎是一樣的,可廣義的看成同一種型別的檔案,在 Windows 下可把它們統稱為 PE-COFF 格式,在 Linux 下可把它們統稱為 ELF 檔案
  • 可執行檔案、動態連結庫即靜態連結庫檔案都按照可執行檔案格式儲存。可執行檔案格式 :主要是 Windows 下的 PELinux 的 ELF,都是 COFF 格式的變種
  • 靜態連結庫和動態連結庫都是按照可執行檔案格式儲存
  • ELF 檔案歸為 4 類 :
    • 可重定位檔案 : 如 .o / .obj
    • 可執行檔案 : 如 .exe
    • 共享目標檔案 : 如 .so / .dll
    • 核心轉儲檔案 : Linux 下的 core dump

2. 目標檔案

  • 包含 : 編譯後的機器指令程式碼、資料、連結時所需要的一些資訊(如 符號表、除錯資訊、字串等)
  • 目標檔案將這些資訊以“段”的形式儲存
  • 程式原始碼被編譯後主要分成兩種段:
    • 程式指令 : 程式碼段
    • 程式資料 :
      • 資料段 : 已初始化的 全域性變數和區域性靜態變數
      • .bss 段 : 為 未初始化的 全域性變數和區域性靜態變數 預留位置,並沒有內容,所以在檔案中也不佔據空間
    • 資料和指令分段的好處:
      • 資料區域是可讀寫的,指令區域是隻讀的,防止程式的指令被改寫
      • 對 CPU 的快取命中率提高有好處
      • 當系統中執行著多個該程式的副本時,指令等資源都是共享的,只需儲存一份,而每個副本的資料區域是不一樣的,是程式私有的,可節省大量空間

3. 挖掘目標檔案

  • objdump -h SimpleSection.o : 檢視各種目標檔案的結構和內容
  • size SimpleSection.o : 檢視 ELF 檔案的程式碼段、資料段和 BSS 段的長度
  • 程式碼段
  • 資料段和只讀資料段
    • .data 段儲存的是那些已經初始化了的全域性變數和區域性靜態變數
    • .rodata 段存放的是制度資料,一般是程式裡面的只讀變數和字串常量
  • BSS 段 : .bss 段存放的是 未初始化的全域性變數和區域性靜態變數,.bss 段為它們預留了空間,但有些編譯器不存放全域性的未初始化變數,只是預留一個未定義的全域性變數符號,等到最終連結成可執行檔案的時候再在 .bss 段分配空間,但是編譯單元內部可見的靜態變數的確是存放在 .bss 段的
  • 其他段
    • 這些段的名字都是由 . 作為字首,表示這些表的名字是系統保留的,應用程式也可以使用一些非系統保留的名字作為段名
    • 自定義段 : 在全域性變數或函式之前加上 __attribute__((section("name"))) 屬性就可以把相應的變數或函式放到以 name 作為段名的段中

4. ELF 檔案結構描述

  • 檔案頭
    • readelf -h 詳細檢視 ELF 檔案
    • 檔案頭重定義了 ELF 魔數(最前面的 “Magic” 的 16 個位元組,被 ELF 標準規定用來標識 ELF 檔案的平臺屬性,用來確認檔案的裡型別)、檔案機器位元組長度、資料儲存方式、版本、執行平臺、ABI 版本、ELF 重定位型別、硬體平臺、硬體平臺版本、入口地址、程式頭入口和長度、段表的位置和長度、段的數量等
  • 段表
    • 儲存各個段的基本屬性的結構,描述了 ELF 各個段的資訊,如每個段的段名、長度、在檔案中的偏移、讀寫許可權、段的其他屬性
    • 是一個以 Elf32_Shdr 結構體為元素的陣列,元素的個數等於段的個數
    • 段的名字只是在連結和編譯過程中有意義,對於編譯器和連結器來說,主要決定段的屬性的是 段的型別段的標誌位
      • 段的型別 相關常量以 SHT_ 開頭
      • 段的標誌位 表示該段在程式虛擬地址空間中的屬性,如是否可寫、是否可執行等,相關常量以 SHF_ 開頭
  • 重定位表 : 連結器在處理目標檔案時,須要對目標檔案中的某些部位進行重定位,即程式碼段和資料段中那些對絕對地址的引用的位置
  • 字串表
    • 把字串集中起來存放到一個表,然後使用字串在表中的偏移來引用字串
    • 字串表(儲存普通的字串) 和 段表字串表(儲存段表中用到的字串)

5. 連結的介面 —— 符號

  • 在連結中,將函式和變數統稱為符號,函式名或變數名就是符號名
  • 每一個目標檔案都會有一個相應的符號表,每個定義的符號有一個對應的值,叫做符號值
  • 符號表中的所有符號進行分類 :
    • 定義在本目標檔案的全域性符號
    • 在本目標檔案中引用的全域性符號(外部符號)
    • 段名,它的值就是該段的起始地址
    • 區域性符號
    • 行號資訊
  • ELF 符號表結構 :ELF 符號表往往是檔案中的一個段,段名一般叫 .symtab,是一個 Elf32_Sym 結構的陣列
    • st_name 符號名
    • st_value 符號對應的值
    • st_size 符號大小
    • st_info 符號型別和繫結資訊
    • st_other (沒用)
    • st_shndx 符號所在的段 : 如果符號定義在本目標檔案中,那麼這個成員表示符號所在段表的下表
  • 特殊符號 :使用 ld 作為連結器來連結生產可執行檔案時,他會為我們定義很多特殊的符號,可以直接宣告並且引用它
  • 符號修飾與函式簽名
    • 防止不同目標檔案中的符號名衝突,如 C++ 增加了名稱空間的方法來解決多模組的符號衝突問題
    • C++ 符號修飾
    • 函式簽名 : 包含一個函式的資訊,包括函式名、引數型別、類和名稱空間及其他資訊
  • extern "C" : C++ 會將在其的大括號內的程式碼當做 C 語言程式碼處理
  • 弱符號與強符號
    • 對於 C/C++ 語言來說,編譯器預設函式和初始化了的全域性變數為強符號,未初始化的全域性變數為弱符號,也可以通過 GCC 的 __attribute__((weak)) 來定義任何一個強符號為弱引用,強、弱符號不是針對符號的引用,只針對定義
    • 不允許強符號被多次定義、如果一個符號在某個目標檔案中是強符號而在其他檔案中都是弱符號,那麼選擇強符號
    • 弱引用與強引用 :對於未定義的弱引用,連結器不認為它是一個錯誤,預設為 0 或一個特殊的值,而對於未宣告的強引用,連結器會報符號未定義錯誤

四、靜態連結

1. 空間與地址分配

  • 連結過程 :將幾個輸入目標檔案加工後合併成一個輸出檔案
  • 按序疊加 :會造成記憶體空間大量的內部碎片
  • 相似段合併 :將相同性質的段合併到一起
    • 連結器為目標分配地址和空間 :對於有實際資料的段,兩者都要分配空間;對於 ".bss" 這樣的段只侷限於分配虛擬地址空間
      • 在輸出的可執行檔案中的空間
      • 在裝載後的虛擬地址中的虛擬地址空間
    • 兩步連結
      • 空間與地址分配
      • 符號解析與重定位
  • 符號地址的確定 :各個符號在段內的相對位置是固定的,連結器須要給每個符號加一個偏移量,使它們能夠調整到正確的虛擬地址

2. 符號解析與重定位

  • 重定位
    • 原始碼被編譯成目標檔案時,編譯器不知道定義在其他目標檔案中的符號地址,所以編譯器暫時把地址 0 看作是該變數的地址,該函式的地址也是一個臨時的假地址
    • 連結器在完成地址和空間分配之後就可以確定所有符號的虛擬地址了,連結器可以根據富豪的地址對每個需要重定位的指令進行地位修正,call 指令是一條近址相對位移呼叫指令,它後面跟的是呼叫指令的下一條指令的偏移量
  • 重定位表
    • objdump -r 每個要被重定位的 ELF 段都有一個對應的重定位表,也就是 ELF 檔案中的一個段
    • 每個要被重定位的地方叫一個重定位入口,重定位入口的偏移表示該入口在要被重定位段中的位置
  • 符號解析 :重定位過程中,每個重定位的入口都是對一個符號的引用,重定位過程中,連結器會去查詢由所有輸入目標檔案的符號表組成的全域性符號表,找到對應的符號後進行重定位
  • 指令修正方式
    • 絕對定址修正 S + A :修正後的地址為該符號的實際地址
    • 相對定址修正 S + A - P :修正後的地址為該符號距離被修正位置的地址差

3. COMMON 塊

  • 編譯器將未初始化的全域性/靜態變數作為弱符號處理,連結時存在多個同名的弱符號,連結後輸出檔案中以最大的那個為準
  • 編譯時將弱符號標記為 COMMON 型別,由於該若符號最終所佔的空間大小是未知的,所以無法為該弱符號在 BSS 段分配空間,但是連結器在連結過程後確定了一個若符號的最終大小,所以它可以在最終輸出檔案的 BSS 段為其分配空間

4. C++ 相關問題

  • 重複程式碼消除
    • C++ 編譯器在很多時候會產生重複的程式碼,如模板、外部行內函數、虛擬函式表都有可能在不同的編譯單元生成相同的程式碼
    • 一個比較有效的做法就是將每個示例程式碼都單獨地存放在一個段裡,每個段只包含一個例項,連結器在最終連結的時候可以區分這些相同的例項段,然後將他們合併入最後的程式碼段
    • 函式級別連結 :一個編譯選項,讓所有的函式都儲存到一個段裡面,連結器須要用到某個函式時,才將它合併到輸出檔案中
  • 全域性構造與析構
    • 在 main 函式被呼叫之前,為了使程式能夠順利執行,要先初始化程式執行環境,如堆分配初始化、執行緒子系統等
    • C++ 的全域性物件的建構函式 在 main 之前被執行,解構函式在 main 之後被執行
    • .init 段裡儲存的是可執行指令,它構成了程式的初始化程式碼; .fini 段儲存著程式終止程式碼指令
  • C++ 與 ABI
    • 使兩個編譯器編譯出來的目標檔案能互相連結,則兩個目標檔案須滿足:採用同樣的目標檔案格式、擁有同樣的符號修飾標準、變數的記憶體分佈方式相同、函式的呼叫方式相同,等等
    • ABI (Application Binary Interface) :符號修飾標準、變數記憶體佈局、函式呼叫方式等這些跟可執行程式碼二進位制相容性相關的內容
    • 硬體、程式語言、編譯器、連結器、作業系統等都會影響 ABI
  • 靜態庫連結
    • 一個靜態庫可以簡單地看成一組目標檔案的集合,即很多目標檔案經過壓縮打包後形成的一個檔案
    • 編譯和連結一個普通 C 程式的時候,不僅要用到 C 語言庫 libc.a ,而且還有其他一些輔助性質的目標檔案和庫。中間步驟:
      • 呼叫 cc1 程式,實際就是 GCC 的 C 語言編譯器,將原始檔編譯成一個臨時的彙編檔案
      • 呼叫 as 程式,as 程式是 GNU 的編譯器,將臨時的彙編檔案彙編成臨時目標檔案
      • GCC 呼叫 collet2 程式來完成最後的連結,collet2 可以看做是 ld 連結器的一個包裝
  • 連結過程控制 :對於一些特殊要求的程式如作業系統核心、BIOS 或一些在沒有作業系統的情況下執行的程式,以及另外的一些須要特殊的連結過程的程式,往往受限於一些特殊的條件,對程式的各個段的地址有著特殊的要求,須要進行連線過程控制
    • 連結控制指令碼
      • 使用命令列來給連結器指定引數,如 ld 的 -o 、-e
      • 將連結指令放在目標檔案裡面,編譯器京城會通過這種方法想連結器傳遞指令
      • 使用連結控制指令碼
    • “小”程式
      • -fno-builtin :關閉 GCC 內建函式功能
      • -stati :ld 將使用靜態方式來連結程式
      • -e nomain :該程式的入口函式為 nomain
    • 使用 ld 連結指令碼
      • 簡單來講,連結控制過程就是控制輸入端如何變成輸出段,比如哪些輸入端要合併成一個輸出段,哪些輸入段要丟棄;指定輸入段的名字、裝載地址、屬性等
      • 連結控制指令碼是控制連結過程的“程式”,使得連結過程以“程式”要求的方式將輸入加工成所需要的輸出結果,一般連結指令碼都以 lds 作為副檔名
    • ld 連結指令碼語法簡介
      • 連結指令碼由一系列語句組成,一種是命令語句,另外一種是賦值語句
        • 命令語句 :ENTRY(symbol)STARTUP(filename)SEARCH_DIR(path)INPUT(file,file,...)INCLUDE filenamePROVIDE(symbol)SECTIONS
      • 語句之間使用分號 ; 作為分割符
      • 表示式與運算子 :可以使用 C 語言類似的表示式和運算操作符
      • 註釋和字元引用 :使用 /**/ 作為註釋
  • BFD 庫 (Binary File Descriptor library)
    • 五花八門的軟硬體平臺基礎導致每個平臺都有它獨特的目標檔案格式,即使同一個格式在不同的軟體平臺都有著不同的變種,導致編譯器和連結器很難處理不同平臺之間的目標檔案
    • BFD 庫 :一個 GNU 專案,目標是希望通過一種統一的藉口來處理不同的目標檔案格式,通過操作抽象的目標檔案模型就可以實現操作所有 BFD 支援的目標檔案格式
    • GNU 彙編器 GAS、連結器 ld、偵錯程式 GBD 及 binutils 的其他工具都通過 BFD 庫來處理目標檔案,而不是直接操作目標檔案,將編譯器和連結器本身同具體的目標檔案格式隔離開

五、Windows PE / COFF

1. Windows 的二進位制檔案格式 PE / COFF

  • PE :Protable Executable ,與 ELF 同根同源,都是由 COFF 格式發展而來的
  • 討論 Windows 平臺上的檔案結構時,目標檔案預設為 COFF 格式,而可執行檔案為 PE 格式
  • 也採用基於段的格式

2. PE 的前身 —— COFF

  • COFF 檔案結構 :由標頭檔案及後面的若干個段組成,再加上檔案末尾的符號表、除錯資訊的內容
    • 檔案頭包括
      • 描述檔案總體結構和屬性的映像頭
      • 描述檔案中包含的段的屬性的段表 :是一個型別為 “IMAGE_SECTION_HEADER”結構的陣列,陣列裡面每個元素代表一個段,用來描述每個段的屬性
    • 段的內容與 ELF 中幾乎一樣,兩個 ELF 檔案不存在的段 :“.drectve”段 和 “.debug$S”段

3. 連結指示資訊

  • 內容是編譯器傳遞給連結器的指令
  • 段名後面就是段的屬性,最後一個屬性是標誌位 “flags”,即 IMAGE_SECTION_HEADERS裡面的 Characteristics 成員
  • 輸出資訊中緊隨其後的是該段在檔案中的原始資料

4. 除錯資訊

  • COFF 檔案中所有以 “.debug” 開始的段都包含著除錯資訊
    • “.debug$S” 符號相關的除錯資訊段
    • “.debug$P” 包含預編譯標頭檔案相關的除錯資訊段
    • “.debug$T” 包含型別相關的除錯資訊段

5. 符號表

  • COFF 檔案的符號表包含的內容跟 ELF 檔案的符號表一樣,主要就是符號名、符號的型別、所在的位置
  • 符號表的輸出結果從左到右 :符號的編號、符號的大小、符號所在的位置、符號型別、符號的可見範圍、符號名

6. Windows 下的 ELF —— PE

  • PE 檔案是基於 COFF 的擴充套件

    • 檔案的最開始部分不是 COFF 檔案頭,而是 DOS MZ 可執行檔案格式的檔案頭和樁程式碼
    • 原來的 COFF 檔案頭中的 IMAGE_FILE_HEADER 部分擴充套件成了 PE 檔案頭結構 IMAGE_NT_HEADERS。包括了原來的 “Image Header” 及新增的 PE 擴充套件頭部檔案
  • DOS 下的可執行檔案格式是 “MZ” 格式,與 Windows 下的 PE 不同,雖然它們使用相同的副檔名 “.exe”

  • IMAGE_NT_HEADERS 是 PE 真正的檔案頭,包含了一個標記和兩個結構體,標記是一個常量,結構體是映像頭和 PE 擴充套件頭部結構

  • 為了區別,Windows 中把 32 位的 PE 檔案格式叫做 PE32,把 64 位的 PE 檔案格式叫做 PE32+

  • PE 資料目錄

    • 在 Windows 裝載 PE 檔案時需要很快的找到一些裝載所需要的資料結構如匯入表、匯出表、資源。重定位表等,這些常用的資料的位置和長度都被儲存在了一個叫資料目錄的結構裡
    • DataDirectory 陣列裡面每一個元素都對應一個包含一定含義的表,每個結構有兩個成員,是虛擬地址以及長度

六. 可執行檔案的裝載與程式

1. 程式虛擬地址空間

  • 每個程式被執行起來以後,它將擁有自己獨立的虛擬地址空間,大小由計算機的硬體平臺決定,具體地說是由 CPU 的位數決定的,比如 32 位的硬體平臺決定了虛擬地址空間的地址為 0 到 2 ^ 32 - 1,即 0x00000000 ~ 0xFFFFFFFF,也就是常說的 4GB 虛擬空間大小;而 64 位的硬體平臺具有 64 位定址能力,它的虛擬地址空間達到了 2 ^ 64 - 1,總共 17179864184GB
  • 32 位 Linux 下,整個 4GB 被劃分成兩部分,其中作業系統本身用去了一部分:從地址 0xC0000000 ~ 0xFFFFFFFF,共 1GB。剩下的從 0x00000000 ~ 0xBFFFFFFF 共 3GB。從原則上講(其實程式並不能完全使用這 3GB 的虛擬空間,其中有一部分是預留給其它用途的),我們的程式最多可以使用 3GB 的虛擬空間,整個程式在執行的時候,所有的程式碼、資料包括通過 C 語言 malloc() 的那個方法申請的虛擬空間之和不可以超過 3GB
  • 對於 Windows 作業系統,程式虛擬地址空間劃分是作業系統佔用 2GB,程式只剩下 2GB 空間,所以有個啟動引數可以將作業系統佔用的虛擬地址空間減少到 1GB
  • PAE :從硬體層面上來講,原先的 32 位地址線只能訪問最多 4GB 的實體記憶體,但是如果擴充套件至 36 位地址線之後, Intel 修改了頁對映的方式,使得新的對映方式可以訪問到更多的實體記憶體,這個地址擴充套件方式叫做 PAE

2. 裝載的方式

  • 覆蓋裝入
    • 編寫程式的時候手工將程式分割成若干塊。然後編寫一個小的輔助程式碼來管理這些模組何時應該駐留記憶體何時應該被替換掉,這個小的輔助程式碼就是覆蓋管理器
    • 在有多個模組的情況下,需要手工將模組按照它們之間的呼叫依賴關係組織成樹狀結構
      • 樹狀結構中從任何一個模組到樹的根模組都叫呼叫路徑,當該模組被呼叫時,整個呼叫路徑上的模組都必須在記憶體中
      • 禁止跨樹間呼叫,任何一個模組不允許跨過樹狀結構進行呼叫
  • 頁對映
    • 將記憶體和所有磁碟中的資料和指令按照“頁”為單位劃分為若干個頁,以後所有的裝載和操作的單位就是頁,目前硬體規定的頁的大小有 4096 位元組、8192 位元組、2MB、4MB 等,最常見的 Intel IA32 處理器一般都使用 4096 位元組的頁
    • 有很多演算法解決選擇哪個頁來替換,如 FIFO、LUR 等

3. 從作業系統角度看可執行檔案的裝載

  • 程式的建立
    • 從作業系統的角度來看,一個程式最關鍵的特徵是它擁有獨立的虛擬地址空間
    • 建立一個程式,然後裝載相應的可執行檔案並且執行 的過程最開始做的事分三步 :
      • 建立虛擬地址空間 :虛擬空間到實體記憶體的對映關係
      • 讀取可執行檔案頭,並且建立虛擬空間與可執行檔案的對映關係 :虛擬空間與可執行檔案的對映關係
        • 對映關係是儲存在作業系統內部的一個資料結構,Linux 中將程式虛擬空間中的一個段叫做虛擬記憶體區域(VMA),Windows 中將這個叫做虛擬段
      • 將 CPU 指令暫存器設定成可執行檔案入口,啟動執行
  • 頁錯誤
    • 上面的步驟之後,作業系統只是通過可執行檔案頭部的資訊建立起可執行檔案和程式虛存之間的對映關係而已
    • CPU 真正開始執行時,會發現程式的入口地址是一個空頁面,認為這是一個頁錯誤,作業系統有專門的頁錯誤處理例程來處理,將查詢前面提到的裝載過程的第二步建立的資料結構,然後找到空頁面所在的 VMA,計算出相應的頁面在可執行檔案中的偏移,在實體記憶體中分配一個物理頁面,將程式中該虛擬頁與分配的物理頁之間建立對映關係,然後把控制權再還給程式,程式從剛才頁錯誤的位置重新開始執行

4. 程式虛存空間分佈

  • ELF 檔案連結檢視和執行檢視

    • ELF 檔案被對映時,是以系統的頁長度作為單位的,如果每個段都佔用整數倍個頁的長度,浪費記憶體空間。因此,裝載時,對於相同許可權的段,把它們合併到一起當做一個段進行對映
    • ELF 可執行檔案引入了 “segment” 的概念,一個 “segment” 包含一個或多個屬性類似的 “section”,裝載時將他們看作一個整體一起對映,使得對映以後在程式虛存空間中只有一個相應的 VMA,減少了頁面內部碎片,節省了記憶體空間
    • 描述 “segment” 的結構叫做程式頭,描述了 ELF 檔案該如何被作業系統對映到程式的虛擬空間
  • 堆和棧

    • 作業系統通過使用 VMA 來對程式的地址空間進行管理,很多情況下,一個程式中的棧和堆分別有一個對應的 VMA
    • 作業系統通過給程式空間劃分出一個個 VMA 來管理程式的虛擬空間,基本原則是將相同許可權屬性、有相同映像檔案的對映成一個 VMA,一個程式基本上可以分為如下幾種 VMA 區域 :程式碼 VMA、資料 VMA、堆 VMA、棧 VMA
  • 堆得最大申請數量

    • Linux 下虛擬地址空間分給程式本身的是 3GB(Windows 預設是 2GB)
    • 具體數值會受到作業系統版本、程式本身大小、用到的動態/共享庫數量、大小、程式棧數量、大小等,甚至可能每次執行的結果都不同
  • 段地址對齊

    • 可執行檔案最終是要被作業系統裝載執行的,這個裝載的過程一般是通過虛擬記憶體的頁對映機制完成的,對映過程中,頁是對映的最小單位
    • 為了解決每個段分開對映所帶來的浪費磁碟空間的問題,可以讓各個段接壤部分共享一個物理頁面,然後將該物理頁面分別被對映兩次,系統將他們對映到兩份虛擬地址空間,其他的頁都按照正常的頁粒度進行對映,系統將 ELF 檔案頭也看作是系統的一個段,將其對映到程式的地址空間,好處是程式中的某一段區域就是整個 ELF 檔案的映像,對於一些須訪問 ELF 標頭檔案的操作可以直接通過讀寫記憶體地址空間進行
    • 從某種角度看,好像是整個 ELF 檔案從檔案最開始到某個點結束,被邏輯上分成了以 4096 位元組為單位的若干個塊,每個塊都被裝載到實體記憶體中,對於那些位於兩個段中間的快,它們將會被對映兩次
    • 在 ELF 檔案中,對於任何一個可裝載的 “segment”,它的 p_vaddr 除以對其屬性的餘數等於 p_offset 除以對齊屬性的餘數
  • 程式棧初始化

    • 程式剛開始啟動的時候,須知道一些程式執行的環境,最基本的就是系統環境變數和程式的執行引數,常見的做法是作業系統在程式啟動前將這些資訊提前儲存到程式的虛擬空間的棧中
    • 程式在啟動之後,程式的庫部分會把堆疊裡的初始化資訊中的引數資訊傳遞給 main() 函式,也就是 main() 函式的兩個 argc 和 argv 兩個引數,這兩個引數分別對應這裡的命令列引數數量和命令列引數字串指標陣列
  • Linux 核心裝載 ELF 過程簡介

    • 在使用者層面,bash 程式會呼叫 fork() 系統呼叫穿件一個新的程式,然後新的程式呼叫 execve() 系統呼叫執行指定的 ELF 檔案,原先的 bash 程式繼續返回等待剛才啟動的新程式結束 ,然後繼續等待使用者輸入命令
    • 在進入 execve() 系統呼叫之後,Linux 核心就開始進行真正的裝載工作
    • 主要步驟 :
      • 檢查 ELF 可執行檔案格式的有效性
      • 尋找動態連結的 “.interp” 段,設定動態連結路徑
      • 根據 ELF 可執行檔案的程式頭表的描述,對 ELF 檔案進行對映
      • 初始化 ELF 程式環境
      • 將系統呼叫的返回地址修改成 ELF 可執行檔案的入口,這個入口取決於程式的連結方式,對於靜態連結 ELF 可執行檔案,這個程式入口就是 ELF 檔案的檔案頭中的 e_entry 所指的地址;對於動態連結的 ELF 可執行檔案,程式入口點是動態連結器
  • Windows PE 的裝載

    • PE 檔案中,連結器在生成可執行檔案時,往往將所有的段儘可能合併,所以一般只有程式碼段、資料段、只讀資料段和 BSS 等為數不多的幾個段
    • RVA 相對虛擬地址,是相對於 PE 檔案的裝載基地址的一個偏移地址
    • 基地址 :每個 PE 檔案在裝載時都會有一個裝載目標地址

七、動態連結

1. 為什麼要動態連結

  • 動態連結 :連結過程推遲到了執行時再進行
  • 解決了共享目標檔案多個副本浪費磁碟和記憶體空間的問題
  • 方升級程式庫或程式共享某個模組時,新版本的目標檔案會被自動裝載到記憶體並且連結起來,使得各個模組更加獨立,耦合度更小
  • 加強程式的可擴充套件性,程式在執行時可以動態地選擇載入各種程式模組,後來被人們利用來製作程式的外掛
  • 加強程式的相容性,動態連結庫相當於在程式和作業系統之間增加了一箇中間層,從而消除了程式對不同平臺之間以來的差異性
  • 基本思想 :把程式按照模組拆分為各個相對獨立部分,在程式執行時才將它們連線在一起形成一個完整的程式,而不是像靜態連結一樣,把所有程式模組都連結成一個單獨的可執行檔案
  • Linux 系統中,ELF 動態連結檔案被稱為動態共享物件,簡稱共享物件,一般都是以 “.so” 為副檔名的一些檔案;Windows 系統中,動態連結檔案被稱為動態連結庫,通常是以 “.dll” 為副檔名的檔案
  • 程式被裝載的時候,系統的動態連結器會將程式所需要的所有動態連結庫裝載到程式的地址空間,並且將程式中所有未決議的符號繫結到相應的動態連結庫中,並進行重定位工作。動態連結把連結這個過程從本來的程式裝載錢被推遲到了裝載的時候。

2. 簡單的動態連結

  • 動態連結下,一個程式被分成若干個檔案,有程式的主要部分,即客戶性檔案和程式所依賴的共享物件,把這些部分稱為模組,即動態連結下的可執行檔案和共享物件都可以看作是程式的一個模組
  • 如果函式是一個定義在某個動態共享物件中的函式,那麼連結器就會將這個符號的引用標記為一個動態連結的符號,不對它進行地址重定位,把這個過程留到裝載時再執行
  • 共享物件的最終裝載地址在編譯時是不確定的,而是在裝載時,裝載器根據當前地址空間的空閒情況,動態分配一塊足夠大小的虛擬地址空間給對應的共享物件

3. 地址無關程式碼

  • 固定裝載地址的困擾

    • 靜態共享庫的做法是將程式的各種模組統一交給作業系統來管理,作業系統在某個特定的地址劃分出一些地址塊,為那些已知的模組預留足夠的空間
    • 地址衝突問題、靜態共享庫升級問題
    • 解決 :讓共享物件在任意地址載入,共享物件在編譯時不能假設自己在程式虛擬地址空間中的位置
  • 裝載時重定位

    • 在連結時,對所有絕對地址的引用不作重定位,而把這一步推遲到裝載時再完成,一旦模組裝載地址確定,即目標地址確定,那麼系統就對程式中的絕對地址引用進行重定位
    • 靜態連結時的重定位叫做連結時重定位,現在這種叫做裝載時重定位,在 Windows 中又被叫做基址重置
    • 裝載時重定位不適合用來解決共享物件所存在的問題
  • 地址無關程式碼 PIC

    • 裝載時重定位是解決動態模組中的有絕對地址引用的辦法之一,但是指令部分無法在多個程式之間共享,失去了動態連結節省記憶體的優勢
    • 地址無關程式碼 :把指令中哪些需要修改的部分分離出來,跟資料部分放在一起,這樣指令部分可以保持不變,而資料部分可以在每個程式中擁有一個副本,程式模組中共享的指令部分在裝載時不需要因為裝載地址的改變而改變
    • 把共享物件模組中的地址引用按照是否為跨模組分為模組內部引用和模組外部引用,按照不同的引用方式又可以分為指令引用和資料訪問
      • 型別一 模組內部呼叫或擴充套件 :可以使相對地址呼叫,或者是基於暫存器的相對呼叫,這種指令是不需要重定位的
      • 型別二 模組內部資料訪問 :任何一條指令與它需要訪問的模組內部資料之間的相對位置是固定的,只需要對於當前指令加上固定的偏移量就可以達到訪問相應變數的目的(PC 值加上一個偏移量)。模組在編譯時可以確定模組內部變數相對於當前指令的偏移
      • 型別三 模組間資料訪問 :ELF 的做法是在資料段裡面建立一個指向這些變數的指標陣列,也被稱為全域性偏移表 GOT,當程式碼需要引用全域性變數時,也可以通過 GOT 中相對應的項間接引用。在編譯時確定 GOT 相對於當前指令的偏移,然後通過得到 PC 值後加上一個偏移量,根據變數地址在 GOT 中的偏移就可以得到變數的地址,是的 GOT 做到指令的地址無關
      • 模組四 模組間呼叫、跳轉 :與資料訪問類似,GOT 中相應的項儲存的是目標函式的地址,當模組要呼叫目標函式時,可以通過 GOT 中的項進行間接跳轉
    • 使用 "-fPIC" 和 "-fpic" 引數來產生地址無關程式碼
  • 共享模組的全域性變數問題

    • 當一個模組引用了一個定義在共享物件的全域性變數的時候,編譯器在編譯這個模組時,無法根據上下文判斷變數是定義在同一個模組的其他目標檔案還是定義在另外一個共享物件之中,即無法判讀是否為跨模組間的呼叫
    • 解決辦法 :把所有使用這個變數的指令都指向位於可執行檔案中的那個副本,ELF 共享庫在編譯時,預設都把定義在模組內部的全域性變數當做定義在其他模組的全域性變數,通過 GOT 來實現變數的訪問,該變數在執行時實際上最終就只有一個例項
    • 特殊需求 :多程式共享全域性變數叫做“共享資料段”、多個執行緒訪問不同的全域性變數副本叫做“執行緒私有儲存”
  • 資料段地址無關性

    • 對於資料段來說,它在每個程式都以一份獨立的副本,並不擔心被程式改變,可以選擇裝載時重定位的方法來解決資料段中絕對地址引用問題
    • 對於共享物件來說,如果資料段有絕對地址引用,那麼編譯器和連結器就會產生一個重定位表。當動態連結器裝載共享物件時,如果發現該共享物件有重定位入口,那麼動態連結器就會對該共享物件進行重定位
    • 對於可執行檔案來說,預設情況下,如果可執行檔案是動態連結的,那麼 GCC 會使用 PIC 方法來產生可執行檔案的程式碼段部分,一邊與不同的程式能夠共享程式碼段

4. 遲延繫結 PLT

  • 動態連結比靜態連結慢

    • 動態連結下對於全域性和靜態的資料訪問都要進行復雜的 GOT 定位,然後間接定址;對於模組間的呼叫也要先定位 GOT,然後進行間接跳轉
    • 動態連結的連結工作在執行時完成,即程式開始執行時
  • 延遲繫結實現

    • 當函式第一次被用到時才進行繫結,如果沒有用到則不進行繫結
    • ELF 使用 PLT 的方法來實現,呼叫某個外部模組的函式時,通常做法是通過 GOT 中相應的項進行間接跳轉,PLT 在這個過程中間又增加了一層間接跳轉,只有使用到該函式才跳轉到 GOT 來完成符號解析和重定位工作

5. 動態連結相關結構

  • 動態連結情況下,可執行檔案的裝載與靜態連結情況基本一樣,首先作業系統會讀取可執行檔案的頭部,檢查檔案的合法性,然後從頭部中的 “Program Header” 中讀取每個 “segment” 的虛擬地址、檔案地址和屬性,並將它們對映到程式虛擬空間的相應位置。但是,可執行檔案依賴於很多共享物件,對於很多外部符號的引用還處於無效地址的狀態,即還沒有跟相應的共享物件中的實際位置連結起來。所以在對映完可執行檔案之後,作業系統會先啟動一個動態連結器。作業系統同樣通過對映的方式將它將它載入到程式的地址空間中,載入完動態連結之後,就將控制權交給動態連結器的入口地址,當動態連結器得到控制權之後,它開始執行一系列自身的初始化操作,然後根據當前的環境引數,開始對可執行檔案進行動態連結工作。當所有動態連結工作完成以後,動態連結器會將控制權轉交到可執行檔案的入口地址,程式開始執行

  • “.interp” 段

    • 動態連結器是由是由 ELF 可執行檔案決定的,在動態連結的 ELF 可執行檔案中,有一個專門的段叫做 “.interp” 段
    • “.interp” 裡面儲存的是一個字串,就是可執行檔案所需要的動態連結器的路徑,在 Linux 中,作業系統在對可執行檔案的進行載入的時候,它回去尋找裝載蓋可執行檔案所需要相應的動態連結器,即 “.interp” 段指定的路徑的共享物件
  • “.dynamic” 段

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

    • 為了表示動態連結模組之間的符號匯入匯出關係,ELF 專門有一個叫做動態符號表的段來儲存這些資訊,這個段的段名通常叫做 “.dynsym”,只儲存了與動態連結相關的符號,對於那些模組內部的符號,比如模組私有變數則不儲存,而 “.symtab” 中往往儲存了所有的符號
    • 動態符號字串表,用於儲存符號名的字串表,類似於靜態連結時的符號字串表 “.strtab”
    • 動態連結下,需要在程式執行時查詢符號,為了加快符號的查詢過程,往往還有輔助的符號雜湊表 “.hash”
  • 動態連結重定位表

    • 對於使用 PIC 技術的可執行檔案或共享物件來說,雖然它們的程式碼段不需要重定位(因為地址無關),但是資料段還包含了絕對地址的引用,因為程式碼段中與絕對地址相關的部分被分離了出來,變成了 GOT,而 GOT 實際上是資料段的一部分
    • 動態連結重定位相關結構
      • 動態連結的檔案中,有重定位表叫做 “.rel.dyn” 和 “.rel.plt”,前者實際上是對資料引用的修正,它所修正的位置位於 “.got” 以及資料段,後者是對函式引用的修正,它所修正的位置位於 “.got.plt”
      • 共享物件的資料段是沒有辦法做到地址無關的,它可能會包含絕對地址的引用,對於這種絕對地址的引用,我們必須在裝載時將其重定位
      • 匯入函式從 “.rel.plt” 到了 “.rel.dyn”,引數字串常量的地址在 PIC 時不需要重定位而非 PIC 時需要重定位,因為 PIC 時,這個字串可以看做是歐痛的全域性變數,地址是可以通過 PIC 中相對當前指令的位置加上一個固定偏移計算出來的;而在非 PIC 中,程式碼段不再使用這種相對於當前指令的 PIC 方法,而是採用絕對地址定址,所以它需要重定位
  • 動態連結時程式堆疊初始化資訊

    • 動態連結器需要知道關於可執行檔案和本程式的一些資訊,這些資訊往往由作業系統傳遞給動態連結器,儲存在程式的堆疊裡面,在程式初始化的時候,堆疊裡面儲存了關於程式執行環境和命令列引數等資訊,還儲存了動態連結器所需要的一些輔助資訊陣列
    • 輔助資訊陣列位於環境變數指標的後面

6. 動態連結步驟和實現

  • 啟動動態連結器本身

    • 動態連結器不可以依賴於其他任何共享物件
    • 動態連結器本身所需要的全域性和靜態變數的重定位有它本身完成,動態連結器必須在啟動的時候有一段非常精巧的程式碼可以完成這項工作又不能用到全域性和靜態變數,這種具有一定限制條件的啟動程式碼往往被稱為自舉
    • 動態連結器入口地址即是自舉程式碼的入口,當作業系統將程式控制權交給動態連結器時,動態連結器的自舉程式碼即開始執行執行。自舉程式碼首先找到自己的 GOT,而 GOT 的第一個入口儲存的即是 “.dynamic” 段的偏移地址,由此找到了動態連結器本身的 “.dynamic” 段,獲得動態連結器本身的重定位表和符號表等,從而得到動態連結器本身的重定位入口,先將它們全部重定位
    • 實際上在動態連結器的自舉程式碼中,動態連結器本身的函式也不能呼叫,因為使用 PIC 模式變異的共享物件,對於模組內部的函式呼叫採用的跟模組外部函式呼叫一樣的方式,即使用 GOT/PLT 的方式,所以在 GOT/PLT 沒有被重定位之前,自舉程式碼不可以使用任何全域性變數,也不能呼叫函式
  • 裝載共享物件

    • 完成基本自舉以後,動態連結器將可執行檔案和連結器本身的符號表都合併到一個符號表當中,可稱為全域性符號表

    • 連結器開始尋找可執行檔案所依賴的共享物件,“.dynamic” 段中有一種型別的入口是 DT_NEEDED,它所指出的是該可執行檔案(或貢共享物件)所依賴的共享物件,由此連結器可以列出可執行檔案所需要的所有共享物件,並將這些共享物件的名字放入一個裝載集合中

    • 連結器開始從集合中取出一個所需要的共享物件的名字,然後將它相應的程式碼段和資料段對映到程式空間中

    • 如果這個 ELF 共享物件還依賴於其他共享物件,則將所依賴的共享物件的名字放到裝載集合中,迴圈直到所有所依賴的共享物件都被裝載進來為止,可以看作一個圖

    • 全域性符號介入 :當一個符號需要被加入全域性符號表時,如果相同的符號名已經存在,則後加入的符號被忽略 * 全域性符號介入與地址無關程式碼 :模組內部呼叫或跳轉的處理時,如果內部函式由於全域性符號介入被其他模組的同名函式覆蓋,如果採用相對地址呼叫,那個相對地址部分就需要重定位,與共享物件的地址無關性矛盾,所以只能當做模組外部符號處理。解決辦法 :把內部函式程式設計編譯單元私有函式,即使用 “static” 關鍵字

  • 重定位和初始化

    • 連結器開始重新遍歷可執行檔案和每個共享物件的重定位表,將它們的 GOT/PLT 中的每個需要重定位的位置進行修正
    • 重定位完成之後,如果某個共享物件有 “.init” 段,那麼動態連結器會執行 “.init” 段中的程式碼,用以實現共享物件特有的初始化過程,相應的還可能有 “.finit” 段,程式退出時會執行
    • 完成重定位和初始化之後,所有的準備工作就完成可,所需要的共享物件已經裝載並連結完成了,動態連結器將控制權轉交給程式的入口並開始執行
  • Linux 動態連結器實現

    • 對於靜態連結的可執行檔案來說,程式的入口就是 ELF 檔案頭重的 e_entry 指定的入口;對於動態連線的可執行檔案,核心會分析它的動態連結器地址(在 “.interp” 段),將動態連結器對映至程式地址空間,然後把控制權交給動態連結器
    • 動態連結器是個非常特殊的共享物件,它不僅是個共享物件,還是個可執行的程式
    • Linux 核心在執行 execve() 時不關心目標 ELF 檔案是否可執行,直接是簡單按照程式頭表裡的描述對檔案進行裝載然後把控制權轉交給 ELF 入口地址,所以共享課和可執行檔案實際上沒什麼區別,除了檔案頭的標誌位和副檔名有所不同
    • 動態連結器本身是靜態連結的
    • 動態連結器本身可以是 PIC 也可以不是,但是用 PIC 會簡單一些
    • 動態連結器可以被當做可執行檔案執行,裝載地址跟一般的共享物件沒區別,即為 0x00000000。這是一個無效的裝載地址,核心在裝載它時,會為其選擇一個合適的裝載地址

7. 顯式執行時連結

  • 顯式執行時連結,有時候也叫作執行時載入,就是讓程式自己在執行時控制載入指定的模組,並且可以在不需要該模組時將其解除安裝。一般的共享物件不需要進行任何修改就可以進行執行時裝載,這種共享物件往往被叫做動態裝載庫
  • 使得程式的模組組織變得靈活,可以用來實現一些諸如外掛、驅動等功能,只有程式需要用到某個外掛或驅動的時候才會將相應的模組裝載進來,而不需要在一開始就見它們全部裝載進來,減少了程式啟動時間和記憶體使用;並且程式可以在執行的時候載入某個模組,使得程式本身不必重新啟動而實現模組的增加、刪除、更新等
  • 動態裝載庫的裝載是通過一系列動態連結器提供的 API,具體的講共有 4 個函式:開啟動態庫(dlopen)、查詢符號(dlsym)、錯誤處理(dlerror)、關閉動態庫(dlclose)
    • dlopen()
      • 用來開啟一個動態庫,並將其載入到程式的地址空間,完成初始化過程
      • 引數 :filename(被載入動態庫的路徑,為空則返回全域性符號表的控制程式碼)、flag(函式符號的解析方式)
      • 返回值 :被載入模組的控制程式碼,後面使用 dlsym 或者 dlclose 時要用到
    • dlsym()
      • 是執行時裝載的核心部分,通過這個函式來找到所需要的符號
      • 引數 :handle(動態庫的控制程式碼)、symbol(所要查詢的符號的名字)
      • 返回值 :查詢到的符號
      • 符號優先順序 :多個同名符號衝突時,現裝入的符號優先,這種優先順序方式稱為裝載序列
      • 如果在全域性符號表中進行符號查詢,則 dlsym() 使用的是裝載序列,如果對某個 dlsym() 開啟的共享物件進行符號查詢,那麼採用一種叫做依賴序列的優先順序
    • dlerror()
      • 在呼叫其他幾個函式時,用來判斷上一次呼叫是否成功
      • 如果返回 NULL,則表示上一次呼叫成功;否則返回相應的錯誤訊息
    • dlclose()
      • 將一個已經載入的模組解除安裝
      • dlopen() 和 dlclose() 使用計數器

八、Linux 共享庫的組織

1. 共享庫版本

  • 共享庫相容性

    • 相容更新

    • 不相容更新

      • 匯出函式的行為發生改變,呼叫這個函式以後產生的結果和以前不一樣
      • 匯出函式被刪除
      • 匯出資料的結構發生變化
      • 匯出函式的介面發生變化
    • 匯出介面為 C++ 的共享庫相容非常困難

  • 共享庫版本命名

    • Linux 規定共享庫的檔案命名規則必須如下 :libname.so.x.y.z
    • 最前面使用字首 “lib”、中間是庫的名字和字尾 “.so”,最後面跟著的是三個數字組成的版本號。“x” 表示主版本號(庫的重大升級),“y” 表示次版本號(庫的增量升級),“z” 表示釋出版本號(庫的錯誤的修正、效能的改進等)
  • SO-NAME

    • 程式必須記錄被依賴的共享庫的名字和主版本號
    • SO-NAME :共享庫的檔名去掉次版本號和釋出版本號,保留主版本號
    • “SO-NAME” 的兩個相同共享庫,次版本號大的相容次版本號小的
    • 系統會為每個共享庫在它所在的目錄建立一個跟 “SO-NAME” 相同的並且指向它的軟連結,實際上這個軟連結會指向目錄中主版本號相同、次版本號和釋出版本號最新的共享庫。目的 :使得所有依賴某個共享庫的模組,在編譯、連結和執行時,都使用共享庫的 SO-NAME,而不使用詳細的版本號
    • 編譯輸出 ELF 檔案時,將被依賴的共享庫的 SO-NAME 儲存到 “.dynamic” 中,這樣當連結器進行共享庫依賴檔案查詢時,就會根據系統中各種共享庫目錄中的 SO-NAME 軟連結自動定向到最新版本的共享庫
    • SO-NAME 表示一個庫的介面,介面不向後相容,SO-NAME 就發生變化

2. 符號版本

  • 基於符號的版本機制

    • 次版本號交會問題沒有因為 SO-NAME 而解決,當某個程式依賴於較高的次版本號的共享庫,而執行於較低次版本號的共享庫系統時,就可能產生缺少某些符號的錯誤
    • 解決次版本號交會問題 :讓每個匯出和匯入的符號都有一個相關聯的版本號,做法類似於名稱修飾的方法,一個共享庫每一次次版本號升級,都能給那些在新的次版本號中新增的全域性符號打上相應的標記,可以清楚地看到共享庫中的每個符號都擁有相同的標籤
  • Solaris 中的符號版本機制

    • ld 連結器為共享庫新增了版本機制和範圍機制
    • 版本機制定義一些符號的集合,這些集合本身都有名字,每個集合都包含一些指定的符號,除了可以擁有符號之外,一個集合還可以包含另外一個集合
    • 範圍機制 :共享庫外部的應用程式或其他的共享庫將無法訪問這些符號,可以保護那些共享庫內部的公用實用函式,但是共享庫的作者又不希望共享庫的使用者能夠有意或無意地訪問這些函式
    • 是對 SO-NAME 機制保證共享庫主版本號一致的一種非常好的補充
  • Linux 中的符號版本 :允許同一個名稱的符號存在多個版本

3. 共享庫系統路徑

  • FHS(File Hierarchy Standard)標準規定了一個系統中的系統檔案應該如何存放,包括各個目錄的結構、組織和作用
    • /lib :存放最關鍵和基礎的共享庫
    • /usr/lib :儲存一些非系統執行時所需要的關鍵性的共享庫,還包含了開發時可能會用到的靜態庫、目標檔案等
    • /usr/local/lib :放置一些跟作業系統本身並不十分相關的庫

4. 共享庫查詢過程

  • 啟動動態連結器
  • 動態連結的模組所依賴的模組路徑儲存在 “.dynamic” 段裡面,由 DT_NEED 型別的項表示
  • Linux 系統中有一個叫做 ldconfig 的程式,為共享目錄下的各個共享庫建立、刪除或更新相應的 SO-NAME,將這些收集起來,集中存放,建立快取,大大加快了共享庫的查詢過程

5. 環境變數

  • LD_LIBRARY_PATH :可以臨時改變某個應用程式的共享庫查詢路徑,而不會影響系統中的其他程式。預設情況為空,如果為某個程式設定了,那麼程式啟動時,動態連結器在查詢共享庫時,會首先查詢指定的目錄
  • LD_PRELOAD :指定預先裝載的一些共享庫甚或是目標檔案
  • LD_DEBUG :開啟動態連結器的除錯功能,會在執行時列印出各種有用的資訊

6. 共享庫的建立和安裝

  • 共享庫的建立 :與建立一般共享物件的過程基本一致,最關鍵的是使用 GCC 的兩個引數,即 “-shared” 和 “-fPIC”
  • 清除符號資訊 :使用一個叫 “strip” 的工具清除掉共享庫或可執行檔案的所有符號和除錯資訊,也可使用 ld 的 “-s” 和 “-S” 引數使得連結器生成輸出檔案時就不產生符號資訊
  • 共享庫的安裝 :將共享庫複製到某個標準的共享庫目錄,如 /lib、/usr/lib 等,然後執行 ldconfig 即可。不過需要系統的 root 許可權,可以通過建立相應的 SO-NAME 軟連結,告訴編譯器和程式如何查詢該共享庫等
  • 共享庫構造和解構函式
    • 在函式宣告時加上 __attribute__((constructor)) 的屬性,指定為共享庫建構函式,會在共享庫載入時被執行,即在程式的 main 函式之前執行
    • 在函式宣告時加上 __attribute__((destructor)) 的屬性,指定為共享庫解構函式,會在程式的 main() 函式執行完畢之後執行
    • 如果有多個建構函式,執行順序是沒有規定的,可以指定某個構造或解構函式的優先順序,建構函式優先順序小的先執行,解構函式相反
  • 共享庫版本
    • 共享庫還可以是符合一定格式的連結指令碼檔案
    • 一個或多個輸入檔案以一定的格式經過變換之後形成一個輸出檔案

九、Windows 下的動態連結

1. DLL 簡介

  • DLL 即動態連結庫,相當於 Linux 下打共享物件

  • Windows 下的 DLL 檔案和 EXE 檔案實際上是一個概念,都是 PE 格式的二進位制檔案

  • 程式地址空間和記憶體管理 :DLL 的程式碼並不是地址無關的,所以它在某些情況下可以被多個程式共享

  • 基地址和 RVA(相對地址)

    • 當一個 PE 檔案被裝載時,其程式地址空間中的起始值就是基地址,對於任何一個 PE 檔案來說,它都有一個優先裝載的基地址,這個值就是 PE 檔案頭中的 Image Base
    • WIndows 在裝載 DLL 時,會先嚐試把它裝載到由 Image Base 指定的虛擬地址,若該地址已經被其他模組佔用,那 PE 裝載器會選用其他空閒地址,而相對地址就是一個地址相對於基地址的偏移
  • DLL 共享資料段

    • 使用 DLL 來實現程式間通訊
    • Windows 允許將 DLL 的資料段設定成共享的,即任何程式都可以共享該 DLL 的同一份資料段
    • 常見的做法是將一些需要程式間共享的變數分離出來,放到另外一個資料段中,然後將這個資料段設定成程式間可共享的,也就是說一個 DLL 中有兩個資料段,一個程式間共享,另外一個私有
    • 為安全考慮,DLL 共享資料段來實現程式間通訊應該儘量避免
  • DLL 的簡單例子

    • DLL 需要顯式地告訴編譯器需要匯出某個符號,否則編譯器預設所有符號都不匯出
    • 可以通過 __declspec 屬性關鍵字來修飾某個函式或者變數,當使用 __declspec(dllexport) 時表示該符號是從本 DLL 匯出的符號,__declspec(dllimport) 表示該符號是從別的 DLL 匯入的符號
    • 可以使用 “.def” 檔案來宣告匯入匯出符號,類似於 ld 連結器的連結指令碼檔案
  • 建立 DLL :使用編譯器 cl 進行編譯 :引數 /LDd 表示生產 Debug 版的 DLL,不加任何引數則表示生產 EXE 可執行檔案,可以使用 /LD 來編譯生成 Release 版的 DLL

  • 使用 DLL

    • 程式使用 DLL 的過程其實是引用 DLL 中的匯出函式和符號的過程,即匯入過程
    • “.lib” 檔案中並不真正包含 “.c” 檔案的程式碼和資料,是用來描述 “.dll” 的匯出符號,包含了 連結時所需要的匯入符號以及一部分“樁程式碼”,以便將程式與 DLL 粘在一起,這樣的 “.lib” 檔案被稱為匯入庫
  • 使用模組定義檔案

    • .def 檔案在連結過程中的作用與連結指令碼檔案在 ld 連結過程中的作用類似,是用於控制連結過程,為連結器提供有關連結程式的匯出符號、屬性以及其他資訊
    • 好處 :可以控制匯出符號的符號名;可以將匯出函式重新命名;當一個 DLL 語言被多個語言編寫的模組使用時,採用這種方法匯出一個函式往往會很有用 ;可以控制一些連結的過程,可以控制輸出檔案的預設堆大小、輸出檔名、各個段的屬性、預設堆疊大小、版本號等
  • DLL 顯式執行時連結

    • LoadLibrary :用來裝載一個 DLL 到程式的地址空間,功能與 dlopen 類似
    • GetProcAddress :用來查詢某個符號的地址,與 dlsym 類似
    • FreeLibrary :用來解除安裝某個已載入的模組,與 dlclose 類似

2. 符號匯出匯入表

  • 匯出表

    • 當一個 PE 需要將一些函式或變數提供給其他 PE 檔案使用時,把這種行為叫做符號匯出,最典型的情況就是一個 DLL 將符號匯出給 EXE 檔案使用
    • 所有的符號被集中存放在了被稱為匯出表的結構中,提供了一個符號名與符號地址的對映關係
    • 匯出表的最後 3 個成員指向的是 3 個陣列,他們是到處地址表(EAT)、符號名錶、名字序號對應表
      • 序號 :一個函式匯出的符號就是函式在 EAT 中的地址下標加上一個 Base 值
      • 使用序號匯入匯出省去了函式名查詢過程,函式名錶也不需要儲存在記憶體中了,但是一個函式的序號可能會改變
      • 現在 DLL 基本都直接使用符號名作為匯入匯出,進行動態連結時,動態連結器在函式名錶中進行二分查詢,找到後在名字序號對應表中找到所對應的序號,減去 Base 值,然後在 EAT 中找到對應下標下標的元素
  • EXP 檔案

    • 連結器在建立 DLL 時與靜態連結一樣採用兩遍掃描過程
    • 第一遍會遍歷所有的目標檔案並且收集所有匯出符號資訊並且建立 DLL 的匯出表,連結器會把這個匯出表放到一個臨時的目標檔案叫做 “.edata” 的段中,這個目標檔案就是 EXP 檔案
    • 第二遍,連結器把這個 EXP 檔案當做普通目標檔案一樣,與其他輸入的目標檔案連結在一起並且輸出 DLL,這時 EXP 檔案中的 “.edata” 段也就會被輸出到 DLL 檔案中並且成為匯出表
  • 匯出重定向

    • 將某個符號重定向到另外一個 DLL
    • 正常情況下,匯出表的地址陣列中包含的是函式的 RVA,但是如果這個 RVA 指向的位置位於匯出表中,那麼表示這個符號被重定向了,被重定向了的符號的 RVA 並不代表該函式的地址,而是執行一個 ASCII 的字串,這個字串在匯出表中,是符號重定向後的 DLL 檔名和符號名
  • 匯入表

    • 如果某個程式中使用到了來自 DLL 的函式或者變數,那麼這種行為叫做符號匯入
    • 某個 PE 檔案被載入時,Windows 載入器的其中一個任務就是將所有需要匯入的函式地址確定並且將匯入表中的元素調整到正確的地址,以實現動態連結
    • 匯入地址陣列 IAT :每個元素對應一個被匯入的符號,元素的值在不同的情況下有不同的含義,動態連結器剛完成對映還沒有開始重定位和符號解析時,IAT 中的元素值表示相對應的匯入符號的序號或者是符號名;當 Windows 的動態連結器在完成該模組的連結時,元素值會被動態連結器改寫成真正的符號地址。匯入地址陣列與 ELF 中的 GOT 非常相似
    • 對於 32 位的 PE 來說,如果最高位被置 1,那麼低 31 位值就是匯入符號的序號值;如果沒有,那麼元素的值是指向一個 RVA
    • 對於 Windows 來說,它的動態連結器其實是 Windows 核心的一部分,所以它可以隨心所欲地修改 PE 裝載以後的任意一部分內容,包括內容和它的頁面屬性;在裝載時,將匯入表所在的位置的頁面改寫成可讀寫的,一旦匯入表的 IAT 被改寫完,再將這些頁面設回只讀屬性
  • 匯入函式的呼叫

    • PE DLL 的程式碼段並不是地址無關的,使用了一種叫做重定基地址的方法
    • 連結器在連結時會將匯入函式的目標地址導向一小段樁程式碼,由這個樁程式碼再將控制權交給 IAT 中真正的目標地址
    • 編譯器在產生匯入庫的時候,同一個匯出函式會產生兩個符號的定義,一個指向樁程式碼,一個指向函式在 IAT 中的位置

3. DLL 優化

  • DLL 的程式碼段和資料段本身並不是地址無關的,預設需要被裝載到由 ImageBase 指定的目標地址中,被佔用就需要裝載到其他得知,引起整個 DLL 的 Rebase

  • 重定基地址

    • PE 的 DLL 中的程式碼段並不是地址無關的,也就是說它在被裝載時有一個固定的目標地址,就是 PE 裡面所謂的基地址。預設情況 PE 檔案將被裝載到這個基地址,一般來說,EXE 的基地址預設為 0x00400000,而 DLL 檔案基地址預設為 0x10000000
    • 解決共享物件的地址衝突問題 :Windows PE 採用的是裝載時重定位的方法,在 DLL 模組裝載時,如果目標地址被佔用,那麼作業系統就會為它分配一塊新的空間,並且將 DLL 裝載到該地址,對於每個絕對地址引用都進行重定位,所有這些需要重定位的地方只需要加上一個固定的差值,也就是說加上一個目標裝載地址與實際裝載地址的差值
    • 由於 DLL 內部的地址都是基於基地址的,或者是相對於基地址的 RVA,那麼所有需要重定位的地方都只需要加上一個固定差值,PE 裡面把這種特殊的重定位過程又叫做重定基地址,好處是比 ELF 的 PIC 機制有著更快的執行速度,因為 PE 的 DLL 對資料段的訪問不需要通過類似於 GOT 的機制,對於外部資料和函式的引用不需要每次都計算 GOT 的位置
    • 改變預設基地址 :MSVC 的連結器提供了指定輸出檔案的基地址的功能,可以在連結時使用 link 命令中的 “/BASE” 引數指定基地址
    • 系統 DLL :Windows 在安裝時就把一塊地址分配給了系統 DLL,調整這些 DLL 的基地址使得它們相互之間不衝突,從而在裝載時就不需要進行進行重定基址了
  • 序號

    • 一個 DLL 中每一個匯出的函式都有一個對應的序號,一個匯出函式甚至可以沒有函式名,但必須有一個唯一的序號
    • 一般來說,那些僅供內部使用的匯出函式,只有序號沒有函式名,外部使用者無法推測它的含義和使用方法,以防止誤用
    • 現在的 DLL 中,匯出函式表的函式名是經過排序的,所以查詢可以使用二分查詢法,所以綜合來看,一般情況下不推薦使用序號作為匯入匯出的手段
  • 匯入函式繫結

    • DLL 繫結 :對繫結的程式的匯入符號進行遍歷查詢,找到以後就把符號的執行時的目標地址寫入到被繫結程式的匯入表內
    • DLL 繫結的地址失效 :被依賴的 DLL 更新導致 DLL 的匯出函式地址發生變化;被依賴的 DLL 在裝載時發生重定基址,導致 DLL 的裝載地址與被繫結時不一致

4. C++ 與動態連結

  • 使用 C++ 編寫 DLL 時很容易遇到相容性問題
  • 使用 C++ 編寫動態連結庫,要儘量遵循 :
    • 所有的幾口函式都應該是抽象的,所有的方法都應該是純虛的
    • 所有的全域性函式都應該使用 extern "C" 來防止名字修飾的不相容
    • 不要使用 C++ 標準庫 STL
    • 不要使用異常
    • 不要使用虛解構函式,可以建立一個 destroy() 方法並且過載 delete 操作符並且呼叫 destroy()
    • 不要再 DLL 裡面申請記憶體,而且在 DLL 外釋放
    • 不要在介面中使用過載方法(Overloaded Methods,一個方法多重引數)

5. DLL HELL

  • 總的來說,有三種可能的原因導致了 DLL Hell 的發生 :

    • 由使用舊版本的 DLL 替代原來一個新版本的 DLL 而引起,在安裝時將一箇舊版的 DLL 覆蓋掉一個更新版本的 DLL
    • 由新版 DLL 中的函式無意發生改變而引起
    • 由新版 DLL 的安裝引入一個新 BUG
  • 解決 DLL Hell 的方法 :

    • 靜態連結 :避免使用動態連結,執行程式是就不再依賴 DLL 了,但是會喪失動態連結帶來的好處
    • 防止 DLL 覆蓋 :使用 Windows 檔案保護技術來緩解
    • 避免 DLL 衝突 :讓每個程式擁有一份自己依賴的 DLL,把問題 DLL 的不同版本放到該應用程式的資料夾中,而不是系統資料夾中
    • .NET 下 DLL Hell 的解決

十、 記憶體

1. 程式的記憶體佈局

  • 32 位的系統裡,記憶體空間擁有 4GB(2^32)的定址能力,被稱為平坦的記憶體模型,整個記憶體是喲個統一的地址空間
  • 實際上記憶體仍然在不同的地址區間上有著不同的地位,例如大多數 OS 會將 4GB 的記憶體空間中的一部分挪給核心使用,應用程式無法直接訪問這一段記憶體,這一部分記憶體地址被稱為核心空間,Windows 在預設情況下會將高地址的 2GB 空間分配給核心(也可配置為 1GB),Linux 預設情況下將高地址的 1GB 空間分配給核心
  • 使用者使用的剩下的 2GB 或 3GB 的記憶體空間稱為使用者空間,一般來講,應用程式使用的記憶體空間裡有如下“預設”的區域 :
    • 棧 :用於維護函式呼叫的上下文,離開了棧函式呼叫就沒法實現;通常在使用者空間的最高地址處分配,通常有數兆位元組的大小
    • 堆 :用來容納應用程式動態分配的記憶體區域,當程式使用 malloc 或 new 分配記憶體時,得到的記憶體來自堆裡;通常位於棧的下方(低地址方向),在某些時候,堆也有可能沒有固定統一的儲存區域。堆一般比棧大很多
    • 可執行檔案映像 :儲存著可執行檔案在記憶體裡的映像
    • 保留區 :不是一個單一的記憶體區域,而是對記憶體中受到保護而禁止訪問的記憶體區域的總稱

2. 棧與呼叫慣例

  • 什麼是棧

    • 棧被定義為一個特殊的容器,先進後出
    • 棧儲存了一個函式呼叫所需要的維護資訊,這場被稱為堆疊幀或活動記錄,堆疊幀一般包括如下幾方面內容 :
      • 函式的返回地址和引數
      • 臨時變數 :包括函式的非靜態區域性變數以及編譯器自動生成的其他臨時變數
      • 儲存的上下文 :包括在函式呼叫前後需要保持不變的暫存器
    • 在 i386 中,一個函式的活動記錄用 ebp 和 esp 這兩個暫存器劃定範圍
      • esp 暫存器始終指向棧的頂部沒同時也就執行了當前函式活動記錄的頂部

      • ebp 暫存器指向了函式活動記錄的一個固定位置,又被成為幀指標,在引數之後的資料(包括引數)即是當前函式的活動記錄,ebp 固定在哪個位置,不隨函式的執行而變化;固定不變的 ebp 可以用來定位函式活動記錄中的各個資料,在 ebp 之前首先是這個函式的返回地址,再往前是壓入棧中的引數;ebp 所直接指向的資料是呼叫該函式前 ebp 的值,這樣在函式返回的時候,ebp 可以通過讀取這個值恢復到呼叫前的值

      • 把 ebp 壓入棧中,是為了在函式返回的時候便與恢復以前的 ebp 值;之所以可能要儲存一些暫存器,在於編譯器可能要求某些暫存器在呼叫前後保持不變,那麼函式就可以在呼叫開始時將這些暫存器的值壓入棧中,結束後再取出

      • i386 標準函式進入和退出指令序列,基本的形式為 :

          push ebp      // 儲存 ebp
          mov ebp, esp  // 讓 ebp 指向目前的棧頂
          sub esp, x    // 在棧上開闢一塊空間
          [push reg1]   // 儲存暫存器
          ...
          [push regn]
          
          // 函式實際內容
          mov eax, x   // 通過暫存器傳遞返回值
          
          [pop reg1]    // 從棧上恢復暫存器
          ...
          [pop regn]
           mov esp, ebp // 恢復進入函式前的 esp 和 ebp
           pop ebp       
           ret          // 返回
        
  • 呼叫慣例 :函式的呼叫方和被呼叫方對於函式如何呼叫要有一個明確的規定,只有雙方都遵守,函式才能被正確地呼叫

    • 函式引數的傳遞順序和方式 :最常見的一種是通過棧傳遞
    • 棧的維護方式 :函式將引數壓棧之後,函式體會被呼叫,此後需要將被壓入棧的引數全部彈出,以使得棧在函式呼叫前後保持一致
    • 名字修飾的策略 :不同的呼叫慣例有不同的名字修飾策略
    • cdecl 是 C 語言預設的呼叫慣例 :引數傳遞順序為從右至左的順序壓引數入棧,出棧方為函式呼叫方,名字修飾為直接在函式名稱前加一個下劃線
  • 函式返回值傳遞

    • eax 是傳遞返回值的通道,函式將返回值儲存在 eax 中,返回後函式的呼叫方再讀取 eax
    • eax 本身只有 4 個位元組,返回大的返回值需要 :
      • 首先 main 函式在棧上額外開闢了一片空間,並將這塊空間的一部分作為傳遞返回值的臨時物件,這裡稱為 temp
      • 將 temp 物件的地址作為隱藏引數傳遞給函式
      • 函式將資料拷貝給 temp 物件,並將 temp 物件的地址用 eax 傳出
      • 函式返回之後,main 函式將 eax 指向的 temp 物件的內容拷貝給 n
    • 如果返回值型別尺寸太大,C 語言在函式返回時會使用一個臨時的棧上記憶體區域作為中轉,結果返回值會被拷貝兩次

3. 堆與記憶體管理

  • 什麼是堆
    • 堆是一塊巨大的記憶體空間,常常佔據整個虛擬空間的絕大部分,在這片空間裡,程式可以請求一塊連續記憶體,並自由地使用,這塊記憶體在程式主動放棄之前都會一直保持有效
    • 如果每次程式申請或者釋放對空間都需要進行系統呼叫,系統呼叫的效能開銷是很大的,頻繁操作嚴重影響效能,比較好的做法就是程式向作業系統申請一塊適當大小的堆空間,然後程式自己管理這塊空間,管理者堆空間分配的往往是程式的執行庫
  • Linux 程式堆管理
    • 提供兩個系統呼叫 :
      • 一個是 brk() 系統呼叫,作用實際上是設定程式資料段的結束地址,可以擴大或者縮小資料段(Linux 下資料段和 BSS 合併在一起統稱資料段)
      • 另一個是 mmap(),作用是向作業系統申請一段虛擬地址空間,這段虛擬地址空間可以對映到某個檔案,當它不將地址空間對映到某個檔案時,又稱這塊空間為匿名,匿名空間就可以拿來作為堆空間
    • 從理論可以推論,2.6 版的 Linux 的 malloc 的最大空間申請數應該在 2.9G 左右(可執行檔案佔去一部分、0x08040000 之前的地址佔去一部分、棧佔去一部分、共享庫佔去一部分)
  • Windows 程式堆管理
    • Windows 的程式將地址空間分配給了各種 EXE、DLL 檔案、堆、棧
    • 每個執行緒的棧都是獨立的,所以一個程式中有多少個執行緒,就應該有多少個對應的棧,對於 Windows 來說,每個執行緒預設的棧大小是 1MB,線上程啟動時,系統會為它在程式地址空間中分配相應的空間作為棧
    • Windows 系統提供了一個 API 叫做 VirtualAlloc(),用來向系統申請空間,與 Linux 下的 mmap 非常相似,實際上申請的空間不一定只用於堆,僅僅是想系統預留了一塊虛擬地址,應用程式可以按照需要隨意使用
    • 使用 VirtualAlloc() 函式申請空間時,系統要求空間大小必須為頁的整數倍
    • 在 Windows 中,有基於對管理器實現的分配的演算法,對管理器提供了一套與堆相關的 API 可以用來建立、分配、釋放和銷燬堆空間
    • 通過 Windows 程式地址空間分佈,可知一個程式中能夠分配給堆用的空間不是連續的,所以當一個堆的空間已經無法再擴充套件時,必須建立一個新的堆,執行庫的 malloc 函式已經解決了這一切
    • 程式中可能存在多個堆,但是一個程式中能夠分配的最大堆空間取決於最大的那個堆
  • 堆分配演算法
    • 空閒連結串列
      • 實際上就是把堆中各個空閒的塊按照連結串列的方式連線起來,當使用者請求一塊空間時,可以遍歷整個列表,直到找到適合大小的塊並且將它拆分;當使用者釋放空間時將它合併空閒連結串列中
      • 但是一旦連結串列被破壞,或者記錄長度的那 4 位元組被破壞,整個對就無法正常工作
    • 點陣圖
      • 核心思想是將整個堆分配為大量的塊,每個塊的大小相同,當使用者請求記憶體的時候,總是分配整數個塊的空間給使用者,第一個塊稱為已分配區域的頭,其餘的稱為已分配區域的主體
      • 可以用一個整數陣列來記錄塊的使用情況,由於每個塊只有頭/主體/空閒三種狀態,因此僅需要兩位即可表示一個塊,因此稱為點陣圖
      • 優點 :
        • 速度快
        • 穩定性好
        • 塊不需要額外資訊
      • 缺點
        • 分配記憶體的時候容易產生碎片
        • 如果對很大,或者設定一個快很小,那麼點陣圖將會很大,可能失去 cache 命中率的優勢,也會浪費一定的空間
    • 物件池
      • 如果每一次分配的空間大小都一樣,那麼就可以按照這個每次請求分配的大小作為一個單位,把整個堆空間劃分為大量的小塊,每次請求的時候只需要找到一個小塊就可以了
      • 每次總是隻請求一個單位的記憶體,因此請求得到滿足的速度非常快,無需查詢一個足夠大的空間

十一、 執行庫

1. 入口函式和程式初始化

  • 程式從 main 開始嗎

    • 作業系統裝載程式之後,首先執行的程式碼並不是 main 的第一行,而是某些別的程式碼,這些程式碼負責準備好 main 函式執行所需要的環境,並且負責呼叫 main 函式這時才可以在 main 函式裡大膽地寫各種程式碼 :申請記憶體、使用系統呼叫、觸發異常、訪問 I/O。在 main 返回之後,會記錄 main 函式的返回值,呼叫 atexit 註冊的函式,然後結束程式
    • 執行這些程式碼的函式稱為入口函式或入口點,程式的入口點實際上是一個程式的初始化和結束部分,往往是執行庫的一部分
    • 典型的程式執行步驟大致如下 :
      • 作業系統在建立程式後,把控制權交到了程式的入口,這個入口往往是執行庫中的某個入口函式
      • 入口函式對執行庫和程式環境進行初始化,包括堆、I/O 、執行緒、全域性變數構造,等等
      • 入口函式在完成初始化之後,呼叫 main 函式,正式開始執行程式主體部分
      • main 函式執行完畢以後,返回到入口函式,入口函式進行清理工作,包括全域性變數析構、堆銷燬、關閉 I/O 等,然後進行系統呼叫結束程式
  • 入口函式如何實現

    • GLIBC 入口函式
    • MSVC CRT 入口函式
      • 程式一開始堆還沒有被初始化,alloca 是唯一可以不使用堆得動態分配機制,可以在棧上分配任意大小的空間,並在函式返回的時候會自動釋放,就好像區域性變數一樣
  • 執行庫與 I/O

    • 一個程式的 I/O 指代了程式與外界的互動,包括檔案、管道、網路、命令列、訊號等
    • 廣義地講,I/O 指代任何作業系統理解為“檔案”的事物,許多 OS 都將各種具有輸入和輸出概念的實體——包括裝置、磁碟檔案、命令列等——統稱為檔案
    • C 語言檔案操作是通過一個 FILE 結構的指標來進行的
    • OS 層面上,檔案操作也有類似於 FILE 的一個概念,在 Linux 裡,叫做檔案描述符,在 Windows 裡,叫做控制程式碼。使用者通過某個函式開啟檔案以獲得控制程式碼,然後使用者操縱檔案皆通過該控制程式碼進行,因為控制程式碼可以防止使用者隨意讀寫作業系統核心的檔案物件,檔案控制程式碼總是和核心的檔案物件相關聯的
  • MSVC CRT 的入口函式初始化

    • 系統堆初始化 :由函式 _heap_init 完成,呼叫了 HeapCreate 這個 API 建立了一個系統堆
    • I/O 初始化
      • 在 MSVC 中,FILE 結構中最重要的一個欄位 _file_file 是一個整數,通過它可以訪問到內部檔案控制程式碼表中的某一項
      • 在 Windows 中,使用者態使用控制程式碼來訪問核心檔案物件
      • 訪問檔案時,必須要從 FILE 結構轉換到作業系統的控制程式碼
      • MSVC 的 I/O 初始化就是要構造二維的開啟檔案表
      • _ioinit 函式初始化了 _pioinfo 陣列的第一個二級陣列,接下來,將一些預定義的開啟檔案給初始化,包括:
        • 從父程式繼承的開啟檔案控制程式碼,可以選擇繼承自己的開啟檔案控制程式碼
        • OS 提供的標準輸入輸出
      • MSVC 的 I/O 初始化主要進行了如下幾個工作 :
        • 建立開啟檔案表
        • 如果能夠繼承自父程式,那麼從父程式獲取繼承的控制程式碼
        • 初始化標準輸入輸出

2. C/C++ 執行庫

  • C 語言執行庫

    • 使程式能夠正常執行,至少包括入口函式,及其所依賴的函式所構成的函式集合,還理應包括各種標準庫函式的實現,這樣的一個程式碼集合稱之為執行時庫,C 語言的執行庫被稱為 C 執行庫
    • 一個 C 語言執行庫大致包含了如下功能 :
      • 啟動與退出
      • 標準函式
      • I/O
      • 語言實現
      • 除錯
  • C 語言標準庫

    • 例如 :標準輸入輸出、檔案操作、字元操作、字串操作、數學函式、資源管理、格式轉換、時間/日期、斷言、各種型別上的常數,還有一些特殊的操作如:變長引數、非區域性跳轉
  • glibc 與 MSVC CRT

3. 執行庫與多執行緒

  • CRT 的多執行緒困擾

    • 執行緒的訪問許可權

      • 實際運用中執行緒也擁有自己的私有儲存空間
        • 執行緒區域性儲存 TLS
        • 暫存器
      • 從 C 程式設計師的角度來看 :
        • 執行緒私有 :
          • 區域性變數
          • 函式的引數
          • TLS 資料
        • 執行緒之間共享(程式所有):
          • 全域性變數
          • 堆上的資料
          • 函式裡的靜態變數
          • 程式程式碼
          • 開啟檔案
    • 多執行緒執行庫

      • 提供多執行緒操作的介面
      • C 執行庫本身要能夠在多執行緒的環境下正確執行
  • CRT 改進

    • 使用 TLS
    • 加鎖
    • 改進函式呼叫方式 :修改所有執行緒不安全的函式的引數列表,改成某種執行緒安全的版本
  • 執行緒區域性儲存實現

    • 一旦一個全域性變數被定義成 TLS 型別的,那麼每個執行緒都會擁有這個變數的一個副本,然和執行緒對該變數的修改都不會影響其他執行緒中該變數的副本
    • 使用 __declspec(thread) 定義一個執行緒私有變數的時候,編譯器會把這些變數放到 PE 檔案的 “.tls” 段中。當系統啟動一個新的執行緒時,它會從程式的堆中,分配一塊足夠大小的空間,然後把 “.tls” 段中的內容複製到這塊空間,於是每個執行緒都有自己獨立的一個 “.tls” 副本
    • 對於每個 Windows 執行緒來說,系統都會建立一個關於執行緒資訊的結構,叫做執行緒環境塊,儲存了現成的堆疊地址、執行緒 ID 等相關資訊
    • 顯式 TLS
    • _beginthread() 是對 CreateThread() 的包裝,當使用 CRT 時,儘量使用 _beginthread/_beginthreadex()/_endthread()/_endthreadex() 這組函式來建立執行緒

4. C++ 全域性建構函式與析構

  • C++ 入口函式需要在 main 的前後完成全域性變數的構造與析構

  • glibc 全域性構造與析構

    • _start -> __libc_start_main -> __libc_csu_init -> _init -> __do_global_ctors_aux
    • _init 呼叫了 __do_global_ctors_aux 函式,它不屬於 glibc,而是來自於 GCC 提供的一個目標檔案 crtbegin.o,負責構造的函式來自於 GCC
    • __CTOR_LIST__ 陣列裡面存放的就是全域性物件的建構函式的指標
    • 對於每個編譯單元,GCC 編譯器會遍歷其中所有的全域性物件,生成一個特殊的函式,這個特殊函式的左右就是對本編譯單元裡的所有全域性物件進行初始化
    • 把每個目標檔案的複雜全域性/靜態物件構造的函式地址放在一個特殊的段裡面,讓連結器把這些特殊的段收集起來,收集齊所有的全域性建構函式後就可以在初始化的時候進行構造了
    • 每個目標檔案的 .ctors 段會被合併為一個 .ctors 段,拼接起來的 .ctors 段成為了一個函式指標陣列,每一個元素都指向一個目標檔案的全域性建構函式
    • glibc 的全域性建構函式是放置在 .ctors 段裡的
    • 為了保證全域性物件構造和析構的順序(先構造後析構),連結器必須包裝所有的 “.dtor” 段的合併順序必須是 “.ctors” 的嚴格反序,後來採用一種新的做法是通過__cxa_atexit() 在 exit() 函式中註冊程式退出回撥函式來實現析構
    • 全域性物件的構建和析構都是由執行庫完成的
  • MSVC CRT 的全域性構造與析構

    • mainCRTStartup -> _initterm
    • _initterm 遍歷所有的函式指標並且呼叫
    • MSVC CRT 的全域性構造實現在機制上與 Glibc 基本是一樣的,不過名字略有不同
    • MSVC CRT 析構 :通過 atexit() 實現全域性析構

5. fread 實現

  • fread 最終是通過 Windows 的系統 API :ReadFile() 來實現對檔案的讀取的;fread 有 4 個引數,功能是嘗試從檔案流 stream 裡讀取 count 個大小為 elementSize 個位元組的資料,儲存在 buffer 裡,返回實際讀取的位元組數

  • 緩衝

    • 如果每次讀或寫資料都進行一次系統呼叫,讓核心讀寫資料,系統開銷很大,要進行上下文切換、核心引數檢查、複製等,會嚴重影響程式和系統的效能
    • 行緩衝和全緩衝
  • fread_s

    • fread 將所有的工作都轉交給了 _fread_s
    • fread_s 的引數比 fread 多了一個 bufferSize,用於指定引數 buffer 的大小,而 fread 只有 SIZE_MAXfread_s 可以指定這個引數以防止越界
    • fread_s 首先對各個引數檢查,然後使用 _lock_str 對檔案進行加鎖,以防止多個執行緒同時讀取檔案而導致緩衝區不一致
  • fread_nolock_s

    • 所有的線索最終都指向 _read 函式,它主要負責兩件事 :
      • 從檔案中讀取資料
      • 對文字模式開啟的檔案,轉換回車符
  • _read

    • _read 函式在每次讀取管道和裝置資料的時候必須先檢查 pipech,以免漏掉一個位元組
    • ReadFile 是一個 Windows API 函式,由 Windows 系統提供,作用和_read 類似,用於從檔案裡讀取資料
  • 文字換行

    • _read 要為以文字模式開啟的檔案轉換回車符
    • 首先檢查檔案是否以文字模式開啟,再進行 “\r\n” 之類的轉換
  • fread 回顧

    • fread
    • fread_s 增加緩衝溢位保護,加鎖
    • _fread_nolock_s 迴圈讀取、緩衝
    • _read 換行符轉換
    • ReadFile Windows 檔案讀取 API

十二、 系統呼叫與 API

1. 系統呼叫介紹

  • 什麼是系統呼叫
    • 為了讓應用程式有能力訪問系統資源,也為了讓程式藉助作業系統做一些必須由作業系統支援的行為,每個作業系統都會提供一套介面,以供應用程式使用,這些介面往往通過中斷來實現
    • 涵蓋程式執行所必須的支援、系統資源的訪問、對圖形介面的操作支援等
    • 需要保持穩定和向後相容
    • Windows 與應用程式的最終介面是 API
  • Linux 系統呼叫
    • x86 下,系統呼叫有 0x80 中斷完成,各個通用暫存器用於傳遞引數,EAX 暫存器用於表示系統呼叫的介面號
    • 包括程式處理、讀寫檔案、許可權管理、定時器、訊號、網路等
  • 系統呼叫的弊端
    • 使用不便
    • 各個作業系統之間系統呼叫不相容
    • 解決辦法 :使用執行庫為系統呼叫和程式之間的一個抽象層

2. 系統呼叫原理

  • 特權級與中斷
    • 現代 OS 中,通常有兩種特權級別 :使用者模式和核心模式,也被稱為使用者態和核心態
    • 系統呼叫是執行在核心態的,而應用程式基本都是執行在使用者態的
    • 作業系統一般通過中斷來從使用者態切換到核心態
    • 中斷是一個硬體或軟體發出的請求,要求 CPU 暫停當前的工作轉手去處理更加重要的事
    • 中斷一般具有兩個屬性:中斷號和中斷處理程式;不同的中斷具有不同的中斷號,一箇中斷處理程式一一對應一箇中斷號
  • 基於 int 的 Linux 的經典系統呼叫實現
    • 觸發中斷 :利用巨集
    • 切換堆疊 :呼叫中斷時,程式的執行會在使用者態和核心態之間切換,程式的當前棧也在使用者棧和核心棧之間切換,當前棧指的是 ESP 的值所在的棧空間,暫存器 SS 的值還應該指向當前棧所在的頁
    • 中斷處理程式
    • 使用者呼叫系統呼叫時,根據系統呼叫引數數量不同,依次將引數放入 EBX、ECX、EDX、ESI、EDI、EBP 這 6 個暫存器中傳遞,進入系統呼叫的服務程式 system_call 的時候,呼叫了一個巨集 SAVE_ALL 來儲存各個暫存器
  • Linux 的新型系統呼叫機制
    • 使用 ldd 來獲取一個可執行檔案的共享庫的依賴情況,會看到 linux-gate.so.1 沒有與任何實際的檔案相對應,是用於支援新型系統呼叫的“虛擬”共享庫,並不存在實際的檔案,只是作業系統生成的一個虛擬動態共享庫
    • 新型系統呼叫指令 :sysenter,呼叫之後系統會直接跳轉到某個暫存器指定的函式執行,並自動完成特權級轉換、堆疊切換等功能;在引數傳遞方面,新型的系統呼叫與使用 int 的系統呼叫完全一樣

3. Windows API

  • Windows API 是指 Windows 作業系統提供給應用程式開發者最底層的、最直接與 Windows 打交道的介面,在 Windows OS 下,CRT 是建立在 Windows API 之上的,MFC 是很著名的一種以 C++ 形式封裝的庫
  • 概述 :Windows API 是以 DLL 匯出函式的形式暴露給應用程式開發者的
  • 為什麼要使用 Windows API(放著系統呼叫不用,在 CRT 和系統呼叫之間增加一層 Windows API 層):系統呼叫實際上是非常依賴於硬體結構的一種介面,受到硬體的嚴格限制,比如暫存器的數量、呼叫時的引數傳遞、中斷號、堆疊切換等,都與硬體密切相關,如果硬體結構稍微發生改變,大量的應用程式可能就會出現問題。 所以 Windows OS 把系統呼叫包裝了起來,使用 DLL 匯出函式作為應用程式的唯一可用的介面暴露給使用者
  • API 與子系統
    • 子系統又稱為 Windows 環境子系統,簡稱子系統
    • 子系統又是 Windows 架設在 API 和應用程式之間的另一箇中間層,是用來為各種不同平臺的應用程式建立與它們相容的執行環境

十三、 執行庫實現

1. C 語言執行庫

  • 實現 Mini CRT,它應該具備 CRT 的基本功能以及遵循幾個基本設計原則 :
    • 應該以 ANSI C 的標準庫為目標,儘量做到與其介面一致
    • 具有自己的入口函式
    • 基本的程式相關操作
    • 支援堆操作
    • 支援基本的檔案操作
    • 支援基本的字串操作
    • 支援格式化字串和輸出操作
    • 支援 atexit() 函式
    • 應該是跨平臺的
    • 實現應該儘量簡單
  • 開始
    • 從入口函式開始
      • 程式的最初入口點不是 main 函式,而是由執行庫為其提供的入口函式,主要負責 :準備好程式執行環境及初始化執行庫、呼叫 main 函式執行程式主體、清理程式執行後的各種資源
      • 執行庫為所有程式提供的入口函式應該相同,在連結程式時須要指定該入口函式名
      • 須要確定入口函式的函式原型,包括函式名、輸入引數及返回值
      • 初始化主要負責好程式執行的環境,包括準備 main 函式的引數、初始化執行庫,包括堆、IO 等,結束部分主要負責清理程式執行資源
    • main 函式 :argc、argv
    • CRT 初始化 :主要是堆和 IO 部分
    • 結束部分 :呼叫由 atexit() 註冊的退出回撥函式、實現結束程式
  • 堆的實現
    • 實現一個以空閒連結串列演算法為基礎的堆空間分配演算法
    • 為了簡單,堆空間大小固定為 32MB,初始化之後空間不再擴充套件或縮小
    • 採用 VirtualAlloc 向系統直接申請 32MB,由自己的對分配演算法實現 malloc
    • Linux 平臺下,使用 brk 將資料段結束地址向後調整 32MB,將這塊空間作為堆空間
  • IO 與檔案操作
    • 僅實現基本的檔案操作,包括 fopen、fread、fwrite、fclose、fseek
    • 不實現緩衝機制
    • 不對 Windows 下的換行機制進行轉換
    • 支援三個標準的輸入輸出 stdin、stdout、stderr
    • 在 Windows 下,檔案基本操作可以使用 API
    • Linux 不像 Windows 那樣有 API 介面,必須使用內嵌彙編實現 open、read、write、close、seek 這幾個系統呼叫
    • fopen 時只區分“r”、“w”、“+”這幾種模式及它們的組合,不對文字模式和二進位制模式進行區分,不支援追加模式(“a”)
  • 字串相關操作 :無須涉及任何與核心互動
  • 格式化字串 :實現 printf

2. 如何使用 Mini CRT

  • Mini CRT 也將以庫檔案和標頭檔案的形式提供給使用者,可以建立一個 minicrt.h 的標頭檔案,然後將所有相關的常數定義、巨集定義,以及 Mini CRT 所實現的函式宣告等放在該標頭檔案裡,當使用者使用時,僅需要 #include "minicrt.h" 即可
  • MiniCRT 僅依賴與 Kernel32.DLL,的確繞過了 MSVC CRT 的執行庫 msvcr90.dll

3. C++ 執行庫實現

  • 在 Mini CRT 的基礎上實現一個支援 C++ 的執行庫
  • 遵循以下原則 :
    • 儘量簡化設計,儘量符合 C++ 標準庫的規範
    • 對於可以直接在標頭檔案實現的模組儘量在標頭檔案中實現
    • 可以在 Windows 和 Linux 上同事執行,因此對於平臺相關部分要使用條件編譯分別實現
    • 模板是不需要執行庫支援的,它的實現依賴於編譯器和連結器
  • new 與 delete :在堆上分配空間
  • C++ 全域性構造與析構 :實現依賴於編譯器、連結器和執行庫三者共同的支援和協作
  • atexit 實現 :由它註冊的函式會在程式退出前,在 exit() 函式中呼叫;實現的原因是 所有全域性物件的解構函式都是通過 atexit() 或其類似函式來註冊的,以達到在程式退出時執行的目的
  • 入口函式修改 :把對 do_global_ctors()mini_crt_call_exit_routine 的呼叫加入到 entry() 和 exit() 函式中去
  • stream 和 string

4. 如何使用 Mini CRT++

相關文章