14 [虛擬化] 虛存抽象;Linux進程的地址空間
南京大學操作系統課蔣炎巖老師網絡課程筆記。
視頻:https://www.bilibili.com/video/BV1N741177F5?p=14
講義:http://jyywiki.cn/OS/2021/slides/10.slides#/
本講概述
程序 = 狀態機;進程 = 狀態機的執行(路徑)
- 狀態機的狀態由內存和寄存器(M,R)決定
- 寄存器會在發生中斷之后保存到進程的內存(內核棧)中
- 內存呢?
虛存抽象:
- 進程的地址空間
- 分頁機制
- 分頁機制和虛擬存儲
進程的地址空間
進程的地址空間中有什么
進程的地址空間 = 內存中若干連續的 “段”,每一段是可訪問的(讀/寫/執行)的內存,可能映射到某個文件和 / 或在進程間共享。
進程執行指令需要代碼、數據、堆棧:
- 代碼(如main,%rip會從此處取出待執行的指令)
- 數據(如static int x)
- 堆棧(如int y)
地址空間中還有:
- 動態鏈接庫
- 運行時分配的內存
以上這些都可以直接用指針訪問。
那么,這個地址空間是怎么創建的呢?創建之后,我們還可以修改它嗎?肯定是能的,如動態鏈接庫可以動態地加載。
管理進程地址空間的系統調用
// 映射
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);// 修改映射權限
int mprotect(void *addr, size_t length, int prot);
mmap的作用就是把磁盤文件的一部分直接映射到進程的內存中
說人話:在狀態機上增加或者刪除一段可訪問的內存。
把文件映射到地址空間?
它們好像的確沒什么區別:
- 文件 = 字節序列
- 內存 = 字節序列
- 操作系統允許這樣映射好像挺合理的,下一課中,ELF loader用mmap非常容易實現,解析出要加載哪部分到內存,然后直接mmap就完了。
查看進程的地址空間
pmap
pmap
命令可以查看某個進程的地址空間:
pmap [PID]
動態鏈接 / 靜態鏈接的地址空間
我們準備一個死循環C程序:
int main(){while (1);
}
分別用動態鏈接和靜態鏈接的方式來編譯它:
gcc test.c -o test_d.out
gcc -static test.c -o test_s.out
分別把得到的test_d.out
和test_s.out
后臺執行并用pmap
來查看它們的地址空間:
$ ./test_d.out &
[1] 5002
$ ./test_s.out &
[2] 5015pmap 5002
pmap 5015
分別得到動態鏈接和靜態鏈接的pmap如下:
5002: ./test_d.out
000055cfab135000 4K r-x-- test_d.out
000055cfab335000 4K r---- test_d.out
000055cfab336000 4K rw--- test_d.out
00007f26750a9000 1948K r-x-- libc-2.27.so
00007f2675290000 2048K ----- libc-2.27.so
00007f2675490000 16K r---- libc-2.27.so
00007f2675494000 8K rw--- libc-2.27.so
00007f2675496000 16K rw--- [ anon ]
00007f267549a000 164K r-x-- ld-2.27.so
00007f2675691000 8K rw--- [ anon ]
00007f26756c3000 4K r---- ld-2.27.so
00007f26756c4000 4K rw--- ld-2.27.so
00007f26756c5000 4K rw--- [ anon ]
00007fff1d64d000 132K rw--- [ stack ]
00007fff1d6cd000 12K r---- [ anon ]
00007fff1d6d0000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]total 4384K
5015: ./test_s.out
0000000000400000 728K r-x-- test_s.out
00000000006b6000 24K rw--- test_s.out
00000000006bc000 4K rw--- [ anon ]
0000000000e17000 140K rw--- [ anon ]
00007fff1bf5b000 132K rw--- [ stack ]
00007fff1bfc5000 12K r---- [ anon ]
00007fff1bfc8000 4K r-x-- [ anon ]
ffffffffff600000 4K --x-- [ anon ]total 1048K
可以看到動態鏈接比靜態鏈接多了很多動態鏈接庫.so
,占用的內存空間也較大。而通過ls -l
命令,我們發現動態鏈接生成的可執行文件所占的磁盤空間更小。
pmap的實現
我們不禁好奇pmap是怎樣實現的,可以通過追蹤系統調用的strace
工具來查看:
strace pmap 5002
實際上,我們多次強調過的一個概念:程序就是一個狀態機,而這樣一個狀態機想要得到操作系統里的任何東西,都要通過系統調用,所以當我們想知道pmap
這樣的程序是怎樣實現的,最好的辦法就是去看一下它執行了哪些系統調用,因此說追蹤系統調用的strace
工具是十分有用的。
言歸正傳,上述pmap
指令的輸出中最關鍵的是這一句:
openat(AT_FDCWD, "/proc/5002/maps", O_RDONLY) = 3
我們看到,pmap
是去讀/proc
文件中相關進程號的內存信息maps。(關于/proc
:linux /proc 詳解)
我們發現了什么寶藏?
我們直接看一下上面動態鏈接的可執行文件的進程:
cat /proc/5--2/maps
輸出:
55cfab135000-55cfab136000 r-xp 00000000 103:02 28869833 /home/song/CppProjects/test_d.out
55cfab335000-55cfab336000 r--p 00000000 103:02 28869833 /home/song/CppProjects/test_d.out
55cfab336000-55cfab337000 rw-p 00001000 103:02 28869833 /home/song/CppProjects/test_d.out
7f26750a9000-7f2675290000 r-xp 00000000 103:02 8393695 /lib/x86_64-linux-gnu/libc-2.27.so
7f2675290000-7f2675490000 ---p 001e7000 103:02 8393695 /lib/x86_64-linux-gnu/libc-2.27.so
7f2675490000-7f2675494000 r--p 001e7000 103:02 8393695 /lib/x86_64-linux-gnu/libc-2.27.so
7f2675494000-7f2675496000 rw-p 001eb000 103:02 8393695 /lib/x86_64-linux-gnu/libc-2.27.so
7f2675496000-7f267549a000 rw-p 00000000 00:00 0
7f267549a000-7f26754c3000 r-xp 00000000 103:02 8393690 /lib/x86_64-linux-gnu/ld-2.27.so
7f2675691000-7f2675693000 rw-p 00000000 00:00 0
7f26756c3000-7f26756c4000 r--p 00029000 103:02 8393690 /lib/x86_64-linux-gnu/ld-2.27.so
7f26756c4000-7f26756c5000 rw-p 0002a000 103:02 8393690 /lib/x86_64-linux-gnu/ld-2.27.so
7f26756c5000-7f26756c6000 rw-p 00000000 00:00 0
7fff1d64d000-7fff1d66e000 rw-p 00000000 00:00 0 [stack]
7fff1d6cd000-7fff1d6d0000 r--p 00000000 00:00 0 [vvar]
7fff1d6d0000-7fff1d6d1000 r-xp 00000000 00:00 0 [vdso]
ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall]
前面都好理解,是我們進程執行時的代碼、數據、堆棧、動態鏈接庫等,但是最后那三個:vvar、vdso、vsyscall
是什么鬼?
vvar、vdso、vsyscall是什么鬼?
讓內核和進程共享數據 (內核可寫,進程只讀)
-
vvar
: 內核和進程共享的數據 -
vdso
: 系統調用代碼實現 (是操作系統的一部分) -
vsyscall
: (ffffffffff600000
這么詭異的地址???) -
是普通系統調用的包裝
- 曾經的 exception-less syscall 實現,但存在安全問題
- 依然存在,保持向后兼容
vsyscall 的例子
- 時間:內核維護秒級的時間 (所有進程映射同一個頁面)
- 例子:time (2)
- 我們甚至可以調試它
- 例子:time (2)
- getcpu:per-CPU 映射頁面
計算機系統里沒有魔法!我們理解了 Linux 進程地址空間的全部!
使用共享內存與內核通信
有些系統調用不陷入內核也可以執行,使用共享內存和內核通信!
- 內核線程在 spinning 等待系統調用的到來
- 收到系統調用請求后立即開始執行
- 進程 spin 等待系統調用完成
- 如果系統調用很多,可以打包處理
實現虛擬存儲:分頁機制
需求分析
我們的操作系統看到的內存是真實的物理內存,而為各個線程提供的,即線程看到的是虛擬內存。那么,操作系統怎樣事項這一虛擬化呢?
操作系統希望實現地址空間的管理(mmap、munmap API)
- 進程的地址空間是由若干 “段” 組成的,但是操作系統只擁有一個物理地址空間(物理內存)
- 操作系統需要
- 為進程存儲各個段的信息(例如在struct proc里)
- 在物理內存中實際分配內存( pmm->alloc() )
- 借助硬件的機制實現虛擬化 (CPU在執行用戶進程時,強制進行地址翻譯)
所以,我們需要一個函數 f:[0,M)→[0,M)f : [0,M) \rightarrow [0,M)f:[0,M)→[0,M),把 ”虛擬地址“ 翻譯為 ”物理地址“ ,畢竟我們真實的物理內存只有一份,fff 應當由操作系統控制,即應用程序不可見 fff。
操作系統為每個進程準備一個映射函數 fff ,當進程運行時,fff 被 ”加載“ 到CPU上,此后該進程每次訪問內存,都需要通過CPU上對應的 fff 來進行從該進程可見的虛擬內存到真實物理內存的映射,而該進程的任何越權訪問物理內存地址,都將觸發異常(缺頁?)。
應當注意,我們的 fff 有以下幾方面的要求:
- 支持 fff 在運行時動態地進行修改(mmap,munmap)
- 非常節約:fff 的存儲開銷必須遠小于實際使用的內存,總不能為了維護映射函數 fff 所使用的內存比實際要使用的內存還多
- 非常高效:因為每次訪問內存都要計算 fff, 因此其實現需要非常高效
分頁機制
把地址空間切成大小為 ppp 的 “頁面” ,比如在x86中,頁面大小為4KiB。只維護以頁面為單位的映射,而非整個物理內存大小的虛擬內存到整個物理內存的映射。這樣我們要維護:[0,M/p)→[0,M/p)[0,M/p) \rightarrow [0,M/p)[0,M/p)→[0,M/p) 的映射。
我們有這樣一個基本假設:進程內存地址的空間局部性,即絕大部分頁面都沒有映射,且映射一般都是連續的空間
Radix Tree(Trie) + TLB(Translation Lookaside Buffer):
32位機和64位機的分頁尋址過程如圖所示:
分頁+保護:實現虛擬化
映射是頁面到頁面的,也就意味著映射的低位永遠是0,4kiB的頁面就會有12bits空閑,可以用來存儲頁面的存儲保護等信息。
分頁機制與虛擬存儲
mmap并不需要為進程分配任何頁面,只需要 “讓操作系統知道這么映射” 就夠了,進程訪問頁面會進入缺頁進入操作系統。
操作系統并不需要在這一段創建的時候,就立即給進程分配內存,而是操作系統完全可以等到進程真正訪問這個頁面并發生缺頁時,再去分配這塊內存。當然,如果操作系統根據之前的映射發現進程訪問的這塊內存是不合法的,就會Segmentation Fault。
缺頁
缺頁時操作系統會得到缺頁的地址(%cr2),根據操作系統維護的進程地址信息分配頁面。
Memory-Mapped File:一致性
這樣的設計也有些問題需要明確,比如:
- 如果把頁面映射到文件
- 修改什么時候生效(立即生效,會造成大量的磁盤IO;等到unmap或者進程結束在生效,又太遲了)
- 若干映射到同一個文件的進程(共享一份內存?各自有本地的副本?)
Takeaways and Wrap-up
虛擬化
- 程序 = 狀態機 (進程的地址空間里到底有什么)
- 操作系統 = 狀態機的管理者,借助硬件(物理狀態機)實現多個并發執行的虛擬狀態機(進程)
- 狀態機中的地址空間(虛擬地址空間)
- 應用視角:用mmap系統調用管理
- 硬件視角:用分頁機制實現