一、介绍

1 一切皆文件

“一切皆文件”是linux系统中非常重要的概念,例如将文档、目录、硬盘驱动器、调制解调器、键盘、打印机,甚至是进程、网络通信(socket)都抽象成文件,这样做的好处是可以在广泛的资源上使用同一组工具、程序和API,对它们的操作可以统一起来,只需要使用一些文件操作接口就可以完成绝大部分操作。

因此,了解文件操作是基础,同时也是极其重要的。

2 头文件的引用路径

在这里介绍头文件的引用,是鼓励读者去头文件源码里寻找声明,例如stdio.hfcntl.h等等,本节就是介绍寻找这些头文件路径的方法。

include后的文件名可以用双引号括起来,也可以用尖括号括起来。例如以下写法都是允许的:

1
2
#include "..."
#include <...>

前者是从当前的目录来搜索。

关于后者,这里主要讨论在linux系统gcc的头文件搜索路径,可以通过以下方法查看:

1
cpp -v

不同的机器可能有不同的结果,但都大同小异,我这里的一台机器的结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Using built-in specs.
COLLECT_GCC=cpp
OFFLOAD_TARGET_NAMES=nvptx-none
OFFLOAD_TARGET_DEFAULT=1
Target: x86_64-redhat-linux
Configured with: ../configure --enable-bootstrap --enable-languages=c,c++,fortran,lto --prefix=/usr --mandir=/usr/share/man --infodir=/usr/share/info --with-bugurl=http://bugzilla.redhat.com/bugzilla --enable-shared --enable-threads=posix --enable-checking=release --enable-multilib --with-system-zlib --enable-__cxa_atexit --disable-libunwind-exceptions --enable-gnu-unique-object --enable-linker-build-id --with-gcc-major-version-only --with-linker-hash-style=gnu --enable-plugin --enable-initfini-array --with-isl --disable-libmpx --enable-offload-targets=nvptx-none --without-cuda-driver --enable-gnu-indirect-function --enable-cet --with-tune=generic --with-arch_32=x86-64 --build=x86_64-redhat-linux
Thread model: posix
gcc version 8.5.0 20210514 (Red Hat 8.5.0-4) (GCC)
COLLECT_GCC_OPTIONS='-E' '-v' '-mtune=generic' '-march=x86-64'
/usr/libexec/gcc/x86_64-redhat-linux/8/cc1 -E -quiet -v - -mtune=generic -march=x86-64
ignoring nonexistent directory "/usr/lib/gcc/x86_64-redhat-linux/8/include-fixed"
ignoring nonexistent directory "/usr/lib/gcc/x86_64-redhat-linux/8/../../../../x86_64-redhat-linux/include"
#include "..." search starts here:
#include <...> search starts here:
/usr/lib/gcc/x86_64-redhat-linux/8/include
/usr/local/include
/usr/include
End of search list.

注意结尾的关于编译器的搜索路径:

1
2
3
4
#include <...> search starts here:
/usr/lib/gcc/x86_64-redhat-linux/8/include
/usr/local/include
/usr/include

即按照顺序,从上到下依次搜索。

如果想在指定路径下检索头文件,可在编译时加选项-I。如/home/Desktop目录下有个头文件local1.h,在编译包含local1.htest.c文件时,可用:gcc test.c -o test -I /root/Desktop

总结一下linux系统gcc的头文件搜索路径,优先级为:

  1. 在gcc编译的时候,通过参数-I指定头文件的搜索路径

    • 命令格式为:gcc -I /home/somepath/ sourcefile.c

    • 如果指定有多个路径时,则按照指定路径的顺序搜索头文件

    • 这里的路径可以是绝对路径,也可以是相对路径

  2. 通过设置的环境变量路径进行查找

    • C_INCLUDE_PATH为C的头文件路径
    • CPLUS_INCLUDE_PATH为C++的头文件路径
    • OBJC_INCLUDE_PATH为Objective-C的头文件路径
  3. 再通过默认的目录按顺序搜索,可以通过cpp -v查看,在默认情况下,都会指定到/usr/include目录下寻找

二、常用文件操作

文件操作大体可以分为两类,系统调用和库函数调用。它们是通过函数调用的方式进行,并且,后者本质上还是通过系统调用,下面分别对其进行介绍。

1 系统调用

1 open

open的函数原型为:

1
2
3
int open(const char *pathname, int flags);
// or
int open(const char *pathname, int flags, mode_t mode);

它依据pathname打开指定的文件,如果指定的文件不存在,可以使用值为O_CREATEflags来创建文件。函数的返回值叫做文件描述符,为一个非负整数。

文件描述符(file descriptor,FD)作为参数被后续的系统调用(如read、write等)使用,生成的文件描述符编号是当前系统未运行进程中的最小数字,即使两个进程同时打开一个文件,文件描述符也不相同(即:不会和任何其他运行中的进程共享)。

