《Unix/linux系统编程》教材第3章学习笔记

发布时间 2023-10-22 09:17:58作者: 20211424罗杰逊

|第3章|

Unix/Linux进程管理

多任务处理
一般来说,多任务处理指的是同时进行几项独立活动的能力。在计算机技术中,多任务处理指的是同时执行几个独立的任务。在单处理器(单CPU)系统中,一次只能执行一个任务。多任务处理是通过在不同任务之间多路复用CPU的执行时间来实现的,即将CPU执行操作从一个任务切换到另一个任务。不同任务之间的执行切换机制称为上下文切换,将一个任务的执行环境更改为另一个任务的执行环境。如果切换速度足够快,就会给人一种同时执行所有任务的错觉。这种逻辑并行性称为“并发”。在有多个CPU或处理器内核的多处理器系统中,可在不同CPU上实时、并行执行多项任务。此外,每个处理器也可以通过同时执行不同的任务来实现多任务处理。多任务处理是所有操作系统的基础。总体上说,它也是并行编程的基础。

进程的概念
操作系统是一个多任务处理系统。
进程是对映像的执行。
在操作系统内核中,每个进程用一个独特的数据结构表示,叫作进程控制块(PCB)或任务控制块(TCB)等。可以直接称它为PROC结构体。与包含某个人所有信息的个人记录一样,PROC结构体包含某个进程的所有信息。

typedef struct proc{
    struct proc *next;      //next proc pointer
    int *ksp;               //saved sp: at byte offset 4
    int pid;                //process ID
    int ppid;               //parent process pid
    int status;             //PROC status=FREE|READY,etc.
    int priority;           //scheduling priority
    int kstack[1024];       //process execution stack
}PROC;

在PROC结构体中,next是指向下一个PROC结构体的指针,用于在各种动态数据结构(如链表和队列)中维护PROC结构体。ksp字段是保存的堆栈指针。当某进程放弃使用CPU时,它会将执行上下文保存在堆栈中,并将堆栈指针保存在PROC.ksp中,以便以后恢复。在PROC结构体的其他字段中,pid是标识一个进程的进程ID编号,ppid是父进程ID编号,status是进程的当前状态,priority是进程调度优先级,kstack是进程执行时的堆栈。操作系统内核通常会在其数据区中定义有限数量的PROC结构体,表示为:

PROC proc[NPROC]; //NPROC a constant, e.g. 64
用来表示系统中的进程。在一个单CPU系统中,一次只能执行一个进程。操作系统内核通常会使用正在运行的或当前的全局变量PROC指针,指向当前正在执行的PROC。在有多个CPU的多处理器操作系统中,可在不同CPU上实时、并行执行多个进程。因此,在一个多处理器系统中正在运行的[NCPU]可能是一个指针数组,每个指针指向一个正在特定CPU上运行的进程。为简便起见,只考虑单CPU系统。

多任务处理系统
说明多任务处理、上下文切换和进车处理原则的编程示例。
多任务处理系统,简称MT,有以下几个部分组成。

  • type.h文件
    文件定义了系统常熟和表示进程的简单PROC结构体

ts.s文件
ts.s在32位GCC汇编代码中可实现进程上下文切换.

queue.c文件
queue.c文件可实现队列和链表操作函数。
enqueue() 函数按优先级将 PROC输入队列中在优先级队列中,具有相同优先级的进程按先进先出(FIFO)的顺序排序。
dequeue()函数可返回从队列或链表中删除的第一个元素。
printList()函数可打印链表元素。

t.c文件
t.c文件定义MT系统数据结构、系统初始化代码和进程管理函数。

