很早就聽過這本書的介紹,每次想靜下心來研讀的時候總會被一些瑣事打斷。這段時間比較空閑,正好把這把咀嚼一下。
這本書詳細描述了Windows和Linux操作系統(tǒng)各自的可執(zhí)行文件、目標文件格式。
- C/C++代碼如何被編譯成目標文件及程序在目標文件中文件存儲。
- 目標文件如何被鏈接器鏈接到一起,并且形成可執(zhí)行文件的。
- 目標文件在鏈接時符號處理、重定位及地址分配如何進行
- 可執(zhí)行文件如何被裝載并且執(zhí)行
- .....
- 什么是堆,什么是棧
- ......
下面開始探索之旅吧!
操作系統(tǒng)基礎(chǔ)回顧
溫故而知新,可以為師
下圖為全文的導(dǎo)圖
組成部分
計算機三個最為重要的部件:中央處理器CPU、內(nèi)存、I/O控制芯片。
早起計算機因為核心頻率不高所以設(shè)備都是連在同一條總線上,并且每個設(shè)備都還有一個響應(yīng)的I/O控制器。
后來核心頻率提升,內(nèi)存跟不上CPU的速度,于是產(chǎn)生了與內(nèi)存頻率一致的系統(tǒng)總線,CPU采用倍頻的方式與系統(tǒng)總線通信。再到后來出現(xiàn)了北橋芯片(協(xié)調(diào)高速芯片),南橋芯片(協(xié)調(diào)低速設(shè)備)
北橋芯片:協(xié)調(diào)CPU、內(nèi)存、高速的圖形設(shè)備,以便高速的交換數(shù)據(jù)
南橋芯片:協(xié)調(diào)磁盤、USB、鍵盤、鼠標等慢速設(shè)備
上圖可見,位于中間的是連接所有高速芯片的北橋(PCI),他的左邊是CPU,右邊是內(nèi)存。
系統(tǒng)軟件可以分為兩塊,一塊是平臺性的,比如操作系統(tǒng)內(nèi)核,驅(qū)動程序,運行庫和數(shù)以千計的系統(tǒng)工具;另一塊是用于程序開發(fā)的,比如編譯器、匯編器、鏈接器、開發(fā)庫和開發(fā)工具。這里主要介紹的是鏈接器及庫相關(guān)的內(nèi)容。
無論是在計算機軟件體系還是硬件體系有一句至理名言:計算機科學(xué)領(lǐng)域的任何問題都可以通過一個間接的中間層來解決。基本上這菊花概括了所有的設(shè)計要點。(想想我們平時所用的什么架構(gòu)、模式都是這個道理)
可以把這種方式叫做層次體系,相鄰的層次通過定義接口實現(xiàn)通訊,一般是下面那層是接口的提供者,上層使用接口(對應(yīng)到軟件開發(fā)中也就是面向接口編程,這套在Java中的Spring框架體現(xiàn)得淋漓盡致)。接口都是被精心設(shè)計過的,盡量保證穩(wěn)定不變,這樣對上層屏蔽了具體實現(xiàn),任何一層次能都可以被修改或者被替換。
整體體系結(jié)構(gòu)依賴關(guān)系:上次應(yīng)用層程序——》操作系統(tǒng)應(yīng)用程序編程接口——》運行庫——》系統(tǒng)調(diào)用接口(以軟件終端的方式)——》硬件接口
- 運行庫API:Linux下的GLibc庫提供的POSIX的API,Window運行庫提供Windows API,比如Win32
- 系統(tǒng)調(diào)用接口:系統(tǒng)調(diào)用接口在實現(xiàn)中以一般軟件中斷的方式提供,Linux中以0x80號中斷作為系統(tǒng)調(diào)用接口,Winodows以0x2E作為系統(tǒng)調(diào)用接口
硬件的接口定義決定操作系統(tǒng)內(nèi)核,確定驅(qū)動程序如何操作硬件,如何與硬件通信,這種接口叫做硬件規(guī)格,硬件廠商負責(zé)提供硬件規(guī)格,操作系統(tǒng)及驅(qū)動程序的開發(fā)者通過閱讀硬件規(guī)格,文檔來編寫。
操作系統(tǒng)主要是提供抽象的接口以及管理硬件資源(大學(xué)的時候老師講過)。
CPU進化過程:CPU只能運行一個程序——》多道程序(監(jiān)控CPU是否空閑)——》分時系統(tǒng)(每個程序都有機會運行一小段時間,任何一個死循環(huán)都可能導(dǎo)致死機)——》多任務(wù)系統(tǒng)(操作系統(tǒng)管理硬件資源,運行在硬件保護的級別。)
多任務(wù)系統(tǒng)中所有應(yīng)用程序都以進程的方式運作,但是比操作系統(tǒng)的權(quán)限更低,每個進程有自己獨立的地址空間,進程之前的地址空間相互隔離。CPU由操作系統(tǒng)統(tǒng)一分配,根據(jù)進程優(yōu)先級調(diào)度,如果進程占用CPU太久的時間則會被暫停,進而分配給其他等待運行的進程。分配給每個進程的時間都很短,CPU在多個進程間快速切換,造成了多個進程同時運行的假象。
內(nèi)存、分段、分頁(這個很重要!)
早期計算機中,程序是直接運行在物理內(nèi)存的。比如計算機128M,程序A10M,程序B100M,程序C20M。那么最直接的就是將內(nèi)存的前10M給A,10M-110M給B。但是這種方式有很多問題:
- 地址空間不隔離(非常危險):直接訪問物理地址,惡意程序很容易改寫其他程序的內(nèi)存數(shù)據(jù);除此之外如果程序有bug,同樣會出現(xiàn)不小心改了其他程序的數(shù)據(jù)現(xiàn)象。這樣就非常不安全,穩(wěn)定
- 內(nèi)存使用率低:比如上面例子想要運行C,這個時候內(nèi)存空間已經(jīng)不足了,這個時候需要把其中部分數(shù)據(jù)寫到磁盤上,等到要用的時候在讀回來。由于程序空間是連續(xù)的,將A寫到磁盤還是不夠用還需要將B程序到磁盤。中間有大量數(shù)據(jù)的換入換出
- 程序運行地址不確定:程序每次載入運行的時候,需要從內(nèi)測中分配一塊足夠大的空間,但是這個無內(nèi)置是不確定會給編寫程序帶來麻煩。因為編寫程序的時候,訪問數(shù)據(jù)、指令的目標地址很多是固定的,需要重定位。
以上幾個問題通過增加了一個中間層解決,也就是間接訪問地址。將程序給出的地址看做是虛擬地址,然后通過映射關(guān)系,將虛擬地址轉(zhuǎn)換為物理地址。只要能夠管理好映射過程就能保證程序之間的內(nèi)存區(qū)域不會重疊,達到地址空間隔離的效果。——虛擬內(nèi)存
分段用來解決前面提到的地址空間不隔離和程序運行地址不確定。基本思路是把一段與程序所需要的內(nèi)存空間大小的虛擬空間映射到某個地址空間,比如A程序10M,0X00000000到0X00A00000的10M虛擬空間,然后物理地址分配同樣大小的區(qū)域,比如0x00100000到0x00B00000。然后將這兩塊相同大小的地址做空間一一映射,操作系統(tǒng)提供這個映射函數(shù),實際由硬件轉(zhuǎn)換,后面會提到具體是由MMU(內(nèi)存管理單元)實現(xiàn)。
比如A中訪問0x00001000,CPU會將這個二地址轉(zhuǎn)換為實際物理地址的0X00101000.
異常情況處理:雖然分段做到了地址隔離,A和B沒有任何重疊,但是如果A訪問虛擬地址空間超過了0x00A00000范圍,那么硬件就會判斷這是一個非法訪問,拒絕這地址請求,并將這個請求報告給操作系統(tǒng)或監(jiān)控程序,讓它們進行下一步處理,一般就是產(chǎn)生異常。
分段也做到了程序不需要重定位,對程序而言不需要關(guān)系物理地址變化,只需要安裝從地址0X00000000到0X00A00000l來編寫程序。但是還是沒有解決內(nèi)存使用效率的問題。如果內(nèi)存不足還是會造成很大的換入換出(以程序為單位,粒度還是太大)
根據(jù)程序局部性原理,當程序運行時,某個時間段內(nèi),它只會頻繁的使用到一小部分數(shù)據(jù),也就是說很多數(shù)據(jù)其實并不會被使用到,于是粒度更小的內(nèi)存分隔和映射方法孕育而生,那就是分頁。
分頁是把地址空間人為的分成固定大小的頁,頁大小范圍由硬件決定,其次由操作系統(tǒng)最終確定頁大小。
舉個例子:
把進程的虛擬空間地址以頁分隔,數(shù)據(jù)和代碼也裝載到內(nèi)存,不常用的放到磁盤保存,需要的時候在從磁盤中讀取處理。默認情況下虛擬也大小為4k。
- 有兩個進程Process1、Process2
- Process1中虛擬頁VP0、VP1、VP7映射到物理頁PP0、PP2、PP3
- Process2中虛擬頁VP0、VP2、VP3、VP7映射到物理頁PP5、PP0、PP1、PP3
- 虛擬VP2、VP3頁面保存在磁盤頁中的DP0、DP1,并不在內(nèi)存中,當進程需要這兩個也的時候,硬件會捕獲這個信息,就是所謂的頁錯誤,然后操作系統(tǒng)接管進程,負責(zé)將VP2、VP3從磁盤中載入到內(nèi)存。
- 可以看到到物理頁中的PP0、PP3在進程Process1、Process2都有虛擬頁映射到其中。這樣就實現(xiàn)了內(nèi)存共享
虛擬內(nèi)存實現(xiàn)需要依賴硬件支持,不同CPU處理方式不同,但是所有的硬件都采用MMU(內(nèi)存管理單元)部件來進行頁映射管理。MMU將CPU發(fā)出的虛擬地址轉(zhuǎn)換為物理地址。
多線程
線程被稱為輕量級進程,程序執(zhí)行流的最小單元。線程由線程ID,當前指令指針,寄存器和堆棧組成。進程由多個線程組成,各個線程之間共享內(nèi)存空間(代碼段、數(shù)據(jù)段、堆)及進程級別的資源(打開文件和信號)。
進程、線程關(guān)系圖:
線程訪問非常自由,可以訪問進程內(nèi)存中的所有數(shù)據(jù),甚至包含其他線程轉(zhuǎn)給你的堆棧(如果知道其他線程的堆棧地址,但是很少見),線程也擁有自己的私有存儲空間。
- 棧:雖然不是被其他線程完全無法訪問,但是一般可以認為是私有數(shù)據(jù)
- 線程局部存儲(TLS):操作系統(tǒng)為線程單獨提供的私有空間,但是很有限
- 寄存器:包括PC,寄存器是執(zhí)行流的基本數(shù)據(jù),為線程私有。
可以總結(jié)如下表
線程總是并發(fā)
的執(zhí)行,線程數(shù)小于等于處理器數(shù)量,線程的并發(fā)才是真真的并發(fā)(同一時刻,多個進行),不同線程運行在不同的處理器上;當大于處理器數(shù)量,此時會出現(xiàn)一個處理器運行多個線程的情況。
單處理器情況下,多線程并發(fā)是一種模擬出來的狀態(tài),操作系統(tǒng)讓多線程輪流執(zhí)行,每次僅僅執(zhí)行很小一段時間,這稱作線程調(diào)度。
線程調(diào)度至少有三個狀態(tài):
- 運行:線程正在執(zhí)行。
- 就緒:線程可以立刻運行,但CPU被其他線程占用。
- 等待:線程在等待某一個事件(比如:I/O或者同步)發(fā)生,無法執(zhí)行。
運行狀態(tài)下的線程執(zhí)行時間叫做時間片,
- 時間片用盡了就進入就緒狀態(tài);——用盡
- 如果時間片用盡之前線程就開始等待某事件,則會就進入等待狀態(tài);——事件
- 線程離開運行態(tài),操作系統(tǒng)就會調(diào)度其他就緒的線程執(zhí)行;
- 在等待狀態(tài)的線程等待的事件發(fā)生之后,線程就進入就緒狀態(tài);
狀態(tài)切換如下圖:
主流的調(diào)度策略雖然不同但是都有優(yōu)先級調(diào)度及輪轉(zhuǎn)法。
- 輪轉(zhuǎn)法:讓各個線程輪流執(zhí)行一小段時間的方法,線程之間交錯執(zhí)行
- 優(yōu)先級調(diào)度:確定線程按照什么順序輪流執(zhí)行難,線程都有自己的優(yōu)先級,具有高優(yōu)先級的會更早的執(zhí)行,低優(yōu)先級的需要等待沒有高優(yōu)先級線程存在的時候才執(zhí)行。比如Linux中就是通過pthread來實現(xiàn),iOS中也是
線程可以只定義優(yōu)先級,系統(tǒng)也會根據(jù)線程執(zhí)行狀態(tài)改變線程的優(yōu)先級。頻繁進入等待狀態(tài)的線程(處理I/O)比頻繁進行大量計算(每次把時間片用盡的線程)有更多的機會執(zhí)行,因為頻繁等待的線程只占用很少的時間片,CPU喜歡先執(zhí)行簡單的。
如果一個線程一直都得不到執(zhí)行,這就是餓死現(xiàn)象。優(yōu)先級較低,總有較高優(yōu)先級在占用CPU,為了避免這種現(xiàn)象,調(diào)度系統(tǒng)會逐步提升等待時間過長卻得不到機會執(zhí)行的線程優(yōu)先級。
優(yōu)先級改變觸發(fā)條件:
- 用戶指定優(yōu)先級
- 根據(jù)線程進入等待狀態(tài)的頻率提升或降低優(yōu)先級
- 長時間得不到執(zhí)行而被提升優(yōu)先級
搶占:線程在用盡時間片之后被強制剝奪執(zhí)行的權(quán)利進入就緒狀態(tài),這個過程叫做搶占,這樣的線程就是搶占線程。目前基本所有的線程都是搶占式的。
不可搶占線程:也就是線程不可被搶占,只有線程主動發(fā)出放棄執(zhí)行的命令,主動進入就緒態(tài),而不靠時間片才會空出當前占用的資源。
不可搶占線程觸動放棄情況:
- 線程視圖等待某事件
- 線程主動放棄時間片
雖然不可搶占線程可以避免一些因為搶占線程里調(diào)度時機不確定而產(chǎn)生的線程安全問題,但是現(xiàn)在非搶占式線程很少。
Linux多線程
Linux內(nèi)核中并不存在真正的線程概念,Linux將所有的執(zhí)行實體(線程還是進程都一樣)都稱為任務(wù),每一個任務(wù)都類似于一個單線程的進程,具有內(nèi)存空間,執(zhí)行實體,文件資源等。
進程:不同任務(wù)之間可以選擇共享內(nèi)存空間,共享同一個內(nèi)存空間的多任務(wù)構(gòu)成一個進程,這些任務(wù)也就是這個進程里面的線程。
Linux創(chuàng)建一個新的任務(wù)的方式:
fork函數(shù)產(chǎn)生一個和當前進程完全一樣的新進程,fork產(chǎn)生的新任務(wù)速度非???,因為不會復(fù)制原任務(wù)的內(nèi)存空間,而是共享一個寫時復(fù)制的內(nèi)存空間。
寫時復(fù)制就是指兩個任務(wù)可以同時自由得讀取內(nèi)存,但任意一個任務(wù)要對內(nèi)存修改,內(nèi)存就會賦值一份給修改方使用。fork只能產(chǎn)生本任務(wù)的鏡像,所以需要用exec才能啟動新的任務(wù)。
線程安全(開發(fā)中經(jīng)常遇到的)
多線程程序中,可訪問的全局變量及堆數(shù)據(jù)隨時都可能被其他線程改變,由此產(chǎn)生了的線程安全。
線程安全的根本原因是同時寫一個共享數(shù)據(jù),每個線程都有自己的寄存器。
計算機中單條指令在執(zhí)行的時候不會被打斷,所以在執(zhí)行單條指令的時候不會存在線程安全問題,稱為具有原子性;常見的自增++操作,因為自增++操作會被匯編為多條指令,所以在執(zhí)行自增的時候可能被打斷,去執(zhí)行其他代碼,不具有原子性,就有可能造成線程安全的問題
一個例子:
其中的i為共享變量。
在計算機中會按照下面的步驟
- 讀取i到某個寄存器X(每個線程有自己的寄存器)
- X++
- 將X的內(nèi)容返回給i(也就是從寄存器取值然后送入內(nèi)存)
由于1、2線程是并發(fā)執(zhí)行,隱藏坑出現(xiàn)如下的執(zhí)行序列(每個線程有自己獨立寄存器)
如果按照正常邏輯來看i的值應(yīng)該為1。但是通過上面的執(zhí)行順序最后的結(jié)果是0,所以出現(xiàn)了問題。實際上可能會是0或者1或者2。i的最終值取決于最終是從哪個線程中賦值的,也就是最終是由哪個線程的寄存器回寫到內(nèi)存中的。
同步與鎖
所謂同步就是指在一個線程訪問數(shù)據(jù)未結(jié)束的時候其他線程不能對同一數(shù)據(jù)訪問。最常見的同步方式就是使用鎖,分為加鎖和解鎖過程,在加鎖之后其他線程需要等待解鎖之后才能訪問資源。
其次還有二元信號量,也即是占有和非占有兩個狀態(tài),如果允許對個線程并發(fā)訪問資源,多遠信號量簡稱信號量。給定一個初始值為N的信號量則允許N個線程并發(fā)訪問。具體來講:
- 信號量值減去1
- 如果信號量值小于0則進入等待狀態(tài),否則繼續(xù)執(zhí)行。訪問完資源之后,線程釋放信號量
- 將信號量加1
- 如果信號量的值大于1,則喚醒一個等待中的線程
互斥量和二元信號量類似,資源同一時刻只能一個線程訪問,和信號量不同的是,信號量在整個系統(tǒng)可以被任意線程獲取并釋放,而互斥量只能在哪個線程獲取的,也就只能在獲取的那個線程釋放,跨線程釋放是無效的?!我饩€程
臨界區(qū)是比互斥量更加嚴格的同步方式,臨界區(qū)與信號量大而區(qū)別在于,互斥量和幸好量在系統(tǒng)的任何進程里都是可見的,也即是一個進程創(chuàng)建了互斥量或信號量,其他進程視圖去獲取該鎖是合法的,臨界區(qū)作用范圍僅僅限于本進程,其他進程無法獲取該鎖,除此之外臨界區(qū)和互斥量具有相同的性質(zhì)?!我膺M程
條件變量同樣是一個同步的手段,作用類似于柵欄。線程對條件變量有兩種操作方式,一種是條件變量可以被多個線程等待;另一種是線程可以喚醒條件變量,此時所有等待此條件變量的線程都會被喚醒。也就是條件變量可以讓許多線程一起等待某個事件的發(fā)生。當事件發(fā)生時(條件變量被喚醒),線程繼續(xù)執(zhí)行——柵欄、多個線程
函數(shù)被重入表示這個函數(shù)沒有執(zhí)行完成,又進入 了該函數(shù)的執(zhí)行。在多線程同時執(zhí)行這個函數(shù)或者函數(shù)自身調(diào)用自己就會出現(xiàn)重入現(xiàn)象。如果函數(shù)被重入之后不會產(chǎn)生任何不良后果函數(shù)被稱為具有可重入性。
比如:
類似這樣的函數(shù),沒有使用任何局部靜態(tài)或全局的變量、沒有依賴調(diào)用方提供的參數(shù)、不調(diào)用任何不可重入的函數(shù),是線程安全的。
在開發(fā)中,有時候即使使用了鎖也不一定能保證線程安全。這是因為落后的編譯技術(shù)無法滿足增長的并發(fā)需求。這樣會導(dǎo)致很多看似不合理的情況!
比如:
從代碼上來講有了lock和unlock的包含,x++的是原子性的,那么值似乎必然是2,但是如果編譯器為了提高x的訪問速度,把x放到某個寄存器里,我們知道不同線程的寄存器是獨立的,因此Thread1先獲得鎖,則程序執(zhí)行可能會出現(xiàn)如下情況
可見這樣并不能達到線程安全,原因就是在R1++之后沒有立即回寫到內(nèi)存中,而是緩存在寄存器中,過了一段時間才回寫到內(nèi)存中。**
另一個例子
邏輯上講r1、r2至少有一個為1,不可能同時為0。但是這種同時為0的情況確實存在。因為CPU有動態(tài)調(diào)度的特性,在執(zhí)行程序的時候為了提高效率有可能交換指令的順序,同樣編譯器在優(yōu)化的時候也可能為了效率而交換毫不相干的相鄰指令如x=1、r1=y的執(zhí)行順序。
上面的代碼可能就變成了:
這個時候就可能出現(xiàn)r1=r2=0,關(guān)鍵詞volatile(C語言中)可以阻止這種過度優(yōu)化。它可以實現(xiàn)兩件事:
- 阻止編譯器為了提高速度將一個變量緩存在寄存器內(nèi)不回寫到內(nèi)存中。
- 阻止編譯器調(diào)整操作volatile變量的指令順序。
CPU動態(tài)調(diào)度換序
CPU動態(tài)調(diào)用換序也算是過度優(yōu)化的范疇。
源代碼:
從邏輯上講是沒有問題的,函數(shù)返回時,Pinst總是指向一個有效的對象,同時也加了鎖。其實有問題,主要是因為CPU亂序執(zhí)行,C++中的new包含了兩個步驟
- 分配內(nèi)存
- 調(diào)用構(gòu)造函數(shù)
那么Pinst = new T其實包含了三個步驟
- 分配內(nèi)存
- 調(diào)用構(gòu)造函數(shù)
- 將內(nèi)存地址復(fù)制給PInst
其中2和3順序可以顛倒。產(chǎn)生的問題就是這個時候PInst雖然不是NULL,但還沒構(gòu)造完成,這個時候如果另一個線程滴啊用了GetInstance,那么就會直接返回PInst,但是這個地址是并沒有構(gòu)造完全的對象地址。如果后面使用了這個沒有被構(gòu)造完成的毒性地址,那么會發(fā)生異常現(xiàn)象
所以需要阻止CPU換序,但是并不存在這樣的方法。而是通過一條 指令解決,指令叫做barrier,barrier會阻止CPU將之前的指令交換到barrier之后,相當于一個柵欄。
改進后的代碼如下
通過barrier保證了對象構(gòu)造一定是在barrier之前完成的,那么PInst被復(fù)制的時候?qū)ο笫峭暾摹?/p>
這種情況什么時候會出現(xiàn),目前我也沒遇到過類似的場景。
多線程內(nèi)部
這里涉及到內(nèi)核線程與用戶線,一些輕量級的線程,對用戶而言如果有三個線程同時執(zhí)行,對內(nèi)核而言可能就只有一個線程。
一對一的線程模型最為簡單,一個用戶線程對應(yīng)一個內(nèi)核線程(但是內(nèi)核態(tài)的線程不一定在用戶太有對應(yīng)的線程)。這樣方式實現(xiàn)了真真的并發(fā),一個線程阻塞不會應(yīng)影響其他線程。但是也有缺點:
- 許多操作系統(tǒng)限制了內(nèi)核線程的數(shù)量,一次一對一的線程會讓用戶線程受到限制
- 許多操作系統(tǒng)內(nèi)核線程調(diào)度時,上下文切換開銷比較大,導(dǎo)致用戶線程的執(zhí)行效率下降
除了一對一還有多對一的線程模型
多對一將多個用戶線程映射到一個內(nèi)核線程上,線程之間由用戶態(tài)的代碼來驚醒,因此對弈一對一的線程模型而言,多對一在線程切換的時候就快很多。多對一的問題:
- 最大問題就是一旦其中一個用戶線程被阻塞了,那么所有的線程都無法繼續(xù)執(zhí)行,因此內(nèi)核里面的線程也被阻塞;最大的好處就是線程的上下文切換和無限制的線程數(shù)量
多對多的線程模型結(jié)合了一對一,多對一的特點。將多個用戶線程映射到不知一個內(nèi)核線程上。
一個線程阻塞并不會影響其他線程,而且也沒有用戶線程數(shù)量的限制。
小結(jié)
第一篇就到這里了,這一節(jié)主要是復(fù)習(xí)操作系統(tǒng)層面的知識。其中比較難(現(xiàn)在也沒弄明白)就是CPU的動態(tài)調(diào)度。不知道有沒有大咖懂的。
擴展閱讀
下面這幾篇都講得比較深入,比如關(guān)于虛擬地址、進程、線程等??梢钥匆豢矗?/p>
Linux虛擬地址空間布局
Linux 中的各種棧:進程棧 線程棧 內(nèi)核棧 中斷棧
Linux下Fork與Exec使用
Linux虛擬地址空間布局以及進程棧和線程??偨Y(jié)