深入理解 Linux 环境变量与进程地址空间布局

预备知识:命令行参数

代码1

代码2

代码3

1:环境变量

1.1:基本概念

1.2常见环境变量

1.3:查看环境变量

1.4:测试PATH

代码1

重启前

重启后

路径的分隔符

1.5:查看其他环境变量

2:环境变量的组织方式

2.1:代码1

2.2:代码2

3:环境变量具有全局属性

4:程序地址空间

代码1

5:进程地址空间

5.1:写时拷贝与页表

5.1.1:页表

5.1.2:写时拷贝

5.1.3:fork函数的原理

5.2:如果理解地址空间

5.2.1:地址空间的理解

5.2.2:为什么要有地址空间

6:Linux2.6内核进程调度队列

6.1:一个CPU拥有一个runqueue

6.2:优先级

6.3:活动队列

6.4:过期队列

6.5:active指针和expired指针

6.6:总结


预备知识:命令行参数

在C语言阶段,我们知道main函数其实是可以带有参数的,只是我们在实际写main函数的时候并没有带上对应的参数,那么实际上的main原型应该是这样子的.

cpp 复制代码
int main(int argc,char * argv[]);
  • argv:是一个指针数组,里面的每一个元素指向的是字符串.
  • argc:指针数组的元素个数.

那么有的uu会好奇,这个指针数组是用来干啥的,我们来看下面这段代码

代码1

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

int main(int argc,char *argv[])
{
    for(int i = 0; i < argc; i++)
    {
        printf("argv[%d]->%s\n",i,argv[i]);
    }
}
  • 我们可以清晰地看到,操作系统将我们输入的命令行参数拆成了字符串放到了argv这个指针数组中.
  • 有些操作系统的功能会将用户输入的命令行参数空格为分隔符(将这些空格换成\0,这样子就将大字符串拆分成了一个一个小字符串) 拆分成字符串放到argv这个数组中.

那么有的uu会好奇,这个指针数组是以什么结尾的呢?其实是NULL,我们来验证一下

代码2

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

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

有的uu会好奇,为什么要有命令行参数呢,我们来看下面这段代码

代码3

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <string.h>
int main(int argc,char *argv[])
{
    if(argc != 2)
    {
        printf("Usage:%s -[a,b,c,d]\n",argv[0]);
        return 1;
    }
    else if(strcmp(argv[1],"a") == 0)
    {
        printf("This is Function 1\n");
    }
     else if(strcmp(argv[1],"b") == 0)
    {
        printf("This is Function 2\n");
    }
     else if(strcmp(argv[1],"c") == 0)
    {
        printf("This is Function 3\n");
    }
     else if(strcmp(argv[1],"d") == 0)
    {
        printf("This is Function 4\n");
    }
    else
    {
        printf("No This Function\n");
    }
    return 0;
}

我们可以看到,当输入不同的命令行参数时,会执行不同的功能,所以命令行参数的本质是

  • 交给程序的不同的选型,用来定制程序功能,根据场景的需求来选择该程序里面的不同功能.

1:环境变量

1.1:基本概念

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

我们编写的C/C++代码,在各个目标文件进行链接的时候,从来不知道我们所链接的动静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。

  • 环境变量通常具有某些特殊用途,并且在系统当中通常具有全局特性。

1.2常见环境变量

  • PATH:指定命令的搜索路径.
  • HOME: 指定用户的主工作目录(即用户登录到Linux系统中的默认所处目录)。
  • SHELL: 当前Shell,它的值通常是/bin/bash。

1.3:查看环境变量

  • echo $NAME //NAME为待查看的环境变量名称

1.4:测试PATH

代码1

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main(int argc,char *argv[])
{
    printf("hello world\n");
}

我们可以看到,当执行自己的可执行程序时一定要带路径,那么这是为什么呢

  • 因为在Linux中,存在一些全局的设置,表明并且告诉命令行解释器,应该在哪些路径下去寻找对应的可执行程序.

那么有没有什么办法可以通过不带路径去执行自己的可执行程序呢,其实是有的.

当我们将自己可执行程序所在的路径导入到环境变量中,此时再去执行自己的可执行程序就不用再带路径了.并且我们也可以看到,当查看环境变量PATH以后,此时也多了我们自己的可执行程序的路径.

重启前
重启后

