Linux一切皆文件

尝试去理解 “Linux 下一切皆文件”

1、先从文件的系统接口open以及它的flags谈起

我们在用系统调用open打开一个文件的时候会传几个flags参数,比如O_RDWR、O_RDONLY、O_WRONLY、O_CREAT、O_APPEND,这些都是对文件的操作模式。可以传入一个flags或者几个flags用"|“(或)的方式,比如O_WRONLY | O_CREAT,”|"(或)让我们想起了位的概念,按位或按位与等,这里引入了第一个概念:位图,下面会提到的。
1、系统下的文件操作
文件=文件内容+文件属性,文件属性也是数据,这也就意味着创建一个空的文件也会占用磁盘空间,所以文件的操作=文件内容的操作+文件属性的操作,在操作文件的过程中既改变文件的内容又改变文件的属性很正常,不要割裂开来。当你往文件写入内容时,你的最新的修改时间、以及文件的 Size 大小时间以及属性数据也可能同时变化!所以,一般而言:属性是随着内容的变化而 (可能) 变化的
2、打开文件
所谓的打开文件究竟是在做什么?访问文件的时候通过fread、fwrite、fgets这样的代码来完成对文件的操作,通过这些方式那么打开文件就需要将文件属性或内容加载到memory中,那也不是所有的文件都处于打开的状态,没有打开的文件还躺在磁盘上呢。对文件的理解在宏观上可以分为内存文件和磁盘文件,如果一个文件从来没有被打开过,那么他就应该是一个纯磁盘文件,通常我们打开文件、访问文件、关闭文件是谁在进行操作:fopen,fclose,fwrite,fread-》代码-》程序-》到我们的文件程序,程序运行起来的时候才会执行相应的代码,然后才是对文件进行的操作,实际上是进程在对文件进行操作,这里就把文件与进程联系起来了,学习文件操作实际上就是学习文件与进程的关系
3、回顾C语言对文件的写入操作

#include<stdio.h>

int main()
{
	FILE* pf=fopen("log.txt","w");
	if(pf==NULL)
	{
		perror("fopen");
		return 1;
	}
	const char* msg="linux文件!";
	int count=0;
	while(count<10)
	{
		fprintf(pf,"%s:%d\n",msg,count);
		count++;
	}
	fclose(pf);
}

运行结果如下:
在这里插入图片描述
默认这个log.txt文件会在当前路径下形成,什么是当前路径?如果对当前路径的理解,仅仅停留于 “当前路径就是源代码所在的路径” 是远远不够的!前面说文件的本质实际上是进程与打开文件之前的关系,因此操作文件和进程有关系,在代码中获取一下进程pid查看一下进程信息:

#include<stdio.h>
#include <unistd.h>


int main()
{
	FILE* pf=fopen("log.txt","w");
	if(pf==NULL)
	{
		perror("fopen");
		return 1;
	}

	printf("Mypid: %d\n", getpid());
	while(1)
	{
		sleep(1);
	}
	const char* msg="linux文件!";
	int count=0;
	while(count<10)
	{
		fprintf(pf,"%s:%d\n",msg,count);
		count++;
	}
	fclose(pf);
}

在这里插入图片描述
一直在while(1)循环里面,再打开一个终端通过$ls proc查看进程信息:
在这里插入图片描述
我们重点关注 exe和 cwd,exe后面链接指向的是可执行程序 file_open,即 路径 + 程序名
而cwd(current working directory),即当前工作路径,记录着当前进程所处的路径,每个进程都有一个工作路径,创建文件时,如果文件在当前目录下不存在,fopen 会默认在当前路径下自动创建文件。
默认创建在当前路径,和源代码、可执行程序在同一路径下,取决于cwd:
cwd->/home/nvidia/hello/file
可以用chdir接口来验证一下

#include<stdio.h>
#include <unistd.h>


int main()
{
	chdir("/home/nvidia/hello/file1");
	FILE* pf=fopen("log.txt","w");
	if(pf==NULL)
	{
		perror("fopen");
		return 1;
	}

	printf("Mypid: %d\n", getpid());
	while(1)
	{
		sleep(1);
	}
	const char* msg="linux文件!";
	int count=0;
	while(count<10)
	{
		fprintf(pf,"%s:%d\n",msg,count);
		count++;
	}
	fclose(pf);
}

此时log.txt应该不在刚才的路径下了,而在chdir(“/home/nvidia/hello/file1”);路径下了,
在这里插入图片描述
此时进程的工作路径变了,

