任振强
(国家知识产权局专利局专利审查协作天津中心,天津 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]和定制化提供了一个新的思路,并为嵌入式控制提供了一个新的方案。