Unix/Linux中的进程

  • 进程来源
    当操作系统启动时,操作系统内核的启动代码会强行创建一个PID=0的初始进程,即通过分配PROC结构体(通常是proc[0])进行创建,初始化PROC内容,并让运行指向proc[0]。然后,系统执行初始进程P0。大多数操作系统都以这种方式开始运行第一个进程。P0继续初始化系统,包括系统硬件和内核数据结构。然后,它挂载一个根文件系统,使系统可以使用文件。在初始化系统之后,P0复刻出一个子进程P1,并把进程切换为以用户模式运行P1。

  • INIT和守护进程
    当进程P1开始运行时,它将其执行映像更改为INIT程序。因此,P1通常被称为INIT进程,因为它的执行映像是init程序。P1开始复刻出许多子进程。P1的大部分子进程都是用来提供系统服务的。它们在后台运行,不与任何用户交互。这样的进程称为守护进程。

  • 登录进程
    除了守护进程之外,P1还复刻了许多LOGIN进程,每个终端上一个,用于用户登录。每个LOGIN进程打开三个与自己的终端相关联的文件流。这三个文件流是用于标准输人的stdin、用于标准输出的stdout和用于标准错误消息的stderr。每个文件流都是指向进程堆区中FILE结构体的指针。每个FILE结构体记录一个文件描述符(数字),stdin的文件描述符是0,stdout的是1,stderr的是2。然后,每个LOGIN进程向stdout显示一个
    login:
    以等待用户登录。用户账户保存在/etc/passwd和/etc/shadow文件中。每个用户账户在表单的/etc/passwd文件中都有一行对应的记录,
    name:x:gid:uid:description:home:program
    其中name为用户登录名,x表示登录时检查密码,gid为用户组ID, uid为用户ID, home为用户主目录,program为用户登录后执行的初始程序。其他用户信息保存在/etc/shadow文件中。shadow文件的每一行都包含加密的用户密码,后面是可选的过期限制信息,如过期日期和时间等。当用户尝试使用登录名和密码登录时,Linux将检查/etc/passwd文件和/etc/shadow文件,以验证用户的身份。

  • sh进程
    当用户成功登录时,LOGIN进程会获取用户的gid和uid,从而成为用户的进程。它将目录更改为用户的主目录并执行列出的程序,通常是命令解释程序sh。现在,用户进程执行sh,因此用户进程通常称为sh进程。它提示用户执行命令。一些特殊命令,如cd(更改目录)、退出、注销等,由sh自己直接执行。其他大多数命令是各种bin目录(如/bin、/sbin ,/usr/bin,/usr/local/bin等)中的可执行文件。对于每个(可执行文件)命令,sh会复刻一个子进程,并等待子进程终止。子进程将其执行映像更改为命令文件并执行命令程序。子进程在终止时会唤醒父进程sh,父进程会收集子进程终止状态,释放子进程PROC结构体并提示执行另一个命令等。除简单的命令之外,sh还支持I/O重定向和通过管道连接的多个命令。

  • 进程的执行模式
    在Unix/Linux中,进程以两种不同的模式执行,即内核模式和用户模式,简称Kmode和Umode。在每种执行模式下,一个进程有一个执行映像,如下图

在图中,索引i表示这些是进程i的映像。通常,进程在Umode下的映像都不相同。但是,在Kmode下,它们的Kcode、Kdata和Kheap都相同,这些都是操作系统内核的内容,但是每个进程都有自己的Kstack。
在进程的生命周期中,会在Kmode和Umode之间发生多次迁移。每个进程都在Kmode下产生并开始执行。事实上,它在Kmode下执行所有相关操作,包括终止。在Kmode模式下,通过将CPU的状态寄存器从K模式更改为U模式,可轻松切换到Umode。但是,一旦进入Umode,就不能随意更改CPU的状态了,原因很明显。Umode进程只能通过以下三种方式进入Kmode:
(1)中断:中断是外部设备发送给CPU的信号,请求CPU服务。当在Umode下执行时,CPU中断是启用的,因此它将响应任何中断。在中断发生时,CPU将进入Kmode来处理中断,这将导致进程进入Kmode。
(2)陷阱:陷阱是错误条件,例如无效地址、非法指令.除以0等,这些错误条件被CPU识别为异常,使得CPU进人Kmode来处理错误。在Unix/Linux中,内核陷阱处理程序将陷阱原因转换为信号编号,并将信号传递给进程。对于大多数信号,进程的默认操作是终止。
(3)系统调用:系统调用(简称syscall)是一种允许Umode进程进入Kmode以执行内核函数的机制。当某进程执行完内核函数后,它将期望结果和一个返回值返回到Umode,该值通常为0(表示成功)或-1(表示错误)。如果发生错误,外部全局变量errno(在errno.h中)会包含一个ERROR代码,用于标识错误。用户可使用库函数
perror("error message");
来打印某个错误消息,该消息后面跟着一个描述错误的字符串。
每当一个进程进入Kmode时,它可能不会立即返回到Umode。在某些情况下,它可能根本不会返回到Umode。例如,_exit() syscall和大多数陷阱会导致进程在内核中终止,这样它永远都不会再返回到Umode。当某进程即将退出Kmode时,操作系统内核可能会切换进程以运行另一个具有更高优先级的进程。