当前路径更准确的说是在当前进程所处的工作路径,只不过默认情况下 (默认路径) ,一个进程的工作路径在它当前所处的路径而已,这是可以改的,所以我们在写文件操作代码时,不带路径默认是源代码所在的路径,注意是默认!而已!
4、文件操作模式(File Operation Mode)
由文件操作符参数来指定,常用的模式包括:
r:只读模式,打开一个已存在的文本文件,允许读取文件。

r+:读写模式,打开一个已存在的文本文件,允许读写文件。

w:只写模式,打开一个文本文件并清除其内容,如果文件不存在,则创建一个新文件。

w+:读写模式,打开一个文本文件并清除其内容,如果文件不存在,则创建一个新文件。

a:追加模式,打开一个文本文件并将数据追加到文件末尾,如果文件不存在,则创建一个新文件。

a+:读写模式,打开一个文本文件并将数据追加到文件末尾,如果文件不存在,则创建一个新文件。
这里重点讲一下a和a+
a对应的是append追加的意思,属于写入操作,不会覆盖源文件内容

#include <stdio.h>

int main()
{
	FILE* pf=fopen("log.txt","a");
	fprintf(pf,"hello!\n");
	fclose(pf);
	return 0;
}

第一次运行如下:
在这里插入图片描述
第二次运行如下:

在这里插入图片描述
在后面追加了,并不会删除之前写入的,不同于w,当我们以w方式打开时,其实文件已经被洗空了,
5、文件的读取
文本行输入函数 fgets,它可以从特定的文件流中,以行为单位读取特定的数据:

char* fgets(char* s, int size, FILE* stream);_

从指定的流stream读取一行,并将它存储在string所指的字符串中,当读取(n-1)个字符时或者读取到换行符、到达文件末尾时就会停止,

#include<stdio.h>


int main()
{
	FILE* pf=fopen("log.txt","r");
	if(pf==NULL)
	{
		perror("fopen");
		return 1;
	}
	char buffer[64];
	while(fgets(buffer,sizeof(buffer),pf)!=NULL)
	{
		printf("echo:%s",buffer);
	}
	fclose(pf);
}

运行结果如下:
在这里插入图片描述
6、文件系统接口
系统调用与封装
当我们向文件写入时,最终是不是向磁盘写入?是!磁盘是硬件吗?就是硬件
当我们像文件写入时,最后是向磁盘写入。磁盘是硬件,那么谁有资格向磁盘写入呢?只能是操作系统,所有上层访问文件的操作都必须贯穿操作系统,想要被上层调用,必须使用操作系统的相关系统调用,我们来回顾一下我们学习 C 语言的第一个函数接口:

printf("Hello, World!\n");

如何理解 printf?我们怎么从来没有见过这些系统调用接口呢?
显示器是硬件,我们 printf 的消息打印到了硬件上,是你自己调用的 printf 打印到硬件上的,
但并不是你的程序显示到了显示器上,因为显示器是硬件,它的管理者只能是操作系统,你不能绕过操作系统,而必须使用对应的接口来访问显示器,我们看到的 printf 一打,内容就出现在屏幕上,实际上在函数的内部,一定是 调用了系统调用接口 的,printf 函数内部一定封装了系统调用接口。任何语言都是这样,用到的接口都是语言提供给你的,所有语言提供的接口之所以看不到系统调用,是因为进行了封装,所以你看不到对应的底层的系统接口的差别。为什么要封装?原生系统接口,使用成本比较高。系统接口是 OS 提供的,就会带来一个问题:如果使用原生接口,你的代码只能在一个平台上跑,如果语言直接使用操作系统接口,那么它就不具备跨平台性,可是为什么采用封装就能解决?封装是如何解决跨平台问题的呢?很简单:穷举所有+条件编译,我们学习的接口,C 库提供的文件访问接口,系统调用。它们两具有上下层关系,C 底层一定会调用这些系统调用接口。解释:不同的语言,有不同的文件访问接口,系统调用:这就是我什么我们必须学习文件级别的系统接口!
7、文件打开open()
打开文件,在 C 语言上是 fopen,在系统层面上是 open,open 接口是我们要学习的系统接口中最重要的一个,没有之一!所以我们放到前面来讲

#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);

相较于c的fopen来说,这个接口很不友好,然而,更恐怖的还在后面,有一大坨繁冗而复杂的东西……我们看到,这个 open 接口一个是两参数的,一个是三参数的,这个我们放到后面解释
① open 接口的 pathname 参数表示要打开的文件名,和 C 语言的 fopen 一样,是要带路径的
② flags 参数是打开文件要传递的选项,即 系统传递标记位,我们下面会重点讲解。
③ mode 参数,就是 “文件操作模式” 了。但在这里:又臭又长
在linux下C语言文件不存在,就直接创建它,那么需不需要权限呢?当然是需要的,需要给文件设置初始权限,mode参数就是干这个活的。
open的函数返回值是int,而fopen的返回值是FILE*,
open 接口中的 flags 参数在 OS 底层接口函数中还是很常见的,所以我们要重点讲解

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

