轻松学Linux文件系统(Ext2/Ext3/Ext4)

序言

  当谈到计算机文件系统时,我们通常指的是操作系统用来组织和存储文件数据的方式。常见的文件系统也是千姿百态,比如CentOS 5/6上使用的ext2/ext3/ext4,CentOS 7上使用的xfs,Windows上默认使用的是NTFS,光盘类的文件系统ISO9660,Mac上的混合文件系统HFS,网络文件系统NFS,Oracle研发的btrfs,还有老式的FAT/FAT32等。虽文件系统众多,但文件系统通常由以下几个主要组成部分构成:

  • 文件:存储数据的基本单位,可以是文本文件、图像、视频、程序等。

  • 目录(或文件夹):用于组织和管理文件的容器,可以包含文件和其他目录。

  • 文件属性:包括文件名、大小、创建时间、修改时间、访问权限等信息。

  • 数据结构:用于跟踪文件的物理存储位置以及文件之间的关联关系。

注:我主要以讲解 E x t 文件系统的 E x t 2 / E x t 3 / E x t 4 为主。 \color{#f00}{注:我主要以讲解Ext文件系统的Ext2/Ext3/Ext4为主。} 注:我主要以讲解Ext文件系统的Ext2/Ext3/Ext4为主。

不明白的可以查阅最权威 L i n u x 网站: \color{#f00}{不明白的可以查阅最权威Linux网站:} 不明白的可以查阅最权威Linux网站:The Linux Kernel documentation

一:文件系统的组成部分

(一):block(逻辑块)

  硬盘的底层IO读写一次是一个扇区(512字节),那么读取一个大文件,若以扇区为单位会进行多次读写,这样会很耗用性能,所以便引出了逻辑块的概念。逻辑块是由磁盘驱动器负责维护和操作,它并不像扇区一样被物理划分的,而是一个逻辑块的大小可能包含一个或多个扇区,每个逻辑块都有唯一的地址,称为LDBA。有了逻辑块之后,磁盘控制器对数据的操作就以逻辑块为单位,一次读写一个逻辑块,磁盘控制器知道如何将逻辑块翻译成对应的扇区并读写数据。

  在Linux的文件系统里也提供了块(逻辑块)读写单元;这里以ext4的文件系统来说,块大小可以为1024字节(1KB)、2048字节(2KB)、4096字节(4KB)等。要知道文件系统数据块也是逻辑概念,需要文件系统层次的维护,而磁盘上的逻辑数据块是由磁盘控制器维护的;

  文件系统的IO管理器负责管理文件系统与磁盘之间的数据读写操作。当文件系统需要读取或写入数据时,它会将数据分割成逻辑块(或数据块)进行处理。每个逻辑块都有一个对应的逻辑块地址(Logical Block Address,LBA),用于标识该逻辑块在文件系统中的位置。然而物理层面上的磁盘并不关注逻辑块地址,而是使用磁盘维护的数据块地址(Logical Disk Block Address,LDBA)来定位和访问实际存储数据的位置。因此文件系统的IO管理器需要将逻辑块地址转换为磁盘维护的数据块地址(通常是磁盘上的扇区地址或页地址),以便正确地与磁盘进行交互。

  这种逻辑块地址到磁盘维护的数据块地址的转换过程,通常使用磁盘分区表或映射表等数据结构来完成。这些数据结构存储了逻辑块地址与实际磁盘数据块地址之间的映射关系。
当文件系统需要读取某个逻辑块时,IO管理器会查询这些映射表,将逻辑块地址转换为相应的磁盘数据块地址后,磁盘控制器再将这些逻辑块翻译成扇区地址,然后从扇区中读取数据,再通过磁盘控制器将这些扇区数据重组写入到内存中去。

  文件系统block的出现使得在文件系统层面上读写性能大大提高,也大量减少了碎片。但是它的副作用是可能造成空间浪费。由于文件系统以block为读写单元,
即使存储的文件只有1K大小也将占用一个block,剩余的空间完全是浪费的。在某些业务需求下可能大量存储小文件,这会浪费大量的空间。

(二):inode(索引和索引表)

  假如一个block(逻辑块)占用4096字节(4KB),那么存储一个大小为5MB的文件就需要用掉1280个Block空间,并且这1280个Block在物理的磁盘块中并不是连续存储的;那读取这个文件难道是把整个磁盘都扫描一遍,再找到这1280个块文件后并组成一个要读取的文件吗(再说这1280个块也无法被读出,因为不知道从哪开始读,读哪个逻辑块文件)?可以确认的是文件系统并不是使用这种方式;另外每个文件并不是只有保存自身文件数据就完事了,它还要有用来描述文件自身信息的元数据(如大小、时间戳、权限等),那这些数据保存在哪呢?

  所以通过上面的疑问可以得知,文件系统肯定是解决好了这些问题的,并且是有一套完整的文件存储方式;对于这上面的问题,文件系统在这里引入了索引,通过扫描索引找到对应的数据,而且索引内部也是可以存储部分数据,这部分数据也包含文件的元数据。

  在文件系统中那个索引节点被称为(Index node,inode),在索引节点上存储的部分数据是文件的元数据以及其它少量信息。一般来说索引占用的空间相比索引的文件数据而言要占用的空间会小很多,扫描索引比扫描整个数据要快得多,而且知道了文件索引后也可以快速的通过文件索引内记录的文件逻辑块地址快速找到那些零散存放的逻辑块信息,这样一来就解决了前面所有的问题。

  解剖Inode索引节点内部结构后,会发现其内部存储了如inode号(注,inode中并未存储inode num,但为了方便理解,这里暂时认为它存储了inode号)、文件类型、权限、文件所有者、大小、时间戳等元数据信息等(注意:inode内并未存储文件名称哟),最重要的是还存储了指向属于该文件block的指针(逻辑块地址),这样读取inode就可以找到属于该文件的block,进而读取这些block并获得该文件的数据。还要说明的是一般inode大小为128字节或256字节,相比那些MB或GB计算的文件数据而言小得多的多,但是也有一个文件大小小于inode大小,例如只占用1个字节的文件。

  我们再看这样一个问题,一个文件系统中有非常多的文件,每个文件都对应一个inode,难道每一个仅128字节的inode都要单独占用一个block进行存储吗(数据存储需要以block为最小单位)?这太浪费空间了。所以更优的方式是将多个inode合并存储在block中,对于128字节的inode,一个block可存储32个inode,对于256字节的inode,一个block可存储16个inode。这就使得每个存储inode的块都不浪费。

  在ext文件系统上,将这些物理上存储inode的block组合起来,在逻辑上形成一张inode表(inode table)来记录所有的inode。但想一想就能发现一个大的文件系统占用大量的块来存储inode,想要找到其中的一个inode记录也需要不小的开销,尽管它们已经形成了一张逻辑上的表,但也扛不住表的记录太多。所以为了快速找到inode,文件系统会将block进行分组划分,每个组中都存有本组的inode table范围、和下面说的bmap等。

(三):bmap(块映射表)

  在向磁盘存储数据时,文件系统需要知道哪些块是空闲的,最差的方式将磁盘从前向后扫描,遇到空闲块就存储一部分数据,然后继续扫描直到存储完所有的数据。

  为了解决这种最差的方式,文件系统引入了块位图(bitmap,简称bmap),bmap它使用0和1标识对应block是空闲的还是被占用的,bmap和block是对应的,比如一个bmap的数据为“1010…”,那么就代表block的第0块和第2块是已经存储了数据的,而第1块和第3块空闲的;磁盘有多少个block,对应的bmap就有多少个位标识。还有就是bmap可以用一个字节的空间就可以标识8个block是占用还是空闲,比如对磁盘大小为1GB空间内的的262144个block(一个block占4096字节/4KB)进行标记空闲还是占用,bmap只需要占用32KB的空间就可以标记这262144个block是空闲还是占用了,而且计算机对这32KB的位图信息扫描起来也是很快的。

  bmap只是针对写优化,因为只有写才需要找到空闲block并分配空闲block。对于磁盘读数据并不会使用到bmap,而是读只要通过inode找到了block的位置,cpu就能迅速计算出block在物理磁盘上的地址,然后再通过磁头进行数据的读取,而大一点的文件可能都会存储在不连续的block上,而且使用了一段时间的文件系统可能会有不少碎片,这时硬盘的随机读取性能直接决定读数据的速度,这也是机械硬盘速度相比固态硬盘慢的原因之一,而且固态硬盘的随机读和连续读取速度几乎是一致的,对它来说,文件系统碎片的多少并不会影响读取速度。

  虽然bmap已经极大的优化了扫描,但仍有瓶颈:如果文件系统是1TB呢?1TB的文件系统内有268435456个block(1个block占4KB),那么换算下来就是268435456÷8(位)÷1024(KB)÷1024(MB)=32MB空间;我们可以想想这些block还并不是连续的,扫描这些block也得花费一些时间,虽然快但是扛不住这种每次存储文件都要扫描带来的巨大开销。所以文件系统会把磁盘划分开形成块组,这样会减少扫描事件,这个后面会说。

说明:1GB空间需要占用多少大小的bmap空间(这里一个block占4096字节/4KB)
    从上面我们可以得出,大小1GB磁盘空间可以划分出262144个block,那么这些block的占用或空闲需要记录在bmap的
    位图上,每个字节的bmap可以记录8个block的状态,所以262144÷8=32768字节的bmap空间来记录block状态,换算
    成KB则是32768÷1024=32KB。

(四):imap(索引映射表)

  我们首先要知道inode号在格式化创建文件系统后就被计算好了放在一个个连续的block逻辑块里,此时这些inode号都是空的,还要注意的是,创建文件系统时会为每个块组都计算好该块组下拥有哪些inode号,每个块组的inode号都放在一个个连续的block逻辑块里。

  上面说了bmap块位图用于标识文件系统中哪些block是空闲的或者是占用的。对于inode也一样,在存储任何文件时都需要事先为其分配一个inode号。那么可以发现,随着inode号的分配,文件系统怎么知道哪些inode号是否已经被分配了呢?所以文件系统就搞了个像bmap记录block的占用情况一样。使用索引位图(inodemap,简称imap)的方式来记录indoe是否被使用,这时要为一个文件分配inode号只需扫描imap即可知道哪一个inode号是空闲的。

  imap存在着和bmap一样需要解决的问题,就是如果文件系统比较大,imap本身就会很大,每次存储文件都要进行扫描,会导致效率不够高。同样,优化的方式是将文件系统占用的block划分成块组,每个块组有自己的imap范围。

(五):block group(块组)

  之前一直提到文件系统占用的多个block会被划分成了一个块组(block group),为的是解决在一个分区里bmap、inode table(索引表)和imap太大的问题,所以就被拆分成多个块组。我们在物理层面上将磁盘按柱面划分为多个分区,即每个分区就是一个文件系统;并且还要在文件系统内的逻辑层面上再次划分出多个块组,需要知道的是每个文件系统内部是包含多个块组(block group);每个块组内部都包含一个元数据区和一个数据存储区(还包含超级块和块组描述符GDT),元数据区就是存储bmap、inode table和imap等数据,而数据存储区就是存储文件最终数据的区域。特别要注意的是块组是逻辑层面的概念,所以并不会真的在磁盘上按柱面、按扇区、按磁道等概念进行划分。

  其实块组在文件系统创建完成后就已经划分完成了,也就是说元数据区(bmap、inode table、imap等)信息占用的block以及数据存储区占用的block都已经在格式化后就划分好了。一个块组的大小需要根据block和bmap来计算的,假设这里的block占用4KB的空间,知道了block的大小后,那我们再看看bmap,一个块组的大小最多占用一个完整的block大小的bmap,可能有点绕,但看这个案例就可以理解,我首先通过“df -h”获取到sda2这个文件系统一共20GB,再通过“dumpe2fs /dev/sda2”命令查看出我的文件系统一共有159个(从0开始)块组,那么怎么通过159个块组就能计算20GB的空间呢?先计算出bmap占用多少个block,可以使用4096×8得出32768个block,那一个块组占用多大空间则使用32768(一个块组的block数量)×4096(block大小字节) ÷ 1024 (KB) ÷ 1024(MB) =128MB,这就得出一个块组占用128MB,那么我们拿160(块组) × 128 得出 ÷ 1024 (GB) = 20GB;注意的是块组是从0开始的

  既然每个组的block数量是划分好的,但每个组设定多少个inode号、inode table占用多少block这些都是由系统决定了,因为描述“每多少个数据区的block就为其分配一个inode号”的指标默认是我们不知道的,当然创建文件系统时也可以人为指定这个指标或者百分比,后面章节inode会详细阐述。

我们可以使用dumpe2fs命令将ext类的文件系统信息全部显示出来,其实bmap是每个块组固定一个block的不用显示,imap
比bmap更小所以也只占用1个block不用显示。下面是一个文件系统的部分信息,也是上面的计算示例信息,在这些信息的后面
还有每个块组的信息,其实这里面的很多信息都可以通过几个比较基本的元数据推导出来。

    root@jack-None:/dev# dumpe2fs  /dev/sda2
        Inode count:              1310720   文件系统inode号数量
        Block count:              5242112   文件系统block块总数
        Reserved block count:     262105    保留的block块数量
        Free blocks:              2735814   空闲的block块数量
        Free inodes:              1144046   空闲的inode号数量
        First block:              0         第一个block块号
        Block size:               4096      block块大小4KB
        Fragment size:            4096      碎片大小
        Reserved GDT blocks:      1024      保留GDP的块总数
        Blocks per group:         32768     每个块组的block块数量
        Inodes per group:         8192      每个块组的inode号数量
        Inode blocks per group:   512       每个块组inode占用的block块数量
        Flex block group size:    16        inode表大小
        First inode:              11        文件系统的第一个inode号
        Inode size:               256       每个inode的大小为256字节
        后面省略...
从上面这张表中能计算出文件系统的大小,该文件系统共5242112个blocks,每个block大小为4096B(字节),所以文件系
统大小为5242112*4096÷1024(KB)÷1024(MB)÷1024(GB)=19.997GB。
注意:这19.997GB是实实在在可存储的文件大小,所以我们也就知道为什么有的磁盘买的是8G,可实际也就7GB多些。

也能计算出分了多少个块组,因为每个块组的block数量为32768个,所以块组的数量为159.9765625÷32768=159.976即
160个块组。由于块组从0开始编号,所以最后一个块组编号为Group 159。如下所示是最后一个块组的信息:
    组 159:(块 5210112-5242111) 校验和 0xd815 [INODE_UNINIT, ITABLE_ZEROED]
      块位图位于 4718607 (bg #144 + 15),校验和 0xa8269ef3
      inode 位图位于 4718623 (bg #144 + 31),校验和 0x00000000
      inode 表位于 4726304-4726815 (bg #144 + 7712)
      32000 个可用块,8192 个可用 inode,0 个目录 ,8192 个未使用的 inode
      可用块数: 5210112-5242111
      可用 inode 数: 1302529-1310720

二:文件系统的完整结构

  上面我们大致了解了文件系统的组成部分(inode、inode table、bmap、imap、data block),但是这不够完整,下面我就详细介绍一个完整的文件系统。

  通过下图可以看出多了Boot Block、Super Block、GDT、Reserver GDT这几个概念。但下面会分别介绍它们。还可以详细看到块组中每个部分占用的block数量,但除了superblock、bmap、imap能确定占用1个block,其它部分的block都不能确定占用几个。也指明了Super block、GDT和Reserved GDT是必须同时出现但不一定每一个块组中都存在,还也指明了bmap、imap、inode table和data blocks是每个块组都有的。

  关于硬盘结构请参考(磁盘的内部结构及原理(详细) - 掘金 (juejin.cn))

image.png

(一):引导扇区(Boot Sector)

  我们看一下上面的Boot Sector(引导扇区、引导块)。MBR程序所做的第一件事情是确定活动分区,并读入所有分区内处于活动的引导块,然后装入内存并执行它,引导块位于每个分区内的第一个逻辑块的前1024个字节里(如果逻辑块大小是512字节,那么会占用2个逻辑块存储引导块)。引导块是操作系统的引导程序和文件。每个分区都保留了引导块,不管这个分区是否已经安装了操作系统。在MBR分区的方式下,启动的分区必须是主分区,不能是逻辑分区,逻辑分区只能是被管理。引导块可以用来确认当前分区是否安装了操作系统,

(二):超级块(Super Block)

  试想一下,既然一个文件系统会被分成多个块组,那么当前分区内的文件系统又如何知道此文件系统内一共分了多少个块组,每个块组又有多少个block多少inode号等信息;还有就是在哪记录文件系统本身的属性信息如各种时间戳、block总数量和空闲数量、inode总数量和空闲数量、当前文件系统是否正常、什么时候需要自检等等;所以超级块(Super Block)的出现就是为了存储这些信息的,存储这些信息需要占用一个1024字节大小空间。

  超级块的block号可能为0也可能为1。如果引导块正好占用一个block(假设block大小为1024字节),那么引导块的block号为0,所以超级块的block号就为1;如果block大小大于1024字节(假设block大小为4096字节),则引导块和超级块同置在一个block中,这个block号为0。总之超级块的起止位置是第二个1024(1024-2047)字节。

  超级块对于文件系统而言是至关重要的,超级块丢失或损坏必将导致文件系统的损坏。所以旧式的文件系统将超级块备份到每一个块组中,但是这又浪费空间,所以ext2/3/4文件系统只在块组0、1、3、5、7幂次方的块组中保存超级块的信息,如Group3、Group9、Group13等。尽管保存了这么多的超级块,但是文件系统只使用第一个块组即Group0中超级块信息来获取文件系统属性,只有当Group0上的超级块损坏或丢失才会找下一个备份超级块复制到Group0中来恢复文件系统。

(三):块组描述符表(GDT)

  从上图可以看出文件系统划分了块组,那么每一个块组的信息和属性元数据都需要占用一个32字节(Ext文件系统是这样的)的存储空间,这32字节的存储空间被称为块组描述符,所有块组的块组描述符放在一起会组成块组描述符表GDT(group descriptor table)。

  虽然每个块组都需要块组描述符来记录块组的信息和属性元数据,但并不是每个块组中都存放了块组描述符。ext文件系统的存储方式是:将它们组成一个GDT,并将该GDT存放于某些块组中,它的存放方式和超级块存储的方式一样,也就是说它们是同时出现在某一个块组中的。读取时也总是读取Group0中的块组描述符表信息。

  假如一个block大小为4096字节的文件系统划分了160个块组,每个块组描述符32字节,那么GDT就需要160×32=5120个字节即两个block来存放。这两个GDT block中记录了所有块组的块组信息,且存放GDT的块组中的GDT都是完全相同的。块组描述符 — Linux 内核文档 (kernel.org)

我们可以通过这个命令“dumpe2fs /dev/sda2”来查看“/dev/sda2”设备磁盘的其中一个块组信息:
    组 159:(块 5210112-5242111) 校验和 0xd815 [INODE_UNINIT, ITABLE_ZEROED]
      块位图位于 4718607 (bg #144 + 15),校验和 0xa8269ef3
      inode 位图位于 4718623 (bg #144 + 31),校验和 0x00000000
      inode 表位于 4726304-4726815 (bg #144 + 7712)
      32000 个可用块,8192 个可用 inode,0 个目录 ,8192 个未使用的 inode
      可用块数: 5210112-5242111
      可用 inode 数: 1302529-1310720
可以看出上面展示了其中一个块组信息,这些信息都是由块组描述符记录着,如从哪开始是inode表、从哪开始是数据块等信息

(四):保留GDT(Reserved GDT)

  保留GDT用于以后扩容文件系统使用,防止扩容后块组太多,使得块组描述符超出当前存储GDT的blocks。保留GDT和GDT总是同时出现,当然也就和超级块同时出现了。例如前面160个块组使用了2个block来存放GDT,但是此时第二个block还空余很多空间,当扩容到一定程度时2个block已经无法再记录这些块组描述符了,所以这时就需要分配一个或多个Reserved GDT的block来存放超出的块组描述符。

  由于新增加了GDT block,所以应该让每一个保存GDT的块组都同时增加这一个GDT block,所以将保留GDT和GDT存放在同一个块组中可以直接将保留GDT变换为GDT而无需使用低效的复制手段备份到每个存放GDT的块组。同理,新增加了GDT需要修改每个块组中超级块的文件系统属性,所以将超级块和保留GDT/GDT放在一起又能提升效率。

(五):目录数据块(Directory Data Block)

  对照上图我们可以看出,除了Data Blocks其它的部分都解释过了。我们最好的理解就是Data Blocks是直接存储数据的Block;通过inode里存储的该文件block的指针(逻辑块地址)找到对应的Data blocks里的block信息;Data Blocks确实存储了文件的数据信息,但事实上并不是只存储文件的数据信息,记得我在第一节里的inode讲述中,明确指出inode元数据内并未存储文件名称,所以文件名称存储在哪呢?

  对于Linux系统来说,不同的文件类型,数据在block中存储的内容是不一样的;对于常规文件来说数据正常存储在数据块中。但对于文件目录信息来说,该目录下的所有文件和一级子目录的目录名也存储在数据块中,但这个数据块常被人称为目录数据块(注意:Linux中文件目录也是文件的一种哟,只不过它是目录文件类型)。

  我在第一节里的inode讲述中还明确强调,文件名称和inode号并不是存储在其自身的inode中,而是存储在其所在目录数据块内(存储目录的data block)。具体的细节就看如下的目录数据块。

三:详解目录数据块

(一):目录数据块的存储结构

image.png

  由图可知,在目录数据块中存储了其目录下的文件名、目录名、目录本身的相对名称".“和上级目录的相对名称”…“,还存储了这些文件名对应的inode号、目录项长度rec_len、文件名长度name_len和文件类型file_type。注意的是除了文件本身的inode记录了文件类型,其所在的目录的数据块也记录了文件类型。由于rec_len只能是4的倍数,所以需要使用”\0"来填充name_len不够凑满4的倍数部分。至于rec_len具体是什么,只需知道它是一种偏移即可。

  还需要注意的是,inode table中的inode自身并没有存储每个inode的inode号,而inode号是存储在目录数据块中的,通过inode号可以计算并索引到inode table表中的inode号对应的inode记录,可以认为这个inode号是一个inode指针 (并非真的是指针,但有助于理解通过inode号索引找到对应inode的这个过程,后文将在需要的时候使用inode指针这个词来表示inode号。至此已经知道了两种指针:一种是inode table中每个inode记录指向其对应data block的block指针,另一个是此处的“inode指针”,用来计算inode table表中的inode记录)。

除了inode号,目录的data block中还使用数字格式记录了文件类型,数字格式和文件类型的对应关系如下图。  
 编码   文件类型  
  0   Unknown(未知类型)  
  1   Regular file(常规普通文件类型)  
  2   Directory(目录类型)  
  3   Character device(字符设备类型)  
  4   Block device(块设备类型)  
  5   Named pipe(管道文件类型)  
  6   Socket(套接字文件类型)  
  7   Symbolic link(链接文件类型)  
注意到目录的data block中前两行存储的是目录本身的相对名称"."和上级目录的相对名称"..",  
它们实际上是目录本身的硬链接和上级目录的硬链接。硬链接的本质后面说明。

(二):为何目录数据块的inode号能找到inode

  要知道inode结构里并没有保存inode号(也没有保存文件名),而目录数据块中却保存了该目录中每个文件的inode号。既然inode中没有inode号,那么如何根据目录数据块中的inode号找到inode table中对应的inode呢?

  其实只要有了inode号,就可以计算出inode表中对应该inode号,再根据inode号就可以找到inode结构以及引用的数据存储块。在创建文件系统的时候,每个块组中的起始inode号以及inode table的起始地址都已经确定了,所以只要知道inode号,就能知道这个inode号和该块组起始inode号的偏移数量,再根据每个inode结构的大小(256字节或其它大小),就能计算出来对应的inode结构(就像计算数组偏移一样)。

  所以目录数据块中的inode number(inode号)和inode table中的inode是通过计算的方式映射起来的。而目录数据块中的inode number(inode号)也是找到inode table中对应inode记录的唯一方式。

  有个特殊情况就是目录数据块中的记录已经删除,但是该记录对应的inode结构仍然存在于inode table中。这种inode称为孤儿inode(orphan inode),存在于inode table中,但却无法再索引到它。因为目录中已经没有该inode对应的文件记录了,所以其它进程将无法找到该inode,也就无法根据该inode找到该文件所指向占用的数据存储块(data block),这正是创建后删除所实现的真正临时文件,该临时文件只有当前进程和子进程才能访问。

(三):符号链接的存储方式

  Linux中符号链接又被称为软连接,它就像我们在Windows系统创建的快捷方式一样,最终目的就是指向原文件和目录;软链接之所以也被称为特殊的文件原因是:它一般情况下不占用data block,而是通过它对应的原文件inode记录就能将其信息描述完成;软链接的大小是其指向目标路径占用的字符个数,例如某个符号链接的指向方式为"rmt --> …/sbin/rmt",则其链接的文件大小为11字节;只有当软链接指向的目标的路径名称较长(大于60个字节)时文件系统才会划分一个data block给它;还有就是软链接的权限始终为777,它只是一个指向原文件的"工具",最终决定是否能读写执行的权限由原文件决定。

  需要注意的是,软链接的目录或文件是存储在目录数据块中的,通过对应目录数据块中存储的inode号找到对应inode table表里的inode,而关键就是这个inode里的数据指针不是指向一个data block,而是存储的是被链接的目标文件名。也就是说,链接文件的一切都依赖于其目标文件名。

  符号链接请参考(内含软链接和硬链接说明):详解Linux硬链接与软链接(ln命令)

(四):设备文件

  设备文件是与系统中特定设备相对应的文件。在Linux内核中,每种设备类型都对应有设备驱动程序接口,用于处理该设备的所有IO请求。设备驱动程序是内核代码,可以执行一系列操作,通常与相关硬件的输入/输出动作相对应。操作系统提供的设备驱动程序API是固定的,包括开启、关闭、读取、写入、内存映射和ioctl等操作,类似于系统调用中的open()、close()、read()、write()、mmap()和ioctl()。需要注意的是,每个设备驱动程序所提供的接口是一致的,因此每个设备都必须实现这些接口,才能被操作系统调用其指定的功能接口。

  还要注意的是设备文件不占用任何数据块(还有FIFO、套接字文件也不占用数据块),所以它们是特殊文件。设备文件的主设备号和次设备号保存在inode中,还要注意到这些信息中没有大小的信息,因为设备文件不占用数据块所以没有大小的概念。

设备文件可以分为两种:  
块设备(以b开头):  
  以固定大小的块(大小为该文件系统的block逻辑块大小)为单位进行数据读写的设备,块设备的访问速度相对  
  较慢,但能够处理大量的数据,且支持随机访问。在Linux中,块设备通常被挂载到文件系统上,然后就可以在  
  文件系统中进行读写操作了。(如硬盘、软盘、CD-ROM驱动器及DVD播放器)  
字符设备(以c开头):  
  以字符流的形式进行数据读写的设备,字符设备的访问速度相对较快,但只能以流的方式读写数据,无法直接跳  
  转到某个位置,所以也被称为线性访问。(如键盘、鼠标等输入设备和打印机、串口等输出设备)  
    root@jack-None:/dev# ll  
                    主设备 次设备  
    drwxr-xr-x 19 root root 4240          12月 10 13:51 ./  
    drwxr-xr-x 20 root root 4096          11月 13 21:17 ../  
    crw------- 1 root root   10,   183 12月 10 13:51 hwrng  
    crw------- 1 root root   10,   1   12月 10 13:51 psaux  
    crw-r--r-- 1 root root   1,   11  12月 10 13:51 kmsg  
    brw-rw---- 1 root disk   7,   0   12月 10 13:51 loop0  
    brw-rw---- 1 root disk   7,   1   12月 10 13:51 loop1  
    brw-rw---- 1 root disk   8,   0   12月 10 13:51 sda  
    brw-rw---- 1 root disk   8,   1   12月 10 13:51 sda1  
    brw-rw---- 1 root disk   8,   2   12月 10 13:51 sda2  
    brw-rw---- 1 root disk   8,   16  12月 10 13:51 sdb
注:只要设备文件才有主设备号和次设备号,如上面的../和./是没有设备号的

常见设备文件类型: \color{#f00}{常见设备文件类型:} 常见设备文件类型:

image.png
image.png

四:详解inode索引结构

  前面说过,每个文件都有一个inode,将inode关联到文件后,系统将通过目录数据块内存储的inode号来识别文件,而不是文件名。并且访问文件时将先找到inode,通过inode中记录的block位置找到该文件。那inode的内部结构又是什么样的呢?下面将对重要的进行详细说明。

(一):inode大小划分

  其实inode大小必须是128字节的倍数,最小为128字节。它有默认值大小,它的默认值由/etc/mke2fs.conf文件中指定。不同的文件系统默认值可能不同。我们可以通过cat /etc/mke2fs.conf命令查看具体信息:

# 当前为 Ubuntu 23 系统
> cat /etc/mke2fs.conf 
    [defaults]
        base_features = sparse_super,large_file,filetype,resize_inode,dir_index,ext_attr
        default_mntopts = acl,user_xattr
        enable_periodic_fsck = 0
        blocksize = 4096
        inode_size = 256
        inode_ratio = 16384
    
    [fs_types]
        ext3 = {
                features = has_journal
        }
        ext4 = {
                features = has_journal,extent,huge_file,flex_bg,metadata_csum,64bit,dir...
        }

  其实这个文件中还记录了blocksize的默认值和inode分配比率inode_ratio。可以看到inode_ratio=16384表示每16384个字节即16KB就分配一个inode号,那么blocksize=4KB,所以每4个block就会分配一个inode号。当然分配的这些inode号只是预分配,并不真的代表会全部使用,毕竟每个文件只分配一个inode号。但是分配的inode自身会占用block,而且通过上面可以看出每个inode自身占用大小256字节,所以inode号的浪费代表着空间的浪费。

  既然知道了inode分配比率,就能计算出每个块组分配多少个inode号,也就能计算出inode table占用多少个block。假设文件系统中存储大量电影等大文件,inode号就浪费很多,因为每一部电影它只会分配一个inode号,这样就会导致大量的inode号未被使用,所以inode占用的空间也浪费很多。但是没办法,文件系统又不知道你这个文件系统是用来存什么样的数据,多大的数据,多少数据。当然inode size、inode分配比例、block size都可以在创建文件系统的时候人为指定。

  补充:可以使用tune2fs -l /dev/sda2 | grep "Block size"命令校验blocksize具体大小是否符合。

(二):ext2/3中的inode结构和间接寻址

  可以看出inode的基本结构图如下,inode它包含文件访问权限、属主、组、大小、生成时间、访问时间、最后修改时间等信息。它是Linux管理文件系统的最基本单位。下面大概看看inode的构造图:
83BWTAM@C_N@_F68S6BX}44.png
ext4_inode结构:

具体参考:https://www.kernel.org/doc/html/latest/filesystems/ext4/dynamic.html
struct struct ext4_inode {
    __le16 i_mode;          // 文件模式(权限和类型)
    __le16 i_uid;           // 文件所有者的用户ID
    __le32 i_size_lo;       // 文件大小的低32位(字节为单位)
    __le32 i_atime;         // 最后访问时间
    __le32 i_ctime;         // 创建时间
    __le32 i_mtime;         // 最后修改时间
    __le32 i_dtime;         // 删除时间
    __le16 i_gid;           // 文件所属组的组ID
    __le16 i_links_count;   // 硬链接计数
    __le32 i_blocks_lo;     // 分配给文件的数据块数量的低32位
    __le32 i_flags;         // 文件标志
    union {
        struct {
            __le32 l_i_reserved1;
        } linux1;
        struct {
            __le32 h_i_translator;
        } hurd1;
        struct {
            __le32 m_i_reserved1;
        } masix1;
    } osd1;
    __le32 i_block[EXT4_N_BLOCKS];  // 数据块指针数组
    __le32 i_generation;    // 文件版本号
    __le32 i_file_acl;      // 文件访问控制列表(ACL)
    __le32 i_size_high;     // 文件大小的高32位(字节为单位)
    __le32 i_obso_faddr;    // 文件片段地址(已废弃)
    union {
        struct {
            __le16 l_i_blocks_high; // 分配给文件的数据块数量的高16位
            __le16 l_i_file_acl_high;
            __le16 l_i_uid_high;    // 文件所有者的高16位用户ID
            __le16 l_i_gid_high;    // 文件所属组的高16位组ID
            __le16 l_i_checksum_lo; // 低16位的inode校验和
            __le16 l_i_reserved2;
        } linux2;
        struct {
            __le16 h_i_reserved1;
            __le16 h_i_mode_high;
            __le16 h_i_uid_high;
            __le16 h_i_gid_high;
            __le32 h_i_author;
        } hurd2;
        struct {
            __le16 h_i_reserved1;
            __le16 m_i_checksum_lo; // 低16位的inode校验和
            __le32 m_i_reserved2[2];
        } masix2;
    } osd2;
    __le16 i_extra_isize;   // 额外信息的大小
    __le16 i_checksum_hi;   // 高16位的inode校验和
    __le32 i_ctime_extra;   // 创建时间的额外纳秒部分
    __le32 i_mtime_extra;   // 最后修改时间的额外纳秒部分
    __le32 i_atime_extra;   // 最后访问时间的额外纳秒部分
    __le32 i_crtime;        // 创建时间(纳秒部分)
    __le32 i_crtime_extra;  // 创建时间的额外纳秒部分
    __le32 i_version_hi;    // 高32位的版本号
    union {
        struct {
            __u8 l_i_frag;         // 文件片段号
            __u8 l_i_fsize;        // 文件片段大小
            __u16 i_pad1;
            __le16 l_i_uid_high;   // 文件所有者的高16位用户ID
            __le16 l_i_gid_high;   // 文件所属组的高16位组ID
            __u32 l_i_reserved2;
        } linux3;
        struct {
            __u8 h_i_frag;         // 文件片段号
            __u8 h_i_fsize;        // 文件片段大小
            __le16 h_i_mode_high;
            __le16 h_i_uid_high;
            __le16 h_i_gid_high;
            __le32 h_i_author;
        } hurd3;
        struct {
            __u8 m_i_frag;         // 文件片段号
            __u8 m_i_fsize;        // 文件片段大小
            __u16 m_pad1;
            __le32 m_i_reserved2[2];
        } masix3;
    } osd3;
};

  前面说过,inode中保存了blocks指针,但是一条inode记录中能保存的指针数量是有限的,否则就会超出inode大小(128字节或256字节)。在ext2和ext3文件系统中,一个inode中最多只能有15个指针,每个指针使用i_block[n]表示。前12个指针i_block[0]到i_block[11]是直接寻址指针,每个指针指向一个数据区的block;假设每个数据block大小为4096字节(4KB),那么前十二个直接寻址就可以指向一共48KB大小的数据块,具体如下图所示:
image.png

  我们知道了直接寻址指针,它只有12个(i_block[0]~i_block[11])地址指向记录,一共可指向48KB的空间大小;那么不可能文件都低于48KB的,所以大于48KB的文件那又如何指向呢?之前说过在ext2和ext3文件系统中,一个inode中最多只能有15个寻址指针;除了前12个寻址指针是直接寻址指针外,后面3个寻址指针就是下面要说的一级间接寻址指针、二级间接寻址指针和三级间接寻址指针,它们分别是i_block[12]、i_block[13]和i_block[14]。

  第13个指针i_block[12]是一级间接寻址指针,它指向了一个block,但是这个block里存储的全部是指针;所以指向关系是:i_block[12] --> PointerBlock(指针块) --> DataBlock(数据块)。

  第14个指针i_block[13]是二级间接寻址指针,它指向了一个block,但是这个block里存储的全部是指针,而指向的这个block里的全部指针指向的block还是一个存储指针的block;所以指向关系是:i_block[13] --> PointerBlock1(指针块1) --> PointerBlock2(指针块2) --> DataBlock(数据块)。

  第15个指针i_block[14]是三级间接寻址指针,它指向了一个block,这个指针block下还有两次指针指向。所以指向关系是:i_block[13] --> PointerBlock1(指针块1) --> PointerBlock2(指针块2) --> PointerBlock3(指针块3) --> DataBlock(数据块)。

  具体的一二三级间接寻址指针的指向关系请参考如下图:
image.png

上面我们介绍了寻址的指向,但一个inode最大可以指向多大的数据存储空间呢?其实具体的计算如下:
说明:
    一个block占用大小为4096个字节;
    一个寻址指针的占用空间大小4个字节;
直接寻址指针可存储的空间:
    占用指针数量:12个
    12 * 4096(字节,数据block) = 49152(字节) ÷ 1024(KB) = 48KB
一级间接寻址指针可存储的空间:
    占用指针数量:i_block[12]因为是指向一个指针块索引,
               所以一个指针块内可存4096(字节)÷4(字节,指针占用大小)=1024个
    1024 * 4096(字节,数据block) = 4194304(字节) ÷ 1024(KB) ÷ 1024(MB) = 4MB
一级间接寻址指针可存储的空间:
    占用指针数量:一级间接寻址占用1024个指针,那么二级则是 1024^2=1048576个指针。
    1048576 * 4096(字节,数据block) ÷ 1024(KB) ÷ 1024(MB) ÷ 1024(GB) = 4GB
三级间接寻址指针可存储的空间:
    占用指针数量:二级间接寻址占用1024^2个指针,那么三级则是 1024^3=1073741824个指针
    1073741824 * 4096(字节,数据block) ÷ 1024(KB) ÷ 1024(MB) ÷ 1024(GB) ÷ 1024(TB) = 4TB
整个计算公式:([4096/4]^3 + [4096/4]^2 + [4096/4] +12) * 4096(字节) ≈ 4TB
注:寻址指向空间计算不一定就是最大能存放的文件大小;它还受到其它条件的限制。计算只是说明大文件是如何寻址和分配的

  关于block的寻址指针的占用空间大小为什么是4个字节,我查阅资料发现i_block[14]一共占用60字节,每4字节代表一个i_block,那么一级寻址的PointerBlock为4096字节/4字节指针大小就等于1024个指针数了。具体这个我也是猜测,若有不对了希望大家可以指出。我参考的是:https://www.kernel.org/
image.png
  上面是基于ext2和ext3的文件系统计算,它对超大文件的存取效率是低下的,它要核对太多的指针,特别是4KB大小的blocksize时。而ext4针对这一点就进行了优化,ext4使用extent的管理方式取代ext2和ext3的块映射,大大提高了效率也降低了碎片。

(三):Ext文件系统预留Inode号

  Ext文件系统其实预留了一些inode来做特殊场景使用,但是下面的只是参考,具体的inode需要使用下面的命令来验证查询。

  Inode号    用途
    0      不存在0号inode,可用于标识目录data block中已删除的文件
    1      虚拟文件系统,如/proc和/sys
    2      根目录 "/"
    3      ACL索引
    4      ACL数据
    5      Boot  loader
    6      未删除的目录
    7      预留的块组描述符inode
    8      日志inode
    11     第一个非预留的inode,通常是lost+found目录
所以在ext4文件系统的dumpe2fs信息中,能观察到fisrt inode号可能为11也可能为12。

上面某些可能并非准确,具体的inode号对应什么文件可以使用" find / -inum NUM号 "查看:
    root@jack-None:/# find / -ignore_readdir_race -inum 2 -ls
        2   4 drwxr-xr-x  21 root     root     4096 1219 23:38 /
        2   0 -r--r--r--   1 root     root        0 114 17:13 /sys/kernel/security/lsm
        2   0 drwxr-xr-x   8 root     root        0 114 17:13 /sys/fs
        2   0 -rw-r--r--   1 root     root        0 114 17:13 /sys/fs/cgroup/cgroup.procs
        2   0 c---------   1 root     root   5,   2 114 17:13 /dev/pts/ptmx
        2   0 crw-rw-r--   1 root     root  10, 242 114 17:13 /dev/rfkill
        2   0 dr-x------   2 root     root        0 11  1970 /run/user/0/doc/by-app
        2   0 drwxr-xr-x   5 root     root      140 114 17:14 /run/user/0/systemd
注:除了根路径的Inode号为2,还有几个文件的inode号也是2,
    其实它们都属于独立的文件系统,有些是虚拟文件系统,如:/sys。

五:文件系统中文件操作原理

  这章将解释在Linux上执行创建、删除、复制、重命名、移动等操作时是如何进行的,并且是如何找到具体的文件并访问此文件。我们只要了解本文上面的一些概念后就会很容易知道文件操作的原理了。

  注:下面只是简单阐述单文件系统操作 \color{#f00}{  注:下面只是简单阐述单文件系统操作}   注:下面只是简单阐述单文件系统操作

(一):读取文件

  假设这里执行 “cat /var/log/app.log” 命令会涉及到了cat命令的寻找、权限判断以及app.log文件
的寻找等复杂的过程。这里只解释和本节内容相关的如何寻找到被cat的/var/log/app.log文件。

  首先找到根文件系统(“/”)的那个GDT(块组描述符)表所在的逻辑块(Blocks),读取GDT(已在内存中)找到Inode Table(索引表)的Block号。因为GDT总是和Super Block在同一个块组,而Super Block总是在分区的第1024-2047个字节,所以很容易就知道第一个GDT所在的块组以及GDT在这个块组中占用了哪些Block。其实GDT已经在内存中了,因为在系统开机的时候就会把根目录挂载到根文件系统,挂载的时候就已经将所有的GDT放进内存中。

  在Inode Table中查找到目录数据块并定位到根"/“的inode,找出”/“指向的data block(因为是目录,所以指向的是Directory Data Block,目录数据块)。前文说过,ext文件系统预留了一些inode号,其中”/“的inode号为2,所以可以根据inode号直接定位根目录文件的data block。

  在”/“的目录数据块中记录了var目录名和var的inode号,找到该inode记录,inode记录中存储了指向var的目录数据块指针,所以也就找到了var目录文件的目录数据块。

  需要注意的是,通过var目录的inode号,可以寻找到var目录的inode记录,但是在寻找的过程中,还需要知道var的inode记录所在的块组以及所在的inode table,所以需要读取GDT,同样GDT已经缓存到了内存中。

  在var的目录数据块中记录了log目录名和其inode号,通过该inode号定位到该inode所在的块组及所在的inode table,并根据该inode记录找到log的目录数据块。在log目录文件的目录数据块中记录了app.log文件名和对应的inode号,通过该inode号定位到该inode所在的块组及所在的inode table,并根据该inode记录找到app.log的data block。最后读取app.log对应的data block。

  将上述步骤中GDT部分的步骤简化后比较容易理解。如下:找到GDT–>找到”/“的inode–>找到”/"的数据块读取var的inode–>找到var的数据块读取log的inode–>找到log的数据块读取app.log的inode–>找到app.log的数据块并读取它们。

  当然,在每次定位到inode记录后,都会先将inode记录加载到内存中,然后查看权限,如果权限允许,将根据block指针找到对应的data block。

(二):删除文件

  其实删除文件分为普通文件和目录文件,知道了这两种类型的文件的删除原理,就知道了其它类型特殊文件的删除方法。

对于删除普通文件:
    (1):找到文件的inode和data block(根据上一节"读取文件"的方法寻找)(2):将inode table中该inode记录中的data block指针删除;
    (3):在imap中将该文件的inode号标记为未使用;
    (4):在其所在目录的data block中将该文件名所在的记录行删除,删除了记录就丢失了指向inode的指针;
    (5):将bmap中data block对应的block号标记为未使用。
    
对于删除目录文件:
    找到目录和目录下所有文件、子目录、子文件的inode和data block;将inode table中inode记录中的data
    block指针删除,并且在imap中将这些inode号标记为未使用;在bmap中将这些文件占用的block号标记为未使
    用;并在该目录的父目录的data block中将该目录名所在的记录行删除。
    "需要注意的是,删除父目录data block中的记录是最后一步,如果该步骤提前,将报目录非空的错误,"
    "因为在该目录中还有文件占用。"
    
关于上面的(2)-(5)的步骤详细说明:
    当(2)中删除data block指针后,将无法再找到这个文件的数据;
    当(3)标记inode号未使用,表示该inode号可以被后续的文件再次使用;
    当(4)删除目录data block中关于该文件的记录,真正的删除文件,外界再也无法看到这个文件了;
    当(5)标记data block为未使用后,表示开始释放空间,这些data block可以被其他文件重用。

  注意:实际上不是真的删除,直接删除的话会在目录data block的数据结构中产生空洞,所以实际的操作是将待删除文件的inode号设置为特殊的值0,这样下次新建文件时就可以重新使用该行记录。

  上面在操作第(5)步之前,由于data block还未被标记为未使用,在Super Block中仍然认为这些data block是正在使用中的。这表示尽管文件已经被删除了,但空间却还没有释放,df命令也会将其统计到已使用空间中(df是读取super block中的数据块数量,并计算转换为空间大小)。

  一个进程正在引用文件时将该文件删除了,就会出现文件已删除但空间未释放的情况。若这时步骤已经进行到第(4)步了,外界就无法再找到该文件,但由于进程在加载该文件时已经获取到了该文件所有的data block指针,该进程可以获取到该文件的所有数据,但却暂时不会释放该文件空间。直到该进程结束,文件系统才将未执行的步骤(5)继续完成。这也是为什么有时候du的统计结果比df小的原因。

(三):重命名文件

  重命名文件分为同目录内重命名和非同目录内重命名。非同目录内重命名实际上就是移动文件的过程。

  同目录内重命名文件的动作仅仅只是修改所在目录的data block中该文件记录的文件名部分,不是删除再重建的过程。如果重命名时有文件名冲突(该目录内已经存在该文件名),则提示是否覆盖。覆盖的过程是覆盖目录data block中冲突文件的记录。例如/tmp/下有a.txt和a.log,若将a.txt重命名为a.log,则提示覆盖,若选择覆盖,则/tmp的data block中关于a.log的记录被覆盖。

(四):移动文件

  同文件系统下移动文件实际上是修改目标文件所在目录的data block,向其中添加一行指向inode table中待移动文件的inode指针,如果目标路径下有同名文件,则会提示是否覆盖,实际上是覆盖目录data block中冲突文件的记录,由于同名文件的inode记录指针被覆盖,所以无法再找到该文件的data block,也就是说该文件被标记为删除(如果多个硬链接数,则另当别论)。所以在同文件系统内移动文件相当快,仅仅在目录data block中添加或覆盖了一条记录而已。也因此,移动文件时,文件的inode号是不会改变的。对于不同文件系统内的移动,相当于先复制再删除的动作。

(五):存储文件

文件存储存储的具体步骤:  
    (1):读取GDT,找到各个(或部分)块组imap中未使用的inode号,并将inode号分配给待存储的文件;  
    (2):在inode table中完善该inode号所在行的记录;  
    (3):在目录data block中添加一条该文件的相关记录,并将文件或目录的指针指向inode table表中的inode; 
    (4):将数据填充到data block中。  
    (5):填充完之后,去inode table中更新该文件inode记录中指向data block的寻址指针。

  其实填充到data block中的时候会调用block分配器:一次分配4KB大小的block数量,当填充完4KB的data block后会继续调用block分配器分配4KB的block,然后循环直到填充完所有数据。也就是说,如果存储一个100M的文件需要调用block分配器100×1024/4=25600次。另一方面,在block分配器分配block时,block分配器并不知道真正有多少block要分配,只是每次需要分配时就分配,在每存储一个data block前,就去bmap中标记一次该block已使用,它无法实现一次标记多个bmap位。这一点在ext4中进行了优化。

注:对于复制完全就是另一种方式的存储文件 \color{#f00}{注:对于复制完全就是另一种方式的存储文件} 注:对于复制完全就是另一种方式的存储文件

可以读写Ext2,以Ext2方式挂载Ext3文件系统(不支持Ext3日志),不支持中文! It provides Windows NT4.0/2000/XP/2003/Vista/2008 with full access to Linux Ext2 volumes (read access andwrite access). This may be useful if you have installed both Windows and Linux as a dual boot environment on your computer. What features are supported? Complete reading and writing access to files and directories of volumes with theExt2 orExt3 file system. Supports features which are specific to the I/O-system of Windows: Byte Range Locks, Directory Notfication (so the Explorer updates the view of a directory on changes within that directory), Oplocks (so SMB clients are able to cache the content of files). Allows Windows to run with paging files on Ext2 volumes. UTF-8 encoded file names are supported. The driver treats files with file names that start with a dot "." character ashidden. Supports GPT disks if the Windows version used also does. Supports use of the Windows mountvol utility to create or delete drive letters for Ext2 volumes (except on Windows NT 4.0). See also section"Can drive letters also be configured from scripts?". What features are *not* supported? Inodes that are larger than 128 bytes are not supported. Access rights are not maintained. All users can access all the directories and files of an Ext2 volume. If a new file or directory is created, it inherits all the permissions, the GID and the UID from the directory where it has been created. There is one exception to this rule: a file (but not a directory) the driver has created always has cleared "x" permissions, it inherits the "r" and the "w" permissions only. See also section"What limitations arise from not maintaining access rights?". The driver does not allow accessing special files at Ext2 volumes, the access will be always denied. (Special files are sockets, soft links, block devices, character devices and pipes.) Alternate 8.3-DOS names are not supported (just because there is no place to store them in an Ext2 file system). This can prevent legacy DOS applications, executed by the NTVDM of Windows, from accessing some files or directories. Currently the driver does not implement defragging support. So defragmentation applications will neither show fragmentation information nor defragment any Ext2 volume. This software does not achieve booting a Windows operating system from an Ext2 volume. LVM volumes are not supported, so it is not possible to access them.
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值