1. 知识铺垫

  1. 文件=内容+属性。

文件属性、文件内容都是二进制数据,都需要占据磁盘的存储空间。

对文件的所有操作,本质上是要么对文件的内容做操作,要么对文件的属性做操作。

  1. 打开或修改文件,都是通过执行代码(如:文本编辑器)的方式来完成的。

文件打开、编辑、保存的流程:软件启动 -> 文件加载 -> 读取文件内容 -> 用户交互 -> 文件修改 -> 保存修改。

  1. 需要先打开文件,才能访问它 —> 将文件加载到内存中。

代码本身不能直接修改磁盘上的数据,CPU执行代码来修改这个文件时,实际上是在内存中进行的,因为CPU只能访问内存,所以磁盘的数据需要通过OS提供的文件系统调用接口和磁盘IO操作,来加载到内存中,然后CPU才能对这些数据进行处理。

  1. 谁在打开文件?进程。

打开文件之前,需要把访问这个文件的程序,编译形成的可执行程序,通过某种方式启动变成进程,等待cpu调度,执行完fopen后,文件才会打开。

  1. 一个进程可以打开多个文件吗?可以。

  2. 进程与文件的关系:结构体struct task_struct和结构体struct file之间的关系。

在一段时间内,系统存在多个进程,也可能同时存在多个被打开的文件,OS需要管理多个被打开的文件(先描述,再组织),所以内核中一定有描述被打开文件的结构体,并用其定义对象。

  1. 系统中是不是所有的文件都被打开了?不是,未打开的文件存放在磁盘中,被称为磁盘文件;打开的文件,存放在内存中,被称为内存文件。

2. C文件I/O

2.1. C文件接口

fopen、fclose

fread、fwrite; fgetc、fputc;fgets、fputs; fscanf、fprintf

fseek、ftell、rewind

feof、ferror

有关C文件接口,请看C语言博客中的文件内容,都有详细的介绍。

2.2 fopen()与重定向

  1. fopen以"w"方式打开:如果文件不存在,先会创建一个文件 / 如果文件存在,先会清空文件内容,然后再从头进行写入操作。
  • 相当于输出重定向(>)。
//文件以写的方式打开,如果文件不存在,新建文件 / 如果文件存在,先会清空文件内容,然后再从头进行写入操作
FILE *fp = fopen("example.txt", "w"); 
if (fp == NULL) {  //打开失败
    perror("Failed to open file"); 
    return 1;
}

fprintf(fp, "New content\n"); //向文件中进行写入
fclose(fp)  //关闭文件


2. fopen以"a"方式打开:本质也是写入,如果文件不存在,先会创建一个文件,然后进行写入 / 如果文件存在,会在文件原有内容的末尾处追加写入。

  • 相当于追加重定向(>>)。
//文件以追加的方式打开,如果文件不存在,先会创建一个文件,然后进行写入 / 如果文件存在,会在文件原有内容的末尾处追加写入
FILE *fp = fopen("example.txt", "a");
if (fp == NULL) {
    perror("Failed to open file");
    return 1;
}

fprintf(fp, "Additional content\n");
fclose(fp);


3. fopen以"r"方式打开:读取文件的内容,如果文件不存在,则读取失败,返回NULL 。

  • 相当于输入重定向(<)。
//以读的方式打开文件,读取文件的内容,如果文件不存在,则读取失败,返回NULL 
FILE *fp = fopen("example.txt", "r");
if (fp == NULL) {
    perror("Failed to open file");
    return 1;
}

char buffer[1024];
if(fgets(buffer, sizeof(buffer), fp) != NULL) //文件存在
    printf("%s", buffer);

2.3. 当前路径

  1. 当一个进程在启动时,它的当前工作目录(当前路径),通常是启动该进程时所在的路径。这个路径通常由父进程(Shell)决定,它会被保存到/proc/pid/cwd中。

fopen(“data.txt”,“w”),如果data.txt文件不存在,就会在当前路径下创建此文件。

int chdir(const char* path);

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
 
int main()
{ 
    printf("self id:%d\n", getpid());
    printf("更改前:当前工作目录\n");
     sleep(25);
 
     chdir("/root/tmp");
 
     printf("更改后:当前工作目录\n");
     sleep(25);                                                     

     FILE* fp = fopen("110.txt","w"); 
    //此处表示,如果不存在110.txt文件,就会在当前路径(cwd)下,创建此文件
     if(fp == NULL) return 1;
     fclose(fp);
     printf("文件创建成功\n");

     return 0;
}

