[Linux-进程控制] 进程创建&进程终止&进程等待&进程程序替换&简易shell

BingWallpaper 22


image-20221004163141929

进程创建

fork函数回顾

之前在进程概念一章中提到了fork函数,fork功能和fork分流操作,本章中我们会进行细分和深入学习

image-20220628181809833

操作系统为每个进程使用唯一的 id 来跟踪所有进程。为此,fork()不接受任何参数并返回一个 int 值,如下所示:

#include <unistd.h>
pid_t fork(void);
  • 零:如果是子进程(创建的进程)。
  • 正值:如果是父进程。
  • 负值:如果发生错误。

双返回值

为什么要给子进程返回0,给父进程返回子进程的pid

一个父进程可以创建多个子进程,而一个子进程只能有一个父进程。因此,对于子进程来说,父进程是不需要被标识的

而对于父进程来说,子进程是需要被标识的,因为父进程创建子进程的目的是让其执行任务的,父进程只有知道了子进程的PID才能很好的对该子进程指派任务。

如何理解fork会有两个返回值

首先要知道fork是一个函数

父进程调用fork函数后,为了创建子进程,fork函数内部将会进行一系列操作,包括创建子进程的进程控制块、创建子进程的进程地址空间、创建子进程对应的页表等等。子进程创建完毕后,操作系统还需要将子进程的进程控制块添加到系统进程列表当中,此时子进程便创建完毕了。

//一个子进程
pid_t fork()
{
    //拷贝自父进程,形成子进程对应的数据结构
    task_struct;
    mm_struct;
    页表;
    文件;
    其他信息;

    //OS也要管理子进程,描述+组织
    task_struct 链接进入系统进程列表中
    add task_struct into CPU runqueue
    //走到这里,进程创建完了
    return pid;
}

也就是说,在fork函数内部执行return语句之前,子进程就已经创建完毕了,那么之后的return语句不仅父进程需要执行,子进程也同样需要执行,这就是fork函数有两个返回值的原因。

调用fork之后

进程调用fork,当控制转移到内核中的fork代码后,内核做:

🌵 分配新的内存块和内核数据结构给子进程
🌵 将父进程部分数据结构内容拷贝至子进程
🌵 添加子进程到系统进程列表当中
🌵 fork返回,开始调度器调度

int main()
{
    pid_t pid;
    printf("Before: pid is %d\n", getpid());
    if ( (pid=fork()) == -1 )
        perror("fork()"),exit(1);
    printf("After:pid is %d, fork return %d\n", getpid(), pid);
    sleep(1);
    return 0;
}

输出:

Before: pid is 14791
After:pid is 14791, fork return 14792
After:pid is 14792, fork return 0

这里看到了三行输出,一行before,两行after。进程14791先打印before消息,然后它有打印after。另一个after消息有14792打印的。注意到进程14792没有打印before

image-20220628195440922

fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意,fork之后,谁先执行完全由调度器决定。

fork常规用法

🦌 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。

🦌 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

fork失败

fork是一个函数,是一个函数就有可能调用失败,虽然发生情况还是很少的,那么fork失败的原因有什么呢?

🦅 系统中有太多的进程

🦅 实际用户的进程数超过了限制

写时拷贝

image-20220628204751303

一张有趣的有关写时拷贝有关的图,from https://twitter.com/b0rk/status/987727508241092608

通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自拷贝一份副本

共享的本质其实就是页表指向同一块地方

image-20220628204448155

写是拷贝触发前后过程

为什么要写时拷贝?

🐤 为什么我们不可以直接在原来的地方直接修改,而是要拷贝一份数据呢?

​ 👽 进程具有独立性,父子进程也是这样的,多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。

🐤 为什么不在创建的时候就分开?

​ 👽 子进程不一定会使用父进程的所有数据,写入只在需要的时候,所以我们不应该完全拷贝一份父亲所有的数据,因为实际上有时候我也用不着所有的字段,所以是浪费的,因此写时拷贝就体现了他的优势

​ 💎 按需分配(不用拷贝所有的字段,只拷贝我需要的)

​ 💎 延时分配(本质是,可以高效使用任何内存空间),分配了之后马上不用,就会浪费资源,所以什么时候用了再拷贝

🐤 代码会不会写时拷贝?

