0x00 环境搭建

先去官网下载源码http://www.apuebook.com/

1
2
3
4
5
6
7
8
#mac如下操作即可(我是m1)
cd apue.3e
make
......

sudo cp ./include/apue.h /usr/local/include/
sudo cp ./lib/libapue.a /usr/local/lib/
gcc filename.c -lapue

0x01 UNIX基础知识概述

  1. 我们可以将操作系统定义为一种软件,它用来控制计算机硬件资源,提供程序运行环境,我们将这个软件称为内核(kernel)。
  2. 内核的接口被称为系统调用(system call),下图的阴影部分。公用函数库构建在系统调用之上,应用程序既可以使用公用函数,也可以使用系统调用。shell是一个特殊的应用程序,为运行其他应用程序提供了一个接口。

0x02 文件I/O

1. 文件描述符

  1. 文件描述符是一个小的非负整数,内核用其标识一个正在访问的文件。当内核打开一个文件或者创建一个文件的时候,会返回一个文件描述符。读写文件时就用这个文件描述符。
  2. 在运行一个新程序的时候,所有的shell都会为其打开3个文件描述符(标准输入(0),标准输出(1),标准错误(2)),如果不做特殊处理,这3个文件描述符都会链接到shell。
  3. 使用open新打开的文件会从3开始以此递加
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include "apue.3e/include/apue.h"

#define BUFFSIZE 4096

int main(int argc, char *argv[]) {
int n;
char buf[BUFFSIZE];

while ((n = read(STDIN_FILENO, buf, BUFFSIZE)) > 0) // 标准输入0
// read函数返回读取的字节数,如果执行失败会返回-1
if (write(STDOUT_FILENO, buf, n) != n) // 标准输出1
// write函数返回写入的字节数,如果执行失败返回-1
err_sys("write error");

if (n < 0)
err_sys("read error");
exit(0);
}
1
2
3
4
5
6
7
8
$ ./a.out > data # 标准输入是终端,标准输出为data
aaaa
^C
$ cat data
aaaa
$ ./a.out < data > outfile # 标准输入是data,标准输出是outfile
$ cat outfile
aaaa
1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int a=open("hello.txt",O_RDONLY);
printf("%d",a);
int b=open("hello.txt",O_RDWR);
printf("%d",b);
}
// 34

2. 常用函数

  • open
1
2
3
4
5
6
7
8
int open(const char *path,int oflag,.../* mode_t mode */);
// path:要打开或创建文件的名字;
// oflag:用下列一个或多个常量进行“或”运算构成oflag参数;
// O_RDONLY:文件以只读方式打开;
// O_WRONLY:文件以只写模式打开;
// O_RDWR:文件以可读可写模式打开;
// 等等....
// 成功返回文件描述符,否则返回-1
  • close
1
2
3
int close(int fd);
// 关闭文件描述符
// 成功返回0,否则返回-1
  • lseek
1
2
3
4
5
off_t lseek(int fd, off_t offset, int whence);
// 打开一个文件的下一次读写的开始位置
// offset是指whence的偏移量
// whence可以是SEEK_SET(文件指针开始),SEEK_CUR(文件指针当前位置),SEEK_END(文件指针尾部)
// 成功返回当前文件指针到开头的距离,否则返回-1
  • read
1
2
3
4
ssize_t read(int fd,void *buf,int count);
// buf 要读取的内容存放处
// count 要读取的字节数
// 成功返回读取的字节数,出错返回-1并设置errno
  • write
1
2
3
4
ssize_t write(int fd, const void *buf, size_t nbyte);
// buf 要写入的内容
// nbyte 要写入的字节数
// 成功返回写入的字节数,失败返回-1
1
2
3
4
5
6
7
#include <unistd.h>
int main()
{
char buf[10];
read(0,buf,10);
write(1,buf,10);
}
  • dup和dup2
1
2
3
int dup(int oldfd);
int dup2(int oldfd, int newfd);
// 复制文件描述符,重定向输入输出

返回值:
成功:dup函数返回当前系统可用的fd中最小的整数值。
dup2函数返回第一个不小于newfd的整数值,分两种情况:

​ 1. 如果newfd已经打开,则先将其关闭,再复制文件描述符;

​ 2. 如果newfd等于oldfd,则dup2函数返回newfd,而不关闭它。

​ 失败:dup和dup2函数均返回-1,并设置errno。

  • fcntl
1
2
int fcntl(int fd, int cmd, .../* int arg */);
// fcntl(file control)函数可执行各种描述符控制操作。

fcntl函数有以下5种功能

  1. 复制一个已有的描述符(cmd=F_DUPFD或F_DUPFD_CLOEXEC)
  2. 获取/设置文件描述符标志(cmd=F_GETFD或F_SETFD)
  3. 获取/设置文件状态标志(cmd=F_GETFL或F_SETFL)
  4. 获取/设置异步I/O所有权(cmd=F_GETOWN或F_SETFL)
  5. 获取/设置记录锁(cmd=F_GETLK、F_SETLK或F_SETLKW)

