上帝模式看程式從出生到死亡

Jerry4me發表於2016-10-17

111862021-5e9d18823bc285bb

程式的一生

GitHub : Jerry4me


前言

我們中有許多程式設計師打碼幾年還沒有搞清楚一個程式從原始碼 -> 可執行程式 -> 執行 -> 死亡, 經歷了什麼變化. 他們只知道, 編譯, 連結, 執行…由於強大的IDE已經幫我們把這些過程遮蔽掉了, 我們不知道底層他們幹了什麼. 但是我們只有明白這些執行機制和機理, 才能解決一些莫名其妙的錯誤, 提升效能瓶頸.

筆者在看了>這本書後決定把這些過程用比較簡單易懂的文字敘述出來, 如有不對的地方還請各位指出, 謝謝~


目錄

預編譯
編譯
彙編
目標檔案
連結
可執行檔案
裝載
動態連結(需要的話)
執行
死亡
小知識


編譯

編譯又分為 預處理(Preprocessing), 編譯(Compilation)彙編(Assembly).

預編譯

預編譯過程主要處理原始碼檔案那些#開頭的預編譯指令

編譯

編譯過程可分為6部 : 掃描, 語法分析, 語義分析, 原始碼優化, 程式碼生成和目的碼優化.

彙編

彙編器將彙編程式碼轉換成機器可以執行的指令, 輸出目標檔案. 該過程比較簡單, 就是翻譯程式碼.

經過上述多個步驟, 原始碼終於被編譯成了目標檔案. 這個目標檔案肚子裡又賣的是什麼藥呢? 我們接著看~

目標檔案

由於不同的作業系統下, 目標檔案, 可執行檔案等都有些出入. 本文是用Linux系統下的ELF檔案作為例子

編譯之後生成的目標檔案內容肯定少不了機器指令程式碼, 資料等. 不過除了這些之外, 目標檔案還包括了連結時所需的一些資訊, 而目標檔案將這些資訊按照不同的屬性, 以段(Section)來儲存.

Question :
為什麼要把資料和指令分開呢? 經典的馮諾依曼體系不是不分指令還是資料的嗎?

Answer :
1 : 當程式被裝載後, 資料和指令被對映到兩個虛存區域. 資料區域對於程式而言, 是可讀寫的, 而指令區域則只可讀. 這樣方便分別設定他們的許可權, 防止程式指令被惡意修改
2 : 把指令和資料分開有利於提高程式的區域性性, 對於提高CPU快取命中率有幫助
3 : 最重要的原因, 當系統中執行著多個該程式的副本時, 他們的指令都是一樣的, 所以程式之間能共享指令和其他只讀資料, 而資料區域則為程式私有. 如果系統中執行了數百個程式, 可以想象共享為我們節省了多少空間

這裡插一句 : 其實不是可執行檔案才才按照執行檔案的格式儲存. 什麼意思呢? 除了可執行檔案之外, 目標物件, 動態連結庫, 靜態連結庫也按照可執行檔案的格式儲存. 某種程度上他們也是可執行檔案. 所以我們可以把他們視為同一類檔案

目標檔案有什麼

ELF檔案頭(ELF Header)

包含了整個檔案的基本屬性

段表(Section Header Table)

描述了ELF檔案包含的所有段的資訊

重定位表

連結器在處理目標檔案時, 要對目標檔案中某些符號進行重定位, 即程式碼段和資料段那些對絕對地址引用的符號. 這些重定位資訊就記錄在重定位表中.

符號表

在連結中, 我們將函式和變數統稱為符號(Symbol), 函式名和變數名稱為符號名(Symbol Name). 符號表記錄著該目標檔案所用到的所有符號, 每個符號都有一個對應的值, 符號值(Symbol Value), 對於函式和變數來說, 符號值就是他們的地址.

強符號與弱符號, 強引用與弱引用

如果在目標檔案A和目標檔案B都定義了一個全域性變數global, 並將他們都初始化. 那麼連結的時候就會報multiple definition of 'global'的錯誤. 這種符號就是強符號. 預設所有符號都是強符號, 可以使用GCC的__attribute__ ((weak))定義一個弱符號.

強符號與弱符號的規則 :

符號引用被最終連結的時候必須要被正確決議, 如果沒有找到該符號的定義, 就會報符號未定義錯誤undefined symbol of xxx, 這種稱為強引用(Strong Reference). 而弱引用(Weak Reference)則被處理的時候如果未定義, 不報錯, 連結器會預設其為0或者是一個特殊值. 預設都是強引用, 可以使用GCC的__attribute__ ((weakref))定義一個弱引用.