我们称 flags 为 标记位,并且它是个整数类型(C99 标准之前没有 bool 类型)
标记位实际上我们造就用过了,比如定义 flag 变量,设 flag=0,设 flag=1,传的都是单个的,但如果我想一次传递多个标志位呢?定义多个标记位?flag1, flag2, flag3,系统是通过位图来进行传递的,这里的 flags当成位图,就是一串整数00000000,如此一来标记的可能性就直接加倍了,可以不同的位表示,是否只读,是否只写,是否读写…… 等等等等
在这里插入图片描述
每个 宏标记一般只需要满足有一个比特位是 1,并且和其它宏对应的值不重叠 即可
让每一个宏对应不同的比特位,在内部就可以做不同的事情,为了大家能够更好的理解
open接口用法演示:

int open(const char* pathname, int flags);
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
	int fd = open("log.txt",O_WRONLY | O_CREAT);
	if(fd<0)
	{
		perror("open");
		return 1;
	}
	printf("fd:%d\n",fd);
	return 0;
}

① 这里我们选择取名为 fd,而不是我们 fopen 习惯用的 pf/fp,因为 fd 描述文件描述符,这也是我们后面章节要重点讲解的,所以这里取 fd 来接收 open 接口的返回值,也算是预热一下
② 只写是 O_WRONLY,如果没有对应文件就创建,创建时 O_CREAT,这里我们用 | 把二者相关联可以了。
③ open 的返回值是 int,如果返回 -1 则表示 error,所以如果 fd < 0 就说明打开失败了,我们礼貌性的 perror 一下(保持我们 fopen 的习惯,这是好习惯)。
④ 最后,文件打开成功,我们把 fd 的值顺便打印出来看看。
打印结果如下:
在这里插入图片描述
在这里插入图片描述
如果要创建这个文件,该文件是要受到Linux权限约束的,创建一个文件,需要告诉系统默认权限是什么,当打开一个曾经不存在的文件不能使用两个参数的open,也就是带mode_t mode的open,这里的mode代表的是创建文件的权限,

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

修改一下代码:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
	int fd = open("log.txt",O_WRONLY | O_CREAT,0666);
	if(fd<0)
	{
		perror("open");
		return 1;
	}
	printf("fd:%d\n",fd);
	return 0;
}

在这里插入图片描述
权限正常多了,但是现在的权限是644,而不是664,这是为什么呢?因为你要创建的文件,你再怎么🐂🍺,你也要听操作系统的!我们来看看 umask:
在这里插入图片描述
你要 666,操作系统不能给你,因为 umask 是 0002,所以最多只能给你 664,
非要创建666呢?那就要修改umask,

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main()
{
	umask(0);
	int fd = open("log.txt",O_WRONLY | O_CREAT,0666);
	if(fd<0)
	{
		perror("open");
		return 1;
	}
	printf("fd:%d\n",fd);
	return 0;
}

在这里插入图片描述
现在就是666了,
8、文件关闭close()
在 C 语言中,关闭文件可以调用 fclose,在系统接口中我们可以调用 close 来关闭:

#include <unistd.h>
int close(int fd);

该接口相对 open 相对来说比较简单,只有一个 fd 参数,我们直接看代码:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>  // 需引入头文件
 
int main(void)
{
    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
        perror("open"); 
        return 1;
    }
    printf("fd: %d\n", fd); 
 
    close(fd);  // 关闭文件
 
    return 0;
}

9、文件写入write()
文件打开和文件关闭都有了,我们总要干点事吧?现在我们来做文件写入!
在 C 语言中我们用的是 fprintf, fputs, fwrite 等接口,而在系统中,我们可以调用 write 接口

#include <unistd.h>
ssize_t write(int fd, const void* buf, size_t count);

write 接口有三个参数:
fd:文件描述符
buf:要写入的缓冲区的起始地址(如果是字符串,那么就是字符串的起始地址)
count:要写入的缓冲区的大小

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
	umask(0);
	int fd = open("log.txt",O_WRONLY | O_CREAT,0666);
	if(fd<0)
	{
		perror("open");
		return 1;
	}
	int cnt=0;
	const char* msg = "linux:";
	while(cnt<5)
	{
		write(fd,msg,strlen(msg));
		cnt++;
	}
	close(fd);
	return 0;
}

