5 程序地址空间
5.1 复习
- 我们先写一段复习一下
#include<stdio.h>
#include<stdlib.h>
int int_a;
int int_b = 1000;
int main(int argc, char* argv[], char* env[])
{
const char* str = "oldkingnana";
(void)argc;//防warning
char* char_a = (char*)malloc(1);
char* char_b = (char*)malloc(1);
char* char_c = (char*)malloc(1);
char char_d = 'a';
char char_e = 'a';
char char_f = 'a';
static char char_g = 'a';
printf("main_pointer:%p\n", main);
printf("str_pointer:%p\n", str);
printf("int_b_pointer:%p\n", &int_b);
printf("static_char_g_pointer:%p\n", &char_g);
printf("int_a_pointer:%p\n", &int_a);
printf("char_a_heap_pointer:%p\n", char_a);
printf("char_b_heap_pointer:%p\n", char_b);
printf("char_c_heap_pointer:%p\n", char_c);
printf("char_d_stack_pointer:%p\n", &char_d);
printf("char_e_stack_pointer:%p\n", &char_e);
printf("char_f_stack_pointer:%p\n", &char_f);
printf("argv_pointer:%p\n", argv);
printf("env_pointer:%p\n", env);
return 0;
}
$ ./test
main_pointer:0x40057d
str_pointer:0x400780
int_b_pointer:0x60103c
static_char_g_pointer:0x601040
int_a_pointer:0x601048
char_a_heap_pointer:0x25a3010
char_b_heap_pointer:0x25a3030
char_c_heap_pointer:0x25a3050
char_d_stack_pointer:0x7fff9017ecaf
char_e_stack_pointer:0x7fff9017ecae
char_f_stack_pointer:0x7fff9017ecad
argv_pointer:0x7fff9017edb8
env_pointer:0x7fff9017edc8
- 我们之前有了解过一个叫程序地址空间的东西,即以下这张图
- 解释一下:
- 代码区包括:代码段,符号表,只读数据,调试符号,字符常量区
- 数据区包括:已初始化数据区和未初始化数据区
- 堆区:从低地址向高地址增长,用于动态内存分配
- 栈区:从高地址向低地址增长,用于编译器的自动内存分配
- 命令行参数区:包含命令行参数表
- 环境变量区:包含环境变量表
- 内核区:属于操作系统
- 用户区:除了内核区的剩余部分
- 32位机器下,用户区的大小为3G,内核区的大小为1G,一共4G(0x00000000~0xffffffff)
5.2 进程地址空间/程序地址空间的概念
-
值得注意的是,我们在学习C/C++的时候,似乎从来没有提到过有内核区的存在,或者说哪怕提到过咱也不清楚内核区究竟是干嘛的
-
细心的你可能已经感受到这张表似乎有些不对劲了
-
我们之前的知识局限在某一个程序中,一般认为程序在内存中的分配都是这样的,非常有规律地这样排布
-
事实是,这张表虽然是站在某个单一的程序的角度上的,但和实际的物理地址存在非常大的差距
-
所以这张表因该有个正确的名字:进程地址空间/虚拟地址空间
-
那既然这张表是站在操作系统的角度上的,是否就意味着我们之前所学的都是有问题的?
-
也不太对,但我们得站在另一个视角看
-
每一个进程都会拥有一个虚拟地址空间,每个虚拟地址空间都会包含这张表中的所有内容(我们暂时不用去管内核区)
-
我们可以写一个程序来验证一下这个问题
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int a = 1000;
int main()
{
pid_t pid = fork();
if(pid == 0)
{
//child
while(1)
{
printf("child: a:%d, a_point:%p, pid: %d, ppid: %d\n", a, &a, getpid(), getppid());
a++;
sleep(1);
}
}
else if(pid < 0)
{
perror("fork error");
return 1;
}
else
{
//father
while(1)
{
printf("father: a:%d, a_point:%p, pid: %d, ppid: %d\n", a, &a, getpid(), getppid());
sleep(1);
}
}
return 0;
}
$ ./test
father: a:1000, a_point:0x60105c, pid: 28938, ppid: 19617
child: a:1000, a_point:0x60105c, pid: 28939, ppid: 28938
father: a:1000, a_point:0x60105c, pid: 28938, ppid: 19617
child: a:1001, a_point:0x60105c, pid: 28939, ppid: 28938
father: a:1000, a_point:0x60105c, pid: 28938, ppid: 19617
child: a:1002, a_point:0x60105c, pid: 28939, ppid: 28938
father: a:1000, a_point:0x60105c, pid: 28938, ppid: 19617
child: a:1003, a_point:0x60105c, pid: 28939, ppid: 28938
-
不难发现,父子进程的两个全局变量因为写时拷贝的操作,导致他俩共享的全局变量被迫分离,变得不共享了
-
但哪怕是不共享了,为什么他俩的地址却一样呢?
-
不可能是真的使用同一个地址对吧
-
只有一种可能,物理上他俩的地址是冲突的,但对于进程自己而言,地址的相对位置是一样的
-
即对于单个进程而言,进程独自占有一个叫做虚拟地址空间的东西,意味着对于C/C++以及其他几乎所有语言来说,其中用到的地址都是虚拟地址
-
多个虚拟地址空间共同以某种方式组成了我们所说的进程地址空间
-
我们称这种方式为"内存映射",即将虚拟地址空间映射到物理地址空间中
-
比方说,我们有一个变量
a,其虚拟地址为0x123456 -
但在物理内存中,它的物理地址却是
0x666666 -
在操作系统中,会专门为每一个进程开一张表,我们称为"页表",是
Key_Value结构,用作映射
虚拟地址 (Key) | 物理地址 (Value) |
|---|---|
0x123456 | 0x666666 |
0x876541 | 0x665544 |
... | ... |
- 值得注意的是,每个
task_struct都会对应一个虚拟地址空间,意味着一个task_struct会对应一套页表
5.3 理解虚拟地址空间
-
虚拟地址空间其实就是在物理内存的基础上抽象出来的
-
这样的好处是:
- 对于每个进程来说,所有进程都认为自己独占内存,所有进程都可以敞开肚子使用内存
- 每个进程都认为自己拥有4G(32位)的内存,都认为自己占据的内存大得吓人
-
事实上其实就是操作系统在画饼,操作系统通过虚拟地址空间,让每个进程误以为物理内存十分充足,同时,操作系统也可以做一些手脚,比方说让两个进程共享部分内存之类的(虽然进程都认为自己独享这部分内存),这样就可以节省内存开支
-
但操作系统中进程如此多,自然需要一个数据结构对它们的虚拟地址空间进行管理,所以我们就设计出了一个叫做
mm_struct的结构体,它是某个数据结构(可能是链表,也有可能是红黑树等等)的节点,用于对某个单一进程的空间进行分配和管理(注意,mm_struct是用来管理虚拟地址空间的,他自己本身不是虚拟地址空间) -
对于做手脚方面,我们可以举一个例子:
-
比方说父进程
fork()了一个子进程,我们知道子进程会拷贝父进程的task_struct,代码,资源等等,问题是:页表,mm_struct这些东西需不需要拷贝? -
答案是一样需要,我们慢慢聊其中的过程
-
当父进程
fork()了一个子进程之后,页表,mm_struct都被拷贝进了子进程,此时我们会发现,子进程会和父进程共享一片物理空间,因为他俩的页表都相同,但对于他俩自己而言,他俩压根不知道有人和自己共享内存,如果此时子进程修改某个变量的值,操作系统会悄悄的将物理内存中的对应变量复制一份,然后将子进程的页表中对应对应变量的物理地址的那一栏填写成新的物理地址,但虚拟地址不变,然后进程修改了该变量的值,实际上是在物理地址中的拷贝被修改了 -
但这是操作系统做的事儿,进程自己不用管这些的,它是真的认为自己改了物理空间中的地址的(虽然实际是虚拟地址空间的地址)
-
这张动图看着应该会更形象一些
5.4 mm_struct源码鉴赏与功能分析
-
mm_struct作为"先描述,再组织"中,描述的那一环,重要性自然是毋庸置疑的 -
我们可以看一下源码(linux-2.6.18\include\linux\sched.h)(浅浅看一下就行,待会会解释其成员的功能)
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;
};
- 我们也可以回过头再看一眼
task_struct的源代码(节选部分),其中也有个成员是mm_struct的指针
struct task_struct {
/*......*/
struct list_head tasks;
/*
* ptrace_list/ptrace_children forms the list of my children
* that were stolen by a ptracer.
*/
struct list_head ptrace_children;
struct list_head ptrace_list;
//这个就是一个指向 mm_struct 的指针
struct mm_struct *mm, *active_mm;
/* task state */
struct linux_binfmt *binfmt;
long exit_state;
int exit_code, exit_signal;
int pdeath_signal; /* The signal sent when the parent dies */
/* ??? */
unsigned long personality;
unsigned did_exec:1;
pid_t pid;
pid_t tgid;
/*......*/
5.4.1 区域划分
-
我们知道,
mm_struct用来管理虚拟地址空间,所以它必须要用某种方式,给进程的空间分区,即我们之前给出的"程序地址空间"的那张图 -
用来分区的方式也十分简单,我们只需要标记两个虚拟地址就行,比方说
0x00000000~0x00100000给代码段 -
更形象点:搞两个变量标记一下:比方说源码中的
start_code,end_code这俩玩意就是用来区分代码段的 -
扩充区域也挺简单,直接对这几个变量进行
+,-操作就可以了 -
以下这段主要就是用来完成分段工作的
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
// 1. 该进程总虚拟内存大小
// 2. 加锁的虚拟内存大小(锁我们后面也会聊到)
// 3. 共享的虚拟内存的大小
// 4. 表示进程被标记为可执行的虚拟内存的大小(一般是代码段中的部分)
// (以上所有变量的单位是页,页这个东西我们后面会聊到的)
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
// 1. 栈空间大小
// 2. 预留的虚拟内存大小(用于日后扩展某个区域的空间)
// 3. 默认内存映射的标志
// 4. 进程页表项的数量
unsigned long start_code, end_code, start_data, end_data;
// 1. 代码段起始位置
// 2. 代码段终止位置
// 3. 数据段起始位置
// 4. 数据段终止位置
unsigned long start_brk, brk, start_stack;
// 1. 堆的起始位置
// 2. 堆的终止位置
// 3. 栈的起始位置
unsigned long arg_start, arg_end, env_start, env_end;
// 1. 命令行参数区的起始位置
// 2. 命令行参数区的终止位置
// 3. 环境变量区的起始位置
// 4. 环境变量区的终止位置
5.4.2 解决堆区问题
-
本小节建议看完整个第5章再回头看
-
在谈堆区的问题之前,我们必须要梳理一下堆区和其他区域的最大区别
-
我们知道,一旦程序编译好了,并且该进程已经启动了,至少在代码层面,程序就已经很难产生变化了,即绝大部分的内容已经定下来了,比方说代码区的部分肯定是定下来的,同时程序的全局变量也都是定下来的,因为程序已经跑起来了,所以命令行参数表和环境变量表也会定下来,所以一旦程序跑起来,这些区域就很难变化了
-
另一个很难变化的区域是栈区,同样是因为代码已经规定死了,所以栈区也只需要一个固定的空间就行了(当然,如果触发了无限递归,再大的栈区也无济于事)
-
而堆区的最大区别是动态开辟,因为程序运行中包含非常多的不确定因素,比方说用户的不同操作等,所以我们需要一个可以等程序处于运行状态后,动态开辟的空间,所以设计出了堆区,以应对这种会动态使用内存的情况
-
于是,我们得了解了解堆区是怎么在看起来和其他区域的开辟方式大差不差的情况下,是怎样做到动态开辟空间的
-
首先,最为基础的开辟方式,就是通过调整区域划分中
brk指针的位置以开辟虚拟内存,这个很好理解 -
但这种方式并不能满足所有需求,比方说开辟的空间要求内存对齐,或者说用户在玩游戏,需要分配超过4G的虚拟内存(32位)
-
所以堆区还有另一种开辟方式,我们先介绍一个新结构:
vm_area_struct
struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
//......
}
- 我们知道,
mm_struct管理的区域类型还是挺多的,为了更加方便地管理这些区域的各种属性,我们将他们全部抽象为一个个的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;
//......
}
-
解释一下:
vm_mm:指向自身属于的mm_structvm_start:指向所需要描述的区的起始地址vm_end:指向所需要描述的区的结束地址vm_next:表示下一个vm_area_struct节点(当然还会有vm_prev的啦,太多了就没展示了)vm_flags:描述该区的各种属性标志,例如访问权限等等vm_rb:vm_area_struct可以通过vm_rb链接进一颗红黑树,在节点过多的情况下可以进行高效地查询修改
-
每个区都会使用
vm_area_struct来描述,包括堆区,于是我们能得到这样一张图表
-
现在我们就可以介绍一下堆区新的开辟空间的方式,即,再开一个
vm_area_struct -
当用户需要再开辟空间时,进程会通过系统调用请求开辟堆区空间,系统会判断开辟空间的具体方式,并采取对应的方式
-
如果需要重新开一个
vm_area_struct,系统将会在初始化完这个结构之后将其链入链表 -
然后为新开的空间分配虚拟地址并填写页表,物理地址暂时不填
-
等到已经需要使用新开的空间的时候,就会触发缺页中断(下一小节会详细提到),交由系统分配物理地址,填写页表的物理地址部分
-
所以
mm_struct用来宏观上描述和管理虚拟地址空间,vm_area_struct则是在微观上描述和管理虚拟地址空间的各个分区
5.5 一个进程启动时,相关部件怎么运作的
- 首先OS会根据进程的需求,申请虚拟地址空间,其实就是开一个
mm_struct - 然后OS将会申请相应的物理内存,当然,如果一个进程需要的内存资源过大,比方说光代码段都有2G左右,OS很可能不会直接申请相同大小的物理内存而是只申请一部分,比方说四分之一之类的
- 接着创建页表,并将对应的虚拟地址和物理地址一一对应地放进页表中,并初始化相关权限(页表也包含一列,为权限,具体我们后面的小节会提到),同时,如果进程所需空间过大,可能会导致页表不会填满,物理地址部分可能会有空缺(页表中也还会存在一列数据,是一个标记符,用于标记当前物理地址是否已经填入)
- 最后OS将会把磁盘的相关代码和资源拷贝进内存中,如果需要拷贝的代码/资源过多,OS会暂时只拷贝一部分然后页表中物理地址的很多部分也会因此造成空缺
- 进程运行时,如果OS发现物理地址有空缺,并检查了页表中的相关标记符,排除完野指针等问题之后,CPU会进入内核模式,此时CPU将会直接暂停处理进程的任务,转而调整内存和磁盘数据,将没有拷贝的部分拷贝进物理内存中,并填写好页表,大功告成之后会返回来继续处理进程
- 如果是因为内存资源严重不足,我们之前有提到过进程可能会变成挂起状态,此时进程的资源并不完全在物理内存中,而是有一部分在磁盘的
swap区,当OS发现页表中没有物理地址时,并且判断该进程因为内存资源不够而导致还有内容在磁盘时,OS一样会进入内核模式,交换磁盘swap区和物理内存相关区域的相关数据,并修改页表的物理地址部分,然后回头再继续处理进程
5.6 为什么需要虚拟地址空间??
5.6.1 从物理地址的无序到虚拟地址的有序的过程
- 虚拟地址空间将物理地址转化为虚拟地址,从本质结构上,虚拟地址空间映射的物理地址全都是分散的,但通过页表的映射,使得虚拟地址空间呈现出一种虚拟的整合
5.6.2 通过虚拟地址空间,为进程和物理内存之间添加中间层,通过中间层对进程操作的检查以及合法性判定以保护物理内存
-
这点我们需要简单举个例子:比方说野指针访问,进程只能访问到虚拟地址,但OS查找不到页表中的地址,于是在虚拟地址空间这层就拒绝了进程的访问,并且OS还有可能直接杀死进程,使进程崩溃(其实更为准确是一个叫
MMU的硬件在查页表,然后抛出错误,最后被OS捕获,然后OS再对进程做调整,这里我们暂时就理解为OS在做查找等等左右的内容,硬件的话题我们后面还会再了解的) -
同时,页表也不仅仅只有"虚拟地址"和"物理地址"这两栏,还有"权限"栏位
-
例如如果你想修改只读区域(例如"字符常量区")的数据,因为"字符常量区"是只读,即其地址在页表中的权限全部是
r--(权限全开为rwx,和Linux用户权限保持一致),此时程序运行时,OS也会像野指针一样拒绝进程访问并抛出错误,并可能直接杀死进程 -
PS:
MMU全称为"Memory Management Unit",即"操作系统的内存管理单元",是一个硬件,当检查页表时发现问题,会直接触发"页错误中断",然后抛出错误,由OS捕获错误并处理相关进程
5.6.3 将进程管理和内存管理进行解耦和
-
传统在物理内存中管理进程的形式,进程管理与物理内存的管理是强关联的,进程必须要考虑物理内存的大小,会不会干扰其他进程运行等等问题,同时OS在处理进程造成的页错误时,也会更加棘手,因为这个错误有可能已经修改了其他进程的数据
-
一旦通过虚拟地址空间,进程就因为"自认为自己内存很充足"而能放开了干活,同时进程运行也会更加安全,增强了进程的独立性
-
同时,OS在管理进程和管理内存这两个部分就能分开干,不用再整合在一起,相对来说没这么麻烦,即操作系统管理进程时,不用再关心内存布局,而在管理内存时,也不需要再关心进程的内存需求和结构
-
所以谁在这其中最关键?
-
虚拟内存和物理内存之间的转换是通过页表实现的,所以页表则是沟通进程管理和内存管理的桥梁
- 如有问题或者有想分享的见解欢迎留言讨论