(點選上方公眾號,可快速關註)
來源:MRRiddler ,
blog.mrriddler.com/2017/05/01/計算機體系-行程與虛擬儲存器/
上篇講述了程式的編譯體系。然而,經過編譯體系後,程式離乖乖執行還有很遠的路要走,這篇文章繼續伴隨我們寫的程式,來看看what’s beyond the scene。
裝載
程式要想乖乖執行,其資料和指令都要在記憶體中。那麼,經過編譯體系後,就是裝載了。當然,裝載不可能只是一口氣將可執行檔案載入記憶體這麼簡單。一般作業系統先要建立一個與程式對應的行程(僅創立虛擬空間,但還未分配,物理空間更沒有分配了),然後將控制權交由行程,CPU讀取可執行檔案的入口地址,才開始執行。接下來聊聊行程。
行程(Process)
在寫這部分的時候重溫了在大學時代看的現代作業系統時候,看到了自己記下的講述行程的筆記—-行程是具有獨立功能的程式關於某個資料集合上的一次執行的活動…這都是什麼鬼,我當時在學啥???(艹皿艹 )
實際上啥是行程?就是一段程式。那麼行程的主旨是啥?主旨在為這段程式提供一個單一的環境,這個單一的環境讓程式好好執行就好,不要管環境外的事。其中,環境包括CPU和記憶體。CPU指的就是行程在佔用CPU執行的時候,造成了沒有別的行程存在的假象,即行程是獨佔CPU的。記憶體指的是行程的地址空間,行程之間的地址空間相互隔離,行程無法訪問到其他行程的空間。怪不得說,要理解行程,能理解到行程是CPU的抽象就到點上了。
接著我們來看一下行程的地址空間,這就是行程“實際”的樣子。
Linux行程地址空間
Linux行程地址空間如下圖所示:
—— —— —— —— —— —— —— high
|kernel space |
|—— —— —— —— —— —— —— |
|stack |
|—— —— —— —— —— —— —— |
|….. |
|—— —— —— —— —— —— —— |
|dynamic libraries |
|—— —— —— —— —— —— —— |
|….. |
|—— —— —— —— —— —— —— |
|heap |
|—— —— —— —— —— —— —— |
|….. |
|—— —— —— —— —— —— —— |
|R/W .data .bss |
|—— —— —— —— —— —— —— |
|R .text .rodata .init|
|—— —— —— —— —— —— —— |
|reserved |
|—— —— —— —— —— —— —— | low
最高地址先是作業系統核心空間。然後是棧,棧底部放置系統環境變數和命令列引數,比如main函式中的argc和argv,分別代表命令列符號和數量,就放在這裡。然後是動態連結庫空間。然後是堆。然後是可讀寫的.data和.bss,已初始化和未初始化的資料段。然後是隻可讀的程式碼段、可讀資料段、初始化段。最後是保留段,保留段空間被認為是禁止訪問的無效空間,比如說空指標指向的0×00,就屬於保留段。可以看到,棧是向低地址方向增長,堆是向高地址方向增長。越往高地址,越是作業系統空間,除了保留空間,越往低地址越是使用者空間。
虛擬儲存器(Virtual-Memory)
虛擬儲存器又被譯作虛擬記憶體,CSAPP中被譯作虛擬儲存器(我更加偏向後者)。它在程式中訪問儲存器這一過程中扮演了舉重若輕的角色。它主要做了兩件事:
-
將物理空間抽象成虛擬空間,這裡指的物理空間專指記憶體上的空間。
-
管理儲存器體系,包括記憶體和磁碟。
雖然這兩件事互相之間藕斷絲連,但咱們還是儘量一件事一件事來聊。
第一點,將物理空間抽象成虛擬空間。我們平時寫程式包括行程訪問的都是虛擬地址,而CPU訪問的才是物理地址。那為什麼要向用戶將物理空間抽象成虛擬空間呢?
-
解放物理空間的儲存管理。這樣一來,記憶體分配的管理方式和其分配的內容可以毫無關聯。也就是說,資料被分配的地址與邏輯上程式執行的背景關係毫無關係。
-
提供行程之間的地址空間隔離。如果直接使用物理地址,是不可能用硬體手段阻止行程訪問某個區域的儲存器的。而使用虛擬地址就可以很簡單的偵測出行程訪問地址是否合法,是否越界。
-
多個虛擬空間中的地址可以對映同一物理空間中的地址。這也是可以使動態連結庫共享的本質。
-
簡化連結階段分配地址空間。如果使用物理地址,連結階段程式碼都還未載入記憶體,不可能分配地址空間。而使用虛擬空間將行程之間的地址空間隔離後,直接分配固定地址就可以了。比如,.text段被分配到0×08048000(對於32位)或0×400000(對於64位)。
這裡可以看出,連結、裝載、行程、虛擬儲存器之間環環相扣,一同支撐起計算機體系。
第二點,管理儲存器體系。
虛擬儲存器管理儲存器體系只基於一個思想,記憶體是磁碟的快取。實際上,在儲存器體系中,高一級的儲存器必定是低一級的儲存器的快取。而虛擬儲存器劃分頁來管理記憶體和磁碟構成的儲存器體系。
頁(Page)
為什麼要分頁。假設,現在計算機要執行一個在磁碟上的程式,而記憶體中已經被其他程式佔據,空間不夠了,這時候怎麼辦?只能將其中的一個程式置換到磁碟上,然後將要執行的程式從磁碟置換到記憶體,來執行程式。若是這個程式佔的空間很大,比如說1/2記憶體大小,那麼現在CPU又要在這兩個程式之間切換執行。記憶體就要一直置換1/2大小的空間,可想而知程式就不要執行了,一直置換玩吧,這實際上是就是記憶體使用效率低下。
這時我們再重新思考一下,根本不需要整個程式都在記憶體中,只要執行的那部分在就可以了。所以,很自然的將程式減小粒度,劃分固定地大小來置換空間就可以了,這樣就提升了記憶體的使用效率。更別說,基於區域性性原理這個頁的劃分方式還很高效呢。
頁的屬性、頁表(Page-Table)
從頁開始,虛擬儲存器做的這兩件事相連的地方就會慢慢呈現出來。在物理空間中劃分好了頁,虛擬空間中也要劃分好頁與之對應。被劃分好的頁可以未被分配、已分配、已快取,這可以看做成頁的一個屬性。頁面未被分配,就是頁面還未關聯任何資料,頁面還未被用到。頁面已被分配就是已被關聯資料,但是資料還在磁碟上,未被快取到記憶體中,虛擬頁(Virtual Page)特指的就是這樣的頁。而頁已被快取,就是資料已經被快取到記憶體中,物理頁(Physcial Page)特指的就是這樣的頁。
頁表就是一個記錄頁的屬性和頁虛擬空間與物理空間對映關係的資料結構。頁表用一個有效位(valid bit)就記錄了頁的屬性,有效位為1代表頁已被快取,有效位為0且頁表項不為空代表頁已被快取,有效位為0且頁表項為空代表頁未被分配。對於已被快取的頁,頁的虛擬空間直接對映到物理空間。
就像上面記憶體中滿了後執行磁碟上的程式猜測的一樣,當去頁表中查詢到所需頁面未被快取到記憶體中,作業系統會發起硬體中斷,將所需頁快取到記憶體中,然後重新發起頁表查詢動作,這一過程就是缺頁中斷。如果記憶體不足,這時還要淘汰頁面以釋放空間。淘汰頁面有多種演演算法,這裡就不細聊了。缺頁中斷很好理解,很有趣的一點是,管理儲存器系統時,頁面從不會主動快取到記憶體中,就有被用到時,才會透過缺頁中斷快取到記憶體。
而malloc和calloc就是從側面證明瞭這一點,malloc和calloc只是提供一塊線性的虛擬空間,而物理空間上沒做任何分配。等到真正使用虛擬空間的時候,才會缺頁中斷,實際分配物理空間。
Copy-On-Write
除了malloc和calloc,行程的Copy-On-Write也與虛擬儲存器息息相關。fork產生子行程後,子、父行程擁有不同的虛擬空間,但是對映同一物理空間。並且子、父行程都對物理空間只有隻讀許可權,等到子行程或者父行程要修改物理空間,會觸發無許可權的缺頁中斷。這時,作業系統才將真正複製物理空間,使子、父行程擁有自己的物理空間。 這就是Copy-On-Write,write lead to copy。
再看裝載
文章開頭聊裝載的時候,實際上忽略瞭如何真正的“載入”可執行檔案。在聊過缺頁中斷後,再來看看裝載。因為缺頁中斷,極大的簡化了裝載。裝載不需進行任何的顯式資料IO,全部交給缺頁中斷來真正的“載入”。裝載不用將可執行檔案快取到記憶體,而是等到指令跑到記憶體中無快取的虛擬地址觸發缺頁中斷。裝載只需要提供出可執行檔案與虛擬空間的對映就好。裝載會將對映記錄在行程的vma(Virtual Memory Area)資料抽象中。
從虛擬空間來看,行程建立後,虛擬空間也被建立。此時,虛擬空間是未分配的,裝載將對映記錄在行程的vma中,就是將虛擬空間從未分配轉換為已分配。等到缺頁中斷,再將虛擬空間從已分配轉換到已快取。
虛擬地址翻譯(Translation)成物理地址
將虛擬地址翻譯成物理地址,是由作業系統軟體、CPU中的硬體MMU(memory management unit)、頁表一起協同做到的。
如何翻譯呢?最簡單的方法就是記錄每一個虛擬地址到物理地址的對映,缺陷很明顯,要維護一個與地址空間同樣大的表,時間複雜度、空間複雜度都不理想。
這裡,我們來回想一下,頁表已經以頁為單位映射了虛擬空間到物理空間。那麼,地址可分為頁號+頁內offset兩部分,只要保持虛擬空間和物理空間劃分大小一樣的頁,就可如下計算:
virtual address = page in virtual space number + page offset(後12位)
|
↓ page table map ||
↓
pysical address = page in phsical space number + page offset(後12位)
有了這個基本思想,再需要一個MMU硬體就可以實現地址翻譯了。CPU會將虛擬地址交給MMU,MMU負責進行虛擬地址到虛擬空間中頁號的計算,還有物理空間中頁號到物理地址的計算。頁表存放在記憶體,MMU用虛擬地址查詢物理地址需要進行記憶體訪問。MMU最後計算出物理地址,就去記憶體中物理空間中取資料,最後傳回給CPU。如果頁是已分配,MMU就會發起缺頁中斷。
以上就實現出了整個地址翻譯過程,但是,這還不夠。在翻譯過程中,MMU訪問記憶體在硬體計算級別上,慢得不可忍。這就需要TLB(Transfer lookinside buffer),TLB就是在MMU中的快取暫存器。TLB用虛擬地址作為索引快取物理地址和頁屬性。如果TLB快取命中(cache hit),就可以大大減少記憶體訪問的開銷。
多級頁表
即使按頁作為粒度劃分,頁表還是太大了。頁表可以做成多級結構。對映的本質本來是,用更小的空間索引更大的空間,用索引的空間換搜尋的時間。有趣的一點是,多級對映結構正好相反,用多級結構搜尋時間換頂級結構的空間。多級結構只有頂級需要常駐在儲存器中,次級可以快取,增加了搜尋的時間,減少了儲存器中的空間。
mmap
類似於裝載交給虛擬儲存器做真正的“載入”。將虛擬儲存器的能力開放出來,檔案也可以交給虛擬儲存器做管理。這樣,修改行程虛擬空間中的資料,就等同於修改磁碟上的檔案。這就是mmap,行程空間對映檔案。更加詳細的內容,推薦這篇文章。
總結
虛擬儲存器這個翻譯是有深意的,上面所說的這些都可以看做是一個整體的抽象,使用者(包括行程)不care資料是在記憶體還是磁碟上,也不care是用虛擬地址還是物理地址訪問,使用者只認為是用地址訪問一個儲存器。而這個儲存是“虛擬”的、不是實際存在的。也就是說虛擬儲存器隱藏了,虛擬地址、物理地址、記憶體、磁碟。
—— —— —— —— —— —— —— —— —— —— —— —— —— —— —— |CPU/OS’s view | | –>| | –>| | –>| |
| MMU | | PageTable | | | | |
| |
—— —— —— —— —— —— —— | | | Disk |
| |
—— —— —— —— | |
| |
—— —— —— ——
|User/Process’s view —— —— —— —— —— ——
| |
|”virtual” memory |
| |
—— —— —— —— —— ——
終於,在結合連結、裝載、行程、虛擬儲存器之後,程式可以乖乖“開始”運行了,對,只是“開始”執行而已。你看看,我們寫下任何一行的code,都是站在巨人的肩膀上仰望星空。
在這裡我就多貧幾句,為什麼要學習這些計算機體系?明明在平常工作中都不會用到。有沒有覺得,平時在寫業務的時候,我們就是行程。而架構師提供給我們一個與行程類似的一個單一的環境,單執行緒、完備的抽象。換句話說,架構師就充當了虛擬儲存器的職能。那麼,想當一個架構師去架構軟體,萬裡長徵的第一步難道不是學習已有的計算機體系如何做抽象?
接下來,補充一點平時容易迷糊的併發程式設計相關概念,就權當是彩蛋了O(∩_∩)O!
併發(Concurrency)與並行(Parallelism)
個人認為淺顯區別開就好,不是搞高併發的就不長篇大論了。實際上,這兩個概念不是在一個層級上,併發指的是程式的設計結構,編出來的程式可能會併發。並行是指計算機處理器同時執行多個任務,並行執行。一個指的是邏輯上程式設計的方式,一個指的是物理上處理器處理的方式。
執行緒安全(Thread-Safe)
執行緒安全指的是,多次以多執行緒執行程式和這段程式以單執行緒執行最後結果一致。而執行緒不安全,就需要加鎖考慮執行緒同步。而不可變資料是執行緒安全的,可變資料才會出現執行緒不安全的情況。
鎖
鎖實質上都是用訊號(signal)去提示操作,其實並沒有實質上的物理手段去阻止操作,比如說阻止儲存器的讀寫,鎖就像紅綠燈一樣,沒有實際的牆阻止汽車啟動,而是用訊號去提示汽車。
比較常見的鎖機制就是互斥鎖(mutex)和訊號量(semaphore)。而互斥鎖就是個特殊的二元訊號量這點就不多說了。但是互斥鎖與二元訊號量有什麼區別?區別在於,互斥鎖只能在同一個執行緒上獲取和釋放,而二元訊號量沒有這種限制。
還有很不常見的鎖機製為條件(condition),condition有些特殊,它已經不是上鎖、解鎖這樣一對一的模型了。它更像是事件分發。condition可以等待新的條件的觸發,不觸發就堵塞。而condition觸發一次,可以即沒有等待的或者多個等待的。這就像是事件派發,有觀察者堵塞著等待事件源發生。事件源發生後,被派發給單個或者所有觀察者,然後觀察者繼續執行。說白了也是一種訊號提示。
取用
-
深入理解計算機系統(CSAPP)
-
程式員的自我修養—連結、裝載與庫
看完本文有收穫?請轉發分享給更多人
關註「ImportNew」,看技術乾貨