Linux下的進程地址空間詳解
程序地址空間回顧
我們在初學C/C++的時候,我們會經常看見老師們畫這樣的內存布局圖:

可是這真的是內存嗎?
如果不是它內存,那它是什么呢?
從代碼結果推結論
在回答上面的問題之前我們先看一段代碼:

運行結果:

通過運行結果我們可以看出,父進程的g_val一直都是保持為10,但是子進程的g_val卻是一直在變化!這還不是最恐怖的,最恐怖的是父子進程的g_val是一樣的地址,那么說明父子進程的g_val是同一塊空間啊,那么是同一塊空間的話,子進程去修改了g_val,父進程再去讀取g_val應該是子進程修改過后的,可是父進程似乎還是讀取的以前的值(10);
首先一塊空間是不可能保存兩份值的!父子進程能從統一個變量里讀取兩份值出來,那么說明父子進程的g_val絕對不是表示的同一塊空間,&g_val出來的地址也絕對不可能是真實的物理地址!
如果&g_val出來的是物理地址的話,那么父子進程的g_val就表示的同一塊空間,那么從同一塊空間讀取出來的值就應該是一樣的,但是事實卻不是這樣的!
那么就說明我們在語言級別上的取地址,取出來的絕對不是真實的物理地址,相對的我們把這種地址叫做虛擬地址!
引入進程地址空間
通過上面的例子我們知道了我們在語言層面上取出來的地址絕對不是真實的物理地址,這個我們知道了,可是這與我們的進程地址空間有什么關系?
在講解進程地址空間之前,我們先講一個故事:
在遙遠的美國,有一個大富豪,這個大富豪有100億美金,同時他有4個私生子:A、B、C、D這4個私生子都不知道彼此的存在,都認為自己是大富豪唯一的孩子:

有一天呢大富豪對分別A、B、C、D單獨說:你好好干,以后我的這100億家產都是你的!用我們現在的話說,大富豪的承諾相當于在給A、B、C、D畫餅!A、B、C、D都認為自己擁有100億美金,因為他們都認為自己是大富豪的唯一繼承人!在某一天A對大富豪說:“爹,給我1000美金,我需要買個表”,大富豪說沒問題,大富豪就從100億美金中拿出了1000美金給了A,B這時候也想大富豪申請了900美金,大富豪毫不猶豫的就答應了,但是C對大富豪說:“爹,快給我50億美金,我這出了
點事,需要擺一下”,大富豪說:“滾!”,大富豪無情的拒絕了C的請求!但是C還是認為自己擁有100億美金,因為它認為自己是大富豪的唯一繼承人,等到大富豪駕鶴西去之時就是100億美金到賬之日!
在上面的故事中呢:A、B、C、D相當于我們的進程;
大富豪給A、B、C、D畫的“餅”就是進程地址空間!
100億美金就是物理內存;
大富豪就是OS;
其中A、B、C、D向大富豪借錢的動作就是向OS申請物理內存!申請的太多,OS是會拒絕我們的!
OS最為我們計算機中的管理者,那么它要不要把給進程們畫的“餅”管理起來呢?
答案是要的!為什么呢?如果不管理起來的畫,在進程多起來的時候OS也不知到他給進程們到底畫的什么餅!
那么如何管理這些“餅”呢?
先描述,再組織
在Linux中,OS利用了一個struct mm_struct{}的結構體將這個餅管理了起來,每個進程都有屬于自己的專屬大餅,其中進程的pcb是中具有指向該大餅的指針!OS呢會將這些大餅做一個區(qū)域劃分!比如規(guī)定大餅的這個區(qū)域是干嘛的,那個區(qū)域是干嘛的!這些區(qū)域,也就是我們看到的什么堆區(qū)、棧區(qū)、代碼區(qū)等等!這個大餅也就是我們老師在日常中講解的C/C++內存布局:

Linux下的mm_struct 結構體就是專門記錄這張大餅的!
struct mm_struct{
long Code_start;
long Code_end;
long init_start;
long init_end;
……
long stack_start;
long stack_end;
}
當我們向堆區(qū)申請空間時heap_end就會變大,free時heap_end就會變?。?/p>
說白了進程地址空間就是OS欺騙進程的一種手段,讓內存誤以為自己擁有全部的物理內存;
頁表
可是進程地址空間畢竟只是邏輯上的內存,并不是真正的物理內存,是不能存儲數據,進程的數據和代碼是只能存儲在物理內存上的,但是進程使用的是虛擬內存!進程也只能訪問虛擬地址,但是實際的數據是存儲在物理內存上的,那么進程是如何通過虛擬地址拿到數據和代碼的?
實際上在虛擬地址與物理地址之間是有一種映射關系的,這種映射關系被存儲在頁表中!每個進程都有自己的頁表!

