Linux系统编程学习笔记--第五章

 第五章 进程控制

该节对应第八章——进程控制。

5.1 进程标识

每个进程都有一个非负整型表示的唯一进程ID。因为进程ID标识符总是唯一的,常将其用作其他标识符的一部分以保证其唯一性。例如,应用程序有时就把进程 ID 作为名字的一部分来创建一个唯一的文件名。

进程标识符的类型为pid_t,其本质上是一个无符号整型(unsigned int)的类型别名。

进程ID是可复用的。当一个进程终止后,其进程ID就成为复用的候选者。大多数 UNIX 系统实现延迟复用算法,使得赋予新建进程的 ID不同于最近终止进程所使用的ID。这防止了将新进程误认为是使用同一ID的某个已终止的先前进程。


系统中有一些专用进程,但具体细节随实现而不同。

ID为0的进程通常是调度进程,常常被称为交换进程(swapper)。该进程是内核的一部分,它并不执行任何硬盘上的程序,因此也被称为系统进程。
进程ID1通常是 init 进程,在自举过程结束时由内核调用。该进程的程序文件在UNIX的早期版本中是/etc/init,在较新版本中是/sbin/init。此进程负责在自举内核后启动一个UNIX系统。init 进程决不会终止。它是一个普通的用户进程(与交换进程不同,它不是内核中的系统进程),但是它以超级用户特权运行。

常用系统调用

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

// 返回该函数进程的父进程标识符
pid_t getppid(void);

// 返回当前进程标识符
pid_t getpid(void);

补充:ps命令

Linux 中的 ps 命令是 Process Status 的缩写。ps 命令用来列出系统中当前正在运行的那些进程,就是执行 ps 命令的那个时刻的那些进程的快照。

参数:

参数含义
-e显示所有进程
-f 全格式
-l长格式
a显示终端上的所有进程,包括其他用户的进程
r只显示正在运行的进程
x显示没有控制终端的进程

常用组合:

ps aux # 查看全部进程,以用户为主的格式显示进程情况
ps ef # 显示出linux机器所有详细的进程信息

ps aux | grep bash

5.2 进程产生

5.2.1 fork

init进程:pid为1,是所有进程的祖先进程,注意不是父进程。

一个现有的进程可以调用fork函数创建一个新进程:

#include <unistd.h>

pid_t fork(void);

由fork创建的新进程被称为子进程(child process)。

返回值:fork函数被调用一次,但返回两次。子进程的返回值是0,父进程的返回值则是新建子进程的进程PID。如果失败则返回-1,并设置errno。和setjmp类似,fork语句后常常跟上分支语句进行判断。

子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆、栈和缓冲区和文件描述符的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分,除了读时共享的部分。

fork后父子进程的区别

fork返回值不同

两个进程的pid不同

两个进程的ppid也不同,父进程的ppid是它的父进程pid,而子进程的ppid是创建它的进程的pid

父进程的未决信号和文件锁不继承

子进程的资源利用量归零

程序实例1——fork的使用

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

int main(void) {
    pid_t pid;
    printf("[%d]:Begin!\n", getpid());
    pid = fork();
    // ---------------------------
    // 父进程调用fork后,父子进程都从这里开始执行
    if(pid < 0) {
        perror("fork()");
        exit(1);
    }
    
    if(pid == 0) { // 如果返回值pid为0,则为子进程
        printf("[%d]:Child is working!\n", getpid());
    } else { // 如果返回值pid大于0,则为父进程
        printf("[%d]:Parent is working!\n", getpid());
    }

    printf("[%d]:End!\n", getpid());
    exit(0);
}

运行结果:(可能形式)

[root@zoukangcheng proc]# ./fork1 
[16023]:Begin!
[16023]:Parent is working!
[16023]:End!
[root@zoukangcheng proc]# [16024]:Child is working!
[16024]:End!

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

如果在main程序返回前添加一行:

getchar();

使得父子进程都暂停,再使用命令:

ps axf

可以看到两个进程与bash的关系如下:

程序实例2——fflush的重要性

对于上述程序的结果,注意到Begin只打印了一次:

[root@zoukangcheng proc]# ./fork1 
[16023]:Begin!
[16023]:Parent is working!
[16023]:End!
[16024]:Child is working!
[16024]:End!

如果将该打印信息重定向至某个文件:

./fork1 > /tmp/out

 再查看该文件的内容:

[root@HongyiZeng proc]# cat /tmp/out
[18060]:Begin!
[18060]:Parent is working!
[18060]:End!
[18060]:Begin!
[18061]:Child is working!
[18061]:End!

注意到Begin打印了两次。

原因:对于重定向至文件,采用的是全缓冲(除标准输出和标准错误输出),只有进程结束或者缓冲满的时候才刷新缓冲区(遇到换行符不刷新),将缓冲区的内容写入到文件。因此,父进程fork时,尚未刷新缓冲区,因此缓冲区的内容[18060]:Begin!(注意进程号已经固定了!)被复制到子进程的缓冲区中,当父子进程执行结束时,强制刷新,输出两次[18060]:Begin!。

为防止缓冲区内容被复制,父进程在fork之前需要强制刷新所有已经打开的流:

在进程中,应在fork前使用fflush(NULL),刷新所有流,输出设备默认为行缓冲模式,遇到\n会刷新缓冲区,而文件默认为全缓冲模式,遇到\n不会刷新缓冲区

int main(void) {
    pid_t pid;
    printf("[%d]:Begin!\n", getpid());
    
    // 强制刷新所有打开的流!!!
    fflush(NULL);
    // 再调用fork
    pid = fork();
    // ---------------------------
    // 父进程调用fork后,父子进程都从这里开始执行
    // ...
}

 此时,只打印了一句Begin:

[root@zoukangcheng proc]# ./fork1 > /tmp/out
[root@zoukangcheng proc]# cat /tmp/out
[19853]:Begin!
[19853]:Parent is working!
[19853]:End!
[19854]:Child is working!
[19854]:End!

程序实例3——找质数

需求:找出30000000~30000200的所有质数。

单进程版:

#include <stdio.h>
#include <stdlib.h>
#define LEFT 30000000
#define RIGHT 30000200

int main(void) {
    int i, j, mark;
    for(i = LEFT; i <= RIGHT; i++) {
        mark = 1;
        for(j = 2; j < i/2; j++) {
            if(i % j == 0) {
                mark = 0;
                break;
            }
        }
        if(mark)
            printf("%d is a primer.\n", i);
    }
    exit(0);
}

 打印结果:

[root@zoukangcheng proc]# time ./primer0 
30000001 is a primer.
30000023 is a primer.
30000037 is a primer.
30000041 is a primer.
30000049 is a primer.
30000059 is a primer.
30000071 is a primer.
30000079 is a primer.
30000083 is a primer.
30000109 is a primer.
30000133 is a primer.
30000137 is a primer.
30000149 is a primer.
30000163 is a primer.
30000167 is a primer.
30000169 is a primer.
30000193 is a primer.
30000199 is a primer.

real    0m0.967s
user    0m0.950s
sys     0m0.001s

多进程协同:

 一个错误的程序:

#include <stdio.h>
#include <stdlib.h>
#define LEFT 30000000
#define RIGHT 30000200

int main(void) {
    int i, j, mark;
    pid_t pid;
    for(i = LEFT; i <= RIGHT; i++) {
        pid = fork();
        if(pid < 0) {
            perror("fork()");
            exit(1);
        }
        if(pid == 0) { // child
            mark = 1;
            for(j = 2; j < i/2; j++) {
                if(i % j == 0) {
                    mark = 0;
                    break;
                }
            }
            if(mark)
                printf("%d is a primer.\n", i);
        }
    }
    exit(0);
}

分析:子进程执行完pid==0的分支后,又会执行for循环的部分,此时会再次fork,导致进程数量指数式的增长,超出可用内存。

更正:在执行完pid==0的分支后面(完成了对某个数i的判断的任务),需要正常退出exit(0):

#include <stdio.h>
#include <stdlib.h>
#define LEFT 30000000
#define RIGHT 30000200

int main(void) {
    int i, j, mark;
    pid_t pid;
    for(i = LEFT; i <= RIGHT; i++) {
        pid = fork();
        if(pid < 0) {
            perror("fork()");
            exit(1);
        }
        if(pid == 0) { // child
            mark = 1;
            for(j = 2; j < i/2; j++) {
                if(i % j == 0) {
                    mark = 0;
                    break;
                }
            }

            if(mark)
                printf("%d is a primer.\n", i);
            // 子进程退出
            exit(0);
        }
    }
    exit(0);
}

执行结果:

[root@HongyiZeng proc]# time ./primer1
30000037 is a primer.
30000071 is a primer.
30000059 is a primer.
30000079 is a primer.
30000083 is a primer.
30000049 is a primer.
30000023 is a primer.
30000137 is a primer.
30000149 is a primer.
30000041 is a primer.
30000167 is a primer.
30000193 is a primer.
30000109 is a primer.
30000001 is a primer.
30000199 is a primer.
30000169 is a primer.
30000163 is a primer.
30000133 is a primer.

real    0m0.048s
user    0m0.001s
sys     0m0.008s

程序实例4——孤儿进程和僵尸进程

 修改1:在子进程在退出前,先睡眠1000s,这样父进程会先执行完毕而退出。

int main(void) {
    int i, j, mark;
    pid_t pid;
    for(i = LEFT; i <= RIGHT; i++) {
        pid = fork();
        if(pid < 0) {
            perror("fork()");
            exit(1);
        }
        if(pid == 0) {
            mark = 1;
            for(j = 2; j < i/2; j++) {
                if(i % j == 0) {
                    mark = 0;
                    break;
                }
            }
            if(mark)
                printf("%d is a primer.\n", i);
            // 子进程睡眠1000s
            sleep(1000);
            exit(0);
        }
    }
    exit(0);
}

再使用命令ps axf查看:

此时201个子进程的状态为S(可中断的睡眠状态),且父进程为init进程(每个进程以顶格形式出现)。这里的子进程在init进程接管之前就是孤儿进程。

孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么这些子进程将成为孤儿进程。孤儿进程将被 init 进程所收养,并由 init 进程对它们完成状态收集工作,孤儿进程并不会有什么危害。


修改2:在父进程退出之前,先休眠1000s,再查看进程状态。

int main(void) {
    int i, j, mark;
    pid_t pid;
    for(i = LEFT; i <= RIGHT; i++) {
        pid = fork();
        if(pid < 0) {
            perror("fork()");
            exit(1);
        }
        if(pid == 0) {
            mark = 1;
            for(j = 2; j < i/2; j++) {
                if(i % j == 0) {
                    mark = 0;
                    break;
                }
            }
            if(mark)
                printf("%d is a primer.\n", i);
            exit(0);
        }
    }
    // 父进程睡眠1000s再退出
    sleep(1000);
    exit(0);
}

执行结果:

可以看到子进程状态为Z,即为僵尸状态。

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

僵尸进程虽然不占有任何内存空间,但如果父进程不调用 wait() / waitpid() 的话,那么保留的信息就不会释放,其进程号就会一直被占用,而系统所能使用的进程号是有限的,如果大量的产生僵死进程,将因为没有可用的进程号而导致系统不能产生新的进程,此即为僵尸进程的危害。

避免产生僵尸进程的方式

僵尸进程和危害:当一个进程已经结束,但是系统没有把它的进程的数据结构完全释放,此时用 ps 察看它的状态是defunt。 僵尸进程占据进程表的空间,而且不能被kill掉因为它已经死了,所以在开发多进程尤其是守护进程时注意要避免产生僵尸进程。

产生原因:子进程先于父进程退出,且父进程没有给子进程收尸。

和孤儿进程的区别:就是爸爸(父进程)和儿子(子进程)谁先死的问题
如果当儿子还在世的时候,爸爸去世了,那么儿子就成孤儿了,这个时候儿子就会被init收养,换句话说,init进程充当了儿子的爸爸,所以等到儿子去世的时候,就由init进程来为其收尸。
如果当爸爸还活着的时候,儿子死了,这个时候如果爸爸不给儿子收尸,那么儿子就会变成僵尸进程。
SIGCHLD信号:当子进程退出时发送给父进程,默认动作是忽略。

避免产生僵尸进程的方式:

方式1:父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。该方式详见6.3节。

方式2:通过两次调用fork。父进程首先调用fork创建一个子进程然后waitpid等待子进程退出,子进程再fork一个孙进程后退出。这样子进程退出后会被父进程等待回收,而对于孙子进程其父进程已经退出所以孙进程成为一个孤儿进程,孤儿进程由init进程接管,孙进程结束后,init会等待回收。

int main(void) {
    pid_t pid;
    pid = fork();
    if(pid < 0) {
        perror("fork");
        exit(1);
    }
    if(pid == 0) { // 子进程
        pid_t newpid;
        newpid = fork();
        // 检错...
        if(newpid == 0) { // 孙子进程
            // 做自己的事情
        }
        exit(0); // 子进程退出
    } else { // 父进程
        waitpid(pid, NULL, 0); // 阻塞等待子进程退出
    }
    exit(0);
}

方式3:通过signal通知内核表明父进程对子进程的结束不关心,由内核回收。如果不想让父进程挂起,可以在父进程中加入一条语句:signal(SIGCHLD,SIG_IGN);表示父进程忽略SIGCHLD信号,该信号是子进程退出的时候向父进程发送的。该方式不会阻塞父进程。

int main(void) {	
   	int i;
  	pid_t pid;
	signal(SIGCHLD, SIG_IGN); // 显式的忽略SIGCHLD信号
	for(i = 0; i < 10; i++) 
	{
   		if ((pid = fork()) == 0)
 			_exit(0);
    }
   	sleep(10);
   	exit(0);
}

方式4:对子进程进行wait,释放它们的资源,但是父进程一般没工夫在那里守着,等着子进程的退出,所以,一般使用信号的方式来处理,在收到SIGCHLD信号的时候,在信号处理函数中调用wait操作来释放他们的资源。

void avoid_zombies_handler(int signo) {
	pid_t pid;
	int exit_status;
	int saved_errno = errno;
	while ((pid = waitpid(-1, &exit_status, WNOHANG)) > 0) {
		/* do nothing */
	}
	errno = saved_errno;
}
int main(void) {
	pid_t pid;
	struct sigaction child_act;
	memset(&child_act, 0, sizeof(struct sigaction));
	
	child_act.sa_handler = avoid_zombies_handler; // 信号注册函数
	child_act.sa_flags = SA_RESTART | SA_NOCLDSTOP;
	sigemptyset(&child_act.sa_mask);
	if(sigaction(SIGCHLD, &child_act, NULL) == -1) { // 注册失败
		perror("sigaction error");
		_exit(EXIT_FAILURE);
	}
	while(1) {
		if ((pid = fork()) == 0)  {/* child process */
			_exit(0);
		}else if (pid > 0); /* parent process */
	}
	_exit(EXIT_SUCCESS);
}

方式5:使用sigaction对SIGCHLD注册信号处理函数,并设置sa_flags标志位为SA_NOCLDWAIT,这样,当子进程终止时,子进程不会被设置为僵尸进程。

