1. 程式人生 > >對現代操作系統進程地址空間的想法

對現代操作系統進程地址空間的想法

span 優勢 真的 碎片 head 內存分配 必須 重新 運行

什麽是堆,什麽是棧,什麽是數據段,什麽是代碼段...這些都是歷史遺留問題。如今編程真的沒有必要在意這些了!不要被/proc/xx/{maps,smaps}裏面的內容所迷惑和縈繞。自己管理好自己的內存分配就好。假設程序不是自己寫的,那麽就找寫它的人。
本文將從一個鏈接動態庫的可運行文件怎樣載入進程地址空間開始,談一下我對進程地址空間布局的看法。我沒有採用精確的方式描寫敘述ELF或PE文件怎樣載入的,而僅僅表述一種思想過程,目的是為了不讓看到本文的人重新的陷入無窮盡的代碼分析的深淵,因此我僅僅講過程而不談細節。

1.可運行文件的結構

可運行文件包含以下的內容:
自描寫敘述頭:描寫敘述該文件的類型。大小,運行平臺,兼容信息,入口地址等元數據。
符號表:所謂的符號表就是函數或者變量的標識。這些標識代表的實體終於映射到地址空間的某個地址。
代碼:存在自身的可運行二進制代碼。可運行程序的要旨在此。
已經初始化數據:程序中定義的已經初始化的數據。除了保存數據符號本身之外。還要保存數據值。
未初始化數據:程序中聲明的數據。可是還未經初始化。

僅僅保存數據符號,不保存值。
鏈接庫信息:本運行文件鏈接到的動態庫信息。
無論是Windows的PE文件還是UNIX的ELF文件,存放的無非就是上面這些東西,復雜性在於,它們的格式不同,細節太多。

2.可運行文件的載入

所謂的可運行文件載入,說的是將一個文件系統中的可運行文件映射到一個虛擬地址空間中。內容即包含上述的可運行文件本身。通過自描寫敘述頭這類元數據能夠找到程序的入口地址。因此此步驟的最後就是跳轉到該地址運行。

註意。此步驟並不載入動態庫,載入動態庫的職責由用戶態負責。通常是解釋器的事情。

假設你運行一下以下的序列。就知道動態庫是什麽時候由誰載入的了:
readelf -a /bin/ls
ldd /bin/ls
strace -e trace=open /bin/ls


我看過非常多文章,都沒有談到這個細節。對於Linux而言,都說是在load_elf_library中完畢的動態庫載入。甚至毛德操也是這麽說的。他們根本沒有註意到C庫的行為。難道搞內核的人就如此高深嗎?非常多人都認為僅僅要事情在內核完畢的,那就高深無比。其實根本不是這回事!


3.動態庫的載入

因為上述第2步,進程地址空間裏面往往已經有了動態庫的信息,包含庫文件名稱字。那麽依據一些環境變量指示的庫路徑。則有希望在這些路徑下找到這些庫,然後便逐一將其載入進程地址空間了。

在上面的第2步最後,程序已經返回用戶態運行了,一般而言此時運行的是一個叫做解釋器的程序,它會解析須要載入的動態庫信息,然後逐一載入動態庫的。隨後會解決一些符號重定向的問題。
其實。在直接運行main函數之前,有非常多工作要做。載入動態庫僅僅是當中最復雜的之中的一個。

須要明白的是,僅僅要有動態庫,進入main之前的工作就會無比復雜,這是必定的代價。收益就是在load_elf_library中的工作量大大降低了,換句話說,你的可運行文件的負擔降低了,假設想為main前行為減負,就用靜態鏈接吧。

4.地址空間布局

至此。一個進程的地址空間就被填充好了,接下來就要跳轉到我們定義的main函數了。空間布局什麽樣子呢?假設問起來,一般人都會回答:
代碼段|數據段|BSS|堆|空暇|棧|內核
是這樣嗎?某種意義上是的,可是請細致看第1步和第2步。我並沒有刻意強調那些可運行文件或者動態庫的數據是怎樣被塞到這些段的,其實,根本沒有必要用這些段,這些都是歷史遺留,真正的地址空間布局應該是:
動態庫1|空暇|動態庫2|動態庫3|可運行程序|空暇|棧|內核
看到了嗎?沒有堆,是的。沒有堆。

以現有的Linux為例,實際上除了保留了一個brk來表示堆之外,代碼段,數據段等古老的段已經全然弱化了,甚至退化成了例行公事或者純粹為了喚起人的記憶。在我看來。堆也是應該摒棄的。僅僅有棧,因為和處理器極度相關。因此須要保留。


如今的內存管理方式是保護模式下虛擬內存管理。對於一個進程而言。32位的虛擬地址擁有4G的空間,對於64位的虛擬地址而言。則更大,還有必要將內存依照屬性劃分區段嗎?其實,在實模式的時代,有專門的硬件寄存器來表示這些區段的起始地址,因為處理器就是這麽設計的。因此必定要劃分內存為一系列內部連續的區段,可是到了32位保護模式以後,非常多這中劃分就沒有必要了,段寄存器在平坦模式下依舊例行公事但不再起作用,無論是代碼段還是數據段,都是占領整個地址空間。


堆是怎麽回事呢?堆就是heap,也是一個古老的概念,它在地址空間是一段連續的空間,一般位於棧的以下。和棧向下增長不同,堆是向上增長的。

在史前時期,大家是共享內存地址空間的,每個進程都在一個連續的空間範圍內。而且有確定的大小。和數據段,代碼段空間連續的意義一樣,堆也是一段連續的空間,因為和棧的增長方向相反。就保證了程序使用的內存在越界之前就會出錯。

