基础IO(文件操作、文件描述符fd、重定向)
文章目录
一、回顾C和C++的文件操作
1、空文件也要在磁盘占用
- 我们创建的文件,虽然里面并没有存放数据,但是文件属性也是数据,即便你创建一个空文件,也要占据磁盘空间
2、文件 = 文件内容 + 文件属性
- 文件内容就是真正写入的内容,文件属性就是文件名、文件大小、文件的权限、拥有者所属组……
3、文件操作 = 文件内容的操作 + 文件属性的操作 + 文件内容和属性的操作
-
我们有可能在操作文件的过程中,既改变内容,又改变属性。
文件操作的本质:进程和被打开文件的关系
4、一个文件如果没有被打开,可以直接对文件进行访问吗?
- 不能!一个文件要被访问,就必须先被打开!
5、所谓的“打开”文件,究竟在干什么?
- 将文件的属性内容或内容加载到内存中,根据冯诺依曼体系结构决定的,cpu只能从内存中对数据做读写
6、是不是所有的文件,都会处于被打开的状态?没有被打开的文件,在哪里?
- 不是的,没有被打开的文件只在磁盘上静静的存储着!
7、标定一个文件,必须使用:文件路径 + 文件名(唯一性)
-
我们要寻找或标定一个文件,必须使用文件名和文件路径,因为每个文件的(文件名+文件路径)都是唯一的,帮助我们快速确定文件的位置。
如果没有指明对应的文件路径,默认是在
当前路径
进行文件访问
。
8、通常我们打开文件,访问文件,关闭文件,是谁在进行相关操作?
- 进程!C语言中我们访问文件需要用到fopen、fclose、fread、fwrite等接口写完之后,代码编译之后,形成二进制可执行程序后,如果不运行,文件对应的操作不能被执行。如果运行此文件程序,此时才会执行对应的代码,然后才是真正的对文件进行相关的操作,所以真正对文件操作的其实是进程。
二、C语言文件IO
1.什么是当前路径?
根据我们前面的学习,当fopen以写入的方式打开一个文件时,若文件不存在,则会自动在当前路径创建该文件,那么这里的当前路径是什么呢?以下面的代码为测试用例:
#include<stdio.h>
#include<unistd.h>
int main()
{
FILE* fp = fopen("flie.txt", "w");//写入
if (fp == NULL)
{
perror("fopen");
return 1;
}
printf("mypid: %d\n", getpid());
while (1)
{
sleep(1);
}
const char* msg = "hello world";
int cnt = 1;
while (cnt < 20)
{
fprintf(fp, "%s: %d\n", msg, cnt++);
}
fclose(fp);
return 0;
}
此段代码我fopen写入的方式打开了file.txt文件,那么我在lesson19目录下运行可执行程序myfile,那么该可执行程序创建的file.txt文件会出现在lesson19目录下,此外上述代码还获取到了当前进程的pid,并且让此进程一直循环下去,方便后续操作。
根据我们获取到的当前进程的pid,再根据我们先前学到的知识,根据该pid在根目录下的proc目录下查看此进程的信息如下:
下面来解释下cwd和exe:
- cwd表示当前进程所处的工作路径。
- exe表示进程对应的可执行程序的磁盘文件。
上面的运行结果也正如我们所料:file.txt创建在了与当前可执行程序路径所在的位置,也是当前进程所处的工作路径,那是否就意味着这里说的“当前路径”是指“当前可执行程序所处的路径”呢?还是说“当前路径”是指“当前进程所处的路径”呢?
- 这里我们把刚才生成的log.txt文件删除掉,对代码进行如下的修改,利用上次学到的chdir函数来更改此进程的工作路径:chdir(“/home/wei”)
然后后再次运行myfile程序,结果如下:
此时现象已经很明显了,我运行了file可执行程序,但是并没有在当前可执行程序file所处在的lesson19目录下看到我想要的file.txt文件,相反我却在/home/wei路径下看到了file.txt文件,这就足以证明我利用chdir更改进程所处的路径后,生产的文件也随之更改,这就证明此当前路径即为当前进程所处的路径,为了更具有说服力,我们依旧是利用proc查看当前进程9752的相关信息:
综上,当前路径就是当前进程所处的路径!!!
2.C语言文件接口汇总
C语言文件操作函数如下:
*文件操作函数* |
*功能* |
---|---|
fopen | 打开文件 |
fclose | 关闭文件 |
fputc | 写入一个字符 |
fgetc | 读取一个字符 |
fputs | 写入一个字符串 |
fgets | 读取一个字符串 |
fprintf | 格式化写入数据 |
fscanf | 格式化读取数据 |
fwrite | 向二进制文件写入数据 |
fread | 从二进制文件读取数据 |
fseek | 设置文件指针的位置 |
ftell | 计算当前文件指针相对于起始位置的偏移量 |
rewind | 设置文件指针到文件的起始位置 |
ferror | 判断文件操作过程中是否发生错误 |
feof | 判断文件指针是否读取到文件末尾 |
C语言的文件操作我之前已经详细讲解过,如果想了解上述诸多文件操作函数的使用方法,还请跳转到如下博文:
下面对fopen再强调下:
//打开文件
FILE * fopen ( const char * filename, const char * mode );
fopen参数含义:
- 参数1:文件名
- 参数2:文件打开方式
打开方式如下:
文件使用方式 | 含义 | 如果指定文件不存在 |
---|---|---|
“r”(只读) | 为了输入数据,打开一个已经存在的文本文件 | 出错 |
“w”(只写) | 为了输出数据,打开一个文本文件 | 建立一个新的文件 |
“a”(追加) | 向文本文件尾添加数据 | 建立一个新的文件 |
“rb”(只读) | 为了输入数据,打开一个二进制文件 | 出错 |
“wb”(只写) | 为了输出数据,打开一个二进制文件 | 建立一个新的文件 |
“ab”(追加) | 向一个二进制文件尾添加数据 | 出错 |
“r+”(读写) | 为了读和写,打开一个文本文件 | 出错 |
“w+”(读写) | 为了读和写,建立一个新的文件 | 建立一个新的文件 |
“a+”(读写) | 打开一个文件,在文件尾进行读写 | 建立一个新的文件 |
“rb+”(读写) | 为了读和写打开一个二进制文件 | 出错 |
“wb+”(读写) | 为了读和写,新建一个新的二进制文件 | 建立一个新的文件 |
“ab+”(读写) | 打开一个二进制文件,在文件尾进行读和写 | 建立一个新的文件 |
接下来取其中部分进行演示:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define FILE_NAME "log.txt"
int main()
{
FILE*fp = fopen(FILE_NAME,"a");
//FILE*fp = fopen(FILE_NAME,"w");
//FILE*fp = fopen(FILE_NAME,"r");
// r、w、r+(读写,文件不存在就出错)、w+(读写,文件不存在就创建文件)、a(append,追加,只写的形式打开文件)、a+(以可读写的方式打开文件)
if(fp==NULL)
{
perror("fopen");
exit(1);
}
int cnt = 5;
while(cnt)
{
fprintf(fp,"%s:%d\n","hello Linux",cnt--);// 对文件进行写入
}
fclose(fp);
// char buffer[64];
// while(fgets(buffer,sizeof(buffer) - 1,fp) != NULL)// sizeof-1的原因是有给文本末尾留一个位置,让fgets放入 null character
// {
// buffer[strlen(buffer)-1]=0;//把换行符改成结束符
// puts(buffer);
// }
// fclose(fp);
return 0;
}
1、”r”只读:
- 我们预先给log.txt文件输入以下内容,然后运行myfile程序:
- fgets在读取文件内容的时候,换行符会被认为是有效字符读取到缓冲字符数组里面的,并且在每行读取结束后,fgets会自动添加null character到缓冲字符数组的每个字符串末尾处。
-
puts在将字符串打印的时候,会自动在字符串末尾追加一个换行符
。所以为了防止puts打印两个换行符,在while循环里面将buffer数组里面的换行符改为null character。 -
fgets在读取的时候,
以读取到num-1个字符,或换行符,或者文件结束符为止,以先发生者为准
,这就是读取一行的内容。所以如果想要读取多行内容,就需要搞一个while循环。
2、”w”只写:
- 如果一个文件本就存在,那么以w的方式写入会先清空你文件的内容,如果不存在那么就创建个新的,并且从文件的最开始写入。
- 我们在上述已经创建好log.txt文件的基础上执行此程序:
- 此时当我们以w方式打开文件,准备写入的时候,其实文件已经先被清空了。
- fprintf向文件写入时,换行符也是会被写入到文件当中的
3、”a”追加:
4、根据上述对文件的读取,我们现在写个小功能(利用文件操作模拟实现cat命令):
- 代码如下:
#include<stdio.h>
#include<unistd.h>
//myfilel filename
int main(int argc, char* argv[])
{
if (argc != 2)
{
printf("Usage: %s filename\n", argv[0]);
return 1;
}
FILE* fp = fopen(argv[1], "r");
if (fp == NULL)
{
perror("fopen");
return 1;
}
char buffer[64];
while (fgets(buffer, sizeof(buffer), fp) != NULL)
{
printf("%s", buffer);
}
fclose(fp);
return 0;
}
- 现在运行可执行程序myfile + 要读取的文件名即可完成cat的功能:
问1:当我们想文件写入的时候,最终是不是向磁盘写入?
- 是的,磁盘是硬件,只有OS操作系统有资格向硬件写入,我们不能绕开操作系统对磁盘资源的访问,换言之,所有的上层访问文件的操作,都必须贯穿操作系统,而操作系统不相信任何人,所以只能使用操作系统提供的相关系统调用来让OS被上层使用。
问2:如何理解printf?我们怎么从来没有见过?
- 首先,printf的结果是显示到显示器上的,显示器是硬件,其管理者只能是操作系统,必须通过对应的调用接口来访问显示器,所以printf内部一定封装了系统接口
- 我们从来没有见过这些系统调用接口是因为所有的语言都对系统接口做了封装,所以看不到底层的系统调用接口的差别
问3:为什么要封装?
- 原生系统接口,使用成本太高了!
- 使用原生系统接口,一段代码只能在一个平台上跑,不同的平台暴露出的文件接口是不一样的有时候,最终会导致语言不具备跨平台性!
问4:封装是如何解决跨平台问题的呢?
- 穷举所有的底层接口 + 条件编译!
3.默认打开的三个流
- 都说 Linux 下一切皆文件,也就是说Linux下的任何东西都可以看作是文件,那么显示器和键盘当然也可以看作是文件。我们能看到显示器上的数据,是因为我们向“显示器文件”写入了数据,电脑能获取到我们敲击键盘时对应的字符,是因为电脑从“键盘文件”读取了数据。
为什么我们向“显示器文件“写入数据以及从“键盘文件”读取数据前,不需要进行打开“显示器文件”和键盘文件“的相应操作?
- 需要注意的是,打开文件一定是进程运行的时候打开的,而任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流、标准错误流,对应到C语言中就是stdin,stdout,stderr。其中,标准输入流对应的设备就是键盘,标准输出流和标准错误流对应的设备都是显示器。
查看man手册我们就可以发现,stdin、stdout、stderr实际上都是FILE*类型的。
当我们的C程序被运行起来时,操作系统就会默认使用C语言的相关接口将这三个输入输出流打开,之后我们才能调用类似于scanf和printf之类的函数向键盘和显示器进行相应的输入输出操作。
也就是说,stdin、stdout、stderr与我们打开某一文件时获取到的文件指针是同一个概念,试想我们使用fputs函数时,将其第二个参数设置为stdout,此时fputs函数会被和将数据显示到显示器上呢?
#include<stdio.h>
int main()
{
fputs("hello stdin\n", stdout);
fputs("hello stdout\n", stdout);
fputs("hello stderr\n", stdout);
return 0;
}
答案是肯定的,此时我们相当于使用fputs函数向“显示器文件”写入数据,也就是显示到显示器上。
注意:不止是C语言中有标准输入流、标准输出流和标准错误流,C++当中也有对应的cin,cout和cerr,其它所有语言都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的。
三、系统文件IO
操作文件除了C语言接口、C++接口或是其他语言的接口外,操作系统也有一套系统接口来进行文件的访问。相比于C库函数或其他语言的库函数而言,系统调用接口更贴近底层,实际上这些语言的库函数都是对系统接口进行了封装。
我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发。
1.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); |
参数 | 见后面 |
返回值 | 文件打开成功,则会返回新的文件描述符(大于0的整数);打开失败就会返回-1 |
-
open的第一个参数(pathname)
第一个参数pathname代表着要打开或创建的目标文件名
- 若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建。
- 若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建。(注意当前路径的含义)
-
open的第二个参数(flags)
第二个参数flags表示打开文件要传递的选项(打开文件的方式),其常用选项有如下几个:
参数选项 | 含义 |
---|---|
O_RDONLY | 以只读的方式打开文件 |
O_WRONLY | 以只写的方式打开文件 |
O_RDWR | 以读写的方式打开文件 |
O_CREAT | 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限 |
O_APPEND | 以追加的方式打开文件 |
O_TRUNC | 把原有文件清空 |
补充1
:
- 实际上传入flags的每一个选项在系统当中都是以宏的方式进行定义的
- 例如,O_RDONLY、O_WRONLY、O_RDWR、O_CREAT在系统中的宏定义如下:
#define O_RDONLY 00
#define O_WRONLY 01
#define O_RDWR 02
#define O_CREAT 0100
- 这些宏定义选项的共同点就是,它们的二进制序列当中有且只有一个比特位是1(O_RDONLY选项的二进制序列为全0,表示O_RDONLY选项为默认选项),且为1的比特位是各不相同的,这样一来,在open函数内部就可以通过使用“与”运算来判断是否设置了某一选项。
int open(arg1, arg2, arg3){
if (arg2&O_RDONLY){
//设置了O_RDONLY选项
}
if (arg2&O_WRONLY){
//设置了O_WRONLY选项
}
if (arg2&O_RDWR){
//设置了O_RDWR选项
}
if (arg2&O_CREAT){
//设置了O_CREAT选项
}
//...
}
补充2:
- 打开文件时,可以传入多个参数选项,当有多个参数选项传入时,将这些选项用或 “ | ” 运算符隔开。
例如,若想以只写的方式打开文件,但当目标文件不存在时自动创建文件,则第二个参数设置如下:
O_WRONLY | O_CREAT
补充3
:
- 且系统接口open的第二个参数flags是整型,有32比特位,若将一个比特位作为一个标志位,则理论上flags可以传递32种不同的标志位,也就是说系统传递标记位,是用位图结构来进行传递的!
- 综上:每一个宏标记,一般只需要有一个比特位是1,并且和其它宏对应的值不能重叠。
- 要想理解open的第二个参数,则需要先理解如何使用比特位来传递选项,如果想让函数实现多种功能的话,我们可以利用或运算将多个选项 “粘合” 到一起,从而让一个接口同时实现多种不同的功能。利用的原理就是宏整数的32比特位中只有一个比特位是1,且不同的宏的1的位置是不重叠的,这样就可以利用或运算来同时实现多个功能。
实例
:(如下我自己设计的标记位,在内部做不同的事情)
#include <stdio.h>
#include <stdlib.h>
//每一个宏,对应的数值,只有一个比特位是1,彼此位置不重叠
#define ONE (1<<0)
#define TWO (1<<1)
#define THREE (1<<2)
#define FOUR (1<<3)
void show(int flags)
{
if(flags & ONE) printf("one\n");
if(flags & TWO) printf("two\n");
if(flags & THREE) printf("three\n");
if(flags & FOUR) printf("four\n");
}
int main()
{
show(ONE);
printf("---------------------\n");
show(TWO);
printf("---------------------\n");
show(ONE | TWO);
printf("---------------------\n");
show(ONE | TWO | THREE);
printf("---------------------\n");
show(ONE | TWO | THREE | FOUR);
printf("---------------------\n");
return 0;
}
下面来正式使用系统的open打开文件,并使用flags选项来做演示:
#include<stdio.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
// C语言中的w选项实际上底层需要调用这么多的选项O_WRONLY O_CREAT O_TRUNC 0666
// C语言中的a选项需要将O_TRUNC替换为O_APPEND
int fd = open("log.txt", O_WRONLY | O_CREAT);
if (fd < 0)
{
//打开失败
perror("open");//输出对应的错误码
return 1;
}
printf("fd: %d\n", fd);
close(fd);
}
我们运行此程序,并用ll指令查看相关信息:
注意看这里输出的fd(open函数的返回值)是3,具体为何是3,等我们讲到文件描述符再细说。我们确实把一个不存在的文件(log.txt)创建好了,可是此文件的权限都是乱码,原因在于你新建一个文件,此文件要受linux权限约束的,这个新建文件的权限必须得告知操作系统,所以当我们打开一个曾经并不存在的文件,我们不能用两个参数的open,而是要用三个参数的open函数,看下文讲解:
-
open的第三个参数(mode)
第三个参数mode表示创建文件的默认权限。
- 例如就上述的例子,我们把log.txt文件的mode默认权限设置为0666:
运行此程序,查看真实的权限值:
- 怎么实际的权限值为0664呢?实际上创建出来的权限值还会收到umask(文件默认权限掩码)的影响,实际创建出来文件的权限值为:mode & (~umask)。umask的默认权限值一般为0002,当我们设置mode值为0666时实际创建出来的文件权限为0664。
若想创建出文件的权限值不受umask的影响,则需要在创建文件前使用umask函数将文件掩码设置为0。这样起始权限实际上就是最终文件的权限了,因为umask按位取反后是全1,起始权限按位与后不会改变。
注意
:创建目录的命令mkdir,目录起始权限默认是777,创建文件的命令touch,文件起始权限是0666,这些命令的实现实际上是要调用系统接口open的,并且在创建文件或目录的时候要在open的第三个参数中设置文件的起始权限。
2.close
C语言关闭文件使用的是fclose,系统接口使用close函数关闭文件,close函数的原型如下:
函数名称 | close |
---|---|
函数功能 | 关闭文件 |
头文件 | #include <unistd.h> |
函数原型 | int close(int fd); |
参数 | 文件描述符fd |
返回值 | 文件关闭成功,则会返回0;关闭失败就会返回-1,并且错误码会被设置 |
使用close函数时传入需要关闭文件的文件描述符即可,若关闭文件成功则返回0,关闭文件失败则返回-1。
注意
:在执行完文件的操作后,要进行“关闭文件”操作。虽然程序在结束前会自动关闭所有的打开文件,但文件打开过多会导致系统运行缓慢,这时就要自行手动关闭不再使用的文件,来提高系统整体的执行效率。
3.write
在C语言中我们对文件的写入使用的是fprintf、fputs、fwrite……。在系统接口中,我们使用的是write函数向文件写入信息,write函数的原型如下:
函数名称 | write |
---|---|
函数功能 | 打开或者创建文件 |
头文件 | #include <unistd.h> |
函数原型 | ssize_t write(int fd, const void *buf, size_t count); |
参数 |
fd:特定的文件描述符 buf:写入缓冲区对应的字符串起始地址 count:写入缓冲区的大小 |
返回值 | 写入成功返回写入的字节数(零表示未写入任何内容),写入失败返回-1 |
示例:
#include<stdio.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#define FILE_NAME "log.txt"
int main()
{
umask(0);//将进程的umask值设置为0000
// C语言中的w选项实际上底层需要调用这么多的选项O_WRONLY O_CREAT O_TRUNC 0666
// C语言中的a选项需要将O_TRUNC替换为O_APPEND
int fd = open(FILE_NAME, O_WRONLY | O_CREAT,0666);//设置文件起始权限为0666
if(fd < 0)
{
perror("open");
return 1;//退出码设置为1
}
close(fd);
int cnt = 5;
char outbuffer[64];
while(cnt)
{
sprintf(outbuffer,"%s:%d\n","hello linux",cnt--);
//以\0作为字符串的结尾,是C语言的规定,和文件没什么关系,文件要的是字符串的有效内容,不要\0
//除非你就想把\0写到文件里面取,否则strlen()不要+1
write(fd,outbuffer,strlen(outbuffer));
}
printf("fd:%d\n",fd);// 文件描述符的值为3
close(fd);
}
运行此程序,并用cat指令输出写入到log.txt文件的内容:
如果write写入时第三个参数要多加一个\0的位置,创建出来的log.txt用vim打开时会出现乱码,以\0作为字符串的结束标志,这是C语言的规定和文件没有关系,文件只要存储有效内容就好了,不需要\0,所以在write写入的时候,strlen求长度不要+1。
只将写入的内容改为aaaa,打印出来的log.txt的内容就发生了覆盖式写入的现象,而不是先将文件原有内容清理,然后在重新写入。
在C语言中,如果再次以写的方式打开文件,会自动将原先文件中的内容清理掉,重新向文件写入内容。
自动清空原有数据,实际上是通过open系统调用中的第三个宏参数O_TRUNC来实现的。
所以C语言中打开文件时,使用的打开方式为w,在底层的open接口中,要用三个宏参数O_WRONLY,O_CREAT,O_TRUNC来实现。
C语言中的a打开方式,在系统底层实现上只需要将O_TRUNC替换为O_APPEND即可。
可见库函数和系统调用的关系,本质就是库函数封装系统调用。
4.read
C语言的读取操作是fread,系统接口使用的是read函数从文件读取信息,read函数的原型如下:
函数名称 | read |
---|---|
函数功能 | 打开或者创建文件 |
头文件 | #include <unistd.h> |
函数原型 | ssize_t write(int fd, void *buf, size_t count); |
参数 |
fd:特定的文件描述符 buf:写入缓冲区对应的字符串起始地址 count:写入缓冲区的大小 |
返回值 | 读取成功返回实际读取数据的字节数(零表示未写入任何内容),数据读取失败返回-1 |
示例:
#include<stdio.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#include<string.h>
int main()
{
umask(0);//将文件权限掩码设置为0
// C语言中的w选项实际上底层需要调用这么多的选项O_WRONLY O_CREAT O_TRUNC 0666
// C语言中的a选项需要将O_TRUNC替换为O_APPEND
int fd = open("log.txt", O_RDONLY, 0666);
if (fd < 0)
{
//打开失败
perror("open");//输出对应的错误码
return 1;
}
char buffer[1024];
ssize_t num = read(fd, buffer, sizeof(buffer) - 1);
//-1是因为我们写入文件的时候并不需要\0,我们读的时候手动添加\0,将其看做字符串
//读出来,每个子字符串都是以\n结尾的,我们直接将整个字符串给读出来
//num>0说明我们读到了有效的内容
if(num > 0) buffer[num]=0;//字符数组中字面值0就是\0
printf("%s",buffer);
close(fd);
return 0;
}
注意:我们知道要读取的内容是字符串,所以在数组buffer里面,
需要手动设置字符串的末尾为\0
,方便printf打印字符串。
0,‘\0’,NULL等字面值实际上都是0,只不过他们的类型不同。
5.对比C语言文件操作与系统的文件操作
1、C中以”w”方式打开文件,是会清空原文件的
- C语言的文件操作中,以w的方式打开文件时,是会清空原文件的,可我们使用系统接口对文件操作,按照如下的测试用例是不足以实现向C语言那样清空原文件再写入的功能:
- 为了实现向C语言那样以w的方式写入并且先清空源文件的内容再写入新内容的操作,我们需要给open的第二个参数多家一个选项(O_TRUNC),此选项的作用就是清空原文件的内容。
- 对比C语言完成上述功能和系统操作完成上述功能:
C语言操作:fopen("log.txt", "w");
系统操作:int id = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
- 综上:实际上C语言以”w”的写入方式底层的open调所传入的选项就是O_WRONLY | O_CREAT | O_TRUNC。C语言只需要用到”w”即可了,但是系统方面就要传这么多选项,而且属性也要设置。
2、C中以”a”方式打开文件,是会追加的
- C中以”a”的方式打开文件,是以追加的方式向文本文件尾部添加数据,为了让我们的系统接口也能完成此追加操作,我们需要将open的第二个参数中的选项O_TRUNC换成O_APPEND:
- 对比C语言完成上述功能和系统操作完成上述功能:
C语言实现:fopen("log.txt", "a");
系统操作:int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
- 综上:C语言中只用了一个”a”实现的追加功能,在系统底层,需要用到O_WRONLY | O_CREAT | O_APPEND这一组选项来完成对应的操作。
总结
:实际上我们系统层面上的接口是我们C语言操作文件的底层实现。
6.open的返回值
open的返回值类型是int,如果打开文件成功,则返回新打开的文件描述符,若打开失败,则返回-1。
我们可以尝试一次打开多个文件,然后分别打印它们的文件描述符:
#include <assert.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#define FILE_NAME(number) "log.txt"#number
int main()
{
umask(0);//将文件权限掩码设置为0
int fd0 = open(FLIE_NAME(1), O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd1 = open(FLIE_NAME(2), O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd2 = open(FLIE_NAME(3), O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd3 = open(FLIE_NAME(4), O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd4 = open(FLIE_NAME(5), O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fda: %d\n", fd0);
printf("fdb: %d\n", fd1);
printf("fdc: %d\n", fd2);
printf("fdd: %d\n", fd3);
printf("fde: %d\n", fd4);
close(fd0);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
运行程序后可以看到,打开文件的文件描述符是从3开始连续且递增的:
问1:为什么返回的文件描述符是从3开始,0,1,2去哪了呢?
其实这里的0、1、2被默认打开了,0对应的就是标准输入(键盘),1对应标准输出(显示器),2对应标准错误(显示器),标准输入、标准输出、标准错误输出是系统默认打开的三个标准文件,系统定义的三个文件指针stdin、stdout、stderr中一定含有文件描述符,我们用如下代码验证:
而C语言我们学过三个文件流指针:
上述一开始的fd返回值是用的系统接口open函数的,而这里stdin,stdout,stderr对应的是C语言的,而C库函数的内部调用的是系统接口,对应关系如下:
上述系统接口的文件描述符和C语言中的文件指针到底是何关系,我们得先搞清楚FILE*的含义:
FILE*是文件指针,FILE是C库提供的结构体,内部封装了多个成员,对文件操作而言,系统接口只认文件描述符fd,FILE内部必定封装了fd(_fileno就是文件指针内部结构体封装的文件描述符)
再回到一开始的问题:
为什么返回的文件描述符是从3开始,0,1,2去哪了呢?
-
答案:因为Linux进程默认打开3个文件描述符,分别是标准输入0,标准输出1,标准错误2,既然0,1,2被占了,所以这就是为什么成功打开文件时所得到的文件描述符是从3开始进行分配的。
问2:为什么返回值文件描述符fd会是0,1,2,3,4,5……,其它的不可以吗?
-
实际上这里所谓的文件描述符本质上是一个指针数组的下标,且这个下标对应的是内核的下标。我们上述调用open,write,read返回的文件描述符都是系统接口,都是操作系统提供的返回值,具体怎么个数组下标还请看下文的文件描述符fd。
四、文件描述符fd
- 文件是由进程运行时打开的,一个进程可以打开多个文件,所以在内核中,进程 : 打开的文件 = 1 : n,而系统中又存在大量进程,也就是说,在系统中任何时刻都可能存在大量已经打开的文件。
- OS操作系统需要对这些被打开的文件进行管理,管理方式就是先描述,再组织。所以一个文件被打开,在内核中,要创建被打开的文件的内核数据结构(先描述),即struct file结构体,其内部包含了我想看到的文件的大部分内容 + 属性,然后将这些结构体以双链表的形式链接起来,随后对被打开文件的管理,就转换成为了对链表的增删改查!
**问3:**
进程和文件之间的对应关系是如何建立的?
- 我们知道,当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct,mm_struct,页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的关系:
- 而task_struct中还有一个指针(struct files_struct * files),该指针指向一个名为files_struct的结构体,在该结构体当中有一个名为fd_array的指针数组,里面存放的是struct file*的指针,该指针指向的就是被打开的文件结构,如果没有指向,那就指向NULL。该数组的下标就是我们所谓的文件描述符。此时就把进程和文件的映射关系建立好了!
- 我们上述所画的这一坨都是在内核中实现的,因此,我们只要有某一文件的文件描述符,就可以找到与该文件相关的文件信息,从而对文件进行一系列输入输出操作。
再来解决上述一开始问的问题:
为什么返回值文件描述符fd会是0,1,2,3,4,5……,其它的不可以吗?
- 答案:本质是因为文件描述符是一个指针数组的下标,系统当中使用指针数组的方式建立进程和文件之间的对应关系,将文件描述符返回给上层用户,上层用户就可以在调用后续接口继续传入文件描述符,来索引对应的指针数组来找到对应的文件。
问4:如何理解Linux下一切皆文件?
- 如果我们要用C语言来实现面向对象(类),只能使用struct结构体,我们采用函数指针的形式来在struct中实现函数方法:
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 ...)
{
//逻辑代码
}
- 在计算机里有各种硬件(键盘、显示器、磁盘、网卡……),这些设备统一称为外设(IO设备),以磁盘为例,它们都一定有对应自己的一套读写方法,不同的设备对应的读写方法一定是不一样的,如果现在要打开磁盘,那么OS就在内核给你创建一套struct file,用readp指针指向read方法,writep指针指向write方法,打开显示器等其它外设也是类似的,这一操作就是OS内的文件系统做的软件封装,再往上就是一个进程里的指针指向一结构体,该结构体内部有一个指针数组,下标就是文件描述符,其内部存放struct file*的指针,从而指向各个设备的读或写的方法。
- 上述整个过程就是“Linux下一切皆文件”,也就是说未来你想打开一个文件,把读写方法和属性记下来,在内核里给你这个硬件创建对应的struct file,初始化时把对应的函数指针指向你具体的设备,但在内核中存在的永远都是struct file,用链表结构关联起来,所以一个进程都以统一的视角看待文件,所以我们访问不同的file指向的谁完全取决于其底层的读写方法。有点多态的感觉了。我们把上述的设计出的struct file来表示一个一个文件的叫做VFS虚拟文件系统。
问5:0,1,2对应stdin,stdout,stderr,对应的设备分别是键盘,显示器,显示器。可这些都是硬件啊,也用你上面的struct file来标识对应的文件吗?
- 其实此问题的答案在问4(Linux下一切皆文件)已经讲解过了,当你打开一个文件,把读写方法和属性记下来,在内核里给你这个硬件创建对应的struct file,初始化时把对应的函数指针指向你具体的设备,但在内核中存在的永远都是struct file,用链表结构关联起来,所以一个进程都以统一的视角看待文件,所以我们访问不同的file指向的谁完全取决于其底层的读写方法。当然需要struct file来标识对应的文件。
五、文件描述符的分配规则
再次连续打开5个文件,看看这5个文件打开后获取到的文件描述符:
#include<stdio.h>
#include<unistd.h>
#include<sys/stat.h>
#include<fcntl.h>
#define FILE_NAME(number) "log.txt"#number
int main()
{
umask(0);//将文件权限掩码设置为0
int fd0 = open(FLIE_NAME(1), O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd1 = open(FLIE_NAME(2), O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd2 = open(FLIE_NAME(3), O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd3 = open(FLIE_NAME(4), O_WRONLY | O_CREAT | O_TRUNC, 0666);
int fd4 = open(FLIE_NAME(5), O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd: %d\n", fd0);
printf("fd: %d\n", fd1);
printf("fd: %d\n", fd2);
printf("fd: %d\n", fd3);
printf("fd: %d\n", fd4);
close(fd0);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
可以看到这五个文件获取到的文件描述符都是从3开始连续递增的,这很好理解,因为文件描述符本质就是数组的下标,而当进程创建时就默认打开了标准输入流、标准输出流和标准错误流,也就是说数组当中下标为0、1、2的位置已经被占用了,所以只能从3开始进行分配。
若我们在打开这5个文件之前,先关闭文件描述符为0的文件,此时文件描述符的分配又会是怎样的呢?
close(0);
可以看到,第一个打开的文件获取到的文件描述符变成了0,而之后打开文件获取到的文件描述符依旧是从3开始依次递增的:
如果我先同时关闭文件描述符为0和2的文件呢,此时文件描述符的分配又会是怎样的呢?
close(0);
close(2);
可以看到前两个打开的文件获取到的文件描述符是0和2,之后打开文件获取到的文件描述符才是从3开始依次递增的。
如果我先关闭文件描述符为1的文件呢,此时文件描述符的分配又会是怎样的呢?
close(1);
结果竟然是空的:
在给出原因前,我们先总结文件描述符的
分配规则:
- 从头遍历数组fd_array[ ],找到一个最小的,没有被使用的下标,分配给新的文件。
而上述输出结果为空的原因就是:printf是往stdout输出的,stdout对应的就是1,根据fd的分配规则,fd就是1,虽然已经不再指向对应的显示器了,但是已经指向了log1.txt的底层struct file对象!正常情况下结果应该出现在log1.txt文件里,可是我们却并不能看到:
理论上来说输出的值是在loga.txt文件里的,这里我们在close关闭文件之前指向下面的语句即可:
fflush(stdout);
至于这里为什么必须要加fflush,这就和我们之前提到过的缓冲区有关了,并且我printf应该是往显示器上输出内容的,却直接输出到了文件里,这就是重定向。具体详情下文解释。
六、重定向
1.重定向的原理
*1*
*、输出重定向原理:*
- 输出重定向就是,将我们本应该输出到一个文件的数据重定向输出到另一个文件中。
如上图所示:
- 当我close(1)后,指针数组下标1的位置就不再指向标准输出流文件了,此时使用open函数打开一个log.txt文件, 根据文件描述符的分配规则:从头遍历数组fd_array[ ],找到一个最小的并且没有被使用的下标,分配给新的文件。找到的1就分配给了log.txt文件,于是乎log.txt的地址就填到1里头了,并把1返回给用户。
- 但是我标准库里头有个stdout,就是FILE,FILE内部又封装了fd,这个fd天然就是1,上述使用的fprintf就是向stdout打印,我上面所作的内容对应stdout来说是不知道的,它只知道要向1里写入,但其实已经被狸猫换太子了,自然数据就写入上文已经调整后的log.txt新文件了。
综上
,我们要进行重定向,上层只认0,1,2,3,4,5这样的fd,我们可以在OS内部,通过一定的方式调整数组的特定下标的内容(指向),我们就可以完成重定向操作了,
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 0;
}
//本来应该向显示器打印,最终却变成了向指定文件打印
fprintf(stdout, "open fd: %d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
注意:C语言的数据并不是立马写到了内存操作系统里头,而是写到了C语言的缓冲区当中,所以当使用printf打印完后需要使用fflush将C语言缓冲区当中的数据刷新到文件中
*2、追加重定向原理:*
- 追加重定向和输出重定向的唯一区别就是,输出重定向是覆盖式输出数据,而追加重定向是追加式输出数据。
例如:我们想让本应该输出到“显示器文件”的数据追加式输出到log.txt文件当中,那么我们应该先将文件描述符为1的文件关闭,然后再以追加式写入的方式打开log.txt文件,这样一来,我们就将数据追加重定向到了文件log.txt当中。
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
if (fd < 0)
{
perror("open");
return 0;
}
//本来应该向显示器打印,最终却变成了向指定文件打印
fprintf(stdout, "open fd: %d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
3、输入重定向<原理:
- 输入重定向就是,将我们本应该从一个文件读取数据,现在重定向为从另一个文件读取数据。
例如,如果我们想让本应该从“键盘文件”读取数据的scanf函数,改从log.txt文件中读取数据,那么我们可以打开log.txt文件之前将文件描述符为0的文件关闭,也就是将“键盘文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是0.
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
close(0);
int fd = open("log.txt", O_RDONLY);
if (fd < 0)
{
perror("open");
return 0;
}
char line[64];
while (1)
{
printf("<");
if(fgets(line,sizeof(line),stdin)==NULL) break; printf("%s",line);
}
fflush(stdout);
close(fd);
return 0;
}
*问:标准输出流1和标准错误流2对应的都是显示器,它们有什么区别?*
来看如下的代码,代码中分别向标准输出流和标准错误流输出了几行字符串:
#include<iostream>
#include<cstdio>
using namespace std;
int main()
{
//stdout
printf("hello printf 1\n");
fprintf(stdout, "hello fprintf 1\n");
fputs("hello fputs 1\n", stdout);
//stderr
fprintf(stderr, "hello fprintf 2\n");
fputs("hello puts 2\n", stderr);
perror("hello perror 2"); //stderr
//cout
cout << "hello cout 1" << endl;
//cerr
cerr << "hello cerr 2" << endl;
return 0;
}
直接运行程序,结果很显然就是在显示器上输出8行字符串:
这样看起来标准输出流和标准错误流并没有区别,都是向显示器输出数据。但我们若是将程序运行结果重定向输出到文件log.txt当中,我们会发现log.txt文件当中只有向标准输出流输出的四行字符串,而向标准错误流输出的两行数据并没有重定向到文件当中,而是仍然输出到了显示器上。
实际上我们使用重定向时,默认重定向的是文件描述符是1的标准输出流,而并不会对文件描述符是2的标准错误流进行重定向。 如果我现在也想重定向文件描述符为2的标准错误流呢?看如下的指令:
./process stdout.txt 2>stderr.txt
上述指令做了两次重定向,第一次把标准输出重定向到了文件描述符为1的显示器,第二次是把标准错误重定向到了文件描述符为2的显示器,上述把标准输出和标准错误区分开的意义是可以区分日常程序中哪些是输出,哪些是错误,我们需要对错误信息做区分。
前面已经提到,重定向只会默认把标准输出的进行处理,并不会重定向标准错误,如果我想让标准输出和标准错误一同混合起来到一个文件显示,看如下指令:
./process all.txt 2&1
此时会发现此指令让标准输出和标准错误输出到了同一个文件,画图解释:
- 上述指令让本来应该指向1的内容全部写到all.txt文件,随后把1里的内容拷贝到2里,再把2的内容写到all.txt文件。
补充:perror
- perror内部套用了errno(全局变量),目的是记录最近一次C库函数调用失败的原因。下面使用库函数的perror和自己模拟实现的my_perror分别测试一次:
库函数的perror:
自己实现的my_perror:
2.dup2
在Linux操作系统中提供了系统接口dup2,我们可以使用该函数完成重定向。dup2的函数原型如下:
函数名称 | dup2 |
---|---|
函数功能 | 复制一个文件描述符 |
头文件 | #include<unistd.h> |
函数原型 | int dup2(int oldfd,int newfd); |
参数 | oldfd:被复制的文件描述符 newfd:新的文件描述符 |
返回值 | >-1:复制成功,返回新的文件描述符 -1:出错 |
**函数功能:**dup2会将fd_array[oldfd]的内容拷贝到fd_array[newold]当中,如果有必要的话我们需要先使用关闭文件描述符为newfd的文件。
注意:
- 如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
- 如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
*补充:*
- 上述的拷贝是把旧的文件描述符下标oldfd的内容拷贝到新的文件描述符下标newfd,并非拷贝数字,最终只剩oldfd下标对应的内容。
示例1:输出重定向
- 抛去上文的close(1),这里我们新打开的文件log.txt分配的文件描述符理应为3,如下图所示:
我本来是向显示器打印的,现在想输出重定向到log.txt上, 也就是把log.txt的文件描述符3的内容拷贝到stdout文件描述符1里头去。
代码如下:
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 0;
}
int ret = dup2(fd, 1);
if (ret > 0) close(fd);//关闭旧的
printf("ret: %d\n", ret);//1
//本来应该向显示器打印,最终却变成了向指定文件打印
fprintf(stdout, "open fd: %d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
示例2:追加重定向
- 追加重定向仅仅是在打开文件的方式发生了改变,由原先的O_TRUNC变成了O_APPEND,其它的和输出重定向完全一样:
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 0;
}
int ret = dup2(fd, 1);
if (ret > 0) close(fd);//关闭旧的
printf("ret: %d\n", ret);//1
//本来应该向显示器打印,最终却变成了向指定文件打印
fprintf(stdout, "open fd: %d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
示例3:输入重定向<
- 输入重定向就是把从键盘读取数据改为重定向从文件读取数据,如下代码示例:
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
int fd = open("log.txt", O_RDONLY);
if (fd < 0)
{
perror("open");
return 0;
}
dup2(fd, 0);
char line[64];
while (fgets(line, sizeof line, stdin) != NULL)
{
printf("%s\n", line);
}
fflush(stdout);
close(fd);
return 0;
}