下面我们来谈谈进程的地址空间:
目录
我们做一个之前做过的小实验:
- #include
- #include
- #include
- int g_val = 100;
- int main()
- {
- pid_t ret = fork();
- assert(ret != -1);
- if (ret == 0)
- {
- //子进程
- while (1)
- {
- printf("我是子进程,pid:%d ppid:%d,g_val=%d,&g_val:%p\n", getpid(), getppid(),g_val,&g_val);
- sleep(1);
- g_val++;
- }
- }
- else if (ret > 0)
- {
- //父进程
- while (1)
- {
- printf("我是父进程,pid:%d ppid:%d,g_val=%d,&g_val:%p\n", getpid(), getppid(), g_val, &g_val);
- sleep(1);
- }
- }
- else {}
- return 0;
- }
该段代码的运行结果为:
我们可以看到父子进程访问同一个全局变量数据却不一样,这在之前我们说过由于进程有独立性,所以有写时拷贝这个机制造成了这种结果。下面我们要研究的是为什么写时拷贝造成的变量空间地址并不一样,但打印出来的是同一个地址?
从上面这个结果我们可以看出,在编程语言中地址并不是物理地址,而是虚拟地址!
我们之前在C语言专栏画过这样的图:
这个内存区域的划分实际上就是虚拟地址(线性地址)
我们知道虚拟地址空间的基本单位是字节,所以在32位平台下虚拟地址空间上会有多少个地址呢?
答案是:2^32个!虚拟地址空间中的每一个地址依次为 [ 0 , 2^32 − 1 ]即 0x00000000 - 0xFFFFFFFF,每一个单位地址有1字节的空间,总共也就是我们常说的 4 GB 虚拟内存空间。
下图是虚拟地址与物理地址的对照示意图:
我们可以看到要通过虚拟地址找到实际物理地址所存储的数据,会有一个页表的东西用来对照(具体的对照过程我们后期会详细讲解)
我们在之前的学习了解到进程在操作系统下是由task_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 */ //mmap的高速缓冲器,指的是mmap最后指向的一个虚拟地址区间
- #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 */ //mmap区域的基地址
- unsigned long mmap_legacy_base; /* base of mmap area in bottom-up allocations */ //自底向上的配置
- 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 */ //不受约束的空间大小
- unsigned long highest_vm_end; /* highest vma end address */ //虚拟地址空间最大结尾地址
- 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
- atomic_long_t nr_ptes; /* Page table pages */ //页表
- int map_count; /* number of VMAs */ //虚拟地址空间的个数
-
- spinlock_t page_table_lock; /* Protects page tables and some counters */ //保护页表和用户
- struct rw_semaphore mmap_sem; //读写信号
-
- 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; /* Total pages mapped */
- unsigned long locked_vm; /* Pages that have PG_mlocked set */
- unsigned long pinned_vm; /* Refcount permanently increased */
- unsigned long shared_vm; /* Shared pages (files) */
- unsigned long exec_vm; /* VM_EXEC & ~VM_WRITE */
- unsigned long stack_vm; /* VM_GROWSUP/DOWN */
- unsigned long def_flags;
- 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 */
- ...
- };
我们知道了虚拟地址与物理地址之间存在着页表对应的映射关系,还了解了在Linux系统虚拟地址被管理的方式,下面就可以解释为什么引入的代码中一个虚拟地址有两个不一样值的问题了:
在fork函数运行之前,程序进程在操作系统中对应关系如下:
在fork函数运行之后,创建一个子进程,刚开始子进程的页表对应指向的物理地址和其父进程是同一块地址,在子进程要修改物理地址中的数据时,在操作系统中会对其要修改的物理空间的数据进行写实拷贝,对应关系如下:
我们可以看到子进程task_struct结构体都是继承父进程的,所以这里的虚拟地址并不会发生改变,但是为了维护进程的独立性物理地址上发生了写时拷贝,这时子进程的页表中的虚拟地址对应的物理地址会发生改变,最终造成了我们看到的现象
如果我们不用虚拟地址,直接在物理空间上进行操作难道不可以吗?
当然可以,但是相对于直接在物理地址上进行操作,使用虚拟地址具有以下优势:
1.方便编译器和操作系统安排程序的地址分布
程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区。
2.方便进程之间隔离
不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程使用的物理内存。
3. 一个系统如果同时运行着很多进程,为各进程分配的内存之和可能会大于实际可用的物理内存,虚拟内存管理使得这种情况下各进程仍然能够正常运行
程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动。
4.虚拟内存管理最主要的作用是让每个进程有独立的地址空间 (进程间的安全)
每个进程都认为自己独占整个虚拟地址空间,这样链接器和加载器的实现会比较容易,不必考虑各进程的地址范围是否冲突
5. 读写内存的安全性
实际上页表还有一列记录着各种指令的权限,这让执行错误指令或恶意代码的破坏能力受到了限制,顶多使当前进程因段错误终止,而不会影响整个系统的稳定性。