Linux C 文件基本操作

本文章已经生成可运行项目,

在UNIX系统当中,“万物皆文件”是一种重要的设计思想。在传统的定义当中,我们把存储在磁盘当中的数据集合称为文件。而在UNIX的设计当中,文件这个概念得到了进一步泛化,所有满足速度较慢、容量较大和可以持久化存储中任意一个特征的数据集合都可以称为文件,包括不限于磁盘数据、输入输出设备、用于进程间通信的管道、网络等等都属于文件。
文件系统是操作系统用于管理文件的子模块。在文件系统当中,会根据文件的存在形式分为普通文件、目录文件、链接文件和设备文件等类型:

  • 普通文件:也称磁盘文件,并且能够进行随机(能够自由使用lseek或者fseek定位到某一个位置)的数据存储;
  • 管道:是一个从一端发送数据,另一端接收数据的数据通道;
  • 目录:也称为目录文件,它包含了保存在目录中文件列表的简单文件;
  • 设备:该类型的文件提供了大多数物理设备的接口。它又分为两种类型:字符设备和块设备。字符
  • 设备一次只能读出和写入一个字节的数据,包括终端、打印机、声卡以及鼠标;块设备必须以一定
  • 大小的块来读出或者写入数据,块设备包括CD-ROM、RAM驱动器和磁盘驱动器等。一般而言,字符设备用于传输数据,块设备用于存储数据;
  • 链接:类似于Windows的快捷方式,指包含到达另一个文件路径的文件。

基于文件流的文件操作

文件流,又称为(用户态)文件缓冲区,它是由标准C库(ISO C)设计和定义的用于管理文件的数据结构。如果进程想要使用C库函数操作文件数据,就必须提前在内存中先申请创建一个文件流对象。

文件流操作基于缓冲机制,标准库会为文件流分配一个缓冲区(buffer),用于存储读取或写入的数据。这种缓冲机制可以减少对底层文件系统的访问次数,提高文件操作的效率。

文件流的创建与关闭

fopen与fclose

创建文件流使用 fopen ,关闭文件流使用 fclose 。

#include <stdio.h> 
FILE* fopen(const char* path, const char* mode);//创建文件流
int fclose(FILE* stream);    //关闭文件流
  • filename:一个指向以 null 结尾的字符串的指针,表示要打开的文件的路径和名称。

  • mode:一个指向以 null 结尾的字符串的指针,表示文件的打开模式。常见的模式包括:

    • "r":以只读模式打开文件。文件必须存在。

    • "w":以写模式打开文件。如果文件存在,其内容会被清空;如果文件不存在,会创建一个新文件。

    • "a":以追加模式打开文件。如果文件存在,写入的内容会被追加到文件末尾;如果文件不存在,会创建一个新文件。

    • "r+":以读写模式打开文件。文件必须存在。

    • "w+":以读写模式打开文件。如果文件存在,其内容会被清空;如果文件不存在,会创建一个新文件。

    • "a+":以读写模式打开文件。如果文件存在,写入的内容会被追加到文件末尾;如果文件不存在,会创建一个新文件。

a和a+为追加模式,在此两种模式下,在一开始的时候读取文件内容是从文件起始处开始读取的,而无论文件读写点定位到何处,在写数据时都将是在文件末尾添加(写完以后读写点就移动到文件末尾了),所以比较适合于多进程写同一个文件的情况下保证数据的完整性。

  • 成功时返回一个指向 FILE 结构的指针。

  • 失败时返回 NULL,可以通过 errnoperror/strerror 获取错误原因。

fclose

  • stream:一个指向 FILE 结构的指针,表示要关闭的文件流。

  • 成功时返回 0

  • 失败时返回 EOF(通常定义为 -1),可以通过 errnoperror/strerror 获取错误原因。

读写文件

数据块读写

#include <stdio.h>
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(void *ptr, size_t size, size_t nmemb, FILE *stream);
  • fread 从文件流stream 中读取nmemb个元素,写到ptr指向的内存中,每个元素的大小为size个字节
  • fwrite 从ptr指向的内存中读取nmemb个元素,写到文件流stream中,每个元素的大小为size个字节
  • 所有的文件读写函数都从文件的当前读写点开始读写,读写完成以后,当前读写点自动往后移动size*nmemb个字节。 

格式化读写