进程管理的系统调用
fork(),wait(),exec(),exit()
每个都是发出实际系统调用的库函数:
int syscall(int a, int b, int c, int d)
其中,第一个参数a表示系统调用号,b、c、d表示对应核函数的参数。在基于Intel x86的Linux中,系统调用是由汇编指令INT 0x80实现的,使得CPU进入Linux内核来执行由系统调用号a标识的核函数。

  • fork()
    Usage: int pid = fork();
    fork()创建子进程并返回子进程的pid,如果失败则返回-1。
    操作图:

fork()成功之后,父进程和子进程都执行它们自己的Umode映像,紧跟着fork()之后的映像是完全相同的。从程序代码角度来看,借助返回的pid是判断当前正在执行进程的唯一方法。程序代码:

int pid = fork();
if(pid){
    //parent executes this part
}
else{
    //child executes this part
}
  • 进程执行顺序
    在fork()完成后,子进程与父进程和系统中所有其他进程竞争CPU运行时间。接下来运行哪个进程取决于它们的调度优先级,优先级呈动态变化。下面是演示进程可能的各种执行顺序的代码示例。

  • 进程终止
    执行程序映像的进程可能以两种方式终止。

  1. 正常终止:每个C程序的main()函数都是由C启动代码crt0.o调用的。如果程序执行成功,main()最终会返回到crt0.o,调用库函数exit(0)来终止进程。首先,exit(value)函数会执行一些清理工作,如刷新stdout、关闭I/O流等。然后,它发出一个_exit(value)系统调用,使进入操作系统内核的进程终止。退出值0通常表示正常终止。如果需要,进程可直接从程序内的任何位置调用exit(value),不必返回到crt0.o。再直接一点,进程可能会发出_exit(value)系统调用立即执行终止,不必先进行清理工作。当内核中的某个进程终止时,它会将_exit(value)系统调用中的值记录为进程PROC结构体中的退出状态,并通知它的父进程并使该进程成为僵尸进程。父进程可通过系统调用找到僵尸子进程,获得其pid和退出状态
    pid = wait(int *status);
    它还会清空僵尸子进程PROC结构体,使该结构可被另一个进程重用。

2.异常终止:在执行某程序时,进程可能会遇到错误,如非法指令、越权、除零等,这些错误会被CPU识别为异常。当某进程遇到异常时,它会阱入操作系统内核。内核的异常处理程序将陷阱错误类型转换为一个幻数,称为信号,将信号传递给进程,使进程终止。在这种情况下,进程非正常终止,僵尸进程的退出状态是信号编号。除了陷阱错误,信号也可能来自硬件或其他进程。例如,按下“Ctrl+C”组合键会产生一个硬件中断信号,它会向该终端上的所有进程发送信号2中断信号,使进程终止。或者,用户可以使用命令
kill -s signal_number pid # signal_number=1 to 31
向通过pid识别的目标进程发送信号。对于大多数信号数值,进程的默认操作是终止。

