載入到內存中不就會把另一個程序的指令覆蓋了嗎

補充:是經過objdump以後,每條指令前面都有的那個地址,一般是0x400000左右,然後我還發現一些常數一般在0x600000左右,然後rsp棧頂指針一般是0x7fffffffff....


這要分兩大類三種情況討論。

第一大類是實模式下跑的各種應用,此時代碼中的地址就是物理地址。

這個大類又分兩種情況。

一是單片機那樣,地址就是最終的物理地址,將來就要分毫不差的寫進內存晶元對應的單元里。這種情況下自然不可能完成同時載入兩個地址衝突的程序這樣的任務。

二是DOS那樣,程序中的地址並不是最終的物理地址,而是所謂的「相對地址」;OS載入程序時必須先做一個「重定位」,比如前200k的內存被別人佔用了;那麼當載入你的程序時,就從201K開始(同時把201K這個值載入基址寄存器)。當遇到跳轉指令時,只需把裡面寫死的相對地址提取出來、加上基址寄存器的值(也就是201K)即可。這就是運行時的重定位(對初步編譯出的.o/.obj文件來說,在鏈接時也需要做一次重定位,原理類似,也是給裡面的所有涉及到的地址加上一個固定的偏移;但這次重定位是靜態的,和載入執行時的動態重定位不一樣)。

實際上,現在的內核代碼仍然需要跑在實模式下,因此運行時的動態重定位仍然是必需的;另外相對定址也有很多種不同手法,這裡僅僅是個原理性的簡化說法。

第二大類是虛地址模式下跑的各種應用,也就是第三種情況。

這個模式下,OS一般會事先做一個約定,比如內存前1G(或更大空間)屬於OS,其它空間屬於應用。真正運行時,通過虛擬地址映射機制,OS內容就被映射到了約定好的空間、而用戶應用則從約定好的起始點開始順序載入(實踐中也可能故意在每次載入時都偏移一個隨機值,這樣萬一有緩衝區溢出之類漏洞,黑客們想完成攻擊就沒那麼容易了。畢竟重定位是個早已被解決的問題,不用白不用)。

藉助虛擬地址機制,每個應用都會「覺得」自己獨佔了全部內存空間,並不能意識到其它應用的存在。

所謂虛擬地址機制,說白了和實模式下的「基址相對定址」是一個原理;只是現在負責內存映射的寄存器對應用來說不再可見了而已——實模式下,應用知道自己被載入到了201K開始處,因此當它要訪問內存地址1235時,實際訪問的是201K+1235處,所有一切它都知道的清清楚楚;而虛模式下,應用以為自己就是從0開始載入的,訪問1235就是1235,並不知道實際上OS「偷偷」替它加了個偏移值、使它訪問到了正確的地方(這個偏移值被OS自動維護在「頁表」里,每個進程都有獨屬於自己的一套單獨的頁表;當這仍然是個簡化的、原理性的說法,實際上還有頁式管理、段頁式管理等不同分類,而且不同的CPU支持的管理方案也有所不同)。

有了虛擬地址機制,當系統資源不足時,某些應用的某些內存頁面就可能被暫存於磁碟(對windows,默認是c:pagefile.sys;當然你可以通過配置改變位置),從而盡量騰出內存條空間給前台應用——當你在內存小於8G(甚至4G)的電腦上同時開N個巨耗內存的應用時,就會發現機器運行緩慢、硬碟燈不停的閃爍(同時伴隨著咯吱咯吱的讀盤聲,假如你用的還是機械硬碟的話)。原因就是內存不足,OS不得不在內存和硬碟間不停倒騰,犧牲速度盡量保證任務完成。這個過程中,程序被換出/換入內存的那部分頁面就必須更新它在頁表中的索引信息,這才能隨便搬哪都不妨礙程序的運行。

藉助類似手法,一個運行中的應用甚至可以臨時被儲存到磁碟上、然後斷電關機;等下次計算機重啟後再從磁碟載入這個應用對應的內存數據(到隨便什麼地方)、讓這個應用從上次關機的地方繼續運行(比如你玩槍戰遊戲時,可以在槍榴彈出膛時休眠斷電、等半個月後重新加電開機,你會看見畫面上那顆槍榴彈繼續飛向目標……)——當然,雖然原理相同,但這時候用的可能並不是交換文件(比如,如果你沒有改過配置的話,Windows下就是C盤根目錄下的hiberfil.sys)。


兩位高贊答主說的非常全面了,我寫一段省流量簡化版。

題主已經發現了矛盾點,出現矛盾的原因是:「c語言程序經過編譯後,每條指令都有一個內存地址」,這句問題描述是錯誤的。

編譯的機會只有一次,如果在編譯時直接指定代碼的內存地址,也就是「指定代碼放在內存的哪裡」,顯然是不可能的。

就像去學校澡堂洗澡。每次去洗澡,澡堂大叔會挑一個空衣櫃給你鑰匙,即動態給你分配一個編號。這個編號每次去都會變。