弱符號和弱引用的作用 :

符號修飾和函式簽名

很久之前, 編譯器編譯原始碼產生目標檔案時, 符號名與相應的變數和函式的名字是一樣的, 例如函式foo, 經過編譯後對應的符號名也是foo, 那麼久會產生衝突, 例如要使用Fortran語言編寫的目標檔案, 一連結就會報錯. 為了解決這種衝突, 規定C語言的全域性變數和函式經編譯後, 符號名前加上_, 此時foo編譯後符號名為_foo. 但是還是不能完全解決C語言原始檔之間連結產生的問題, 因為大家都有下劃線啊! 於是C++開始設計的時候就考慮到了這個問題, 衍生出了名稱空間(Name Space).

在C++中, int func()int func(int)int func(float)是三個不一樣的函式, 這裡我們引用一個術語函式簽名(Function Signature), 函式簽名包括一個函式的資訊, 包括函式名, 引數型別, 所在的類和名稱空間等其他資訊. 於是, 以上三個函式編譯後各自的符號名均不一樣但是有規律可循.

連結

很久很久以前, 人們把所有程式碼寫在一個檔案中, 到後來, 人類已經沒有能力維護這個程式了. 於是人們把程式碼根據功能或性質劃分為不同的模組. 於是, 將這些模組拼接起來的過程就叫 : 連結

不知道大家看完上述的編譯過程有沒有這麼一個疑問 : 如果編譯的時候編譯器不知道一個外部符號的地址, 怎麼辦? 答案就是不管, 先放一邊, 等到連結的時候再把地址修正, 這就是重定位該做的事.

連結過程包括 : 地址和空間分配(Address and Storage Allocation), 符號決議(Symbol Resolution)重定位(Relocation).

靜態連結

最基本的靜態連結過程 : 把各個目標檔案(.o檔案)和庫(Library)一起連結形成可執行檔案.

那麼他們每個檔案中的段是怎麼合併起來呢?

ELF用的就是相似段合併 : a的.text和b的.text合併, a的.data與b的.data合併, 其他段類似.

符號決議和重定位

符號地址的確定

121862021-3ea8d5f769171c0b
符號地址的確定.png

重定位表

每個需要被重定位的段都有一個與之相對應的重定位表, 如.text段對應.rel.text

根據重定位表中每個符號的資訊, 找到每個符號對應的目標物件檔案, 再根據偏移(offset)確定其絕對地址(或相對地址).

動態連結

為什麼有了靜態連結還需要動態連結?

動態連結怎麼解決以上靜態連結的短板?

程式可擴充套件性和相容性

動態連結是否完美無缺

動態連結的基本實現

動態連結是不是直接使用目標檔案(.o檔案)進行連結呢? 理論上可行, 但實際有區別. 由於動態連結的情況下, 程式的虛擬地址空間的分佈會比靜態連結更為複雜, 還有一些儲存管理啊, 記憶體共享, 程式執行緒等機制也會有變化.

Linux系統下, ELF動態連結檔案為動態共享物件(DSO, Dynamic Shared Objects), 簡稱共享物件, 一般以.so為字尾

Windows系統下, 稱為動態連結庫(Dynamical Linking Library), 就是我們常見的.dll為字尾的檔案.

也就是說, 動態連結在這個階段, 實際上是把目標檔案和.so檔案(或.dll檔案)進行連結.

What? 為什麼不是把全部.o檔案連結起來? 實際上動態連結主要工作並不是在連結這個階段做的, 否則跟靜態連結有什麼區別, 何來的動態? 對吧. 其主要工作是在程式被裝載進記憶體的時候. 那麼這個階段的連結有啥用?

還記得連結要完成的三件事情嗎? 地址和空間分配, 符號決議和重定位.

對的沒錯說的就是你 -> 符號決議. 這個.so動態庫的作用就在於此. 它是用來告訴連結器 : “哥們, 這個符號採取的是動態連結, 在這裡你就別管它地址是多少了, 等程式被裝載進記憶體的時候自然有人負責的啦.”

於是, 連結就這麼結束了, 可執行檔案就這麼被生成了咯. 剩下的動態連結工作在下面裝載的時候由動態連結器完成.

可執行檔案

我們前面說過, 可執行檔案也就是目標檔案, 其實沒什麼不一樣. 略過

裝載

程式想要執行起來, 就必須被裝載進記憶體中才能被CPU排程到.