​ 👽 90%不会,并不代表不能,进行进程替换的时候,则需要进行代码的写时拷贝。

进程终止

进程是么时候退出

🍂 代码运行完毕,结果正确

🍂 代码运行完毕,结果不正确

🍂 代码异常终止(进程崩溃)

进程退出

main函数退出

为什么main要有返回值来返回给OS?

运行程序,加载,形成进程,目的就是为了完成某种工作

为了来判断结果对或者时不对,我们就要通过进程退出码来判断,这个退出码就是main的返回值

我们怎么来看这个进程的退出码呢?可以使用命令行,来获取最近一次进程的退出码

echo $?

image-20220629132240976

我们给ls传入一个不正确的选享,输出的代码跑完且结果不正确,就返回值不是0

由于成功我们一般都不关心原因,所以我们刚给它0,但是失败的时候我们往往会给他多种错误码,来表示不同的失败的原因

int main()
{
    for(int i=0;i<100;i++){
        printf("%d: %s\n",i,strerror(i));
    }
    return 0;                                                               
}

下面通过上述代码来打印一下错误码

0: Success
1: Operation not permitted
2: No such file or directory
3: No such process
4: Interrupted system call
5: Input/output error
6: No such device or address
7: Argument list too long
8: Exec format error
9: Bad file descriptor
10: No child processes
11: Resource temporarily unavailable
12: Cannot allocate memory
13: Permission denied
14: Bad address
15: Block device required
16: Device or resource busy
17: File exists
18: Invalid cross-device link
19: No such device
20: Not a directory
21: Is a directory
22: Invalid argument
23: Too many open files in system
24: Too many open files
25: Inappropriate ioctl for device
26: Text file busy
27: File too large
28: No space left on device
29: Illegal seek
30: Read-only file system
31: Too many links
32: Broken pipe
33: Numerical argument out of domain
34: Numerical result out of range
35: Resource deadlock avoided
36: File name too long
37: No locks available
38: Function not implemented
39: Directory not empty
40: Too many levels of symbolic links
41: Unknown error 41
42: No message of desired type
43: Identifier removed
44: Channel number out of range
45: Level 2 not synchronized
46: Level 3 halted
47: Level 3 reset
48: Link number out of range
49: Protocol driver not attached
50: No CSI structure available
51: Level 2 halted
52: Invalid exchange
53: Invalid request descriptor
54: Exchange full
55: No anode
56: Invalid request code
57: Invalid slot
58: Unknown error 58
59: Bad font file format
60: Device not a stream
61: No data available
62: Timer expired
63: Out of streams resources
64: Machine is not on the network
65: Package not installed
66: Object is remote
67: Link has been severed
68: Advertise error
69: Srmount error
70: Communication error on send
71: Protocol error
72: Multihop attempted
73: RFS specific error
74: Bad message
75: Value too large for defined data type
76: Name not unique on network
77: File descriptor in bad state
78: Remote address changed
79: Can not access a needed shared library
80: Accessing a corrupted shared library
81: .lib section in a.out corrupted
82: Attempting to link in too many shared libraries
83: Cannot exec a shared library directly
84: Invalid or incomplete multibyte or wide character
85: Interrupted system call should be restarted
86: Streams pipe error
87: Too many users
88: Socket operation on non-socket
89: Destination address required
90: Message too long
91: Protocol wrong type for socket
92: Protocol not available
93: Protocol not supported
94: Socket type not supported
95: Operation not supported
96: Protocol family not supported
97: Address family not supported by protocol
98: Address already in use
99: Cannot assign requested address
100: Network is down
101: Network is unreachable
102: Network dropped connection on reset
103: Software caused connection abort
104: Connection reset by peer
105: No buffer space available
106: Transport endpoint is already connected
107: Transport endpoint is not connected
108: Cannot send after transport endpoint shutdown
109: Too many references: cannot splice
110: Connection timed out
111: Connection refused
112: Host is down
113: No route to host
114: Operation already in progress
115: Operation now in progress
116: Stale file handle
117: Structure needs cleaning
118: Not a XENIX named type file
119: No XENIX semaphores available
120: Is a named type file
121: Remote I/O error
122: Disk quota exceeded
123: No medium found
124: Wrong medium type
125: Operation canceled
126: Required key not available
127: Key has expired
128: Key has been revoked
129: Key was rejected by service
130: Owner died
131: State not recoverable
132: Operation not possible due to RF-kill
133: Memory page has hardware error
exit()退出和_exit()退出
exit和main区别

