操作系统综合实验1

一、实验目的与要求

综合利用进程控制的相关知识,结合对shell功能的和进程间通信手段的认知,编写简易shell程序,加深操作系统的进程控制和shell接口的认识。

二、实验内容

  • 使用Linux操作系统;学习使用Linux进程间控制,进程间通信,管道,消息队列,共享内存等手段以及处理机调度
  • 学会使用POSIX信号量实现生产者与消费者间同步关系
  • 编写简易shell程序

三、实验步骤与过程

1 Linux学习与实践

1.1 管道

进程间的管道通信有两种形式,无名管道用于父子进程间,命名管可以用于任意进程间— —命名管道在文件系统中有可访问的路径名。管道通信方式主要用于单向通信,如果需要双向 通信则建立两条相反方向的管道。管道实质是由内核管理的一个缓冲区(一边由进程写入,另 一边由进程读出),因此要注意,如果缓冲区满了则写管道的进程将会阻塞。另外管道内部没 有显式的格式和边界,需要自行处理消息边界,如果多进程间共享还需要处理传送目标等工作。

无名管道

管道(pipe),或称无名管道,是所有 Unix 都提供的一种进程间通信机制。管道是单向的 信道,进程从管道的写端口写入数据,需要数据的进程从读端口中获取数据,数据在管道中按 到达顺序流动。Unix 命令中使用“|”来连接两个命令时使用的就是管道,例如“ls | more”将 ls命令的标准输出内容写入到管道中,管道的输出内容作为 more 命令的标准输入。注意,重定向技术虽然看起来和管道很相似,例如“ls > temp”,但重定向并不使用管道。

如下面图 1,以 pipe()为例展示父子进程间使用管道进行通信的方法,pipe()将通过两个文件描述符(整数)来指代管道缓冲区的读端和写端(代码中用 fds[] 变量记录)。其中父进程关闭管道的读端 fds[0]并往管道的写端 fds[1]写出信息,子进程关闭了管道的写端 fds[1]并从管道的读端 fds[0]读回信息。

1 pipe-demo.c 代码

2 pipe-demo.c输出

图 2是图 1中pipe-demo运行的输出,其表明父进程发送了消息到管道,子进程成功接收到了“Message from parent”。

命名管道

前面提到的无名管道有一个主要缺点,只能通过父子进程之间(及其后代)使用文件描述符的继承来访问,无法在任意的进程之间使用。命名管道(named pipe)或者叫 FIFO 则突破了这个限制。可以说 FIFO 就是无名管道的升级版——有可访问的磁盘索引节点,即 FIFO 文件将出现在目录树中(不像无名管道那样只存在于 pipefs 特殊文件系统中)。

下面图 3我们用 mkfilo 命令来创建命名管道 os-exp-fifo,如屏显 4-2 所示,其中 ls 命令查看时可以看出其类型是管道“p”。

3 mkfifo 创建命名管道

此时可以用 cat os-exp-fifo 命令尝试从管道中读入数据,但是此时管道中还没有写入任何数据,因此 cat 将进入阻塞状态,如图 4:

4 用 cat 尝试读取空的管道文件(阻塞)

如果此时在另一个终端上用“echo Hello, Named PIPE! >os-exp-fifo”则cat 会被唤醒并读入管道数据回显字符串“Hello, Named PIPE!”,如图 5

5 用 echo 向管道写入数据

1.2 System V IPC

Linux 的进程通信继承了 System V IPC。System V IPC 指的是 AT&T 在 System V.2 发行版中 引入的三种进程间通信工具:

  1. 信号量,用来管理对共享资源的访问
  2. 共享内存,用来高效地实现进程间的数据共享
  3. 消息队列,用来实现进程间数据的传递。

我们把这三种工具统称为 System V IPC 的对象,每个对象都具有一个唯一的IPC标识符 ID。为了使不同的进程能够获取同一个 IPC 对象,必须提供一个IPC 关键字(IPCkey),内核负责把 IPC 关键字转换成 IPC 标识符 ID。下面我们观察这三种IPC 工具。