SA_NOCLDWAIT在man手册中的描述:

f signum is SIGCHLD, do not transform children into zombies when they terminate. See also waitpid(2).

 This flag is meaningful only when establishing a  handler  for  SIGCHLD,  or when setting that signal's disposition to SIG_DFL.
如果信号是SIGCHLD,不会在孩子终止时将他们变成僵尸。另请参见waitpid(2)。该标志仅在为SIGCHLD建立处理程序或将信号的处理设置为SIG_DFL时才有意义。

// 父进程
int main(void) {
    struct sigaction sa, osa;
    sigset_t set, oset;
	
    // 避免子进程成为僵尸进程
    sa.sa_handler = SIG_DFL; // 或者自己定义的处理程序
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_NOCLDWAIT;
    sigaction(SIGCHLD, &sa, &osa);
    
    // ...
}

父子进程之间的文件共享

fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。我们说“复制”是因为对每个文件描述符来说,就好像执行了dup函数。父进程和子进程每个相同的打开描述符共享一个文件表项。

考虑下述情况,一个进程具有3个不同的打开文件,它们是标准输入、标准输出和标准错误。在从fork返回时,我们有了如图8-2中所示的结构。

重要的一点是,父进程和子进程共享同一个文件偏移量。

考虑下述情况:一个进程 fork 了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父进程和子进程都向标准输出进行写操作。如果父进程的标准输出已重定向(很可能是由 shell 实现的),那么子进程写到该标准输出时,它将更新与父进程共享的该文件的偏移量。在这个例子中,当父进程等待子进程时,子进程写到标准输出:而在子进程终止后,父进程也写到标准输出上,并且知道其输出会追加在子进程所写数据之后。如果父进程和子进程不共享同一文件偏移量,要实现这种形式的交互就要困难得多,可能需要父进程显式地动作。

如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步(如使父进程等待子进程),那么它们的输出就会相互混合(假定所用的描述符是在fork之前打开的)。

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

父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已做了相应更新。
父进程和子进程各自执行不同的程序段。在这种情况下,在fork之后,父进程和子进程各自关闭它们不需使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程经常使用的。

5.2.2 vfork

考虑这样一个场景,父进程使用了一个占用内存很大的数据,此时它fork了一个子进程,而子进程仅仅打印一个字符串就退出了,此时这块很大的数据复制到子进程的内存空间中,造成了很大的内存浪费。

为了解决这个问题,在fork实现中,增加了读时共享,写时复制(Copy-On-Write,COW)的机制。写时复制可以避免拷贝大量根本就不会使用的数据(地址空间包含的数据多达数十兆)。因此可以看出写时复制极大提升了Linux系统下fork函数运行的性能。

写时复制指的是子进程的页表项指向与父进程相同的物理页,这也只需要拷贝父进程的页表项就可以了,不会复制整个内存地址空间,同时把这些页表项标记为只读。

读时共享:如果父子进行都不对页面进行操作或只读,那么便一直共享同一份物理页面。

写时复制:只要父子进程有一个尝试进行修改某一个页面(写时),那么就会发生缺页异常。那么内核便会为该页面创建一个新的物理页面,并将内容复制到新的物理页面中,让父子进程真正地各自拥有自己的物理内存页面,并将页表中相应地页表项标记为可写。

写时复制父子进程修改某一个页面前后变化如下图所示:

在fork还没实现copy on write之前。Unix设计者很关心fork之后立刻执行exec所造成的地址空间浪费,所以引入了vfork系统调用。而现在vfork已经不常用了。

vfork和fork的区别/联系:vfork函数和 fork函数一样都是在已有的进程中创建一个新的进程,但它们创建的子进程是有区别的。
父子进程的执行顺序
fork: 父子进程的执行次序不确定。
vfork:保证子进程先运行,在它调用 exec/exit之后,父进程才执行
是否拷贝父进程的地址空间
fork: 子进程写时拷贝父进程的地址空间,子进程是父进程的一个复制
vfork:子进程共享父进程的地址空间
调用vfork函数,是为了执行exec函数;如果子进程没有调用 exec/exit,程序会出错

代码示例

int main(int argc, char *argv[]){
	pid_t pid;
	pid = vfork();	// 创建进程
	if(pid < 0){
		perror("vfork");
	}
	if(0 == pid){  
		sleep(3); // 延时 3 秒
		printf("i am son\n");
		
		_exit(0); // 退出子进程,必须
	}
	else if(pid > 0){ // 父进程
		printf("i am father\n");
	}
}

执行结果:已经让子进程延时 3 s,结果还是子进程运行结束后,父进程才执行

static int a = 10;

int main(void){
	pid_t pid;
	int b = 20;
	pid = vfork();
	if(pid < 0){
		perror("vfork()");
        exit(1);
	}
	if(0 == pid){
		a = 100, b = 200;
		printf("son: a = %d, b = %d\n", a, b);
		_exit(0);  
	}
	else if(pid > 0){ 
		printf("father: a = %d, b = %d\n", a, b);	
	}
    exit(0);
}

执行结果:子进程先执行,修改完a,b的值后,由于父子进程共享内存空间,因此会影响父进程

son: a = 100, b = 200
father: a = 100, b = 200

 如果采用fork的话,会有写时复制,此时父子进程的变量无关:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

static int a = 10;
int main(void) {
    pid_t pid;
    int b = 20;
    fflush(NULL);
    pid = fork();
    if(pid < 0) {
        perror("fork()");
        exit(1);
    }
    if(pid == 0) {
        a = 100;
        b = 200;
        printf("son: a = %d, b = %d.\n", a, b);
        _exit(0);
    } else {
        printf("father: a = %d, b = %d.\n", a, b);
    }
    exit(0);
}

执行结果:

[root@zoukangcheng proc]# ./vfork 
father: a = 10, b = 20.
son: a = 100, b = 200.

5.3 wait和waitpid

wait系统调用:等待进程改变状态。

进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。

wait函数原型如下:

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);

status用来保存子进程退出时的一些状态。如果不在意子进程的退出状态,可以设定status为NULL。如果参数status的值不是NULL,wait就会把子进程退出时的状态取出,并存入其中。可以使用下列的宏函数来处理status:
WIFEXITED(status):用来指出子进程是否为正常退出,如果是,则会返回一个非零值。
WEXITSTATUS(status):当WIFEXITED返回非零值时,可以用这个宏来提取子进程的返回值。
如果执行成功,wait会返回子进程的PID;如果没有子进程,则wait返回-1。

代码示例1

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

int main (void) {
    pid_t pc, pr;
    pc = fork();

    if(pc < 0) {
        printf ("error ocurred!\n");
    }else if (pc == 0) { /* 如果是子进程 */
        printf("This is child process with pid of %d\n", getpid());
        sleep (10); /* 睡眠10秒钟 */
    }else { /* 如果是父进程 */
        pr = wait(NULL); /* 在这里阻塞,收尸 */
        printf ("I catched a child process with pid of %d\n", pr);
    }
    exit(0);
}

 打印结果:

This is child process with pid of 298
等待10秒
I catched a child process with pid of 298

程序实例2

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

int main(void) {
    int status;
    pid_t pc, pr;
    pc = fork();

    if(pc < 0) {
        printf( "error ocurred!\n" );
    }
    if(pc == 0) { /* 子进程 */
        printf("This is child process with pid of %d\n", getpid());
        exit(3); /* 子进程返回3 */
    if(pc > 0) { /* 父进程 */
        pr = wait(&status);
        if(WIFEXITED(status)) { /* 如果WIFEXITED返回非零值 */
            printf("the child process %d exit normally\n", pr);
            printf("the return code is %d\n", WEXITSTATUS(status ));
        } else { /* 如果WIFEXITED返回零 */
            printf("the child process %d exit abnormally\n", pr);
        }
    }
    exit(0);
}

打印结果:

This is child process with pid of 308
the child process 308 exit normally
the return code is 3


 waitpid函数原型如下:

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);

