humid1ch blogs

本篇文章

手机用户建议
PC模式 或 横屏
阅读


Linux系统 2023 年 3 月 6 日

[Linux] 什么是进程地址空间?父子进程的代码时如何继承的?程序是怎么加载成进程的?为什么要有进程地址空间?

在介绍C++的内存控制时, 我用了这样一张图来大致表述一个程序的程序地址空间, 并且也提到过这块空间占用的是内存. 不过这张图, 在Linux系统中需要稍微改动一下

Linux下的进程地址空间

在介绍C++的内存控制时, 我用了这样一张图来大致表述一个程序的程序地址空间, 并且也提到过这块空间占用的是内存, 并且通过下面的一段代码大致分析了, 各区域存储的变量类型:

如果不了解C++内存管理, 可以去看一下博主介绍C++内存管理的博客:

[C++] 超详细分析 C++内存分布、管理(new - delete) ~ C 和 C++ 内存管理关系 ~ 内存泄漏 ~_c++ 嵌套delete

虽然了解了各种数据在此空间中大致所存储的位置, 但是其实并不理解这块空间:
  1. 这块空间表示实际的物理地址空间吗?
  2. CPU是如何从这块空间中获取数据并处理的呢?
  3. ……
对于这块空间, 其实还有许多的问题没有理解, 这篇文章就是介绍Liunx中关于这快空间的相关介绍分析
不过针对Linux, 需要在将上图再细微修改一下:
这张图可以大致用来表示 Linux下进程地址空间区域分布

验证各种类数据存储区域

Linux系统中, 程序加载为进程之后, 进程中的数据会按照上图所表示的各数据区域在内存中存储
可以使用以下的代码来简单的验证一下:
#include <stdio.h>
#include <stdlib.h>

int global_Var;
int init_global_Var = 1;

int main() {
	static int static_Var = 1;
	char* stack_data1 = (char*)malloc(100);
	char* stack_data2 = (char*)malloc(100);
	char* stack_data3 = (char*)malloc(100);
	char* stack_data4 = (char*)malloc(100);
	printf("main              addr:: %p\n", main);
	printf("global_Var        addr:: %p\n", &global_Var);
	printf("init_global_Var   addr:: %p\n", &init_global_Var);
	printf("static_Var        addr:: %p\n", &static_Var);
	printf("stack_data1       addr:: %p\n", &stack_data1);	
	printf("stack_data2       addr:: %p\n", &stack_data2);	
	printf("stack_data3       addr:: %p\n", &stack_data3);	
	printf("stack_data4       addr:: %p\n", &stack_data4);
	printf("heap_data1        addr:: %p\n", stack_data1);
	printf("heap_data2        addr:: %p\n", stack_data2);
	printf("heap_data3        addr:: %p\n", stack_data3);
	printf("heap_data4        addr:: %p\n", stack_data4);

	return 0;
}
运行此代码程序可以看到:
  1. 首先输出 main函数地址:

    是输出的所有地址中最小的, 也就是最低的

    但也不是整个进程的首地址, 这可以大致说明 进程的代码地址应该是在其他所有数据之前的

  2. 其次是 未初始化的全局变量、初始化的全局变量 和 初始化的函数内部定义的静态变量:

    首先是未初始化、初始化的全局变量:可以看到, 未初始化的全局变量的地址是在已经初始化的全局变量上面的, 也就对应了图中细分的静态区区域:

    全局变量相对来讲:未初始化数据在高地址, 初始化数据在低地址

    而且可以看到, 经过初始化的在main函数体内部定义的static变量的地址 位于两个全局变量之间, 其实这就说明, 被static修饰的变量 其实实际上就是一个全局变量, 只有在进程结束后才会被释放的

  3. 定义在栈上的数据:

    按照定义的顺序, 最先定义的数据的地址空间最大最高, 之后定义的按照定义顺序逐渐减小, 这表明在栈上定义数据 是由高到低占用空间的, 即在栈上定义数据占用空间是向下增长的

  4. 定义在堆上的数据:

    按照定义的顺序, 其占用空间的方向 与栈刚好相反. 在堆区定义数据 是由低到高占用空间的, 即在堆区定义数据占用空间是向上增长的

  5. 栈 和 堆区数据的地址, 存在非常大的断层:

    这也说明 堆和栈之间是存在着非常大的一块地址空间的

如何感知到进程确实存在进程地址空间

上面举例介绍、也验证了, 进程的各种种类数据确实是按照 一定的区域分布的, 但是也没有办法说明进程有一块自己的空间来存储数据
那么, 如何说明进程确实存在一块空间呢?
还是使用一段代码:
#include <stdio.h>
#include <unistd.h>
int global_Var = 100;