但是,当我们重启Xshell以后,会发现此时PATH路径又重新回来了,那么这是为什么呢

  • 系统中的很多配置,在我们登陆Linux系统的时候,就已经加载到了bash进程中,而实际对应的bash进程它就在内存中.
  • bash进程默认我们查到的环境变量是内存级的,即使在Xshell中改变了PATH的路径,但只要关闭重启Xshell,路径就能重新回来了.
  • 那么为什么可以重新回来,原因在于,最开始的环境变量不是在内存中,而是在系统对应的配置文件中.
  • 所以环境变量它就是系统的配置文件,当操作系统或者bash进程在启动的时候,它会把环境变量从配置文件导入到操作系统内部或者bash解释器里面.

路径的分隔符

有的uu会比较好奇,为什么这些路径是以:为分隔符呢

  • 因为bash在执行命令的时候,需要先找到命令,因为未来需要加载并且执行命令.

1.5:查看其他环境变量

2:环境变量的组织方式

每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以'\0'结尾的环境字符串.并且结尾以NULL结束

2.1:代码1

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main(int argc,char *argv[],char * env[])
{
    for(int i = 0;env[i]; i++)
    {
         printf("%s\n", env[i]);
    }
    return 0;
}

2.2:代码2

cpp 复制代码
#include <stdio.h>
int main(int argc, char *argv[])
{
    //libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明
    extern char **environ;
    int i = 0;
    for(; environ[i]; i++)
    {
        printf("%s\n", environ[i]);
    }
    return 0;
}

我们可以看到,这些环境变量,就是刚刚Shell内部的环境变量,而我们通过代码的方式获取到了,说明环境变量是默认可以被子进程拿到的(在命令行执行的可执行程序都是bash的子进程).

3:环境变量具有全局属性

  • 环境变量具有全局属性,可以被子进程继承下去
cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
int main()
{
     char * env = getenv("MYENV");
     if(env){
     printf("%s\n", env);
     return 0;
}

我们可以发现,当将MYENV环境变量导入以后,此时再运行程序就有了,说明环境变量是可以被子进程继承下去的.

有的uu就会好奇,export也是条命令,那么在使用的时候为什么不会创建子进程呢?因为如果创建的了子进程,那么导入的环境变量就不应该被bash看到,像echo export等等都是内建命令,这是由bash亲自来执行的,而百分之80的命令都是由bash创建子进程来执行的,像ps这些.

4:程序地址空间

在C语言阶段,相信大家见过下面的空间布局图,可是uu们对这个并不了解,下面我们先通过一段代码来感受一下.

代码1

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

int global_value = 100;

int main()
{
  
  printf("Parent process is running and my pid = %d,ppid%d\n",getpid(),getppid());
  pid_t id = fork();
  //返回0则表示的是子进程
  if(id == 0)
  {
    //child
    int count = 0; 
    while(1)
    {
      printf("I am child process and my pid = %d,ppid = %d,global_value = %d,&global_value = %p\n",getpid(),getppid(),global_value,&global_value);
      sleep(1);
      count++;
      if(5 == count)
      {
        global_value = 300;
        printf("I am child process,change %d--->%d\n",100,global_value);
      }
    }
  }
  else
  {
    //parent
    while(1)
    {
      printf("I am parent process and my pid = %d,ppid = %d,global_value = %d,&global_value = %p\n",getpid(),getppid(),global_value,&global_value);
      sleep(1);
    }
  }

  return 0;
}

我们可以清晰地看到,在global_value修改前,父子进程输出来的变量值和地址是一模一样的,这个很好理解,因为子进程会以父进程为模板,父子并没有对变量进行任何修改.

但是当子进程对global_value进行修改时,我们会发现输出的地址是一样的,但是变量内容不一样,那么可以得出以下结论:

  • 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量.
  • 地址值是一样的,说明该地址不是物理地址,因为物理地址是唯一的.
  • 在Linux下,这种地址叫做虚拟地址.
  • 我们在用C/C++语言所看到的地址,全部都是虚拟地址物理地址,用户一概看不到,由OS统一管理.
  • OS必须负责将虚拟地址转换为物理地址.

5:进程地址空间

  • 之前将那张布局图称为程序地址空间实际上是不准确的,那张布局图实际上应该叫做进程地址空间,进程地址空间本质上是内存中的一种内核数据结构,在Linux当中进程地址空间具体由结构体mm_struct实现.
  • 每一个进程除了要能够把代码和数据加载到内存外,对于Linux操作系统,该操作系统会为进程创建一个名叫**地址空间(32位和64位环境下的大小不一样)**的东西.
  • 所以每创建一个进程,除了会创建对应的进程控制块(task_struct),同时也会创建对应的地址空间(mm_struct)
  • 由于数据不在操作系统为进程所创建的地址空间中保存,地址空间只会为数据提供线性的,连续的地址进而让我们能够去访问数据,那么该地址则被称为虚拟地址.

  • 由于虚拟地址需要被转化到物理内存中,那么在计算机体系结构当中存在一个叫做页表的东西.

  • 页表的作用在于将地址空间中的虚拟地址与物理内存中的物理地址建立映射关系.
    那么我们再回到刚刚的代码1

  • 当子进程要对global_value进行修改时,操作系统会发现global_value不单单只是子进程在使用,父进程也在使用,并且global_value是属于父进程的,那么为了保证进程的独立性,此时操作系统会在物理内存中重新开辟一段空间,开辟完新空间后,此时会将旧空间的global_value的数据拷贝到新空间中,然后将新开辟的空间的物理地址覆盖掉旧的物理地址,在页表中重新与虚拟地址形成新的映射.

  • 只有在该工作做完时,子进程才会重新对global_value重新进行写入.

  • 重新开辟空间,重新进行拷贝,重新写入页表,这一工作是由OS自主完成------>这种操作被称为写时拷贝.

  • 如果说父子进程不对全局变量进行写入的话,由于一个全局变量默认是被父子进程共享的,代码是共享(代码是只读的),因此父子进程不对全局变量进行写入的话,父子进程之间的数据不会发生分离,在底层执行的是同一块物理内存,只有在写入的时候,OS会执行写时拷贝.

  • 那么为什么要这么做呢,因为进程具有独立性,多进程运行的时候,需要独享各自的资源,多进程运行要能够互不干扰.

  • **那么有的uu会好奇,为什么不能够在子进程创建的时候,把父进程的数据直接全部拷贝一份给子进程呢,**而是使用写时拷贝.

  • **因为父进程中的很多数据,子进程不一定会对其全部进行修改,因此使用写时拷贝能够减少空间的浪费,**写时拷贝的本质是按需申请即通过拷贝的时间顺序,来达到节省空间的目的.

5.1:写时拷贝与页表

5.1.1:页表

在上面我们有提到页表

  • 页表的作用在于将地址空间中的虚拟地址与物理内存中的物理地址建立映射关系.
  • 但页表还有各种各样的标记位,例如标记是否具有rwx权限以及标记物理地址是否在内存当中.

在磁盘中存在一个区域叫做swap分区,这个swap分区呢,是在当进程变换位挂起状态时,此时将其对应的在物理内存中的代码 + 数据唤入到磁盘当中的swap分区,然后将其在页表对应的标记位标记为0,将物理地址消除,虚拟地址保留.

cpp 复制代码
char * str = "hello world";
*str = 'H';

上面这段代码在运行的时候,很明显会崩溃的,在C语言阶段博主给出的答案是字符常量区的变量不能被修改,那么这是为什么呢

  • 因为地址空间中的每一块区域都通过页表进行映射,页表中有各种各样的标记位,页表在进行映射时会对其进行权限管理,对常量区只提供了r权限.

5.1.2:写时拷贝

  • 重新开辟空间,重新进行拷贝,重新写入页表,这一工作是由OS自主完成------>这种操作被称为写时拷贝.
  • 那么操作系统层面上如果进行写时拷贝的呢?原本父进程对g_val的权限是rw,当父进程创建了子进程后,父进程为了支持写时拷贝,操作系统修改了父子进程对g_val的权限,父子进程对g_val的权限由rw权限变成了r权限,一旦父子进程要对g_val进行写入时,那么OS会识别到错误,会进行以下判断.
  1. 是不是数据不在内存当中-------->发生缺页中断
  2. 是不是数据需要写时拷贝-------->发生写时拷贝
  3. 如果都不是,才进行异常处理.

5.1.3:fork函数的原理

在之前博主有讲到系统的fork函数,他能够帮助我们创建子进程,而且返回值是两个不同的id,但是那时候博主并没有讲原因,这里其实是因为发生了写时拷贝.

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

int main()
{
  pid_t id = fork();
  if(id == 0)
  {
    while (1)
    {
      printf("I am child,my id == %d,%p\n",id,&id);
      sleep(1);
    }
  }
  else if(id > 0)
  {
    while (1)
    {
      printf("I am Parent,my id == %d,%p\n",id,&id);
      sleep(1);
    }
  }
  return 0;
}

我们可以看到上面两个id的虚拟地址是一样的,只是值不一样,因为return的本质是对id进行写入,fork函数能够返回两个不一样的id其本质就是因为发生了写时拷贝.

5.2:如果理解地址空间

这里首先就要讲解划分区域,我们通过一个小故事来理解划分区域,相信uu们在念书的时候,总会遇到一些比较离谱的同桌,那么这时候呢,就会和同桌划分区域,一开始呢,两人商量好了,假设桌子是100cm长,两人是一人一半,然后呢你那同桌觉得这面积有些窄,想占你便宜,说自个儿要占70cm,然后呢你气不过,觉得对方得寸进尺,这时候也不跟对方客气了,直接就是自己占70cm

那么用计算机语言来描述就是.

cpp 复制代码
//区域划分
struct area
{
    int start;
    int end;
}
//桌子
struct desktop
{
    struct area left;
    struct area right;
}


//最初的一人一半
struct desktop d;
d.left.start = 1;
d.left.end = 50;

d.right.start = 50;
d.right.end = 100;

//调整区域
d.left.end += 20;
d.right.start += 20;

那么结合上面的小故事,地址空间的本质其实就是一个struct结构体!内部的很多属性是start,end的范围.

5.2.1:地址空间的理解

5.2.2:为什么要有地址空间

  • 地址空间的作用则是将无序变成有序,让进程以统一的视角看待物理内存以及自己运行的各个区域.
  • 地址空间将进程管理模块与内存管理模块进行解耦------>站在进程的角度上,在申请内存的时候,只要能在地址空间上申请就ok了即在地址空间申请了空间然后在页表的左侧填充的了虚拟地址,右侧可能暂时不填,当需要使用内存的时候,再进行填充.
  • 拦截非法请求(本质是对物理内存的保护)

6:Linux2.6内核进程调度队列

上图是Linux2.6内核中进程队列的数据结构

6.1:一个CPU拥有一个runqueue

  • 如果有多个CPU就要考虑进程个数的负载均衡问题

6.2:优先级

  • 普通优先级:100~139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)
  • 实时优先级:0~99(不关心)