当系统开始运行时,它一般会有三个已经打开的特殊文件描述符,分别为:

  • 0:标准输入
  • 1:标准输出
  • 2:标准错误

其它文件的文件描述符,通过系统调用open返回。

linux系统对每个用户、进程、或整个系统的可打开文件描述符数量都有一个限制,一般默认为1024。当我们在系统或应用的日志中出现too many open files错误记录时,说明打开的文件描述符数量已达到了限制,此时增加文件描述符数量限制通常可以解决。

flags为参数标志,它们均定义在fcntl.h头文件中。必须包含以下模式之一:

  • O_RDONLY:只读方式打开文件
  • O_WRONLY:只写方式打开文件
  • O_RDWR:读写方式打开文件

除此之外,flags还可以包括0个或多个下列模式的组合,通过按位或|进行连接:

  • O_APPEND:文件附加模式,当使用write系统调用之前,将文件偏移量置于文件末尾。就像使用lseek一样,区别在于文件偏移量的修改lseek和写入操作write均为原子操作,这是写入附加到文件的唯一可靠方法;相反,如果只是将文件偏移量设置为文件末尾并写入(非原子操作),那么另一个进程可以在你lseek之后且在write之前写入文件,导致当前进程写入的数据出现在文件真正末尾之前的某个位置。

  • O_ASYNC:开启信号驱动IO,后续的IO操作将立即返回,同时在IO操作完成时发出相应的信号。(当前存在bug,需要使用fcntl启用此标志)。

  • O_CLOEXEC(Linux 2.6.23以上):在进程执行exec系统调用时关闭此打开的文件描述符。防止父进程泄露打开的文件给子进程,即便子进程没有相应权限。

    当一个Linux进程使用fork创建子进程后,父进程原有的文件描述符也会复制给子进程。而常见的模式是在fork之后使用exec函数族替换当前进程空间。此时,由于替换前的所有变量都不会被继承,所以文件描述符将丢失,而丢失之后就无法关闭相应的文件描述符,造成泄露。

    解决这个问题的方法一般有两种:

    • fork之后,execve之前使用close关闭所有文件描述符。但是如果该进程在此之前创建了许多文件描述符,在这里就很容易漏掉,也不易于维护。

    • 在使用open创建文件描述符时,加入O_CLOEXEC标志位:

      1
      int fd = open("./text.txt", O_RDONLY | O_CLOEXEC);

      通过这种方法,在子进程使用execve时,文件描述符会自动关闭。

  • O_CREAT:需要使用三个参数的open函数。当指定了这个标志,如果文件不存在,则按照第三个参数mode给出的模式创建文件:

    • S_IRWXU:该文件所有者拥有rwx操作的权限

    • S_IRUSR:该文件所有者拥有r操作的权限

    • S_IWUSR:该文件所有者拥有w操作的权限

    • S_IXUSR:该文件所有者拥有x操作的权限

    • S_IRWXG:该文件所属组拥有rwx操作的权限

    • S_IRGRP:该文件所属组拥有r操作的权限

    • S_IWGRP:该文件所属组拥有w操作的权限

    • S_IXGRP:该文件所属组拥有x操作的权限

    • S_IRWXO:其他用户拥有rwx操作的权限

    • S_IROTH:其他用户拥有r操作的权限

    • S_IWOTH:其他用户拥有w操作的权限

    • S_IXOTH:其他用户拥有x操作的权限

      下面三个为特殊权限(s和t权限)

    • S_ISUID:SUID位,在大部分情况下,如果将一个可执行的二进制程序的该位设为1,则运行该二进制程序产生的进程的euid与该文件的uid相同。该进程拥有该文件属主的权限。

    • S_ISGID:SGID位,在大部分情况下,如果将一个可执行的二进制程序的该位设为1,则运行该二进制程序产生的进程的egid与该文件的gid相同。该进程拥有该文件属组的权限。如果将一个目录的该位设为1,则表明在其中创建的所有文件的gid均与该目录相同,而不与创建该文件的进程的gid相同。

    • S_ISVTX:Sticky位,如果将一个目录的该位设为1,则该目录中所有文件只能被该文件的属主、该目录的属主以及特权进程重命名或删除。

  • O_DIRECT(Linux 2.4.10以上):无缓冲的输入、输出,Linux允许应用程序在执行磁盘IO时绕过缓冲区高速缓存,从用户空间直接将数据传递到文件或磁盘设备,称为直接IO(direct IO)或者裸IO(raw IO)。O_DIRECT标志本身致力于同步传输数据,但不提供O_SYNC标志所保证的数据和必要的元数据被传输。为了保证I/O同步,必须在O_DIRECT的基础上使用O_SYNC

  • O_DIRECTORY(Linux 2.1.126以上):如果pathname不是目录,则打开失败。

  • O_DSYNC:向文件写入数据的时候,只有当数据写到了磁盘时,写入操作才算完成(write才返回成功)

  • O_EXCL:确保这个调用创建了文件,通常与O_CREAT一起使用。使用O_CREAT后,如果文件不存在则创建一个空的新文件,如果这个文件存在则会重新创建这个文件,原来的内容会被清除。这样可能会由于无意间指定错误的文件名导致原来的文件被抹掉,使用O_EXCL|O_CREAT后,如果文件存在(包括符号链接),则不会创建文件,也不会对这个文件做任何改动,返回-1文件描述符(创建失败)。

  • O_LARGEFILE:在 32 位系统中大文件支持。需要在引用头文件最开始定义#define _LARGEFILE64_SOURCE以生效。

  • O_NOATIME(Linux 2.6.8以上):当使用read系统调用时,不更新文件的最后访问时间。需要满足其中一个条件:调用进程的有效用户 ID 必需与文件的拥有者相匹配,或者进程拥有特权CAP_FOWNER

  • O_NOCTTY:如果pathname为终端设备,此标志防止其成为控制终端;如果正在打开的文件不是终端设备,此标志无效。

  • O_NOFOLLOW:不对符号链接解引用。通常如果pathname是符号链接,open会对其解引用,而指定此标志,且pathname是符号链接时,open函数将返回失败。

  • O_NONBLOCKO_NDELAY:在可能的情况下以非阻塞方式打开,即后续的IO操作立即返回,而不是等IO操作完成后返回。对正常文件的描述符无效,对套接字等文件描述符有效。对于以此种方式打开的文件,后续的readwrite操作可能会产生特殊的错误——EAGAIN(对于套接字文件还可能产生EWOULDBLOCK)。这种错误的含义是接下来的读取或写入会阻塞,常见的原因可能是已经读取完毕了,或者写满了。比如说,当客户端发送的数据被服务器端全部读取之后,再次对以非阻塞形式打开的套接字文件进行read操作,就会返回EAGAINEWOULDBLOCK错误。

  • O_PATH(Linux 2.6.39以上):使用O_PATH将不会真正打开一个文件,而只是准备好该文件的文件描述符。有时,我们不想打开文件或目录。相反,我们只需要对该文件系统对象的引用,以便执行某些操作。以这种方式打开文件通常有两个目的:一是在文件系统中找到相应的文件,二是打开文件对其内容进行查看或修改。如果传入O_PATH标志位,则只执行第一个目的,不仅耗费更低,同时所需要的权限也更少。通过O_PATH打开的文件描述符可以传递给close, fchdir, fstat等只在文件层面进行的操作,而不能传递给read, write等需要对文件内容进行查看或修改的操作。

  • O_SYNC:比O_DSYNC更严格,以同步方式写入文件,强制刷新内核缓冲区到输出文件,启用此标志调用open后,每个write调用会自动将文件数据和元数据(例如文件长度等)刷新到磁盘上,只有真正写入到磁盘上才会返回成功。

  • O_TMPFILE(Linux 3.11以上):可以在一个目录下创建一个匿名文件,一旦关闭文件描述符,文件就自动删除。

  • O_TRUNC:如果文件已经存在且为普通文件,那么将清空文件内容,将其长度置为0(前提是以写方式打开,即O_WDONLYO_RDWR)。

2 close

close的函数原型为:

1
int close(int fd);

关闭文件描述符fd,使其不再指向任何文件,并且这个文件描述符能够被重新使用。

3 read

read的函数原型为:

1
ssize_t read(int fd, void *buf, size_t count);

read尝试从文件描述符fd所指向的文件读取count字节到缓冲区buf。它返回实际读入的字节数,这可能会小于请求的字节数。如果返回0,就表示没有读入任何数据,已到达了文件尾;如果返回-1,则表示出现了错误。

size_tssize_t分别是由POSIX.1指定的无符号和有符号整型数据类型。在Linux上,read以及类似的系统调用最多传输0x7ffff000(即2,147,479,552KB或1.9999961853027344GB),在32位和64位系统上都是如此。

4 write

write的函数原型为:

1
ssize_t write(int fd, const void *buf, size_t count);

wrtie从缓冲区buf开始将count字节写入到fd所指向的文件。写入的字节数可能小于count,例如,在底层物理介质上没有足够的空间,或者遇到了资源限制或者调用被中断等。如果成功,则返回写入的字节数。返回0表示没有写入,发生错误时,返回-1

5 lseek

函数原型如下:

1
off_t lseek(int fd, off_t offset, int whence);

打开的文件fd都有一个当前文件偏移量(current file offset,cfo),通常是一个非负整数,用于表明文件开始处到文件当前位置的字节数。读写操作通常开始于cfo,并且使cfo增大,增量为读写的字节数。文件被打开时,cfo会被初始化为0,除非在open时使用了O_APPEND

