目录
1、进程创建回顾
1.1、进程创建
在 linux 中 fork 函数 是非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程 ,而原进程为父进程。
返回值:子进程中返回 0 ,父进程返回子进程 id ,出错返回 -1。
例如:循环创建多个子进程:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include<sys/types.h>
#define N 5
void runchild()
{
int cnt=10;
while(cnt)
{
printf("I am a child:%d\n",getpid());
sleep(1);
cnt--;
}
}
int main()
{
for(int i=0;i<N;i++)
{
pid_t id=fork();
if(id==0)
{
runchild();
exit(0);
}
}
sleep(1000);
return 0;
}
1.2、进程创建失败的原因
1、系统中有太多进程。
2、实际用户的进程数超过了限制。
2、进程退出
在父子进程中,一般而言是父进程关心子进程的运行情况,因为父进程要知道交给子进程的任务完成的如何。父进程就是通过子进程的退出码来判断子进程任务完成的情况。
在main函数中,return 0;这个返回值就是进程的退出码,退出码用来表示进程的运行结果是不是正确的,如果返回的是0,就表示正确执行。可以使用return返回不同的数字,表征不同的出错原因,这就是进程的退出码。
2.1、进程退出的场景
第一种情况:代码运行完毕,结果正确。
第二种情况:代码运行完毕,结果不正确。
第三种情况:代码异常终止。 进程出现异常本质就是我们的进程收到了对应的信号(信号后面再提)。
在这三个场景中,我们关心的是程序为什么跑错了,也就是第二和第三种情况是我们比较关心的。如果一个程序运行是正确的话,我们是不关心为什么程序运行是正确的。
与退出码类似的一个概念是 错误码 ,区别是错误码是用来表示在使用标准库中函数发生的错误,
两者都是用来表示状态或错误信息,帮助开发者或调用者理解程序的执行结果。退出码通常是程序结束时返回的单一值,而错误码可能在程序运行时多次发生。退出码更多关注程序的整体执行结果,而错误码则关注具体的错误情境。可以使用strerror()函数来查看错误码的含义。系统提供的错误码和错误码的含义是一一对应的,其中errno是一个全局变量,是指最近一次执行的错误码。
2.2、进程常见退出方法
可以使用**echo ?** 的方式来查看命令行中最近一个执行程序的退出码。其中**?**中就保存着最近一次进程退出的时候的退出码。
例如:我们有这样的一个代码
#include<stdio.h>
int main()
{
printf("this is a test\n");
return 1;
}
运行后,在命令行输入下面的命令
echo $?
就可以看到如下结果:

我们是可以自己设计退出码的,例如:
const char* errString[]={
"sucess",
"error 1",
"error 2"
};
然后返回时返回数组的下标即可。 也就是用数组的下标,来代表各种可能出现的问题。
2.3、进程退出的方式
2.3.1、exit()函数
函数原型为:
#include <stdlib.h>
void exit(int status);
其中status就是退出码。
exit 最后也会调用_exit()函数 , 但在调用_e xit()函数之前,还做了其他工作:
执行清理函数 :调用所有通过 atexit()
或 on_exit()
注册的清理函数。这些函数可以用于释放资源或执行其他的善后工作。
关闭打开的流:关闭所有打开的文件流(如标准输入、输出、错误),并确保所有缓冲的数据被写入到相应的文件中。这一步确保数据不会丢失,并且文件能够正确关闭。
调用 _exit()
:最后,exit
函数会调用 _exit()
函数来实际终止进程。_exit()
不会执行任何清理或缓冲处理,因此是一个快速的结束进程的方式。
注意 :exit函数在任意位置调用都表示调用进程退出,return则不同,return在main函数中表示进程退出,执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做exit的参数,在非main函数仅表示当前函数返回,而不是进程退出。
2.3.2、_exit()函数
函数原型为:
#include <unistd.h>
void _exit(int status);
这个函数是一个系统调用。不会执行任何清理操作,直接终止进程。它用于确保程序立即终止,而不进行任何额外的操作。
一般代码异常本质可能就是代码没有跑完,所以在代码没有跑完的情况下就结束了看退出码是没有意义的。进程退出我们优先看的是进程是否异常,如果没有异常我们再看运行的结果是否正确。
进程的异常,比如:在代码中用0作为分母;再比如:在代码中对空指针进行解引用;这些操作都会引发异常。基本上代码异常都是发生了硬件级别的错误,比如:除0会导致CPU的状态寄存器出现溢出错误,然后操作系统就会给对应的的进程发送信号。比如:
8号信号就是指Floating point exception。
kill -8 PID
引发8号信号所对应的错误。
11号信号就表示Segmentation fault。
kill -11 PID
引发11号信号所对应的错误。
我们可以使用上面的方式,让正在执行的程序出现异常。
可以使用下面的方式列出信号。
kill -l
具体有关于信号的内容之后的文章再讲。
注意:缓冲区并不在进程地址空间中的内核空间,缓冲区就在用户空间中(后面再讲)。
3、进程等待
之前提到子进程退出,父进程如果不管不顾,就可能造成 僵尸进程 的问题,进而造成内存泄漏。
另外,进程一旦变成僵尸状态,那就刀枪不入, 的 kill -9 也无能为力,僵尸进程是无法被杀死的,因为谁也没有办法杀死一个已经死去的进程。
最后,父进程派给子进程的任务完成的如何,我们需要知道。如子进程运行完成,结果对还是不对
或者是否正常退出。 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息,从而判断子进程的任务完成的如何。
3.1、wait方法
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
返回值:成功返回被等待进程pid,失败返回-1。
参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL。
wait是等待任意一个子进程的退出,一次只等待一个进程。如果子进程不退出,父进程在wait的时候也就不会返回,也就是父进程会一直等待子进程的退出。也就是wait是阻塞等待进程的。
3.2、waitpid方法
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项 WNOHANG, 而调用中 waitpid 发现没有已退出的子进程可收集, 则返回 0 ;
如果调用中出错, 则返回 -1, 这时 errno 会被设置成相应的值以指示错误所在;
参数:
pid: Pid=-1,等待任一个子进程。与wait 等效。
Pid>0, 等待其进程pid 与 pid 相等的子进程。
status: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL。这个输出型参数是被当成好几部分来用的,下面讲。
options:
WNOHANG:若pid 指定的子进程没有结束,则 waitpid() 函数返回 0 ,不予以等待。若正 常结束,返 回该子进程的PID。
填写0,则表示阻塞等待,与wait相同。
注:可以使用WIFEXITED(status)来检测进程是否正常退出;若WIFEXITED(status)非零,WEXITSTATUS(status) 是用来提取子进程退出码。这两个东西本质上就是宏。
3.3、获取子进程的status
wait 和 waitpid ,都有一个 status 参数,这两个函数的status参数的用法是一样的,该参数是一个输出型参数。
如果传递 NULL ,表示不关心子进程的退出状态信息。 否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。 status不能简单的作整形来看待,可以当作位图来看待,具体细节如下图(只研究 status 低 16 比特位):
注: 终止信号为0表示未收到过信号,终止信号为非0时,表示收到对应的信号。
例如:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();
if(id==-1)
{
perror("fork failed");
exit(1);
}
if(id==0)
{
sleep(5);
exit(10);
}
else
{
int st;
int ret=wait(&st);
if(ret>0 && (st&0x7f)==0)
{
printf("child exit:%d\n",(st>>8)&0xff);
}
else if(ret>0)
{
printf("sig code:%d\n",st&0x7f);
}
else
{
printf("wait failed\n");
}
}
return 0;
}
再比如:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#define TASK_NUM 10
typedef void(*task_t)();
task_t tasks[TASK_NUM];
void task1()
{
printf("这是一个执行打印日志的任务:%d\n",getpid());
}
void task2()
{
printf("这是一个执行检测网络健康状态的任务:%d\n",getpid());
}
void task3()
{
printf("这是一个绘制图形界面的任务:%d\n",getpid());
}
int AddTask(task_t t);
void InitTask()
{
for(int i=0;i<TASK_NUM;i++)
tasks[i]=NULL;
AddTask(task1);
AddTask(task2);
AddTask(task3);
}
int AddTask(task_t t)
{
int pos=0;
for(;pos<TASK_NUM;pos++)
{
if(!tasks[pos])
break;
}
if(pos==TASK_NUM)
return -1;
tasks[pos]=t;
return 0;
}
void DelTask(){}
void CheckTask(){}
void UpdateTask(){}
void ExecuteTask()
{
for(int i=0;i<TASK_NUM;i++)
{
if(!tasks[i])
continue;
tasks[i]();
}
}
int main()
{
pid_t id=fork();
if(id<0)
{
perror("fork");
return -1;
}
else if(id==0)
{
int cnt=5;
while(cnt)
{
printf("child pid:%d,ppid:%d,cnt:%d\n",getpid(),getppid(),cnt);
cnt--;
sleep(1);
}
exit(11);
}
else
{
int status=0;
InitTask();
while(1)
{
pid_t ret=waitpid(id,&status,WNOHANG);
if(ret>0)
{
if(WIFEXITED(status))
{
printf("正常跑完,退出码:%d\n",WEXITSTATUS(status));
}
else
{
printf("进程异常\n");
break;
}
}
else if(ret<0)
{
printf("wait failed\n");
break;
}
else
{
ExecuteTask();
usleep(500000);
}
}
}
return 12;
}
4、进程程序替换
用 fork 创建子进程后执行的是和父进程相同的程序 ( 但有可能执行不同的代码分支 ), 子进程往往要使用进程程序替换以执行另一个完全不同的程序。
当进程调用一种exec系列 函数时, 该进程的用户空间代码和数据完全被新程序替换, 从新程序的启动例程开始执行。调用exec并不创建新进程, 所以调用 exec 前后该进程的PID 并未改变。
进程程序替换的本质就是拿别的代码和数据替换现有的代码和数据,然后从开始执行代码。
程序替换会导致页表中的物理地址发生改变,虚拟地址是不发生改变的。
4.1、替换函数
有六种以 exec 开头的函数, 统称 exec 函数:
#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[]);
这些函数如果调用成功则加载新的程序,从启动代码开始执行,不再返回原程序。如果调用出错则返回-1 。 也就是说程序替换成功后,exec系列函数的后续代码不会被执行;如果替换失败,才会继续执行后续的代码,因此只有失败有返回值,成功是没有返回值的。
这些函数原型看起来很容易混, 但只要掌握了规律就很好记:
l(list) : 表示参数采用列表。
v(vector) : 参数用数组。
p(path) : 有 p 自动搜索环境变量 PATH。
e(env) : 表示自己维护环境变量。
如图:
事实上, 只有execve是真正的系统调用,其它的函数六个函数最终都调用 execve,这些函数的关系如下图所示:
例如:我们要替换ps这个可执行程序,也就是Linux中的命令
char *const argv[] = {"ps", "-ef", NULL};
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
execl("/bin/ps", "ps", "-ef", NULL);//第一个参数是路径,中间的是可执行程序名和该可执行程序的
//选项,最后一个参数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);
4.2、单进程的程序替换
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("before:I am a process\n");
execl("/usr/bin/ls","ls","-a","-l",NULL);
printf("this is a test");//之所以不会执行该代码原因在于程序替换后跑的已经是替换后的程序了。
return 0;
}
对于单进程版本的程序替换,是直接拿别的代码和数据替换现有的代码和数据。
4.3、多进程的程序替换
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t id=fork();
if(id==0)
{
printf("I am a process\n");
execl("/usr/bin/bash","bash","shell.sh",NULL);
exit(1);
}
pid_t ret=waitpid(id,NULL,0);
if(ret>0)
{
printf("wait sucess\n");
}
sleep(5);
return 0;
}
shell.sh:其中#!是脚本语言的开头,后面跟的是脚本解释器,所谓的shell脚本语言其实就是我们写的命令。
#!/usr/bin/bash
function myfun()
{
cnt=1
while [ $cnt -le 10 ]
do
echo "hello $cnt"
let cnt++
done
}
echo "hello"
ls -a -l
myfun
对于多进程版本的程序替换,会发生代码和数据的写时拷贝。
实际上脚本语言的执行是脚本解释器在执行,例如:
python3 test.py
之所以可以跨语言调用,本质上是因为语言运行起来都是进程。
注:Linux中形成的可执行程序是有格式的,并不是杂乱无章的二进制,这个格式叫做ELF格式。在这个可执行程序的最开始有可执行程序的表头,可执行程序的入口地址就在表头当中。
注意:文件后缀为**.cpp** 和**.cc** 和**.cxx** 都表示C++的源文件。程序替换不仅仅可以替换C或C++写的程序,也可以替换Java、python、shell等程序(前提是配置了相关的语言环境)。 以**.sh** 为后缀的文件是shell脚本文件,以**.py**为后缀的文件是python脚本文件。可以使用bash 文件名的方式执行脚本文件,python也是如此。
4.4、给子进程传递环境变量
环境变量也是数据,创建子进程的时候,环境变量就已经被子进程给继承下去了。程序替换环境变量信息不会被替换。
给子进程传递环境变量的方式:
第一种:在命令行中直接导入环境变量,环境变量自然会被后来的子进程给继承。
第二种,在父进程的代码中使用putenv函数导入环境变量,环境变量自然会被子进程给继承。
第三种:自己组装环境变量,通过程序替换函数,传递给子进程。
例如:
main.c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};
pid_t id=fork();
if(id==0)
{
execle("./otherExe","otherExe","-a","-w","-v",NULL,envp);
exit(1);
}
pid_t ret=waitpid(id,NULL,0);
if(ret>0)
{
printf("wait sucess\n");
sleep(5);
}
return 0;
}
otherExe.cpp
#include<iostream>
using namespace std;
int main(int argc,char* argv[],char* env[])
{
cout<<"这是命令行参数:\n";
for(int i=0;argv[i];i++)
{
cout<<i<<":"<<argv[i]<<endl;
}
cout<<"这是环境变量:\n";
for(int i=0;env[i];i++)
{
cout<<i<<":"<<env[i]<<endl;
}
return 0;
}
运行结果为:

注:如果不传则默认继承父进程的环境变量,如果传了,则覆盖原本从父进程继承下来的环境变量。可以传环境变量表,也可以传自己定义的环境变量表,传自己定义的环境变量会覆盖系统原本的环境变量,而不是追加。
注意:环境变量表中存的不是环境变量的字符串,而是指针,这个指针指向的是环境变量的字符串,使用putenv函数添加环境变量,本质上是往环境变量表中添加一个指针,这个指针指向新环境变量的字符串。
当我们进行登录的时候,系统就会启动一个shell进程,shell进程会读取该用户目的**.bash_profile**文件,里面保存了导入环境变量的方式。
最后:区分xshell中的复制会话与复制SSH渠道,如下图:
