Linux内核态、用户态以及fork进程管理

一:内核态和用户态

内核态:

 通常一个内核由负责响应中断的中断服务程序,负责管理多个进程从而分享处理器时间的调度程序,负责管理进程地址空间的内存管理程序和网络,进程间通信等系统服务程序共同组成。其独立于普通应用程序,一般处于系统态,拥有受保护的内存空间和访问硬件设备的所有权限,这种系统态和被保护起来的内存空间统称为内核空间。

用户态:

 应用程序在用户空间执行,它们只能看到允许它们使用的部分系统资源,并且只能使用某些特定的系统功能,不能直接访问硬件,也不能访问内核划给别人的内存范围。
 当内核运行时,系统以内核态进入内核空间执行,而执行一个普通用户程序时,系统将以用户态进入用户空间执行。
 Linux内核态、用户态以及fork进程管理
 这里我们来简单介绍一下x86架构中的四种CPU权限,对我们理解内核态和用户态有帮助。
 

从特权级看用户态和内核态

 熟悉Unix/Linux系统的人都知道,fork的工作实际上是以系统调用的方式完成相应功能的,具体的工作是由sys_fork负责实施。其实无论是不是Unix或者Linux,对于任何操作系统来说,创建一个新的进程都是属于核心功能,因为它要做很多底层细致地工作,消耗系统的物理资源,比如分配物理内存,从父进程拷贝相关信息,拷贝设置页目录页表等等,这些显然不能随便让哪个程序就能去做,于是就自然引出特权级别的概念,显然,最关键性的权力必须由高特权级的程序来执行,这样才可以做到集中管理,减少有限资源的访问和使用冲突。
 特权级显然是非常有效的管理和控制程序执行的手段,因此在硬件上对特权级做了很多支持,就Intel x86架构的CPU来说一共有0~3四个特权级,0级最高,3级最低,硬件上在执行每条指令时都会对指令所具有的特权级做相应的检查,相关的概念有CPL、DPL和RPL,这里不再过多阐述。硬件已经提供了一套特权级使用的相关机制,软件自然就是好好利用的问题,这属于操作系统要做的事情,对于Unix/Linux来说,只使用了0级特权级和3级特权级。也就是说在Unix/Linux系统中,一条工作在0级特权级的指令具有了CPU能提供的最高权力,而一条工作在3级特权级的指令具有CPU提供的最低或者说最基本权力。
 现在我们从特权级的调度来理解用户态和内核态就比较好理解了,当程序运行在3级特权级上时,就可以称之为运行在用户态,因为这是最低特权级,是普通的用户进程运行的特权级,大部分用户直接面对的程序都是运行在用户态;反之,当程序运行在0级特权级上时,就可以称之为运行在内核态。
 虽然用户态下和内核态下工作的程序有很多差别,但最重要的差别就在于特权级的不同,即权力的不同。运行在用户态下的程序不能直接访问操作系统内核数据结构和程序。
 当我们在系统中执行一个程序时,大部分时间是运行在用户态下的,在其需要操作系统帮助完成某些它没有权力和能力完成的工作时就会切换到内核态。
 实际上我们可以将每个处理器在任何指定时间上的活动概括归属为以下三者之一:
  (1).运行于用户空间,执行用户进程;
  (2).运行于内核空间,处于进程上下文,代表某个特定的进程执行;
  (3).运行于内核空间,处于中断上下文,与任何进程无关,处理某个特定的中断;
 那么,就有一个问题,处于用户态的应用程序是如何切换到内核态呢?
 用户态切换到内核态:
  从用户态切换到内核态的三种方式:
  (1).系统调用:首先我们都知道应用程序通过系统调用界面陷入内核,这是应用程序完成其工作的基本行为方式;
  (2).中断进入内核:当外围设备完成用户的请求操作后,会像CPU发出中断信号,此时,CPU就会暂停执行下一条即将要执行的指令,转而去执行中断信号对应的处理程序,如果先前执行的指令是在用户态下,则自然就发生从用户态到内核态的转换。
  (3).异常进入内核: 当CPU正在执行运行在用户态的程序时,突然发生某些预先不可知的异常事件,这个时候就会触发从当前用户态执行的进程转向内核态执行相关的异常事件,典型的如缺页异常,异常与中断不同,它在产生时必须考虑与处理器始时钟同步,所以异常又称之为同步中断。

注意:系统调用的本质其实也是中断,相对于外围设备的硬中断,这种中断称为软中断,这是操作系统为用户特别开放的一种中断,如Linux int 80h中断。所以,从触发方式和效果上来看,这三种切换方式是完全一样的,都相当于是执行了一个中断响应的过程。但是从触发的对象来看,系统调用是进程主动请求切换的,而异常和硬中断则是被动的。

