狭义的文件:普通的磁盘文件
广义的文件:几乎所有的外设,都可以称作文件
站在系统的角度,那些能够被读取(input),或者能够被写出(output)的设备就叫做文件。
// 此时是打开(或创建)当前路径下的文件 log.txt
FILE* pf = fopen("log.txt", "w");
所谓当前路径,准确来说是:当一个进程运行起来的时候,这个进程所处的工作路径。
以w
的方式打开,如果文件存在,会将文件清空(是在对文件读写操作之前);如果文件不存在,则会创建。
这里可以get到一个tips,就是如何将文件清空。
这里给出更直接的使用方法:> yourfile
。>
表示输入重定向。
以a
方式打开,是为了open for appending
,与其对应的有>>
追加重定向。
文件的关闭使用fclose
,传入文件指针就可以了。
文件的写操作有多种,下面代码中给出部分示例。
const char* s[] = {
"hello fwrite\n",
"hello fprintf\n",
"hello fputs\n"};
fwrite(s[0], strlen(s[0]), 1, pf);
fprintf(pf, "%s", s[1]);
fputs(s[2], pf);
下面再以读的方式r
模拟实现cat
命令
读取信息的就用fgets
函数,
void Test1(int argc, char* argv[])
{
if(argc != 2)
{
printf("argv error!");
exit(1);
}
FILE* pf = fopen(argv[1], "r");
if(pf == NULL)
{
perror("fopen");
exit(2);
}
char line[64] = {0};
while(fgets(line, sizeof(line), pf))
{
fprintf(stdout, "%s", line);
}
fclose(pf);
}
访问文件本质其实是进程通过调用接口访问,下面就来学习一下文件的调用接口。
open
的接口使用比较复杂。
如果想实现w
方式打开,需要如下的传参。
/*
* 宏定义,代表一个 bit 数据
* O_WRONLY 代表只写
* O_CREAT 代表创建文件
* O_TRUNC 代表清空文件
*/
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC)
因为对于是否只写,是否创建,是否清空,这种非此即彼的选择很符合二进制位0与1的选择,open接口内部会对传输的flags
做位数据的判断来决定是否执行对应操作,这样可以简化接口的传参和节省空间。
上面O_*
的传参其实是宏定义,每一个这样的定义都对应一个bit位上的数据。
参数mode
是完成对文件权限属性的设置,用来设置文件权限的。
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); // rw-rw-rw-
上面对文件权限的设置应该是-rw-rw-rw-
,可创建之后为什么是-rw-rw-r--
呢?
这和权限掩码umask
有关。
umask(0);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); // -rw-rw-rw-
将umask
设置0后,就看到了预期的-rw-rw-rw-
了。
文件关闭传入文件描述符(file descriptor)就行了。
对于文件描述符,open
函数创建文件成功就会返回这个文件的文件描述符,这里我们接收就好。
void Test2()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
exit(1);
}
// 写操作
const char* s = "hello world\n";
write(fd, s, strlen(s));
close(fd);
}
void test3()
{
// O_RDONLY 只读
int fd = open("./log.txt", O_RDONLY);
if(fd < 0)
{
perror("open");
exit(1);
}
// 读操作
char buffer[64] = {0};
read(fd, buffer, sizeof(buffer));
// 打印读取结果
printf("%s", buffer);
}
上面对文件的操作都用到了一个东西,叫做文件描述符fd
,下面就来了解一下fd
是什么样一个东西吧。
void Test4()
{
int fd1 = open("log1.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
assert(!(fd1 < 0));
printf("open success, fd1: %d\n", fd1);
int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
assert(!(fd1 < 0));
printf("open success, fd2: %d\n", fd2);
int fd3 = open("log3.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
assert(!(fd1 < 0));
printf("open success, fd3: %d\n", fd3);
int fd4 = open("log4.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
assert(!(fd1 < 0));
printf("open success, fd4: %d\n", fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
}
为什么我们所创建的文件的文件描述符fd
是从 3 开始的呢?为什么不从 0 ,从 1 开始呢?
这是因为在运行C/C++程序是,会默认打开三个文件流:
stdin
标准输入;stdout
标准输出;stderr
标准错误。
这三个流的类型都是FILE*
的,FILE*
类型是结构体指针类型(FILE结构体是C标准库提供的),文件描述符fd
也只是这个结构体中的一个成员变量。而默认打开的这三个文件,它们已经占据了0,1,2三个fd
的值。
文件大致可以分成两类:
进程要访问文件,必须先打开文件。一个进程可以打开多个文件(文件要被访问,必须是先加载到内存中)。
当多个进程都要访问文件时,系统中会存在大量的被打开的文件。
面对如此之多的被打开的文件,操作系统就要采用先描述,再组织的方式将这些被打开的文件进行管理。
Linux内核中,面对每一个被打开的文件都要构建一份struct file结构体(包含了一个被打开的文件的几乎所有的内容,不仅仅包含属性)
通过创建struct file对象来充当一个被打开的文件,并通过双链表的结构组织起来。
上面进程与文件的关系在内核代码中的体现如下图。
每个进程都有一个指针*files
, 指向一张表files_struct
,该表最重要的部分就是包含的一个指针数组,数组中每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。
void Test5()
{
close(1);
// fd的分配规则是:优先分配最小的,没有被占用的文件描述符
int fd = open("./log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
exit(1);
}
// 应该往显示器(标准输出)上写入的
// 但是都写入到了 log.txt
// 这是什么呢?-> 输出重定向
printf("fd: %d\n", fd);
fprintf(stdout, "hello fprintf\n");
const char* s = "hello fwrite\n";
fwrite(s,strlen(s), 1, stdout);
fflush(stdout);
close(fd);
}
代码中首先关闭了fd
为1的文件stdout
,体现在底层就是,文件描述符表中下标为1的元素不再指向标准输出流了。
这时立即创建了log.txt
文件,而fd
的分配规则是:优先分配最小的,没有被占用的文件描述符。于是给log.txt
分配文件描述符fd
是1。
然后再调用文件写入操作的一些接口向stdout
写入。默认是向fd
为1的文件进行写入。
这时就将本应显示到显示器上的内容写入到了log.txt
中。
同理,可以演示输入重定向的问题。
void Test6()
{
close(0);
int fd = open("./log.txt", O_RDONLY);
if(fd < 0)
{
perror("open");
exit(1);
}
printf("fd: %d\n", fd);
// 输入重定向
// 本来从键盘读取数据,变成从log.txt读取
char buffer[64] = {0};
fgets(buffer, sizeof buffer, stdin);
// 将读取的信息进行打印
printf("%s", buffer);
close(fd);
}
从上面的式样中可以看出,重定向问题本质其实是在操作系统内部,更改fd
对应的内容的指向。
但是上面的重定向操作需要需要先手动关闭默认打开的文件流,这里介绍一个dup2
函数来更好的完成重定向操作。
dup2
接口中oldfd
和newfd
的拷贝不是单纯的int
的拷贝,反应在底层上是文件描述符所对应的数组元素中file*
指针的拷贝。
oldfd
拷贝给newfd
,最后newfd
所指向的内容是要和oldfd
一样的。
这里利用dup2
接口将>
重定向和>>
重定向的功能模拟实现一下。
void Test7()
{
if(argc != 2)
{
exit(1);
}
// 输入重定向
int fd = open("./log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
// 追加重定向
//int fd = open("./log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
if(fd < 0)
{
perror("open");
exit(2);
}
dup2(fd, 1);
fprintf(stdout, "%s\n", argv[1]);
close(fd);
}
LInux下一切皆文件,是体现在Linux操作系统的软件设计层面上的设计哲学。
当操作系统面对底层不同的硬件,一定对应的是不同的操作方法。
但是对于硬件这种设备都是外设,所以面对每一个设备的核心访问方法,都可以是 read/write
(I/O),但是基于不同的设备可以有自己不同的 read/write
,即在代码实现上是有差别的。
而且有些设备只能read
或者只能write
,对应实现的功能也会又所差异(比如键盘只能read
)。
但是操作系统将所有这些设备都看待成struct file
结构体,每一个设备都对应一个这样的struct file
,结构体里面包含了这些设备的属性和方法(方法其实是函数指针)。
当操作系统上层要调用哪个设备,就可以通过找到对应的struct file
,然后使用对应的操作方法使用设备了。
也就是说,在操作系统以上,就可以用一切皆文件的视角看待这些不同的设备了。
下面是内核代码中文件操作方法的函数指针。
缓冲区,简单说就是一段内存空间。它是由语言层提供的。它的存在可以提高整机效率,主要还是为了提高用户的响应速度(是一种写回模式)。
一般情况下有三种刷新策略:
特殊情况也会刷新:
其实,所有的设备,永远都倾向于全缓冲策略(缓冲区满了才刷新),这样做可以更少次地对外设进行访问,提高效率(和外部设备进行IO的时候,预备IO的过程是最费时间的)。
一般而言,行缓冲的设备文件有显示器,全缓冲的设备文件有磁盘文件。显示器的行刷新策略也是对效率和用户体验的折中处理。
void Test8()
{
// C语言提供
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* s1 = "hello fputs\n";
fputs(s1, stdout);
// OS提供
const char* s2 = "hello write\n";
write(1, s2, strlen(s2));
// 调用fork时,上面的函数已经被执行完了,但并不代表数据已经刷新了
fork();
}
Test8程序在执行向显示器打印时输出4行文本,向普通文件(磁盘上)打印却变成输出7行。这是为什么呢?
其实很明显是fork()
的原因,那fork()
又是如何作用出现这种情况呢?
细心观察发现,打印的内容中,系统接口的打印始终是一次,C语言接口相比会多打印一次。(从这里也可以猜到缓冲区是语言层提供的)
其实,如果是向显示器打印,刷新策略是行刷新,那么最后执行fork
的时候,之前的函数一定是执行完了,且数据已经被刷新了(行刷新)的,此时fork
就无意义了。
当程序重定向后,向磁盘文件打印,刷新策略隐形地变成了满刷新,此时fork
的时候,之前的函数一定是执行完了,但数据还没有刷新(在当前进程对应的C标准库中的缓冲区),这部分数据是属于父进程的数据的。而fork
之后,程序就要退出了,同时会强制将缓冲区的数据进行刷新。而父子进程的数据被刷新势必会影响到另一方,所以发生写时拷贝,父子进程各刷新一份数据到文件,就出现C接口的打印出现两份的情况了。
缓冲区是语言层提供的,封装的struct FILE
结构体内部就有所打开文件对应的语言层的缓冲区结构。
下面通过Test9可以对上面的解释做一个验证。
void Test9()
{
// C语言提供
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* s1 = "hello fputs\n";
fputs(s1, stdout);
// OS提供
const char* s2 = "hello write\n";
write(1, s2, strlen(s2));
// fork之前,进行强制刷新
fflush(stdout);
// 调用fork时,上面的函数已经被执行完了,但并不代表数据已经刷新了
fork();
}
stdout
对应的文件描述符是1,stderr
对应的文件描述符是2。
它们对应的都是显示器文件,但却是不同的。可以认为,同一个显示器文件,被打开了两次。
void Test10()
{
// stdout -> 1
printf("hello printf 1\n");
fprintf(stdout, "hello fprintf 1\n");
const char* s1 = "hello write 1\n";
write(1, s1, strlen(s1));
std::cout << "hello cout 1" << std::endl;
// stderr -> 2
perror("hello perror 2");
const char* s2 = "hello write 2\n";
write(2, s2, strlen(s2));
std::cerr << "hello cerr 2" << std::endl;
}
从这里发现,重定向的是1号文件描述符对应的文件。
所以可以通过重定向把标准输入和标准输出的内容输入到不同的文件中进行分开查看。
![在这里插入图片描述](https://img-blog.csdnimg.cn/e6143fe26eaa4f91b5604c946427517b.png =x150x)
一般而言,如果程序运行有,可能有错误的话,建议使用stderr
,或者cerr
来打印。如果是常规文本内容,建议使用cout
,stdout
打印。
当然也是可以将标准输入和标准输出的内容都输入到一个文件中的。
打印的内容中,有一个细节是perror
的打印包含有一段信息,其实就是错误码errno
对应的错误信息。
这里根据perror
的功能可以模拟实现一个进行理解。
void my_perror(const char* msg)
{
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
}