// 下面两者等价:
wait(&status);
waitpid(-1, &status, 0);

从本质上讲,waitpid和wait的作用是完全相同的,但waitpid多出了两个可以由用户控制的参数pid和options:

pid:当pid取不同的值时,在这里有不同的意义:

取值意义
> 0只等待进程ID等于pid的子进程
== -1等待任何一个子进程退出,此时waitpid和wait的作用一模一样
== 0等待同一个进程组process group id中的任何子进程
<-1等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值

options:是一个位图,可以通过按位或来设置,如果不设置则置为0即可。最常用的选项是WNOHANG,作用是即使没有子进程退出,它也会立即返回,此时waitpid不同于wait,它变成了非阻塞的函数。
waitpid的返回值有如下几种情况:
当正常返回时,waitpid返回子进程的PID。
如果设置了WNOHANG,而waitpid没有发现已经退出的子进程,则返回0。
如果waitpid出错,则返回-1。例如参数pid指示的子进程不存在,或此进程存在,但不是调用进程的子进程。

代码示例1

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#define LEFT 30000000
#define RIGHT 30000200

int main(void) {
    int i, j, mark;
    pid_t pid;
    pid_t pid_child;
    for(i = LEFT; i <= RIGHT; i++) {
        pid = fork();
        if(pid < 0) {
            perror("fork()");
            exit(1);
        }
        if(pid == 0) {
            mark = 1;
            for(j = 2; j < i/2; j++) {
                if(i % j == 0) {
                    mark = 0;
                    break;
                }
            }

            if(mark)
                printf("[%d]:%d is a primer.\n", getpid(), i);
            exit(0);
        }
    }
    // 循环201次,给201个子进程收尸
    for(i = LEFT; i <= RIGHT; i++) {
        pid_child = wait(NULL);
        printf("Child process with pid: %d.\n", pid_child);
    }
    exit(0);
}

执行结果:省略了没有打印质数的输出

[32444]:30000023 is a primer.
Child process with pid: 32444.
[32462]:30000041 is a primer.
Child process with pid: 32462.
[32458]:30000037 is a primer.
Child process with pid: 32458.
[32422]:30000001 is a primer.
Child process with pid: 32422.
[32470]:30000049 is a primer.
Child process with pid: 32470.
[32480]:30000059 is a primer.
Child process with pid: 32480.
[32530]:30000109 is a primer.
Child process with pid: 32530.
[32504]:30000083 is a primer.
Child process with pid: 32504.
[32614]:30000193 is a primer.
Child process with pid: 32614.
[32590]:30000169 is a primer.
Child process with pid: 32590.
[32492]:30000071 is a primer.
Child process with pid: 32492.
[32620]:30000199 is a primer.
Child process with pid: 32620.
[32500]:30000079 is a primer.
Child process with pid: 32500.
[32588]:30000167 is a primer.
Child process with pid: 32588.
[32554]:30000133 is a primer.
Child process with pid: 32554.
[32558]:30000137 is a primer.
Child process with pid: 32558.
[32584]:30000163 is a primer.
Child process with pid: 32584.
[32570]:30000149 is a primer.
Child process with pid: 32570.

5.4 exec函数族

5.4.1 简介

fork函数是用于创建一个子进程,该子进程几乎是父进程的副本,而有时我们希望子进程去执行另外的程序,exec函数族就提供了一个在进程中启动另一个程序执行的方法。它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段,在执行完之后,原调用进程的内容除了进程号外,其他全部被新程序的内容替换了。这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行脚本文件。

当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。

为什么需要exec函数

fork子进程是为了执行新程序(fork创建了子进程后,子进程和父进程同时被OS调度执行,因此子进程可以单独的执行一个程序,这个程序宏观上将会和父进程程序同时进行);
可以直接在子进程的if中写入新程序的代码(参见6.2.1节的做法)。这样可以,但是不够灵活,因为我们只能把子进程程序的源代码贴过来执行(必须知道源代码,而且源代码太长了也不好控制),譬如说我们希望子进程来执行ls -la命令就不行了(没有源代码,只有编译好的可执行程序/usr/bin/ls);
使用exec族运行新的可执行程序(exec族函数可以直接把一个编译好的可执行程序直接加载运行);
我们有了exec族函数后,典型的父子进程程序是这样的:子进程需要运行的程序被单独编写、单独编译连接成一个可执行程序(叫hello),(项目是一个多进程项目)主程序为父进程,fork创建了子进程后在子进程中exec来执行hello,达到父子进程分别做不同程序同时(宏观上)运行的效果;

5.4.2 使用

有多种不同的exec函数可供使用,它们常常被统称为exec函数。

#include <unistd.h>

extern char **environ;

// 直达
int execl(const char *path, const char *arg, ...);
// 从$PATH里找
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[]);
// 从$PATH里找
int execvp(const char *file, char *const argv[]);
// 从$PATH里找
int execvpe(const char *file, char *const argv[], char *const envp[]);

以上函数成功执行时不返回,失败时返回-1并设值errno
后缀含义

l:以list形式传入参数
v:以vector形式传入参数
p:在$PATH中查找可执行程序
e:在envp[]中查找可执行程序
execl和execv:这两个函数是最基本的exec,都可以用来执行一个程序,区别是传参的格式不同:

execl是把参数列表(本质上是多个字符串,必须以NULL结尾)依次排列而成
execv是把参数列表事先放入一个字符串数组中(必须以NULL结尾),再把这个字符串数组传给execv函数,类似于char **argv
path:完整的文件目录路径
execlp和execvp:这两个函数在上面2个基础上加了p

file:文件名,系统就会自动从环境变量$PATH所指出的路径中进行查找该文件。如果包含/,则视为路径名path。
execle和execvpe:这两个函数较基本exec来说加了e

envp:自己指定的环境变量。在envp[]中指定当前进程所使用的环境变量替换掉该进程继承的环境变量$PATH。


代码示例——环境变量

myexec.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    
    pid_t pid;
    pid = fork();
    if(pid < 0) {
        perror("fork()");
        exit(1);
    } else if(pid == 0) { // 子进程
        // 参数
        char * const param[] = {"myHello", "-a", "-l", NULL};
        // 自己设置的环境变量
        char * const envp[] = {"AA=aaa", "BB=bbb", NULL};
        // 执行同目录下的hello
        execvpe("./hello", param, envp);
        perror("execvpe()");
        exit(1);
    } else { // 父进程
        wait(NULL);
    }
    exit(0);
}

hello.c

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char **argv, char **env) {

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

    int i;

    for(i = 0; argv[i] != NULL; i++) {
        printf("argv[%d]: %s\n", i, argv[i]);
    }

    for(i = 0; env[i] != NULL; i++) {
        printf("env[%d]: %s\n", i, env[i]);
    }

    exit(0);
}

编译链接为hello

执行结果:

[root@zoukangcheng proc]# ./myexec 
argc = 3
argv[0]: myHello
argv[1]: -a
argv[2]: -l
env[0]: AA=aaa
env[1]: BB=bbb

代码示例——程序名称

补充:argv第一个参数为程序名称,后面的参数为命令行参数。程序名称可以任意设置,一般来说,如果一个源码文件的名称为XXX.c,则编译生成的可执行程序为XXX,此时运行,程序名称(argv[0])就是XXX

