进程的控制(进程终止/进程等待/进程程序替换)

fork()函数初识

✳️
fork()有两个返回值,会返回两次,这是因为父子进程都会执行return;
同一个id会有不同的值,是因为地址空间的存在,一个地址能够通过页表映射到不同的物理内存,会产生出同一个变量会不同的值 。

✳️在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

✳️#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1

1

✳️进程调用fork,当控制转移到内核中的fork代码后,内核做:
1.分配新的内存块和内核数据结构给子进程:一个是task_struct需要创建出来,然后第二个mm_struct进程的地址空间也要创建出来。

2.将父进程部分数据结构内容拷贝至子进程:以父进程为模版,子进程的相关数据结构设置为和父进程的相关字段保持一样,最典型的是task_struct里面有很多都一样,包括地址空间mm_struct也是一样,地址空间区域划分也是一样,但是拷贝也不是无脑拷贝,一定会有自己特有的一部分内容,最基本的就是pid、ppid、调度的时间片等等会有些不一样。

3.添加子进程到系统进程列表当中:将子进程列入到运行队列当中

4,fork返回,开始调度器调度:上面三个工作都已经做好了,父进程当然继续执行,执行return,而子进程有没有被调度呢?有可能被调度了,所以子进程也有可能会执行return,返回的本质就是通过寄存器向我们的接受变量写入,写入的本质就是要进行修改,所以会发生写时拷贝,进而让同一个变量会出现不同的值这样就完美解释了fork之后会有两个不同的值。

✳️所以fork之前父进程独立执行,fork之后,父子两个执行流分别执行。注意fork之后,谁先执行完全由调度器决定。
因为fork之前只有父进程,所以父进程独立执行;
fork之后有两个进程了当然两个执行了分别执行;
❓:那么fork之后,是否只有fork之后的代码是被父子进程共享的??
我们之前有个观点:进程具有独立性。除了我们感受到的数据结构方面是独立的,代码和数据也都是独立的(给了写时拷贝的解释)。
我们说过代码只能读取,因为我们之前写C/C++觉得进程的代码不合适,让代码把自己程序代码改改,从来没有,我们基本改的都是数据。因为是只读的所以不可能被写入,也就不可能发生写时拷贝。确实如我们所说,。fork之后代码是共享的!
❓但我们要明确一下fork之后代码共享指的是什么??(fork前面的代码呢,前面的代码也是父进程的,既然是父进程,那么会不会也被共享呢?)
✳️fork之后代码共享不算准确,明确一下fork之后,父子共享所有的代码! —➡️所以子进程执行的后续代码 != 共享的所有代码,只不过子进程只能从里开始执行。
❓那么为什么子进程只能从fork之后开始呢?
解答:是因为,我们要记住,当我们在执行进程的时候,CPU里面有一批寄存器,寄存器会保存当前执行到什么位置,叫做eip寄存器,也叫做程序计数器。eip叫做,保存当前正在执行指令的下一条指令!(也叫pc指针)也就是说我们的代码被编译之后,会变成我们对应的具有地址的,所以我们怎么知道程序从上往下执行,接下来该循环了,循环怎么实现的,函数跳转又是怎么实现的,本质上就是通过修改eip来执行的,eip在创建子进程的时候,属于当前父进程的上下文,它会被拷贝到我们子进程当中。
所以eip程序计数器会拷贝到子进程,子进程便会从该eip所指向的代码块处开始执行啦!(比如说我们进程有很多行代码,当我们eip指向第一行,CPU就读取第一行,CPU读取指令总是伸手向eip去要,一旦把指令读到CPU后,eip自己就会做++,指向下一行代码,就这样不断指向下一条指令,那么fork指向完毕后会指向下一条命令,接下来就是创建子进程了,创建完成后吧eip拷贝给子进程,所以子进程也只能从这里开始,所以并不能代表子进程只有fork之后的代码 如果你愿意是可以拿到的,如果我们将子进程的eip改成main函数的入口不就行了吗)
✳️fork之后父子进程必须保证独立性,必须保证独立性就必须代码和数据独立,数据以写时拷贝的方式独立,而代码是两个共享的,因为代码是只读的,不需要被修改,如果你想修改,你把它改一下也不是不行,你再来一份也可以,但就是两份一样的代码了,白白浪费资源
请添加图片描述

2

❓今天谈论fork创建子进程那么操作系统给我们做了什么?
✳️进程=内核的数据结构 ➕ 进程的代码和数据;
fork创建子进程:无非就是创建子进程的内核数据结构(task_struct + mm_struct + 页表) + 代码继承父进程,数据以写时拷贝的方式,来进行共享或者独立!

fork的常规用法

✳️第一种用法是,我们创建一个子进程来和父进程做类似的事情,但不是指一样的事情,最典型的就是fork之后,用if/else来进行分流,父子进程执行不同的代码块。
第二种就是创建一个子进程做和我父进程不一样的事情,这个会在讲进程程序替换的时候会讲。

fork调用失败的原因

✳️系统进程过多
每个用户创建的进程是有限制的

写时拷贝

通常代码是共享的,父子不再写入,一旦写入了程序也就报错了,当任何一方进行写入,便以写时拷贝写入,各自产生一个副本。

子进程创建之后,父子进程都有地址空间和页表,只不过子进程的各种映射关系都继承于父进程,所以父子的代码是共享的。后面会讲页表里面会包含读写属性
。也表不单单会维护映射关系,也还有一些读写属性的设置。
请添加图片描述

❓写时拷贝是怎么实现的呢??
其实就是我们创建子进程之后,我们把所有页表的映射关系改成只读的,不管是父进程还是子进程,当我读取的时候,就给你正常读,当你写入的时候,当然也会判断当前内存区域是代码还是数据,比如说我们想写入一部分数据时候,父进程自己没有写他不管,它依然指向自己的数据地址,而子进程尝试写入,则操作系统会讲数据拷贝一份到新的内存空间,然后给子进程的映射关系作修改。
所以都没有写入的时候,父子进程页表下建立虚拟地址和物理内存地址的映射关系指向同一块内存,但一旦你写入的时候,操作系统给你拷贝之后,重新会改写子进程页表的映射关系,让子进程虚拟地址指向新的物理内存空间,然后会把父子页表维护的读的属性给去掉,改成正常的属性。
🌟写时拷贝本身就是有OS的内存管理模块完成的

❓操作系统为什么会这么干??(为什么要写时拷贝?)
我们在创建之时就可以把父子进程分开呀!有什么不行吗?创建后你们的代码共享呗,但是创建的时候我就把数据各自拷贝一份,因为我们总要保持进程的独立性嘛。 所以创建的时候就给子进程拷贝一份数据,父子进程完全独立了!这样不行吗?
❓创建子进程的时候,就把数据分开,不行吗??
1.父进程的数据,子进程不一定全用,即便使用也不一定全部写入。我不写入它呀,我可能只写入一个,而你拷贝所有是不是空间会浪费?-----会有浪费空间嫌疑。
(在创建的时候,就把你子进程要用的数据拷贝一份不就好了嘛?先不谈技术上能不能实现,我不修改的数据你拷贝一份不还是浪费嘛?更重要的是在技术上不好实现)
2.最理想的情况,只有会被修改的数据,进行分离拷贝,不需要修改的共享即可-----但是从技术角度实现复杂
3.如果fork的时候,就无脑拷贝数据给子进程,会增加fork的成本(内存和时间)

✳️所以最终我们采用写时拷贝:只会拷贝父子修改的,变相的,就是拷贝数据的最小成本。
❓拷贝成本依旧存在,那么我为什么一定要等到写的时候才拷贝呢?(这回到之前讲过的故事:你想要你妈妈买玩具车,可是你晚上买了又玩不了,所以等明天能完了再给你买)
所以写时拷贝其实是延迟拷贝的策略:只有真正使用的时候,才给你。
✳️你想要,但是不理吗使用的空间,先不给你,那么也就意味着可以先给别人!这样是一种变相提高内存使用率!(拷贝成本总是得有, 是早给你还是晚给你,你现在不用,当你要时候给你,晚给你意味着着这段空间可以给别人使用,这样内存使用率非常高的!)
请添加图片描述

进程终止

关于终止的正确认识

我们在写C/C++的时候,main()函数是入口函数(main函数在虚拟地址空间是确定的,当程序编译好,加载到内存的时候,我们就把main函数的地址喂给CPU的eip,CPU就从eip的开始出执行你的代码,这就是虚拟地址空间,甚至大家都可以认为自己main函数的入口地址都是一样的,因为每个程序都认为自己有4gb的地址空间,所以main函数的入口可以是个常数。)我们main函数总是会有return 0。
❓main函数retun给谁?

✳️常见进程退出有三种退出方案
1.代码跑完结果正确。(比如说我们今天写了排序算法,代码跑完,结果也成功了升序排序。)
2.代码跑完,结果不正确。(上面反的)
3.代码没跑完,程序异常了。(比如说我们写了链表插入,代码里面有野指针,结果节点没有插入成功,程序直接崩溃了,在win下叫程序崩溃,但在今天应该意识到是出现异常了!异常导致进程退出,代码不会被执行,也就导致程序退出,后面会专门有进程异常的话题 )—什么叫做异常呢?为什么终止了呢?是谁把它终止了呢?终止之后又做了什么呢?这些问题要在系统部分才能回答! 也是后面在进程信号会解决!

❓为何是0?其他值可以吗?
✳️传说中main函数的返回值为何是0,其他值可以吗?如果return 返回0代表进程进程代码跑完,结果是否正确,用0表示sucess,非零,表示失败。无脑写0是不正确的 。
当非0(失败的)的时候最想知道什么?失败的时候我最想知道失败的原因!所以,非零标识不同的原因!

✳️我们把main函数的return返回值叫做“进程退出码”!
进程退出码表征了退出的信息(曾经前面说过进程退出的信息概念,进程退出不就进入了僵尸状态吗?进程退出变成僵尸状态不就得被父进程/操作系统读取之后,才能进入X状态得到释放吗。)
所以将来的退出信息是要给父进程读取的!
所以我们曾经说创建子进程是要其完成事情的,但他完成的怎么样我怎么知道。所以等待虽然还没讲,但是我们已经给退出做了铺垫,相当于现在退出码我们已经有了,有了之后我们只要不出现异常,我就可以通过退出码来知道事情办的怎么样。父进程就可以通过子进程退出码得知退出原因。
编写一个程序验证退出码:编写myoproc程序编写return 123;然后在命令行运行,我们曾经讲过./myproc的父进程是bash进程,又讲过#echo命令,echo命令是内置命令,它是让bash自己执行bash内部的函数 。
#echo $?便可以得到myproc的退出码。
$?表示在bash,最近一次执行完毕时,对应进程的退出码!

✳️一般而言,失败的非零值我该如何设置呢?以及默认表达的含义?—都是自定义的!非零是什么意思自己知道就好

✳️错误码退出码可以对应不同的错误原因,方便定位问题!

✳️strerror()函数【将一个错误码转化成错误码描述】

#include <string.h>
char *strerror(int errnum);
int strerror_r(int errnum, char *buf, size_t buflen);    
char *strerror_r(int errnum, char *buf, size_t buflen);

请添加图片描述

进程终止的常见方法

🌟1.在main函数中return,可以代表进程退出
❓为什么其他函数不行呢??—不可以!非main函数return代表函数调用结束,也就是他只代表函数调用的返回值,main函数retrun才代表进程退出

🌟2.在自己的代码中任意地点,调用exit(),main函数可以调用,非main函数也可以调用,都能代表进程退出。

✳️exit()函数【进程终止,返回退出码】

1.用法
#include <stdlib.h>
void exit(int status);

2.打样
void Func()
{
	 printf("hello world\n");
	 exit(111)------➡️意思是说main函数调用了Func函数,执行了exit,表示进程都没返回,在函数内部就把进程终止了,这就叫调exit直接终止进程
}

🌟3.
✳️_exit()函数----不常用,基本都是用exit()
exit调用了_exit()函数,但是exit终止进程,会刷新缓冲区
_exit直接终止进程码不会有任何刷新操作
要在基础IO才会讲缓冲区的问题。
请添加图片描述

关于终止,内核做了什么?

✳️进程 = 内核数据结构 ➕ 代码和数据,我的内存是被加载到内存里的,当一个进程不跑了,它终止了,它首先要做的就是进入Z状态,进入Z状态后,父进程会等待他,回收它子进程的信息,然后将进程设置为X状态,X状态才是真正的退出,退出就是释放内核结构和进程加载到内存时所对应的代码和数据 ,这就是退出的时候做了什么。
请添加图片描述

✳️了解一下
代码和数据必定会被释放掉,因为代码和数据没有什么价值,你该怎么样就怎样,空间你占了多少全都给你free掉,所以对我们来讲,进程在终止的时候,有个东西叫做内核结构,最典型的就是task_struct&&mm_struct数据结构,将来是用这些数据结构定义出对象,充当内核的结构,实际上操作系统可能并不会释放掉该进程的内核数据结构 (取决于空间内存够不够,和空间内存管理策略有关系),我们要知道的是可能不会释放,那不释放难道一直占用吗?它也没有一直占用,相当于创建进程我们要从0开始构建对象。
我们也知道创建对象:一是要开辟空间;二就是初始化;无论开辟空间还是初始化都是要花时间的,那么我们Linux会维护一张废弃的数据结构列表,假如称之为obj,所以当进程被释放后,相关的数据结构会被维护进链表当中,不要的数据结构全部维护进去,它的结构已经被操作系统释放掉,只不过空间并没有 释放掉,只是把它设置为无效,当我们再次创建队列是,它会从链表当中去出相应的task_struct&&mm_struct拿出来,拿出来不就节省了开辟空间所花费的时间吗?然后只要做初始化就够了。
这种就可以理解成内核的数据结构缓冲池,s也叫lab分派器。

进程等待

进程等待的必要性

·之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
(所以我们必须得有办法使他从Z状态切换为X状态,进而允许操作系统去释放它,所谓释放就是把代码和数据释放掉,把相关数据结构归还给slap分派器或者释放掉)
·另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。

·最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
(获取子进程的退出状态-----进程退出是有退出码的 ,换句话说,我们是需要让子进程退出时,它的return结果,或者exit结果需要被父进程读到的,从而让我们的父进程得知子进程把事情办的怎么样)

·父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
请添加图片描述

✳️为什么要父进程获取子进程的退出状态呢?举个例子:领导让你去和客户谈生意,作为领导我不可能静静的一句话不跟我们说,要给我们汇报工作,所以叫我需要获取你的退出码,我为什么要获取你的退出码呢?因为我还想要汇报给领导,所以父进程创建出子进程,要获取你的退出结果,父进程要向他的领导汇报。我们在命令行启动的程序,那父进程要向谁汇报呢?就是我们用户呀!

如何进程等待

wait()函数

✳️让父进程掉wait函数

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);-----参数先暂时不讲,后面会去讲
pid_t waitpid(pid_t pid, int *status, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

返回值:成功时会返回等待的进程的pid,失败的话是-1。

我们做实验,用wait函数等待子进程退出,看是否子进程的僵尸状态Z能否转成X状态(其中我们用kill命令杀掉子进程)。其中启动监控脚本
#ps ajx | head -1 && ps ajx | grep myproc | grep -v grep
#while : ; do ps ajx | head -1 && ps ajx | grep myproc | grep -v grep; echo “------------” -----➡️—是自己想打的分隔符

✳️ wait()的方案可以解决回收子进程Z状态,让子进程进入X状态。

waitpid()函数等待

✳️我们实际上在进行进程等待的时候用的是waitpid()函数

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

wai()是等待任意一个退出的子进程;
wait()waitpid()的子功能,waitpid能够完全包含wait
wait()waitpid()都是系统调用

1.认识返回值
pid_t:>0 等待子进程成功,返回值就是子进程的pid。
	   =0 表示等待成功,但是子进程没有退出(非阻塞等待是这样的,阻塞等待是没有的)
	   <0 等待失败 。


2.认识函数参数:
🌟pid:它的值在设置的时候,我们今天最重要的是设置两种值
		一种是设置为 >0:值是多少就代表等待哪一个子进程,若pid=1234就代表我们等待的是pid为1234的进程----➡️代表等待指定进程。
		另一种是设置为 -1:代表等待任意进程。

🌟options:今天我们先设置为0----若要阻塞等待
0表示 阻塞等待(子进程说你等我,我就要退出吗?我退出你就等吗?你要是等的时候我不退出呢?好那我不退出,你等我挂呢,我不挂你怎么办,那么你等待的一方就要阻塞,这是一种软件条件的阻塞。我们以前学习c++,我们主要用的是CPU资源,CPU快感觉做什么事都会很快,但我们学系Linux和网络最终的目的是一定要吧IO学好,当IO的时候一定会存在大量的等待,今天虽然不是IO,但也是一个进程等另一个进程)
		若要非阻塞等就设置为:WNOHANG这样一个选项 

🌟status:是一个指针参数,这个参数,是一个输出型参数(意思就是说我不想传进去什么参数, 我想从里面拿出些东西),通过调用该函数,从函数内部拿出来特定的数据,可以看出来拿出来的一定是一个整数。
		从操作系统中拿出来吗?  
		我们要让父进程等待子进程,父进程的task_struct,子进程的task_struct,子进程会执行自己的代码,当子进程退出的时候,执行了return或者exit,那么子进程相当于退出了,退出之时子进程会将自己的信息写入自己的进程控制块PCB中,那么子进程进入Z状态,本质上就是将自己的PCB维护起来,代码可以释放,但是task_struct必须维护,此时就是Z状态,父进程叫用waitpid接口就会在操作系统内部找到它的task_struct提取出它的退出码
换句话说我们wait和waitpid拿出来退出信息是从子进程的进程控制块PCB中拿出来的。
	所以是从子进程的task_struct中拿出子进程退出的退出码!
	所以wait/waitpid是系统调用,就是调用系统的功能,能不能把子进程的退出码从子进程当中拿给父进程呢?传入其status当中呢?当然可以!
	若父进程等的时候压根没退出,那么父进程就是阻塞式的等,等子进程退出之后才正式拿出这里等status 。

现在要验证子进程退出只拿退出码吗?但实际上还包含其他东西。	

✳️waitpid()—➡️阻塞等待和非阻塞等待:当我们调用某些函数的时候,因为条件不就绪,需要我们阻塞等待,本质:就是当前进程自己变成阻塞状态,等待条件就绪的时候,再被唤醒。
我今天在等待一个子进程,将我自己父进程的task_strucrt进程状态设置成S,变成阻塞状态,我们子进程task_struct内部也维护了一个task_strucrt* queue等待队列,我们父进程要去等待子进程 ,从R状态变成S状态,将自己放入到子进程task_struct的等待队列里。当进程退出时,操作系统最清楚,操作系统发现子进程退出了,将父进程唤醒继续执行waitpid(),回收子进程的退出信息!
所谓的阻塞其实就是进程阻塞。我们今天等的不是磁盘不是硬件,而是等的是硬件。
所谓条件不就绪,是软硬件不就绪。
请添加图片描述

理解阻塞等待和非阻塞等待

阻塞

✳️如何理解父进程阻塞?
我们说过一个进程在系统层面上要等待某件事情发生,但是事情还没有发生,代码便无法继续向后运行,那么此时只能让进程处于阻塞状态。
本质上就是将父进程运行状态由R变为S,将task_struct放入等待队列,等子进程退出。
子进程退出,本质是条件就绪;
那么接下来操作系统就会逆序进行上面工作,将父进程对应的PCB从等待队列里,搬到运行队列,并将进程状态由S设置为R,则父进程会继续执行回收退出信号。
上层用户层表现就是进程卡住。

✳️由小故事(找人给我复习的例子)解释阻塞,并引出非阻塞等待。
学渣从来没学过屁颠屁颠的跑到8号楼学霸楼下,找他划重点进行复习。然后打电话给学霸,但是学霸说叫学渣等他一下。学渣说你忙你的,但你不要挂电话,你保持电话一直畅通。然后学渣一直渴望电话另一头传来的声音。
此时打电话的过程相当于调用函数接口(waitpid())的过程,学渣相当于父进程,然后父进程叫OS去回收一下子进程退出结果并把他的僵尸进程也给回收了,操作系统说可以,但你得等一会儿,因为它还没退出,然后对操作系统说你别挂,接口你别返回,你就让我一直卡在接口里面。waitpid()里面也有很多代码,在某些代码内部让我阻塞住,当子进程就绪了你给我说一声,把我唤醒,唤醒之后再继续向后执行,后面才返回。所以打电话相当于调用接口函数的过程。然后学渣卡在等电话的过程中,这就是阻塞。
当打电话检索条件就绪的时候,这个时间段内什么都不做,就是阻塞状态。
在内核当中,实际上系统调用waitpid() ,阻塞实际上是进程阻塞,进程阻塞实际上就是改变进程状态,吧R改为S,把PCB放到等待队列里,这些都是操作系统干的,恰好waitpid()是系统调用,恰好里面有判断条件没就绪把你进程阻塞
过了几天又去找学霸,打电话给学霸,学霸说等他然后把电话挂了,然后学渣忙了一会儿做自己的事情,然后过了一会儿又给他打电话,然后说没好,自己又去做自己事情;过了一会儿学渣又打电话,然后问好了没有,学霸说还是没有;然后你又去忙自己的事情;过了一会儿又去打电话…来来回回10几次学霸才说事情忙完了。
学渣是用户,学霸是操作系统,学渣给学霸打电话就是调用接口waitpid(),如果此时没好就直接返回(就是挂电话)相当于用户去掉waitpid()让操作系统去检测一下子进程是否退出,没有退出waitpid()就直接返回,然后就忙自己的事情;做了一会儿事情后再去询问。这就是非阻塞!

非阻塞

✳️多次调用非阻塞接口,这种叫做轮询检测。
✳️非阻塞就意味着,在每次等待完毕之后会紧接着再去进行处理我们其他的事情,可以让父进程做其他更多的任务。
看一段代码:非阻塞轮训检测
关于函数指针和typedef函数指针见以下
函数指针
typedef函数指针

typedef void (*handler_t)();-----➡️对函数指针定义了一个类型,类型名为handler_t,指向某一类函数的指针,也可以说是指针类型  

//方法集
std::vector<handler_t> handlers;-----➡️用vector存储一个个函数方法(函数指针)

void fun1()
{
    printf("hello, 我是方法1\n");
}
void fun2()
{
    printf("hello, 我是方法2\n");
}

void Load()
{
    //加载方法
    handlers.push_back(fun1);------➡️函数名可以初始化函数指针
    handlers.push_back(fun2);
}

int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //子进程
        while(1)
        {
            printf("我是子进程, 我的PID: %d, 我的PPID:%d\n", getpid(), getppid());
            sleep(3);
            //int a = 10;
            //a  /= 0;
            //int *p = NULL;
            //*p = 100; //野指针
            //break;
        }

        exit(104);
    }
    else if(id >0)
    {
        //父进程
        // 基于非阻塞的轮询等待方案
        int status = 0;
        while(1)
        {
            pid_t ret = waitpid(-1, &status, WNOHANG);
            if(ret > 0)
            {
                printf("等待成功, %d, exit sig: %d, exit code: %d\n", ret, status&0x7F, (status>>8)&0xFF);
                break;
            }
            else if(ret == 0)
            {
                //等待成功了,但是子进程没有退出
                printf("子进程好了没,奥, 还没,那么我父进程就做其他事情啦...\n");
                if(handlers.empty()) Load();
                for(auto f : handlers)
                {
                    f(); //回调处理对应的任务
                }
                sleep(1);
            }
            else{
                //出错了,暂时不处理
            }
        }

谈一谈函数参数status

我们目前知道他是一个整数,它是包含一个进程的退出码,实际上进程退出还有一些其他信息,今天还要再讲一个,有一个叫做异常退出(前面进程退出有提过)

✳️ 下面我们正式提一下status的构成:
虽然他是一个整数,但整体并不是作为一个整数去使用的,而是被当作一个位图结构。
关于status是一个整数,我们只需要关心:该整数的低16个比特位,而低16个比特位是会被分为3部分的。
🌟第一部分叫做整数的低16位,高16位我们不关心,因为低16位足以让我们判断了,所以高16位不需要关心。低16位当中,由低到高,8-15表示的就是退出状态,也就是通过status的次低8位就可以得到子进程的退出码。好我们赶紧去验证一下,如何验证呢?status不是整体被使用的,而是区域性的被使用,既然是区域性的被使用,那么次低8位使用做退出状态的,那么我如何提取次低8位呢?
如何拿出整出的次低8位呢?那么这里是涉及到了一位操作,那么这里就是用到右移,因为是次低八位嘛,那就是右移8位,右移8位后,我们这里的次低8位变成了最低8位,那么我们要做的就是把最低8位保留,高24位全部清零,所以我们有通过按位与(&)上0xFF----➡️经过实验是可以拿到的!
🌟我定义个一个全局变量code,然后子进程对全局变量作修改,我在父进程能拿到修改后的值吗?(因为我觉得用waitpid获取整数等等操作好麻烦,那我定义一个全局变量code,然后我让子进程退出一设置,父进程不就看到了吗?)-------➡️不可以!因为进程具有独立性,你虽然code是全局性,但是父子进程任何一方尝试去修改变量,它一定会发生写时拷贝,所以你父进程是拿不到子进程的数据的!

下面我们再进一步谈谈,你刚刚终于给我证明了退出状态,那么我今天是不是就可以根据退出状态为0还是非0来判定子进程退出结果是否符合我的预期,好的我懂了,你说的挺对的。这就代表代码跑完结果对,代码跑完结果不对。
🌟那如果我异常了呢??就引出了最低8位是用来表示处理异常情况,今天有一个字段我们还无法解决就是:core dump标志,只占了一个比特位(什么是core dump?以及core dump是用来干什么的,以及我们怎么从来没见过的问题暂时不说。)我们现在只关心最低8位和次低8位表示进程退出时收到的信号
一个结论:一个进程退出,如果异常退出是因为这个进程收到了特定的信号(比如kill -9)。
我们刚刚讲的全都是正常退出那如果是异常退出呢?所以进程的异常退出我们可以模拟。我们子进程永远不退出,然后父进程就永远在那里阻塞式的等。
当一个进程在退出的时候,除了有退出码,还有可能当一个进程异常的时候,它会收到特定的信号,那么怎么做呢?
我们status>>8(右移8位)并不影响本身的值,只有status >>=8才会改变。我们此时status&0x7F(7就是3个1然后F就是4个1正好7个1)所以我们就提取出来了子进程的异常退出信号。
我们用kill命令来给子进程发信号(kill有很多信号都可以给子进程发出去)。或者我们在子进程的代码里面写10/0(除0错误)等等使进程出现异常退出。
那么操作系统凭什么终止我?它发信号怎么发呀?发信号的时候又是怎么把我终止的?详细的过程是怎么样的呢?----➡️这些都得等到讲信号才能说。

✳️退出信号代表进程是否正常,退出码代表进程将代码跑完了结果对还是不对。

❓退出码和退出信号我们应该先看谁?—退出信号
✳️解答:常见进程退出:
1.代码跑完,结果正确;
2.代码跑完,结果不正确;
3.代码没跑完,程序异常了。
一旦进程出现异常,只关心退出信号,退出码没有任何意义。
所以应该先关心退出信号!
请添加图片描述请添加图片描述

✳️其实我们也可以不用位操作来看退出信号/退出码,系统已经设计好了宏来供我们调用。
等待成功不一定代表子进程成功了,而是调取函数成功,获取退出信息成功。
应该这么写代码

if(ret > 0)-----进程正常退出
{
	if(WIFEXITED(status))-----检测是否正常退出(即检测信号是否为0)
	{
  	   printf("子进程是正常退出的,退出码: %d\n", WEXITSTATUS(status));---获取退出码
	}
}

请添加图片描述

我们的父进程是在阻塞性的等待
int main()
{
    pid_t id = fork();
    if(id == 0)
    {
        //int cnt = 5;
        //child
        while(1)
        {
            printf("我是子进程, 我正在运行...Pid: %d\n", getpid());
            sleep(5);
            break;
            //int a = 10/0;
          //  cnt--;
          //  if(!cnt)
          //  {
          //      break;
          //  }
        }
        exit(10);
    }
    else
    {
        int status = 0;
        printf("我是父进程:pid:%d, 我准备电脑等待子进程啦\n", getpid());
        pid_t ret = waitpid(id, &status, 0);
        if(ret > 0)
        {
            if(WIFEXITED(status))
            {
                printf("子进程是正常退出的,退出码: %d\n", WEXITSTATUS(status));
            }
            //code??可以吗??
            //printf("wait success, ret : %d, 我所等待的子进程的退出码: %d, 退出信号是: %d\n", 
            //        ret, (status>>8)&0xFF, status&0x7F); //status >>= 8;
        }

进程程序替换

✳️1.是什么?(概念,原理)
2.为什么?(一句话)
3.怎么办?(编码,如何进行程序替换)

是什么?(概念,原理)

概念

✳️我们有时候创建一个子进程,子进程执行的是父进程的代码片段(曾经讲过,我们曾经创建过的子进程,是要做到和父进程代码共享的,然后通过if/else进行分流执行,然后对数据作修改后要通过写时拷贝,通过让同一个变量由页表完成同一个虚拟地址映射到不同的物理内存地址,让父子进程得到不同的值,即fork返回的id值,从而判定让父子进程执行不同的代码片段)
如果我们想让创建出来的子进程,执行全新的程序呢?换句话说,我们曾经写时拷贝,让父子进程在数据上互相解藕,互相保证独立性,代码虽然不会写入,只是读取,当然不会互相影响。但我想让父子进程彻底分开呢?我想让子进程彻底执行一个全新的程序呢?
那么我就需要用到程序替换!

✳️那么为什么我要有程序替换呢?因为我想要子进程执行一个全新的程序!

✳️我们一般在服务器设计(Linux编程)等时候,往往需要子进程干两件种类的事情
1.让子进程执行父进程的代码片段(服务器代码)
2.让子进程执行磁盘中一个权限的程序(shell,想让客户端执行对应的程序,通过我们的进程,执行其他人写的进程代码等等),比如说我们用C/C++调用其他程序如Java/C/C++/Pytrhon…编写的。我写的一个功能,他人也写了功能,但是并不是用的同一种语言,那我怎么把我的程序把他人写的功能调用起来呢,这就是我们为什么要进行程序替换的远影!
请添加图片描述

原理

✳️我们进程的PCB,它有虚拟地址空间(里面有代码、数据、堆、栈),有页表映射到物理内存当中的特定区域,然后就可以执行该进程的特定代码,访问该进程的数据、栈或者堆了。然后他现在也创建了一个子进程。
如果父进程,它曾经一定执行过一个程序a.exe,所以父进程在执行的时候,a.exe中的数据和代码全部都加载到内存里,然后在内核中形成对应的数据结构,至此我们就完成了一个基本的进程创建,当然大家都知道,我们也可以通过fork来创建子进程,一旦创建了子进程,那么他也要创建自己对应的PCB、mm_struct、页表等数据结构 。子进程也有自己的PCB以父进程为模版,虚拟地址空间也以父进程为模版,也有自己的页表,只不过呢代码段它是指向父进程的代码段,数据呢也以写时拷贝的方法(延时拷贝策略)来和我们父进程进行共享,其他区域也类似。
接下来呢,我们如果磁盘上有个全新的程序,这个程序我们称之为b.exe,那么当我们有b.exe的时候,我们今天不想让子进程去执行你所说的任何父进程的代码,以及访问父进程的相关数据工作,我们都不想让子进程干。你子进程去执行b.exe。那么怎么做呢?我们先把b.exe从磁盘上加载到内存里,加载后它一定有自己的代码段和数据段、堆区、栈区等等,然后我们让子进程重新调整它的页表,它的页表不再和父进程有任何的关系,而是直接指向自己的代码和自己的数据区,此时就和我们父进程数据和代码没有半毛钱关系了。那么我们这种方式叫做将我们的一个可执行程序加载到内存,并且重新调整子进程的页表映射,使之指向新的代码和数据段,这个过程就叫做程序替换!

✳️程序替换的原理:
1.将磁盘中的程序,加载入内存结构
2重新建立页表映射,谁执行程序替换,就重新建立谁的映射(我们上面讲的是子进程)
效果:让我们的父进程和子进程彻底分离,并让子进程执行一个全新的程序!

❓请问这个过程有没有创建新的进程呢?----➡️根本没有!
因为子进程的内核数据结构基本没变,只是重新建立了一下虚拟到物理地址之间的映射关系罢了,对应的数据结构没有发生任何变化,包括子进程的pid都没变,子进程的pid没变也就意味着子进程压根没有创建新的进程,它依旧是老进程,只不过让新的进程执行了不同的程序罢了,这就叫做进程程序替换。
请添加图片描述

为什么呢?

✳️那么为什么我要有程序替换呢?因为我想要子进程执行一个全新的程序!

✳️我们一般在服务器设计(Linux编程)等时候,往往需要子进程干两件种类的事情
1.让子进程执行父进程的代码片段(服务器代码)
2.让子进程执行磁盘中一个权限的程序(shell,想让客户端执行对应的程序,通过我们的进程,执行其他人写的进程代码等等),比如说我们用C/C++调用其他程序如Java/C/C++/Pytrhon…编写的。我写的一个功能,他人也写了功能,但是并不是用的同一种语言,那我怎么把我的程序把他人写的功能调用起来呢,这就是我们为什么要进行程序替换的远影!

怎么做呢?(编码,如何进行程序替换)

✳️上面原理已经讲了,那我该怎么做呢?难道要我自己把磁盘里的程序load到内存里…答案是不可能,也不应该让你去做。因为对用户来讲,磁盘是一个硬件设备,把数据从外设导入到内存过程当中,是一个硬件把数据搬到另一个硬件的,这个工作是由操作系统完成!
我们要调用接口,让操作系统去完成这个工作,就是系统调用!
因为冯诺伊曼体系告诉我们,从一个硬件搬到另一个硬件,比如说从外设搬到内存,那么它一定是一个数据从一个硬件到另一个硬件,而将我们的数据从一个硬件到另一个硬件,只能由操作系统来做到这个工作,因为操作系统是软硬件的管理者!基本是通过系统调用来完成任务!

先见见猪跑----最基本的代码

我们要进行程序替换,首先要调的系统接口是execl()。
🌟1.execl()—系统接口不太准确,暂时叫系统接口

我们将会演示5个
#include <unistd.h>
extern char **environ;

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[]);

🌟2.execve()

 #include <unistd.h>
int execve(const char *filename, char *const argv[],
                  char *const enzvp[]);

✳️ 为什么要有这么多接口?
	答:是因为要适配各种各样的应用场景

✳️为什么execve是单独拿出来的?            
因为execve是真正的系统接口,我们讲的上面5个是基于系统接口之上做的封装,也就是上面5个所用的接口都会转为对execve的调用接口,只是为了适配各种应用场景给我们对系统接口进行了封装。    

请添加图片描述
✳️我们想要一个程序执行我们一个全新的程序的时候,要解决几个问题?
我们系统当中存在着大量的命令,我们要执行这些命令程序要做几件事情呢?
首先要执行一个全新的程序,程序的本质不就是磁盘的文件嘛
第一件事情就得先找到这个程序
指令我们都用了这么长时间,指令也是一个程序,我们指令可以携带各种选项,当然也可以不携带,我们曾经在讲命令行和参数和环境变量的时候,我们自己的程序也是可以携带所谓的选项,也就是说我们执行一个程序可能携带选项去执行。
既然程序可以携带选项去执行(也可以不携带)
第二件事明确告诉操作系统,我想怎么去执行这个程序,要不要带选项。
所以execl的功能得把这两件事都体现出来!

✳️函数参数中是"…"是什么?----➡️可变参数列表:说白了就是可以按照用户的意愿去传入大小,数量不等的参数。

✳️excel()函数

int execl(const char *path, const char *arg, ...);
肯定要围绕我们上面两件事情来展开,
		   第一个函数参数解决的是先帮我们找到程序在哪里
		   第二个函数参数解决的是怎么去执行----➡️比如我们在xshell命令行当中执行ls -a -l等等指令,这里的每一个参数都以命令行参数方式传递给了main函数
		   所以命令行怎么写的(ls -a -l),这个函数参数就怎么填(我传入“ls”,“-l”,“-a”)
		   最后必须是NULL,标识【如何执行程序的】参数传递完毕!

参数解释:
第一个参数 path:是一个路径,实际上我么调用execl的时候要执行它,我们必须得把你要执行的程序或软件带全路径、绝对路径或相对路径都可以,
第二个参数 arg:然后把程序名也带上,
第三个参数 “...:然后后面的一坨参数代表你想在命令行上怎么执行它.
int main()
{	
	printf("我是一个进程,我的pid是:%d \n", getpid())
	//ls -a -l
	execl("/usr/bin/ls", "ls", "-a", "-l",NULL);
	printf("我执行完毕了,我的pid是:%d \n", getpid())----➡️本应该被执行,可最后并没有,而是跑去执行了ls的程序了
}
我写的C语言代码myproc变成了一个程序,当我./myproc的时候,就是一个进程,这个进程在里面调用了execl也就会执行ls命令,当然你还想执行top等其他命令,同样的方法。
这就叫做程序替换
什么意思:就是我本来要执行第三行的pritf代码,可是我们发现他跑过去执行了一个新的程序了ls的程序。

❓请问为什么我们最后的一行printf代码并没有打印出来呢??
答案是:一旦执行execl,替换成功,是将当前进程的代码和数据全都替换的了!
		后面的是代码吗?有没有被替换呢??
		当然!已经早就被替换了!该代码相当于就不存在了!
		一旦执行execl执行成功,后面的代码就是没了,因为它在程序替换的时候,已经把进程的代码和数据全部替换掉了,当然包括后续的代码。
		前面的printf被执行的时候,程序替换并没有被执行,一旦调用execl成功,后面的代码不复存在!


	❓我再问一下,请问int ret = execl(),ret返回值是不是程序替换之前属于当前进程内的数据 ,一旦替换成功了,还会执行返回语句吗???
	不会了!因为一旦程序替换了,我们进程就开开心心去执行人家ls的代码了,当我们return(指的是execl里面的retunr)的时候,我们fork说过,如果一个函数准备执行return了,它就把工作做完了,execl就是这样,当它return的时候,它的主逻辑已经做完了,意味着已经开始执行ls的代码了,程序替换压根就不会去执行return。
	❓那么这里的返回值有意义吗?
	有意义!因为我们刚刚说的是替换成功的情况,如果替换失败呢? 
	
	❓所以这个程序替换函数,用不用判断返回值?为什么?
	✳️所以程序替换是不用判断返回值,因为只要成功了就不会有返回值,失败的时候,必然会向后执行,最多通过返回值得到什么原因导致的替换失败,所以返回值根本不用判断。
	如果替换成功了就不会执行后面的代码,所以后面的if/else判断一点意义也没有!

✳️所以最后结论是,一旦execl替换成功,就会跟着新程序逻辑走了,不会再return,再也回不来了,退出也是新程序执行完退出。只有调用失败才会return执行自己原来后面代码

✳️我们初步让自己程序跑了,但发现小问题就是,我们是一个单进程程序,我们并没有创建子进程,相当于我们一启动就替换了我们,把我自己的代码全替换掉了 ,而我们就是有时候不想让我们父进程去做这件事情,我想让子进程去做这些事情,所以我们把进程创建引入进来!请添加图片描述

引入进程创建

int main()
{
	printf("我是父进程,我的pid是:%d \n", getpid())
	pid_t id = fork();
	
	if(id == 0)
	//子进程
	//想让子进程执行全新的程序,以前是执行父进程的代码片段
	{
		execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
		exit(1);//只要执行了exit,意味着,execl系列的函数失败了,就不执行后面代码了。但父进程还是会运行的!
	}
	//父进程
	int status =0;
	int ret = waitpid(id, &status, 0);
	if(ret == id)
	{
		printf("父进程等待成功\n");
	}
}

❓子进程执行程序替换,会不会影响父进程呢??
答案:不会!因为进程具有独立性!
❓为什么?是如何做到的?
子进程是如何做到和父进程的代码和数据做分离呢?我们回忆曾经讲的fork,我们让子进程去执行父进程相似的代码片段,我们定义了一个全局变量,让父子进程各自打印,然后让其中一个进程去修改该变量,改了之后再去打印,发现父子进程都不受影响,当时我们提出是在数据层面发生写时拷贝。可是不是说好的,我们讲过fork之后代码是共享的,数据写时拷贝,好你子进程现在要替换新的程序,也就是说,不就是把新的程序代码加载到内存里,新程序来了之后,我给子进程发生数据的写时拷贝,然后形成新的数据段,把空间开辟出来,把新程序的数据拷贝进来,都能理解。你不是说代码共享的吗?我子进程掉execl的时候难道不会把新程序的代码直接替换到父进程,从而影响到父进程吗?
✳️当程序替换的时候,我们可以理解为,代码和数据都发生了写时拷贝完成了父子分离!
我就是告诉你,我们当加载新的程序的时候,我们给子进程重新开辟空间。
概念统一一下,我们父子进程代码,在fork之后和在execl之前,代码是共享的,但一旦到execl的时候,操作系统立马识别到程序替换了,不能把代码替换了,不然父进程就要受到影响了,所以即便代码只读的,我们也要照样给代码发生写时拷贝,因为我们要让新的代码在新的空间里面保存起来,重新建立映射,从而达到父子进程相互独立,父子工作彻底分离。

✳️稍微总结:我已经知道程序替换是什么,就是让我们子进程或是一个进程,不管是父进程还是子进程,只要你调,我都是让我的代码和数据,去替换你当前的代码和数据,如果是子进程调,那就是去替换子进程的代码和数据,父进程就是父进程的代码和数据。
为什么要替换呢?我们将来要用到这个特性,来完成一些功能性的设定,比如我们想让我们的程序去执行别人的程序,举个例子:我是一个C++程序,我要去调一下Python程序员写的功能,可是C++和Phthon没办法一块混编,只能是通过我自己这边写好,去执行他Python的功能,那么程序替换就是一个办法。

大量测试各种不同的接口

还有很多接口我们都没试,那么这些接口怎么用?
然后细心会发现这些接口有一些规律,这些规律我们现在该怎么去理解呢?规律最好的总结,就是先把这些接口全部都用一遍。

execv()

✳️int execv(const char *path, char *const argv[]);
 
我们前面已经说了,要让我们的进程执行新程序要做两件事!
我得先找到这个程序,然后是怎么去执行这个程序。

参数的理解:
第一个参数 path:我们如何找到这个程序
第二个参数 argv:我们如何去执行

但是发现问题刚刚execl和他有点不一样,但现在他出现这么一个类型:char *const [],这是一个指针数组,放char*类型的数组
我们刚刚命令行上是 :ls -a -l
我们execl是这样去执行---➡️"ls", "-a", "-l"
execv和execl相比唯一的差别就在于,它传递如何执行时传递参数时,我们以前execl传递参数时是一个一个传进去,现在就直接变成让他指向一个字符指针,然后将整体数组传进去。

✳️结论:execv和execl只有传参数的区别!  

让我们来看看代码

int main()
{	
	printf("我是一个进程,我的pid是:%d \n", getpid())
	//ls -a -l
	char* const argv_[] = {
	(char*)"ls",
	(char*)"-a",
	(char*)"-l"
	NULL
	}
	execv("/usr/bin/ls", argv_);
	printf("我执行完毕了,我的pid是:%d \n", getpid())

✳️excel vs exec:记忆法
execl中由l就可以看住list,因为是list,所以参数是一个一个传递给函数,就叫做列表传参。
execv中由v就可以看住vector,因为是vector,就叫做数组传参数。
请添加图片描述

execlp()

✳️int execlp(const char *file, const char *arg, ...);
参数讲解:
第一个参数 file:你想执行什么程序---找到它
				执行指令的时候,默认的搜索路径在哪儿?---在环境变量!PATH中!
				命名带p的,可以不带路径,只说出你要执行哪一个程序即可!
第二个餐素 arg:你想如何执行它(同execl用法一样)

下面看看代码写法
execlp("ls", "ls","-a", "-l",NULL);//这里出现了两个ls,含义一样吗?
第一个参数是供系统去找,你要去执行谁的;
第二个参数后面的一坨是你想怎么去执行它								   

execvp()

int execvp(const char *file, char *const argv[]);

解释参数:
第一个参数 file:PATH找,只要程序名即可
第二个参数 argv:如何执行,将命令行参数字符串,统一放入数组中即可完成调用!

看看代码
char* const argv_[] = {
(char*)"top",
NULL
}:
execvp("top", argv_);

execle()

int execle(const char *path, const char *arg,
                  ..., char * const envp[]);

参数解释:
第一个参数,第二个参数同上面解释的差不多;
第三个参数:是环境变量(如果你想给你的程序,传入对应的环境变量信息,那么你可把对应的环境变量的参数传进去) 如果我想传我自己定义的环境变量MYPATH呢?

系统中没有我们自己定义的环境变量MYPATH,但是我想让我自己在用execle程序替换调用我自己的程序mycmd的时候,我手动给他导入自己的环境变量MYPATH,所以接口就的选execle

这里环境变量参数是一个字符指针数组:-----➡️这里的代码应写入myexec.c源文件中韩
char* const envp_[] = 「
(char*)“MYPATH=YOUCANSEEME!!”,
NULL
}

execle("./mycmd", "mycmd". NULL, envp_);----➡️相当于我myexe.c自己导入了一个环境变量,我把envp_传递给我即将要执行mycmd,相当于我手动把环境变量传进去

-------------------------------------------------------------------
mycmd.cpp
#include <iostream>
#include <stdlib.h>

int main()
{
    std::cout << "PATH:" << getenv("PATH") << std::endl;
    std::cout << "-------------------------------------------\n";
    std::cout << "MYPATH:" << getenv("MYPATH") << std::endl;
    std::cout << "-------------------------------------------\n";

    std::cout << "hello c++" << std::endl;
    std::cout << "hello c++" << std::endl;
}
-------------------------------------------------------------------
myexe.c------➡️下面代码是简写,是父进程fork之后子进程执行的代码
extern char** environ-------➡️环境变量的指针声明
char* const envp_[] = 「
(char*)“MYPATH=YOUCANSEEME!!”,
NULL
}

//execle("./mycmd", "mycmd". NULL, envp_);------➡️要改成下面那种
execle("./mycmd", "mycmd". NULL, environ);
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>


int main()
{
    //环境变量的指针声明
    extern char**environ;

    printf("我是父进程,我的pid是: %d\n", getpid());
    pid_t id = fork();
    if(id == 0){
        //child
        //我们想让子进程执行全新的程序,以前是执行父进程的代码片段
        
        printf("我是子进程,我的pid是: %d\n", getpid());
        char *const env_[] = {
            (char*)"MYPATH=YouCanSeeMe!!",
            NULL
        };
        //e: 添加环境变量给目标进程,是覆盖式的,还是新增式的!
        execle("./mycmd", "mycmd", NULL, environ);
        //execle("./mycmd", "mycmd", NULL, env_);
        
        //execl("/usr/bin/bash", "bash", "test.sh", NULL);
        //execl("/usr/bin/python3", "python3", "test.py", NULL);
        //execl("./mycmd", "mycmd", NULL);
        //execl("/home/whb/104/phase-104/lesson17/mycmd", "mycmd", NULL);
        //char *const argv_[] = {
        //    (char*)"ls",
        //    (char*)"-a",
        //    (char*)"-l",
        //    (char*)"-i",
        //    NULL
        //};
        //char *const argv_[] = {
        //    (char*)"top",
        //    NULL
        //};
        //sleep(1);
        //execvp("top", argv_);
        //execlp("ls", "ls", "-a", "-l", NULL);// 这里出现了两个ls, 含义一样吗?不一样!
        //execv("/usr/bin/top", argv_);
        //execl("/usr/bin/ls", "ls", "-a", "-l", NULL);
        exit(1); //只要执行了exit,意味着,execl系列的函数失败了
    }
    // 一定是父进程
    int status = 0;
    int ret = waitpid(id, &status, 0);
    if(ret == id)
    {
        sleep(2);
        printf("父进程等待成功!\n");
    }


    //printf("我是一个进程,我的pid是: %d\n", getpid());
    //ls -a -i
    
    //int ret = execl("/usr/bin/lafds", "ls", "-l", "-a", NULL); //带选项
    //execl("/usr/bin/top", "top", NULL); //不带选项
    //execl("/usr/bin/pwd", "pwd", NULL); //不带选项
    //printf("我执行完毕了,我的pid是: %d, ret: %d\n", getpid(), ret);
    return 0;
}
-------------------------------------------------------------------
若在mycmd.cpp中调用getenv("MYPATH")查看MYPATH的环境变量的时候,我系统里面并没有MYPATH的环境变量,那么我调用的话就会出错!但是PATH的环境变量是能打印出来的。
也就是现在我系统默认的环境变量里面没有你说的“MYPATH”环境变量,但是下面我想在我myexec调用mycmd这个程序的时候,我手动给他导入环境变量MYPATH,那行不行呢?
在刚刚单独执行的时候,因为系统根本不存在MYPATH环境变量程序就直接崩了,后续的结果就不会执行了。
但是现在我发现了一个问题,PATH却不再打印内容,PATH怎么不打印内容呢?我们现在把PATH的代码那一行给注释掉,再来运行myexec,此时MYPATH出现了!来我的问题就是,很显然用我们的程序myexec调mycmd的时候我们是给他传递了环境变量,是我自己定义的,可是不传递的时候,MYPATH打印是什么都没有,程序后面的也就不执行了,有个小问题就是,为什么我们实际在调用的时候,PATH放出来的时候,PATH是可以打出来的,只不过MYPATH显示不出来罢了,可是如果我用我的myexec执行,发现PATH都执行不出来了,如果把PATH注释掉,MYPATH才能打印出来。我想问下大家,所以呢,我自己直接执行mycmd的时候可以直接执行出PATH的内容,因为我的mycmd程序运行起来,我的父进程相当于bash,把PATH给我了,我能够拿到,我不定义我自己的环境变量MYPATH,我可以获取到,但是我自己传入了我自己的环境变量,我发现PATH就打印不出来了,因为PATH没有,后续的MYPATH也就不会打印出来了。所以我想问大家,myexec中添加环境变量,是覆盖式的还是,还是新增式的?
	是覆盖式的!则系统中的环境变量就被覆盖类。如果我就是单纯的想把自己的环境变量传递给子进程对应的程序,那么可以直接传入我们曾经讲过的环境变量里面有个extern char** environ
	extern char** environ,可以通过他把我们的环境变量获取到,将原来的
execle("./mycmd", "mycmd". NULL, envp_);改为
execle("./mycmd", "mycmd". NULL, environ);
然后再执行myexec程序发现可以打印出PATH的内容了,但是MYPATH还是不能打,因为系统本来就没有他,但是现在可以再xhsell命令行上手动导入MYPATH的环境变量
export MYPATH = “刚刚断网了”,然后再执行myexec就可以看到MYPATH的内容了。也就是execle的函数参数char* const envp[]我们并没去使用。而是用environ去替换他的。
	PATH可以被父进程的子进程拿到了,MYPATH也可以被别人拿到了,环境变量也就传递给了所有进程
	✳️将来想要把你的环境变量传给子进程/子子进程/其他程序,都可以手动用execle传给他,如果你想自定义就把自己的环境变量传进去就行了


✳️execl、execlp这些末尾不带e的程序替换函数,不要认为子进程就没有环境变量,它的环境变量是自动会被子进程继承的,如果你用带e的但又不想环境变量被覆盖,那你就把系统环境变量传进来就行了。其二你想覆盖式的把环境变量交给子进程,那你之间吧环境变量设置成函数参数要求的数组传进去,用的就是你的新的环境变量。
	那么我既不想覆盖系统的,也想添加我想要的环境变量然后我想把我的环境变量传给我的子进程,传递的环境变量列表里面,包含了老的还有自己新添的,这个该怎么做呢?
	我们想打印环境变量的方式有三个:一个是用函数getenv(),另外是命令行参数#env。还有就是char** environ声明一个变量。在代码里面加extern char** environ声明这么一个变量。我们在命令行执行程序时,我的父进程是bash,所以我环境变量来自于bash,若我开心exprot xxx=123导入一个环境变量,当我再去执行程序第时候,会有xxx的环境变量(程序代码里面是写了extern char** environ).
	今天我们写的shell子进程用的execvp()程序替换函数,并没有使用末尾带e的程序替换函数,我们没有手动传环境变量,当走到exenvp的时候其实已经是孙子进程了,当用自己写的shell命令行上写#env的时候起相当于子进程创建的子进程(派生子进程),因为环境变量是无脑去继承的,所有其他程序照样能获取到环境变量,这正是环境变量具有全局属性的特点。
	我们现在就想手动传入,用execvpe()程序替换函数,execvpe("...","...",environ);将environ系统环境变量手动传进去,照样可以用自己写的shell打印出所有环境变量。
	🌟想讲的是你不想传入环境变量,它默认继承父进程的环境变量就已经够了。

✳️我们前面讲过将来想自己传一个环境变量指针数组,发现他会覆盖我们曾经的环境变量列表,我想新增而不是覆盖。你说你改改系统的环境变量指针extern char** environ,往里面添加字符串不就完了?记住了系统不允许我们直接对环境变量指针数组做任何修改!那我们怎么做呢?我们发现启动自己写的shell,在自己的shell上用export导入环境变量是导入不成功的!下面我们问题就是在原始环境变量基础之上我不想改,而是想新增,不是想像历史那般我传入环境变量数组覆盖掉系统的。我也不想我父进程的环境变量原封不动的交给子进程,我想在我这里多加一些环境变量怎么做呢?
	我们可以用putenv()函数:把特定的环境变量导入到当前的进程的上下文当中,谁调用这个putenv()谁就把环境变量导入到自己的上下文当中。所以将putenv()像cd那样给自己写的shell的父进程写个内建命令,那么父进程的环境变量会被子进程继承下去。但是我们启动自己写的shell,用export导入环境变量发现还是没有。为什么呢?我们只是将环境变量的地址传给了putenv()的接口,在系统里面会给我们维护一张环境变量的指针,地址是传进去了,但是内容还是需要我们自己维护。因为我们的环境变量是放在commad_line数组当中,每次执行完一个命令是会被清空的。所以需要再创建一个数组去保存我们传入的环境变量!其实还少了一部分讲解,算了不讲了。

✳️execle()函数中的参数envp[]环境变量,如果你自定义话它是以覆盖的形式直接影响到你的环境变量,如果我不想覆盖

调用自己写的程序

目前我们执行的程序,全部都是系统命令,如果我们要执行自己写的C/C++程序呢?
如果我们要执行其他语言写的程序?
我们现在有两个源文件:mycmd.cpp、my exe.c,然后我想要他们生成两个可执行程序后,能够有一个可执行程序去调另一个可执行程序。
现在我想要我写的myexec把我写的mycmd可程序程序调起来
先pwd拿到当前路径
然后


execl("/......./mycmd", "mycmd", NULL);--➡️省略号是我赖的写,真要写的话加去全的!
所以程序替换不挑程序!
我们也可以用相对路径
execl("./mycmd", "mycmd", NULL);(因为我mycmd和myexec在同一个目录下所以是./程序名)
这样我们就实现了用C语言调用C++写的东西了!

✳️我们用exec系列的函数可以将任何语言耦合在一起!
  我们上面所讲想告诉大家一个道理:我们任何的程序都是可以用系统级的接口调用其他语言!
  所以操作系统是所有语言的基座。

execve()

当我们要讲:int execve(const char *filename, char *const argv[],
                  char *const enzvp[]);
            那他和execle又什么区别吗
          int execle(const char *path, const char *arg,
                  ..., char * const envp[]);
            
无非唯一区别就是,execve是数组传参,execle是列表传参而已,环境变量跟刚刚一摸一样,
其他没讲的也一样的了!           

简易版的Shell

✳️ 我们现在会创建,终止,等待,程序替换了,便也就可以写一个简易版的shell了!
如果我们写的命令是能够在命令行当中输入,然后把命令解析好交给execle去执行,那不就叫做我写了个shell吗。
[tangchonghao@VM-24-5-centos ~]$ 这提示符就是一行字符串,我们也可以做一个自己的提示符号.
shell本质上就是一个死循环,只要启动就会一直在,除非你退出。
//保存完整的命令行字符串
上面提示符的每一个都能够通过接口获取,但我们简易版就不关心算了。如果想的话可以网上搜索获取主机名的接口。

✳️讲解步骤
printf("[root@localhost myshell]# ");我们发现如果就这样他会一直打印,就算加上\n和slepp他也会不断刷新,那该怎么办呢?所以我们将来不想新起一行该怎么办呢?那我就不要\n了,然后我们会发现打印不出来了,因为缓冲区没有刷新那我们就要用上fflush()进行刷新缓冲区。用scanf确实能够刷新出来,但是scanf获取数据以空格为单位,所以不想用它。 现在我们回到界面输入确实可以有了
第二步获取用户输入,就要订一个数组command_line[NUM],我们输入指令的时候是ls空格-a空格-l,所以是带空格,所以不能用scanf和cin,因为默认情况下,scanf必须制定你的格式控制 ,但是你又不知道有多少参数,所以必须把输入的整体当个字符串!用getline也可以,就相当于把我们获取从特定的流当中获取到。但我们不想用,用起来不方便,我们想到用fgets()。它相当于从特定的流当中获取的内容,保存到字符串里面,可以指定长度,获取成功就会返回字符串的地址,获取失败就是NULL,因为它的参数使用起来不是二进制更加简单,此时唯一要说的是,你从哪里获取呢?获取的内容放到command_line,获取的大小为NUM,从键盘获取(也就是标准输入stdin)。我们用fgets获取字符串后,是获取到C风格的字符串,默认就是‘\0’为结尾。因为我们最后输入enter是回车默认是换行符也被获取到了。那么我们就要把\n改成\0,那么就不会再我们输入完后多带一个\n了。那么\n在哪里呢,则就是command_line[strlen(command_line)-1] = ‘\0’;这样就清空了\n。
第三步就是将获取到的字符串“ls -a -l -i”,转化成如“ls” “-a” “-l” “-i”,将他们打散了,因为我们用程序替换的接口,它无论是列表传参数,还是数组传参的形式,都必须要我们打散。也就是字符串切割。关于切割,可以用C++当中的substring,构建string对象然后去做,但我不想,因为我们用的C语言,其实substring更简单,它用空格符做为一个分隔符就行了。但是我今天想调用一个接口strtok()接口,它的作用相当于将一个字符串按照特定的分割符,打散成我们所对应的子串,将子串依次给我们返回。调用的时候第一次调用,把我们对应的字符串传进去,设置分隔符获取子串,往后再调用,继续切割,继续获取,我们就不用再传入字符串参数了,直接把它设为NULL就行了。然后我们定义一个char* command_args[SIZE],定义了一个指针数组,(因为打散的字符串相当于一个一个指针呀!)将来想把第一个字符串的地址写到0号下标,第二个字符串用1号下标指向他,以此类推。我们可以用#define “ ” SEP,然后SEP就是空格符了。因为strstok截取成功,返回字符串起始地址,截取失败,返回NULL,所以写成while(command_args[index++] = strtok(NULL, SEP));这样写的好简洁!
第四步TODO,在没有这步也是可以完成的,但是发现我们写的shell并不完美。
一、因为xshell他的很多文件是带颜色的,而我们自己写的minishell它是不带颜色的,那么怎么让我的东西也带上颜色呢?实际上这个工作并不难做,实际我们的命令最本质就是alias ls --color=auto,其中“alias”是别名的意思,裸的ls是不带任何颜色的,实际上xshell的ls是会带颜色的,因为ls携带了选项,并且将它命名成了一个别名了。比如说我们今天写了ls -a -l -i是我们的一条命令,但是我觉得这条命令携带的选项太多了,那么我就用alias 随便起个名字 alias my_104_cmd=‘ls -a -l -i ’ ,就相当于给这个长命令取了一个别名,接下来运行着一行字符串的时候不用输入这么多了,就直接输入my_104_cmd那么就可以执行和他一样功能的命令了,这就是起别名。程序替换在执行的时候,就相当于直接在系统当中找系统命令了,就会少了color的选项的。解决办法就是对程序名单独做判断,将color加上
二、还有一个问题就是我们在执行cd…回退到上级目录的时候,会发现我的当前所处路径并不会发生任何变化。我们发现在xshell自己的系统目录下做cd…它是能够让我回退到上个路径 。虽然系统当中存在cd命令,但我们自己写的shell当中用的不是和它那个一样的cd命令。我们自己写的shell执行的cd命令是系统特定下的路径,但这个命令只影响了子进程!如果子进程执行程序替换,最多,只是让子进程进行路径切换,子进程是一运行就完毕的进程!所以子进程执行这个命令是无意义的,我们在shell中,更希望谁的路径发生变化呢?(一个进程是存在对应的路径,实际上就是我们进程在哪个路径上启动,这个进程所在路径就是当前所在路径,一般路径是会被子进程继承的,我们执行任何命令,都希望是父进程路径发生变化)------➡️父进程,也就是shell本身。我们现在写的代码是不能够让父进程路径发生变化,因为我们所有代码在实际上进行操作的时候,本质上所有操作会落实到fork之后的子进程上面的。此时我们就有了需求,如果有些行为,是必须让父进程shell执行的,不想让子进程执行,绝不能让子进程!只能是父进程自己实现对应的代码!
所以shell自己执行的命令,我们称之为:内建(内置bind-in)命令
内建命令本质上相当于shell内部的一个函数!
chdir()是更改工作路径,哪个进程调这个函数,哪个进程调工作路径就会发生便会发生变化,chdir()的参数很简单,就是你想去哪个路径下,你就把路径参数传给我就行了。
所以我让父进程识别到是cd命令的时候让其执行函数chdir(),若你想到是让父进程去程序替换执行cd命令,那就大错特错,因为一旦父进程执行程序替换,那么父进程的代码全部被替换,程序替换完,则父进程也就结束了,与自己要实现的逻辑完全违背!所以只能让父进程执行函数ChangeDir()自己封装的函数!
第五步创建进程执行它, 为什么不直接程序替换,如果直接程序替换就会把shell替换了所以我让子进程去执行它,我们数据一旦改好了,子进程是能看到的,因为子进程不作修改的话就不会有影响,所以让他直接fork创建子进程就行了,pid_t id =fork()。
第六步进行程序替换,若子进程执行到exit(1)则说明程序替换失败,若程序替换成功会根据替换的命令程序的代码走,替换的命令程序代码跑完子进程就相当于跑完,执行结束。我们选择execvp是最容易的,因为我们写的所有命令行参数都被打散到了数组里面,所以我们选的程序替换最好用数组传参来进行,其二我们要执行的程序名字已经有了,要他执行的程序名字是Xshell中的已经写好了,能够找到该命令所以用file。

char *fgets(char *s, int size, FILE *stream);

char *strtok(char *str, const char *delim);----➡️Linux函数博客有他解析

shell代码(自己104期的)

	int ChangeDir(const char* new_path)----封装chdir()函数
	{
		chdir(new_path);
		return 0;//----调用成功
	}
	char env_buffer[NUM]; 
	char* commadn_args[SIZE]---➡️保存打散之后的命令行字符串

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

#define SEP " "
#define NUM 1024
#define SIZE 128

char command_line[NUM];
char *command_args[SIZE];---➡️保存打散之后的命令行字符串

char env_buffer[NUM]; //for test

extern char**environ;

//对应上层的内建命令
int ChangeDir(const char * new_path)----封装chdir()的函数
{
    chdir(new_path);

    return 0; // 调用成功
}

void PutEnvInMyShell(char * new_env)
{
    putenv(new_env);
}

int main()
{
    //shell 本质上就是一个死循环
    while(1)
    {
        //不关心获取这些属性的接口, 搜索一下
        //1. 显示提示符
        printf("[张三@我的主机名 当前目录]# ");
        fflush(stdout);

        //2. 获取用户输入
        memset(command_line, '\0', sizeof(command_line)*sizeof(char));
        fgets(command_line, NUM, stdin); //键盘,标准输入,stdin, 获取到的是c风格的字符串, '\0'
        command_line[strlen(command_line) - 1] = '\0';// 清空\n

        //3. "ls -a -l -i" -> "ls" "-a" "-l" "-i" 字符串切分
        command_args[0] = strtok(command_line, SEP);
        int index = 1;
        // 给ls命令添加颜色
        if(strcmp(command_args[0]/*程序名*/, "ls") == 0 ) 
            command_args[index++] = (char*)"--color=auto";
        // = 是故意这么写的
        // strtok 截取成功,返回字符串其实地址
        // 截取失败,返回NULL
        while(command_args[index++] = strtok(NULL, SEP));

        //for debug
        //for(int i = 0 ; i < index; i++)
        //{
        //    printf("%d : %s\n", i, command_args[i]);
        //}
    
        // 4. TODO, 编写后面的逻辑, 内建命令
        if(strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL)
        {
            ChangeDir(command_args[1]); //让调用方进行路径切换, 父进程
            continue;
        }
        if(strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
        {
            // 目前,环境变量信息在command_line,会被清空----BUG
            // 此处我们需要自己保存一下环境变量内容
            strcpy(env_buffer, command_args[1]);
            PutEnvInMyShell(env_buffer); //export myval=100, BUG?
            continue;
        }

        // 5. 创建进程,执行
        pid_t id = fork();
        if(id == 0)
        {
            //child
            // 6. 程序替换
            //exec*?
            execvp(command_args[0]/*不就是保存的是我们要执行的程序名字吗?*/, command_args);
            exit(1); //执行到这里,子进程一定替换失败
        }
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        if(ret > 0)
        {
            printf("等待子进程成功: sig: %d, code: %d\n", status&0x7F, (status>>8)&0xFF);
        }
    }// end while
}
	
-------------------------------------------------------------------

有关写shell时所引出的环境变量相关知识

✳️execl、execlp这些末尾不带e的程序替换函数,不要认为子进程就没有环境变量,它的环境变量是自动会被子进程继承的,如果你用带e的但又不想环境变量被覆盖,那你就把系统环境变量传进来就行了。其二你想覆盖式的把环境变量交给子进程,那你之间吧环境变量设置成函数参数要求的数组传进去,用的就是你的新的环境变量。
那么我既不想覆盖系统的,也想添加我想要的环境变量然后我想把我的环境变量传给我的子进程,传递的环境变量列表里面,包含了老的还有自己新添的,这个该怎么做呢?
我们想打印环境变量的方式有三个:一个是用函数getenv(),另外是命令行参数#env。还有就是char** environ声明一个变量。在代码里面加extern char** environ声明这么一个变量。我们在命令行执行程序时,我的父进程是bash,所以我环境变量来自于bash,若我开心exprot xxx=123导入一个环境变量,当我再去执行程序第时候,会有xxx的环境变量(程序代码里面是写了extern char** environ).
今天我们写的shell子进程用的execvp()程序替换函数,并没有使用末尾带e的程序替换函数,我们没有手动传环境变量,当走到exenvp的时候其实已经是孙子进程了,当用自己写的shell命令行上写#env的时候起相当于子进程创建的子进程(派生子进程),因为环境变量是无脑去继承的,所有其他程序照样能获取到环境变量,这正是环境变量具有全局属性的特点。
我们现在就想手动传入,用execvpe()程序替换函数,execvpe(“…”,“…”,environ);将environ系统环境变量手动传进去,照样可以用自己写的shell打印出所有环境变量。
🌟想讲的是你不想传入环境变量,它默认继承父进程的环境变量就已经够了。

✳️我们前面讲过将来想自己传一个环境变量指针数组,发现他会覆盖我们曾经的环境变量列表,我想新增而不是覆盖。你说你改改系统的环境变量指针extern char** environ,往里面添加字符串不就完了?记住了系统不允许我们直接对环境变量指针数组做任何修改!那我们怎么做呢?我们发现启动自己写的shell,在自己的shell上用export导入环境变量是导入不成功的!下面我们问题就是在原始环境变量基础之上我不想改,而是想新增,不是想像历史那般我传入环境变量数组覆盖掉系统的。我也不想我父进程的环境变量原封不动的交给子进程,我想在我这里多加一些环境变量怎么做呢?
我们可以用putenv()函数:把特定的环境变量导入到当前的进程的上下文当中,谁调用这个putenv()谁就把环境变量导入到自己的上下文当中。所以将putenv()像cd那样给自己写的shell的父进程写个内建命令,那么父进程的环境变量会被子进程继承下去。但是我们启动自己写的shell,用export导入环境变量发现还是没有。为什么呢?我们只是将环境变量的地址传给了**putenv()**的接口,在系统里面会给我们维护一张环境变量的指针,地址是传进去了,但是内容还是需要我们自己维护。因为我们的环境变量是放在commad_line数组当中,每次执行完一个命令是会被清空的。所以需要再创建一个数组env_buffr[]去保存我们传入的环境变量!其实还少了一部分讲解,算了不讲了。

✳️如何在shell内部新增自己的环境变量—putenv()由内建命令的原理去实现,但一定要注意的是,存储环境变量要有独立的空间,不要被清理掉了

✳️环境变量的本质就是由当前进程维护的,在进程上下文当中所维护的一组数据,这个数据会被所有的子进程在程序替换之时继承下去
也就是你以后你并不想覆盖式的去新增环境变量,也并不想默认只使用系统的环境变量,你想新增很简单,就让你的父进程putenv()导入一个环境变量,就能被继承了。

✳️环境变量的数据,在进程的上下文当中
1。环境变量会被子进程继承下去,所以他会有全局属性
2.当我们进行程序替换的时候,当前进程环境变量非但不会被替换,而 且是继承父进程的!
(环境变量是数据,当进行程序替换的时候,程序替换不是会替换代码和数据吗?但是环境变量很特殊,它属于系统的数据,所以子进程在被替换,当前环境变量的数据不会被替换掉,而是以父进程的模版继承下去的,所以才有了刚刚让父进程以内建的命令putenv()函数手动添加环境变量,然后让子进程继承下去的)

为什么要让子进程去程序替换

让子进程去替换为什么呢?因为如果只有一个进程去替换之后,那么这一个进程的代码就荡然无存了,那么只能让这一个程序去执行新的程序,这样有问题吗?没有问题,但是你如果想周期性的去执行同样的任务,比如说周期的去创建子进程,去执行新的程序,执行完了之后,再识别命令,再去执行新的程。那么你想让你的程序周期性,周而复始的去干一件事,那么我们通过创建子进程的方式,那么是很值得我们去推荐的。
为什么呢?因为进程替换永远只影响进程本身,子进程替换不会去影响父进程,因为进程具有独立性,这独立性体现在内核层面,不同进程,有不同的地址空间,有不同的页表,替换只是加入新的代码和数据,重新建立的是页表映射,但并不影响具体的内核数据结构。第二个我们在替换的时候,我们今天就可以理解成,在实际替换的时候,子进程和父进程虽然数据写时拷贝,代码共享。但一旦发生程序替换,那么就可以认为父子进程在代码和数据两方面都发生了写时拷贝,就彻底将父子进程独立开了。
所以最后我们引入一个问题为什么要创建子进程,答案就出来了,因为我们想让子进程去干的事情,一方面把事情干了,另一方面还不会影响我父进程,因为父进程将来还可能去接受新的命令去执行新的程序,以前在解释这句话的时候可能会比较费劲,但今天不费劲了,我们写了一个mini版的xshell,它能够帮我们去把对应的命令跑起来,因为是子进程替换,并不会影响父进程运行

内建命令

上面讲第四步TODO的时候也有一小部分内建命令解释。

export导入的环境变量交给了父进程bash,它一定是个内建命令,要不然它的环境变量怎么会导入到你的,如果他创建子进程,他怎么会导入到你的shell呢;
echo也是内建命令,本地变量和环境变量本质上不同的是,本地变量是不可被继承的,只有是环境变量才能被子进程继承下去。当你在命令行定义变量#myval = 100;你用echo $myval是可以打印myval的值。若你是派子进程去执行是看不到myval的,所以echo也是内建命令。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值