Linux系统编程系列之文件fd


前言

本次主要讲解的就是文件fd--文件描述符,会带着重定向和缓冲区的知识。


一、预备知识

1.文件 = 内容 + 属性,对文件操作不是修改内容就是修改属性!

2.文件分为打开的文件和没有打开的文件

3.打开的文件:由进程来打开,本质是研究进程和文件的关系!

4.没打开的文件,在哪里放着呢?在磁盘上,没有被打开的文件非常多,

文件如何被分门别类的放置好--我们要进行快速的增删查改---快速

找到文件

文件被打开,必须先被加载到内存!

OS如何管理被打开的大量文件呢?先描述再组织,在内核中,一个被打开

的文件都必须有自己的文件打开对象,包含文件的很多属性

struct file {} ---这个在后面还会提到。

Linux中一切皆文件,如何理解?

不是说所有东西在物理上都是文件,磁盘、显示器、网卡等是硬件不是文件,但是所有的资源都通过文件接口和文件描述符来操作!底层通过内核的VFS(虚拟文件系统)来屏蔽差异,把这些资源抽象成了文件的概念。底层也是统一的,比如

统一的标识:所有资源都通过 inode唯一标识,一个文件一个inode。

统一的接口:对所有资源的操作,都通过 open()/read()/write()/close() 等标准系统调用,不用为不同资源学习不同接口,比如打开目录的接口底层也一定封装了open ! 那我们C语言学的fopen等也是封装了open吗? 对!

统一的管理:进程通过文件描述符fd,如 0 = 标准输入、1 = 标准输出、2 = 标准错误来引用资源,内核用文件描述符表管理进程打开的所有文件。

这里先简单理解即可,随着后面的学习这种理解会越来越深刻,VFS后面讲。

二、C语言中的文件操作

三个标准输入输出流

我们打开一个文件需要fopen,关闭需要close,那我们printf向显示器上打印,怎么没有打开显示器文件呢??显示器也是文件 ---一切皆文件! C程序在启动的时候,默认会打开三个标准输入输出流!stdin,stdout,stderr

FILE*是C库自己封装的结构体,关于它后面还会再谈,一定会和后面操作系统的管理能够相吻合!

这个东西C++里面也有---cin,cout,cerr,Java中有System.in,System.out,

System.err, 所以这是操作系统的特性,进程会默认打开键盘,显示器,显示器。

读写文件

文件其实是在键盘上的,键盘是外部设备,访问文件其实是访问硬件!

几乎所有的库只要是访问硬件设备,必定要封装系统调用,之前提到的那一套体系,用户 库函数/lib/指令 系统调用接口 操作系统 驱动 硬件,C语言中的读写文件接口也一定封装了系统调用接口。后面再提open/read/write/close

写操作

cpp 复制代码
#include<bits/stdc++.h>
#include<unistd.h>
#include<sys/types.h>
#include<cstdio>
using namespace std;


int main()
{
  FILE* fp = fopen("t.txt","w");
  if(fp == nullptr) {
    perror("open fail");
    exit(2);
  }
  const char*p1 = "hello fwrite!\n";
  fwrite(p1,strlen(p1),1,fp);

  const char*p2 = "hello fprintf!\n";
  fprintf(fp,"%s",p2);

  const char*p3 = "hello fputs!\n";
  fputs(p3,fp);

  fclose(fp);

  return 0;
}

"w"是覆盖写,每次都会重新覆盖内容,"a"是追加写,每次都会在文件末尾接着写。--在open中都可以体现。

strlen(p1)中不需要加一,字符串末尾以'\0'结尾是C语言的规定,文件中不需要。

fwrite 、fprintf 、fputs底层都一定做了相同的封装!

读操作

用fgets一行一行读取,读到空为止即可。也可以用fread直接全部读出来,fscanf需要控制格式,当然这三种都可以

cpp 复制代码
int main()
{
  FILE* fp = fopen("t.txt","r");
  if(fp == nullptr) {
    perror("open fail");
    exit(2);
  }
  char buff[1024];
  bzero(buff,sizeof(buff));
  // fread(buff,sizeof(buff),1,fp);
  // cout << buff << endl;
  char line[64];
  while(fgets(line,sizeof(line),fp) != nullptr) {
    strcat(buff,line);
  }
  cout << buff;
  fclose(fp);

  return 0;
}

这些接口需要的文件流类型都是FILE*,三个标准输入输出流也是FILE*,用它们会有什么结果呢?也比较好理解,比如fprintf()如果向stdout里打印,就会打印在显示屏上,fscanf从文件中获取,从stdin里面获取就是等待键盘输入。