使用gcc默认编译链接得到的可执行文件名称为a.out,此时程序名称(argv[0])就是a.out。


使用exec族函数实现date +%s命令打印时间戳的功能。

[root@HongyiZeng proc]# date +%s
1670902531

这里的参数依次是程序名,+%s,NULL,注意第一个参数代表的是程序的名称,可以任意设置,类似于argv[0],之后的参数才是重要的命令行参数。

代码实现:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    puts("Begin!");
	// 注意这里的程序名称给了一个myDate
    execl("/bin/date", "myDate", "+%s", NULL);
    perror("execl()");
    exit(1);

    puts("End!");
    exit(0);
}

或者使用execv:

int main(void) {
    puts("Begin!");
    
	char * const param[] = {"myDate", "+%s", NULL};
    execv("/bin/date", param);
    perror("execl()");
    exit(1);

    puts("End!");
    exit(0);
}

执行结果:

[root@zoukangcheng proc]# ./ex 
Begin!
1670902607

为什么不打印End!:执行exec后,原进程映像被替换成新的进程映像(即/bin/date程序),从main函数开始执行/bin/date的代码了。

我不再是我,我已成新的我。


让子进程睡眠1000s:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    pid_t pid;
    printf("[%d]Begin!\n", getpid());
    fflush(NULL);
    pid = fork();
    if(pid < 0) {
        perror("fork()");
        exit(1);
    }
    if(pid == 0) {
        // 注意这里的程序名称给了一个httpd
        execl("/bin/sleep", "httpd", "1000", NULL);
        perror("execl()");
        exit(1);
    }

    wait(NULL);
    printf("[%d]End!\n", getpid());
    exit(0);
}

执行后查看:

ps axf

这里子进程运行时执行的是sleep程序,但是程序名称却被设置成了httpd,这实际上是一种低级的木马程序隐藏的办法。

代码示例——刷新缓冲区的重要性

在讲fork的时候提到过,在fork之前,最好将强制刷新所有已经打开的流,这里的exec也不例外,例如使用上面的程序,将结果重定向到/tmp/out中:

[root@zoukangcheng proc]# ./ex > /tmp/out
[root@zoukangcheng proc]# cat /tmp/out
1670902720

发现Begin!不见了,原因就在于重定向是全缓冲,当执行完puts("Begin!")后,该进程的缓冲区内容为Begin!\n,并不刷新到文件中,此时执行exec后,进程映像被替换成新的进程映像(即/bin/date程序),除了原进程的进程号外,其他全部(包括缓冲区)被新程序的内容替换了,之后新程序的缓冲区内容为时间戳,程序结束后,强制刷新到文件。

因此需要在执行exec之前强制刷新所有打开的流:

int main(void) {
    puts("Begin!");
	
    fflush(NULL);
    
    execl("/bin/date", "date", "+%s", NULL);
    perror("execl()");
    exit(1);

    puts("End!");
    exit(0);
}

再次执行:

[root@HongyiZeng proc]# ./ex > /tmp/out
[root@HongyiZeng proc]# cat /tmp/out
Begin!
1670903209

代码示例——fork,exec和wait结合使用

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    pid_t pid;
    printf("[%d]Begin!\n", getpid());
    fflush(NULL);
    pid = fork();
    if(pid < 0) {
        perror("fork()");
        exit(1);
    }
    if(pid == 0) { // 子进程
        execl("/bin/date", "date", "+%s", NULL);
        perror("execl()");
        exit(1);
    }
    // 等待子进程结束,收尸
    wait(NULL);
    printf("[%d]End!\n", getpid());
    exit(0);
}

执行结果:

[17301]Begin! // 父进程打印
1670903917 // 子进程打印,子进程和父进程完全是不同的程序了
[17301]End! // 父进程打印

至此,UNIX系统进程控制原语更加完善。用fork可以创建新进程,用exec可以初始执行新的程序。exit函数和wait函数处理终止和等待终止。这些是我们需要的基本的进程控制原语。

5.5 shell外部命令实现

内部命令和外部命令

内部命令指的是集成在Shell里面的命令,属于Shell的一部分。这些命令由shell程序识别并在shell程序内部完成运行,通常在linux系统加载运行时shell就被加载并驻留在系统内存中,比如cd命令等,这些命令在磁盘上看不见。
外部命令是linux系统中的实用程序部分,因为实用程序的功能通常都比较强大,所以其包含的程序量也会很大,在系统加载时并不随系统一起被加载到内存中,而是在需要时才将其调用内存。通常外部命令的实体并不包含在shell中,但是其命令执行过程是由shell程序控制的。shell程序管理外部命令执行的路径查找(PATH环境变量中)、加载存放,并控制命令的执行。这些命令的二进制可执行文件在磁盘上可见。

外部命令执行流程:

1 shell建立(fork)一个新的子进程,此进程即为Shell的一个副本
2 在子进程里,在PATH变量内所列出的目录中,寻找特定的命令。
/bin:/usr/bin:/usr/X11R6/bin:/usr/local/bin为PATH变量典型的默认值。 当命令名称包含有斜杠(/)符号时,将略过路径查找步骤。
3 在子进程里,以所找到的新程序取代(exec)子程序并执行。
4 父进程shell等待(wait)程序完成后(子进程exit),父进程Shell会接着从终端读取下一条命令或执行脚本里的下一条命令

相关命令:

type # 判断是外部命令还是内部命令
which # 查看命令所在的文件路径

示例:

[root@zoukangcheng ~]# type cd
cd is a shell builtin
[root@zoukangcheng ~]# type mkdir
mkdir is /usr/bin/mkdir
[root@zoukangcheng ~]# which ls
alias ls='ls --color=auto'
        /usr/bin/ls

之前在终端上执行primer1.c时,出现下列情况:

[root@zoukangcheng proc]# ./primer1
[root@zoukangcheng proc]# 30000037 is a primer.
30000001 is a primer.
30000041 is a primer.
30000023 is a primer.
30000079 is a primer.
30000133 is a primer.
30000137 is a primer.
30000049 is a primer.
30000109 is a primer.
30000083 is a primer.
30000071 is a primer.
30000059 is a primer.
30000193 is a primer.
30000169 is a primer.
30000167 is a primer.
30000199 is a primer.
30000163 is a primer.
30000149 is a primer.

发现终端先于子程序打印。

原因:在终端上执行primer1时,父进程(终端,即shell)fork了一个子进程,然后exec了primer1程序,并且wait到primer1退出,所以当primer1退出时,就立刻出现了终端,此时primer1fork的子进程还在运行打印结果,所以出现了终端先于子进程的结果出现。

重要!!!(外部命令执行流程):一般的,当shell执行某个程序时,首先fork一个子进程,然后该子进程exec那个执行程序,shell此时wait该程序退出exit。

shell伪代码示例

int main(void) {
    // 死循环,shell不断接收用户命令
    while(1) {
        // 终端提示符
        prompt();
        
        // 获取命令
        getline();
        
        // 解析命令
        parse();
        
        if(内部命令) {
            // ...
        } else { // 外部命令
            fork();
            if(pid < 0) {
                // 异常处理...
            }
            if(pid == 0) { // 子进程
                exec(); // 将子进程替换为待执行程序
                // 异常处理...
            }
            if(pid > 0) { // shell父进程
                wait(NULL); // 等待子进程结束
            }
        }
        
    }
    exit(0);
}

代码实现

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <glob.h>

// 分隔符:空 制表符 换行符
#define DELIMS " \t\n"

struct cmd_st {
    glob_t globres;
};

static void prompt(void) {
    printf("mysh-0.1$ ");
}