在Linux中执行ipcs命令可以查看到当前系统中所有的System V IPC对象,如图 6所示。

6 ipcs 命令的输出

查看这些 IPC 对象时还可以带上参数,ipcs -a 是默认的输出全部信息、ipcs -m 显示共享内 存的信息、ipcs -q 显示消息队列的信息、ipcs -s 显示信号量集的信息。另外用还有一些格式控 制的参数,–t 将会输出带时间信息、-p 将输出进程 PID 信息、-c 将输出创建者/拥有者的 PID、 -l 输出相关的限制条件。例如用 ipcs -ql 将显示消息队列的限制条件,如图 7所示。

7 ipcs -ql 的输出

删除这些 IPC 对象的命令是 ipcrm,它会将与 IPC 对象及其相关联的数据也一起删除, 管 理员或者 IPC 对象的创建者才能执行删除操作。该命令可以使用 IPC 键或者 IPC 的 ID 来指定 IPC 对象:ipcrm -M shmkey 删除用 shmkey 创建的共享内存段而 ipcrm -m shmid 删除用 shmid 标识的共享内存段、ipcrm -Q msgkey 删除用 msqkey 创建的消息队列而 ipcrm -q msqid 删除用 msqid 标识的消息队列、ipcrm -S semkey 删除用 semkey 创建的信号而 ipcrm -s semid 删除用 semid 标识的信号。

1.2.1 消息队列

消息队列有些项邮政中的邮箱,里面的消息有点像信件——有信封以及写有内容的信纸。 由于各条消息可以通过类型(type)进行区分,因此可以用于多个进程间通信。比如一个任务 分派进程,创建了若干个执行子进程,不管是父进程发送分派任务的消息,还是子进程发送任务执行的消息,都将 type 设置为目标进程的 PID,目标进程只接收消息类型为 type 的消息就 实现了子进程只接收自己的任务,父进程只接收任务结果。

