
磁盘这东西做Linux系统编程的迟早得跟它较劲。很多人写了几年代码文件读写调接口一套一套的可真要问到“文件在磁盘上到底怎么存的”、“inode里的地址映射是怎么找到数据块的”往往就卡壳了。这篇东西就是把这些硬件底层的东西掰开揉碎了讲清楚从盘片上的物理结构一路讲到虚拟地址映射把Ext2到Ext4的演进逻辑串起来。如果你正准备深入系统编程、内核驱动相关的方向或者想搞明白性能调优时那些参数到底在调什么这篇文章应该能帮你把这块短板补上。我会把人话和内核原理混着讲尽量让每个概念都能在脑子里形成画面。1. 先搞懂磁盘物理结构别把“块”和“扇区”混为一谈1.1 从盘片、磁道到扇区一个文件真正睡在哪里机械硬盘的结构其实很像老式黑胶唱片机。一张盘片正反两面都能存数据每面配一个磁头。盘片高速旋转磁头在盘面上方几纳米的地方飘着靠磁场变化读写数据。盘面上那些同心圆就是磁道。每个磁道又被等分成好几段圆弧每一段就是一个扇区。核心来了扇区是硬件层面的最小读写单位。传统磁盘一个扇区是512字节现在大容量盘普遍用了4K扇区也叫高级格式化。这个尺寸是硬件出厂就定死的操作系统想改也改不了。所以无论上层怎么包装最终读写磁盘时一定是整块整块地读扇区不可能说只读半个扇区。那为什么我们在Linux里用stat命令看文件系统看到的块大小往往是4096字节因为文件系统在扇区之上又做了一层封装把连续的几个扇区拼成一个逻辑块。4K块就是8个512字节扇区或1个4K扇区。这层包装的意义在于减少寻址开销提高大文件读写效率。为什么块要大于扇区一个文件不可能只有512字节通常都是几KB到几GB。如果数据块太小一个稍微大点的文件就得占几千上万个块每个块都要配套的管理结构去记账光元数据就得吃掉一大堆空间。块设成4K既兼顾了小文件的存储效率也不会让大文件的块数量多到难以管理。1.2 分区、整列、对齐一个被无数人忽视的物理细节分区这层也很关键。一块磁盘可以切成多个分区但无论怎么切分区其实只是磁盘上的一段连续地址空间。分区表记录的是“从哪个扇区到哪个扇区”。文件系统是在分区之上构建的而不是直接建在整块盘上。这就引出一个经常被踩的坑分区对齐。早年间很多分区工具从柱面边界开始划分而柱面边界和现代4K扇区的对齐方式不一致结果就是逻辑块跨越物理扇区边界一次IO要触发两次物理读写。检查方法很简单align-check optimal /dev/sda1如果输出1 aligned就是对齐的0 not aligned就得用parted重新对齐一次。大多数现代安装器Anaconda、Debian installer默认已经处理了这个问题但如果你在做嵌入式系统或者手动分区还是要检查一下。还有一层是IO调度。磁盘的寻道时间是所有操作里最贵的SSD虽然不存在寻道但随机读写依然比顺序读写慢。所以Linux内核里有个IO调度器把散乱的请求按扇区顺序排队尽量让磁头沿着一个方向移动。2. Ext系列文件系统的核心设计从Ext2到Ext4的演进逻辑2.1 Ext2朴素的块位图和inode表Ext2是Linux早期最经典的文件系统没有日志功能结构简单直白。它的磁盘布局大致是这样的最开头是引导块然后是块组描述符表再往后就是每个块组内部的数据区。每个块组里都有这么几样东西超级块副本记录整个文件系统的元信息比如块大小、inode总数、空闲块数块位图标记这个块组里哪些块是空闲的哪些已经占用inode位图标记哪些inode编号是空闲的inode表实际的inode数据结构数组数据块区真正存放文件内容的块inode本身是个128字节的结构体Ext2/3默认128字节Ext4支持256字节里面保存了文件类型、权限、所有者、时间戳、大小、引用计数、数据块指针数组等。注意文件名不在inode里而是存在目录项dentry里。目录其实就是一个特殊文件它的数据块里放着一串记录每条记录包含文件名和对应的inode编号。2.2 Ext3:日志来了但映射方式没变Ext3和Ext2最大的区别就是多了日志Journal。这个设计借鉴了数据库的WAL思想在真正修改元数据之前先把操作记到日志区等所有步骤都安全完成了再把日志区清掉。这样即使系统在操作进行到一半时崩溃重启后也能通过回放日志恢复到一致状态。但Ext3的块寻址方式和Ext2完全一样仍然是块位图加多级间接块。所以Ext3在超大规模文件系统上依然有瓶颈4K块大小下单个文件最大只能到16GB12个直接块 4K/4字节个一级间接块 ...而文件系统最大也就16TB。2.3 Ext4:extent树解决大文件寻址问题Ext4最大的变化是引入了extent树来替代传统的间接块映射。这是地址映射机制的一次革命——不是简单的数量扩展而是从O(n)的间接寻址变成了O(log n)的树形查找。再加上其他改进多块分配、延迟分配、日志校验和、在线扩容等Ext4单文件上限达到了16TB4K块下单文件系统上限达到了1EB。虽然实际中因为各种工具限制一般用不到这么大但设计上的冗余给了运维很大的余量。3. 地址映射机制深入剖析inode、间接块到extent树3.1 经典Ext2/Ext3的12123映射结构传统的块映射方案长这样inode里有一个15个元素的数组i_block[15]每个元素是一个32位的块号。前12个是直接块指针直接指向文件内容的第0到第11个数据块。如果文件不超过12个块4K块下就是48KB一次间接寻址都不用直接就能拿到数据。大部分配置文件、日志碎片、小脚本都在这个范围内所以访问速度极快。当文件超过12个块就要用第13个元素了一级间接块指针。这个指针指向一个块这个块里存储的是一组指针每个指针再指向实际的数据块。4K块大小、4字节一个指针的话一级间接块里可以放1024个指针覆盖4MB的文件空间。二级间接块就是指向一个块这个块里的指针指向一级间接块以此类推覆盖4GB。三级间接块覆盖4TB实际上因为inode大小限制封顶16GB。文件大小 直接块 一级间接块 二级间接块 三级间接块 12×4K 1024×4K 1024²×4K 1024³×4K 48KB 4MB 4GB 4TB这个数学公式看着挺规整但实际使用中有个大问题大文件的随机访问性能极差。如果一个文件超过4GB比如虚拟机的qcow2镜像要访问文件末尾的数据得先读三次间接块才能拿到目标数据块号这中间多出三次磁盘IO。如果是机械盘每多一次IO就是一次寻道加旋转延迟性能直接崩。3.2 Ext4的extent树把连续块打包管理Ext4在设计时做了个统计大部分文件其实都是顺序写入的数据块在磁盘上是连续的。既然连续为什么不用“起点长度”的方式来描述呢这就是extent的核心思想。一个extent结构体12字节包含ee_block逻辑块号4字节ee_len连续块的个数2字节ee_start_hi物理块号高16位2字节ee_start_lo物理块号低32位4字节所以一个extent最多可以描述单个文件里连续128MB的存储空间4K块 × 32768个连续块。相比间接块需要1024个指针才能描述4MBextent的压缩比高出太多了。inode的i_block数组在Ext4里变成了extent树的根节点。前12个字节存ext4_extent_header后面就是extent条目。小文件可以直接放在inode里大文件则可以扩展成树形结构。树的每个节点是一个块块里可以有4个extent或者更多索引项。查找流程从根开始逐层下降每次通过二分查找确定下一步走向。这个过程是纯内存操作因为整条路径上的节点块在读文件内容时已经被cache住了相比间接块每次都要重新读盘性能差距是数量级的。3.3 延迟分配和多块分配extent的两位帮手extent树设计得再好如果写文件时还是一个块一个块地分配那就前功尽弃了。所以Ext4引入了延迟分配Delayed Allocation写入数据时先不急着分配磁盘块而是在页缓存里攒着等到writeback的时候一次分配多个块。这样做的好处是文件系统能看清整个写的范围可以把连续的块一次性划给同一个文件产生更长的extent减少树的高度和碎片化。对应的机制叫多块分配mballoc用预搜索的方式寻找连续的空闲块组。这两个机制配合起来能有效降低磁盘碎片率。碎片率低文件读取时的寻道就少性能自然就上去了。延迟分配也有代价如果系统在数据还没有落到磁盘前崩溃那些在页缓存里但还没分配磁盘块的数据就全丢了。虽然正常关机时sync会把数据刷下去但这也解释了为什么Ext4的官方文档建议数据库这类对持久性要求极高的应用最好关闭延迟分配功能。4. 从虚拟地址到物理扇区一个read()调用的完整旅程4.1 路径解析从路径到inode当你在Linux里执行cat /var/log/messages内核要做的事情远比你想象的多。首先VFS从进程的fs_struct中找到当前根目录和当前工作目录的dentry。然后开始逐级解析路径先找根目录的inode2号inode这是个约定再在根目录的目录项里查找var得到var目录的inode再到var目录里查log一直到找到messages文件的inode。这个查找过程听起来简单其实每一级都可能触发磁盘IO。所以内核维护了dentry cache和inode cache频繁访问的目录结构都在内存里不会每次重新都从磁盘读。拿到inode之后内核会检查权限、更新atime然后根据inode里的文件内容布局信息定位到文件数据所在的物理块号。4.2 映射到物理块号extent树查找如果文件系统是Ext4且文件数据是连续存放的那么inode里的extent信息可能直接就能覆盖整个文件。这时内核直接算出目标逻辑块号对应的物理块号一步到位。如果extent信息不在inode里那就得加载extent树的根节点块逐层向下查找。整个过程在ext4_map_blocks()函数里完成这个函数接收逻辑块号返回物理块号和映射的块数。// 内核实测代码的精简逻辑 int ext4_map_blocks(handle_t *handle, struct inode *inode, struct ext4_map_blocks *map, int flags) { if (ext4_test_inode_flag(inode, EXT4_INODE_EXTENTS)) { // 走extent树路径 return ext4_ext_map_blocks(handle, inode, map, flags); } // 走传统间接块路径 return ext4_ind_map_blocks(handle, inode, map, flags); }内核里有两条路径并存就是为了兼容旧格式的文件系统。如果你的Ext4文件系统是直接从Ext2/Ext3升级上来的老的间接块映射文件还是走老路径只有新建的文件才会用extent。4.3 从块号到磁盘扇区再到DMA拿到物理块号之后块层还要把它转换成磁盘上的扇区号公式很简单sector_t block_to_sector(struct super_block *sb, ext4_fsblk_t block) { return block (sb-s_blocksize_bits - SECTOR_SHIFT); }4K块等于8个扇区块号1对应扇区8。换算好之后块层把这个请求交给IO调度器。调度器按LBA逻辑块地址顺序整合请求能合并的合并能重排的重排最后变成一条或多条NVMe/ATA命令通过驱动程序发送给磁盘控制器。磁盘控制器收到命令后如果是NVMe SSD直接走PCIe总线做DMA传输把数据从盘上搬到内存指定地址。整个过程数据不经过CPU逐字节搬运CPU只需要在DMA完成时收到一个中断。这也是Linux能支撑高吞吐的基石之一。寻址的终极映射关系是文件逻辑偏移 →inode映射→ 物理块号 →块层转换→ 扇区号 →设备驱动→ 磁盘LBA。4.4 dump工具实战亲手看映射关系理论讲再多不如动手看一眼。我用debugfs来dump一个真实文件的映射关系# 找到目标文件的inode $ ls -i /var/log/messages 123456 /var/log/messages # 查看inode详细信息 $ debugfs -R stat 123456 /dev/sda2输出会显示inode的模式、大小、块数以及extent树的具体内容EXTENTS: (0-127): 8820736-8820863, (128-255): 8821000-8821127, ...这表示文件的前128个逻辑块映射到物理块号8820736到8820863连续128块第128到255块映射到8821000到8821127又是连续128块。这种结构说明文件在磁盘上基本是连续写的碎片很少读取效率高。如果看到的是这样的碎片化结构(0-63): 100000-100063, (64-91): 888880-888907, (92-123): 500500-500531, ...那这个文件被反复改写、打散过读起来会有多次跳转。对这种文件执行e4defrag /var/log/messages可以尝试在线整理碎片。5. 实战在Linux里查看和调试地址映射5.1 stat、dumpe2fs和debugfs三板斧要在Linux里查看文件系统布局最有用的三个命令是stat查看inode元数据$ stat /etc/hostname File: /etc/hostname Size: 11 Blocks: 8 IO Block: 4096 regular file Device: 803h/2051d Inode: 131078 Links: 1注意Blocks: 8这是512字节扇区为单位计的所以实际用了8×5124096字节正好一个逻辑块。IO Block: 4096说明了文件系统的块大小。dumpe2fs查看整个文件系统结构$ dumpe2fs -h /dev/sda2 Block count: 2048000 Block size: 4096 Inode count: 524288 First inode: 11 Inode size: 256这些数字能让你对文件系统的整体规模和每个块组的大小有直接概念。debugfs查看inode的extent信息$ debugfs -R stat 131078 /dev/sda2这条命令会输出类似上面说的EXTENTS信息直观展示逻辑块到物理块的映射。5.2 用hdparm测试物理扇区大小有时候一个文件系统性能不理想问题出在最底层的物理扇区配置上。用hdparm看物理扇区大小$ hdparm -I /dev/sda | grep -E Sector size|Logical Logical Sector size: 512 bytes Physical Sector size: 4096 bytes如果逻辑是512、物理是4096说明是块4K扇区盘。这种盘如果分区不是从8的整数倍扇区开始就会导致一个逻辑块跨越两个物理扇区。检查分区起始扇区$ fdisk -l /dev/sda Device Start End Sectors Size Type /dev/sda1 2048 2099199 2097152 1G EFI System起始扇区2048是8的倍数对齐没问题。5.3 分配策略的选择预留空间和碎片整理文件系统格式化的参数选择也会影响未来的映射质量。两个关键参数mkfs.ext4 -b 4096 -m 2 -E stride32,stripe-width64 /dev/sdb1-b 4096块大小。小文件多的场景用更小块大文件多的场景用4K更省管理空间。-m 2预留块比例。默认5%对大容量盘来说有点浪费2%一般够用了。-E stride/stripe-width如果是RAID阵列让文件系统知道条带大小分配时就能按条带对齐避免一个逻辑块跨越两个RAID条带。在线碎片整理是e4defrag命令$ e4defrag -c /var/log/messages # 只看碎片程度 $ e4defrag /dev/sda2 # 整理整个文件系统实测过一个长期跑数据库的服务器碎片率从15%整理到接近0%后顺序读性能提升了大约20%。但对正在运行的数据库执行全盘整理风险不低建议在维护窗口操作整理前先做快照。6. 寻址机制的发展方向与性能优化的几点考量6.1 Btrfs和XFS的映射策略对比Ext4不是唯一的选择。XFS也用extent机制但它的extent是用B树管理的根节点直接存在inode里天生更适合大文件大目录的场景。B树的优势在于范围查询和并发访问多个文件同时写入时各自的extent查找路径互不干扰。Btrfs则更进一步把整个文件系统的数据结构都放到B树里支持子卷、快照、校验和还引入了CoW写时复制机制。CoW保证写操作不会覆盖旧数据而是先写入新位置然后原子地更新元数据指向。这让快照非常容易做但也意味着文件更新会产生大量碎片需要定期运行btrfs balance来整理。6.2 文件系统层面之外页缓存和Page Cache地址映射不是只有磁盘寻址这一个环节页缓存Page Cache对性能的影响甚至更大。Linux会尽量在内存里缓存文件的页面读操作优先命中缓存写操作先改缓存再延迟回写。这就是为什么free命令看到buff/cache总是占满了大部分内存。核心回写参数在/proc/sys/vm/里$ cat /proc/sys/vm/dirty_ratio # 默认20脏页占内存比例超过这个值就开始写回 $ cat /proc/sys/vm/dirty_expire_centisecs # 默认3000即30秒 $ cat /proc/sys/vm/dirty_writeback_centisecs # 默认500即5秒唤醒一次写回线程如果你的应用是数据库、Redis这种需要高持久性的可以把dirty_expire_centisecs调小让脏页更快落盘。如果是大量顺序写日志的场景保持默认就好让内核尽可能多攒一些连续页再批量刷。6.3 排查思路为什么我的大文件读取这么慢如果你遇到了大文件读取速度不理想的问题排查路径可以是把物理映射结构、缓存命中、IO调度和底层硬件逐一排查一遍大多数性能问题都会水落石出。90%的“文件系统性能问题”最后都跟物理碎片、块对齐和缓存参数有关纯寻址算法本身反而很少出问题。7. 常见问题与排查技巧实录7.1 文件系统报错“No space left on device”但df显示还有空间典型的inode耗尽问题。文件系统里的inode总数在格式化时定死大量小文件会迅速消耗inode。解决方法是格式化时调大-i参数inode字节比或使用-N指定更多inode$ df -i /data # 查看inode使用率 $ mkfs.ext4 -i 16384 /dev/sdb1 # 每16K字节分配一个inode已格式化的文件系统如果inode不足Ext4支持resize2fs在线扩容时可以增加inode但过程不可逆操作前一定要备份。7.2 大量小文件读写性能极差小文件的性能瓶颈主要在两个环节一是inode分配和目录项更新的开销二是文件的寻址跳跃。小文件通常只有几KB但每个文件要占一个inode对应一个或多个block目录项也要维护。Ext4的目录已经用了Htree索引目录大时查找是高效了但创建删除的速度还是赶不上大文件的顺序读写。优化思路是有意识地合并小文件比如日志按天归档、数据库的binlog定期清理或者用tar、sqlite这类工具把一堆小文件打包成一个文件。7.3 开机时fsck长时间卡住fsck本质上是检查并修复文件系统的元数据一致性。如果意外断电导致元数据损坏fsck会遍历整盘检查时间会非常长。避免的办法是确保Ext3/Ext4的日志功能正常并且设置正确的挂载参数$ mount -o dataordered /dev/sdb1 /data对于可容忍数据丢失的嵌入式环境可以加上datawriteback提高性能但代价是异常断电后文件内容可能和元数据不一致。生产环境强烈不建议用writeback模式。还有一招是配置自动fsck检查周期$ tune2fs -c 30 -i 30d /dev/sda2每30次挂载或30天检查一次。如果检查本身在合理时间内没完成那多半是有硬件层面的坏道或控制器问题别跟fsck死磕优先检查SMART信息。7.4 查看块组和位图相关的细节最后分享一下我常用的几个“窥探”文件系统内部的命令组合对学习和排查都很有帮助# 查看块组详细信息 $ dumpe2fs /dev/sda2 | head -60 # 查看某个文件具体占用的块 $ debugfs -R blocks 131078 /dev/sda2 # 查看superblock备份位置 $ mke2fs -n /dev/sda2 | grep -i superblock这些输出能把文件和底层磁盘对应关系完全摊开在你面前。我自己当年就是靠反复对比debugfs的extent输出和实际block位置才真正把地址映射这回事在脑子里立起来的。建议你也动手试试找个小U盘或虚机磁盘格式化后写几个不同大小的文件再用这些命令逐个查看映射关系。系统的底层设计从来不是靠背概念能掌握的亲手操作一遍比看十篇文档都管用。