只有在main函数当中的return才能起到退出进程的作用,子函数当中return不能退出进程,而exit函数和_exit函数在代码中的任何地方使用都可以起到退出进程的作用。

exit则可以在任何地方执行退出,使用exit函数退出进程前,exit函数会执行用户定义的清理函数、冲刷缓冲,关闭流等操作,然后再终止进程,而_exit函数会直接终止进程,不会做任何收尾工作。

void myexit()
{
	exit(0);
}
int main()
{
    printf("hello");
    myexit();
}
exit和_exit之间的区别
  1. exit会释放进程曾经占用的资源,比如说:缓冲区
  2. _exit会直接终止进程,不会做任何收尾工作

image-20220629141118426

从工作上区分exit
#include <unistd.h>
void exit(int status);
void _exit(int status);
参数:status 定义了进程的终止状态,父进程通过wait来获取该值

那我们从具体干的工作上来区分exit和_exit

exit具体干的工作是:

  1. 执行用户通过 atexit或on_exit定义的清理函数。
  2. 关闭所有打开的流,所有的缓存数据均被写入
  3. 调用_exit

image-20220629141044978

异常终止

情况一:向进程发生信号导致进程异常退出。

例如,在进程运行过程中向进程发生kill -9信号使得进程异常退出,或是使用Ctrl+C使得进程异常退出等。

情况二:代码错误导致进程运行时异常退出。

例如,代码当中存在野指针问题使得进程运行时异常退出,或是出现除0的情况使得进程运行时异常退出等。

进程终止小结

🥝main 函数 return 的值给谁看 ?

其实 main 函数 return 的值是给系统看的,以此来判断进程执行后的结果。

🥝程序员怎么看 main 函数 return 的值吗 ?

echo $?用来保存最近一次程序运行结束时退出码的值是多少。

🥝所有的父进程都关心子进程退出结果 ?

大部分情况下通常退出码是父进程关心的,因为父进程费了很大的劲把子进程创建出来干活,活干的怎么样,父进程得知道。但并不是所有的父进程都关心子进程退出结果,比如说公司老板想开除我,然后 hr 找我谈,说你的合同到期了,可以走了,再干下去你也没工资,此时你肯定会走,hr 也不需要关心。换言之,我们后面可能会碰到父进程不需要关心子进程的退出结果的场景。

🥝进程非正常结束 ?

野指针、/0、越界等都可能导致进程非正常结束,父进程也要关心这种情况,但此时退出码是无意义的。好比,今天考试,因为肚子痛考了 0 分,那么这个理由是可以被妈妈信服的。但因为考试作弊被抓,考了 0 分,这个其实不算理由,因为你都不是正常考完的,后面你再解释的所有理由就毫无意义。

进程等待

进程等待必要性

为什么我们一定要进程等待?

🍌 子进程退出,父进程如果不读取子进程的退出信息,子进程就会变成僵尸进程,进而造成内存泄漏。

🍌 进程一旦变成僵尸进程,那么就算是kill -9命令也无法将其杀死,因为谁也无法杀死一个已经死去的进程。

🍌 对于一个进程来说,最关心自己的就是其父进程,因为父进程需要知道自己派给子进程的任务完成的如何。

🍌 父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。

进程等待的方法

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

返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL

下面一段代码中我们可以在child结束之后,让父亲wait孩子,直到父亲收到孩子返回的信息,这样的话父进程也不会进入僵尸状态,可以正常退出

而在父亲等待子进程退出的是个,父亲的等操作被称为阻塞等待,直到子进程退出

非阻塞等待是什么?非阻塞就是也是等,但是不会因为条件不满足而卡住,阻塞就是一直等

wait示例
int main()
{
    pid_t id = fork();
    if(id == 0){
        //child
        int count = 0;
        while(count < 10){
            printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());
            count++;
            sleep(1);
        }
        exit(0);
    }
    else{
        //father
        printf("I am father, pid: %d, ppid: %d\n", getpid(), getppid());
        pid_t ret = wait(NULL);
        if(ret >= 0){
            printf("wait child success!, %d\n", ret);
        }
       printf("Fahter run ....\n");
       sleep(10);
    }
}