lseek可以改变文件的cfo,其中,offset参数的含义取决于whence的值:

  • SEEK_SET:文件偏移量将被设置为offset

  • SEEK_CUR:文件偏移量将被设置为当前偏移量加上offset

  • SEEK_END:文件偏移量被设置为文件长度加上offset

    从Linux 3.1开始,whence还支持以下值:

  • SEEK_DATA:由于lseek允许把偏移值设定的比文件大(比如文件只有6字节,offset设置为8字节),在这之间的数据会被填充为0,就像是在文件里留一个“洞”(hole)。因此,SEEK_DATA的作用就是当此时处于“洞”内时,文件偏移被设置为从offset之后第一个包含数据的位置;而如果此时offset指向数据,则文件偏移量就设置为offset

  • SEEK_HOLE:文件偏移量被设置为offset之后的下一个“洞”。如果offset此时正处于“洞”内,则不改变offset的位置;如果offset后面没有“洞”,则将offset调整到文件的末尾。

    需要注意的是,由于不同的文件系统对SEEK_DATASEEK_HOLE的支持不同,上述行为在不同文件系统下可能会不同。

成功时lseek返回此时文件的偏移量;在发生错误时,返回-1

下面介绍一些lseek用例:

1
2
3
4
5
6
7
8
// 1、获取fd当前的偏移量
off_t offset;
offset = lseek(fd, 0, SEEK_CUR);
// 2、获取fd文件大小(单位:字节)
off_t size;
size = lseek(fd, 0, SEEK_END); // 注意此时偏移量被设置为文件末尾,需要重新设置回来,见3
// 3、重置偏移量至文件开头
lseek(fd, 0, SEEK_SET);

2 库函数

对于频繁IO操作,直接使用系统调用效率会非常低,因为系统调用有从用户态到内核态的切换过程,系统开销很大。库函数虽然也在底层使用系统调用,也有系统调用的开销。那么为什么不直接使用系统调用呢?这是因为读写文件通常是大量的数据(这种大量是相对于底层驱动的系统调用所实现的数据操作单位而言),这时,使用库函数就可以大大减少系统调用的次数。由于缓冲区技术的存在,在用户空间和内核空间,对文件操作都使用了缓冲区。例如用fwrite写文件,先将内容写到用户空间缓冲区,当用户空间缓冲区满或者写操作结束时,才将用户缓冲区的内容写到内核缓冲区,同样的道理,当内核缓冲区满或写结束时才将内核缓冲区内容写到文件对应的硬件媒介。因此,通常情况下库函数可以减少系统调用的次数。在使用时,如果存在对应于系统调用的库函数,应该优先使用库函数。同理,如果你想对文件进行精确的控制,则应该避开缓冲区,转而使用系统调用而不是IO库函数。

文件操作的头文件stdio.h为底层I/O系统调用提供了一个通用的接口。其中,与文件描述符对应的叫流:stream,它被实现为指向结构FILE的指针。

1 fopen

函数原型如下:

1
FILE *fopen(const char *pathname, const char *mode);

open类似,这里的pathname为文件路径,mode为打开模式,见下表:

mode模式说明对应于openflags
r只读模式打开,stream在文件开头,如果文件不存在则返回NULLO_RDONLY
w只写模式打开,如果文件存在则将文件清空,如果文件不存在则创建文件,stream在文件开头O_WRONLY|O_CREAT|O_TRUNC
a追加模式打开,如果文件不存在则创建文件,stream在文件末尾O_WRONLY|O_CREAT|O_APPEND
r+以读写模式打开,stream在文件开头,如果文件不存在则返回NULLO_RDWR
w+以读写模式打开,如果文件存在则将文件清空,如果文件不存在则创建文件,stream在文件开头O_RDWR|O_CREAT|O_TRUNC
a+以读追加模式打开,如果文件不存在则创建文件,stream在文件末尾O_RDWR|O_CREAT|O_APPEND

2 fread

函数原型如下:

1
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);

freadstream读取nmembsize字节放入缓冲区ptr。返回成功读取到数据缓冲区的字节数,如果发生错误,或文件结束,则返回少于预期的字节数(或者0)。

3 fwrite

函数原型如下:

1
size_t fwrite(const void *ptr, size_t size, size_t nmemb,FILE *stream);

fread相反,从ptr中获取并写入nmembsize字节到stream,返回值为成功写入的字节数,当发生错误时返回可能少于预期的字节数(或者0)。

4 fseek

函数原型如下:

1
int fseek(FILE *stream, long offset, int whence);

seek系统调用类似,offset为偏移量,whence可选项为SEEK_SETSEEK_CURSEEK_END