3.文件共享(简略介绍进程表)

内核负责管理维护所有进程,为了管理进程,内核在内核空间创建了一个被称为进程表的数据结构,这个数据结构中记录了所有进程,每个进程在数据结构中都被称为一个进程表项。

进程表中除了记录所有进程的PID,还使用一个字段记录了所有进程的指针,指向了每个进程的进程控制块。

进程控制块中有一个是跟文件描述符相关的,如下图

i-node的信息是打开文件时,从磁盘读入内存的。里面存放了文件所有者、文件长度、指向文件实际数据在磁盘地址等。

如果两个独立进程打开了同一个文件,如下图。

之所以有文件表项是因为每个进程都可以有自己对该文件的偏移量。

  • 文件状态标志:可读可写等
  • 偏移量:就是文件读写位置(去看lseek函数)
  • 文件长度是实时更新的

dup函数执行之后的文件表项:

0x03 文件和目录

1. 常用函数

  • stat
1
2
3
int stat(const char * file_name,struct stat *buf);
// stat()用来将参数file_name所指的文件状态, 复制到参数buf所指的结构中。
// 执行成功返回0,失败返回-1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct stat {
dev_t st_dev; //device 文件的设备编号
ino_t st_ino; //inode 文件的i-node
mode_t st_mode; //protection 文件的类型和存取的权限
nlink_t st_nlink; //number of hard links 连到该文件的硬连接数目, 刚建立的文件值为1.
uid_t st_uid; //user ID of owner 文件所有者的用户识别码
gid_t st_gid; //group ID of owner 文件所有者的组识别码
dev_t st_rdev; //device type 若此文件为装置设备文件, 则为其设备编号
off_t st_size; //total size, in bytes 文件大小, 以字节计算
unsigned long st_blksize; //blocksize for filesystem I/O 文件系统的I/O 缓冲区大小.
u nsigned long st_blocks; //number of blocks allocated 占用文件区块的个数, 每一区块大小为512 个字节.
time_t st_atime; //time of lastaccess 文件最近一次被存取或被执行的时间, 一般只有在用mknod、 utime、read、write 与tructate 时改变.
time_t st_mtime; //time of last modification 文件最后一次被修改的时间, 一般只有在用mknod、 utime 和write 时才会改变
time_t st_ctime; //time of last change i-node 最近一次被更改的时间, 此参数会在文件所有者、组、 权限被更改时更新
};

2. 文件类型

  1. 普通文件:最常见的文件类型,至于这种数据是文本还是二进制数据,对于内核来说都一样,对于普通文件内容的解释由处理该文件的应用程序进行。
  2. 目录文件:这种文件包含了其他文件的名字,以及指向这些文件的有关信息的指针。对一个目录文件具有读权限的任一进程都可以读目录的内容,但是只有内核可以直接写目录文件。
  3. 块特殊文件:这种类型的文件提供对设备带缓冲的访问,每次访问以固定长度为单位进行。
  4. 字符特殊文件:这种类型的文件提供对设备不带缓冲的访问,每次访问时长度可变。(系统中的设备(打印机、磁盘、网络等等设备,一般位于/dev目录下)要么是字符特殊文件,要么是块特殊文件)。
  5. FIFO:用于进程间通信,有时也被称为管道(named pipe)。
  6. 套接字(socket):用于进程间的网络通信。
  7. 符号链接:这种类型的文件指向另一个文件。

这些信息存在stat结构的st_mode成员中

1
2
3
4
5
6
7
S_ISREG() // 普通文件
S_ISDIR() // 目录文件
S_ISCHR() // 字符特殊文件
S_ISBLK() // 块特殊文件
S_ISFIFO() // 管道或FIFO
S_ISLNK() // 符号链接
S_ISSOCK() // socket

0x04 进程

  1. kernel使用exec函数将程序从磁盘读入内存并执行。
  2. 正在执行的程序被称为进程,每个进程都有一个唯一的数字标识符,称为PID(Process ID)

并行:指两个或两个以上的事件或活动在同一时刻发生,在多道程序下,并行使多个程序同一时刻可在不同CPU上同时执行

并发:在同一个CPU上同时运行多个程序(CPU在多个程序之间切换,只是看起来在运行多个程序)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include "apue.3e/include/apue.h"

#include <sys/wait.h>