image-20220629204613590

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

返回值
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;
参数
💎 pid:
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。
💎 status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
💎 options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。

wait/waitpid快速入门

#️⃣ 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
#️⃣ 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
#️⃣ 如果不存在该子进程,则立即出错返回。

waitpid可以做到等某一个指定的进程退出,而wait进程等待成功不意味子进程运行成功,只意味着子进程退出了

image-20220629211132205

获取子进程status

wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,可以当作位图来看待(一般只研究status低16比特位):

image-20220629210330792

在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志。

exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F;      //退出信号

我们通过一系列位操作,就可以根据status得到进程的退出码和退出信号。当然系统提供了宏给我们方便使用

exitNormal = WIFEXITED(status);  //是否正常退出
exitCode = WEXITSTATUS(status);  //获取退出码

需要注意的是,当一个进程非正常退出时,说明该进程是被信号所杀,那么该进程的退出码也就没有意义了。

下面是waitpid获取status的实例:

int main()
{
  pid_t id = fork();
    if(id == 0){
		//child
        int count = 0;
        while(count < 10){
            printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());   
            count++;                 
            sleep(1);    
        }    
        exit(0);    
    }    
    else{    
        //father    
        printf("I am father, pid: %d, ppid: %d\n", getpid(), getppid());    
        //pid_t ret = wait(NULL);    
         int status = 0;          
         pid_t ret = waitpid(id, &status, 0);    
        if(ret >= 0){             
            printf("wait child success!, %d\n", ret);    
             printf("status: %d\n", status);
             printf("child exit code : %d\n", (status>>8)&0xFF);//退出码
             printf("child get signal: %d\n", status&0x7F);//退出信号
        }
       printf("Fahter run ....\n");
       sleep(10);
    }
}

image-20220630101444406

获取退出码和退出信号

如果进程是异常终止的时候,那么退出码是没有意义的,此时退出信号才是表示实际的进程的完成状况

进程异常的时候,本质是进程运行的时候出现了某种错误,导致进程收到信号

退出信号可以通过kill -l查看,其中前1-31个常用

 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX	
waitpid示例

创建一批子进程,让父进程等待子进程,然后再退出

我们在这里利用提供的宏WIFEXITEDWEXITSTATUS来获取进程退出时的信号信息,而不用使用之前的status&0x7F(status>>8)&0xFF

例如,以下代码中同时创建了10个子进程,同时将子进程的pid放入到ids数组当中,并将这10个子进程退出时的退出码设置为该子进程pid在数组ids中的下标,之后父进程再使用waitpid函数指定等待这10个子进程。

int main()
{
  pid_t ids[10];
    for (int i = 0; i < 10; i++)
    {
        pid_t id = fork();
        if (id == 0)
        {
            // child
            int count = 10;
            while (count > 0)
            {
                printf("child do something!: %d, %d\n", getpid(), getppid());
                sleep(1);
                count--;
            }
            exit(i);
        }
        // father
        ids[i] = id;
    }

    int count = 0;
    while (count < 10)
    {
        int status = 0;
        pid_t ret = waitpid(ids[count], &status, 0);
        if (ret >= 0)
        {
            printf("wait child success!, %d\n", ret);
            if (WIFEXITED(status))
            {
                printf("child exit code : %d\n", WEXITSTATUS(status));
            }
            else
            {
                printf("child not exit normal!\n");
            }
            //printf("status: %d\n", status);
            //printf("child get signal: %d\n", status&0x7F);
            //printf("child exit code : %d\n", (status>>8)&0xFF);
        }
        count++;
    }  
}

image-20220630105212046

阻塞和非阻塞轮询

如果我们在waitpid中参数增加WNOHANG就是让父亲做一个非阻塞等待,下面的例子只是演示,不是一个特别好的处理方式

int main()
{
   pid_t id = fork();
    if(id == 0){
		//child
        int count = 0;
        while(count < 10){
            printf("I am child, pid: %d, ppid: %d\n", getpid(), getppid());   
            sleep(1);  
            count++;                             
        }    
        exit(1);    
    }       
         int status = 0;          
         pid_t ret = waitpid(id, &status, WNOHANG);    
        if(ret > 0){             
            printf("wait child success!, %d\n", ret);    
            printf("child exit code : %d\n", WEXITSTATUS(status));//退出码
        }
       printf("ret: %d\n",ret);
    	return 0;
}

