【Linux篇章】踏入 Linux 进程控制的奇幻迷宫,解锁系统调度奥秘(秒懂版)
本篇小鸡汤:人们会常常欺骗你,是为了让你明白,有时候,你唯一应该相信的人是你自己 。
冲冲冲!!!!!!!!!!
羑悻的小杀马特.-CSDN博客羑悻的小杀马特.擅长C/C++题海汇总,AI学习,c++的不归之路,等方面的知识,羑悻的小杀马特.关注算法,c++,c语言,ubuntu,linux,数据结构领域.
https://blog.csdn.net/2401_82648291?type=lately
欢迎拜访:羑悻的小杀马特.-CSDN博客
本篇主题:进程控制
制作日期:2025.04.14
隶属专栏:Linux之旅
目录
一·进程创建:
1.1对fork的认识:
1.2写时拷贝:
1.3fork用途及失败原因:
二·进程终止:
2.1退出码:
2.2_exit函数:
2.3exit函数:
三·进程等待:
3.1为何要进程等待:
3.2wait函数:
3.3waitpid函数:
3.4status如何提取子进程退出信息:
3.5waitpid的阻塞等待与非阻塞等待:
阻塞等待:
非阻塞等待:
四·进程中的程序替换:
4.1何为程序替换:
4.2六大替换函数(语言封装):
4.2.1execl:
4.2.2execlp:
4.2.3execle:
4.2.4execv:
4.2.5execvp:
4.2.6execvpe:
4.2.7上述函数总结及巧记:
4.3替换底层调用实现原理:
五.应用进程控制模拟实现自定义shell命令行解释器:
一·进程创建:
1.1对fork的认识:
在linux中fork函数是⾮常重要的函数,它从已存在进程中创建⼀个新进程。新进程为⼦进程,⽽原进程为⽗进程。
返回值:子进程中返回0,⽗进程返回⼦进程id,出错返回-1。
调用fork:
进程调⽤fork,当控制转移到内核中的fork代码后,内核会:
1·分配新的内存块和内核数据结构给⼦进程。
2·将⽗进程部分数据结构内容拷⻉⾄⼦进程。
3·添加⼦进程到系统进程列表当中。4·fork返回,开始调度器调度。
下面我们来演示一下:
#include
#include
#include
int main()
{
pid_t id = fork();
if(id == 0)
{
//child
int count = 5;
while(count)
{
printf("我是子进程,pid:%d 我正在运行: %d
",getpid(), count);
sleep(1);
count--;
}
}
else
{
while(1)
{
printf("我是父进程,我正在运行 %d...
",getpid());
sleep(1);
}
}
return 0;
}
首先,我们创建了子进程然后父进程与子进程同时进行,两者id各不同。
因此;我们就可以把fork后理解成分流:返回的id=0的子进程是一个流而id=子进程pid 父进程是一个流;各自干各自的事。
1.2写时拷贝:
首先我们先看一个例子来了解什么事写时拷贝:
#include
#include
#include
int gval = 100;
int main()
{
printf("父进程开始运行,pid: %d
", getpid());
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
printf("我是一个子进程 !, 我的pid: %d, 我的父进程id: %d, gval: %d
", getpid(), getppid(), gval);
sleep(5);
// child
while(1)
{
sleep(1);
printf("子进程修改变量: %d->%d", gval, gval+10);
gval+=10; // 修改
printf("我是一个子进程 !, 我的pid: %d, 我的父进程id: %d
", getpid(), getppid());
}
}
else
{
//father
while(1)
{
sleep(1);
printf("我是一个父进程 !, 我的pid: %d, 我的父进程id: %d, gval: %d
", getpid(), getppid(), gval);
}
}
printf("进程开始运行,pid: %d
", getpid());
}
代码解释:
我们定义全局变量gval;然后fork分出子进程;子进程不断修改它然后打印;父进程也在打印。
是不是有个疑问;明明子进程修改了gval但是父进程却没有变;这里就涉及到我们子父进程之间的写时拷贝了(虽然上面说了是直接拷贝给子一份;但是还是有相关规矩的)。
通常,⽗⼦代码共享,⽗⼦再不写⼊时,数据也是共享的,当任意⼀⽅试图写⼊,便以写时拷⻉的⽅式各⾃⼀份副本:
也就是子进程对应的要修改的位置会重新给它开一份空间;让它自己对自己的区域进程操作。
下面看张图:
因此,我们就可以理解为:
对于fork前存在的量;如果不修改;那么就是一份;如果一方修改那么就会写时拷贝;另一方仍旧不变。对于fork后的量就是自己两个进程各自维护;该咋样咋样。
因为有写时拷⻉技术的存在,所以⽗⼦进程得以彻底分离离!完成了进程独⽴性的技术保证!
写时拷⻉,是⼀种延时申请技术,可以提⾼整机内存的使⽤率。
1.3fork用途及失败原因:
而我们使用fork就是为了让父进程去分配给子进程让他去执行程序,任务等(用到了程序替换也就是后面我们要讲的) 。
对于使用fork而言一般不会失败;除非系统中有太多的进程与实际⽤⼾的进程数超过了限制。
二·进程终止:
进程终⽌的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。
进程退出场景: | 进程常⻅退出⽅法: |
代码运⾏完毕,结果正确 | 从main返回 |
代码运⾏完毕,结果不正确 | 调⽤exit(c库封装) |
代码异常终⽌ | 调用_exit(系统) |
异常退出也就是我们手动的了:ctrl+c,--->信号终⽌。
2.1退出码:
概念:
退出码(退出状态)可以告诉我们最后⼀次执⾏的命令的状态。在命令结束以后,我们可以知道命令是成功完成的还是以错误结束的。其基本思想是,程序返回退出代码0 时表⽰执⾏成功,没有问题。代码1或0 以外的任何代码都被视为不成功。
下面一张图主要说明Linux_Shell中的主要退出码:
退出码 0 表⽰命令执⾏⽆误,这是完成命令的理想状态。
退出码1 我们也可以将其解释为“不被允许的操作”。例如在没有sudo权限的情况下使⽤
yum;再例如除以0 等操作也会返回错误码 1。
130 ( SIGINT 或 ^C )和143 ( SIGTERM )等终⽌信号是⾮常典型的,它们属于128+n 信号,其中n 代表终⽌码。
可以使⽤strerror函数来获取退出码对应的描述。(c的函数)
#include
char *strerror(int errnum); printf("Error opening file: %s ", strerror(errno));
在多线程环境中,
strerror
不是线程安全的,建议使用线程安全版本的strerror_r
函数。这里目前作为了解即可。
#include
#include #include #include void *thread_function(void *arg) { char errmsg[100]; // 使用 strerror_r 获取错误信息 if (strerror_r(EACCES, errmsg, sizeof(errmsg)) != 0) { perror("strerror_r"); } else { printf("Thread: Error message for EACCES: %s ", errmsg); } return NULL; } int main() { pthread_t thread; if (pthread_create(&thread, NULL, thread_function, NULL) != 0) { perror("pthread_create"); return 1; } if (pthread_join(thread, NULL) != 0) { perror("pthread_join"); return 1; } return 0; }
2.2_exit函数:
虽然status是int,但是仅有低8位可以被⽗进程所⽤。所以_exit(-1)时,在终端执⾏$?发现返回值是255(这是被处理的。后面我们在进程等待模块会谈到)。
普及个指令echo$?:打印上一个进程的退出码。
正常退出就是0否则就是非0。
其次就是有个特殊点就是:它不会刷新缓冲区 :
int main()
{
printf("hello");
_exit(0);
}
就啥也不打印;因为没有 (c语言规定会遇到它刷新)即hello还停留在缓冲区。
任何地方调用exit,表示进程结束!!并返回给父进程bash,子进程的退出码!!
我们一般就是传null或者0给status。
也可以理解成这个函数是系统自己的;调用它就表示进程终止掉了。
2.3exit函数:
它是c库封装(系统的_exit)的一个函数,它会默认清空缓冲区;也就是不会发生像上面那样的情况。
int main()
{
printf("hello");
exit(0);
}
此时就会打印hello了。
exit最后也会调⽤_exit,但在调⽤_exit之前,还做了其他⼯作:
1·执⾏⽤⼾通过atexit或on_exit定义的清理函数。
2. 关闭所有打开的流,所有的缓存数据均被写⼊。3. 调⽤_exit
因此这里我们可以简单理解成:
exit封装了_exit;然后调用exit会先调清理函数,清空缓冲区等;最后才是调用系统的_exit函数。
比如我们c语言main函数最后常用return 0结束其实相等于exit(0)发挥的作用。
三·进程等待:
3.1为何要进程等待:
⼦进程退出,⽗进程如果不管不顾,就可能造成‘僵⼫进程’的问题,进⽽造成内存泄漏。
另外,进程⼀旦变成僵⼫状态,kill-9也不行,因为谁也没有办法杀死⼀个已经死去的进程。
最后,⽗进程派给⼦进程的任务完成的如何,我们需要知道。如:⼦进程运⾏完成,结果对还是不对,或者是否正常退出。
父进程等待子进程为了干什么???
1·回收⼦进程资源。
2·获取⼦进程退出信息。
3.2wait函数:
对于返回值:成功返回被等待进程pid,失败返回-1。(父进程会在这一直等着子进程)
输出型参数(wstatus):获取⼦进程退出状态,不关⼼则可以设置成为NULL(传地址)
首先来讲;它是可以等待任意子进程的;如果不想使用父进程看相关信息等;这个输出型参数就可以设置成null。
其实它就等同于我们后面讲的waitpid(-1,&status,0)(阻塞等待状态);后面会讲到。
下面我们举个例子来说一下用法:
#include
#include
#include
#include
#include
#include
#include
int main( )
{
pid_t pid;
if ( (pid=fork()) == -1 )
perror("fork"),exit(1);
if ( pid == 0 ){
printf("i am child process
");
sleep(5);
exit(10);
} else {
int st;
int ret = wait(&st);
printf("exit signal: %d,coredump:%d",st&0X7F,(st>>7)&1);
}
}
这个代码就是子进程休眠一会就退出;而父进程就在这等着;最后进行回收相关资源信息等;对于如何从st这个输出型参数中提取出信息;后面3.4我们会讲到;这里就是可以利用位运算得到上面的信息。
运行:
因为它是程序正常结束退出;故退出信号就是0(对于正常退出状态低七位都是0);而coredump对于正常退出的进程也是0。
下面形象看一下st是如何被子进程退出后的exit的码填充的:
3.3waitpid函数:
对于第一个参数pid:
如果想像wait一样那么就传-1﹔相当于等待任意子进程﹔传某子进程pid就是指定子进程 。
对于第二个参数wstatus:
同上面我们讲的wait了(用于父进程查看子进程):
这里补充一下其实我们还有专门的函数进行提取(也就不用像上面wait演示的手动提取状态等):
WIFEXITED(status): 若为正常终⽌⼦进程返回的状态,则为真。(查看进程是否是正常退出)【查看低七位】也就是我们对应的:(st&0x7F)
WEXITSTATUS(status): 若WIFEXITED⾮零,提取⼦进程退出码。(查看进程的退出码)【查看高八位】在保证WIFEXITED为0也就是子进程是正常退出的情况下才有意义。 也就是我们对于的:((st>>8)&0XFF)
对于第三个参数options:
这里可以分为填0(阻塞状态:就是父进程只能在这行代码等着子退出):此时pid_t返回-1或者得到子的pid。
或者WNHONG(非阻塞状态:就是父进程还可以执行后面的代码等):子进程还在继续就返回0,调用出错就返回-1成功就返回子pid(异常终止或者正常退出)
后序会在3.5讲到。
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在。
下面就是需要注意的:
①如果⼦进程已经退出,调⽤wait/waitpid时,wait/waitpid会⽴即返回,并且释放资源,获得⼦进程退出信息。
② 如果在任意时刻调⽤wait/waitpid,⼦进程存在且正常运⾏,则进程可能阻塞。
③如果不存在该⼦进程,则⽴即出错返回。
这里我们就先不代码演示了;留在3.5讲解阻塞和非阻塞再对waitpid演示。
3.4status如何提取子进程退出信息:
wait和waitpid,都有⼀个status参数,该参数是⼀个输出型参数,由操作系统填充。
如果传递NULL,表⽰不关⼼⼦进程的退出状态信息。否则,操作系统会根据该参数,将⼦进程的退出信息反馈给⽗进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16⽐特位):
下面请看博主结合上面的代码以及自己理解画的图:
比如我们就以_exit(-1)为例说明一下:
这里的-1变成补码截取低八位储存就是status的储存;接着从status中提取:
首先-1会变成补码储存也就是全都是1;那么由于是正常终止status低八位全为0;其次status低16位剩下的前八位就都是1(提取的存入的-1的补码的低八位)故提取八个1就是255;因此我们echo$?就是255了。
下面总结一下:
只要是正常终止那么低8位都是0;就可以查看退出状态;反之,异常的话;这个coredump会被标记;然后就会存在终止信号(也就是低7位不为0;此时就只需查看终止信号;而退出状态(退出码)就没有意义了) 。
因此根据不同的退出分为两种提取情况;我们既可以用上面的位运算提取也可以用上面给定的专属的函数进行提取查看相关信息等。即WIFEXITED(status)/WEXITSTATUS(status)或者(st&0x7F)/((st>>8)&0XFF)
3.5waitpid的阻塞等待与非阻塞等待:
下面就是我们要测试的waitpid代码了:
阻塞等待:
也就是我们的wait或者waitpid(-1,&status,0);此时父进程会在这一直等着;啥也不干。
下面我们来测试一下:
#include
#include
#include
#include
#include
#include
#include
int main()
{
pid_t pid;
pid = fork();
if(pid < 0){
printf("%s fork error
",__FUNCTION__);
return 1;
}
else if( pid == 0 ){ //child
printf("child is run, pid is : %d
",getpid());
sleep(5);
exit(257);
}
else{
int status = 0;
pid_t ret = waitpid(-1, &status, 0);//阻塞式等待,等待5S
printf("this is test for wait
");
printf("child pid:%d
",pid);
printf("child ret:%d
",ret);
//if( WIFEXITED(status) && ret == pid ){
// printf("wait child 5s success, child return code is :%d.
",WEXITSTATUS(status));
// }
if((status&0X7F)==0&& ret == pid ){
printf("wait child 5s success, child return code is :%d
",status>>8&0XFF);
}
else{
printf("wait child failed, return.
");
return 1;
}
}
return 0;}
代码解释:
子进程休眠五秒后退出;而父进程一直在这阻塞等待并回收它。
运行:
因为我们的exit传的是257;也就是我们的status它的低16位分别是前八位00000001末八位00000000(正常退出都是0);然后我们提取前八位自然就是1了。
如果要是异常退出;我们可以再开一个窗口给杀死子进程即可;下面演示一下:
这里我们可以在里面使用(status&0X7F) 把终止信号打印出来;这里就不演示了。
非阻塞等待:
下面就是我们演示非阻塞(waitpid(-1, &status, WNOHANG));父进程等待的过程可以干其他事情(如果子进程还在运行就返回0):
下面我们就来测试一下:
#include
#include
#include
#include
#include
#include
int main()
{
pid_t pid;
pid = fork();
if(pid < 0){
printf("%s fork error
",__FUNCTION__);
return 1;
}else if( pid == 0 ){ //child
printf("child is run, pid is : %d
",getpid());
sleep(5);
exit(1);
} else{
int status = 0;
pid_t ret = 0;
do
{
ret = waitpid(-1, &status, WNOHANG);//非阻塞式等待
if( ret == 0 ){
printf("child is running
");
}
sleep(1);
}while(ret == 0);
if( WIFEXITED(status) && ret == pid ){
printf("wait child 5s success, child return code is :%d.
",WEXITSTATUS(status));
}else{
printf("wait child failed, return.
");
return 1;
}
}
return 0;
}
代码解释:
这里和上面阻塞差不多;只是父进程等待过程还进行其他的打印工作等;直到成功等到就返回。
这里采取的轮巡方式;执行完回来看看子进程是否被成功等回(用的dowhile 结构完成)
运行:
同理如果信号终止也就是异常的我们仍旧可以像阻塞等待那样更改一下代码进行信号杀死进行打印信号等。
小结:
这里分析一下父进程的等待:当子进程还在运行时;父进程可以选择阻塞等;也可以返回后去做自己的事;当接收到子进程结束的信号(可以是exit的正常终止也可以是异常;然后通过wait或者waitpid把它记录到status;随后我们能得到它的相关信息;最后子进程被回收)。
这里父进程对子进程:1·资源回收2·获得一定信息
四·进程中的程序替换:
4.1何为程序替换:
fork() 之后,⽗⼦各⾃执⾏⽗进程代码的⼀部分如果⼦进程就想执⾏⼀个全新的程序呢?进程的程序替换来完成这个功能!
程序替换是通过特定的接⼝,加载磁盘上的⼀个全新的程序(代码和数据),加载到调⽤进程的地址空间中!
⽤fork创建⼦进程后执⾏的是和⽗进程相同的程序(但有可能执⾏不同的代码分⽀),⼦进程往往要调⽤⼀种exec函数以执⾏另⼀个程序。当进程调⽤⼀种exec函数时,该进程的⽤⼾空间代码和数据完全被新程序替换,从新程序的启动例程开始执⾏。调⽤exec并不创建新进程,所以调⽤exec前后该进程的id并未改变。
下面我们来抽象理解一下:
我们可以把程序替换理解成:当一个进程如果想进行程序替换;它必然调用exec接口函数(后面我们会讲到);然后这个函数就会找到对应的程序;相当于把对应代码展开覆盖下去【那么如果成功调用后我们exec函数后面的代码就都被覆盖掉不可能执行了;如果调用失败就返回-1;后面不覆盖接着执行】然后我们根据传递的执行程序;对应命令行参数等传递给对应main中执行里面的代码。
下面我们以我们命令行输入的ls -l -a为例子用程序替换解释一下:
首先ls就是我们一个可执行程序;然后-l -a是一些命令行参数;首先,当我们调用这个命令的时候;那么父进程会让子进程去执行;因此子进程就会调用对应的exec函数进行程序替换;即找到对应ls的代码然后把-l -a作为命令行参数传给main中的argv数组;然后也就是argv[0]=ls;argv[1]=-l;argv[2]=-a;然后对应的替换后的main中按照这个参数是啥进行分块执行对应的代码;进而来完成我们想要的操作。
总结一下:
对于程序替换;我们可以模糊理解成就是把对应执行程序代码替换覆盖到当前以及后面的位置;然后根据传进来的命令行参数来执行对应的代码即可(此时环境变量也就是environ指针指向的是我们传进来的环境变量【后面会讲到是什么】)。
4.2六大替换函数(语言封装):
这里我们想必execvpe而言还是execvp更多一些。
首先;先普及一下:这些函数它们除了带p的都会默认向当前目录进行查找指定程序(如果当前没有的话就按照给的路径或者环境变量PATH中找(有p)否则直接返回-1;替换失败);对于带p的;它只会在PATH中找(或者表明当前目录如./t)否则就失败。
下面我们来一次讲一下它们的用法(不要忘记对应要以NULL结尾):
4.2.1execl:
int execl(const char *path, const char *arg, ...);
这里我们所需要传递的就是对应文件的路径以及 链表形式的 程序名字,命令行参数等。
当然了对于path如果要替换的程序是当前目录就可以直接文件名字否则就要加上路径了。
下面我们来执行一下对应我们上面解释的ls -l -a命令(用程序替换):
这里如果我们ls在当前目录的话path就不用全写;直接ls即可(因为默认都会当前目录找一下)。
巧记:这里execl只有 l;说明我们要以链表形式传递(勿忘NULL)而无e说明只需传命令行参数;环境变量参数无需传。
4.2.2execlp:
int execlp(const char *file, const char *arg, ...);
下面使用一下:
PATH中没有但是表明了当前目录:
可执行程序t的源代码:
#include
#include
int main(int argc, char *argv[], char *env[])//argc自动识别多少个命令参数
{
printf("hello,world
");
for(int i = 0; i < argc; i++)
{
printf("argv[%d]: %s
", i, argv[i]);
}
printf("
");
for(int i = 0; env[i]; i++)
{
printf("env[%d]: %s
", i, env[i]);
}
return 0;
}
帮助我们打印所传递给被替换程序的命令行参数数组以及环境变量数组。
运行下:
这里我们会发现明明没传环境变量数组;但是它却打印了;因为底层默认如果没有维护自己的环境变量数组就会默认把environ(也就是全局的)传给execve(系统的程序替换函数)函数接着就到了main中了(也就是我们所看到的)。
巧记: 这里由于我们加了p故它可以除了当前目录找还可以去环境变量表了;因此我们path就不用输入只需要输入文件名即可;而无e说明只需传命令行参数;环境变量参数无需传。
4.2.3execle:
int execle(const char *path, const char *arg, ...,char *const envp[]);
同上面大差不大;来测试一下:
这里就需要我们手动传递自己维护的环境变量数组了(有e)。
这里可能会有个疑问;明明没有路径(p)为什么直接一个文件就ok;因为上面说了它(不带p)默认都会先在当前目录查找一下的。
测试结果:
完成了env的覆盖了(后面原理会讲到)。
巧记:有l故命令行参数以链表形式(勿忘NULL);其次就是有e故需要自己手动传递维护的环境变量数组。
4.2.4execv:
int execv(const char *path, char *const argv[]);
这里和上面一样只不过把命令行参数从list的形式变成了vector了。
演示一下:
同上面。
巧记:有v故把命令行参数以数组形式传入;无e故直接无需传递环境变量数组。
4.2.5execvp:
int execvp(const char *file, char *const argv[]);
这里我们直接给它表明当前目录;如果要是填别的目录的程序要么直接把路径和程序都加上要么直接写个程序名(但是PATH要导入它的路径【利用getenv和putenv导入】)因此这样还不如直接用不带p的
下面我们演示一个PATH本身就存在这个可执行程序路径的:
这样;只要文件名本身就可以执行:
巧记:带v故命令行参数以数组形式;带p故直接文件名即可(但是要保证PATH中一定存在对应路径)否则就要传绝对路径咯
4.2.6execvpe:
这个不经常用但是我们还是演示一下:
同理只不过是多了个环境变量数组而已。
巧记:有v故命令行参数用数组形式;有p故如果PATH可以找到就直接传文件即可;有e故需要环境变量数组。
4.2.7上述函数总结及巧记:
这些函数如果调⽤成功则加载新的程序从启动代码开始执⾏,不再返回。如果调⽤出错则返回-1;所以exec函数只有出错的返回值⽽没有成功的返回值。
上面每个函数例子后面都有提到记忆方法。
l(list):表⽰参数采⽤列表。
v(vector):参数⽤数组。
p(path):有p⾃动搜索环境变量PATH。e(env):表⽰⾃⼰维护环境变量。
对于execvpe因为和execvp差不多;只是多了个需要重组自己环境变量而已。
最后一个是系统的函数;也就是前六个函数最后都会调用execve。(后面讲原理会提到)
4.3替换底层调用实现原理:
下面我们将详细讲一下关于上面六个函数是如何完成底层调用的:
下面我们先看张图:
我们不难明白最后它都能转化成execve函数了(系统自己的):
int execve(const char *path, char *const argv[], char *const envp[]);
下面我们就来抽象解释一下(通俗易懂):
首先命令行参都要变成数组形式然后传给execve的第二个参数然后有p的就去PATH找到路径与文件传给它第一个参数;其次就是没有自己维护的环境变量数组的就用全局环境变量指针的environ传给第三个参数;否则就是用自己维护的环境变量表给第三个参数(但是这样就会覆盖了;即原来全局的都没了换成了我们传入的了)。
下面就是执行execve函数了:
根据第一个参数展开要执行文件的代码;然后把后两个参数分别传给该文件的main函数参数里;接着执行展开的代码就ok了。
需要注意的就是:
我们替换后得到的那个程序它的代码中的环境变量是被完全覆盖掉的(被我们传递的environ或者自己维护的环境变量数组覆盖)也就是被替换后的main中我们使用environ访问的是我们传递进来的环境变量数组。
五.应用进程控制模拟实现自定义shell命令行解释器:
下面就是我们应用本篇所讲解的进程控制相关的知识来实现的小项目;见博主的这篇文章:传送门:Linux命令行解释器的模拟实现_linux命令模拟-CSDN博客