int main() {
	pid_t id = fork();
	if(id == 0) {
		int cnt = 5;
		while(1) {
			if(cnt > 0) {
				prinf("我是子进程, global_Var= %d, addr=%p, 还有 %ds 修改global_Var\n", global_Var, &global_Var, cnt);
				cnt--;
				if(cnt == 0) {
					global_Var = 200;
					printf("我是子进程, 我已修改global_Var\n");
				}
			}
			else
				printf("我是子进程, global_Var= %d, addr=%p\n", global_Var, &global_Var);
			sleep(1);
		}
	}
	else {
		while(1) {
			printf("我是父进程, global_Var= %d, addr=%p\n\n", global_Var, &global_Var);
			sleep(2);	
		}
     }

	return 0;
}
运行上述代码, 你会发现一个令人震惊的现象:
前5s, 父子进程global_Var的地址相同, 值也相同; 但是5s之后, 发现**父子进程的global_Val地址相同, 但是值却不同了**
为什么会出现这种现象?以往的认知中, 同一个地址存储的内容应该是相同的, 但这为什么不同了?
虽然现在不太知道原因, 但是一定能推断出一个结论, 就是 这两个进程使用的地址, 一定不是内存的物理地址, 即 C/C++中的地址一定不是内存的物理地址. 因为, 如果使用的物理地址根本不可能存在同一个地址却拥有两个不同的值的这种情况
那么C/C++使用的地址是什么地址呢?C/C++使用的地址空间其实是虚拟地址空间, 而并不是实际的内存物理地址空间

虚拟(进程)地址空间

在Linux系统中, 每一个进程被加载内存中时, 操作系统都会为其创建一个虚拟地址空间(也叫进程地址空间), 这个虚拟地址空间并不是内存的物理空间但是存在一定的映射关系, 且虚拟地址是被task_struct描述的. 也就是说, 进程的task_struct、虚拟地址与物理地址存在类似此图示一样的关系:
task_struct描述着进程的所有属性, 也描述着进程的虚拟地址空间, 而虚拟地址空间与内存实际的物理地址只存在相互映射的关系
内存中加载的进程的代码和数据, 会通过一个叫页表的东西映射到虚拟地址空间中:
每个进程都会有一个虚拟地址空间, 这个虚拟地址空间也是被操作系统管理着的, 即 操作系统也会对虚拟地址空间生成一个类似PCB的数据结构, 在Linux中叫 struct mm_struct{}
此结构中也描述着有关进程地址空间的属性, 它描述着进程地址空间中各个区域之间的范围:栈区、堆区、静态区、代码段……各区域的地址范围, 实际上 struct mm_struct{}就是用来维护进程地址空间的

父子进程代码继承关系

Linux中, 调用fork()系统调用创建的子进程的代码是继承自其父进程的. 当时只是简单的提了一句:可以看作父进程fork()之后的代码与子进程是共享的

博主有关此问题的博客

但是并不知道实际情况到底是如何的, 那么就还以上面父子进程的程序代码为例, 介绍一下父子进程代码继承的实际关系:
首先, 操作系统会针对父进程生成一个属于父进程的进程地址空间, 当子进程被创建出来之后, 操作系统也会针对子进程生成一个属于子进程的进程地址空间:
而所谓的共享代码, 实际的意思其实是代码的物理地址共享父进程地址空间中的代码、数据地址通过页表映射到物理地址中其相对应的指定地址, 而子进程地址空间中的代码、数据地址通过页表映射到与父进程相同的物理地址
在父子进程都未修改数据时, 父子进程的数据的物理地址也是共享的, 也就是说在父子进程都未修改数据的时候, 虽然父子进程都有属于自己的进程地址空间, 但内存中实际只加载了一份代码与数据
当子进程继续执行代码, 修改了0x60104C地址所存储的值时, 操作系统就会在在物理空间中申请一个新的地址供子进程存储数据使用, 同时修改子进程页表内容:

这种在数据做修改时, 才实际操作物理地址拷贝一份空间的方法叫写时拷贝

这就是父子进程共享代码的实际情况

程序是如何加载为进程的

我们知道, 当源代码被编译器编译链接之后, 会生成可执行程序. 运行这个可执行程序, 然后操作系统生成PCB 与 程序的代码、数据一起加载到内存中, 创建了一个进程地址空间, 此时就说一个进程被创建了.
但这只是一个流程, 那么实际上操作系统是根据什么将程序的数据加载到内存中的呢?
在回答这个问题之前, 先分析这两个问题:
  1. 程序在没有运行、没有被加载到内存中的时候, 程序内部是否存在地址?

    一定是存在的. 程序是源文件被编译器编译链接而生成的, 编译的过程暂且不讲. 而链接, 其实就是将程序内各种数据、函数的地址与库中的地址链接起来, 才能成为可执行程序的. 所以 程序中原本就是存在地址的.

  2. 程序在没有运行、没有被加载到内存中的时候, 程序内部是否存在类似进程地址空间里设置的区域?

    程序内, 其实也是存在区域的. 在Linux系统中, 可以很简单的观察到:

    readelf 指令可以用来查看文件的某些信息

