操作系统概述
操作系统:指在内核态(kernel mode)或称管态(supervisor mode)下运行的软件,它受到硬件的保护,用户不能随便去篡改它的内容。
1.1 什么是操作系统
操作系统作为扩展机:操作系统的功能就是为用户提供一台等价的扩展计算机,或称虚拟机(virtual machine),它比底层硬件更容易编程。
操作系统作为资源管理器:从资源管理器的角度来说,操作系统的主要任务是跟踪资源的使用状况、满足资源请求、提高资源利用率,以及协调不同程序和用户对资源的访问冲突。
1.2 操作系统的发展历史
第一代计算机(1945-1955):真空管和插接板。
第二代计算机(1955-1965):晶体管和批处理系统。
第三代计算机(1965-1980):集成电路和多道程序。
第四代计算机(1980-至今):个人计算机。
1.3 操作系统的概念
1.3.1 进程
在MINIX3及所有操作系统中,一个重要的概念就是进程(process)。从本质上来说,一个进程就是一个正在执行的程序。
在许多操作系统中,一个进程的所有信息(除了它的地址空间中的内容)均存放在操作系统的一张表中,该表称为进程表(process table),它实际上是一个结构数组(或链表)。
对于一个被挂起的进程,主要包括两部分的内容。一是进程的地址空间,称为内核映像(core image),二是相应的进程表项,包含寄存器值及其他信息。
1.3.2 文件
MINIX 3中的文件和目录通过一个11位的二进制码来保护。保护码包括三个3位的域,分别描述文件的所有者、同组用户和其他用户。每个域有1位标识读权限、1位标识写权限和1位标识可执行权限。
例如,保护码rwxr-x--x表示文件的所有者可以进行读、写和执行操作;同组用户可以读和执行,但不能写;而其他用户只能执行,不能读写。对目录来说,x表示搜索权限,短横线(-)表示不具备相应权限。
MINIX 3允许将光驱等可移动介质上的文件系统挂装(mount)到主文件树上。例如,CD-ROM上的文件系统被挂装在目录b下,这样就可以去访问文件/b/x和/b/y。
MINIX 3的另一个重要概念是设备文件(special file)。设备文件分为两类:块设备文件(block special files)和字符设备文件(character special files)。块设备文件描述的是以随机访问的数据块为单元的设备,如磁盘。在打开一个块设备文件后,可以直接去访问它的某一个数据块。字符设备文件指那些以字符流方式进行操作的设备,如打印机、调制解调器等。
管道(pipe)是一种用来连接两个进程的虚拟文件。
1.4 系统调用
从某种意义上来说,发出一个系统调用类似于发出一个特殊的函数调用,两者的区别仅在于,发出系统调用后,将进入内核或其他的特权操作系统组件,而函数调用则不会这样。
MINIX 3总共有53条系统调用,被分为6大类。
表MINIX的系统调用
进程管理pid = fork() 创建一个与父进程相同的子进程
pid = waitpid(pid, &statloc, opts) 等待一个子进程结束
s = wait(&status) waitpid的老版本
s = execve(name, argv, envp) 替换一个进程的内核映像
exit(status) 终止进程的执行并返回status
size = brk(addr) 设置数据段的大小
pid = getpid() 返回调用进程的标识号
pid = getpgrp() 返回调用进程的组标识号
pid = setsid() 创建一个新的会话并返回其组标识号
l = ptrace(req, pid, addr, data) 用于调试
信号s = sigaction(sig, &act, &oldact) 定义针对信号的处理操作
s = sigreturn(&context) 从信号返回
s = sigprocmask(how, &set, &old) 检查或修改信号屏蔽码
s = sigpending(set) 获得阻塞信号集合
s = sigsuspend(sigmask) 替换信号屏蔽码并挂起进程
s = kill(pid, sig) 给进程发送一个信号
residual = alarm(seconds) 设置警报时钟
s = pause() 将调用进程挂起直到下一个信号一块操
文件管理fd = creat(name, mode) 创建一个新文件(已过时)
fd = mknod(name, mode, addr) 创建一个普通文件、设备文件或目录的i节点
fd = open(file, how, …) 打开一个文件进行读、写或读写
s = close(fd) 关闭—个文件
n = read(fd, buffer, nbytes) 从文件中读数据到缓冲区
n = write(fd, buffer, nbytes) 将缓冲区中的数据写人文件
pos = lseek(fd, offset, whence) 移动文件指针
s = stat(name, & buf) 获取文件的状态信息
s = fstat(fd, & buf) 获取文件的状态信息
fd = dup(fd) 为打开文件分配一个新的文件描述符
s = pipe(&fd[0]) 创建一个管道
s = ioctl(fd, request, argp) 对设备文件进行控制操作
s = access(name, amode) 检查一个文件的可访问性
s = rename(old, new) 修改文件的名字
s = fcnt(fd, cmd, …) 文件加锁及其他操作
目录及文件系统管理s = mkdir(name, mode) 创建一个新目录
s = rmdir(name) 删除一个空目录
s = link(name1, name2) 创建一个新的目录项name2,指向name1
s = unlink(name) 删除一个目录项
s = mount(special, name, flag) 挂装一个文件系统
s = umount(special) 卸装一个文件系统
s = sync() 将缓冲区中的数据块回写到磁盘
s = chdir(dimname) 改变当前工作目录
s = chroot(dirame) 改变根目录
保护s = chmod(name, mode) 改变文件的保护位
uid = getuid() 获取调用进程的uid
gid = getgid() 获取调用进程的gid
s = setgid(uid) 设置调用进程的uid
s = setgid(gid) 设置调用进程的gid
s = chown(name, owner, group) 改变文件的所有者及其所在的组
oldmask = umask(complmode) 改变模式屏蔽码
时间管理seconds = time(&seconds) 获取当前时间,以1970年1月1日为起点
s = stime(tp) 设置当前时间,以1970年1月1日为起点
s = utime(file, timep) 设置文件的“上次访问”时间
s = times(buffer) 获取用户和系统所使用的时间
1.4.1 进程管理的系统调用
MINIX 3中,fork是创建一个新进程的唯一途径。fork实际上创建的是原进程的一个副本,包括文件描述符、寄存器的值等,所有内容都是完全相同的。在调用fork后,原进程和新进程(即父进程和子进程)各自执行,互不相关。
在执行fork时,两个进程的所有对应变量都具有相同的值,但由于父进程和子进程的地址空间是相互独立的,因此在fork执行之后,如果其中一个进程的变量值发生了变化,并不会影响到另一个进程(代码段是不可修改的,由父、子进程共享)。
在正常情形下,如果fork函数的返回值为0,则表明当前进程是子进程;如果返回值为一个正整数,则表明当前进程是父进程,而该整数即为子进程的标识号PID,因此,尽管在调用fork函数后,父进程和子进程的内容是完全相同的,但是通过这个函数的返回值,还是可以将父进程和子进程区分开来。
对于一个shell,首先从终端读取一条命令,然后创建一个子进程来执行该命令,并等待子进程执行完毕,然后再读取下一条命令。为了等待子进程结束,父进程会执行一个waitpid系统调用,该调用将使父进程阻塞,直到子进程结束。第二个参数statloc指向的是子进程的终止状态值所在的内存单元。
当用户键入一条命令时,shell首先创建一个新进程。这个子进程必须执行该用户命令,这是通过execve 系统调用来实现的,它将用第一个参数所指定的可执行文件来替换当前的内核映像。
一个高度简化的shell框架如下:
#define TRUE 1
while(TRUE){
type_prompt(); /* 在屏幕上显示提示符*/
read_command(command, parameters); /* 从终端读取输入*/
if(fork() != 0){ /* 创建子进程*/
/* Parent code. */
waitpid (-1, &status, 0); /* 等待子进程退出*/
}else{
/* Child code. */
execve(command, parameters, 0); /* 执行命令*/
}
}
考虑如下一条命令
cp file1 file2
它的功能是为文件file1制作一个副本file2,在shell创建一个子进程后,子进程将查并执行程序cp,同时向它传递执行的参数:源文件名和目标文件名。
cp程序的主函数格式如下:
main (argc, argv, envp)
参数argc是命令行中的参数个数(包括程序名在内),对于上述例子,argc为3。
参数argv是一个指向数组的指针,该数组的第i个元素就是命令行中的第i个字符串。对于上述例子,argv[0]为“cp”,argv[1]为“file1”,argv[2]为“file2”。
参数envp是一个环境指针,环境是一个字符串数组,其中每个元素形如name=value,将各种环境信息传递给程序,如终端类型、主目录名等。
当一个进程完成任务后,可以用exit系统调用来结束运行。这个系统调用只有一个参数,也就是退出的状态值(0-255)。这个值通过waitpid系统调用中的staloc,返回给父进程。statioc的低字节存放结束状态,0表示正常退出,其他值表示不同的错误类型。statloc的高字节包含子进程的退出状态(0-255)。
MINIX 3中,进程的内存空间被分为三个部分:代码段(text segment,即程序代码)、数据段(data segment,即变量)和栈段(stack segment),数据段从下往上增长,而栈从上向下增长。在这两者之间是空闲的地址空间。栈的增长是随着程序的执行自动进行的,而数据段的扩展则需要通过brk系统调用来显式地完成,brk有一个参数来指定数据段的结束地址,它可以比当前值大(表示扩展数据段),或是比当前值小(表示缩小数据段)。当然,这个参数必须小于栈指针,否则栈和数据段将会重叠,这是不允许的。
出于程序员的方便考虑,系统\提供了一个库函数sbrt来改变数据段的大小,它只有一个参数,即数据段的增加量(以字节为单位,负数表示缩小数据段)。
下一个系统调用getpid也非常简单,它返回调用进程的进程标识号PID,如前所述,在调用fork时,只有父进程能够获得子进程的PID,如果子进程想要知道它自己的PID,就必须使用getpid。类似的系统调用包括:getpgrp返回调用进程的组标识号,setsid创建一个新的会话,并将进程组的PID设置为调用者的PID。
最后一个系统调用是ptrace,它主要用来对被调试的程序进行控制。通过ptrace,调试器可以读、写被控进程的地址空间,并进行其他方式的管理。
1.4.2 信号管理的系统调用
对于一个尚未声明愿意接收信号的进程,如果此时它收到一个信号,那么该进程将会被杀死。为了避免这种结局,进程可以用sigaction系统调用来声明它准备接收某种类型的信号,并提供两个参数:一个是信号处理程序的地址,另一个是内存单元,用于保存该信号的原先处理程序的地址。在执行完sigaction系统调用后,如果进程收到相关类型的信号,那么就会把进程的当前状态压入栈中,然后调用相应的信号处理程序。当信号处理程序结束后,就调用sigreturn函数,返回到被此次信号所打断的指令,继续往下执行。
在MINIX 3中,信号可以被阻塞。被阻塞的信号一直被挂起,直到阻塞解除。在这段时间内,它不会被传递,但也不会丢失。sigprocmask系统调用允许进程通过提交一幅位图的方式,来定义一组被阻塞的信号集合。进程可以使用系统调用sigpending来查询当前因阻塞而挂起的信号集。sigsuspend系统调用允许进程去设置阻塞信号的位图并将其挂起。
程序可以使用常量SIG_IGN来忽略指定类型的信号,或者使用SIG_DFL来恢复默认的信号处理程序。有以下命令,其功能是让shell创建一个后台进程:
command &
对于这个后台进程来说,它不希望被SIGINT信号打扰(该信号由CTRL-C组合键产生),因此shell程序在执行fork之后、exec之前,需要执行:
sigaction(SIGINT, SIG_IGN, NULL);
sigaction(SIGQUIT, SIG_IGN, NULL);
这两条命令来忽略SIGINT和SIGQUIT信号(SIGQUTT信号由CTRL-\组合键产生)。对于前台进程(即命令中不带&),这些信号不能被忽略。
再来看一下后台进程的例子,假设一个后台进程已被启动,但随后发现它应被终止,此时SIGINT和SIGQUIT都已被禁用。解决方案就是使用kill程序,该程序将使用kill系统调用来发送一个信号。当后台进程收到信号9(SIGKIL)时,该进程将被撤销。SIGKILL不能被捕获或忽略。
对于许多实时应用,当一个进程在运行一段时间间隔后,需要发生一次中断,以进行其他的一些处理。对此,系统提供了alarm系统调用。alarm的参数指定了一个时间间隔,以秒为单位。一旦进程的运行时间超过了该时间间隔,就会收到一个SIGALRM信号。在任意时刻,一个进程只能设定一个警报时钟。如果alarm的参数为0,则所有挂起的SIGALRM信号都被取消。
在某些情形下,一个进程在信号到达之前无须进行任何操作。可以使用pause系统调用,通知MINIX3把当前进程挂起,直至信号到来。