static void parse(char *line, struct cmd_st *res) {
    char *tok;
    int i = 0;
    while(1) {
        tok = strsep(&line, DELIMS);
        // 分割完毕
        if(tok == NULL)
            break;
        if(tok[0] == '\0')
            continue;
       	// 选项解释
        // NOCHECK:不对pattern进行解析,直接返回pattern(这里是tok),相当于存储了命令行参数tok在glob_t中
        // APPEND:以追加形式将tok存放在glob_t中,第一次时不追加,因为globres尚未初始化,需要系统来自己分配内存,因此乘上i(乘法优先于按位或)
        glob(tok, GLOB_NOCHECK|GLOB_APPEND*i, NULL, &res->globres);
        // 置为1,使得追加永远成立
        i = 1;
    }
}


int main(void) {
    // getline的参数要初始化
    char *linebuf = NULL;
    size_t linebuf_size = 0;
    struct cmd_st cmd;
    pid_t pid;
    while(1) {
        prompt();
        // getline函数参见2.10节
        if(getline(&linebuf, &linebuf_size, stdin) < 0) {
            break;
        }
        // 解析命令
        parse(linebuf, &cmd);
        if(0) { // 内部命令,暂不做实现

        } else { // 外部命令
            pid = fork();
            if(pid < 0) {
                perror("fork()");
                exit(1);
            }
            if(pid == 0) {
                execvp(cmd.globres.gl_pathv[0], cmd.globres.gl_pathv);
                perror("execvp()");
                exit(1);
            } else {
                wait(NULL);
            }
        }
    }
    exit(0);
}

程序分析:

strsep函数原型:

#include <string.h>

char * strsep(char **stringp, const char *delim);

strsep实现字符串的分割,把stringp里面出现的delim替换成'\0',后将 stringp 更新指向到'\0'符号的下一个字符地址,函数的返回值指向原来的 stringp 位置。直到分割完毕返回NULL。


代码执行流程分析:

例如:

[root@zoukangcheng proc]# ./mysh 
mysh-0.1$ ls -l
total 144
-rwxr-xr-x 1 root root 8600 Dec 13 11:46 ex
-rw-r--r-- 1 root root  213 Dec 13 11:46 ex.c
-rwxr-xr-x 1 root root 8552 Dec 13 12:52 exv
-rw-r--r-- 1 root root  232 Dec 13 12:52 exv.c
-rwxr-xr-x 1 root root 8752 Dec 13 11:58 few
-rw-r--r-- 1 root root  361 Dec 13 11:58 few.c
-rwxr-xr-x 1 root root 8656 Dec 12 10:19 fork1
-rw-r--r-- 1 root root  402 Dec 12 10:19 fork1.c
-rwxr-xr-x 1 root root 8912 Dec 14 12:06 mysh
-rw-r--r-- 1 root root  953 Dec 14 12:06 mysh.c
-rwxr-xr-x 1 root root 8448 Dec 12 10:28 primer0
-rw-r--r-- 1 root root  313 Dec 12 10:28 primer0.c
-rwxr-xr-x 1 root root 8552 Dec 14 11:05 primer1
-rw-r--r-- 1 root root  437 Dec 14 11:04 primer1.c
-rwxr-xr-x 1 root root 8656 Dec 13 10:02 primer2
-rw-r--r-- 1 root root  652 Dec 13 10:02 primer2.c
-rwxr-xr-x 1 root root 8672 Dec 12 11:56 vfork
-rw-r--r-- 1 root root  372 Dec 12 11:56 vfork.c

getline得到字符串ls -l

parse解析该字符串,将分割结果存在globres中,其中:

globres.gl_pathv[0] = "ls";
globres.gl_pathv[1] = "-l";
globres.gl_pathv[2] = NULL;

子进程execvp(cmd.globres.gl_pathv[0], cmd.globres.gl_pathv);

第一个参数为要执行的可执行程序的名字,为ls,从环境变量PATH中找到/usr/bin/路径下的ls程序
第二个参数为指针数组,为ls和-l,第一个为程序名,任意,第二个和后面的为命令的参数,重要,这里的参数为-l

5.6 用户权限和组权限

在UNIX系统中,特权(如能改变当前日期的表示法)以及访问控制(如能否读、写一个特定文件),是基于用户ID和组ID的。当程序需要增加特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID或组ID,使得新ID具有合适的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源的访问时,也需要更换用户ID或组ID,新ID不具有相应特权或访问这些资源的能力。

5.6.1 UID和GID

Linux采用一个32位的整数记录和区分不同的用户。这个区分不同用户的数字被称为User ID,简称UID。Linux系统中用户分为3类,即普通用户、根用户root、系统用户。

普通用户是指所有使用Linux系统的真实用户,通常UID>500;
根用户即root用户,UID为0。
系统用户是指系统运行必须有的用户,但并不是真实使用者。UID为1~499。对于系统用户,可能还不能理解是什么。比如,在Redhat或CentOS下运行网站服务时,需要使用系统用户Apache来运行httpd,而运行MySQL数据库服务时,需要使用系统用户mysql来运行mysqld进程。这就是系统用户。
可以使用id [用户名]命令查看uid,gid和组名:

[root@zoukangcheng proc]# id
uid=0(root) gid=0(root) groups=0(root)
[root@zoukangcheng proc]# id lighthouse
uid=1000(lighthouse) gid=1000(lighthouse) groups=1000(lighthouse),991(docker)

要确认自己所属的用户组,可以使用groups命令:

[root@zoukangcheng proc]# groups
root

系统用来记录用户名、密码的最重要两个文件是/etc/passwd和/etc/shadow,详见4.1.2节。

5.6.2 SUID和SGID

内核为每个进程维护的三个UID值(和三个GID值,是对应关系,略),这三个UID分别是:

RUID:(Real UID,实际用户ID),我们当前以哪个用户登录,我们运行程序产生进程的RUID就是这个用户的UID。
EUID:(Effective UID,有效用户ID),指当前进程实际以哪个UID来运行。一般情况下EUID等于RUID;但如果进程对应的可执行文件具有SUID权限(也就是rws的s),那么进程的EUID是该文件属主的UID,鉴权看的就是这个ID。
SUID:(Saved Set-user-ID,保存的设置用户ID),EUID的一个副本,与SUID权限有关。

特殊权限

文件和目录权限除了普通权限rwx外,还有三个特殊权限:

SUID:在属主的x位以s标识,全称SetUID
SGID:在属组的x位以s标识,全称SetGID
STIKCY:黏着位,详见4.2.4.③节

[root@zoukangcheng proc]# ll /usr/bin/passwd 
-rwsr-xr-x 1 root root 27856 Apr  1  2020 /usr/bin/passwd

上面第4位的s就是特殊权限SUID,属主为root,其uid为0。当普通用户执行该命令时,会以root的身份去执行该命令。

下面将由五个问题来说明什么是SUID:

# 1.普通用户可不可以修改密码?
可以,修改自己的密码

# 2./etc/shadow文件的作用?
存储用户密码的文件

# 3./etc/shadow文件的权限?
[root@localhost ~]# ll /etc/shadow
----------1 root root 16404 Apr  8 11:41 /etc/shadow

# 4.普通用户,是否可以修改/etc/shadow文件?
不可以,/etc/shadow文件,对于普通用户没有任何权限,所以不能读取,也不能写入内容。

普通用户的信息保存在 /etc/passwd文件中,与用户的密码在 /etc/shadow 文件中,也就是说,普通用户在更改自己密码时,修改了 /etc/shadow 文件中的加密密码,但是文件权限显示,普通用户对这两个文件都没有写权限。

