文件和目录

1 文件和目录

随着时间的推移,在存储虚拟化过程中形成了两个关键的抽象概念。第一个是文件。文件只是一个由字节组成的线性数组,每个字节都可以读写。每个文件都有某种底层名称,通常是某个数字;通常情况下,用户并不知道这个名称(我们将看到)。由于历史原因,文件的底层名称通常被称为其inode number(索引节点号)。

在大多数系统中,操作系统并不了解文件的结构(例如,它是图片、文本文件还是 C 代码);相反,文件系统的职责仅仅是将这些数据持久地存储在磁盘上,并确保当你再次请求数据时,你能得到当初放在那里的数据。做到这一点并不像看起来那么简单!

第二个抽象概念是目录。目录和文件一样,也有一个底层名称(即inode number),但其内容却非常具体:它包含一个(用户可读名称、底层名称)对列表。例如,假设有一个底层名称为 “10 “的文件,用户可读文件名为 “foo”。因此,“foo “所在的目录就会有一个条目(“foo”, “10”),将用户可读名称映射到底层名称。目录中的每个条目都指向文件或其他目录。通过将目录放置在其他目录中,用户可以建立一个任意的目录树(或目录层次结构),所有文件和目录都存储在该目录下。

目录层次结构从根目录开始(在基于 UNIX 的系统中,根目录简称为 /),并使用某种分隔符来命名随后的子目录,直到所需的文件或目录被命名为止。例如,如果用户在根目录/中创建了一个目录 foo,然后在目录 foo 中创建了一个文件 bar.txt,我们可以通过其绝对路径名来引用该文件,在本例中为 /foo/bar.txt 。更复杂的目录树如下图所示。

image-20240416102432763

示例中的有效目录为 //foo/bar/bar/bar/bar/foo,有效文件为 /foo/bar.txt/bar/foo/bar.txt。目录和文件可以具有相同的名称,只要它们位于文件系统树中的不同位置即可(例如,图中有两个名为 bar.txt 的文件,/foo/bar.txt/bar/foo/bar.txt)。

您可能还注意到,此示例中的文件名通常由两部分组成:bartxt,以.分隔。第一部分是任意名称(描述文件),而文件名的第二部分通常用于指示文件的类型,例如,它是 C 代码(例如.c)还是图像(例如 .jpg) ,或音乐文件(例如.mp3)。然而,这通常只是一个约定:通常没有强制规定名为 main.c 的文件中包含的数据确实是 C 源代码。

因此,我们可以看到文件系统提供的一件伟大的事情:一种命名我们感兴趣的所有文件的便捷方法。名称在系统中很重要,因为访问任何资源的第一步就是能够命名它。因此,在 UNIX 系统中,文件系统提供了一种统一的方式来访问磁盘、U盘、CD-ROM、许多其他设备以及事实上还有许多其他东西,它们都位于同一个目录树下。

2 文件系统接口

2.1 文件操作

2.1.1 创建文件

我们将从最基本的操作开始:创建文件。这可以通过 open 系统调用来实现;调用 open() 并传递 O_CREAT 标志,程序就可以创建一个新文件。下面是一些示例代码,用于在当前工作目录下创建一个名为 “foo “的文件。

1
int fd = open("foo", O_CREAT|O_WRONLY|O_TRUNC, S_IRUSR|S_IWUSR);

例程open() 使用多个不同的标志。在本例中,如果文件不存在,第二个参数会创建文件(O_CREAT),确保该文件只能被写入(O_WRONLY),并且如果文件已经存在,则将其截断为 0 字节大小,从而删除任何现有内容(O_TRUNC)。第三个参数指定权限,在这种情况下,文件所有者可以读写文件。

open() 的一个重要方面是它的返回值:文件描述符文件描述符只是一个整数,每个进程都是私有的,在 UNIX 系统中用于访问文件;因此,一旦文件被打开,你就可以使用文件描述符来读取或写入文件,前提是你有这样做的权限。因此,文件描述符是一种能力,即一个不透明的句柄,它赋予你执行某些操作的权力。另一种将文件描述符视为指向文件类型对象的指针的方法是:一旦你有了这样一个对象,你就可以调用其他 “方法 “来访问文件,如read()write()。如上所述,文件描述符由操作系统按进程进行管理。这意味着在 UNIX 系统的 proc 结构中保存了某种简单的结构(如数组)。下面是 xv6 内核中的相关内容:

1
2
3
4
5
struct proc {
    ...
    struct file *ofile[NOFILE]; // Open files
	...
};

一个简单数组(最多包含 NOFILE 打开的文件)可以跟踪每个进程打开了哪些文件。数组的每个条目实际上只是一个指向struct file的指针,它将用于跟踪正在读取或写入的文件信息。

2.1.2 读写文件

2.1.2.1 顺序读写

有了一些文件后,我们当然会想读取或写入它们,让我们从读取一个现有文件开始。如果我们在命令行中输入,我们可能只使用程序 cat 将文件的内容转储到屏幕上。

1
2
3
> echo 'Hello, World' > foo
> cat foo
Hello, World

在此代码片段中,我们将程序 echo 的输出重定向到文件 foo,然后该文件中包含内容“Hello, World”。然后我们使用 cat 来查看文件的内容。但是cat程序如何访问文件foo呢?

为了找到这一点,我们将使用一个非常有用的工具来跟踪程序进行的系统调用。在 Linux 上,该工具称为 strace;其他系统也有类似的工具(请参阅 Mac 上的 dtruss,或某些较旧的 UNIX 变体上的 truss)。 strace 的作用是跟踪程序运行时所做的每个系统调用,并将跟踪转储到屏幕上供您查看。

下面是一个使用 strace 来确定 cat 正在做什么的示例(为了便于阅读,删除了一些调用):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
> strace cat foo
...
openat(AT_FDCWD, "foo", O_RDONLY|O_LARGEFILE)       = 3
...
read(3, "Hello, World\n", 131072)       = 13
write(1, "Hello, World\n", 13)          = 13
Hello, World
read(3, "", 131072)                     = 0
close(3)                                = 0
...
+++ exited with 0 +++

