Linux系统编程1

208 阅读38分钟

第一章 Linux简介

Linux 不是一个操作系统,只是一个 Linux 系统中的内核,是计算机软件与硬件通讯之间的平台;Linux的全称是GNU/Linux,这才算是一个真正意义上的Linux系统。

为程序分配系统资源,处理计算机内部细节的软件叫做操作系统或者内核。用户通过Shell与Linux内核交互。Shell是一个命令行解释工具(是一个软件),它将用户输入的命令转换为内核能够理解的语言(命令)。

Linux版本

Linux 的每个内核版本使用形式为 x.y.zz-www 的一组数字来表示。其中:

x.y:为linux的主版本号。通常y若为奇数,表示此版本为测试版,系统会有较多bug,主要用途是提供给用户测试。 zz:为次版本号。 www:代表发行号(注意,它与发行版本号无关)。

当内核功能有一个飞跃时,主版本号升级,如 Kernel2.2、2.4、2.6等。如果内核增加了少量补丁时,常常会升级次版本号,如Kernel2.6.15、2.6.20等。

Linux体系结构

在所有Linux版本中,都会涉及到以下几个重要概念:

内核:内核是操作系统的核心。内核直接与硬件交互,并处理大部分较低层的任务,如内存管理、进程调度、文件管理等。

Shell:Shell是一个处理用户请求的工具,它负责解释用户输入的命令,调用用户希望使用的程序。

命令和工具:日常工作中,你会用到很多系统命令和工具,如cp、mv、cat和grep等。在Linux系统中,有250多个命令,每个命令都有多个选项;第三方工具也有很多,他们也扮演着重要角色。

文件和目录:Linux系统中所有的数据都被存储到文件中,这些文件被分配到各个目录,构成文件系统。Linux的目录与Windows的文件夹是类似的概念。

第二章操作系统简介

2.1操作系统的概念

操作系统是管理计算机硬件与软件资源的计算机程序。操作系统需要处理如管理与配置内存、决定系统资源供需的优先次序、控制输入设备与输出设备、操作网络与管理文件系统等基本事务。操作系统也提供一个让用户与系统交互的操作界面。

2.2Linux接口

Linux系统是一种金字塔型的系统

image.png

Linux具有三种不同的接口:系统调用接口、库函数接口和应用程序接口

2.3Linux操作系统重要概念

2.3.1并发

假设我们的计算机只有一个cpu,并且只有一个核心(core)

并发:在操作系统中,一个时间中有多个进程都处于开始运行到结束运行之间的状态但任一个时刻点上任只有一个进程在运行

单道程序设计:所有程序一个一个排队执行。若A阻塞,B只能等待,即使CPU处于空闲状态。

多道程序设计:在计算机内存中同时存放几道相互独立的程序,它们在管理程序控制之下,相互穿插的运行。这种设计需有硬件基础作为保证。

并发时,任意进程在执行期中都不希望放弃CPU。因此系统需要一种强制让进程让出CPU资源的手段。时钟中断有硬件基础作为保障,对进程而言不可抗拒。操作系统中断处理函数,来负责调度程序执行。

在多道程序设计模型中,多个进程轮流使用CPU(分时复用CPU资源)。而当下常见CPU为纳米级,1秒可以执行大约10亿条指令。由于人眼的反应速度是毫秒级,所以看似同时进行。

2.3.2 进程的基本概念

进程是计算机中的程序关于某数据集合上的一次运动活动,是系统进行资源分配的基本单位,是操作系统结构的基础。

进程的概念主要有两点:第一,进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text regio)。数据区域(date region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。第二,进程是一个“执行中的程序”。程序是一个没有生命的实体,只有处理器赋予程序生命时(操作系统执行之),它才能成为一个活动的实体,我们称其为进程。

2.3.3 PCB进程控制块和文件描述符表

每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是ask_struct结构体。

进程id。 系统中每个进程有唯一的id,在C语言中用pid_t类型表示,其实就是一个非负整数。

进程的状态: 有就绪、运行、挂起等状态。

描述虚拟地址空间的信息。

文件描述符: 包含很多指向file结构体的指针。

进程切换时需要保存和恢复的一些CPU寄存器

2.3.4虚拟地址空间

进程的虚拟地址空间分为用户区和内核区,其中内核区是受保护的,用户是不能否对其进行读写操作的;

内核区中很重要的一个就是进程管理,进程管理中有一个区域就是PCB(本质是一个结构体);

PCB中有文件描述符表,文件描述符表中存放着打开的文件描述符,涉及到文件的IO操作都会用到这个文件描述符。

2.3.5CPU的两种运行状态

CPU有两种运行状态:

用户态:运行用户程序

内核态:运行操作系统程序,操作硬件

CPU状态之间的转换:

用户态-->内核态:只能通过中断、异常、陷入指令

内核态-->用户态:设置程序状态PSW

内核态于用户态的区别:

两种运行级别,3级特权级上时,为用户态。因为这是最低特权,当程序运行在0级特权上时,运行在内核态。

这两种状态的主要差别是:

处于用户态执行时,进程所能访问的内存空间和对象受到限制,其占有的处理器资源是可被占有的。

处于内核态执行时,则能访问所有的内存空间和对象,且所占的处理器是不允许被抢占的。

通常来说,以下三种情况会导致用户态到内核态的切换

系统调用:

这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如fork()实际上就是执行了一个创建新进程的系统调用

而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现

用户程序通常调用库函数,由库函数在调用系统调用,因此有的库函数会使用户程序进入内核态(只要库函数中某处调用了系统调用),有的则不会

异常:

当CPU执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。

外围设备的中断:

当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这是CPU不会执行下一条即将要执行的指令而去执行与中断信号对应的处理程序。

如果先前执行的指令时用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换,比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。

这三种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。

2.3.6什么是库函数

库函数是把函数放在库里,供别人使用的一种方式。方法是把一些常用的库函数编完放到一个文件里,供不同的人进行调用。调用的时候把它所在的文件名用#includde<>加到里面,一般放到lib文件里。

2.3.7什么是系统调用

由操作系统实现提供的所有系统调用所构成的集合即程序接口或应用编程接口。是应用程序同系统之间的接口,用户程序只在用户态下运行,有时需要访问系统核心功能,这时通过系统调用接口使用系统调用。

C标准库函数和系统函数调用关系:一个hello word如何打印到屏幕。

第三章文件IO

3.1c库io函数的工作流程

磁盘为什么慢:

大部分硬盘都是机械硬盘,读取寻道时间都是在毫秒级(ms)

相对来说内存速写速度都非常快,因为内存属于电子设备,读写速度时纳米级(ns)级别的

c语言操作文件相关问题:

使用fopen函数打开一个文件,返回一个FILE* fp,这个指针指向的结构体有三个重要的成员

文件描述符:通过文件描述可以找到文件的inode,通过inode可以找到对应的数据块

文件指针:读和写共享一个文件指针,读或写都会引起文件指针的变化;

文件缓冲区:读或写会先通过文件缓冲区,主要目的是为了减少磁盘的读写次数,提高读写磁盘的效率

3.1.1文件读写的基本流程

读文件

进程调用库函数向内核发其读写文件请求

内核通过检查进程的文件描述符定位到虚拟文件系统的以打开文件列表表项

调用该文件可用的系统调函数read()

read()函数通过文件表项链接到目录项模块,根据传入的文件路径,在目录项模块中检索,找到该文件的inode

在inode中通过文件内容偏移量计算出要读取的页

通过inode找到文件对应得sddress_space

在address_space中访问该文件的页缓存树,查找对应的页缓存节点

如果页缓存命中,那么直接返回文件内容

如果页缓存缺失,那么产生一个缺页异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页,重新进行第六步查找页缓存

文件读取成功

写文件

前5步和读文件一致,在address_space中查询对应的页缓存是否存在。

6.如果页缓存命中,直接把文件内容修改更新在页缓存的页中。写文件就结束了。这时文件修

改位于页缓存,并没有写回磁盘文件中去。

7.如果页缓存缺失,那么产生一个缺页异常,创建一个页缓存页,同时通过inode找到文件该页的磁盘地址,读取相应的页填充该缓存页命中,进行第六步。

8.一个页缓存中的页如果被修改,那么会被标注成脏页。脏页需要写回到磁盘中的文件块,有两种方式可以把脏页写回到磁盘:

手动调用sync()或者fsync()系统调用把脏页写回

b.pdflush进程会定时把脏页写回到磁盘

同时注意:脏页不能被换出内存,如果脏页正在被写回,那么会设置写回标记,这时候该页就会被上锁,其他写请求被阻塞直到锁释放

3.2c库函数与系统函数的关系

3.3文件描述符

一个进程启动之后,默认打开三个文件描述符:

#define STDIN_FILENO 0

#define STDOUT_FILENO 1

#define STDERR_FILENO 2

新打开文件返回文件描述符表中未使用的最小文件描述符,调用open函数可以打开或创建一个文件,得到一个文件描述符。

3.4文件IO函数

3.4.1open/close

open函数:

函数描述:打开或者新建一个文件

函数原型:

int open(const char *pathname,int flags)

int open(const chat *pathname,int flags,mode_t mode)

函数参数:pathname参数是要打开或创建的文件名,和fopen一样, pathname既可以是相对路径也可以是绝对路径。

flags参数有一系列常数值可供选择,可以同时选择多个常数用按位或运算符(|)连接起来,所以这些常数的宏定义都以o_开头,表示or。

必选项:以下三个常数中必须指定一个,且仅允许指定一个。

O_RDONLY 只读打开

O_WRONLY 只写打开

O_RDWR 可读可写打开

以下可选项可以同时指定0个或多个,和必选项按位或起来作为flags参数。可选项有很多,这里只介绍几个常用选项:

O_APPEND 表示追加。

O_CREAT 若此文件不存在则创建它。

O_EXCL 若果同时指定O_CREAT,并且文件已存在,则出错返回

O_TRUNC 如果文件已存在,将其长度截断为0字节

O_NONBLOCK 对于设备文件,以O_NONBLOC方式打开可以做非阻塞I/O

函数返回值:

成功:返回一个最小且未被占用的文件描述符

失败:返回-1,并设置errno值.

close函数:

函数描述:关闭文件

函数原型:

int close (int fd)

函数参数:fd文件描述符

函数返回值:

成功返回0

失败返回-1,并设置erron值

3.4.2read/write read函数:

函数描述:从打开的设备或文件中读取

函数原型:

ssize_t read(int fd,void *buf,size_t count)

函数参数:

fd:文件描述符

buf:读上来的数据保存在缓冲区buf中

count:buf缓冲区存放的最大字节数

函数返回值:

0:读取到的字节数

=0:文件读取完毕

-1:出错,并设置errno

write函数:

函数描述:向打开的设备或文件中写数据

函数原型:

ssize_t write(int fd,const void *buf,size_t const);

函数参数:

fd:文件描述符

buf:缓冲区,要写入文件或设备的数据

count:buf中数据的长度

函数的返回值:

成功:返回写入的字节数

失败:返回-1并设置errno

3.4.3lseek

所有打开的文件都有一个当前文件偏移量,以下简称为cfo。cfo通常是一个非负整数,用于表明文件开始出到文件当前位置的字节数。读写操作会被初始化为0,除非使用了O_APPEND。

使用lseek函数可以改变文件的cfo

头文件:

#include<sys/types.h>

#include<unistd.h>

函数描述:移动文件指针

函数原型:

off_t lseek(int fd,off_t offset,int whence);

函数参数;

fd:文件描述符

参数offset的含义取决与参数whence

如果whence是SEEK_SET,文件偏移量将设置为offset。

如果whence是SEEK_CUR,文件偏移量将被设置为cfo加上offset,offset‘可以为正也可以为负。

函数返回值:

若lseek成功执行,则返回新的偏移量

失败返回-1并设置errno

lseek函数常用操作:

文件指针移动到头部

lseek(fd,0,SEEK_SET);

获取当前文件指针当前位置

int len=lseek(fd,0,SEEK_CUR);

获取文件长度

int len=lseek(fd,0,SEEK_END);

lseek实现文件拓展

off_t currpos; //从文件尾部开始向后拓展1000个字节 currpos = lseek(fd, 1000, SEEK_END); //额外执行一次写操作,否则文件无法完成拓展 write(fd, "a",1); //数据随便写 3.4.4perror和errno 许多系统调用和库函数都会因为各种各样的原因失败。

常用错误代码的取值和含义如下:

1 EPERM 操作不允许

2 ENOENT 文件或目录不存在

3 EINTR 系统调用被中断

4 EAGAIN 重试,下次有可能成功!

5 EPADF 文件描述符失效或本身无效

6 EIO I/O错误

7 EBUSY 设备或资源忙

9 EEXIST 文件存在

10 EINVL 无效参数

11 EMFILE 打开的文件过多

12 ENODEV 设备不存在

13 EISDIR 是一个目录

14 ENOTDIR 不是一个目录

两个有效函数可报告出现的错误:strerror和perror

strerror函数

作用:把错误带好映成一个字符串,该字符串对发生的错误类型进行说明。

#include <string.h> char *strerror(int errnum); perror函数

作用:perror函数也把error变量中报告的当前错误映射成一个字符串,并把它输出到标准错误输出流。

#include <stdio.h> void perror(const char *s); perror("text");

结果:

text:Too many open files

3.4.5阻塞和非阻塞 普通文件: hello.c

默认是非阻塞的

终端设备:如/dev/tty

默认阻塞

管道和套接字

默认阻塞

读写普通文件,没有阻塞非阻塞的概念

只有在读写c\b\p\s文件的时候才有阻塞非阻塞的概念

阻塞

当以阻塞方式读取文件的时候

文件为空 进程阻塞等待

文件非空 read write返回操作成功的字节数

以非阻塞方式读取文件

文件为空 read write返回-1,设置errno EAGAIN 什么也没读到 read返回

文件非空 read write返回操作成功的字节数

问题:

比较:如果一个只读一个字节实现文件拷贝,使用read、write效率高,还是使用对应的标库函数(fgetc、 fputc)效率高呢?

strace命令

shell 中使用strace命令跟踪程序执行,查看调用的系统函数。

使用库函数效率高,能使用库函数尽量使用库函数

预读入缓输出:

第四章文件和目录

文件通常有两部分组成:内容+属性,属性即管理信息 :包括文件的创建修改日期和访问权限等。

4.1文件操作相关函数

4.1.1 stat/lstat

stat/lstat函数:

函数描述:获取文件属性

函数原型;

int stat(const char *pathname, struct stat *buf)

int lstat(const char *pathname,struct stat *buf)

函数返回值:

成功返回0

失败返回-1

参数类型:

pathname为待解析文件的路径名,可以为绝对路径,也可以为相对路径

buf为传出,传出文件的解析结果,buf为struct stat*类型,需要进一步解析

struct stat结构体:

4.2目录操作相关函数

opendir函数:

函数描述:打开一个目录

函数原型:

DIR *opendir(const char *name)

函数返回值:指向目录的指针

函数参数:要遍历的目录(相对路径或者绝对路径)

readdir函数:

函数描述:读取目录内容--目录项

函数原型:

struct dirent *readdir(DIR *dirp);

函数返回值:读取的目录项指针

函数参数:opendir函数的返回值

closedir函数:

函数描述:关闭目录

函数原型:

int closedir(DIR *dirp)

函数返回值:

成功返回0

失败返回-1

函数参数:opendir函数的返回值

4.3读取目录的一般操作步骤

DIR *pDIR = opendir("dir");//打开目录 while (p = readdir(pDIR) != NULL) {}//循环获取文件 closedir(pDIR);//关闭目录

4.4dup/dup2/fcntl

图解dup和dup2的功能

dup函数:

函数描述:复制文件描述符

函数原型:

int dup(int oldfd);

函数参数:old fd -要复制的文件描述符

函数返回值:

成功:返回最小没被占用的文件描述符

失败:返回-1,设置error值

dup2函数:

函数描述:复制文件描述符

函数原型:

int dup2(int oldfd,int newfd)

函数参数:

oldfd:原来的文件描述符

newfd:复制成的新文件描述符

函数返会值:

成功:将oldfd复制给newfd,两个文件描述符指向同一个文件

失败:返回-1,设置errorno值

假设newfd已经指向一个文件:

首先close原来的文件,然后newfd指向oldfd指向的文件

若newfd没有被占用:

newfd指向oldfd指向的文件

fcntl函数:

函数描述:改变已经打开的文件的属性

函数原型:

int fcntl(int fd,int cmd,.../arg/ );

cmd值:

若cmd为F_DUPFD,复制文件描述符,与dup相同

若cmd为F_GETEL,获取文件描述符的flag属性值

若cmd为F_SETFL,设置文件描述符的flag属性

函数返回值:返回值取决于cmd

成功

若cmd为F_DUPFD,返回一个文件描述符

若cmd为F_GETFL,返回文件描述符的flag值

若cmd为F_SETFL,返回0

失败返回-1,并设置errorno值

fcntl函数常用的操作:

复制一个新的文件描述符

int newfd=fcntl(fd,F_FUPFD,0)

获取文件的属性标志

设置文件状态

常用的属性标志

第五章进程

5.1对进程的理解

在许多多道程序系统中,CPU会在进程间快速切换,使每个程序运行几十或者几百毫秒。然而,严格意义来说,在某一个瞬间,CPU只能运行一个进程,然而我们如果把时间定位为1秒内的话,它可能运行多个进程。这样就会让我们产生并行的错觉。有时候人们说的伪并行(pseudoparallelism)就是这种情况,以此来区分多处理器系统(该系统由两个或多个CPU来共享同一个物理内存)

再来详细解释一下伪并行:伪并行是指单核或多核处理器同时执行多个进程,从而使程序更快。通过以非常有限的时间间隔在程序之间快速切换CPU,因此会产生并行感。缺点是CPU时间可能分配给下一个进程,也可能不分配给下一个进程。

因为 CPU执行速度很快,进程间的换进换出也非常迅速,因此我们很难对多个并行进程进行跟踪,所以,在经过多年的努力后,操作系统的设计者开发了用于描述并行的一种概念模型(顺序进程),使得并行更加容易理解和分析,对该模型的探讨,也是本节的主题。下面我们就来探讨一下进程模型

5.2进程模型

在进程模型中,所有计算机上运行的软件,通常也包括操作系统,被组织为若干顺序进程,简称为进程。一个进程就是一个正在执行的实例,进程也包括程序计数器、寄存器和变量的当前值。从概念上来说,每个进程都有各自的虚拟CPU,但是实际前情况是CPU会在各个进程之间进行来回切换。

如上图所示,这是一个具有4个程序的多道程序,在进程不断切换的过程中,程序计数器也在不同的变化。

这四道程序被抽象为四个拥有各自控制流程(即所有自己的程序计数器)的进程,并且每个程序都是独立运行的。当然,实际上只有一个物理程序计数器,每个程序要运行时,其逻辑程序计数器会装载到物理程序计数器中。

从下图我们可以看到,在观察足够长的一段时间后,所有的进程都运行了,但在任何一个给定的瞬间仅有一个进程真正运行

因此,我们说一个CPU只能真正一次运行一个进程的时候,即使有两个核, 每一个核也只能一次运行一个线程

5.3进程创建

操作系统需要一些方式来创建进程。下面是一些创建进程的方式

系统初始化(init)

正在运行的程序执行了创建进程的系统调用(比如fork)

用户请求创建一个新进程

5.3.1系统初始化

启动操作系统时,通常会创建若干个进程。其中有些是前台进程,也就是同用户进行交互并替换它们的工作的进程。一些运行在后台,并不与特定的用户进行交互,例如设计一个进程来接收发来的电子邮件,这个进程大部分的时间都是在休眠,但是只要邮件到来后这个进程就会被唤醒。还可以设计一个进程来接收对该计算机上网页的传入请求,在请求到达的进程唤醒来处理网页的传入请求。进程运行在后台来处理一些活动像是e_mail,web网页,新闻,打印等等被称作守护进程。大型系统会有很多守护进程。在UNIX中,ps程序可以列出正在运行的进程,在Windows中,可以使用任务管理器。

5.3.2系统调用创建

除了在启动阶段创建进程之外,一些新的进程也可以在后面创建。通常,一个正在运行的进程会发出系统调用用来创建一个或多个新进程来帮助其完成工作。例如,如果有大量的数据需要经过网络调取并进行顺序处理,那么创建一个进程读数据,并把数据放到共享缓冲区中,而让第二个进程取走并正确处理会比较容易些。在多处理器中,让每个进程同时运行在不同的CPU上也可以使工作做的更快。

5.3.3用户请求创建

在许多交互式系统中,输入一个命令或者双击图标就可以启动程序,以下任意一种操作都可以选择开启一个新的进程,在基本的UNIX系统中运行X,新进程将接管启动它的窗口。在Windows中启动进程时,它一般没有窗口,但是它可以创建一个或者多个窗口。每个窗口都可以运行进程。通过鼠标或者命令·切换窗口并与进程进行交互。

交互式系统是以人与计算机之间大量交互为特征的计算机系统,比如游戏,web浏览器,IDE等集成开发环境。

在UNIX和Windows中,进程创建之后,父进程和子进程有各自不同的地址空间。如果其中某个进程在其地址空间中修改了一个词,这个修改将对另一个进程不可见。在UNIX中,子进程的地址空间是父进程的一个拷贝,但是确是两个不同的地址空间;不可写的内存区域是共享的。某些UNIX实现是正是在两者之间共享,因为它不能被修改。或者,子进程共享父进程的所有内存,但是这种情况下内存通过写时复制,共享,这意味着一旦两者之一想要修改部分内存,则这块内存首先明确的复制,以确保修改发生在私有内存区域。再次强调,可写的内存是不能被共享的。但是,对于一个新进程来说,确实有可能共享创建者的资源,比如可以共享打开的文件。在Windows中从一开始父进程的地址空间和子进程的地址空间就是不同的。

5.4进程的终止

进程在创建之后,它就开始运行并做完成任务。然而,没有什么事是永不停歇的,包括进程也一样。进程早晚会发生终止,但是通常是由于以下情况触发的

正常退出(自愿的)

错误退出(自愿的)

严重错误(非自愿的)

被其他进程杀死(非自愿的)

5.4.1正常退出

多数进程是由于完成了工作而终止。当编译器完成了所给定程序的编译之后,编译器会执行一个系统调用告诉系统它完成了工作。这个调用在UNIX中是exit,在Windows中是ExitProcess。面向屏幕中的软件也是支持自愿终止操作。自处理软件、Internet浏览器类似的程序中总有一个供用户点击的图标或菜单项,用来通知进程删除它所打开的任何临时文件,然后终止。

5.4.2错误退出

进程发生种终止的第二个原因是发现严重错误,例如,如果用户执行如下命令

gcc main.c 为了能够编译main.c但是该文件不存在,于是编译器就会发出声明并退出。在给出了错误参数时,面向屏幕的交互式进程通常并不会直接退出,因为这从用户的角度来说并不合理,用户需要知道发生了什么并想要进行重试,所以这时候应用程序通常会弹出一个对话框告知用户发生了系统错误,是需要重试还是退出。

5.4.3严重错误

进程终止的第三个原因时由进程引起的错误,通常时由于程序中的错误所导致的。例如,执行了一条非法指令,引用不存在的内存,或者除数是0等。在有些系统比如UNIX中进程可以通知操作系统,它希望自行处理某种类型的错误,在这类错误种进程会收到信号(中断)而不是在这类错误出现时直接终止进程。

5.4.4被其它进程杀死

第四各终止进程的原因时,某个进程执行系统调用告诉操作系统杀死某个进程。在UNIX中,这个系统调用就是kill。

5.5进程的层次结构

在一些系统中,当一个进程创建了其它进程后,父进程和子进程就会以某种方式进行关联。子进程它自己就会创建更多进程,从而形成一个进程层次结构。

5.5.1UNIX进程体系

在UNIX中,进程和它的所有子进程以及子进程的子进程共同组成一个进程组。当用户从键盘中发出一个信号后,该信号被发送给当前与键盘相关的进程组中得所有成员(它们通常是在当前窗口创建的所有活动进程)。每个进程可以分别捕获该信号、忽略该信号或采取默认的动作,即被信号kill掉。

这里另外一个例子,可以用来说明层次的作用,考虑UNIX在启动时如何初始化自己。一个称为init的特殊进程出现在启动映像中,当init进程开始运行时,它会读取一个文件,文件会告诉它有多少个终端。然后为每个终端创建一个新进程。这些进程等待用户登录。如果登录成功,该登录进程就执行一个shell来等待接收用户输入指令,这些命令可能会启动更多的进程,以此类推。因此,整个操作系统中所有的进程都隶属于一个以init为根的进程树。

5.6进程的状态

尽管每个进程是一个独立的实体,有其自己的程序计数器和内部状态,但是,进程之间仍然需要相互帮助。例如,一个进程的结果可以作为另一个进程输入,在shell命令中

cat chapter1 chapter2 chapter3 | grep tree 第一个进程时cat,将三个文件级联并输出。第二个进程是grep,它从输入中选这具有包含关键字tree的内容,根据这两个进程的相对速度(这取决于两个程序的相对复杂度和各自所分配到的CPU时间片),可能会发生下面的情况,直到输入完毕。

当一个进程开始运行时,他可能会经历下面的这几种状态

图中会涉及三种状态

运行态:运行态指的就是进程实际占用CPU时间片运行时

就绪态:就绪态指的是可运行,但因为其它进程正在运行而处于就绪状态

阻塞态:除非某种外部事件发生,否则进程不能运行

逻辑上来说,运行态和就绪态时很相似的。这两种情况下都表示进程可运行,但是第二种情况没有获得CPU时间分片。第三种状态与前两种状态不同的原因是这个进程不能运行,CPU空闲时也不能运行。

三种状态会涉及四种状态间的切换,在操作系统发现进程不能继续执行时会发生状态1的轮转,在某些系统中进程执行系统调用,例如pause,来获取一个阻塞的状态。在其它系统中包括UNIX,当进程从管道或特殊文件(例如终端)中读取没有可用的输入时,该进程会被自动终止。

转换2和转换3都是由进程调度程序(操作系统的一部分)引起的,进程本身不知道调度程序的存在。转换2的出现说明进程掉度器认定当前进程已经运行足够长的时间,是时候让其它进程运行CPU时间片了。当所有其它进程都运行过后,这时候该让第一个进程重新获得CPU时间片的时候了,就会发生转换3.

程序调度指的是,决定哪个进程优先被运行和运行多久,这是很重要的一点。

当进程等待的一个外部事件发生时(如从外部输入一些数据后),则发生转换4,如果此时没有其它进程在运行,则立刻触发转换3,该进程便开始运行,否则该进程会处于就绪阶段,等待CPU空闲后在轮到它运行。

从上面的观点引入了下面的模型

操作系统最底层的就是调度程序,在它上面有许多进程。所有关于中断处理、启动进程和停止进程的具体细节都是隐藏在调度程序中。事实上,调度程序只是一段非常小的程序。

*5.7进程的实现

操作系统为了执行进程间的切换,会维护这一张表格,这张表就是进程表(process table)。每个进程占用一个进程表项。该表项包含了进程状态的重要信息,包括进程计数器、堆栈指针、内存分配状况、所打开文件的状态、账号和调度信息,以及其它在进程由运行态转换到就绪态或阻塞态时所必须保存的信息,保证该进程随后能再次启动,就像从未被中断过一样。

下面展示了一个典型系统中的关键字段

第一列内容与进程管理有关,第二列内容与存储管理有关,第三列内容与文件管理有关。

存储管理的text segment . data segment、stack segment

现在我们应该利进程表有个人致的了解了,就可以在刘单个CPU上如何运行多个顺序进程的错觉做更多的解释。与每一I/0 类相关联的是一个称作中断向量

(interrupt vector)的位置(靠近内存底部的固定区域)。它包含中断服务程序的入口地址。假设当一个磁盘中断发生时,用户进程3正在运行,则中断硬件将程序计数器、程序状态字、有时还有一个或多个寄存器压入堆栈,计算机随即跳转到中断向量所指示的地址。这就是硬件所做的事情。然后软件就随即接管一切剩余的工作。

当中断结束后,操作系统会调用一个C程序来处理中断剩下的工作。在完成剩下的工作后,会使某些进程就绪,接着调用调度程序,决定随后运行哪个进程。然后将控制权转移给一段汇编语言代码,为当前的进程装入寄存器值以及内存映射并启动该进程运行。

下面显示了中断处理个调度的过程。

硬件压入堆栈程序计数器等

硬件从中断向量装入新的程序计数器

汇编语言过程设置保存寄存器的值

汇编语言过程设置新的堆栈

C中断服务器运行(典型的读和缓存写入)

调度器决定下面哪个程序先运行

C过程返回至汇编代码

汇编语言过程开始运行新的当前进程

5.8进程的控制

5.8.1进程的创建函数

进程的创建函数fork()函数

Linux系统允许任何一个用户进程创建子进程,创建成功后,子进程将存在于系统之中,并且独立于父进程,该子进程可以接受系统调度,可以得到分配的系统资源,系统也可以检测到子进程的存在,并且赋予它与父进程同样的权利。

Linux系统下使用fork()函数创建一个子进程,其函数原型如下:

#include<unistd.h> pid_t fork(void); fork()函数不需要参数,返回值是一个进程标识符( PID )对于返回值,有以下3种情况:

对于父进程, fork()函数返回新创建的子进程的ID ;

对于子进程,fork()函数返回0;

如果创建出错,则fork ()函数返回-1,子进程不被创建。

fork ()函数会创建一个新的进程,并从内核中为此进程分配一个新的可用的进程标识符(PID),之后,为这个新进程分配进程空间,并将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段,这时候,系统中又多了一个进程,这个进程和父进程一样,两个进程都要接受系统的调度。由于在复制时复制了父进程的堆栈段,所以两个进程都停留在了fork()函数中,等待返回,因此,fork()函数返回两次,一次是在父进程中返回,另一次是在子进程中返回,这两次的返回值是不一样的。

fork函数创建子进程:

#include<stdio.h>
#include<unistd.h>
int main(int argc,char* argv[])
{
    int pid=fork();
    if(pid==0)
    {
        printf("I'm child,ID=%d,Myparent ID=%d\n",getpid(),getppid());
    }
    else if(pid>0)
    {
        printf("I'm parent,ID=%d,Myparent ID=%d\n",getpid(),getppid());
        sleep(1);
    }
    return 0;
}

fork后父进程和子进程的异同:

父子进程之间在fork 后。有哪些相同,哪些相异之处呢?

刚fork 之后:

父子相同处:全局变量、.data、.text、栈、堆、环境变量、用户ID、宿主目录、进程工作目录.....

父子不同处:进程ID、fork 返回值、父进程ID、进程运行时间。

似乎,子进程复制了父进程0-3G用户空间内容,以及父进程的 PCB,但 pid 不同。真的每 fork一个子进程都要将父进程的0-3G地址空间完全拷贝一份,然后在映射至物理内存吗?

当然不是!

父子进程间遵循读时共享写时复制(copy-on-write)的原则。

现在的Linux内核在fork()函数时往往在创建子进程时并不立即复制父进程的数据段和堆栈段,而是当子进程修改这些数据内容时复制操作才会发生,内核才会给子进程分配进程空间,将父进程的内容复制过来,然后继续后面的操作,这样的实现更加合理,对于那些只是为了复制自身完成一些工作的进程来说,这样做的效率会更高这也是现代操作系统的一个重要的概念之一“写时复制”的一个重要体现。

5.8.2进程的结束函数

进程的结束exit()函数

当一个进程需要退出时,需要调用退出函数,Linux环境下使用exit()函数退出进程,其函数原型如下:

#include<stdlib.h> void exit(int status); exit()函数的参数表示进程的退出状态,这个状态的值是一个整型,保存在全局变量?中,Linux程序员可以通过shell得到已结束进程的结束状态,执行“echo?中 ,Linux程序员可以通过shell得到已结束进程的结束状态,执行“echo ?”命令即可

$?是 Linux shell中的一个内置变量其中保存的是最近一次运行的进程的返回值,这个返回值有以下3种情况:

1.程序中的main函数运行结束,$?中保存main 函数的返回值;

2.程序运行中调用exit函数结束运行,$?中保存exit函数的参数;

3.程序异常退出$?中保存异常出错的错误号。

5.8.3问题?

创建n个进程,当n=10:

int main(int argc,char* argv[])
{
        pid_t pid;
        int i=0;
        for(i;i<10;i++)
        {
                pid=fork();
                if(pid==0)
                {
                        return 0;
                }
                printf("i=%d\n",i);
                printf("pid=%d\n",getpid());
        }
        return 0;
}

5.8.4exec函数族

fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种 exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id 并未改变。

将当前进程的.text、.data 替换为所要加载的程序的.text、.data,然后让进程从新的.text第一条指令开始执行,但进程ID不变,换核不换壳。

其实有六种以exec开头的函数,统称exec函数:

#include <unistd.h>

int execl(const char path, const char arg, ... / (char) NULL*/);

int execlp(const char file, const char arg, ... / (char) NULL*/);

int execle(const char *path, const char arg, ... /, (char )NULL, char * const envp[]/ );

int execv(const char *path, char *const argvI[]);

int execvp(const char *file, char *const argv[]);

int execvpe(const char *file, char *const argv[]);

execlp函数

加载一个进程,借助PATH环境变量

int execlp(const char *file, const char arg, ... / (char )NULL/); 成功:无返回;失败:-1

参数1:要加载的程序的名字。该函数需要配合PATH环境变量来使用,当PATH中所有目录搜索后没有参数1则出错返回。

该函数通常用来调用系统程序。如:ls、data、cp、cat、等命令

execl函数

加载一个进程,通过路径+程序名来加载

int execl(const char *path, const char arg, ... / (char )NULL/); 成功:无返回;失败:-1

对比execlp,如加载"ls"命令带有-1,-h参数

5.8.5孤儿进程

孤儿进程:父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为init进程,称为init进程领养孤儿进程。

5.8.6僵尸进程

僵尸进程:进程终止,父进程尚未回收,子进程残留资源(PCB)存放于内核中,变成僵尸(zombie)进程。

特别注意,僵尸进程是不能使用kill命令杀掉的。因为kill命令只是用来终止进程的,而僵尸进程已经终止。

5.8.7守护进程

守护进程是什么?

Linux Daemon(守护进程)是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事件。他不需要

创建守护进程的步骤

进程组:

每个进程也属于一个进程组

每个进程组都有一个进程组号,该号等于进程组阻长的PID号。

一个进程只能为它自己或子进程设置进程组ID号

会话:

会话是一个或多个进程组的集合。

setsid()函数可以建立一个会话:

如果调用setsid的进程不是一个进程组的组长,此函数创建一个新的会话。

此进程变成该会话的首进程

此进程变成一个新进程组的组长进程

此进程没有控制终端,如果在调用setsit前,该进程有控制终端,那么与该终端的联系会解除、如果该进程是一个进程组的组长,此函数返回错误。

为了保证这一点,我们先调用fork()然后exit(),此时只有子进程在运行

编写守护进程的一般步骤:

父进程中执行fork并exit退出;

在子进程中调用setsid函数创建新的会话

在子进程中调用chdir函数,让根目录”/“成为工作目录

在子进程中调umask函数,设置进程的umask为0

在子进程中关闭任何不需要的文件描述符

创建守护进程

#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>
#include<stdlib.h>
#include<sys/socket.h>
#include<sys/stat.h>
int main(int argc,char* argv[])
{
        pid_t pid;
        pid=fork();
        if(pid==0)
        {
                setsid();
                chdir("/");
                umask(0);
                int fd=open("/dev/null",O_RDWR);
                dup2(fd,0);
                dup2(fd,1);
                dup2(fd,2);
                while(1);
        }
        if(pid>0)
        {
                return 0;
        }
        return 0;
}

/dev/null:表示 的是一个黑洞,通常用于丢弃不需要的数据输出, 或者用于输入流的空文件

说明:

1.在后台运行

为避免挂起控制终端将Daemon放入后台执行。方法是在进程中调用fork使父进程终止,让Daemon在子进程中后台执行

if(pid=fork())
    exit(0);//是父进程,结束父进程,子进程继续

2.脱离控制终端,登录会话进程组

Linux中的进程与控制终端,登录会话和进程组之间的关系:进程属于一个进程组,进程组号(GID) 就是进程组长的进程号(PID) 。登录会话可以包含多个进程组。这些进程组共享一个控制终端。这个控制终端通常是创建进程的登录终端。

控制终端,登录会话和进程组通常是从父进程继承下来的。我们的目的就是要摆脱它们,使之不受它们的影响。方法是在第1点的基础上,调用setsid() 使进程成为会话组长:

setsid(); 说明:当进程是会话组长时setsid()调用失败。但第一点已经保证进程不是会话组长。setsid()调用成功后,进程成为新的会话组长和新的进程组长,并与原来的登录会话和进程组脱离。由于会话过程对抗控终端的独占性,进程同时与控制终端脱离。

3.禁止进程重新打开控制终端

现在,进程已经成为无终端的会话组长。但它可以重新申请打开一个控制终端。可以通过使进程不再成为会话组长来禁止进程重新打开控制终端:

if(pid=fork())
    exit(0);//结束第一个子进程,第二个进程继续(第二子进程不再是会话组长)

4.关闭打开的文件描述符

进程从创建它的父进程那里继承了打开的文件描述符。如不关闭,将会浪费系统资源,造成进程所在的文件系统无法卸下以及引起无法预料的错误。

5.改变当前工作目录

进程活动时,其工作目录所在的文件系统不能卸下。一般需要将工作目录改变到根目录。对于需要转储核心,写运行日志的进程将工作目录改变到特定目录

如/ tmpchdir("/")

6.重设文件创建掩码

进程从创建它的父进程那里继承了文件创建掩码。它可能修改守护进程所创建的文件的存取位。为防止这一一点, 将文件创建掩码清除: umask(O) ;

7.处理SIGCHLD信号

处理SIGCHLD信号并不是必须的。但对于某些进程,特别是服务器进程往往在请求到来时生成子进程处理请求。如果父进程不等待子进程结束,子进程将成为僵尸进程(zombie)从而占用系统资源。如果父进程等待子进程结束,将增加父进程的负担,影响服务器进程的并发性能。在Linux 下可以简单地将SIGCHLD信号的操作设为SIG_ IGN。

signal(SIGCHLD,SIG_LGN); 这样,内核在子进程结束时不会产生僵尸进程。这一点与BSD4不同,BSD4下必须显示等待子进程结束才能释放僵尸进程

5.8.5进程回收

wait函数

一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。这个进程的父进程可以调用wait或waitpid 获取这些信息,然后彻底清除掉这个进程。一个进程的退出状态可以在Shell 中用特殊变量$?查看,因为Shell 是它的父进程,当它终止时Shell调用wait 或waitpid得到它的退出状态同时彻底清除掉这个进程。

父进程调用wait函数可以回收子进程终止信息。该函数有三个功能:

阻塞等待子进程退出

回收子进程残留资源

获取子进程结束状态(退出原因)

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int *status);

成功:返回清理掉的子进程ID;

失败:返回-1 (没有子进程)

当进程终止时,操作系统的隐式回收机制会:

1.关闭所有文件描述符

2.释放用户空间、分配的内存。内核的PCB 仍存在。其中保存该进程的退出状态。(正常终止→退出值;异常终止→终止信号)

可使用wait函数传出参数status 来保存进程的退出状态。借助宏函数来进一步判断进程终止的具体原因。宏函数可分为如下三组:

1.WIFEXITED(status) //为真 ->进程正常结束 WEXITSTATUS(status) //如上宏为真,使用此宏 ->获取进程退出状态(exit的参数) 2.WIFSIGNALED(status) //为真 ->进程异常终止 WTERMSIG(status) //上宏为真,使用此宏 ->取得使进程终止的那个信号的编号。

  • 3. WIFSTOPPED(status) //为非真->进程处于暂停状态 WSTOPSIG(status) //如上宏为真,使用此宏->取得使进程暂停的那个信号的编号。 WIFCONTINUED(status) //如WIFSTOPPED(status)为真->进程暂停后已经继续运行 waitpid函数

作用同wait,但可指定进程id为pid的进程清理,可以不阻塞。

#include<sys/types.h>
#include<sys/wait.h>
pid_t waitpid(pid_t,int *status,int options);
成功:返回清理掉的子进程ID;

失败:-1(无子进程)

特殊参数和返回情况:

参数pid:

>0回收指定ID的子进程

-1回收任意子进程(相当于wait0回收和当前调用waitpid一个组的任一子进程

<0回收指定进程组内的任意子进程

返回0:参数3为WNOHANG,且子进程正在运行

一次wait或waitpid调用只能清理一个子进程,清理多个子进程应使用循环。