大体过程如下:
 用户空间应用程序调用库中的函数,这些库中的函数绝大部分都运行在用户空间,如果该库函数需要用到一些受保护的地址空间的程序或者需要在特权级下执行某些程序,那么就需要切换到内核空间了,而切换到内核空间绝大部分都是由调用系统调用而触发的。
 系统调用号是可以关联一个系统调用的唯一标识,内核中有一个系统调用表,这个表记录了所有已经注册过的系统调用的列表,存储在sys_call_table中,这个表为每一个有效的系统调用指定了一个唯一的系统调用号。在陷入内核之前,用户空间就把相应的系统调用号放入了eax寄存器中,一旦系统调用处理程序运行,就可以在该寄存器中得到数据。
 陷入内核的过程中还需要将系统调用需要的一些外部参数从用户空间传递到内核,通常这些参数也被放在寄存器中,但当参数数目比较多时,应该用一个单独的寄存器存放指向所有参数在用户空间地址的指针。
 在x86系统上定义的软中断的中断号为128,通过 int .$0x80指令触发该中断,这条指令导致系统切换到内核态并执行第128号中断处理程序,该程序正是系统调用处理程序叫system_call()。执行中断处理程序具体需要进行的操作步骤有:请求中断→响应中断→关闭中断→保留断点→中断源识别→保护现场→中断服务子程序→恢复现场→中断返回。
  1.请求中断
  当某一中断源需要CPU为其进行中断服务时,就输出中断请求信号,使中断控制系统的中断请求触发器置位,向CPU请求中断。系统要求中断请求信号一直保持到CPU对其进行中断响应为止。
  2.中断响应
  CPU对系统内部中断源提出的中断请求必须响应,而且自动取得中断服务子程序的入口地址,执行中断 服务子程序。对于外部中断,CPU在执行当前指令的最后一个时钟周期去查询INTR引脚,若查询到中断请求信号有效,同时在系统开中断(即IF=1)的情 况下,CPU向发出中断请求的外设回送一个低电平有效的中断应答信号,作为对中断请求INTR的应答,系统自动进入中断响应周期。
  3.关闭中断
  CPU响应中断后,输出中断响应信号,自动将状态标志寄存器FR或EFR的内容压入堆栈保护起来,然后将FR或EFR中的中断标志位IF与陷阱标志位TF清零,从而自动关闭外部硬件中断。因为CPU刚进入中断时要保护现场,主要涉及堆栈操作,此时不能再响应中断,否则将造成系统混乱。
  4.保护断点
  保护断点就是将CS和IP/EIP的当前内容压入堆栈保存,以便中断处理完毕后能返回被中断的原程序继续执行,这一过程也是由CPU自动完成。
  5.中断源识别
  当系统中有多个中断源时,一旦有中断请求,CPU必须确定是哪一个中断源提出的中断请求,并由中断控制器给出中断服务子程序的入口地址,装入CS与IP/EIP两个寄存器。CPU转入相应的中断服务子程序开始执行。
  6.保护现场
  主程序和中断服务子程序都要使用CPU内部寄存器等资源,为使中断处理程序不破坏主程序中寄存器的内容,应先将断点处各寄存器的内容压入堆栈保护起来,再进入的中断处理。现场保护是由用户使用PUSH指令来实现的。
  7.中断服务
  中断服务是执行中断的主体部分,不同的中断请求,有各自不同的中断服务内容,需要根据中断源所要完成的功能,事先编写相应的中断服务子程序存入内存,等待中断请求响应后调用执行。
  8.恢复现场
  当中断处理完毕后,用户通过POP指令将保存在堆栈中的各个寄存器的内容弹出,即恢复主程序断点处寄存器的原值。
  9.中断返回
  在中断服务子程序的最后要安排一条中断返回指令IRET,执行该指令,系统自动将堆栈内保存的 IP/EIP和CS值弹出,从而恢复主程序断点处的地址值,同时还自动恢复标志寄存器FR或EFR的内容,使CPU转到被中断的程序中继续执行。
  Linux内核态、用户态以及fork进程管理
 总结以上,大致可分为以下几步:
  [1] 用户空间应用程序调用库中的函数;
  [2] 库函数调用系统调用;
  [3] 开始进行由用户态向内核态切换的准备工作,包括从当前进程的描述符中提取其内核栈的ss0及esp0信息,将系统调用号放入eax寄存器中,以及将系统调用所需要的外部参数传递给内核;
  [4] 使用ss0和esp0指向的内核栈将当前进程的cs,eip,eflags,ss,esp信息保存起来,这个过程也完成了由用户栈到内核栈的切换过程,同时保存了被暂停执行的程序的下一条指令;
  [5] 将先前由中断向量检索得到的中断处理程序的cs,eip信息装入相应的寄存器,开始执行中断处理程序,这时就转到了内核态的程序执行了;
  [6] 中断处理程序完成,恢复现场并返回用户态。

注意:
 中断上下文:中断处理程序是被内核调用来响应中断的,而它们运行于称之为中断上下文的特殊上下文中,由于中断上下文不可以睡眠,所以又称之为原子上下文,它有严格的时间限制,所以分为上半部和下半部,由于它极有可能是打断了其他中断线上的另一个中断处理程序,所以尽量把工作从中断处理程序中分离出来,放在下半部执行,因为下半部可以在更合适的时间运行;
 进程上下文:它是内核所处的一种操作模式,此时内核代表进程在执行,比如执行系统调用或者内核线程,进程上下文可以睡眠。

