Linux--环境变量和进程地址空间

目录

    • 1. 进程优先级
      • 1.1 PRI and NI
    • 2. 环境变量
      • 2.1 获取命令行参数
      • 2.2 main函数的第三个参数
      • 2.3 获取环境变量的方法
      • 2.4 环境变量的全局性
    • 3. 虚拟地址空间
    • 4. 虚拟地址空间划分
      • 4.1 具体的划分
      • 4.2 虚拟地址特点
    • 5. 为什么这样设计
      • 5.1 为什么设计页表
      • 5.2 为什么设计地址空间
    • 6. 总结

1. 进程优先级

  • 概念

cpu资源分配的先后顺序,就是指进程的优先权(priority)。

  • 作用

优先权高的进程有优先执行权利。

  1. 配置进程优先权对多任务环境的linux很有用,可以改善系统性能;
  2. 还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能。

1.1 PRI and NI

在linux系统中运行一个进程后输入ps -l命令可查看具体情况:

在这里插入图片描述

  1. UID : 代表执行者的身份
  2. PID : 代表这个进程的代号
  3. PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
  4. PRI :代表这个进程可被执行的优先级,其值越小越早被执行
  5. NI :代表这个进程的nice值

PRI即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高。

NI就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值。这里其实可以理解为优先级调整的幅度大小,有了nice值可以直观地看出来。

PRI值越小越早被执行,那么在此基础上加入nice值后,将会使得进程优先级PRI变为:PRI(new)=PRI(old)+nice。这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行。所以,调整进程优先级,在Linux下,就是调整进程nice值;nice其取值范围是-20至19,一共40个级别。

这样设置的原因是:优先级是一种相对的,而不是绝对的。意思就是不能让某个进程享有绝对的优先,这种情况会导致严重的进程“饥饿问题”,而OS中的调度器就是能让每个进程都享受到CPU资源。

2. 环境变量

系统内部命令为什么不需要使用路径?因为有环境变量的存在。

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

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

环境变量的本质是OS在内存/磁盘文件中开辟的空间,用来保存系统相关的数据

可以这么理解:int a = 10;环境变量就相当于此时的a,数字10就相当于对应指令的路径。那么找到a的地址然后取里面的值就可以拿到对应的指令结果。

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

常见的环境变量:

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

运行echo $PATH可以看到下列结果:

在这里插入图片描述

这个结果的意思是:我们输入的系统指令,系统会在这些以冒号分隔的路径找;原则是从第一个冒号分隔的路径开始,找不到就到下一个冒号分隔的路径,以此类推。

对于HOME变量,其作用是指定不同用户的初始家目录:用root用户和自定义用户进入系统时的初始目录不同,分别是/root和/home/user;其原因就是对于不同用户,HOME变量不同。

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

举个例子:

myval=100;//设置一个本地变量,注意不要有空格

此时的myval是本地变量,只在本地显示:

set | grep myval;
//输出结果myval = 100;

在全局情况下不显示:

env | grep myval;
//无显示内容

把本地变量转为环境变量:

export myval;
env | grep myval;
//输出myval = 100;

2.1 获取命令行参数

系统是会去找指令,但是这个过程是怎么做到的?

  • 举例

其实main函数也是有参数列表的:

argv是一个指针数组,而argc表示的是该数组的元素个数,第三个参数暂不讨论

那么创建一个test.c程序对该指针数组进行遍历如下:

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

可以发现输出结果只有一个可执行程序的执行命令./test

argv[0]:./test

此时再输入几条指令-a -b -c

[zcb@VM-8-7-centos lesson2]$ ./test -a -b -c

会发现它们作为argv数组的成员被打印了出来

argv[0]:./test
argv[1]:-a
argv[2]:-b
argv[3]:-c

如何理解?类比ls指令(列出指定路径下所有文件),ls可以带有指令-a -l -i,把ls看做一个可执行程序,而后面接着的指令被作为命令行参数导入进该指针数组了,那么不同的参数列表传递给main函数,就可以让该程序呈现出不同的结果。

Linux--环境变量和进程地址空间_第1张图片

./test是这样的原理,类比ls等系统的指令也是这样的原理,这就很好的解释了为什么ls 后带的命令行参数等可以呈现出不同的结果,这本质上就是通过给main函数传递不同的参数,以实现不同的功能。