在这两种情况下,当进程终止时,最终都会在操作系统内核中调用kexit()函数。唯一的区别是Unix/Linux内核将擦除终止进程的用户模式映像。
在Linux中,每个PROC都有一个2字节的退出代码(exitCode)字段,用于记录进程退出状态。如果进程正常终止,exitCode的高位字节是_exit(exitValue)系统调用中的exitValue。低位字节是导致异常终止的信号数值。因为一个进程只能死亡一次,所以只有一个字节有意义。

  • 等待子进程终止
    在任何时候,一个进程都可以使用
    int pid = wait(int *status);
    系统调用,等待僵尸子进程。如果成功,则wait()会返回僵尸子进程的PID,而且status包含僵尸子进程的exitCode。此外,wait()还会释放僵尸子进程,以供重新使用。wait()系统调用将调用内核中的kwait()函数。

为什么wait()要等待任何僵尸子进程?
在复刻若干登录进程后,P1等待任何僵尸子进程。一旦用户从终端注销,P1一定会立即响应以复刻该终端上的另一个登录进程。由于P1不知道哪个登录进程会先终止,所以它必须等待任何僵尸登录子进程,而不是等待某个特定的进程。或者,某进程可以使用系统调用
int pid = waitpid(int pid, int *status, int options);
等待由pid参数指定的具有多个选项的特定僵尸子进程。例如,wait(&status)等于waitpid(-i,&status,0)。

  • Linux中的subreaper进程
    进程可以用系统调用将自己定义为subreaper:
    prctl(PR_SET_CHILD_SUBREAPER);
    这样,init进程P1将不再是孤儿进程的父进程。相反,标记为subreaper的最近活跃祖先进程将成为新的父进程。如果没有活跃的subreaper进程,孤儿进程仍然像往常一样进入INIT进程。实现该机制的原因如下:许多用户空间服务管理器(如upstart、systemd等)需要跟踪它们启动的服务。这类服务通常通过两次复刻来创建守护进程,但会让中间子进程立即退出,从而将孙子进程升级为P1的子进程。该方案的缺点是服务管理器不能再从服务守护进程接收SIGCHLD(death_of_child)信号,也不能等待任何僵尸子进程。当P1清理重定父级的进程时,将会丢失关于子进程的所有信息。使用subreaper机制,服务管理器可以将自己标记为“sub-init”,并且现在可以作为启动服务创建的所有孤儿进程的父进程。这也减少了P1的工作量,它不必处理系统中所有的孤儿进程。在Ubuntu-15.10和后续版本中,每个用户init进程均标记为subreaper。它在Umode下运行,属于用户。
    使用sh命令:
    ps fxau | grep USERNAME | grep "/sbin/upstart"
    可以显示subreaper进程的PID和信息。相反,它是用户所有孤儿进程的父进程,而不是P1。下面是subreaper进程代码示例。

  • exec():更改进程执行映像
    进程可以使用exec()将其Umode映像更改为不同的(可执行)文件。exec()库函数有几个成员:

int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, .., char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);

以上函数都是针对系统调用进行封装的库函数,准备参数并最终发出系统调用
int execve(const char *filename, char *const argv[], char *const envp[]);
在execve()系统调用中,第一个参数文件名与当前工作目录(CWD)或绝对路径名有关。参数argv[]是一个以NULL结尾的字符串指针数组,每个指针指向一个命令行参数字符串。按照惯例,argv[0]是程序名,其他argv[ ]项是程序的命令行参数。例如,对于命令行:
a.out one two three

  • 环境变量
    环境变量是为当前sh定义的变量,由子sh或进程继承。当sh启动时,环境变量即在登录配置文件和.bashrc脚本文件中设置。它们定义了后续程序的执行环境。各环境变量定义为:
    关键字=字符串
    在sh会话中,用户可使用env或printenv命令查看环境变量。下面列出了一些重要的环境变量:
SHELL=/bin/bash
TERM=xterm
USER=kcw
PATH=/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:./
HOME=/home/kcw

SHELL:指定将解释任何用户命令的sh。
TERM:指定运行sh时要模拟的终端类型。
USER:当前登录用户。
PATH:系统在查找命令时将检查的目录列表。
HOME:用户的主目录。在Linux中,所有用户主目录都在/home中。
在sh会话中,可以将环境变量设置为新的(字符串)值,如:
HOME=/home/newhome
可通过EXPORT命令传递给后代sh,如:
export HOME
也可以将它们设置为空字符串来取消设置。在某个进程中,环境变量通过env[]参数传递给C程序,该参数是一个以NULL结尾的字符串指针数组,每个指针指向一个环境变量。
环境变量定义了后续程序的执行环境。例如,当sh看到一个命令时,它会在PATH环境变量的目录中搜索可执行命令(文件)。大多数全屏文本编辑器必须知道它们所运行的终端类型,该类型是在TERM环境变量中设置的。如果没有TERM信息,文本编辑器可能会出错。因此,命令行参数和环境变量都必须传递给正在执行的程序。这是所有C语言程序中main()函数的基础,可以写成:
int main(int argc, char *argv[], char *env[])

I/O重定向

  • 文件流和文件描述符
    sh进程有三个用于终端I/O的文件流:stdin(标准输人)、stdout(标准输出)和stderr(标准错误)。每个流都是指向执行映像堆区中FILE结构体的一个指针,如下图:

