Linux 系统入门:环境变量虚拟地址空间
目录
一.环境变量
一).基本概念
1.命令行参数
2.环境变量相关的命令
3.PATH
4.HOME
5.其他环境变量
二).通过代码获取环境变量
1.命令行参数获取环境变量表
2.getenv()函数获取环境变量
3.全局变量 char **environ 获取环境变量表
三).环境变量的特性和本地变量
二.程序地址空间
一).语言的内存空间分布
二).虚拟地址
三).进程地址空间
四).虚拟内存管理
1.mm_struct结构体
一.环境变量
一).基本概念
- 环境变量(environment variables)⼀般是指在操作系统中用来指定操作系统运行环境的一些参数。
- 如:我们在编写C/C++代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
- 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
看完概念当然还是很陌生,毕竟概念是别人总结出来的,我们一点点来认识。
1.命令行参数

那么这些参数是什么意思呢?

进程中有一张表,argv表用来支持实现选项功能。

main的命令行参数是实现不同子功能的方法,指令选项的实现原理。

要执行一个程序,必须先找到它。
- 谁找?,bash,通过PATH(环境变量)来找。
系统中存在环境变量,来帮助系统找到目标二进制文件。
- 环境变量(PATH),系统中搜索指令的默认搜索路径。
2.环境变量相关的命令
- echo: 显示某个环境变量值
- export: 设置一个新的环境变量
- env: 显示所有环境变量
- unset: 清除环境变量
- set: 显示本地定义的shell变量和环境变量


环境变量是以 名称 = 内容 的形式进行展现的

3.PATH
PATH:系统中搜索命令的默认搜索路径。
#include<stdio.h>
int main()
{printf("hello\n");return 0;
}
查看当前的PATH环境变量,发现当前工作目录没有在环境变量PATH中,所以只能使用./hello来执行程序。使用下列命令在PATH环境变量中添加当前工作目录使用 hello 直接运行程序,bash找不到文件。

使用下列命令在PATH环境变量中添加当前工作目录
export PATH=$PATH:/home/hjh/hjh/linux/linux/10.29
这样就能在当前工作目录中直接运行 hello 来执行程序了。
如下还有其他的方法不带路径直接就可以运行:
- 将自己写的二进制程序拷贝到环境变量PATH中存在的目录下,但是不推荐这样,因为PATH环境变量中默认存在的目录都是与系统配置相关的目录,如果将自己的程序拷贝到这些目录中,可能会污染系统指令的环境等。
- 将当前的工作目录信息拷贝到系统相关配置文件中,使系统一启动就将该目录加载到环境变量PATH中。在CentOS7系统中,家目录下有两个隐藏文件 .bashrc 和 .bash_profile,修改.bash_profile中的环境变量PATH路径即可在启动程序时自动加载修改后的环境变量PATH。
总结:
- 环境变量是内存级的环境变量,每次启动一个bash,在bash进程中向内存申请一段空间来存储环境变量,每次重新启动系统时,之前修改过的环境变量都会重置,默认的环境变量最开始是系统相关配置文件中读取的。
- bash进程启动时,会从系统中读取环境变量,形成一张环境变量表。也是一个字符指针数组,每个元素指向一个key - value 的环境变量长字符串。env查询的时候就是打印这张表中元素指向的内容。每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向⼀个以’\0’结尾的环境字符串
- 执行 "ls -a -l "命令时,首先bash先拿到这个字符串,然后通过分割字符串,形成命令行参数表,然后去PATH环境变量的路径中查找 " ls " 命令,如果存在,就拼接命令路径创建子进程执行该命令。
4.HOME
HOME:指定用户的主工作目录(即用户登录到Linux系统中时默认的目录)
echo $HOME
当执行 cd ~ 为什么会直接转到 /home/hjh 中,因为在执行这条命令时,把 ~ 替换为环境变量 /HOME 中存储的目录,所以直接就跳转到 /home/hjh 中了。
切换为 root 用户,环境变量HOME则会变为 /root.
5.其他环境变量
- SHELL:当前Shell,代表的是使用哪个版本的Shell,它的值通常是/bin/bash这个目录。
- USER:当前用户是谁。
- LOGNAME:当前登录的用户是谁。
- HISTSIZE:当前用户存储历史命令的最大的数量。
- HOSTNAME:表示当前主机的主机名。
- PWD:表示当前的工作目录。
- OLDPWD:表示切换前的工作目录。cd - 命令中的 - 就是 OLDPWD对应的值
su 命令相当于对当前用户提权,并不会更新环境变量中的USER和LOGNAME。su - 命令相当于使用root账号重新登录,会将USER和LOGNAME更新为root。
二).通过代码获取环境变量
1.命令行参数获取环境变量表
main函数的参数最多可以有三个,我们用下列程序遍历父进程(bash)的环境变量表:
#include <stdio.h>
#include <string.h>int main(int argc,char *argv[],char* env[])
{for(int i=0;env[i];i++){printf("env[%d]->%s",i,env[i]);}return 0;
}程序的环境变量是哪来的?该参数是父进程传递给我们的。