观察代码可以发现父进程其实是提前结束,所以子进程变成了孤儿进程,然后被操作系统接收

image-20220630161041833

所以说我们常用的一般是基于非阻塞接口的轮询式检查方案,这样的话子进程不结束,父进程也可以执行自己的事情,互不干扰,这个是常见的使用方式

while(1)
{
    int status = 0;          
    pid_t ret = waitpid(id, &status, WNOHANG);    
    if(ret > 0){             
        printf("wait child success!, %d\n", ret);    
        printf("child exit code : %d\n", WEXITSTATUS(status));//退出码
        break;
    }     	
    else if (ret == 0)
    {
        // child not quit ,waitpid success
        printf("father do other things! \n");
        sleep(1);
    }
    else
    {
        printf("waitpid error!\n");
        break;
    }
}

image-20220630162056792

进程等待小结

🍊为什么现实世界中大部分选择非阻塞轮询 ?

这种高效体现在:主要是对调用方高效,你给张三打电话,张三就要 10 分钟,那就是 10 分钟,类似于计算机,你再怎么催都没用,所以我们就不会死等,我们可以先做其它的事,反正不会让因为等待你,而让我做不了事情。

🍊那为什么我们写的代码大部分都是阻塞调用 ?

根本原因在于我们的代码都是单执行流,所以选择阻塞调用更简单。

🍊为什么是 WNOHANG ?

在服务器资源即将被吃完时,卡住了,我们一般称服务器hang住了,进而导致宕机。所以 W 表示等待,NO 表示不要,HANG 表示卡了,所以这个宏的意思是等待时不要卡住。

🍊如何理解父进程等子进程中的 “ 等 ” ?

所谓的等并不是把父进程放在 CPU 上,让父进程在 CPU 上边跑边等。本来父子进程都在运行队列中等待 CPU 运行,当子进程开始被 CPU 运行后,就把父进程由 R 状态更改为 !R 状态,并放入等待队列中,此时父进程就不运行了,它就在等待队列中等待。当子进程运行结束后,操作系统就会把父进程放入运行队列,并将状态更改为 R 状态,让 CPU 运行,这个过程叫做唤醒等待的过程。

🍊操作系统是怎么知道子进程退出时就应该唤醒对应的父进程呢 ?

wait 和 waitpid 是系统函数,是由操作系统提供的,你是因为调用了操作系统的代码导致你被等待了,操作系统当然知道子进程退出时该唤醒谁。

这里,我们只要能理解等待就是将当前进程放入等待队列中,将状态设置为 !R 状态。所以一般我们在平时使用计算机时,肉眼所发现的一些现象,如某些软件卡住了,根本原因是要么进程太多了,导致进程没有被 CPU 调度;要么就是进程被放到了等待队列中,长时间不会被 CPU 调度。我们曾经在写 VS 下写过一些错误代码,一旦运行,就会导致 VS 一段时间没有反应。所谓的没有反应就是因为程序导致系统出现问题,操作系统在处理问题区间,把 VS 进程设置成 !R 状态,操作系统处理完,再把 VS 唤醒。

🍊子进程已经退出了,子进程的退出码放在哪 ?

换句话说,父进程通过 waitpid 要拿子进程的退出码应该从哪里去取呢,明明子进程已经退出了。子进程是结束了,但是子进程的状态是僵尸,也就是说子进程的相关数据结构并没有被完全释放。当子进程退出时,进程的 task_struct 里会被填入当前子进程退出时的退出码,所以 waitpid 拿到的 status 值是通过 task_struct 拿到的。

进程程序替换

替换原理

在实际的执行环境中,大部分的进程并不是直接在父进程的基础上运行,产生一个新进程的目的在于重新运行一个完全不同的程序,这时候就需要用到 exec 函数族,exec 函数族提供了一个在进程中启动另一个程序执行的方法。它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变,原调用进程的内容除了进程号以及一些系统配置外,其他全部被新的进程替换了。

image-20220630162454137

总结程序替换的特点就是:不创建新的进程,仅仅替换掉该进程的代码和数据。

