C語言與操作系統(tǒng)的內(nèi)存布局
?C語言之所以適合寫操作系統(tǒng),就在于它的內(nèi)存布局簡單:
1,所有的全局變量都被常量初始化,
2,不需要運行時的狀態(tài),
3,也不需要在main()函數(shù)之前運行額外的初始化代碼。
操作系統(tǒng)的初始化是很復(fù)雜的。
在C語言寫成的內(nèi)核main()函數(shù)運行之前,操作系統(tǒng)要運行一段很復(fù)雜的匯編代碼,以完成內(nèi)核的內(nèi)存初始化。
這段匯編代碼包含著很多重要的內(nèi)核全局數(shù)據(jù),它是由內(nèi)核作者精心定制的,沒法由編譯器自動生成。
對于內(nèi)核程序員來說,編譯器做的事越少越好,但是又不能像匯編器那么少?
C語言適合寫操作系統(tǒng),我覺得跟丹尼斯-里奇發(fā)明它的目的就是為了寫Unix有關(guān):不好用的地方已經(jīng)被優(yōu)化過了。
1970年,丹尼斯-里奇怎么一邊改unix系統(tǒng)的代碼、一邊改cc編譯器的代碼的咱就不回憶了。
這里說說C語言和操作系統(tǒng)的內(nèi)存布局。
1.C語言的內(nèi)存布局。
C語言編譯連接之后的可執(zhí)行文件,分為:
1) 代碼段(.text),
2) 只讀數(shù)據(jù)段(.rodata),
3) 數(shù)據(jù)段(.data),
4) 堆 (heap),
5) 棧 (stack),
其中需要存儲在文件里的只有前3個,
后2個在進程運行期間是動態(tài)變化的臨時數(shù)據(jù),并不需要存儲在文件里。
代碼段的權(quán)限是只讀+可執(zhí)行,
只讀數(shù)據(jù)段的權(quán)限是只讀,
數(shù)據(jù)段、堆、棧的權(quán)限都是可讀可寫的,但不能運行。
如果系統(tǒng)內(nèi)核發(fā)現(xiàn)了進程的內(nèi)存權(quán)限是錯誤的,那么就是段錯誤:信號是SIGSEGV。
*("hello") = 1;
這種代碼肯定是“段錯誤”的,因為常量字符串位于只讀數(shù)據(jù)段,它的內(nèi)容是不可寫的。
通過緩沖區(qū)溢出來覆蓋棧的返回地址的黑客代碼,也會被系統(tǒng)內(nèi)核發(fā)現(xiàn)運行地址不在代碼段,所以也是段錯誤。
2.內(nèi)核的內(nèi)存布局。
內(nèi)核的內(nèi)存布局,包含這幾個重要的全局數(shù)據(jù):
1)內(nèi)核頁表
它是內(nèi)核的虛擬內(nèi)存與物理內(nèi)存的映射。
在開啟分頁機制之前,就要設(shè)置好內(nèi)核頁表的前幾頁:
至少要把內(nèi)核代碼所在的內(nèi)存空間映射到頁表里,否則開啟分頁機制時就直接出錯了。
在32位機上,它是由頁目錄-頁表構(gòu)成的2級數(shù)組:
頁目錄里的每一項記錄每個頁表的物理地址,頁表里的每一項記錄每個內(nèi)存頁的物理地址。
在64位機上頁表的結(jié)構(gòu)更為復(fù)雜,intel手冊上有:我沒仔細看過,有興趣的可以看看。
1個內(nèi)存頁是4096字節(jié),所以物理地址的最低12位全是0,用來記錄每個頁的讀寫權(quán)限。
頁目錄里每項的最低12位,用于記錄它對應(yīng)的整個頁表的讀寫權(quán)限。
1個頁表記錄1024個頁,每個頁4096字節(jié),所以1個頁表管理4M的物理內(nèi)存。
2)中斷向量表
它存放各種硬件中斷、以及int 0x80軟件中斷的處理函數(shù),也叫中斷服務(wù)例程(irq)。
int 0x80軟件中斷,就是Linux系統(tǒng)調(diào)用的中斷號。
當(dāng)然,在64位機上,直接使用syscall匯編指令就行。
syscall的軟件中斷機制,是intel在64位上又新造的一種進入CPU ring0特權(quán)級的指令,使用方式跟之前的int指令不大一樣。
我懷疑intel的CPU研發(fā)也是有KPI的,怪不得Linus大牛也經(jīng)常吐槽intel的CPU設(shè)計。
一個版本加一個新的指令,純屬給系統(tǒng)軟件的開發(fā)者找難題?
中斷向量表,也是個256項的數(shù)組,每項都是某個中斷的函數(shù)指針。
在中斷被觸發(fā)之后,CPU就是靠這個數(shù)組去查找對應(yīng)的中斷處理函數(shù)的。
3)全局描述符表
它描述的是內(nèi)核的內(nèi)存布局,每項8個字節(jié),共256項。
但實際上,只需要使用前5項就行:
0x0,不使用,
0x8,內(nèi)核代碼段,
0x10,內(nèi)核數(shù)據(jù)段,內(nèi)核堆棧段,它們2個的權(quán)限一樣,可以共用一項。
0x20,任務(wù)門的描述項,
0x28,局部描述符表的描述項。
siska內(nèi)核demo的內(nèi)存布局
因為每項都是8字節(jié),所以地址都是8的倍數(shù)。
4)局部描述符表
它是用于進程的,進程因為跟內(nèi)核的權(quán)限不同,所以進程的段選擇符都在局部描述符表里:
內(nèi)核的段選擇符是0x8,進程的是0xf。
段寄存器CS、DS、SS,到了保護模式下都成了段選擇符,真正的內(nèi)存地址在GDT表里。
在16位的實模式下,它們才存儲真正的段的內(nèi)存地址。
5)任務(wù)門
CPU把每個進程看做一個任務(wù),所以要切換進程時需要任務(wù)門的描述結(jié)構(gòu)。
它是104個字節(jié)。
但是,Linux系統(tǒng)的進程切換是軟切換:任務(wù)門的描述結(jié)構(gòu)只在系統(tǒng)初始化時加載一次,具體的進程切換時只切換頁表和內(nèi)核棧,然后就可以騙過CPU了?
重新加載任務(wù)門的時間消耗比較大,而軟切換的時間消耗比較小。
intel的這個設(shè)計,也是不受Linus大牛待見的設(shè)計之一?
6)系統(tǒng)調(diào)用表
它也是一個大數(shù)組,它的每一項也是函數(shù)指針。
系統(tǒng)調(diào)用的入口是int 0x80軟件中斷(64位機上是syscall指令)。
進入內(nèi)核之后,每個號碼對應(yīng)一個系統(tǒng)調(diào)用。
open()、close()、write()、read(),這些系統(tǒng)調(diào)用都有各自的號碼,這些號碼就是系統(tǒng)調(diào)用表的數(shù)組索引。
如果open()的系統(tǒng)調(diào)用號碼是i,那么open()在內(nèi)核里實際運行的就是這行代碼:
syscall_table[i]();
7)物理內(nèi)存的管理數(shù)組
物理內(nèi)存的管理結(jié)構(gòu),是一個很大的一維數(shù)組。
假設(shè)物理內(nèi)存有4G,1個內(nèi)存頁是4K,那么這個數(shù)組的元素個數(shù)就是1024x1024,1M。
數(shù)組的每一項,記錄1個物理內(nèi)存頁的狀態(tài)。
如果每項是4個字節(jié)的話,那么管理效率就是:(4096-4) / 4096。
管理數(shù)據(jù)所占的字節(jié)數(shù)越多,對物理內(nèi)存的浪費越大。
get_free_pages()函數(shù),就是通過查看這個數(shù)組來分配物理內(nèi)存頁的。
因為內(nèi)核是一個高并發(fā)環(huán)境,這個管理結(jié)構(gòu)里必須要有自旋鎖,以控制多個CPU的并發(fā)訪問。
自旋鎖+引用計數(shù)就至少8字節(jié),所以這個數(shù)組也是非常浪費內(nèi)存的。
如果多個線程之間要共享內(nèi)存,那么只要把同一個物理內(nèi)存頁映射到這幾個線程的頁表里,然后增加物理內(nèi)存頁的引用計數(shù)就行:
這就是共享內(nèi)存在內(nèi)核里的本質(zhì)。
8)進程的頁表和內(nèi)核棧
進程的頁表和內(nèi)核棧,不屬于內(nèi)核的全局數(shù)據(jù),而是附屬于進程的局部數(shù)據(jù)。
內(nèi)核在調(diào)度某個進程的時候,就把頁目錄基地址寄存器cr3和棧寄存器rsp切換成這個進程的頁表和內(nèi)核棧。
不同的進程之間,之所以有各自的虛擬內(nèi)存空間,互相不干擾,就是因為每個進程的頁表不一樣。
要在進程之間共享內(nèi)存,也跟線程之間共享內(nèi)存一樣,把同一個物理內(nèi)存頁映射到它們各自的頁表就行。