1. 进程地址空间的引出
我们在学习C语言的过程中,可能听说过这样的空间布局图:
但是他是真正的内存吗,下面我们来写一份代码验证一下:
执行一下代码:
当在子进程中将全局变量g_value
修改后,并不会影响父进程中g_value
的值,这是因为fork函数在创建子进程后,子进程会拷贝一份父进程的代码和数据,并创建自己的task_struct
,由于进程间的独立性,子进程对于全局变量的改变并不会父进程中数据的改变。
但是,他们的地址为什么会是相同的呢?这就恰恰说明了一个问题:这个地址肯定不是物理地址,因为同一时间内的一个物理地址只能存储一个进程的数据,那么他是什么呢?这就和我们本次要讲解的虚拟地址空间有关了。
2. 进程地址空间是什么?
操作系统会给每一个进程都创建一个独立的虚拟地址空间,虚拟地址也叫线性地址,因为虚拟地址空间中的地址是线性增长的。通过页表将虚拟地址中地址和物理内存中的地址进行一个映射,所以我们平常看到的地址都是虚拟地址空间中的虚拟地址,而不是真实的物理地址。
这时候,我们就可以解释以上代码中的现象了,为什么子进程对全局变量g_value的修改不会影响父进程中g_value得值。其实是因为发生了写实拷贝。最开始的时候由于子进程的地址空间是从父进程拷贝而来的,所以二者指向的是同一块虚拟地址。同时,他们映射的也是同一块物理内存。
子进程想要修改自己的g_value,操作系统通过页表发现g_value是被父进程和子进程同时指向的一块物理内存,因为进程间是有独立性的,所以操作系统会在物理内存中寻找一块新的空间,将原来的值拷贝到新的物理内存中,然后在将其修改,同时子进程将原来g_value的虚拟地址与新的物理内存建立映射关系。这个现象被称为写时拷贝
。当然因为父进程和子进程的虚拟地址是相同的,所以我们在运行结果中看到他们的地址还是相同的。
当然,进程地址空间中的地址是按照从全0到全1进行排列的,所以这个地址是连续的,因此,他被称为线性地址。在Linux中,虚拟地址、线性地址和逻辑地址都是一样的。
3. 进程地址空间的管理
操作系统中的每一个进程都会有一个进程地址空间,但是OS中会存在很多的进程,为了保证各个进程能够正常运行,所以操作系统需要将每个进程的进程地址空间进行管理。
我们知道,管理的本质是先描述,再组织。所以,操作系统会使用一种内核数据结构对进程地址空间进行管理,操作系统会创建一个mm_struct
的结构体。同时为每个进程创建一个mm_struct
类型的结构体对象。
下面我们来看一下Linux中mm_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 指向最近找到的虚拟区间 */
#ifdef CONFIG_MMU
/*用来在进程地址空间中搜索有效的进程地址空间的函数*/
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);
#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 */
/*
* Special counters, in some configurations protected by the
* page_table_lock, in other configurations by being atomic.
*/
struct mm_rss_stat rss_stat;
struct linux_binfmt *binfmt;
cpumask_t cpu_vm_mask;
/* Architecture-specific MM context */
mm_context_t context;
/* Swap token stuff */
/*
* Last value of global fault stamp as seen by this process.
* In other words, this value gives an indication of how long
* it has been since this task got the token.
* Look at mm/thrash.c
*/
unsigned int faultstamp;
unsigned int token_priority;
unsigned int last_interval;
unsigned long flags; /* Must use atomic bitops to access the bits */
struct core_state *core_state; /* coredumping support */
#ifdef CONFIG_AIO
spinlock_t ioctx_lock;
struct hlist_head ioctx_list;
#endif
#ifdef CONFIG_MM_OWNER
/*
* "owner" points to a task that is regarded as the canonical
* user/owner of this mm. All of the following must be true in
* order for it to be changed:
*
* current == mm->owner
* current->mm != mm
* new_owner->mm == mm
* new_owner->alloc_lock is held
*/
struct task_struct *owner;
#endif
#ifdef CONFIG_PROC_FS
/* store ref to file /proc/<pid>/exe symlink points to */
struct file *exe_file;
unsigned long num_exe_file_vmas;
#endif
#ifdef CONFIG_MMU_NOTIFIER
struct mmu_notifier_mm *mmu_notifier_mm;
#endif
};
进程地址空间被分为很多的区域,例如:堆区、栈区、未初始化数据区、初始化数据区、代码段等区域。操作系统会使用两个整形变量区间【start,end】
来维护每一个区域边界的内存区域的。
例如:
所以想要对地址空间区域进行调整,只需要调整区域变量start
和end
即可。
4. 进程地址空间存在的三个意义
进程地址空间存在的意义是什么呢?我们直接使用物理内存代替进程地址空间不行吗?
💕 防止地址随意被访问,保护物理内存及其他进程
如果直接使用物理地址,如果我们的代码写错了,就有可能导致越界非法访问或修改其他地址处的数据,有了虚拟地址和页表的出现,如果出现进程非法访问或者非法读写的操作,页表就可以直接对其进行拦截。
💕 将进程管理和内存管理进行解耦合,保证了进程独立性的特点
每一个进程都需要有独立的进程地址空间及页表,并通过页表映射到不同的物理内存中,一个进程数据的改变并不会影响另一个进程。对于父子进程也是同样的道理,父进程/子进程在修改数据时会发生写时拷贝,并不会影响彼此,所以保证了进程的独立性。
💕 可以让进程以统一的视角来看待自己的代码和数据
可执行程序被编译器编译的时候每个代码和数据在内存中已经有虚拟地址了(在磁盘上称为逻辑地址),也就是说,地址空间对于操作系统和编译器都是遵守的。所以当程序被加载到内存成为进程后,每个变量/函数都具备了物理地址。
所以我们现在有两套地址:
- 标识物理内存中代码和数据的地址
- 在程序内部互相跳转的时候的虚拟地址
加载完成之后,代码的各个区域的地址已经知道。进程被调度时,CPU拿到虚拟地址,经过地址空间查页表通过映射,进行访问查到物理地址往后执行。也就是CPU通过了虚拟地址——页表映射——物理地址
执行。也就是在整个CPU运行过程中,CPU并没有见到物理地址,用的都是虚拟地址。
答案是不一定,举个简单的例子:比如我们平成玩的王者荣耀,一般都是十几个G,可是我们的手机内存一般都只有8个G或者16个G,这么大的内存不可能全部加载到内存中去,因为虚拟地址空间的存在,所以我们需要用多少就加载多少,执行完的代码直接扔掉,这样就可以边加载边执行。需要的时候就将代码和数据换到内存里,不需要的时候就扔掉。这样就可以保证我们控制在一个很低的内存使用量的同时还能保证将这个很大的软件跑起来。所以平常我们打游戏的时候手机会发热呢?这是因为做了比其他App更多的操作,大部分的时间我们的手机可能都在进行网络IO,内存和固态磁盘不断的进行着数据交换。所以电池了各种设备了压力都比较大。因此手机就会发热。