131862021-112e08ba37849785
頁對映和頁裝載.png

事實上, 可執行檔案並不是直接與實體記憶體直接對映的, 否則也沒有虛擬記憶體什麼事了對吧. 而且程式直接訪問實體記憶體有幾個壞處 : 地址空間不隔離, 記憶體使用效率低, 程式執行的地址不確定等.. 實際上, CPU發出的Virtual Address經過MMU(Memory Management Unit)轉換成physical Address之後才能訪問實體記憶體

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

建立一個獨立的虛擬地址空間

這一步所做的是虛擬空間與實體記憶體的對映關係. 分配一個頁目錄(Page Directory), 頁對映關係可以等到後面程式發生頁錯誤再設定

讀取可執行檔案, 建立可執行檔案與虛擬空間的對映關係

這一步做的是虛擬空間與可執行檔案的對映關係. 當發生頁錯誤時, 作業系統從實體記憶體中分配一個物理頁, 然後將該”缺頁”從磁碟中讀到記憶體中, 再設定虛擬頁和物理頁的記憶體對映關係. 作業系統捕捉到頁錯誤時, 它應該知道程式當前所需要的頁在可執行檔案中的哪一個位置, 這就是可執行檔案和虛擬空間的對映關係.

將CPU的指令暫存器設定成可執行檔案的入口地址, 啟動執行!

這一步作業系統執行一條跳轉指令跳到可執行檔案的入口地址( 不是main函式, 不是main函式, 不是main函式, 重要的事情說三遍!!! ). 實際上並沒有那麼簡單, 到程式能成功執行還差許多步驟. 這裡只是將這些過程遮蔽了.

VMA

虛擬記憶體中分為許多個段(Segment), 每一個段就成為VMA(Virtual Memory Area). 還記得目標檔案我們說過的段(Section)嗎, 此段非彼段, 這裡我們就說英文吧. 目標檔案中的多個操作許可權相同的Section在這裡要被合併成一個個Segment, 再裝載程式序的虛擬記憶體中. 如圖所示 :

141862021-f0f3a07d2e202628
ELF可執行檔案與程式虛擬空間對映關係.png

程式執行在記憶體中的的VMA佈局就如圖所示 :

151862021-8140a240ee2a3ede
常見程式的虛擬空間.png
頁錯誤

執行完上面那些步驟, 實際上可執行檔案的真正指令和資料都還沒被裝入到記憶體中. 可執行檔案只是與虛擬記憶體建立了對映關係. 當真正執行指令的時候, 會發現虛擬記憶體中的頁面為空, 這時候就產生頁錯誤(Page Fault). 程式將控制權交給作業系統, 作業系統由上面所說Page Directory, 找到空頁面所在的VMA, 計算出相應的頁面在可執行檔案中的偏移(Offset), 然後在實體記憶體中分配一個物理頁面, 將程式中該虛擬頁與物理頁之間建立對映關係, 再把控制權交回給程式, 程式從剛才頁錯誤的位置重新開始執行.

動態連結器

還記得之前連結的時候如果是動態連結的話, 那麼我們會把符號決議和重定位推遲到載入時進行嗎? 如果該程式採取的是動態連結, 那麼可執行檔案裝載完之後, 動態連結器就要閃亮登場了!!!

啟動動態連結器本身 -> 裝載所有需要的動態庫 -> 重定位和初始化

啟動動態連結器本身

動態連結器本身也是一個動態庫, 其他普通動態庫的重定位工作由動態連結器來完成, 那麼動態連結器的重定位又由誰來完成? 它可否依賴於其他動態庫?

這是一個先有雞還是先有蛋的問題, 為了解決該問題, 動態連結器必須有些特殊 :

這樣, 動態連結器必須在啟動時有一段精巧的程式碼完成這項工作而又不能用到全域性和靜態變數, 這就是自舉(Bootstrap).

裝載所有需要的動態庫

完成bootstrap後, 動態連結器把ELF檔案和連結器本身的符號表都合併到一個表中, 稱為全域性符號表(Global Symbol Table)中. 然後連結器開始尋找ELF檔案所依賴的動態庫, 一個個遍歷下去, 直到所有動態庫都被載入進來.

重定位和初始化

裝載完所有需要的動態庫後, 動態連結器開始重新遍歷ELF檔案和每個動態庫的重定位表, 把每個需要被重定位的位置進行修正.

完成這些工作後, 連結器就可以鬆一口氣, 把程式的控制權交還給程式的入口並且開始執行了.