#include <stdio.h>
int printf(const char *format, ...);   //格式化输出到标准输出(通常是屏幕)
//相当于fprintf(stdout,format,…);      
int scanf(const char *format, …);      //从标准输入(通常是键盘)读取格式化输入
int fprintf(FILE *stream, const char *format, ...);//将格式化输出写入指定文件流
int fscanf(FILE *stream, const char *format, …);   //从指定文件流读取格式化输入
int sprintf(char *str, const char *format, ...);   //将格式化输出写入字符串
//eg:sprintf(buf,”the string is;%s”,str);        
int sscanf(char *str, const char *format, …);      //从字符串读取格式化输入
  • stream:指向 FILE 结构的指针,表示目标文件流。

  • const char format:格式化字符串,用于指定输入或输出的格式。它包含普通字符和格式说明符(如 %d%s%f 等)。

  • ...:可变参数列表,根据格式说明符提供相应的值。

  • 对于以上输出类型函数 ,成功时返回输出的字符数,如果发生错误,返回负值。

  • 对于以上输入类型函数 ,成功时返回读取的输入项数。发生错误或到达文件末尾,返回 EOF(通常为 -1)。

单个字符读写

#include <stdio.h>
int fgetc(FILE *stream);        //从指定的文件流中读取下一个字符。
int fputc(int c, FILE *stream); //将一个字符写入指定的文件流。
int getc(FILE *stream);         //从指定的文件流中读取下一个字符,等同于 fgetc(FILE* stream)
int putc(int c, FILE *stream);  //将一个字符写入指定的文件流。,等同于 fputc(int c, FILE* stream)
int getchar(void);              //用于从标准输入(通常是键盘)读取下一个字符。,等同于 fgetc(stdin);
int putchar(int c);             //用于将一个字符写入标准输出(通常是屏幕)。等同于 fputc(int c, stdout);
  • getc 与 fgetc 功能相同,但通常比 fgetc 更快,因为它可能使用了宏实现。
  • putc 与 fputc 功能相同,但通常比 fputc 更快,因为它可能使用了宏实现。
  • 对于以上输出类型函数 ,如果成功读取字符,返回读取的字符(以 int 类型表示)。如果到达文件末尾或发生错误,返回 EOF(通常为 -1)。
  • 对于以上输入类型函数 ,如果成功写入字符,返回写入的字符。如果发生错误,返回 EOF(通常为 -1)。

字符串读写

char *fgets(char *s, int size, FILE *stream);  //从指定的文件流中读取一行字符串,直到遇到换行符或达到指定的字符数。
int fputs(const char *s, FILE *stream);        //将一个字符串写入指定的文件流。
int puts(const char *s);                       //将一个字符串写入标准输出(通常是屏幕),并在字符串末尾自动添加换行符。等同于 fputs(const char *s,stdout);
char *gets(char *s);                          //从标准输入(通常是键盘)读取一行字符串,直到遇到换行符或文件结束符。等同于 fgets(const char *s, int size, stdin);
  • s:指向字符数组的指针,用于存储读取的字符串。

  • size:指定最多读取的字符数(包括换行符和终止符 \0)。

  • stream:指向 FILE 结构的指针,表示要从中读取的文件流。

  • fgets 和 fputs 从文件流stream中读写一行数据;
  • puts 和 gets 从标准输入输出流中读写一行数据。
  • 对于以上输出类型函数 ,如果成功读取字符串,返回指向字符串的指针(即参数 s)。如果到达文件末尾或发生错误,返回 NULL
  • 对于以上输入类型函数 ,如果成功写入字符串,返回非负值。如果发生错误,返回 EOF(通常为 -1)。

注意事项:

  • fgets 会读取直到换行符 \n 或达到 size - 1 个字符为止,并在字符串末尾添加空字符 \0。如果读取到换行符,换行符也会被存储在字符串中。

  • fputs 不会自动添加换行符。如果需要换行,需要在字符串末尾手动添加 \n

  • puts 会在字符串末尾自动添加换行符 \n

  • gets 是不安全的函数,因为它不会检查目标缓冲区的大小,容易导致缓冲区溢出。

文件定位

文件定位指读取或设置文件当前读写点,所有的通过文件指针读写数据的函数,都是从文件的当前读写点读写数据的。常用的函数有:

#include <stdio.h>
int feof(FILE * stream);  //检查文件流的末尾是否已到达,通常的用法为while(!feof(fp))
int fseek(FILE *stream, long offset, int whence); //设置当前读写点到偏移whence 长度为offset处
long ftell(FILE *stream);  //用来获得文件流当前的读写位置
void rewind(FILE *stream); //把文件流的读写位置移至文件开头 fseek(fp, 0, SEEK_SET);
  • stream:指向 FILE 结构的指针,表示要操作的文件流。
  • offset:要移动的字节数。正值表示向前移动,负值表示向后移动。
  • whence:指定移动的参考位置。常见的值有:
  • feof 如果文件流的末尾已到达,返回非零值。如果文件流的末尾未到达,返回零。

  • fseek 如果成功移动文件指针,返回零。如果发生错误,返回非零值。

  • ftell 返回文件指针的当前位置(以字节为单位)。如果发生错误,返回 -1L

基于文件描述符的文件操作

