Bootstrap

Linux入门:环境变量与进程地址空间

一. 环境变量

1. 概念

1️⃣基本概念

环境变量(environment variables)一般是指在操作系统中用来指定操作系统运行环境的一些参数

如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。

2️⃣见一见环境变量:通过指令env 可以看到当前环境下的环境变量表
在这里插入图片描述
3️⃣可以用环境变量回答的诸多疑问

疑问1 :🤔为什么我们平常输入的指令需要直接运行,而我们编译好的可执行文件需要+./指明在当前路径下?

——》💡因为一般我们输入的命令可执行文件,在/usr/bin目录下,并且是PATH这个环境变量告诉OS,要在哪一个目录下查指令的!我们+./ 是为了告诉OS,要在当前目录下寻找!
在这里插入图片描述
——》PATH环境变量是一个由 : 为分隔符的路径集,我们直接输入的命令通常会直接在这几个路径下寻找

疑问2 :🤔如果我也想不输入./就能运行我的程序,改怎么做?

——》💡(1) cp /usr/bin ./程序名将我们的程序拷贝到PATH路径中的其中一个搜索路径!
——》💡(2)在PATH添加一个自己的路径!这样OS就会在自己的路径下寻找!
在这里插入图片描述
—》(2)ps: 在指令中直接输入PATH= …就能添加自己的路径,要注意,这样的写法是覆盖式的,也就是要加上原本环境变量的路径,再以:为分隔符+上需要添加的路径。或者PATH=$PATH:…… 这样的意思就是在原有路径的基础上新增路径

—》像这样在命令行环境变量的修改的生命周期随以你的bash进程的结束而重置的,意思就是说,如果你在以上操作中误改了环境变量也没关系,只需重启一下Xshell,所有的环境变量都会重置,我们的修改只影响当次使用

疑问3 :🤔这些环境变量的内容最开始从哪里来的?
——》💡首先,肯定不是从内存中来的。环境变量的内容最开始都是从系统的配置文件中来的我们登录Xshell—>启动一个shell进程—>进程会读取跟环境变量有关的配置文件—>然后形成自己的环境变量表—>之后再执行命令时生成子进程去执行命令,就可以把环境变量表传给子进程。

——》这也能解释,为什么在疑问2修改环境变量时,我们重启shell,就能重置环境变量表,因为我们根本就没有真正的修改环境变量!我们的修改操作都是内存级的,没有触碰到配置文件,bash进程在启动时只会去读取配置文件的环境变量表!

疑问4 :🤔我们知道,进程会记录是谁启动了自己——》你在启动进程的时候,系统怎么知道你是谁的?并且把你的uid写到进程的PCB结构体里面去?

——》环境变量表早就告诉了你答案
在这里插入图片描述
当前环境变量下,早就记录了你是谁,你在启动进程的时候,bash进程会传入环境变量表到子进程,根据环境变量表也就知道是谁启动了自己。

2. 查看环境变量的方法

2.1 在命令行中

env ——》查看当前进程的环境变量表

在这里插入图片描述

echo $PATH ——》查看一个环境变量,以PATH为例

在这里插入图片描述

2.2 在c语言程序中获取

getenv ——》系统级接口函数,获取一个环境变量的字符串

在这里插入图片描述

#include<stdio.h>
#include<stdlib.h>


int main()
{
    char * arr = getenv("PATH");
    printf("%s\n",arr);
}

在这里插入图片描述

**extern char environ ——》第三方变量引入
在这里插入图片描述

#include<stdio.h>

extern char **environ;
int main()
{
    for(int i = 0 ; environ[i];i++)
    {
        printf("%s\n",environ[i]);
    }
}

在这里插入图片描述

3. 常见的环境变量

  • PATH : 指定命令的搜索路径
    在这里插入图片描述
  • HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录

在这里插入图片描述

  • SHELL : 当前Shell,它的值通常是/bin/bash
    在这里插入图片描述
  • PWD:保存当前进程的工作路径
    在这里插入图片描述

——》PWD:由于这个环境变量的存在,我们创建新文件,删除文件,或者通过./执行命令,系统都可以根据PWD找到当前工作路径执行操作。

4. 命令行参数

