前言:本章我们讲解它的 options 参数。在讲解之前我们需要理解进程阻塞,然后我们重点讲解二进程程序替换,这是本章的重点,然后介绍一个进程替换函数 execl,通过介绍这个函数来打开突破口,引入进程创建的知识点。最后,我们在学习进程创建的 exec 函数簇。
一、进程阻塞(Process Blocking)
1、继续讲解 waitpid
我们先来简单回顾一下上一章的内容:
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int* status, int options);
上一章介绍了 status 参数,知道了如何通过位操作来截 status 获取进程错误码与错误信号:
status&0x7F // 获取错误信号
(status>>8)&0xFF // 获取错误码
但是我们还是不建议这么做,因为直接用操作系统提供的宏就好了,我们可以通过 WIFEXITED 宏来检测子进程是否正常退出(检测进程退出时信号是否为 0),在用 WEXITSTAUS 宏还获取进程的退出码:
if (WIFEEXITED(status))
{
printf("等待成功: exit code: %d\n", WIFEEXITED(status));
}
这些都是上一章讲解 status 参数的内容了,我们下面要讲的是 waitpid 另一个参数 options。
当 options 为 0,则标识为 阻塞等待
比如:如果子进程不退出,父进程在等,等的时候子进程是卡在那等的。
2、理解进程阻塞
思考:如何理解父进程进程阻塞?
首先,进程状态我们说过:如果一个进程在系统层面上要等待某件事情发生,
但这件事情还没发生,那么当前进程的代码还没法向后运行,只能让该进程处于阻塞状态。
就是让父进程的 task_struct 状态由 ,从运行队列投入到等待队列,等待子进程退出。
子进程退出的本质是条件就绪,如果子进程退出条件一旦就绪,操作系统会逆向地做上述工作。
将父进程的从等待队列再搬回运行队列,并将状态,此时父进程就会继续运行。
3、轮询检测(Polling)
所谓的阻塞,其实就是挂起。在上层表现来看,就是进程卡住了
而非阻塞式等待是会做些自己的事,而不是傻等!
多次调用非阻塞接口,这个过程我们称之为 轮询检测 (Polling)。
我们上一章中讲解 waitpid 时,举的例子都是 阻塞式 的等待。
如果我们想 非阻塞式 的等,我们可以设置 options 选项为 WNOHANG
(With No Hang)。
这个选项通过字面很好理解,就是等待的时候不要给我挂 (Hang) 住,其实就是非阻塞!
现在我们正式介绍一下 waitpid 的返回值:
- 如果此时等待成功,返回值是子进程的退出码。
- 如果你是非阻塞等待 (
WNOHANG
),等待的子进程没有退出,返回值为 0。
4、 基于非阻塞的轮询等待(waitpid)
如果我们想把我们上一章节,演示 waitpid 使用方式的代码,改为非阻塞等待。
我们只需要将 waitpid 的 options 参数加上。
代码演示:基于非阻塞的轮询等待
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
int main(void)
{
pid_t id = fork();
if (id == 0) {
// 子进程
while (1) {
printf("子进程:我的PID: %d,我的PPID: %d\n", getpid(), getppid());
sleep(5); // 先睡眠 5s,5s后退出
break;
}
exit(233);
}
else if (id > 0) {
// 父进程
/* 基于非阻塞的轮询等待方案 */
int status = 0;
while (1) {
pid_t ret = waitpid(-1, &status, WNOHANG);
if (ret > 0) { // 等待成功
printf("等待成功,%d,退出信号: %d,退出码: %d\n", ret, status&0x7F, (status>>8)&0xFF);
}
else if (ret == 0) { // 等待成功,但是子进程没有退出
printf("父进程:子进程还没好,那我先做其他事情\n");
sleep(1);
}
else {
// 出错了,暂时不作处理
}
}
}
else {
// 什么也不做
}
}
说明:我们只需要将 waitpid 中的 options 参数带上 WHOHANG 就可以了。返回值 ret>0 就是等待成功,我们这里新增一个等于 0 的判断,作为 "等待成功但是子进程还没有退出" 的情况,因为等待的子进程没有退出,返回值为 0 。运行后,就会问子进程好没好,如果没有好父进程就可以做自己的事情了,这就是非阻塞式轮询等待。
运行结果如下:
我们可以让父进程在非阻塞等待时真正做点事
代码演示:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <vector>
typedef void (* handler_t)(); // 函数指针类型
// 方法集
std::vector<handler_t> handlers;
void func1() {
printf("Hello,我是方法1\n");
}
void func2() {
printf("Hello,我是方法2\n");
}
void Load() {
// 加载方法
handlers.push_back(func1);
handlers.push_back(func2);
}
int main(void)
{
pid_t id = fork();
if (id == 0) {
// 子进程
while (1) {
printf("子进程:我的PID: %d,我的PPID: %d\n", getpid(), getppid());
sleep(3);
}
exit(233);
}
else if (id > 0) {
// 父进程
/* 基于非阻塞的轮训等待方案 */
int status = 0;
while (1) {
pid_t ret = waitpid(-1, &status, WNOHANG);
if (ret > 0) { // 等待成功
printf("等待成功,%d,退出信号: %d,退出码: %d\n", ret, status&0x7F, (status>>8)&0xFF);
}
else if (ret == 0) { // 等待成功,但是子进程没有退出
printf("父进程:子进程好了没?哦,还没,那我先做其他事情啦\n");
if (handlers.empty()) {
Load();
}
for (auto f : handlers) {
f(); // 回调处理对应的任务
}
sleep(1);
}
else {
// 出错了,暂时不作处理
}
}
}
else {
// 什么也不做
}
}
如果你想要你的程序直接父进程做更多的事情,把方法加到 Load 里就可以了。
写下 Makefile:
mytest:mytest.cpp
g++ -o $@ $^ -std=c++11
.PHONY:clean
clean:
rm -f mytest
运行结果如下:
二、 进程程序替换(Process Substitution)
1、 让子进程执行一个新的程序
我们之前做的所有代码演示,子进程执行的都是父进程的代码片段。
如果我们想让创建出来的子进程,执行全新的程序呢?
之前我们通过写时拷贝,让子进程和父进程在数据上互相解耦,保证独立性。如果想让子进程和父进程彻底分开,让子进程彻彻底底地执行一个全新的程序,我们就需要 进程的程序替换。
那为什么要让子进程执行新的程序呢?
我们一般在服务器设计的时候(Linux 编程)的时候,往往需要子进程干两件种类的事情:
- 让子进程执行父进程的代码片段(服务器代码…)
- 想让子进程执行磁盘中一个全新的程序(shell、想让客户端执行对应的程序、通过我们的进程执行其他人写的进程代码、C/C++ 程序调用别人写的 C/C++/Python/Shell/Php/Java...)
2、程序替换原理
程序替换的原理:
- 将磁盘中的内存,加载入内存结构。
- 重新建立页表映射,设执行程序替换,就重新建立谁的映射(下图为子进程建立)。
- 效果:让父进程和子进程彻底分离,并让子进程执行一个全新的程序!
程序替换的本质
本质上就是去替换一个进程pcb在内存中的对应的代码和数据(加载新程序到内存——>更新页表信息——>初始化虚拟地址空间),然后这个进程pcb重新开始执行这个新的程序
这个过程有没有创建新的进程呢?没有!根本就没有创建新的进程!
因为子进程的内核数据结构根本没变,只是重新建立了虚拟的物理地址之间的映射关系罢了。
内核数据结构没有发生任何变化! 包括子进程的 , 都不变,说明压根没有创建新进程。
以可变参数列表的接收参数的 execl 接口
我们要调用接口,让操作系统去完成这个工作 —— 系统调用。
如何进行程序替换?我们先见见猪跑 —— 从 execl 这个接口讲,看看它怎么跑的。
int execl(const char* path, const char& arg, ...);
如果我们想执行一个全新的程序,我们需要做几件事情:
- 第一件事情:先找到这个程序在哪里。
- 第二件事情:程序可能携带选项进行执行(也可以不携带)。
简单来说就是:① 程序在哪? ② 怎么执行?
所以,execl 这个接口就必须得把这两个功能都体现出来!
- 它的第一个参数是 path,属于路径。
- 参数 const char* arg, ... 中的 ... 表示可变参数,命令行怎么写(ls, -l, -a) 这个参数就怎么填。ls, -l, -a 最后必须以 NULL 结尾,表示 "如何执行程序的" 参数传递完毕。
代码演示:exec()
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("我是一个进程,我的PID是:%d\n", getpid());
// ls -a -l
execl("/usr/bin/ls", "ls", "-l", "-a", NULL); // 带选项
printf("我执行完毕了,我的PID是:%d\n", getpid());
return 0;
}
运行结果:
运行最后就会执行ls命令execl("/usr/bin/ls","ls","-l","-a",NULL);
刚才是带选项的,现在我们再来演示一下不带选项的:
execl("/usr/bin/top", "top", NULL); // 不带选项
运行结果如下:
这样我们的程序就直接能执行 top 命令了,除此之外,我们曾经学的大部分命令其实都可以通过 execl 执行起来。这就叫做 程序替换。
printf("我执行完毕了,我的PID是:%d\n", getpid());
这句话为什么没有打印出来??
为什么我们最后的代码并没有被打印出来?
因为 一旦替换成功,是会将当前进程的代码和数据全部替换的!
所以自然后面的 printf 代码早就被替换了,这意味着该代码不复存在了,荡然无存!
因为在程序替换的时候,就已经把对应进程的代码和数据替换掉了!
而第一个 printf 执行了的原因自然是因为程序还没有执行替换,
所以,这里的程序替换函数用不用判断返回值?为什么?
int ret = execl(...);
一旦替换成功,还会执行返回语句吗?返回值有意义吗? 没有意义的!
程序替换不用判断返回值!因为只要成功了,就不会有返回值。 而失败的时候,必然会继续向后执行。通过返回值最多能得到是什么原因导致替换失败的。只要执行了后面的代码,看都不用看,一定是替换失败了;只要有返回值,就一定是替换失败了。
我们来模拟一下失败的情况,我们来执行一个不存在的指令 :
execl("/usr/bin/lssssss", "ls", "-l", "-a", NULL); // 带选项
execl 替换失败,就会继续向后执行。但是,一旦 execl 成功后就会跟着新程序的逻辑走,就不会再 return 了,再也不回来了,所以返回值加不加无所谓了。
3、引入进程创建
以前我们的示例都是让子进程执行父进程的代码,我们今天想让子进程执行自己的程序。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main(void)
{
printf("我是父进程,我的PID是: %d\n", getpid());
pid_t id = fork();
if (id == 0) {
/* child
我们想让子进程执行全新的程序 */
printf("我是子进程,我的PID是:%d\n", getpid());
execl("/usr/bin/ls", "ls", "-a", "-l", NULL); /* 让子进程执行替换 */
exit(1); /* 只要执行了exit,就意味着 excel 系列函数失败了,终止子进程*/
}
/* 一定是父进程 */
int status = 0;
int ret = waitpid(id, &status, 0);
if (ret == id) {
/* 等待成功 */
sleep(2);
printf("父进程等待成功!\n");
}
return( 0);
}
运行结果:
成功执行代码,父进程也等待成功了。这里的子进程没有执行父进程的代码,而是执行了自己的程序。
子进程执行程序替换,会不会影响父进程呢?不会!因为进程具有独立性。
当程序替换的时候,我们可以理解成 —— 代码和数据都发生了写时拷贝,完成了父子分离。
三、exec 函数簇(Sheaf of functions exec)
1、以指针数组接收参数的 execv 接口
刚才我们学会了 execl 接口,我们下面开始学习更多的 exec 接口!它们都是用来替换的。
下面我们先来讲解一下和 execl 很近似的 execv:
int execv(const char* path, char* const argv[]);
path 参数和 execl 一样,关注的都是 "如何找到"
argv[] 参数关注的是 "如何执行",是个指针数组,放 char* 类型,指向一个个字符串。
大家在命令行上 $ ls -a -l ,在 execl 里我们是这么传的: "ls", "-a", "-l", NULL 。
所以 execv 和 execl 只有传参方式的区别,一个是可变参数列表 (l),一个是指针数组 (v)。
值得注意的是,在构建 argv[] 的时,结尾仍然是要加上 NULL!
代码:execv()
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
#define NUM 16
int main()
{
pid_t id=fork();
if(id==0){
//子进程
//ls -a -l
printf("子进程开始运行,pid: %d\n",getpid());
sleep(3);
char* const _argv[NUM]={
(char*)"ls",
(char*)"-l",
(char*)"-a",
NULL
};
//execl("/usr/bin/ls","ls","-a","-l",NULL);
execv("/usr/bin/ls",_argv);
exit(1);
}
else{
//父进程
int status=0;
printf("父进程开始运行,pid: %d\n",getpid());
pid_t id=waitpid(-1,&status,0);//阻塞等待
if(id>0){
printf("wait success,exit code: %d\n",WEXITSTATUS(status));
}
}
return 0;
}
运行结果:
2、无需带路径就能直接执行的 execlp 接口(可变参数列表)
int execlp(const char* file, const char* arg, ...);
execlp,它的作用和 execv、execl 是一样的,它的作用也是执行一个新的程序。
仍然是需要两步:① 找到这个程序 ② 告诉我怎么执行
第一个参数 file 也是 "你想执行什么程序",第二个参数 arg 是 "如何去执行它"。
所以这一块的参数传递,和 execl 是一样的,唯一的区别是比 execl 多了一个 p!
我们执行指令的时候,默认的搜索路径在环境变量 中,所以这个 p 的意思是环境变量。
这意味着:执行 execlp 时,会直接在环境变量中找,不用去输路径了,只要程序名即可。
execlp("ls", "ls", "-a", "-l", "NULL"); // 路径都不用,直接扔
这里出现的两个 ls 含义是不一样的,不可以省略
代码演示:execlp()
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
#define NUM 16
int main()
{
pid_t id=fork();
if(id==0){
//子进程
//ls -a -l
printf("子进程开始运行,pid: %d\n",getpid());
sleep(3);
char* const _argv[NUM]={
(char*)"ls",
(char*)"-l",
(char*)"-a",
NULL
};
//execl("/usr/bin/ls","ls","-a","-l",NULL);
//execv("/usr/bin/ls",_argv);
execlp("ls","ls","-l","-a",NULL);
exit(1);
}
else{
//父进程
int status=0;
printf("父进程开始运行,pid: %d\n",getpid());
pid_t id=waitpid(-1,&status,0);//阻塞等待
if(id>0){
printf("wait success,exit code: %d\n",WEXITSTATUS(status));
}
}
return 0;
}
运行结果:
3、无需带路径的 execvp 接口(指针数组)
int execvp(const char* file, char* const argv[]);
execvp 也是带 p 的,执行 execvp 时,会直接在环境变量中找,只要程序名即可。
代码
char* const _argv[NUM]={
(char*)"ls",
(char*)"-l",
(char*)"-a",
NULL
};
execvp("ls", _argv);
运行结果:
目前我们执行的程序,全部都是系统命令,如果我们要执行自己写的 C/C++ 程序呢?
4、利用 exec 调各种程序
假设有两个可执行程序:mycmd.c & exec.c,我们期望用 exec.c 调用 mycmp.c:
//mycmd.c
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
int main(int argc,char *argv[])
{
if(argc!=2){
printf("can not execute!\n");
exit(1);
}
if(strcmp(argv[1],"-a")==0){
printf("helllo a!\n");
}
else if(strcmp(argv[1],"-b")==0){
printf("hello b!\n");
}
else{
printf("default!\n");
}
return 0;
}
也就是 C 语言的可执行程序调用 C++ 的可执行程序,我们先来设计一下 Makefile。
我们需要在前面添加 .PHONY:all ,让伪目标 all 依赖 exec 和 mycmd。
如果不这样做,直接写,默认生成的是 mycmd,轮不到后面的 exec,属于 "先到先得"。
且 Makefile 默认也只能形成一个可执行程序,想要形成多个就需要用到 all 了。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/wait.h>
#define NUM 16
const char *myfile="/home/amx/lesson3/mycmd";
int main()
{
pid_t id=fork();
if(id==0){
//子进程
//ls -a -l
printf("子进程开始运行,pid: %d\n",getpid());
sleep(3);
char* const _argv[NUM]={
(char*)"ls",
(char*)"-l",
(char*)"-a",
NULL
};
execl(myfile,"mycmd","-a",NULL);
//execv("/usr/bin/ls",_argv);
//execlp("ls","ls","-l","-a",NULL);
exit(1);
}
else{
//父进程
int status=0;
printf("父进程开始运行,pid: %d\n",getpid());
pid_t id=waitpid(-1,&status,0);//阻塞等待
if(id>0){
printf("wait success,exit code: %d\n",WEXITSTATUS(status));
}
}
return 0;
}
运行结果:
这是用的是绝对路径,那我们使用相对路径可以吗?
结果:
依然可以!!!!
那么如何 执行其它语言的程序呢??
我们创建两个文件
准备工作:先给两个文件写一些内容
试试能否运行:
都没问题:
那么我们修改我们的exec.c文件
运行结果:
完美!!!
运行test.sh也是一样!!!
运行结果:
还有一种方法:
修改:exec.c
一样可以!!
5、添加环境变量给目标进程的 execle 接口
int execle(const char* path, const char* arg, ..., char* const envp[]);
我们可以使用 execle 接口传递环境变量,相当于自己把环境变量导进去。
打开 mycmd 文件,我们加上几句环境变量:
我们自己在exec.c中定义一个
传进去:
我们来试一下:
超级缝合怪 execvpe 接口
v - 数组,p - 文件名,e - 可自定义环境变量:
int execvpe(const char* file, char* const argv[], char* const envp[]);
这也没什么好说的,execle、execve、execvpe 都是 "环境变量" 一伙的。
6、为什么会有这么多 exec 接口?
唯一的差别就是传参的方式不一样,有的带路径,有的不带路径,有的是列表传参,有的是数组传参,有的可带环境变量,有的不带环境变量。
因为要适配各种各样的应用场景,使用的场景不一样,有些人就喜欢列表传参,有些人喜欢数组传参。所以就配备了这么多接口,这就好比我们 C++ 函数重载的思想。
那为什么 execve 是单独的呢?
int execve(const char* file, char* const argv[], char* const envp[]);
它处于 man 2 号手册,execve 才属于是真正意义上的系统调用接口。
总结一下它们的命名规律,通过这个来记忆对应接口的功能会好很多:
- l (list) :表示参数采用列表形式
- v (vector) :表示参数采用数组形式
- p (path):有 p 自动收缩环境变量 PATH
- e (env) :表示自己维护环境变量
感谢观看!!!