替换函数exec+

image-20220630165640833

exec+ 语法
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[]);

使用举例

#include <unistd.h>
int main()
{
    char *const argv[] = {"ps", "-ef", NULL};
    char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
    execl("/bin/ps", "ps", "-ef", NULL);
    // 带p的,可以使用环境变量PATH,无需写全路径
    execlp("ps", "ps", "-ef", NULL);
    // 带e的,需要自己组装环境变量
    execle("ps", "ps", "-ef", NULL, envp);
    execv("/bin/ps", argv);
    // 带p的,可以使用环境变量PATH,无需写全路径
    execvp("ps", argv);
    // 带e的,需要自己组装环境变量
    execve("/bin/ps", argv, envp);
    exit(0);
}

参数:
🍨 path: 表示你要启动程序的名称包括路径名(你想执行谁)
🍨 arg: 表示启动程序所带的参数,一般第一个参数为要执行命令名,不是带路径且arg必须以NULL结束(你想做怎么执行)
🍨 返回值: 成功返回0,失败返回-1
🍨 ...: 可变参数列表
🍨 filename:指向可执行文件名的用户空间指针。
🍨 argv[]:参数列表,指向用户空间的参数列表起始地址
🍨 envp:环境变量表,环境变量是一系列键值对,字符串类型

exec+ 快速入门
  1. 在程序替换的时候我们没有进行任何的进程创建,虽然我们将新的代码和数据装入了这个进程
  2. 如果调用成功则加载新的程序从启动代码开始执行,不再返回。如果调用出错则返回-1
  3. exec函数只有出错的返回值而没有成功的返回值。也就是说,exec系列函数只要返回了,就意味着调用失败。

子进程进行进程程序替换后,会影响父进程的代码和数据吗?

子进程刚被创建时,与父进程共享代码和数据,但当子进程需要进行进程程序替换时,也就意味着子进程需要对其数据和代码进行写入操作,这时便需要将父子进程共享的代码和数据进行写时拷贝,此后父子进程的代码和数据也就分离了,因此子进程进行程序替换后不会影响父进程的代码和数据。

exec+ 理解

l(list) : 表示参数采用列表
v(vector) : 参数用数组
p(path) : 有p自动搜索环境变量PATH
e(env) : 表示自己维护环境变量

image-20220630165230504

image-20220630165306937

From https://eric-lo.gitbook.io/lab4-process/process-execution/exec-family

现在来详解每个函数的用法:

execlp

execlp是带p的执行方式是只需要第一个参数给传入filename就可以了 ,因为默认路径已经给定在环境变量中

参数[你想执行谁给我名字,你想怎么执行]

   // 带p的,可以使用环境变量PATH,无需写全路径
  execlp("ls","ls", "-a", "-i", "-l", NULL);

看上去好像给了两个ls好像是重复的,其实不是的,第一个ls指的是我们要执行的文件是ps,然后后面的ls指的是执行的参数命令,会和"-a", "-i", "-l",连在一起产生效果

execv

execv是通过传入一个数组的形式指定命令

参数[你想执行谁给我位置,你想怎么执行给我数组]

char *myargv[] = {
            "ls",
            "-a",
            "-i",
            "-l",
            NULL};
execv("/usr/bin/ls",myargv);
execvp
 char *myargv[] = {
            "ls",
            "-a",
            "-i",
            "-l",
            NULL};
execvp("ls", myargv);
execle

之前的exec执行的都是系统的命令,现在我想要执行我自己写的命令,怎么执行?

参数[你想执行谁给我执行方式和名字,你想怎么执行,你执行的环境我需要]

char *myenv[] = {
    "MYENV="阿巴阿巴",
    NULL};
execle("./cmd", "cmd", NULL, myenv);

我们的execle可以调用的不只是c生辰的执行文件,可以是python,shell,c++,等的可执行文件

比如执行下面的cmd

int main()
{
    printf("I am a new exe, my cmd!\n");
    printf("my env: %s\n", getenv("MYENV"));
    //getenv是获取传入的参数的环境变量,不存在就为空
    printf("OS env: %s\n", getenv("PATH"));
    return 0;
}

补充:Makefile一次生成多个可执行文件

.PHONY:all
all:exec cmd pokemon