6.3:活动队列

  • 时间片还没有结束的所有进程都按照优先级 放在该队列
  • nr_active: 总共有多少个运行状态的进程
  • queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO 规则进行排队调度,所以,数组下标就是优先级!
  • 从该结构中,选择一个最合适的进程,过程是怎么样的呢?
  1. 从0下表开始遍历queue[140]
  2. 找到第一个非空队列,该队列必定为优先级最高的队列
  3. 拿到选中队列的第一个进程,开始运行,调度完成!
  4. 遍历queue[140]时间复杂度是常数!但还是太低效了.
  • bitmap[5]:一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,就可以用5*32个比特位表示队列是否为空,这样,便可以大大提高查找效率~

6.4:过期队列

  • 过期队列活动队列结构一模一样
  • 过期队列上放置的进程,都是时间片耗尽的进程
  • 当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算.

6.5:active指针和expired指针

  • active指针永远指向 活动队列
  • expired指针永远指向 过期队列
  • 可是活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直都存在的
  • 没关系,在合适的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了一批新的活动进程!

6.6:总结

在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法!

相关推荐
视觉震撼14 分钟前
本地机器远程连接配置与文件传输可行性检测工具
运维·服务器·网络·windows·php·apache
yuanManGan15 分钟前
走进Linux的世界:虚拟内存空间
linux·运维·服务器
落羽的落羽19 分钟前
【Linux系统】解明进程优先级与切换调度O(1)算法
linux·服务器·c++·人工智能·学习·算法·机器学习
代码游侠39 分钟前
复习笔记——C语言指针
linux·c语言·开发语言·笔记·学习
百***69441 小时前
Linux下MySQL的简单使用
linux·mysql·adb
飞凌嵌入式2 小时前
飞凌嵌入式RK3568开发板的TFTP烧写文件系统指南
linux·嵌入式硬件·嵌入式
❀͜͡傀儡师2 小时前
修改centos服务器启动画面
linux·服务器·centos
倔强的石头1065 小时前
openGauss数据库:从CentOS 7.9部署到实战验证
linux·数据库·centos
梁正雄6 小时前
linux服务-Nginx+Tomcat+Redis之Session 共享 - 容器单机版
linux·nginx·tomcat