GitHub : Jerry4me
前言
我們中有許多程式設計師打碼幾年還沒有搞清楚一個程式從原始碼 -> 可執行程式 -> 執行 -> 死亡, 經歷了什麼變化. 他們只知道, 編譯, 連結, 執行…由於強大的IDE已經幫我們把這些過程遮蔽掉了, 我們不知道底層他們幹了什麼. 但是我們只有明白這些執行機制和機理, 才能解決一些莫名其妙的錯誤, 提升效能瓶頸.
筆者在看了>這本書後決定把這些過程用比較簡單易懂的文字敘述出來, 如有不對的地方還請各位指出, 謝謝~
目錄
預編譯
編譯
彙編
目標檔案
連結
可執行檔案
裝載
動態連結(需要的話)
執行
死亡
小知識
編譯
編譯又分為 預處理(Preprocessing)
, 編譯(Compilation)
和彙編(Assembly)
.
預編譯
預編譯過程主要處理原始碼檔案那些#
開頭的預編譯指令
1 2 3 4 5 6 7 8 |
1. 刪除所有#define, 展開所有巨集定義 2. 處理所有預編譯指令, 如#if, #ifdef, #elif, #else, #endif 3. 遞迴處理#include 4. 刪除所有註釋, // 和 /**/ 5. 新增行號和檔名標識 6. 保留所有#pragma編譯器指令 因此如果我們無法判斷巨集定義是否正確, 標頭檔案包含是否正確時 -> 檢視預編譯後的檔案來確定問題 |
編譯
編譯過程可分為6部 : 掃描, 語法分析, 語義分析, 原始碼優化, 程式碼生成和目的碼優化.
1 2 3 4 5 6 7 8 9 10 11 12 |
1. 掃描 : 掃描器運用一種類似於有限狀態機的演算法把原始碼分割成一些列的記號(Token) 2. 語法分析 : 語法分析器採用上下文無關語法(Context-free Grammar)將Token進行語法分析, 生成語法樹(Syntax Tree).該語法樹就是以表示式為節點的樹 3. 語義分析 : 語法分析只是對錶達式的語法進行層面的分析, 它並不知道該語句是否真正有意義. 在這裡, 語義分析器能夠進行靜態語義分析, 分析過後整個語法樹的表示式都被標識了型別 靜態語義 : 在編譯器可以確定的語義, 通常包括宣告和型別的匹配, 轉換. 動態語義 : 在執行時才能確定的語義, 比如0作為除數則在這裡報錯 4. 原始碼優化 : 原始碼級優化器(Source Code Optimizer)在原始碼級別進行優化, 把一些類似於(2+6)這些在編譯器就能確定的表示式優化成值, 從而把整個語法樹轉換成中間程式碼(Intermediate Code) 中間程式碼使得編譯器可以被分為前端和後端, 前端負責產生機器無關的中間程式碼, 後端將中間程式碼轉換成目標機器程式碼 5. 程式碼生成與優化 : 程式碼生成器(Code Generator)將中間程式碼轉換成目標機器程式碼(該過程十分依賴於目標機器), 最後目的碼優化器(Target Code Optimizer)將上述的程式碼進行優化, 例如選擇合適的定址方式, 使用位移來代替乘法運算, 刪除多餘的指令等. |
彙編
彙編器將彙編程式碼轉換成機器可以執行的指令, 輸出目標檔案. 該過程比較簡單, 就是翻譯程式碼.
經過上述多個步驟, 原始碼終於被編譯成了目標檔案. 這個目標檔案肚子裡又賣的是什麼藥呢? 我們接著看~
目標檔案
由於不同的作業系統下, 目標檔案, 可執行檔案等都有些出入. 本文是用Linux系統下的ELF檔案作為例子
編譯之後生成的目標檔案內容肯定少不了機器指令程式碼, 資料等. 不過除了這些之外, 目標檔案還包括了連結時所需的一些資訊, 而目標檔案將這些資訊按照不同的屬性, 以段(Section)來儲存.
1 2 |
程式原始碼編譯後的機器指令 -> 程式碼段(Code Section), ".code"或".text" 全域性變數和區域性靜態變數資料 -> 資料段(Data Section), ".data"或".bss" |
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))
定義一個弱符號.
強符號與弱符號的規則 :
1 2 3 |
1. 不允許多次定義強符號 2. 如果一個符號在某個目標檔案中是強符號, 在其他目標檔案中是弱符號, 那麼連結時選擇強符號 3. 如果一個符號在全部目標檔案中都是弱符號, 那麼選擇佔用空間最大的一個. |
符號引用被最終連結的時候必須要被正確決議, 如果沒有找到該符號的定義, 就會報符號未定義錯誤undefined symbol of xxx
, 這種稱為強引用(Strong Reference). 而弱引用(Weak Reference)則被處理的時候如果未定義, 不報錯, 連結器會預設其為0或者是一個特殊值. 預設都是強引用, 可以使用GCC的__attribute__ ((weakref))
定義一個弱引用.
弱符號和弱引用的作用 :
1 2 3 4 |
對於庫來說十分有用, 庫中定義的弱符號可以被使用者定義的強符號覆蓋, 程式則可以使用自定義的庫函式 或者程式可以對某些擴充套件功能模組的引用定義為弱引用, 當我們將擴充套件模組與程式連結在一起時, 功能模組可以正常使用; 如果我們去掉了功能模組, 程式也可以正常連結, 只是擴充套件模組的功能將不起作用. |
符號修飾和函式簽名
很久之前, 編譯器編譯原始碼產生目標檔案時, 符號名與相應的變數和函式的名字是一樣的, 例如函式foo, 經過編譯後對應的符號名也是foo, 那麼久會產生衝突, 例如要使用Fortran語言編寫的目標檔案, 一連結就會報錯. 為了解決這種衝突, 規定C語言的全域性變數和函式經編譯後, 符號名前加上_
, 此時foo編譯後符號名為_foo
. 但是還是不能完全解決C語言原始檔之間連結產生的問題, 因為大家都有下劃線啊! 於是C++開始設計的時候就考慮到了這個問題, 衍生出了名稱空間(Name Space).
在C++中, int func()
和int func(int)
和int func(float)
是三個不一樣的函式, 這裡我們引用一個術語函式簽名(Function Signature)
, 函式簽名包括一個函式的資訊, 包括函式名, 引數型別, 所在的類和名稱空間等其他資訊. 於是, 以上三個函式編譯後各自的符號名均不一樣但是有規律可循.
1 2 3 4 5 |
例如 : int func() -編譯後-> _int_func_ int func(int) -編譯後-> _int_func_int_ int func(float) -編譯後-> _int_func_float_ // 這裡只是舉個栗子, 告訴大家他們的符號名不一致, 至於會變成什麼樣, 需要看是什麼編譯器 |
連結
很久很久以前, 人們把所有程式碼寫在一個檔案中, 到後來, 人類已經沒有能力維護這個程式了. 於是人們把程式碼根據功能或性質劃分為不同的模組. 於是, 將這些模組拼接起來的過程就叫 : 連結
不知道大家看完上述的編譯過程有沒有這麼一個疑問 : 如果編譯的時候編譯器不知道一個外部符號的地址, 怎麼辦? 答案就是不管, 先放一邊, 等到連結的時候再把地址修正, 這就是重定位該做的事.
連結過程包括 : 地址和空間分配(Address and Storage Allocation)
, 符號決議(Symbol Resolution)
和 重定位(Relocation)
.
靜態連結
最基本的靜態連結過程 : 把各個目標檔案(.o檔案)和庫(Library)一起連結形成可執行檔案.
那麼他們每個檔案中的段是怎麼合併起來呢?
ELF用的就是相似段合併 : a的.text
和b的.text
合併, a的.data
與b的.data
合併, 其他段類似.
符號決議和重定位
符號地址的確定
重定位表
每個需要被重定位的段都有一個與之相對應的重定位表, 如.text
段對應.rel.text
根據重定位表中每個符號的資訊, 找到每個符號對應的目標物件檔案, 再根據偏移(offset)確定其絕對地址(或相對地址).
動態連結
為什麼有了靜態連結還需要動態連結?
1 2 3 4 5 |
1. 為了節省記憶體和磁碟空間 例如 : 由於我們作業系統中總是多程式併發的, 如果程式A和B都用到了lib.o, 那麼由於是靜態連結, 每個程式都有lib.o的一份副本, 當我們同時執行A和B時, lib.o在磁碟和記憶體中便存在兩個副本. 這多耗費記憶體空間. 2. 靜態連結對程式的更新, 部署和釋出帶來許多麻煩 例如 : 程式A用到了一個第三方廠商提供的lib.o, 當廠商更新了lib.o, 例如修復了其中一個bug. 那麼程式A就必須先拿到最新的lib, 再將其連結, 釋出. 缺點非常明顯, 只要有任何一個模組更新, 整個程式就必須重頭連結, 釋出給使用者. |
動態連結怎麼解決以上靜態連結的短板?
1 2 3 |
1. 假設我們要執行程式A, 系統會首先載入programA.o, 當系統發現其用到了lib.o, 就會接著載入lib.o, 如果還依賴其他檔案, 就會繼續按照這種方法逐個載入進記憶體. 當我們接著執行程式B的時候, 就只載入programB.o而不需載入lib.o, 因為此時系統記憶體中已經有一份lib.o的副本了, 系統只需要把他們兩連結起來即可. 2. 當有新的模組更新時, 只需要將舊的目標檔案更新覆蓋掉, 不需要將所有程式重新連結一遍. 當程式下次執行時, 新版本的目標檔案會被自動裝載到記憶體並且連結起來, 程式就完成了升級了. |
程式可擴充套件性和相容性
1 2 3 4 5 |
程式可擴充套件性 : 動態連結還有一個特點就是程式可以在執行的時候選擇載入各種程式模組, 這就是我們熟知的`外掛`. 相容性 : 一個程式在不同的平臺執行時可以動態連結該作業系統的動態連結庫, 這就消除了程式對作業系統的依賴性. 例如 : 作業系統A和作業系統B對於printf的實現機制不同, 程式A如果是採用靜態連結, 那麼就必須分別針對作業系統A和B分佈兩個不同的版本, 如果是採取動態連結就減少了這種麻煩. |
動態連結是否完美無缺
1 |
答案肯定是否. 否則早就把靜態連結淘汰掉了. 由於程式所依賴的某個模組更新後有可能與舊模組之間`介面不相容`, 導致程式無法執行, 崩潰, 這種問題成為`DLL Hell` |
動態連結的基本實現
動態連結是不是直接使用目標檔案(.o檔案)進行連結呢? 理論上可行, 但實際有區別. 由於動態連結的情況下, 程式的虛擬地址空間的分佈會比靜態連結更為複雜, 還有一些儲存管理啊, 記憶體共享, 程式執行緒等機制也會有變化.
Linux系統下, ELF動態連結檔案為動態共享物件(DSO, Dynamic Shared Objects)
, 簡稱共享物件, 一般以.so
為字尾
Windows系統下, 稱為動態連結庫(Dynamical Linking Library)
, 就是我們常見的.dll
為字尾的檔案.
也就是說, 動態連結在這個階段, 實際上是把目標檔案和.so
檔案(或.dll
檔案)進行連結.
What? 為什麼不是把全部.o
檔案連結起來? 實際上動態連結主要工作並不是在連結這個階段做的, 否則跟靜態連結有什麼區別, 何來的動態? 對吧. 其主要工作是在程式被裝載進記憶體的時候. 那麼這個階段的連結有啥用?
還記得連結要完成的三件事情嗎? 地址和空間分配, 符號決議和重定位.
對的沒錯說的就是你 -> 符號決議. 這個.so
動態庫的作用就在於此. 它是用來告訴連結器 : “哥們, 這個符號採取的是動態連結, 在這裡你就別管它地址是多少了, 等程式被裝載進記憶體的時候自然有人負責的啦.”
於是, 連結就這麼結束了, 可執行檔案就這麼被生成了咯. 剩下的動態連結工作在下面裝載的時候由動態連結器完成.
可執行檔案
我們前面說過, 可執行檔案也就是目標檔案, 其實沒什麼不一樣. 略過
裝載
程式想要執行起來, 就必須被裝載進記憶體中才能被CPU排程到.
1 2 3 4 5 6 7 8 9 |
早期程式裝載 : 把整個程式一次性載入到記憶體中, 然後執行. // 現在的遊戲動不動機會幾十G的, 哪來那麼多記憶體資源啊 覆蓋裝入 : 在覆蓋管理器(Overlay Manager)的輔助下, 程式使用到什麼模組就把該模組載入到記憶體中替換掉不需要使用的模組 // 隨著虛擬儲存機制的發明而誕生出一種技術 -> 頁對映 頁對映 : 將記憶體和磁碟中的資料和指令按照"頁(Page)"為單位劃分, 以後所有的裝載, 操作的單位就是頁. x86下頁的大小為4096位元組 |
事實上, 可執行檔案並不是直接與實體記憶體直接對映的, 否則也沒有虛擬記憶體什麼事了對吧. 而且程式直接訪問實體記憶體有幾個壞處 : 地址空間不隔離, 記憶體使用效率低, 程式執行的地址不確定等.. 實際上, CPU發出的Virtual Address經過MMU(Memory Management Unit)轉換成physical Address之後才能訪問實體記憶體
從作業系統的角度看可執行檔案的裝載
建立一個獨立的虛擬地址空間
這一步所做的是虛擬空間與實體記憶體的對映關係. 分配一個頁目錄(Page Directory), 頁對映關係可以等到後面程式發生頁錯誤再設定
讀取可執行檔案, 建立可執行檔案與虛擬空間的對映關係
這一步做的是虛擬空間與可執行檔案的對映關係. 當發生頁錯誤時, 作業系統從實體記憶體中分配一個物理頁, 然後將該”缺頁”從磁碟中讀到記憶體中, 再設定虛擬頁和物理頁的記憶體對映關係. 作業系統捕捉到頁錯誤時, 它應該知道程式當前所需要的頁在可執行檔案中的哪一個位置, 這就是可執行檔案和虛擬空間的對映關係.
將CPU的指令暫存器設定成可執行檔案的入口地址, 啟動執行!
這一步作業系統執行一條跳轉指令跳到可執行檔案的入口地址( 不是main函式, 不是main函式, 不是main函式, 重要的事情說三遍!!! ). 實際上並沒有那麼簡單, 到程式能成功執行還差許多步驟. 這裡只是將這些過程遮蔽了.
VMA
虛擬記憶體中分為許多個段(Segment), 每一個段就成為VMA(Virtual Memory Area). 還記得目標檔案我們說過的段(Section)嗎, 此段非彼段, 這裡我們就說英文吧. 目標檔案中的多個操作許可權相同的Section在這裡要被合併成一個個Segment, 再裝載程式序的虛擬記憶體中. 如圖所示 :
程式執行在記憶體中的的VMA佈局就如圖所示 :
頁錯誤
執行完上面那些步驟, 實際上可執行檔案的真正指令和資料都還沒被裝入到記憶體中. 可執行檔案只是與虛擬記憶體建立了對映關係. 當真正執行指令的時候, 會發現虛擬記憶體中的頁面為空, 這時候就產生頁錯誤(Page Fault)
. 程式將控制權交給作業系統, 作業系統由上面所說Page Directory, 找到空頁面所在的VMA, 計算出相應的頁面在可執行檔案中的偏移(Offset), 然後在實體記憶體中分配一個物理頁面, 將程式中該虛擬頁與物理頁之間建立對映關係, 再把控制權交回給程式, 程式從剛才頁錯誤的位置重新開始執行.
動態連結器
還記得之前連結的時候如果是動態連結的話, 那麼我們會把符號決議和重定位推遲到載入時進行嗎? 如果該程式採取的是動態連結, 那麼可執行檔案裝載完之後, 動態連結器就要閃亮登場了!!!
啟動動態連結器本身
-> 裝載所有需要的動態庫
-> 重定位和初始化
啟動動態連結器本身
動態連結器本身也是一個動態庫, 其他普通動態庫的重定位工作由動態連結器來完成, 那麼動態連結器的重定位又由誰來完成? 它可否依賴於其他動態庫?
這是一個先有雞還是先有蛋的問題
, 為了解決該問題, 動態連結器必須有些特殊 :
1 2 |
1. 動態連結器本身不能依賴於其他任何動態庫 2. 動態連結器本身所需要的全域性和靜態變數的重定位工作由它本身完成 |
這樣, 動態連結器必須在啟動時有一段精巧的程式碼完成這項工作而又不能用到全域性和靜態變數, 這就是自舉(Bootstrap)
.
裝載所有需要的動態庫
完成bootstrap後, 動態連結器把ELF檔案和連結器本身的符號表都合併到一個表中, 稱為全域性符號表(Global Symbol Table)
中. 然後連結器開始尋找ELF檔案所依賴的動態庫, 一個個遍歷下去, 直到所有動態庫都被載入進來.
重定位和初始化
裝載完所有需要的動態庫後, 動態連結器開始重新遍歷ELF檔案和每個動態庫的重定位表, 把每個需要被重定位的位置進行修正.
完成這些工作後, 連結器就可以鬆一口氣, 把程式的控制權交還給程式的入口並且開始執行了.
特殊的動態連結
延遲繫結
我們知道, 有一些函式或者一些使用者比較少用的功能模組, 也許到程式結束執行都不會用到, 那麼如果程式執行的時候也把這些一併連結的話, 這實際是一種浪費, 無用功. 所以才有了這個延遲繫結
: 當函式第一次被用到時才進行繫結(符號查詢, 重定位等), 沒用到則不繫結.
顯式執行時連結(Explicit Run-time Linking)
也叫執行時載入. 也就是讓程式自己在執行時控制載入指定的模組, 並且可以在不需要該模組的時候將其解除安裝. 這種動態庫往往被叫做動態裝載庫(Dynamic Loading Library)
.
這種載入方式對於需要長期執行的程式來說具有很大的優勢, 最常見的便是Web伺服器程式.
執行
上面我們說到, 動態連結器的任務完成之後就會把控制權交回給程式的入口, 那麼這個所謂的程式入口, 是一個什麼傢伙呢? 我們建立一個命令列專案看看
1 2 3 4 5 6 7 |
#include int main(int argc, const char * argv[]) { // insert code here... printf("Hello, World!\n"); return 0; } |
首先, 這個程式入口肯定不是main函式, 你看他還有引數啊哥們!! 那就是別的函式傳給他的玩意!
1 2 |
argc : 儲存的是命令列引數數量 argv : 儲存的是命令列引數字串陣列 |
在執行main函式以前, 程式需要初始化執行環境, 初始化堆疊, I/O, 執行緒等等. 這通通在一個我們稱之為入口函式或入口點(Entry Point)的地方完成. 等初始化之後, 才輪到main函式出場. main函式結束後, 回到入口函式, 進行清理工作, 然後進行系統呼叫結束程式.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 虛擬碼如下 Enrty Point() { if (程式執行){ init run-time environment; // 存在於系統中的一些公用資料, 任何程式都可以訪問, 如系統搜尋路徑, 當前OS版本等. init heap, stack; init I/O; init thread; ... main(argc, argv); // 初始化完畢, 執行main函式 } else if (程式退出) /* 做一些清理工作 */ exit(); // 呼叫系統介面結束程式 } } |
記憶體
終於講到記憶體了, 關於記憶體那就是程式永恆的話題, 各種記憶體管理, 洩露問題讓程式設計師頭疼不已啊…
每個程式記憶體空間內都有以下預設的區域 :
1 2 3 4 5 6 7 |
棧 : 用於維護函式呼叫的上下文, 離開了棧函式呼叫就無法實現. 棧通常在使用者空間的最高地址處分配. 堆 : malloc或new分配的記憶體就在這裡. 堆通常在棧的下方(低地址方向). 可執行檔案映像 : 儲存可執行檔案在記憶體的映像. 保留區 : 對記憶體中受到保護而禁止訪問的記憶體區域的總稱. |
於是有了以下這個經典的程式記憶體佈局圖 :
棧
棧是程式中最重要的概念之一, 沒有棧就沒有函式, 沒有區域性變數, 棧遵循FILO(First In Last Out)
規則.
棧儲存了一個函式呼叫所需要的維護資訊, 稱為堆疊幀(Stack Frame). 堆疊幀一般包括如下幾個方面的內容 :
1 2 3 |
1. 函式的返回地址和引數 2. 臨時變數 : 包括函式的非靜態區域性變數以及編譯器自動生成的其他臨時變數 3. 儲存的上下文 : 包括在函式呼叫前後需要保持不變的暫存器 |
一個堆疊幀用兩個暫存器劃定範圍 : ebp
和esp
.
esp暫存器 : 始終指向棧的頂部
ebp暫存器 : 指向堆疊幀的一個固定位置, 又稱幀指標(Frame Pointer).
函式的返回地址 : ebp-4
壓入棧中的引數地址 : 分別是 ebp-8, ebp-12等示引數的數量和大小而定.
ebp所直接指向的資料是呼叫該函式前ebp的值, 這樣在函式返回的時候, ebp可以通過讀取這個值恢復到呼叫前的值.
i386下的函式呼叫流程
- 把所有或一部分引數壓入棧中, 如果有其他引數沒有入棧, 那麼使用某些特定的暫存器傳遞
- 把當前指令的下一條指令的地址壓入棧中
- 跳轉到函式體執行
其中第2, 3步由指令call一起執行. 虛擬碼如下
1 2 3 4 |
push ebp; // 把ebp壓入棧中(稱為old ebp) mov ebp, esp; // ebp = esp(這時ebp指向棧頂, 此時棧頂就是old ebp) [可選] sub esp, XXX; // 在棧上分配XXX位元組的臨時空間 [可選] push XXX; // 如有必要, 儲存名為XXX暫存器(可重複多個) |
把ebp壓入棧中, 是為了在函式返回的時候便於恢復以前的ebp值. 那為什麼儲存一些暫存器呢, 有一些編譯器可能要求某些暫存器在呼叫前後保持不變. 於是在函式返回時, 程式碼就恰好相反
1 2 3 4 |
[可選] pop XXX; // 如有必要, 恢復儲存過的暫存器(可重複多個) mov esp, ebp; // 恢復esp同時回收區域性變數空間 pop ebp; // 從棧中恢復儲存的ebp的值 ret; // 從棧中取得返回地址, 並跳轉到該位置. |
呼叫慣例
毫無疑問, 函式的呼叫方與函式被呼叫方對函式如何呼叫必須有著相同的理解, 否則將會出現錯亂. 如
1 2 |
Mike : hello, john! 二狗蛋 : 黑龍江? |
這種對函式的約定稱為呼叫慣例(Calling Convention) , 內容如下 :
1 2 3 4 5 6 7 8 9 |
- 函式引數的傳遞順序和方式 1. 通過棧傳輸, 壓棧順序是從左往右還是從右往左? 2. 通過暫存器傳輸, 提高效能. - 棧的維護方式 函式引數pop是由函式呼叫方來完成還是函式本身來完成? - 名字修飾(Name-mangling)的策略 對函式名進行修飾(連結的時候曾講到這個問題, 如foo() -> _foo() ) |
在C語言中預設的呼叫慣例是cdecl
1 2 3 |
引數傳遞 : 從右往左的順序壓引數入棧 出棧方 : 函式呼叫方 名字修飾 : 直接在函式名稱前加1個下劃線 |
下面我們用一個例子來形容這個呼叫慣例.
程式碼 :
1 2 3 4 5 6 7 8 9 10 11 |
void func(int x, int y) { ... return; } int main() { func(1, 3); return 0; } |
流程如下 :
堆
相對於棧而言, 堆更加複雜, 程式隨時可能發出申請記憶體和釋放記憶體的指令, 而申請的記憶體的大小也大小不一. 下面介紹堆的工作原理.
為什麼需要堆? 什麼是堆?
如果只有棧, 那麼函式返回的時候棧上的資料就會全部被pop掉, 無法將資料傳給函式外部. 這樣的話全域性變數則無法動態地產生與銷燬
相對於棧, 堆是一塊巨大的空間, 佔用了程式大多數的虛擬空間. 在這裡, 程式可以自由地申請和釋放記憶體空間. 如 :
1 2 3 |
char *p = (char *)malloc(1000); // 申請1000個位元組的記憶體空間 ... free(p); // 釋放1000個位元組的記憶體空間 |
既然是申請記憶體空間, 那麼這個過程完全可以丟給作業系統去做. “喂! 作業系統, 我這裡需要xxx位元組的記憶體, 快給我分配一下..”, 想象下我們作業系統多程式併發的情況, 這顯然非常低效. 所以應該一次性向作業系統申請一塊適當大小的堆空間. 就像你爸一次性給你一個月的零花錢而不用你每天張手跟他要零花錢一樣.
堆管理
怎麼向堆申請空間呢? 我們知道用malloc函式, 卻不知道其背後做了什麼.
程式的確是通過malloc向堆申請空間, 而我們清楚的知道, 如果每次malloc都向作業系統申請的話很影響效率, Linux下通過mmap()函式向作業系統申請一塊堆空間(Windows下為VitualAlloc() ), 以後malloc時就會從這裡索取需要的空間, 只有這裡的堆空間又不足了, 堆才會向作業系統再申請多一塊堆空間.
記憶體洩漏
假設程式設計師總是向堆申請記憶體空間, 使用完後又不及時釋放(free)掉, 這就會造成記憶體洩漏. 作業系統不會自動回收堆空間, 因為它不知道這一塊記憶體到底是不是有人在用啊. 於是久而久之, 作業系統能用的記憶體空間就會越來越少, 我們就會感到越來越卡. 這個時候往往重啟一下電腦(手機), 這種情況就會改善. 就是這個原因啦.
多執行緒
執行緒相對於程式而言, 其訪問許可權就沒那麼多約束, 一般來說執行緒與執行緒共享整個程式記憶體的所有資料, 執行緒甚至可以訪問其他執行緒的堆疊(比較少見)
執行緒私有
1 2 3 |
區域性變數 函式的引數 執行緒區域性儲存(TLS, Thread Local Storage)資料 |
執行緒之間共享(程式所有)
1 2 3 4 5 |
全域性變數 堆上的資料 函式裡的靜態變數 程式程式碼, 任何執行緒都有權利讀取並執行任何程式碼 開啟檔案, A執行緒開啟的檔案可以有B執行緒讀寫 |
使用者執行緒和核心執行緒之間的關係 :
1 2 3 4 5 6 7 |
一對一 : 好處 : 執行緒之間真正的併發, 一個執行緒阻塞不會影響到其他執行緒 壞處 : 由於作業系統限制了核心執行緒的數量, 所以使用者執行緒的數量也會受到影響, 核心執行緒排程時, 上下文切換開銷大導致使用者執行緒執行效率低 多對一 : 好處 : 高效的上下文切換和幾乎無限制的使用者執行緒數量 壞處 : 由於多個使用者執行緒對應一個核心執行緒, 所以其中一個使用者執行緒阻塞會導致其他執行緒也隨之阻塞 多對多 : 結合了一對一和多對一的優缺點, 折中 |
死亡
程式的生命終究走到了盡頭, 從main函式return之後就回到入口函式處, 回到夢開始的地方, 把所有資源一一釋放掉, 然後轉身走開, 不帶走一片雲彩… 有緣再見~
小知識
API與ABI
1 2 3 4 5 6 7 |
相同點 : 都是應用程式介面 不同點 : > API是原始碼層面的介面, 而ABI是二進位制層面的介面 > API相同不等於ABI相同 (例如, 同樣一個printf函式, 在不同的系統中的底層實現有可能不一樣) ps : 二進位制相容(ABI相容)實現相當困難 |
編譯器優化 和 CPU的動態排程換序
編譯器優化
即便是短短的一條x++;
的程式碼, 翻譯成組合語言之後也是需要幾條來執行. 那麼編譯器為了優化程式碼, 有時候會將一個變數快取到暫存器而不立即寫回(時間區域性性原理), 又或者調整這些指令的順序… 所以多執行緒下沒有絕對的安全(上鎖也不例外)
CPU的動態排程換序
CPU的為了優化有時也會亂序執行程式碼, 也就是說不是一行接著一行執行程式碼, 那麼在一些情況下也會有安全漏洞, 例如單例模式下, 有可能取到的是一個未初始化的物件.
解決辦法 :
1 2 3 |
編譯器優化 : 使用volatile關鍵字 CPU的動態排程換序 : 使用barrier |
變長引數
我們最熟悉不過的帶變長引數的函式就是int printf(const char *format, ...);
我們知道, 除了第一個引數外, 還可以追加任意數量, 任意型別的引數.
我們用一個簡單的函式來說明這種變長引數的實現原理. 如 : int sum(int num, ...);
當我們呼叫 int n = sum(1, 3, 5, 7);
時, 按道理我們只能用num來訪問1這個引數, 其他引數訪問不了. 但是多虧了C語言預設的cdecl呼叫慣例的自右向左壓棧的傳遞方式. 此時函式內部的堆疊如下 :
由於其他的幾個引數在num的高地址方向, 所以我們可以間接利用num來訪問其他的那幾個引數. 而printf函式接收的引數變數型別不一致, 所以比這個要複雜得多得多
格式化
對於一些人來說, 對硬碟格式化就是硬碟的資料全部都沒啦..
這種觀點完全錯誤!!! 硬碟上裝的是什麼? 0和1的序列.. 實際上作業系統是根據一張表, 在表中找到你要找的檔案在硬碟中的具體位置, 然後再到硬碟中訪問.
格式化的本質就是把這張表的內容全擦除掉, 這樣作業系統就忘記了你的檔案放在哪裡了. 儘管檔案還是在原來的地方, 他也找不到了.
現在有種方法就是格式化就把這些區域全部用0或者用1填充.
所以大家的SD卡啊, 硬碟啊, 寧願破壞再扔掉也不要輕易交給別人啊!!!
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式