下面图 8图 9给出代码 msgtool.c,其每次启动都是以新的进程形式运行,因此各次运行都是相互独立的。其中发送消息的核心函数是 msgsnd(),第一个参数是消息队列的 ID,第二个参数时被发送消息的起始地址(消息的第一个成员是一个整数用于指出消息类型),第三个参数时消息长度,第四个参数指定写消息时的一些行为(此例子用 0);接受消息的函数是 msgrcv(),第一个参数用于指定消息队列的ID,第二个参数是接受缓冲区地址,第三个参数指出希望接受的消息类型(0 表示接受任意类型的一条消息,>0表示接受指定类型的消息,

8 msgtool.c 代码

9 msgtool.c 代码

执行 msgtool s 1 Hello,my_msg_queue!发送类型为 1 的消息,然后用ipcs -q 查看新创建的消息队列,里面有 20 个字节的一条消息。此时再执行 msgtool -r 1(另一个进程了)读走类型为 1 的消息,然后再用 ipcs -q 可以看到该消息队列为空(0 字节)了。上述操作的输出如图 10:

10 msgtool 的执行结果

1.2.2 共享内存

System V IPC 的共享内存是由内核提供的一段内存,可以映射到多个进程的续存空间上, 从而通过内存上的读写操作而完成进程间的数据共享。我们首先来看看如何创建共享内存的, 示例代码如图 11所示,它创建了一个 4096 字节的共享内存区。shmget()的第一个参数 IPC_PRIVATE(=0,表示创建新的共享内存),第二个参数是共享内存区的大小,第三个是访问 模式。虽然也可以像前面的消息队列的例子那样通过 ftok()将键值转换成 ID,但这里没有指定 ID,而是创建共享内存后由系统返回一个 ID 值(后面的进程要使用该共享内存时需要指定该 ID)。

11 shmget-demo.c代码

执行该程序,其输出如图 12所示。输出结果表明新创建的共享内存的 ID为24,长度为 4096 字节,当前还没有进程将他映射到自己的进程空间(连接数列为 0)。

12 shmget-demo.c的运行结果

下面展示另一个进程通过影射该共享内存而使用它的过程,具体如图 13所示

13 shmatt-write-demo.c 代码

我们运行 shatt-demo 24(命令行参数中指出共享内存的 ID 为 24),其第一段输出结果如图 14所示。完成共享内存的映射后,shmatt-write-demo 往共享内存中写入一个 字符串“Hello shared memory!”。shmatt-write-demo 还通过 system()执行了“ipcs -m”,因此 也输出了当前的共享内存信息,可以看到 ID 为 24 的共享内存已经有被映射了一次 (nattach 列为 1)。

14 shmatt-write-demo.c运行结果

接下来使用 ps -a 指令,可以看到 shmatt-write-de 的 PID为4898,如图 15

15  ps -a 指令查看matt-write-de 的 PID

使用 cat /proc/4898/maps 查看进程的进程空间,可以看到进程布局如图 16所示

16 运行之后的进程布局

在 shmatt-write-demo击键回车后将解除共享内存的映射,此时ipcs -m显示对应的共享内存区没有人使用(连接数为 0),如图 17所示。此时如果检查内存布局可以发现原来区间的虚存已经消失。

17 运行write后的共享内存

此时再尝试用另一个程序去映射该共享内存并从中读取数据,shmatt-read-demo 代码如图 18所示。

18 shmatt-read-demo.c代码

如图 19,可以看到虽然创建该共享内存的进程已经结束了,可是shmatt-read-demo映射 ID 为 24 的共享内存后仍读出了原来写入的字符串。

19 运行read后的共享内存

从上面实验看出共享内存是比较灵活的通信方式,不需要像管道一样用文件接口read()、write()等函数,也不需要像消息队列那样用 msgsend()/msgrcv()等函数来操作,直接用内存指针的方式就可以操作。虽然实验中没有验证其容量,但是共享内存的容量远比管道和消息队列大。

1.2.3 信号量数组/信号量集

在操作系统原理性课程中我们以及学习过信号量和信号量集机制。Linux 支持的System V IPC 中的信号量实际上是信号量数组(信号量集),一次可以创建多个信号量。创建或者获得信号量集之后,可以对各个信号量进行 P/V 操作(或者称up/down 操作),进程进行 P/V 操作时遵循信号的同步约束关系——由操作系统完成进程的阻塞或唤醒。

1.3 进程间同步

Linux 同时支持 System V IPC 中的信号量集和 POSIX 信号量。前者常用于进程间通信、是基于内核实现的(不随进程结束而消失);而后者是常用于线程间同步、方便使用且仅含一个信号量。POSIX 信号量分成有名信号量和无名信号量,前者和一个文件的路径名相关联,创建 后不随进程结束而消失(可用于进程间通信),反之无名信号量则只在进程生命周期内存在且 只能在该进程创建的线程间使用。 上述两种信号量的编程接口函数是很容易被区分:对于所有 System V信号量函数,在它们的名字里面没有下划线(例如,有semget()而不是sem_get()),然而所有的 POSIX 信号量 函数都有一个下划线(例如,有sem_post()而不是sempost())。Linux操作系统内核内部也有多个并发的执行流,它们之间使用内核的信号量,和这里讨论的用户态信号量又不相同。

1.3.1 System V IPC 信号量集

进程间的 System V IPC 信号量集的同步机制已经在前面 System V IPC 中和进程间通信主题一并讨论过,这里不再重复。

1.3.2 POSIX 信号量

POSIX 信号量又分成有名信号量和无名信号量,前者可以用于在多个进程间或多个线程间的同步,无名信号量只能用于线程间同步。两者的创建函数不同,但是响应的P/V操作函数是一样的。有名信号量由于可以通过标识来访问,因此可以同时用于进程间同步和线程间同步。有名信号量的创建使用 sem_open()完成,代码 psem-named-open.c 如图 20所示,其先用 sem_open()创建了一个信号量,该信号量由一个字符串所标识。

20 psem-named-open.c代码

然后用 gcc psem-named-open.c -o psem-named-open -lpthread(参数-lpthread 用于指出链接时所用的线程库)完成编译,然后运行 psem-named-open。如果没有输入作为标识的文件名字符串,则给出提示要求用户输入;如果输入一个文件名字符串,正常情况将完成创建过程,如图 21所示。

21 psem-named-open 的输出

之后尝试执行 P/V 操作中的 V 操作(即对信号量进行减 1 操作,可能引发阻塞),程序psem-named-wait-demo.c如图 22所示,它通过 sem_wait()来执行V 操作(减1 操作),并且通过 sem_getvalue()来查看信号量的值。同样出于代码简洁的考虑,这里的代码也是没有检查 sem_open()是否成功获得了信号量。因此,如果输入错误的标识字符串,则无法成功获得所指定的信号量,sem_wait()引用无效的信号量而引发段错误。

22 psem-named-wait-demo.c 代码

编译并执行psem-named-wait-demo,输入前面创建信号量时使用的文件名标识,此时打印出当前信号量值为 0,如果再运行一遍,由于此时信号量的值已经为0,再进行V操作(减 1 操作)将阻塞该进程。程序运行情况如图 23所示。

23 psem-named-wait-demo 的运行结果

图 23显示该程序第二次运行后并没有返回到 shell 提示符,如果此时用另一个终端执行 ps 命令可以看到该进程处于S+状态,如图 24所示。

24 查看 psem-named-wait-demo 的运行状态

再接下来看 P 操作,使得前面的 psem-named-wait-demo 进程从原来的阻塞状态唤醒并执行结束。程序如图 25所示,这里也要注意实际上代码并没有对sem_open()的返回值进行判断。

25 psem-named-post-demo.c 代码

编译并执行 psem-named-post-demo(与前面不在同一个终端 shell 上),可以看到此时信号量的值增加到 1,并使得原来阻塞的psem-named-wait-demo被唤醒并执行完毕,如图 26所示。

26 psem-named-post-demo 的运行输出

同时可以看到,原来阻塞的 psem-named-wait-demo 被唤醒并执行完毕,如图 27所示。

27 唤醒阻塞的 psem-named-wait-demo 进程

最后,如果不希望使用这个信号量可以通过 sem_unlink()撤销该信号量,如图 28所示。

28 psem-named-unlink-demo.c 代码

而 POSIX 无名信号量适用于线程间通信,如果无名信号量要用于进程间同步,信号量要放在共享内存中(只要该共享内存区存在,该信号灯就可用)。无名信号量使用sem_init()创建。

互斥量是信号量的一个退化版本,仅用于并发任务间的互斥访问。下面先用一个代码来展示多线程并发且没有用互斥量保护共享变量的情形,如代码4-10 所示,此时结果可能会出现错误。该程序对一个缓冲区(缓冲区内是数值为 3、4、3、4......交织的整数)内的每个整数进行检查,并对数值为 3 的整数进行计数统计,统计工作由16 个线程并发完成(每个线程负责缓冲区的 1/16 的数据)。

下面的代码 no-mutex-demo.c 展示多线程并发且没有使用互斥量保护共享变量的状况,代码如图 29所示,编译后运行 no-mutex-demo,所得结果如图 30所示。可以看到,每次运行的结果并不唯一,共享变量没有被互斥访问。

29 no-mutex-demo.c 代码

30 no-mutex-demo 的运行结果

如果对 count++这个临界区加以保护,即增加一个互斥量 mutex m;就能避免出现这个问题。编译运行no-mutex-demo1,每次运行都能获得相同的结果。代码如图 31所示,结果如所示。由于实现了共享变量的互斥访问,因此每次运行的结果都是确定的值。

31 no-mutex-demo1.c 代码

图 32 no-mutex-demo1.c 结果

       至此,自行完成的Linux学习与实践全部结束。

2 使用POSIX信号量完成生产者与消费者的同步关系

根据前面的学习与本地的题目要求,我们可以得到设计的思路:

(1)要创建一个生产者消费者共用的缓冲区;

(2)对于生产者程序,首先要获取共享内存区并且挂入内存,之后创建三个信号量,将读取的行写入缓冲区(信号量在过程中要有对应的操作),然后释放信号量,结束内存映射并删除共享内存区域;

(3)对于消费者程序,同样要获取共享内存区并挂入内存,之后获取三个信号量,将缓冲区中的行字符串打印(信号量在过程中要有对应的操作),然后释放信号量。这里需要创建两个进程并发的进行上述操作。

头文件:

首先,定义如图 33的头文件,定义NUM_LINE=16作为共享内存的行数(可存储的行数),每行的内存大小为256;并且定义三个信号量分别用来判断是否互斥以及共享内存的空、满。

33 头文件

生产者代码:

       由于本次实验已经给出了代码框架,只需要补充框架中对应的内容即可。补全代码如图 34,定义共享内存指针、共享内存id以及访问共享内存的信号量指针,然后获取共享内存区,并将共享内存映射到内存空间。

34 获取共享内存区并放入内存

之后需要创建三个信号量,sem_queue、sem_queue_empty、sem_queue_full 的信号量初始值分别为 1、NUM_LINE 和 0,具体如图 35:

35 创建信号量

接下来如图 36,将读写指针初始化,开始时都指向第0行,将输入的行写入缓冲区,并且要有信号量操作。对共享区域加锁,输出信号量的值,把输入的内容存入共享区域,更新写操作的行并判断:如果是quit 则跳出循环。

36 将输入的行写入缓冲区

最后一部分如图 37,为释放信号量,结束共享内存在本进程的挂载映象,删除共享内存区域。该部分仿照前面的实验指导部分完成。

37 释放信号量

生产者代码:

如图 38,消费者代码与生产者代码相似,首先获得生产者代码中的已创建的共享内存,然后将共享内存区映射到本进程的进程空间。

38 生产者获取共享内存区

       如图 39,然后要获取 生产者创建的 3 个信号量。创建两个进程,当进入子进程时先等待信号量,进行 P 操作,当成功后打印消费内容及进程号,发现 quit 后退出,释放信号量。父子进程都采用相同的处理方式。

39 生产者信号量操作

       如图 40,同图 39,父子进程采用相同的处理方式。此处要注意的是,父进程释放信号量时,最后要加上 waitpid(fork_result,NULL,0); 用以等待所有的子进程结束后再退出,防止有孤儿进程生成。

40 生产者父进程信号量操作

       完成代码的编写后,将进行测试。如图 41,左侧为producer的运行,右侧为customer的运行,可以看到,程序完成了同步与通信功能,producer的输入通过共享内存,customer能够进行读取,producer输入的产品内容与customer输出的消费信息一一对应,其中产品号为奇数的内容对应父进程的运行,产品号为偶数的内容对应子进程的运行。当producer输入 quit 之后,两边都正常退出。

41 生产者/消费者问题的代码运行结果

3 设计简单Shell程序

根据前面的学习,我们可以知道,首先需要将输入的命令进行读入,并进行内部命令与外部命令的解析,如果成功解析出对应的命令则进行执行,否则视为无效命令。

首先,如图 42,引入必须的头文件,借助宏定义完成几个命令的分类并预先定义好几个函数。具体作用将在下面介绍。

42 shell程序的头文件以及函数

然后,定义如图 43的主函数。主函数的主要作用为申请存命令的空间,循环读入命令,并根据命令的不同做出对应的执行。在此,如“help”,“exit”等的内部命令将使用字符串比较的形式进行解析,并直接进行执行。

43 shell程序的主函数

由于shell需要打印提示符信息,在图 44中,我定义了一个函数获取当前目录并输出提示符。

44 shell程序的提示信息

对于帮助信息,将调用图 45中的函数直接输出帮助信息即可。

45 shell程序的帮助信息函数

由于要完成命令行的输入,我定义了如图 46的输入函数。利用循环将字符一个一个读入,当读入换行符或者超过长度时则终止循环。每次读入的时候都将读到的字符存入命令数组中。

46 shell程序的读入函数

由于需要对命令进行解析,我定义了如图 47的函数。他将利用空格,对输入的命令进行拆分,分别存入数组中,直到遇到了换行符。

47 shell程序的命令解析函数

Shell中最重要的是执行命令。执行命令中最重要的是判断命令是否合法。并根据命令的类型(重定向命令,管道命令等)对命令进行分类,代码如图 48,首先需要将命令取出,并判断是否含有后台运行符。

48 shell程序的命令判断

接着,判断是否为重定向或者管道命令。如果非法则flag++,如果合法将进行分类。首先,对重定向符号以及管道符号进行判断,具体如图 49。在此将判断对重定向符号以及管道符号的个数,并对命令进行分类。然后对于重定向命令,将取出重定向命令的目标,并存入file变量中。如果是管道命令,则将管道符号后的可执行shell命令取出,以便进行执行。

49 shell程序的重定向与管道命令的判断

完成了命令的分类,将进行命令的执行。首先,如图 50,如果不含有重定向及管道符号的最常规命令,直接调用execvp函数进行执行即可。

50 shell程序的常规命令执行

对于有输出重定向符号的命令,如图 51,利用dup2函数进行重定向即可。

51 shell 程序的输出重定向命令执行

对于有输入重定向符号的命令,如图 52,利用dup2函数进行重定向即可,与输出重定向类似。

52 shell程序的输入重定向命令执行

对于管道命令,则相对复杂,需要利用子进程将管道符前面的命令执行完毕后再调用父进程完成管道符右侧命令的执行。具体代码如图 53,首先,使用fork开辟子进程,并利用子进程将管道符左边的命令的输出写入到借助中间文件里。这个过程中父进程需要等待子进程完成执行后再行执行。接着,父进程将由子进程写入的中间文件作为输入,运行管道符右侧的命令。最后删除暂存的文件即可。

53 shell程序的管道命令执行

此外,如果有后台运行符,则父进程直接返回而不需等待子进程。此时代码如图 54

54 shell程序的后台运行符处理

此外,在每次执行的过程中都需要对命令进行查找,因此使用了如图 55的函数对命令进行查找。将分别在当前目录,bin目录,以及user下的bin目录进行查找。

55 shell程序的命令查找

所有代码如上,接下来进行测试如下。首先,测试内部命令“help”,效果如图 56,将打印帮助信息

56 shell程序输出帮助信息

       如果输入非法命令,则也会进行提示,具体如图 57

57 shell程序非法命令

对外部命令的支持,首先,对“ls”进行测试,结果如图 58

58 shell程序ls运行结果

接着,对“ps”进行测试,结果如图 59

59 shell程序ps运行结果

接着,对“cp”命令进行测试,在此,我测试使用cp命令复制“helloworld.c”文件,结果如图 60和图 61

60 shell 程序的cp命令

61 shell 程序的cp命令结果

此外,还可以在我的shell里对c程序进行编译并运行,结果如图 62

62 shell程序对c代码的编译与运行

接着,测试管道功能,使用 ls | grep helloworld 和 ls | grep shell 作为指令。grep 指令是用于查找文件里符合条件的字符串,ls | grep helloworld 就是找出当下文件夹里含有helloworld的文件名并打印出来。在 myshell 下的运行结果如图 63所示,可以看到管道命令可以正常执行。

63 shell程序的管道命令测试

接着,测试重定向功能。首先,对输入重定向进行测试。为了完成输入重定向的测试,我编写了一个简单的a+b程序,代码如图 64,并创建了用于重定向的文件,如图 65所示。

64 a+b程序

65 输入重定向的文件

接着,在我的shell中,进行输入重定向的测试,结果如图 66,可以看到程序执行正确

66 shell的输入重定向测试

最后,对输出重定向进行测试,如图 67,使用ps命令,将结果重定向到“log.txt”中,结果如图 68。

67 shell的输出重定向测试

68 shell输出重定向测试的结果

可以看到输出重定向的结果也符合预期。至此,shell编写完毕,我的shell可以接受内部,外部命令并以包含路径的信息作为提示符,可以在shell内部循环读取执行命令。此外,我的shell也实现了输入输出的重定向以及管道命令。

猜你喜欢

转载自blog.csdn.net/m0_46326495/article/details/124731735
今日推荐