然而如今都是獨享虛擬內存了,怎樣管理物理內存已經被剝離出去了,因此就沒有必要採用這些原始的方式了。
註意,以上並沒有涉及內碎片和外碎片的問題,因為在保護模式時代,大得多的獨享地址空間能夠利用充分且設計良好的內存管理算法來高效避免兩種碎片。而在史前。極小的共享的內存不足以容納復雜的內存管理程序本身,也就僅僅能設計一個簡單一致的分段約定來依照數據的屬性將空間分為不同區段來避免內存碎片。

5.再說一點關於堆的

本質上講,在獨享的進程虛擬地址空間內,一切都由mmap來確定才是簡單的方式。究竟是數據,還是代碼全都體如今mmap區段的權限上。對於Linux而言,一個區段就是一個vm_area_struct。它的權限體現了它裏面映射的是代碼還是數據。那麽堆有存在的必要麽?誠然。它在虛擬地址空間是連續的,可是連續並非它的最重要的意義,它很多其它的是體現了用戶怎樣管理內存這一點上。問題歸結為怎樣為用戶提供一塊用戶指定大小的虛擬內存上,假設Linux不採用堆,而是將全部的空暇的沒有映射給vm_area_struct的空間依照夥伴系統組織起來,豈不更好?夥伴系統本身就有避免碎片的功能。

6.運行期分配內存

原則上。我傾向於全部的內存分配都是用mmap來進行,即便內核眼下還沒有實現虛擬內存夥伴系統的情況下也是如此。

其實,標準的做法是,在malloc申請少於128K的內存時。採用堆上分配(眼下Linux還在使用堆),大於128K的時候使用mmap來分配。


假設你非常精通Linux內存的使用。就會認為以上我對堆的抨擊簡直就是一派胡言。因為我忽略了堆內存和mmap的申請和釋放機制全然不同。某種意義上確實是,但不幸的是,我對這樣的不同相同也是深刻理解的。假設man一下mallopt,就會發現M_MMAP_THRESHOLD以下有一句話:
mmap()-allocated memory can be immediately returned to the OS when it is freed, but this is not true for all mem‐
ory allocated with sbrk(); however, memory allocated by mmap() and later freed is neither joined nor reused, so
the overhead is greater. Default: 128*1024.

這不正是在否定mmap,這不正是和我的提議相反嗎?是的!
可是。brk的高效性和用戶程序怎樣使用內存有極大的關系。堆上的內存釋放。假設不是邊緣的話,就不會釋放給操作系統。而是繼續留在地址空間內,而這部分空間是由C庫的malloc來管理的。它可能在malloc內部實現了一個相似夥伴系統的管理算法,使得能夠自己管理自己的內存區。可是你不得不信賴這樣的C庫相關的一切實現,這樣的針對mmap的優勢並非操作系統級別的,而是C庫級別的。對於mmap而言,僅僅要你調用free,那麽底層就會調用munmap,此時這段內存在地址空間就不再可用了,頻繁的mmap/munmap操作當然會帶來不小的開銷,而且因為每次mmap的地址可能距離比較遠,因此也會喪失局部性的優勢。
我認為。討論這個問題和討論TCP在內核中的各種優化屬於一類問題。終於的結果就是,不要在內核做這樣的比較了,全然交給用戶程序或者庫自身,相信用戶程序能夠完美解決。

內核僅僅要把內存給用戶就能夠了,怎麽管理怎麽使用全部用戶程序自己負責。操作系統內核應該把這樣的事情剝離出去。對於堆這樣的內存區段而言。它在內核中的地位確實非常尷尬。它本來代表著一種內存管理方式,即一段連續的內存隨著程序的分配和釋放被切分為不同大小的小段,這些段組成一個堆數據結構,確實。在史前的實模式時代。物理內存確實是這麽管理的,可是當保護模式明白區分了內核空間和用戶空間時。內存的堆式管理就被移植到了C庫,實際上它們一直都在C庫,僅僅是說後來C庫被安排到了用戶空間而已。結果內核態的進程地址空間中就空留了一個堆的影子。卻早就沒有了堆的實質。這樣的heap夾在程序和庫之間的可能性是存在的,你自己通過遍歷/proc/xx/maps就能夠知道有多少,造成這樣的尷尬的原因在於可運行文件布局原則和進程空間布局原則的不一致性,解決這樣的不一致性當然要以可運行文件的建議為準,因為它是最先載入的。
還有一個尷尬的地方在於。假設一個進程的堆被夾在了可運行程序映像和動態庫映像之間,例如以下:
可運行程序|heap|動態庫1|大量空暇|...
那麽堆的擴展空間無疑受到了限制,緊跟著動態庫1的有大量的本能夠用於堆擴展的空間沒,卻因為動態庫1本身被取消了資格,因為堆必須是連續的,在Linux上它僅僅是添加brk來進行擴展。

假設將堆的概念取消,那麽地址空間變成以下這樣:
可運行程序|空暇1|動態庫1|空暇2|...
因此空暇1和空暇2能夠組織成一個鏈表。在該鏈表中能夠採用夥伴系統或者別的不論什麽的管理算法。堆空間的連續性當初僅僅是為了讓內存使用更加緊湊,這是時代的遺留,就像當初確切劃分空間連續的代碼段,數據段,BSS段而如今不須要了一樣,對堆的連續性也要有新的理解。

畢竟在資源豐盈的年代做事就不能過於小家子氣。


對現代操作系統進程地址空間的想法