4.1 main函数的两个参数

😇其实我们的main函数是可以带参数的,因为他其实也是被别人调用的一个函数,分别是int argc ,char * argv[]

#include <stdio.h>
int main(int argc,char * argv[])
{
    printf("argc:%d\n,argc");
    
    for(int i = 0 ; i< argc ; i++)
    {
        printf("argv[%d]:%s\n",i,argv[i]);
    }
}
  • argc : 记录命令行参数的个数
  • argv[]:命令行参数表,里面存储就是命令行参数,最后一个成员是null

以上程序运行的例子:
在这里插入图片描述
在这里插入图片描述
——》命令行参数其实就是我们在执行命令时,包括命令在内的以空格为分割的各个选项,argc记录个数,argv[]以字符串数组的形式记录命令行参数

——》为什么需要命令行参数?同一个程序,就可以根据命令行参数中选项的不同,表现出不同的功能了!我们在shell上的指令都是这样的!

4.2 与环境变量相关的参数

👉 其实main函数除了命令行参数的两个参数外,还有一个参数(第三个参数)——》char * env[] ——》它用于记录传入的环境变量表!

#include <stdio.h>


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

在以上的程序中,我们尝试打印出char * env 的内容,执行命令,他打印出了当前所有的环境变量
在这里插入图片描述
——》综上,我们知道了,在程序中有两个重要的表:命令行参数表,环境变量表

4.3 理解环境变量的"全局性"

🤔思考:当我们执行程序时,环境变量是怎么一步一步传到我们的程序的?

——》💡我们登录Xshell 👉 启动一个shell进程 👉 进程会读取跟环境变量有关的配置文件 👉 然后形成自己的环境变量表👉 我们执行一个程序,而我们我们执行程序所启动的进程,本质上bash的子进程,子进程会拷贝父进程的相关变量,即使进行了程序替换👉子进程拿到环境变量表

——》就是因为所有的进程都是bash的亲子进程,而bash已启动时就会获取环境变量——>环境变量可以被所有bash之后的进程全部看到!!所以,环境变量具有“全局属性”
在这里插入图片描述
💭就算其中一个子进程修改了环境变量表,也不会影响全局的环境变量表,子进程修改环境变量表,只会影响他自己的子进程
在这里插入图片描述
💭环境变量的“全局性”是充分运用了进程的继承性质的:子进程在开始时与父进程除了数据是独立的之外完全一样

4.4 环境变量 VS 本地变量

🤔什么是本地变量?

在这里插入图片描述
——》像这样在命令行中直接定义的变量,就是本地变量,它可以通过echo $指令查看

💭本地变量与环境变量相比,只会在bash内部有效,不会被继承

🤔什么情况下需要本地变量呢??

——》就是只希望在bash里面使用但是不希望被子进程继承下去的,比如说我们的命令行提示符,如果是root用户就是# 如果是普通用户就是$

5 总结

💭环境变量一般是指在操作系统中用来指定操作系统运行环境的一些参数,它一般具有全局属性。
在这里插入图片描述

💭环境变量表记录这每个环境变量,每个程序都会收到一张环境变量表,它是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串,这个指针数组以NULL结尾

  1. echo: 显示某个环境变量值
  2. export: 设置一个新的环境变量
  3. env: 显示所有环境变量
  4. unset: 清除环境变量
  5. set: 显示本地定义的shell变量和环境变量

二. 虚拟进程地址空间

先来看一个现象:

 #include <stdio.h>
 #include <unistd.h>
 #include <stdlib.h>
 
int g_val = 0;
 