每个文件流对应Linux内核中的一个打开文件。每个打开文件都用一个文件描述符(数字)表示。stdin、stdout、stderr的文件描述符分别为0、1、2。当某个进程复刻出一个子进程时,该子进程会继承父进程的所有打开文件。因此,子进程也具有与父进程相同的文件流和文件描述符。

  • 文件流I/O和系统调用
    当进程执行库函数
    scanf("%s", &item);
    它会试图从stdin文件输入一个(字符串)项,指向FILE结构体。如果FILE结构体的fbuf[]为空,它会向Linux内核发出read系统调用,从文件描述符0中读取数据,映射到终端(/dev/ttyX)或伪终端(/dev/pts/#)键盘上。

  • 重定向标准输入
    如果用一个新打开的文件来替换文件描述符0,那么输入将来自该文件而不是原始输入设备。因此,如果执行以下代码:

#include <fcntl.h>  //contains O_RDONLY, O_WRONLY, O_APPEND, etc
close(0);           //syscall to close file descriptor 0
int fd=open("filename", O_RDONLY);  //open filename for READ,
                                    //fd replace 0

close(0)系统调用会关闭文件描述符0,使0成为未使用的文件描述符。open()系统调用会打开一个文件,并使用最小的未使用描述符数值作为文件描述符。在这种情况下,打开文件的文件描述符为0。因此,原来的文件描述符0会被新打开的文件取代。或者也可以使用:

int fd = open("filename", O_RDONLY);    //get a fd first
close(0);                               //zero out fd[0]
dup(fd);                                //duplicate fd to 0

系统调用dup(fd)将fd复制到数值最小的未使用文件描述符中,允许fd和0都能访问同一个打开的文件。此外,系统调用
dup2(fd1, fd2)
将fd1复制到fd2中,如果fd2已经打开,则先关闭它。因此,Unix/Linux提供了几种方法来替换/复制文件描述符。完成上述任何一项操作之后,文件描述符0将被替换或复制到打开文件中,以便每个scanf()调用都将从打开的文件中获取输入。

  • 重定向标准输出
    当进程执行库函数
    printf("format=%s\n", items);
    它试图将数据写入stdout文件FILE结构体中的fbuf[],这是缓冲行。如果fbuf[]有一个完整的行,它会发出一个write系统调用,将数据从fbuf[]写入文件描述符1,映射到终端屏幕上。要想将标准输出重定向到一个文件,需执行以下操作。
close(1);
open("filename", O_WRONLY|O_CREAT, 0644);

更改文件描述符1,指向打开的文件名。然后,stdout的输出将会转到该文件而不是屏幕。同样,也可以将stderr重定向到一个文件。当某进程(在内核中)终止时,它会关闭所有打开的文件。

管道
管道是用于进程交换数据的单向进程间通信通道,有一个读取端和一个写入端。可以从管道的读取端读取写入管道写入端的数据。

  • Unix/Linux中的管道编程

在Unix/Linux中,一系列相关系统调用为管道提供支持。系统调用

int pd[2];              //array of 2 integers
int r = pipe(pd);       //return value r=0 if OK, -1 if failed

在内核中创建一个管道并在pd[2]中返回两个文件描述符,其中pd[0]用于从管道中读取,pd[1]用于向管道中写入。然而,管道并非为单进程而创建。例如,在创建管道之后,如果进程试图从管道中读取1个字节,它将永远不会从读取的系统调用中返回。因为当进程试图从管道中读取数据时,管道中尚无数据,但是有一个写进程,所以它会等待数据。但是写进程是进程本身。所以进程在等待自己,可以说是把自己锁起来了。相反,如果进程试图写入的大小超过管道大小(大多数情况下为4KB),则当管道写满时,进程将再次等待自己。因此,进程只能是管道上的一个读进程或者一个写进程,但不能同时是读进程和写进程。在创建管道后,进程复刻一个子进程来共享管道。在复刻过程中,子进程继承父进程的所有打开文件描述符。因此,子进程也有pd[0](用于从管道中读取数据)和 pd[1](用于向管道中写入数据)。用户必须将其中一个进程指定为管道的写进程,并将另一个进程指定为管道的读进程。只要指定每个进程只扮演一个角色,指定的顺序并不重要。假设父进程被指定为写进程,子进程被指定为读进程。各进程必须关闭它不需要的管道描述符,即写进程必须关闭pd[0],读进程必须关闭pd[1]。然后,父进程可向管道写入数据,子进程可从管道读取数据。下图是管道操作的系统模型。

  • 管道命令处理
    在Unix/Linux中,命令行
    cmd1 | cmd2
    包含一个管道符号“|”。sh将通过一个进程运行cmd1,并通过另一个进程运行cmd2,它们通过一个管道连接在一起,因此cmd1的输出变成cmd2的输入。

  • 连接管道写进程与管道读进程
    (1)当sh获取命令行cmd1|cmd2时,会复刻出一个子进程sh,并等待子进程sh照常终止。
    (2)子进程sh:浏览命令行中是否有|符号。在这种情况下,
    cmd1 | cmd2
    有一个管道符号|。将命令行划分为头部=cmd1,尾部=cmd2
    (3)然后,子进程sh执行以下代码段:

int pd[2];
pipe(pd);           //creates a PIPE
pid = fork();       //fork a child(to share the PIPE)
if(pid){            //parent as pipe WRITER
    close(pd[0]);   //WRITER MUST close pd[0]
    close(1);       //close 1
    dup(pd[1]);     //replace 1 with pd[1]
    close(pd[1]);   //close pd[1]
    exec(head);     //change image to cmd1
}
else{               //child as pipe READER
    close(pd[1]);   //READER MUST close pd[1]
    close(0);
    dup(pd[0])      //replace 0 with pd[0]
    close(pd[0]);   //close pd[0]
    exec(tail);     //change image to cmd2
}

管道写进程重定向其fd=i到pd[i],管道读进程重定向其fd=0到pd[0]。这样,这两个进程就可以通过管道连接起来了。

  • 命名管道
    命名管道又叫作FIFO。它们有“名称”,并在文件系统中以特殊文件的形式存在。它们会一直存在下去,直至用rm或unlink将其删除。它们可与非相关进程一起使用,并不局限于管道创建进程的子进程。

GPT苏格拉底挑战

  • 多任务处理


  • 进程


学习中的问题

问题1:进程对映像的执行不太理解。

询问千问: