Linux系统编程之进程篇

几个入门问题

  1. 什么是程序,进程又是什么
  • 简单地来说,程序是静态文件的概念,比如gcc demo.c -o demo,这条命令将在当前目录下生成demo文件,可以被执行,叫做程序。
  • 进程就是程序的一次运行活动,通俗点讲就是程序运行起来了,系统中就多了一个进程
  1. 怎么查看系统中的进程
  • 使用ps命令可以查看当前进程:ps -aux将打印出当前所有运行中进程,配以grep表达式能更快找到目标进程
  • top命令,能将各进程的详情,包括进程id、所属用户、优先级、占用资源等,按照pid大小从小到大的顺序排列出来
  1. 什么是进程标识符
    进程描述符是进程的唯一标识符,用pid表示,是一个非负整数。在Linux下有两个特殊进程:
  • pid=0:称为交换进程,用于进程调度
  • pid=1:叫做init进程,也叫开机启动程序,作用是系统的初始化,系统启动时初始化系统环境、图形化界面等

通过getpid()函数可获取当前进程pid,通过getppid()函数可获取父进程pid

  1. 什么叫父进程,什么叫子进程
    如果使用进程A创建了进程B,那么A称为B的父进程,B称为A的子进程

  2. C程序的存储空间是如何分配的
    如下图

在上图中,左边的存储空间根据内容可分为几种类型:

  • 代码段,大部分的代码片段存储于此
  • 数据段,经过初始化的全局变量和main函数中经过初始化的变量
  • bss段,函数外未被初始化的变量
  • 堆,用于进程运行时malloc动态申请的空间
  • 栈,表示函数调用关系

进程创建——fork的使用

fork的翻译是“分叉”,顾名思义就是创建一个子进程,将当前流程分为两条链路,后续代码两条路都将执行
pid_t fork(void),调用成功会返回两次(返回值保存在子进程变量中),其中:

  • 返回0表示子进程
  • 返回非负数表示父进程

调用失败,会返回-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
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t pid;
pid_t pid2;

pid_t ret=-1;

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

ret=fork();//从这里,进程开始分流

pid2 = getpid();
printf("after fork: pid = %d\n",pid2 );

if(pid == pid2)
{
printf("this is father print with retpid:%d\n",ret);
}
else if(pid != pid2){
printf("this is child print,child pid = %d and retpid=%d\n",getpid(),ret);
}
return 0;
}

进程创建后会发生什么

当使用fork()创建一个新进程,那么原来子进程的内存空间是怎样的?原来的变量会受影响吗?

在Linux早期设计中,采用的是全局拷贝策略,将原来的数据、变量、堆、栈、正文、IO流等全部拷贝到一个新空间,后文的执行过程中,子进程和父进程的变量互不影响。
后面随着系统升级,拷贝策略逐渐变为了“写时拷贝”(Copy-On-Write,COW),即fork的时候,父子进程都还是先共享原来的变量,只有当子进程对变量进行写操作,才会在子进程空间里面对该变量进行拷贝,并该值。

创建新进程的应用场景和fork用法总结

使用fork函数创建子进程,一般有两种目的:

  1. 一个父进程希望复制自己,使父、子进程同时执行不同的代码段。这在网络服务进程中是常见的——父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求到达。(最直接的例子就是QQ,服务器要处理多个用户的聊天请求,就必须要有进程与其进行数据交互,但同时还要保持处理其他用户的请求的能力,这个时候子进程负责与单个用户的交互,父进程负责控制子进程和其他事务)

  2. 一个进程要执行一个不同的程序。这对shell是常见的情况。在这种情况下,子进程从fork返回后立即调用exec

fork 总结

由fork创建的新进程被称为子进程(child process)。fork函数被调用一次,但返回两次。两次返回的唯一区别是子进程的返回值是0,而父进程的返回值则是新子进程的进程ID。将子进程ID返回给父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程ID。fork使子进程得到返回值0的理由是:一个进程只会有一个父进程,所以子进程总是可以调用getppid以获得其父进程的进程ID(进程ID0总是由内核交换进程使用,所以一个子进程的进程ID不可能为0)。

子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父、子进程并不共享这些存储空间部分。父、子进程共享正文段

由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全复制。作为替代,使用了写时复制(Copy-On-Write, COW)技术。这些区域由父、子进程共享,而且内核将它们的访问权限改变为只读的。如果父、子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储器系统中的一“页”。

vfork

和fork一样,vfork函数也可以创建进程,但与fork的区别主要有两点:

  1. vfork直接使用父进程的存储空间,不拷贝
  2. vfork保证子进程先运行,当子进程退出(推荐使用exit)后,父进程再继续执行
    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
    #include <stdio.h>
    #include <stdio.h>
    #include <sys/types.h>
    #include <unistd.h>
    #include <stdlib.h>


    int main()
    {
    pid_t pid;
    int cnt = 0;
    pid = vfork();

    if(pid > 0)
    {
    while(1){
    printf("cnt=%d\n",cnt);
    printf("this is father print, pid = %d\n",getpid());
    sleep(1);
    }
    }

    else if(pid == 0){
    while(1){
    printf("this is chilid print, pid = %d\n",getpid());
    sleep(1);
    cnt++;
    if(cnt == 3){
    exit(0);//试一试使用break退出循环会发生什么?
    }
    }
    }
    return 0;
    }

上面这段代码,会在连续输出3次子进程pid后开始打印父进程pid,和cnt=3

进程退出

进程退出的两种方式:

  • 正常退出,
    • main函数调用return
    • 进程调用C库标准函数exit()
    • 进程调用_exit()或_Exit(),属于系统调用
    • 补充:进程正常退出是由进程的线程调用pthread_exit实现的
  • 异常退出
    • 进程调用abort
    • 当进程收到某些信号,比如Ctrl+C、上一小节vfork例子中的break
    • 最后一个线程对取消(cancellation)请求做出相应

不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等。

对上述任意一种终止情形、我们都希望终止进程能够通知其父进程它是如何终止的。对于三个终止函数(exit、_exit和_Exit),实现这一点的方法是,将其退出状态(exit status)作为参数传送给函数。在异常终止情况下,内核(不是进程本身)产生一个指示其异常终止原因的终止状态(termination status)。在任意一种情况下,该终止进程的父进程都能用wait或waitpid函数取得其终止状态。

上面的三个函数都可以使进程退出,且exit()函数背后也是通过调用_exit()实现的,并且进程退出之前会对运行产生的变量、内存等进行清除(直接调用后面两个方法不会)

等待子进程退出

为什么要等待子进程退出?

创建子进程的目的是为了让子进程“干活”,那就必须判断子进程干活了没有,干完了没有,如果没有干完,是什么原因导致的。如果子进程直接退出不返回状态码,父进程就无法得知子进程执行的状态。

如何等待子进程退出并获取状态码?

两种方式——wait和waitpid

对于等待子进程的退出状态,有四个预定义的宏:

如果想要确切知道子进程返回的状态码,使用WEXITSTATUS(status)即可。

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
#include <stdio.h>
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid;
int cnt = 0;
int status = 10;
pid = fork();

if(pid > 0)
{
wait(&status);
printf("child quit, child status = %d\n",WEXITSTATUS(status));
while(1){
printf("cnt=%d\n",cnt);
printf("this is father print, pid = %d\n",getpid());
sleep(1);
}
}

else if(pid == 0){
while(1){
printf("this is chilid print, pid = %d\n",getpid());
sleep(1);
cnt++;
if(cnt == 5){
exit(3);
}
}
}

return 0;
}

status参数

status参数是一个整数型指针,如果其为空,则不关心退出状态,只有值非空才会将退出状态存放到其指向地址中。

wait与waitpid的区别

两个函数的区别在于wait是阻塞式的,waitpid具有一个选项,可以使调用者不被阻塞。(如果进程没有任何等待父进程收集状态的子进程,会立即出错返回)

waitpid的pid参数作用解释:

pid取值 说明
等于-1 等待任一子进程,就这一方面而言,waitpid与wait等效
大于0 等待期进程ID与pid相等的子进程
等于0 等待其组ID等于调用进程组ID的任一子进程
小于-1 等待其组ID等于pid和绝对值得任一子进程

关于waitpiddeoptions常量:

常量 说明
WCONTINUED 若实现支持作业控制,则由pid指定的任一子进程在暂停后已经继续,但其状态尚未报告,则返回其状态
WNOHANG 若由pid指定的子进程并不是立即可用的,则waitpid不择色,此时期返回值为0
WUNTRACED 若实现支持作业控制,而由pid指定的任意紫禁城已经处于暂停状态,并且其状态自暂停以来还未报告过,则返回其状态,WIFSTOPPED宏确定其返回值是否对应于一个暂停子进程

僵尸进程与孤儿进程

如果子进程退出,但状态码不被父进程收集,则其状态变为僵尸进程

如果在子进程退出之前,父进程结束了自己的“生命”,则此时的子进程叫做孤儿进程。

Linux系统为避免存在过多的孤儿进程,会由init收留孤儿进程,成为故而进程的父进程。

编译运行下面的例子,你会发现子进程输出三次父pid后,就开始打印父pid=1,这表明在父进程return 0以后,init进程收留了它。

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 <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
pid_t pid;
int cnt = 0;
int status = 10;
pid = fork();

if(pid > 0)
{
printf("this is father print, pid = %d\n",getpid());
}

else if(pid == 0){
while(1){
printf("this is chilid print, pid = %d,my father pid=%d\n",getpid(),getppid());
sleep(1);
cnt++;
if(cnt == 5){
exit(3);
}
}
}

return 0;
}

exec族函数

exec族函数主要的功能是在调用进程内不执行一个可执行文件。可执行文件既可以是二进制文件,也可以是Linux下任何可执行的脚本文件

与使用fork创建一个子进程的方式不同,调用exec族函数时,当前进程完全被替代为一个新程序,但并不创建进程,所以进程id不变

在fork函数小节,介绍为什么使用fork函数时,提到了子进程从fork返回后立即调用exec函数,这种做法适合让子进程执行不同的程序的情景。

函数原型

exec族函数有如下原型:

1
2
3
4
5
6
7
8
//包含头文件:
#include <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,..., char * const envp[]);//比较深入,暂不要求掌握
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);//比较深入,暂不要求掌握

返回值

exec函数族的函数执行成功后不会返回,调用失败时,会设置errno并返回-1,然后从原程序的调用点接着往下执行。

参数说明

  1. path:可执行文件的路径名,支持相对位置
  2. arg: 可执行程序所带的参数,第一个参数为可执行文件名字,没有带路径且arg必须以NULL结束
  3. file:如果参数file中包含/,则就将其视为路径名,否则就按 PATH环境变量,在$PATH中搜寻可执行文件。

exec族函数记忆的一些小技巧:

  • l:list,,使用参数列表
  • p:path,使用文件名,并从PATH环境进行寻找可执行文件
  • v:vector,先构造一个指向各参数的指针数组,然后将该数组的地址作为这些函数的参数。
  • e:environment,多了envp[]数组,使用新的环境变量代替调用进程的环境变量

《linux进程—exec族函数》一文(作者云英,来自CSDN)中,提供了这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("before execl\n");
if(execl("./bin/echoarg","echoarg","abc",NULL) == -1)
{
printf("execl failed!\n");
}
printf("after execl\n");
return 0;
}

如果当前目录下没有bin/echoarg这个可执行文件,直接编译运行的话,控制台会输出:

1
2
3
before execl
execl failed!
after execl

前面提到过,exec函数如果调用执行成功是不会返回的,只有执行失败才会返回调用位置继续向下执行。如果想要知道执行失败的原因,可以使用perror()查看:

1
2
3
4
5
if(execl("./echoarg","echoarg","abc",NULL) == -1)
{
printf("execl failed!\n");
perror("why");
}

在下面的例子中,execl会使用ls命令和-l选项调用/bin/ls目录下的ls可执行文件,最终打印出当前目录下的文件信息,相当于在该目录下直接调用ls -l命令

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

int main(void)
{
printf("before execl\n");
if(execl("/bin/ls","ls","-l",NULL) == -1)
{
printf("execl failed!\n");
perror("why");
}
printf("after execl\n");
return 0;
}

这为我们不会使用编程去解决一些问题,但是有现成的系统调用命令的情况提供了极大的方便。比如获取当前时间,在不会C中date函数用法的情况下还需要先去学习一下怎么用,耗费很多时间,用这种方式直接调用系统api即可。

但是这种方式还不够方便,因为在调用命令之前得先找到可执行文件的位置(可以通过whereis命令获取)。为了解决这个问题,我们可以使用execlp。

1
execlp("ps","ps",NULL,NULL)

可以看到,execlp函数前两个参数一样,调用该函数会在环境变量里的目录下去查找是否有可执行文件提供该命令

如果是我们自己编译产生的可执行文件呢?比如这样一个脚本:

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

int main(int argc,char *argv[])
{
int i = 0;
for(i = 0; i < argc; i++)
{
printf("argv[%d]: %s\n",i,argv[i]);
}
return 0;
}

能打印出调用文件时所带的参数,我们想要在主机的任何目录下都能使用它而不加目录,该怎么做?

很简单,把可执行文件所在目录加入到环境变量里即可:export PATH=$PATH;"当前绝对目录"
即若当前目录为”/home/user/xxx”,则export PATH=$PATH;/home/user/xxx分号作为分隔符不能少

execv 与 execvp

看例子学习即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
printf("before execlp****\n");
char *argv[] = {"ps","-l",NULL};
if(execvp("ps",argv) == -1)
{
perror("execvp failed: \n");
}
printf("after execlp*****\n");
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
printf("this pro get system date:\n");

char *argv[] = {"ps","-l",NULL};

if(execvp("ps",argv) == -1)
{
printf("execl failed!\n");
perror("why");
}
printf("after execl\n");
return 0;
}

exec 配合fork 使用

小案例:实现功能,当父进程检测到输入为1的时候,创建子进程把配置文件的字段值修改掉

如果只用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
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
pid_t pid;
int data = 10;
while (1)
{
printf("please input a data\n");
scanf("%d", &data);
if (data == 1)
{
int fdSrc;
pid = fork();
if (pid > 0)
{
wait(NULL);
}

if (pid == 0)
{
char *readBuf = NULL;
fdSrc = open("config.txt", O_RDWR);
int size = lseek(fdSrc, 0, SEEK_END);
lseek(fdSrc, 0, SEEK_SET);

readBuf = (char *)malloc(sizeof(char) * size + 8);

int n_read = read(fdSrc, readBuf, size);

char *p = strstr(readBuf, "LENG=");
if (p == NULL)
{
printf("not found\n");
exit(-1);
}

p = p + strlen("LENG=");
*p = '5';

lseek(fdSrc, 0, SEEK_SET);
int n_write = write(fdSrc, readBuf, strlen(readBuf));
close(fdSrc);
exit(0);
}
}
else
{
printf("wait ,do nothing\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
37
38
39
40
41
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

int main(int argc, char **argv)
{
int fdSrc;
char *readBuf = NULL;

if (argc != 2)
{
printf("pararm error\n");
exit(-1);
}
fdSrc = open(argv[1], O_RDWR);
int size = lseek(fdSrc, 0, SEEK_END);
lseek(fdSrc, 0, SEEK_SET);

readBuf = (char *)malloc(sizeof(char) * size + 8);

int n_read = read(fdSrc, readBuf, size);

char *p = strstr(readBuf, "LENG=");
if (p == NULL)
{
printf("not found\n");
exit(-1);
}

p = p + strlen("LENG=");
*p = '5';
lseek(fdSrc, 0, SEEK_SET);
int n_write = write(fdSrc, readBuf, strlen(readBuf));
close(fdSrc);

return 0;
}

将上述代码另存为文件编译运行生成可执行文件changeData,移动到所需目录下,则最终只需要这样写:

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

int main()
{
pid_t pid;
int data = 10;
while (1)
{
printf("please input a data\n");
scanf("%d", &data);
if (data == 1)
{
int fdSrc;
pid = fork();
if (pid > 0)
{
wait(NULL);
}
if (pid == 0)
{
execl("./changData", "changData", "config.txt", NULL);
}
}
else
{
printf("wait ,do nothing\n");
}
}
return 0;
}

看起来简洁多了。

system函数

与exec相比,system函数作用相同,甚至比exec函数更简便

上面是system函数的帮助手册,可以看到,system函数的原理是tongguofork创建一个子进程,在子进程中使用execl函数调用shell命令

这不就是上小节fork结合exec命令吗……

关于返回值

如果函数正常退出,则返回进程id,如果sh命令不能执行,返回127,其余失败情况127。

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

int main()
{
pid_t pid;
int data = 10;

while (1)
{
printf("please input a data\n");
scanf("%d", &data);
if (data == 1)
{
int fdSrc;
pid = fork();
if (pid > 0)
{
wait(NULL);
}
if (pid == 0)
{
system("./changData config.txt");
}
}
else
{
printf("wait ,do nothing\n");
}
}
return 0;
}

简单粗暴

需要注意的是,system函数与直接调用exec相比,区别在于执行完任务后还会回到调用位置继续执行。
参考system函数

popen函数

popen,p+open。p代表管道,所以该命令的作用就是打开一个管道,将进程运行的输出结果定向到管道中,具体的内容可通过fread读取。

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char ret[1024] = {0};
FILE *fp;
fp = popen("ps","r");
int nread = fread(ret,1,1024,fp);
printf("read ret %d byte, ret=%s\n",nread,ret);
return 0;
}

参考linux下popen的使用心得

进程小结

一个遗留问题:fork创建新进程的时候,初始化的局部变量是放在数据段还是在栈里面


Linux系统编程之进程篇
https://dockingyuan.top/2022/09/18/LinuxProgramming/Process/
作者
Yuan Yuan
发布于
2022年9月18日
许可协议