同樣的原理能否用在程序載入時呢?最簡單的辦法就是——編譯器僅初步指定代碼的內存位置,假設代碼從0開始,那麼第一句代碼在地址1,第2句代碼在地址2,第一個跳轉去100。這樣做,把相對位置先定下來。

實際載入程序運行的時候,操作系統會挑一塊能放下程序的空閑空間給你,比如給了你22222起始地址。那麼前面的1、2、100就順勢改為22223、22224、22322,即可。

以上說的就是純粹的原理了,無論在什麼系統上,其本質原理不會變。

至於實用化的操作系統,會將上述過程封裝再封裝,形成像「虛擬地址」這樣的高級概念,讓你誤以為每次棧頂地址都是一樣的。

虛擬地址就相當於:老大爺每次給你的柜子編號都是1,讓你覺得好像受到了優待。但實際上每次1對應的都是不同的柜子。到底哪個編號對應哪個柜子,只有老大爺知道。

無論操作系統如何複雜,演算法的本質不會變,也沒法改變。

更有意思的是,當C/C++載入動態鏈接庫時,情況會變得更加撲朔迷離。如果對載入的原理感興趣,推薦《程序員的自我修養》一書。


很意外好像沒有人答對(在寫本回答的時候好像只有一個人回答沾邊),大部分回答都在談內存地址的虛擬化,那麼在CPU有保護模式(內存地址的虛擬化)之前,難道就只能同時載入一個程序?

(編輯:說虛擬內存的錯在邏輯上本末倒置。就好像吊葡萄糖可以補充能量,吊的時候不吃飯也行,然而說是吊葡萄糖解決了吃飯問題這就是邏輯上的本末倒置。)

歡迎來到凱叔講故事。

其實這個問題的關鍵和解決的方法並不是內存虛擬地址,雖然內存虛擬地址的確貌似可以解決這個問題(其實並不能,最後有提到),但是歷史上這個問題的出現和解決遠早於保護模式(內存虛擬地址)的出現。

這個問題的秘密在於EXE/ELF等可執行文件格式上。這些可執行文件格式的發明和制定,就是為了解決這個問題。

在很久很久以前(其實也就是30多年前),計算機程序的確是只能載入到內存當中的固定位置的。比如,BOOTLOADER、以及DOS啟動時所需的那3個著名文件(這三個模塊是輸入輸出模塊(IO.SYS)、文件管理模塊(MSDOS.SYS)及命令解釋模塊)就是這樣。

然後,就有了題主所擔心的問題,很不方便。

於是,就有了. COM的格式(可能還不是最早的,只是比較著名的)。. COM格式允許將程序放在任意一個內存分段當中,但是程序在段當中的偏移地址是固定的,也不能有另外獨立的數據段。整個程序的大小(含數據)不能超過64K,因為這是一個內存段的大小。

內存需要分段,以及一個段是64K的原因是,那個時候的CPU本身是16bit的(e,忽略更老的8bit CPU不談),就是所有寄存器都是16bit的,但是地址匯流排是20bit的。這樣的話,就需要將一個被稱為段寄存器的內容左移4bit然後加上另外一個寄存器(稱為偏移寄存器)的內容,才能得到20bit的內存地址。16bit正好64K,也就是在不修改段寄存器的情況下,能夠直接定址的範圍就是64K。超過這個範圍就需要修改段寄存器。這在以前也叫做長跳轉。

但是後來隨著程序越來越大,64K已經放不下了。首先想到的是將程序當中的數據部分分離出去放在單獨的段當中,這就是數據段。然後程序本身也可能需要跨多個段。

但是這樣還是不是很方便。由於數據段分離出去了,有的程序段可能很小。但是如果所有程序都是從一個固定的偏移地址開始,那麼這些程序至少不能放在同一個段內。這就比較浪費內存,特別是在沒有保護模式(虛擬地址)的年代,每個段可就是實打實的64K物理內存。那個時候,總共物理內存大部分也就256K,1M已經算高端機了(1M是20bit地址匯流排的定址上限),所以並沒有那麼多段可用。

所以就有了浮動二進位可執行方式,這就是EXE或者ELF等。這種可執行文件當中的地址只是對於程序當中某個位置(比如main函數開始位置)的一個偏移量,操作系統可以將其載入到內存任何位置,然後根據實際main函數入口所處地址和這些偏移量,去更新機器碼當中所有的地址參照,將其改為實際地址。也就是,在程序實際被執行之前,操作系統會對其進行一次修改/更新。

而題主用objdump所看到的「地址」,其實就是這個相對於程序自身的偏移地址,並不是內存地址,即便是保護模式下,也不是按照這個地址執行的。當然,更加準確地來說,還要看被objdump的是什麼文件。如果是. obj/.a文件,也就是鏈接之前的文件,那麼這個偏移只是對於. obj/.a自身入口的偏移,在C語言鏈接的這一步,linker會將這些. obj/.a組織到可執行文件當中,並且更新這些偏移。(然後在可執行文件被載入到內存之後,操作系統還會再次更新。是的,這個過程和linker很像)