int main(int argc, char *argv[]) {
char buf[MAXLINE]; // apue.h 中定义 #define MAXLINE 4096
pid_t pid;
int status;

printf("%% ");
while (fgets(buf, MAXLINE, stdin) != NULL) {
if (buf[strlen(buf) - 1] == '\n')
buf[strlen(buf) - 1] = 0;
// fgets返回的每一行都以换行符结束,我们用null字节替换换行符
// 因为execlp函数要求的参数要以null结束
if ((pid = fork()) < 0) {
err_sys("fork error");
// 调用fork函数创建一个子进程,fork对父进程返回子进程的进程id
// 对子进程返回0
} else if (pid == 0) {
// 如果是子进程就执行命令
execlp(buf, buf, (char *) 0);
// 函数执行成功就没有返回值,执行失败返回-1
// int execl(const char * path, const char * arg, ...);
// path是环境变量,后面的arg表示参数
// 最后一个参数必须用NULL结束
// 这里第一个直接用buf是因为环境变量PATH中已经包含路径/usr/bin了
err_ret("couldn't execute: %s", buf);
exit(127);
}
if ((pid = waitpid(pid, &status, 0)) < 0)
// pid_t waitpid(pid_t pid, int *status, int options);
// waitpid()会暂时停止目前进程的执行, 直到有信号来到或子进程结束.
// 该函数可以判读子进程是如何终止的
err_sys("waitpid error");
printf("%% ");
}
exit(0);
}

1. PCB进程控制块

操作系统中用进程控制块(Process Control Block)来描述和管理进程

PCB是进程存在的唯一标识,PCB主要包含如下信息:

  1. 进程描述信息
  • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符
  • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务
  1. 进程控制和管理信息
  • 进程当前状态:如new、ready、running、waiting或blocked等
  • 进程优先级:进程抢占CPU的优先级
  1. 资源分配清单
  • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的I/O设备信息
  1. CPU相关信息
  • CPU中各个寄存器的值,当进程被切换时,CPU的状态信息被保存在相应的PCB中,以便进程重新执行

这个图没画全,进程表应该还有PID,可以看前面的补充一下

2. 常用函数

  • fork
1
pid_t fork( void);

fork函数用于创建一个新的进程,称为子进程,它与父进程同时运行fork函数后的下一条指令。fork函数被调用一次会返回两次。两次返回的区别是子进程返回0和父进程返回子进程ID。如果出错会返回负值。

子进程会获得父进程的副本,并且子进程和父进程不共享存储空间。

一般来,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法

  • kill
1
2
3
4
5
6
7
8
int kill(pid_t pid,int signo)
// pid: 接收信号的进程(组)的进程号
// pid>0: 发送给进程号为pid的进程
// pid=0: 发送给当前进程所属进程组里所有的进程
// pid=-1: 发送给除1号进程和自身以外的所有进程
// pid<-1: 发送给属于进程组-pid的所有进程

// signo: 发送的信号

向进程或者进程组发送一个信号(成功返回0,失败返回-1)