顺便教一个清空文件的小技巧: > 文件名 ,前面什么都不写,直接重定向 + 文件名:

$ > log.txt

10、系统传递标记位
O_WRONLY 没有像 w 那样完全覆盖?
C语言在 w模式打开文件时,文件内容是会被清空的,但是 O_WRONLY 好像并非如此?
当前我们的 log.txt 内有 5 行数据,现在我们执行下面的代码:可以通过重定位方式向文件中写入:
在这里插入图片描述

echo hello > log.txt//将内容输出到文件里,但是会覆盖原来文件里的内容,如果文件不存在会自动新建
echo hello >> log.txt//追加输出重定向: 作用将内容输出追加到文件的末尾,不覆盖原来文件的内容,如果文件不存在会自动新建
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
	umask(0);
	int fd = open("log.txt",O_WRONLY | O_CREAT,0666);
	if(fd<0)
	{
		perror("open");
		return 1;
	}
	printf("fd:%d\n",fd);

	int cnt=0;
	const char* msg = "666\n";
	while(cnt<2)
	{
		write(fd,msg,strlen(msg));
		cnt++;
	}
	close(fd);
}

执行完如下:
在这里插入图片描述
O_WRONLY 怎么没有像 w 那样完全覆盖???我们以前在 C语言中,w 会覆盖把全部数据覆盖,每次执行代码可都是会清空文件内容的。而我们的 O_WRONLY 似乎没有全部覆盖,曾经的数据被保留了下来,并没有清空!其实,没有清空根本就不是读写的问题,而是取决于有没有加 O_TRUNC 选项!因此,只有 O_WRONLY 和 O_CREAT 选项是不够的:如果想要达到 w 的效果还需要增添 O_TRUNC如果想到达到 a 的效果还需要 O_APPEND
O_TRUNC 截断清空:(对标 w)
在我们打开文件时如果带上O_TRUNC,那么将会清空原始文件,如果文件存在,并且打开是为了写入,O_TRUNC 会将该文件长度缩短 (truncated) 为 0。也就是所谓的 截断清空 (Truncate Empty) ,我们默认情况下文件系统调用接口不会清空文件的,但如果你想清空,就需要给 open() 接口 带上 O_TRUNC 选项

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
	umask(0);
	int fd = open("log.txt",O_WRONLY | O_CREAT | O_TRUNC,0666);
	if(fd<0)
	{
		perror("open");
		return 1;
	}
	printf("fd:%d\n",fd);

	int cnt=0;
	const char* msg = "666\n";
	while(cnt<2)
	{
		write(fd,msg,strlen(msg));
		cnt++;
	}
	close(fd);
}

在这里插入图片描述
此时就被清空了,然而 C语言的 fopen 函数,只需要浅浅地标上一个 “w” 就能搞定了:

fopen("log.txt", "w");

调一个 w 就以写的方式打开了,不存在会自动创建,并且会完全覆盖原始内容,是如此的简单!
它对应的底层 open 调用,调用接口所传入的选项就是 O_WRONLY, O_CREAT, O_TRUNC。
由此可见,C的 fopen 是多么的好用!open 不仅要传这么多选项,而且属性也要设置:

open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
fopen("log.txt", "w");

O_APPEND 追加(对标 a):
C语言中我们以 a 模式打开文件做到追加的效果。现在我们用 open,追加是不清空原始内容的,所以我们不能加 O_TRUNC,得加 O_APPEND:

int fd = open("log.txt", O_WRONLY | O_CREATE | O_APPEND, 0666);
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
	umask(0);
	int fd = open("log.txt",O_WRONLY | O_CREAT | O_APPEND,0666);
	if(fd<0)
	{
		perror("open");
		return 1;
	}
	printf("fd:%d\n",fd);

	int cnt=0;
	const char* msg = "666\n";
	while(cnt<2)
	{
		write(fd,msg,strlen(msg));
		cnt++;
	}
	close(fd);
}

再来对照C语言中的fopen,想做到这样的效果只需要一个a

open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
fopen("log.txt", "a");

实际上,系统级别的接口本来就是被文件接口封装的,fopen 是系统级文件接口的底层实现。
我们的 a, w, r… 在底层上实际上就是这些 “O_” 组合而成的,使用系统接口麻烦吗?
当然麻烦!要记这么多东西,当然还是 C 语言用的更爽了,一个字母标明文件模式就行了。
O_REONLY 读取:
如果我们想读取一个文件,那么这个文件肯定是存在的,我们传 O_RDONLY 选项:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>


