环境变量与虚拟地址空间——理解进程背后的内存世界
一、什么是环境变量?
环境变量可以简单理解成:
操作系统中用于指定程序运行环境的一些参数。
例如 Linux 中比较常见的:
PATH
HOME
SHELL
二、PATH 是什么?
我们平时输入:
ls
为什么系统知道应该去哪里找 ls?
这就和:
PATH
有关。
PATH 本质上保存了一组命令搜索路径。
当 shell 接收到:
ls
时,它会按照 PATH 中的目录寻找对应的可执行程序。
三、为什么自己的程序要写 ./hello?
例如我们自己编译:
gcc hello.c -o hello
之后执行:
./hello
为什么不能直接:
hello
这是学习 PATH 时非常重要的一个实验。
因为当前目录如果没有被加入 PATH,shell 默认不会因为你输入:
hello
就自动去当前目录寻找。
而:
./hello
则是在明确告诉 shell:
执行当前目录下的 hello。
四、如何查看环境变量?
最简单的方法:
echo $PATH
例如:
echo $HOME
还可以:
echo $SHELL
其他相关命令还有:
echo
export
env
unset
set
其中:
echo
可以查看变量;
export
可以设置并导出环境变量;
env
可以查看环境变量;
unset
可以清除环境变量;
五、环境变量为什么能影响子进程?
这里有一个非常有意思的特点:
环境变量具有继承性。
例如:
export MYENV="hello world"
然后运行自己的程序:
getenv("MYENV");
就可以获取到:
hello world
环境变量可以被子进程继承。
这让我对“父进程和子进程之间的关系”又有了新的理解。
六、C/C++ 程序如何获取环境变量?
一种很常见的方式就是:
getenv("PATH");
例如:
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("%s\n", getenv("PATH"));
return 0;
}
七、环境变量到底放在哪里?
env
本质上是一张环境变量表。
每一个程序都会接收到一张环境表,它本质上是一个字符指针数组。
还可以通过:
extern char **environ;
访问整个环境变量表。
这让我第一次感觉到:
所谓“环境变量”,最终还是以数据的形式存在于进程能够访问的空间中。
八、进程地址空间
下面开始进入一个很容易让人困惑的概念:
地址空间。
我们以前写 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;
}
打印各种变量地址观察:
$ ./a.out
code addr: 0x40055d
init global addr: 0x601034
uninit global addr: 0x601040
heap addr: 0x1791010
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038
stack addr: 0x7ffd0f9a4368
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
env[1]: 0x7ffd0f9a482e
env[2]: 0x7ffd0f9a4845
env[3]: 0x7ffd0f9a4850
env[4]: 0x7ffd0f9a4860
env[5]: 0x7ffd0f9a486e
env[6]: 0x7ffd0f9a4892
env[7]: 0x7ffd0f9a48a5
env[8]: 0x7ffd0f9a48ae
env[9]: 0x7ffd0f9a48f1
env[10]: 0x7ffd0f9a4e8d
env[11]: 0x7ffd0f9a4ea6
env[12]: 0x7ffd0f9a4f00
env[13]: 0x7ffd0f9a4f13
env[14]: 0x7ffd0f9a4f24
env[15]: 0x7ffd0f9a4f3b
env[16]: 0x7ffd0f9a4f43
env[17]: 0x7ffd0f9a4f52
env[18]: 0x7ffd0f9a4f5e
env[19]: 0x7ffd0f9a4f93
env[20]: 0x7ffd0f9a4fb6
env[21]: 0x7ffd0f9a4fd5
env[22]: 0x7ffd0f9a4fdf
通过这种方式,可以直观看到:
一个进程的地址空间并不是杂乱无章的,而是存在不同的区域。
十、最关键的问题:这些地址是真实物理地址吗?
这个问题非常重要。
例如:
int g_val = 0;
父进程和子进程都打印:
&g_val
结果可能发现:
parent: 0x80497d8
child : 0x80497d8
两个进程的地址居然一样!
如果这个地址是物理地址,那么意味着:
两个进程正在访问同一块物理内存。
可如果把代码修改一下:
g_val = 100;
让子进程修改变量,再让父进程输出。
结果可能变成:
child : 100 : 0x80497e8
parent: 0 : 0x80497e8
地址相同,数据不同。
这个实验明确指出:
C/C++ 程序中看到的地址是虚拟地址,而不是物理地址。
十一、虚拟地址到底是什么?
现在我们可以总结:
程序看到的地址
↓
虚拟地址
↓
页表映射
↓
物理地址
↓
真正的内存
所以:
两个进程完全可以拥有相同的虚拟地址,但它们最终映射到不同的物理内存。
这就解释了刚才那个非常神奇的实验。
十二、为什么必须有虚拟地址空间?
如果没有虚拟地址空间,而是让每个程序直接操作物理内存,会出现很多问题。
1. 安全问题
一个程序如果可以任意访问物理内存:
进程A
↓
任意读写
↓
进程B的数据
↓
甚至系统内核数据
那么一个恶意程序就可能破坏整个系统。
2. 地址不确定
同一个程序每次运行时,物理内存中的空闲位置都可能不一样。
第一次:
放在某个位置
第二次:
可能已经有其他程序
↓
只能换一个位置
那么如果程序直接使用物理地址,程序本身就必须处理大量复杂的问题。
虚拟地址可以把这些细节隐藏起来。
3. 内存管理效率问题
如果物理内存不够:
进程A
进程B
进程C
...
直接操作物理内存时,管理会非常麻烦。
而有了虚拟地址空间之后,操作系统可以通过分页和映射机制更灵活地管理内存。
十三、mm_struct:进程的内存描述符
Linux 既然要管理进程,也必须管理进程的地址空间。
所以出现了:
mm_struct
可以把它理解成:
进程地址空间的描述信息。
每个普通进程都有自己的:
mm_struct
而 task_struct 中又保存了指向它的指针。
十四、VMA 又是什么?
一个进程的地址空间里,不同区域有不同功能:
代码
数据
堆
栈
参数
环境变量
...
Linux 使用:
vm_area_struct
描述这些独立的虚拟内存区域,也就是 VMA。
Linux 可以使用链表或者红黑树组织这些 VMA,以便管理和查找。
所以这时候整个关系已经逐渐清楚了:
task_struct
↓
mm_struct
↓
多个 VMA
↓
不同的虚拟内存区域
十五、虚拟地址空间最大的意义
看到这里,我觉得虚拟地址空间最核心的作用可以概括成三点:
第一:保护
不同进程之间彼此隔离。
第二:抽象
程序不用关心物理内存具体在哪里。
第三:解耦
进程管理和物理内存管理不需要完全绑定在一起。地址空间和页表由操作系统负责创建和维护,同时能够让进程的管理和物理内存管理实现一定程度上的解耦。
十六、malloc 到底申请了什么?
我们平时写:
malloc(1024);
感觉自己是在“申请物理内存”。
但严格来说,并不是这么简单。
在虚拟地址空间机制下,malloc/new 首先是在进程的地址空间中申请空间。
真正访问物理内存时,再由操作系统完成对应的内存管理和页表映射。
这让以前 C 语言里很多看似简单的代码背后突然变得非常复杂。
十七、学习总结
四天学习下来,我觉得最大的收获并不是记住几个函数,而是逐渐建立了一个完整的 Linux 进程模型。
最开始:
计算机
↓
冯诺依曼体系
然后:
操作系统
↓
管理硬件和软件资源
再往下:
进程
↓
task_struct
↓
PCB
↓
PID / PPID
接着:
fork
↓
父子进程
↓
进程状态
↓
僵尸 / 孤儿
↓
进程调度
最后进入:
进程地址空间
↓
虚拟地址
↓
页表
↓
mm_struct
↓
VMA
↓
物理内存
以前我看 C/C++ 程序,总觉得:
int a = 10;
就是简单地“定义一个变量”。
现在我开始知道,在这背后还有:
进程
↓
虚拟地址空间
↓
页表
↓
物理内存
↓
操作系统
这么一整套机制。
这可能就是学习 Linux 系统编程最大的意义之一:
从“会写代码”,逐渐走向“知道代码为什么能够运行”。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)