linux 系统

linux 系统相关知识, 文章内容来自狄泰软件学院, 如有需要淘宝执自行购买学习.

一、linux 系统概要

计算机系统由 “躯体” 和 “灵魂” 两部分组成

  • 躯体:构成计算机系统的电子设备(硬件) --> 中央处理器(CPU)
  • 灵魂:指挥躯体完成动作的指令序列       –> 操作系统(OS)

计算任务执行流程

  • 通过交互设备或网络像计算机系统发起计算请求
  • 根据请求将任务指令加载进入内存
  • cpu 从内存中取指令,并逐条执行
  • 计算任务的最终结果暂存入内存
  • 内存数据通过交互设备或网络进行反馈(也可直接写入外存)

1. 程序

    程序的本质是指令和数据的集合。指令是指示 cpu 执行动作的命令, 数据是 cpu 执行动作的目标. 程序的分类:

  • 应用程序:用户可以直接使用,为用户提供直接帮助的程序
  • 程序中间件:多数应用程序的通过功能,用于辅助应用程序的运行
  • 操作系统:直接操作硬件设备,并为应用程序与程序中间件提供运行环境

1.1 进程

    进程是程序的执行. 通常情况下, 程序在操作系统上以进程为单位运行. 每个程序运行后由一个或者多个进程构成. 进程是操作系统任务的基本单元, 也是系统资源的基本分配单元. 程序是"死的", 进程是活的. 程序的本质只是二进制数据, 不加载执行就没有任何价值. 进程是计算机系统对程序的一次加载执行, 即:执行计算任务的过程.

1.2 应用程序与外部设备

    多数情况下,应用程序需要借助外部设备才能外层计算任务. 外部设备是处cpu与内存之外的其他计算机硬件(如: 硬盘, 网卡, 显卡). 直接访问, 开发成本高, 应用开发者必须熟悉各类外设的硬件特性. 开发周期长, 业务逻辑加设备逻辑. 应用场景难, 其他应用程序可能同事访问外设. 间接访问, 应用程序通过某软件层(驱动程序)接口以统一的方式访问外设.

2. 设备驱动程序

    设备驱动程序是外设访问接口, 对应用程序提供统一的外设访问方式. 它象各种外设的共性, 简化设备驱动开发方式. 设备类型有字符设备, 块设备, 网络设备, 等. 对于同一类型的设备, 可以通过统一接口进行访问. 设备驱动程序并非唯一的访问外设的方式. 如何限制进程必须按照规则通过驱动程序访问外部设备? 可以通过工作模式进行限制.

3. linux 的工作模式

linux 系统的工作模式有用户模式内核模式两种:

  • 用户模式(user mode)执行应用程序私有代码, 受限制的访问内存, 无法直接访问外部设备.
  • 内核模式执行内核代码,可以访问所有的硬件资源, 可立即暂停进程的执行.对大多数设备驱动程序属于内核模式.

内核职责: 以统一的方式有序的分配硬件资源, 保证用户任务按照预期的方式执行.

4. linux 系统调用(system call)

     应用程序与操作系统内核直接的接口(表现形式为函数). 系统调用决定了应用程序如何与内核打交道.它解决了以下问题

  1. 系统资源有限, 需要统一有序的调配.
  2. 多个进程可能同时访问同一资源, 进而产生冲突.
  3. 一些特定的功能必须由操作系统内核完成(如: 精确延时).

    进程系统调用后, 由用户模式切换到内核模式(执行内核代码). 工作模式的转变通常由中断触发(不同于普通的函数调用). 用户进程通过系统调用请求内核完成资源分配, 硬件访问等操作. 所有进程请求集中到内核, 内核可以统一调度处理, 协调进程的执行.

4.1 深入理解系统调用

    模式切换是系统调用的本质, 系统模式切换依赖于 cpu 提供的工作方式, 一般来说, 大部分 cpu 至少有两种工作方式.

  • 高特权级, 可以访问任意数据, 包括外围设备, 比如网卡, 硬盘等.
  • 低特权级, 只能首先访问内存, 并且不允许访问外围设备, 可被打断.

    系统模式切换通过执行特殊的 cpu 指令发起(int 0x80). 应用程序(进程)无法直接切换 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
void print(const char*s, int l);
void exit(int code);

void program()
{
print("Hello World!\n", 13);
exit(0);
}

// 使用系统调用打印字符串
void print(const char*s, int l)
{
asm volatile (
"movl $4, %%eax\n" // 制定编号为 4 的系统调用(sys_wirte)
"movl $1, %%ebx\n" // 指定 sys_write 的输出目标, 1 为标准输出
"movl %0, %%ecx\n" // 指定输出字符地址
"movl %1, %%edx\n" // 指定输出字符串长度
"int $0x80 \n" // 执行系统调用
:
: "r"(s), "r"(l) // 参数
: "eax", "ebx", "ecx", "edx"); // 保留寄存器, 不用于关联变量
}

// 退出当前进程
void exit(int code)
{
asm volatile (
"movl $1, %%eax\n"
"movl %0, %%ebx\n"
"int $0x80 \n"
:
: "r"(code)
: "eax", "ebx"
);

}

运行结果:
Hello World!

编译命令注释如下:

1
2
3
4
gcc -m32 -e program -fno-builtin -nostartfiles program.c
-m32 : 采用 32 位编译
-e program : 指定函数入口
-fno-builtin -nostartfiles : 不链接到 start 函数

4.2 strace

strace 是系统调用工具它具有以下功能:

  1. 用于判断应用程序是否触发系统调用.
  2. 用于监控进程与内核的交互(监控系统调用).
  3. 用于追踪进程内部状态(定位运行时的问题).
  4. 按序输出进程运行过程想通调用名称, 参数和返回值.

实例分析如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
strace -o ./program.log ./a.out // 使用 strace 工具查看 a.out 这个程序的系统调用.

execve("./a.out", ["./a.out"], 0x7ffcc94c5760 /* 23 vars */) = 0 // 用来加载执行应用程序, 使其变成进程, 返回值为 0
/* ============> 参数分析 */
// pid : 进程号
// execve : 系统调用的名称, 用来加载执行应用程序, 使其变成进程
// ("./a.out", ["./a.out"], 0x7ffd160fcea0 /* 23 vars */) : 系统调用的参数
// = 0: 系统调用的返回值.
/* ============> 结束 */

brk(NULL) = 0x56ca1000 // 创建应用程序所需的数据段
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
mmap2(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0xf7f8e000
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
set_thread_area({entry_number=-1, base_addr=0xf7f8e9c0, limit=0x0fffff, seg_32bit=1, contents=0, read_exec_only=0, limit_in_pages=1, seg_not_present=0, useable=1}) = 0 (entry_number=12)
mprotect(0x56581000, 4096, PROT_READ) = 0
write(1, "Hello World!\n", 13) = 13 // 往标准输出(屏幕), 写入字符串 "Hello World!\n" , 长度为13
exit(0) = ?
+++ exited with 0 +++

使用标准方式打印 "Hello World!\n"

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>

int main()
{
printf("Hello World\n");
return 0;
}

运行结果:
Hello World!

使用 strace 查看系统调用过程

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
execve("./a.out", ["./a.out"], 0x7ffdda4b7c80 /* 23 vars */) = 0  // 用来加载执行应用程序, 使其变成进程, 返回值为 0
brk(NULL) = 0x557d7ea3b000 // 创建应用程序所需的数据段
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=103208, ...}) = 0
mmap(NULL, 103208, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f5b96694000
close(3) = 0
access("/etc/ld.so.nohwcap", F_OK) = -1 ENOENT (No such file or directory)
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\240\35\2\0\0\0\0\0"..., 832) = 832
fstat(3, {st_mode=S_IFREG|0755, st_size=2030928, ...}) = 0
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f5b96692000
mmap(NULL, 4131552, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f5b96094000
mprotect(0x7f5b9627b000, 2097152, PROT_NONE) = 0
mmap(0x7f5b9647b000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1e7000) = 0x7f5b9647b000
mmap(0x7f5b96481000, 15072, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS, -1, 0) = 0x7f5b96481000
close(3) = 0
arch_prctl(ARCH_SET_FS, 0x7f5b966934c0) = 0
mprotect(0x7f5b9647b000, 16384, PROT_READ) = 0
mprotect(0x557d7d3f5000, 4096, PROT_READ) = 0
mprotect(0x7f5b966ae000, 4096, PROT_READ) = 0
munmap(0x7f5b96694000, 103208) = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 0), ...}) = 0
brk(NULL) = 0x557d7ea3b000
brk(0x557d7ea5c000) = 0x557d7ea5c000
write(1, "Hello World\n", 12) = 12 // 往标准输出(屏幕), 写入字符串 "Hello World!\n" , 长度为13
exit_group(0) = ?
+++ exited with 0 +++

为什么采用标准方式系统调用会多很多, 因为标准方式是基于 c 标准库来做的, 中间有很多复杂的调用是我们看不见的. 同理 c++ 的结果将更为复杂.

4.3 track 常用选项

  • -t 在前面打印出调用的实时时间
  • -tt 在前面, 更精确的打印出调用的实时时间
  • -T 在后面打印出调用函数花费的时间
  • -c 打印出调用的次数与总时间 ------ 最常用
  • -s 更详细的展示数据内容, 后跟要展示的数据长度
  • -x 涉及到的数据,优先以字符串的方式进行展示, 以16进制的数据进行展示
  • -xx 涉及到的数据强制以16进制的数据进行展示
  • -e 只关注某个系统调用, 后跟要关注的系统调用函数
  • -e trace= 只关注 trace 后根的系统调用,例如trace=file,read,write
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
strace -c -o ./program.log ./program.out

------ ----------- ----------- --------- --------- ----------------
0.00 0.000000 0 1 execve
------ ----------- ----------- --------- --------- ----------------
100.00 0.000000 1 total
System call usage summary for 32 bit mode:
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
0.00 0.000000 0 1 write
0.00 0.000000 0 2 2 access
0.00 0.000000 0 1 brk
0.00 0.000000 0 1 mprotect
0.00 0.000000 0 1 mmap2
0.00 0.000000 0 1 set_thread_area
------ ----------- ----------- --------- --------- ----------------
100.00 0.000000 7 2 total

编译形语言的效率比解释行语言的效率高.

二、linux 进程

    execve 系统调用用来加载应用程序。进程是linux 任务的执行单元, 也是 linux 系统资源的分配单元. 每个 linux 应用程序运行后由一个或多个进程构成. 每个 linux 进程可以执行一个或多个应用程序. linux 进程有多重不同状态.

  • 就绪/执行状态 : TASK_RUNNING
  • 阻塞状态 : 可中断TASK_INTERRUPTIBLE和不可中断TASK_UNINTERRUPTIBLE
  • 停止状态: TASK_DEAD
  • 退出状态: 僵尸 EXIT_ZOMBIE. 死亡 EXIT_DEAD.
1
2
3
4
5
book@100ask:~$ ps ax | grep pts/0
10132 ? S 0:00 sshd: book@pts/0
10135 pts/0 Ss 0:00 -bash
10220 pts/0 R+ 0:00 ps ax
10221 pts/0 S+ 0:00 grep --color=auto pts/0

1. 进程基本特性

  1. 每个进程都有一个唯一标识符 PID.
  2. 每个进程都是由另一个进程创建.
  3. 整个 linux 系统的所有进程构成一个树状结构. 树根由内核自动创建, 即 IDLE(PID -> 0). 系统中的第一个进程是 init/systemd (PID->1). 0 号进程创建 1 号进程, 1号进程负责完成内核部分初始化工作. 1 号进程加载执行初始化程序, 演变为用户态 1 号进程. 使用 pstree -A -p -s $$ 打印从树根开始到当前进程的树.