2.4. stdin、stdout、stderr

一、标准输入流stdin

  1. 定义:标准输入是程序可以从中读取输入数据的位置,它默认指向键盘,但也可以被重定向为文件或者其他输入设备。

  2. 作用:允许用户通过键盘或者其他输入设备向用户提供数据,也可以从文件中读取数据。

  3. 文件描述符:在linux系统中,stdin文件描述符为0。

二、标准输出流stdout

  1. 定义:标准输出是程序用于发送其输出数据的位置,它默认指向终端屏幕,但也可以被重定向为文件或者其他输出设备。

  2. 作用:stdout用于显示程序的正常输出,包括结果、状态信息、其他非错误信息。

  3. 文件描述符:在linux系统中,stdout文件描述符为1。

  4. 缓冲:stdout通常是行缓冲的,意味着输出会先存储在缓冲区中,直到遇到换行符或者缓冲区满才会刷新到目的地。

三、标准错误输出流stderr

  1. 定义:标准错误是程序用于发送错误、异常信息的位置,它默认指向终端屏幕,但也可以被重定向为文件或者其他输出设备。

  2. 作用:用于输出错误信息,以便用户能够识别并解决问题。

  3. 文件描述符:在linux系统中,stderr文件描述符为2。

  4. 缓冲:stderr是非缓冲的,意味着错误信息会被立即发送到目的地,以便用户能够尽快的看到它。

💡注意:程序在启动时,默认会打开stdin、stdout、stderr流。即:可以直接使用它们,无需手动打开。

如:printf是向标准输出流stdout发送格式化数据,因为stdout在启动时已经被打开了,并关联到了输出设备(显示器),所以printf可以直接使用它们,而无需进行额外的打开操作。

问:为什么程序在启动时,默认会打开stdin、stdout、stderr流?

  • 原因:大部分的程序员,无论你用什么语言进行编程,需求就是让你的计算机去帮你加工和处理你的数据,就需要处理这三个问题(用户的数据从哪里来,计算完毕后怎么让用户看到对应的计算结果,计算过程中出错了怎么办),所以我们就需要stdin,给用户提供一个输入方式把数据输入进来,需要stdout,向一个特定的显示设备进行打印,方便给用户把计算结果显示出来,需要stderr,帮助用户把错误信息打印到特定设备中。大部分程序默认情况都要项你的程序输入数据,由你自己的程序计算完毕后,把结果进行返回,如果语言不提供,就需要程序员自己打开,太麻烦,所以程序默认给你打开这三个流(不用声明和定义,程序在启动时,就已经帮你初始化列表,直接使用就可以了)。

3. 系统文件I/O

3.1. 前言

一、访问文件不仅有C语言上的文件接口,OS必须提供对应的访问文件的系统调用接口。即:C标准库中的文件IO接口,底层一定封装了系统调用接口。

问:为什么C语言的文件接口,底层要封装对应的系统调用接口?

  1. 简化了操作,增强了安全性和稳定性。

文件进行读写操作,需要访问硬件,而OS作为软硬件资源的管理者,所以用户不能越过OS来直接访问硬件,OS会提供一组访问文件的系统调用接口 。

  1. 保证了语言的跨平台性、可移植性。

不同的操作系统有不同的系统调用接口和文件的操作机制。如果直接使用系统调用接口进行文件操作,那么编写的代码很难在不同OS之间移植。C语言标准库通过封装系统调用,为开发者提供了一套统一的文件操作接口,这样开发者就能够使用相同的代码,在不同OS上进行文件操作,提高了代码的跨平台性。

💡Tisp:如果让语言直接使用原生的系统接口,就注定了这个语言不具备跨平台性、可移植性。

二、在语言上,C、C++在访问文件时的接口和方式是不同的,在OS看来,底层原理是一样的,都是对底层系统调用接口的封装。

在C++中,主要是通过istream库中的fstream(ifstream、ofstream)类来实现,成员函数封装了对这些资源的操作,包括调用系统调用接口来实现底层的读写操作。

所有语言的文件操作不一样,但底层原理都是一样的,不随着意志而转移。上层就只要熟悉用法。

3.2. open

int open(const char* pathname,int flags);

  1. 功能:打开或创建一个文件。

  2. 参数pathname:要打开或者创建文件的路径名。

如果pathname为绝对路径时,当需要创建一个文件,会在pathname路径下创建它;

