在開啟保護模式之後(步驟見Linux使用bochs模擬啟動保護模式),CPU的運行分為3個方面:內存管理、任務管理、中斷管理。
1,內存管理,
一般是分頁管理。
單純的分段管理比較死板,在多進程系統裡處理起進程的動態創建和退出時不夠靈活,而且任務切換的代價太高:需要切換任務寄存器TR,以及由此導致一系列任務上下文的切換。
單純的分段內存+多任務方式,趙炯老師的書裡有個簡單的例子,固定寫好的2個任務,根據系統時鐘進行切換。從0換到1,從1換到0,與我在Linux的fork()系統調用這篇文章裡演示的例子差不多。
趙老師的代碼是內核態彙編代碼,需要一定的「計算機組成原理與彙編語言」基礎,有興趣的可以看一下。
Linux裡使用的是分頁管理,一個內存頁的大小是4096位元組(4k),而且進程的task_struct或者thread_info與內核棧是在一組連續的頁上的(且以4k * N對齊,N為這組頁的個數),見之前介紹內核current宏的文章:Linux內核獲取當前進程結構的current宏。
經典的32位分頁模式,是由頁目錄和頁表組成的二級數據結構管理,頁目錄的物理地址放在cr3寄存器裡,所以cr3也叫頁目錄基地址寄存器。
一個虛擬地址vaddr轉換到物理地址paddr時,它的最高10位用來在頁目錄中查詢頁表,中間10位用來在頁表中查詢具體的頁,最低12位是頁內的偏移量。
unsigned long vaddr;
unsigned long* pgtable = (unsigned long*)pgdir[ vaddr >> 22 ];
unsigned long page = pgtable[ (vaddr >> 12) & 0x3ff ];
unsigned long paddr = (page &~0xfff) + (vaddr & 0xfff);
無符號整數的右移>>是邏輯右移,高位添0,所以vaddr >> 22取最高10位時省略了& 0x3ff。當然,為了避免編譯器出漏子,也可以添上。
因為CPU使用頁表項的低12位記錄各種權限標誌,所以在計算物理地址時低12位要清零。在寫時複製copy-on-write時,要在對應的頁表項裡添加低12的權限標誌。
在打開分頁之後,頁目錄、頁表裡填寫的是物理地址,代碼裡使用的是虛擬地址,CPU的內存管理單元MMU依據cr3寄存器負責地址轉換。
如果是外部設備,它很大可能沒有MMU,所以DMA傳輸時還是需要物理地址。
Linux把內核空間放在3G-4G,所以物理地址在內核態時與虛擬地址有個3G的偏移量。在用戶態時,則要根據進程的頁表進行映射,這個是寫時複製,如果不寫則與父進程共享。
x64的內存管理,等我研究明白了英特爾那4卷天書再補充(捂臉
這個圖來自英特爾的4卷天書,可以看出線性地址到物理地址的轉換過程。
2,段描述符,
段描述符的結構如下,它可以是全局描述符表GDT或者局部描述符表LDT裡的一項,可以有多個,每項8個字節。
GDT:global descriptor table.
LDT:local descriptor table.
一般情況下,LDT表裡的段描述符要有代碼段、數據段、堆棧段。
GDT表裡除了代碼段、數據段、堆棧段之外,還要有LDT描述符、任務狀態段描述符,等等。
段描述符的結構都是大同小異的,分為3部分:基地址base、段限長limit、各種屬性欄位。
base是32位,limit是20位,都是分在了兩個4位元組整數裡,並不連續。
權限標誌都在第2個4位元組:
1)第23位,G,表示內存粒度的大小,置1表示4k,置0表示1位元組,所以20位的limit欄位可以表示2^20 * 4k = 2^20 * 2^12 = 2^32 = 4G內存空間。
段限長,表示段內的可以允許的最大索引號,是2^20 - 1,反正把第1個4位元組的低16位設置為0xff,第2個4位元組的16-19位設置為0xf,就行了。
2)13-14位,DPL,表示描述符的權限優先級,即所謂的ring0-ring3,內核段的這兩位都是0,用戶段的這兩位都是1(0b11 = 3),Linux不使用中間的ring1和ring2。
3)22位,D/B,默認操作數大小,在保護模式是32位,置1。
就算是64位代碼,默認操作數的大小也是32位。除了特別的指令之外,一般指令都是添加0x48前綴表示64位,添加0x66前綴表示16位。
4)15位,P,存在位,表示這個欄位正在內存裡,它是1時圖中的大多數項才有意義。
5)12位,S,表示是系統描述符(S = 0,任務、中斷等),還是代碼或者數據段描述符(S = 1)。
趙老師書裡的例子(第145頁),
代碼段的描述符是.quard 0x00 c0,9a 00,00 00,07 ff;
數據段的描述符是.quard 0x00 c0,92 00,00 00,07 ff。
前8位全是0,對應base的最高8位。
然後是c,即12,0b1100,對應23-20位,可以看出G為1(4k分頁),D/B為1(32位),L為0(不開啟64位模式),AVL 用戶可用欄位為0(OS暫時不使用它)。
然後是limit的高4位,也是0,即第2個8位是0xc0。
接下來的4位是9,0b1001,P為1(在內存裡),DPL為0(ring0),S為1(最後一位為1,表示是代碼或者數據段)。
最後8位是0x7ff,表示段的大小是2k個4k,即8M內存。0x7ff + 1=0x800=2048=2k。
6)8-11位,type欄位,在代碼段或者數據段裡,涉及到擴展方向、讀寫執行權限,見下圖。
數據段需要讀寫,所以第9位是1,8-11位組合一起是0x2。
代碼段需要執行,8-11位對應數字10,即0xa。
所以代碼段和數據段的8-16位,分別為0x9a和0x92。
3,任務管理,
任務相關的段描述符,屬於系統描述符,主要是任務狀態段TSS,它包含了任務的上下文信息,見下圖。
一堆寄存器,其中堆棧相關的SS和ESP分了3組,分別對應ring0 - ring2,在用戶態ring3不使用這個數據結構。
Linux的任務切換是軟切換,TSS結構只需要開始加載一次,之後整個系統內核和用戶進程都是當作一個任務來運行的(在CPU看來)。所以前段時間,Linus把英特爾懟了,說他們設計的機制太複雜,軟體開發人員不願意用:(
趙老師書裡的例子,兩個任務的描述符分別是:
.word 0x68,tss0,0xe900,0x00;
.word 0x68,tss1,0xe900,0x00。
其中的tss0和tss1分別是兩個任務的TSS的地址,這幾個字節描述的就是上圖的TSS。
因為使用了.word而不是.quard,所以與前面的代碼段和數據段的排列是反著的。
0x68 = 104,即任務狀態段TSS的段限長limit,tss0是第一個任務的TSS地址,這個數據結構是寫在head.s裡的,偏移量不會超過16位,因為head.s的代碼很短。
0xe9,0xe = 0b1110對應P DPL S欄位,S=0表示是系統段,P=1表示在內存裡,DPL=3表示是ring3權限,即運行的代碼是用戶態的。
0x9,對應下圖的32-bit TSS,即這個描述符的類型。
最高的16位元組全0,說明tss0的地址最高位是0,內存粒度是按1位元組表示的,即0x68的長度限制是104位元組。
開啟分頁之後,系統要模擬從中斷中返回,把權限從ring0降到ring3,進入0號進程idle。
只要把中斷返回的地址設置為C語言寫的main()函數,之後就不需要用彙編了。