在之前我们已经了解了进程基本的概念、知道了如何去创建子进程;还了解了进程状态、进程切换、进程O(1)调度算法等,那么接下来在本篇当中我们就来学习环境变量和程序地址空间的相关知识,相信通过本篇的学习你会有很大的所获,一起加油吧!!!

1. 环境变量
1.1 环境变量的概念
• 环境变量(environment variables) ⼀般是指在操作系统中用来指定操作系统运行环境的⼀些参数
• 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪
里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
• 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
1.2 命令行参数表
以上给了环境变量的概念,但是这些概念现在对我们来说没啥用,因为目前对环境变量完全就没有一定认识,之后通过了解过环境变量具体的实例时候再去理解概念才会又收获。但在此在了解环境变量之前先要来了解命令行参数。
在之前我们在C/C++内编写代码的时候main函数都是没有参数的,那么是否main函数就不能带参呢?
其实和普通的函数一样,main函数也是可以带上参数的,在此第一个参数为就为在执行对应的可执行程序时输入的参数的个数,第二个参数就是一个字符串指针。
例如以下实现的代码:
cpp
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main(int argc,char* argv[])
{
for(int i=0;i<argc;i++)
{
printf("argv[%d]:%s\n",i,argv[i]);
}
return 0;
}
将以上的代码编译成才执行查询之后运行,当我们在运行的时候带上参数就会输出以下的内容

通过以上的示例就可以看出main是可接收用户输入的参数的,之前我们没有使用过只不过是之前实现的程序只需要从头跑到尾即可,不需要处理用户进行选择的情况。
那么用户输入的参数又是怎么样传输给main函数的呢,main函数内又是怎么保存的呢?
其实在我们运行对应的程序时,程序内部都会有一张命令行参数表,在该表当中就存储着命令行内输入的参数。
例如以上的示例,命令行参数表就如下所示:
1.3 环境变量表
以上我们就了解了命令行参数表,那么接下来我们就要来思考一个问题了就是在执行我们自己写的程序时是需要在之前带上./的,而使用系统自带的命令时直接写出名字即可运行对应的指令,这是为什么呢?
要解答以上的问题就需要了解到环境变量,我们知道要执行一个程序就需要先找到对应的程序的路径,在进程内部就会使用PATH来存储系统当中默认搜索指令的路径。
在此可以使用env指令来查看所有的环境变量

在以上就可以看到环境变量内是存在PATH的。
除此之外要查询环境变量还可以使用 echo $环境变量名来进行查询

那么这时就可以解释为什么我们在使用系统内的指令可以不带路径而直接使用指令的名称就可以调用,这其实就是在输入的指令只要是在PATH内的路径下,就会在用户输入的指令前加上路径。
这时你可能就会好奇那是不是只要将以上我们的mytest的路径也加到PATH内,那么是不是在运行mytest的时也可以不带./就可以执行了呢?
要解答这个问题很简单,来试试看不就知道了

以上直接使用PATH=当前路径这时就可以直接使用mytest了,但是这时又有问题了,那就是现在再查询PATH会发现我们的操作是将之前的PATH给覆盖了,那么这就会造成系统原本的指令无法正常的直接使用了。
注:在此PATH改变时pwd还能正常的使用是因为pwd是内建命令,在之后会进行详细的讲解。

那么这时我们要怎么才能将PATH恢复呢?
这时只需要将Xshell重启即可

在此在PATH内进行路径追加的正确方式是如下所示:
此时就将当前的路径追加到了PATH内,并且还保留了原来的环境变量,这样系统内的指令就还能正常的使用。
其实以上我们能使用env,echo等的指令查询环境变量其实是系统的bash内部存在一张环境变量表,和之前提到的命令行参数表类似,环境变量表也是本质也是一个指针数组。当bash启动的时候就会构建出环境变量表。
当我们使用ls -a等的指令时bash就会先将输入的命令分解到命令行参数表当中,之后再在环境变量表当中查询路径。如果指令存在就创建子进程执行,不存在就报command not found错误提示。

到现在我们就知道了在bash创建时就会在bash内部存在两张表分别是命令行参数表和环境变量表,那么接下来问题又来了,那就是环境变量最开始是从哪里来的呢?
其实环境变量表最开始是存在系统当中的配置文件,使用cd ~指令回到家目录之后就可以看到存在以上的两个隐藏文件
查看.bashrc

查看bash_profile

接下来再查看.bashrc内提到的/etc/bashrc
在此就可以看到很多的环境变量,在此还可以看到之前我们学习过的权限掩码umask
当我们将配置文件当中的环境变量修改时就能让每次登入Xshell的时候查询环境变量都是发生修改的,不过强烈不建议随意的修改配置文件当中的环境变量,这样有时会造成系统的混乱。
那么如果当中Linux当中有10个用户登入,就会存在10个bash,此时这10个bash都会各有一张环境变量表、一张命令行参数表。都会从配置文件当中拷贝对应的环境变量。
通过以上了学习就知道了我们执行一个指令前提是找到对应的指令,在此其实就是bash来进行的,这时因为bash既有命令行参数表;又有环境变量表。
1.4 更多环境变量
以上我们了解了环境变量表,但是我们只了解了PATH这一个环境变量,那么接下来就来了解更多的环境变量。
cppUSER与LOGNAME
在环境变量当中USER表示的用户名,LOGNAME表示的是当前登录的用户名,正常情况下这两个是同是相同的。
cppSHELL
SHELL表示的是当前bash的路径
cppHISTCONTROL
HISTCONTROL的作用就是保存用户最近的指令,这也是为什么在我们可以使用CTRL+r和上下键去查询历史的指令,但是一般自会保存最近的指令,要不然会占据太多的内存资源。
cppHOME
HOME保存的是当前用户的家目录
cppPWD
PWD存储的是当前的路径
cppOLDPWD
OLDPWD保存的是最近一次的路径,该环境变量在我们使用cd-的时候就起作用了
1.5 环境变量相关的操作
以上我我们了解了一系列的环境变量,那么接下来就来就来学习一些获取环境变量的方法
其实在以上我们已经了解了两个环境变量的操作,分别是使用env将所有的环境变量打印出来以及使用echo单独的将一个环境变量打印出来。
其实除了以上的之前提到的环境变量的操作方法,接下来再来补充几个相关的操作。
首先是如果我们要创建一个新的环境变量,那么就可以使用export来实现
export
cpp
export 变量的名称以及值
例如使用export导入一个新的名为tmp1的环境变量接下来再使用env就可以在环境变量表当中查询到该环境变量


unset
以上使用export就可以创建环境变量,此时如果要将对应的环境变量删除就需要使用unset
cpp
unset 环境变量名
例如要将以上创建的tmp1的环境变量从环境变量表当中删除就可以使用unset来实现

1.6 通过代码如何获取环境变量
接下来将学习三种使用代码获取环境变量的方法
1.命令行第三个参数
在之前了解命令行参数表的时候我们已经知道了其实在main函数当中也是可以有参数的,第一个参数为命令行参数的个数,第二个变量为命令行参数的指针数组,但其实除了以上的两个参数以外main函数还可以存在第三个参数;那就是环境变量表的指针数组
例如以下的代码
cpp
#include<stdio.h>
#include<sys/types.h>
#include<unistd.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;
}
注:以上的代码当中将argc和argv强转成void是因为在gcc/g++当中如果函数的参数在函数体内未使用,那么就会编译报错,因此在此的操作是为了避免编译器的报错。
以上的代码编译运行之后就会输出以下的内容,此时就可以发现在子进程当中已经将bash当中的环境变量表给继承下来了

2.使用系统调用getenv
首先来使用man手册来查询getenv系统调用的的使用方法

通过以上man手册内的描述就可以看出getenv的作用是获取指定的环境变量。
如果现在我们要写一个只有指定的用户才能执行的程序,实现的代码如下所示:
cpp
#include<stdio.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,"zhz")==0)
{
printf("程序正常的执行!\n");
}
else{
printf("不是指定的zhz用户无法执行!\n");
}
return 0;
}
在以上的代码当中就使用了getenv来获取当前用户名的环境变量,接下来使用strcmp来判断当前的用户是不是zhz,是的话就正常的执行否则就输出当前的用户不是zhz。
当使用zhz执行以上的代码生成的可执行程序
使用其他的用户执行生成的可执行程序
通过以上使用getenv的2示例就可以看出为什么子进程可以被子进程继承?
让子进程继承对应的环境变量表就可以在子进程内部实现个性化的需求
3.使用全局指针environ
在此先使用man手册来查询environ的使用方法

通过以上的描述就可以看出erviron其实是一个二级指针也就是环境变量表的指针

接下来来看以下的代码:
cpp
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
extern char** environ;
int main(int argc,char* argv[],char* env[])
{
(void)argc;
(void)argv;
(void)env;
for(int i=0;environ[i];i++)
{
printf("env[%d]->%s\n",i,environ[i]);
}
return 0;
}
1.7 环境变量的特性
通过以上的environ全局的指针就可以看出环境变量是具有全局特性的
补充概念:
1.本地变量
其实在相同当中除了环境变量之外还存在本地变量,在此压查询存在的本地变量就需要使用指令set


以上就可以看到使用set指令之后就看到环境变量以及本地变量,当时和环境变量不同的是本地变量是不会被子进程进继承的。
通过以上set输出的结果还可以看到本地变量当中还存在命令行提示符的输出格式

2. 内建命令
以上我们使用的export其实是内建命令 ,也就是命令的执行不是通过创建子进程来实现的,而是让bash自己亲自执行的,具体的过程是通过bash调用相应的函数或者系统调用。
其实pwd,cd等指令也是内建命令。这也解释了为什么当我们将环境变量当中的PATH改变之后这些命令还能执行,而其他的命令却无法正常的使用。
注:具体的内建命令的讲解将在之后的自定义shell实现章节进行。
2. 程序地址空间
在之前学习C/C++的之后我们就了解到了计算机当中是存在栈区、堆区等不同的区域的。
来看以下的代码:
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld";
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
static int test = 10;
char *heap_mem = (char*)malloc(10);
char *heap_mem1 = (char*)malloc(10);
char *heap_mem2 = (char*)malloc(10);
char *heap_mem3 = (char*)malloc(10);
printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
printf("test static addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
printf("read only string addr: %p\n", str);
for(int i = 0 ;i < argc; i++)
{
printf("argv[%d]: %p\n", i, argv[i]);
}
for(int i = 0; env[i]; i++)
{
printf("env[%d]: %p\n", i, env[i]);
}
return 0;
}
以上的代码编译形成可执行程序之后输出的结果如下所示:

通过以上的输出结果就可以看出环境变量和命令行参数的地址其实是非常接近的,这其实就是因为这两个就是存储在同一内存空间的。
在学习C/C++的时候我们都看过以下类似的图

以上我们在学习C/C++的时候将以上的图表示的叫做做程序地址空间,但其实之前这种说法是错误的,之前这样讲只不过是为了让我们更好的理解,因为到了内存上的概念就不是语言层面上能理解的了。其实以上空间正确的叫法叫做进程地址空间或者虚拟地址空间。并且该内存实际上不是真正的物理内存。
接下来再来看以下的代码:
cpp
#include <stdio.h>
#include <unistd.h>
#include<sys/types.h>
#include <stdlib.h>
int g_unval=0;
int main()
{
pid_t pid=fork();
if(pid==0)
{
while(1)
{
printf("子:g_unval:%d,pid:%d,ppid:%d,&g_unval:%p\n",g_unval,getpid(),getppid(),&g_unval);
g_unval++;
sleep(1);
}
}
else{
while(1)
{
printf("父:g_unval:%d,pid:%d,ppid:%d,&g_unval:%p\n",g_unval,getpid(),getppid(),&g_unval);
sleep(1);
}
}
return 0;
}
在以上的代码当中我们使用fork创建子进程,之后在子进程内每秒使得变量g_unval的值加一,而在父进程内不对g_unval的值做任何的修改。观察变量g_unval值的的变化,以及变量的地址。

通过以上的输出结果就会发现一个非常奇怪的问题,那就是在父进程和子进程当中的变量g_unval的地址竟然是一样的,但是在这两个进程当中的g_unval的值却是不一样的,那么这不就出现同一个地址内的变量同时拥有两个值了吗?这就更加说明以上我们看到的地址不是物理地址。
那么要解答以上的问题就需要来学习进程地址空间的相关概念。
2.1 进程地址空间
其实在虚拟地址空间和物理地址空间之后是通过页表进行映射的。
例如以下的示例当一个程序当中创建了一个变量之后之后就会得到该变量的虚拟地址空间的起始地址,那么这时就会通过一个页表将此时的虚拟地址看见内的地址映射出变量实际存储的物理空间的地址。

并且在虚拟地址空间内的每个内存单元的大小和物理空间也是一样的都是一字节,对于32位的机器,那么虚拟地址空间的总大小就是2^32次方字节;对于64位的机器虚拟地址空间的总大小就是2^64次方字节。
在进程的task_struct内存储着指向对应的虚拟地址空间的指针

以上我们就知道了一个进程实际上进程内的数据是任何映射到物理内存上的,那么当我们使用fork创建出子进程的时候又是什么样的呢?
通过之前进程的概念的学习我们知道当创建子进程的时候就会进行写实拷贝,这时子进程会将父进程的task_struck拷贝一份之后对内部的数据进行一定的修改,在此我们要知道的是每个进程都会有自己的虚拟内存空间和页表。 子进程创建之后也会将父进程的虚拟地址空间进行和页表拷贝,并且这时和父进程是共用同一份的代码和数据。

当我们在父进程和子进程当中没有对该数据进行修改的时候,父子进程会一直共用物理内存当中的这一份数据,但是当父子进程当中出现了对该数据进行修改的操作的时候,在物理内存当中就会立刻创建一份和原数据一样大的空间并且将原空间内的数据也拷贝过来,之后再改变父子进程当中一个页表的指针映射。
那么有了以上的知识就可以解释为什么之前我们的代码当的g_unval的值在父子进程当中是不一样的,但是地址却是一样的。这其实就是当我们在子进程当中对该变量进行修改的时候在物理内存当中就会创建出一个新的数据块。我们之后在子进程当中访问的实际上是物理内存当中新的变量数据块,而父进程访问的是旧的数据块。而这两个变量的地址相同是因为子进程的虚拟地址空间是拷贝至父进程,变量相同的只是虚拟地址,而物理地址已经不一样了。
在《进程概念(上)》我们在了解fork的时候其实还留了一个问题,那就是为什么fork的返回值即等于0又大于0,现在我们了解了虚拟地址到物理地址的映射之后旧很好理解了;实际上fork函数的返回值在在物内存当中是有两份的,只不过是虚拟父子进程地址是一样的。
在了解了以上的概念之后接下来将通过两个故事来进一步理解虚拟地址空间
故事1:
假设现在有一个美国的大富翁,虽然的他在自己的人生当中积累了很多的财富,但是他的私生活比较乱,他有非常多的私生子。他这个人还有一个特性就是非常喜欢对他的孩子们画大饼,今天和他正在上班的的大儿子说你要好好努力啊,之后你能力够了就让你接班我这个董事长的位置,过来几天又和他正在上高中的二女儿说你要好好学习啊,等你考上了大学我就给你买一份苹果的全家桶,又过了几天又对每天就知道玩游戏的三儿子说你不要每天就知道玩游戏啊,我还等着你继承家产啊。

那么在以上的故事当中其实大富翁就是操作系统,而他的财富就是物理内存,他的一个个私生子就是一个个进程,而他给这些私生子进行画大饼的时候就是操作系统给一个个进程分配对应的虚拟地址空间;这一个个进程都认为自己是独占对应的物理内存。
由于大富翁的私生子非常的多,那么他有时候就会忘了给各个私生子画的饼分别是什么,那么时候就需要将画的饼记录下来,反应到操作系统当中就是要将各个进程的虚拟地址空间记录下来,南萨摩时候就要进行先描述再组织的操作 。这时我们就可以知道了其实虚拟地址空间的本质其实也是一个结构体的对象。
那么这个结构体内部的元素又是怎么样的呢?这时候就要我们来接着看以下的故事
故事2:
在小学里三年级的小明是和一个女生坐同桌,但是他的同桌让他觉得很烦,因为他的同桌最近给他们的课桌画了一条"三八线";只要小明过了线就会被揍一下,并且他的这个同桌还有有点怪癖就是将自己的课桌划分为了几个区域,每个区域内都要放对应的物品,就比如在课桌的最旁边到课桌的内的10厘米要放自己的笔盒,以下的30厘米要放自己的书本。她还不允许别让将她桌子上的物品给搞乱。

那么在以上的故事当中小明的同桌将她的位置进行了划分的依据是各个区域的起始位置和终止的位置。其实在各个进程的虚拟地址空间当中和以上故事当中的类似也是按照起始地址和终止的地址来划分各个区间的。当我们要调整对应区间的大小时只需要将对应区间的起始和终止的地址进行修改。

接下来在Linux的源代码当中就可以看到在进程的task_struct当中存在一个mm_struct的指针也就是指向虚拟地址空间的指针,在观察该指针指向的结构体mm_struct就可以看到在该结构体的内部存在区域的划分


以上我们就了解了虚拟地址空间也是数据结构的对象,那么此时问题就来了,那就是每个进程内部各个区域的划分一开始是从哪里来的呢?
一开始对应的进程是在磁盘当中的,之后其实将进程加载到内存之前就已经构建出对应的数据结构对象,并且在该数据结构对象内部完成了各个区域的划分。
就例如一个进程的代码和数据一共大小是2GB,当该进程被加载到操作系统当中时就会开辟出正文代码和初始化数据为2GB的区域,但是一开始如果物理内存只加载了0.5GB的数据;此时还没有将剩下的数据加载到物理内存当中,那么此时在页表当中就只会将0.5GB的数据进行虚拟和物理之间的映射。
之后当需要使用到0.5GB之后的数据时此时操作系统会发现在该进程的页表内部没有对应的剩下的1.5GB的数据的映射,此时接下来就会发生缺页中断 ,也就是会将磁盘当中剩下的数据也加载到物理内存当中,此时进程将短暂的停止运行,之后等到剩下的物理内存数据和虚拟内存当中在页表也建立映射关系之后继续运行。
因此总的来说磁盘当中的程序加载到操作系统当中会进行以下的操作:
1.在虚拟内存当中申请指定大小的空间,同时还会进行区域的调整
2.加载程序,申请物理空间
3.通过页表建立物理地址和虚拟地址之间的映射关系
通过以上的讲解我们就可以发现虚拟地址空间存在的意义其实是可以让进程管理和内存管理以及IO等操作解耦的,从而实现进程和内存之间的高内聚、低耦合
2.2 虚拟地址空间存在的意义
以上我们了解了虚拟地址空间是如何实现虚拟地址到物理内存之间的映射的,那么此时我们就要来思考了为什么在计算机当中要存在虚拟地址空间,不是直接将进程与物理地址之间建立联系不是更方便吗?
如果是在没有虚拟地址空间的状态下,那么在物理内存的加载数据时就需要对不同的进程之间进行各个数据区的管理,若出现稍微的数据管理不当就会出现一个进程的数据覆盖另外的进程,从而造成数据的丢失,在这种情况下物理内存的管理就会很困难。
而有了虚拟地址空间之后就可以让操作系统只需要对虚拟地址空间进行管理;而无需对应数据实际上映射的物理内存而考虑,这样就可使得每个进程在虚拟地址空间内都是有序的,但是实际上映射的物理空间是乱序的,从而减少了在管理进程的同时还要管理内存。
因此总的来说存在的第一点意义就是:将物理内存当中的"无序"变为"有序"
在之前学习C/C++的时候我们就知道空指针是指指针指向的内存空间已经为空了,此时再对该内存空间内进行数据的修改时就会造成程序的奔溃。那么在了解了虚拟地址空间之后我们要了解其实程序当中空指针解引用出现报错其实是对应的虚拟地址在页表上进行物理地址映射时发现没有对应的物理地址,此时就会映射失败。
还有就是我们之前就了解到如果在程序当中出现以下的代码就会造成程序的奔溃
cpp
const char* str="hello world";
*str="YYY";
之前只是了解到常量字符串是存储在常量区的,是不能修改的。其实这里存储的常量区也就是存储在正文代码的。但实际上更本质的原因是该区域的变量在页表内进行虚拟到物理之间的映射时会发现这部分的数据是自读的,没有写的权限,那么此时在查询页表的时候就会进行权限的拦截。

因此总的来说存在的第二点意义就是:在地址转化的过程当中,可以对你的地址和操作进行合法性的判断,进而保护物理内存。
除了以上的存在的第三点意义就是是:让进程管理和内存管理进行一定程度的解耦合。
2.3 虚拟内存空间再理解
以上我们了解了虚拟地址空间实际上是就是一个mm_struct的对象,那么接下来我们就要来思考一个问题就是之前再C/C++当中使用malloc和new等申请内存空间每次申请内存可能不是连续的,那么是不是就是说明在虚拟地址空间内会存在多个堆区呢?
确实是这样的,linux内核使用vm_area_struct 结构来表示⼀个独立的虚拟内存区域(VMA),由于每个不同质的虚拟内存区域功能和内部机制都不同,因此⼀个进程使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。上面提到的两种组织方式使用的就是vm_area_struct结构来连接各个VMA,方便进程快速访问。


以上就是本篇的全部内容了,接下来我们将在下一篇当中开始进程控制的学习,未完待续......