目录
前言
路漫漫其修远兮,吾将上下而求索;
一、C/C++ 内存空间布局
验证空间布局:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int g_unval;//全局未初始化变量
int g_val = 10;//全局初始化变量
int main(int argc , char* argv[] , char* env[])
{
const char* str = "hello world!";
printf("code addr:%p\n" , main);
printf("init global addr:%p\n" , &g_val);
printf("uninit global addr:%p\n" , &g_unval);
static int cnt = 10;
char* heap_men1 = (char*)malloc(10);
char* heap_men2 = (char*)malloc(10);
char* heap_men3 = (char*)malloc(10);
printf("headp addr: %p\n" , heap_men1);
printf("headp addr: %p\n" , heap_men2);
printf("headp addr: %p\n" , heap_men3);
printf("test static addr:%p\n" , &cnt);
printf("stack addr:%p\n" , &heap_men1);
printf("stack addr:%p\n" , &heap_men2);
printf("stack addr:%p\n" , &heap_men3);
return 0;
}
注:这也可以理解为什么静态变量的作用域没变而其生命周期变长了,因为静态变量存储在全局;
上述所打印出来的空间并非是物理地址,而是虚拟地址;
注:程序地址空间(语言上的叫法)又称进程地址空间、虚拟地址空间;
我们先来看一段代码来初步理解虚拟地址空间:
#include<stdio.h>
#include<unistd.h>
//全局变量
int g_val = 100;
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
while(1)
{
printf("子进程:pid: %d , ppid : %d , g_val: %d , &g_val: %p\n", getpid() , getppid() , g_val , &g_val);
sleep(1);
}
}
//父进程
while(1)
{
printf("父进程: pid: %d , ppid: %d , g_val: %d , &g_val: %p\n" , getpid() , getppid() , g_val , &g_val);
sleep(1);
}
return 0;
}
fork 之后,父子进程数据、代码共享;倘若一方修改便会发生写实拷贝:
#include<stdio.h>
#include<unistd.h>
//全局变量
int g_val = 100;
int main()
{
pid_t id = fork();
if(id == 0)
{
//子进程
while(1)
{
printf("子进程:pid: %d , ppid : %d , g_val: %d , &g_val: %p\n", getpid() , getppid() , g_val , &g_val);
g_val++;//子进程中修改g_val
sleep(1);
}
}
//父进程
while(1)
{
printf("父进程: pid: %d , ppid: %d , g_val: %d , &g_val: %p\n" , getpid() , getppid() , g_val , &g_val);
sleep(1);
}
return 0;
}
在上述例子中,为什么变量g_val 地址明明相同,但是却查出了不同的值;如若此地址是物理地址,父子进程查询g_val 的值一定是相同的,那么可以肯定,上述我们看到的地址一定不是物理地址;
实际上,这就是我们本篇博文所要讲的:虚拟地址;之前我们用C/C++语言所看到的地址均为虚拟地址,而物理地址用户无法看见,物理地址由操作系统统一进行管理;
二、什么是虚拟地址空间
以32位机器为例:
注:用户空间:程序员可以直接使用地址来进行访问的空间;
虚拟地址中所有的虚拟地址都要与物理地址进行一一映射;物理内存中也有自己的“编号”;此时就需要在内存当中建立一种数据结构:页表,用来进行从虚拟地址到物理地址的映射;
上层使用的均为虚拟地址,拿着虚拟地址去访问数据的时候首先会经过页表将虚拟地址转换为物理地址,才可以访问该数据;也就是说,进程在访问内存的时候,要进行虚拟地址到物理地址的映射,找到物理内存才可以访问物理内存中的数据;
从现在看来,当将磁盘中的代码和数据加载进内存的时候,操作系统并非只创建了tast_struct 这样的数据结构,还创建了虚拟地址空间(从全0到全F的地址范围,依次从低地址向高地址增长);
那么对于,fork 之后不做任何修改,父子进程代码和数据共享,是因为:
后来,子进程对数据进行了修改,会发生写时拷贝:
当子进程对数据进行修改引发写时拷贝:子进程开辟新空间、拷贝原父进程中数据内容到新空间中、更改子进程页表中虚拟地址到物理的址的映射关系;整个过程只是影响了子进程页表中的物理地址,而并未影响其虚拟地址,即父子进程中g_val 的虚拟地址相同而物理地址不同,所以子进程中 g_val++, 子进程中g_val 一直在变化,而父进程中的g_val 没有发生改变;
同样也可以回答,在上述例子中,fork 之后,为什么 id 既等于0又大于0:
- 变量id 也有自己的虚拟地址,而fork 函数内部有 return ;return 的本质是对变量id 做写入,即“修改”,而fork 的目的是创建子进程,倘若需要返回了就说明fork 完成了其功能,即fork return 时会发生写实拷贝,所以对于父子进程来说其id 不同,让用户感觉“id 既等于0又大于0”;
三、如何理解空间划分
在32位机器下,虚拟地址空间本质上是OS 为进程画的“大饼”(即虚拟地址空间),每个进程均有task struct ,但每个进程看待物理内存的时候,均认为自己拥有4GB的空间;而进程很多,每个进程都有一张OS给的“大饼”,OS需要对这些“饼”(虚拟地址空间)进行管理,即需要描述与组织进程的虚拟地址空间,那么对虚拟地址空间的管理就会转换为对数据结构的增删查改;
描述虚拟空间的结构体为 mm_struct;
每个进程只有⼀ 个mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该进程的虚拟空间结构, 这样每一个进程都会有自己独立的地址空间得以互不干扰;
源码:
struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
struct rb_root mm_rb;
struct vm_area_struct * mmap_cache; /* last find_vma result */
unsigned long (*get_unmapped_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
void (*unmap_area) (struct mm_struct *mm, unsigned long addr);
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) */
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
*/
/* Special counters, in some configurations protected by the
* page_table_lock, in other configurations by being atomic.
*/
mm_counter_t _file_rss;
mm_counter_t _anon_rss;
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 */
unsigned dumpable:2;
cpumask_t cpu_vm_mask;
/* Architecture-specific MM context */
mm_context_t context;
/* Token based thrashing protection. */
unsigned long swap_token_time;
char recent_pagein;
/* coredumping support */
int core_waiters;
struct completion *core_startup_done, core_done;
/* aio bits */
rwlock_t ioctx_list_lock;
struct kioctx *ioctx_list;
};
而区域划分,就需要有变量来记录该区间开始和结尾的位置来表明一段范围,即虚拟空间对应的内核数据结构mm_struct 中存在大量的int start , int end 来记录不同区域;
源码截图:
mm_struct 与 task_struct 的关系:
Q:虚拟地址空间将堆区当作了大整体,就只能知道对空间的起始地址,可堆空间中有许多分块的地址,又该如何管理?
- 用vm_area_struct管理;创建更多的vm_area_struct ,便可以用其中的start、end 对堆空间进一步细分;
vm_area_struct 源码:
struct vm_area_struct {
struct mm_struct * vm_mm; /* The address space we belong to. */
unsigned long vm_start; /* Our start address within vm_mm. */
unsigned long vm_end; /* The first byte after our end address
within vm_mm. */
/* linked list of VM areas per task, sorted by address */
struct vm_area_struct *vm_next;
pgprot_t vm_page_prot; /* Access permissions of this VMA. */
unsigned long vm_flags; /* Flags, listed below. */
struct rb_node vm_rb;
/*
* For areas with an address space and backing store,
* linkage into the address_space->i_mmap prio tree, or
* linkage to the list of like vmas hanging off its node, or
* linkage of vma in the address_space->i_mmap_nonlinear list.
*/
union {
struct {
struct list_head list;
void *parent; /* aligns with prio_tree_node parent */
struct vm_area_struct *head;
} vm_set;
struct raw_prio_tree_node prio_tree_node;
} shared;
/*
* A file's MAP_PRIVATE vma can be in both i_mmap tree and anon_vma
* list, after a COW of one of the file pages. A MAP_SHARED vma
* can only be in the i_mmap tree. An anonymous MAP_PRIVATE, stack
* or brk vma (with NULL file) can only be in an anon_vma list.
*/
struct list_head anon_vma_node; /* Serialized by anon_vma->lock */
struct anon_vma *anon_vma; /* Serialized by page_table_lock */
/* Function pointers to deal with this struct. */
struct vm_operations_struct * vm_ops;
/* Information about our backing store: */
unsigned long vm_pgoff; /* Offset (within vm_file) in PAGE_SIZE
units, *not* PAGE_CACHE_SIZE */
struct file * vm_file; /* File we map to (can be NULL). */
void * vm_private_data; /* was vm_pte (shared mem) */
unsigned long vm_truncate_count;/* truncate_count or restart_addr */
#ifndef CONFIG_MMU
atomic_t vm_usage; /* refcount (VMAs shared if !MMU) */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
};
所以:
由上图可以清晰地知道,虚拟地址空间使用 mm_struct 、vm_area_struct,一个宏观统计,一个细粒度划分,便可以构建出一个进程看待物理内存的方案,本质上就是在mm_struct 后面增加更多的vm_area_struct;而在vm_area_struct 中有vm_page_prot来记录访问权限,即用VM_READ、VM_WRITE、VM_EXEC来表示指定某个区域的权限;所以当你拿着虚拟地址做操作时,OS会首先根据你的虚拟地址判断你在哪一个区域,是否有对应的权限,如果没有权限,OS就会拦截你;
虚拟空间的组织方式有两种:
- 1、当虚拟区间较少时采用单链表,由 mmap 指针指向这个链表;
- 2、当虚拟区间较多的时候采用红黑树进行管理,由 mm_rb 来指向这棵树;
在访问虚拟地址空间的时候,大部分时间在查找,而一个数据结构可以既属于链表也属于其他运行队列……当虚拟空间块多了的时候,变可采用红黑树来管理,使用红黑树可以大大提高查找效率;
linux 内核中使用 vm_area_struct 结构来表示一个独立的虚拟空间区域(VMA), 由于不同的虚拟内存区域功能和内部机制都不相同,因此一个进程使用多个 vm_area_struct结构来分别表示不同类型的虚拟内存区域。上面所提到的两种组织方式使用的就是vm_area_struct 来连接各个VMA , 方便进程快速访问;
因为进程被维护成了链表,那么变相地将所有 mm_struct 也维护成了链表,mm_struct 虚拟地址空间,随着进程的存在而存在,随进程的销毁而销毁;因为stak_strcut 指向当前进程的地址空间,所以我们只要把task_struct(pcb) 管理好了,也是将虚拟地址空间(mm_struct)管理好了;
注:页表,可以通过mm_struct 中的 pgd_t * pgd;找到;
进一步理解fork:
- fork 之后,子进程会以父进程为模板(即拷贝父进程的task_strcuct、虚拟地址空间mm_struct 、页表……,并修改子进程pid 等属性);正是因为子进程的task_struct、mm_struct、页表均是拷贝父进程的,而页表中存储着虚拟地址到物理地址的映射关系,所以在二者均未修改的情况下,子进程与父进程指向相同的物理空间,共享同一份代码与数据;而在父、子进程一方发生了修改的时候,OS底层会进行写时拷贝,将修改的数据与原始数据做分离,即父子进程可以拿到相同“虚拟地址空间”中不同的数据了;
Q:如何理解进程具有独立性?
- 进程 = 内核数据结构(task_struct ,mm_struct ,页表) + 进程代码和数据 ;一旦父、子进程的一方对数据进行修改,就会引发“写实拷贝”,即OS将两个进程数据层面进行分离;
Q:如何理解代码区只读、已初始化数据区、未初始化数据区可读可写?
- 页表中有一列信息代表映射对应目标内存中指向数据的权限信息,若是代码其的数据,其对应的权限为r;同理……
我们再测试一下命令行参数、环境变量的地址:
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int g_unval;//全局未初始化变量
int g_val = 10;//全局初始化变量
int main(int argc , char* argv[] , char* env[])
{
const char* str = "hello world!";
printf("code addr:%p\n" , main);
printf("init global addr:%p\n" , &g_val);
printf("uninit global addr:%p\n" , &g_unval);
static int cnt = 10;
char* heap_men1 = (char*)malloc(10);
char* heap_men2 = (char*)malloc(10);
char* heap_men3 = (char*)malloc(10);
printf("headp addr: %p\n" , heap_men1);
printf("headp addr: %p\n" , heap_men2);
printf("headp addr: %p\n" , heap_men3);
printf("test static addr:%p\n" , &cnt);
printf("stack addr:%p\n" , &heap_men1);
printf("stack addr:%p\n" , &heap_men2);
printf("stack addr:%p\n" , &heap_men3);
printf("read only string addr: %p\n" ,str);//字符串常量
for(int i = 0;argv[i];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;
}
基于此,我们可以得到三点结论:
1、定义全局变量,全局有效;因为只要虚拟地址空间存在,那么全局数据区就会存在,而虚拟地址空间的生命随进程,所以全局变量会一直存在与进程的运行期间,包括static静态变量;
2、字符串常量其实和代码是编译在一起的,因为代码为只读权限,所以字符串常量也是只读;(字符常量区被页表映射的时候,有权限的约束,不让写入操作进行转换;而对于const 修饰的字符串,只是用const 来约束编译器,让编译器进行写入的检查,如果有“写入”操作,就会在编译层面上报错,而非运行时报错)
这也正可以回答为什么字符常量区不可以被修改,当我们拿着字符串常量区的虚拟地址尝试修改其中的数据时,首先需要经过页表将虚拟地址转换为物理地址,而在转换的过程中,会做权限的检查,当发现你对于此地址的权限只能是只读操作,但你非要去写入,权限不匹配,那么虚拟地址转物理地址就会失败;OS意识到你的违法操作,就会将此进程“杀”掉,我们就会看见报错,这个报错是运行时报错(运行时报错均是进程出异常);
3、命令行参数和环境变量属于父进程地址空间内的数据资源,和代码区数据一样;子进程会继承父进程的地址空间,所以子继承也能看到父进程的命令行参数与环境变量;
四、为什么要有虚拟地址空间
在计算机早期发展的时候,是可以直接访问内存的,有了虚拟地址空间之后,相当于在进程与物理内存之间添加了一层软件层;想要访问物理内存中的数据,必须是由虚拟地址转换成物理地址才可以访问……
理由一:保证物理内存的安全,维护进程独立性特性;
- 物理内存最终是存储代码和数据的硬件,所以有了虚拟地址就必须将其转换为物理地址,而当你在进行转换的时候就会进行安全审核,此时在一定程度上可以保护物理内存的安全,况且,如果直接访问物理内存,一个进程处出了问题可能会影响另外一个进程,不满足进程具有独立性的特性;
理由二:虚拟地址空间让“无序”变为“有序”
- 我们的可执行程序会被加载到物理内存的任意位置,虽然有些内存被OS占着,有些内存是OS涉及的缓存区……但是只要有空间,理论上就可以在物理内存上开辟仍以空间来使用;且将来在加载代码、数据的时候,有可能只加载了一部分,剩余的代码、数据会随后再加载到内存之中,只要重新创建虚拟地址到物理地址的映射关系就可以了;在虚拟地址空间中,局部上相同属性的数据会存放在一起,宏观上一定会按照这样的顺序排布……站在进程的角度,如果没有虚拟地址空间,将来代码、数据在物理内存中加载时,该进程就需要将代码、数据加载的位置全部记录下来,记录的位置一定是乱的;但是有了虚拟地址空间,编译器会进行“统一编址”,于是乎就将“无序”的地址变为“有序”的地址;
理由三:将内存管理与进程管理解耦
- 创建一个进程是先有内核数据结构,然后再加载进程的代码和数据;并且,创建一个进程的时候,不一定会立即加载这个进程的代码、数据到内存之中,甚至可以在创建该进程时,一行代码、数据都不加载;让进程pcb 去调度队列中排队,当轮到该进程调度时,再边执行进程代码边从外设中加载该进程的代码和数据,这种加载方式称为“惰性”加载,惰性加载可以提高内存的使用率;将外设上的数据加载到磁盘之前,需要先申请物理内存,申请好了之后再加载代码、数据,将在加载的过程之中,进行物理内存申请的模块称为内存管理模块;而创建进程pcb、地址空间、构建页表映射……属于进程管理的范畴;进程运行时需要内存,进程对于内存的申请通过页表的映射与物理内存关联了起来,在一定程度上就可以将内存管理与进程管理进行解耦;那么进程想要调度执行,拿着虚拟地址去调度即可,OS会自动开辟空间,将该进程的数据、代码加载进物理内存中,修改页表的映射关系,进程在调度的时候并不会知道也不用知道自己代码、数据是否加载进物理内存中,只需要按虚拟地址依次执行便可,需要什么地址,物理内存管理模块会自动进行管理;
总结
1、C/C++语言所看到的地址均为虚拟地址,而物理地址用户无法看见,物理地址由操作系统统一进行管理;
2、虚拟地址中所有的虚拟地址都要与物理地址进行一一映射;物理内存中也有自己的“编号”;此时就需要在内存当中建立一种数据结构:页表,用来进行从虚拟地址到物理地址的映射;
3、描述虚拟空间的结构体为 mm_struct;每个进程只有⼀ 个mm_struct结构,在每个进程的task_struct结构中,有⼀个指向该进程虚拟空间的结构mm_struct , 这样每一个进程都会有自己独立的地址空间得以互不干扰;而在mm_struct 中存在结构体 vm_area_struct 来支持区域的划分;
4、为什么要有虚拟地址空间
- 保证物理内存的安全,维护进程独立性特性;
- 虚拟地址空间让“无序”变为“有序”
- 将内存管理与进程管理解耦