任振強
(國家知識產權局專利局專利審查協作天津中心,天津 300304)
MTX是美國華盛頓大學電子工程與計算機科學系教授WANG K C于2015年發布的一款小型操作系統[1],該系統基于Intel x86體系結構,具有結構標準的類Unix內核,可在x86的實模式下實現內存管理,進程控制,進程調度,線程、進程間消息、管道、中斷處理,輸入輸出設備驅動等典型的UNIX/Linux內核[2]功能。此外,MTX還支持對稱多處理器結構(SMP),并使用了與Linux兼容的EXT2文件系統;其所有功能模塊均能在編譯時進行裁剪選擇,編譯后內核體積可以控制在幾十甚至十幾KB。而且由于MTX內核模塊間耦合度極低,因此可以自由調整進程調度功能以滿足實時性要求。整體而言,MTX操作系統具有高度的靈活性和較低的學習門檻,非常適合于課堂教學和二次開發。
目前主流的類UNIX操作系統均不在實模式下運行[3-4],國內開發者雖然對UNIX/Linux內核設計原理較熟悉,但對MTX的實現方案并不了解。因此本文選擇對MTX內核的實現代碼進行研究,重點分析靠近底層硬件的進程切換和系統調用代碼,并且對系統引導程序設計要點進行總結,整體通過進程視角對MTX操作系統進行初步分析。
MTX操作系統內核在Linux環境下使用BCC(Bruce’s C Compiler)Cross-Compiling Package進行編譯和鏈接。MTX使用BCC而不是GCC進行編譯,因為BCC不僅能編譯生成8086兼容的16位匯編代碼,還可以基于單一內存段(one-segment memory)模型對代碼進行編譯。
工作于實模式的CPU以64 KB分段的形式訪問物理內存的最低1 MB地址,CPU設置了4個16位段寄存器CS、DS、SS、ES,分別用于標記代碼(Code)段、數據(Data)段、棧(Stack)段和附加(Extra)段。BCC編譯生成的CS、DS和SS完全重合,相當于只有一個段,簡化了內存模型,方便了程序的初始化。由于只有一個段,程序可以被載入內存中任意一個可用段中運行,加載和啟動更為靈活。
內核作為MTX操作系統的第0進程,其加載和啟動方式有一定特殊性,同時又影響著其他進程的加載和運行。
計算機硬件啟動后首先將BIOS載入內存運行,BIOS完成自檢并尋找可啟動設備進行啟動。具體而言,BIOS首先將啟動設備的起始512 B載入內存地址(0x0000,0x7C00),也就是物理地址0x07C00,隨后跳轉到該內存地址運行指令。該起始512 B即為引導程序。完整的引導程序往往大小會超過512 B,因此當計算機運行該512 B的代碼時,需要完成以下步驟:將引導程序的剩余部分載入內存運行,尋找操作系統內核并將其載入內存,最后運行操作系統內核的起始代碼。
在系統引導的過程中,引導程序會將其載入段地址為0x1000的內存段中,隨后引導程序會跳轉到0x1000段執行操作系統內核代碼。之所以將內核載入到0x1000段,一是為了給內核足夠的內存空間,二是為了方便進程管理和系統調用。MTX將第 1個用戶程序加載到內存0x2000段、第 2個用戶程序加載到0x3000段,并以此類推。當用戶程序執行系統調用后,CPU將返回0x1000段運行內核代碼。
引導程序的前512 B已經被加載到內存中,該512 B程序運行時不能覆蓋自身,因此通常將完整的引導程序加載到另外的內存地址。MTX的選擇與早期Linux內核zImage的引導程序相同[5],即內存的0x9000段。
引導程序運行時沒有任何庫可用,只能使用BIOS中斷INT13提供的讀寫功能。當完整的引導程序加載到0x9000段之后,執行以下指令:
jmpi start,0x9000
start:
mov ax,cs
mov ds,ax
mov ss,ax
mov es,ax
mov sp,8192
call _main
jmpi指令將代碼段寄存器設置為0x9000后執行start處的代碼。由于BCC編譯生成的CS、DS和SS段重合,因此該段代碼使用4個mov語句將所有段寄存器都設置為0x9000,之后通過“mov sp,8192”將棧指針設在偏移量為8KB的地址上,為后面的main函數提供了足夠的棧空間,隨后調用內核的main函數。可見,由于引導程序是由自己的前512 B代碼將自身載入內存,因此也必須自己為自己設置好所有的寄存器狀態。
此時操作系統尚未載入內存中,因此讀磁盤寫內存的I/O工作依然要通過BIOS中斷INT13來完成。main函數在EXT2文件系統中尋找到MTX內核文件,并將其載入內存的0x1000段。從main函數返回后,引導程序執行“jmpi 0,0x1000”,該指令將CPU的代碼段寄存器設置為內核加載地址0x1000,并運行段內第一條代碼。內核正式啟動。
MTX內核main函數的部分工作可簡化為如下算法:
while (1) {
if (readyQueue)
tswitch();
}
上述代碼只要readyQueue不為空則運行tswitch進行進程切換。readyQueue中保存著所有可運行的進程,tswitch從readyQueue中取出一個進程并將上下文(context)切換為該進程的上下文。完成切換之后,CPU將運行與main函數完全無關的另一個進程。如果其他進程運行tswitch()后又切換回上述內核進程,則此時CPU繼續運行tswitch之后的下一條語句。
要理解進程切換,必須對進程的數據結構進行了解。
操作系統使用一個進程代表一個程序的執行,內核通常使用一個進程控制塊(Process Control Block,PCB)代表一個進程[6]。MTX的進程控制塊具有如下結構:
typedef struct proc {
struct proc *next;
int *ksp;
…
int kstack[SSIZE];
} PROC
內核使用全局變量PROC proc[NPROC]保存系統中所有的進程,因此系統最多可以建立NPROC個進程。main函數在運行while(1)循環之前完成以下步驟:初始化proc[]數組,設置好proc[0]并將其關聯給內核自身,建立第一個程序的PROC結構proc[1],并將proc[1]加入readyQueue中。因此第一次運行while(1)循環時,判斷readyQueue不為空后,系統切換到proc[1]進程開始運行內核以外的第一個程序。
PROC中的next指針指向下一個進程,進程因此可以被放入進程隊列中,如前文中所述的readyQueue。ksp用于存放內核的棧指針;進程的內核棧kstack則位于PROC結構的最尾端。
PROC中next、ksp和kstack三個變量的順序至關重要:next是struct proc的第1項,ksp是第2項,kstack是最后一項。當running保存當前進程PROC結構的內存地址時,running+2為該PROC的ksp的地址,而running+sizeof(PROC)正好是其kstack數組中最后一項的內存地址,也就是空棧棧頂的位置。
進程切換的過程非常簡單:將當前進程的上下文存入當前進程的PROC,從另一個PROC中取出另一進程的上下文,最后用新進程的上下文設置CPU,從而使CPU運行新進程。MTX操作系統用全局變量PROC *running指向當前運行的進程。tswitch的代碼如下:
_tswitch:
SAVE:
push ax
push bx
push cx
push dx
push bp
push si
push di
pushf
mov bx,_running
mov 2[bx],sp
FIND: call _scheduler
RESUME:
mov bx,_running
mov sp,2[bx]
popf
pop di
pop si
pop bp
pop dx
pop cx
pop bx
pop ax
ret
SAVE過程保存上下文,push和pushf語句將MTX用到的CPU寄存器值壓入running的kstack棧中,再將running的值(即當前進程的PROC結構的地址)賦給bx,由之前的討論可知bx+2即為該PROC中ksp變量的地址,因此“mov 2[bx],sp”語句將棧頂地址保存在ksp變量中。至此,當前進程的上下文被保存至running的kstack棧當中。
與SAVE對應的RESUME負責對進程上下文的恢復,其完全是SAVE的逆向過程。保存和恢復之間的是進程調度函數scheduler。scheduler將當前進程的PROC放入readyQueue,從readyQueue中取出一個新PROC,并將running指向該PROC。而RESUME函數通過彈棧設置CPU寄存器,從而設置好新進程的上下文。
由上述代碼還可以看出,MTX的進程調度功能由scheduler完成,其不與系統其他部分耦合,可單獨更換以滿足實時性要求。
MTX內核的main函數在初始階段建立第一個用戶程序的PROC結構proc[1],tswitch()之后系統運行該程序。MTX內核建立新進程的過程被稱為fork,MTX內核中的函數原型為PROC *kfork(char *filename),kfork可以通過filename參數指定程序的路徑,并返回該程序對應的PROC的指針。例如,內核可在初始階段運行kfork(“/bin/shell”),將shell程序作為第一個程序,以允許用戶通過shell執行命令。
與Linux相同,MTX將內核所在的內核空間(Kernel space)與用戶程序所在的用戶空間(User space)分離。MTX的內核加載至內存0x1000段,proc1程序加載到0x2000段。因此0x1000段為內核空間,0x2000段為用戶空間。操作系統的核心功能由內核程序完成,當運行于User space的用戶程序需要使用核心功能時,其通過系統調用運行相應的內核函數。系統調用被稱為System call或syscall[7]。當用戶程序運行系統調用之后,CPU跳轉到0x1000段運行內核代碼,該過程通常被稱為陷入內核(trap)。而當內核代碼運行完畢之后,CPU返回User space內存段繼續運行用戶程序的代碼。
3.3.1進程的建立
MTX將內核加載到0x1000段,將用戶程序Uimage加載到0x2000、0x3000等段。內核的CS、DS、SS段寄存器均為0x1000,而用戶程序的段寄存器值為其所在的內存段地址。因此,fork建立第i進程的過程,就是將用戶程序Uimagei加載到內存0x(i+1)000段并初始化proc[i]的過程。加載用戶程序的原理與加載內核的原理相同,而初始化過程就是將proc[i]中的變量賦值為該進程對應的值。因此要了解fork如何賦值PROC結構中的變量,必須理解該變量在進程中代表的含義。
以第1進程proc1為例:MTX內核通過kfork語句加載Uimage1并初始化proc[1],執行tswitch()之后,內核設置Uimage1的上下文,并最終使CPU進入內存的0x2000段運行Uimage1。tswitch函數最后的ret語句設置CPU的返回地址,即CPU要執行的下一條指令的地址。該返回地址存儲在運行完“pop ax”之后的棧中,也就是kstack數組的最后一項kstack[SSIZE-1]之中。執行ret之后CPU將執行kstack[SSIZE-1]保存地址的語句,開始設置Uimage1的上下文。MTX內核中設置Uimage上下文的過程為goUmode函數。
與tswitch函數類似,goUmode函數使用pop類指令設置Uimage1運行上下文的CPU寄存器。Uimage1被加載到內存0x2000段,因此其CS、DS、ES均應為0x2000。這三個寄存器以及ax、bx等其他寄存器的值均保存在用戶程序的棧中,以便通過pop類命令進行彈棧。
一個用戶程序加載到哪個段無法由程序自身決定,因此用戶程序的棧是在kfork過程中建立的。kfork將Uimage1加載到0x2000段之后,手動在0x2000段結尾的空內存中開辟出proc1的棧,將CS、DS等所有寄存器的值寫入棧中,再把棧頂的內存地址寫回到PROC,以便goUmode過程能正確設置用戶程序的棧頂。
因此,PROC結構中應包含用戶程序的棧信息,MTX在PROC中設置了uss和usp兩項,以存儲用戶程序棧的ss和sp寄存器信息。添加uss和usp之后的PROC結構代碼如下:
typedef struct proc {
struct proc *next;
int *ksp;
int uss;
int usp;
…
int kstack[SSIZE];
} PROC
其中uss在proc結構中的偏移量為4,usp的偏移量為6,因此_running+4指向PROC中的uss,_running+6指向usp。因此goUmode的代碼如下,其中USS=4,USP=6:
_goUmode:
mov bx,_running
mov ax,USS[bx]
mov ss,ax
mov sp,USP[bx]
pop ds
pop es
pop di
pop si
pop bp
pop dx
pop cx
pop bx
pop ax
iret
正如前文所述,上述代碼通過PROC結構中的uss和usp設置好棧頂的位置,此后通過一系列pop命令設置CPU的寄存器,從而設置好用戶程序的運行上下文。當最后的iret命令執行后,CPU正式開始運行用戶程序。
3.3.2系統調用
當運行于User space的用戶程序需要內核功能時,例如運行于0x2000段中的proc1需要內核fork以建立新進程時,其使用系統調用(syscall)調用內核中的函數。由于用戶程序與內核分別位于不同的內存段中,proc1無法通過普通的函數調用(function call)來調用內核函數。因此系統調用需要做到:讓0x1000段中的kfork函數獲得0x2000段中proc1程序代碼的調用參數,運行kfork,再將結果返回給0x2000段中的程序代碼。其效果好像proc1運行了普通的函數調用一樣,但參數和返回值跨越內核空間和用戶空間進行傳遞。
跨越內核空間和用戶空間傳遞參數較為容易,其本質上是在兩個確定的內存地址之間進行復制,只不過二者位于不同的段中。而CPU則需要在用戶空間和內核空間的指令之間切換運行,因此在CPU進入內核空間前,必須對用戶空間的上下文進行保存,以便當內核函數返回之后,對用戶程序的上下文進行恢復,從而繼續運行用戶程序的代碼。恢復用戶程序上下文的代碼即是前述的goUmode,保存上下文的過程是goUmode的逆過程。而保存上下文之后則應為CPU設置內核棧,并使CPU開始運行內核函數代碼。
與Linux操作系統一樣,MTX以函數的形式進行系統調用,并在函數中觸發0x80中斷(INT80)。接收到INT80之后,CPU將當前標志位(flag)和CS、PC寄存器壓入用戶程序棧保存(與此對應,goUmode結尾的iret指令彈棧恢復這三個值),然后將預存于指定地址的數據載入PC、CS,從而開始運行與當前完全無關的指令。該預存的(PC,CS)被稱為中斷向量(interrupt vector),中斷向量保存在內存的0x0000段中。因此可以將內核函數地址(0x1000)作為中斷向量保存于0x0000段的指定位置。
INT80中斷處理程序的部分關鍵代碼如下:
mov bx,_running
…
mov sp,bx
add sp,_procSize
call _kcinth
_goUmode:
…
其中procSize為sizeof(PROC)的值,也就是PROC結構的大小。因此上述代碼將棧頂設置在當前進程的PROC結構的結尾,即kstack[]數組的最后一項,然后運行kcinth函數。kcinth函數所做的工作即如前文所述,從用戶空間取得系統調用的參數,根據參數調用內核函數,最后將函數的返回值復制回用戶空間。由于用戶程序以函數形式進行系統調用,因此返回值應覆蓋用戶程序棧中的ax寄存器。當kcinth返回后,CPU執行goUmode函數以返回User space繼續執行用戶程序。
MTX操作系統在實模式下構建了結構標準的類UNIX內核,其對硬件的要求極低,甚至可運行于8086 CPU上,為操作系統微型化[8]和定制化提供了一個新的思路,并為嵌入式控制提供了一個新的方案。