2.2 main函数的第三个参数

接下来讨论第三个参数

第三个参数实际上就是OS自己实现的系统默认的指令字符指针数组,和上面的例子一样。只不过前面的是指向变量的字符指针数组,而第三个参数是指向环境变量的字符指针数组。

使用while循环对该指针数组进行遍历,得到的是一堆指令,这便是系统默认的指令,而给main函数传第三个参数的这个行为是OS做的。

一个进程运行起来后,如果你自定义了并export了一个环境变量,此时搜索,是可以搜到的;因为环境变量是一个系统级别的全局变量,bash之下所有进程都可以获取,本质就是调用方会给main函数传env,让所有进程都能找的到该变量。

2.3 获取环境变量的方法

  1. 命令行第三个参数
int main(int argc, char *argv[], char *env[])
{
	int i = 0;
	for(; env[i]; i++){
	printf("%s\n", env[i]);
}
	return 0;
}
  1. 通过第三方变量environ获取

libc中定义的全局变量environ指向环境变量表,environ没有包含在任何头文件中,所以在使用时 要用extern声明。

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

注意到这个方法没有在main函数实现参数列表也是可以得到环境变量的,这是因为environ指向的就是上述存储环境变量地址,所以environ其实就是一个二级指针:
Linux--环境变量和进程地址空间_第2张图片

图中是获取环境变量的方法,系统会生成一个char**的指针,指向main函数接收到的指针数组,进而访问其成员。

其实上述的方法大多数是不适用的,因为太过麻烦,常用的是下列方法:

printf("%s\n", getenv("PATH"));
//例如
//mystr=“hello”;//注意不要带空格
//export mystr;
//printf("%s\n",getenv("mystr"));

getenv是可以直观地把环境变量的内容打印出来,这个函数的本质其实是把上面的遍历方式封装起来了,其函数实现就是上面写的遍历数组方式。

2.4 环境变量的全局性

环境变量的全局性本质是环境变量可以被子进程继承下去。

但是本地变量是不可以的,因为它的生命周期不具有全局性。

如果这时候把该本地变量导出为环境变量(按照上面提到的导出方法),就可以被子进程所继承。(如果未创建其他进程,那么上述操作其实就是把本地变量的地址写入到了bash进程的环境变量路径内,此时env查找指令是可以看到该环境变量的)

此时的my_env还是本地变量,下列程序运行为null

int main(){
  printf("%s\n",getenv("my_env"));
  return 0;
}

执行下列语句后变成环境变量:

export my_env="hello"

再次运行程序结果为hello

原本子进程中打印出来的是null,将my_env本地变量导出为环境变量以后子进程打印的也就变成了hello,这说明了什么?

这说明了环境变量是可以被子进程继承下去的,也就是说只要父进程设置了环境变量,所有的子进程都能找到该环境变量并使用。

例如bash进程设置了环境变量以后,以后创建的所有子进程都可以找到它并使用。

3. 虚拟地址空间

在linux系统中进行实验:

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;
}

这段代码的意思是,让子进程和父进程分别打印一个全局变量的地址和值。得到的结果是一样的。

parent[2995]: 0 : 0x80497d8
child[2996]: 0 : 0x80497d8

这很好理解:因为子进程按照父进程为模版,父子并没有对变量进行进行任何修改。但是如果做出如下修改:

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;
}
child[3046]: 100 : 0x80497e8
parent[3045]: 0 : 0x80497e8

在我的认知中,父进程此时打印出来的值肯定和子进程相同,因为地址相同的一个变量,值在子进程被修改,父进程得到的值也随之改变。但是结果却是值不同,地址相同。这是为什么?难道同一个物理地址的变量里,能存储两个不同的数据?答案是否定的。

所以得出结论:该地址不是物理地址。

在Linux地址下,这种地址叫做虚拟地址(虚拟地址空间)。我们在用C/C++语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。

OS必须负责将 虚拟地址 转化成 物理地址 。

所以准确地来说,应该叫做虚拟地址空间,而不是程序地址空间,并且这个地址空间是虚拟的。

4. 虚拟地址空间划分

4.1 具体的划分

  • 数据结构类型与进程地址空间