後來,隨著32bit/64bit CPU的出現,CPU定址範圍大大增加,才出現所謂的flat模式內存空間,也就是不分段(整個內存空間只有一段)。但是即便這樣,在使用動態庫等的時候,依然存在動態庫載入到哪裡的問題,因為不同的宿主程序長度不同,動態庫載入數量和順序不同,即便有虛擬地址空間,同一個動態庫在不同程序的虛擬地址空間當中也不可能永遠都載入到同一個位置。

此外還有安全方面的考慮。如果程序每次都載入到同樣的地方,那麼程序當中的每個變數也很可能每次都出現在同樣的位置,尤其是全局變數。這使得程序非常容易被監視及hack。所以每次讓操作系統將其載入到不同位置,可以提高一點兒安全性。

總之,這個問題其實與虛擬地址空間並沒有什麼關係。雖然虛擬地址空間的確可以避免程序之間的內存訪問衝突,但是歷史上並不是用虛擬地址空間解決的這個問題,而且它解決不了動態庫的載入問題。


你說的是裸機,確實會有這種情況。

但是進入操作系統之後,你的程序看到的內存全是假的,甚至有可能對應的地址根本就不在內存,而是在硬碟上。

因為操作系統會為每個進程虛擬出內存,讓每個進程都覺得整個系統中只有自己一個程序在運行。


這是個好問題。這個問題涉及到虛擬內存的概念。(虛擬內存可能有歧義,在此處應該就是邏輯地址的意思)

對於每個進程來說,他們都有一個獨立的地址空間。比如A進程的0x400000和B進程的0x400000,都是合理的,但是他們不指向同一個位置。

你可能會疑惑,為什麼地址一樣,卻不在同一個位置。這就是虛擬地址的概念了,我們之前提到的0x400000是一個虛擬的地址,要通過轉換才能變成真正的地址,叫做物理地址。這個轉換我們可以讓他根據進程不同而不同,就實現了每個進程有他自己的虛擬空間。

至於這個轉換是怎麼發生的,現在一般通過頁表,早期通過段寄存器。當然再早一點是沒有虛擬內存這個概念的,直接就是物理地址,A和B的0x400000就是同一個地方,就會出現題主的問題。

如果有人看我再進一步講講頁表吧。


關於從虛擬地址到物理地址的問題,不同的操作系統有不同的實現。我講一個簡化的模型(我只是個大二的學生,講的不對的地方還請多多指教)。

首先有兩個東西,一個是物理內存,他是實實在在的,你可以認為就是你的電腦的內存,給他物理地址,他就能拿到東西。還有一個虛擬地址空間,他是每個進程都有的,操作系統提供給每個進程的。進程自己不在乎給他用地址是啥,他只要能正確的從地址里拿到想要的內容就行了。

比如A進程想拿0x400000這個地址里的內容。我們前面說進程只知道虛擬地址,但是內容保存在物理內存里呀,我們要用物理地址到內存里去找內容。所以問題來了,怎麼把虛擬地址變成物理地址呢?我們可以用頁表。

假設我們的物理地址的範圍是0x00~0xFF,一共256個。簡單起見,假設我們的虛擬地址的範圍也是0x00~0xFF。(這兩個的空間大小沒有必然的聯繫)

我們還有一個表,這張表一共有16項,我們把他叫做頁表。我們把我們的虛擬地址分成兩部分,第一位和第二位。比如0x13這個地址就分成0x1和0x3。用前一個0x1到頁表裡去找內容,找到0x2對吧。再和0x3拼起來,就得到了0x23,最後我們找到的內容就是物理地址

那麼這個是怎麼解決題主的問題的呢?我們再來看個例子,我們有兩個進程,他們的頁表是下面這個樣子,左進程找地址0x11,按照上面的找法是不是變成0x21,右進程找地址0x11,算不算找到了0xE1。

你可能還會問,這個頁表在哪裡?頁表的值是怎麼寫的?物理地址不夠了怎麼辦?這些問題都很有意思,等進一步的學習就會明白啦。另外,真實的情況複雜的多,我這個只是非常簡易的版本啦,感受一下思想就好了。


看到樓上越講越深入了,我也加幾句。不然要被diss沒了(逃

  1. 段寄存器的概念在我這裡完全忽略,因為我主要學的是riscv架構,至於大家電腦上普遍的intel架構,段寄存器仍是繞不過去的歷史。
  2. 上面講的頁表,都是操作系統創建的。也就是說在操作系統啟動前是沒有的。虛擬地址空間是操作系統賦予用戶進程,至於他自己怎麼裝在進去啟動運行那就是另一個故事了。
  3. pe/elf格式中有segment和section的概念,這個是操作系統能裝載程序進入內存的重要幫助,樓上的答主講的比我好。


推薦閱讀:
相关文章