cat 做的第一件事是打开文件进行读取。我们应该注意以下几点:

  1. 首先,该文件仅打开用于读取(而不是写入),如 O_RDONLY 标志所示;
  2. 其次,使用 64 位偏移量 (O_LARGEFILE);
  3. 第三,对 openat()(和oepn()一样)的调用成功并返回一个文件描述符,其值为 3。

为什么第一次调用 openat() 返回 3,而不是您可能期望的 0 或 1?事实证明,每个正在运行的进程已经打开了三个文件:标准输入(进程可以读取以接收输入)、标准输出(进程可以写入以将信息转储到屏幕)和标准错误(进程可以向其写入错误消息)。它们分别由文件描述符 0、1 和 2 表示。因此,当您第一次打开另一个文件时(如上面的 cat 所做的那样),它几乎肯定是文件描述符 3。

打开成功后,cat 会使用 read() 系统调用从文件中重复读取一些字节。

  1. read() 的第一个参数是文件描述符,它告诉文件系统要读取哪个文件;当然,一个进程可能同时打开多个文件,因此描述符能让操作系统知道某个特定读取指向哪个文件。
  2. 第二个参数指向一个缓冲区,read()的结果将放置在这个缓冲区中;在上面的系统调用跟踪中,strace 在这个位置(“Hello, World”)显示了读取的结果。
  3. 第三个参数是缓冲区的大小,在本例中为 131072 B。对 read() 的调用也成功返回,这里返回的是读取的字节数(13,其中 12 个字节表示 “Hello, World"中的字符,1 个字节表示行结束标记)。

此时,你会看到 strace 的另一个有趣结果:对 write() 系统调用的一次调用,调用的是文件描述符 1。如上文所述,这个描述符被称为标准输出,因此它被用来将 “Hello, World"这个字符串写到屏幕上,就像 cat 程序要做的那样。但它会直接调用 write() 吗?也许会(如果高度优化的话)。但如果没有,cat程序可能会调用库例程 printf()printf() 会在内部计算出传给它的所有格式细节,并最终写入标准输出,将结果打印到屏幕上。

然后,cat 程序尝试从文件中读取更多信息,但由于文件中已经没有字节了,read() 返回 0,程序知道这意味着它已经读完了整个文件。因此,程序会调用 close() 来表示它已经读完了文件 “foo”,并传入相应的文件描述符。文件就这样关闭了,文件的读取也就完成了。

写文件的步骤与此类似。首先,打开一个文件进行写入,然后调用 write() 系统调用,对于较大的文件,可能会重复调用,最后关闭 write()。使用 strace 来跟踪对文件的写入,或许是跟踪你自己编写的程序,或许是跟踪 dd 工具,例如 dd if=foo of=bar(从文件foo中读取数据,并将其写入到文件bar中)。

2.1.2.2 非顺序读写

到目前为止,我们已经讨论了如何读取和写入文件,但所有访问都是顺序的;也就是说,我们要么从头到尾读取一个文件,要么从头到尾写出一个文件。

然而,有时能够读取或写入文件中的特定偏移量是很有用的。例如,如果您在文本文档上构建索引,并使用它来查找特定单词,您最终可能会从文档中的一些随机偏移量中读取。为此,我们将使用 lseek() 系统调用。这是函数原型:

1
off_t lseek(int fildes, off_t offset, int whence);
  • 第一个参数是filedes的(文件描述符)。
  • 第二个参数是offset,它将文件偏移量定位到文件中的特定位置。
  • 第三个参数由于历史原因被称为 whence,它决定了寻找的具体执行方式。摘自man page: man lseek
    • 如果whenceSEEK_SET,则偏移量设置为偏移字节。
    • 如果whenceSEEK_CUR,则偏移量设置为其当前位置加上偏移字节。
    • 如果whenceSEEK_END,则偏移量设置为文件的大小加上偏移量字节。
数据结构——打开文件表

每个进程都维护一个文件描述符数组,每个文件描述符都引用系统范围的打开文件表中的一个条目。该表中的每个条目都会跟踪描述符引用的底层文件、当前偏移量以及其他相关详细信息,例如文件是否可读或可写。

从上述描述中可以看出,对于进程打开的每个文件,操作系统都会跟踪一个 “当前 “偏移量,该偏移量决定了下一次读取或写入将从文件的哪个位置开始。因此,打开文件的抽象概念之一就是它有一个当前偏移量,该偏移量通过两种方式之一进行更新

  • 第一种方式是,当读取或写入 $N$ 个字节时,$N$ 会添加到当前偏移量中;因此每次读取或写入都会隐式更新偏移量
  • 第二种方式是通过 lseek 来显式更新偏移量,如上文所述。

正如你可能已经猜到的,偏移量保存在我们之前看到的struct file中,由 struct proc 引用。下面是该结构的 xv6(简化)定义:

1
2
3
4
5
6
7
struct file {
    int ref;
    char readable;
    char writable;
    struct inode *ip;
    uint off;
};

正如您在该结构中所看到的,操作系统可以使用它来确定打开的文件是否可读或可写(或两者)、它引用的底层文件(由 struct inode 指针 ip 指向)以及当前偏移量(off)。还有一个引用计数(ref),我们将在下面进一步讨论。

这些文件结构代表了系统中当前打开的所有文件;它们有时一起称为打开文件表。 xv6 内核也将它们保留为数组,每个条目有一个锁,如下所示:

1
2
3
4
struct {
    struct spinlock lock;
    struct file file[NFILE];
} ftable;

让我们通过几个例子来更清楚地说明这一点。首先,让我们跟踪一个打开文件(大小为 300 字节)并通过重复调用 read() 系统调用来读取该文件的进程,每次读取 100 字节。以下是相关系统调用的跟踪,以及每个系统调用返回的值,以及此文件访问的打开文件表中的当前偏移量的值:

image-20240416142836821

跟踪中有几项值得注意。

  1. 首先,您可以看到打开文件时当前偏移量如何初始化为零。
  2. 接下来,您可以看到它是如何随着进程的每次 read() 递增的;这使得进程可以轻松地继续调用 read() 来获取文件的下一个块。
  3. 最后,您可以看到最后尝试的 read() 超过文件末尾如何返回零,从而向进程表明它已完整读取文件。

让我们跟踪一个打开同一个文件两次并向每个文件发出读取的进程。

image-20240416143511054

在此示例中,分配了两个文件描述符(3 和 4),每个描述符都引用打开文件表中的不同条目(在本示例中,条目 1011,如表标题所示;OFT 代表打开文件表)。如果您跟踪所发生的情况,您可以看到每个当前偏移量是如何独立更新的。

在最后一个示例中,进程在读取之前使用 lseek() 重新定位当前偏移量;在这种情况下,只需要一个打开文件表条目(与第一个示例相同)。

image-20240416143932186

这里,lseek() 调用首先将当前偏移量设置为 200。随后的 read() 读取接下来的 50 个字节,并相应地更新当前偏移量。

调用 LSEEK()不执行磁盘寻道

lseek() 调用只是简单地改变操作系统内存中的一个变量,该变量跟踪了特定进程下一次读取或写入将从哪个偏移开始。当向磁盘发出读取或写入请求时,如果不在与上次读取或写入相同磁道上,则会发生磁头移动,这就是磁盘寻道。更令人困惑的是,通过调用 lseek() 从文件的随机部分进行读取或写入,并且接着对这些随机部分进行读取/写入,确实会导致更多的磁盘寻道。因此,调用lseek() 确实可能导致在即将进行的读取或写入中产生一次寻道,但绝对不会引起任何磁盘 I/O 操作本身发生。

2.1.3 共享文件表条目

在许多情况下(如上面的示例所示),文件描述符到打开文件表中的条目的映射是一对一的映射。例如,当一个进程运行时,它可能决定打开一个文件,读取它,然后关闭它;在此示例中,该文件将在打开的文件表中具有唯一的条目。即使其他进程同时读取同一个文件,每个进程也会在打开的文件表中拥有自己的条目。这样,文件的每次逻辑读取或写入都是独立的,并且每次访问给定文件时都有自己的当前偏移量。

然而,有一些有趣的情况,打开文件表中的条目是共享的。其中一种情况发生在父进程使用 fork() 创建子进程时。下面显示了一个小代码片段,其中父级创建了一个子级,然后等待它完成。子进程通过调用 lseek() 调整当前偏移量,然后退出。最后,父进程在等待子进程后,检查当前偏移量并打印出其值。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>

int main(int argc, char *argv[]) {
    int fd = open("file.txt", O_RDONLY);
    assert(fd >= 0);
    int rc = fork();
    if (rc == 0) {
        rc = lseek(fd, 10, SEEK_SET);
        printf("child: offset %d\n", rc);
    } else if (rc > 0) {
        (void) wait(NULL);
        printf("parent: offset %d\n", (int) lseek(fd, 0, SEEK_CUR));
    }
    return 0;
}

当我们运行这个程序时,我们会看到以下输出:

1
2
3
4
5
6
❯ make fork-seek
gcc -c fork-seek.c
gcc fork-seek.o -o fork-seek
❯ ./fork-seek
child: offset 10
parent: offset 10

下图显示了连接每个进程私有描述符数组、共享打开文件表条目以及从它到底层文件系统 inode 的引用的关系。

image-20240416145540776

请注意,我们最终在这里使用了引用计数当文件表项被共享时,其引用计数会增加;只有当两个进程都关闭该文件(或退出)时,该条目才会被删除。

在父进程和子进程之间共享打开的文件表条目有时很有用。例如,如果您创建多个协作处理某项任务的进程,它们可以写入同一输出文件,而无需任何额外的协调。有关调用 fork() 时进程共享的内容的更多信息,请参阅手册页:man fork

另一种有趣且可能更有用的共享情况发生在dup() 系统调用(及其非常相似的系统调用dup2() 甚至 dup3())中。

dup()调用允许进程创建一个新的文件描述符,该文件描述符引用与现有描述符相同的底层打开文件。下面这个代码片段展示了如何使用 dup()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <assert.h>

int main(int argc, char *argv[]) {
    int fd = open("README", O_RDONLY);
    assert(fd >= 0);
    int fd2 = dup(fd);
    // 现在 fd 和 fd2 可以互换使用
    return 0;
}

dup() 调用(特别是 dup2())在编写 UNIX shell 和执行输出重定向等操作时非常有用,有以下几点:

  1. 标准流重定向: 在 shell 编程中,通常需要将标准输入、标准输出和标准错误流重定向到文件或者其他进程的管道中。通过 dup() 函数,可以复制文件描述符,并将其与标准流相关联,从而实现输出重定向操作。
  2. 管道通信: 在 shell 中,管道用于将一个进程的输出连接到另一个进程的输入,以实现进程之间的通信。dup() 函数可以用于复制文件描述符,从而创建管道的输入和输出端口。
  3. 文件描述符管理: 在大型 shell 脚本中,可能会涉及到大量的文件描述符操作。通过 dup() 函数,可以更方便地管理文件描述符,使得代码更加清晰易读。

2.1.4 使用 fsync() 立即写入

大多数时候,当程序调用 write() 时,它只是告诉文件系统:请在将来的某个时刻将此数据写入持久存储。出于性能原因,文件系统会将此类写入在内存中缓冲一段时间(例如 5 秒或 30 秒);在稍后的时间点,写入实际上将被发送到存储设备。从调用应用程序的角度来看,写入似乎很快完成,并且只有在极少数情况下(例如,在 write() 调用之后但在写入磁盘之前机器崩溃)才会丢失数据。

然而,某些应用程序需要的不仅仅是这个最终保证。例如,在数据库管理系统(DBMS)中,正确的恢复协议的开发需要能够不时地强制写入磁盘。

为了支持这些类型的应用程序,大多数文件系统提供了一些额外的控制 API。在 UNIX 世界中,提供给应用程序的接口称为 int fsync(int fd)。当进程为特定文件描述符调用 fsync() 时,文件系统会通过将指定文件描述符引用的文件的所有脏(即尚未写入)数据强制写入磁盘来做出响应。一旦所有这些写入完成,fsync() 例程就会返回。

flush的软刷新版本,相比于fsync,它更为柔和,因为它不直接将数据写入到磁盘中的持久存储,而是将用户空间级别的缓冲区中的数据刷新到操作系统的缓冲区(例如glibc中的缓冲区)。

具体来说,它的作用是将用户空间(应用程序)中的数据刷新到操作系统的内核缓冲区中,而不是直接写入磁盘。这使得数据在应用程序和操作系统之间进行了一次更柔和的传递,不需要等待数据真正写入磁盘,因此称为“柔和版本”。相比之下,fsync是一个更严格的操作,它要求将数据直接写入到磁盘中,确保数据的持久化,并等待写入完成的确认。

下面这段代码如何使用 fsync() 的简单示例。该代码打开文件 foo,向其中写入单个数据块,然后调用 fsync() 以确保立即强制写入磁盘。一旦 fsync() 返回,应用程序就可以安全地继续前进,知道数据已被持久化(如果 fsync() 正确实现了)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>

int main(int argc, char *argv[]) {
    int fd = open("foo", O_CREAT | O_WRONLY | O_TRUNC, S_IRUSR | S_IWUSR);
    assert(fd > -1);
    
    char buffer[] = "Hello, World!";
    size_t size = sizeof(buffer) - 1;
    
    ssize_t rc = write(fd, buffer, size);
    assert(rc == size);

    rc = fsync(fd);
    assert(rc == 0);

    close(fd);
    printf("Data written to disk successfully.\n");
    return 0;
}

有趣的是,这个序列并不能保证你所期望的一切;在某些情况下,你还需要对包含文件 foo 的目录进行 fsync()。添加这一步不仅能确保文件本身在磁盘上,还能确保文件(如果是新创建的)也能持久地成为目录的一部分。不足为奇的是,这类细节经常被忽视,从而导致许多应用程序级的错误……

2.1.5 重命名文件

一旦我们有了一个文件,有时为文件指定一个不同的名称会很有用。当在命令行中输入时,这是通过 mv 命令完成的;在此示例中,文件 foo 被重命名为 bar

1
2
3
4
5
$ mv foo bar
$ strace mv foo bar
...
rename("foo", "bar")                    = 0
...

使用 strace,我们可以看到 mv 使用了系统调用 rename(char *old, char *new),它需要两个参数:文件的原始名称(old)和新名称(new)。

rename()调用提供了一个有趣的保证,那就是它(通常)是作为与系统崩溃有关的原子调用实现的;如果系统在重命名过程中崩溃,文件要么被命名为旧名,要么被命名为新名,不会出现奇怪的中间状态。因此,rename() 对于支持某些需要对文件状态进行原子更新的应用程序至关重要。

让我们说得具体一点。想象一下,你正在使用一个文件编辑器(如 emacs),然后在文件中间插入一行。例如,文件名是 foo.txt。编辑器可能会更新文件,以保证新文件的内容与原来的内容一致,并加上插入的一行,具体方法如下(为简单起见,忽略了错误检查):

1
2
3
4
5
6
int fd = open("foo.txt.tmp", O_WRONLY|O_CREAT|O_TRUNC,
S_IRUSR|S_IWUSR);
write(fd, buffer, size); // write out new version of file
fsync(fd);
close(fd);
rename("foo.txt.tmp", "foo.txt");

在此示例中,编辑器所做的事情很简单:以临时名称 (foo.txt.tmp) 写出文件的新版本,使用 fsync() 将其强制写入磁盘,然后当应用程序确定新文件时元数据和内容都在磁盘上,将临时文件重命名为原始文件的名称。最后一步以原子方式将新文件交换到位,同时删除旧版本的文件,从而实现原子文件更新。

2.1.6 获取文件信息

除了文件访问之外,我们期望文件系统保留有关其存储的每个文件的大量信息。通常我们称这些关于文件的数据为元数据。要查看特定文件的元数据,我们可以使用 stat()fstat() 系统调用。这些调用接受一个路径名(或文件描述符)到一个文件,并填充一个如下所示的 stat 结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
struct stat {
    dev_t st_dev; /* ID of device containing file */
    ino_t st_ino; /* inode number */
    mode_t st_mode; /* protection */
    nlink_t st_nlink; /* number of hard links */
    uid_t st_uid; /* user ID of owner */
    gid_t st_gid; /* group ID of owner */
    dev_t st_rdev; /* device ID (if special file) */
    off_t st_size; /* total size, in bytes */
    blksize_t st_blksize; /* blocksize for filesystem I/O */
    blkcnt_t st_blocks; /* number of blocks allocated */
    time_t st_atime; /* time of last access */
    time_t st_mtime; /* time of last modification */
    time_t st_ctime; /* time of last status change */
};

可以看到,每个文件都保存了大量信息,包括文件大小(以字节为单位)、底层名称(即 inode number)、一些所有权信息、文件被访问或修改的时间等。要查看这些信息,可以使用命令行工具stat

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
> echo 'Hello, World' > file
> stat file
  File: file
  Size: 13        	Blocks: 8          IO Block: 4096   regular file
Device: fc03h/64515d	Inode: 1339196     Links: 1
Access: (0664/-rw-rw-r--)  Uid: ( 1000/    zfhe)   Gid: ( 1000/    zfhe)
Access: 2024-04-16 19:10:20.591807830 +0800
Modify: 2024-04-16 19:10:20.591807830 +0800
Change: 2024-04-16 19:10:20.591807830 +0800
 Birth: 2024-04-16 19:10:20.591807830 +0800

事实证明,每个文件系统通常将此类信息保存在称为 inode的结构中。当我们讨论文件系统实现时,我们将了解更多关于 inode 的知识。现在,您应该将inode视为由文件系统保存的持久数据结构,其中包含我们上面看到的信息。所有 inode 都驻留在磁盘上;活动副本通常缓存在内存中以加快访问速度。

2.1.7 删除文件

至此,我们知道如何创建文件并按顺序或不按顺序访问它们。但是如何删除文件呢?如果您使用过 UNIX,您可能认为您知道:只需运行 rm 程序即可。但是 rm 使用什么系统调用来删除文件呢?让我们再次使用strace来找出答案。这里我们删除文件“file”:

1
2
3
4
> strace rm file
...
unlinkat(AT_FDCWD, "file", 0)           = 0
...

我们已经从跟踪输出中删除了大量无关紧要的内容,只留下对神秘系统调用 unlinkat() 的一次调用。如你所见,unlinkat() 第一个参数AT_FDCWD 表示使用当前工作目录作为基础目录进行文件操作,第二个参数接受要删除的文件名,第三个是一个标志参数0,通常用于指定操作行为的一些选项,但在这种情况下,它是用来指示删除操作的默认行为,成功后返回 0。但这也给我们带来了一个巨大的谜团:为什么这个系统调用被命名为 “unlink”?为什么不直接使用 “remove “或 “delete"呢?要了解这个谜题的答案,我们首先必须了解的不仅仅是文件,还有目录。

在Linux系统中,系统调用 openatunlinkat 等带有 at 后缀的调用是为了提供更灵活的文件操作方式。这些带有 at 后缀的系统调用允许在指定的目录中执行文件操作,而不是在当前工作目录中。

这种设计的优点在于它允许程序员指定一个基础目录进行文件操作,而不必依赖于当前工作目录。这对于需要跨多个目录操作文件的程序尤其有用。例如,如果程序需要打开不在当前目录下的文件,而是相对于某个固定的基础目录,那么使用 openat 调用就可以轻松实现这一点。

因此,openatunlinkat 等系统调用提供了更加灵活和安全的文件操作方式,使程序员可以更精确地控制文件操作的上下文。

2.2 目录操作

除了文件之外,还可以使用一组与目录相关的系统调用来创建、读取和删除目录。请注意,您永远不能直接写入目录;由于目录的格式被视为文件系统元数据,因此您只能通过在其中创建文件、目录或其他对象类型等方式间接更新目录。通过这种方式,文件系统可以确保目录的内容始终符合预期。

2.2.1 创建目录

要创建目录,可以使用单个系统调用 mkdir()。同名的 mkdir 程序可用于创建这样的目录。让我们看一下当我们运行 mkdir 程序来创建一个名为 foo 的简单目录时会发生什么:

1
2
3
4
> strace mkdir foo
...
mkdir("foo", 0777)                      = 0
...

当创建这样的目录时,它被视为“empty”,尽管它确实具有最少的内容。具体来说,一个空目录有两个条目:一个条目引用其自身,另一个条目引用其父目录。前者被称为“.” (点)目录,后者为“..”(点-点)。其中根目录是文件系统的顶层目录,因此它没有父目录,在 UNIX 文件系统中,根目录的父目录通常被表示为自身,即指向自己。

您可以通过将标志 (-a) 传递给程序 ls 来查看这些目录:

1
2
3
4
> ls -al foo
total 8
drwxrwxr-x 2 zfhe zfhe 4096 Apr 16 19:48 .
drwxrwxr-x 3 zfhe zfhe 4096 Apr 16 19:48 ..

2.2.2 读取目录

现在我们已经创建了一个目录,我们可能也希望读取一个目录。事实上,这正是程序 ls 所做的。让我们编写自己的小工具(例如 ls),看看它是如何完成的。

我们不只是像打开文件一样打开目录,而是使用一组新的调用。下面是一个打印目录内容的示例程序。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>
#include <dirent.h>
#include <assert.h>

int main(int argc, char *argv[]) {
    DIR *dp = opendir(".");                 // Open current directory
    assert(dp != NULL);
    struct dirent *d;
    while ((d = readdir(dp)) != NULL) {  // Read one directory entry
        // Print the inode number and name
        printf("%lu %s\n", (unsigned long) d->d_ino, d->d_name);
    }
    closedir(dp);                       // Close the directory
    return 0;
}

该程序使用了三个调用:opendir()readdir()closedir() 来完成工作,您可以看到接口是多么简单;我们只是使用一个简单的循环一次读取一个目录条目,并打印出目录中每个文件的名称和inode number

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
❯ make read_dir
gcc -c read_dir.c
gcc read_dir.o -o read_dir
❯ ./read_dir
26212234 .
22996964 ..
26257183 file.txt
26257285 fork-seek
26256904 Makefile
26261892 fsync.c
26257284 fork-seek.o
26258349 dup
26261922 fsync
26258348 dup.o
26405969 read_dir.o
26212238 README.md
26261927 foo
26405970 read_dir
26405873 read_dir.c
26258222 dup.c
26256864 fork-seek.c
26261921 fsync.o

下面的声明显示了 struct dirent 数据结构中每个目录条目中的可用信息:

1
2
3
4
5
6
7
struct dirent {
    char d_name[256]; /* filename */
    ino_t d_ino; /* inode number */
    off_t d_off; /* offset to the next dirent */
    unsigned short d_reclen; /* length of this record */
    unsigned char d_type; /* type of file */
};

由于目录中的信息很少(基本上只是将名称映射到 inode number,以及其他一些细节),程序可能希望在每个文件上调用 stat() 来获取每个文件的更多信息,如长度或其他详细信息。事实上,当你给 ls 传递 -l 标志时,它就会这么做。

2.2.3 删除目录

最后,你可以调用 rmdir()(由同名程序 rmdir 使用)删除目录。不过,与删除文件不同,删除目录更加危险,因为一条命令就可能删除大量数据。因此,rmdir() 要求在删除之前目录必须为空(即只有”. “和”.. “条目)。如果试图删除非空目录,rmdir() 函数的调用就会失败。

1
2
3
4
5
> echo "Hello, World" > foo/foo.txt
> rmdir foo
rmdir: failed to remove 'foo': Directory not empty
> rm foo/foo.txt
> rmdir foo

3 链接

3.1 硬链接

现在,我们通过了解一种在文件系统树中创建条目的新方法,即 link() 系统调用,回到为什么要通过 unlink() 来删除文件的谜题上来。link() 系统调用需要两个参数,一个旧路径名和一个新路径名;当你把一个新文件名 “链接 “到一个旧文件名时,你基本上就创造了另一种方式来引用同一个文件。在本例中,命令行程序 ln 就是用来实现这一功能的:

1
2
3
4
5
6
> echo "Hello, World" > foo
> cat foo
Hello, World
> ln foo foo2
> cat foo2
Hello, World

这里我们创建了一个包含单词“Hello, World”的文件,并将其命名为foo。然后我们使用 ln 程序创建到该文件的硬链接。之后,我们可以通过打开 foofoo2 来检查该文件。

链接的工作原理是,它只是在创建链接的目录中创建另一个名称,并将其指向与原始文件相同的 inode number(即底层名称)。文件并没有以任何方式复制;相反,你现在只有两个名称(foofoo2),它们都指向同一个文件。我们甚至可以在目录本身中看到这一点,打印出每个文件的 inode number

1
2
3
> ls -i foo foo2
1339196 foo  
1339196 foo2

通过向 ls 传递 -i 标志,它会打印出每个文件的 inode number(以及文件名)。这样,你就能看到 link 到底做了什么:它只是对相同的 inode number(本例中为 1339196)进行了新的引用。

现在,你可能开始明白为什么 unlink() 要叫 unlink()了。当你创建文件时,实际上是在做两件事。

  • 首先,你正在创建一个结构(inode),它将跟踪文件的几乎所有相关信息,包括文件大小、块在磁盘上的位置等等。
  • 其次,将一个人类可读的名称链接到该文件,并将该链接放到一个目录中。

在文件系统中创建了文件的硬链接后,原始文件名(foo)和新创建的文件名(foo2)就没有什么区别了;事实上,它们都只是指向文件底层元数据的链接,而文件底层元数据就在 inode number1339196中。

因此,要从文件系统中删除文件,我们需要调用 unlink()。在上面的例子中,我们可以删除名为 file 的文件,并且仍然可以顺利访问该文件:

1
2
3
4
5
> ls
foo  foo2
> rm foo
> cat foo2
Hello, World

这样做的原因是,当文件系统取消链接文件时,它会检查 inode number内的引用计数。该引用计数(有时称为链接计数)允许文件系统跟踪有多少不同的文件名已链接到该特定 inode。当调用 unlink() 时,它会删除文件名(正在删除的文件)与给定 inode number之间的“链接”,并减少引用计数;只有当引用计数为零时,文件系统才会同时释放inode和相关数据块,从而真正“删除”文件。

当然,您可以使用 stat() 查看文件的引用计数。让我们看看当我们创建和删除文件的硬链接时会发生什么。在此示例中,我们将创建指向同一文件的三个链接,然后将其删除。观察链接计数!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
> echo "Hello, World" > foo
> stat foo | grep Inode
Device: fc03h/64515d	Inode: 1338857     Links: 1
> ln foo foo2
> stat foo | grep Inode
Device: fc03h/64515d	Inode: 1338857     Links: 2
> ln foo2 foo3
> stat foo | grep Inode
Device: fc03h/64515d	Inode: 1338857     Links: 3
> rm foo
> stat foo2 | grep Inode
Device: fc03h/64515d	Inode: 1338857     Links: 2
> rm foo2
> stat foo3 | grep Inode
Device: fc03h/64515d	Inode: 1338857     Links: 1

3.2 符号链接(软链接)

还有另一种非常有用的链接类型,它称为符号链接,有时也称为软链接。事实证明,硬链接有一定的局限性:

  • 您不能为目录创建硬链接,因为担心会在目录树中创建循环。例如,假设有两个目录A和B,它们都包含一个硬链接到对方的硬链接。这种情况下,无论你从A开始还是从B开始,都会导致无限的循环,因为通过任一路径进入其中一个目录后,你可以通过硬链接返回到另一个目录,反复无穷地进行。
  • 您不能硬链接到其他磁盘分区中的文件(因为 inode number仅在特定文件系统内唯一,而不是跨文件系统);

因此,创建了一种称为符号链接的新型链接。要创建这样的链接,您可以使用相同的程序 ln,但带有 -s 标志。这是一个例子:

1
2
3
4
> echo "Hello, World" > foo
> ln -s foo foo2
> cat foo2
Hello, World

正如您所看到的,创建软链接看起来非常相似,现在可以通过文件名 foo 以及符号链接名 foo2 来访问原始文件。

然而,除了表面上的相似性之外,符号链接实际上与硬链接有很大不同。符号链接实际上本身就是一个不同类型的文件。我们已经讨论过常规文件和目录;符号链接是文件系统所知的第三种类型。符号链接上的统计数据揭示了一切:

1
2
3
4
> stat foo | grep regular
  Size: 13        	Blocks: 8          IO Block: 4096   regular file
> stat foo2 | grep symbolic
  Size: 3         	Blocks: 0          IO Block: 4096   symbolic link

运行 ls 也揭示了这个事实。如果仔细观察 ls 输出的长格式的第一个字符,您会发现最左侧列中的第一个字符是 - 表示常规文件,d 表示目录,l 表示软链接。您还可以查看符号链接的大小(在本例中为 3 个字节),以及链接指向的内容(名为 foo 的文件)。

1
2
3
4
5
6
> ls -al
total 12
drwxrwxr-x  2 zfhe zfhe 4096 Apr 16 21:02 .
drwxr-x--- 22 zfhe zfhe 4096 Apr 16 21:03 ..
-rw-rw-r--  1 zfhe zfhe   13 Apr 16 20:56 foo
lrwxrwxrwx  1 zfhe zfhe    3 Apr 16 20:56 foo2 -> foo

foo2 是 3 个字节的原因是因为符号链接的形成方式是将链接到的文件的路径名作为链接文件的数据。因为我们链接到了一个名为 foo 的文件,所以我们的链接文件 foo2 很小(3 个字节)。如果我们链接到更长的路径名,我们的链接文件会更大:

1
2
3
4
5
> echo "Hello, World" > a_longer_filename
> ln -s a_longer_filename file
> ls -al a_longer_filename file
-rw-rw-r-- 1 zfhe zfhe 13 Apr 16 21:04 a_longer_filename
lrwxrwxrwx 1 zfhe zfhe 17 Apr 16 21:05 file -> a_longer_filename

最后,由于符号链接的创建方式,它们留下了所谓的悬空引用的可能性,悬空引用可能会导致程序错误,因为它们试图访问不再有效的内存位置或资源。

1
2
3
4
5
6
7
> echo "Hello, World" > foo
> ln -s foo foo2
> cat foo2
Hello, World
> rm foo
> cat foo2
cat: foo2: No such file or directory

正如您在此示例中所看到的,与硬链接完全不同,删除名为 foo 的原始文件会导致链接指向不再存在的路径名。

4 权限位和访问控制列表

进程的抽象提供了两个中心虚拟化:CPU和内存。每一种虚拟化都会给进程造成一种错觉,以为它拥有自己的专用 CPU 和专用内存;实际上,操作系统使用了各种技术,以安全可靠的方式在相互竞争的实体之间共享有限的物理资源。

正如本章所述,文件系统也提供了磁盘的虚拟视图,将磁盘从一堆原始块转化为更方便用户使用的文件和目录。然而,文件系统的抽象与 CPU 和内存的抽象明显不同,因为文件通常由不同用户和进程共享,并不总是私有的。因此,文件系统中通常有一套更全面的机制来实现不同程度的共享。

此类机制的第一种形式是经典的 UNIX 权限位。要查看文件 foo.txt 的权限,只需输入:

1
2
> ls -l foo.txt
-rw-rw-r-- 1 zfhe zfhe 0 Apr 16 21:12 foo.txt

我们只关注该输出的第一部分,即 -rw-r--r--。这里的第一个字符仅显示文件的类型: - 表示常规文件(即 foo.txt),d 表示目录,l 表示符号链接,依此类推;这(大部分)与权限无关,所以我们暂时忽略它。

我们感兴趣的是权限位,它们由接下来的九个字符(rw-r--r--)表示。对于每个常规文件、目录和其他实体,这些位确定谁可以访问它以及如何访问它。

权限由三组组成:

  1. 文件所有者可以对文件执行哪些操作;
  2. 组中的某个人可以对文件执行哪些操作;
  3. 最后是任何人(有时称为其他人)都可以执行哪些操作。

所有者、组成员或其他人可以拥有的能力包括读取文件、写入文件或执行文件的能力。在上面的示例中,ls 输出的前三个字符表明该文件可由所有者 (rw-) 读取和写入,并且只能由组zfhe成员以及系统中的其他任何人读取 (r -- 后面跟着 r--)。

文件的所有者可以轻松更改这些权限,例如通过使用 chmod命令(更改文件模式)(还有chown:更改文件或目录的所有者;chgrp:更改文件或目录的所属组)。要删除除所有者之外的任何人访问该文件的能力,您可以输入:

1
2
3
> chmod 600 foo.txt
> ls -l foo.txt
-rw------- 1 zfhe zfhe 0 Apr 16 21:12 foo.txt

这条命令启用了所有者的可读位(4)和可写位(2)(将它们 OR 在一起会产生上面的 6),但将组和其他人的权限位分别设置为 0 和 0,从而将权限设置为 rw-------

执行位尤其有趣。对于普通文件,它的存在决定了程序是否可以运行。例如,如果我们有一个名为 hello.csh 的简单 shell 脚本,我们可能希望通过输入以下内容来运行它:

1
2
> ./hello.csh
hello, from shell world.

但是,如果我们没有正确设置该文件的执行位,就会发生以下情况:

1
2
3
> chmod 600 hello.csh
> ./hello.csh
zsh: permission denied: ./hello.csh
文件系统的超级用户

允许哪个用户执行特权操作以帮助管理文件系统?例如,如果需要删除一个不活动用户的文件以节省空间,谁有权这样做?

在本地文件系统中,常见的默认设置是存在某种超级用户(即 root),它可以访问所有文件,而不受权限限制。在分布式文件系统(如 AFS,它有访问控制列表)中,一个名为 system:administrators 的组包含受信任的用户。

在这两种情况下,这些受信任的用户都代表着固有的安全风险;如果攻击者能够以某种方式冒充此类用户,攻击者就可以访问系统中的所有信息,从而违反预期的隐私和保护保证。

对于目录,执行位的行为略有不同。具体来说,它使用户(或组或每个人)能够执行诸如将目录(即 cd)更改为给定目录之类的操作,并结合可写位在其中创建文件。在 UNIX 文件系统中,具体如下:

  1. 读权限(r):允许查看目录中的文件列表(即列出目录中的内容)。
  2. 写权限(w):允许在目录中创建、删除和重命名文件。
  3. 执行权限(x):允许进入目录。要进入目录,用户必须拥有目录的执行权限。

除了权限位之外,一些文件系统,包括称为 AFS 的分布式文件系统,还包括更复杂的控制。例如,AFS 以每个目录的访问控制列表 (ACL) 的形式执行此操作。访问控制列表是一种更通用、更强大的方式来准确表示谁可以访问给定资源。在文件系统中,这使用户能够创建一个非常具体的列表,其中列出谁可以读取一组文件,谁不能读取一组文件,这与上述权限位的所有者/组/所有人模型不同。

例如,以下是一位用户的 AFS 帐户中的私有目录的访问控制,如 fs listacl 命令所示:

1
2
3
4
5
> fs listacl private
Access list for private is
Normal rights:
    system:administrators rlidwka
    remzi rlidwka

该列表显示系统管理员和用户 remzi 都可以查找、插入、删除和管理此目录中的文件,以及读取、写入和锁定这些文件,具体标识符解释如下。

  • r: 读取权限 (Read)
  • l: 列出目录权限 (List)
  • i: 插入权限 (Insert)
  • d: 删除权限 (Delete)
  • w: 写入权限 (Write)
  • k: 锁定权限 (Lock)
  • a: 管理权限 (Administer)

要允许某人(在本例中为其他用户)访问此目录,用户 remzi 只需输入以下命令即可。

1
2
3
4
5
6
7
> fs setacl private/ andrea rl
> fs listacl private
Access list for private is
Normal rights:
    system:administrators rlidwka
    remzi rlidwka
    andrea rl
警惕TOCTTOC

TOCTTOU 是 “Time of Check to Time of Use” 的缩写,指的是在检查某个条件和使用该条件之间可能存在的时间间隔。这个术语通常用于描述安全漏洞,特别是在多线程或并发环境中,由于时间间隔导致的条件竞争问题。

1974 年,McPhee注意到计算机系统存在问题。具体来说,McPhee 指出“…如果有效性检查和与该有效性检查相关的操作之间存在时间间隔,并且通过多任务处理,可以在该时间间隔期间故意更改有效性检查变量,导致控制程序执行无效操作。”今天,我们将此称为“Time of Check to Time of Use ”(TOCTTOU) 问题,可惜,这种情况仍然可能发生。

Bishop 和 Dilger描述的一个简单示例展示了用户如何欺骗更值得信赖的服务,从而造成麻烦。例如,想象一下,邮件服务以 root 身份运行(因此有权访问系统上的所有文件)。该服务将传入消息附加到用户的收件箱文件中,如下所示。首先,它调用 lstat() 来获取有关该文件的信息,特别是确保它实际上只是目标用户拥有的常规文件,而不是指向邮件服务器不应更新的另一个文件的链接。然后,检查成功后,服务器用新消息更新文件。

不幸的是,检查和更新之间的差距导致了一个问题:攻击者(在本例中,是接收邮件的用户,因此有权访问收件箱)切换收件箱文件(通过调用 rename()) 指向敏感文件,例如 /etc/passwd(其中保存有关用户及其密码的信息)。如果这种切换发生在正确的时间(在检查和访问之间),服务器将用邮件的内容更新敏感文件。攻击者现在可以通过发送电子邮件写入敏感文件,从而提升权限;通过更新/etc/passwd,攻击者可以添加具有root权限的帐户,从而获得系统的控制权。

TOCTTOU 问题没有任何简单而出色的解决方案。一种方法是减少需要 root 权限才能运行的服务数量,这会有所帮助O_NOFOLLOW 标志使得如果目标是符号链接,open() 将失败,从而避免需要所述链接的攻击。更激进的方法,例如使用事务性文件系统,可以解决问题,但广泛部署的事务性文件系统并不多。因此,通常的建议:编写以高权限运行的代码时要小心!

5 制作和挂载文件系统

我们现在已经了解了访问文件、目录和某些特殊类型链接的基本接口。不过,我们还应该讨论一个话题:如何从许多底层文件系统中生成完整的目录树。要完成这项任务,首先要制作文件系统,然后挂载这些文件系统,以便访问其中的内容。

为了创建文件系统,大多数文件系统都提供了一个工具,通常被称为 mkfs(读作 “make fs”),它可以完成这项任务。其原理如下:输入一个设备(如磁盘分区,如 /dev/sda1)和一个文件系统类型(如 ext3)给该工具,它就会在该磁盘分区中写入一个以根目录为起点的空文件系统。mkfs 说:“那就有一个文件系统吧!”

不过,一旦创建了这样一个文件系统,就需要在统一文件系统树中对其进行访问。这项任务需要通过 mount 程序来完成(它会让底层系统调用 mount() 来完成真正的工作)。mount 程序的作用非常简单,就是将一个现有目录作为目标挂载点,然后在目录树上粘贴一个新的文件系统。

这里的一个例子可能很有用。假设我们有一个未挂载的 ext3 文件系统,存储在设备分区 /dev/sda1,其内容如下:一个根目录,其中包含两个子目录 ab,每个子目录又包含一个名为 foo 的文件。假设我们希望将该文件系统挂载到挂载点 /home/users。我们可以这样输入:

1
> mount -t ext3 /dev/sda1 /home/users

如果挂载成功,这个新文件系统就可用了。不过,请注意现在访问新文件系统的方式。要查看根目录的内容,我们可以这样使用 ls

1
2
> ls /home/users/
a b

可以看到,路径名 /home/users/ 现在指的是新挂载目录的根目录。同样,我们可以使用 /home/users/a/home/users/b 这两个路径名访问目录 ab。最后,可以通过 /home/users/a/foo/home/users/b/foo 访问名为 foo 的文件。这就是挂载的魅力所在:挂载将所有文件系统统一为一棵树,使命名统一、方便,而不是拥有多个独立的文件系统。要查看系统上挂载了哪些文件,以及挂载在哪些位置,只需运行mount程序即可。你会看到如下内容:

1
2
3
4
5
6
7
8
> mount
/dev/sda1 on / type ext3 (rw)
proc on /proc type proc (rw)
sysfs on /sys type sysfs (rw)
/dev/sda5 on /tmp type ext3 (rw)
/dev/sda7 on /var/vice/cache type ext3 (rw)
tmpfs on /dev/shm type tmpfs (rw)
AFS on /afs type afs (rw)

这种疯狂的组合表明有大量不同的文件系统,包括 ext3(基于磁盘的标准文件系统)、proc 文件系统(用于访问当前进程信息的文件系统)、tmpfs(仅用于临时文件的文件系统) )和 AFS(分布式文件系统)都粘合到这台机器的文件系统树上。