bash的kill命令实际上是对kill函数的一种封装(kill(pid, SIGINT);

  • getpid/getppid
1
2
3
4
5
pid_t getpid(void);
// 用来获得目前进程的pid
// 返回进程的pid
pid_t getppid(void);
// 获得目前进程的父进程pid
  • wait
1
2
pid_t wait (int * status);
// 等待一个子进程是否退出,直到退出前一直进行等待
  • waitpid
1
2
3
4
5
6
7
8
9
10
pid_t waitpid(pid_t pid,int * status,int options);
// 如果在调用waitpid()函数时,当指定等待的子进程已经停止运行或结束了,则waitpid()会立即返回;
// 但是如果子进程还没有停止运行或结束,则调用waitpid()函数的父进程则会被阻塞,暂停运行。
// 返回: 如果成功,则为子进程的PID,如果options为WNOHANG,则返回0,如果发生其他错误,则返回-1。
// pid < -1 等待进程组号为pid绝对值的任何子进程。
// pid = -1 等待任何子进程,此时的waitpid()函数就退化成了普通的wait()函数。
// pid = 0 等待进程组号与目前进程相同的任何子进程,也就是说任何和调用waitpid()函数的进程在同一个进程组的进程。
// pid > 0 等待进程号为pid的子进程。

// int * status 保存子进程的状态信息,有了这个信息父进程就可以了解子进程为什么会退出
说明
WIFEXITED(status) 如果子进程正常结束,它就返回真;否则返回假。
WEXITSTATUS(status) 如果WIFEXITED(status)为真,则可以用该宏取得子进程exit()返回的结束代码。
WIFSIGNALED(status) 如果子进程因为一个未捕获的信号而终止,它就返回真;否则返回假。
WTERMSIG(status) 如果WIFSIGNALED(status)为真,则可以用该宏获得导致子进程终止的信号代码。
WIFSTOPPED(status) 如果当前子进程被暂停了,则返回真;否则返回假。
WSTOPSIG(status) 如果WIFSTOPPED(status)为真,则可以使用该宏获得导致子进程暂停的信号代码。
1
// int options 提供了一些另外的选项来控制waitpid()函数的行为
参数 说明
WNOHANG 如果pid指定的子进程没有结束,则waitpid()函数立即返回0,而不是阻塞在这个函数上等待;如果结束了,则返回该子进程的进程号。
WUNTRACED 如果子进程进入暂停状态,则马上返回
  • execl
1
2
3
4
int execl(const char * path, const char * arg, ...);
// execl()用来执行参数path字符串所代表的文件路径, 接下来的参数代表执行该文件时传递过去的argv(0), argv[1], …, 最后一个参数必须用空指针(NULL)作结束
execl("/bin/ls", "ls", "-al", "/etc/passwd", (char *)0);
// 如果执行成功则函数不会返回, 执行失败则直接返回-1, 失败原因存于errno中
  • execlp
1
2
3
4
int execlp(const char * file, const char * arg, ...);
// execlp()会从PATH环境变量所指的目录中查找符合参数file 的文件名, 找到后便执行该文件, 然后将第二个以后的参数当做该文件的argv[0], argv[1], …, 最后一个参数必须用空指针(NULL)作结束
execlp("ls", "ls", "-al", "/etc/passwd", (char *)0);
// 如果执行成功则函数不会返回, 执行失败则直接返回-1, 失败原因存于errno中

上述两个函数的区别是,execlp可以直接调用系统自带的命令,但是execl需要将路径一同带上。

3. fork一些细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
int i = 0;
for(i=0; i<3; i++)
{
//创建子进程
pid_t pid = fork();
if(pid<0) //fork失败的情况
{
perror("fork error");
return -1;
}
else if(pid>0)//父进程
{
printf("%d\n",i);
printf("father: pid==[%d], fpid==[%d]\n", getpid(),getppid());
sleep(1); // 因为是同步执行,所以需要sleep,你可以尝试注释该行再多运行几次
}
else if(pid==0) //子进程
{
printf("child: pid==[%d], fpid==[%d]\n", getpid(), getppid());
}

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0
father: pid==[42903], fpid==[92376]
child: pid==[42904], fpid==[42903]
1
father: pid==[42904], fpid==[42903]
child: pid==[42905], fpid==[42904]
2
father: pid==[42905], fpid==[42904]
child: pid==[42906], fpid==[42905]
2
father: pid==[42904], fpid==[42903]
child: pid==[42909], fpid==[42904]
1
father: pid==[42903], fpid==[92376]
child: pid==[42910], fpid==[42903]
2
father: pid==[42910], fpid==[42903]
child: pid==[42911], fpid==[42910]
2
father: pid==[42903], fpid==[92376]
child: pid==[42912], fpid==[42903]

为了避免这种情况,我们可以在子进程里加一个break退出循环或者直接关掉进程exit(0)|return

父进程和子进程打开文件描述符会共享用一个文件表项,这说明它们会用同一个文件偏移量。

在fork之后处理文件描述符有两种常见的情况:

  1. 父进程等待子进程完成。这种情况下父进程不需要进行任何处理,在子进程终止后,再重新对偏移量进行更新。
  2. 父进程和子进程执行不同的程序段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("hello");
pid_t pid=fork();
}
// 此时会输出两个hello
int main()
{
printf("hello\n");
pid_t pid=fork();
}
// 但是这样写就只会输出一个
// 这是因为fork之后会复制资源,而printf("hello")之后不会立即输出
// 而是会把值存到缓冲区,\n可以起到清除缓冲区的作用
int main()
{
printf("hello");
fflush(stdout);
pid_t pid=fork();
}
// 或者使用fflush(stdout)强制清除缓冲区

4. 孤儿进程和僵尸进程

因为父进程不能预测子进程什么时候结束,当一个子进程完成它的工作后,它的父进程需要调用wait或者waitpid函数获取子进程的终止状态。

  孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

  僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

init进程是内核启动的第一个用户级的进程,它会启动getty(用户登录),实现运行级别和处理孤儿进程等功能。

5. 进程组

  1. 每一个进程都属于一个进程组,创建进程组的目的是简化向组内所有进程发送信号的操作,如果一个信号是发给一个进程组的,则这个组内的所有进程都会收到该信号
  2. 进程组内的所有进程都有相同的PGID,等于该组组长的PID。组长是该进程组第一个创建的进程。
  3. 通常是由shell的管道符将几个进程编成一组
  4. 所有的进程以init进程为根,形成一个树状结构

6. 会话session

  1. 当一个用户注销的时候,内核会终止用户之前启动的所有进程,为了简化这个任务,内核将几个进程组合并成一个会话。
  2. 用户一次登录就形成一次会话。
  3. 会话的sid是会话里的第一个进程的pid,这个进程通常是用户的shell。

维持一个会话最常见的有两种方式:

  1. 一是基于某种凭证,比如web网站的登录会话,在登录验证之后,服务器就会返回一个session id作为凭证。用户之后的请求总是会带上这个id,而服务器可以通过这个id知道用户是谁。直到用户注销登录、登录超时或者服务器清洗掉相应的session id,这个id就会失效。会话结束。
  2. 第二种方式是基于连接的,当用户和系统之间连接启动时,系统会对用户进行验证,验证通过之后,来自这个连接的操作都是属于这个用户的。

linux系统的会话是使用第二种方式维持的,最常见的两种连接为:

  1. 本地连接:用户直接通过键盘和显示器来跟系统进行交互,连接基本上不可能被修改
  2. 远程连接:用协议进行加密,避免连接被修改

7. job

  1. 一个会话的几个进程组可以被分成一个前台进程组以及一个或者多个后台进程组。
  2. 无论何时输入终端的退出键,都会将退出信号发送给前台进程组的所有进程。
  3. 前台进程组中的进程可以向终端设备进行读写操作
  4. 后台进程组中的进程只能向终端设备进行写操作

8. 控制终端

  1. 每个会话最多有一个对应的终端,会话中的进程从这个终端得到输入并输出,该终端被称为控制终端(controlling terminal)。
  2. 会话的领头进程打开一个终端之后,该终端就会成为该会话的控制终端,与该终端建立连接的进程被称为控制进程。
  3. 我们打开多个终端窗口时,实际上就创建了多个终端会话。每个会话都会有自己的前台工作和后台工作。

终端是用户输入shell命令的窗口,shell把一些消息输出到终端,同时接收终端设备的输入。可以理解为,终端中输入命令,shell用来解释命令。

0x05 进程间通信IPC

进程之间会保证相对独立,一个进程不能随便访问另一个进程的地址空间,这是系统安全性的保证和需要。但有些时候进程需要协作一起完成任务。

进程间通信有两种基本模型:共享内存和消息传递

  • 共享内存:建立起一块供协作进程共享的内存区域。
  • 消息传递:通过在协作进程间交换消息来实现通信。

1. 直接通信和间接通信

进程间通信从通信路径上可以分为直接通信和间接通信

直接通信:进程A直接将消息发送给进程B,不经过内核。

send(A,message) receive(B,message)

在发送数据之前,我们需要建立链路,链路的建立需要操作系统的支持,因为进程间通信实际上是打破了进程之间的隔离,没有操作系统的支持是无法完成的。

这种方案的链路需要有以下功能:

  • 在需要通信的每个进程之间,自动建立链路。进程仅需要知道对方身份就可以交流
  • 每个链路只与两个进程相关
  • 每对进程之间只有一个链路

间接通信:进程A把信息发给内核,让内核把消息发给进程B。

在内核中存在一块区域,用来维护进程间的通信的队列:每个消息队列都有着唯一的id,两个进程之间只有共享了一个消息队列才能通信。

send(Q,message) receive(Q,message)

2. 管道

子进程从父进程继承文件描述符。管道来源于早期unix命令行输入时的想法,让上一个进程的输出重定向为下一个进程的输入。流水线方式,称为管道机制。

管道通过调用pipe函数创建的,shell会为每个命令单独创建一个进程,然后用管道将前一个命令进程的标准输出和标准输入相连接。

1
2
3
4
5
#include <unistd.h>
int pipe(int fd[2]);
// fd[0]为读,fd[1]为写
// fd[1]的输出是fd[0]的输入
// 只有当一段被关闭的时候,另一段才有用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define MAXLINE 20
int main(void) {
int n;
int fd[2];
pid_t pid;
char line[MAXLINE];

if (pipe(fd) < 0) {
printf("pipe error");
}
if ((pid = fork()) < 0) {
printf("fork error");
} else if (pid > 0) {
/* parent */
close(fd[0]);
write(fd[1], "hello world\n", 12);
if(waitpid(pid,0,0) != -1){
printf("wait success\n");
}
} else {
close(fd[1]);
n = read(fd[0], line, MAXLINE);
write(STDOUT_FILENO, line, n);
exit(1);
}
return 0;
}

image-20220428230838014

管道的局限:

  1. 管道是半双工的,数据只能向一个方向流动;
  2. 所传送的是无格式字节流,这就要求管道的读出方和写入方必须事先约定好数据的格式,比如多少字节算作一个消息(或命令、或记录)等等;
  3. 只能用于具有亲缘关系的进程之间。

3. 命名管道FIFO

未命名的管道只能在两个相关的进程之间使用,而且这两个相关的进程还需要有一个共同的创建了它们的祖先进程。通过FIFO,不相关的进程也可以交换数据。

FIFO是一种文件类型,可以看到p标识

image-20220428231005263

我们向管道写入内容,但是却没有直接输出,我们需要另外开一个终端进行输出,这里可以理解为暂存管道。

4. 消息队列

  • 消息队列存储在内核中,可以进行双工通信
  • 进程发送的消息可以是定长也可以是变长,如果只发送定长消息,那么系统级实现就简单。但是编程会变困难;发送变长消息则是相反。
  • 消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺点。
1
2
3
4
5
int msgget(key_t key, int msgflag);
// 创建/打开一个消息队列
// key: 多个进程可以通过key值访问
// msgflag: 权限标志位,表示队列的访问权限
// 返回消息队列的标识符,失败返回-1
1
2
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg);
// 将新消息添加到队列尾部
1
2
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg);
// 从队列中提取消息

5. 共享内存通信

当我们需要交换比较大的数据的时候,一发一收不能及时的感知数据,就不能使用消息队列了。

每个进程都有自己的独立虚拟内存空间,我们可以通过shmget创建一份共享内存,然后通过ipcs命令查看我们创建的共享内存。它往往与信号机制配合使用,不然很多进程共享一块内存,同时写就可能会出现问题。

6. 信号量

信号量是一个计数器,主要实现进程之间的同步和互斥,而不是存储通信内容。它经常作为一种锁的机制,防止进程在访问共享资源的时候,还有其他进程也访问。

0x06 信号

1. 常用函数

  • sigset_t
1
sigset_t是代表信号集等数据结构,一个进程有一个信号集,这个信号集表示当前阻塞了哪些信号
  • signal
1
2
3
4
5
6
void (*signal(int sig, void (*func)(int)))(int);
// 设置一个函数来处理信号
// sig信号
// func是一个指向函数的指针,当触发信号的时候就会跳转该函数执行
// 当func==SIG_IGN时,忽视信号
// 现在基本上都使用sigaction函数,signal被淘汰了
SIGABRT (Signal Abort) 程序异常终止。
SIGFPE (Signal Floating-Point Exception) 算术运算出错,如除数为 0 或溢出(不一定是浮点运算)。
SIGILL (Signal Illegal Instruction) 非法函数映象,如非法指令,通常是由于代码中的某个变体或者尝试执行数据导致的。
SIGINT (Signal Interrupt) 中断信号,如 ctrl-C,通常由用户生成。
SIGSEGV (Signal Segmentation Violation) 非法访问存储器,如访问不存在的内存单元。
SIGTERM (Signal Terminate) 发送给本程序的终止请求信号。
  • sigaction
1
2
3
4
5
int sigaction(int signum,const struct sigaction *act ,struct sigaction *oldact);
// 用来查询或设置信号处理方式。
// 参数 signum要操作的信号
// act 要设置的对信号的新处理方式(传入)
// oldact 原来对信号的处理方式(传出)
1
2
3
4
5
6
7
8
9
10
11
12
struct sigaction{
union __sigaction_u __sigaction_u; // 信号处理函数地址
// sa_handler 代表消息处理函数
// sa_sigaction 代表消息处理函数,这两个没区别
sigset_t sa_mask; // 信号集,当调用信号处理函数时,程序将阻塞sa_mask中的信号
int sa_flag; // 指定用于控制信号处理过程中的各种选项。
};
// SA_NOCLDSTOP 使父进程在它的子进程暂停或继续运行时不会收到SIGCHLD信号
// SA_NOCLDWAIT 使父进程在它的子进程退出时不会收到SIGCHLD信号,这时子进程如果退出也不会成为僵尸进程
// SA_NODEFER 使对信号的屏蔽无效,即在信号处理函数执行期间仍能发出这个信号
// SA_RESETHAND 信号处理之后重新设置为默认处理方式
// SA_SIGINFO 使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<stdio.h>
#include<stdlib.h>
#include<signal.h>
void func(){
printf("func\n");
}
int main(){
struct sigaction act;
act.sa_sigaction=func;
act.sa_flags=SA_SIGINFO;
sigaction(SIGINT,&act,NULL);
while(1){}
return 0;
}
// 或者这么写也可以
act.sa_handler=func;
//act.sa_flags=SA_SIGINFO;
  • kill
1
2
int kill(pid_t pid,int signo);
// 向进程/进程组发送一个信号
  • abort
1
2
void abort(void);
// 异常终止一个程序

信号集:

  • sigemptyset
1
2
int sigemptyset(sigset_t *set);
// 用来将set信号集初始化并且清空
  • sigfillset
1
2
int sigfillset(sigset_t * set);
// 用来将set信号集初始化使其包含所有信号,并将信号标志位设置为1,屏蔽所有信号
  • sigaddset
1
2
int sigaddset(sigset_t *set, int signum); 
// 将参数signum代表的信号添加到set信号集
  • sigdelset
1
2
int sigdelset(sigset_t * set,int signum);
// 将参数signum代表的信号从参数set信号集里删除
  • sigismember
1
2
int sigismember(const sigset_t *set,int signum);
// 用来测试信号是否在信号集中
  • sigprocmask
1
2
3
4
5
int sigprocmask(int how, const sigset_t *restrict set, sigset_t *restrict oldset);
// 用于改变进程的当前阻塞信号集,也可以用来检测当前进程的信号屏蔽字。
// 如果oldset不是NULL,那么信号屏蔽字就会由此指针返回
// 如果set不是NULL,则参数how会修改当前信号的屏蔽字
// 如果set是NULL,则how无意义

how取值:

1.SIG_BLOCK: 该值代表的功能是将set所指向的信号集中所包含的信号加到当前的信号屏蔽字中,作为新的信号屏蔽字。

2.SIG_UNBLOCK:将参数set所指向的信号集中的信号从当前的信号屏蔽字中移除。

3.SIG_SETMASK:设置当前信号屏蔽字为参数set所指向的信号集中所包含的信号。

  • sigpending
1
2
3
int sigpending(sigset_t *set);
// 读取当前进程的未决信号集
// set是传出参数

2. 信号

信号在内核中一般有三种状态:

  1. 信号递达:实际执行信号的处理动作称为信号递达
  2. 信号未决:信号从产生到递达之间的状态
  3. 信号阻塞:被阻塞的信号产生时保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的操作。
  4. 信号递达的几种方式:忽略,默认处理,自定义

  • 1表示阻塞,0表示不阻塞。1表示未决,0表示可以递达。
  • 屏蔽状态字就是阻塞信号集,未决状态字就是未决信号集

3. 代码例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 编写程序,电脑1秒种能数多个数字
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void clock()
{
printf("end\n");
kill(0,SIGKILL);
}
int main()
{
signal(SIGALRM,clock);
alarm(1);
int i=0;
while(1){
printf("%d\n",i++);
}
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 设置阻塞信号集并把所有常规信号的未决状态打印至屏幕
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void printped(sigset_t *ped)
{
int i;
for(i = 1; i < 32; i++)
{
if(sigismember(ped,i))
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
}
int main()
{
sigset_t myset,ped;
sigemptyset(&myset);
sigaddset(&myset,SIGINT); // ctrl c来自键盘的中断
sigprocmask(SIG_BLOCK,&myset,NULL);
while(1)
{
sigpending(&ped);
printped(&ped);
sleep(1);
}
return 0;
}
// ctrl c之后就会发现第二个信号变成了1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 父进程创建三个子进程,然后让父进程捕获SIGCHLD信号完成对子进程的回收。
#include<stdio.h>
#include<stdlib.h>
#include <unistd.h>
#include<signal.h>
void fun(){
pid_t pid;
// 当SIGCHLD信号函数处理期间, SIGCHLD信号若再次产生是被阻塞的
// 而且若产生了多次, 则该信号只会被处理一次, 这样可能会产生僵尸进程。
// 可以使用while循环解决,这样可以捕获一次SIGCHLD信号
// 但是回收多个子程序,避免僵尸进程
printf("hello\n");
while(1){
pid=waitpid(-1,NULL,WNOHANG);
if(pid==0){
printf("child is end\n");
}else if(pid>0){
printf("child is quit\n");
}else if(pid==-1){
printf("no child is living\n");
sleep(10);
}
}
}
int main(){
int i=0;
sigset_t set;
sigemptyset(&set);
sigaddset(&set,SIGCHLD);
sigprocmask(SIG_BLOCK,&set,NULL);
for(i=0;i<3;i++){
pid_t pid=fork();
if(pid<0){
printf("fork error\n");
return -1;
}else if(pid==0)
{
printf("child: pid=%d fpid=%d\n",getpid(),getppid());
break;
}else if(pid>0)
{
printf("father: pid=%d fpid=%d\n",getpid(),getppid());
sleep(1);
}
}
if(i==3)
{
struct sigaction act;
act.sa_sigaction=fun;
act.sa_flags=SA_SIGINFO;
sigaction(SIGCHLD,&act,NULL);
printf("1\n");
sleep(5);
sigprocmask(SIG_UNBLOCK,&set,NULL);
while(1){}
}
if(i==0){
printf("2\n");
sleep(1);
}
if(i==1){
printf("3\n");
sleep(1);
}
if(i==2){
printf("4\n");
sleep(1);
}
return 0;
}

0x07 线程

1. 线程的基本概念

进程:在系统中运行的一个应用程序,拥有独立的虚拟空间。一个进程所拥有的数据只属于它自己。一个进程最少拥有一个线程

线程:是进程中相对独立的可执行单元,与父进程以及其他线程共享该进程的所有虚拟空间和全局变量,但是拥有独立的栈(局部变量对线程来说是私有的)和寄存器。

pid是进程标识符,tid是线程标识符。

一个线程由线程id,当前指令指针(PC),寄存器集合和栈组成。

上下文:记录不同线程分别执行到哪里,这样切换进程的时候就知道做完哪些工作了。

上下文切换:运行了线程1的指令->保存状态->读取线程2上一次的运行状态->运行线程2

时间片:定一个时间,比如每个线程执行10毫秒,或者每个线程执行100条命令。

信号量/锁:设一个标志位,比如一个工作要等待线程1工作完成才能继续,把它锁住。线程挂起,等待有信号通知才检查标志位的是信号量,不断检查标志位的是锁。

原子操作:在这些代码没执行完之前,不能进行上下文切换,哪怕没有时间片。

调度器:实现包含但不限于这些功能的东西叫调度器。

当一个程序被创建的时候,会有一个进程被操作系统创建,与此同时会有一个线程立即运行。一个进程中的线程是没有父子之分的,都是平级关系,退出一个不会影响另一个。

2. 常见函数

  • pthread_create
1
2
3
4
5
6
7
8
int  pthread_create(pthread_t *tidp, const pthread_attr_t *attr,
(void*)(*start_rtn)(void*),void *arg);
// 创建线程
// tidp指向线程标识符的指针
// attr设置线程属性
// start_rtn是线程运行函数的起始地址
// arg是运行函数的参数
// 返回值:如果执行成功将返回0,失败则返回一个错误号。
  • pthread_exit
1
2
3
void pthread_exit(void* retval);
// 终止线程
// retval存放了返回值,如果不需要返回值可以直接存放NULL
  • pthread_join
1
2
3
4
5
int pthread_join(pthread_t thread, void **retval);
// 这个函数是一个线程阻塞的函数,调用它的函数将会挂起等待直到thread线程结束为止。
// retval是用户定义的指针,用来存储被等待线程的返回值
// 返回值:如果执行成功将返回0,失败则返回一个错误号。
// 创建一个线程默认的状态是joinable
  • pthread_detach
1
2
3
4
int pthread_detach(pthread_t tid);
// 将该子线程的状态设置为detached分离,则该线程运行结束后会自动释放所有资源。
// 返回值:如果执行成功将返回0,失败则返回一个错误号。
// 线程
  • pthread_cancel
1
2
3
4
5
6
int pthread_cancel(pthread_t thread);
// 发送终止信号给thread线程,如果成功则返回0,否则为非0值。发送成功并不意味着thread会终止。
// 线程的取消并不是实时的,而有一定的延时。需要等待线程到达某个取消点(检查点)。杀死线程不是立刻就能完成,必须要到达取消点。
// 取消点是线程检查是否被取消,并按请求进行动作的一个位置。
void pthread_testcancel();
// 设置取消点
  • pthread_equal
1
2
3
int pthread_equal(pthread_t t1, pthread_t t2);
// 比较两个线程ID是否相等。
// 相等返回非0值,不等返回0值。
  • pthread_self
1
2
pthread_t pthread_self(void);
// 获得线程自身的tid

3. 代码例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 编写程序,主线程循环创建5个子线程,并让子线程判断自己是第几个子线程并输出
// 使用pthread_detach设置分离状态
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
void fun(int i){
pid_t pid=getpid();
//pthread_exit(NULL);
pthread_t tid=pthread_self();
printf("pid: %d, tid: %d, num: %d\n",pid,tid,i);
//pthread_exit(NULL);
return;
}
int main()
{
int err;
pthread_t ntid[5];
for(int i=0;i<5;i++){
err = pthread_create(&ntid[i], NULL, fun, i);
if(err != 0){
printf("can't create thread: %s\n",strerror(err));
}
}
pthread_detach(ntid[1]);
for(int i=0;i<5;i++){
int ret=pthread_join(ntid[i],NULL);
if(ret!=0){
printf("error:[%s],id=%d\n",strerror(ret),i);
}
}
printf("main pid:%d\n",getpid());
printf("main thread:%d\n",pthread_self());
return EXIT_SUCCESS;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 编写程序,让主线程取消子线程的执行
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
void fun(){
pid_t pid=getpid();
pthread_t tid=pthread_self();
sleep(1);
pthread_testcancel();
printf("pid: %d, tid: %d\n",pid,tid);
return;
}
int main()
{
int err;
pthread_t ntid;
err = pthread_create(&ntid, NULL, fun, NULL);
if(err != 0){
printf("can't create thread: %s\n",strerror(err));
}
sleep(1);
pthread_cancel(ntid);
int ret=pthread_join(ntid,NULL);
if(ret!=0){
printf("error:[%s]\n",strerror(ret));
}
printf("main pid:%d\n",getpid());
printf("main thread:%d\n",pthread_self());
return 0;
}
// 线程的取消并不是实时的,而有一定的延时。需要等待线程到达某个取消点
// 通常是一些系统调用creat、open、pause、close、read、write
// 执行命令man 7 pthreads可以查看具备这些取消点的系统调用列表

0x08 线程同步

1. 线程同步的概念

在一个线程在对内存进行操作的时候,其他线程都不可以对着该内存地址进行操作,直到该线程完成操作,其他线程才能对该内存地址进行操作。

2. 解决同步问题的方法

原子操作:这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch

互斥锁(mutex):用于多线程中,防止两条线程同时对同一个公共资源进行读写的机制。互斥锁是多个线程一起去抢,抢到锁的线程先执行,没有抢到锁的线程需要等待,等互斥锁使用完释放后,其它等待的线程再去抢这个锁。

3. 常见函数

  • pthread_mutex_init
1
2
3
4
5
6
int pthread_mutex_init(pthread_mutex_t *mutex,const pthread_mutexattr_t *attr);
// 互斥锁初始化
// ptread_mutex_t类型,通过对该结构的操作,来判断资源是否可以访问。
// pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
// attr指定了新建互斥锁的属性,如果为NULL,则使用默认的互斥锁属性。
// 函数成功返回0
  • pthread_mutex_destroy
1
2
int pthread_mutex_destroy(pthread_mutex_t *mutex)
// 互斥锁销毁
  • pthread_mutex_lock
1
2
int pthread_mutex_lock(pthread_mutex_t *mutex);
// 锁定互斥锁
  • pthread_mutex_unlock
1
2
int pthread_mutex_unlock(pthread_mutex_t *mutex);
// 释放互斥锁
  • pthread_mutex_trylock
1
2
int pthread_mutex_trylock( pthread_mutex_t *mutex );
// 非阻塞的锁定互斥锁