理解当前路径

FILE* fp = fopen("t.txt","w");这个打开文件没有加路径,是在当前路径创建的文件,怎么理解当前路径呢??这里复习一遍,启动进程,打开/proc/pid看一眼.

比如我在用户目录下启动1_2linux下面的这个可执行。

cwd是进程运行时用户所处的路径,exe是可执行所在的路径,所以这里的当前路径指的是进程运行时用户所在的路径(cwd),不是可执行在的路径!

三、Linux中的文件接口

如果C的接口用的很明白的话,这些接口看几眼手册就能上手用了。

open和close

上面提到的fopen就是封装了open,来看open的三个参数。

pathname: 要打开文件的路径,不带路径默认当前路径,也可以带绝对或者相对路径。

flags:

O_RDONLY: 只读打开 ---对应fopen中的r

O_WRONLY: 只写打开---对应fopen中的w

O_RDWR : 可读可写打开。

这三个常量,必须指定一个且只能指定一个

O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限,配合第三个参数使用--文件权限问题。

O_APPEND: 追加写

O_TRUNC: 覆盖写

truncate也是mysql和linux下的命令,在Linux下作用就是修改文件大小到指定,可以截断或者加长。

一次是可以带多个选型的! 比如O_WRONLY | O_APPEND | O_CREAT,就是没有就创建文件,只能写,追加写。实现也比较好理解,这三个本质上是宏,其实就是具体的数字,0x0001,0x0010这种,或起来就能执行对应的选项了。

第三个参数:mode,上面说了含义,配合创建文件使用。

返回值:成功打开返回文件描述符fd,失败返回-1,错误码被设置.

close:

比较好理解,关闭文件描述符。和fclose底层就是封装了close,这个翻译比较有内容。第二段话比较有内容:

若 fd 是指向底层 "打开文件描述"(参见 open(2))的最后一个文件描述符,则与该打开文件描述关联的资源会被释放;若该文件描述符是已通过 unlink(2) 标记移除的文件的最后一个引用,则该文件会被彻底删除。

这两句话很有意义,后面还会再提。

read和write


这俩接口参数和返回值都相同,第一个参数是文件描述符,第二个参数就是要读/写的缓冲区,第三个参数是要写入的长度。返回值是ssize_t 本质就是size_t表示成功写入/读了多少。

cpp 复制代码
int main()
{
  int fd = open("t.txt",O_CREAT | O_TRUNC | O_WRONLY);
  if(fd < 0)
  {
    perror("open fail");
    exit(errno);
  }
  const char* p = "hello world!\n";
  write(fd,p,strlen(p));
  write(fd,p,strlen(p));
  write(fd,p,strlen(p));

  close(fd);
  return 0;
}

我们可以看到确实是覆盖写,返回值方面这里想判断就判断,就是个简单的实验代码。read不演示了,同理。

四、fd的认识和理解

概念

上面多次提到fd,fd到底是啥,为啥拿着这个数字就可以进行wrtie等操作。

多创建几个文件看看

fd从3开始逐渐递增。0、1、2去哪了呢? 标准输入、标准输出、标准错误!

怎么验证?read、write第一个下标传的是fd!我如果传的是0、1会有对应的效果!比如write往1号里面写就会写到显示器上!

下面正式讲一下这个fd

前面提到,操作系统一定要管理打开的文件,先描述再组织!task_struct中有一个指针指向files_struct结构体,里面有一个struct file* fd[],这是个指针数组,每一个存放一个指针,指针指向特定的文件,这个数组的下标就是文件描述符了!

strucf file里面包含了很多属性:磁盘的什么位置,基本属性,权限,大小,读写位置,文件的内核缓冲区信息,struct file* next指针...

具体一点就是:

open打开了一个文件,从这个角度简单来说就是创建了一个struct file对象,找到fd_array中最小空闲下标放入,增加该struct file的引用计数,链入到struct file* next中.至于引用计数是啥后面再谈。

这里我们也能看出文件描述符的分配规则:

从0下标开始,寻找最小的没有被使用的数组位置,他的下标就是新文件

的文件描述符. 所以0,1,2也可以使用,先close()就可以!

FILE 和 fd

FILE具体是什么不重要,但是它里面一定含有fd!!

无论是谁都要经过系统调用!FILE结构体里面有一个_fileno就代表着fd!

四、重定向

回忆一下重定向

什么是重定向?比如echo hello world > t.txt本来应该打印到显示屏文件上的hello world但是写入了t.txt中,这就是重定向!

又包含输入重定向: cat < t.txt

输出重定向:echo hello > t.txt