特殊的動態連結

延遲繫結

我們知道, 有一些函式或者一些使用者比較少用的功能模組, 也許到程式結束執行都不會用到, 那麼如果程式執行的時候也把這些一併連結的話, 這實際是一種浪費, 無用功. 所以才有了這個延遲繫結 : 當函式第一次被用到時才進行繫結(符號查詢, 重定位等), 沒用到則不繫結.

顯式執行時連結(Explicit Run-time Linking)

也叫執行時載入. 也就是讓程式自己在執行時控制載入指定的模組, 並且可以在不需要該模組的時候將其解除安裝. 這種動態庫往往被叫做動態裝載庫(Dynamic Loading Library).

這種載入方式對於需要長期執行的程式來說具有很大的優勢, 最常見的便是Web伺服器程式.

執行

上面我們說到, 動態連結器的任務完成之後就會把控制權交回給程式的入口, 那麼這個所謂的程式入口, 是一個什麼傢伙呢? 我們建立一個命令列專案看看

首先, 這個程式入口肯定不是main函式, 你看他還有引數啊哥們!! 那就是別的函式傳給他的玩意!

在執行main函式以前, 程式需要初始化執行環境, 初始化堆疊, I/O, 執行緒等等. 這通通在一個我們稱之為入口函式或入口點(Entry Point)的地方完成. 等初始化之後, 才輪到main函式出場. main函式結束後, 回到入口函式, 進行清理工作, 然後進行系統呼叫結束程式.

記憶體

終於講到記憶體了, 關於記憶體那就是程式永恆的話題, 各種記憶體管理, 洩露問題讓程式設計師頭疼不已啊…

每個程式記憶體空間內都有以下預設的區域 :

於是有了以下這個經典的程式記憶體佈局圖 :

161862021-f5e79a0c71adedc1
程式記憶體佈局圖.png

棧是程式中最重要的概念之一, 沒有棧就沒有函式, 沒有區域性變數, 棧遵循FILO(First In Last Out)規則.

棧儲存了一個函式呼叫所需要的維護資訊, 稱為堆疊幀(Stack Frame). 堆疊幀一般包括如下幾個方面的內容 :

一個堆疊幀用兩個暫存器劃定範圍 : ebpesp.

esp暫存器 : 始終指向棧的頂部

ebp暫存器 : 指向堆疊幀的一個固定位置, 又稱幀指標(Frame Pointer).

171862021-7e1ba8b07c352099
堆疊幀.png

函式的返回地址 : ebp-4

壓入棧中的引數地址 : 分別是 ebp-8, ebp-12等示引數的數量和大小而定.

ebp所直接指向的資料是呼叫該函式前ebp的值, 這樣在函式返回的時候, ebp可以通過讀取這個值恢復到呼叫前的值.

i386下的函式呼叫流程

  • 把所有或一部分引數壓入棧中, 如果有其他引數沒有入棧, 那麼使用某些特定的暫存器傳遞
  • 把當前指令的下一條指令的地址壓入棧中
  • 跳轉到函式體執行

其中第2, 3步由指令call一起執行. 虛擬碼如下

把ebp壓入棧中, 是為了在函式返回的時候便於恢復以前的ebp值. 那為什麼儲存一些暫存器呢, 有一些編譯器可能要求某些暫存器在呼叫前後保持不變. 於是在函式返回時, 程式碼就恰好相反

呼叫慣例

毫無疑問, 函式的呼叫方與函式被呼叫方對函式如何呼叫必須有著相同的理解, 否則將會出現錯亂. 如

這種對函式的約定稱為呼叫慣例(Calling Convention) , 內容如下 :

在C語言中預設的呼叫慣例是cdecl

下面我們用一個例子來形容這個呼叫慣例.

程式碼 :

流程如下 :

181862021-250c95bca722bc95
呼叫慣例例項.png

相對於棧而言, 堆更加複雜, 程式隨時可能發出申請記憶體和釋放記憶體的指令, 而申請的記憶體的大小也大小不一. 下面介紹堆的工作原理.

為什麼需要堆? 什麼是堆?

如果只有棧, 那麼函式返回的時候棧上的資料就會全部被pop掉, 無法將資料傳給函式外部. 這樣的話全域性變數則無法動態地產生與銷燬

相對於棧, 堆是一塊巨大的空間, 佔用了程式大多數的虛擬空間. 在這裡, 程式可以自由地申請和釋放記憶體空間. 如 :