如果pathname为文件名时,当需要创建一个文件,会在当前路径(进程启动时所在的路径)下创建它。

  1. 返回值:打开成功,返回非负整数,即:文件描述符(用于后续文件操作);如果失败,返回-1,并设置errno以指示错误的原因。

fopen:"r"-> open:O_RDONLY
fopen:"w"-> open:O_WRONLY|O_CREAT|O_TRUNC
fopen:"a"-> open:O_WRONLY|O_CREAT|O_APPEND
  • 底层调用Open,传递不同的参数,在上层表现为fopen以r、w、a方式打开文件。即:不同的fopen风格,代表着open传递了不同的选项。

3.2.1. flags

  1. 参数flag:标记位,用于指定文件的打开模式(只读、只写、追加等)和其他选项(创建文件、截断文件等)。
//宏
#define O_RDONLY    0x00000000  // 只读模式  16进制
#define O_WRONLY    0x00000001  // 只写模式
#define O_RDWR      0x00000002  // 读写模式
//以上三个常量,必须且只能指定一个
#define O_CREAT     0x00000100  // 如果文件不存在,则创建文件
#define O_TRUNC     0x00000200  // 如果文件存在,则截断为零长度
#define O_APPEND    0x00000400  // 每次写入时追加到文件末尾
#define O_EXCL      0x00000800  // 如果文件已存在,则打开失败
#define O_NONBLOCK  0x00001000  // 非阻塞模式 
  1. flag参数是一个整数,每个比特位代表一个标记位。通过位操作(|、&),可以一次性向函数传递多个标记位。

在编程中,涉及需要向函数传递多个布尔选项(标记位)时,使用单个整数(int,32位),并通过位操作来设置和检查这些选项,这种方法被称为"位图",是一种非常高效和节省资源的方法。

  1. 使用宏定义,来表示各种标记位,每个宏定义只有一位为1(每个宏中为1的位是错开的),其余位全为0。在这个整数中为1的位,用来表示某个特定的选项是否被设置。多个宏通过位操作(|按位或)组合,一次性地向函数传递多个标记位。即:通过位图的方式,传递多个标记位。
#include<stdio.h> 

#define ONE 1
#define TWO (1 << 1)
#define THREE (1 << 2)
#define FOUR (1 << 3)
  
void print(int flag)
{
    if(flag & ONE) printf("1\n");
    if(flag & TWO) printf("2\n");
    if(flag & THREE) printf("3\n");
    if(flag & FOUR) printf("4\n");
}
 
 int main()
 {
     print(ONE);
     printf("-----------\n");
  
     print(ONE|TWO);
     printf("-----------\n");
  
     print(ONE|TWO|THREE);  //相当于是将两个宏中为1的位,进行了合并
     printf("-----------\n");

     print(ONE|TWO|THREE|FOUR);                                                      
     printf("-----------\n");
  
    return 0;
 }

3.2.2. mode

  1. 参数mode:可选参数,用于创建文件时设置文件权限。
#include<stdio.h>
#include<sys/types.h>
#include<fcntl.h>
   
int main()
{
    //以"w"方式打开文件,文件存在,就在当前路径下新建文件
    int fd = open("data.txt", O_WRONLY|O_CREAT|O_TRUNC);
    if(fd == -1)  //打开失败
    {
        perror("open");
        return 1;
    }
  
    return 0;                                              
}


现象:新建文件的权限乱码了。 -> 原因:新建文件,未设置文件权限。

#include<stdio.h>
#include<sys/types.h>
#include<fcntl.h>
   
