ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

进程映像与内存紧缩:从内核布局到完整搬移机制详解

进程映像与内存紧缩:从内核布局到完整搬移机制详解 进程管理里的“进程图像”和“收缩”这两个概念初学操作系统时特别容易被一句话带过。教材上可能就写“进程映像由PCB、程序段、相关数据、堆栈组成”“收缩也叫紧凑Compaction”然后就没了。当年我复习时对着这两段发呆图像到底长什么样收缩是为了解决什么问题为什么说开销大后来啃完《计算机操作系统》汤小丹版、做完几个内存管理实验又把Linux内核源码翻了一遍才真正把这块串起来。这篇笔记就把我踩过的坑和梳理清楚的脉络全写出来给同样卡在这里的朋友参考。这篇文章围绕进程的“图像”与“动态收缩”展开图像部分讲进程在内存中的真实布局收缩部分讲内存碎片整理的核心机制与代价操作系统课程设计、考研复习、期末冲刺都适用。尤其建议配合一个模拟内存分配的C语言小实验来理解收缩后面我会把实验思路给出。1. 先搞懂“进程图像”到底长什么样1.1 PCB、程序段、数据段、堆栈的物理归宿进程图像Process Image不是屏幕上窗口截图而是进程在内存中存放的完整“底稿”。教材上四个组成部分——PCB、程序段、相关数据、堆栈——很多人背得溜但问一句“程序段放在哪个区、数据段放在哪个区、堆栈向哪个方向长”就懵了。我用一张自画的逻辑分区图来记画多了自然就记住了PCB存放在内核空间的进程表项中现实中位于系统区程序段代码段放在用户空间的低地址区只读可共享数据段紧跟着代码段细分为初始化数据段.data、未初始化数据段.bss和只读数据段.rodata堆Heap在数据段上方向上增长栈Stack在用户空间的高地址区向下增长中间留着巨大的“空洞”供堆和栈动态伸缩使用。这个布局是x86/Linux下最经典的形态。不同教材表述略有差异但本质一样程序是磁盘上的静态文件进程是程序在内存中的活动化身。进程图像 静态部分的代码与数据 动态部分的内核记录、堆和栈。1.2 从磁盘到内存装进进程图像的过程程序在磁盘上只是一堆字节运行时要经过“加载”变成内存中的进程图像。加载过程不是简单的拷贝而是分区的映射代码段直接映射到用户空间低地址并设置只读可执行权限数据段拷贝到内存完成后在CPU眼中它才是“进程的数据”而不是“文件里的数据”BSS段按大小清零堆区初始化brk指针用户栈由内核按栈大小上限初始化栈底是最高地址。Linux中fork()创建子进程时子进程的进程图像是父进程的“逐字节复制”写时复制优化则先共享页表exec族函数则是重新加载新程序的图像。操作系统原理教材大多讲“由PCB、程序段、数据、堆栈组成”内核视角就是“mm_struct 代码段vma 数据段vma 堆vma 栈vma”。1.3 一个直观的进程图像地址示例下面这段代码我常在梳理“进程图像”时跑一遍打印关键地址来对照理论#include stdio.h #include stdlib.h int g_val 10; // 初始化数据段 int g_uninit; // BSS段 const char *ro hello; // 只读数据段指针变量本身在数据段 int main() { int local 1; // 栈 int *heap malloc(16); // 堆 printf(main: %p\n, main); printf(ro str: %p\n, ro); printf(g_val: %p\n, g_val); printf(g_uninit: %p\n, g_uninit); printf(heap: %p\n, heap); printf(stack: %p\n, local); return 0; }某次在64位Linux系统上的运行结果地址值从高到低排列对象地址区间所属分区栈变量 local0x7ffd...高地址栈向下长堆变量 heap0x5579...中低地址堆向上长初始化全局 g_val0x5579...更低.data未初始化 g_uninit0x5579...再低.bss只读字符串 ro0x5579...低地址.rodata函数 main0x5579...最低地址.text我建议你也跑一遍眼见为实。这里有个经常被忽视的点栈和堆都在用户空间但栈底接近地址空间顶端堆在数据段上方二者相向生长才有“中间空洞”一旦相逢就会发生栈溢出或堆溢出这是后话。2. 为什么需要“收缩紧凑”这个动作2.1 动态分区分配把内存切碎了讨论收缩必须回到动态分区分配。连续分配管理方式下内存被切成若干分区给不同进程进程结束释放分区后内存中留下大小不一的“洞”。随着时间推移大的洞被分成小块这种被切割得无法放下一个新进程的小空闲区就是外部碎片。试想一个100MB的内存分布着若干进程和若干空闲块每个空闲块都不超过1MB但空闲块总量有20MB。此时来个需要3MB的新进程内存总量足够却一个连续3MB的区间都找不到。这就是外部碎片最直观的困境。2.2 收缩的定义把“洞”搬到一起收缩Compact又叫紧凑或内存紧缩。思路直白移动已在内存中的进程把所有占用分区往低地址一端挪把空闲分区集中到高地址一端拼出一个更大的连续空闲区。教科书里的图通常是三四个进程与若干碎片交错收缩后全部进程紧贴碎片收集成单独一块。这个动作看起来“搬个家就行”可每个进程搬家都要改“地址翻译”的逻辑。在静态重定位的年代进程内所有绝对地址已经在程序装入时就定死了一搬就全错。要在运行时动态搬移必须依赖动态重定位——MMU中的重定位寄存器进程的基址寄存器值随搬迁更新即可。页面映射、段映射同理换页表项、段表项里的物理块号。2.3 收缩的目标与代价收缩的最大优点是能真正解决外部碎片让分配器有能力容纳大作业。缺点也很明显开销大每一次搬移都要拷贝进程地址空间大进程动辄几百MB停机时间长搬移过程中该进程不能运行IO、 DMA都只能暂停等待开销与时间不定系统负载高时收缩可能持续数秒甚至更久实际操作系统几乎不做“全量搬移”Linux靠分页虚拟内存避免碎片而不是靠收缩。教学中收缩的价值是理解连续分配机制的理论基石实践中它被分页虚拟化取代。但UNIX早期、某些嵌入式实时系统依然在使用紧凑思想的变体。3. 收缩机制的完整拆解从触发到完成3.1 什么时候会触发收缩只有发生了“分配失败但总空闲够”的情况才有做收缩的意义。例如系统收到新进程的请求所需连续区大小 N查找空闲分区表发现每个空闲块都 N但所有空闲块累计 N于是启动内存紧缩把进程全部向低地址端搬移空闲区合并成一大块重新分配。条件判断里隐藏一个细节如果总空闲区 N就别做收缩了浪费时间。可以先把空闲块累加小于N直接报“内存不足”。这个判断逻辑我在学生时代的实验里漏掉过直接导致程序低效空转。3.2 搬移过程中“地址重定位”怎么保证不翻车搬移的本质是把进程映像的一个物理区域搬到另一物理区域。难点在“进程不知道自己在动”。动态重定位方案里CPU每次取址或访存都会通过MMU完成“逻辑地址→物理地址”的转换转换时依赖寄存器基址寄存器或页表/段表。搬移步骤暂停目标进程不能让它在搬一半时访问到半新半旧的地址拷贝进程的数据和代码到新位置比如更低地址的空闲区头部拷贝完成修改该进程的基址寄存器或相关表项指向新物理基址修改完成后解除暂停进程继续运行随后每一次地址转换都落新地址上。先改表项再拷贝是最容易出的反向错误进程一旦被调度立即用新地址访问旧内容结果一片乱。顺序必须是“搬完→更新映射→恢复运行”。我见过不止一个实验报告在这步翻车给人调疯了——代码把更新映射写在拷贝之前页表改完之后DMA又往旧地址去拷贝两边就全错乱。3.3 一段模拟收缩的C代码可直接运行为了把理论落地我在Linux下写过一个动态分区模拟器核心函数就是紧凑#include stdio.h #include stdlib.h #include string.h #define MEM_SIZE 100 #define MAX_PROC 5 typedef struct proc { int pid; int addr; // 起始地址 int size; // 大小 } Proc; Proc procs[MAX_PROC]; int proc_count 0; char memory[MEM_SIZE]; // 模拟物理内存 void show_memory(int *occupied) { for (int i 0; i MEM_SIZE; i) { if (occupied[i]) { printf(#); } else { printf(.); } } printf(\n); } void compact(int *occupied) { printf(\n 开始收缩 \n); int dest 0; for (int i 0; i proc_count; i) { if (procs[i].size 0) continue; int src procs[i].addr; if (src ! dest) { memmove(memory dest, memory src, procs[i].size); procs[i].addr dest; } dest procs[i].size; } memset(memory dest, 0, MEM_SIZE - dest); memset(occupied, 0, sizeof(int) * MEM_SIZE); for (int i 0; i proc_count; i) { if (procs[i].size 0) { for (int j 0; j procs[i].size; j) { occupied[procs[i].addr j] 1; } printf(进程 %d 新地址: %d, 大小: %d\n, procs[i].pid, procs[i].addr, procs[i].size); } } show_memory(occupied); }这段代码的诀窍在于用memmove而不是memcpy——源区域和目标区域可能重叠memcpy的行为在重叠时未定义。memmove保证安全先拷到临时缓冲或按安全顺序搬移。很多学生用memcpy做模拟结果搬移本身就把数据覆盖了碎片的坑还没填上数据先坏了。这个模拟器把“物理内存”抽象成一个字节数组occupied数组标记每个字节是否占用。收缩的流程跟真实操作系统完全同构找到每块进程的新目标地址搬过去改记录中的addr最后把所有标记重建。3.4 收缩算法的时间复杂度与选择紧凑过程中需要扫描所有进程决定搬迁顺序通常按地址从低到高排列。总体开销 遍历进程O(n) 数据搬移按总内存计O(M) 重映射更新O(n)。M是内存总大小n是进程数量真正耗时间的是O(M)的搬移。假设内存8GB、搬移速率每秒几百MB一次全量收缩要几十秒。实时系统扛不住所以这些系统宁可用成熟分区策略也不愿意频繁收缩。选择收缩时机还有策略问题是每次失败都收缩还是内存碎片化达到某个阈值才收缩前者响应快但频繁大动作后者平滑但判断复杂。实际设计里要用“空闲区总量减去最大空闲块”作为碎片度指标碎片度超过阈值再触发。4. 收缩与其他内存管理技术的对比4.1 对比分页、分段与分区机制碎片类型解决碎片方式额外开销一次搬移成本动态分区收缩外部碎片搬移进程集中碎片高必须暂停高O(内存大小)分页内部碎片按页分配减小内部碎片中页表低按页换入换出分段外部碎片段表重定位可收缩中段表低按段搬移段页式内外部碎片两级表管理高中这里要理清一个常见误区分页本身不产生外部碎片但每页最后一块总可能装不满产生平均半页的内部碎片。而连续分区的收缩用搬移来换取连续大块效率远低于分页“天然无需连续”的哲学。所以现代通用操作系统不会靠收缩处理碎片而是靠页。实时操作系统、没有MMU的嵌入式裸机环境中连续分配还在用紧凑机制仍有工程价值。4.2 动态重定位是收缩的前提条件重定位分两种静态重定位程序装入时改妥地址和动态重定位运行时由硬件转换。收缩只可能在动态重定位的系统下工作。读取一个程序时如果链接器生成的是绝对地址代码装入后就不能动了如果要支持收缩编译和链接需要生成可重定位代码PIC系统运行时通过基址寄存器动态换算。所以回忆教材顺序先讲重定位再讲收缩是有原因的。进程图像所以能“搬得动”前提就是“图像里的地址不是物理地址而是逻辑地址”。4.3 Linux现实中怎么处理碎片Linux采用伙伴系统Buddy System SLUB分配器物理页面被拆成2的幂次块分配和释放都极快再加上页表映射进程的虚拟地址空间天然“不需要连续物理内存”。所以Linux几乎不需要做“收缩”这种全量搬移只会有页面迁移page migration用于内存热插拔、CMA区域整理、NUMA均衡这些特殊场景。页面迁移可以看作“收缩的页级版本”一次只搬一页更新该页的页表项其他页不受影响开销小且可并发。明白了这一点再回看操作系统的演进其实脉络很清晰连续分配→动态分区→紧凑解决外部碎片→分页从根本消除连续需求→页式管理按需调页解决内存不足。表面上是不同技术选型背后是“连续”二字被一步步放弃换取效率。5. 难免出错的几个细节与实战教训5.1 收缩必须在“空闲总容量足够”时才能做前文提过收缩前必须判断。假设内存总共100MB空闲碎片合计15MB来了一个请求20MB的进程就算把天搬穿也凑不出20MB。这时直接拒绝或换其他策略如换出进程到磁盘省得做无用的搬移。这个判断虽然基础但模拟实验里人人踩坑。5.2 搬家先暂停恢复前再把地址改掉我重复一遍这个顺序因为真的重要暂停进程 → 搬移数据 → 更新映射 → 恢复。若是分页系统搬完一页更新对应页表若是连续分区要更新整个进程的基址。若先改映射再搬家只要有一瞬间调度到该进程CPU就去新地址找到一堆还没搬来的残骸当场崩溃。5.3 用memmove别用memcpy写模拟器的直接教训如果源区间和目标区间存在重叠memcpy的行为无法保证直接拷贝可能把数据覆盖。memmove则专门处理重叠场景它会判断方向从后往前拷或借助临时存储。所以说很小的代码差异实际表现天差地别这属于写出“像样”的模拟器必须掌握的点。5.4 收缩不是越快越好有的实现里为了“快”直接调用系统级的页面迁移甚至DMA搬运。但DMA搬运期间内存总线忙CPU要等待实时任务直接被卡顿。工程上宁可选择多个时间点小规模迁移也不要做一次大搬家把系统冻住。这也是“收缩”思想在现代系统中的变体——渐进式迁移。5.5 学会用“空闲分区合计”快速判断是否该紧凑实际判断逻辑我简化成了几行伪代码if (available_total need) { if (max_free_block need) { // 直接分配 } else { // 执行compact然后分配 } } else { // 无法满足报错或换出 }这比盲目紧凑更合理也符合真实内核中内存碎片整理的触发条件。理解这个伪代码就理解收缩存在的意义内存总量够但连续性不够。6. 常见问题与排查技巧快查表我把平时被问最多的问题和对应排查思路整理成了一张表问题原因排查/解决思路收缩后程序运行崩溃映射更新先于数据搬移检查顺序搬移→更新映射→恢复收缩后输出数据错乱用memcpy搬移导致重叠覆盖改成memmove模拟器中检查dest和src区间系统频繁触发收缩仍卡顿收缩阈值过低或总空闲不足提高碎片度阈值或直接拒绝超额请求进程搬迁后DMA数据不一致搬移期间DMA仍在往旧地址写搬移前停掉IO设备搬完再恢复或使用IOMMU收缩后性能反而下降大进程搬移开销太大设置进程大小上限超过上限不搬移放弃分配定位不到空闲块空闲表结构没维护每次搬移后合并相邻空闲区重建空闲表在实际操作系统课设里还容易遇到一个很隐蔽的问题模拟器显示的内存占用和真实系统里的free命令结果对不上。原因通常是模拟器省略了内核自身占用、页表占用的内存以及TCP/文件缓存。这不怪模拟器只是做实验时要清楚自己模拟的是“用户态视域”下的内存不是物理内存全貌。还想补充一个考研高频考点紧凑只解决外部碎片不解决内部碎片而分页恰好解决外部碎片但产生内部碎片。做题时遇到“哪种技术解决外部碎片”就把收缩、紧凑、分段搬移、分页写上去遇到“内部碎片”就跟分页最后一页联系。这两组概念容易混淆我当年在卷子上栽过跟头。7. 实操在Linux下验证进程图像地址分布纸上谈兵终觉浅。建议你开一个Linux虚拟机或直接用WSL把上面的C代码编译运行。如果没装gcc可以用Ubuntu的APT装也可以在Windows的WSL环境里直接跑干净利落。操作步骤gcc -o procimg procimg.c ./procimg观察输出的地址范围再对照/proc/pid/maps文件能看到每个映射区域的起始和结束地址。我用一个例子说明假设输出里main地址是0x55a123456000而/proc/self/maps的第一行包含同样的库或者可执行文件路径这就验证了代码段在内存中的映射。cat /proc/self/maps在程序内自视很特别会显示进程自身的内存映射目录。编译时加-fPIE和默认PIE对比你会发现地址随机化ASLR会改变基址但相对布局不变。这也是进程图像一个重要特性用户态看到的地址虽然是随机的但偏移关系稳定。实验之余再看一眼栈的向下生长和堆的向上生长连续打印两个局部变量地址先打印的地址更高连续两次malloc第二次的地址更高但实际堆区可能因为分配器缓存而不同这个要留意不能作为绝对规律。8. 我记得最牢的一道考研真题“某系统采用动态分区分配方式现有空闲分区按地址从小到大排列大小分别为10KB、4KB、20KB、18KB、7KB、9KB、12KB、15KB。请回答下列问题用户依次请求12KB、10KB、9KB……”。这类题考的是分配算法不是收缩但衍生题就问“这时若采用紧凑技术简述过程和代价”。我的做题思路分三步先算总空闲容量是否满足请求再描述怎么把现有进程向低地址搬移最后强调要更新重定位寄存器。把“动态重定位是前提”这句话写上基本能拿全分。教材里很少强调“紧凑的完成要改基址”很多同学答题只答到“搬移数据”忘了映射更新分数就丢了半截。再有就是“为什么引入分页后紧凑技术不重要了”标准答法是分页将逻辑地址空间和物理地址空间分离页表提供任意映射进程不要求物理上连续因此不存在外部碎片也就没必要高开销地搬移进程。想拿高分可以补充“现代系统在NUMA、内存热插拔场景下保留页迁移算是紧凑思想的进化版本”既显示广度又和教材不冲突。9. 结尾前把进程图像的“活”再想一遍很多人把“进程图像”理解成一张静止的快照但实际运行中的进程图像在不停“变形”堆在长、栈在长、页表在换。教材里画的那个方框是某个瞬间的样子不是常态。所以学习这部分时我建议你脑中始终有一个动画进程被创建时是“图像建立”内存不足时是“图像休眠换出”搬移时是“图像换了个位置但逻辑没变”进程结束时是“图像销毁”。所有进程管理操作本质上都是对这个图像的管理。再补充一个和“收缩”直接相关的细节进程被换出到磁盘Swap时处理的是“整幅图像放到外存”的粒度而分页系统是按页换出。如果连续分配系统没有收缩能力大量进程换出后留下的碎片就永远凑不出一块合适的分区给新进程。所以“进程在内存中的存放方式”决定了“回收内存的手段”两者一环扣一环。10. 给学习者的几个实际操作建议喜欢写模拟器的朋友建议给模拟器加上三种放置策略首次适应、最佳适应、最坏适应再对比它们的碎片产生量。你会发现最佳适应留下的碎片小而多最坏适应的大空闲区分割慢碎片总量各有特点。在这些策略之上加上收缩按钮观察“收缩前碎片率”和“收缩后可用最大块”感受会远超背教材。想深入了解Linux真实实现可以看内核源码中mm/compaction.c。现代内核的memory compaction就是页级的碎片整理把可移动页迁移到另一处从而形成大的连续物理页块用于分配大页HugeTLB、CMA等。它跟教科书“收缩”的底层逻辑完全一致但代价小得多这也是一个极好的答辩素材。嵌入式裸机环境下我实际写过一个基于数组的内存池用首次适应收缩来应对偶尔的大块请求。我的体会是只要系统能接受“关键时刻停几个毫秒不响应”紧凑就是最简单可靠的大块分配方案尤其在没有MMU的单片机上比外挂SRAM换页方案工程上省太多。最后留一句话我个人实际操作中最受用的一点是别把“进程图像”和“收缩”当两个孤立知识点背而是把它们放进“进程的一生”里看——图像是进程的身体收缩是身体在内存里的搬家而搬家有没有意义完全取决于系统允不允许进程“换个地方继续活”。这个理解一旦建立很多题不用背自然就会写。希望这篇笔记能帮你少走我踩过的那些弯路特别是在收缩的顺序、memmove的使用和动态重定位的前提这三点上。
返回列表