對于人類來說,我們不喜歡拐彎抹角,喜歡更直接的東西,“有話直說”、“沒有中間商賺差價”、“簡潔的設計”等等,然而對于計算機,尤其是對內存管理來說則恰恰相反,在這里"簡潔"的設計往往不是好的設計,這到底是什么意思呢?
我們在很早的文章中就提到過,內存從本質上將非常簡單,你可以將其想象成一個個的小盒子組成,每個小盒子要么能存儲1要么存儲0,每8個小盒子組成一個字節(8比特),每個字節都有一個唯一的地址,通過這個地址我們就能從相應的一組小盒子取出這個比特。
其他沒了??吹搅税?,內存本身其實是非常簡單的,然而程序員以及程序使用內存的方式又讓這個問題變得復雜起來,分析任何復雜問題都要抓住重點、抓住核心問題,那么這里的重點以及核心是什么呢?
不賣關子,這里的核心在于兩個字:尋址,Addressing。
一切都是圍繞尋址展開的。
尋址,最重要的就是尋址
什么是尋址 Addressing?所謂尋址就是找到內存中某個我們需要的數據的方式。
哪怕以我們平時去儲物柜取東西都有很多“尋址”方式:
- 直接告訴我們一個編號,我們拿到這個編號后按個去找,就像下面這張圖,我們需要找到東西在第15號儲物柜中,那么我們根據15這個地址就能找到第15號儲物柜。
- 當然我們也可以將儲物柜劃分區域,還是以剛才的儲物柜為例我們可以劃分為3個區域,當我們需要找東西時告訴我們其在儲物柜的哪個區域,以及在該區域中的"偏移"是多少。
以下圖為例我們需要的東西在第二個區域,區域內的偏移為6(該區域中的第6個儲物柜)。
實際上,第一種更像是“絕對尋址”,什么意思呢?就是找到某個具體的儲物柜是根據一個“寫死的地址”(hardcode),很死板,第二種更像是相對尋址,稍顯靈活一些。
怎么樣,你是不是感覺這兩種其實也沒什么區別嘛,的確,對于找儲物柜這個例子來說這兩種方式的確沒什么區別,但對于內存來說就不太一樣了。
死板 vs 靈活
我們知道程序以及程序使用的數據編譯好后存放在磁盤上,運行時要加載到內存中,因此這里同樣存在尋址問題:我們需要根據內存地址找到機器指令以及數據,接下來假設有一個只有8字節大小的內存和一個只有2字節機器指令的程序(無需關心實際意義):
這段2字節的代碼非常簡單,其實就是一個無意義的while循環,注意看這里的jmp這條指令,我們直接跳轉到內存地址2,這就是一個寫死(hard code)的內存地址,這就意味著我們必須把該程序加載到內存地址為2的位置上:
否則這段指令根本沒有辦法運行,比如我們把這段代碼加載到內存地址6上去:
那么在執行jmp 2時我們根本沒有辦法跳轉到add這行指令,有的同學可能覺得無所謂,不就是內存地址寫死了嘛,好像也沒什么大不了的吧。
如果一次只能運行一個程序的確也沒什么大不了的,但對于操作系統最核心的功能之一:多任務,也就是一次可以運行多個程序來說這個方案簡直行不通。
在這種方案下你幾乎沒有辦法一次運行多個程序,除非在運行之前你給要運行的這幾個程序劃定好區域,比如要運行兩個程序A和B,A占用0~3這個區域的內存;B占用4~6這個區域的內存,對于現代程序員來說你能想象在程序運行之前就需要給它劃定好區域嗎?顯然,這非常繁瑣,也容易出錯。
如果你在上世紀六七十年代寫代碼,面臨的大概就是這樣一種狀況。
實際上這個問題的核心就在于重定位程序使用的地址不能綁定在一個內存區域上,需要足夠靈活,我們需要在不修改代碼的情況下把程序加載到任意內存區域上運行!想一想該怎么解決這個問題。
作為程序員肯定和文件路徑打過交道,如果你能明白絕對路徑與相對路徑就能解決重定位問題。
絕對路徑與相對路徑
想一想絕對地址有什么問題?這個問題就好比你在程序中讀取一個絕對地址時:
/user/xiaofeng/doc/a.c
如果是你自己的計算機那么沒有問題,但如果這個程序在其它人的計算機上運行就不一定了,因為其它人的計算機中不一定有這個路徑,這時該怎么辦呢?聰明的你一定知道,那就不要使用絕對路徑,而是使用相對路徑就可了:./a.c
其中./表示程序運行時所在的路徑,這時不管這個程序在哪個路徑下運行都能找到a.c這個文件,這時所在的目錄就成為了基準
解決重定位這個問題也是同樣的道理,編程生成可執行程序時不再使用絕對內存地址,而是使用相對地址,怎么使用相對地址呢?相對于誰呢?很簡單,相對于該程序被加載到的內存起始地址
此時我們的jmp命令后面不再是一個絕對的內存地址,而是一個相對地址:0,但畢竟向內存發出讀寫指令時必須使用一個內存地址,那么CPU執行jmp 0時該怎樣將其轉為一個內存地址呢?
很簡單,因為這一段程序被加載到了內存起始地址2,因此只需要用相對地址加上起始地址得到的就是真實的物理內存地址:
物理地址= 起始地址 + 相對地址
很簡單吧,這樣不管這段程序被加載到了哪個內存區域,只要我們知道起始地址那么總能計算出真實的物理內存地址,重定位問題就可以這樣解決。
實際上你會發現,這個儲物柜的第二種尋址方式也沒有什么區別
分段式內存管理
我們知道,程序的內存從內容上可以分為存放機器指令的代碼區域、存放全局變量的數據區域、保存函數運行時信息的棧區等,顯然我們可以將程序按照這種劃分進行分段管理,段內使用相對地址,這樣無論這些段被加載到內存的哪個區域我們都能方便的計算出正確的物理內存地址。
我們將各個段在內存中的起始地址放到專用的寄存器中,X86 CPU中有這樣幾個段寄存器,CS、DS、SS以及ES,這些寄存器有什么用呢?這幾個寄存器就用來存放各個段在內存中的起始地址(暫且這樣理解,稍后你會發現這些寄存器的真實用法):
- 保存機器指令的區域,這個區域就是我們所說的代碼段(Code Segment),因此我們可以使用一個寄存器來專門指向代碼段,這就是CS寄存器的作用,CS也是Code Segment的縮寫。
- 同樣的道理,程序運行起來后還有專門的區域用來保存數據,因此必須要專門的寄存器指向數據段(Data Segment),這就是DS寄存器的作用,DS是Data Segment的縮寫。
- 程序運行起來后還有運行時棧(Stack Segment),因此可以使用SS寄存器來指向程序員運行時棧,SS是Stack Segment的縮寫。
- 此外還有ES寄存器,Extra Segment,其用作臨時段寄存器。
除了內存分段管理之外,我們的程序可以讀寫任意內存區域,有的同學可能不以為意,這又能怎樣呢?
沒有內存保護會怎樣?
至今,在多線程編程中這個問題依然困擾著程序員,因為同一個進程中的線程共享同一個地址空間,這也就意味著你的線程可以修改地址空間中任何可寫的區域,包括棧區以及堆區,當然這也就意味著其它線程可以修改你的線程使用的數據,這是多線程中一大類bug的來源。
而這個問題在內存地址沒有任何保護情況下更加嚴重,因為這時不是一個進程而是多有進程包括操作系統都共享同一個物理內存地址,任何一個進程都可以修改內存中任何位置,你的進程可以破壞其他進程使用的內存,可以破壞操作系統使用的內存,破壞其它進程大不了重新啟動這個進程,但是如果破壞了操作系統那么沒有辦法,此時你只能重新啟動計算機,如果CPU沒有提供內存保護機制,那么操作系統連自己都保護不了更何況去保護其它進程。
沒想到吧,看似簡單直接的內存讀寫竟然會有這么多問題。
實模式
好啦,到目前為止讓我們暫且總結一下。
- 絕對的內存地址不好用,這樣的地址必須將程序加載到內存的特定位置上,為解決這個問題使用相對地址,x86中為每個程序的區域都配備有專用的寄存器用來存放該段在內存中的起始地址,這樣就可以根據基址加偏移計算出物理內存地址,注意,這里計算出來的是真實的物理內存地址。
- 內存讀寫沒有任何保護,程序可以讀寫內存的任何區域。
實際上這就是早前內存管理的模式,非常直接非常原始,x86 CPU將這種原始的內存管理方法稱之為實模式,real mode,這種模式也被稱之為real address mode,顧名思義,我們在程序中看到的都是真實的物理內存地址。
原來,早期的x86 CPU能訪問的最大內存被限制在1MB(2^20 byte),你可能會想這可用內存也太少了吧,對于當今程序員或者用戶來說1MB幾乎什么都干不了,哪怕都存不下一首歌,然而在上世紀80年代,1MB內存是一片極為廣闊的空間,以至于比爾蓋茨在上世紀80年代說過:640k ought to be enough for anyone,對大部分人來說640K內存已經足夠用了。
除此之外,更加捉襟見肘的是早期x86 CPU寄存器只有16位,16位寄存器是沒有辦法訪問整個1MB內存的,16位寄存器最多能訪問64K大小的內存,要想訪問1MB內存那么內存地址就需要20位,而寄存器本身就16位,因此根本裝不下,怎么辦呢?
很簡單,一個寄存器不夠我們就用兩個,第一個寄存器被叫做selector,說白了其實存放的是儲物柜區域的編號,因此也叫做段寄存器, segment register,管叫做區域還是叫做段本質上沒啥區別。
第二個寄存器被叫做offset,說白了就是區域內的編號或者叫做區域內的偏移,這樣真正的內存地址就由兩部分組成selector:offset,此時內存地址的計算方式是這樣的:
16 ? selector + offset
此時給定一個段寄存器再給出一個偏移我們就能直接在內存中找到需要的數據:
因此這里計算出來的內存地址就是物理內存地址。
此外,在實模式下CPU不提供內存保護機制,程序可以隨意讀寫任何內存區域,哪怕是操作系統所在的區域其它程序也可以讀寫。
現在可以總結下早期x86處理器的特點了:
- 尋址空間有限,只有1MB
- 利用selector:offset的方式利用兩個16位寄存器來尋址1MB內存
- 沒有內存保護機制,當然,沒有內存保護機制的一大優點就在于內存讀寫速度要更快,原因就在于不需要經過虛擬內存地址到物理內存地址的轉換,也不需要進行任何檢查(這可能是實模式下僅有的優點)
在80286之前,所有x86 CPU都運行在實模式下,而為了后向兼容,即使是現代x86在重置(加電時)后也會首先進入實模式,后續才會跳轉到保護模式(protected mode),關于保護模式我們在后續文章中講解。
實模式與操作系統
實模式是x86系列處理器最早期的內存管理模式,這一時期的操作系統別無選擇,只能運行在這種模式下,早期的DOS系統以及早期的Microsoft Windows操作系統就運行在實模式下。
雖然實模式理解起來很簡單,但這種模式最主要的問題在于:
- 把物理內存暴露給程序
- 沒有內存保護機制
這兩者結合起來的后果就是程序不被受限,程序員都知道,我們寫的代碼充滿了bug,在現代操作系統中程序很容易把自己搞掛,而在早期的操作系統中程序就會很容易的把整個系統搞掛,為解決這一問題,x86 CPU在80286開始引入保護模式,后續文章會有詳細講解。
盡管現代操作系統(Windows、Linux)等早已不運行在實模式下,然而實模式卻依然保留了下來,你可能會想為什么x86 CPU依然需要保留實模式呢?
我們都知道代碼有屎山一說,其實對于歷史悠久的x86來說也有類似的問題。
CPU這種硬件和軟件一樣也是在不斷演變進化的,從16位實模式演進到了32位保護模式以及現代的64位處理器,但早期程序員圍繞著16位實模式的x86CPU編寫了很多軟件,當CPU發展到32位保護模式時之前的基于16位實模式編寫的軟件該怎么辦?不支持了嗎?不支持的話只有兩種可能:1) 用戶不再購買不兼容16位軟件的CPU 2) 重寫代碼,以程序員的尿性來說大概率不會重寫,intel也非常識時務,因此在后來的32位乃至現代的64位處理器上依然保留了實模式,x86系列處理器在重置時會首先進入實模式,對于不使用實模式的現代操作系統來說簡單的初始化工作后會跳轉到保護模式。
因此我們可以看到,實模式就像原始的進化基因一樣依然存在,就像動物胚胎有腮一樣,只不過該過程一閃而過,實模式也是在計算機啟動階段快速閃現,這種古老的內存管理方式依然留下了自己的烙印。
總結
實模式是一種非常古老的內存管理方式,在這種方法下程序員直面物理內存,且處理器沒有提供內存讀寫機制,程序員可讀寫任何內存區域。
實際上實模式對于現代操作系統來幾乎沒什么用處,只不過如果你針對x86 CPU編寫操作系統那么實模式是必須要了解的,但對于其它CPU來說則沒有這樣的歷史包袱,因此有很多操作系統教材開始基于非X86平臺來講解,這樣能更快速的講解操作系統而不是在一上來就在各種內存模式中打轉。
注意,本文提到的實模式僅僅針對x86系列處理器而言,對于上層應用的大部分程序員來說根本就不需要關心實模式,然而技術就和生物一樣也在不斷演變進化,了解過去才能更好的理解當下以及未來。
特別聲明:以上內容(如有圖片或視頻亦包括在內)為自媒體平臺“網易號”用戶上傳并發布,本平臺僅提供信息存儲服務。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.