你有没有想过,一个程序从双击图标到窗口弹出,在这期间操作系统究竟在内存里做了哪些事?很多人学操作系统,一上来就被进程、线程、死锁这些概念劝退,其实这个领域最核心的套路,从三四十年前就已经定下来了。
这篇文章是电子科技大学《操作系统》网课的笔记整理,对应的是存储管理部分的开篇内容——“简单存储管理”。也就是现代内存管理的“史前史”,讲的是在没有分页、没有分段、没有虚拟内存的年代,操作系统是怎么把程序装进内存跑起来的。学完这一块,你再去看后面那些复杂机制,会发现它们全都是为了解决这里暴露出的问题而设计的,思路一下就通了。
这篇笔记适合三类人:正在上操作系统课、被各种分配算法绕晕的学生;期末复习想要快速梳理存储管理脉络的考生;以及自学计算机基础、想弄明白“内存地址到底怎么来的”的爱好者。我会尽量用大白话和实际案例来讲,保证每一个概念都能落地,不看天书。
1. 程序跑起来之前,内存里发生了什么
很多教材一上来就扔给你一堆术语:逻辑地址、物理地址、重定位、碎片……但少有人先回答一个最基础的问题:为什么操作系统要专门搞一套存储管理机制?程序直接读取内存不行吗?
1.1 没有存储管理器的年代:一个程序“独占”内存的奢侈日子
在最原始的计算机上,情况确实就是“直接读”。那时候没有多道程序的概念,一次只跑一个任务。程序员把程序加载到内存的固定位置,比如从地址0开始,然后CPU按顺序取指令执行。整个过程里,内存就是程序的地盘,谁也不用跟谁抢。
这个阶段最大的问题不是“浪费”,而是“脆弱”。程序在运行中如果发生了逻辑错误,比如数组越界、野指针操作,它可能直接写入任意一个内存地址。写到哪里算哪里,轻则程序崩溃,重则把操作系统的代码也给覆盖掉,整个机器死机。早期计算机的调试难度之所以那么高,很大一部分原因就是这个——程序对内存拥有绝对的访问权,出了错你根本定位不到是哪一句指令干的好事。
更要命的是,这种模式完全无法支持多道程序。你想想,如果内存里同时驻留两个程序,程序A写坏一个地址,程序B可能就莫名其妙崩了。两个程序之间没有任何隔离,大家都裸奔。而后来计算机的发展方向恰恰是多道批处理、分时系统,需要多个程序同时待在内存里,轮流使用CPU。一旦要“同时共存”,“地盘划分”和“互不侵犯”就成了刚需。
1.2 存储管理要干的事情,其实就四件
所以存储管理机制不是设计者闲得慌,而是被现实逼出来的。归纳一下,任何存储管理系统都要回答四个问题:
- 内存空间怎么分:多个程序同时驻留,每个程序占哪块区域?这是空间分配问题。
- 内存不够怎么办:程序需要的空间大于物理内存怎么办?这是空间扩充问题。
- 物理地址怎么定:程序里写的地址和实际内存地址对不上,谁来翻译?这是地址转换问题。
- 程序之间怎么隔离:怎么防止程序A访问程序B的内存?这是保护问题。
电子科大网课里把“简单存储管理”当成一个整体来介绍,但我学下来觉得,它其实就是在逐一回答这四个问题:先是不管不顾地让程序直接跑,然后发现要分区,分区之后要处理地址转换,处理完还要面对碎片。整个演进过程,就是不断拆东墙补西墙、再补东墙的过程。理解了这一条主线,后面所有细节都顺理成章。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分区管理:把内存切成块,各家用各家
2.1 固定分区:像宿舍分配一样简单,但浪费惊人
最早的多道程序存储管理方案是固定分区。思路很朴素:开机的时候把内存划分成几个固定大小的区域,每一个分区里放一个程序。比如把64KB的内存分成4个16KB的分区,每个分区住一个作业,谁进来谁住,互不打扰。
这个方案的好处是简单,操作系统只需要维护一张分区表,记录每个分区的起始地址和大小,就能完成分配。但它的问题一眼就能看出来:分区大小是写死的,如果来了一个20KB的程序,16KB的分区装不下,即使其他分区都闲着,你也只能干瞪眼。反过来,如果来了一个5KB的程序,分配给它一个16KB的分区,剩下的11KB就白白浪费了。
这种“分配给你的空间你用不完,多出来的部分又没法给别人用”的现象,就是内部碎片。固定分区是内部碎片的重灾区,因为分区大小和作业大小几乎不可能恰好相等。我当年学这块的时候就觉得,这设计太浪费了,为什么不等程序来了再决定分多大的空间?这正是动态分区要解决的。
2.2 动态分区:按需分配,但管理就复杂了
动态分区和固定分区的区别只有一点:分区的边界不是预先划死的,而是在装载程序时,根据程序的实际大小动态划定。比如内存现在空闲着100KB,来了一个30KB的程序,就划给它30KB,剩下70KB继续留着等下一个程序。
这看起来完美解决了浪费问题,但代价是你要不停记录“哪里有空闲的空间”,并且能回答“空闲空间够不够装下新程序”这个问题。于是就得有一个数据结构来管理空闲空间。最常见的做法有三种:
- 空闲分区表:一张表,每一行记录一个空闲分区的起始地址和大小。
- 空闲分区链:用链表把空闲分区串起来,每个空闲分区开头保存指向下一个空闲分区的指针。
- 位图:把内存划分为固定大小的单元,每个单元用1位表示“被占”或“空闲”。
从理论上来讲,三种方式区别不大,但实际工程里位图用得比较多,因为它的查找效率高、开销固定。Linux内核里管理物理内存页帧用的就是一个叫mem_map的数组加位图思想,你会在后续课程里反复看到它的影子。
动态分配的核心难点是:当一个作业运行结束释放内存时,它旁边的区域可能是空闲的,这时候必须把“相邻的小空闲区”合并成“一个大的空闲区”,否则内存会越切越碎。我在复习时经常看到有同学忽略这一步,导致空闲分区表里出现一堆相邻但没合并的小条目,这实际上为后面的“外部碎片”埋下了伏笔。
2.3 四种分配算法:谁的性价比最高
有了空闲分区表/链表,接下来就要回答“新作业来了,从哪个空闲分区里切一块给它”。经典算法有四种:
| 算法 | 分配策略 | 优点 | 缺点 |
|---|---|---|---|
| 首次适应 | 从头找第一个够大的分区 | 简单、快速,高地址区保留了大块内存 | 低地址区容易积累碎片 |
| 循环首次适应 | 从上次找到的位置继续找 | 空闲分区分布更均匀 | 不容易保留大块区域 |
| 最佳适应 | 找能满足需求的最小分区 | 尽量利用小碎片,保留大块 | 产生大量难以利用的微小碎片 |
| 最坏适应 | 找最大的分区切 | 剩下的空间还比较大 | 破坏了原本的大块连续空间,对大作业不友好 |
我刚开始学的时候,总觉得最佳适应最“精打细算”,一定最好。后来老师提了一个问题让我恍然大悟:“如果每次都从最小的够用分区里切,那些差异极小、几乎用不上的碎块会越来越多,系统维护这些碎块的代价甚至超过了它们本身的价值。”实际测试下来,首次适应在多数场景下表现最稳定,因为它简单、开销低,而且能自然地把大空闲区保存在高地址区,后续大作业来了也接得住。这也是很多教材推荐首次适应的原因。
3. 内存不够怎么办:覆盖与交换这对“老搭档”
动态分区解决了“多人分地盘”的问题,但还没有回答一个更现实的疑问:我的程序本身就比物理内存大,怎么办?
在早期计算机上,内存容量非常有限,一个编译程序、一个数据库程序经常塞不进内存。于是工程师们发明了两个现在看来很“原始”但极其巧妙的方法:覆盖和交换。它们在现代操作系统中虽然不再是主流,但思想渗透到了虚拟内存、页面置换等高级功能里。
3.1 覆盖:把程序拆成“必用”和“可选”,分批装入内存
覆盖技术的基本思想是:一个程序虽然很大,但运行时并不需要所有代码和数据都在内存里。我们完全可以只保留当前执行阶段需要的部分,等执行到其他阶段时,再把别的模块装进来,把已经用完的模块覆盖掉。
但这里有一个关键前提:程序必须能被合理地划分成模块,而且模块之间的调用关系是清晰的、单向的。让我用一个经典的编译程序例子来说明。
假设一个编译程序由四部分组成:主控模块(10KB)、词法分析(20KB)、语法分析(40KB)、语义分析和代码生成(30KB)。四个模块加起来100KB,但内存只有60KB。这时候可以这样设计:主控模块10KB常驻内存,词法分析、语法分析、语义分析三个模块分段装入,同一时刻只保留其中一个。
问题来了:如果整个程序只能同时保留一个分析模块,那模块之间怎么传递数据?实际设计里,覆盖并不是简单地“当前用到谁就装谁”,而是由程序员或者链接器提前规定好“覆盖段”,让那些互相之间没有调用关系的模块共用一个覆盖区。比如词法分析结束之后才会进入语法分析,那它们就可以共用一块内存区域。操作系统里会有个“覆盖管理器”,在需要时从磁盘把对应模块读入内存。
覆盖技术最大的问题在于——它把内存管理的负担交给了程序员。程序员必须自己摸清程序的调用结构,手工划分覆盖段,这简直是一场噩梦。而且一旦模块间存在间接调用、递归调用,覆盖方案就基本不可行了。所以后来它逐渐被虚拟存储技术取代,但它“程序不必全部装入内存就能运行”的思想,是所有虚拟内存系统的启蒙。
3.2 交换:把整进程搬到磁盘,给别的进程腾地方
和覆盖不同,交换技术的目标是“多个进程之间”的空间调度。基本思想是:当内存不够的时候,把正在等待的或者优先级较低的进程,整个换出到磁盘上的一块专门区域(叫交换区),把内存腾出来给需要运行的进程;等那个进程运行一阵了,再把之前换出的进程换回来。
这里有两个必须在实操中琢磨清楚的细节。
第一个是换出谁。操作系统通常会优先换出处于阻塞状态或等待I/O的进程,因为它们反正也用不了CPU。如果所有进程都在就绪状态,那就按某种策略选一个,比如优先换出优先级最低的,或者换出运行时间最长的。每次换出换入都有很大的磁盘I/O开销,所以换出算法设计得好不好,直接影响系统整体效率。
第二个是换回来之后地址怎么办。一个进程在内存时的物理地址,和它再次被换入时的物理地址不一定是同一个位置。如果程序里用的是物理地址,一回车全乱了。所以交换技术往往必须配合“动态重定位”机制(下一节详细讲),让进程的逻辑地址和物理地址解耦。这也是为什么交换技术不是孤立的存储管理方案,它必须和其他机制配合使用。
3.3 覆盖和交换,到底谁更“高级”
我的理解是:覆盖是在“单个程序内部”做文章,需要程序员手动拆模块;交换是在“多个进程之间”做文章,由操作系统自动调度。两者不冲突,甚至可以同时使用。早期分时系统里,内存里放几个进程,每个进程内部还可以用覆盖技术控制自己的占用空间。
用一张表看它们的关系:
| 对比项 | 覆盖 | 交换 |
|---|---|---|
| 作用范围 | 单个进程内部 | 多个进程之间 |
| 对程序员的要求 | 高,需要手动划分模块 | 低,全由OS负责 |
| 实现粒度 | 模块级 | 进程级 |
| 磁盘交换量 | 只换需要的模块 | 整个进程都要换 |
| 主要代价 | 程序员负担重 | 磁盘I/O开销大 |
学到这里就会发现,所谓“简单存储管理”一点都不简单,它已经包含了“按需加载”“空间换时间”“程序局部性”这些思想的雏形。
4. 地址的“翻译官”:逻辑地址和物理地址是怎么对上号的
4.1 为什么程序里的1000,不是内存里的1000
先解释两个术语:逻辑地址是CPU发出的地址,是程序视角的地址,也就是程序员写代码、编译器生成指令时使用的地址;物理地址是内存单元的地址,是内存硬件视角的地址,也就是真正送给内存总线的那个地址。
在没有存储管理的年代,逻辑地址和物理地址是同一个东西。但一旦有了动态分区、交换技术,进程在内存里的位置随时可能变。这次运行它在地址100开始,下次被换入可能就到了地址500,程序里的地址不可能跟着改。所以必须有一个“翻译”机制,把程序内部的逻辑地址,现场换成真实的物理地址。
这一“翻译”过程,专业术语叫重定位。
4.2 静态重定位:装的时候改一遍,运行中就管不了了
最早的解决方案是静态重定位。在程序装载入内存时,由装载器把程序里的所有绝对地址,统一加上一个偏移量。比如程序加载到地址300开始的地方,那么程序里凡是引用地址1000的指令,装载器就把它改成1300。这样一来,程序内部的所有地址都在装载阶段就被“翻译”成了物理地址,运行的时候CPU直接用的是改过的地址,不需要额外处理。
这个方案的缺陷很明显:程序一旦装载,就不能再移动。如果系统想把一个进程从内存的A区挪到B区(比如为了合并碎片、或者执行交换),挪完之后所有地址又全错了。所以静态重定位无法配合交换技术使用,也限制了系统调度的灵活性。
4.3 动态重定位:基址寄存器加界限寄存器,运行时现场算
动态重定位要灵活得多。它不修改程序里的任何指令,而是在CPU里增设一对寄存器:基址寄存器和界限寄存器。
CPU每次发出一条逻辑地址时,硬件的地址转换单元会做两件事:
- 判断逻辑地址是否小于界限寄存器的值,防止程序越界访问;
- 把逻辑地址加上基址寄存器的值,得到真正的物理地址。
举个例子,一个进程被加载到物理地址30000处,基址寄存器里就被操作系统写入30000,界限寄存器写入进程的大小15000。程序里访问逻辑地址1000,CPU实际访问的就是物理地址31000。如果程序试图访问逻辑地址16000(超出了自己的地址空间),硬件会触发一个越界异常,操作系统会终止这个进程。
动态重定位最精妙的地方在于:进程在内存里换个位置,只需要修改基址寄存器的值,程序代码一个字都不用动。这就是支撑交换、紧凑(碎片整理)这些高级操作的前提。你后面学到分页、分段,会发现“基址+界限”的思路还在,只不过基址和界限不再是一个寄存器,而是变成了页表、段表里的条目。
| 对比项 | 静态重定位 | 动态重定位 |
|---|---|---|
| 修改时机 | 装载时统一改 | 运行时逐条计算 |
| 硬件需求 | 无特殊要求 | 需要基址+界限寄存器 |
| 程序能否移动 | 不能 | 可以 |
| 内存保护 | 依赖装载器自觉 | 硬件自动越界检查 |
| 配合交换/紧凑 | 不能 | 完全可以 |
我复习到这一节时的体会是:动态重定位是存储管理从“分配内存”走向“管理内存”的分水岭。有了它,程序的物理位置变得任意可摆,操作系统才真正获得掌控全局的能力。
5. 碎片的困局与伙伴系统的巧思
5.1 内部碎片和外部碎片:两种浪费截然不同
动态分区虽然解决了“按需分配”的问题,却带来了新的麻烦——碎片。碎片有两种,前面提到的内部碎片和现在要说的外部碎片。
内部碎片发生在固定分区和分页系统里,是“分配给你的空间比你需要的多出来的部分”。外部碎片发生在动态分区里,是“所有空闲内存加起来足够大,但没有一块连续空间能装下新作业”。
外部碎片最折磨人的地方是它的隐蔽性。系统可能只剩下总空闲10KB的内存,分布成五六个连续的小块,新程序需要8KB连续空间,却可能一个都装不下。这时候内存明明“够用”,程序却“进不去”。
解决外部碎片的标准思路叫紧凑(也叫压缩、碎片整理):把内存中所有正在运行的进程往一端靠拢,把分散的空闲小块合并成一个连续的大块。听起来很美,但紧凑需要修改所有进程的地址,它只能在动态重定位的环境下使用,而且移动进程本身要复制大量数据,开销很高。更重要的是,紧凑必须“暂停所有进程”,这在实时性要求高的场景里是不可接受的。所以紧凑简单有效,却很难频繁使用。
5.2 伙伴系统:二进制切分,花小力气解决碎片问题
既然紧凑代价太高,那有没有一种分配策略,能让碎片问题从根本上减轻?有一个很经典的折中方案叫伙伴系统(Buddy System),它既有固定分区的快速分配,又有动态分区的灵活性,还避免了大量外部碎片。现代Linux内核的页分配器本质上就是伙伴系统的变体。
伙伴系统的规则很简单:
- 所有的空闲块大小都是2的幂次方,比如1KB、2KB、4KB、8KB。
- 当需要分配一个大小为S的块时,找到满足2^(k-1) < S ≤ 2^k的最小块。
- 如果是请求大小为3KB,向上取整到4KB,找到4KB块分配。
- 如果没有正好大小的空闲块,就把一个大块一分为二(比如8KB分成两个4KB),一直分到满足要求为止。
- 释放时,如果与“伙伴”(即与它同块一分为二的另一半)都空闲,就合并成一个大块,再继续尝试向上合并。
用一个具体例子走一遍:内存有16KB空闲,进程A请求3KB。
- 系统找到唯一的16KB空闲块,按2的幂要求,3KB需要分配到4KB。
- 16KB不满足,折半成两个8KB;8KB不满足,继续折半成两个4KB。
- 从4KB块中分配一个给A。此时链表里有一个4KB空闲、一个8KB空闲、一个16KB空闲(被拆剩下的)。
如果进程A释放,系统发现它的伙伴(另一个4KB)也空闲,就把两者合并回8KB,再检查8KB的伙伴(另一个8KB)是否空闲,如果空闲继续合并回16KB。这种“自下而上合并”的机制,保证了内存块不会一直处于被切碎的状态。
伙伴系统的优点是分配和释放的时间复杂度都是O(log N),而且能有效减少外部碎片。缺点也很明显:内部碎片依旧存在——请求3KB实际占4KB,那1KB就是内部碎片;而且合并且机制要求块地址满足特定对齐关系(比如伙伴必须是同一父块切出来的两半地址最低位不同),实现起来比一般的链表分配复杂不少。
学完伙伴系统,我对“为什么操作系统不直接采用纯动态分配”这个问题有了更深的体会。纯动态分配确实能消除内部碎片,但它为了管理那些大小不一的空闲块,需要的时间开销和内存碎片化风险很难控制。工程上宁可用一点点内部碎片,换取分配效率的确定性。
5.3 碎片困境的终极答案,其实指向了“分页”
讲到这里,碎片问题看上去已经有些束手无策了:紧凑太贵,伙伴系统只能缓解不能根治,而碎片问题的真正根源在于必须给程序分配一段连续的内存。
如果打破“连续”这个执念呢?允许一个程序的若干内存块在物理内存里不连续,地址转换时由系统维护一张映射表来找物理块。这就是分页存储管理的核心思路。在电子科大网课里,分页是“存储管理2”的重点内容,但它的动机在这里已经埋下了:整个简单存储管理的历史,就是一步步把“连续性”这个约束条件逼到穷途末路,最终被分页彻底抛弃。
我当时学到这里最大的感悟是:技术演进不是突然跳变,而是旧方案的问题积累到一定程度后,自然孕育出新方案。存储管理的发展史,就是一部“优化—碰壁—创新”的循环史。
6. 从“简单存储管理”看现代内存管理的遗产
很多人学完简单存储管理,觉得这章又老又偏,好像跟现代操作系统关系不大。真不是这样。这一章里几乎每一个看似“过时”的概念,都能在现代系统里找到进化后的影子,而且越学越会发现,它就是理解现代内存管理的一把钥匙。
6.1 那些“老古董”思想的现代转世
覆盖技术看起来已经没人用了,但现代操作系统的虚拟内存机制,本质上就是对覆盖思想的系统化和自动化。虚拟内存允许进程“部分装入内存、部分留在磁盘”,并且由硬件和操作系统自动管理页面调度,不再需要程序员手工拆模块。你学页面置换算法时感到熟悉的“LRU”“FIFO”,其实就是覆盖时代“哪些模块该留在内存”的手动策略,被自动化、算法化的结果。
交换技术在现代系统中依然存在,只是换的粒度更小了。过去一次交换换出整个进程,现在Linux的swap机制可以按页换出,粒度小得多,灵活性也大得多。内存紧张时,内核把不常用的页面写到交换分区,这些页面依然属于进程的地址空间。你在free命令里看到的Swap列,就是交换技术活到今天的证据。
动态重定位看起来被页表取代了,但页表的本质仍然是“逻辑地址→物理地址”的映射表,只不过不再是一个基址寄存器加一个界限寄存器,而是每个进程一张多级表格。CPU访问内存时,通过MMU(内存管理单元)查页表完成翻译,整个过程和“逻辑地址+基址寄存器”那套在思路上是一脉相承的。
伙伴系统在现代Linux内核里更是直接继承了下来。物理页面分配器用伙伴系统管理空闲页块,配合zone、pageblock等概念来减少碎片。你在阅读内核代码时看到的alloc_pages,底层就是伙伴系统的分配路径。
6.2 学完这一章,你该重点消化哪些东西
结合我自己复习和做项目的经验,给你划几条重点,这些都是期末和考研常考的,也是后续学习的基础:
- 四个分配算法的对比和适用场景:尤其要会做“给一个空闲分区序列,分别用这四种算法模拟分配”的题。这种题看着简单,但很容易在“起始地址从小往大排”“从哪开始找”这些细节上翻车,最好自己完整走一遍。
- 外部碎片和内部碎片的区别:会举例子。内部碎片是“给多了用不完”,外部碎片是“零散加一起够但连不起来”。考试最爱考的判断,就是给你一个场景问是哪类碎片。
- 动态重定位为什么是其他技术的基础:没有它,紧凑和交换都无从谈起。这个逻辑关系如果能讲清楚,说明你是真理解了,而不是死记硬背。
- 覆盖和交换的本质区别:一个是程序内部模块的“换血”,一个是进程之间整体的“对调”。这个区别不仅考试常考,也是理解虚拟内存两条技术路线的关键。
我在学这一章时,曾经花了很多时间死磕伙伴系统的合并条件,后来才发现,真正的理解方式是把它当成一种“二进制拆箱”的游戏。你可以自己在纸上画一画内存块拆分和合并的过程,画两遍就记住了。
6.3 给自学者的几条实战建议
如果是自学,我强烈建议看完这一章后,去做两件事巩固。
第一,装一个Linux虚拟机,在命令行里运行cat /proc/meminfo和free -h,观察一下物理内存的布局、空闲空间的统计方式,再找机会运行dmesg | grep -i memory看看系统启动时的内存探测信息。你会发现,理论里的“分区”“碎片”“空闲空间”,在真实系统里都是异常清晰的数据结构。
第二,可以写个小程序模拟固定分区和动态分区的分配过程。不用多高级,能维护一张空闲分区表、实现四种分配算法就可以。我当年就写过这个,写完再去看考试题,感觉完全是降维打击。动手永远比光看强。
现代操作系统的内容非常多,存储管理只是其中一块。但“存储管理1:简单存储管理”这块奠基石打得牢不牢,直接决定了你后续学分页、分段、虚拟内存时是“轻舟已过万重山”还是“一步一个坎”。把这章吃透,后面会顺畅很多。