6 总结

  • 文件是可以创建、读取、写入和删除的字节数组。它有一个唯一引用它的底层名称(即number)。此层名称通常称为inode number
  • 目录是元组的集合,每个元组都包含一个人类可读的名称及其映射到的底层名称。每个条目要么引用另一个目录,要么引用一个文件。每个目录本身也有一个底层名称(inode number)。目录总是有两个特殊条目:.条目(引用自身)和 .. 条目(引用其父条目)。
  • 目录树目录层次结构将所有文件和目录组织成一棵大树,从根开始。
  • 要访问文件,进程必须使用系统调用(通常为 open())来请求操作系统的许可。如果授予权限,操作系统会返回一个文件描述符,然后在权限和意图允许的情况下,该文件描述符可用于读或写访问。
  • 每个文件描述符都是一个私有的、每个进程的实体,它引用打开文件表中的一个条目。其中的条目跟踪这次访问引用了哪个文件、文件的当前偏移量(即下一次读取或写入将访问文件的哪一部分)以及其他相关信息。
  • 调用read()write() 自然会更新当前偏移量;否则,进程可以使用 lseek() 来更改其值,从而能够随机访问文件的不同部分。
  • 要强制更新持久性存储,进程必须使用fsync() 或相关调用。然而,在保持高性能的同时正确执行此操作具有挑战性,因此在执行此操作时请仔细考虑。
  • 要使文件系统中的多个人类可读名称引用同一基础文件,请使用硬链接或符号链接。每种方法在不同的情况下都有用,因此在使用之前请考虑它们的优点和缺点。请记住,删除文件只是从目录层次结构中执行最后一次unlink() 操作
  • 大多数文件系统都有启用和禁用共享的机制。此类控制的基本形式是由权限位提供的;更复杂的访问控制列表(ACL)可以更精确地控制谁可以访问和操作信息。

相关内容

Buy me a coffee~
HeZephyr 支付宝支付宝
HeZephyr 微信微信
0%