🫙 前言
在c/C++中存在一种内存的概念;
一般来说一个内存的空间分布包括栈区
,堆区
,代码段
等等;
且内存是自底向上(由0x00000000
至0xFFFFFFFF
);
以该图为例:
该图即为常见的内存分布图;
- 正文代码段
正文代码段所存放的数据一般为函数体的二进制代码; - 已初始化数据区
已初始化数据区所存放的数据是在程序中声明的,并且具有初始值的变量,这些变量需要占用存储器的空间; - 未初始化数据区
未初始化数据区所存放的数据是没有进行初始化或者初始值为0的数据,这些数据在存储时不需要额外占用存储器的空间; - 堆
堆空间一般为动态空间,即需要成需要手动分配释放;若是分配了堆区空间但使用过后未对堆空间进行手动释放则将会出现内存泄漏的问题; - 栈
一般情况下栈所存放的数据基本上都为局部变量; - 命令行参数/环境变量
命令行参数/环境变量,顾名思义该段空间用来存放OS给程序所传递的命令行参数与环境变量; - 内核空间
在Linux操作系统当中,内存的分布一般为其中3G为用户空间,1G为内核空间;
以下操作均在CentOS7_x64环境下进行 |
存在一个程序 ( mytest )
:
int init = 10; int uninit; int main(int argc,char *argv[],char *env[]) { char*ch1= new char[10]; char*ch2= new char[10]; char*ch3= new char[10]; char*ch4= new char[10]; char*ch5= new char[10]; printf("init : %p\n",&init);//已初始化数据 printf("uninit : %p\n",&uninit);//未初始化数据 printf("text : %p\n",main);//正文代码段 cout<<"--------------"<<endl; //堆区 printf("heap1 : %p\n",ch1); printf("heap2 : %p\n",ch2); printf("heap3 : %p\n",ch3); printf("heap4 : %p\n",ch4); printf("heap5 : %p\n",ch5); cout<<"--------------"<<endl; //栈区 printf("stack1 : %p\n",&ch1); printf("stack2 : %p\n",&ch2); printf("stack3 : %p\n",&ch3); printf("stack4 : %p\n",&ch4); printf("stack5 : %p\n",&ch5); cout<<"--------------"<<endl; //命令行参数 for(int i = 0;i<argc;++i){ printf("argv[%d] : %p\n",i,argv[i]); } cout<<"--------------"<<endl; //环境变量 for(int i = 0;env[i];++i){ printf("env[%d] : %p\n",i,env[i]); } return 0; }
从这段代码中可以打印出内存中不同数据的内存分布情况;
但实际上在OS层面中,这些所谓的内存并非物理内存;
🫙 进程地址空间是什么
在上文中说到,进程所访问的地址并不是物理地址;
存在一个程序(证明):
using namespace std; int tmp = 100; int main() { pid_t id = fork(); if(id == 0){ int s = 5; while(1){ cout<<"pid : "<<getpid()<<" ppid : "<<getppid()<<" tmp : "<<tmp<<" &tmp : "<<&tmp << endl; sleep(1); s--; if(!s) tmp = 200; } } else{ while(1){ cout<<"pid : "<<getpid()<<" ppid : "<<getppid()<<" tmp : "<<tmp<<" &tmp : "<<&tmp << endl; sleep(1); } } return 0; }
在该程序中定义了一个全局变量,并使用fork()
函数对该进程创建了一个子进程,同时分别在父子进程中打印该全局变量的值与地址;
pid : 28930 ppid : 28929 tmp : 100 &tmp : 0x60108c pid : 28929 ppid : 28812 tmp : 100 &tmp : 0x60108c pid : 28930 ppid : 28929 tmp : 200 &tmp : 0x60108c pid : 28929 ppid : 28812 tmp : 100 &tmp : 0x60108c
当五秒过后,子进程修改了全局变量的值;
可在父进程当中的这个全局变量并未被更改,且父子进程中所显示的这个全局变量tmp
的地址相同;
然而实际上,一个程序在运行的过程中所使用的内存地址为虚拟地址(线性地址);
在过去的计算机中,进程对于内存的访问是以直接访问的形式,即运行程序时程序载入至内存当中称为进程,CPU根据进程中的代码数据对内存的各个地址(物理地址)进行操作;
但是由于访问的是物理内存地址,所以若是程序在内存当中误操作则会导致某些进程的崩溃;
这种操作是十分不安全的操作;
所以为了保证安全性同时也保证进程间的独立性,现在的OS当中,出现了进程地址空间的概念;
每个进程都存在一个称为进程地址空间的数据结构(mm_struct
结构体);
在这个结构体当中以一种类似于区间的方式模拟出地址(在Linux2.6的版本中使用unsigned long类型实现)
;
/*释放线性区的调用方法*/ void (*unmap_area) (struct mm_struct *mm, unsigned long addr); #endif unsigned long mmap_base; /* base of mmap area ,内存映射区的基地址*/ unsigned long task_size; /* size of task vm space */ unsigned long cached_hole_size; /* if non-zero, the largest hole below free_area_cache */ unsigned long free_area_cache; /* first hole of size cached_hole_size or larger */ pgd_t * pgd; /* 页表目录指针*/ atomic_t mm_users; /* How many users with user space?,共享进程的个数 */ atomic_t mm_count; /* How many references to "struct mm_struct" (users count as 1),主使用计数器,采用引用计数,描述有多少指针指向当前的mm_struct */ int map_count; /* number of VMAs ,线性区个数*/ struct rw_semaphore mmap_sem; spinlock_t page_table_lock; /* Protects page tables and some counters,保护页表和引用计数的锁 (使用的自旋锁)*/ struct list_head mmlist; /* List of maybe swapped mm's. These are globally strung * together off init_mm.mmlist, and are protected * by mmlist_lock */ unsigned long hiwater_rss; /* High-watermark of RSS usage,进程拥有的最大页表数目 */ unsigned long hiwater_vm; /* High-water virtual memory usage ,进程线性区的最大页表数目*/ unsigned long total_vm, locked_vm, shared_vm, exec_vm; unsigned long stack_vm, reserved_vm, def_flags, nr_ptes; unsigned long start_code, end_code, start_data, end_data; /*维护代码区和数据区的字段*/ unsigned long start_brk, brk, start_stack; /*维护堆区和栈区的字段*/ unsigned long arg_start, arg_end, env_start, env_end; /*命令行参数的起始地址和尾地址,环境变量的起始地址和尾地址*/ unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */
除此之外在进程地址空间这个结构体中有一个指针,这个指针所指向的位置即为页表
;
所谓的页表就是一种映射关系,这种映射关系以一种key/value
的模型将对应的物理地址与虚拟地址进行一种存储,在查找或访问时将访问至虚拟地址,通过该虚拟地址通过页表的key/value
模型找到其对应的物理内存再进行访问;
在CPU中存在一个内存管理单元(MMU),这个内存管理单元是CPU中的一个模块,这个模块具体的作用为负责虚拟地址到物理地址的转换;
以该图为例,其中task_struct
表示PCB结构体
,即进程控制块;
mm_struct
即为该进程的进程地址空间,mm_struct
中的pgd
即为页表;
🫙 写时拷贝
当多个进程或线程共享同一块内存时,内核会使用写时拷贝来优化内存的复制行为;
即当有一个进程尝试修改共享内存页面时,Linux内核会触发写时拷贝机制;
它会为修改的进程创建一个新的私有副本,并将修改的内容写入新的副本中,而不是立即修改原始的共享页面;
以该例子为例:
using namespace std; int tmp = 100; int main() { pid_t id = fork(); if(id == 0){ int s = 5; while(1){ cout<<"pid : "<<getpid()<<" ppid : "<<getppid()<<" tmp : "<<tmp<<" &tmp : "<<&tmp << endl; sleep(1); s--; if(!s) tmp = 200; } } else{ while(1){ cout<<"pid : "<<getpid()<<" ppid : "<<getppid()<<" tmp : "<<tmp<<" &tmp : "<<&tmp << endl; sleep(1); } } return 0; }
在该例子中程序运行的结果为:
pid : 28930 ppid : 28929 tmp : 100 &tmp : 0x60108c pid : 28929 ppid : 28812 tmp : 100 &tmp : 0x60108c pid : 28930 ppid : 28929 tmp : 200 &tmp : 0x60108c pid : 28929 ppid : 28812 tmp : 100 &tmp : 0x60108c
两个进程中的变量的地址相同但其值不同的原因就是在于其所在的虚拟地址相同但页表中虚拟地址所映射的物理地址不同;
在这个程序当中,使用fork()
函数创建了子进程,由于子进程是由父进程创建的,所以对应的子进程的PCB结构体继承于父进程,即当父进程创建出一个子进程时,该子进程将会对父进程的PCB结构体进行一次浅拷贝,所以父子进程所对应的代码资源是共享的;
在只读的情况下两个进程的页表所映射至的物理地址也许相同的,而当一个进程要修改该物理内存中的内容时,OS将会重新在物理内存中申请一块空间,同时修改该进程所对应的页表映射关系;
🫙 可执行程序中的虚拟地址
实际在可执行程序当中也存在着所谓的虚拟地址,在一般的教材当中也被称为"逻辑地址";
存在一个程序:
#include<iostream> using namespace std; int g_val = 100; int main() { cout<<&g_val<<endl; return 0; }
这个程序运行之后可以打印出该程序中全局变量g_val
的地址;
在Linux中存在一个命令可以打印出一个可执行程序中的逻辑地址(虚拟地址),即objdump
;
语法:
objdump -x <executable_file>
在此处配合| grep
打印出该可执行程序中的虚拟地址,即:
objdump -x mytest | grep g_val
使用该命令后运行该程序:
$ objdump -x mytest | grep g_val 00000000004007f7 l F .text 0000000000000015 _GLOBAL__sub_I_g_val 000000000060105c g O .data 0000000000000004 g_val $ ./mytest 0x60105c
在上面的程序当中,程序运行的结果(打印全局变量地址)与使用objdump
所显示出磁盘中的全局变量g_val
的地址相同,由此可见其进程中的虚拟地址与本在磁盘中的虚拟地址相同;
实际上在计算机当中,本质上无论是磁盘中的虚拟地址(逻辑地址)还是在进程当中的虚拟地址都是相同的;
只不过是在进程与磁盘中的表现形式不同;
当程序编译链接完成时生成的可执行程序当中将会存在代码数据等,在这些代码数据当中存在着静态的虚拟地址,这些地址被称作逻辑地址;
当这个程序被执行后即被加载至内存当中成为进程时,进程将会去初始化自身的PCB结构体;相对应的PCB结构体内的各种数据结构也将要被进行维护与初始化;
磁盘中的虚拟地址(逻辑地址)将会初始化PCB结构体中对应的进程地址空间,使得进程地址空间中的虚拟地址与原本磁盘内的虚拟地址(逻辑地址)保持一致;
🫙 物理地址分布方式
在上面的图中可以发现:
在对进程地址空间进行初始化时,真正将虚拟地址与物理地址进行关联的时候,其物理地址并没有按照原本的虚拟地址原模原样的进行对应的初始化;
在对对应物理地址进行初始化时更像是以一种随机的方式;
为了物理内存的安全性,Linux中采用了一种地址空间随机化(ASLR)的一种内存攻击缓存技术;
当对应的进程地址空间的虚拟地址在初始化时通过页表映射至物理内存时将会采用这种方式;
使得对应进程的物理内存地址无法被预测,也保证了进程在运行时的安全性;