當進程需要訪問虛擬地址上某一處的數據時,OS就會拿著進程提供的虛擬地址,根據該進程提供的頁表轉換成對于的物理地址,然后去對于的物理內存上取數據在交給進程!這個過程進程是看不到的,站在進程的角度就是,我(進程)需要訪問虛擬地址為0x11223344處的數據,然后就直接拿到了數據,在進程看來它就認為自己的數據是存儲在虛擬內存上的,只要自己需要,隨時都可以拿到,殊不知其真實數據是存儲在物理內存上的,進程之所以能隨時拿到數據,都是由OS完成的!我們來畫個圖來理解:

只要理解了這一層,我們就能回答開頭的問題了:
老師們經常給我們講的內存分布實際上并不是真實(物理內存)的內存的分布,而是OS給進程畫的一張“大餅”,就是讓進程認為自己一個就擁有整個內存!說白了進程空間就是OS欺騙進程的一種手段!
每個進程都有屬于自己的一張進程地址空間和對應的頁表!
回答了開頭的問題,我們再來解釋一下,上面代碼表現出的情況,父子進程對于g_val取地址取出的地址是一樣的,但是父子進程從g_val取出來的值卻不一樣:
首先父進程會有自己的pcb、進程空間地址、頁表,那么子進程也會擁有這些東西,但是子進程作為父進程的兒子,它會繼承父進程的大部分屬性,包括進程空間地址、頁表等,畫圖表示就是:

那么根據上面圖的表示的話,父子進程的g_val不就是同一塊空間嘛,取出來的值也應該是一樣的,可是為什么父子進程g_val取出來不同的值?
我們需要記得進程之間是具有獨立性的!包括父子進程之間也是如此!當我們的子進程在嘗試對g_val變量的值進行修改時,為了不影響父進程的正常讀取g_val,OS會啟動"寫時拷貝"技術,當父子進程中的某個進程需要對父子進程共享的同一塊空間
進行修改時,OS會在物理內存重新開辟一塊一摸一樣大的空間,然后再將數據拷貝過來,修改需要修改數據的進程的頁表映射關系!此時需要修改數據的進程就可以隨意的修改了,同時不會影響另一個進程的數據!保持了進程之間的獨立性;
畫個圖來表示:

這也就解釋了為什么父子進程的g_val是同一個地址,但是卻存著不同的值!
地址相同的原因就是:g_val都處于父子進程的進程地址空間的同一個位置(這里說的“處于”并不是真實的存儲,而是邏輯上的存儲!),取地址取出來的地址當然一樣,但是由于子進程的g_val++造成了寫實拷貝,就導致了父子進程的g_val映射到不
同的物理地址空間,取出來的值自然不一樣!
寫時拷貝是發(fā)生在物理內存,對于虛擬內存沒有影響!
注意:我們平時&地址,取出來的全是虛擬地址(也就是進程地址空間中的地址),我們用戶沒辦法取到真實的物理地址,畢竟誰叫我們的進程被OS欺騙了,癡癡的認為自己享有全部內存!
明白了上面的例子,那么我們也就能很好的明白了使用fork函數時,利用變量接受fork返回值時,明明是同一個變量(虛擬地址相同),但是再父子進程中卻輸出了不同的值;
主要是應為再fork函數的內部也就是return的前一步的時候,子進程就已經被創(chuàng)建出來了,此時對于接受fork返回值的變量在父子進程中也還是映射的同一塊物理空間,但是當return的時候,就會向這個接受返回值的變量中寫入數據,此時就會觸發(fā)寫時拷貝,那么這時候父子進程中的某個進程就會為自己的這個接受fork返回值的變量重新映射一塊新的物理空間!這也就是fork函數能返回兩個返回值的秘密!實際上并不是真的能返回兩個返回值,只是父子進程中的接受返回值的變量已經是兩塊獨立的物理空間了,不在是同一塊!在虛擬內存上他們也許是同一塊,但是,真實情況并不是?。。?nbsp;
同時頁表也不止是會映射虛擬地址的物理地址,頁表同時也會記錄一下映射的物理地址的讀寫權限!
比如:

這也是為什么我們平常所說的代碼區(qū)的數據只能讀!不可修改的原因!
因為當我們進程試圖修改代碼區(qū)的數據時,OS會拿著進程提供的虛擬地址(代碼區(qū)的地址),然后根據頁表映射到對應的物理內存上去,但是OS這時候發(fā)現這次映射的物理空間在頁表中的權限也就只有可讀,不可修改!我們的操作屬于權限放大了,
OS會直接拒絕我們的請求!并不是這塊空間(物理內存)本身就只是可讀的!而是我們通過一些手段從邏輯上限制了這塊空間(物理內存)的權限!
為什么要有進程地址空間
上面我們大概講解了什么是進程地址空間和怎么使用進程地址空間,但是為什么要有這個東西呢?
1、防止地址隨意訪問,保護我們進程的安全和獨立;
假設我們不使用虛擬內存,就直接使用物理內存:

我們現在在物理內存中加載了兩個程序,現在A程序是我們寫的,但是我們的代碼能力有問題,我們的A程序有bug,當我們cpu在處理進程A的時候,會從進程中讀取到不屬于進程A的地址,也就是說A進程存在野指針問題,但是剛好這個野指針被OS分配給了進程B使用;
要是這時候我們的進程A有個對該野指針解引用并修改數據的操作的話,那就完了因為這就造成了我們明明實在運行進程A但是由于野指針的問題間接的將B進程的數據修改了,如果進程B是個銀行的賬戶信息的話,那么后果就會很嚴重!這也就破壞了進程之間的獨立性?。⊥瑫r也對進程的安全運行造成了威脅!但是我們使用虛擬內存時,我們如果造成了越界訪問,OS會在映射該虛擬地址的時候檢測出來,從而拒絕我們的訪問!這也就讓我們無法隨意的根據地址訪問其他空間了,同時進程的獨立性和安全性也就增加了!
2、進程管理與內存管理解耦合了;
再此之前我們先來談談malloc的本質!
請問只要是我們已使用malloc或new申請空間OS就會立即給我們嗎?
答案:顯然不是!OS作為整個計算機最基礎的軟件,也是整個計算機中的管理者!它是不允許發(fā)生任何不高效和浪費的操作的!
如果有,那么一定是OS的bug;
如果OS在我們申請的時候就把空間給我們了,那么我們能保證我們申請了就一定使用嗎?我們一定寫過這樣的代碼:在程序的開頭就先申請了一段空間,但是我們可能寫了幾十行代碼才開始使用這塊空間!那么在你從申請空間開始到你真正使用這塊空間之間,這塊空間就一直被我們占著,其他進程也用不到,實屬有點“站著茅坑不拉屎”的感覺!你說一個進程這樣!OS還能理解,但是如果每個進程都像這樣了!這就會嚴重的造成內存資源使用不充分、不高效;
如果在我們并未真正使用這段空間的時間段內,OS將這塊空間拿去給需要的進程使用,當我們真正需要使用這塊空間的時候OS再給我們,這樣的話內存使用率不就起來了!
那也有人會說,我們申請了立馬使用就好了嘛,對不起!在你剛好申請完這塊空間的時候CPU處理你的時間到了,該換下一個進程被CPU處理了!在你等待下一次CPU處理的時候,你又是單獨站在這塊空間,自己不用其他進程也用不了!又會造成內存資源的浪費!OS也不會允許!
為此在我們向OS申請空間的時候,OS不會立馬給我們!而是當我們真正需要的時候才會給我們!
我們平常使用的malloc、new就是這樣的原理;malloc、new是在虛擬內存上開辟空間(也就是邏輯上開辟的空間)返回的指針也自然是虛擬指針,雖然我們有了空間,但這些空間畢竟是邏輯上的,并不能真實的存儲數據,也就是說這些虛擬空間還沒有在頁表中建立起與物理內存的映射關系,進程現在拿到的只是一張空頭支票,具體的兌換,還是得靠OS!只有當我們真正需要使用這塊空間的時候,OS才會將我們申請的虛擬空間映射到對應的物理內存!也就是為我們的虛擬空間在頁表中建立起物理地址!只有完成映射關系,我們進程才能算是真正的擁有自己的空間(物理空間)!
同時我們進程也不必關心,OS到底給我們映射的那塊空間,在物理內存中是否連續(xù)等!OS可以在物理內存的任何位置映射空間,物理內存并不一定是連續(xù)的,但是我們在虛擬內存上申請的空間一定是連續(xù)的!
我們作為進程是不關心我們的數據到底存儲在物理內存的那一塊空間的、申請的物理空間是否連續(xù)等等,在進程看來進程空間地址就是它的“內存”,只要在進程空間地址上連續(xù)就行了!至于映射到物理內存上是什么情況,我們進程壓根不關心!
為此我們把就把內存管理與進程管理分開了!內存管理就處理內存的事!進程管理就專門管理進程!兩個管理之間互不干擾!