exec:exec.c
	gcc -o $@ $^

cmd:mycmd.c
	gcc -o $@ $^

pokeomon:pokemon.cc
	g++ -o $@ $^

.PHONY:clean
clean:
	rm -f exec cmd pokemon

image-20220630205930644

exec函数族总结

事实上,只有execve才是真正的系统调用,其它五个函数最终都是调用的execve,所以execve在man手册的第2节,而其它五个函数在man手册的第3节,也就是说其他五个函数实际上是对系统调用execve进行了封装,以满足不同用户的不同调用场景的。

image-20220630191226573

fork V.S. exec

下面是浏览到的一张有意思的图

image-20220630165710034

From https://pediaa.com/what-is-the-difference-between-fork-and-exec/

进程替换小结

系统是如何做到重新建立映射关系的呢 ?

当子进程里要加载新进程时,操作系统可以设置一些特殊信号让该进程对全部代码和数据的写入,子进程会自动触发写时拷贝,重新开辟空间,再重新把代码和数据加载。

在进行进程替换时,有没有创建新的进程 ?

我们并不需要重新开辟新的 PCB、地址空间、页表,没有创建新进程的最有力证据是 pid 没变。所以我们曾经说过,程序要运行起来,必须先加载到内存,这句话当然没问题。但是反过来,程序只要加载到内存了,一定是变成一个进程,这句话有纰漏,因为进程是否是新创建是不一定的。不过大部分情况下是创建新进程的,进程替换是属于少数。

简易Shell

分析需求

期望执行效果可以像bash一样

