# 文件

Linux 系统中,一切对象都是文件。比如数据、目录、设备、套接字都以文件的形式展示,供用户读写。

  • 目录也是一种文件,为 directory 类型,本身占 4 kB 的磁盘空间。
    • 目录文件与目录下的各个文件存在逻辑上的层级关系,但在磁盘中它们是分别存储的文件。
    • 例如用 vim 可以编辑目录文件,显示它包含的所有文件。
  • 平时提到的 "文件" 概念比较模糊,有时提到的 "文件" 包括目录文件,有时则不包括。

# 文件命名规则

  • 区分大小写,不能包含正斜杆 / 。
    • 建议只包含 [0-9A-Za-z._-] 这些常用字符,尽量避免使用特殊字符,以免需要转义,或者与某些软件不兼容。
  • 名字以 . 开头的文件是隐藏文件。
  • 每个目录下都有 . 和 .. 两个隐藏文件,指向当前目录和上一级目录。

# 文件路径

  • 文件路径有两种写法:
    • 绝对路径(absolute path)
      • :从根目录 / 开始的路径。例如 /proc/tty
      • 每个文件有且仅有一个绝对路径。
    • 相对路径(relative path)
      • :相对于当前目录的路径,从 . 或 .. 开始。
      • 例如:以 /proc/tty 为起点的相对路径 ../sys ,对应的绝对路径为 /proc/sys
      • 每个文件可以有任意个相对路径。
  • Linux 系统的文件路径以正斜杠 / 作为分隔符,而反斜杠 \ 通常用于转义字符。
    • 文件路径中多余的 / 会被忽略。例如,以下几个路径指向同一个目录:
      /proc/tty
      /proc/tty/
      /proc//tty
      

# 文件类型

# 常用符号

文件类型 英文名 符号
普通文件 regular file -
目录文件 directory d
软链接文件 symbolic link l
套接字文件 socket s
块设备文件 block special file b
字符设备文件 character special file c

# 链接文件

Linux 系统中有两种链接文件(link file):

  • 硬链接(hard link)
    • :与目标文件的 inode 编号相同,因此文件内容、大小、文件类型等元数据都相同。
    • 如果文件 B 是文件 A 的硬链接,则两者指向磁盘中存储的同一个文件。删除文件 A 不会影响到文件 B ,但是修改文件 A 会同时修改文件 B ,反之亦然。
    • 不支持给目录创建硬链接,避免出现循环的目录树。
    • 不支持跨磁盘分区创建硬链接,否则会报错 Invalid cross-device link ,因为每个磁盘分区拥有独立的 inode table 。
  • 软链接(soft link)
    • :又称为符号链接(symbolic link),是一个 2 Bytes 大小的文件,存储目标文件的绝对路径。
    • 如果文件 B 是文件 A 的软链接,则两者指向存储中存储的不同文件。删除文件 A 之后,文件 B 就指向一个无效的路径。
    • 可以给目录创建软链接,可以跨磁盘分区创建软链接。

# 稀疏文件

稀疏文件(Sparse file):文件内容中包含一些连续空字节的数据块。

  • 这些数据块称为空洞(hole),可能有多个,大小不一。
  • 文件系统将稀疏文件写入磁盘时,通常会跳过空洞,只记录非空数据、空洞的位置和大小,从而节省磁盘空间。
    • 文件系统从磁盘读取稀疏文件时,会自动将空洞填充到文件的非空数据中。
    • 因此,用户查看稀疏文件的内容、文件体积时,跟正常文件看起来一样,但它实际占用的磁盘空间小于文件体积。