二:进程管理

 在讨论fork创建子进程之前,我们先来讨论一下写时拷贝的概念:
 写时拷贝:传统的fork()系统调用直接把所有的资源复制给新创建的进程,这种实现过于简单并且效率低下。Linux的fork()使用写时拷贝页实现,写时拷贝是一种可以推迟甚至是免拷贝数据的一种技术,内核此时并不复制整个进程的地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝,资源的复制只有在需要写入时才进行,在此之前,只是以只读的方式共享,这种技术使得地址空间上的页的拷贝被推迟到实际发生写入的时候才进行。因为fork()的实际开销就是复制父进程的页表以及子进程创建唯一的进程描述符。
 那么在fork()一个子进程的流程有哪些呢?
 Linux内核态、用户态以及fork进程管理 值得注意的是vfork()不拷贝父进程的页表项,其他与fork()功能基本相同,子进程作为父进程的一个单独的线程在它的地址空间中运行,父进程阻塞,直到子进程退出或执行exec(),子进程不能向地址空间写入。
 僵尸进程:当父进程还没有获取到子进程的退出状态码,子进程就已经退出的状态下,造成子进程成为了一个僵尸进程。
 解决方案是让父进程等待子进程结束时获得子进程的退出状态码后再接着执行。
  1.在main函数中给SIGCHLD信号注册一个信号处理函数(sig_chld),然后在子进程退出的时候,内核递交一个SIGCHLD的时候就会被主进程捕获而进入信号处理函数sig_chld,然后再在sig_chld中调用wait,就可以清理退出的子进程。这样退出的子进程就不会成为僵尸进程。
  2.调用waitpid而不是wait,这个办法的方法为:信号处理函数中,在一个循环内调用waitpid,以获取所有已终止子进程的状态。我们必须指定WNOHANG选项,它告知waitpid在有尚未终止的子进程在运行时不要阻塞。(我们不能在循环内调用wait,因为没有办法防止wait在尚有未终止的子进程在运行时阻塞,wait将会阻塞到现有的子进程中第一个终止为止)。
  进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。
  waitpid系统调用在Linux函数库中的原型是:
  .#include <.sys/types.h> /* 提供类型pid_t的定义 */
  .#include <.sys/wait.h>
   pid_t waitpid(pid_t pid,int *status,int options)
  从本质上讲,系统调用waitpid和wait的作用是完全相同的,但waitpid多出了两个可由用户控制的参数pid和options,从而为我们编程提供了另一种更灵活的方式。下面我们就来详细介绍一下这两个参数:
  pid:从参数的名字pid和类型pid_t中就可以看出,这里需要的是一个进程ID。但当pid取不同的值时,在这里有不同的意义。
  [1].pid>0时,只等待进程ID等于pid的子进程,不管其它已经有多少子进程运行结束退出了,只要指定的子进程还没有结束,waitpid就会一直等下去。
  [2].pid=-1时,等待任何一个子进程退出,没有任何限制,此时waitpid和wait的作用一模一样。
  [3].pid=0时,等待同一个进程组中的任何子进程,如果子进程已经加入了别的进程组,waitpid不会对它做任何理睬。
  [4].pid<-1时,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值。
  options:options提供了一些额外的选项来控制waitpid,目前在Linux中只支持WNOHANG和WUNTRACED两个选项,这是两个常数,可以用”|”运算符把它们连接起来使用。如果我们不想使用它们,也可以把options设为0,如果使用了WNOHANG参数调用waitpid,即使没有子进程退出,它也会立即返回,不会像wait那样永远等下去。而WUNTRACED参数,由于涉及到一些跟踪调试方面的知识,加之极少用到,这里就不多费笔墨了。
waitpid的返回值比wait稍微复杂一些,一共有3种情况:
  1、当正常返回的时候,waitpid返回收集到的子进程的进程ID;
  2、如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
  3、如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
 当pid所指示的子进程不存在,或此进程存在,但不是调用进程的子进程,waitpid就会出错返回,这时errno被设置为ECHILD;
 孤儿进程:当父进程在子进程还未结束时就已经退出时,此时的子进程就成为了一个孤儿进程,孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
 守护进程:守护进程就是在后台运行,不与任何终端关联的进程,通常情况下守护进程在系统启动时就在运行,它们以root用户或者其他特殊用户(apache和postfix)运行,并能处理一些系统级的任务.习惯上守护进程的名字通常以d结尾(sshd),但这些不是必须的.
 下面介绍一下创建守护进程的步骤
 1.调用fork(),创建新进程,它会是将来的守护进程;
 2.在父进程中调用exit,保证子进程不是进程组长;
 3.调用setsid()创建新的会话区;
 4.将当前目录改成跟目录(如果把当前目录作为守护进程的目录,当前目录不能被卸载他作为守护进程的工作目录);
 5.将标准输入,标注输出,标准错误重定向到/dev/null。
 注意:Linux系统最多可以运行的进程数为默认是 32768 (2^11),最大值不能超过 2^22 (4194304) 400万。可以用cat /proc/sys/kernel/pid_max命令来查看。