很多人在学操作系统的时候,文件系统这一章最容易卡住,尤其是讲到磁盘空间分配时,连续分配、链式分配、索引分配这几个概念来回对比,总感觉懂了又不太会用。今天这篇Day 46,我把文件系统里最基础的两种分配方式——连续分配与链式分配,掰开揉碎讲清楚,从实现原理到优劣对比,再到怎么用代码模拟一遍,一次把这块补扎实。
这篇文章是操作系统学习系列里的文件系统核心篇,适合正在复习操作系统期末、准备考研408、或者面试前临时抱佛脚的朋友,也适合那些想彻底搞懂文件系统底层原理但不想只背结论的人。看明白这篇,后面再看ext4的extent机制、NTFS的run列表、FAT文件系统,都会有"原来如此"的感觉。
1. 先理清楚:文件系统到底在分配什么东西
1.1 磁盘空间管理的本质问题
文件系统说白了,就是帮用户把"逻辑上连续的一整块数据"映射到"物理上分散的多个存储单位"上。一个文件可能只有几KB,但磁盘上不可能每次都为这个文件划一块刚好一样大的专用区域,因为文件会增删改,磁盘空间会被不断占用和释放。所以文件系统必须管理一个核心问题:文件的数据块,到底怎么放、放哪里、怎么找到它们。
这个"怎么放"的策略,就是磁盘空间分配方式。它直接决定了文件读取快不快、空间利用率高不高、文件能不能动态扩展、系统崩溃后能不能恢复。连续分配和链式分配是两种最原始、思路最简单的方案,也是理解现代文件系统的地基。
1.2 块、扇区、簇:分配的最小单位
讲分配方式之前,必须先确定"分配单位"。磁盘是按扇区读写的,一个扇区传统上是512字节,但文件系统不会一个扇区一个扇区地管,那样元数据太庞大了。文件系统会把连续的若干个扇区合成一个"块"(block),在Linux的ext系列里默认是4KB,也就是把8个512字节的扇区拼成一个块。
块也可以叫簇,FAT文件系统里就习惯叫簇(cluster)。文件存储时按块分配,也就是说一个文件哪怕只有1个字节,也得占一整块4KB。这是文件系统空间利用率的第一个损耗来源,叫"内部碎片"——块内部没用完的那部分。这个理解了,后面再看"外部碎片"才不混乱。内碎片是块内部的浪费,外碎片是空闲空间被切得太碎、无法满足连续分配需求的浪费。
1.3 分配策略要回答的三个问题
任何分配方式都必须回答三个问题:
- 文件元数据里记录什么信息,才能在需要时找到文件所有数据块?
- 空闲空间怎么管理,新文件来了能不能快速找到合适的位置?
- 文件要增长时,现有的方案支不支持原地扩展?如果不支持,代价是什么?
连续分配和链式分配,就是两组截然不同的答案。连续分配说"我一口气给你一段连续空间,记下起点和长度就行";链式分配说"我不保证连续,但每个块里都记着下一块在哪,你顺着链子走就行"。理解了这两句话,后面所有细节都是展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 连续分配:最直觉的方案,也是最脆的方案
2.1 核心思想与目录项结构
连续分配太好理解了,就好比图书馆里把一本书的所有页都放在书架相邻的位置。文件系统向磁盘申请空间时,一次性拿到一组连续的块,然后在文件的目录项里记录两个关键数字:起始块号和文件总块数(或者文件字节长度)。读到文件时,从起始块开始,按块号递增读下去就行,不需要任何额外的查找。
目录项里存的信息大概是这样的:
| 字段 | 含义 |
|---|---|
| 文件名 | 用户看到的名称 |
| 起始块号(start_block) | 文件第一块数据所在的物理块号 |
| 文件长度(file_size) | 文件占用的字节数或块数 |
这个结构极其简洁,查询文件时一次定位,不需要遍历什么东西。如果我们要读文件里的第N个字节,可以直接算:物理块号 = 起始块号 + (N / 块大小)。这种"算术寻址"的能力,是连续分配最大的性能优势。
2.2 为什么连续分配读起来飞快
如果你了解一点机械硬盘的物理结构,就知道磁头移动(寻道)是访问磁盘最贵的操作之一。读一个块可能只需要几十微秒,但把磁头从当前磁道移到目标磁道可能要几毫秒,两者差了上百倍。
连续分配正好把文件所有数据块挨在一起,读取时只需要一次寻道,之后可以连续读。顺序读整个文件时磁头几乎不用换道,随机读某个指定位置也能通过公式算出目标块,直接定位,也只寻道一次。在磁盘还是主要存储介质的年代,这种特性非常诱人——它让顺序读性能逼近硬件极限。
用生活例子说,连续分配就像你在一个装满纸的档案柜里,把某个项目的所有文件按顺序放进同一层抽屉的相邻位置。你想找第10份文件,直接数过去就行,不用来回翻柜子。
2.3 绕不开的三大问题
连续分配看起来很美,但它有三个非常致命的缺点,这也是它最终没有被现代通用文件系统直接采用的原因。
第一个是外部碎片。文件不断地创建和删除后,磁盘上的空闲空间会被切成一堆不连续的小段。一个文件明明总共只有10MB,磁盘总空闲空间有100MB,但如果最大的一段连续空间只有8MB,这个文件就放不下。这就是"空间足够却没有连续空间可用"的典型困境。
第二个是文件增长问题。文件创建时你根本不知道它以后会变成多大。如果分配了10个块,文件写满了想变成12个块,而第11个块已经被其他文件占了,那就无法原地扩展。解决办法是先把文件复制到一个更大的连续区域再释放原空间,这个操作叫"迁移",代价极高,而且迁移过程中一旦断电,文件可能就坏了。
第三个是预留问题。为了避免频繁迁移,一种妥协做法是文件创建时就按可能的最大长度预留空间,但这样会造成大量浪费——你给一个可能长到10MB的文件预留了10MB,结果它最后只有2KB,剩下全浪费了。
2.4 缓解手段:紧凑与预分配
针对外部碎片,经典方案是"紧凑"(compaction),就是通过调整文件位置,把所有空闲空间拼成一大块连续区域。听着简单,实际上是一个巨重的操作,需要把所有文件重新移动一遍,移动期间文件系统基本不可用,适合停机维护场景,不适合在线服务。
针对文件增长,方案是预分配。创建文件时就按某一策略预留较大空间,但这又和空间利用率冲突。所以你会看到,连续分配只适合一些固定大小、一次性写入的场景,比如光盘上的ISO9660文件系统、某些嵌入式设备上的只读文件系统。这些场景文件大小已知、基本不会变,连续分配的优点被放大,缺点被规避。
3. 链式分配:不连续也能存,但链子也不能乱断
3.1 核心思想与目录项结构
链式分配的思路恰好反过来:不管物理块连续不连续,每个空闲块都可以被任意文件使用。关键技巧是,在每个数据块的末尾留出一部分空间,专门存放"下一个块号"。文件被打散存放在不同的块中,通过指针串成一条链表。目录项里只需要记录起始块号和结尾块号。
目录项结构大概是这样:
| 字段 | 含义 |
|---|---|
| 文件名 | 用户看到的名称 |
| 起始块号(start_block) | 文件第一块数据所在的物理块号 |
| 结尾块号(end_block) | 文件最后一块数据所在的物理块号 |
为什么要记结尾块号?因为文件长度没法从链表里直接算出来,只有顺着链走到头才知道有多少块;记了结尾块号,遍历时就可以在到达尾部时停止。链式分配解决了连续分配最大的痛点——外部碎片问题。只要有空闲块,不管在哪,都能串进文件里;文件要增长,只需要分配一个新块,改一下最后一块的指针就行,不用迁移文件。
3.2 随机访问成了灾难
链式分配的代价在随机访问上暴露得非常彻底。想要读文件里的第N个数据块,你得从起始块开始,顺着指针一个一个找,读第0块才知道第1块在哪,读第1块才知道第2块在哪。算一下复杂度,读第N块需要N次磁盘读操作。如果文件有1000个块,想读中间第500块,就要读500次磁盘,这在机械硬盘时代是不可接受的。
顺序读文件倒还好,因为读每个块时自然就能拿到下一个块的指针,性能虽然不如连续分配(因为每读一个块都要跳一次磁道),但还能接受。问题在于,文件系统不可能只做顺序访问,很多应用都需要随机定位,比如数据库索引、压缩包里的单个文件,所以纯链式分配没法直接用在通用文件系统上。
另一个问题是可靠性。链式分配里所有指针都散布在各个数据块里面,每个数据块既是数据存储区也是"导航路标"。如果链式结构中一个块的指针被写坏或磁盘物理损伤,那从这一块之后的所有数据都找不回来了,相当于一条链子断了一环,后半段全丢。
3.3 空间利用率也没想象中高
链式分配还带来一个隐蔽的空间浪费:每个数据块都要留出一段空间存指针。假如块大小是4KB,每个指针占4字节,那每个块最多只能容纳4096-4=4092字节的数据。如果你写了一个4093字节的文件,就得占两个块,而不是理论上的一个块要多一点。这种损失在整个大文件里累积起来,虽然比例不高,但在极小文件场景下格外疼。
更麻烦的是对齐问题。数据从块的起点开始存储,但指针固定在块末尾,这给文件系统实现带来了很多边界算术上的麻烦。现在的磁盘块大小越来越大,指针占的空间占比会降低,但依然是个不能不计算的开销。
基于这些原因,纯链式分配在现代文件系统里也很少直接使用,但它启发了一个极其重要的改进思路——把指针集中管理,不放在数据块里。这就是FAT表的由来。
3.4 里程碑改进:FAT文件系统
FAT(File Allocation Table)的核心想法是:单独划出一块区域,做成一张表,表的每一项对应磁盘上的一个块,表项里存的不是别的东西,正是"下一个块号"。数据块本身不再存指针,完全用于数据。文件访问时,先查FAT表找到下一块的号,再读对应数据块。
这个改进使随机访问速度大大提升,因为整个文件链都集中在一张表里,而这张表在系统运行期间可以缓存到内存中。读第N块,只需在内存里跳N次表项,然后一次磁盘I/O直接读数据块,比纯链式分配的N次磁盘I/O快得多。FAT的缺点是表本身要占额外空间,而且表很大时维护成本高;FAT16、FAT32这些老系统仍然在U盘、相机存储上存活至今,就是因为它实现简单、兼容性好。严格来说,FAT仍然属于链式分配的思想框架,只是把链从数据区抽了出来。
4. 两种方案全方位对比:没有银弹,只有取舍
4.1 关键维度对比
下面这张表,我建议你直接背下来,考试、面试、写代码时都很有用。
| 对比维度 | 连续分配 | 链式分配 |
|---|---|---|
| 目录项记录内容 | 起始块号、文件长度 | 起始块号、结尾块号 |
| 外部碎片 | 严重,需要紧凑处理 | 没有外部碎片 |
| 文件增长 | 困难,可能需要迁移文件 | 容易,分配新块改指针即可 |
| 顺序读性能 | 极快,磁头几乎不动 | 较快,每块需跳转 |
| 随机读性能 | 极快,公式直接定位 | 极慢,需要逐块遍历 |
| 空间额外开销 | 极小,只有目录项 | 每块尾部指针浪费若干字节 |
| 可靠性 | 高,块坏只影响单块 | 低,链上任意一击就丢后半段 |
| 典型应用 | ISO9660、只读嵌入式文件系统 | 早期操作系统如某些版本的MS-DOS、FAT的思想原型 |
连续分配赢在性能和简单,输在碎片和扩展性;链式分配赢在空间利用灵活、扩展方便,输在随机访问和可靠性。两者的优缺点高度互补,这正好解释了为什么后来的索引分配要同时吸收两者的思想。
4.2 现代文件系统为什么不用这两种原始方案
现代通用文件系统(ext4、NTFS、APFS)几乎都采用索引分配,也就是每个文件有一个inode节点,节点里存着一组直接块指针,指向数据块;数据量大了,再用间接块、双重间接块甚至extent(连续区段)来扩展。
索引分配的思想相当于是"连续分配+链式分配"的融合:每个文件仍然以块为单位散布在磁盘上,解决碎片和扩展问题;但所有指针集中在inode和间接块里,不依赖数据块之间的物理连续性,也不需要像链式分配那样从头遍历才能找到目标块——先读inode,再按索引直接定位目标块,通常只需要一两次磁盘I/O。
另外现代文件系统特别爱用extent,extent的本质是"一段连续块区域",它其实吸收了连续分配"连续区域读取快"的优点,把连续块打包成一个区间记录下来。比如ext4的一个extent可以描述一段最多128MB的连续区域,文件在物理上连续的地方越多,元数据越精简,顺序读性能越好。所以你学这两种经典分配方式,不只是在学历史,而是在为理解现代文件系统的设计哲学打底。
4.3 考试和面试高频考点
这块内容在408操作系统真题和各大厂面试里出现频率都不低,常见考法有这么几类:
- 给一个文件大小和块号分布,要求计算连续分配和链式分配下访问某个块的磁盘I/O次数。
- 问某文件系统出现大量外部碎片,可能是什么分配方式导致的?答案自然指向连续分配。
- 问链式分配如何优化随机访问性能?标准答案是引入FAT表集中管理指针,或改为索引分配。
- 问为什么FAT系统里删除文件后不需要像连续分配那样做紧凑?因为链式分配的每个块都是独立分配,空闲块之间不存在"必须连续"的要求。
备考建议是:不要只背结论,把每个答案背后的推导过程记下来。比如计算磁盘I/O次数时,别忘了把读取目录项的那次也算进去。
5. 手写一个极简模拟,彻底吃透两种分配机制
5.1 模拟环境与整体设计
光看理论和对比,可能还是隔了一层。我建议你花半小时亲手写个小模拟器,把两种分配方式的分配、释放、访问过程跑一遍。不用太复杂,我用Python写了一个简化版本,核心逻辑只有几十行。
模拟环境只需要一个Python 3环境,没有第三方依赖。我们定义磁盘总共有64个块,用一个数组disk表示数据区,用一个位图bitmap表示块是否空闲。为了模拟方便,我们设计一个简单的文件目录表dir_table,文件项记录文件名、类型(连续或链式)、起始块号。
5.2 连续分配的核心实现
连续分配的分配逻辑很简单:从位图里找一段连续的空闲块,记录起始块和长度,如果没有足够的连续空闲块,就返回失败。
python复制BLOCKS = 64
bitmap = [False] * BLOCKS # False表示空闲
def alloc_contiguous(file_name, size):
# 找一段长度为size的连续空闲块
count = 0
start = -1
for i in range(BLOCKS):
if not bitmap[i]:
if count == 0:
start = i
count += 1
if count == size:
break
else:
count = 0
start = -1
if count < size:
return None, "外部碎片不足,找不到连续空间"
for i in range(start, start + size):
bitmap[i] = True
dir_table[file_name] = ("contig", start, size)
return (start, size), "ok"
注意这段代码里如果循环到末尾还没凑够size,count会被重置,最后判断count < size就返回失败。这就是外部碎片的直接体现:可能磁盘剩余块总数够,但找不到连续的。
释放连续分配的文件时,把起始块到起始块+长度之间所有的块都标记为空闲即可,不需要跟踪其他东西,非常干净。
5.3 链式分配的核心实现
链式分配的逻辑复杂一点。分配时不管连续性,只要空闲块够,就逐个取用,同时记录块之间的指针关系。我这里用一个字典next_block来模拟每个块的尾部指针。
python复制next_block = {} # 记录当前块 -> 下一块
def alloc_linked(file_name, size):
if sum(1 for b in bitmap if not b) < size:
return None, "空闲块总数不足"
start = None
prev = None
for i in range(BLOCKS):
if size == 0:
break
if not bitmap[i]:
bitmap[i] = True
if start is None:
start = i
else:
next_block[prev] = i
prev = i
size -= 1
next_block[prev] = -1 # -1表示链尾
dir_table[file_name] = ("linked", start, prev)
return start, "ok"
这段代码展示了链式分配的最大优势:for循环体里,只需要判断这个块是不是空闲,跟它前面的块是不是连续完全没有关系。磁盘空得再碎,也能用。
释放链式分配的文件时,必须从起始块开始,按next_block一路走到底,遍历所有块并释放。这个操作比连续分配复杂很多,也容易出错——如果中间某个块的指针坏了,后面的块就永远变成了孤儿块,既不在任何文件的链上,也没被标记为空闲。
5.4 实测对比:碎片场景下的结果
我在模拟器里做了一组实验:先连续分配几个文件,再删除其中一部分,制造外部碎片,然后尝试分配一个较大的新文件。
结果很直观:
- 连续分配模式下,即使空闲块总数够,新文件也可能创建失败,返回"外部碎片不足"。
- 链式分配模式下,同样的碎片状态,新文件可以顺利创建,文件块散布在多个空闲位置。
同时我统计了访问性能:连续分配访问文件第30个块,只需要一次磁盘I/O;链式分配访问第30个块,需要从头开始遍历30次指针才能定位,相当于30次块读取。实验跑完,你会对"碎片影响"和"随机访问代价"有很深的体感,这比背十遍结论都管用。
6. 实操中常见问题与易错点排查
6.1 块号换算最容易算错
无论是实现还是做题,把文件的字节偏移转换成物理块号时特别容易翻车。正确的公式是:块号 = 字节偏移 / 块大小,块内偏移 = 字节偏移 % 块大小。注意这是整数除法,很多人忽略了一个隐藏步骤:文件目录项里记录的"文件长度"到底是字节还是块,如果系统按块记录长度,那么最后一块的利用率要结合文件实际字节数计算,而不是简单按块数算。
6.2 链式分配的"指针占位"最容易被忽略
写模拟器时最容易漏掉的是:链式分配的每个数据块都要留空间存下一块号。这意味着文件系统在分配块时,必须把块的一部分空间作为指针,实际可用容量是块大小减指针大小。考试里如果给出块大小和指针大小,让你算一个文件需要多少块,记得把指针开销算进去。我曾经在这个问题上栽过跟头,少算了一块,写出来的模拟器文件内容会对不上。
6.3 删除链式文件时忘记遍历整个链
删除链式文件时,如果只删目录项不释放链上所有块,那这些块就变成孤儿块。磁盘空间会越用越少,而且很难排查。正确做法是遍历整条链,逐个把块标记为空闲。一些旧系统的磁盘修复工具,最重要的功能之一就是扫描孤儿块并回收。你在自己的实现里,可以在删除时打日志,输出释放了哪些块,方便验证。
6.4 FAT到底算不算"链式分配"的变形
很多同学纠结这个问题。我的看法是:FAT本质还是链式分配的思想,只是把指针从数据块内部抽出来集中存放。它没有改变"通过链表定位文件块"这个核心逻辑,只是优化了链的存储位置,从而大幅提升随机访问性能。所以如果题目说"链式分配的缺点之一是随机访问慢",然后问怎么改进,你答"引入FAT表"是完全合理的;但如果题目问"哪些文件系统是纯链式分配",FAT不能算纯链式,因为数据块里已经没有指针了。
我自己在调试连续分配模拟器那会儿,为了复现外部碎片,专门写了创建一堆小文件然后再删掉其中一部分的用例。看到明明空闲块还剩一堆,分配大文件却失败时,那一刻对"碎片"这个词的领悟比看十页书都深刻。建议你也去折腾一遍,代码不复杂,但跑完你对这两种分配方式的理解会完全不一样。
