【Linux 系统】进程虚拟地址空间与写时拷贝
父进程创建子进程后两边打印同一个全局变量可能得到完全相同的地址等子进程修改变量后两边的地址仍然相同值却不一样。如果把程序打印的地址理解成物理内存地址这个现象就解释不通。真正需要补上的一层是进程虚拟地址空间程序使用虚拟地址内核通过内存区域和页表把它转换为实际的物理内存访问。fork()的写时拷贝也正是建立在这一层映射之上的。抓住整条主线这一部分可以先压缩成两条链。程序访问内存时C 语言中的指针值 ↓ 进程虚拟地址 ↓ 查询地址所在的虚拟内存区域 访问类型是否合法 ↓ 页表完成地址转换并检查权限 ↓ 物理页中的实际数据fork()之后发生写入时父进程创建子进程 ↓ 父子拥有各自的虚拟地址空间 ↓ 初始页表可以映射同一批物理页 ↓ 双方暂时只读共享可写数据页 ↓ 某一方第一次写入 触发页故障并进入内核 ↓ 确认这是合法的写时拷贝访问 ↓ 准备独立物理页并复制原有内容 ↓ 只修改写入方的页表映射 ↓ 重新执行写指令因此写时拷贝不是独立于虚拟内存的技巧。没有虚拟地址到物理页的映射关系就无法在保持虚拟地址不变的同时让父子进程逐渐使用不同的物理页。1. 程序打印的地址是什么在普通 Linux 用户程序中指针保存的是进程虚拟地址。CPU 执行访存指令时由内存管理单元结合当前地址空间的页表完成转换用户程序通常不会直接得到普通内存对应的物理地址。一个地址值必须放在所属进程的上下文中理解进程 A 中的 0x1234 ──► 可能映射物理页 X 进程 B 中的 0x1234 ──► 可能映射物理页 Y数字相同只能说明两个进程使用了相同的虚拟地址不能说明它们一定访问同一个物理位置。反过来不同虚拟地址也可能因为共享库、共享内存或文件映射而指向同一个物理页。地址空间大小不能一律写成 4 GB在 32 位地址模型中地址宽度为 32 位理论范围是02^32-1也就是 4 GiB 的虚拟地址范围。但这并不表示用户进程可以使用全部 4 GiB更不表示机器真的安装了 4 GiB 物理内存。现代 Linux 经常运行在 64 位平台上。虚拟地址宽度、用户空间和内核空间的划分、实际可用范围都与处理器架构、内核配置和 ABI 有关不能把 32 位平台的布局数值直接搬过来。2. 常见的用户虚拟地址布局从低地址到高地址Linux 进程经常能观察到代码、只读数据、可写数据、堆、内存映射区和栈等区域。不过这只是帮助理解的典型布局不是所有平台都必须遵守的固定刻度图。较高虚拟地址 ┌──────────────────────────────┐ │ 栈、启动参数和环境字符串 │ │ 通常按需扩展 │ ├──────────────────────────────┤ │ 共享库、文件映射、匿名映射 │ │ 大块动态分配也可能出现在这里 │ ├──────────────────────────────┤ │ 传统堆区域 │ │ 一部分动态分配可能来自这里 │ ├──────────────────────────────┤ │ BSS零初始化的静态存储期对象 │ ├──────────────────────────────┤ │ 可写数据 │ ├──────────────────────────────┤ │ 只读数据 │ ├──────────────────────────────┤ │ 可执行代码 │ └──────────────────────────────┘ 较低虚拟地址几个名称容易混在一起名称观察角度例子ELF section编译、链接时组织目标文件.text、.rodata、.data、.bssELF segment程序装载时使用的视图可执行、只读、可写的装载段VMA进程运行时由内核维护的连续虚拟区间代码映射、堆、栈、共享库、匿名映射它们之间有联系但不是三个完全相同的概念。一个装载段可以包含多个 section内核最终维护的是运行时映射及其权限而不是照着教科书画出几个永远不变的方框。静态变量放在哪里全局变量和static变量都具有静态存储期。它们更常按“是否需要在可执行文件中保存非零初值”来组织带有非零初始值的对象通常进入可写数据区域没有显式初始化或初始化为零的对象通常可以放入 BSS具体 section 和排列顺序由编译器、链接器和目标格式决定。所以“静态变量固定放在初始化数据和未初始化数据之间”并不是可靠规则。static说明的是生命周期和链接属性不是一个独立的地址区。3. 用地址观察运行时布局下面只打印地址不尝试读取物理页。递归调用用于观察当前平台上不同栈帧的地址变化。#includestdio.h#includestdlib.hintglobal_initialized10;intglobal_uninitialized;staticvoidshow_stack_frames(intdepth){intmarkerdepth;printf(stack frame %d : %p\n,depth,(void*)marker);if(depth3){show_stack_frames(depth1);}}intmain(intargc,char*argv[]){staticintstatic_initialized20;staticintstatic_uninitialized;constchar*literalhello virtual memory;intstack_local30;void*heap_firstmalloc(32);void*heap_secondmalloc(32);if(heap_firstNULL||heap_secondNULL){free(heap_first);free(heap_second);perror(malloc);return1;}printf(global initialized : %p\n,(void*)global_initialized);printf(global uninitialized : %p\n,(void*)global_uninitialized);printf(static initialized : %p\n,(void*)static_initialized);printf(static uninitialized : %p\n,(void*)static_uninitialized);printf(string literal : %p\n,(constvoid*)literal);printf(heap block 1 : %p\n,heap_first);printf(heap block 2 : %p\n,heap_second);printf(stack local : %p\n,(void*)stack_local);if(argc0){printf(argument string : %p\n,(void*)argv[0]);}show_stack_frames(1);free(heap_second);free(heap_first);return0;}运行结果global initialized : 0x60381fd83010 global uninitialized : 0x60381fd8301c static initialized : 0x60381fd83014 static uninitialized : 0x60381fd83020 string literal : 0x60381fd81021 heap block 1 : 0x603854f90010 heap block 2 : 0x603854f90040 stack local : 0x7ffda731ac6c argument string : 0x7ffda731cf84 stack frame 1 : 0x7ffda731ac34 stack frame 2 : 0x7ffda731ac04 stack frame 3 : 0x7ffda731abd4这次运行可以观察到字符串字面量与可写的全局、静态对象不在同一小段地址范围两次小块动态分配地址接近栈和启动参数位于较高地址在当前 x86-64 环境中递归变深时栈帧地址逐渐减小。这些地址不能作为固定答案。地址空间布局随机化会让程序每次运行时的栈、堆、共享库以及位置无关可执行文件等映射发生变化。C 语言也没有规定栈必须向低地址增长更没有保证连续两次malloc()一定按地址递增。实验打印的是几个点而不是整张内存映射。要查看一个 Linux 进程当前有哪些映射区域及其权限可以观察/proc/pid/maps其中每一行表示一段虚拟地址范围权限字段中的r、w、x分别表示读、写、执行p表示私有映射s表示共享映射。4. 内核怎样描述一张地址空间对普通用户进程来说可以先记住三层关系task_struct │ └──► mm_struct描述一张完整的用户虚拟地址空间 │ ├──► VMA一段连续、属性一致的虚拟地址范围 ├──► VMA另一段范围 └──► 页表把虚拟页映射到物理页并记录权限mm_structtask_struct中的mm指针把任务与用户虚拟地址空间联系起来。同一进程中的线程共享地址空间因此它们引用同一个mm_struct由fork()创建的父子进程则拥有不同的地址空间描述。VMA一个 VMA 通常描述一段[vm_start, vm_end)形式的连续虚拟区间并记录读、写、执行、共享或私有、文件映射或匿名映射等属性。两个相邻区域如果权限或后端对象不同就需要分开描述。访问一个地址时内核首先要判断它是否落在有效 VMA 中以及此次访问是否符合该区域的逻辑权限。没有对应区域或者对只读区域进行写入都不能仅靠“页表中有一个地址”就放行。内核数据结构会随版本演进早期 Linux 资料常用 VMA 链表和红黑树解释区域管理这与 Linux 2.6.32 一类旧内核背景相符。当前内核文档中mm_struct使用 Maple Tree 管理 VMA。学习时真正需要保留的是稳定关系mm_struct描述地址空间VMA 描述连续且属性一致的区域。链表、红黑树或 Maple Tree 属于具体版本为了查找、修改和并发性能作出的实现选择不应背成永远不变的接口。5. 页表怎样连接虚拟地址与物理内存虚拟地址通常被拆成“虚拟页编号 页内偏移”。多级页表根据虚拟页编号逐层查找在叶子项中找到物理页信息和权限位页内偏移在转换前后保持不变。虚拟地址 ┌──────────────────────┬─────────────┐ │ 用于逐级查询页表的部分 │ 页内偏移 │ └──────────┬───────────┴──────┬──────┘ ↓ │ 保持不变 多级页表查询 │ ↓ ↓ ┌──────────────────────┬─────────────┐ │ 物理页框编号 │ 页内偏移 │ └──────────────────────┴─────────────┘页表项不只是保存地址还会带有是否存在、是否可写、用户态能否访问等状态。处理器还会使用 TLB 缓存近期地址转换避免每次访存都完整遍历多级页表。需要注意地址位于某个 VMA并不表示对应物理页已经常驻内存。物理页可能尚未按需分配也可能被换出此时访问会引发页故障由内核判断怎样处理。6. 为什么需要虚拟地址空间6.1 隔离与保护每个进程在自己的地址空间中运行不能仅凭一个普通指针访问另一个进程的私有内存。VMA 属性和页表权限还可以把代码设为可执行但不可写把普通数据设为可读写但不可执行。6.2 给程序稳定的地址视图程序不必知道某个页面最终放在哪个物理页框。即使物理页位置变化只要内核更新映射进程仍可继续使用原虚拟地址。加载器、共享库和地址空间随机化也都能利用这种间接层。6.3 支持稀疏分配和按需调页一大段虚拟地址范围不一定立即对应同样大小的常驻物理内存。只有真正访问到某些页面时内核才可能为它们准备物理页或把数据调入内存。6.4 支持共享和写时拷贝多个地址空间可以把各自的虚拟地址映射到同一个物理页用于共享库、共享内存和写时拷贝。需要隔离时再修改其中一方的映射即可。malloc()到底申请了什么malloc()是 C 库内存分配接口不是简单等同于“直接向内核要一个物理内存块”。以 glibc 为例一部分分配可能由传统堆区域提供较大的分配也可能使用私有匿名映射多线程情况下还可能存在多个分配 arena。因此malloc()成功首先表示分配器交给程序一段可使用的虚拟地址范围。受按需调页和 Linux 内存过量承诺策略影响相关物理页不一定在返回瞬间全部准备完成。真正触碰页面时才可能发生实际的页分配与映射工作。这也说明两点堆不应理解成进程中唯一的动态内存来源多次分配得到的地址也不保证严格连续或始终向同一方向增长。7.fork()为什么使用写时拷贝fork()成功后父子进程运行在不同内存空间中但创建时两边的虚拟地址空间内容相同。最直接的实现方式是立即复制父进程的所有可写内存问题是很多子进程很快就会调用exec即使不替换程序也未必会修改大部分页面提前复制会浪费时间和物理内存。Linux 通常采用写时拷贝fork() 刚完成 父页表 ──┐ ├──► 同一物理页value 100 子页表 ──┘ 父子从各自地址空间访问页面暂时共享对原本可写、需要采用 COW 的私有页面内核让相关页表映射暂时不能直接写入。当子进程执行写指令时第一步CPU 发现当前页表项不允许直接写触发页故障 第二步内核找到对应 VMA确认这段区域逻辑上允许写入 第三步判断这是合法的写时拷贝情况而不是非法越界 第四步为写入方准备可写物理页并保留原页面内容 第五步只更新写入方的页表项 第六步重新执行刚才的写指令完成后映射关系变成父页表 ─────► 原物理页value 100 子页表 ─────► 新物理页value 200 父子使用的虚拟地址数值仍可完全相同写时拷贝通常以页为管理粒度而不是以某个 C 变量为粒度。修改一个int可能需要处理包含它的整页。实际内核还会根据页面引用情况优化如果写入方已经成为该页的唯一使用者就可能不再需要真正复制只需恢复合适的写权限。8. 用父子进程观察写时拷贝后的结果下面使用管道控制顺序子进程完成修改并输出后通知父进程父进程再读取自己的变量。这样观察结果不依赖谁先被调度也不用通过固定休眠时间碰运气。#define_POSIX_C_SOURCE200809L#includeerrno.h#includestdio.h#includestdlib.h#includesys/types.h#includesys/wait.h#includeunistd.hstaticintvalue100;staticintsend_ready(intfd){constchartokenR;while(write(fd,token,1)-1){if(errno!EINTR){return-1;}}return0;}staticintwait_ready(intfd){chartoken0;ssize_tresult;do{resultread(fd,token,1);}while(result-1errnoEINTR);returnresult1tokenR?0:-1;}intmain(void){setvbuf(stdout,NULL,_IONBF,0);intready_pipe[2];if(pipe(ready_pipe)-1){perror(pipe);return1;}printf(before fork : value%d, virtual_address%p\n,value,(void*)value);pid_tpidfork();if(pid-1){perror(fork);return1;}if(pid0){close(ready_pipe[0]);printf(child before write : value%d, virtual_address%p\n,value,(void*)value);value200;printf(child after write : value%d, virtual_address%p\n,value,(void*)value);if(send_ready(ready_pipe[1])-1){_exit(1);}close(ready_pipe[1]);_exit(0);}close(ready_pipe[1]);if(wait_ready(ready_pipe[0])-1){fprintf(stderr,failed to synchronize with child\n);return1;}close(ready_pipe[0]);printf(parent after child write: value%d, virtual_address%p\n,value,(void*)value);intstatus0;if(waitpid(pid,status,0)-1){perror(waitpid);return1;}if(!WIFEXITED(status)||WEXITSTATUS(status)!0){fprintf(stderr,child process failed\n);return1;}return0;}运行结果before fork : value100, virtual_address0x6088a4b50010 child before write : value100, virtual_address0x6088a4b50010 child after write : value200, virtual_address0x6088a4b50010 parent after child write: value100, virtual_address0x6088a4b50010结果中有三个关键点子进程写入前父子看到相同的初始内容子进程写入后打印出的虚拟地址没有变化父进程最后仍然读到100说明子进程的写入没有改掉父进程的私有数据视图。这个实验直接证明了父子进程内存写入彼此隔离也说明相同的指针数值不等于同一个可共同修改的变量。至于“第一次写入前共享物理页、写入时按页复制”的具体实现需要结合 Linux 的fork()语义和内核内存管理机制来解释只看四行用户态输出不能直接读取或证明某个物理页框编号。9. 页故障不等于程序崩溃“页故障”描述的是 CPU 无法按当前页表状态完成访问需要内核介入。它既可能是正常机制也可能来自程序错误。可以被内核正常处理第一次访问尚未分配物理页的匿名内存访问尚未调入内存的文件映射页页面已被换出需要重新调入对合法的私有 COW 页面进行第一次写入。内核完成分配、调页或写时拷贝后程序通常从原指令继续运行用户不会看到崩溃。无法按当前映射规则完成地址根本不属于任何有效 VMA写入逻辑上只读的映射用户态访问仅允许内核使用的地址执行没有执行权限的数据页。这类访问不能被解释为合法需求Linux 通常向进程发送SIGSEGV。修改字符串字面量不是写时拷贝实验字符串字面量不应被程序修改。在 C 语言层面对它写入属于未定义行为在常见 Linux 映射中它通常位于只读区域写入可能收到SIGSEGV。虽然它和 COW 都可能先表现为“向当前不可写的页执行写指令”但内核还要查看 VMA 的逻辑权限原本可写的私有 COW 页面此次写入合法可以复制后继续原本就只读的字符串区域此次写入不合法不能通过复制变成可写。所以页表中的“暂时不可写”和程序语义上的“永远不允许写”不能混为一谈。10. 堆向上、栈向下应该怎样理解在常见 x86-64 Linux 进程中传统brk堆区域常向较高地址扩展主线程栈常向较低地址扩展。这是理解典型布局的有用观察但不能升级成 C/C 语言层面的硬规则。栈方向由体系结构和 ABI 决定有的架构可以采用不同方向编译器可以重排、复用或直接优化掉局部变量比较几个局部变量地址不一定能推出调用栈方向malloc()是分配器接口可能从brk堆、匿名映射或不同 arena 中提供空间相邻两次分配的地址先后受空闲块复用、对齐、分配大小和实现策略影响。因此更准确的说法是“本次平台和本次运行观察到这样的增长方向”而不是“堆栈天生相向生长”。11. 几个容易写错的结论11.1 页表就是一张简单的虚拟地址—物理地址表吗这是入门模型。现代系统通常使用多级页表页表项还包含权限、存在状态和其他架构相关标志TLB 又会缓存转换结果。11.2 父子进程继承同一张页表吗不能简单说成共享同一张可随意修改的页表。父子拥有不同地址空间fork()需要建立子进程自己的页表结构或等价管理状态初始页表项可以指向相同物理页以便实现写时拷贝。之后一方更新自己的映射不会直接把另一方的映射一起改掉。11.3 地址相同、值不同是否已经完整证明 COW它证明了地址是按进程解释的并证明父子私有内存修改相互隔离。COW 的物理页共享和复制时机属于内核实现需要结合系统接口文档、内核文档或更底层的内存统计工具确认不能只凭指针输出推导全部细节。11.4malloc()返回成功物理内存就已经全部分配了吗不一定。分配器管理、匿名映射、按需调页和过量承诺共同影响实际物理页何时准备。返回非空指针也不是未来绝不会遇到内存压力的保证。11.5 写时拷贝会复制整个进程吗不会。核心目标正是避免立即复制整个地址空间。发生写入时通常按页面处理而且只处理需要独立化的页面。11.6 所有页故障都会收到SIGSEGV吗不会。按需分配、调页和 COW 都可能利用页故障正常完成工作。只有内核确认访问不合法或无法修复时用户进程才会收到相应信号。12. 小结进程地址空间可以看成进程对内存的虚拟视图。内核使用 VMA 描述哪些虚拟范围有效、具有什么属性再通过页表把虚拟页映射到物理页。虚拟地址空间解决“进程看见什么” VMA 解决“这一段允许怎样访问” 页表解决“虚拟页当前映射到哪里” 页故障解决“当前映射不能直接完成访问时怎么办” 写时拷贝解决“父子怎样先共享、写入后再隔离”理解了这几层“相同地址为什么能有不同值”就不再矛盾相同的是父子进程中的虚拟地址数值不同的是各自页表最终提供的数据视图。写时拷贝利用这层映射关系在保证进程独立性的同时避免fork()一开始就复制所有可写内存。