怎么去做一个操作系统
对于应用程序开发者来说,操作系统是一个强大而神秘的东西。特别是现有操作系统如此之庞大,linux3.11.6的源码压缩包是71MB,代码量在2008年就超过了1000万行。做一个操作系统真的有这么复杂吗?
实际上真的没那么恐怖,因为linux其实包含了数十个平台、N多种硬件的驱动,所以才会如此庞大。如果我们只是做一个在x86上能跑起来的、简单点的操作系统,绝不是个Mission Impossible.
操作系统需要做什么
操作系统最终是要为应用程序服务的,操作系统不需要无所不能,因为大部分任务是应用程序能完成的,操作系统只需要负责那些应用程序不能做的部分就可以了。
比如说启动计算机后引导操作系统,初始化键盘、硬盘等硬件(为它们配置中断),提供创建进程/线程的方法……这些任务如果某个应用程序包干了,那这个应用程序也是一个操作系统了。
操作硬件
我们平时用C语言写应用程序,你会发现纯粹的C语言只是在利用CPU和内存进行各种运算,如果没有调用printf这样的封装了IO操作的函数,那么这种运算毫无意义,因为我们看不到任何结果。x86使用in/out指令对硬件端口进行读写来进行IO(一般情况,也有只要写某块内存的),比如可通过in/out读写0xCF8、0xCFC两个端口来操作PCI总线。
需要注意的是in/out指令是特权指令,在x86进入32位保护模式后用户态的应用程序无权执行in/out指令,x86的权限(CPL)分4个等级,应用程序应该使用权限最低的3级也就是用户态,操作系统的内核应该使用0级也就是内核态(最高权限),linux系统中只使用了这两个级,1、2级没去用。CPU对用户态程序的权限限制,保证了系统的安全性:不是随便哪个程序都可以格式化我的硬盘的。而在DOS时代的8086,还没有保护模式,应用程序和操作系统都可以完全掌控整个计算机的资源,它想格式化我的硬盘就真可以格式化我的硬盘;但是DOS也是安全的,因为我们都向比尔保证过了,不会糊涂地进行以下操作:
C:\>cd 病毒库
C:\病毒库>木马.exe
对于这种没有后台的单任务字符界面的操作系统来说,任何操作都是由我们用户发起的;而对于现在的图形界面的Windows,各种U盘自启动,光盘自启动,我们往往察觉不到某些有害软件已经悄悄的在后台工作起来了;所以DOS是依靠它的"弱智"保护了自己。
总之就是IO操作现在必须由内核来做了,应用程序不能去直接操作硬件。而硬件操作只要知道了它的使用方法也不会很难,程序最复杂的部分往往是那些使用CPU和内存完成的数据运算,也就是说其实内核的大部分都是C语言能够胜任的,只有小部分我们必须通过汇编或者内联汇编去实现。
但是直接读写端口还面临着一个问题,那就是现在的电脑都是用PCI总线了,PCI总线上的设备的端口地址是动态可修改的,不是ISA总线的那些固定端口了,比如ISA总线的硬盘0的端口地址是0x1F0~0x1F7,这要是在虚拟机中还可以继续这么用,而在真机中基本上都不是这几个端口了;但是BIOS的扩展13h软中断还是能够准确地读写取硬盘的任意一个扇区,所以我们想偷懒把这种工作赖给BIOS(以后有更好的办法了就再改过去),具体这个怎么实现还得看0xAA55的16位子系统是怎么工作的。
应用软件与操作系统内核之间的交互
既然操作系统是为应用程序提供服务的,那么应用程序怎么去享用这些服务呢?这是通过系统调用来实现的:
1. linux内核使用int 0x80(执行0x80号软中断)来进行系统调用(新的linux内核改为用sysenter进行系统调用了,详见:http://stackoverflow.com/questions/12806584/what-is-better-int-0x80-or-syscall,这个只是进入方式不一样,我们以后要支持的话应该不麻烦);
2. CPU执行int 0x80后就进入到内核态了,linux将系统调用的参数依次存放在eax、ebx、ecx、edx、esi、edi中;
3. 其中eax放的是功能号,如1是结束进程的exit函数、2是复制进程的fork函数、3是读文件read、4是写文件write、5是打开文件open、……,详见http://docs.cs.up.ac.za/programming/asm/derick_tut/syscalls.html,1~66号功能在linux0.01时就确定了,现在已经陆续增加到将近200个功能了;
4. ebx、ecx、edx、esi、edi存的就是真正的参数了,因为系统提供的这些函数的参数个数不一样,所以实际用到的参数个数也不一样;
5. 然后0x80号中断处理程序中就会根据功能号去执行对应的函数;
6. 系统功能执行完后通过iret返回到用户态,继续执行int 0x80后的用户代码。
系统调用其实看起来就跟函数调用差不多,但是执行int 0x80后CPU可是工作在权限最高的内核态了哦,它就可以执行IO操作了。也许你会想到应用程序可以这样随随便便地进入到内核态的话,那系统是不是不安全了?其实不然,因为软中断处理程序是操作系统的内核提供的,不是应用程序中的代码。所以我们提供了什么,应用程序才可以执行什么;而不是应用程序提供代码,要我们进入内核态去执行,所以系统还是安全的。
举个栗子,linux系统库的open函数就可以这样来实现(以下代码会被链接到应用程序中,工作在用户态):
int open(const char *filename,int flag,int mode){
int res;
asm( "int $0x80"
:"=a"(res)
:"0"(5),"b"(filename),"c"(flag),"d"(mode));
return res;
}
而标准库中的fopen则要在该函数的基础上再进行一下封装。
多任务(进程线程)的实现
实现多任务的关键是任务切换,怎么才能保证一个任务(线程)的现场能够被可靠地保存下来并可以在以后的某个时间恢复过来并淡定、无误地继续执行呢?其实我们只要将这个任务用的内存保护起来(不允许别的进程修改其中的数据),并将当时CPU的各个寄存器的值保存好就可以了。
内存保护其实没啥工作,每个进程都用独立的内存空间就可以保证这一点;使用ljmp指令切换任务,它自己会完成保存要离开的任务的各个寄存器,恢复要接手的任务的各个寄存器(这条指令的工作量挺大的,因为要保存/恢复的寄存器挺多的,这条指令据说要用300个时钟周期才能完成,这是个什么概念呢,《计算机组成原理》课上说过CPU现在都采用了流水作业的方式执行机器指令,每条机器指令单独执行其实是要分多个步骤的,每个步骤要花一个时钟周期,但是一旦流水作业后平均每条指令的执行时间就缩短为1个时钟周期,所以ljmp切换任务的执行时间是挺长的,但是300/3GHz = 10^-7s=0.1us,在我们看起来的话时间还是可以忽略不计的),寄存器会被保存到一个TSS结构中:
struct tss_struct {
long back_link; /* 16 high bits zero */
long esp0;
long ss0; /* 16 high bits zero */
long esp1;
long ss1; /* 16 high bits zero */
long esp2;
long ss2; /* 16 high bits zero */
long cr3;
long eip;
long eflags;
long eax,ecx,edx,ebx;
long esp;
long ebp;
long esi;
long edi;
long es; /* 16 high bits zero */
long cs; /* 16 high bits zero */
long ss; /* 16 high bits zero */
long ds; /* 16 high bits zero */
long fs; /* 16 high bits zero */
long gs; /* 16 high bits zero */
long ldt; /* 16 high bits zero */
long trace_bitmap; /* bits: trace 0, bitmap 16-31 */
struct i387_struct i387;
};
x86的任务切换就是这个样子的,那么什么时候进行任务切换呢,时间片+调度算法是公认的好办法,linux0.01中通过设置8253计数器,让它每10ms产生一次时钟中断,10ms就是linux0.01的一个时间片,再配上一个精美的调度算法,多任务就跑起来了。
页:
[1]