跳到主要内容C
Linux 进程概念(下):环境变量与程序地址空间
Linux 进程概念涵盖环境变量与程序地址空间。环境变量用于指定系统运行参数,如 PATH 决定指令搜索路径,支持 env、export、unset 等操作,代码中可通过 argv 第三参数、getenv 或全局指针 environ 获取。程序地址空间即虚拟地址空间,通过页表映射物理内存,实现进程隔离。fork 创建子进程采用写时拷贝机制,父子进程共享物理内存直至修改。虚拟地址空间由 mm_struct 管理,划分为多个 VMA 区域,将物理内存无序变为有序,解耦进程与内存管理,并提供权限保护。
未来可期14 浏览 本文继续探讨进程概念,重点学习环境变量和程序地址空间。

1. 环境变量
1.1 环境变量的概念
• 环境变量 (environment variables) 一般是指在操作系统中用来指定操作系统运行环境的一些参数
• 如:我们在编写 C/C++ 代码的时候,在链接的时候,从来不知道我们的所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
• 环境变量通常具有某些特殊用途,还有在系统当中通常具有全局特性。
1.2 命令行参数表
以上给了环境变量的概念,但需要结合实例理解。在此之前先了解命令行参数。
在 C/C++ 内编写代码时 main 函数是否不能带参?
其实 main 函数可以带上参数,第一个参数为执行可执行程序时输入参数的个数,第二个参数是一个字符串指针数组。
例如以下实现的代码:
#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 来存储系统当中默认搜索指令的路径。
除此之外要查询环境变量还可以使用 echo $环境变量名 来进行查询。
这时就可以解释为什么在使用系统内的指令可以不带路径而直接使用指令的名称就可以调用,这其实就是在输入的指令只要是在 PATH 内的路径下,就会在用户输入的指令前加上路径。
那是不是只要将以上我们的 mytest 的路径也加到 PATH 内,那么在运行 mytest 时也可以不带 ./ 就可以执行了呢?
以上直接使用 PATH=当前路径这时就可以直接使用 mytest 了,但是这时又有问题了,那就是现在再查询 PATH 会发现我们的操作是将之前的 PATH 给覆盖了,那么这就会造成系统原本的指令无法正常的直接使用了。
注:在此 PATH 改变时 pwd 还能正常的使用是因为 pwd 是内建命令。
在此在 PATH 内进行路径追加的正确方式如下所示:
此时就将当前的路径追加到了 PATH 内,并且还保留了原来的环境变量,这样系统内的指令就还能正常的使用。
其实以上我们能使用 env、echo 等的指令查询环境变量其实是系统的 bash 内部存在一张 环境变量表,和之前提到的命令行参数表类似,环境变量表也是本质也是一个指针数组。当 bash 启动的时候就会构建出环境变量表。
当我们使用 ls -a 等的指令时 bash 就会先将输入的命令分解到命令行参数表当中,之后再在环境变量表当中查询路径。如果指令存在就创建子进程执行,不存在就报 command not found 错误提示。
到现在我们就知道了在 bash 创建时就会在 bash 内部存在两张表分别是命令行参数表和环境变量表,那么接下来问题又来了,那就是环境变量最开始是从哪里来的呢?
其实环境变量表最开始是存在系统当中的配置文件,使用 cd ~ 指令回到家目录之后就可以看到存在以上的 两个隐藏文件。
接下来再查看.bashrc 内提到的/etc/bashrc。
在此就可以看到很多的环境变量,在此还可以看到之前我们学习过的 权限掩码 umask。
当我们将配置文件当中的环境变量修改时就能让每次登入 Xshell 的时候查询环境变量都是发生修改的,不过强烈不建议随意的修改配置文件当中的环境变量,这样有时会造成系统的混乱。
那么如果当中 Linux 当中有 10 个用户登入,就会存在 10 个 bash,此时这 10 个 bash 都会各有一张环境变量表、一张命令行参数表。都会从配置文件当中拷贝对应的环境变量。
通过以上了学习就知道了我们执行一个指令前提是找到对应的指令,在此其实就是 bash 来进行的,这时因为 bash 既有命令行参数表;又有环境变量表。
1.4 更多环境变量
以上我们了解了环境变量表,但是我们只了解了 PATH 这一个环境变量,那么接下来就来了解更多的环境变量。
在环境变量当中 USER 表示的用户名,LOGNAME 表示的是当前登录的用户名,正常情况下这两个是同是相同的。
SHELL 表示的是当前 bash 的路径
HISTCONTROL 的作用就是保存用户最近的指令,这也是为什么在我们可以使用 CTRL+r 和上下键去查询历史的指令,但是一般自会保存最近的指令,要不然会占据太多的内存资源。
HOME 保存的是当前用户的家目录
PWD 存储的是当前的路径
OLDPWD 保存的是最近一次的路径,该环境变量在我们使用 cd- 的时候就起作用了
1.5 环境变量相关的操作
以上我我们了解了一系列的环境变量,那么接下来就来就来学习一些获取环境变量的方法。
其实在以上我们已经了解了两个环境变量的操作,分别是 使用 env 将所有的环境变量打印出来以及使用 echo 单独的将一个环境变量打印出来。
其实除了以上的之前提到的环境变量的操作方法,接下来再来补充几个相关的操作。
首先是如果我们要创建一个新的环境变量,那么就可以使用 export 来实现。
export
例如使用 export 导入一个新的名为 tmp1 的环境变量接下来再使用 env 就可以在环境变量表当中查询到该环境变量。
unset
以上使用 export 就可以创建环境变量,此时如果要将对应的环境变量删除就需要使用 unset。
例如要将以上创建的 tmp1 的环境变量从环境变量表当中删除就可以使用 unset 来实现。
1.6 通过代码如何获取环境变量
在之前了解命令行参数表的时候我们已经知道了其实在 main 函数当中也是可以有参数的,第一个参数为命令行参数的个数,第二个变量为命令行参数的指针数组,但其实除了以上的两个参数以外 main 函数还可以存在第三个参数;那就是环境变量表的指针数组
#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 当中的环境变量表给继承下来了。
首先来使用 man 手册来查询 getenv 系统调用的使用方法。
通过以上 man 手册内的描述就可以看出 getenv 的作用是获取指定的环境变量。
如果现在我们要写一个只有指定的用户才能执行的程序,实现的代码如下所示:
#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。
通过以上使用 getenv 的 2 示例就可以看出为什么子进程可以被子进程继承?
让子进程继承对应的环境变量表就可以在子进程内部实现个性化的需求。
在此先使用 man 手册来查询 environ 的使用方法
通过以上的描述就可以看出 environ 其实是 一个二级指针也就是环境变量表的指针。
#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 全局的指针就可以看出 环境变量是具有全局特性的。
其实在相同当中除了环境变量之外还存在本地变量,在此压查询存在的本地变量就需要使用指令 set。
以上就可以看到使用 set 指令之后就看到环境变量以及本地变量,当时和环境变量不同的是本地变量是不会被子进程进继承的。
通过以上 set 输出的结果还可以看到本地变量当中还存在命令行提示符的输出格式。
以上我们使用的 export 其实是 内建命令,也就是命令的执行不是通过创建子进程来实现的,而是让 bash 自己亲自执行的,具体的过程是通过 bash 调用相应的函数或者系统调用。
其实 pwd, cd 等指令也是内建命令。这也解释了为什么当我们将环境变量当中的 PATH 改变之后这些命令还能执行,而其他的命令却无法正常的使用。
2. 程序地址空间
在之前学习 C/C++ 的之后我们就了解到了计算机当中是存在栈区、堆区等不同的区域的。
#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 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++ 的时候将以上的图表示的叫做做程序地址空间,但其实之前这种说法是错误的,之前这样讲只不过是为了让我们更好的理解,因为到了内存上的概念就不是语言层面上能理解的了。其实以上空间正确的叫法叫做 进程地址空间或者虚拟地址空间。并且该内存实际上不是真正的物理内存。
#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_struct 拷贝一份之后对内部的数据进行一定的修改,在此我们要知道的是 每个进程都会有自己的虚拟内存空间和页表。子进程创建之后也会将父进程的虚拟地址空间进行和页表拷贝,并且这时和父进程是共用同一份的代码和数据。
当我们在父进程和子进程当中没有对该数据进行修改的时候,父子进程会一直共用物理内存当中的这一份数据,但是当父子进程当中出现了对该数据进行修改的操作的时候,在物理内存当中就会立刻创建一份和原数据一样大的空间并且将原空间内的数据也拷贝过来,之后再改变父子进程当中一个页表的指针映射。
那么有了以上的知识就可以解释为什么之前我们的代码当的 g_unval 的值在父子进程当中是不一样的,但是地址却是一样的。这其实就是当我们在子进程当中对该变量进行修改的时候在物理内存当中就会创建出一个新的数据块。我们之后在子进程当中访问的实际上是物理内存当中新的变量数据块,而父进程访问的是旧的数据块。而这两个变量的地址相同是因为子进程的虚拟地址空间是拷贝至父进程,变量相同的只是虚拟地址,而物理地址已经不一样了。
在 《进程概念(上)》 我们在了解 fork 的时候其实还留了一个问题,那就是为什么 fork 的返回值即等于 0 又大于 0,现在我们了解了虚拟地址到物理地址的映射之后旧很好理解了;实际上 fork 函数的返回值在在物内存当中是有两份的,只不过是虚拟父子进程地址是一样的。
在了解了以上的概念之后接下来将通过两个故事来进一步理解虚拟地址空间
假设现在有一个美国的大富翁,虽然的他在自己的人生当中积累了很多的财富,但是他的私生活比较乱,他有非常多的私生子。他这个人还有一个特性就是非常喜欢对他的孩子们画大饼,今天和他正在上班的的大儿子说你要好好努力啊,之后你能力够了就让你接班我这个董事长的位置,过来几天又和他正在上高中的二女儿说你要好好学习啊,等你考上了大学我就给你买一份苹果的全家桶,又过了几天又对每天就知道玩游戏的三儿子说你不要每天就知道玩游戏啊,我还等着你继承家产啊。
那么在以上的故事当中其实大富翁就是操作系统,而他的财富就是物理内存,他的一个个私生子就是一个个进程,而他给这些私生子进行画大饼的时候就是操作系统给一个个进程分配对应的虚拟地址空间;这一个个进程都认为自己是独占对应的物理内存。
由于大富翁的私生子非常的多,那么他有时候就会忘了给各个私生子画的饼分别是什么,那么时候就需要将画的饼记录下来,反应到操作系统当中就是要将各个进程的虚拟地址空间记录下来,南萨摩时候就要进行 先描述再组织的操作。这时我们就可以知道了其实 虚拟地址空间的本质其实也是一个结构体的对象。
那么这个结构体内部的元素又是怎么样的呢?这时候就要我们来接着看以下的故事。
在小学里三年级的小明是和一个女生坐同桌,但是他的同桌让他觉得很烦,因为他的同桌最近给他们的课桌画了一条'三八线';只要小明过了线就会被揍一下,并且他的这个同桌还有有点怪癖就是将自己的课桌划分为了几个区域,每个区域内都要放对应的物品,就比如在课桌的最旁边到课桌的内的 10 厘米要放自己的笔盒,以下的 30 厘米要放自己的书本。她还不允许别让将她桌子上的物品给搞乱。
那么在以上的故事当中小明的同桌将她的位置进行了划分的依据是各个区域的起始位置和终止的位置。其实在各个进程的虚拟地址空间当中和以上故事当中的类似也是按照起始地址和终止的地址来划分各个区间的。当我们要调整对应区间的大小时只需要将对应区间的起始和终止的地址进行修改。
接下来在 Linux 的源代码当中就可以看到在进程的 task_struct 当中存在一个 mm_struct 的指针也就是指向虚拟地址空间的指针,在观察该指针指向的结构体 mm_struct 就可以看到在该结构体的内部存在区域的划分。
以上我们就了解了虚拟地址空间也是数据结构的对象,那么此时问题就来了,那就是每个进程内部各个区域的划分一开始是从哪里来的呢?
一开始对应的进程是在磁盘当中的,之后其实将进程加载到内存之前就已经构建出对应的数据结构对象,并且在该数据结构对象内部完成了各个区域的划分。
就例如一个进程的代码和数据一共大小是 2GB,当该进程被加载到操作系统当中时就会开辟出正文代码和初始化数据为 2GB 的区域,但是一开始如果物理内存只加载了 0.5GB 的数据;此时还没有将剩下的数据加载到物理内存当中,那么此时在页表当中就只会将 0.5GB 的数据进行虚拟和物理之间的映射。
之后当需要使用到 0.5GB 之后的数据时此时操作系统会发现在该进程的页表内部没有对应的剩下的 1.5GB 的数据的映射,此时接下来就会发生 缺页中断,也就是会将磁盘当中剩下的数据也加载到物理内存当中,此时进程将短暂的停止运行,之后等到剩下的物理内存数据和虚拟内存当中在页表也建立映射关系之后继续运行。
因此总的来说磁盘当中的程序加载到操作系统当中会进行以下的操作:
- 在虚拟内存当中申请指定大小的空间,同时还会进行区域的调整
- 加载程序,申请物理空间
- 通过页表建立物理地址和虚拟地址之间的映射关系
通过以上的讲解我们就可以发现虚拟地址空间存在的意义其实是可以让进程管理和内存管理以及 IO 等操作解耦的,从而实现进程和内存之间的 高内聚、低耦合。
2.2 虚拟地址空间存在的意义
以上我们了解了虚拟地址空间是如何实现虚拟地址到物理内存之间的映射的,那么此时我们就要来思考了为什么在计算机当中要存在虚拟地址空间,不是直接将进程与物理地址之间建立联系不是更方便吗?
如果是在没有虚拟地址空间的状态下,那么在物理内存的加载数据时就需要对不同的进程之间进行各个数据区的管理,若出现稍微的数据管理不当就会出现一个进程的数据覆盖另外的进程,从而造成数据的丢失,在这种情况下物理内存的管理就会很困难。
而有了虚拟地址空间之后就可以让操作系统只需要对虚拟地址空间进行管理;而无需对应数据实际上映射的物理内存而考虑,这样就可使得每个进程在虚拟地址空间内都是有序的,但是实际上映射的物理空间是乱序的,从而减少了在管理进程的同时还要管理内存。
因此总的来说存在的第一点意义就是:将物理内存当中的'无序'变为'有序'
在之前学习 C/C++ 的时候我们就知道空指针是指指针指向的内存空间已经为空了,此时再对该内存空间内进行数据的修改时就会造成程序的奔溃。那么在了解了虚拟地址空间之后我们要了解其实程序当中空指针解引用出现报错其实是对应的虚拟地址在页表上进行物理地址映射时发现没有对应的物理地址,此时就会映射失败。
还有就是我们之前就了解到如果在程序当中出现以下的代码就会造成程序的奔溃。
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,方便进程快速访问。
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online