之前所讨论的文件操作都是操作文件流,即FILE。我们把所有和FILE类型相关的文件操作(比如fopen,fread等等)称为带缓冲的IO,它们是ISO C的组成部分,它们都是库函数,其底层调用了系统调用来使用内核的功能。
POSIX标准支持另一类无缓冲的IO,这些操作都是系统调用。值得注意的是,在这里无缓冲是没有分配用户态文件缓冲区的意思。在操作文件时,进程会在内存地址空间的内核区部分里面维护一个数据结构来管理和文件相关的所有操作,这个数据结构称为打开文件或者是文件对象(file / file struct),除此以外,内核区里面还会维护一个索引数组来管理所有的文件对象,该数组的下标就被称为文件描述符(file descriptor)。
从类型来说,文件描述符是一个非负整数,它可以传递给用户。用户在获得文件描述符之后可以定位到相关联的文件对象,从而可以执行各种IO操作。

文件描述符操作不依赖缓冲区,直接通过系统调用(如readwrite)与操作系统内核进行交互。每次读写操作都会直接触发对文件系统的访问。而带缓冲的IO文件操作,则会在用户态缓冲区填充到一定容量时才会发送给内核进行批量写入,减少了系统调用的开销。

打开、创建和关闭文件

open与close

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);    //文件名 打开方式
int open(const char *pathname, int flags, mode_t mode);//文件名 打开方式 权限
int close(int fd);    //fd表示文件描述符,是先前由open或creat创建文件时的返回值。
  • open 可以打开一个已存在的文件或者创建一个新文件,并在内核态创建一个文件对象,返回相关的文件描述符。使用完文件以后,要记得使用 close 来关闭文件。一旦调用 close ,会使文件的打开引用计数减1,只有文件的打开引用计数变为0以后,文件才会被真正的关闭。

  • pathname:指向以 null 结尾的字符串,表示要打开的文件的路径。

  • flags:指定文件的打开模式。常见的标志包括:

    • O_RDONLY:以只读模式打开文件。

    • O_WRONLY:以只写模式打开文件。

    • O_RDWR:以读写模式打开文件。

    • O_CREAT:如果文件不存在,则创建文件。

    • O_TRUNC:如果文件已存在,并且以写模式打开,则清空文件内容。

    • O_APPEND:写入时将数据追加到文件末尾。

    • O_NONBLOCK,O_NDELAY  :对管道、设备文件和socket使用,以非阻塞方式打开文件,无论有无数据读取或等待,都会立即返回进程之中

  • mode:(可选)当使用 O_CREAT 标志时,指定文件的权限模式。通常使用八进制表示,例如:

    • 0644:所有者可读写,组用户和其他用户可读。

    • 0755:所有者可读写执行,组用户和其他用户可读执行。

  • open 成功时返回一个非负的文件描述符,失败时返回 -1,并设置 errno 以指示错误原因。

  • 成功时返回 0。失败时返回 -1,并设置 errno 以指示错误原因。

  • 在使用 open 系统调用的时候,内核会按照最小可用的原则分配一个文件描述符。一般情况下, 进程一经启动就会打开3个文件对象,占用了0、1和2文件描述符,分别关联了标准输入、标准输出和标准错误输出,所以此时再打开的文件占用的文件描述符就是3。

基本的读写操作

read和write

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);//文件描述符 缓冲区 缓冲区长度上限
ssize_t write(int fd, const void *buf, size_t count);//文件描述符 缓冲区 内容长度

read

  • fd:文件描述符,标识要从中读取数据的文件或设备。

  • buf:指向缓冲区的指针,读取的数据将存储在这个缓冲区中。

  • count:指定最多读取的字节数。注意这里是“最多”,意味着文件大小超出count则会先读取count个字节,长度不足count,那么本次 read 会读取文件剩余内容;

  • 成功时返回实际读取的字节数。如果读取到文件末尾(EOF),返回 0

  • 如果发生错误,返回 -1,并设置 errno 以指示错误原因。

  • read 的原理是将数据从文件对象内部的内核文件缓冲区拷贝出来(这部分的数据最初是在外部设备中,通过硬件的IO操作拷贝到内存之上)到用户态的buf之中。

write

  • fd:文件描述符,标识要写入数据的文件或设备。

  • buf:指向要写入的数据的缓冲区的指针。

  • count:指定要写入的字节数。注意如果写入不足count个字节则会默认写入多余空格补足

  • 成功时返回实际写入的字节数。

  • 如果发生错误,返回 -1,并设置 errno 以指示错误原因。

  • write 将数据从用户态的buf当中拷贝到内核区的文件对象的内核文件缓冲区,并最
    终会写入到设备中。

实战:通过基本文件读写操作实现文件复制