int main()
{
	umask(0);
	int fd = open("log.txt",O_RDONLY);
	if(fd<0)
	{
		perror("open");
		return 1;
	}
	printf("fd:%d\n",fd);

	char buffer[128];
	ssize_t s = read(fd,buffer,sizeof(buffer)-1);
	if(s>0)
	{
		buffer[s]="\0";
		printf("s=%d\n",s);
		printf("%s",buffer);
	}
	close(fd);
}

11、文件描述符(fd):
open 参数的返回值:

int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);

我们使用 open 函数举的例子中,一直是用一个叫做 fd 的变量去接收的。fopen 中我们习惯使用 fp / pf 接收返回值,那是因为是 fopen 的返回值 FILE* 是文件指针,file pointer 的缩写即是 fp,所以我们就习惯将这个接收 fopen 返回值的变量取名为 fp / pf,那为什么接收open的返回值的变量叫fd呢?
open 如果调用成功会返回一个新的 文件描述符 (file descriptor) ,如果失败会返回 -1 。
我们现在多打开几个文件,观察 fd 的返回值

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main(void)
{
    int fd_1 = open("log1.txt", O_WRONLY | O_CREAT, 0666);
    int fd_2 = open("log2.txt", O_WRONLY | O_CREAT, 0666);
    int fd_3 = open("log3.txt", O_WRONLY | O_CREAT, 0666);
    int fd_4 = open("log4.txt", O_WRONLY | O_CREAT, 0666);
    int fd_5 = open("log5.txt", O_WRONLY | O_CREAT, 0666);
 
    printf("fd_1: %d\n", fd_1); 
    printf("fd_2: %d\n", fd_2); 
    printf("fd_3: %d\n", fd_3); 
    printf("fd_4: %d\n", fd_4); 
    printf("fd_5: %d\n", fd_5); 
    
    close(fd_1);
    close(fd_2);
    close(fd_3);
    close(fd_4);
    close(fd_5);
 
    return 0;
}

在这里插入图片描述
我们发现这 open 的 5 个文件的 \textrm{fd} (返回值) 分别是 3,4,5,6,7 ,那么问题了来了:
(1)为什么从3开始,0 1 2去哪了,
0:标准输入,键盘:stdin
1:标准输出,显示器:stdout
2:标准错误,显示器:stderr
系统接口认的是外设,而C标准库函数认的是:

#include <stdio.h>
 
extern FILE* stdin;
extern FILE* stdout;
extern FILE* stderr;

系统调用接口!那么 stdin, stdout, stderr 和上面的 0,1,2 又有什么关系呢?想解决这个问题,先说说FILE:我们知道,FILE* 是文件指针,那么 FILE是什么呢?它是 C 库提供的结构体。只要是结构体,它内部一定封装了多个成员,虽然 C 用的是 FILE*,但是系统的底层文件接口只认 fd
,也就是说:c标准库调用的系统接口,对文件操作而言,系统接口只认文件描述符。因此FILE内部必定封装了文件操作符fd,下面我们来验证一下,先验证 0,1,2 就是标准I/O

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
	char buffer[1024];
	ssize_t s=read(0,buffer,sizeof(buffer)-1);
	if(s>0)
	{
		buffer[s]='\0';
		printf("echo:%s",buffer);
	}
	return 0;

}

在这里插入图片描述
在这里插入图片描述
stdout 标准写入(1) 和 stderr 错误写入(2) :

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
	const char* s = "hello linux\n";
	write(1,s,strlen(s));
	write(2,s,strlen(s));
}

在这里插入图片描述
至此证明了每次我们打开文件虽然打开的是 3,但是可以像 3,4,5,6…… 去写,默认系统就会帮我们打开:0 (标准输入, stdin) ,1 (标准输出, stdout),2 (错误输出, stderr) 下面我们要做的是,验证一下 0,1,2 和 stdin, stdout 和 stderr 的对应关系。根据我们目前的分析,FILE本来就是个结构体,因为系统只认fd,所以C语言本身调用的一定是系统结构,这就直接决定了不管怎么封装,底层必须有fd,
证明fd的存在,证明 stdin, stdout 和 stderr 的对应关系

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
	printf("stdin:%d\n",stdin->_fileno);
	printf("stdout:%d\n",stdout->_fileno);
	printf("stderr:%d\n",stderr->_fileno);
	return 0;

}

