深入解析Linux进程地址空间与虚拟内存管理
目录
一、研究平台
二、程序地址空间回顾
程序功能说明
三、虚拟地址
四、进程地址空间(虚拟地址空间)
实际操作
重点问题探讨
1、为什么采用写时拷贝?
2、为何不在创建子进程时立即拷贝数据?
3、代码会执行写时拷贝吗?
进程创建包含三个关键步骤
五、32位与64位机器的地址空间
1、地址空间的基本概念
2、32位机器的地址空间
3、64位机器的地址空间
4、关键区别
5、为什么需要更大的地址空间?
6、常见问题
六、进程独立性
1、内核数据结构独立
为什么需要独立的内核数据结构?
2、代码和数据独立
(1)写时复制(Copy-on-Write, COW)
(2)虚拟地址空间隔离
(3)动态链接库的独立加载
七、分页管理与缺页中断
1、页表(Page Table)
1. 页表的作用
2. 页表的结构
(1)基本组成
(2)多级页表(Hierarchical Page Table)
2、分页管理(Paging)
关键点:
3、缺页中断(Page Fault)
缺页中断的处理流程:
常见触发缺页的场景:
4、分页与缺页中断的意义
示例:访问未加载的页
八、虚拟内存管理(进程地址空间管理)
关键说明:
1、mm_struct:进程内存的“总管”
2、vm_area_struct(VMA):内存区域的“切片”
3、二者的协作关系
(1)组织结构
(2)实际工作流程
九、为什么需要虚拟地址空间
安全风险
地址不确定性
效率低下
那么引入虚拟地址空间和分页机制能否解决这些问题?答案是肯定的!
关键优势
安全保护机制
解耦设计
延迟分配机制
灵活映射
十、关于一些问题和回答
1、不加载代码和数据,仅维护内核结构
核心概念:轻量级进程框架
仅保留关键内核结构
应用场景
优势:节省内存和启动时间,适用于后台任务或快速进程孵化。
2、进程创建顺序:先有结构,再加载数据(先骨架(内核结构),后血肉(代码数据))
详细流程
为什么这样设计?
3、进程挂起(Process Suspension)
定义与机制
挂起 vs 就绪/阻塞
实现原理
示例场景
4、关联问题扩展
一、研究平台
- Linux内核版本:2.6.32
- 平台架构:32位系统
二、程序地址空间回顾
如下的内存空间布局示意图,以32位机器的内存地址空间为例:
不过我们对其机制尚不了解。可以先通过区域分布验证来测试:
#include <stdio.h> // 标准输入输出库头文件
#include <stdlib.h> // 标准库头文件,包含malloc等函数声明
#include <unistd.h> // UNIX标准头文件,提供系统调用APIint g_unval; // 未初始化的全局变量,存储在BSS段
int g_val = 100; // 已初始化的全局变量,存储在数据段int main(int argc, char *argv[], char *env[])
{const char *str = "helloworld"; // 字符串常量,存储在只读数据段// 打印代码段地址(函数main的地址)printf("code addr: %p\n", main);// 打印已初始化全局变量的地址(数据段)printf("init global addr: %p\n", &g_val);// 打印未初始化全局变量的地址(BSS段)printf("uninit global addr: %p\n", &g_unval);static int test = 10; // 静态局部变量,存储在数据段// 动态分配堆内存(4次分配)char *heap_mem = (char*)malloc(10);char *heap_mem1 = (char*)malloc(10);char *heap_mem2 = (char*)malloc(10);char *heap_mem3 = (char*)malloc(10);// 打印堆内存地址printf("heap addr: %p\n", heap_mem);printf("heap addr: %p\n", heap_mem1);printf("heap addr: %p\n", heap_mem2);printf("heap addr: %p\n", heap_mem3);// 打印静态局部变量地址(数据段)printf("test static addr: %p\n", &test);// 打印栈上变量的地址(局部变量heap_mem等的地址)printf("stack addr: %p\n", &heap_mem);printf("stack addr: %p\n", &heap_mem1);printf("stack addr: %p\n", &heap_mem2);printf("stack addr: %p\n", &heap_mem3);// 打印只读字符串常量的地址(只读数据段)printf("read only string addr: %p\n", str);int i = 0;// 打印命令行参数的地址for(; i < argc; i++) {printf("argv[%d]: %p\n", i, argv[i]);}i = 0;// 打印环境变量的地址for(; env[i]; i++) {printf("env[%d]: %p\n", i, env[i]);}return 0; // 程序正常退出
}
程序功能说明
这段代码主要演示了C程序中各种不同类型变量和数据的存储位置,包括:
-
代码段(text segment): 存储可执行指令,如
main
函数的地址 -
数据段(data segment): 存储已初始化的全局变量和静态变量(如
g_val
和test
) -
BSS段: 存储未初始化的全局变量(如
g_unval
) -
堆(heap): 动态分配的内存(通过
malloc
分配) -
栈(stack): 存储局部变量(如
heap_mem
等指针变量本身) -
只读数据段: 存储字符串常量(如
"helloworld"
) -
命令行参数和环境变量: 存储程序启动时传入的参数和环境变量
程序通过打印这些变量和数据的地址,展示了它们在不同内存区域的分布情况。
运行结果如下,与布局图所示是吻合的:
三、虚拟地址
下面我们来段代码感受一下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int g_val = 0;int main()
{pid_t id = fork();if(id < 0) {perror("fork");return 0;}else if(id == 0) { // 子进程printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else { // 父进程printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
编译后运行:
我们发现,输出的变量值和地址完全相同。这很好理解,因为子进程以父进程为模板创建,且父子进程都未对变量进行任何修改。但当我们将代码稍作调整时:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int g_val = 0;int main()
{pid_t id = fork();if (id < 0) {perror("fork");return 0;}else if (id == 0) {// 子进程先执行修改操作g_val = 100;printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);}else {// 父进程等待3秒后读取sleep(3);printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);}sleep(1);return 0;
}
编译后运行:
我们发现,父子进程输出的变量地址相同,但变量内容不一致。由此可以得出以下结论:
- 由于变量内容不同,父子进程输出的变量绝对不是同一个变量
- 地址值相同表明该地址绝非物理地址
- 在Linux系统中,这类地址称为虚拟地址
- 我们在C/C++语言中看到的地址都是虚拟地址。物理地址对用户完全不可见,由操作系统统一管理。
- 操作系统必须负责将虚拟地址转换为物理地址。
- 平时我们在高级语言(如C/C++)中使用的指针得到的地址并不是物理内存的地址,而是虚拟内存空间的地址。
四、进程地址空间(虚拟地址空间)
我们之前将那张布局图称为"程序地址空间"并不准确,正确的名称应该是"进程地址空间"。进程地址空间本质上是一种内存中的内核数据结构(并不是内存中的某一空间),在Linux系统中由mm_struct结构体具体实现。
进程地址空间就像一把刻度从0x00000000延伸到0xffffffff的尺子,这把尺子被划分为多个区域,如代码区、堆区和栈区等。mm_struct结构体则记录了各个区域的边界刻度值,比如代码区的起始和结束刻度,如下图所示:
在mm_struct结构体中,每个边界刻度都对应一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立关联。由于虚拟地址从0x00000000到0xffffffff呈线性增长,因此虚拟地址也被称为线性地址。
实际操作
- 堆向上增长和栈向下增长的过程,本质上是通过调整mm_struct中堆栈的边界刻度来实现的。
- 可执行程序在编译时就被划分为不同区域(如初始化区、未初始化区等)。
- 程序运行时,操作系统只需将对应数据加载到相应内存区域即可,这显著提升了系统效率。这种"分区"操作是由编译器完成的,因此代码优化级别实际上取决于编译器设置。
进程创建时,系统会同时生成对应的进程控制块(task_struct)和进程地址空间(mm_struct)。操作系统可以通过task_struct中的mm_struct指针访问进程的地址空间信息。例如:
- 父进程拥有独立的task_struct和mm_struct
- 子进程也会创建自己的task_struct和mm_struct
- 父子进程的虚拟地址分别通过各自的页表映射到物理内存的不同位置
当子进程刚被创建时,它与父进程共享相同的数据和代码,即两者的页表都映射到物理内存的同一区域。只有当父进程或子进程试图修改数据时,才会触发数据复制:系统会将需要修改的数据在内存中复制一份,再进行修改。
如上面的代码对比,若子进程要将全局变量g_val的值改为100,系统会在内存中开辟新空间存储这个新值,然后只需调整子进程页表中g_val虚拟地址对应的物理映射即可(进程具有独立性):
这种在数据需要修改时才进行拷贝的技术,称为写时拷贝(Copy-on-Write)。
重点问题探讨
1、为什么采用写时拷贝?
进程具有独立性。在多进程运行环境中,每个进程需要独享资源且互不干扰。通过写时拷贝可以确保子进程的修改不会影响父进程的数据。(一句话来说就是:减少拷贝时间,减少内存浪费!!!)
2、为何不在创建子进程时立即拷贝数据?
子进程可能不会使用父进程的全部数据。在没有写入需求时,立即拷贝会造成内存浪费。采用按需分配的延迟拷贝策略,可以更高效地利用内存空间。
3、代码会执行写时拷贝吗?
通常情况下(约90%)不会。但在特殊场景如进程替换时,代码也会进行写时拷贝。
进程创建包含三个关键步骤
创建进程控制块(task_struct)、建立进程地址空间(mm_struct)以及初始化页表。
五、32位与64位机器的地址空间
1、地址空间的基本概念
-
地址空间是操作系统为进程或硬件分配的可用内存地址范围,分为:
-
物理地址空间:实际内存(RAM)的地址范围。
-
虚拟地址空间:进程“看到”的地址范围,通过页表映射到物理地址。
-
-
地址位数决定了理论可寻址的内存大小:
-
32位机器:地址总线宽度为32位,可表示 232232 个唯一地址。
-
64位机器:地址总线宽度为64位,可表示 264264 个唯一地址。
-
2、32位机器的地址空间
-
地址范围:
-
实际限制:
-
部分地址可能保留给硬件(如显存、BIOS),用户进程通常只能使用约3~3.5GB。
-
若启用PAE(物理地址扩展),可支持超过4GB物理内存,但单个进程仍限制在4GB虚拟地址空间内。
-
3、64位机器的地址空间
-
地址范围:
-
实际限制:
-
当前硬件和操作系统通常仅支持48~52位有效地址(如x86-64架构使用48位虚拟地址,支持256TB虚拟空间)。
-
物理内存支持远超实际需求(如TB级内存),但受主板和成本限制。
-
4、关键区别
特性 | 32位机器 | 64位机器 |
---|---|---|
理论地址空间 | 4GB | 16EB |
单个进程可用虚拟内存 | ≤4GB | ≥256TB |
物理内存支持 | 通常≤4GB(PAE可扩展) | TB级以上 |
典型应用场景 | 老旧设备、嵌入式系统 | 现代服务器、PC、移动设备 |
5、为什么需要更大的地址空间?
-
大内存需求:如科学计算、数据库、虚拟化等应用需要TB级内存。
-
多进程隔离:每个进程拥有独立的虚拟地址空间,避免冲突。
-
未来扩展性:预留足够地址空间应对技术进步(如非易失性内存)。
6、常见问题
-
Q1:为什么32位系统只能识别约3.2GB内存?
A1:部分地址被硬件保留(如显存、PCI设备),操作系统无法分配。 -
Q2:64位程序是否一定比32位快?
A2:不一定。64位优势在于大内存支持,但指针和数据类型占用空间更大,可能增加缓存压力。
六、进程独立性
1、内核数据结构独立
每个进程在内核中拥有完全独立的数据结构,主要包括:
-
进程控制块(PCB, Process Control Block)
-
唯一标识:PID(进程ID)、PPID(父进程ID)。
-
状态管理:运行态、就绪态、阻塞态等。
-
资源记录:打开的文件描述符、内存分配、信号处理表等。
-
-
其他内核对象
-
例如:独立的页表(实现虚拟地址空间隔离)、信号量、消息队列等。
-
为什么需要独立的内核数据结构?
-
避免竞争:若多个进程共享同一PCB,可能导致状态冲突(如同时修改进程状态)。
-
权限控制:内核通过PCB验证进程的合法性(如系统调用时检查PID)。
示例:
进程A(PID=100)和进程B(PID=101)的PCB完全独立,即使两者运行同一程序,内核也能通过PID区分它们。
2、代码和数据独立
每个进程的代码和数据在内存中是私有的,即使多个进程运行相同的程序(如多个ls
进程),其内存内容也互不干扰。关键机制包括:
(1)写时复制(Copy-on-Write, COW)
-
原理:
-
子进程共享父进程的代码段和只读数据段。
-
当任一进程尝试修改数据时,内核会为该进程复制一份私有副本。
-
-
优势:减少内存拷贝开销(如
fork()
创建子进程时无需立即复制全部内存)。
(2)虚拟地址空间隔离
-
每个进程拥有独立的虚拟地址空间(如32位进程的0~4GB范围),通过页表映射到不同的物理内存区域。
-
效果:进程A访问地址
0x12345678
与进程B的同地址实际指向不同的物理内存。
(3)动态链接库的独立加载
-
即使多个进程共享同一个动态库(如
libc.so
),其数据段(如全局变量)会按需复制,保证独立性。
示例:
两个进程同时运行/bin/bash
:
-
代码段:共享物理内存中的同一份二进制代码(只读)。
-
数据段:每个进程的堆、栈、全局变量均为独立副本。
七、分页管理与缺页中断
1、页表(Page Table)
页表是操作系统用于实现虚拟内存管理的核心数据结构,负责将进程的虚拟地址映射到物理内存的物理地址。它是分页机制(Paging)的关键组成部分,确保每个进程拥有独立的虚拟地址空间,同时高效共享物理内存。
1. 页表的作用
-
地址转换:将进程使用的虚拟地址(如
0x00400000
)转换为实际内存中的物理地址(如0x12345000
)。 -
内存隔离:不同进程的页表独立,即使虚拟地址相同,也会映射到不同的物理地址,保证进程间互不干扰。
-
权限控制:通过页表项(PTE)标记页的访问权限(如可读、可写、可执行)。
2. 页表的结构
(1)基本组成
-
页表项(Page Table Entry, PTE)
每个虚拟页对应一个页表项,存储以下信息:-
物理页框号(PFN):虚拟页映射的物理内存位置。
-
有效位(Valid Bit):标记该页是否已加载到物理内存(触发缺页中断的依据)。
-
权限位:控制读/写/执行权限(如代码页只读)。
-
其他标志:如脏位(Dirty Bit)、访问位(Accessed Bit)等。
-
(2)多级页表(Hierarchical Page Table)
-
问题:32位系统下,单级页表需要 220220 个项(假设页大小4KB),占用大量内存。
-
解决:采用多级页表(如x86的二级页表,现代系统的四级页表),仅分配实际使用的部分,节省空间。
2、分页管理(Paging)
分页是操作系统管理物理内存和虚拟内存的核心机制,它将内存划分为固定大小的块(称为页),并通过页表实现虚拟地址到物理地址的映射。
关键点:
-
页(Page)与页框(Page Frame)
-
页:进程虚拟地址空间的固定大小单元(如4KB、2MB等)。
-
页框:物理内存中与页大小相同的存储块,用于存放页的内容。
-
-
页表(Page Table)的作用
每个进程有自己的页表,记录虚拟页到物理页框的映射关系。若页表项标记为“无效”,则触发缺页中断。
-
按需加载(Demand Paging)
进程的页不会一次性全部加载到内存,而是仅在访问时由操作系统动态分配物理页框(如程序启动时只加载代码段,数据段在首次访问时加载)。
3、缺页中断(Page Fault)
当进程访问的虚拟页未映射到物理内存(页表项无效或不存在)时,CPU触发缺页中断,由操作系统处理。
缺页中断的处理流程:
-
CPU检测到无效访问:例如,进程访问虚拟地址
0x12345678
,但页表中对应页表项标记为“不存在”。 -
触发缺页中断:CPU保存当前进程上下文,切换到内核模式,执行缺页中断处理程序。
-
操作系统处理中断
-
检查原因:
-
是否合法访问(如越界访问会终止进程)。
-
是否是“合法缺页”(页在磁盘交换区或未初始化)。
-
-
分配物理页框:从空闲列表或通过页面置换算法(如LRU)获取一个空闲页框。
-
加载数据:
-
若页在磁盘(如交换文件或程序文件),从磁盘读取数据到物理页框。
-
若页是匿名页(如堆内存),直接清零初始化。
-
-
更新页表:将虚拟页映射到新分配的物理页框,并标记为“有效”。
-
-
恢复进程执行:重新执行触发缺页的指令,此时访问已合法。
常见触发缺页的场景:
-
首次访问动态分配的堆内存(如
malloc
分配的页面初始时为无效)。 -
访问被换出到磁盘的页面(物理内存不足时,操作系统会将不活跃的页换出)。
-
共享库的延迟加载(程序运行时才加载动态链接库的代码页)。
4、分页与缺页中断的意义
-
内存高效利用:按需加载减少内存浪费。
-
进程隔离:每个进程通过独立的页表访问物理内存,避免相互干扰。
-
支持虚拟内存:即使物理内存不足,也能通过磁盘交换运行大型程序。
示例:访问未加载的页
-
进程访问虚拟地址
0x00400000
(代码段)。 -
页表显示该页未加载(页表项无效)。
-
触发缺页中断 → 操作系统从程序文件中读取代码到物理内存 → 更新页表 → 继续执行。
八、虚拟内存管理(进程地址空间管理)
在Linux系统中,每个进程的地址空间信息都由mm_struct
结构体(内存描述符)来描述。系统中每个进程拥有唯一的mm_struct
结构,该结构通过task_struct
中的指针进行关联:
struct task_struct
{/*...*/struct mm_struct *mm; // 指向进程用户空间虚拟地址空间的指针struct mm_struct *active_mm; // 内核线程使用的内存管理结构/*...*/
};
关键说明:
- 对普通用户进程而言,
mm
指针指向其用户空间部分的虚拟地址空间 - 内核线程的
mm
字段为NULL,表示其没有独立的用户地址空间 - 内核线程通过
active_mm
字段是内核线程使用的。当该进程是内核线程时,它的mm字段为NULL,表示没有内存地址空间,可也并不是真正的没有,这是因为所有进程关于内核的映射都是⼀样的,内核线程可以使用任意进程的地址空间。
mm_struct
和 task_struct
的存放路径相同,但文件不同。mm_struct
定义于 mm_types.h
文件中:
struct mm_struct
{/*...*/struct vm_area_struct *mmap; /* 虚拟内存区域(VMA)链表 */struct rb_root mm_rb; /* 红黑树结构 */unsigned long task_size; /* 进程虚拟地址空间大小 *//* 各内存段边界地址 */unsigned long start_code, end_code; /* 代码段 */unsigned long start_data, end_data; /* 数据段 */unsigned long start_brk, brk; /* 堆段 */unsigned long start_stack; /* 栈段 */unsigned long arg_start, arg_end; /* 参数段 */unsigned long env_start, env_end; /* 环境段 *//*...*/
};
由于每个进程都拥有独立的mm_struct结构,操作系统需要高效管理这些数据结构。Linux内核提供了两种虚拟内存区域的存储方式:
- 对于较少的虚拟区,采用单链表结构,通过mmap指针进行连接;
- 当虚拟区数量较多时,则使用红黑树进行管理,由mm_rb指针指向树结构。
内核使用vm_area_struct结构来表示独立的虚拟内存区域(VMA)。由于不同虚拟内存区域的功能和实现机制存在差异,一个进程通常需要使用多个vm_area_struct结构来管理不同类型的VMA。上述两种组织方式都是基于vm_area_struct结构来连接各个VMA,从而确保进程能够快速访问这些内存区域。
struct vm_area_struct
{unsigned long vm_start; // 虚拟内存区域起始地址unsigned long vm_end; // 虚拟内存区域结束地址struct vm_area_struct *vm_next; // 链表后向指针struct vm_area_struct *vm_prev; // 链表前向指针struct rb_node vm_rb; // 红黑树节点位置unsigned long rb_subtree_gap; // 子树间隙struct mm_struct *vm_mm; // 所属内存管理结构体pgprot_t vm_page_prot; // 页面保护属性unsigned long vm_flags; // 区域标志位struct {struct rb_node rb;unsigned long rb_subtree_last;} shared;struct list_head anon_vma_chain; // 匿名虚拟内存链表struct anon_vma *anon_vma; // 匿名虚拟内存区域const struct vm_operations_struct *vm_ops; // 虚拟内存操作函数集unsigned long vm_pgoff; // 文件映射偏移量(页对齐)struct file *vm_file; // 映射的文件对象void *vm_private_data; // 私有数据区atomic_long_t swap_readahead_info; // 交换预读信息#ifndef CONFIG_MMUstruct vm_region *vm_region; // NOMMU映射区域
#endif#ifdef CONFIG_NUMAstruct mempolicy *vm_policy; // NUMA内存策略
#endifstruct vm_userfaultfd_ctx vm_userfaultfd_ctx; // 用户态缺页处理上下文
} __randomize_layout;
因此,我们可以对上述内容进行更详细的说明,具体如下图所示:
通过上图我们可以知道:
1、mm_struct:进程内存的“总管”
-
角色:描述一个进程的整个虚拟地址空间(如 32 位进程的 0~4GB 范围)。
-
关键字段:
-
pgd
:指向页全局目录(Page Global Directory),用于地址转换。 -
mmap
:指向一个 VMA 链表,记录进程的所有内存区域。 -
start_code
/end_code
:代码段的起止地址。 -
start_brk
/brk
:堆的起止地址。 -
start_stack
:栈的起始地址。
-
作用:
-
管理进程的全局内存布局(如代码、堆、栈的分布)。
-
通过页表(
pgd
)实现虚拟地址到物理地址的映射。
2、vm_area_struct(VMA):内存区域的“切片”
-
角色:描述进程地址空间中的一个连续内存区域(如代码段、堆、栈、内存映射文件等)。
-
关键字段:
-
vm_start
/vm_end
:该区域的起始和结束虚拟地址。 -
vm_flags
:权限标志(如VM_READ
、VM_WRITE
、VM_EXEC
)。 -
vm_file
:若映射文件,指向对应的struct file
。 -
vm_next
:指向下一个 VMA,形成链表。
-
作用:
-
记录每一块内存区域的属性和映射关系(如只读代码段、可读写堆等)。
-
在缺页中断时,内核通过 VMA 判断是否合法访问,并加载缺失的数据。
3、二者的协作关系
(1)组织结构
-
mm_struct
是进程内存的“总控结构”,其mmap
字段指向一个 VMA 链表。 -
每个 VMA 描述进程地址空间的一个独立区间(如代码段、堆、栈、共享库等)。
(2)实际工作流程
-
进程访问虚拟地址
0x08048000
(代码段):-
CPU 触发缺页中断,内核查询
mm_struct->pgd
找到页表。 -
若页表项无效,内核遍历
mm_struct->mmap
链表,找到包含0x08048000
的 VMA。 -
检查 VMA 的
vm_flags
确认权限(如是否可执行)。 -
若合法,从磁盘加载代码到物理内存,更新页表。
-
-
malloc
申请堆内存:扩展堆的 VMA(修改mm_struct->brk
),分配新的物理页并映射。
九、为什么需要虚拟地址空间
这个问题可以更直观地表述为:直接操作物理内存会带来哪些问题?
在早期计算机系统中,程序运行时需要全部加载到内存中,并且直接访问物理内存地址。当多个程序同时运行时,必须确保它们占用的内存总量不超过实际物理内存容量。
那么操作系统如何为多个并发程序分配内存呢?举例来说,假设一台计算机拥有128MB物理内存,同时运行程序A(需要10MB)和程序B(需要110MB)。操作系统会采取这样的分配策略:首先分配内存前10MB给程序A,然后在剩余的118MB内存中划出110MB分配给程序B。
这种分配方法虽然能确保程序A和B正常运行,但采用简单内存分配策略会带来诸多问题:
安全风险
- 每个进程都能访问任意内存空间,意味着系统内存区域可能被任意读写。若存在木马病毒,可以直接篡改内存导致设备瘫痪。
地址不确定性
- 编译后的程序存储在硬盘上,运行时需加载到内存。若直接使用物理地址,每次运行时的实际内存地址都不确定。例如:
- 首次执行a.out时内存空闲,加载地址为0x00000000
- 第二次执行时若有10个进程在运行,加载地址就难以确定
效率低下
- 直接操作物理内存时,进程作为整体(内存块)处理。当内存不足时,通常需要将不常用进程转移到磁盘交换分区。若使用物理地址,必须迁移整个进程,导致内存与磁盘间拷贝耗时过长,效率低下。
那么引入虚拟地址空间和分页机制能否解决这些问题?答案是肯定的!
每个进程拥有独立的虚拟地址空间(如32位系统通常为0~4GB),使得进程认为自己独占内存,实际上并没有,而是先画个大饼(虚拟地址空间),等需要的时候再分配物理内存空间。
关键优势
-
安全保护机制
- 地址空间和页表由OS创建维护,所有映射操作都必须在OS监管下进行
- 有效保护物理内存中的所有合法数据,包括各进程及内核数据
-
解耦设计
- 地址空间和页表映射使物理内存可以任意位置加载数据
- 物理内存分配与进程管理完全解耦,实现模块化
-
延迟分配机制
- 在C/C++中使用new/malloc时,实际只在地址空间申请
- 物理内存可能暂不分配,直到真正访问时才建立页表映射
- 整个过程由OS自动完成,对用户和进程完全透明(不可见)
-
灵活映射
- 页表映射允许程序在物理内存中任意位置加载
- 将虚拟地址与物理地址映射,使进程视角的内存分布保持有序性
十、关于一些问题和回答
1、不加载代码和数据,仅维护内核结构
核心概念:轻量级进程框架
-
仅保留关键内核结构
-
task_struct
:进程描述符,管理进程状态、PID、调度信息等。 -
mm_struct
:虚拟内存管理结构,记录地址空间、页表等。 -
页表:初始化为空或最小映射(如仅内核空间)。
-
-
应用场景
-
内核线程:无需用户态代码(如
ksoftirqd
)。 -
vfork()
优化:子进程共享父进程空间,延迟加载。
-
-
优势:节省内存和启动时间,适用于后台任务或快速进程孵化。
2、进程创建顺序:先有结构,再加载数据(先骨架(内核结构),后血肉(代码数据))
详细流程
-
分配内核结构:内核首先创建
task_struct
和mm_struct
,初始化基本字段(如PID、优先级)。 -
建立虚拟地址空间框架:初始化页表(仅映射内核空间),用户空间暂为空。
-
加载代码和数据
-
execve()
时加载:读取可执行文件,填充页表并分配物理页。 -
写时复制(COW):
fork()
时共享父进程页表,修改时再分配。
-
为什么这样设计?
-
灵活性:允许先创建进程框架,再按需加载(如动态链接库延迟绑定)。
-
性能优化:避免无用的代码加载(如
fork()
后立即execve()
)。
3、进程挂起(Process Suspension)
定义与机制
-
挂起状态:进程被暂时移出内存(常驻磁盘),保留元数据(如
task_struct
)。 -
触发条件
-
系统资源不足(如OOM Killer)。
-
用户主动挂起(如
Ctrl+Z
发送SIGTSTP
)。 -
调试或检查点(Checkpointing)。
-
挂起 vs 就绪/阻塞
状态 | 内存驻留 | CPU调度 | 恢复条件 |
---|---|---|---|
就绪 | 是 | 可被调度 | 获取CPU时间片 |
阻塞 | 是 | 不可调度 | 等待的资源可用 |
挂起 | 否 | 不可调度 | 显式唤醒或资源充足 |
实现原理
-
内存回收:将进程的代码、数据页换出到磁盘(交换分区/Swap)。
-
元数据保留:
task_struct
和mm_struct
保留,标记为TASK_STOPPED
或TASK_SUSPENDED
。 -
恢复流程:重新加载页表,将数据从磁盘换入内存,状态置为就绪。
示例场景
-
交换空间不足:系统挂起低优先级进程以释放内存。
-
进程调试:GDB挂起进程检查变量后恢复。
4、关联问题扩展
-
Q1:
task_struct
和mm_struct
谁先创建?
A1:task_struct
优先,mm_struct
是其成员,在进程初始化时分配。 -
Q2:挂起的进程如何避免资源泄漏?
A2:内核会释放CPU和内存,但保留文件描述符等元数据。