如果沒有進程空間地址的話,我們的進程在申請空間的時候,OS就會立馬給它,這樣導致內存資源浪費不說!OS會需要去刻意尋找一塊物理內存,這時候就造成進程管理與內存管理耦合!也就是說我們我們在進行進程管理的時候就必須借助內存管理的力量!這是我們不希望看到的,我們希望進程管理能夠單獨完成自己的事情,內存管理也能單獨完成自己的事情,兩個進程耦合度不要太高!保證我們內存管理崩潰的時候不影響進程管理!進程管理崩潰的時候不影響內存管理!
有了進程地址空間,我們在申請空間的時候就只啟用進程管理,先申請?zhí)摂M內存,當我們真正需要的時候,再啟動內存管理來為我們分配物理空間!這樣的話就算內存管理崩潰掉了也不影響進程管理!
3、讓進程以統一的視角看待內存;
虛擬內存是OS欺騙進程的一種手段,進程在看待進程的時候都認為自己擁有整塊內存,然后開始對著“這塊內存”開始布局自己的代碼和數據,但是實際上這些代碼和數據到底存沒存儲起來,還得看OS,但是站在進程的角度,他是認為我們已經布局完整個內存了!進程是看不到真實物理內存的!進程只能看到進程地址空間!
4、可以充分的利用內存資源,讓內存的利用率變的高效起來!
比如:兩個進程可能都需要訪問某個動態(tài)庫;如果沒有進程地址空間的話,OS就會將這個動態(tài)庫加載內存兩次,也就是內存中會有兩份一模一樣的數據!這是沒必要的!但是有了進程地址空間過后,我們可以讓兩個進程的虛擬地址同時映射到這同一份數據!也就是說兩個進程可以共享這份數據!這份數據也就只需要在內存中存在一份就行了!但是在進程看來他們都認為這份數據是自己獨享的!

重新理解進程地址空間
請問我們的程序在編譯完畢,但是還沒有加載進內存的時候,我們的程序內部是否有地址呢?
答案是當然有的!
我們可以來看看一段代碼的匯編文件:

我們將這段程序先編譯成可執(zhí)行程序,然后利用命令objdump -S對其進行反匯編:

我們會發(fā)現,在我們的程序在未加載進內存的時候,編譯器就已經確定好了各條指令的地址!這是為什么??
答:進程地址空間不止是欺騙進程的,也會連同編譯器也一起欺騙!當然這都是非常不標準的描述,嚴格意義上來說,源代碼在被編譯的時候,就已經按照虛擬地址空間的方式對代碼和數據進行了地址的編制,只不過只些代碼和數據的地址都是虛擬地址,并不是真實的物理地址!
只有當我們的程序被加載進內存了,才會真正的擁有物理地址!
現在我們來理一理整個程序的運行過程:
1、將我們的程序加載進內存(注意并不是一次性全部加載進去,而是先加載一些比較重要的代碼和數據);
2、OS為該程序建立pcb,來管理該進程;
3、OS為該進程創(chuàng)建地址空間地址和頁表;
4、cpu從特定的進程空間地址處讀取數據!然后OS在根據cpu提供的虛擬地址,映射到對應物理地址,獲取對應的數據給cpu,cpu開始處理!如果OS在根據cpu提供虛擬地址沒有建立起對應的物理地址時,OS會暫停cpu對于該進程的處理,然后重新加載一部分數據進入內存,然后再建立映射關系,出現這種情況:叫做缺頁中斷!
我們畫個圖來理解:

注意:在CPU上讀取到的地址,全是進程空間上的地址,也就是虛擬地址!CPU也不會直接去物理內存上讀取數據!
總結
以上為個人經驗,希望能給大家一個參考,也希望大家多多支持腳本之家。
相關文章
VirtualBox 未指定要bridged的網絡界面的解決辦法
這篇文章主要介紹了VirtualBox 未指定要bridged的網絡界面的解決辦法的相關資料,希望通過本文能幫助到大家,讓大家解決遇到這樣的問題,需要的朋友可以參考下2017-10-10

