进程地址空间
因为 window 像是 VS 会对这些空间进行一系列的操作,导致我们设置的变量看上去不遵循以下的规则,所以使用 Linux 做演示
进程地址空间不是我们常说的内存
地址空间
引子
有这样一段代码:
#include<unistd.h>
#include<stdio.h>
int g_val = 100; //设置一个全局变量
int main(){
pid_t ret_id = fork(); //创建子进程
if(ret_id == 0){
int count = 0;
while(1){
printf("I'm child progress id:%d g_val: %d &g_val:%p\n",getpid(), g_val, &g_val);
sleep(1);
count++;
if(count == 2){
g_val = 200;
printf("child change g_val 100 -> 200\n");
}
}
}
else{
while(1){
printf("I'm father progress id:%d g_val: %d &g_val:%p\n",getpid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
输出结果显示,父子进程 g_val 变量共用一块地址,但在子进程修改 g_val 后父进程打印的 g_val 却未发生改变(同一个地址在同时读取时竟读到了不同的值)
- 同时读取到的变量内容不同,所以父子进程输出的变量绝对不是同一个变量
- 但地址值是一样的,说明,该地址绝对不是物理地址(不是硬件上具体的某个位置)
● 由此,我们引出虚拟地址(线性地址)的概念
虚拟地址(线性地址)
● 我们学习的语言中所谓 “地址” 的概念,基本指的都是虚拟地址
● 不止CPU 有寄存器,磁盘、网卡等外设一样有寄存器,但他们结构各不同,实际各部件摆放的位置(物理地址)散布各处,显然不是线性结构。
而现在,计算机将各硬件的寄存器也好,内存也好,全都看作内存,通过虚拟地址去映射它们的位置。那么之后要写入读取时就不需要用散布各处的物理地址,而是用线性排布的虚拟地址
● 没有虚拟地址(以前确实如此),有什么坏处
其一:用户直接访问物理地址,那就是想访问哪里就访问哪里(毕竟物理内存本身可以被随时读写),如果出现野指针问题……不安全
验证地址空间(虚拟地址空间)分布
写完才发现前面的图里写的差不多了……烦内
#include<stdio.h>
#include<stdlib.h>
int g_unval;//未初始化
int g_val = 100;//初始化
int main(int argc, char* argv[], char* env){
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
char* heap_mem1 = (char*)malloc(10);
char* heap_mem2 = (char*)malloc(10);
char* heap_mem3 = (char*)malloc(10);
printf("heap addr1: %p\n", heap_mem1);
printf("heap addr2: %p\n", heap_mem2);
printf("heap addr3: %p\n", heap_mem3);
printf("stack addr1: %p\n", &heap_mem1);
printf("stack addr2: %p\n", &heap_mem2);
printf("stack addr3: %p\n", &heap_mem3);
int i = 0;
for(i=0;i < argc; i++){
printf("argv[%d]:%p\n", i, argv[i]);
}
for (i = 0; env[i]; i++) {
printf("env[%d]: %p\n", i, env[i]);
}
return 0;
}
-
为什么动态内存开辟 10 字节,堆中的变量却相隔了 32 字节:
多出来的空间用于记录本次申请的属性(像是开空间时可以直接从命令中获取 开10字节 的信息,释放空间时则需要依靠这些属性去释放相应大小的空间) -
关于常量
如果我们再加入一段代码:
const char* str = "12345678";
printf("string constant: %p\n",str);
可以看到该字符串常量的位置和 main 函数地址相近
● 常量所处位置也在代码区(而且代码、常量均具有只读性)
虚拟地址与物理地址
● 进程地址空间(虚拟地址空间)如何管理才能便于进程使用?—— 先描述后组织
即进程地址空间本质是一种数据结构 mm_struct:
框出的部分为对其中区域的划分
进程的虚拟地址与物理地址的关系:
虚拟地址空间存在的意义
1. 禁止非法访问
● 有了地址空间,非法的访问或映射会被 OS 识别到并终止该进程(虚拟地址空间、页表是由 OS 创建并维护的,所以想要使用地址空间和页表就一定在 OS 的监管之下)
2. 解耦合
● 因为有地址空间与页表映射关系的存在,数据可以加载到物理内存的任意位置(操作系统只管访问虚拟地址空间,具体的物理地址是什么不重要,这是页表的事),这就使得进程管理模块和内存管理模块之间没有关系,完成了解耦合(各模块之间关联越少,耦合度越低,维护成本越低)
进而让进程管理、内存管理能够独立设计
在C、C++语言上new、malloc空间时,本质是在哪里申请 —— 虚拟地址空间
如果直接在物理内存上申请空间,如果不马上使用,则造成空间浪费
但有了地址空间后,我们在地址空间上申请,直到我们真正使用(访问物理地址空间)时,才执行内存相关算法,申请内存空间,构建页表映射关系
——延迟分配的策略,提高了整机的效率(通过 “缺页中断” 实现)
3. 实现进程独立性
● 物理内存理论上能进行任意位置的加载,所以物理内存中的代码、数据基本都是乱序
但因为有页表的存在,从进程视角看,内存(在虚拟地址上)分布是有序的
一方面,这实现了上面的解耦合(内存乱关我进程地址空间什么事)
另一方面,不同进程访问内存时:
因为地址空间的存在,每个进程都认为自己有 4GB 大小空间(32位),且各区域有序
只要保证每一个进程的页表,映射的是物理内存的不同区域,就能实现进程间互不干扰
相关问题
1. 为何之前父子进程在同一地址读取到了不同的值
子进程会继承父进程的大部分属性(包括地址空间和页表),所以刚开始父子进程访问的是同一个值。但当子进程想要修改该变量时,为保证进程的独立性,页表的映射关系会发生改变(映射到了另一块物理内存),而原本的虚拟地址不会变化,于是出现了上述现象
而这种直到修改变量时才去修改映射关系以改变数据的操作称为:写时拷贝
2. fork 如何返回给一个变量两个不同的值
pid_t id = fork();
在 return 之前父子进程就已创建完毕,所以 return 的本质,即是对 id 进行写入
发生写时拷贝,父子进程各自在物理内存中有属于自己的变量空间,只是在用户层用了同一个变量(虚拟地址)来标识
3. 可执行程序的虚拟地址
程序内部有地址码?
有,程序内部需要地址标定代码中的逻辑关系、函数入口
所以不仅是操作系统,编译器同样遵循地址空间的分布。在编译代码时,编译器就已经形成了各区域,给每一个变量、每一行代码进行了编址(程序在编译时,每一个字段就已具有了一个虚拟地址)
程序加载到内存时,这些虚拟地址则会映射到物理内存上
我们写的程序调用函数时需要通过函数名(函数地址)跳转到相应函数去执行,那么程序运行时当 CPU 读到该位置时,这是个物理地址还是虚拟地址?
—— 虚拟地址,原本编译好的虚拟地址不会变化,CPU调用无非是通过该地址跳转到相应的位置(虚拟地址),再通过映射寻找物理地址