[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# ps
PID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps

用下图的时间轴来表示事件的发生次序。其中时间从左向右。shell由标识为sh的方块代表,它随着时间的流逝从左向右移动。shell从用户读入字符串"ls"。shell建立一个新的进程,然后在那个进程中运行ls程序并等待那个进程结束。

image-20220630211226924

然后shell读取新的一行输入,建立一个新的进程,在这个进程中运行程序 并等待这个进程结束。

实现思路

  1. 获取命令行
  2. 解析命令行
  3. 建立一个子进程(fork)
  4. 替换子进程(execvp)
[root@localhost epoll]# ls
client.cpp readme.md server.cpp utility.h
[root@localhost epoll]# ps
PID TTY TIME CMD
3451 pts/0 00:00:00 bash
3514 pts/0 00:00:00 ps
  1. 父进程等待子进程退出(wait)

简易实现

可以看到我们自己模拟的 shell 可以支持大部分命令,但有部分命令是不支持的,如 ll、>、| 。不支持的原因也很好理解,重定向和管道是需要就进程之间的通信的,同时诸如cd之类的调用的是系统的函数,而不是用进程替换的原理

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include<ctype.h>
#define LEN 1024
#define NUM 32

int main()
{
    char cmd[LEN];
    char *myarg[NUM];
    char name[32];                                                      
    while(1){
        gethostname(name,sizeof(name)-1);
        printf("[allen@%s ~]$  ",name);
        fgets(cmd, LEN, stdin);
        cmd[strlen(cmd)-1] = '\0';//把原先的\n转化到'\0'
        //切割参数
        myarg[0] = strtok(cmd, " ");
        int i = 1;
        while(myarg[i] = strtok(NULL, " ")){
            i++;
        }
        //子进程替换执行
        pid_t id = fork();
        if(id == 0){
            //child
            execvp(myarg[0], myarg);
            exit(10); //替换失败的退出
        }
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        if(ret > 0){
            printf("exit code: %d\n", WEXITSTATUS(status));
        }
    }
    return 0;
}

函数和进程

exec/exit就像call/return

一个C程序有很多函数组成。一个函数可以调用另外一个函数,同时传递给它一些参数。被调用的函数执行一定的操作,然后返回一个值。每个函数都有他的局部变量,不同的函数通过call/return系统进行通信。

这种通过参数和返回值在拥有私有数据的函数间通信的模式是结构化程序设计的基础。Linux鼓励将这种应用于程序之内的模式扩展到程序之间。如下图

image-20220701082243479

一个C程序可以fork/exec另一个程序,并传给它一些参数。这个被调用的程序执行一定的操作,然后通过exit(n)来返回值。调用它的进程可以通过wait(&ret)来获取exit的返回值。

总结

进程创建的时候,操作系统做了什么?

操作系统运行程序必须做的第一件事情就是将磁盘上的代码和数据加载到内存中自己的地址空间中。在早期操作系统,加载过程会在运行程序之前全部完成,而现代操作系统相反,只会在程序执行期间把需要的代码和数据加载到内存

将代码和数据加载到内存后,操作系统并没有马上运行这个进程,它还需要为程序的运行时栈分配内存,这个栈用来存储程序中的局部变量、函数参数、返回地址。当栈内存被从操作系统分配给进程后,操作系统可能还需要为程序的堆继续分配内存,堆是用来请求动态分配数据,程序通过malloc函数请求这样的空间,堆的内存并不会一开始固定,可能会随着程序运行,继续等待api请求更多的内存请求。

当分配完栈和堆这两个内存后,操作系统还需要执行一些其他初始化任务,如I/O相关的,在Unix中,某默认每个进程都有3个打开的文件描述符,用于标准输入、输出和错误。这些描述符让程序轻松读取来自终端的输入以及打印输出到屏幕。当以上这些都执行完成后,操作系统已经为程序搭好了舞台,是时候启动程序并运行它了,每个程序都有一个main函数,而main函数就是程序的入口,操作系统将会通知CPU接管这个新创建的进程,并开始执行程序中的指令。

磁盘中的程序加载到内存中的地址空间 ——> 创建并初始化栈 ——> 如果有堆请求,并分配内存 ——> 执行I/O设置相关的工作 ——> 启动程序

https://www.cnblogs.com/yuetong-li/p/13543918.html

进程替换的时候,操作系统在干什么

本质上来说就是替换一个pcb对应代码和数据,加载新代码和数据到这个pcb上,更新页表,虚拟地址空间等相关信息,让这个pcb开始调度这个新程序。

站在操作系统角度,如何理解进程终止?

🏮 “ 释放 ” 曾经为了管理进程所维护的所有的数据结构对象。
这里的释放,在操作系统里,并不是真的把数据结构对象销毁,而是设置为不用状态,然后保存起来,如果这样不用的对象多了,就有了一个 “ 数据结构池 ”。

完成这个进程在内核中资源的回收就是由这个do_exit函数完成的,这个函数分别调用了exit_mm,exit_sem,__exit_files,__exit_fs,exit_namespace, exit_thread, cpuset_exitexit_keys等等,其中__exit_files函数所完成的工作就是回收这个进程文件系统相关的资源,因为在linux系统中,一个进程所打开的所有的文件包含各种设备,socket等都是用文件描述符来对应

🏮 “ 释放 ” 程序代码和数据占用的内存空间。
所以有了上面的理解,我们就知道这里的 “ 释放 ” 不是把代码和数据清空,而是把内存设置为无效。

🎃 如果删除的过程和写入的过程是一个相似的、相反的逻辑,写的过程是在磁盘上把数据以二进制写好,删的过程是相反,那么它们所花的时间应该是相同的?

实际我们在进行删除时,就是对所对应的空间标识为无效,这就意味着它是可以被覆盖的,写入新数据的同时就是在覆盖老数据。所以这里想说的是计算机里的释放并不是真的释放,要么就是利用 Slab 分配器以数据结构的方式缓存起来,要么就是把空间设置为无效,你都可以进行二次覆盖。也就是说以前我们经常看到的把文件删除后,文件就跑到回收站里了,此时并不是真正的删除,而是设置为无用状态,本质是临时删除放进回收站的文件只是在注册表中状态被改为无用状态,而再对回收站中的文件进行删除时,就意味着文件在注册表中被除名,但是文件的数据仍在,所以,即使我们把回收站的文件清空了,照样可以通过注册表来恢复文件。

🎃 内存空间怎么做到无效?

内存也要进行管理,其也有对应的数据结构,如果没有人指向这个内存,此时这个内存就是无效的

🏮 取消曾经该进程的链接关系。
比如我是子进程,我有 1 个父进程,3 个兄弟进程,除了所有进程本身是用双链表链接的,这里与父和子也有链接关系,所以我要离开了,就要把之前的关系统统去掉。

参考资料:https://blog.csdn.net/chenlong_cxy/article/details/120444275

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

言之命至9012

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值