1
2
3
4
5
6
7
8
9
10
11
book@100ask:~$ sleep 200 & 创建一个睡眠进程
[1] 10458
book@100ask:~$ sleep 200 & 创建一个睡眠进程
[2] 10459
book@100ask:~$ sleep 200 & 创建一个睡眠进程
[3] 10460
book@100ask:~$ pstree -A -p -s $$
systemd(1)---sshd(1166)---sshd(9862)---sshd(10132)---bash(10135)-+-pstree(10461) // 打印进程树
|-sleep(10458)
|-sleep(10459)
`-sleep(10460)

linux 中可以通过两个函数 fork 和 execve 创建进程, 先创建进程, 才能执行程序代码

1. fork.

1
pid_t fork(void);

fork:通过当前进程创建新进程, 当前进程位父进程, 新进程为子进程. 它的本质上是系统调用

  1. 为子进程申请内存空间, 并将父进程数据完全复制到子进程空间中.
  2. 两个进程中的程序执行位置完全一致(fork()函数调用的位置).
  3. 不同之处, 父进程 fock() 返回子进程 pid. 子进程返回 0. 通过 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
31
32
33
34
35
36
37
38
39
40
41
42
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

static int g_global = 0;

int main()
{
int pid = 0;

printf("hello world\n");

printf("current = %d\n", getpid()); // 打印当前进程 pid 即父进程 pid

if(pid = fork())
{

g_global = 1;

usleep(100);
printf("child = %d\n", pid); // 打印出子进程 pid
printf("%d g_global = %d\n", getpid(), g_global); // 打印出父进程pid
}
else
{
g_global = 10;

printf("parent = %d\n", getppid());
printf("%d g_global = %d\n", getpid(), g_global ); // 打印出子进程 pid
}

return 0;
}

输出结果
book@100ask:~/dt/fock$ ./a.out
hello world
current = 10604 //
parent = 10604 // 父进程 pid
10605 g_global = 10 // 子进程
child = 10605
10604 g_global = 1 // 父进程

2. execve()

1
2
3
4
int execve(const char *pathname, char* const argv[], char* const envp[]);
// pathname: 程序路径
// argv: 启动参数
// envp: 环境变量参数

execve: 在当前进程中执行 pathname 指定的程序代码:

  1. 根据参数指定的路径加载可执行程序.
  2. 通过可执行程信息构建进程数据, 并写入当前进程空间.
  3. 将程序执行位置重置到入口地址处(一去不复回)(即:main()) .execve(…)
  4. 将重置当前进程空间(代码&数据)而不会创建新进程.它会使用 pathname 指定的应用程序,完全替换原有进程的代码、数据和堆栈,将新程序的代码和数据加载到进程的地址空间中. 因此原有进程的状态、变量和内存内容都会被丢弃,并被新程序的内容所取代。
1
2
3
4
5
6
7
8
9
10
11
// hello.c ==> 用来创建 helloworld.out

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

int main()
{
printf("%d, hello world\n", getpid()); // 打印当前进程 pid
return 0;
}

execve.c 调用 execve 重置当前进程空间到 helloworld.out.

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
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

#define EXE "helloworld.out"

int main()
{
char* args[] = {EXE, NULL};

printf("begin\n");

printf("pid = %d\n", getpid()); // 打印当前进程 pid

execve(EXE, args, NULL); // 使用 helloworld.out 这个程序重置当前进程空间.
printf("end\n");

return 0;
}

输出结果:
book@100ask:~/dt/execve$ ./a.out
begin
pid = 10787
10787, hello world

巧用 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
31
32
33
34
35
36
37
38
39
40
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

#define EXE "helloworld.out"

int create_process(const char* path, char* args[])
{
int ret = fork();

if( ret == 0 )
{
execve(path, args, NULL); // 使用 helloworld.out 这个程序重置当前进程空间.
}

return ret;
}

int main()
{
char* args[] = {EXE, NULL};

printf("begin\n");

printf("pid = %d\n", getpid());

printf("child_pid = %d\n", create_process(EXE, args));

printf("end\n");

return 0;
}

运行结果:
book@100ask:~/dt/execve$ ./a.out
begin
pid = 10805
child_pid = 10806
end
book@100ask:~/dt/execve$ 10806, hello world

create_process 函数是调用 fock 创建一个子进程, 然后调用 execve 系统调用, 使用 helloworld.out 重置当前进程空间.

3. 进程空间

一个进程的空间如下所示

  • stack: 栈, 函数调用时使用
  • heap: 堆, 用来分配临时内存
  • uninitialized data: 未初始化的全局变量会被 execve 初始化为 0
  • initialized data: 初始化的全局变量由 execve 直接从程序文件中读出来的.
  • text: 代码段

全局空间用来存储全局变量和静态局部变量.

编程验证

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
#include <stdio.h>
#include <stdlib.h>

static int g_init = 255; // 已初始化的静态全局变量
static float g_uninit; // 未初始化的静态全局变量

static void text() // 函数
{

}

int main(int argc, char* argv[])
{
static double s_init = 0.255; // 已初始化的, 静态局部变量
static double s_uninit; // 未初始化的, 静态局部变量
int i = 0;
int* p = malloc(4);

printf("argv[0] = %p\n", argv[0]); // 打印第 0 个参数的地址
printf("&i = %p\n", &i); // 打印局部变量的地址,即所占用的栈空间的地址
printf("p = %p\n", p); // 打印堆空间的地址
printf("&g_uninit = %p\n", &g_uninit); // 打印未初始化的, 静态全局变量的地址
printf("&s_uninit = %p\n", &s_uninit); // 打印未初始化的, 静态局部变量的地址
printf("&g_init = %p\n", &g_init); // 打印已初始化的, 静态全局变量的地址
printf("&s_init = %p\n", &s_init); // 打印已初始化的, 静态局部变量的地址
printf("text = %p\n", text); // 打印代码段的地址

free(p);
return 0;
}

运行结果: // 进程空间分布
// 栈空间
argv[0] = 0x7fff50ea1784 // 打印第 0 个参数的地址
&i = 0x7fff50e9fd0c // 打印局部变量的地址,即所占用的栈空间的地址

// 堆空间
p = 0x557116225260 // 打印堆空间的地址

// 未初始化的全局变量
&g_uninit = 0x5571152ac028 // 打印未初始化的, 静态全局变量的地址
&s_uninit = 0x5571152ac030 // 打印未初始化的, 静态局部变量的地址

// 已初始化的全局变量
&g_init = 0x5571152ac010 // 打印已初始化的, 静态全局变量的地址
&s_init = 0x5571152ac018 // 打印已初始化的, 静态局部变量的地址

// 代码段
text = 0x5571150ab73a // 打印代码段的地址

三、进程参数

进程参数包括命令行参数(argv)命令行参数数组(argv). execve 在重置空间的时候会将进程参数从父进程拷贝一份到当前的进程. 这意味着当前进程启动之后这两个参数将不会在变化. 编程体验

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/* =======================> execve.c 文件 */
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

#define EXE "helloworld.out"

// 将字符串清空.
void zero_str(char* s)
{
while(s && *s) *s++ = 0;
}

// 使用 fock 创建子进程, 并且在子进程中使用 execve 系统调用
// execve 使用 path 指指定的应用程序 helloworld.out 重置当前进程空间
int create_process(const char* path, char* args[])
{
int ret = fork();

if( ret == 0 )
{
execve(path, args, NULL);
}

return ret;
}

int main()
{

char path[] = EXE;
char arg1[] = "hello";
char arg2[] = "world";
char* args[] = {EXE, arg1, arg2, NULL}; // 初始化进程参数

printf("%d father begin\n", getpid());

// 调用 create_process 创建子进程并打印进程参数
printf("%d child_pid = %d\n", getpid(), create_process(EXE, args));

// 清除父进程的进程参数
zero_str(path);
zero_str(arg1);
zero_str(arg2);

// 打印出对应的进程参数.
printf("%d path = %s\n", getpid(), path);
printf("%d arg1 = %s\n", getpid(), arg1);
printf("%d arg2 = %s\n", getpid(), arg2);

printf("%d father end\n", getpid());

return 0;
}

/*=================================> hello.c 文件 */
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>


int main(int argc, char* argv[])
{
int i = 0;

sleep(3); // 延时保证父进程先执行 zero_str 清除父进程的进程参数.

printf("\n");

printf("child begin\n");

for(i=0; i<argc; i++)
printf("exec = %d, %s\n", getpid(), argv[i]); // 打印出进程参数.

printf("child end\n");

return 0;
}

运行结果:
// 父进程的进程参数
book@100ask:~/dt/execve$ ./a.out
4803 father begin
4803 child_pid = 4804
4803 path =
4803 arg1 =
4803 arg2 =
4803 father end

// 子进程的进程参数
book@100ask:~/dt/execve$
child begin
exec = 4804, helloworld.out
exec = 4804, hello
exec = 4804, world
child end

使用 a, b, c 作为参数单独运行 hello.c 代码.

1
2
3
4
5
6
7
8
book@100ask:~/dt/execve$ ./helloworld.out  a b c // 传入命令行参数, a, b, c

child begin
exec = 4863, ./helloworld.out
exec = 4863, a
exec = 4863, b
exec = 4863, c
child end

因此, 命令行参数, 进程的启动参数, 进程参数他们的本质是相同的东西.

1. linux 命令行参数规范

  • 命令行参数由选项, 选项值, 操作数组成
  • 选项由短横线(-)开始, 选项名必须是单个字母或数字字符
  • 选项可以有选项值, 选项与选项之间可以用空格分隔(-o test <==> -otest)
  • 如果多个选项均无选项值, 可合而为一(-a -b -c <==> -abc)
  • 既不是选项, 也不能作为选项值的参数是操作数
  • 第一次出现的双横线(–)用于结束所有选项, 后续参数作为操作数

实例解析

规则: if:s <==> isf: <==> sif: 规则有有3个合法的选项 -i -s -f 并且 -f 有选项值的否则是不合法.

命令行 -f -s -i parameter err
分析
.demo -f abc def abc x x def -f 是合法选项并指定了选项值 abc, def 不是选项也不是选项值因此是操作数
.demo -s -i -v x o o -v abc 不是选项也不是选项值, 因此是操作数.
-f 是合法的选项, gg 是 -f 的选项值. de 不是选项也不是选项值因此是操作数. -s 是合法选项.
.demo abc-f gg de -s gg o o abc de -s 和 -i 是合法的选项, -v 显然就是非法的选项, -f 没有出现是可以的
.demo abc – -f -s x x x abc -f -s abc 不是选项也不是选项值, 因此是操作数. – 后面的都是操作数, 因此 -f -s 都是操作数
.demo -s abc -i -f x o o abc -f -s 是合法选项没有选项值, 因此 abc 只能是操作数.
-i 是合法选项. -f 是选项但没有选项值因此错误

2. linux 启动参数(命令行参数) 编程

1
2
#include <unistd.h>
int getopt(int argc, char * const argv[], const char *optstring); // 函数原型

getopt(…) 从 argc 和 argv[] 数组中获取启动参数, 并根据 optstring 的规则进程解析.

  • 选项合法: 返回值为选项字符, optarg 指向选项值字符串
  • 选项不合法: 返回字符串’?', optopt 保存当前选项字符(错误)
  • optstring 是以 ‘:’ 开头时并且选项合法但缺少选项值: 返回字符’:', 当不以 ‘:’ 开头时返回 ‘?’, optopt 保存当前选项字符(错误)

默认情况下: getopt(…) 对 argv 进行重排, 所有操作数位于最后位置

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
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>


int main(int argc, char* argv[])
{
int i = 0;
int c = 0;
int iflg = 0;
int fflg = 0;
int sflg = 0;
char* fvalue = NULL;

while( (c = getopt(argc, argv, ":f:is")) != -1 ) // : 开头表示合法选项但没选项值应该返回 :
{
switch(c)
{
case 'f' : fflg = 1, fvalue = optarg; // 获取选项值
break;
case 'i' : iflg = 1;
break;
case 's' : sflg = 1;
break;
case '?' : printf("unknow option: -%c\n", optopt); // 非法选项
break;
case ':' : printf("-%c missing option argument\n", optopt); // 合法选项没有选项值
break;
case '1' : printf("inter: %s\n", optarg);
break;
default:
printf("ret = %d\n", c);

}
}

printf("fflg = %d, fvalue = %s, iflg = %d, sflg = %d\n", fflg, fvalue, iflg, sflg);

// 默认情况下: <font color=dark>getopt(...) 对 argv 进行重排, 所有操作数位于最后位置
// 因此可以通过 optind 打印选项值
for(i = optind; i<argc; i++)
{
printf("parameter: %s\n", argv[i]);
}

return 0;
}

运行结果:
// 选项合法但是没有选项值
book@100ask:~/dt/execve$ ./a.out -f
-f missing option argument
fflg = 0, fvalue = (null), iflg = 0, sflg = 0

// 合法
book@100ask:~/dt/execve$ ./a.out -f abc
fflg = 1, fvalue = abc, iflg = 0, sflg = 0

// 等价于 -i -s 合法
book@100ask:~/dt/execve$ ./a.out -is
fflg = 0, fvalue = (null), iflg = 1, sflg = 1

// -i -s 合法
book@100ask:~/dt/execve$ ./a.out -i -s
fflg = 0, fvalue = (null), iflg = 1, sflg = 1

// 未定义的选项 -v
book@100ask:~/dt/execve$ ./a.out -v
unknow option: -v
fflg = 0, fvalue = (null), iflg = 0, sflg = 0

optstring 规则的扩展定义
起始字符可以是:, +, -或 省略

  • 省略 => 出现选项错误时,程序通过:或?进行处理并给出默认错误提示
  • : 错误提示开关, 程序中通过返回值 : 或 ? 进行处理(无默认错误提示)
  • + 提前停止开关, 遇见操作数时, 返回 -1, 认为选项处理完毕(后续都是操作数)
  • - 不重排开关, 遇见操作数时, 返回 1, optarg 指向操作数字符串

组合 +: or -:

修改前面代码中的 while 循环验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// +:
while( (c = getopt(argc, argv, "+:f:is")) != -1 )
运行结果:
book@100ask:~/dt/execve$ ./a.out -v abc -m -n
unknow option: -v
fflg = 0, fvalue = (null), iflg = 0, sflg = 0
parameter: abc
parameter: -m
parameter: -n

// 先碰见 -v 提示不合法选项, 再遇见 abc 操作数提前停止之后的参数都是操作数

// -:
while( (c = getopt(argc, argv, "-:f:is")) != -1 )
运行结果:
book@100ask:~/dt/execve$ ./a.out -v abc -m -n
unknow option: -v
ret = 1
unknow option: -m
unknow option: -n
fflg = 0, fvalue = (null), iflg = 0, sflg = 0

// 先遇见 -v 提示 -v 不合法, 然后遇见了 abc 操作数, 返回 1 .

四、环境变量

  • 环境变量是进程运行过程中可能用到的 “键值对” (NAME=Value)
  • 进程拥有一个环境表(environment list), 环境表包含了环境变量
  • 环境表用于记录系统中相对固定的共享信息(不特定于具体进程)
  • 进程之间的环境表相互独立(环境表可在父子进程之间传递)

其本质是一个字符串数组, 字符串数组中的每一个元素是字符串指针.该指针指向环境变量. 可以使用 environ 访问环境变量. 编程体验.

hello.c 编译生成 helloworld.out

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 <sys/types.h>
#include <unistd.h>


int main(int argc, char* argv[], char* env[])
{
int i = 0;

printf("\n");

printf("process parameter: \n");

while( i < argc) // 首先打印进程参数
printf("exec = %d, %s\n", getpid(), argv[i++]);


i=0;
printf("environment list:\n");
while( env[i]) // 打印环境表中的环境变量
printf("exec = %d, %s\n", getpid(), env[i++]);

return 0;
}

env.c 编译生成 a.out

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
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

#define EXE "helloworld.out"

void zero_str(char* s)
{
while(s && *s) *s++ = 0;
}

// 创建子进程
int create_process(const char* path, char* args[], char* env[])
{
int ret = fork();

if( ret == 0 )
{
execve(path, args, env);
}

return ret;
}

int main()
{

char path[] = EXE;
char arg1[] = "hello";
char arg2[] = "world";
char* args[] = {EXE, arg1, arg2, NULL};

printf("%d father begin\n", getpid());

printf("%d child_pid = %d\n", getpid(), create_process(EXE, args, args));

printf("%d father end\n", getpid());

return 0;
}

运行结果:
book@100ask:~/dt/env$ ./a.out
14941 father begin
14941 child_pid = 14942
14941 father end
book@100ask:~/dt/env$
// 首先打印进程参数
process parameter:
exec = 14942, helloworld.out
exec = 14942, hello
exec = 14942, world

// 打印环境表中的环境变量
environment list:
exec = 14942, helloworld.out
exec = 14942, hello
exec = 14942, world

单独运行 helloworld.out , 这是由 shell 传入环境变量

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
book@100ask:~/dt/env$ ./helloworld.out a b c

// 打印进程参数
process parameter:
exec = 14949, ./helloworld.out
exec = 14949, a
exec = 14949, b
exec = 14949, c

// 再打环境变量
environment list:
......
exec = 14949, SSH_CONNECTION=192.168.3.130 60751 192.168.3.114 22
exec = 14949, LESSCLOSE=/usr/bin/lesspipe %s %s
exec = 14949, LANG=en_US.UTF-8
exec = 14949, DISPLAY=localhost:10.0
exec = 14949, XDG_SESSION_ID=7
exec = 14949, USER=book
exec = 14949, PWD=/home/book/dt/env
exec = 14949, HOME=/home/book
exec = 14949, SSH_CLIENT=192.168.3.130 60751 22
exec = 14949, XDG_DATA_DIRS=/usr/local/share:/usr/share:/var/lib/snapd/desktop
exec = 14949, SSH_TTY=/dev/pts/0
exec = 14949, MAIL=/var/mail/book
exec = 14949, TERM=xterm
exec = 14949, SHELL=/bin/bash
exec = 14949, SHLVL=1
exec = 14949, LOGNAME=book
exec = 14949, DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1001/bus
exec = 14949, XDG_RUNTIME_DIR=/run/user/1001
exec = 14949, PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
exec = 14949, LESSOPEN=| /usr/bin/lesspipe %s
exec = 14949, _=./helloworld.out
exec = 14949, OLDPWD=/home/book/dt

1. 深入理解环境变量

  • 对于进程来说, 环境变量可以看做特殊的进程参数
  • 环境变量对于启动参数较稳定(系统定义且各个进程共享)
  • 环境变量遵守固定规范(如: 键值对, 变量名大写)
  • 环境变量与启动参数存储与同一内存区域(进程私有的)

环境变量读写接口

头文件 #include <stdlib.h>
char* getenv(const char* name); 返回 name 环境变量的值, 如果不存在, 返回 NULL
int putenv(char* string); 设置/改变环境变量(NAME=Value), string 不能是栈上的字符串
环境表入口 extern char*\ * environ

编程体验:

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
71
72
73
74
75
76
77
78
79
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

int main(int argc, char* argv[], char* env[])
{
int i = 0;

printf("\n");

printf("process parameter: \n");

while( i < argc) // 打印进程参数
printf("exec = %d, %s\n", getpid(), argv[i++]);

i=0;
printf("environment list:\n");
while( env[i]) // 打印传入的环境变量
printf("exec = %d, %s\n", getpid(), env[i++]);

printf("original:\n");

printf("%s=%s\n", "TEST1", getenv("TEST1"));
printf("%s=%s\n", "TEST2", getenv("TEST2"));
printf("%s=%s\n", "TEST3", getenv("TEST3"));

// 写入环境比变量
putenv("TEST1");
putenv("TEST2=NEW-VALUE");
putenv("TEST3=CRATE NEW");

// 写入后回读环境变量
printf("%s=%s\n", "TEST1", getenv("TEST1"));
printf("%s=%s\n", "TEST2", getenv("TEST2"));
printf("%s=%s\n", "TEST3", getenv("TEST3"));

i = 0;

extern char** environ;

printf("change:\n");

// 打印环境表中的环境变量
while( environ[i])
printf("exec = %d, %s\n", getpid(), environ[i++]);

return 0;
}

运行结果:
book@100ask:~/dt/env$ ./a.out

process parameter: // 打印进程参数
exec = 15048, ./a.out


environment list: // 打印传入的环境变量
......
exec = 15048, LESSOPEN=| /usr/bin/lesspipe %s
exec = 15048, _=./a.out
exec = 15048, OLDPWD=/home/book/dt

original:
TEST1=(null)
TEST2=(null)
TEST3=(null)
// 写入后回读环境变量.
TEST1=(null)
TEST2=NEW-VALUE
TEST3=CRATE NEW

change: // 打印修改后的环境变变量表
......
exec = 15048, LESSOPEN=| /usr/bin/lesspipe %s
exec = 15048, _=./a.out
exec = 15048, OLDPWD=/home/book/dt
exec = 15048, TEST2=NEW-VALUE
exec = 15048, TEST3=CRATE NEW

2. 环境变量编程

编写应用程序, 通过命令行参数读写环境变量, 选项定义如下:

  • -a: 无选项值, 输出所有环境变量
  • -r: 读环境变量, -n 环境变量名
  • -w: 写环境变量, -n 环境变量名, -v 环境变量值
  • -t: 环境变量读写测试, 先写入指定环境变量, 之后输出所有环境变量

编程实现

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
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>

typedef int OptCall(const char*, const char*);

typedef struct
{
const char opt;
OptCall* handler;
} CallHandler;

static int A_Handler(const char* n, const char* v)
{
extern char** environ;
int i = 0;

while( environ[i] )
{
printf("%s\n", environ[i++]);
}

return 0;
}

static int R_Handler(const char* n, const char* v)
{

if(n)
{
printf("%s=%s\n", n, getenv(n));
}
else
{
printf("Need environ NAME to read VALUE\n");
}

return 0;
}

static int W_Handler(const char* n, const char* v)
{
int err = 1;

if(n && v)
{
char* kv = malloc( strlen(n) + strlen(v) + 2);

if(kv)
{
strcpy(kv, n);
strcat(kv, "=");
strcat(kv, v);

err = putenv(kv);

if( !err )
{
printf("New Environ: %s\n", kv);
}
else
{
printf("Error on writing new environ value ...\n");
}
}

//free(kv); // 环境变量只能放在全局数据区, 如果释放掉内存空间将导致环境变量失效.
}
else
{
printf(" Need NAME and VALUE to write environ ...\n");
}

return err;
}

static int T_Handler(const char* n, const char* v)
{
return W_Handler(n, v) || A_Handler(n, v);
}

static const CallHandler g_handler[] =
{
{'a', A_Handler},
{'r', R_Handler},
{'w', W_Handler},
{'t', T_Handler},

};

static const int g_len = sizeof(g_handler)/sizeof(*g_handler);

int main(int argc, char* argv[], char* env[])
{
char c = 0;
char opt = 0;
char* name = NULL;
char* value = NULL;

while( (c = getopt(argc, argv, "arwtn:v:")) != -1 )
{
switch(c)
{
case 'a' :
case 'r' :
case 'w' :
case 't' :
opt = c;
break;
case 'n' :
name = optarg;
break;
case 'v' :
value = optarg;
break;
default:
exit(-1);
}
}

for(c=0; c < g_len; c++){
if( opt == g_handler[c].opt )
{
g_handler[c].handler(name, value);
}
}

return 0;
}

运行结果:
// 输出所有环境变量
book@100ask:~/dt/env$ ./a.out -a
......
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
LESSOPEN=| /usr/bin/lesspipe %s
_=./a.out
OLDPWD=/home/book/dt

// 写入环境变量 TEST=123
book@100ask:~/dt/env$ ./a.out -w -n TEST -v 123
New Environ: TEST=123

// 读取环境变量 SHELL
book@100ask:~/dt/env$ ./a.out -r -n SHELL
SHELL=/bin/bash

// 测试写入回读环境变量
book@100ask:~/dt/env$ ./a.out -t -n TEST -v 123
New Environ: TEST=123
......
OLDPWD=/home/book/dt
TEST=123

五、深入 linux 进程

1. 进程参数和环境变量的意义

一般情况下, 子进程的创建是为了解决某个子问题. 子进程解决问题需要父进程的 “数据输入” (进程参数 & 环境变量), 设计原则:

  • 子进程启动时必然用到的参数使用进程参数传递
  • 子进程解决问题可能用到的参数使用环境变量传递.

子进程的创建是为了并行的解决子问题(问题分解), 父进程需要通过子进程的结果最终解决问题(并获取结果). linux 系统提供了以下接口用于获取进程结果状态.

进程等待系统接口

1
2
#include <sys/wait.h>    // 头文件
pid_t wait(int* status); // 等待进程完成
  • 等待一个子进程完成, 并返回子进程标识和进程状态信息
  • 当有多个子进程完成, 随机挑选一个子进程返回
1
pid_t waitpid(pid_t pid, int* status, int options);
  • 可等待特定的子进程或一组子进程
  • 在子进程还为终止时, 可以通过 options 设置不必等待(直接返回).

进程退出学习通接口

1
2
3
4
5
6
#include <unistd.h>     // 头文件
void _exit(int status); // 系统调用, 终止当前进程

#include <stdlib.h> // 头文件
void exit(int status); // c 标准库函数, 先做资源清理, 再通过系统调用终止进程
void abort(void); // 异常终止当前进程(通过产生 SIGABRT 信号终止)

信号: 操作系统发给进程的通知.

2. 进程退出状态

从图中可以知道返回值是 16 位

  • 正常终止: 低八位全 0 , 高八位就是代码中的返回值. ==> exit(-1) ==> oxff00 ==> ff 对应 -1
  • 信号终止: 一般情况下, 被信号终止的进程, 就对应这异常终止进程, 例如 abort 函数. 高八位不使用, 低八位中有 7 位用来描述终止信号, 最高位是 core dump 标志为, 用来表示是否生成 core dump 文件. 即用来描述死前状态的文件.
  • 信号暂停: 低八位固定的是 0x7F, 高八位就是暂停信号的具体值. 在 linux 中进程是可以暂停的, 如果一个进程暂停那么它依旧存在于内存当中, 后续可以恢复执行.
  • 恢复状态: 由暂停状态恢复的状态

进程状态分析: linux 系统也给出了下面态的宏用来分析.

描述
WIFEXITE(stat) 通过 stat 判断进程是否正常结束
WIFSIGNALED(stat) 通过 stat 判断进程是否因信号而被终止
WIFSTOPPED(stat) 通过 stat 判断进程是否因为信号而被暂停
WEXITSTATUS(stat) 获取正常结束时的状态值
WTERMSIG(stat) 获取导致进程终止的信号值
WSTOPSIG(stat) 获取导致进程暂停的信号值

编程体验进程退出状态.

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
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>

int main()
{
pid_t pid = 0;
int a = 1;
int b = 0;
int status = 0;

printf("parent = %d\n", getpid());

if( (pid = fork()) == 0) exit(-1); // 创建一个子进程, 使用库函数直接退出, 退出的时候返回一个值 -1 给父进程.

printf("child = %d\n", pid);

if( (pid = fork()) == 0) abort(); // 创建一个子进程, 直接使用库函数 abort 异常退出, 没有返回值

printf("child = %d\n", pid);

// 创建一个子进程, 先做一个除法, 然后调用 exit 退出
// 注: 除法是不合法的.
if( (pid = fork()) == 0) a = a / b, exit(-1);

printf("child = %d\n", pid);

sleep(3);

while( (pid = wait(&status)) > 0) // 获取子进程的返回值
{
if(WEXITSTATUS(status)) // 获取正常结束时的状态值
printf("Normal - child: %d, code = %d\n", pid, (char)WEXITSTATUS(status));
else if(WTERMSIG(status)) // 获取导致进程终止的信号值
printf("signaled - child: %d, code = %d\n", pid, WTERMSIG(status));
else if(WSTOPSIG(status)) // 获取导致进程暂停的信号值
printf("paused - child: %d, code = %d\n", pid, WSTOPSIG(status));
}

return 0;
}

运行结果:
book@100ask:~/dt/5$ ./a.out
parent = 18887 // 父进程 pid
child = 18888 // 调用 exit 的子进程的 pid
child = 18889 // 调用 abort 的子进程的 pid
child = 18890 // 做除法的子进程的 pid

Normal - child: 18888, code = -1 // 调用 exit 结束进程.
signaled - child: 18889, code = 6 // 调用 abort 自杀, 返回自杀信号 6.
signaled - child: 18890, code = 8 // 在执行 a / b 产生异常信号 8 返回.

3. 僵尸进程

将前面代码的延时修改为 120 毫秒并在后台运行该程序.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
book@100ask:~/dt/5$ ./a.out &
[1] 2304
book@100ask:~/dt/5$ parent = 2304
child = 2305
child = 2306
child = 2307

// 打印出进程树
book@100ask:~/dt/5$ pstree -A -p -s 2304
systemd(1)---sshd(1218)---sshd(1804)---sshd(2066)---bash(2069)---a.out(2304)-+-a.out(2305)
|-a.out(2306)
`-a.out(2307)
book@100ask:~/dt/5$
book@100ask:~/dt/5$
book@100ask:~/dt/5$ ps ax | grep pts/0 // 打印出当前进程
2066 ? S 0:00 sshd: book@pts/0
2069 pts/0 Ss 0:00 -bash
2304 pts/0 S 0:00 ./a.out
2305 pts/0 Z 0:00 [a.out] <defunct>
2306 pts/0 Z 0:00 [a.out] <defunct>
2307 pts/0 Z 0:00 [a.out] <defunct>
2312 pts/0 R+ 0:00 ps ax
2313 pts/0 S+ 0:00 grep --color=auto pts/0

这个进程树的局部向我们表明, 当前的命令行进程 bash(2069) 创建了父进程 a.out(2304), 该父进程创建了 3 个子进程 a.out(2305)/(2306)/(2307). 而当前命令行竟然将 3 个子进程打印出来了, 这说明三个进程依然存在于系统中..

1
2
3
if( (pid = fork()) == 0) exit(-1); // 创建一个子进程, 使用库函数直接退出, 退出的时候返回一个值 -1 给父进程.
if( (pid = fork()) == 0) abort(); // 创建一个子进程, 直接使用库函数 abort 异常退出, 没有返回值
if( (pid = fork()) == 0) a = a / b, exit(-1);

这说明这三个进程虽然返回, 但是依然存在于系统之中. 这种运行之后没有回收的进程就是僵尸进程.理论上, 进程 退出/终止 后应立即释放所有系统资源. 然而, 为了给父进程提供一些重要信息, 子进程 退出/终止 所占的部分资源会暂留.当父进程收集这部分信息后(wait/waitpid), 子进程所有资源被释放.

  • 父进程调用 wait(), 为子进程 “收尸” 处理并释放暂留资源
  • 若父进程退出, init / systemd 为子进程 “收尸” 处理并释放暂留资源

1) 僵尸进程的危害

僵尸进程爆料进程的终止状态和资源的使用信息

  • 进程为何退出, 进程消耗多少 cpu 时间, 进程最大内存驻留值, 等

如果僵尸进程得不到回收, 那么可能影响正常进程的创建

  • 进程创建最重要的资源是内存和进程标识
  • 僵尸进程的存在可看做一种类型的内存泄漏
  • 当系统僵尸进程过多, 可能导致进程标识不足, 无法创建新进程

wait() 的局限性

  • 不能等待指定进程, 如果存在多个子进程, 只能逐一等待完成
  • 如果不存在终止的子进程, 父进程只能阻塞等待.
  • 只针对终止的进程, 无法发现暂停的进程.

wait 升级版 => pid_t waitpid(pid_t pid, int* status, int options);

  • 返回值相同, 终止子进程标识符
  • 状态值意义相同, 记录子进程终止信息

特殊之处:

pid 意义
pid > 0 等到进程标识符为 pid 的进程
pid == 0 等待当前进程租中的任意子进程
pid == -1 等待任意子进程, 即 wait(&stat) <==> waitpid(-1, &stat, 0)
pid < -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
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>


static void worker(pid_t pid)
{
printf("grand-child: %d\n", pid);
sleep(150);
}

int main()
{
pid_t pid = 0;
int status = 0;

printf("parent = %d\n", getpid());

pid = fork(); // 创建一个子进程

if(pid < 0)
{
printf("fork error\n");
}
else if(pid == 0)
{
int i = 0;
for(i=0; i< 5; i++)
{
if((pid = fork()) == 0) // 创建5个孙进程
{
worker(getpid()); // 孙进程干活
break;
}
}

sleep(60); // 休眠 60s

printf("child(%d) is over\n", getpid());

}
else
{
printf("wait child = %d\n", pid);
sleep(120); // 父进程休眠等待子进程退出
while( waitpid(pid, &status, 0) == pid) // 为子进程收尸.
{
printf("Normal - child: %d, status = %x\n", pid, status);
}
}

return 0;
}

程序分析, 父进程创建了一个子进程, 子进程创建了 5 个孙进程, 完事之后子进程休眠一段时间, 休眠之后就退出了. 假设父进程创建子进程之后休眠 120s, 而子进程休眠 60s. 这会发生什么, 很明显子进程先退出. 而在子进程退出的时候父进程还在休眠, 这就会导致子进程变成僵尸进程, 接下来孙进程谁来管, 此时孙进程就会由初始化进程来接管, 这意味着初始化进程是这5个孙进程的父进程, 所以当这5个孙进程退出后, 初始化进程为他们收尸. 对于父进程来说它就只管子进程, 只为子进程收尸. 他不需要管孙进程, 这五个孙进程交给初始化进程来管.这样子初始化进程为这五个进程收尸, 因此不会造成僵尸进程. 编译运行看结果.

1
2
3
4
5
6
7
8
9
book@100ask:~/dt/5$ ./a.out & // 运行上面的程序
[1] 2652
book@100ask:~/dt/5$ parent = 2652
wait child = 2653
grand-child: 2654
grand-child: 2655
grand-child: 2656
grand-child: 2657
grand-child: 2658

马上打印进程表

1
2
3
4
5
6
7
8
9
10
11
book@100ask:~/dt/5$ ps ax | grep pts/0 // 打印进程表
2066 ? S 0:00 sshd: book@pts/0
2069 pts/0 Ss 0:00 -bash
2652 pts/0 S 0:00 ./a.out
2653 pts/0 S 0:00 ./a.out
2654 pts/0 S 0:00 ./a.out
2655 pts/0 S 0:00 ./a.out
2656 pts/0 S 0:00 ./a.out
2657 pts/0 S 0:00 ./a.out
2658 pts/0 S 0:00 ./a.out
2659 pts/0 R+ 0:00 ps ax

可以看到有 7 个进程在运行, 其中一个父进程一个子进程, 5 个孙进程, 于是打印进程树

1
2
3
4
5
6
book@100ask:~/dt/5$ pstree -A -p -s 2652 // 打印进程树
systemd(1)---sshd(1218)---sshd(1804)---sshd(2066)---bash(2069)---a.out(2652)---a.out(2653)-+-a.out(2654)
|-a.out(2655)
|-a.out(2656)
|-a.out(2657)
`-a.out(2658)

可以看到, 这几个进程之间的关系, 接下来等待子进程结束运行. 子进程运行结束后再打印进程表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
book@100ask:~/dt/5$ child(2653) is over  // 子进程运行结束

book@100ask:~/dt/5$ ps ax | grep pts/0 // 打印进程表
2066 ? S 0:00 sshd: book@pts/0
2069 pts/0 Ss 0:00 -bash
2652 pts/0 S 0:00 ./a.out
2653 pts/0 Z 0:00 [a.out] <defunct>
2654 pts/0 S 0:00 ./a.out
2655 pts/0 S 0:00 ./a.out
2656 pts/0 S 0:00 ./a.out
2657 pts/0 S 0:00 ./a.out
2658 pts/0 S 0:00 ./a.out
2662 pts/0 R+ 0:00 ps ax
2663 pts/0 S+ 0:00 grep --color=auto pts/0

子进程现在是僵尸进程, 而五个进程还是存在的. 于是打印进程树

1
2
book@100ask:~/dt/5$ pstree -A -p -s 2654
systemd(1)---a.out(2654)

可以看出孙进程已经属于1号系统进程, 打印系统进程查看.

1
2
3
4
5
6
7
8
book@100ask:~/dt/5$ pstree -A -p -s 1 // 查看 1 号系统进程
systemd(1)-+-ModemManager(996)-+-{ModemManager}(1090)
|-VGAuthService(899)
|-a.out(2654)
|-a.out(2655)
|-a.out(2656)
|-a.out(2657)
|-a.out(2658)

5 个孙进程都变成了系统进程的子进程.

2) 僵尸进程避坑指南

利用 wait(...) 返回值判断是否继续等待子进程

  • while(pid = wait(&status)) > 0 {}

利用 waitpid(...) 以及 init / systemd 回收子进程

  • 通过两次 fock() 创建孙进程解决子问题.

六、进程创建大盘点

fock() 通过完整复制当前进程的方式创建新进程, execve() 根据参数完全覆盖进程数据(一个不留).

1. vfork

1
pid_t vfork(void)
  • vfork() 用于创建子进程, 然而不会复制父进程空间中的数据
  • vfork() 创建的子进程直接使用父进程空间 (没有完整独立的进程空间)
  • vfork() 创建的子进程对数据 (变量) 的修改会直接反馈到父进程中
  • vfork() 是为了 execve() 系统调用而设计

编程体验:

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 <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>

int main(int argc, char* argv[])
{
pid_t pid = 0;
int var = 88;

if( (pid = vfork()) < 0) {
printf("vfork error\n");
}else if( pid == 0){ // vfork 成功则创建出子进程
printf("pid=%d, var=%d\n", getpid(), var); // 打印局部变量 var
var++; // var ++
printf("pid=%d, var=%d\n", getpid(), var); // 再次打印 ++ 后的变量
return 0; // 返回 0 之后程序结束
}

printf("pid=%d, var=%d\n", getpid(), var); // 父进程打印出局部变量的值

return 0;
}

这是一个简单的程序, 如果说 vfork 成功, 那么子进程就创建出来了, 在子进程中会改变局部变量的值, 局部变量的值 +1 打印完之后返回 0 , 返回意味着子进程结束. 子进程结束之后会由父进程打印局部变量的值. 此时推测父进程打印出的值为 89, 实际运行查看结果.

1
2
3
4
5
book@100ask:~/dt/6$ ./a.out
pid=3251, var=88
pid=3251, var=89
pid=3250, var=-1468272342 // 程序报错出现段错误
Segmentation fault (core dumped)

然而实际的运行结果是程序报错, 发生段错误. 为什么会发生这个问题呢? vfork() 创建出来的进程没有独立的进程空间, 所以子进程共享父进程的内存空间, 逻辑上如下图所示.

子进程会使用父进程的堆栈段, 数据段, 代码段. 如果如果子进程的行为仅仅是访问数据, 那么显然不会有问题. 但子进程如果不但要访问数据还要修改数据. 就像前面实验的一样. 问题就出在我们使用了 return 0;. 当子进程调用 return 返回之后就破坏掉了栈空间. 由于父子进程共用空间, 将导致父进程的栈空间也被破坏. 局部变量保存在栈中, 所以导致了这个问题. 修改代码如下.

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
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>


int main(int argc, char* argv[])
{
pid_t pid = 0;
int var = 88;

if( (pid = vfork()) < 0) {
printf("vfork error\n");
}else if( pid == 0){
printf("pid=%d, var=%d\n", getpid(), var);
var++;
printf("pid=%d, var=%d\n", getpid(), var);
_exit(0);
}


printf("pid=%d, var=%d\n", getpid(), var);

return 0;
}

运行结果:
book@100ask:~/dt/6$ ./a.out
pid=3354, var=88
pid=3354, var=89
pid=3353, var=89 // 完美解决该问题.

总结: 在 vfork() 成功后, 父进程将等待子进程结束. 子进程可以使用父进程的数据(堆, 栈, 全局). 子进程可以从创建点调用其他函数, 但不要从创建点返回

  • 当子进程执行流回到创建点 / 需要结束时, 使用 _exit(0) 系统调用.
  • 如果使用 return 0 那么将破坏栈结构, 导致父进程执行出错

2. fork 与 vfork 的选择

vfok 好不好? 总的来说, vfork 是弊大于利. 如果不小心谨慎的使用, 将会出问题. 引入 vfork 的初衷是我们嫌 fork 效率比较低. 相较而言使用 fork 更好. fork 就是无非就是效率更低一点. 而不至于出现一些使用不当而造成的奇奇怪怪的错误.

1) fork() 的现代优化

 Cpoy-on-Write 技术, 多个任务同时访问同一资源, 在写入操作修改资源时, 父子资源的原始副本. fork() 引入Cpoy-on-Write 之后, 父子进程共享相同的进程空间.

  • 当父进程或子进程的其中之一修改内存数据, 则实时复制进程空间
  • fork() + execve() <==> vfork() + execve()

即 fork 之后不会马上复制进程空间, 而是像 vfork 那样, 先让子进程共享父进程的进程空间. 只有当父进程或者子进程要对进程空间数据进行修改. 才会真正的进行进程空间上的复制. 因此在现代 linux 系统中, 我们首选 fork 进行进程创建. vfork 只是一个备选, 只有在迫不得已的时候才使用. 优化前面的 create_process 函数, 使它像 vfork 一样具有等待进程完成的效果:

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
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <sys/types.h>

int create_process(char* const path, char* const args[], char* const env[], int wait)
{
int ret = fork();

if( ret == 0 )
{
if(execve(path, args, env) == -1 ) // 如果子进程运行应用程序失败直接返回
{
exit(-1);
}
}

if(ret && wait)
{
waitpid(ret, &ret, 0); // 在父进程中, 如果等待, 则返回子进程的退出状态
}

return ret; // 不等待直接返回父进程 pid
}

int main(int argc, char* argv[], char* env[])
{
char* const target = argv[1];
char* const ps_argv[] = {target, NULL};
char* const ps_envp[] = {"PATH=/bin:/usr/bin", "TEST=baron", NULL};

int result = 0;

if(argc < 2) exit(-1);

printf("current: %d\n", getpid());

result = create_process(target, ps_argv, ps_envp, 0);

printf("result: %d\n", result);
return 0;
}

测试 helloworld.c 程序.

1
2
3
4
5
6
7
#include <stdio.h>

int main(int argc, char* argv, char* env[])
{
printf("\nhello world\n");
return 0;
}

编译运行

1
2
3
4
5
6
7
book@100ask:~/dt/6$ gcc helloworld.c -ohelloworld
book@100ask:~/dt/6$ gcc process.c
book@100ask:~/dt/6$ ./a.out helloworld
current: 3529
result: 3530
book@100ask:~/dt/6$
hello world

可以发现 hello world 是之后打印输出的, 意味着父进程没有等子进程. 修改 create_process 参数为 1

1
result = create_process(target, ps_argv, ps_envp, 1);

编译运行

1
2
3
4
5
6
book@100ask:~/dt/6$ gcc process.c
book@100ask:~/dt/6$ ./a.out helloworld
current: 3575

hello world
result: 0

这样我们的 create_process 中的 fork 就和 vfork 一样具有等待进程完成的功能了.

3. exec 与 system 简介

1) exec 函数家族

exec 函数家族, 下面列出所有家族成员.

1
2
3
4
5
6
7
8
9
10
#include <unistd.h>

extern char** environ;

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

exec 是英文单词 execute(执行/运行). 所以这个家族都是 linux 中用来运行应用程序的. 不同之处仅仅是用法.

  • l: 表示 list, 进程参数直接在函数的参数列表中指定.
  • p: 表示 path, 指到 PATH 这个环境变量指定的路径中查找可执行程序.
  • e: 表示 environ, 指父进程提供环境变量且环境变量是可定制的
  • v: 表示 vector, 指通过数组给子进程提供进程参数
函数 进程参数 自动搜索 PATH 使用当前环境变量
execl() 列表 No Yes
execlp() 列表 Yes Yes
execle() 列表 No No, 提供可定制环境变量
execv() 数组 No Yes
execvp() 数组 Yes Yes
execve() 数组 No No, 提供可定制环境变量

编程体验:

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
#include <stdio.h>
#include <unistd.h>

int main(int argc, char* argv[])
{

char pids[32] = {0};
char* const ps_argv[] = {"pstree", "-A", "-p", "-s", pids, NULL};
char* const ps_envp[] = {"pATH=/bin:/user/bin", "TEST=baron", NULL};

sprintf(pids, "%d", getpid());

// execl("/usr/bin/pstree", "pstree", "-A", "-p", "-s", pids, NULL);
// execlp("pstree", "pstree", "-A", "-p", "-s", pids, NULL);
// execle("pstree", "pstree", "-A", "-p", "-s", pids, NULL, ps_envp);
// execv("/usr/bin/pstree", ps_argv);
// execvp("pstree", ps_argv);
execve("/usr/bin/pstree", ps_argv, ps_envp);

return 0;
}

运行结果:
book@100ask:~/dt/6$ gcc exec.c
book@100ask:~/dt/6$ ./a.out
systemd(1)---sshd(1224)---sshd(2594)---sshd(2869)---bash(2871)---pstree(3772)

上面函数实现的功能都是一样的, 即运行 pstree -A -p -s $$ 这条命令.

2) system 库函数

进程创建库函数

1
2
#include <stdlib.h>
int system(const char *command);
  • 函数参数: 程序名或者进程参数(如: pstree -A -p -s $$ )
  • 函数返回值: 进程退出状态

流程图如下所示:

这个流程图就是我们前面创建的 create_process 函数. 库函数已经提供了类似的函数 system. 使用该函数打印当前进程的进程树.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char* argv[])
{
int result = 0;

printf("current: %d\n", getpid());

result = system("pstree -A -p -s $$");

printf("result = %d\n", result);

return 0;
}

运行结果:
book@100ask:~/dt/6$ gcc system.c
book@100ask:~/dt/6$ ./a.out
current: 3829
systemd(1)---sshd(1224)---sshd(2594)---sshd(2869)---bash(2871)---a.out(3829)---sh(3830)---pstree(3831)
result = 0

他和我们创建的 create_process 的不同之处在于, system 首先创建的进程是 shell 进程. 所以他能解释 $$ 这个符号. 因此他在功能上比 create_process 强大. 编程体验:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// system.c 文件用来加载运行 test.sh
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char* argv[])
{
int result = 0;

printf("current: %d\n", getpid());

result = system(argv[1]);

printf("result = %d\n", result);

return 0;
}
1
2
3
4
5
6
7
8
// test.sh 文件
echo "hello world from shell"

a=1
b=2
c=$(($a+$b))

echo "c = $c"

让子进程加载运行 shell 程序

1
2
3
4
5
6
7
8
book@100ask:~/dt/6$ chmod 777 test.sh // 给运行权限
book@100ask:~/dt/6$ gcc system.c // 编译
book@100ask:~/dt/6$ ./a.out ./test.sh // 加载运行 test.sh
/* 运行结果 */
current: 3880
hello world from shell
c = 3
result = 0

让子进程加载运行前面编译出来的 helloworld 程序

1
2
3
4
5
book@100ask:~/dt/6$ ./a.out ./helloworld
current: 3907

hello world
result = 0

七、 linux 终端与进程

    控制台就是直接连接设备的面板, 通过这个面板来控制设备. 早期计算机是非常大的, 面对这么大的一个机器, 怎么操控它, 就需要特别的设计一个操作面板, 而这个操作面板又非常大. 为了方便的操控, 于是将这个面板做的稍微那么人性化一点, 看起来像一个台子. 它是直接位于机器上的是设备的一部分, 言下之意就是不可或缺的. 它有按键、旋钮、指示灯. 在早起计算器肯定有一个控制台它用来操控计算器.

  • 控制台(console)是一个直接控制设备的面板(属于设备的一部分).
  • 计算机设备的控制台:按键&指示灯(键盘&显示器).
  • 早期的电子计算机必然有一个控制台.

    终端不是计算机的一部分, 是独立于计算机的一台机器. 用这台机器和计算机进行交互. 在早期计算机的功能就是做计算, 需要将问题输入计算机, 可以使用终端进行输入. 并且展示计算结果给用户. 因此终端和计算机之间要进行连接, 它通过物理线缆直接连接. 其中包括 RX/TX. RX 用来收数据, TX 用来发数据.

    在当时流行一种机器叫做电传打字机, 这种机器在当时是用来发传真的. 后来计算机问世, 大家想着能不能把现有的打字机和计算机相连. 这样就能很方便的使用计算机了, 因为在当时很多人本来就会使用电传打字机. 这个想法提出之后, 立即被采纳. 而电传打字机的缩写就是 tty.

  • TTY–即:TeleType Writer 电传打字机, 一种终端设备.

    然而并不是只有 tty 这一种终端设备, 在当时还有别的与计算机交互的独立的机器. 计算机怎么去统一的识别这些外部的独立的机器. 很显然需要通过驱动, 不同的外设的驱动又不同. 由于 tty 是最先被采纳的终端, 因此在当时 tty 就变成了一种标准协议 tty. 在这种标准下, 所有的终端都被对接到 tty 这个概念之下. 将 tty 抽象成一个通用的标准规则后, 以后无论什么终端设备的驱动都需要将自己注册到 tty 上去满足这个规则.

    随着时间的推移了电传打字机已经淘汰, 现在计算机上的输入设备和显示设备已经从主机中独立出来了. 这意味着控制台与终端的物理表现形式逐渐表现一致. 所以说在现代我们学习计算机系统的时候经常的把"控制台" $\approx$ “终端”.

1. 控制台 vs 终端

  • 控制台是计算机的基本组成部分
  • 终端是 “连接 / 使用” 计算机的附加设备
  • 现代计算机只能有一个控制台, 但可以有多个终端

    怎么区分控制台和终端, 在计算机系统启动的过程中能够显示启动信息,能够响应键盘输入的就是控制台. 而终端需要等到计算机系统启动成功之后才能够连接上使用计算机. 现代的计算机是多任务处理的计算机, 因此就需要终端来让多个用户同时使用计算机. 在现代从功能上来看控制台和终端差不多. 从物理表现形式上来看也是显示器键盘, 因此把他们约等起来没什么关系. 然而作为学习者来说还是有必要知道这些细节.

    tty 最终演变为 linux 中的 tty. 对于进程而言, tty 是一种输入输出设备. 用户进程要接受输入展示输出. 直接通过系统调用访问 tty 设备. tty 设备运行与内核模式, 他可以与外设进行交互. 进程和 tty 的关系就是使用和被使用的关系.

  • 控制台: 是设备必须有的部分, 用来操控机器
  • 终端: 独立于计算机的机器, 连接计算机后与计算机进程交互. 具体的交互过程就是输入输出
  • tty: 早期是一种终端, 随着发展演变成了 linux 中的规范 tty. 进程通过 tty 使用终端, 这是当代 linux 系统运作的模式.

2. 各种终端类型

  • 虚拟终端: 现代 linux 中的正牌终端, 当代 linux 用户界面有两种 GUI 和 CLI(命令行用户界面, 黑底白字). 虚拟终端指的就是 CLI. 这种用户界面有 6 个分身, /dev/tty1 ~ tty6. 这 6 个分身不是真的有 6 个终端. 他是虚拟出来的, 只有一个 CLI 命令用户界面. 通过一定的技术手段让我们感觉有 6 个这样的终端存在, 因此叫做虚拟终端.

  • 串口终端: 通过串口去连接外部设备, 这个外部设备就用作终端操作外部设备. 常用于嵌入式设备. 对于嵌入式 linux 而言调试的手段就是串口. 就是往嵌入式设备接一个串口终端. 用来查看嵌入式设备的运行日志, 以及和设备交互进行调试.

  • 终端模拟器: 广义上讲是终端模拟程序, 就是一种软件, 比如我们常用 qt 等一些应用软件. 这些应用软件可以登录到 linux , 使用起来就和终端的感觉差不多, 他们属于终端模拟器. 狭义上来讲, 终端模拟器是 linux 中的一个内核模块. 可以利用这个内核模块模拟远古时期的终端的行为.

  • 伪终端: 就是运行与用户模式的终端模拟程序, 分为两个设备, 主设备(pty master) 和 从设备 (pty slave).

类型 说明
虚拟终端(virtual Terminal) 这一套键盘和显示器映射为6个终端设备 /dev/tty1~tty6
tty0 指代当前使用的终端
串口终端(Serial Port Terminal) 将连接到串口的外设看作终端设备 /dev/ttyS1, …
终端模拟器(Terminal Emulator) 终端模拟程序 / 内核模拟模块 Putty, MobaXterm, 内核模块, 伪终端, …
伪终端(Pseudo Terminal) 运行在用户模式的终端模拟程序, 主设备(pty master) 和 从设备 (pty slave) /dev/ptmx, /dev/pts/3, …

1) 终端模拟器

    终端模拟器的实现机制如下所示. 终端模拟器位于 linux 内核中. 是 linux 内核的一个模块. 这个模块直接使用了键盘/显卡驱动. 然后注册到 tty driver. 终端模拟器 + tty driver = 一种类型的 tty 设备. 用户进程只需要用统一的方式来访问 tty 设备就可以了.

2) 伪终端 pty

    伪终端 PTY 的机制如下所示. 伪终端分为两个部分 master 和 slave. 他们分别对接进程. 两个进程互相不知道对方的存在.他们面对的都是伪终端 pty 设备. 于是他们只需要打开设备, 读设备, 写设备, 关闭设备就行了. 这里 “进程1” 写一个数据, 数据会从主设备发送到从设备. 然后对接从设备的 “进程2” 就能读到数据. 同理也可以反过来. 于是两个进程就可以通过 “伪终端 pty” 进行通信了. 彼此不知道对方存在的进程就能进行进程间通信了. 这就是传说中的 IPC.

    gnome-terminal 伪终端的实现机制如下所示. 他是一个进程, 主要用来对接 tty 的主设备. 并且通过系统调用可以获取到用户的输入, 也可以操作显示器, 在显示器上输出数据. 完事之后, 他可以将从键盘输入的数据通过 (pty master) --> tty --> (pty slave) --> bash. 最终输入到 bash(shell). 所以 shell 就可以根据用户的输入来干各种各样的事情. 比如用户输入了 ls , shell 就会去执行 ls 对应的应用程序. 进而得到文件输出, 输出的数据又通过 bash --> (pty slave) --> tty --> (pty master) --> gnome-terminal --> 显示器 . 输出的数据最终又回到屏幕上显示出来. 这就是使用图形用户界面时的命令行.

    使用进程树查看当前命令行的进程树信息.

1
2
book@100ask:~$ pstree -A -p -s $$
systemd(1)---systemd(2361)---gnome-terminal-(3100)---bash(3109)---pstree(3119)

    初始化进程创建了一个分身 systemd(2361) , systemd(2361) 继续进行子系统的初始化, 然后初始化了 gnome-terminal 这个进程, gnome-terminal 用来从用户接受键盘的输入, 然后通过 伪终端(pty) 将这个输入转发给 shell , shell 再加载执行应用程序 pstree 之后. 再将输出又通过 伪终端(pty) 转给 gnome-terminal. gnome-terminal 直接对接了显示器. 于是输出的数据就在显示器上显示出来了. 使用 ctrl + alt + f3 打开真正的终端.

    如图所示就是一个非常原始的黑底白字的用户登录界面, 这就是所谓的 CLI 用户登录界面. 直接打印一下进程树. 可以看出初始化进程创建了登录进程 login , 登录进程创建了shell(bash) 进程, shell 进程加载执行 pstree 应用程序. pstree 执行的输出结果又作为输入, 输入到内核的终端模拟器模块, 这个模块再将其显示出来.

    从这两个实验可以看出来在 gui 用户的界面模式下. shell 进程通过 tty 将数据发送给了 gnome-terminal , gnome-terminal 再将数据发送给显示器显示出来. 至于具体的怎么显示就是显卡驱动的事情了. 这里的 shell 进程, 通过 linux 进程中的 tty 来完成数据的输入和输出. 这个 tty 就是 pty. shell 进程本身并不知道数据来自哪里. 在虚拟终端下 shell 进程也通过 tty 这个抽象的概念来完成的数据的输入和输出. 不管是在 gui 模式下还是在 cli 模式下 shell 的行为是完全一致的. 只是实现 tty 对应的驱动不同. 进而导致效果不同.

3) 小结

    对于 shell 进程来说面对的只有 tty 设备. tty 又是 linux 中的抽象概念. 所以我们必须使用具体的模块来支持这个抽象概念. 对于内核来说支持这个概念的模块就是终端模拟器. 终端模拟器是内核的一个模块, 运行于内核模式, 因此直接可以使用键盘/显卡驱动. 伪终端创建一个设备对 master 和 slave. 这个主设备于从设备就是支持 tty 抽象概念的. shell 进程直接对接的是从设备. 主设备对接一个用户进程 gnome-terminal. gnome-terminal 本质是一个 gui 应用程序. 加载后得到一个进程. 它能够获取到用户的输入, 也能够往屏幕输出数据.

2. 伪终端程序设计

创建 PTY 主从设备:

1
2
master = posix_openpt(O_RDWR); // 创建一个主从设备对
// master: 主设备标识

获取主设备权限

1
2
grantpt(master);  // 获取设备使用权限
unlockpt(master); // 解锁设备, 为读写做准备

读写主设备

1
2
c = read(master, &rx, 1); // 读主设备的数据, 数据来源于从设备.
len = write(master, txbuf, strlen(txbuf)); // 向主设备中写数据, 写到从设备.

创建一个伪终端, 主设备运行与前台, 用来检测从设备的输入. 检测到输入数据之后将接收到的数据再发送回从设备. 编程实现 master 部分代码如下.

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
#define _XOPEN_SOURCE 600
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <string.h>

int main()
{
char rx = 0;
char txbuf[256] = {0};
char rxbuf[128] = {0};
int master = 0;
int c = 0;
int i = 0;

master = posix_openpt(O_RDWR);

if( master > 0 )
{
grantpt(master); // 获取设备权限
unlockpt(master); // 解锁设备

printf("Slave: %s\n", ptsname(master)); // 打印出伪终端从设备的名字

while( (c = read(master, &rx, 1)) == 1 ) // 从主设备, 每次读一个字节
{
if( rx == '\r') // 判断是不是回车
{
rxbuf[i] = 0;
sprintf(txbuf, "from slave: %s\r", rxbuf);
write(master, txbuf, strlen(txbuf)); // 将数据写到主设备, 主设备的数据将传入从设备
i = 0;
}
else
{
rxbuf[i++] = rx;
}
}
}
else
{
printf("create pty error...\n");
}

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
36
#define _XOPEN_SOURCE 600
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <string.h>

int main(int argc, char* argv[])
{

int slave = open(argv[1], O_RDWR);

if( slave > 0)
{
char buf[128] = {0};
char* data = "D.T.Software\r";
int len = strlen(data);

write(slave, data, len); // 向从设备写入 D.T.Software\r , 数据最终被写到主设备

sleep(1); // 等待 1s , 等待主设备回写

// 从从设备设备读取数据, 数据来自主设备
len = read(slave, buf, sizeof(buf) -1);
buf[(len > 0) ? len : 0] = 0;

// 显示读到的数据
printf("Read: %s\n", buf);

close(slave);
}

return 0;
}

运行结果:

八、进程层次分析

1. 进程组

    但凡我们在程序设计的需要, 就可以设计一个子进程出来. 创建出来的子进程用来配合父进程完成任务. 父进程创建子进程的意义就在于, 子进程要帮助父进程完成任务. 所以说在逻辑上父进程与子进程是有关系的. 再往下子进程会创建孙进程, 这样不停地创建进程会越来越多. 然而一个有意义的程序, 创建子进程是有理由的. 因此我们可以找到一系列进程. 这些进程有逻辑上面的关系. 于是可以将这些有逻辑关系的进程放在一组进程统一的管理. 进程租就这样诞生了. 其本质是管理意义上的概念.

    最初的操作系统是用来做科学计算的. 随着技术的发展支持了多任务, 支持多任务之后就可以让多个不同的用户通过终端登录到操作系统. 不同的用户使用同一台计算机, 所以不同的用户会创建属于自己任务的进程. 这样来看把进程分组是理所当然的.

  • 每个进程都有一个进程组号(PGID)
  • 进程组: 一个或多个进程的集合(集合中的进程并不孤立)
  • 进程租中的进程通常存在父子关系, 兄弟关系, 或功能相近

进程租概念的引入是为了方便的管理进程, 比如说, 现在用户想要终止一个任务, 而这个任务中包含几十个进程. 要怎么终止任务, 显然需要把几十个任务都杀死. 如果一个一个杀很繁琐. 于是就可以通过进程组来实现杀死某个进程租中的所有进程.

  • 每个进程必定属于一个进程租, 也只能属于某一个进程租
  • 进程除了有 PID 外, 还有 PGID (唯一, 但可变)
  • 每个进程租都有一个进程组长, 进程租组长的 PID 和 PGID 相同
1
2
3
ps -o gpid 19843 // 查看 19843进程 属于哪个进程租
GPID 977 // 它属于 977 进程租
kill -- -977 // 杀死 977 进程租中的所有进程

可以通过手段将进程从1号进程租移到2号进程组. 具体操作如下:

1
2
pid_t getpgrp(void);                // 获取当前进程的进的组标识
pid_t getpgid(pid_t pid); // 获取指定进程的进程租标识
1
int setpgid(pid_t pid, pid_t pgid); // 设置进程的组标识
  • pid == pgid 将 pid 指定的进程设为组长
  • pid == 0 设置当前进程的组标识
  • pgid ==0 将 pid 设为租标识

    默认情况下, 子进程与父进程属于同一个进程组. 对于某个进程租中的进程, 它创建的子进程依然在当前进程组中. 在 fork 的时候会复制当前进程本身, 其中包括数据段, 代码段等完整的数据空间. 对于子进程来说各种标识都和父进程是一样的, 这其中就包括了 pgid. 其本质就是 fork 工作机制的产物.

1) 编程实验 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
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
int pid = 0;
int i = 0;

// 首先打印当前进程 pid , 在打印父进程 pid, 最后打印进程组 id
printf("current = %d, ppid = %d, gpid = %d\n", getpid(), getppid(), getpgrp());

while(i < 5)
{
if((pid = fork()) > 0 )
{
printf("new: %d\n", pid); // 在父进程中打印子进程的 pid
}
else if( pid == 0) // 在子进程中打印进程 pid, 父进程 pid , 进程组 id
{
sleep(1);
printf("child = %d, ppid = %d, pgid = %d\n", getpid(), getppid(), getpgrp());
sleep(10);
break;
}
else
{
printf("fork error ...\n");
}

i++;
}

sleep(10);

return 0;
}

运行结果:
book@100ask:~/dt/8$ ./a.out &
[1] 2465
book@100ask:~/dt/8$ current = 2465, ppid = 2093, gpid = 2465
new: 2466
new: 2467
new: 2468
new: 2469
new: 2470

book@100ask:~/dt/8$ child = 2470, ppid = 2465, pgid = 2465 // 子进程的进程组属于 2465
child = 2468, ppid = 2465, pgid = 2465
child = 2466, ppid = 2465, pgid = 2465
child = 2469, ppid = 2465, pgid = 2465
child = 2467, ppid = 2465, pgid = 2465

使用 ps 查看进程状态, 有一个父进程 2465, 和五个子进程 2466 - 2471. 之后使用 kill 杀死 父进程 2465

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
book@100ask:~/dt/8$ ps  // 查看进程状态, 有一个父进程 2465, 和五个子进程 2466 - 2471
PID TTY TIME CMD
2093 pts/0 00:00:00 bash
2465 pts/0 00:00:00 a.out
2466 pts/0 00:00:00 a.out
2467 pts/0 00:00:00 a.out
2468 pts/0 00:00:00 a.out
2469 pts/0 00:00:00 a.out
2470 pts/0 00:00:00 a.out
2471 pts/0 00:00:00 ps

book@100ask:~/dt/8$ kill 2470 // 杀死 2465

book@100ask:~/dt/8$ ps // 发现子进程依然存活
PID TTY TIME CMD
2093 pts/0 00:00:00 bash
2466 pts/0 00:00:00 a.out
2467 pts/0 00:00:00 a.out
2468 pts/0 00:00:00 a.out
2469 pts/0 00:00:00 a.out
2472 pts/0 00:00:00 ps

发现子进程依然存活, 杀死 2465 进程组中的所有进程.

1
2
3
4
5
6
7
8
9
// 杀死 2465 进程组中的所有进程
book@100ask:~/dt/8$ kill -- -2465

// 进程全部被杀死
book@100ask:~/dt/8$ ps
PID TTY TIME CMD
2093 pts/0 00:00:00 bash
2473 pts/0 00:00:00 ps

    在进程组中有一个进程组长, 进程组组长的 pgid 和 pid 是相同的. 进程组长先运行结束之后, 进程组依然存在. 进程组长仅仅是用来创建新的进程组. 因此进程组长的死活与进程组是否存在没有必然的联系. 进程组中的进程全部运行结束之后进程才会消亡.

    父进程创建子进程后如果要将这个子进程放入别的进程组, 就需要立即通过 setpgid() 改变子进程的组标识(PGID). 同时子进程自身也需要在 fork 之后立即调用 setpgid 改变自身组标识(PGID).

1) 编程实验 2

使用 setpid 设置重新设置进程的进程组.

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
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void)
{

int pid = 0;

printf("current = %d, pgid = %d\n", getpid(), getpgrp());

if( ( pid = fork()) > 0 ) // 父进程通过 fork() 创建了子进程
{
/* 调用 setpgid 设置子进程的进程组标识
* setpgid(pid, pid); 的两个参数相同, 表示将 pid 指定的进程设为组长, 也就是将子进程设置为组长
*/
int r = setpgid(pid, pid);
printf("new: %d, r = %d\n", pid, r);
}
else if(pid == 0)
{
/* 调用 setpgid 设置子进程的进程组标识
* setpgid(pid, pid); 的两个参数相同且为 0
* pid == 0 设置当前进程的组标识, 也就是设置子进程的组组标识
* pgid ==0 将 pid 设为租标识, 也就是将子进程 pid 设置为组表标识, 等价于将子进程设置为组长
*/
setpgid(pid, pid); // 调用 setpgid 设置子进程的进程组标识
sleep(1);
printf("child = %d, ppid = %d, pgid = %d\n", getpid(), getppid(), getpgrp());
}
else
{
printf("fork error ...\n");
}

sleep(10);

return 0;
}

运行结果:
book@100ask:~/dt/8$ ./a.out
current = 3025, pgid = 3025
new: 3026, r = 0
child = 3026, ppid = 3025, pgid = 3026

    父进程的 id 是 3025, 组标识也是 3025 说明, 父进程就是进程组长. 之后用 fock 创建子进程 , 子进程的 id 是 3026, 组标识是 3026. 意味着子进程进入了新的进程组 3026, 并且是组长.

    为什么要调用两次 setpgid(), 为了双重保险. fork 之后谁先执行是不确定的. 如果需要子进程在创建之后立即进入某个进程组. 要达到这个目标就需要双保险. 虽然这两次调用有一次是无效的, 但是这样做可以保证无论哪个进程先运行, 子进程都能立即进入指定的进程组.

3) 编程实验 3

子进程如果先调用了 exec() 函数, 那么父进程就再也无法通过 setpgid() 改变其组标识(PGID). 只能通过自身调用 setpgid() 改变其组标识(PGID).

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
// a.out 主程序
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void)
{

int pid = 0;

if( ( pid = fork()) > 0 )
{
int r = 0;

r = setpgid(pid, pid);
printf("new: %d, r = %d\n", pid, r);
}
else if(pid == 0)
{
char* out = "helloworld.out";
char* const ps_argv[] = {out, NULL};
char* const ps_envp[] = {"PATH=/bin:/user/bin", NULL};
execve(out, ps_argv, ps_envp);
//setpgid(pid, pid);
//sleep(1);
//printf("child = %d, ppid = %d, pgid = %d\n", getpid(), getppid(), getpgrp());
}
else
{
printf("fork error ...\n");
}

return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// helloworld.out 子程序
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void)
{
printf("child = %d, ppid = %d, pgid = %d\n", getpid(), getppid(), getpgrp());

printf("hello world\n");

sleep(30);

return 0;
}

运行结果:

1
2
3
4
book@100ask:~/dt/8$ ./a.out
new: 2817, r = 0
child = 2817, ppid = 1, pgid = 2817
hello world

helloworld 打印出来了, 并且成功设置子进程为进程组的组长. 对主程序做个简单的修改.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// a.out 主程序
......

int main(void)
{

int pid = 0;

if( ( pid = fork()) > 0 )
{
int r = 0;
sleep(1); // 父进程这里增加延时
r = setpgid(pid, pid);
printf("new: %d, r = %d\n", pid, r);
}

......

return 0;
}
1
2
3
4
5
6
7
8
9
// helloworld.out 子程序
......

int main(void)
{
slee(5); // 子进程也做个延时.
printf("child = %d, ppid = %d, pgid = %d\n", getpid(), getppid(), getpgrp());
......
}

在父进程处增加延时, 延时 1s 后再运行 helloworld.out 保证 execve 先运行. 之后子进程 helloworld.out 延时 5s , 保证父进程先运行结束. 运行结果如下.

1
2
3
4
book@100ask:~/dt/8$ ./a.out
new: 2896, r = -1 // 调用失败
book@100ask:~/dt/8$ child = 2896, ppid = 1, pgid = 2895
hello world

    r = -1 表示 setpgid 调用失败了. ppid = 1, 是因为父进程运行结束, 子进程由系统进程接管. pgid = 2895 意味着子进程依旧和父进程处于同一个进程组中. 实验论证了, 前面的观点子进程如果先调用了 exec() 函数, 那么父进程就再也无法通过 setpgid() 改变其组标识(PGID).

2. linux 会话 (session)

    会话(session)是一个用户与操作系统交互的时间段. 它包括用户登录系统、进行操作、退出系统的整个过程. 会话是进程组的集合. 一个会话中包含了多个进程, 会话是比进程组更大的一个概念. 会话是一个或者多个进程组的集合. 每个会话有一个会话标识 (SID). 可以通过进程 id 拿到会话 id.

    会话与终端密切相关, 当通过终端登录系统后就会产生一个会话. 终端是硬件的概念, 使用终端登录系统必须要有软件支持. 这个软件对应到系统中的一个程序. 这个程序加载执行生成的进程就是会话首进程(session leader process), 即在一个新的会话中创建的第一个进程. 通常是一个 shell. 用来建立与终端之间的连接的会话首进程又叫控制进程.

  • 终端登录后的第一个进程称为会话首进程, 通常是一个 shell/bash
  • 对于会话首进程 (session leader) , 其 PID 与 SID 相等
  • 通常情况下会话与一个终端相连接. 该终端用来输入输出. 因此这个终端叫做控制终端.

    登录流程如下所示, linux 启动值后会创建初始化进程, 初始化进程会 fock 自己. fock 出来的初始化进程 init(1) 执行程序 getty. getty 是一个在 linux 系统中用于管理终端登录会话的程序。它负责监听终端设备并处理用户的登录请求。一旦用户通过 Getty 成功登录,Getty 会将控制权转交给 login 程序。login 程序是一个身份验证和会话管理工具,它负责验证用户提供的用户名和密码是否正确, 如果 login 程序验证用户的用户名和密码失败,通常会将控制权返还给 getty 程序,让用户重新尝试登录. 登录成功马上就会 fock 创建一个新进程 shell. shell 通过终端 tty(默认情况下监听控制台终端与虚拟终端) 就可以与终端进行交互. 于是设备就能与用户进行交互. 红色的框框部分就是会话首进程, 表示一个会话, 这里面有多个进程. 而这多个进程又属于不同的进程组. 在不同的时刻前台进程组是不同的.

1) 前台与后台进程组

    会话中的进程组可以分为两类

  • 前台进程组: 可以接受控制终端中的输入, 也可输出数据到控制终端.
  • 后台进程组: 所有进程后台运行, 无法接受终端中的输入, 但可输出数据到终端.

每个会话最多只能有一个前台进程组, 可以有多个后台进程组.

    前台进程组直接与控制终端打交道, 可以对控制终端进行输出, 也可以读取控制终端中的输入. 不仅如此控制终端上的信号发送也是直接发送到前台进程组的. 这里的信号指的是 ctrl + c, 这是一个结束信号. 这个信号发送给前台进程组中的所有进程. 所有进程就结束了. 后台进程组只能输出信息. 将信息输出到终端, 不能接受到控制终端的输入, 也不能接受控制终端上发送的信号.

当前命令行(shell) 运行命令之后创建一个新的进程组. 如果运行的命令中有多个子命令则创建多个进程(处于新建的进程组中).

  • 命令不带 & shell 将新建的进程组设置为前台进程组, 并且暂时将自己设置为后台进程组
  • 命令带 & shell 将新建的进程组设置为后台进程组, 自己依旧是前台进程组.

终端进程组标识(TPGID)表示一个进程是否处于一个和终端连接的进程组中

  • 前台进程组: TPGID == PGID
  • 后台进程组: TPGID != PGID

若进程和任何终端无关: TPGID == -1

通过比较 TPGID 与 PGID 可判断: 一个进程属于前台进程组, 还是后台进程组.
由于前台进程组可能改变, TPGID 用于表示当前的前台进程组.

如果进程组和终端相关联, 如果与终端断开了连接, 这进程组中的所有进程就会被结束掉.

2) 会话编程实验 1

函数接口

1
2
3
4
5
6
7
8
9
#include <unistd.h>

pid_t getsid(pid_t pid); // 获取指定进程的 SID, (pid == 0) ==> 当前进程
pid_t setsid(void); // 调用进程不能是进程组长
/*
* 创建新会话, SID == PID, 调用进程成为会话首进程
* 创建新进程组, PGID == PID, 调用进程成为进程组长
* 调用进程没有控制终端, 若调用前关联了控制终端, 调用后与控制终端断联
*/

    setsid() 用来创建新会话, 调用的进程不能是进程组长, 否则会报错. 如果不是组长则会创建一个新的会话 SID = PID. 调用的进程会变成会话首进程. 在一个新的会话中他是唯一的进程. 在新会话中有一个进程组, 因此他也变成了进程组长 PGID == PID. 又由于是新的会话, 很显然是没有控制终端的. 即使调用 setsid 的进程原来有控制终端, 也会将原来的控制终端断联.

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 <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(void)
{

int pid = 0;
int i = 0;

if((pid = fork()) > 0 )
{

printf("parent = %d, ppid = %d, gpid = %d, sid = %d\n\n", getpid(), getppid(), getpgrp(), getsid(getpid()));

printf("new: %d\n", pid);
}
else if( pid == 0)
{
setsid(); // 创建会话
sleep(180); // 延时方便观察
printf("child = %d, ppid = %d, pgid = %d, sid = %d\n", getpid(), getppid(), getpgrp(), getsid(getpid()));
}
else
{
printf("fork error ...\n");
}


sleep(60);

return 0;
}

直接编译运行看结果.

    可以看出 a.out 存在, 这说明 a.out 已经脱离了原来的进程组. 不仅脱离了原来的进程组还脱离了原来的会话. ? 意味着他不予任何一个终端相关联. 关闭运行 a.out 的终端(左边的终端), 右边的终端依然能够发现 a.out.

调整代码延时

1
2
3
4
5
6
7
8
... // 省略
else if( pid == 0)
{
setsid(); // 创建会话
sleep(2); // 调整延时观察打印
printf("child = %d, ppid = %d, pgid = %d, sid = %d\n", getpid(), getppid(), getpgrp(), getsid(getpid()));
}
...// 省略

运行结果如下,

1
2
3
4
5
6
7
8
9
10
11
12
13
运行结果:
parent = 2187, ppid = 2072, gpid = 2187, sid = 2072

new: 2188
child = 2188, ppid = 2187, pgid = 2188, sid = 2188
^C
book@100ask:~/dt/8$ ps -ajx | grep a.out
1 2188 2188 2188 ? -1 Ss 1001 0:00 ./a.out
2072 2190 2189 2072 pts/0 2189 S+ 1001 0:00 grep --color=auto a.out
book@100ask:~/dt/8$
book@100ask:~/dt/8$ ps -ajx | grep PPID
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
2072 2356 2355 2072 pts/0 2355 S+ 1001 0:00 grep --color=auto PPID

    可以看到父进程的 gpid = 2187 和子进程的 pgid = 2188 不同. 父进程的 sid = 2072 和子进程的 sid = 2188 不同. 但是这里存在一个问题, 对于这个子进程来说, 他与任何终端都没有关系. 那么为什么会有输出呢? 在代码层面子进程调用了 setsid() 这意味着子进程成为了新的会话首进程. 这个新会话与任何终端都没关系, 因此它打印的东西是不应该出现的. 因为子进程使用的 printf 使用 stdout 来进行输出的, 而stdout又挂接到操作的终端上.所以能够打印出信息.标准输出输出与控制终端是无关的, 只不过默认情况下标准输入输出和终端挂接到了一起.

3) 新会话如何关联控制终端

会话首进程成功打开终端主设备(设备打开前出于空闲状态), 那么会话与终端就关联上了. 具体流程如下

  1. 关闭标准输入和标准错误输出
  2. 将 stdin 关联到终端设备: STDIN_FILENO ==> 0 <输入>
  3. 将 stdout 关联到终端设备: STDOUT_FILENO ==> 1 <输出>
  4. 将 stdout 关联到终端设备: STDERR_FILENO ==> 2 <错误>

    新会话关联控制终端后, 会话中的所有进程什么周期与终端相关. 如果终端与会话断联那么所有的进程都会被杀掉, 生命周期都会结束. 只有会话首进程能够关联终端.(会话中的其他进程不行) 进程的标准输入输出标准错误输出可以进程重定向.

  • 由文件描述符 0, 1, 2 决定重定向的目标位置(按顺序打开设备)
  • 控制终端与进程的标准输入以及标准错误输出无直接关系

    如下所示. 在代码中创建一个伪终端出来, 之后新建一个会话并且让会话首进程打开一个从设备. 之后使会话与终端相关联. 于是在会话首进程中调用 printf(“hello world”). hello world 就会从从设备到主设备, master.out 就能够收到从设备传输过来的这个 hello world 数据.

4) 会话编程实验 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
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
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char* argv[])
{

int pid = 0;
int i = 0;


if((pid = fork()) > 0 )
{

printf("parent = %d, ppid = %d, gpid = %d, sid = %d\n\n", getpid(), getppid(), getpgrp(), getsid(getpid()));

printf("new: %d\n", pid);
}
else if( pid == 0)
{
setsid();

// sleep(90);

// 关闭标准输入标准输出以及标准错
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);

// 关闭了之后按顺序打开 3 次. 三次打开的文件描述符分别为 0, 1, 2 (文件打开后描述符依次递增).
// 这三个文件描述符分代表标准输入, 标准输出, 以及标准错误.
// 这里就是重定位了标表标准输入, 标准输出以, 及标准错误到当前终端到 pty, 这个终端就是当前进程的控制终端
i = open(argv[1], O_RDONLY); // --> STDIN 0
i = open(argv[1], O_WRONLY); // --> STDOUT 1
i = open(argv[1], O_RDWR); // --> STDERR 2

printf("child = %d, ppid = %d, pgid = %d, sid = %d\n", getpid(), getppid(), getpgrp(), getsid(getpid()));

printf("i = %d\n", i);
}
else
{
printf("fork error ...\n");
}


sleep(60);

return 0;
}

    在进程中首先调用 setsid, 创建一个新会话. 并且当前的进程成为会话首进程. 然后关闭标准输入标准输出以及标准错误. 关闭了之后按顺序打开 3 次. 三次打开的文件描述符分别为 0, 1, 2 (文件打开后描述符依次递增). 这三个文件描述符分代表标准输入标准输出以及标准错误. 这里就是重定位了标表标准输入, 标准输出以, 及标准错误到当前终端到 pty, 这个终端就是当前进程的控制终端.

会话首进程的控制终端程序实现

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
#define _XOPEN_SOURCE 600
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <string.h>


int main()
{
char rx = 0;
int master = 0;
int c = 0;

master = posix_openpt(O_RDWR);

if( master > 0 )
{
grantpt(master); // 获取设备权限
unlockpt(master); // 解锁设备

printf("Slave: %s\n", ptsname(master)); // 打印出伪终端从设备的名字

while( (c = read(master, &rx, 1)) == 1 ) // 从主设备, 每次读一个字节
{
if( rx != 'r') // 判断是不是换行
{
printf("%c", rx);
}
}
}
else
{
printf("create pty error...\n");
}


return 0;
}

编译运行

九、守护进程

    守护进程是系统中执行任务的后台进程. 它不与任何终端相关联, 因此终端被关闭了或断联了守护进程是不会结束的, 同时它也无法接收和终端相关的信号(ctrl + c 等). 因为是后台进程, 所以它的生命周期很长, 一旦启动, 正常情况下不会终止(直到系统退出才会终止). 一般 linux 大多数服务器程序使用守护进程来实现(守护进程名以后缀 d 结尾).

创建守护进程的步骤如下所示:

    1. 通过 fork() 创建新进程, 成功后父进程退出
    1. 子进程通过 setsid() 创建新会话
    1. 子进程通过 fork() 创建孙进程(肯定不是会话首进程 ==> 无法与终端相关联 ==> 无法成为控制进程)
    1. 顺进程修改模式 unmask(), 改变工作目录为 “/” ==> 不希望守护进程与哪个一个进程相关联, 因为他是一个后台进程
    1. 关闭标准输入输出和标准错误输出 ==> 不希望守护进程在其他终端上打印出东西.
    1. 重定向标准输入输出和标准错误输出 (“/dev/null”) ==> 光关闭还不行还需要重定向, 所有输入 /dev/null 的数据都会消失.

守护进程关键点分析

  • 父进程创建子进程是为了创建新会话
  • 子进程创建孙进程是为了避免产生控制进程
  • 孙进程不是会话首进程, 因此不能关联终端
  • 重定向操作可以避开奇怪的进程输出行为

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
 #include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>

int main(int argc, char* argv[])
{
int pid = 0;
int i = 0;

if((pid = fork()) > 0 ) // 1 创建子进程
{
printf("parent = %d, ppid = %d, gpid = %d, sid = %d\n\n", getpid(), getppid(), getpgrp(), getsid(getpid()));
printf("child: %d\n", pid);
exit(0);
}
else if( pid == 0)
{
setsid(); // 2 创建会话首进程

// sleep(90);

if((pid = fork()) > 0 ) // 3 创建孙进程
{
printf("child = %d, ppid = %d, gpid = %d, sid = %d\n\n", getpid(), getppid(), getpgrp(), getsid(getpid()));
printf("grandson: %d\n", pid);
exit(0);
}
else
{
// 4
umask(0); // 给予当前的顺进程读写文件的权限
chdir("/"); // 修改目录到根目录, 这样就不会影响用户删除文件夹了

// 5 关闭标准输入输出和标准错误
close(STDIN_FILENO);
close(STDOUT_FILENO);
close(STDERR_FILENO);

// 6 将标准输入输出和标准错误, 重定向到黑洞中
i = open("/dev/null", O_RDONLY); // --> STDIN 0
i = open("/dev/null", O_WRONLY); // --> STDOUT 1
i = open("/dev/null", O_RDWR); // --> STDERR 2

printf("grandson = %d, ppid = %d, pgid = %d, sid = %d\n", getpid(), getppid(), getpgrp(), getsid(getpid()));
printf("i = %d\n", i);

while(1)
{
// do somthing
printf("i=%d\n", i++);
sleep(1);
}
}
}
else
{
printf("fork error ...\n");
}

sleep(60);

return 0;
}

编译运行:

1
2
3
4
5
6
7
8
9
10
11
book@100ask:~/dt/9$ ./a.out
parent = 3946, ppid = 2072, gpid = 3946, sid = 2072

child: 3947
book@100ask:~/dt/9$ child = 3947, ppid = 1, gpid = 3947, sid = 3947
grandson: 3948

book@100ask:~/dt/9$
book@100ask:~/dt/9$ ps -ajx | grep a.out // a.out 已经变成了守护进程
1 3948 3947 3947 ? -1 S 1001 0:00 ./a.out
2072 3952 3951 2072 pts/0 3951 S+ 1001 0:00 grep --color=auto a.out

    子进程与父进程都已经自动退出了, a.out 编程了守护进程, 它所有的打印都被重定向到了黑洞(“/dev/null”). 因此看不见任何打印信息. 这一点都不合理, 在实际的场景中守护进程虽然在后台运行的然而, 在一些特殊的情况下需要查看守护进程的日志. 根据守护进程提供的信息来排查问题. 守护进程还是有需要打印数据的需要的. 怎么解决这个问题呢? 将标准的输入输出重定向改为一个具体的文件. 修改如下.

1
2
3
4
5
6
// ...省略
// 6 将标准输入输出和标准错误, 重定向到黑洞中
i = open("/dev/null", O_RDONLY); // --> STDIN 0
i = open("/home/book/dt/9/a.log", O_WRONLY); // --> STDOUT 1 将文件重新定位到 a.log
i = open("/dev/null", O_RDWR); // --> STDERR 2
// ...省略

编译运行:

1
2
3
4
5
6
7
8
9
book@100ask:~/dt/9$ ./a.out
parent = 4198, ppid = 2072, gpid = 4198, sid = 2072
child: 4199
child = 4199, ppid = 1, gpid = 4199, sid = 4199
grandson: 4200

book@100ask:~/dt/9$ cat a.log // 读取文件信息
book@100ask:~/dt/9$
book@100ask:~/dt/9$

    读取文件信息发现, 啥都没有. 定位失败了, 为什么呢? 这涉及到文件缓冲区, 对于文件操作来说, 打开一个文件, 文件的背后会有一个缓冲区. 这个缓冲区用来缓存文件数据的, 这个缓冲区会保存硬盘上面的数据. 同事也会保存想要写入文件的具体数据.于是当我们想要写的时候, 数据会先进入文件缓冲区, 当文件缓冲区的数据满了一次性写入到目标文件中. 这样做的好处就是不用每次写一个字节就进行一次硬盘的 io 操作. 这样做可以提高文件操作的效率. 在代码中增加fflush(stdout);, 这句代码的意思就是强行的将缓冲区的数据写到代码中. 修改如下.

1
2
3
4
5
6
7
8
9
// ... 省略
while(1)
{
// do somthing
printf("i=%d\n", i++);
sleep(1);
fflush(stdout);
}
// ... 省略

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
book@100ask:~/dt/9$ ./a.out
parent = 4313, ppid = 2072, gpid = 4313, sid = 2072
child: 4314
book@100ask:~/dt/9$ child = 4314, ppid = 1, gpid = 4314, sid = 4314
grandson: 4315
book@100ask:~/dt/9$
book@100ask:~/dt/9$
book@100ask:~/dt/9$ cat a.log // 守护进程正常打印出 log
grandson = 4315, ppid = 4314, pgid = 4314, sid = 4314
i = 2
i=2
i=3
i=4
i=5
i=6
i=7
i=8

十、信号发送与处理

1. 信号

    信号是一种 “软件中断” , 用来处理异步事件. 中断起源于硬件, cpu 可以接受外部中断, 某一个外设需要处理, 外设就向 cpu 发送一个中断. cpu 就会立即停止当前程序的执行, 去响应中断执行中断服务程序 ISR. 执行完之后回复到原来的地方继续执行. 打断正在执行的程序, 去执行其他程序, 执行完之后回到之前中断的地方继续执行. 当进程接收到信号的时候, 就会暂停当前程序的执行, 转而去处理信号, 处理的方式是去执行一段特定的程序. 执行完之后回到中断的地方继续执行. 其本质和硬件中断是一样的.

  • 信号是内核发送信号到某个进程,内核负责管理硬件和进程, 他的意义在于通知进程事件的发生.
  • 信号的来源可能来自硬件, 可能来自用户输入, 可能来自除零错误.

    具体信号来自哪里并不重要, 重要的是接收到了什么信号, 如何处理这个信号. 信号可以看做是一种类型的进程间通信方式, 一个进程向另一个进程发送数据(信号)

  • A 进程发生事件 T(可以是硬件事件/用户输入事件/程序自身事件) , 可以向 B 进程发送信号 , 进而进程 B 执行某种动作响应事件.
  • 进程可以对接收到的不同信号进程不同动作响应( 信号–> 处理 )

1) 信号的分类

硬件异常: 内核检测到硬件错误, 发送响应信号给进程, 下面列出部分信号.

信号 说明
SIGBUS 7 总线错误, 进程发生了内存访问错误
SIGFPE 8 算数错误, FPE表是浮点异常
SIGILL 9 指令错误, 进程尝试执行非法指令
SIGSEGV 11 段错误, 进程访问量非法内存区域

终端信号: 在终端输入 “特殊字符” 等价于向前台进程组发送相应信号, 下面列出部分信号.

信号 说明
SIGINT(Ctrl + C) 程序终止信号, 用于通知前台进程组终止信号
SIGQUIT(Ctrl + \) 与 SIGINT 类似, 进程收到该信号退出时可以产生 coredump 文件
SIGSTP(Ctrl + z) 停止进程的运行, 进程收到该信号后停止运行(状态发生转换), 后续可以恢复运行状态.

软件信号: 在软件层面 (进程代码中) 触发的信号(发送给自身或者其他进程), 下面列出部分信号.

信号 说明
子进程退出 父进程收到 SIGCHLD 信号
父进程退出 子进程可能收到信号
定时器到期 slarm(), ualarm(), timer_create(), …
主动发送信号 kill(), raise(), …

三种情况的进程与信号的关系如下所示:

2) 信号的默认处理

    linux 操作系统为信号提供了默认的处理方式. 这意味着在很多的场合我们无需知道 linux 中的型号发送与处理, 将系统编程当做普通的 c 程序编写.

默认处理方式 说明 示例
ignore 进程对齐信号不会产生任何影响 SIGCHLD, SIGURG
terminate 终止进程 SIGKILL, SIGHUP
coredump 终止进程并产生转储文件 SIGQUIT, SIGILL
stop/continue 停止进程执行 / 恢复进程执行 SIGSTOP, SIGCONT

3) 自定义信号处理与发送

System V: 也被称为 AT&T SystemV , 是 unix 操作系统众多版本中的一支.
BSD: 加州大学伯克利分校开创, Unix 衍生系统, 代表由此派生出的各种套件集合.

systemV BSD
root 脚本位置 /etc/init.d /etc/rc.d
默认 shell Bshell Cshell
文件系统数据 /etc/mnttab /etc/mtab
内核位置 /UNIX /vmUnix
打印机设备 lp rlp
字符串函数 memcopy bcopy
终端初始化设置文件 /etc/initab /etc/ttys
中断控制 termio termios

    linux 之所以被称为类 unix 操作系统(Unix Like), 部分原因就是 Linux 的操作风格是介于上述两者之间, 且不同厂商为了照顾不同的用户, 其发行版的操作风格有存在差异. 因此 linux 的一些函数还保留两者的接口.

信号的处理

1
2
3
4
5
6
7
8
9
10
#include <sys/types.h>
#include <signal.h>

typedef void (*sighandler_t)(int);

// 三个函数的功能完全一样
// 函数功能: 映射信号 signum 到信号处理函数 handler, 当产生信号 signum 时会调用 handler
sighander_t signal(int signum, sighandler_t handler);
sighander_t sysv_signal(int signum, sighandler_t handler);
sighander_t bsd_signal(int signum, sighandler_t handler);

信号的发送

1
2
3
4
5
6
7
8
9
#include <sys/types.h>
#include <signal.h>

// 向指定的进程发送信号
// pid > 0, 将信号发送给对应的进程.
// pid == 0, 将信号发送给当前进程组中的每个进程.
// pid < -1000, 将信号发送给 PGID 为 1000 的进程组.
int kill(pid_t pid, int sig);
int raise(int sig); // 将信号发给自己, 信号处理完毕才返回

    标准信号是 unix 系统中的信号, 编号范围从 1 到 31. 实时信号是 Linux 独有信号, 编号范围从 32 到 64.

4) 编程实验 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
//#define _GNU_SOURCE // 用于 sysv_signal

// 用于 bsd_signal
//#define _XOPEN_SOURCE 500
//#define _POSIX_C_SOURCE 200112L

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include<unistd.h>

// 信号处理函数
void signal_handler(int sig)
{
printf("handler : sig =%d\n", sig);
}

int main(int argc, char* argv[])
{
// 映射信号 SIGINT 到 signal_handler
// 当线程接受到 SIGINT( Ctrl+c ) 信号就会调用 signal_handler
signal(SIGINT, signal_handler);
// (SIGINT, signal_handler);
//bsd_signal(SIGINT, signal_handler);

while(1)
{
sleep(1);
}

return 0;
}

    运行程序, 在键盘上输入 ctrl + c 打印如下.

1
2
book@100ask:~/dt/10$ ./a.out
^Chandler : sig =2 // 打印出对应的 log, 信号 2 就是 SIGINT

    同时我们也能发现, ctrl + c 已经不能结束进程了, 因为我们已经将这个信号自定义处理了. 可以通过 ctrl + \ 结束进程.

5) 编程实验 2

handler 的代码如下, 用来接收信号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include<unistd.h>
#include <signal.h>

void signal_handler(int sig)
{
printf("handler : sig =%d\n", sig);
}

int main(int argc, char* argv[])
{
signal(40, signal_handler);

while(1)
{
sleep(1);
}

return 0;
}

send 的代码实现如下, 用来发送信号

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
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <signal.h>

int main(int argc, char* argv[])
{
int pid = atoi(argv[1]);
int sig = atoi(argv[2]);

printf("send sig(%d) to process(%d)\n", sig, pid);

kill(pid, sig); // 发送信号

raise(SIGINT); // 给自己发送结束信号

while(1)
{
printf(" while ....\n"); // 由于提前给自己发了结束信号因此不会有输出
sleep(1);
}

return 0;
}

运行结果:

1
2
3
4
5
6
7
book@100ask:~/dt/10$ ./handler &
[1] 3457
book@100ask:~/dt/10$
book@100ask:~/dt/10$ ./a.out 3457 40 // 向 handler 这个进程发送信号 40
send sig(40) to process(3457)

handler : sig =40 // 接收到信号进程处理

2. 信号处理的区别

    信号的处理函数有三个 signalsysv_signalbsd_signal, 他们之间有什么区别呢. 对于大多数 linux 发行版来说, 系统默认的 signal 函数其行为是和 bsd 封装的 bsd_signal 是一致的.

1) 信号的 OneShot 特性

    System V 风格的 signal 函数, 注册信号的处理是一次性的, 进程收到信号之后, 会调用 sysv_signal 注册的处理函数. 处理函数一旦执行, 之后进程再收到相同的函数, 将会采用默认的方式处理后续相同信号. 如果想要重复的除法, 那么必须再次调用 sysv_signal 注册处理函数. 他的有点在于我们可以控制信号处理的次数, 想处理多少次就处理多少次该信号.

BSD 风格的 signal 函数不存在 OneShot, 能够自动反复处理函数的调用. linux 默认的 signal 函数行为与 BSD 一致.

编程验证 OneShot 特性.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#define _GNU_SOURCE // 用于 sysv_signal
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include<unistd.h>
#include <signal.h>

void signal_handler(int sig)
{
printf("handler : sig =%d\n", sig);
}

int main(int argc, char* argv[])
{
sysv_signal(SIGINT, signal_handler);

while(1)
{
sleep(1);
}

return 0;
}

运行结果

1
2
3
book@100ask:~/dt/10$ ./a.out
^Chandler : sig =2 // 接受到 ctrl + c 进行处理.
^C // 第二次则走默认处理程序.

将 sysv_signal 修改为 bsd_signal 再验证如下.

1
2
3
4
book@100ask:~/dt/10$ ./a.out
^Chandler : sig =2 // 无论多少次 ctrl + c 都能够重复处理.
^Chandler : sig =2
^Chandler : sig =2

2) 信号的自屏蔽特性

    在信号处理函数执行期间, 很可能再次收到当前信号. 即: 处理 A 信号的时候, 再次收到 A 信号. 对于 System V 风格的 signal 函数, 会引起信号处理函数的重入. 即: 调用处理函数的过程中, 再次触发处理函数的次调用.

在注册信号处理函数时:

  • System V 风格的 signal 函数不屏蔽任何信号. 第二次发送过来的相同信号, 会打断第一次发送过来的相同信号.
  • bsd 风格的 signal 函数会屏蔽当前注册的信号. 使当前信号进入等待状态, 等到上次信号处理函数的调用结束. 马上再次调用. 因此不存在相同信号的打断情况.

编程验证信号的自屏蔽特性

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
 #define _GNU_SOURCE // 用于 sysv_signal
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include<unistd.h>
#include <signal.h>

void delay_handler(int sig)
{
int i = 0;

sysv_signal(SIGINT, delay_handler); // 每次进来之后重新注册信号处理函数, 去掉 OneShot 特性.

printf("begin delay handler ...\n");

for(i=0; i<5; i++) // 循环打印方便验证自屏蔽特性
{
printf("sleep %d s\n", i);
sleep(1);
}

printf("end delay handler ...\n");
}

int main(int argc, char* argv[])
{
sysv_signal(SIGINT, delay_handler);

while(1)
{
sleep(1);
}

return 0;
}

运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
book@100ask:~/dt/10$ ./a.out
^Cbegin delay handler ... // ctrl + c 发送信号
sleep 0 s
sleep 1 s
sleep 2 s
^Cbegin delay handler ... // 进程被 ctrl + c 打断, 并且重新开始
sleep 0 s
sleep 1 s
sleep 2 s
sleep 3 s
^Cbegin delay handler ... // 进程被 ctrl + c 打断, 并且重新开始
sleep 0 s
^Cbegin delay handler ... // 进程被 ctrl + c 打断, 并且重新开始
sleep 0 s
sleep 1 s
sleep 2 s
sleep 3 s
sleep 4 s
end delay handler ...

验证 bsd 版本的 sginal 函数

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
#define _XOPEN_SOURCE  500
#define _POSIX_C_SOURCE 200112L

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include<unistd.h>
#include <signal.h>

void delay_handler(int sig)
{
int i = 0;

printf("begin delay handler ...\n");

for(i=0; i<5; i++)
{
printf("sleep %d s\n", i);
sleep(1);
}

printf("end delay handler ...\n");

}

int main(int argc, char* argv[])
{
bsd_signal(SIGINT, delay_handler);

while(1)
{
sleep(1);
}

return 0;
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
book@100ask:~/dt/10$ ./a.out
^Cbegin delay handler ...
sleep 0 s
^Csleep 1 s // ctrl + c 无法打断当前执行
^Csleep 2 s // ctrl + c 无法打断当前执行
^Csleep 3 s // ctrl + c 无法打断当前执行
^Csleep 4 s // ctrl + c 无法打断当前执行
end delay handler ...
begin delay handler ...
sleep 0 s
sleep 1 s
sleep 2 s

    由此可见 bsd 版本的 signal 函数不会发生重入, 在处理当前信号的时候, 如果再次收到了相同信号, 信号会进行排队等待. 当前信号处理完成, 之后再次执行信号处理函数.

3) 系统调用重启特性

    系统调用期间, 可能收到信号, 此时进程必须从系统调用中返回. 对于执行之间较长的系统调用 (wirte / read) , 被信号中断的可能性很大. 如果希望信号处理之后, 被中断的系统调用能够重启, 则: 可以通过条件 errno == EINTR 判断重启系统调用.

1
2
3
4
5
6
7
8
9
// 系统调用重启伪代码示例
pid_t r_wait(int* status)
{
int ret = -1;

// 如果 wait 被信号中断了, wait 会返回 -1. 并且 errno 会被置位 EINTR, 因此通过这个循环再次调用 wait.
while( status && ( (ret = wait(status)) == -1 ) && (errno == EINTR) );
return ret;
}
  • System V 风格的 signal 函数, 系统调用被中断后, 直接返回 -1, 并且 errno == EINTR.
  • bsd 风格的 signal 函数, 系统调用被中断, 内核在信号处理函数结束后, 自动重启系统调用.

编程验证

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
#define _GNU_SOURCE // 用于 sysv_signal
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

void delay_handler(int sig)
{
printf("handler : sig = %d\n", sig);
}

int r_read(char* data, int len)
{
int ret = -1;

// 使用系统调用 read 从标准输入读取键盘输入的数据, 读取时进入阻塞
// 如果被信号中断返回 -1, 同时 errno == EINTR 则利用 while 循环重新读取
while( data &&
( (ret = read(STDIN_FILENO, data, len-1)) == -1) &&
( errno == EINTR) )
{
printf("restart syscall manully ...\n");
}

if( ret != -1 )
{
data[ret] = '\0';
}

return ret;
}

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

sysv_signal(SIGINT, delay_handler);

r_read(buf, 32);

printf("intput: %s\n", buf);

return 0;
}

运行结果:

1
2
3
4
5
book@100ask:~/dt/10$ ./a.out
1234^Chandler : sig = 2 // 输入 1234 之后输入 ctrl + c 产生信号打断 read 系统调用
restart syscall manully ... // 由于系统调用被打断了之后, 巧用 while 循环重启系统调用
test // 输入 test 并回车
intput: test // 打印出输出的 test

将上面代码改成 bsd 风格的 signal 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
//#define _GNU_SOURCE // 用于 sysv_signal
#define _XOPEN_SOURCE 500
#define _POSIX_C_SOURCE 200112L
// ......

int main(int argc, char* argv[])
{
//......
bsd_signal(SIGINT, delay_handler);
//......
return 0;
}

运行结果

1
2
3
4
book@100ask:~/dt/10$ ./a.out
1234^Chandler : sig = 2 // 输入 1234 之后输入 ctrl + c 产生信号打断 read 系统调用
test // 输入 test 并回车
intput: test // 打印出输出的 test

注意事项:

  • 并非所有的系统调用对信号中断都表现同样的行为, 一些信号支持信号中断后自启 read(), write(), wait(), waitpid(), ioctl, ....
  • 一些信号完全不支持中断后自动启动 poll(), select(), usleep()
  • 通过 man 7 signal 查询系统调用是否支持重启

4) bsd 处理不同信号

    bsd 风格的 signal 函数可以防止同一个信号重入, 但是在处理 a 信号的时候收到了 b 信号, 会先处理 b 信号. 处理完 b 信号之后再处理 a 信号. 编程验证

信号处理函数实现如下:

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
#define _XOPEN_SOURCE  500
#define _POSIX_C_SOURCE 200112L
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

void delay_handler(int sig)
{
int i = 0;

printf("begin delay handler ...\n");

for(i=0; i<5; i++)
{
printf("sleep %d s\n", i);
sleep(1);
}

printf("end delay handler ...\n");

}

void ctrl_handler(int sig)
{
printf("handler : sig = %d\n", sig);
}

int main(int argc, char* argv[])
{

bsd_signal(40, ctrl_handler);

bsd_signal(SIGINT, delay_handler);

while(1);

return 0;
}

信号发送函数实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>

int main(int argc, char* argv[])
{
int pid = atoi(argv[1]);
int sig = atoi(argv[2]);

printf("send sig(%d) to process(%d)\n", sig, pid);

kill(pid, sig); // 发送信号

raise(SIGINT); // 给自己发送结束信号

return 0;
}

运行结果如下:

5) 小结

    在信号处理上, Linux 系统更接近 BSD 风格的操作; 默认的 signal 函数在不同 linux 发行版上语义可能不同, 从代码移植性角度, 避免直接使用 signal(…) 函数.

函数 OneShot 屏蔽自身 重启系统调用
sinal(…) false true true
bsd_signal(…) false true true
sysv_signal(…) yes flase flase

3. 现代信号处理

1) 注册函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <signal.h>

// 接受到信号之后采取什么样的行为处理信号
// signum 信号
// act 处理方式
// oldact 原有的处理信号的方式, 一般情况不使用
int sigaction( int signum,
const struct sigaction *act,
struct sigaction* oldact );

struct sigaction {
void (*sa_handler)(int); // 简单的 信号处理函数
void (*sa_sigaction)(int, siginfo_t*, void*) // 附加信息更多的, 信号处理函数
sigset_t sa_mask; // 处理期间屏蔽信号集
int sa_flags; // 信号处理特性标志位, oneshot, restart, ......
void (*sarestorer)(void); // 废弃, 不再使用
};

2) 信号屏蔽与标记

sigset_t sa_mask

  • 信号屏蔽: sa_mask = SIGHUP | SIGINT | SIGUSR1; 在处理信号的时候屏蔽 SIGHUP 、SIGINT 、SIGUSR1 这三个信号. 信号处理函数如果被调用了这三个信号得等待被处理, 不会马上被处理. 即这三个信号无法中断当前的信号处理函数的执行.
  • 注意: 并不是所有信号都可以被屏蔽, 如: SIGKILL, SIGSTOP

int sa_flags

  • 信号特性: sa_flags = SA_ONESHOT | SA_RESTART;, SA_ONESHOT 特性表示当信号处理函数被调用了就会失效了, 想要有效就得再次设置. SA_RESTART 特性表示在系统调用执行期间如果收到了信号, 处理信号处理完之后自动重启系统调用.
  • 特殊特性 (SA_SIGINFO) , 信号处理时能够收到额外的附加信息. 使用这个特性就可以实现进程间通信.

进程间的信号发送是一种形式的进程间通信.

信号状态小知识

  • 信号产生: 信号来源, 如: SI_KERNEL, SI_USER, SI_TIMER
  • 信号未决: 从信号产生到信号被进程接受的状态 (处于未决状态的信号比如已存在)
  • 信号递达: 信号送达进程, 被进程接受 (忽略, 默认处理, 自定义处理)

    信号屏蔽指信号处理函数处理期间被屏蔽的信号, 是不会递送给进程的即进程是不会收到被屏蔽的信号的. 被屏蔽的信号可能有多个. 信号屏蔽指的是信号的未决状态. 例如 sa_mask = SIGHUP | SIGINT | SIGUSR1;

    信号阻塞指信号处理函数执行期间, 当前信号不会递送给进程(当前信号). 本质也是信号未决状态.例如: act.sa_flags = SA_RESTART | SA_NODEFER.

3) 编程实验 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
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>

void delay_handler(int sig)
{
int i = 0;

printf("begin delay handler ...\n");

for(i=0; i<5; i++)
{
printf("sig(%d) - sleep %d\n", sig, i);
sleep(1);
}

printf("end delay handler ...\n");

}

int r_read(char* data, int len)
{
int ret = -1;

// 使用系统调用 read 从标准输入读取键盘输入的数据, 读取时进入阻塞
// 如果被信号中断返回 -1, 同时 errno == EINTR 则利用 while 循环重新读取
while( data &&
( (ret = read(STDIN_FILENO, data, len-1)) == -1) &&
( errno == EINTR) )
{
printf("restart syscall manully ...\n");
}

if( ret != -1 )
{
data[ret] = '\0';
}

return ret;
}

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

struct sigaction act = {0};

act.sa_handler = delay_handler;

// SA_RESTART 系统调用自动重启
// SA_ONESHOT 信号只处理一次
// SA_NODEFER 信号不会阻塞
act.sa_flags = SA_NODEFER;

sigaddset(&act.sa_mask, 40); // 屏蔽信号 40
//sigaddset(&act.sa_mask, SIGINT); // 屏蔽信号 SIGINT

sigaction(40, &act, NULL);
sigaction(SIGINT, &act, NULL);

r_read(buf, 32);

printf("intput: %s\n", buf);

return 0;
}

运行结果分析, 信号处理设置了 SA_NODEFER , 因此不会阻塞自身信号, 但由于后面屏蔽掉了 40 和 SIGINT, 因此这两个信号会被屏蔽. 又因为没有设置 SA_RESTART 所以系统调用不会自动重启, 将打印 restart syscall manully ... . 没有设置 SA_ONESHOT 信号会被多次处理. 运行结果如下.

4. 现代信号发送

1) 函数原型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <signal.h>

// 给指定的进程发送信号
// pid 指定的进程
// sig 信号
// value 附带的数据信息
int sigqueue( pid_t pid,
int sig,
const union sigval value);

// 信号附带数据(四字节)
union sigval {
int sival_int;
void* sival_ptr; // 对 Linux 进程, 指针成员毫无意义.
};

    sigqueue() 的黄金搭档是 sigaction() . sa_flags 设置 SA_SIGINFO 标志位, 可使用三参数信号处理函数.

1
2
3
4
void handler(int sig, siginfo_t* info, void* ucontext)
{
//...
}

    ucontext 类型为 ucontext_t* 指针, 用于描述执行信号处理函数之前的进程上下文信息,例如,进程崩溃的时候. 实际的工程开发极少树情况会使用该参数. 现代信号处理函数的关键参数如下.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/types.h>
#include <signal.h>

typedef struct {
int si_signo; /* 信号编号0 */
int si_errno; /* 保存错误码 */
int si_code; /* 信号产生的来源, 用户, 定时器还是内核等 */
int si_pid; /* 发送信号的进程的 PID */
int si_uid; /* 发送信号的进程的用户 ID */
int si_status;
clock_t si_utime;
clock_t si_stime;
sigval_t si_value; /* 附加的值 */
//......
} siginfo_t;

2) 编程实验

信号发送函数.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <signal.h>

int main(int argc, char* argv[])
{
int pid = atoi(argv[1]);
int sig = atoi(argv[2]);
union sigval sv = {123456}; // 发送 123456 附加数据

printf("current process(%d)\n", getpid()); // 打印出 pid
printf("send sig(%d) to process(%d)\n", sig, pid);

sigqueue(pid, sig, sv);

raise(SIGINT); // 给自己发送结束信号

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
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <errno.h>

void signal_handler(int sig, siginfo_t* info, void* ucontext)
{
printf("handler : sig = %d\n", sig);
printf("info->si_signo = %d\n", info->si_signo); // 信号编号
printf("info->si_code = %d\n", info->si_code); // 信号来源
printf("info->si_pid = %d\n", info->si_pid); // 发送信号的 pid
printf("info->si_value = %d\n", info->si_value.sival_int); // 四字节数据
}

int main(int argc, char* argv[])
{
struct sigaction act = {0};

act.sa_sigaction = signal_handler;
act.sa_flags = SA_RESTART | SA_SIGINFO;

sigaddset(&act.sa_mask, 40);
sigaddset(&act.sa_mask, SIGINT);

sigaction(40, &act, NULL);
sigaction(SIGINT, &act, NULL);

return 0;
}

运行结果如下: