萝卜甲@ZJU 2004 yujiazi@gmail.com
转载请保留以上信息
所有的人都应该明白设计的重要性。如果任何系统在你的眼中只是一段段代码的话,那么你可能有两种情况:一种是你特别牛,牛得过分,已经到达了“世间万象皆代码”的境界。还有一种就是你还没有了解一个系统在开始编码前设计的重要性。系统论的主要思想就是将复杂的问题分解成相对简单相对容易解决的小系统,小系统再分解直至完全能理解。OS就是这样一种复杂的系统,虽然我们今天要写的OS并不是那么的复杂,但是良好的设计基础可以使我们后来的编码工作游刃有余。所以,一个系统在我们普通人严重看来,最好是一个个模块,而不是一行行代码。OK!罗嗦了半天,我们要看看具体一点的吧。
一般的mono-kernel OS结构从上之下一般是这样:
-------- -------- -------- -------- -------- --------
| app1 | | app2 | | app3 | | app4 | | app5 | | app6 |
-------- -------- -------- -------- -------- --------
-----------------------------------------------------
| User library |
-----------------------------------------------------
// ||
-------------- || Message --------------- || SystemCall ---------
|| ||
|| //
-----------------------------------------------------
| OS Kernel |
-----------------------------------------------------
-----------------------------------------------------
| Hardware |
-----------------------------------------------------
这是很简单的mono-kernel OS的结构,所有的应用程序与用户库都运行在User mode,就是图中中间横线的上方。用户程序通过SystemCall来调用系统的功能,既然从上至下能主动通讯,那么OS主动需要跟用户程序通讯的时候怎么办?这就是我们这里看到的很Cool的Message。不过我们这里的Message机制其实也不是真正的OS主动调用用户程序,而是用户程序有一个主循环,不停地查询OS kernel是否有Message,如果有message的话就根据message的做出相应的处理。那么我们就从用户程序开始,一步一步往下挖掘,看看整个OS怎么实现的。先我们来看一下我们的weekos(我们暂时命名我们要写的OS为weekOS)下最简单的用户程序框架:
// sample.c
#include <weekoslib.h>
int main()
{
// do initialization
MESSAGE_S msg;
while(1)
{
GetMessage( &msg );
switch( msg.Type )
{
case KEY_PRESS:
// do something
break;
case MOUSE_MOVE:
// do something
break;
default:
SystemPrint( "Hello world!" );
break;
}
}
return 0;
}
这里我们可以看到,最开始的include里面包含了weekoslib.h,其实这个就是User library的头文件,就像写windows程序一般都有一个#include <windows.h>。weekoslib.h里面定义好了一些API和一些数据结构,比如我们刚才的sample.c里面的MESSAGE_S结构,还有GetMessage(), SystemPrint()等,等会儿我们再来看看User library里面具体是什么。sample.c被编译成sample.o以后跟用户库连接成可执行文件就是一个完整的应用程序了。
好了,先不扯开去了,让我们来看看这个用户程序的主要结构。首先是main函数,这就跟普通的C程序一样,是整个程序的入口,然后是做一些初始化工作,比如有GUI的话就画个窗口啥的,接下来就是一个主循环了,不停地获取Message,然后判断消息的类型从而做出相应的反应。怎么样?很简单吧?不过有了这个还没用,我们需要有一个能工作的用户库。
我们来做一个最简单的用户库,里面包含了MemoryCopy()和SystemPrint()还有GetMessage()这三个库函数:
// weekoslib.c
#include <weekoslib.h>
void StringCopy( void * From, void * To, unsigned int Size )
{
char * Ptr1 = From;
char * Ptr2 = To;
while(Size--)
{
*(Ptr2++) = *(Ptr1++);
}
}
void SystemPrint( char * String )
{
asm volatile( "movl %1,%%eax /n" /
"movl %2,%%ebx /n" /
"int $0x30 /n" /
::"i"(0x1),"m"(String):"eax","ebx" )
}
void GetMessage( MESSAGE_S * msg )
{
asm volatile( "movl %1,%%eax /n" /
"movl %2,%%ebx /n" /
"int $0x30 /n" /
::"i"(0x2),"m"(msg):"eax","ebx" )
}
第一个函数StringCopy()应该很好明白,就是一个普通的拷贝函数。第二个函数SystemPrint()可能对于没有接触过AT&T或者GCC内嵌汇编的朋友来说有些糊涂了,其实这句内嵌汇编很简单,写过DOS下汇编程序的应该都有印象,调用DOS系统一般都是设好相应的寄存器,然后调用int 20h或者int 21h来调用DOS,我们这里也是一样,翻译成Intel的汇编其实就是:
push eax
push ebx
mov eax,1
mov ebx,String
int 0x30
pop ebx
pop eax
我们可以看到其实就是跟DOS调用有异曲同工之妙,这里的eax,ebx等都被用来传递参数,我们假设eax里面放的是OS Kernel的功能号,比如0x1是SystemPrint,也就是系统输出字符串,0x2就是GetMessage,也就是获取消息等等,ebx,ecx等其他寄存器则根据不同的功能号有不同的分配。OK,然后把这些函数的申明写到weekoslib.h里面就是一个很不错的library啦!不过这只是代码而已,要变成真正能执行的代码还需要编译和连接。
首先我们编译用户库,命令行如下(假设使用gcc和ld):
gcc -Wall -W -O2 -ffreestanding -nostdinc -c weekoslib.c
这样我们就会得到一个weekoslib.o的一个obj文件,让我来分别解释一下gcc的这些选项:
-Wall 这个选项会产生所有的警告(warning),其实作为OS developer来说,应该有把"任何warning都当作error来对待"的态度。
-W 这个也是关于警告的,这个选项可以使一些额外的警告也会显示出来,理由同上。
-O2 这个是优化选项,一般都选2级优化。
-ffreestanding 和 -nostdinc 这两个选项都是OS develop特有的选项,这两个选项的作用是不使用标准的头文件和库文件。因为我们写自己的OS Kernel或者User application用的都是自己的库和头文件,不需要系统默认的。
-c 这个选项是"只编译,不连接",把.c文件编译成.o文件而不直接连接产生可执行文件,这一个连接过程由我们手工完成。
OK!这样我们就得到了一个weekoslib.o文件,这个就是我们自己制作的用户库。用同样的办法将sample.c编译成sample.o。下面我们就要将这两个文件连接起来变成可执行文件:
ld weekoslib.o sample.o -Tapp.ld -S -o sample.app
这样,weekoslib.o和sample.o就被连接成sample.app,这个就是我们最终得到的用户应用程序的可执行文件。下面让我们来看看着几个ld的具体选项。先看-o,这个是指定输出文件名,就是最终被连接好的文件名字,后面紧跟需要的文件名。-S是去掉.o文件所有调试信息而不写入到sample.app中。-Tapp.ld这个选项其实是由两个部分组成,-T和app.ld,-T是表示连接使用一个制定的脚本,用来描述和控制整个连接的过程,很明显, app.ld就是连接脚本文件,其实它是一个文本文件,里面具体的内容我们等会儿来分析,先让我们看一下目标文件的格式。
我们经常看到windows下的可执行文件是exe,还有dll甚至编译的中间文件obj等这些文件其实都是同一种格式叫PE(Portable Executable),而Linux下的.so或者可执行文件一般都是同一种格式叫ELF(Executable and Linkable Format)。ELF,PE等都是二进制文件的格式,还有一些不太常见的如COFF,A.OUT,OMF等,他们定义了整个文件的结构,一般都是查不都的结构,如下图:
----------------
| FileHead |
----------------
| Section1 |
----------------
| Section2 |
----------------
| Section3 |
----------------
| Section4 |
----------------
| : |
| : |
| : |
----------------
| SectionN |
----------------
文件的开头一般都有一个FileHead,用来描述整个文件的总体结构,比如文件大小啊,文件入口地址啊(就是 main() 的地址),文件段(Section)的数量啊,位置啊等等,接下来一般就是文件段(Section)了。最主要的一般有三个段: Code段,Data段和BSS段。Code段里面放的就是实际的指令代码,Data段里面的就是初始化的数据,BSS里面就是为初始化的数据(BSS本身不存在于文件中)。比如有下面这样一段小程序:
int global = 1;
int global2;
int main()
{
int local1;
printf("%d/n",local1);
}
这里的global1和"%d/n"就会放入data段,global2就会放入bss段,而其他的指令则会放入code段,局部变量local1不会放入文件,只是在运行时在堆栈里面分配。static的变量跟全局变量是一样的。由于bss里面放的是为初始化数据,所以这些数据不是真正放在文件里的,而是在执行时由系统分配一块内存区域,全部清空为0,用作BSS。
这三个段是所有可执行文件都会有的段,其他的段如C++特有的全局对象构造和析构段,还有比如重定位信息段,调试信息等等。
下面我们来看看我们的ld连接脚本如何控制这些段的生成,这是app.ld的内容:
OUTPUT_FORMAT(coff-go32)
ENTRY(_main)
SECTIONS
{
.text 0x10000000:
{
CODE = .;
*(.text)
. = ALIGN(4096);
}
.data :
{
DATA = .;
*(.data .rdata)
. = ALIGN(4096);
}
.bss :
{
BSS = .;
*(.bss);
. = ALIGN(4096);
}
END = .;
}
OUTPUT_PORMAT(coff-go32)是指定输出文件格式,我们这里输出的是coff-go32格式,之所以选择这种格式而没有选择PE或者ELF等是因为这种格式比较简单。ENTRY(_main)的意思就是这个文件的入口,这里的入口就是_main(前面加_是因为C语言的程序经过编译以后,所有的符号前都加_ )。接下来的一些就是定义段的格式了,一个小点.表示当前的地址指针。一般的段定义结构如下:
OUTPUT_SECTION_NAME START_ADDRESS : { INPUT_SECTION }
可以看到第一个输出的段是名字叫".text",就是一般意义上的code段,起始地址0x10000000,,也就是256M,然后有句CODE = .,这里的"CODE"是一个符号,这句的意思就是将当前地址赋值给此符号,这个符号有什么用呢?这个符号可以让你在程序中引用,这样你就能在程序中得到连接时候的地址信息了。*(.text)的意思就是将所有输入文件中名字叫".text"输出到输出文件。然后是将当前地址4K对齐。下面的几个段都是差不多。
OK!经过这样复杂的过程,我们终于得到了一个WeekOS上的标准应用程序sample.app,累死了,呵呵,本来直接gcc sample.c可以搞定的东西被我们搞得那么烦,不过是不是很有成就感呢?好!我们明天继续往下挖掘,准备把OS Kernel搞定!