例:

  • 虚拟机的磁盘在宿主机上通常保存为稀疏文件的形式,可能文件体积有 100G ,但实际只包含 1G 的非空数据。
  • 执行 ls -l 命令查看的是文件体积(即包含的数据大小), 执行 du 命令查看的是文件实际占用的磁盘空间。
    • 即使一个文件只包含 1 Byte 数据,存储时也要占用 1 个 block 。如下:
      [root@CentOS ~]# echo 1 > f1
      [root@CentOS ~]# ls -lh f1
      -rw-rw-r--. 1 root root 2 Nov 28 16:41 f1
      [root@CentOS ~]# du -h f1
      4.0K    f1
      
    • 即使一个文件体积很大,但占用的磁盘空间可能很小。如下:
      [root@CentOS ~]# truncate f1 -s 10M
      [root@CentOS ~]# ls -lh f1
      -rw-rw-r--. 1 root root 10M Nov 28 16:45 f1
      [root@CentOS ~]# du -sh f1
      4.0K    f1
      [root@CentOS ~]# wc -c f1
      10485760 f1
      
  • 假设执行 ping localhost >> stdout.log
    • 用 >> 重定向输出时,会以 O_APPEND 模式打开文件。因此当文件长度被截断时,会自动将写入时的偏移量设置到文件末尾,避免产生稀疏文件。
    • 因此,保存日志文件时,建议使用 >> ,而不是 > ,便于轮换日志文件。

# 文件描述符

  • 每个进程在启动时,会创建一个文件表(file table),用于记录该进程打开的所有文件的信息,比如文件的 inode 编号、打开模式、当前偏移量。
    • 每个进程的文件表相互独立,保存在 Linux 内核的内存空间中。
  • 每个文件在文件表中的索引序号,称为文件描述符(file descriptor ,fd)。
    • 文件描述符是从 0 开始递增的非负整数,优先分配当前可用的最小值。
  • 例如:
    • 当进程打开文件 /tmp/f1 时,会在文件表中增加一条记录,并给该文件分配一个文件描述符 3 。
    • 当进程打开文件之后、进行读取时,如果将文件 /tmp/f1 重命名为 /tmp/f2 ,并不会影响进程的读取操作。因为进程是根据 inode 从磁盘读取数据的。
    • 当进程关闭文件时,会从文件表中删除该条记录,回收其文件描述符。
  • 每个进程刚启动时,默认会先打开 stdin、stdout、stderr 三个文件:
    • /dev/stdin :标准输入(默认从终端读取输入),文件描述符为 0 。
    • /dev/stdout :标准输出(默认显示到终端),文件描述符为 1 。
    • /dev/stderr :标准错误(默认显示到终端),文件描述符为 2 。 因此,之后打开的文件是使用从 3 开始的文件描述符。
  • 每个进程使用的文件描述符都会记录在 /proc/<PID>/fd/ 目录下。如下:
    [root@CentOS ~]# ls -lh /dev/std*
    /dev/stderr: symbolic link to `/proc/self/fd/2'   # 标准输入、输出文件都是指向 /proc/self/fd/ 目录的符号链接
    /dev/stdin:  symbolic link to `/proc/self/fd/0'
    /dev/stdout: symbolic link to `/proc/self/fd/1'
    
    [root@CentOS ~]# file /proc/self/fd/*             # /proc/self/fd/ 目录下的文件描述符是指向终端设备的符号链接
    /proc/self/fd/0:   symbolic link to `/dev/pts/2'
    /proc/self/fd/1:   symbolic link to `/dev/pts/2'
    /proc/self/fd/2:   symbolic link to `/dev/pts/2'
    

# 相关 API

# 打开文件

#include <fcntl.h>
// #include <sys/types.h>   // 定义了 size_t、time_t、pid_t 等数据类型