int main()
 {
    pid_t id = fork();
    if(id < 0){
        perror("fork");
        return 0;
    }
    else if(id == 0){ //child
     printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }else{ //parent
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    sleep(1);
    return 0;
 }

在以上的代码中,我们创建了一个子进程,然后让父进程和子进程都打印出全局变量g_val的地址,然后发现:

在这里插入图片描述

——》我们发现,输出出来的变量值和地址是一模一样的,这很好理解呀,因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改。可是将代码稍加改动:

 #include <stdio.h>
 #include <unistd.h>
 #include <stdlib.h>
 
int g_val = 0;
 
int main()
 {
    pid_t id = fork();
    if(id < 0){
        perror("fork");
        return 0;
    }
    else if(id == 0){ //child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取
        g_val=100;
        printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }else{ //parent
        sleep(3);
        printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
    }
    sleep(1);
    return 0;
 }

以上代码多了在子进程对g_val对象进行修改,输出现象:

在这里插入图片描述

——》父进程与子进程,输出地址是一致的,但是变量内容不一样!?!!😧这好像不对啊!我们知道,进程之间具有独立性,父进程与子进程的数据是相互独立一份的,子进程的g_val进行修改,那他们的g_val数据也应该是不一样的,但是这二者的地址又相等,这是怎么回事🤔?难道是不同的数据存到同一份地址上😯?那就更不可能了😰!

——》所以在此,可以输出结论👉 :(1)变量内容不一样,所以父子进程输出的变量绝对不是同一个变量(2)这个地址绝对不是物理地址!(3)在Linux地址下,这种地址叫做虚拟地址,或称线性地址(4)我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。

1.1 什么是进程地址空间(是什么)

1.1.1 理解地址空间

在这里插入图片描述
——》在之前的学习中,我们知道这张图被称为程序地址空间,但其实这个说法并不准确,实际上应该叫做进程地址空间,上面的编制(0x000000&&0xFFFFFFF)也全都是虚拟的地址。

——例子引入:
——》有一个大富翁,他不仅100亿的资产,而且还有很多的私生子,私生子与私生子之间互相不知道对方的存在。私生子知道自己的老爸是富翁,所以要钱是大把大把的要,只有一个私生子还好说,但是他有数不清的私生子,他这资产怎么遭得住?所以,他给每一个私生子都画了个大饼:自己死后,会把自己的100亿资产继承给你。有了这个承诺,私生子都不急着要钱了,所以向富翁要钱花的时候,需要多少,才取多少,这样,富翁就实现了保持和每一个私生子的友好关系,又能不被私生子们发现其他私生子的存在。

——》回到话题,这里的大富翁就是操作系统100亿是实际的物理内存而每一个私生子就是进程,大富翁给他们的大饼就是进程地址空间!OS向每一个进程都许诺了它们有4G(如上图所示结构的)的进程地址空间,每一个进程真的有占这么大的内存吗?并不是!实际上是进程要多少空间,才会向OS申请多少空间。OS让每一个进程都认为自己独占了系统的物理内存大小,进程之间彼此不知道,不关心对方的存在,从而实现一定程度的隔离。

——》饼画多了,OS也要对它们进行管理!先描述,再组织!所谓的进程地址空间,在上图是他的逻辑结构,在内存中,本质是一个内核数据结构对象!OS描述为mm_struct
在这里插入图片描述

1.1.2 理解区域划分

我们在上面了解到,os描述进程地址空间的结构体叫做mm_struct——》🤔这样的成员构成是做到进程地址空间的区域划分的?

💡👇
在这里插入图片描述

一个区域只用两个整形进行管理:一个记录着begin的地址,一个记录着end的地址,是这样的实现区域划分!

1.1.3 理解地址空间上的地址

在这里插入图片描述

从0x0000 0000到0xFFFF FFFF,一共有多少地址?答案是 4GB = 2^10 X 2^10 X 2^10 X 2^2 X 1字节 =2^32 个地址,这些地址的线性的,连续的——》这样设置地址的方式叫做内存编址

其实我们可以观察到,地址本质就是一个数字,可以被unsignal long 类型的变量保存着——》这就是区域划分的变量内容,只有一个begin,一个end——》我们不需要记录一个区域中除了begin和end之外其他的地址,就能知道一个地址值是否在这一个区域内了——》所以空间范围内的地址,我们可以随便用,甚至不用记录他,只用知道一个区域的begin和end就行了。

1.1.4 页表

程序在运行时,所用的是虚拟进程地址空间的虚拟地址,这个虚拟地址是怎么和物理地址产生关联的🤔(怎么通过虚拟地址找到物理地址)?

💡进程中维护一个表,用来一个一个记录虚拟地址和物理地址的映射关系,叫做页表

在这里插入图片描述

上图中,管理进程的结构体为task_struct,在task_struct中,有一个mm_struct的结构体指针成员管理者进程地址空间。在其中,g_val变量在进程地址空间中使用的是虚拟地址0x601054,他在实际的物理地址是0x11223344,页表就将g_val的虚拟地址和物理地址记录下来,让他们一一对应

从理解的角度,页表的结构可以看作一个我们曾经学过的一个数据结构——》map(当然,真实的结构还要复杂很多),由虚拟地址作为key,找到对应的物理地址这个value——》很像一张表,所以叫页表。
在这里插入图片描述

父进程创建子进程后,子进程会拷贝父进程PCB中的大部分属性,其中当然包括进程地址空间和页表!页表的内容及映射关系全部都拷贝一份——》在这个时候,父进程的g_val通过页表映射到物理内存的数据和子进程是一样的!父进程的g_val和子进程是同一份!——》类别到其他的数据,比如说,代码区,父子映射到同一份代码区,所以一般来说,父子进程代码共享!

——》有没有一点奇怪?对呀,之前我们说过进程之间具有独立性,他们的代码是共享的,但是数据是独一份的,但是这里的g_val为什么是同一份?原因是,OS具有写实拷贝机制!
在这里插入图片描述

——》当不修改变量的时候,父子进程的数据确实的同一份,但是,一旦子进程或者父进程要对数据进行写入(修改),那么这个时候就会出发写实拷贝机制:子进程对父进程的数据空间进行一次拷贝,修改页表中物理地址的部分,重新建立映射关系,再对数据进行写入。——》这样的机制由OS自主完成——》默认不分开,只有在你需要的时候再分开——》这样好处非常大!既节省了内存空间,避免资源的浪费,又保证了进程之间的独立性

在这里插入图片描述
——》最终,我们终于能够解释我们一开始举例的现象了!🤔为什么同一个地址,能看到不同的内容因为这个地址的虚拟地址!在底层,父子进程使用相同的虚拟地址,但是他们的页表不同,映射到的物理地址也不相同——》所以同一个虚拟地址,在不同的进程,通过同一个页表,就可以看到不同的内容!!!

1.1.5 补充

1️⃣关于变量和地址:
——》在汇编底层中,变量名其实不存在了,转而变成了地址,地址变成了变量的唯一标识符,对变量进行操作,其实也就是对地址的值进行操作。

2️⃣重新理解进程和进程的独立性
——》究其原因,进程为什么具有独立性?我们知道,进程 = 内核数据结构(task_struct/进程地址空间mm_struct/页表/…)+自己的代码和数据代码和数据是独立的,从前文中我们也可以得知,像进程地址空间mm_struct这样的内核数据结构也是独立的,task_struct是独立的——>右式都具有独立性,左式也就具有相同的性质,所以进程就是独立的。

3️⃣关于页表
上文提到页表的数据结构可以看作一个map,一个虚拟地址对应一个物理地址——>但其实没有谈完,每一个对应关系还存在标记位,用来记录映射关系的相关信息,这里谈两个标记位

  1. rwx有一个标记位记录着物理地址的rwx权限,如果有对应的权限,就可以通过映射关系拿到对应的物理地址

——》比如代码区的数据,我们知道它是只读的,读取代码时先拿着虚拟地址,通过页表检查权限,有r权限,就成功拿到物理内存的代码区数据;但如果我要修改代码区数据时,流程在页表时检查没有w权限,虚拟地址无法转化为物理地址(这个时候在cpu一般直接报错,os直接把进程杀了),也就改不了代码区的数据了

——》以前学习C语言的时候我们知道,像char * str = “hello world”,我们通过 * str = “abc”,程序就会直接崩溃——》为什么?原因就是"hello world"这样的数据在内存中是只读的,不可写入,一旦写入,cpu通过页表拿不到对应的物理内存,就会出错(硬件中断),os检查原因认为,你的程序对只读的区域进行写入,是你的代码出了问题,所以就直接杀掉进程,我们的程序也就直接崩溃了。

——》针对上面的问题,C语言就有了const,const是在编译阶段就告诉编译器,我这个变量不可修改,所以一旦对const修饰的变量进行修改,程序就会在编译阶段就给你拦截了。

提个小问题const char * str = “hello world”char * str = “hello world”如果都对* str进行修改而发送报错,区别在哪

——》综上所述,一个是编译器在编译阶段就拦截了,一个是进程运行时cpu报错被OS杀掉了

  1. isexists这个标记位记录着这个映射关系是否有效,说白了就是在页表中填写的这个物理地址是否存在对应的数据

——》数据是从磁盘加载到内存的,有时候你在程序中定义了一个数据,但是你程序并没有使用这个数据,那么这个数据虽然有在页表中有映射关系,但这份数据不一定加载到内存了isexists这个标志位就表示它是否加载。如果后面的代码中使用了,才会从磁盘中加载数据进来。

——》由这个标志位支持分配加载,挂起等操作

4️⃣关于mm_struct
mm_struct他们是进程管理进程地址空间的结构体,在内存中是一个结构体变量,是结构体变量就要初始化啊!进程地址空间相关的信息(比如栈区,代码区,未初始化变量区…各个区域的大小要设置多少)由哪里得到的呢?

——》答案是从可执行程序中来的!编译器在编译程序形成可执行程序后,各个区域的大小信息就已经有了,OS只需要访问特定区域读取可执行文件的相关区域,就能设置好进程地址空间了。——》页表的相关信息包括标记位,映射关系也是一样的!该物理地址是可读可写不是OS规定的,是可执行文件设置的!

——》从这里我们可以窥见一二:操作系统(的进程管理)与(编译器形成的)可执行程序并不是相互独立的关系,二者是息息相关的

——》堆区申请空间的本质——》改变区域划分,给堆区划分更多的虚拟地址——》再由页表中建立映射关系——》在使用时在申请物理内存。
在这里插入图片描述

1.2 进程地址空间存在的原因(为什么)

  1. 虚拟进程地址空间与页表相互配合,可以有效保护内存

——》意思就是可以有效解决野指针问题。什么是野指针?我们程序为什么使用了野指针就会崩溃?野指针有两种情况,第一是指向了未被在页表建立映射的虚拟地址,第二是指向了不该被指向区域的地址(比如代码区)当你通过野指针进行写入时,页表会找不到对应的映射条目,或者检查标记位发现你没有该虚拟地址的权限,虚拟地址到物理地址的转化不成功,cpu报错,OS就会把你的进程杀掉——》这样就有效避免会出现野指针问题胡乱修改物理内存,也就保护了物理内存。
在这里插入图片描述

  1. 进程管理 和 内存管理 在系统层面解耦合了

——》OS在内存管理中,不需要知道进程在物理内存申请一片空间是为了做什么。而在进程管理中,也不需要知道物理内存申请有没有成功,如果没有成功,就没有映射关系,程序再怎么折腾也只是导致自己的进程崩溃。

  1. 让进程以统一的视角看待物理内存

——》可执行程序的代码和数据记载到内存时,不一定是连续的,可能是物理内存的任意位置,或者说连续的虚拟地址在经过页表映射时,物理地址不一定是连续的:
在这里插入图片描述
——》进程不需要关心物理内存是否是连续的,在进程中有页表,使用连续的虚拟进程地址空间。页表和虚拟进程地址空间将地址从“无序”(物理地址)变为“有序”(虚拟地址)

1.3 OS如何管理进程地址空间(怎么做)

进程地址空间在内存中本质是一个结构体:mm_struct,是task_struct结构体(管理进程的结构体)的一个成员,OS只需要把进程(task_struct)管理好,进程地址空间本身就已经管理好了

——》理解全局变量的全局性:今天我们就知道,在代码中具有全局性的变量,不放在栈上,而放在初始化数据区:
在这里插入图片描述
——》而该区域的生命周期的跟随进程的,只有进程被销毁,进程地址空间才会被销毁,该区域的数据才会被销毁,全局变量的虚拟地址一直被整个进程看到,所以,全局变量才具有全局性。

本文就到这里,感谢你看到这里❤️❤️! 我知道一些人看文章喜欢静静看,不评论🤔,但是他会点赞😍,这样的人,帅气低调有内涵😎,美丽大方很优雅😊,明人不说暗话,要你手上的一个点赞😘!

希望你能从我的文章学到一点点的东西❤️❤️

;