# 5.那么普通用户,为什么可以修改密码?
1)因为使用了passwd这个命令
2)passwd命令在属主权限位上,原本是x权限,变成了s权限
3)s权限在属主权限位,又叫做SetUID权限,SUID
4)作用:普通用户在使用有SUID权限的文件或命令时,会以该文件的属主身份去执行该命令,换句话说,普通用户在执行passwd命令时,切换成了passwd属主即root的身份去执行passwd命令。

从进程控制的角度来说,当非root用户执行passwd这个可执行文件的时候,产生的进程的EUID,就是root用户的UID。换言之,这种情况下,产生的进程,实际以root用户的ID来运行二进制文件。

相关命令

chmod u+s 文件名/目录名 # 对文件给予用户s权限,则此用户暂时获得这个文件的属主权限
chmod g+s 文件名/目录名 # 对文件给予用户组s权限,则此用户暂时获得这个文件的属组权限

从进程控制的角度看命令的执行

UNIX系统产生的第一个进程是init进程,其三个uid为root的uid,即res为0 0 0,以init(0 0 0)表示;
init进程fork和exec产生getty(0 0 0)进程,此进程等待用户输入用户名;
用户回车输入了用户名后,getty进程存储用户名,exec产生login(0 0 0)进程,等待用户输入密码并验证口令(查找用户名和密码/etc/passwd);
如果验证成功,login进程则fork并exec产生shell(r e s)进程,即终端,此时的res就是登录用户的UID,即固定了用户产生的进程的身份;
如果验证失败,则返回继续验证;
当用户执行某个命令时,shell进程fork并exec该命令对应的程序,例如ls(r e s),并wait该程序,ls进程退出时,又返回到shell(r e s)终端(因为shell是一个死循环,参见6.5节);
可以看出,整个UNIX的世界就是由fork,exec,wait和exit的进程控制原语搭建起来的
整个过程的图示如下:

又如执行passwd命令时图如下,变化的只有EUID和SUID:

 

5.6.3 相关系统调用

下面的系统调用是特殊权限实现所需的函数。

获取:

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

// 返回当前进程的ruid
uid_t getuid(void);

// 返回当前进程的euid
uid_t geteuid(void);

gid_t getgid(void);
gid_t getegid(void);

 设置:

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

// 设置当前进程的euid
int setuid(uid_t uid);

// 设置当前进程的egid
int setgid(gid_t gid);

交换:

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

// 交换当前进程的ruid和euid
int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);

代码示例

实现任意用户用0号用户(即root)的身份查看/etc/shadow文件的功能:

./mysu 0 cat /etc/shadow

exec参数:

cat -> main的argv[2]:所需要执行的程序;
cat /etc/shadow -> main的argv[2]之后:程序名cat 命令行参数/etc/shadow

代码实现:(以普通用户lighthouse编译链接)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char **argv) {
    pid_t pid;
    if(argc < 3) {
        fprintf(stderr, "Usage...\n");
        exit(1);
    }

    pid = fork();
    if(pid < 0) {
        perror("fork()");
        exit(1);
    }

    if(pid == 0) {
        setuid(atoi(argv[1])); // 将字符串转成int
        execvp(argv[2], argv + 2);
        perror("execvp()");
        exit(1);
    }
    wait(NULL);
    exit(0);
}

查看该文件的属性:

[zoukangcheng@zoukangcheng proc]$ ll mysu
-rwxr-xr-x 1 zoukangcheng zoukangcheng 8800 Dec 16 12:32 mysu

直接运行,权限不够:

[zoukangcheng@zoukangcheng proc]$ ./mysu 0 cat /etc/shadow
cat: /etc/shadow: Permission denied

切换到root用户,将mysu属主更改为root,并给予该文件s权限:

[root@zoukangcheng proc]# chown root mysu
[root@zoukangcheng proc]# chmod u+s mysu
[root@zoukangcheng proc]# ll mysu
-rwsr-xr-x 1 root zoukangcheng 8800 Dec 16 12:32 mysu

然后切换到zoukangcheng,再执行即可:

5.7 解释器文件

解释器文件也叫脚本文件。脚本文件包括:shell脚本,python脚本等;

脚本文件的后缀可自定义,一般来说shell脚本的后缀名为.sh,python脚本的后缀名为.py。

解释器文件的执行过程:当在linux系统的shell命令行上执行一个可执行文件时,系统会fork一个子进程,在子进程中内核会首先将该文件当做是二进制机器文件来执行,但是内核发现该文件不是机器文件(看到第一行为#!)后就会返回一个错误信息,收到错误信息后进程会将该文件看做是一个解释器文件,然后扫描该文件的第一行,获取解释器程序(本质上就是可执行文件)的名字,然后执行exec该解释器,并将该解释器文件当做解释器的一个参数,然后开始由解释器程序从头扫描整个解释器文件,执行每条语句(如果指定解释器为shell,会跳过第一条语句,因为#是注释)。如果其中某条命令执行失败了也不会影响后续命令的执行。

解释器文件的格式:

#!pathname [optional-argument]

内容...

pathname:一般是绝对路径(它不会使用$PATH做路径搜索),对这个文件识别是由内核做为exec系统调用处理的。
optional-argument:相当于提供给exec的参数
内核exec执行的并不是解释器文件,而是第一行pathname指定的文件。一定要将解释器文件(本质是一个文本文件,以 #!开头)和解释器(由pathname指定)区分开。

代码示例1

以普通用户创建脚本test.sh:

#!/bin/bash
ls
whoami
cat /etc/shadow
ps

这个文件没有执行权限,需要添加:

[zoukangcheng@zoukangcheng proc]$ ll test.sh
-rw-r--r-- 1 zoukangcheng zoukangcheng 46 Dec 16 15:09 test.sh
[zoukangcheng@zoukangcheng proc]$ chmod u+x test.sh 
[zoukangcheng@zoukangcheng proc]$ ./test.sh
ex    exv.c  fork1    hello.c   mysh    mysu.c     primer1    primer2.c  test.sh
ex.c  few    fork1.c  myexec    mysh.c  primer0    primer1.c  sleep      vfork
exv   few.c  hello    myexec.c  mysu    primer0.c  primer2    sleep.c    vfork.c
zoukangcheng
cat: /etc/shadow: Permission denied
  PID TTY          TIME CMD
14857 pts/3    00:00:00 bash
19087 pts/3    00:00:00 test.sh
19091 pts/3    00:00:00 ps

shell执行./test.sh时,fork了一个子进程,该进程看到该文件为解释器文件,于是读取第一行,得到解释器程序的PATH,并exec该解释器程序(/bin/bash),然后重新执行这个解释器文件。

可以看出bash跳过了第一句,因为#在bash程序中被看成了注释,cat命令没有权限,但后面的ps命令仍然继续执行。

代码示例2

#!/bin/cat
ls
whoami
cat /etc/shadow
ps

执行该脚本:

[root@HongyiZeng proc]# ./test.sh 
#!/bin/cat
ls
whoami
cat /etc/shadow
ps

发现这次是打印了该脚本文件的所有内容。过程同上,只是这次子进程exec的程序为/bin/cat程序。

代码示例3——自定义解释器程序

解释器程序(或解释器)本质上就是一个可执行文件。解释器文件是一个文本文件。

echoarg.c

#include <stdio.h>
#include <stdlib.h>
 
int main(int argc, char* argv[]) {
	int i;
	
	for(i = 0; i < argc; i++) {
		printf("argv[%d]: %s \n", i, argv[i]);
	}
	exit(0);
}

编译为echoarg,并存放在/usr/local/linux_c/proc/下。

echoarg.sh

#!/usr/local/linux_c/proc/echoarg foo1 foo2 foo3

执行结果:

[root@zoukangcheng proc]# ./echoarg.sh
argv[0]: /usr/local/linux_c/proc/echoarg
argv[1]: foo1 foo2 foo3
argv[2]: ./echoarg.sh


5.8 system函数

函数原型:

#include <stdlib.h>

int system(const char *command);

作用:该函数实际上调用的是/bin/sh -c command,实质上是对fork+exec+wait的封装。

程序实例

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    
    system("date +%s > /tmp/out");
    
    exit(0);
}

该程序实质上执行的命令为:

/bin/sh -c date +%s > /tmp/out

在执行该命令的时候,system函数代码类似于:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void) {
    pid_t pid;
    
    pid = fork();
    if(pid < 0) {
        perror("fork()");
        exit(1);
    }
    
    if(pid == 0) {
        // 实际上在 exec /bin/sh程序
        execl("/bin/sh", "sh", "-c", "date +%s > /tmp/out", NULL);
        perror("execl()");
        exit(1);
    }
    
    wait(NULL);
    exit(0);
}

5.9 守护进程

5.9.1 简介

守护进程也叫做精灵进程(Daemon),是运行在后台的一种特殊进程,它独立于控制终端并且可以周期性的执行某种任务或者等待处理某些发生的事件。

守护进程常常在系统引导装入时启动,在系统关闭时终止。

守护进程是非常有用的进程,在Linux当中大多数服务器用的就是守护进程。比如Web服务器httpd等,同时守护进程完成很多系统的任务。当Linux系统启动的时候,会启动很多系统服务,这些进程服务是没有终端的,也就是说把终端关闭了,这些系统服务是不会停止的。

特点

生存周期长[不是必须]:一般是操作系统启动的时候他启动,操作系统关闭的时候他才关闭
守护进程和终端没有关联,也就是说他们没有控制终端,所以控制终端退出也不会导致守护进程退出
守护进程是在后台运行不会占着终端,终端可以执行其它命令

5.9.2 进程组与会话

进程组:进程除了有PID之外还有一个进程组id,进程组是由一个进程或者多个进程组成。
通常进程组与同一作业相关联,可以收到同一终端的信号:这个信号可以使同一个进程组中的所有进程终止,停止或者继续运行
进程组id就是组长进程的pid,只要在某个进程组中还有一个进程存在,则该进程组就存在
会话:会话是有一个或者多个进程组组成的集合
每打开一个控制中断,或者在用户登录时,系统就会创建新会话
在该会话中允许的第一个进程称作会话首进程,通常这个首进程就是shell
通常,一个会话开始于用户登录,终止于用户退出,在此期间该用户运行的所有进程都属于这个会话

字段含义:

PPID:父进程pid
PID:当前进程pid
PGID:进程组id
SID TTY:当前进程的会话id
TPGID:进程组和终端的关系,-1表示没有关系
STAT:进程状态
UID:启动(exec)该进程的用户的id
TIME:进程执行到目前为止经历的时间
COMMAND:启动该进程时的命令

5.9.3 创建守护进程

相关系统调用:

#include <unistd.h>
// creates a session and sets the process group ID 错误返回-1
pid_t setsid(void);

作用:创建一个新的会话,并让执行的进程称为该会话组的组长


创建流程:

创建自己并被init进程接管:在父进程中执行fork并exit退出,让子进程被init进程接管,从而脱离终端进程shell的控制;
创建新进程组和新会话:在子进程中调用setsid函数创建新的会话和进程组;
修改子进程的工作目录:在子进程中调用chdir函数,让根目录 / 成为子进程的工作目录;
修改子进程umask:在子进程中调用umask函数,设置进程的umask为0;
在子进程中关闭任何不需要的文件描述符
由于守护进程和终端没有关系,所以需要将子进程的标准输入和标准输出重定向到dev/null(空设备当中去)

代码示例
 

#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <sys/stat.h>
#include <cstdlib>
#include <fcntl.h>

void main(void) {
    int fd;
    pid_t pid;
    
    pid = fork();
    
    if(pid < 0) { // 出错
        perror("fork()");
        exit(1);
    } else if(pid == 0) { // 子进程
        //只有子进程才会走到这里
        if(setsid() == -1) {
            perror("setsid()");
            exit(1);
        }
        umask(0); //设置权限掩码
        fd = open("/dev/null",O_RDWR); //打开黑洞设备以读写方式打开
        if(fd == -1) {
             perror("open");
            exit(1);
        }
        if(fd > 3) { // 关闭继承的文件描述符
            if(close(fd) == -1) {
                perror("close()");
                exit(1);
            }
        }
        // 重定向...
        
        for(;;) {
            // 守护进程要完成的任务...
        }
    } else if(pid > 0) {
        exit(0); // 父进程退出
    }
    // nerver reach...
    exit(0);
}

执行结果:

 注意:进程守护化以后,只能使用kill命令杀掉该进程

5.9.4 后台进程和守护化

使用& ,可以将程序执行在后台:

./test >> out.txt 2>&1 &

在命令的末尾加个&符号后,程序可以在后台运行,但是一旦当前终端关闭,该程序就会停止运行,这就是后台进程。

后台进程和守护进程的区别

守护进程与终端无关,是被init进程收养的孤儿进程;而后台进程的父进程是终端,仍然可以在终端打印
守护进程在关闭终端时依然存在;而后台进程会随用户退出而停止
守护进程改变了会话、进程组、工作目录和文件描述符,后台进程直接继承父进程(shell)的

将进程守护化

可以使用nohup(no hang up)命令结合&将进程守护化:

nohup [进程名] [参数] 可执行文件 [重定向] &

例如:

nohup ./test >> out.txt &

将./test守护化,并将缓冲区的内容重定向至out.txt

nohup python -u test.py > nohup.out 2>&1 &

执行python程序,-u为python的参数,意为不启用缓冲,将内存中的内容直接写入到磁盘文件中。

nohup java -jar demo.jar

执行java程序。

5.10 系统日志

发送信息到系统日志上

syslogd服务

openlog();

syslog();

closelog();

函数原型:

 代码实例

#include <errno.h>
#include<string.h>
#include<unistd.h>
#include <fcntl.h>
#include<syslog.h>
#define FNAME "/tmp/out"

static int daemonize()
{

        int fd;
        pid_t pid;
        pid = fork();
        if(pid<0){

                return -1;
        }
        if(pid>0){//parent
                exit(0);
        }
        //childern
        fd = open("/dev/null",O_RDWR);
        if(fd<0)
        {

                return -1;
        }

        dup2(fd,0);
        dup2(fd,1);
        dup2(fd,2);

        if(fd>2){
                close(fd);
        }
        setsid();

        chdir("/");
        //umask(0);
        return 0;
}
int main()
{
        FILE *fp;
        openlog("mydaemon",LOG_PID,LOG_DAEMON);
        int i;
        if(daemonize()){
                syslog(LOG_ERR,"daemonize() failed!");
                exit(1);
        }
        else{
                syslog(LOG_INFO,"daemonize() successded!");//不要写\n,在系统日///志中会写入这两字符
        }

        fp = fopen(FNAME, "w");
        if(fp== NULL)
        {
                syslog(LOG_ERR,"fopen: %s",strerror(errno));
                exit(1);
        }
        syslog(LOG_INFO,"%s was opended.",FNAME);
        for(i=0;;i++)
        {
                fprintf(fp,"%d",i);
                fflush(fp);
                syslog(LOG_DEBUG,"%d is printed.",i);
                sleep(1);

        }
        fclose(fp);
        closelog();
        exit(0);

}

执行结果:

  • 24
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值