int open(const char *pathname, int flags);              // 打开文件
int open(const char *pathname, int flags, mode_t mode); // 先创建文件,再打开它
    // pathname :文件路径
    // flags    :打开模式,是一些二进制标志位
    // mode_t   :权限模式,比如 S_IRWXU 为 00700 ,S_IRUSR 为 00400
    // 函数执行成功时,返回打开的文件描述符
    // 函数执行失败时,返回 -1
  • 文件的打开模式 flags 可取值:
    O_RDONLY      # 只读
    O_WRONLY      # 只写
    O_RDWR        # 可读可写
    
    以上三种是基本的打开模式,不能同时使用,但可以与以下模式通过或运算符 | 组合使用:
    O_APPEND      # 追加模式,每次调用 write() 时,自动将偏移量移动到文件末尾(这样便于多个进程同时追加数据到文件中)
    O_CREAT       # 如果要打开的文件不存在,则自动创建它
    O_EXCL        # 与 O_CREAT 一起使用时,如果文件存在,或者为符号链接,则打开失败
    O_DIRECTORY   # 文件必须为目录文件,否则打开失败
    O_NOFOLLOW    # 文件不能为符号链接,否则打开失败
    O_NONBLOCK    # 读写文件时,不等获取数据就返回,不会阻塞线程
    O_NDELAY      # 等价于 O_NONBLOCK
    O_SYNC        # 同步模式,每次调用 write() 时,会阻塞线程直到数据实际写入磁盘
    O_TRUNC       # 如果文件存在,且采用可写模式,则将文件长度截断为 0
    

# 读写文件

#include <unistd.h>

int close(int fd);
    // 关闭文件描述符
    // 函数执行成功时,返回 0
    // 函数执行成功时,返回 -1

ssize_t read(int fd, void *buf, size_t count);
    // 从文件中读取数据
    // fd   :文件描述符
    // buf  :指向一块内存空间的指针,用作读缓冲。内核会从文件读取数据,写入 buf
    // cout :想要读取的字节数
    // 函数执行成功时,会将读取的数据写入 buf ,并返回实际读取的字节数
    // 函数执行失败时,返回 -1
    // 如果返回值为 0 ,则说明没有读取到数据,例如当前偏移量位于文件末尾时

ssize_t write(int fd, const void *buf, size_t count);
    // 写入数据到文件中
    // buf  :指向一块内存空间的指针,用作写缓冲。内核会从 buf 读取数据,写入文件
    // 函数执行成功时,返回实际写入的字节数
    // 函数执行失败时,返回 -1

off_t lseek(int fd, off_t offset, int whence);
    // 将当前文件偏移量设置为从 whence 开始的 offset 位置处
    // offset :任意整数,可以为负
    // whence :一个参照位置,可取值:
    //   SEEK_SET   :文件首部
    //   SEEK_END   :文件尾部
    //   SEEK_CUR   :当前文件偏移量
  • 当前文件偏移量(current file offset ,cfo):一个非负整数,表示当前读、写的是文件中第几个字节。
    • cfo 保存在 Linux 内核的内存空间中。
    • 打开一个文件时,cfo 默认为 0 。
    • 每次读、写文件 n 个字节,默认会自动将 cfo 的值加 n ,直到加到文件末尾。因此 cfo 默认不会超过文件长度。
    • 如果调用 lseek() 主动设置 cfo ,超过文件长度,并调用 write() 写入数据,则会自动将 cfo 位置之前的空间用空字节填充,导致文件变成稀疏文件。

# 增删文件

#include <unistd.h>

// int creat(const char *pathname, mode_t mode);  // 创建文件。该函数已弃用,可用 open() 创建文件并打开

int unlink(const char *pathname);                 // 取消软链接、硬链接,即从文件系统删除某个路径的文件
  • unlink() 的原理:
    • 如果 path 是硬链接、软链接文件,则直接删除该链接。
    • 如果 path 是硬链接,且指向的文件的 inode 引用计数为 1 (即只有这一个硬链接),则考虑是否删除磁盘中存储的 inode 文件:
      • 如果当前没有进程打开该文件,则删除该文件,释放 inode 。
      • 如果当前有进程打开该文件,则先删除 path 链接,等进程关闭文件时才自动删除 inode 文件。
    • 如果 path 是 socket 或 device 类型的文件,则从文件系统删除它,但已打开该文件的进程依然可以访问它。
  • unlink() 一次只能删除一个文件,且不能删除目录。
    • rm 命令就是调用 unlink() 来删除文件,可递归删除目录。
    • 删除文件有略微耗时,通常低于 1 秒。文件越大、文件数越多,耗时越久,可能阻塞终端几分钟。
  • 例:删除一个正被进程打开的文件
    [root@CentOS ~]# cat /dev/urandom | head -c 2G > f1
    [root@CentOS ~]# tail -f f1 &
    [root@CentOS ~]# rm -f f1
    [root@CentOS ~]# lsof -u root | grep deleted
    tail      11526 root    3r      REG              253,1 2147483648     393694 /root/f1 (deleted)
    
    • 此时执行 ls 命令,不会显示 f1 文件。执行 du -sh . 命令,会发现当前目录的体积减少了。但是执行 df -h 命令,会发现磁盘的已用空间没有减少。需要重启进程,释放文件描述符。
    • 假设一个进程产生了体积很大的日志文件,又不能重启进程,则可以通过 echo > f1truncate f1 -s 10M 等命令减小文件体积。