既然是申請記憶體空間, 那麼這個過程完全可以丟給作業系統去做. “喂! 作業系統, 我這裡需要xxx位元組的記憶體, 快給我分配一下..”, 想象下我們作業系統多程式併發的情況, 這顯然非常低效. 所以應該一次性向作業系統申請一塊適當大小的堆空間. 就像你爸一次性給你一個月的零花錢而不用你每天張手跟他要零花錢一樣.

堆管理

怎麼向堆申請空間呢? 我們知道用malloc函式, 卻不知道其背後做了什麼.

程式的確是通過malloc向堆申請空間, 而我們清楚的知道, 如果每次malloc都向作業系統申請的話很影響效率, Linux下通過mmap()函式向作業系統申請一塊堆空間(Windows下為VitualAlloc() ), 以後malloc時就會從這裡索取需要的空間, 只有這裡的堆空間又不足了, 堆才會向作業系統再申請多一塊堆空間.

記憶體洩漏

假設程式設計師總是向堆申請記憶體空間, 使用完後又不及時釋放(free)掉, 這就會造成記憶體洩漏. 作業系統不會自動回收堆空間, 因為它不知道這一塊記憶體到底是不是有人在用啊. 於是久而久之, 作業系統能用的記憶體空間就會越來越少, 我們就會感到越來越卡. 這個時候往往重啟一下電腦(手機), 這種情況就會改善. 就是這個原因啦.

多執行緒

執行緒相對於程式而言, 其訪問許可權就沒那麼多約束, 一般來說執行緒與執行緒共享整個程式記憶體的所有資料, 執行緒甚至可以訪問其他執行緒的堆疊(比較少見)

執行緒私有

執行緒之間共享(程式所有)

使用者執行緒和核心執行緒之間的關係 :

死亡

程式的生命終究走到了盡頭, 從main函式return之後就回到入口函式處, 回到夢開始的地方, 把所有資源一一釋放掉, 然後轉身走開, 不帶走一片雲彩… 有緣再見~


小知識

API與ABI

編譯器優化 和 CPU的動態排程換序

編譯器優化

即便是短短的一條x++;的程式碼, 翻譯成組合語言之後也是需要幾條來執行. 那麼編譯器為了優化程式碼, 有時候會將一個變數快取到暫存器而不立即寫回(時間區域性性原理), 又或者調整這些指令的順序… 所以多執行緒下沒有絕對的安全(上鎖也不例外)

CPU的動態排程換序

CPU的為了優化有時也會亂序執行程式碼, 也就是說不是一行接著一行執行程式碼, 那麼在一些情況下也會有安全漏洞, 例如單例模式下, 有可能取到的是一個未初始化的物件.

解決辦法 :

變長引數

我們最熟悉不過的帶變長引數的函式就是int printf(const char *format, ...); 我們知道, 除了第一個引數外, 還可以追加任意數量, 任意型別的引數.

我們用一個簡單的函式來說明這種變長引數的實現原理. 如 : int sum(int num, ...);

當我們呼叫 int n = sum(1, 3, 5, 7);時, 按道理我們只能用num來訪問1這個引數, 其他引數訪問不了. 但是多虧了C語言預設的cdecl呼叫慣例的自右向左壓棧的傳遞方式. 此時函式內部的堆疊如下 :

191862021-a30c23beff9867f3
變長引數.png

由於其他的幾個引數在num的高地址方向, 所以我們可以間接利用num來訪問其他的那幾個引數. 而printf函式接收的引數變數型別不一致, 所以比這個要複雜得多得多

格式化

對於一些人來說, 對硬碟格式化就是硬碟的資料全部都沒啦..

這種觀點完全錯誤!!! 硬碟上裝的是什麼? 0和1的序列.. 實際上作業系統是根據一張表, 在表中找到你要找的檔案在硬碟中的具體位置, 然後再到硬碟中訪問.

201862021-1c919cd2724fb2f4
表.png

格式化的本質就是把這張表的內容全擦除掉, 這樣作業系統就忘記了你的檔案放在哪裡了. 儘管檔案還是在原來的地方, 他也找不到了.

現在有種方法就是格式化就把這些區域全部用0或者用1填充.

所以大家的SD卡啊, 硬碟啊, 寧願破壞再扔掉也不要輕易交給別人啊!!!

打賞支援我寫出更多好文章,謝謝!

打賞作者

打賞支援我寫出更多好文章,謝謝!

任選一種支付方式

上帝模式看程式從出生到死亡 上帝模式看程式從出生到死亡

相關文章