Linux系统与系统编程(8)——环境变量、进程控制与进程替换

前言

**欢迎观看Linux系列文章!!**第8篇主要讲述了++环境变量的查找++ 、++存储和操作++ 等知识,以及++进程控制的进程创建++ 、++进程等待++ 、++进程终止++ ,还有最后的++进程替换++。

目录

前言

环境变量

概念

命令行参数

查看环境变量

环境变量的存储

更多环境变量

操作环境变量

export

env

[echo ](#echo )

unset

在代码中获取环境变量

环境变量的相关补充

进程地址空间

基本概念

虚拟地址与进程地址空间

为什么

进程控制

进程创建

进程终止

进程退出场景

进程常见退出方式

进程退出码

进程等待

进程等待必要性

如何进行进程等待

获取进程退出信息

​编辑

正常退出

信号终止

补充

进程程序替换

进程替换相关接口

execl

execlp

execv

execvp

execvpe

execle

execve


环境变量

概念

操作系统中用于指定操作系统运行环境的一些参数。

命令行参数

演示代码:

cpp 复制代码
#include<stdio.h>
#include<string.h>
//main函数是有参数的
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        printf("Usage: %s [-a|-b|-c]\n", argv[0]);
    }

    const char* arg = argv[1];
    if (strcmp(arg, "-a") == 0)
        printf("这是功能1\n");
    else if (strcmp(arg, "-b") == 0)
        printf("这是功能2\n");
    else if (strcmp(arg, "-c") == 0)
        printf("这是功能3\n");
    else
        printf("Usage: %s [-a|-b|-c]\n", argv[0]);

    for(int i = 0; i < argc; i++)
    {
        printf("agrv[%d] = %s\n", i, argv[i]);
    }
    return 0;
}

通过下方指令,运行程序。

bash 复制代码
./code a b c d

我们可以发现,argv数组,把命令行的内容都放到了里面去。

如果我们运行的是普通的指令,如:

bash 复制代码
ls -l -a -d

因为Linux的指令代码也是用C++写的,所以,也会有main函数入口,这些命令行参数就会以相同的方式放入main函数的argv参数中。

argc表示的是argv的大小,argv的最后一个位置需要存放一个nullptr(NULL)占位标识。

argv是命令行参数,我们可以通过命令行参数,实现不同程序的子功能。

当我们输入指令时,指令字符串会先传给bash,bash把指令切分之后再传给指定的指令程序,切分后的字符串会进入argv数组中。

但是为什么我们自己编写的可执行程序,运行需要带路径(即./),系统的指令也是可执行程序,却不需要带路径?

那是因为系统当中存在环境变量PATH!!它能帮助系统找到二进制文件。

如果把自己的可执行程序放到/usr/bin中就可以不带路径了,但是不推荐这样做,会污染系统的指令池。

查看环境变量

bash 复制代码
env

env环境变量:名字=内容

也可以指定查看环境变量

bash 复制代码
echo $[指定环境变量]

PATH环境变量,就是让系统指令运行时不用加路径的关键。也就是说,系统找程序是通过PATH找的。

环境变量的存储

在底层,bash会生成两张表(指针数组),一个是命令行参数表,一个是环境变量表。每一个环境变量都在表中malloc一块空间进行存储。

环境变量最开始从系统的相关配置文件(.bash_profile)中来,系统通过配置文件创建环境变量表。每次开机时,系统会在配置文件中找到环境变量的配置,然后对系统环境变量进行设置。所以不修改配置文件,而是直接修改环境变量时,当时运行时,新的环境变量有效。重启系统后,环境变量又会刷新回配置文件的设置。

更多环境变量

上文讲述了环境变量PATH,还有其他的环境变量。

HOME:登录时地址。登录系统成功后,默认就会进入HOME的目录作为当前工作路径。

**HOSTNAME:**当前主机的主机名。

LANG:编码格式(如utf-8、unicode等)

PWD:当前工作路径。

USER:当前用户。

SHELL:默认 Shell。当前用户登录后默认使用的命令解释器路径。

SSH_TTY:当前命令行窗口对应的设备文件路径。

SSH_CLIENT:SSH客户端信息,包括客户端IP,客户端端口,服务端端口

OLD_PWD:上一次进入的工作路径。

......

操作环境变量

export

创建环境变量

bash 复制代码
export [环境变量名]=[环境变量内容]

env

查看全部环境变量。

echo $

查看指定单个环境变量。

unset

取消环境变量。

在代码中获取环境变量

方法1️⃣

main函数的参数还可以多加一个环境变量参数,父进程可以个子进程传递环境变量作为参数。

cpp 复制代码
#include<stdio.h>
#include<string.h>
//父进程传递给我们的环境变量参数
int main(int argc, char* argv[], char* env[])
{
    (void)argc;
    (void)argv;

    for(int i = 0; env[i]; i++)
    {
        printf("env[%d]->:%s\n", i, env[i]);  
    }
    return 0;
}

程序输出了全部的环境变量

源代码中把获取到的所有环境变量循环获取并输出。

main()函数是由_start函数调用的,_start会扫描main的参数个数。如果没有参数,就不传递东西给main();若有两个参数,说明main()需要命令行参数,_start就会传递命令行参数给main()函数;若果有三个参数,就会再多传递一个环境变量表,这个环境变量表来自于_start的环境变量。

_start是所以main()函数的父进程,环境变量是从父进程上继承下去的。因为这个继承性的存在,所以环境变量可以认为是全局性质的。

通过这一点,就可以靠环境变量,做到进程间传递数据。

方法2️⃣

使用getenv()系统调用函数。

cpp 复制代码
getenv("[指定获取的变量名]")
cpp 复制代码
#include<stido.h>
#include<stdlib.h>
#include<string.h>

int main(int argc, char* argv[], char* env[])
{
	(void)argc;
    (void)argv;
	(void)env;

	const char*who = getenv("USER");
	if(who == NULL)
		return 1;
	if(strcmp(who, "Tom") == 0)
	{
		printf("Tom started this program!\n");
	}
	else
	{
		printf("Only Tom can run this program!\n");
	}
	return 0;
}

此处,代码根据获取到的USER的内容,判断是否是Tom执行的文件。

Tom用户打开的终端和root用户打开的终端,运行后的结果不一样,说明两个终端打开时

方法3️⃣

extern char** environ指针

这个指针指向的是环境变量表的起始位置。

cpp 复制代码
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>

extern char** environ;

int main(int argc, char* argv[])
{
	(void)argc;
	(void)argv;
	for(int i = 0; environ[i]; i++)
	{
		printf("environ[%d]-> %s\n", i, environ[i]);
	}
	return 0;
}

环境变量的相关补充

a.环境变量具有全局性,上文有提到。

b.可以在命令行中定义本地变量

bash会记录两套变量,一套是环境变量,一套是本地变量。

本地变量不像环境变量一样被子进程继承。

查看本地变量用指令set。

bash 复制代码
set

c.环境变量放在bash那里,那我们如何给bash添加环境变量呢?bash作为父进程,它和它的子进程之间的继承关系是单向的。那要怎么逆向去操作bash呢?

使用指令export

bash 复制代码
export [变量名]

通过export指令,把操作给到bash自己来完成。由bash自己变量给到bash去创建环境变量。

d.因为环境变量的作用,我们的gcc编译时,才可以连接到动静态库。

进程地址空间

基本概念

示意图

中间的共享区,是一段很大的镂空空间。

注意!进程地址空间不是在内存中的空间。在Linux系统中,这个又叫虚拟地址空间。

一个进程只有一个PCB,一个进程又对应一个虚拟地址空间。进程运行时,通过虚拟地址访问用户空间里的代码和数据。通过起始地址和偏移量,就可以访问指定的内容了。

一个进程还对应一个页表,页表是用来做虚拟地址和物理地址映射的。

默认情况下,父子进程两个PCB,通过页表的映射,能够访问相同的数据。但子进程要对变化进行修改的话,会在内存新开一段,就会修改子进程页表的映射到新的内存空间,从而完成了写时拷贝。

页表等知识后面会讲。

虚拟地址与进程地址空间

在内核中,虚拟地址空间由结构体实现,虚拟地址空间一个大的结构体,里面包括了各个数据区域的小结构体。其中记录了虚拟地址空间每个区域的起始位置和结束位置的地址。

这些初始化的起始位置和结束位置,是程序加载到内存上时,通过页表的映射获得的虚拟的地址,不是物理的地址。

而访问内存的数据,就靠虚拟地址通过页表再转化回物理地址,从而在物理内存中完成访问。

为什么

为什么要这么做映射,而不是直接去物理内存访问呢?

1️⃣程序在加载到内存上时,是无序的。通过页表的映射,在进程的视角下,生成的虚拟地址是有序的。

2️⃣间接地访问内存可以在地址转换的过程中,对你的地址和操作进行合法性判定,以保护物理内存(如检测野指针,检测权限、修改字符常量等)。

3️⃣让进程管理和内存管理进行一定程度的解耦。

进程控制

进程创建

在之前的文章已经说过,这里简单讲述。

创建进程我们通过fork函数来完成。

内核任务:

1️⃣分配新的内存块和内核数据结构给子进程

2️⃣将父进程的部分数据内容拷贝至子进程

3️⃣添加子进程到系统的进程列表中

4️⃣fork返回其返回值,调度器开始调度。向父进程返回子进程PID,向子进程返回0。

当任意一方试图写入时,就会进行写时拷贝,保证进程独立性。写时拷贝只拷贝需要修改的数据,未修改的数据依旧贡献,从而节省内存,减少创建消耗的时间。

fork可能会调用失败,原因是系统中已经有太多的进程,内存不足或者实际用户的进程超出了限制。

进程终止

本质是释放系统资源,释放进程申请的内核数据结构和相应的数据和代码。

进程退出场景

1️⃣代码运行完毕,结果正确

2️⃣代码运行完毕,结果错误

3️⃣代码异常终止。

进程退出,要根据情况给父进程进行反馈。

例如:bash会通过main函数的返回值来表明程序的执行情况。场景1️⃣就return 0,场景2️⃣就返回非零,场景3️⃣后面再说。

main函数的返回值就叫做进程退出码,进程退出码会写进PCB中。

可以查看上一个进程的退出码

bash 复制代码
echo $?

proc.c代码⬇️

cpp 复制代码
#include<stdio.h>
int main()
{
	//printf("hello world!\n");

	FILE *fp = fopen("log.txt", "r");
	if(fp == NULL) return 1;
	fclose(fp);

	return 0;
}

fp指向的是一个不存在的文件。

第一次echo输出了./proc的退出码,第二次输出的是上一条echo指令的退出码。

进程常见退出方式

1️⃣从main返回退出。

2️⃣调用exit。

3️⃣调用_exit

异常情况退出,

4️⃣Ctrl + Z,强制退出,信号终止。

进程退出码

不同的退出码,表示不同的命令完成情况。

cpp 复制代码
#include<stdio.h>
#include<string.h>
#include<errno.h>
int main()
{
	for (int i = 0; i < 200; i++)
	{
		printf("%d -> %s\n", i, strerror(i));
	}
    return 0;
}

图中输出了不同的退出码对应的退出信息,共134个。除了0是成功之外,其他数字都代表了一种错误情况,所以其他的数字也叫做错误码。

除了这135个数字之外,其他数字是没有意义的,所以我们可以将没用的数字设计成我们需要的自定义状态码。

注意:当代码异常终止(未完成运行)的时候,退出码是没有意义的。

以上是main函数的退出,还有调用exit进行退出的方式。

cpp 复制代码
#include<stdio.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
void func()
{
	printf("func1\n");
    //直接退出进程
	exit(11);
	printf("func2\n");
}

int main()
{
	func();
	printf("hello world\n");
	return 0;
}

运行到exit语句时,直接退出进程,并以指定退出码退出(这里设置为11)。

任意地方调用了exit就表示进程结束,后续代码都不执行了。

exit与_exit的区别:

exit退出会进行缓冲区刷新;_exit退出不会进行缓冲区刷新。

exit底层封装了_exit,都要进行系统调用,因为只有通过操作系统才可以终止一个程序。

缓冲区指的是C语言提供的库缓冲区,这个后面会讲。

进程等待

进程等待必要性

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

最重要的是要完成回收,避免僵尸孤儿;而获取退出信息是可选的。

如何进行进程等待

演示代码:

cpp 复制代码
#include<stdio.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>

int main()
{
	pid_t id = fork();
	//子进程
	if(id == 0)
	{
		int cnt = 5;
		while(cnt)
		{
			printf("我是一个子进程, pid: %d, ppid: %d\n", getpid(), getppid());
			sleep(1);
			cnt--;
		}
		exit(0);
	}
	//父进程
    sleep(7);
	pid_t rid = wait(NULL);
	if(rid > 0)
	{
		printf("wait success, rid == %d\n", rid);
	}
    sleep(2);
	return 0;
}

我们调用wait函数来进行进程等待。

让父进程wait子进程,直到子进程退出。在子进程退出之前,父进程会一直处于阻塞状态。

wait的参数是一个int指针/引用,指向的int变量用来接受子进程的退出信息。传入NULL就是不接收的意思。所以说获取子进程退出信息是自选的。

等待成功之后,返回的是子进程的pid。

也可以使用wait_pid,这是wait的增强版

cpp 复制代码
// waitpid() - 精确控制:指定进程、非阻塞
waitpid(1234, NULL, 0);           // 阻塞等待PID为1234的子进程
waitpid(-1, NULL, WNOHANG);       // 非阻塞查看任意子进程是否结束
waitpid(-1, NULL, 0);             // 完全等价于wait(NULL)

waitpid有三个参数:

1️⃣指定等待的子进程pid;

含义
> 0 指定PID的子进程
0 同进程组的任意子进程
-1 任意子进程(等同于wait)
< -1 同进程组ID = |pid| 的任意子进程

2️⃣用来接收子进程退出信息的整型指针/引用(同wait);

3️⃣阻塞行为选项:

含义
0 阻塞等待同进程组的子进程(和wait()一样)
WNOHANG 非阻塞,没有子进程退出就立即返回0
WUNTRACED 子进程进入暂停状态也返回
WCONTINUED 子进程恢复运行也返回

WNOHANG(即wait no hang)用来查看僵尸进程,如果没有僵尸进程,就直接返回0,有则等待成功,将其回收。

WUNTRACED(即wait untraced)将信号暂停的子进程返回。

WCONTINUED(即wait continued)等待的是进程从暂停到继续运行的瞬间,恢复运行之后立刻返回。

等待失败,会设置退出码10,No child process。

为了避免僵尸进程,一定要使用好进程等待。

获取进程退出信息

wait/waitpid都有一个接受退出信息的整型参数status。

status共32位,高位16位不使用。

正常退出:第8到15位用来存放退出码,其余位置0。

被信号杀死:第8到15位也存放退出码,但是没有意义,第7位存放core dump标志位,第0到6位存放退出时的终止信号(信号部分再讲)。

正常退出

修改上文的演示代码:

cpp 复制代码
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
	printf("wait success, rid == %d, exit code: %d\n",rid,(status>>8)&0xFF);
}

在正常退出时,获取status中的第8到15位的退出码。

信号终止

这里涉及信号的知识,只进行简述。

没有异常被终止:第0到7位都为0,也就是正常退出

异常终止:低7位 !0 。

修改上文的演示代码:

cpp 复制代码
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
	printf("wait success, rid == %d, exit code: %d, exit signal: %d\n",rid,(status>>8)&0xFF,status&0x7F);
}

这里用9信号杀死子进程后,父进程接受并输出了退出信号9,这里的退出码0没有意义。

这是信号9杀死进程,那么异常呢?

再修改上文代码

cpp 复制代码
while(cnt)
{
	printf("我是一个子进程, pid: %d, ppid: %d\n", getpid(), getppid());
	sleep(1);

	int* p = NULL;
	*p = 123;

	cnt--;
}

让子进程进行一次野指针访问。

此处得到了终止信号为11。

通过指令可以查看有哪些终止信号。

bash 复制代码
kill -l

为什么没有信号0?信号0是表示进程存在的信号,但他不是真实存在的信号。

信号其实都是宏,底层都是数字。

补充

1️⃣

以上获取退出信息依靠的是位操作,其实还有别的方法。

cpp 复制代码
WIFEXITED(status);
WEXITSTATUS(status);
WIFSIGNALED(status);
WTERMSIG(status);

WIFEXITED(status)判断进程是否正常退出,若是,则返回ture。

WEXITSTATUS(status)提取正常退出的子进程退出码。

WIFSIGNALED(即wait signaled)用来检查进程是否被信号杀死。

WTERMSIG(即wait termination signal)用来获取杀死进程的信号。

2️⃣

waitpid是默认选项下都是阻塞调用,调用之后要一直阻塞等待。

使用了WNOHANG选项之后就会变成非阻塞调用,查询一次之后不会等待。

非阻塞调用的好处是:父进程不用一直等待,可以去做自己的事情。

但是不阻塞等待,子进程退出之后怎么被父进程回收啊?

将WNOHANG选项与循环配合,让父进程多次查询子进程是否退出,这叫做非阻塞轮询。

相当于阻塞调用时,就是询问是否退出后,一直等待;非阻塞轮询时,就是多次询问,没退出就先干自己的事,一会再问。

进程程序替换

简单样例:

cpp 复制代码
#include<sys/types.h>
#include<stdio.h>
#include<unistd.h>
#include<sys/wait.h>
#include<string.h>
#include<errno.h>
#include<stdlib.h>

int main()
{
	printf("正在运行我的程序!\n");
	execl("/usr/bin/ls", "ls", "-l", "-a", NULL);
	printf("我的程序运行完毕!\n");
	return 0;
}

这就是程序替换。

基本原理:在程序替换的过程中,并没有创建新的进程,只是把当前进程的代码和数据用新的程序代码和数据进行了覆盖式的替换。

所以源代码中 printf("我的程序运行完毕!\n"); 没有被执行,而是被覆盖了。

exec系列函数都是进程替换函数,他们只有失败返回值,没有成功返回值,因为成功了就把代码都替换了,没有意义。exec系列函数的失败返回值都是 -1。

若不想替换把原来的代码给替换了无法执行,可以创建一个子进程,让子进程被替换即可。

程序替换的代码即使语言不一样,也没有关系,照样可以运行。因为实际上替换的是经解释器解释过后的代码。

进程替换相关接口

execl

第一个参数是路径;

第二个参数是传给替换程序main函数的可变参数列表,execl的l指的就是list可变参数列表。

其中,可变参数列表的第一个参数,即arg0,必须是程序名。

注意:可变参数列表必须要以NULL结尾。

cpp 复制代码
execle("/usr/bin/ls", "ls", "-l", "-a", NULL);

如上,是将指令 ls 替换进来,选项 -l, -a 作为选项。

execlp

与execl的唯一区别是,不需要带完整路径。它会自己去找环境变量PATH,将PATH作为路径。

cpp 复制代码
execlp("ls", "ls", "-l", "-a", NULL);

不用带完整路径,但是程序名还要有。

execv

execv的v是vector数组的意思。

path同execl的path,区别是把可变参数列表作为数组传入函数。

cpp 复制代码
char *const argv[]=
{
	(char*const)"ls",
	(char*const)"-l",
	(char*const)"-a",
    (char *const)NULL
};
execv("/usr/bin/ls",argv);

execvp

与execv的唯一区别是,不需要带完整路径。它会自己去找环境变量PATH,将PATH作为路径。

cpp 复制代码
char *const argv[]=
{
	(char*const)"ls",
	(char*const)"-l",
	(char*const)"-a",
    (char *const)NULL
};
execv("ls",argv);
//execv(argv[0], argv);

execvpe

execvpe的最后一个e表示environment,即环境变量。这个函数多了个envp数组传递环境变量。

cpp 复制代码
char *const argv[]=
{
	(char*const)"ls",
	(char*const)"-l",
	(char*const)"-a",
    (char *const)NULL
};

char *const env[] = {
    (char *const)"MYVAL=1234",
    (char *const)NULL
}

execvpe("ls", argv, env);

这里用了自己设置的环境变量MYVAL,但是原本父进程继承bash的环境变量就会被覆盖,替换后的环境变量就只有自己传进去的环境变量。

如果想只新增环境变量,历史继承的变量也给留下,咋办?

使用putenv( )。

cpp 复制代码
char* new = "MYVAL=1234";
putenv(new);

就可以在原来的基础上,新增环境变量,然后再用其他的exec系列函数(函数名不带e的函数)即可。

也可以在替换前,把继承的环境变量都放到new数组中。把new传入替换的程序中,在新的程序代码中就能获得默认环境变量和新增环境变量了。

execle

cpp 复制代码
char *const env[] = {
    (char *const)"MYVAL=1234",
    (char *const)NULL
}
execle("/usr/bin/ls", "ls", "-l", "-a", NULL, env);

在execl的基础上,多传递一个env环境变量数组。

execve

cpp 复制代码
char *const argv[]=
{
	(char*const)"ls",
	(char*const)"-l",
	(char*const)"-a",
    (char *const)NULL
};
char *const env[] = {
    (char *const)"MYVAL=1234",
    (char *const)NULL
}
execv("/usr/bin/ls",argv, env);

这些exec系列函数都是对execve这个系统调用函数的封装,所以实际上在做进程替换时,实际上都会传递环境变量。

❤~~本文完结!!感谢观看!!接下来更精彩!!欢迎来我博客做客~~❤

相关推荐
楚灵魈1 小时前
[SKILL]从零开始的Arch Linux安装工作流程
linux·人工智能
Galsk1 小时前
Linux零拷贝
java·linux·服务器·面试
IT大白鼠10 小时前
Linux进程与计划任务管理:技术详解与实战指南
linux·运维·服务器
拾贰_C10 小时前
【Ubuntu | 公共工作站 | mysql 】 MySQL残留物残留数据
linux·mysql·ubuntu
Ujimatsu11 小时前
虚拟机安装Ubuntu 26.04.x服务器版(命令行版)(2026.5)
linux·windows·ubuntu
hweiyu0011 小时前
Linux命令:arptables
linux·运维
仙柒41512 小时前
管理网络安全
linux·运维·服务器
福尔摩斯·柯南13 小时前
Ubuntu 14.04/16.04/18.04/20.04/22.04/24.04/26.04全系列LTS长期支持版镜像IOS分享
linux·运维·ubuntu
xiaoming001814 小时前
JAVA项目打包部署运维全流程(多服务、批量)
java·linux·运维