每个进程都有一个地址空间,都认为自己独占了自己物理内存。 就比如自己申请了1000个字节的地址,不一定就能马上使用1000个字节的地址;因为我们有不完全使用该地址的可能,所以为了充分安排进程地址空间,不会立即把这1000个字节的地址空间给到你。

那OS如何知道我使用不使用?

OS会辨别你从来没有对该地址空间读写过,判定你不使用该地址空间。

所以,进程的地址空间实际上是以区域划分来实现的,平时我们听到的代码区、数据区、未初始化数据区,其实就是一个个划分好的虚拟地址空间。

并且,系统是通过结构体来管理划分的虚拟地址空间,结构体里存放有关每个区域的起始和结束的信息。
Linux--环境变量和进程地址空间_第3张图片

Linux--环境变量和进程地址空间_第4张图片

可以这样理解:32位系统中,能访问的最大地址空间为232字节,那么如果把0~232 字节的地址比做成一把尺子,那么mm_struct其实就相当于尺子上的刻度;那么虚拟地址空间其实就是这些刻度里的某一个范围,而尺子的刻度又是线性增长的,那么虚拟地址空间也是线性增长的。

所以:进程地址空间本质是内存中的一种内核数据结构----mm_struct。

  • 页表

对于每个进程来说,它们都认为自己占有了整个地址空间(4GB)。 所以其实虚拟地址不过是系统为进程画的一张大饼,但是要真正实现这个大饼的功能,要通过页表。

Linux--环境变量和进程地址空间_第5张图片

图意为:当用户进行内存申请操作时,OS会在虚拟地址空间划分区域,并将对应的地址给到页表,页表再根据拿到的地址在物理地址里开辟对应空间,然后在页表右侧填入对应地址,让两个地址形成映射,然后某一进程需要访问时,就有了地址可以间接找到用户所申请的空间。在这个过程中,物理地址对于用户与进程来说就是无法直接访问到的。

这里就可以解释:所谓栈向下增长、堆向下增长这个过程其实就是通过改变mm_struct里不同区域对应的刻度(地址范围),然后通过页表建立新的地址映射,来达到增长、减少的操作。

  • 编译器在这个过程做的事

平时我们生成的可执行文件.exe,其实也在虚拟地址里划分好了,这里用划分这个词是因为,一个程序里会有不同的代码类型,.code、randomly、init(已初始化)、uninit(未初始化),所以其实可执行程序在虚拟地址空间里是分段的。

这些步骤其实是编译器做的,大大减轻了OS的负担,而在这个过程中编译器可能会对代码进行各种各样的优化;所以说不同的程序在不同的编译器可能会出现不同的结果。

4.2 虚拟地址特点

32位系统中,虚拟地址空间为 0 ~ 4G,将最高的 1G 字节(从虚拟地址 0xC0000000 到 0xFFFFFFFF),供内核使用,称为内核空间,将较低的 3G 字节(从虚拟地址 0x00000000 到 0xBFFFFFFF),供各个进程使用,称为用户空间,虚拟地址空间分布如下图:
Linux--环境变量和进程地址空间_第6张图片

  1. 内核空间

存放内核的代码和数据,所有进程的内核代码段都映射到同样的物理内存,并在内存中持续存在,是操作系统的一部分。内核空间为内核保留,不允许应用程序读写该区域的内容或直接调用内核代码定义的函数。

  1. 用户空间

用户空间给各个进程使用,也称为使用者空间。用户空间中的代码运行在较低的特权级别上,只能看到允许它们使用的部分系统资源,并且不能使用某些特定的系统功能,也不能直接访问内核空间和硬件设备,以及其他一些具体的使用限制。用户空间又大致细分为下列一些空间:

  • 栈空间

栈又称堆栈,由编译器自动分配释放,行为类似数据结构中的栈(先进后出)。存储局部变量、函数参数值。栈从高地址向低地址增长,是一块连续的空间。

  • 共享区

内存映射以及共享库(动态库)所在的内存。

  • 堆空间

堆用于存放进程运行时动态分配的内存段,可动态扩张或缩减。堆从低地址向高地址增长。通过 new() 或者 malloc() 函数可以在堆区开辟空间。

  • BSS 段(未初始化数据段)

BSS(Block Started by Symbol)段中通常存放程序中以下符号:

未初始化的全局变量和静态局部变量;
初始值为 0 的全局变量和静态局部变量(依赖于编译器实现)

  • DATA 段(已初始化数据段)