//copy.c
int main(int argc, char const *argv[])
{
    ARGS_CHECK(argc, 3);
    int fd1 = open(argv[1], O_RDONLY);
    ERROR_CHECK(fd1, -1, "open fdr");
    int fd2 = open(argv[2], O_WRONLY|O_CREAT, 0666);
    ERROR_CHECK(fd2, -1, "open fdw");

    char buf[1024]; //这里的缓冲区设置的较小,如果要复制很大的文件,请调整大小
    while (1){
        memset(buf, 0, sizeof(buf));
        int ret = read(fd1, buf, sizeof(buf));
        ERROR_CHECK(ret, -1, "read error");
        if(ret == 0){
            break;
        }
        write(fd2, buf, strlen(buf));
    }
    
    close(fd1);
    close(fd2);
    return 0;
}

在上述代码cp命令的实现之中,我们可以调整buf数组的长度来影响的程序执行的效率。经过测试,buf的长度越大,则整个程序的执行效率越高,其原因也很简单, read / write 是系统调用,每次执行都需要一段时间来让硬件的状态在用户态和内核态之间切换,在文件长度固定的情况下,buf长度越大,read / write 的执行次数越少,自然效率就越高。

readv

readv()writev() 是 Linux 提供的用于高效 I/O 操作的系统调用,它们支持“散列(scatter/gather)”I/O 操作。这意味着它们可以一次性从多个内存区域读取或写入数据,而无需将数据先拷贝到一个连续的缓冲区中。这种方式可以显著提高性能,尤其是在处理大量数据时。

#include <sys/uio.h>

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

参数说明

  • fd:

    • 文件描述符,表示要从中读取数据的文件或套接字。

  • iov:

    • 指向 struct iovec 数组的指针。每个 struct iovec 定义了一个缓冲区:

iovec 是一个结构体,用于描述分散(scatter)或聚集(gather)I/O 操作中的内存区域。它通常与 readv()writev() 等系统调用一起使用,允许程序一次性从多个内存区域读取或写入数据,而无需将数据先拷贝到一个连续的缓冲区中。这种方式可以显著提高 I/O 操作的效率。

struct iovec {
    void  *iov_base;  // 缓冲区的起始地址
    size_t iov_len;   // 缓冲区的长度
};
  • iovcnt:

    • iov 数组中的元素数量。

返回值

  • 成功:返回实际读取的字节数。

  • 失败:返回 -1,并通过 errno 设置错误码。

writev

writev() 用于将多个缓冲区中的数据写入到文件描述符中。它允许将多个内存区域的数据一次性写入,而无需先将它们拷贝到一个连续的缓冲区中。

#include <sys/uio.h>

ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

参数说明

  • fd:

    • 文件描述符,表示要写入数据的文件或套接字。

  • iov:

    • 指向 struct iovec 数组的指针。每个 struct iovec 定义了一个缓冲区: 

  • iovcnt:

    • iov 数组中的元素数量。

返回值

  • 成功:返回实际写入的字节数。

  • 失败:返回 -1,并通过 errno 设置错误码。

使用场景

readv()writev() 特别适用于以下场景:

  1. 高效 I/O 操作:避免多次调用 read()write(),减少系统调用的开销。

  2. 处理大块数据:将数据分散到多个缓冲区中,避免内存拷贝,提高性能。

  3. 网络编程:在处理网络数据时,可以将数据直接写入或从多个缓冲区中读取,减少中间拷贝。

示例:使用readv读取数据到多个不连续区域中

int main() {
    int fd = STDIN_FILENO; // 标准输入
    struct iovec iov[2];
    char buffer1[10];
    char buffer2[20];

    // 初始化 iovec 结构
    iov[0].iov_base = buffer1;
    iov[0].iov_len = sizeof(buffer1);
    iov[1].iov_base = buffer2;
    iov[1].iov_len = sizeof(buffer2);

    // 使用 readv 读取数据
    ssize_t bytes_read = readv(fd, iov, 2);
    if (bytes_read == -1) {
        perror("readv");
        exit(EXIT_FAILURE);
    }

    // 打印读取的内容
    buffer1[bytes_read > sizeof(buffer1) ? sizeof(buffer1) - 1 : bytes_read] = '\0';
    buffer2[bytes_read - sizeof(buffer1) > sizeof(buffer2) ? sizeof(buffer2) - 1 : bytes_read - sizeof(buffer1)] = '\0';
    printf("Buffer 1: %s\n", buffer1);
    printf("Buffer 2: %s\n", buffer2);

    return 0;
}

文件偏移

lseek

系统调用 lseek 可以(内核中的)文件对象的文件读写偏移量设定到以whence为启动,偏移值为offset的位置。它的返回值是读写点距离文件开始的距离。(所以 lseek 其实可以用来获取文件的大小)

#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
  • fd:文件描述符,标识要操作的文件。

  • offset:偏移量,表示要移动的字节数。可以是正数(向前移动)、负数(向后移动)或零。

  • whence:指定偏移量的参考位置。常见的值包括:

    • SEEK_SET:文件开始位置(offset 是相对于文件开头的偏移量)。

    • SEEK_CUR:当前位置(offset 是相对于当前文件指针位置的偏移量)。

    • SEEK_END:文件末尾位置(offset 是相对于文件末尾的偏移量)。

  • 成功时返回新的文件指针位置(从文件开头开始计算的字节数)。

  • 如果发生错误,返回 -1,并设置 errno 以指示错误原因。

示例:

int main(int argc, char const *argv[])
{
    ARGS_CHECK(argc, 2);
    int fd = open(argv[1], O_RDONLY);
    ERROR_CHECK(fd, -1, "open fd error");

    char buf[10];

    int ret = read(fd, buf, sizeof(buf));
    ERROR_CHECK(ret, -1, "read error");
    printf("the first read is : %s\n",buf);

    ret = read(fd, buf, sizeof(buf));
    ERROR_CHECK(ret, -1, "read error");
    printf("the second read is : %s\n",buf);

    lseek(fd, 0, SEEK_SET);

    ret = read(fd, buf, sizeof(buf));
    ERROR_CHECK(ret, -1, "read error");
    printf("the lseek read is : %s\n",buf);
    close(fd);
    return 0;
}

结果如下:

the first read is : In a small
the second read is :  village, 
the lseek read is : In a small

截断文件

ftruncate

使用 ftruncate 函数可以截断文件,从而控制文件的大小。它可以将文件的大小截断为指定的长度,如果文件原来比指定长度大,则多余的部分会被截断;如果文件原来比指定长度小,则文件会被扩展,新扩展的部分通常会被填充为零。

#include <unistd.h>
int ftruncate(int fd, off_t length);
  • fd:文件描述符,标识要调整大小的文件。

  • length:新的文件大小(以字节为单位)。

  • 成功时返回 0

  • 如果发生错误,返回 -1,并设置 errno 以指示错误原因。

  • ftruncate 特别适合用于扩展文件或者给文件预留空间,扩展文件时会自动将多余部分填充为0,在平常进行下载操作就是事先在磁盘预留空间防止之后下载文件空间不足。

示例:

int main(int argc, char *argv[])
{
    ARGS_CHECK(argc,2);
    int fd = open(argv[1],O_WRONLY);
    ERROR_CHECK(fd,-1,"open");
    printf("fd = %d\n",fd);
    off_t length = 3;
    int ret = ftruncate(fd,length);
    ERROR_CHECK(ret,-1,"ftruncate");
    return 0;
}

假如在上述的例子,把length的长度设置得特别大(比如40960或者更大),然后我们使用stat命令来查看文件实际所分配的磁盘空间大小,会发现文件大小居然会大于分配的磁盘空间大小。这就意味着,文件已经占用了文件系统当中的空间,但是底层磁盘还没有为其分配真正的磁盘块,这就是文件空洞。

文件映射

mmap

使用 mmap 系统调用可以实现文件映射功能,也就是将一个磁盘文件直接映射到内存用户态地址空间的一片区域当中,这样的话,内存内容就和磁盘文件内容一一对应,也不再需要使用 read 和 write 系统调用就可以进行IO操作,直接读写内存数据即可。此时读写内存等价于读写磁盘。
需要注意的是, mmap 不能修改文件的大小,所以需要经常配合函数 ftruncate 来使用。

#include <sys/mman.h>
void *mmap(void *adr, size_t len, int prot, int flag, int fd, off_t off);
  • addr

    • 指定映射区域的起始地址。通常传入 NULL,让操作系统选择合适的地址。

  • length

    • 指定映射区域的长度(以字节为单位)。

  • prot

    • 指定映射区域的保护属性。常见的值包括:

      • PROT_READ:映射区域可读。

      • PROT_WRITE:映射区域可写。

      • PROT_EXEC:映射区域可执行。

      • PROT_NONE:映射区域不可访问。

  • flags

    • 指定映射的类型和行为。常见的值包括:

      • MAP_SHARED:映射区域对其他进程可见(即修改会反映到文件中)。

      • MAP_PRIVATE:映射区域对其他进程不可见(即修改不会反映到文件中)。

      • MAP_FIXED:强制使用指定的 addr 地址(不推荐,除非必要)。

    • 默认使用 MAP_SHARED 即可

  • fd

    • 文件描述符,标识要映射的文件或设备。必须是有效的、已打开的文件描述符。

  • offset

    • 指定文件中映射的起始位置(以字节为单位)。通常必须是页面大小的倍数(如 4096 字节)。

  • 成功时返回映射区域的起始地址。注意这里返回值 void * 是一个万能指针,接受时必须进行强制转换。

  • 如果发生错误,返回 (void *)-1,并设置 errno 以指示错误原因。

注意事项

  • 页面对齐offset 必须是页面大小的倍数(通常是 4096 字节)。可以通过 sysconf(_SC_PAGESIZE) 获取页面大小。

  • 内存保护prot 参数必须与文件的打开模式一致。例如,如果文件是以只读模式打开的,prot 不能包含 PROT_WRITE

  • 取消映射:使用完映射区域后,必须调用 munmap 取消映射,以释放资源。

  • 文件大小:如果文件大小小于映射区域的长度,可能会导致未定义行为。建议在映射前确保文件大小足够。

munmap

munmap 用于取消内存映射。

int munmap(void *addr, size_t length);
  • addr:映射区域的起始地址。

  • length:映射区域的长度。

  • 成功时返回 0

  • 如果发生错误,返回 -1,并设置 errno 以指示错误原因。

  • mmap:将文件或设备的内存映射到进程的地址空间,允许直接通过指针访问文件内容。

  • munmap:取消内存映射,释放资源。

示例:

int main(int argc, char *argv[]){
    // ./mmap file1
    ARGS_CHECK(argc,2);
    // 先 open 文件
    int fd = open(argv[1],O_RDWR);
    ERROR_CHECK(fd,-1,"open");
    // 建立内存和磁盘之间的映射
    char *p = (char *)mmap(NULL,5,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
    ERROR_CHECK(p,MAP_FAILED,"mmap");//mmap失败返回不是NULL
    for(int i = 0; i < 5; ++i){
        printf("%c", *(p+i));
    }
    printf("\n");
    *(p+4) = 'O';
    munmap(p,5);
    close(fd);
    return 0;
}

文件描述符的复制

在一些多线程和多进程情况下,不同的线程可能会同时操作同一个文件,如果线程之间的共享同一个文件描述符会导致冲突。dup和dup2通过复制文件描述符很好的解决了这个问题,复制文件描述符后,新的文件描述符与原始文件描述符是独立的,关闭其中一个不会影响另一个。

dup和dup2

所谓文件描述符的复制并不是简单地拷贝一份文件描述符的整数值,而是使用一个新的文件描述符去引用同一个文件对象。dup 返回一个新的文件描述符,该文件描述符是自动分配的,数值是没有使用的文件描述符的最小编号。该描述符与 oldfd 共享同一个文件表项。同时二者共享偏移量。

dup2 允许调用者用一个有效描述符(oldfd)和目标描述符(newfd)。函数成功返回时,目标描述符将变成旧描述符的复制品,此时两个文件描述符现在都指向同一个文件,并且是函数第一个参数(也
就是oldfd)指向的文件(如果 newfd 已经打开,它会被关闭,然后重新指向 oldfd)。

#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
  • 如果发生错误,返回 -1,并设置 errno 以指示错误原因。

示例:

int main(int argc, char const *argv[])
{
    ARGS_CHECK(argc, 2);
    int fd1 = open(argv[1], O_RDWR);
    ERROR_CHECK(fd1, -1, "open fd1 error");
    printf("old fd = %d\n", fd1);
    int fd2 = dup(fd1);
    ERROR_CHECK(fd2, -1, "dup fd2 error");
    printf("new fd = %d\n", fd2);

    char buf[10];
    int ret = read(fd1, buf, sizeof(buf));
    ERROR_CHECK(ret, -1, "read error");
    printf("old fd: %s\n",buf);

    ret = read(fd1, buf, sizeof(buf));
    ERROR_CHECK(ret, -1, "read error");
    printf("old fd: %s\n",buf);

    
    ret = read(fd2, buf, sizeof(buf));
    ERROR_CHECK(ret, -1, "read error");
    printf("new fd: %s\n",buf);

    lseek(fd2, 0, SEEK_SET); //将fd2位置重置到开头

    ret = read(fd1, buf, sizeof(buf));
    ERROR_CHECK(ret, -1, "read error");
    printf("old fd: %s\n",buf);

    close(fd1);
    close(fd2);
    return 0;
}

输出结果:

old fd = 3
new fd = 4
old fd: In a small
old fd:  village, 
new fd: there live
old fd: In a small

可以观察到新文件描述符和旧文件描述符是共享偏移量的

通过复制文件描述符观察printf的输出结果

#include<54func.h>

int main(int argc, char const *argv[])
{
    ARGS_CHECK(argc, 2);
    int fd = open(argv[1], O_RDWR);   
    ERROR_CHECK(fd, -1, "open fd error");
    
    int tempfd = 10;
    int ret = dup2(STDOUT_FILENO, tempfd);//备份标准输出流
    ERROR_CHECK(ret, -1, "dup2 error");
    printf("hello 1\n");

    ret = dup2(fd, STDOUT_FILENO);        //将文件描述符转移到标准输出中
    printf("hello 2\n");

    ret = dup2(tempfd, STDOUT_FILENO);    //将备份还原
    printf("hello 3\n");

    close(tempfd);
    close(fd);
    return 0;
}

输出结果:

./file2 test1.txt
//命令行显示
hello 1
hello 3

//文件中显示
hello 2

可以观察到,printf默认会向STDOUT_FILENO 中写入数据,并由操作系统输出到命令行中 

文件描述符的属性控制

fcntl

fcntl 函数是 Linux 系统编程中非常重要的一个系统调用,用于对文件描述符进行控制操作。它提供了多种功能,包括修改文件描述符的属性、获取或设置文件锁等。

#include <unistd.h>
#include <fcntl.h>

int fcntl(int fd, int cmd, ... /* arg */ );
  • fd:需要操作的文件描述符。

  • cmd:指定要执行的操作命令。

  • ...:可选参数,用于设置输入的参数,具体取决于 cmd 的类型。

常见的 cmd 参数及其含义

fcntl 函数通过 cmd 参数来决定执行哪种操作。以下是一些常用的 cmd 参数及其功能:

F_DUPFDF_DUPFD_CLOEXEC
  • 功能:复制文件描述符。

  • F_DUPFD:复制文件描述符 fd,返回的新文件描述符是当前可用的最小值,且大于或等于 arg

  • F_DUPFD_CLOEXEC:与 F_DUPFD 类似,但新文件描述符会设置 FD_CLOEXEC 标志(即在执行 exec 系统调用时自动关闭该文件描述符)。

示例:

int new_fd = fcntl(old_fd, F_DUPFD, 10); // 复制 old_fd,新文件描述符 >= 10
error_check(new_fd, -1, "fcntl");
 F_GETFDF_SETFD
  • 功能:获取或设置文件描述符标志。

  • F_GETFD:获取文件描述符 fd 的标志。

    • 成功:返回当前文件描述符的标志值。

    • 失败:返回 -1,并设置 errno

  • F_SETFD:设置文件描述符 fd 的标志,arg 是要设置的标志值。

    • 成功:返回 0。

    • 失败:返回 -1,并设置 errno

  • 标志

    • FD_CLOEXEC:如果设置,执行 exec 时关闭该文件描述符。

文件描述符标志用于控制文件描述符的行为,主要涉及文件描述符在进程执行 exec 系统调用时的行为。这些标志通过 fcntlF_GETFDF_SETFD 命令来获取和设置。

常见的文件描述符标志

  • FD_CLOEXEC:当设置此标志时,文件描述符在执行 exec 系统调用时会被自动关闭。如果未设置此标志,则文件描述符在执行 exec 时会保持打开状态。

示例:

int flags = fcntl(fd, F_GETFD);
ERROR_CHECK(flags, -1, "fcntl F_GETFD");
printf("Current FD flags: %d\n", flags);

int ret = fcntl(fd, F_SETFD, flags | FD_CLOEXEC)
ERROR_CHECK(ret, -1, "fcntl F_SETFD");
 F_GETFLF_SETFL
  • 功能:获取或设置文件状态标志。

  • F_GETFL:获取文件描述符 fd 的状态标志。

    • 成功:返回当前文件状态标志的值。

    • 失败:返回 -1,并设置 errno

  • F_SETFL:设置文件描述符 fd 的状态标志,arg 是要设置的标志值。

    • 成功:返回 0。

    • 失败:返回 -1,并设置 errno

  • 标志

    • O_APPEND:写操作时追加到文件末尾。

    • O_NONBLOCK:非阻塞模式。

    • O_SYNC:同步写操作。

文件状态标志用于控制文件的打开模式,例如是否以非阻塞模式打开、是否追加到文件末尾等。这些标志通过 fcntlF_GETFLF_SETFL 命令来获取和设置。

常见的文件状态标志

  • O_APPEND:写操作时追加到文件末尾。

  • O_NONBLOCK:非阻塞模式,适用于 I/O 操作。

  • O_SYNC:同步 I/O 操作,即写操作会直接写入磁盘。

  • O_DIRECT:直接 I/O,绕过缓存。

  • O_NOATIME:不更新文件的访问时间。

int flags = fcntl(fd, F_GETFL);
ERROR_CHECK(flags, -1, "fcntl F_GETFL");
printf("Current file status flags: %d\n", flags);

int ret = fcntl(fd, F_SETFL, flags | O_APPEND | O_NONBLOCK);
ERROR_CHECK(ret, -1, "fcntl F_SETFL");
printf("O_APPEND and O_NONBLOCK set successfully.\n");
F_GETLKF_SETLKF_SETLKW 
  • 功能:获取或设置文件锁。

    • 成功:返回非负值(具体值取决于 cmd)。

    • 失败:返回 -1,并设置 errno

  • F_GETLK:检查是否存在锁,并返回锁的状态。

  • F_SETLK:设置或释放锁,如果锁冲突则返回错误。

  • F_SETLKW:设置或释放锁,如果锁冲突则阻塞等待。

  • 锁结构:使用 struct flock 来描述锁:

struct flock {
    short l_type;    // 锁类型:F_RDLCK(读锁)、F_WRLCK(写锁)、F_UNLCK(解锁)
    short l_whence;  // 起始位置的基准:SEEK_SET、SEEK_CUR、SEEK_END
    off_t l_start;   // 锁的起始位置
    off_t l_len;     // 锁的长度,0 表示从 l_start 到文件末尾
    pid_t l_pid;     // 锁的持有进程 ID(仅在 F_GETLK 时有效)
};

示例:

struct flock lock;
lock.l_type = F_WRLCK; // 设置为写锁
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 100;

int ret = fcntl(fd, F_SETLK, &lock);
ERROR_CHECK(ret, -1, "fcntl_SETLK");

从OS 底层分析 mmap 与 read/write 之间的效率

1. 数据拷贝次数

  • read/write

    • readwrite 系统调用需要在内核空间和用户空间之间进行数据拷贝。具体来说:

      • 读操作:数据从磁盘读取到内核的页缓存(page cache),然后从页缓存拷贝到用户空间的缓冲区。

      • 写操作:数据从用户空间的缓冲区拷贝到内核的页缓存,然后异步写入磁盘。

    • 这种方式涉及两次数据拷贝,增加了系统调用的开销。

  • mmap

    • mmap 将文件的某一部分直接映射到进程的地址空间,允许进程直接访问内核的页缓存。

    • 数据不需要在内核空间和用户空间之间拷贝,减少了数据传输的开销。

    • 写操作时,数据直接写入映射的内存区域,内核会负责将修改的页面(dirty pages)异步写入磁盘。

2. 系统调用开销

  • read/write

    • 每次读写操作都需要执行系统调用,从用户态切换到内核态,完成后再切换回用户态。

    • 多次系统调用会增加上下文切换的开销。

  • mmap

    • 只需要一次系统调用(mmap)来建立内存映射。

    • 一旦映射完成,后续的读写操作直接在用户空间完成,无需再次进行系统调用。

3. 内存管理与页错误

  • read/write

    • 使用内核的页缓存机制,数据存储在内核空间的缓冲区中。

    • 不涉及复杂的内存映射和页错误处理。

  • mmap

    • 使用内核的页缓存,但通过内存映射的方式直接暴露给用户空间。

    • 当访问未映射的页面时,会触发页错误(page fault),内核会动态加载缺失的页面。

    • 这种按需加载的方式可以节省内存,但如果频繁触发页错误,可能会增加开销。

4. 适用场景

  • read/write

    • 适用于小数据量的文件操作,因为系统调用和数据拷贝的开销相对较小。

    • 对于频繁的小块读写操作,效率较高。

  • mmap

    • 适用于大文件的随机访问,尤其是需要频繁读写同一文件区域的场景。

    • 在处理大文件时,可以显著减少数据拷贝和系统调用的开销。

    • 但需要注意,mmap 的初始化开销较大,对于小数据量或不频繁的文件访问,可能不如 read/write 高效。

    • read/write 在顺序读写的时候性能更好,而 mmap 在随机访问的时候性能更好。

文件流和文件描述符之间的关系

fopen 函数实际在运行的过程中也获取了文件的文件描述符。使用 fileno 函数可以得到文件流的文件描述符。在使用 fopen 打开文件流之后,依然是可以使用文件描述符来执行IO的。

示例:

int main(int argc, char *argv[])
{
    // ./fileno file1
    ARGS_CHECK(argc,2);
    FILE * fp = fopen(argv[1],"w+");
    ERROR_CHECK(fp,NULL,"fopen");
    printf("%d\n", fileno(fp));   //可以查看对应的文件描述符
    write(fileno(fp),"hello",5);
    fclose(fp);
    return 0;
}

fopen 的原理: fopen 函数在执行的时候,会先调用 open 函数,打开文件并且获取文件对象的信息(通过文件描述符可以获取文件对象的具体信息),然后 fopen 函数会在用户态空间申请一片空间作为缓冲区;
fopen 的优势:因为 read 和 write 是系统调用,需要频繁地切换用户态和内核态,所以比较耗时。借助用户态缓冲区,可以减少 read 和 write 的次数。

从另一方面来说,如果需要高效地使用不带缓冲IO,为了和存储体系匹配,最好是一次读取/写入一个块大小的数据。如果获取了文件指针,就不要通过文件描述符的方式来关闭文件

本文章已经生成可运行项目
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值