追加重定向: echo hello >> t.txt

别忘了命令也是编译好的可执行程序!

输出重定向

有了前面还是比较好理解的了,比如我们echo hello > t.txt,就是先关掉标准输出,close(1),然后open t.txt这个文件获取到fd,这个fd就会分配1了,那我们原来对显示屏文件的操作都变成了对t.txt的操作!因为无论是stdout还是cout,对于操作系统来说只用fd统一处理!

简单一份代码演示一下

cpp 复制代码
int main()
{
 close(1);
 int fd = open("t.txt",O_TRUNC | O_RDWR | O_CREAT,0666);
 cout << "fd:" << fd << endl;
 const char* str = "hello fd!\n";
 write(1,str,strlen(str));
 fputs(str,stdout);
 fprintf(stdout,str);
 fflush(stdout);
 close(fd);
  return 0;
}

结果也是跟想的一样,这里需要刷新一下缓冲区,涉及到行缓冲和全缓冲的问题,后面再说。

追加重定向

有了输出重定向追加重定向就非常好理解了。就是把O_TRUNC换成O_APPEND,即可,代码不演示了。

输入重定向

理解了前两个这个也是比较好理解的,就是close(0) -> open -> read

cpp 复制代码
int main()
{
 close(0);
 int fd = open("t.txt",O_RDONLY);
 char buff[1024];
 cin >> buff;
 cout << buff << endl;
 close(fd);
  return 0;
}

dup2

先close再打开肯定很麻烦了,操作系统提供了重定向接口

dup2(int oldfd,int newfd),注意看描述,newfd be the copy of oldfd,所以注意好是谁是谁的拷贝,比如要把1重定向到fd,应该是dup2(fd,1),

dup2实际上就是:两个 fd 指向同一个 file 结构体,但是下面几种情况特别:

若 newfd 已打开,会关闭 newfd,再完成绑定;

若 oldfd 无效(未打开),则 dup2 失败,newfd 不会被关闭;

若 oldfd == newfd,则 dup2 直接返回 newfd。

简单演示一下。

cpp 复制代码
int main()
{
  int fd = open("t.txt",O_CREAT | O_TRUNC | O_WRONLY,0666);
  //判断是否打开成功

  dup2(fd,1);
  cout << "hello dup2!\n";
  close(fd);
  return 0;
}

stdout stderr

这里谈一下重定向方面的一些差异

cpp 复制代码
int main()
{
  fprintf(stdout,"fprintf stdout!\n");
  fprintf(stdout,"fprintf stdout!\n");
  fprintf(stdout,"fprintf stdout!\n");
  fprintf(stdout,"fprintf stdout!\n");

  fprintf(stderr,"fprintf stderr!\n");
  fprintf(stderr,"fprintf stderr!\n");
  fprintf(stderr,"fprintf stderr!\n");
  fprintf(stderr,"fprintf stderr!\n");

  return 0;
}

重定向到log.txt只有stdout重定向了,stderr还是输出到了显示屏幕上,因为重定向的是1号下标不是2号!

可以这样重定向 ./main 1>log.txt 2>err.log 输出到两个文件!

就想输出到一个文件呢?

./a.out 1>all.log 2>&1

可以省略1,默认就是stdout,./a.out >all.log 2>&1

五、缓冲区

四个现象

前面一直在提到缓冲区的问题,这里正式谈一谈缓冲区,先来看一份代码。

cpp 复制代码
int main()
{
  const char* str1 = "hello write!\n";
  const char* str2 = "hello fwrite!\n";
  printf("hello printf\n");
  fprintf(stdout,"hello fprintf\n");
  fwrite(str2,strlen(str2),1,stdout);
  write(1,str1,strlen(str1));

  
  return 0;
}

打印到屏幕和重定向到文件竟然不是一个顺序!这是第一个现象

另外我没有close(1),如果close(1):

这是第二个现象,发现重定向到t.txt中只有write写进去了!如果我fork,但是不close(1)呢。

发现t.txt中除了write其他都被写了两次!这是第三个现象,如果我把所有的换行都去掉,再执行上面的代码呢?

此时重不重定向的结果一样!并且write是一个,只写了一次,其他都写了两次,这是第四个现象。

下面就结合这四个现象来讲解缓冲区

理解

先谈谈缓冲区刷新问题---结合之前写的进度条代码,写进度条的时候我们知道C语言给我们提供了一个缓冲区,并且是遇到'\n'刷新缓冲区。

缓冲区刷新策略一共有三种:

1.无缓冲-----直接刷新(一般标准错误默认无缓冲)