数据段通常用于存放程序中已初始化且初值不为 0 的全局变量和静态局部变量。

  • TEXT 段(代码段)

代码段也称正文段或文本段,通常用于存放程序执行代码(即CPU执行的机器指令)。

  • 保留区

位于虚拟地址空间的最低部分,未赋予物理地址。任何对它的引用都是非法的,用于捕捉使用空指针和小整型值指针引用内存的异常情况。

5. 为什么这样设计

5.1 为什么设计页表

如果我们允许一个进程直接访问到物理地址空间,如何保证这个进程不会越界访问?如何保证它会规规矩矩精确定位到自己应该去的地方?很明显要借助一种标准,用来约束每个进程,防止它们越界。

例如,某一进程要访问物理地址的代码区,经页表查询后,OS发现对应物理地址压根就不存在该进程所访问的地址,就会直接拒绝访问,保证了物理地址和各个进程的代码数据的安全性。 就像下列代码:

const char str[] = "hello world!";
*str[0] = 'a';//error!!

我们都知道无法修改字符串的内容,因为它存在于常量区,那么为什么它存在与常量区就无法修改?

这是因为OS对于该区域,只给了你只读的权限。 这也是页表的功能之一,记录了你对于该区域的权限。所以平时我们写的程序产生野指针会崩溃其实就是该进程被OS杀死。

这时候回到刚才说的申请1000字节的例子,其实就是OS暂时扩大了该进程的虚拟地址空间,让你以为确实多了1000字节的空间,而当你真正需要的时候,OS会通过页表的方式建立好映射,给你到物理内存中分配。这叫做基于缺页中断进行内存申请。 俗称:画大饼。

5.2 为什么设计地址空间

说到进程申请空间的过程其实是OS给进程“画大饼”,那么这张大饼就是通过虚拟地址空间来完成的,通过它,可以将页表映射的这个过程透明化,让进程认为自己真的访问到了这么大的物理内存。但就算最后进程访问到了,它也仅仅只是知道自己访问到了,而不知道OS一开始到底有没有分配给它。

  • 作用
  1. 上文说的,通过添加软件层,完成有效地对内存空间进行风险管理;保证了物理地址和各个进程的代码数据的安全性;通俗点说,就是每个进程之间不会错误地去访问不属于自己的地址空间。

  2. 将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写和OS进行内存管理操作,进行软件上的分离(将应用和内存管理进行解耦)。

  3. 站在CPU和应用层的角度,如果每次运行,CPU都要找到该进程的运行入口,那么每个进程都不一样,这是非常麻烦的;所以前面说到的通过用地址空间对进程“画大饼”的方式,所有进程都会认为自己使用了整个4GB的内存空间,就可以实现进程统一,使用统一的4GB空间,而且每个进程的相对位置都会比较确定。而不是每个进程都分散在不确定的地址空间,需要CPU逐个处理。

  4. 物理内存地址的空间得到充分利用,进程可以使用物理内存的任意内存空间,大大减少内存管理的负担。

最后达到目标:每个进程都认为自己有着4GB的内存空间

  • 作用综合

这时候页表+地址空间的作用就一目了然:

  1. 对于野指针访问,直接阻止在了页表映射的左侧,不让其访问物理地址空间;
  2. 每个进程看到的都是相同的空间范围(构成,顺序),对应的代码就按照规定找到对应的物理内存;
  3. 更好地完成进程独立性(进程访问对应空间)以及合理使用空间(由OS操纵)。
  4. 通过“画大饼”完成进程调度(页表左侧)和内存管理(页表右侧)进行解耦。

6. 总结

在这里就可以很好地理解第3节举的例子,为什么改变了数据后,相同的地址会打印出不同的内容:打印出来的是虚拟地址,会一样是因为子进程是由父进程继承而来,自然也就一样;而子进程对该值进行写入时,发生了写时拷贝,把另一份物理地址空间给到了子进程,重新建立了映射关系,所以才会出现有不同的值;因为这时候对于父子进程来说,对应的物理内存地址本就不同。

创建进程并不是简单的将代码数据加载到内存中,而是要先描述再组织:通过task_struct和mm_struct还有页表,将物理内存和虚拟地址空间还有进程联系起来。

你可能感兴趣的:(操作系统,linux,运维,服务器,1024程序员节)