文章目錄
- 虛擬地址空間
- 用戶空間
- 內核空間
- 用戶空間內存分配
- malloc
- 內核空間內存分配
- kmalloc
- vmalloc
虛擬地址空間
在早期的計算機中,程序是直接運行在物理內存上的,而直接使用物理內存,通常都會面臨以下幾種問題:
- 內存缺乏訪問控制,安全性不足
- 各進程同時訪問物理內存時,可能會產生訪問內存空間重疊的現象,沒有獨立性
- 物理內存極小,而并發執行進程所需又大,容易導致內存不足
- 進程所需空間不一,容易導致內存碎片化問題。
基于以上幾種原因,Linux通過 mm_struct
結構體來描述了一個虛擬的,連續的,獨立的地址空間,也就是我們所說的虛擬地址空間。
原理: 當程序被載入內存時,向其呈現出比實際擁有的地址空間大得多的內存——虛擬地址空間,讓程序誤認為自己目前獨占電腦內存,能夠占用電腦所有的內存,訪問所有內存地址,同時建立虛擬地址與物理地址之間的映射。這就允許多個程序可以同時運行且各個程序之間能夠訪問的物理內存區域不重疊,也杜絕了程序直接操作地址的風險,同時也提高物理地址的使用效率。
值得注意的是,在建立了虛擬地址空間后,并沒有立刻分配實際的物理內存,而是當進程需要實際訪問內存資源的時候,才由內核的 請求分頁機制
產生 缺頁中斷
,這時才會建立虛擬地址和物理地址的映射,調入物理內存頁;如果此時物理內存已經耗盡,則根據內存替換算法淘汰部分頁面至物理磁盤中。通過這種方法,就能夠保證我們的物理內存只在實際使用時才進行分配,避免了內存浪費的問題。
下圖則為Linux下的虛擬地址空間:
32位Linux
的地址空間(232 B = 4 GB)被一分為二:0~3G為用戶空間 , 3~4G為內核空間。
- 操作系統和驅動程序運行在內核空間 ,內核模式下,操作系統可以訪問機器的全部資源。
- 應用程序運行在用戶空間 , 用戶模式下,應用程序不能完全訪問硬件資源。
當進程運行在 內核空間 時,它就處于 內核態 ;當進程運行在 用戶空間 時,它就處于 用戶態 。兩個空間不能簡單地使用指針傳遞數據,因為 Linux
使用了虛擬內存機制,用戶空間的數據可能被換出,當內核空間使用用戶空間指針時,對應的數據可能不在內存中。
用戶空間
用戶空間即進程在用戶態下能夠訪問的虛擬地址空間,每個進程都有自己獨立的用戶空間,大小為 3G
。
用戶空間由以下部分組成:
- 棧: 棧用來存放程序中臨時創建的局部變量,如函數的參數、內部變量等。每當一個函數被調用時,就會將參數壓入進程調用棧中,調用結束后返回值也會被放回棧中。同時,每調用一次函數就會在調用棧上維護一個獨立的 棧幀 ,所以在遞歸較深時容易導致棧溢出。棧內存的申請和釋放由 編譯器 自動完成,并且 棧容量由系統預先定義 。棧從高地址向低地址增長。
棧幀從低到上依次是(從高地址到低地址的方向):
- 參數
- 返回地址:將當前代碼區
調用函數指令
的下一條指令地址
壓入棧中,供函數返回時繼續執行。 - ebp(幀指針):指向當前的棧幀的底部
- 局部變量
- esp(棧指針): 始終指向棧幀的頂部
- 文件映射段: 也叫共享區,文件映射段中主要包括 共享內存、動態鏈接庫 等共享資源,從低地址向高地址增長。
共享資源以動態鏈接庫為例:
-
動態鏈接庫中的函數都與位置無關,即每次被加載進入內存映射區時的位置都是不一樣的,因此使用的是其本身的邏輯地址,經過變換成線性地址(虛擬地址),然后再映射到內存。
-
而靜態庫被鏈接到可執行文件中,因此其位于 代碼段 ,每次在地址空間中的位置都是固定的。
- 堆: 堆用來存放動態分配的內存。堆內存由 用戶 申請分配和釋放,從低地址向高地址增長。不同于數據結構中的堆,存儲空閑內存的方式類似鏈表,因此空閑內存分布不連續。
- BSS段: 存放程序中
未初始化
的全局變量
和靜態變量
,全局變量未初始化
時,其默認值為0
,因此也保存 初始化為0的全局變量 。具體體現為一個占位符,并不給該段的數據分配空間,只是記錄數據所需空間的大小。 - 數據段: 存放程序中
已初始化
的全局變量
與靜態變量
。 - 代碼段: 存放程序執行指令,也可能包含一些只讀的常量(
.rodata段
)。這塊區域的大小在程序運行時就已經確定,并且為了防止代碼和常量遭到修改,代碼段被設置為只讀。 - 保留區(受保護的地址): 大小為128M,位于虛擬地址空間的最低部分,未賦予物理地址。任何對它的引用都是非法的,用于捕捉使用空指針和小整型值指針引用內存的異常情況。它并不是一個單一的內存區域,而是對地址空間中受到操作系統保護而禁止用戶進程訪問的地址區域的總稱。
大多數操作系統中,極小的地址通常都是不允許訪問的,如NULL。C語言將無效指針賦值為0也是出于這種考慮,因為0地址上正常情況下不會存放有效的可訪問數據。
小結堆和棧的區別:
由于:
- 棧沒有內存碎片問題,堆容易造成內存碎片。
- 堆沒有專門的系統支持,效率很低,
- 堆可能引發用戶態和內核態切換,內存申請的代價更為昂貴。
所以棧在程序中應用最廣泛,函數調用也利用棧來完成,調用過程中的參數、返回地址、棧基指針和局部變量等都采用棧的方式存放。所以,建議僅在分配大量或大塊內存空間時使用堆。
內核空間
內核空間即進程陷入 內核態 后才能夠訪問的空間。雖然每個進程都具有自己獨立的虛擬地址空間,但是這些虛擬地址空間中的內核空間 ,其實都關聯的是 同一塊物理內存 ,如下圖:
通過這種方法,保證了進程在切換至內核態后能夠快速的訪問內核空間。
內核空間主要分為 直接映射區 和 高端內存映射區 兩部分:
直接映射區:
從內核空間起始位置開始,從低地址往高地址增長,最大為 896M
的區域即為直接映射區。
直接映射區的 896M
的 虛擬地址
與 物理地址(ZONE_DMA + ZONE_NORMAL)
的前 896M
進行直接映射,所以虛擬地址和分配的物理地址都是連續的。
那么它們是如何轉換的呢?其實它們之間存在著一個偏移量 PAGE_OFFSET
,偏移量的大小即為 0xC0000000
。
虛擬地址 = PAGE_OFFSET + 物理地址
高端內存映射區:
物理內存中 ZONE_DMA + ZONE_NORMAL
被直接聯系到虛擬內存的 直接映射區
中,那么對于剩下的 896M~4G
大小的 ZONE_HIGHMEM
,尋址工作就交給了高端內存映射區。
由于我們的內核空間只有 1G
,而直接映射區又占據了 896M
,因此我們將剩下的 128M
空間劃分成了三個高端內存的映射區,從上往下分別是:
- 動態內存映射區: 該區域的特點是 虛擬地址連續,但是其對應的物理地址并不一定連續。該區域使用內核函數
vmalloc
進行分配,分配的虛擬地址的物理頁可能會處于低端內存,也可能處于高端內存。 - 永久內存映射區: 該區域可以訪問 高端內存 。使用
alloc_page(_GFP_HIGHMEM)
分配高端內存頁,或者使用kmap
將分配的高端內存映射到該區域。 - 固定內存映射區: 該區域的 每個地址項都服務于特定的用途 ,如
ACPI_BASE
。
用戶空間內存分配
malloc
在C語言中,我們可以使用 malloc
來在用戶空間中動態的分配內存,而 malloc
作為庫函數,其本質就是對系統調用進行了一層封裝,因此在不同的系統下其實現不同。
在Linux中,當我們申請的內存小于 128K
時,malloc
會使用 sbrk
或者 brk
在堆區分配內存。而當我們申請大于 128K
的大塊空間時,會使用 mmap
在映射區進行分配。
但是由于上述的 brk/sbrk/mmap
都屬于系統調用,因此當我們每次調用它們時,就會從用戶態切換至內核態,在內核態完成內存分配后再返回用戶態。
倘若每次申請內存都要因為系統調用而產生大量的CPU開銷,那么性能會大打折扣。并且堆也有容易產生內存碎片的問題。
malloc是如何實現解決這個問題的呢?
為了減少內存碎片和系統調用的開銷,malloc
在底層采用了 內存池 來解決這個問題。
它會先申請大塊內存作為堆區,然后將這塊內存拆分為多個不同大小的內存塊,以 塊 作為內存管理的基本單位。同時,會使用 隱式鏈表 來連接所有的 內存塊 ,包括已分配塊和未分配塊。為了方便內存空閑塊的管理,malloc
采用 顯式鏈表 來管理所有的 空閑塊 。
當我們調用 malloc
進行內存分配時,就會去搜索空閑鏈表,找到滿足需求的內存塊,如果內存塊過大,則會將內存塊拆分為兩部分,即一部分用來分配,另一部分則變為新的空閑塊。
同理,當我們釋放內存塊時,會通過遍歷隱式鏈表,判斷釋放塊前后內存塊是否空閑,來決定是否需要合并內存塊
內核空間內存分配
在內核空間中,通過與 malloc
類似的兩個系統調用來進行內存的分配,它們 分別是 kmalloc
和 vmalloc
.
kmalloc
kmalloc
用于為內核空間的 直接內存映射區 分配內存。
kmalloc
以字節為分配單位,通常用于分配小塊內存,并且 kmalloc
確保分配的頁在 物理地址 上是 連續的 ( 虛擬地址 也必然 連續 ) 。并且 kmalloc
為了防止內存碎片的問題,其底層頁面分配算法是基于 slab分配器 實現的。
vmalloc
vmalloc
用于為內核空間中的 動態內存映射區 進行內存分配。
vmalloc
分配的內存 只保證了虛擬地址是連續的,而物理地址不一定連續 。它 記錄非連續的物理內存塊至頁表 ,再通過 修正頁表的映射關系 ,把內存映射到虛擬地址空間的連續區域。
如上圖,就是內核空間中進行內存分配的具體流程。