stdin stdout stderr的类型是struct _IO_FILE *
在这里插入图片描述
在这里插入图片描述
函数接口的对应:fopen / fclose / fread / fwrite : open / close / read / write
数据类型的对应:(FILE* → FILE) → fd
结论就是:我们用的 C 语言接口一定封装了系统调用接口!
这个 0, 1, 2, 3, 4, 5……,是不是有点像数组下标?其实就是数组下标,刚才返回 fd
的,用的都是系统接口,是操作系统提供的返回值。既然操作系统能给你,说明操作系统里面是有的,文件描述符的值为什么是 1,2,3,4,5… ?为了理解这个问题,我们需要做大量的铺垫!
(1)文件描述符的底层理解
进程:内存文件的关系 → 内存 → 被打开的文件实在内存里面的,一个进程可以打开多个文件,所以在内核中,进程与打开的文件之比为1:n,所以系统在运行中,有可能会存在大量的被打开的文件 → OS 要对这些被打开的文件进行管理!OS 如何管理这些被打开的文件呢?还是我们老生常谈的那句话:先描述再组织,所以对我们来说,一个文件被打开不要片面的认为只是对文件内容动动手脚!它还要 在内核中创建被打开文件的内核数据结构 —— 先描述

struct file {
    // 包含了你想看到的文件的所有大部分 内容 + 属性
    
    struct file* next;
    struct file* prev;
};
  • 注:上面的代码是便于理解的,可不是内核真正的代码,真的可远比这复杂得多!
    如果你在内核中打开了多个的文件,那么系统会在内核中为文件创建一个 struct file 结构。可以通过 next 和 prev 将其前后关联起来(内核的链表结构有它自己的设计,这里我们不关注)既然你打开了一个文件,就会创建一个 struct file,那么你打开多个文件,系统中必然会存在大量的 struct file,并且该结构我们用链表的形式链接起来:
    在这里插入图片描述
    如此以来,对被打开的文件管理就转化成为了对链表的增删改查,进程与打开的文件之比为 1:n
    ,进程能打开这么多文件,那么:进程是如何和打开的文件建立映射关系的呢?打开的文件哪一个属于我的进程呢?在内核中,task_struct 在自己的数据结构中包含了一个 struct files_struct *files (结构体指针):
struct files_struct *files;

而我们刚才提到的 “数组” 就在这个 file_struct 里面,该数组是在该结构体内部的一个数组

struct file* fd_array[];

该数组类型为 struct file* 是一个 指针数组,里面存放的都是指向 struct file 的指针!“指向 struct file 的指针!是不是恍然大悟?这不就是文件的 stuct file 结构么?没错!“数组元素映射到各个被打开的文件,直接指向对应的文件结构,若没有指向就设为 NULL,此时建立了进程和文件之间映射关系的桥梁,
在内核中实现的映射关系:
在这里插入图片描述
如此一来,进程想访问某一个文件,只需要知道该文件在这张映射表中的数组下标。上面这些就是在内核中去实现的映射关系了!这个下标 0,1,2,3,4 就是对应的文件描述符 fd!我么调用open、read、write、close接口都需要fd,
当我们 open 打开一个新的文件时,先创建 struct file,然后在当前的文件描述表中分配一个没有被使用的下标,把 stuct file 结构体的地址填入 struct file* 中,然后通过 open 将对应的 fd返回给用户,比如 3,此时我们的 fd 变量接收的 open 的返回值就是 3 了,后续用户再调用 read, write 这样的接口一定传入了对应的 fd,找到了特定进程的 files,在根据对应的 fd索引到指针数组,通过 sturct file* 中存储的 struct file 结构体地址,找到文件对象,之后就可以对相关的操作了,其本质是因为它是一个数组下标,系统中使用指针数组的方式,建立进程和文件之间的关系。将 fd返回给上层用户,上层用户就可以调用后续接口 (read, write…) 来索引对应的指针数组,找到对应文件,这就是 fd 为什么是 0,1,2… 的原因了
(2)Linux 下一切皆文件
我们上面说的 0,1,2 → stdin, stdout, stderr → 键盘, 显示器, 显示器,这些都是硬件啊?也用你上面讲的 struct file 来标识对应的文件吗?在解答这个问题之前,我们需要讲清楚:” Linux 下一切皆文件 ",在说这个话题之前C语言也是可以模拟面向对象的,在C语言中模拟面向对象:

struct file {
    // 对象的是属性
    // 函数指针
    void *(readp)(struct file* filep, int fd ...);
    void *(writep)(struct file* filep, int fd...);
};
 
void read(struct file* filep, int fd...) {
    // 逻辑代码
}
 
void write(struct file* filep, int fd...) {
    // 代码
}

我们举个例子:我们在计算机中,有各种硬件:键盘、显示器、磁盘、网卡、其他硬件…对我们现阶段而言,这些设备我们统一称之为 “外设”,下面我们来看图。注意,下图的 “上层” 是刚才演示的 “映射关系图”
在这里插入图片描述
深灰色层:对应的设备和对应的读写方法一定是不一样的。
黑色层:看见的都是 struct file 文件(包含文件属性, 文件方法),OS 内的内存文件系统。
红色箭头:再往上就是进程,如果想指向磁盘,通过 找到对应的 struct file,根据对应的 file 结构调用读写方法,就可以对磁盘进行操作了。如果想指向对应的显示器,通过 fd 找到 struct file……最后调用读写,就可以对显示器操作了…… 以此类推。
虽然指针指向的是差异化的代码,但是在 深灰色层,我们看到的都是 struct file 文件对象!在这一层我们 以统一的视角看待所有的设备,往上我们就看作 “一切皆文件” !也就是说如果想打开一个文件,打开之后把读写方法交给OS,在内核里给该硬件创建struct file,初始化时把对应的函数指针指向具体的设备,在内核中存在的永远都是 struct file,然后将 struct file 互相之间用链表关联起来。站在用户的角度看,一个进程看待所有的文件都是以统一的视角看待的,所以当我们访问一个 file 的时候,这个 file 具体指向底层的哪个文件或设备,这完全取决于其底层对应的读写方法指向的是什么方法!
多态?C++ 中运行时多态用的虚表和虚函数指针,那不就是函数指针么?“上层使用同一对象,指针指向不同的对象,最终就可以调用不同的方法”
上面画的图,在往上走,就回到了内核的映射关系了
在这里插入图片描述
这里的struct file指向的硬件设备是谁,就取决于底层是怎么设计的了,通过操作系统层做了一层软件封装,达到了这样的效果。 底层叫硬件,而 具体的硬件读写方法是驱动干的,具体的硬件读写是驱动程序要做的,OS 只管跟外设要求其提供读写方法,最终 OS 在内核中给它们抽象成 struct file,把它们都看作文件,然后通过函数指针指向具体的文件对应的设备,就完成了 “一切皆文件” !
(3)初识 VFS(虚拟文件系统)
上面说的这种设置一套 struct file 来表示文件的内存文件系统的操作,我们称之为 \textrm{VFS} (virtual file system) ,即 虚拟文件系统
虚拟文件系统(VFS)是 Linux 内核中非常有用的一个方面,因为它为文件系统提供了一个通用的接口抽象。VFS 在 SCI 和内核所支持的文件系统之间提供了一个交换层。
在这里插入图片描述
12、文件描述符
(1)文件描述符的底层理解
我们已经把 fd 的基本原理搞清楚了,知道了 fd 的值为什么是 0,1,2,3,4,5…
也知道了 fd 为什么默认从 3 开始,而不是从 0,1,2,因为其在内核中属于进程和文件的对应关系。
使用数组来完成映射,0,1,2,3,4,5 就是数组的下标。现在感觉不足为奇了,简直是天经地义!
我们还知道了 fopen / fclose / fread / fwrite… 都必须得用所对应的 0,1,2,3,4,5…
用这些接口来找到对应的 struct file 结构,进而访问到底层对应的读写方法。
最终我们回答了 stdin, stdout, stderr 和 0,1,2 是一一对应关系。
现在再回过头来看这段代码,应该能有不少新的认识了:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
 
int main(void)
{
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open");
        return 1;
    } 
 
    printf("fd: %d\n", fd);
 
    close(fd);
}

(2)探索引用特征
我们既然已经把原理搞清楚了,接下来我们应该探索应用特征了。我们需要探索以下三个问题:
① 文件描述符的分配原则
② 重定向的本质
③ 理解缓冲区
fd 的分配原则:
现在不想把0 1 2打开了,直接在open前close试试,

int main(void)
{
    close(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open");
        return 1;
    } 
 
    printf("fd: %d\n", fd);
 
    close(fd);
}

此时给我们分配的fd就是0,现在我们再把 2 关掉,close(2) 看看:

int main(void)
{
    close(2);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open");
        return 1;
    } 
 
    printf("fd: %d\n", fd);
 
    close(fd);
}

此时分配的就是2,所以,默认情况下 0,1,2 被打开,你新打开的文件默认分的就是 3 (因为 0,1,2 被占了) 。如果把 0 关掉,给你的就是 0,如果把 2 关掉,给你的就是 2……那是不是把 1 关掉,给你的就是 1 呢?我们来看看:

int main(void)
{
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open");
        return 1;
    } 
 
    printf("fd: %d\n", fd);
 
    close(fd);
}

出乎意料啊,fd 居然不是 1,而是什么都没有,这是怎么回事呢?原因很简单,1 是 stdout,printf 打印是往 stdout 打印的,你把 1 关了当然就没有显示了
分配规则:从头遍历数组 fd_array[] ,找到一个最小的且没有被使用的下标分配给新的文件。根据 fd 的分配规则,新的 fd 值一定是 1,所以虽然 1 不再指向对应的显示器了,但事实上已经指向了 log.txt 的底层 struct file 对象了,但是结果没打印出来,log.txt文件里面也什么都没有,至于为什么没有,我们现在暂且不去讲解。但我们可以通过一种方法把它 “变出来” :
.重定向(Redirection)
用 fflush 给它 “变” 出来,实际上并不是没有,而是没有刷新,用 fflush 刷新缓冲区后,log.txt 内就有内容了
fflush 刷新缓冲区:

int main(void)
{
    close(1);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
 
    fflush(stdout);
 
    close(fd);
}

虽然flush之后fd还是没有打印,但是log.txt里面却有内容了,我们自己的代码中调用的就是 printf,printf 本来是往显示器打印的,现在不往显示器打了,而是写到了文件里,它的 “方向” 似乎被改变了
经过这段小代码,我们看到了重定向和缓冲区的身影,这些问题也是我们下面要展开讨论的内容!
本来要往显示器打印的,最终变成了向指定文件打印 → 重定向 (redirection),如果我们要进行重定向,上层只认识 0,1,2,3,4,5 这样的 fd,我们可以在 OS 内部,通过一定的方式调整数组的特定下标的内容 (指向),我们就可以完成重定向操作!
dup2 函数:
上面的一堆数据,都是内核数据结构,只有 OS 有权限,所以其必须提供对应接口,比如 dup
除了 dup,还有有一个 dup2,后者更复杂一些,我们今天主要介绍 dum2 来进行重定向操作

int dup2(int oldfd, int newfd);

dup2 可以让 newfd 拷贝 oldfd,如果需要可以将 newfd 先关闭,newfd 是 oldfd 的一份拷贝,将后者 (newfd) 的内容写入前者 (oldfd),最后只保留 oldfd。至于参数的传递,比如我们要输出重定向 (stdout) 到文件中:我们要重定向时,本质是将里面的内容做改变,所以是要把 fd 的内容拷贝到 1 中的:当我们最后进行输出重定向的时候,所有的内容都和 fd 的内容是一样的了。所以参数在传递时,oldfd 是 fd,所以应该是 dum2(fd, 1); 因为要将显示器的内容显示到文件里,所以 oldfd 就是 fd,newfd 就是 1 了
dum2() 接口在设计时非常地反直觉,所以在理解上特比容易乱,搞清楚原理!
按我们一般的理解,文件 open 后 0,1,2 是现被打开的,0,1,2 才应该是 oldfd。而后打开的 3,4,5… 应该是属于 newfd。但事实恰恰相反,0,1,2 才是 newfd,3,4,5… 反而是 old_fd,所以个人认为在命名上不是很好,容易让人掉坑

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h> 
#include <fcntl.h>
#include <unistd.h>
 
int main(void)
{
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) {
        perror("open");
        return 0;
    }
 
    dup2(fd, 1);   //   fd ← 1
    fprintf(stdout, "打开文件成功,fd: %d\n", fd);
 
    // 暂时不做讲解,后面再说
    fflush(stdout);
    close(fd);
 
    return 0;
}

输入重定向:
之前我们是如何读取键盘上的数据的?

int main(void)
{
    int fd = open("log.txt", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 0;
    }
    // 读数据
    char line[64];
    while (fgets(line, sizeof(line),stdin) != NULL) {
        printf("%s\n", line);
    }
 
    fflush(stdout);
    close(fd);
 
    return 0;
}

现在我们使用输入重新的,说白了就是将 “以前从我们键盘上去读” 改为 “在文件中读”。
所以我们将 open 改为 O_RDONLY,dup(fd, 0)

int main(void)
{
    // 输入重定向
    int fd = open("log.txt", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 0;
    }
    
    // 将本来从键盘上读 (0),改为从文件里读(3)
    dup2(fd, 0);
    // 读数据
    char line[64];
    while (fgets(line, sizeof(line),stdin) != NULL) {
        printf("%s\n", line);
    }
 
    fflush(stdout);
    close(fd);
 
    return 0;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值