前记
这段时间的确太忙了,有很长一段时间没有动笔写报告了,这段时间pyos的开发也基本上处与半停滞状态,前两天刚建了一个pyos的cvs(http://pyos.binghua.com),希望已后的开发可以稍微高效一点~~~:)
近来没做什么东西,只完成了一个系统调用的实验,言归正传,下面我还是来介绍一下我的这个实验吧。
实验综述 与 操作系统中系统调用的必要性
每一个操作系统都有一个很重要的组成部份,这就是系统调用。操作系统通过系统调用为各个进程提供服务,而各个进程也通过相应的系统调用取得操作系统服务。系统调用是连接各个应用程序与操作系统的桥梁,是它们之间的交互界面,也是对程序的编译链接具有直接影响。最常见的一个系统调用就是内存分配函数(如C语言的malloc),这个函数将返回一块内存的指针给应用程序。简单的一看,好像系统调用与普通函数没有什么分别,但要是仔细看一看,就会发现它们之间还是有相当大的分别的。
下面让我们来看一看另外一个系统调用的例子,屏幕输出函数(如C语言的printf),并以此来说明系统调用与一般的函数有什么不同。
下面我们来看看一般的函数是怎样编写的:
void myprint( char ch )
{
static int x_pos = 0 ; // 记录当前光标的x位置,初始等于0
static int y_pos = y ; // 记录当前光标的y位置,初始等于0
output( x_pos , y_pos , ch ) ; // 在屏幕的x_pos与y_pos输出字符ch,
// output是一个虚拟的函数,在我们的讨论中,它就可
// 以用来输出,至于它内部是怎么实现的,我们暂且不
// 用理会
++x_pos ; // 因为输出了一个字符,所以光标应自动下移一个位置,以便在打印新
// 来的字符的时候,不会覆盖掉原来的字符
}
上面的这段代码看似完全没有问题,但下面我们考虑,有两个程序A和B,它们都使用了这个普通函数,于是当它们被载入内存中的时候就会出现如下图所示的情形:
从上图中我们可以很明显的看到,在这种情况下,A和B都含有myprintf这个函数一份独立的拷贝,也即A和B均使用其自身的x_pos与y_pos定位当前光标位置。于是这就出现了这样一个问题:
假设A先运行,x_pos与y_pos被初始化为0,这是A打印了五个字符,那么,x_pos的值应当为5,而这时轮到B运行了,由于B使用的是自身的x_pos与y_pos,因此这时对于B来说,x_pos与y_pos的值仍是0,于是B将在(0,0)位置开始打印其字符,也就是说,B运行的结果会覆盖先前A运行的结果。那么我们再想一想,在DOS或Linux等系统中,你先运行一个程序,再运行第二个程序,第二个程序会覆盖第一个程序的运行结果吗?
产生上述不当产生的问题的原因就在于系统内存空间中存在着两份x_pos与y_pos,也就是说相当于存在着两个光标,而实际上系统只有一个光标存在,因此,只因应当有一份x_pos与y_pos,它们被所有的进程所共用。
于是我们是否就可以改动一下系统的接构,只提供一份myprintf函数,而不同的进程都通过一个类似于函数指针的东东来调用它,那么在内存空间中就只会出现一份x_pos与y_pos,这样就不会产生上述的问题了。那么到底由哪个进程来保留这份唯一的myprintf呢?是用A进程还是用B进程呢?
显然A和B进程都没有能力担任这个责任,因为保留有这唯一一份myprintf的进程必须一直存在于内存当前,以便让不同的进程使用,而A和B很有可能随时被调出内存;其次,这样的进程必须在系统一启动的时候就存在于系统当中,或者是第一个被调入系统内存的,这样才能保存在其它进程被调入内存时,就可以使用myprintf了。由于含有这个myprintf拷贝的进程需要有这些特性,于是我们不难想到把它直接做到内核中,让它成为系统的一部份,无疑是最为方便的。于是,当我们将其采用这种模式重新建构我们的系统后,内存空间的分布便有可能如下图所示:
这样,由于系统中只存在一份myprintf,所以就只有一份x_pos与y_pos,这样就不会出现上述的情况了。由于myprintf目前被做在了系统中,因此我们可以将其称为一个系统调用,其含义为:A和B通过myprintf这个系统调用使用系统所提供的屏幕输出功能,在屏幕上输出信息。
从上面的描述中我们也可以看出系统调用与一般函数不同的地方在于,它们并不被真正的链接到进程中,而在每个进程中只是存放了一个可以调用它的指针,它们只存在于系统中,且只有一份。
那么各个进程是怎样或得这个系统调用的调用入口的呢?系统内部又是怎样实现这种系统调用的呢?这就是本实验将要解决的问题。在这个实验中我们实现了一个屏幕输出的系统调用,当然,我们会继续用pyos做为例子,进行我们的实验。
调用门
从上面的描述中我们可以看出,要想使myprintf能够被所有的进程调用,那么指向它的指针,也即这个系统调用的地址必须是固定的,这样就出现了这样一个问题,到地应当把这个指针放在什么样的一个地方,这样才能使操作系统内核发生改动的时候,这个系统调用的地址却没有改变。仔细分析一下会发现这似乎是一个不可完成的任务,因为操作系统一旦发生变动,我们很难控制其内部的各个部份在内存中的布局即在内存中的存放位置不发生变动,那么怎么解决这个问题呢?我们可以想到在系统中开避这样一个空间,让它存储这个系统调用的指针,那么不论系统怎样改变,只要程序能找到这个空间,它就能获得这个系统调用的指针,于是它就可以调用这个系统调用。那么现在的问题在于,到底把它放在什么空间中,才能找程序能准确定位到呢?这个时候,我们想到了“描述符表”(注,有关描述符表的内容,在《操作系统引导探究》中有详细描述),因为当系统启动的时候,系统后把描述符表载入内存,并将它的地址告诉cpu,于是,cpu是知道它的地址所在的,我们如果在“描述符表”中,专门指定一个位置,比如“二号描述符”用来存放myprintf的指针,这样,程序每次只需告诉cpu使用“第二号描述符”,那么cpu变可得到myprintf的地址,于是变可以调用myprintf进行运行了。这样一来,系统的内存布局就不再被限定为不能改变了,而只需做到始终保持描述符表中的“第二号描述符”是指向myprintf的,那么操作系统升级后,原来的应用程序却不需进行改动。而做到保持上述的一点,对于系统来说,就是一件轻而易举的事情了。
“描述符表”中存放的都是描述符,如果一个描述符保存的是一个函数的地址,那么我们就称之为“调用门”,意味着,通过它可以去调用一个函数。下面我们就来看看这个调用门的结构:
上图就是调用门的结构,偏移量是指此调用门所指向的函数在内存中的偏移量,段选择子是此调用门所指向的函数所用的段选择子,双字计数是当如果在不同特权极之间使用调用门,这将引发特权级转换,而由于不同的特权级使用自己独立的堆栈,于是要发生堆栈切换,在发生堆栈切换的时候,需要指出从上一个堆栈中要拷贝多少的双字字节到新的堆栈中(因为函数的调用参数是通过堆栈传递的,因此需要把原堆栈中的参数拷贝到新的堆栈中)。双字计数就指出了需要拷贝的数量,不过它是以双字为单位的。类型字段需要指定为1100(0xC)以表明此是一个调用门。DT是说明此描述系的类型,为0表示它是一个门描述符(门描述符不仅是指调用门,还有中断门,陷阱门等)。P是存在位,表明它描述符是否可用。
从上面调用门的门描述符的结构中我们可以看出,调用门提供了足够多的信息,因此我们通过调用门,不仅可在相同特权级之间进行调用,还可以在不同的特权级之间进行调用,在不同特权极之间进行调用这对操作系统来说是一个非常棒的功能,这样我们完全可以把应用程序放在用户特权级,而把系统调用放在内核特权级。
好了,有关本实验所用到的知识性的东西已介绍完了,下面我们就来看看我们的实验。
pyos 中系统调用的实现
我们先来看看,被链入内核的真正的系统调用函数:
/* 此函数是系统函数,真正处理系统调用 */
void class_pyos_SystemLib_Video::Print( int x_pos , int y_pos , char ch , char style )
{
if( x_pos >= 0 ){ // 如果是 < 0 就表示在当前位置处打印
cursor_x_pos = x_pos ;
}
if( y_pos >= 0 ){
cursor_y_pos = y_pos ;
}
switch( ch ){
case '\n' : // 回车符
cursor_x_pos = 0 ;
++cursor_y_pos ;
break ;
default : //可打印字符
/* 通过光标位置,计算偏移量 */
unsigned short offset = cursor_y_pos * 80 + cursor_x_pos ;
/* 显示字符 */
char* video = ( char* )0xb8000 + offset * 2 ; // 因为一个字符占两上字节,所以偏移量要 *2
*video++ = ch ;
*video = style ;
// 移动光标到下一位置
++cursor_x_pos ;
break ;
}
/* 重新设置光标位置 */
SetCursor( cursor_x_pos , cursor_y_pos ) ;
}
这个函数完成了在屏幕上输出指定字符,这就是一个被系统调用的函数。现在的问题是,我们需要给调用门一个此函数的指针,而现在这个函数是一个c++函数,它在内存中的指针却不易获得,所以我们还不能直接使用它的指针,而需要用一个C语言函数来帮忙,因为对于C语言函数来说,它的函数名就是它的指针,而函数名我们是知道的(C++编译器会对C++函数的函数名进行扩展,因此我们就无法知道它真实的函数名了L),于是我们能提供给调用门的就是这个C语言函数而不是C++函数的指针,下面就是我们C语言的包装函数:
/* 真实的系统调用接口函数,此函数由调用门调用,而此函数调用真正的系统函数来处理系统调用 */
extern "C" void pyos_true_invoke_video_print( unsigned int x_pos , unsigned int y_pos , char ch , char color )
{
class_pyos_SystemLib_Video::Print( x_pos , y_pos , ch , color ) ;
}
这个C语言函数非常简单,它其实就是直接调用的前面那个别C++语言函数。那么是不是直接使用这个C语言函数就行了呢?由于偏移器的缘故,普通的C语言函数在编译后都会是近调用,也就是说是段内调用,它在call时,只会将ip压入栈中,而通过调用门的调用却是一种远调用,也即常说的段间调用,它不旦要将ip压入栈中,还会将段选择子寄存器cs中的内容压入栈中,因此我们不能直使用这个C语言函数,而必须自己写一个汇编函数,而在这个汇编函数中来调用这个C语言函数;
pyos_asm_invoke_video_print:
;取得第四个参数( char style )
mov eax , [esp+20]
;取得第三个参数( char ch )
mov ebx , [esp+16]
;取得第二个参数( unsigned int y_pos )
mov ecx , [esp+12]
;取得第一个参数( unsigned int x_pos )
mov edx , [esp+8]
;再次压入参数,供C++程序调用
push eax
push ebx
push ecx
push edx
;调用相应的C++处理函数
call pyos_true_invoke_video_print
add esp,16
;返回
retf
这个汇编函数,首先从堆栈中取出传来的参数,然后,再依次将参数重新压栈,以备它所调用的C++函数使用,这里需要知道的是,c/c++的调用习惯是从右至左将参数压栈,所以在这个汇编函数中也需要执行这样的夺栈规矩。另外我们可以看见,这个汇编函数返回用的是retf指令,而不是通常的ret指令。retf指令是一个段间返回指令,这也是我们为什么需要用汇编来编写而不是直接用C语言来编写的原因。这个汇编函数就可以直接被调用门所使用了,下面,我们先用一个结构体来定义调用门的结构:
/* 调用门结构 */
struct struct_pyos_InvokeGate{
unsigned short Offset_0_15 ; /* 偏移量的 0~15 位 */
unsigned short SegSelector ; /* 段选择符 */
unsigned char DWordCount : 5 ; /* 双字计数字段 */
unsigned char Saved_0 : 3 ; /* 保留,需为 0 */
unsigned char Type_1100 : 4 ; /* 类型字段,调用门需为 1100 ( 0xC ) */
unsigned char DT_0 : 1 ; /* 需为 0 , 以表明这是一个系统用的描述符 */
unsigned char DPL : 2 ; /* 特权级 */
unsigned char P : 1 ; /* 存在位 */
unsigned short Offset_16_31 ;
} ;
由于调用门是存在于“描述符表”中的,因此,我们还需要在“描述符表”中声明一个调用门结构体的变量,这里,我们将此调用门安装在“全局描述符”表中:
/* GDT表 */
struct struct_pyos_Gdt{
struct_pyos_GdtItem gdtNull ; //空段,Intel保留
struct_pyos_GdtItem gdtSystemCode ; //系统代码段
struct_pyos_GdtItem gdtSystemDate ; //系统数据段
/* 系统调用门 */
struct_pyos_InvokeGate InvokeGate[ 2 ] ;
} ;
如上面代码所示,我们在GDT(全局描述符表)中声明了一个调用门数组,里面含有两个调用门(本实验实现了两个调用门,一个是屏幕输出调用,一个是清屏调用,原理是一样的,因此这里只描述屏幕输出调用,而有关清屏调用,请参看源代码。)下面,我们将在系统初始化式,调用一个函数来对这些调用门赋值,下面,我们就来看看这个函数的代码:
/* 系统调用类初始化 */
void class_pyos_SystemInvoke::Init()
{
/* 生成设置打印的系统调用门 */
struct_pyos_InvokeGate gate ;
gate.Offset_0_15 = ( unsigned int )pyos_asm_invoke_video_print ;
gate.DPL = 0 ;
gate.DT_0 = 0 ;
gate.P = 1 ;
gate.Saved_0 = 0 ;
gate.SegSelector = 0x8 ; /* 代码段 */
gate.Type_1100 = 0xC ;
gate.Offset_16_31 = ( unsigned int )pyos_asm_invoke_video_print >> 16 ;
class_pyos_System::m_gdt.InvokeGate[ VIDEO_PRINT_INVOKE_NUMBER ] = gate ;
/* 生成清屏的系统调用门 */
gate.Offset_0_15 = ( unsigned int )pyos_asm_invoke_video_clear ;
gate.Offset_16_31 = ( unsigned int )pyos_asm_invoke_video_clear >> 16 ;
class_pyos_System::m_gdt.InvokeGate[ VIDEO_CLEAR_INVOKE_NUMBER ] = gate ;
}
上面的程序应当是比较清析的,因此,这里我们就不在多说了。到此步为止,系统调用是做好了,接下来的就是需要在别的进程中调用这个系统调用,那么别的程序是怎么调用这个系统调用的呢?在pyos中,这同样是通过一个函数来实现的,下面这个函数就是连接进程与系统调用的桥梁,进程调用它,而它调用系统调用:
/* 打印字符信息,此函数封装系统调用,被链入每个进程 */
void class_pyos_Video::Print( char msg , enum_pyos_Color front_color , bool flash , enum_pyos_Color back_color , bool add_light )
{
/* 组合生成系统调用的参数 */
struct_pyos_VideoItem v ;
v.Char = msg ;
v.FrontColor = front_color ;
v.Flash = flash ;
v.BackColor = back_color ;
v.AddLight = add_light ;
/* 产生系统调用,调用调用门 */
// 参数压栈,按从右到左( <-- )的顺序
char ch_tmp = v.Style ;
__asm__( "mov %0,%%eax" : "=m"(ch_tmp) ) ; // 压入风格参数
__asm__( "push %eax" ) ;
ch_tmp = v.Char ;
__asm__( "mov %0,%%eax" : "=m"(ch_tmp) ) ; // 压入字符参数
__asm__( "push %eax" ) ;
int int_tmp = -1 ;
__asm__( "mov %0,%%eax" : "=m"(int_tmp) ) ; // 压入 y 坐标值,为负表是在当前位置打印
__asm__( "push %eax" ) ;
__asm__( "push %eax" ) ; // 压入 x 坐标值
// 调用调用门
__asm__( "lcall $0x18,$0" ) ;
// 恢复栈
__asm__( "add $16,%esp" ) ; // 4 * 4 = 16
}
上面的代码首先构造系统调用所需要的参数,然后,将这些参数按照从左到右的顺序压栈,最后,用一条汇编指令调用系统调用:
__asm__( "lcall $0x18,$0" ) ;
这里需要注意的是,第二个参数$0,本来是偏移量,但对于cpu来说是没用的,应为cpu将使用调用门中的偏移量,但这里又不能省略,因为编译程序指定了“lcall”指令需要两个参数。$0x18是这样计算出来的。仔细看看前面有关GDT表的结构,你会发现第一项是个空项,第二项是代码段描述符,第三项是数据段描述符,而第四项才是一个调用门,由于每一项是8B,所以前面共占用了 3 * 8 = 24B = 0x18B,也就是说从 0x18 这个偏移量开始,才是调用门,所以这里是指定为 0x18。
下面我们来看看进程是怎么调用它的:
/* 内核主函数 */
extern "C" void Pyos_Main()
{
/* 系统初始化 */
class_pyos_System::Init() ;
/* 清屏,打印欢迎信息 */
class_pyos_Video::Clear() ;
class_pyos_Video::Print( "Welcome to pyos~~" ) ;
for( ; ; ){
__asm__( "hlt" ) ;
}
}
这个函数就非常简单了,它使用的就是前面的class_pyos_Video这个类的Print函数,而Print函数,通过调用门调用了系统调用。
本实验中 pyos 的文件结构及调用流程
写到这里,本实验其实已经完成了,但由于在本实验中混合了C++、C、汇编三种代码,且进行了多次控制权的转移,pyos的结构也进行了一些调整,因此,我在此处在详细说明一下文件结构及调用流程。
目前你所看到的调用流程是这样的:
上图中,带有::的是C++函数,带有asm的,是汇编函数,其余的是C函数。
本实验的pyos文件结构中system_lib,是保存实际系统调用处理函数库的文件,system_invoke,是保存与实际调用处理函数库接口的一些C函数,以及初始化系统调用门函数的文件,system_invoke.asm是与每个系统调用函数相对应的汇编函数所存在的文件。这三种文件只被链入内核,它们不被链入进程,以次保证系统中只含有一份代码。
video这个文件中包含了一个显卡处理类,它提供了一些重载的Print()函数,这些函数最终都是通过调用门来调用相应的系统调用,它们的存在使得进程编程更加简便,而不需要每个进程自己去调用调用门,它们都会被链入进程,这样虽然它们在进程中存在着很多份拷贝,但由于它们最后都将调用同一个系统调用,而这个系统调用的代码在内存中却是唯一的,这样就解决了本报告开篇所提出的那个问题。
本实验所遗留的问题
首先,一般而言,系统调用均涉及一些临界资源的分配(所谓临界资源,就是只同一时刻只能由同一个进程所使用),因此,在系统调用中需要做好同步与互斥,可惜的是,本实验并没有体显这一点,在经后的实验中我们将会对此进行详细描述。
其次,调用门支持多种调用方式,前面在描述调用门结构的时候已经对此做了说明,最明显的也最常用的就是在使用调用门的时候发生了特权极的转换,比如从用户级转到内核级,不过本实验并没有体显出这一点,有兴趣的朋友,可以看看本报告的参考资料一《80X86汇编语言程序设计教程》,上面有很详尽的描述。另外,在以后的实验及pyos的实现中,我们也将对此进行描述。
本次实验报告到此也算告一段落了,望各位老师同学多多指教。
参考资料:
《80X86汇编语言程序设计教程》杨季文著,清华大学出版社,1998年6月第一版
《Pentinum Family User’s Manual Volume 3: Architecture and Programming Manual》Intel,1994
原文: http://purec.binghua.com/Article/ShowArticle.asp?ArticleID=147