[操作系统] 深入进程地址空间
程序地址空间回顾
在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 // 正文代码 main()
init global addr: 0x601034 // 未初始化全局变量
uninit global addr: 0x601040 // 初始化的全局变量
heap addr: 0x1791010 // 堆:向上增长 ↑
heap addr: 0x1791030
heap addr: 0x1791050
heap addr: 0x1791070
test static addr: 0x601038 // static int 类型
stack addr: 0x7ffd0f9a4368 // 栈:向下增长 ↓
stack addr: 0x7ffd0f9a4360
stack addr: 0x7ffd0f9a4358
stack addr: 0x7ffd0f9a4350
read only string addr: 0x400800 // const char *str
argv[0]: 0x7ffd0f9a4811
env[0]: 0x7ffd0f9a4819
env[1]: 0x7ffd0f9a482
env[2]: 0x7ffd0f9a4845
env[3]: 0x7ffd0f9a4850
env[4]: 0x7ffd0f9a4860
env[5]: 0x7ffd0f9a486e
通过地址结果的验证可以明确:
- 堆向上增长,随着创建申请空间,空间地址逐渐变大。
- 栈向下增长,随着创建变量,变量空间地址逐渐变小。
const char*
的最字符串常量地址与正文代码的地址相近,说明在编译的时候会将该类型硬编到正文代码中,所以形成了代码只可读。- 函数内部的
static
类型的变量地址与初始化数据中全局变量地址相近,因为static
类型的变量在编译时就会在初始化数据区域,所以就会作为全局变量。则static
是全局属性。
虚拟地址
实际上程序的地址空间是内存吗?
地址空间不是内存地址,而是虚拟地址!
在语言层上,我们会叫做程序地址空间。但是在系统层面上,会将其称为进程地址空间或者虚拟地址空间。
可以通过以下代码来验证:
#include<stdio.h>
#include <unistd.h>
int gval = 100;
int main()
{
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;
}
结果如图:
父子进程按照代码逻辑进行运行,随着每次睡眠过后子进程的全局变量就会+1
。但是通过结果可以发现,父进程和子进程各自的全局变量地址都是0x601054
。明明是同一个地址空间,为什么全局变量gval
的值不同呢?
这就证明了,进程的地址空间一定不是内存地址,不是物理上的地址,而是虚拟地址!我们在程序中使用指针指向的地址,以及取变量地址等操作,实际上都是在访问虚拟地址。
物理地址一般不会向用户展示,操作系统会将虚拟地址转化成物理地址,虚拟地址由操作系统统一管理。
进程地址空间
基础概念
每个进程都有其虚拟地址空间mm_struct
和页表存在于task_struct
中,每个地址空间1
字节。所以对于32位的机器,在虚拟地址空间中共有2^32
个地址空间,64位机器则有2^32
地址空间。
页表中存储的是虚拟地址和物理地址的映射关系。
程序在运行时实际上管理的是虚拟地址空间中的地址,当程序需要进行管理一个地址的时候,操作系统会将该地址在页表中进行查找,就可以得到与其对应映射的物理内存地址。然后操作系统会对物理内存地址的数据进行访问管理。
子进程会继承父进程的虚拟地址空间和页表。
如何通过一个字节地址访问多个字节大小的数据?
通过地址和类型偏移量确定整个数据。
假设存在一个int
变量a
,当我们通过虚拟地址空间的映射找到物理地址后,会通过int
类型在结构体中的偏移量进行确定整个数据内容,因为所有的数据都是通过先描述后组织进行管理,通过对应的数据结构就可以确定数据的位置。
进程如何独立
子进程的虚拟地址空间和页表会继承父进程,那么进程之间是怎么独立的呢?
假设父进程存在一全局变量int g_val
,在当前父进程虚拟地址在页表中已经与物理地址映射。然后创建子进程,当子进程中尝试对g_val
修改时操作系统会进行以下操作:
- 在物理地址空间中会重新开辟一块
int
大小的空间,在此空间内存储修改后的地址。 - 在页表中查询子进程虚拟地址空间中
g_val
虚拟地址,然后将新开辟的物理地址与虚拟地址重新建立映射关系。 - 此时,因为继承的关系,父进程与子进程中的
g_val
使用的是同一个虚拟地址,但由于子进程对g_val
进行修改,所以同一个变量的虚拟地址映射的是不同的物理地址。
这就是写时拷贝的机制!!
所以发生写时拷贝后,子进程对于修改的数据会重新构建映射关系,而其他的数据、代码、变量等都是共享的物理资源,这也避免了重复拷贝的内存的浪费,减少创建时间。
**通过这种机制就形成了进程的独立! **
虚拟地址与进程地址空间关系
通过上文可知,对于32位的机器来说,每个进程的虚拟地址空间有2^32
字节的大小,也就是4个G。但如果整个内存只有4G的话,那么一个进程就要把所有的内存空间全部占满吗?显然不可能。
如其名,虚拟地址空间并不是真正的内存空间。操作系统会让每个进程都认为他们可以独占物理内存,但是在实际使用的时候会根据真实的需求通过映射关系开辟内存空间。
虚拟地址空间如何从物理内存划分
由于进程不会独占物理内存,那么肯定有相对应的划分管理方法。
虚拟地址的本质:结构体对象,数据结构!
mm_struct
中存储的起始地址和结束地址用int
表示。- 每个区域的范围是
[start_address, end_address]
,这些地址用int
记录下来。例如:
struct mm_struct {
int code_start; // 代码段起始地址
int code_end; // 代码段结束地址
int heap_start; // 堆起始地址
int heap_end; // 堆结束地址
int stack_start; // 栈起始地址
int stack_end; // 栈结束地址
};
虚拟地址通常是用 int(4字节,32位) 类型存储的,而每个 int
值就直接对应一个地址。虚拟地址空间中的地址可以用一个 int
值表示,因为 int
的取值范围足够覆盖整个虚拟地址空间的范围(0 ~ 232−12^{32} - 1232−1,即 4GB)。
在32为机器中虚拟地址由2^32字节空间,每个区域(栈,堆…)都有自己确定的区域,然后堆所有的区域进行编址。虚拟地址空间就是结构体mm_struct
,里面存放的就是每个区域的起始地址和结束地址对应的int
值。
区域调整
既然每个区域的大小是用int
值进行确定,那么当需要对这个区域大小进行调整的时候,区域调整就是对起始和结束的整数范围进行调整。
根据各个区域的特性,例如堆向上增长,栈向下增长,将其对应的start
和end
进行+
或者—
,以此来进行区域的调整。
小结:虚拟地址空间是什么
操作系统需要对进程中的虚拟地址空间进行管理,虚拟地址空间是内核中的一种数据结构mm_struct
,大部分属性都是各个区域的开始和结束地址的int
值。
先描述,在组织。作为数据结构,操作系统不仅会对进程进行管理,也会对mm_struct
进行管理,用链表进行管理。但是实际上通过PCB也可以直接访问到mm_struct
。