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