2.行缓冲----不刷新,遇到'\n'才刷新--往显示器中写的策略

3.全缓冲----只有缓冲区满了才刷新----普通文件的写入

另外,进程结束的时候也会刷新缓冲区。

再来谈用户级缓冲区和内核级缓冲区,exit和_exit中我们提到C语言提供的缓冲区一定不在内核中,因为_exit打印不出来。
C语言为我们提供了一个用户级缓冲区,同时OS内还有内核级缓冲区,printf、fwrite、fprintf是先把内容拷贝到用户级缓冲区中!满足了刷新条件再调用对应的write() 将数据拷贝到内核级的缓冲区上 ,我们现在暂时忽略数据从内核级缓冲区到硬件的过程,直接就认为到了内核级缓冲区就到了硬件。
用户刷新的本质就是调用write()将数据写到了内核中!

通过这个我们就能理解前两个现象了,

1.为什么输出顺序不同?? 往显示器中打印的时候,采用行缓冲策略,我们要打印的每一个字符串都有换行!每次调用一个fprintf/printf/fwrite直接满足了刷新条件,刷新到了内核中显示出来了,所以顺序和执行流的顺序一样。

但是如果重定向到普通文件,此时刷新策略变成全缓冲策略,每次调用一个fprintf/printf/fwrite都不满足刷新条件,无法写入内核,而write直接向内核中写,所以文件中第一行对应的是write,当进程结束时,用户级缓冲区也会刷新,所以后三句也能看得到。

2.close(1)之后,往显示器中打印没有变化,这个很好理解,和上面一样的原因,往普通文件里写为什么只剩了个write呢?因为close(1)之后,在用户级缓冲区中调用write(1)就失效了!1号都被关了,怎么可能往内核中写呢?所以只有write留下来了。

fork又是什么情况呢?我们知道fork之后子进程的数据和父进程采用写时拷贝,子进程和父进程指向同一块用户级缓冲区。
当父进程 / 子进程首次尝试刷新缓冲(调用 write 等等),会触发对共享内存页的写时拷贝------ 内核为写入方复制一份新的物理内存页,父子进程的用户缓冲从此分离,两个进程都有了一份数据!
父子各自刷新,写入内核:
父子进程各自将自己的用户级缓冲数据(hello printf hello fprintf hello fwrite! )调用write写入内核,最终输出两份;而 fork 前write的hello write! 已经在内核缓冲区,只会输出一份。

这就完美解释了第三种现象的原因!

第四种现象也好解释了,因为此时没有换行,无论写入到普通文件还是显示器都不会刷新,进程结束了才会刷新,父子进程各写入内核一份,出现了两次。

C语言中提供的缓冲区其实在FILE结构体中,不是说内存在这里,内存当然在用户态地址空间中,而是FILE结构体通过指针指向了这块缓冲,并且管理了这块缓冲。

glibc:

cpp 复制代码
typedef struct _IO_FILE {
    char* _IO_buf_base;  // 指向用户级缓冲区的起始地址
    char* _IO_buf_end;   // 缓冲区的结束地址
    char* _IO_ptr;       // 当前写入/读取的位置指针
    int _IO_buf_mode;    // 缓冲类型
    int _flags;          // 包含缓冲相关标记
    int _fileno;         // 绑定的文件描述符
    //.....
} FILE;

内核中处理不一样,是通过inode来搞的,后面再提。


相关推荐
冉佳驹4 小时前
Linux ——— 文件操作与缓冲机制的核心原理
linux·重定向·用户级缓冲区·open的返回值·进程中的当前路径
牛奶咖啡135 小时前
Linux的ext4文件系统元数据故障恢复实践教程
linux·服务器·机械硬盘的结构·ext4文件系统的构成·ext4超级块故障的修复·ext4块组描述故障修复·ext4块组的构成
hhzz5 小时前
Docker 搭建 NextCloud + OnlyOffice 完整教程(Linux Centos7系统)
linux·docker·容器·onlyoffice·nextcloud
.普通人5 小时前
树莓派4Linux 可操作多个gpio口驱动编写
linux
01传说5 小时前
Linux-yum源切换阿里centos7 实战好用
linux·运维·服务器
颜子鱼5 小时前
Linux字符设备驱动
linux·c语言·驱动开发
是娇娇公主~5 小时前
Redis 悲观锁与乐观锁
linux·redis·面试
晚风_END6 小时前
Linux|服务器运维|diff和vimdiff命令详解
linux·运维·服务器·开发语言·网络
HIT_Weston6 小时前
83、【Ubuntu】【Hugo】搭建私人博客:文章目录(二)
linux·运维·ubuntu