# sendfile

#include <sys/sendfile.h>

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
    // 从一个文件描述符 out_fd 拷贝数据到另一个文件描述符 in_fd ,从 offset 偏移量开始拷贝,最多拷贝 count 个字节
  • sendfile() 是 Linux 系统提供的一种零拷贝技术。
    • read() 读取文件的流程:
      1. DMA 从磁盘拷贝文件数据到 Page Cache 。拷贝完之后,发送中断通知 CPU ,发生 CPU 上下文切换。
      2. CPU 从 Page Cache 拷贝数据到进程物理内存。
    • write() 写入文件的流程相反。
    • 用 sendfile() 拷贝文件的流程:
      1. DMA 从磁盘拷贝数据到 Page Cache 。
      2. DMA 从 Page Cache 拷贝数据到目标文件。
    • 可见,比起用 read() + write() 拷贝文件,用 sendfile() 避免了 CPU Copy ,节约了 CPU 时间,因此称为零拷贝,耗时更少。
      • 例如 Nginx 服务器发送文件时,可以将磁盘文件用 sendfile() 拷贝写入 Socket 文件,提高发送文件的速度。

# mmap

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
    // 将某个文件映射到虚拟内存空间,返回这块虚拟内存空间的首地址作为指针
    // addr   :用于创建映射区域的虚拟内存地址。为 Null 则自动选择
    // length :映射区域的大小,必须是内存页大小的整数倍。因此映射小文件时容易浪费内存,更适合映射大文件
    // fd     :要映射的目标文件
    // offset :从文件的指定偏移量开始映射

int munmap(void *addr, size_t length);
    // 取消映射
  • 一个进程调用 mmap() 的常见用途:

    • 将某块磁盘文件地址,映射到进程的虚拟内存空间
      • 称为内存映射文件(Memory Mapped File)。
      • 当进程读取文件数据时,如果物理内存中没有该数据,则引发缺页异常,由 DMA 从磁盘的映射地址拷贝数据到内存。
      • 当进程修改文件数据之后,内核会每隔一定时间自动将脏页 flush 到磁盘。
    • 将某块物理内存地址,映射到进程的虚拟内存空间
      • 此时 flags 包含 MAP_ANONYMOUS ,表示忽略 fd 参数,不映射文件,称为匿名映射(anonymous mapping)。
  • 内存映射文件的优点:

    • 用 read()、write() 多次读写文件时,需要多次调用函数,每次都发生 CPU 上下文切换。而调用一次 mmap() 之后,就可以通过指针读写文件的任意地址。
    • 用 read()、write() 读写文件时,需要先后拷贝数据到物理内存中的 Page Cache、进程内存空间。而用 MMAP 时,只需拷贝到 Page Cache ,属于零拷贝技术,读写大文件时速度更快。
    • 多个进程可以同时映射同一个文件,类似共享内存,进行进程间通信。
      • 比如进程启动时,就是通过 MMAP 方式加载动态链接库。
  • 创建 mmap 映射之后,关闭文件描述符 fd 并不会取消映射。

    • 可调用 munmap() 取消映射。
    • 进程终止时,会自动取消映射。