int main()
{
    //以"w"方式打开文件,文件存在,就在当前路径下新建文件
    int fd = open("data.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    //int fd = open("data.txt", O_WRONLY|O_CREAT|O_TRUNC);
    
    if(fd == -1)  //打开失败
    {
        perror("open");
        return 1;
    }
  
    return 0;                                              
}

现象:文件权限并不是666。


现象:文件权限不为666。

原因:默认(最终)权限计算公式 = 起始权限 & (~umask值) , 本质是从起始权限中去掉在umask权限中出现的权限,如果在起始权限中某权限位不存在,但umask中该权限位存在,该权限位的结果为0,"去掉"不是删除。

mode_t umask(mode_t mask);

  • 功能:设置文件的权限掩码。

umask函数只会影响调用它的进程所创建的权限掩码,而不会对父进程或其他进程的权限掩码产生影响。

#include <sys/stat.h>
#include<stdio.h>
#include<sys/types.h>
#include<fcntl.h>
   
int main()
{
    umask(0);  //只改变此进程所创建的权限掩码
    
    //以"w"方式打开文件,文件存在,就在当前路径下新建文件
    int fd = open("data.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    //int fd = open("data.txt", O_WRONLY|O_CREAT|O_TRUNC);
    
    if(fd == -1)  //打开失败
    {
        perror("open");
        return 1;
    }
  
    return 0;                                              
}

3.2.3. 返回值fd

一、FILE*与fd的关系

在底层上,fd是文件描述符,用于标识一个打开的文件,用于后续的文件操作。在语言上,FILE*是C语言标准I/O库定义的类型,用于表示文件流,用于后续的文件操作 —> 所以FILE结构体对象必定封装了fd。

  • FILE类型是C语言IO库定义的一个结构体类型,用于表示一个流,这个流可以关联到一个文件、内存区域或其他输出/输入资源。FILE结构体内部封装了与流操作相关的各种信息(如:文件描述符fd、文件读写位置、缓冲区、文件状态等)。

  • 流:抽象的概念,它提供了统一的方式来处理数据的输入、输出,无论这个数据是来自于文件、标准输入输出(键盘、显示器)或其他形式的输入输出资源。流的本质是数据传输。

#include<stdio.h>
#include<sys/types.h>
#include<fcntl.h> 
  
int main()
{
    int fd1 = open("data1.txt", O_WRONLY|O_CREAT|O_TRUNC);
    int fd2 = open("data2.txt", O_WRONLY|O_CREAT|O_TRUNC);
    int fd3 = open("data3.txt", O_WRONLY|O_CREAT|O_TRUNC);
  
    printf("fd1:%d\nfd2:%d\nfd3:%d\n", fd1, fd2, fd3);
    printf("\n");                                          

    //FILEl类型是由C标准库封装的结构体
    printf("stdin:%d\n", stdin->_fileno);
    printf("stdout:%d\n", stdout->_fileno);
    printf("stderr:%d\n", stderr->_fileno);
  
    return 0;
}


二、C语言文件接口,不仅在接口上进行了封装,在类型上也进行了封装。

  1. 接口层面的封装

定义:将文件操作的具体实现隐藏起来,只向用户暴露了一组预定义的函数,用户就可以通过这组函数间接与文件系统进行交互。这种方式使得用户不需要了解文件系统背后复杂的机制。如:磁盘读写。

如:C语言标准库中的fopen、fclose、fread、fwrite等高级函数,底层封装了对应的系统调用接口open、close、read、write。用户通过这些函数可以打开文件、关闭文件、读取文件内容、写入文件内容,而不需要关心这些操作是如何在底层实现的。

  1. 类型层面的封装

定义:为文件操作定义了一个或多个特定的数据类型(如:FILE类型),用于表示文件状态和上下文。

如:FILE结构体封装了文件描述符、文件读写位置、缓冲区、文件状态等其他相关信息。

3.3. write

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

  1. 功能:向打开的文件中写入数据。

  2. 参数:fd,表示写入数据的文件或设备; buf,指向要写入数据的缓冲区的指针; count,要写入的字节数。

如果buf为const char*类型,strlen(buff),不需要在后面+1,即:不需要把字符串结束标志\0写入进去。因为c语言字符串以\0结尾,\0不是字符串内容,而是作为字符串结束标记,与文件无关,若把\0写入,则会造成乱码。

  1. 返回值:如果成功,返回实际写入的字节数。如果出错,则返回-1,并设置errno以指示错误。

3.4. read

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

  1. 功能:从打开的文件中读取数据。

  2. 参数:fd,表示要读取数据的文件或设备; buf,指向读取数据的缓冲区的指针; count,要读取的最大字节数。

  3. 返回值:如果成功,返回实际读取的字节数。如果出错,则返回-1,并设置errno以指示错误。如果到达文件末尾(EOF),则返回0。

3.5. close

int close(int fd);

  1. 功能:关闭一个打开的文件描述符。

  2. 返回值:如果成功,返回0。如果失败,返回-1,并设置errno以指示错误。

3.6. lseek

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

  1. 功能:移动文件指针的位置。

  2. 参数offset:是从whence指定位置开始的偏移量,以字节为单位(在左边,则为负值、在右边,则为正值)。

参数whence:指定偏移量的参考点。

  1. 返回值:如果成功,返回新的文件偏移量(相对于文件开头的字节数)。如果出错,则返回-1,并设置errno以指示错误。
Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