一、什么是环境变量?

环境变量可以简单理解成:

操作系统中用于指定程序运行环境的一些参数。

例如 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 系统编程最大的意义之一:

从“会写代码”,逐渐走向“知道代码为什么能够运行”。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