那么程序内部的这些地址、区域有什么作用呢?
程序没有被加载到内存中时, 程序内部的地址和区域是按照一定的相对关系划分的. 就像上图那样, 当前区域编号是什么, 此区域的名字是什么是存储关于什么内容的, 类型是什么, 区域地址是什么, 与首地址相比偏移量是多少, 区域大小是多少
知道了这些数据, 就可以知道当前的区域的的大小, 存储的内容, 区域的地址等信息:
此图就为readelf打印出的信息所列的表格, 虽然不够详细但是已经可以说明了此程序内数据的区域、地址信息等
其实将readelf打印出的信息, 排版整理之后可以得到程序本身的一个地址区域表, 就像一个地址空间一样, 包含程序数据分布的各种信息
这张记录了程序本身数据分布地址的表格, 是从0000地址开始的. 其实编译器在编译链接源文件时, 会默认认为程序就是按照0000~FFFF(全0~全F)编址的
当程序运行时, 操作系统会根据程序本身的这个地址区域表 将程序的数据加载到内存中.
在操作系统根据程序的地址区域表, 将程序的数据加载到内存的这个过程中, 程序会认为其加载的这块内存也是从全0开始~全F结束的:
此时就操作系统根据程序中的数据地址和实际内存地址计算出了相应的虚拟地址(只是可能, 具体算法要看操作系统), 直到程序中所有数据全部加载到内存中, 操作系统创建进程地址空间, 同时根据虚拟地址和实际地址创建页表:
进程地址空间与也变创建完毕之后, CPU向内存访问数据的时候遇到的地址就是经过计算的虚拟地址, 如果修改了数据, 进程地址空间就会通过页表找到实际的内存物理地址将内存物理地址中的数据进行修改, 完成一系列的数据访问.

程序本身拥有的其数据即代码的相对分布地址, 在程序加载到内存中变为进程的过程中起到了非常至关重要的作用

所以, 虚拟地址空间不仅仅操作空间会考虑, 其实编译器也是需要考虑的, 因为编译器需要创建的是程序自身的程序地址空间

为什么要存在进程地址空间

我们知道了C/C++中使用的地址、存储变量的地址并不是实际的内存物理地址, 知道了操作系统会针对每一个进程维护一个进程地址空间, 也知道了虚拟地址与实际的内存物理地址存在映射关系. 而这种种现象, 好像透露出一件事情:操作系统在限制进程直接访问物理地址
首先要了解一个关于硬件的常识:硬件本身是不会限制软件访问的!硬件的地址只能被动的被读取和写入
也就是说, 如果操作系统不对进程访问物理地址进行限制, 那么进程很有可能访问到其他进程已经占用的地址, 或者访问到硬件本身数据的地址, 这是非常可怕的!一个不小心硬件就出问题了.
也就是说, 为了系统的安全以及硬件的安全, 所以才会存在进程地址空间
而且, 进程地址空间对每个进程来讲其实是非常有好处的. 存在进程地址空间, 也就将各个进程之间隔离开了, 各个进程之间无法互相访问, 互相影响当进程之间不主动互相影响, 也不会互相影响的时候, 程序设计时就不需要考虑更多的东西, 可以使各种程序以一种统一的视角认识内存, 可以方便编译运行加载也保证了进程间的安全 就像上面父子进程的例子, 如果子进程修改global_Var时其实修改的是物理地址的数据, 那么势必会影响到父进程的数据, 显然这对两个进程来说是不合理的, 也是很危险的
还有就是, 操作系统针对每个进程都会创建一个虽然内容不同但是结构相同的进程地址空间, 也就是说对操作系统来说 每个进程的数据、代码包括各种属性虽然内容不同, 但是其结构是相同的(即操作系统针对每个进程维护的task_struct(PCB) 和 mm_struct(进程地址空间)). 每个程序的结构都相同, 这对操作系统来说是非常方便管理的.
总的来讲, 进程地址空间的存在大致会带来三个方面的好处:
  1. 保护硬件, 可以为系统及硬件提供更安全的进程服务
  2. 保护进程, 以及方便编译器编译及操作系统加载
  3. 方便操作系统管理、维护每个进程