进程的虚拟地址空间概念
虚拟地址空间:每个进程独立维护的一组逻辑上的内存地址范围(从 0 到 TASK_SIZE-1,通常是 3GB 或更高),用于抽象物理内存的管理。
核心目的:
内存保护:防止进程非法访问其他进程的内存。
简化内存管理:无需关心物理内存的具体分布。
支持多任务:通过硬件(MMU)隔离不同进程的地址空间。
在Linux系统中,MMU(Memory Management Unit,内存管理单元) 是CPU的核心硬件组件,负责将进程的虚拟地址转换为物理内存的物理地址,同时提供内存保护和隔离机制。以下是详细的解释:
MMU 的定义:MMU 是硬件模块,集成在CPU中,用于管理虚拟内存到物理内存的映射。
MMU核心功能:
地址转换:将进程的虚拟地址转换为物理地址。
内存保护:防止进程越界访问其他进程或内核的内存。
权限检查:控制内存的读写执行权限(如代码段只读、堆可读写)。
虚拟地址空间的原理
每个进程的虚拟地址空间是独立的,逻辑上像独占整个内存,但实际上通过MMU映射到物理内存或磁盘。
其核心原理如下:
一、分页机制
- 虚拟内存分页:虚拟地址空间被划分为固定大小的页(通常4KB)。
- 物理内存分帧:物理内存对应划分为相同大小的页框。
- 页表映射:MMU通过页表(Page Table)记录虚拟页到物理页框的映射关系。
二、地址转换过程
- 1.CPU发出虚拟地址:例如 0x7ffd1234。
2.MMU查询页表: 分解虚拟地址为页号(高位)和页内偏移(低位)。 通过页号在页表中查找对应的物理页框号。
3. 物理地址生成:物理页框号 + 页内偏移 → 物理地址(如 0x12345000 + 0x234 = 0x12345234)。
4. 权限检查:若进程无访问权限(如试图写只读页),触发段错误(Segmentation Fault)。
三、TLB加速转换
- TLB(Translation Lookaside Buffer):MMU内部的缓存,存储最近使用的页表项,避免频繁访问内存中的页表。
- 命中与未命中:若TLB命中,直接获取物理地址;未命中则需访问页表,并更新TLB。
四、缺页中断(Page Fault)
- 触发条件:访问的虚拟页未映射到物理内存(如页表项为空或页被换出到磁盘)。
- 处理流程: 操作系统暂停进程,加载缺失的页到物理内存(从磁盘或初始化零页)。 更新页表并恢复进程执行。
为什么需要MMU与虚拟地址空间?
1.内存隔离与安全
- 进程隔离:每个进程拥有独立的虚拟地址空间,无法直接访问其他进程的内存。
- 内核保护:用户态进程无法访问内核空间,防止恶意操作。
2.物理内存的高效利用
- 共享内存:多个进程可共享同一物理页(如动态库、进程间通信)。
- 按需分页:仅加载实际使用的页到内存,减少浪费(如程序启动时只加载代码段)。
3. 支持大地址空间
- 超越物理内存限制:虚拟地址空间可以远大于物理内存(如64位系统支持48位地址,即256TB)。
- 交换空间(Swap):通过将不活跃的页换出到磁盘,扩展可用内存。
4. 简化内存管理
- 连续虚拟地址:进程看到连续的内存空间,无需关心物理内存碎片。
- 动态内存分配:malloc等函数通过扩展堆或映射新页实现,无需预分配。
5. 高级功能支持
- 写时复制(Copy-on-Write):fork()创建子进程时共享父进程页表,仅在写入时复制,提升性能。
- 内存映射文件:通过mmap将文件映射到虚拟地址空间,直接读写文件像操作内存。
总结:MMU 是操作系统实现虚拟内存的硬件基础,通过地址转换、权限控制和缺页处理,解决了内存隔离、安全、效率和扩展性问题。虚拟地址空间让每个进程仿佛独占内存,而MMU在幕后高效管理物理资源,支撑现代多任务操作系统的稳定与性能。
Linux进程虚拟地址空间的结构
mm_struct 就是(虚拟)进程地址空间,其由task_struct(PCB)维护的。
虚拟地址空间是操作系统的一种内核数据结构,其中有各个区域的划分。可以把虚拟地址空间看作一个结构体对象。
Linux 的内核源代码:
代码示例:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include<sys/types.h>
#include <stdlib.h>
int g_val = 100; //全局变量
int main()
{
printf("father is running, pid: %d, ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if(id == 0)
{
//child
int cnt = 0;
while(1)
{
printf("I am child process, pid: %d, ppid: %d. g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
cnt++;
if(cnt == 5)
{
g_val = 300;
printf("I am child process, change %d -> %d\n", 100, 300);
}
}
}
else
{
//father
while(1)
{
printf("I am father process, pid: %d, ppid: %d. g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
}
程序运行效果:
虚拟地址结构图和代码示例解释
为什么要有地址空间?
1.将无序的物理空间变成有序地址空间,让进程统一管理和利用物理内存。
2.进程管理模块和内存管理模块进行解耦。
3.避免非法访问。
看代码示例:
以上代码以为C/C++语言的角度看绝对是会报错的,默认常量字符是不能被修改的。但是为什么字符常量区不能被修改,只能被读取呢?
代码当然可以写,只不过操作系统设置了 读写权限。即页表在映射时设置了读/写权限。所以,如果我们对只读权限区的数据进行修改,操作系统立马就能检测这个进行非法访问的操作,退出该进程。
延迟分配的策略
在C/C++中,我们 malloc/new 空间,如果不立马使用,必然会造成空间资源的浪费。所以,在操作系统中,因为有地址空间的存在,上层申请空间,其实是在地址空间上申请的,物理内存空间可能一个字节都未分配。
而当你真正进行物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系,然后你才能进行内存的访问。这即是延迟分配的策略,提高整机的效率。
写时拷贝(Copy-on-Write,COW)
写时拷贝(COW)是一种内存管理优化技术,核心思想是:多个进程或线程共享同一份物理内存资源,仅当某个进程尝试修改共享内存时,才会真正复制该内存区域,为修改者创建独立的副本。这种方式避免了不必要的内存复制,显著提升了性能。
在 Linux 中,COW 的本质是一种 内存保护机制 + 惰性复制策略,用于解决以下问题:
- fork() 的效率问题:传统方法需要复制父进程的全部内存页,开销巨大(尤其是大进程)。
- 共享内存的高效管理:多个进程共享只读数据时,避免重复占用物理内存。
结合fork()的COW实现原理
以fork()创建子进程为例,详细流程如下:
fork()的轻量级初始化
当父进程调用fork()时:
复制页表:子进程复制父进程的页表,但所有页表项标记为只读(Read-Only)。
共享物理页:父子进程的页表指向相同的物理页框。
零物理复制:此时未复制任何物理内存,仅修改页表权限。
2 触发写操作时的复制
当父进程或子进程尝试写入共享内存时:
MMU检测权限违规:因页表项标记为只读,触发缺页中断(Page Fault)。
操作系统介入:
检查缺页地址是否属于COW区域。
复制物理页:分配新物理页,复制原页内容到新页。
更新页表:将触发写入的进程的页表项指向新物理页,并恢复可写权限。
进程继续执行:写入操作在新复制的页上完成,原进程仍使用旧页。
当子进程刚刚被创建时,父进程和子进程的数据和代码是共享的,即父子进程的代码和数据通过映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后进行修改。
例如,子进程需要将全局变量 g_val 改为200,那么此时就在内存的某处存储 g_val 的新值,并且改变子进程中 g_val 的虚拟地址和通过页表映射后得到的物理地址即可。
当子进程刚刚被创建时,父进程和子进程的数据和代码是共享的,即父子进程的代码和数据通过映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后进行修改。
例如,子进程需要将全局变量 g_val 改为200,那么此时就在内存的某处存储 g_val 的新值,并且改变子进程中 g_val 的虚拟地址和通过页表映射后得到的物理地址即可。
这种在需要进行数据修改时再进行拷贝的技术,称之为写时拷贝技术。
一些问题:
1.为什么数据要进行写时拷贝?
进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
2.为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝。操作系统采用了按需分配的原则,在需要修改数据时在分配(延时分配),这样额可以高效使用内存空间、提高运行效率。
3.代码会不会进行写时拷贝
90%的情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进程替换的时候,则需要进行代码的写时拷贝。
4.程序内部有地址吗?
在我们程序编译时,没有被加载到内存中时,编译器就在我们程序的内部根据进程地址空间的地址设置了虚拟地址。
地址空间不仅仅是 OS 内部要遵守的,其实编译器同样也遵守。在编译器编译代码的时候,就已经给我们形成了各个区域,例:代码区、数据区。并且采用了和 Linux 内核中一样的编制方式,给每一个变量,每一行代码都进行了编址。故,程序在编译的时候,每一个字段早已经具有了一个虚拟地址。
着重强调,程序内部的地址,依旧使用的编译器编译好的虚拟地址。