总结:
- main函数只是我们当前编写的代码的入口,不是程序启动的入口,当程序启动的时候会先调用一个_start函数,检查main函数中参数的个数,然后把参数传给main函数,然后调用main函数执行我们自己的代码。
- 环境变量会被子进程继承,所以环境变量在系统中具有全局特性。但是如果任意一个子进程想修改环境变量,就和之前进程中的数据一样,会发生写时拷贝。
2.getenv()函数获取环境变量
#include <stdio.h>
#include <string.h>
#include <stdlib.h>int main(int argc,char *argv[],char* env[])
{char *value=getenv("PATH");if(value==NULL){return 1;}printf("PATH->%s\n",value);return 0;
}
写一个只能自己运行的代码,root也无法运行
#include <stdio.h>
#include <string.h>
#include <stdlib.h>int main(int argc,char *argv[],char* env[])
{char *who=getenv("USER");if(strcmp("hjh",who)==0){printf("正常执行\n");}else{printf("error");}return 0;
}为什么子进程要继承环境变量?
- 环境变量本质是进程运行的 “全局配置参数”,子进程继承这些变量,是为了确保它能像父进程一样找到所需的资源,避免 “父进程能运行的程序,子进程却因找不到依赖而失败” 的问题
3.全局变量 char **environ 获取环境变量表
该全局变量指针,指向环境变量表的第一个元素(第一个环境变量的地址),所以是一个二级指针。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>extern char **environ;int main(int argc, char *argv[], char *env[])
{for(int i=0;environ[i];i++){printf("env[%d]->%s\n",i,environ[i]);}return 0;
}
三).环境变量的特性和本地变量
bash会记录两套变量:
- 环境变量:具有全局特性
- 本地变量:不会被子进程继承,只在bash内部使用。在shell中可以使用 i=10 直接定义本地变量,使用 echo $i 可以进行查询, unset i 取消变量 i 。
命令行输入的命令都是bash的子进程,因为进程之间具有独立性,export导入环境变量按道理是不能导入到父进程bash中的,但是为什么可以使用export将环境变量导入到父进程bash中呢?
- 因为export是一种内建命令(built-in command),这种命令不需要创建子进程,而是让bash自己亲自执行。
二.程序地址空间
一).语言的内存空间分布
相信下面这张图我们并不会陌生!

用下列代码来观察各种区域的地址:
#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);printf("heap addr: %p\n", heap_mem1);printf("heap addr: %p\n", heap_mem2);printf("heap addr: %p\n", heap_mem3);printf("test static addr: %p\n", &test);printf("stack addr: %p\n", &heap_mem);printf("stack addr: %p\n", &heap_mem1);printf("stack addr: %p\n", &heap_mem2);printf("stack addr: %p\n", &heap_mem3);printf("read only string add: %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;
}
- 上述打印的地址,从正文代码区到命令行参数环境变量的地址,依次增大。
- 上述内存空间分布并不是实际的物理内存,而是进程地址空间,也叫做虚拟地址空间
二).虚拟地址
用下列一段代码证明上述所说的地址为虚拟地址
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int gval = 100;int main(int argc, char *argv[], char *env[])
{pid_t id = fork();if (id == 0){while(1){printf("子进程: gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());sleep(1);gval++;}}else{while(1){printf("父进程: gval: %d, &gval: %p, pid: %d, ppid: %d\n", gval, &gval, getpid(), getppid());sleep(1);}}return 0;
} 子进程和父进程中的gval值不一样,但地址是一样的,所以这里显示的地址不是实际的物理地址,而是虚拟地址。
子进程和父进程中的gval值不一样,但地址是一样的,所以这里显示的地址不是实际的物理地址,而是虚拟地址。
OS必须负责将 虚拟地址 转化成 物理地址
总结:
- C/C++等语言中输出的地址,全都是虚拟地址。虚拟地址是提供给上层用户使用的。
- 一个进程对应一个虚拟地址空间。一个虚拟地址对应一个字节,32位机器下有2的32次方(4GB)个地址,64位机器下有2的64次方个地址。
三).进程地址空间
一个进程启动之后,就会有一套页表,该页表存储的是进程中各变量的进程地址空间中的虚拟地址和物理内存的地址的映射关系。
下图中是上述例子中父进程和子进程gval变量的虚拟地址空间和物理内存的关系。子进程的代码和数据以及页表都是拷贝父进程的。所以子进程中虚拟地址和物理地址的映射关系和父进程一样,当gval在子进程中被修改时,发生写时拷贝,在物理内存中就会开辟一块新的物理地址来存储子进程的gval变量,然后修改子进程中页表的映射关系,但是gval变量在子进程中的虚拟地址没有改变,所以出现了上述子进程和父进程gval变量虚拟地址一样而变量的值不一样的情况,是虚拟地址相同,内容不同其实是虚拟地址和物理地址的映射关系被修改了。

在32位机器下,每个进程的虚拟地址空间都是4GB,每个进程都认为自己独占全部的物理内存。
四).虚拟内存管理
虚拟地址空间本质就是一个结构体对象,名为mm_struct(内存描述符),描述Linux下进程地址空间的所有信息。每个进程只有一个mm_struct结构,在每个进程的task_struct结构中,有一个指向该进程mm_struct的指针。mm_struct结构体中存储的是对进程地址空间中代码区、堆区、栈区等每个区域进行区域划分的信息,存储的是每个区域的开始位置和结束位置。
struct task_struct
{/*...*/struct mm_struc *mm; //对于普通的⽤⼾进程来说该字段指向他的虚拟地址空间的⽤⼾空间部分//对于内核线程来说这部分为NULL。struct mm_struct *active_mm; //该字段是内核线程使⽤的。当该进程是内核线程时//它的mm字段为NULL,表⽰没有内存地址空间,可也并不是真正的没有//这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使⽤任意进程的地址空间。/*...*/
}为什么要有虚拟地址空间?
- 隔离进程,保证系统稳定性。因为物理内存是所有进程共享的资源,若直接访问物理内存,可能误操作其他进程的数据,导致程序崩溃,或者访问非法地址,引起硬件异常导致系统宕机。
- 给进程提供连续、统一的内存视图。进程的代码和数据以及变量是碎片化的存储在物理内存中的,有了虚拟地址空间,进程看到的地址是连续且统一的,无需关心物理内存的分布,可以直接使用地址编写代码,不会和其他进程产生使用同一个地址的冲突。
- 实现虚拟内存技术。物理内存是有限的,程序的内存需求可能超过物理内存,操作系统将虚拟地址空间分为“页”为单位的块,只加载当前要用的代码和数据到物理内存中,当前不用的存储在磁盘的交换区中,进程访问未加载的代码和数据时,触发缺页中断,将暂时不需要的页唤出到磁盘,加载当前需要的页。
- 内存保护。虚拟地址空间允许操作系统给不同的页设置访问权限(如只读、可写等),防止进程误操作。
创建一个进程的时候先有task_struct这样的内核数据结构,再加载进程对应的代码和数据。所以一个进程可以不用加载程序的代码和数据,只先创建进程的task_struct,mm_struct,页表。
1.mm_struct结构体
下图在逻辑上表示mm_struct中存储的信息,存储的信息为各个区域在虚拟内存空间中的起始位置和结束位置。

struct mm_struct
{/*...*/struct vm_area_struct *mmap;    /* 指向虚拟区间(VMA)链表 */struct rb_root mm_rb;    /* red_black树 */unsigned long task_size;    /*具有该结构体的进程的虚拟地址空间的⼤⼩*//*...*/// 代码段、数据段、堆栈段、参数段及环境段的起始和结束地址。unsigned long start_code, end_code, start_data, end_data;unsigned long start_brk, brk, start_stack;unsigned long arg_start, arg_end, env_start, env_end;/*...*/
}每一个进程都会有自己独立的mm_struct,操作系统要将全部进程的虚拟地址分区组织起来。所以在每个进程的task_struct中有mm_struct。
mm_struct中有以下两种方式组织该进程对应的虚拟内存分区:
- 当虚拟分区较少时采取单链表进行管理,由mmap指针指向这个链表。
- 当虚拟分区较多时采用红黑树进行管理,由mm_rb指向这棵树。
Linux内核使用 vm_area_struct 结构体表示一个独立的虚拟内存区域(VMA),由于每个不同的虚拟内存区域功能和内部机制不同,因此一个进程使用多个vm_area_struct来分别表示不同类型的虚拟内存区域。上述两种组织方式使用的就是vm_area_struct来连接各个VMA,方便程序快速访问。


每个虚拟内存区域中也存有自己分区的起始位置和结束位置,还有一个mm_struct类型的指针,指向自己所属的mm_struct结构体。上图表示用双链表连接每个VMA。
注意:堆区在虚拟内存中会存在多个,并且是离散的,上图中只画了一个,一个进程中实际会存在多个堆区,也和其他VMA一样被这样管理起来。

