本篇是我们该如何理解OS对文件如何写入和输出,通过具体的语言如何实现文件操作在到如何利用系统调用来完成文件输入输出,理解一个重要的概念“重定向”,和OS去描述他们的文件描述符fd(file descriptor)。
废话不多说,我们直接开始:
目录
一、理解Linux下一切皆文件
相信诸位学习过Linux的小伙伴对这句话不陌生吧。Linux下一切皆文件,也就是说在冯诺依曼体系下的任何东西,均可视为文件?为什么能这么说呢?
你还记得最初从电脑建立的那个空白文件夹吗?我们从那里说起吧。
1.文件的理解
普通文件的理解
文件是什么?
文件属于文件的一种,与普通文件载体不同,文件是以硬盘为载体存储在计算机上的信息集合。
文件可以是文本文档、图片、程序等等。文件通常具有点+三个字母的文件扩展名,用于指示文件类型(例如,图片文件常常以JPEG格式保存并且文件扩展名为.jpg)。(来自百度百科)
这就是我们平时所能理解的文件,一个文件通常包含它的属性和内容。
文件 = 内容 + 属性
那么这里的属性也是这个文件的数据吗?当然是,你想想,一个空白文件,在磁盘上占空间吗?自然占,属性需要占用空间,里面存放关于此文件的一些基础信息(创建时间,修改时间,文件名.....)
磁盘上存放着文件,我们难免会对文件进行操作。想一想,我们对文件有什么操作呢?
对文件的所有操作:1.对内容 2.对属性
对内容是经常性的,属性也是如此。
对文件的操作
那么在语言中(比如C语言)我们访问文件并且修改的本质又是什么呢?
我们可以顺着如下思路进行想象:
首先,文件是放在磁盘上的。
我们利用程序去访问磁盘,该C语言程序经过编译后形参exe(可执行文件),运行加载入内存变成进程。
进程进行访问文件->通过操作系统的接口进行访问。
我们可以在观察一下冯诺依曼体系:
要向磁盘进行写入,即输出设备,那么只有执行CPU也就是操作系统才有权利进行写入。(操作系统是硬件的管理者)(PS:想要更进一步了解Linux操作系统和进程之间的关系可以看一下这篇文章哦:【Linux】从冯诺依曼体系到初识Linux下的进程_柒海啦的博客-CSDN博客)
普通用户想要写入,那么就必须要操作系统提供的接口才能对文件进行操作。
语言接口和操作系统接口的区别
那么,从这里开始,就要分清楚,为什么语言会提供访问文件的接口而不直接用操作系统的系统调用了。
1.语言上对此进行封装,为了让接口更加贴切语言,更加好用 -> 导致了不同的语言级别的文件访问接口(都不一样),但是都是用的系统接口。系统接口使用成本高,不贴切语言。
2.跨平台 -- 如何语言不提供对文件的系统接口的封装,那么所有的文件操作,就必须使用OS的接口 -- 这样写出来的代码不具备跨平台性!-- 语言就把所有OS的接口封装实现,条件编译,动态裁剪即可。
我们学习系统调用接口的目的在于更好的了解底层访问文件的步骤,虽然每一种语言的文件io部分的接口不同,但是本质都是通过操作系统去访问文件的,只要本质不变,我们就能更加理解文件IO了。
理解广义文件
那么,我们来回忆一下C/C++内<stdio.h><iostream>文件里的接口:
printf/cout 向显示屏写入数据
scanf/cin 向键盘读取数据
写入和读取,是不是一种文件操作呢?
现在,对文件我们可以进行一个感性的理解:
语言对文件的操作就是对文件内容的读和写操作(read、write),上面的printf/cout就是程序在对显示屏进行写入数据,显示屏才能显示出来,而scanf/cin则是程序从键盘中读取数据,存放入此进行的地址内存空间内。但是,这里的显示屏和键盘难道不是硬件吗?是的,也是文件。
文件的读和写:
普通文件- > fopen/fread -> 你的进程内部(内存)-> fwrite -> 文件中
(input) (output)
那么在整理一下什么叫文件:
站在系统的角度,能被input,output的设备就叫做文件!
狭义的文件:普通的磁盘的文件
广义的文件:显示器、键盘、网卡、声卡、显卡、磁盘.....基本所有的外设,都可被称作文件
那么,现在我们来简单的回忆一下C语言接口来操作文件。
2.利用C接口操作文件
打开文件
首先,我们需要打开文件:
头文件依赖:#include <stdio.h>
函数原型:FILE *fopen(const char *path, const char *mode);
path:
绝对路径/相对路径
mode:
打开方式:w/w+/r/r+/a/a+ ...
w:只写,清空当前文件内容(没有会创建此文件(当前路径下创建对应文件名))
r:只读。
+:可读可写
a:每次在此文件数据末尾进行追加(没有会创建此文件)
使用FILE对象接收(FILE实际上是C语言里的一个结构体)
这里可以稍微谈一下路径这一说法,如何理解此进程的当前路径呢?
比如,我当前写了一个程序,现在正在被运行,我们可以利用命令:ls /proc/pid -l 来查看此进程的相关属性:
如上图所示,其中进程属性cwd为当前工作目录,exe为此程序路径。
关闭文件
打开文件后,如何关闭呢?关闭就非常简单:
int fclose(FILE *fp);
fp就是对应的文件对象,此时该文件就不会在从内存中读取到了。
写操作output
(下面没有列举全部,只是举例部分)
依赖头文件:<stdio.h>
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
描述:
返回值:写入成功,返回写的项数,单位为字节数。发生错误或者文件末尾到达时,返回值是简短的项目计数(或0)
ptr 写入的字符串
size:每个数据的大小(单位字节)
nmemb:数据个数
stream:被写入的文件。
int fputc(int c, FILE *stream); 写入一个字符
int fputs(const char *s, FILE *stream); 写入一个字符串。成功返回非负数,否则返回EOF。
int fprintf(FILE *stream, const char *format, ...);
成功时,返回输入字符数,遇到错误返回负数
现在我们利用前面的打开文件和写操作来具体利用代码实现一下:
比如,现在我们想要对当前路径下test.txt文件输入Hello,Linux!。没有的话就创建,你来试试吧~
-
#include <stdio.h>
-
#include <string.h>
-
-
int main()
-
{
-
FILE* fp =
fopen(
"test.txt",
"w+");
// 使用绝对路径来进行查找,读写操作,没有会进创建对应文件,有会将其数据清空
-
const
char* ptr =
"Hello, Linux!\n";
// 输入我们想要写入的字符串
-
fwrite(ptr,
1,
strlen(ptr), fp);
// 输出字符串 每个数据大小 多少个数据 目标写入文件
-
fclose(fp);
// 不要忘记关闭文件哦
-
return
0;
-
}
此时就可以发现在当前工作目录下会存在一个test.txt文件,我们将它打印出来就可以发现数据已经写入啦:
其余接口也可以按照如上的操作,这里特别的演示一下和printf很像的fprintf,它也是格式化输出数据,只不过不是像printf那样指向的是显示屏:
我们利用此接口向test.txt写入你好,Linux!。试试吧:
-
#include <stdio.h>
-
#include <string.h>
-
-
int main()
-
{
-
FILE* fp =
fopen(
"test.txt",
"w+");
// 使用绝对路径来进行查找,读写操作,没有会进创建对应文件,有会将其数据清空
-
const
char* ptr =
"你好, Linux!\n";
// 输入我们想要写入的字符串
-
//write(ptr, 1, strlen(ptr), fp); // 输出字符串 每个数据大小 多少个数据 目标写入文件
-
fprintf(fp,
"%s", ptr);
-
fclose(fp);
// 不要忘记关闭文件哦
-
return
0;
-
}
C语言默认打开的三个流
可以发现,fprintf的使用方式和printf果然很像,实际上,我们知道,C语言程序会默认打开三个文件。
stdin,stdout,stderr(标准输入、标准输出、标准错误),第一个对应的是键盘(意思是打开的文件是键盘),第二和三个对应的是显示屏。(后面会细说)
printf实际在fprintf的基础上吧传入文件的位置替换为了stdout而已。
比如如下程序就可以让fprintf模拟实现printf:
-
void test2()
-
{
-
const
char* ptr =
"你好, Linux!\n";
-
fprintf(stdout,
"%s", ptr);
-
}
可以发现,我们以运行此程序,就会给我们打印到屏幕上了。这样就模拟实现了printf了。
读操作input
依赖头文件 <stdio.h>
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
描述:返回值:读入成功,返回读的项数,单位为字节数。发生错误或者文件末尾到达时,返回值是简短的项目计数(或0)
ptr 读取存入的指针
size:每个数据的大小(单位字节)
nmemb:数据个数
stream:被读取的文件。
fgets、fscanf.....
类似于写操作,这里不再多赘述,向了解更多细节可以查文档或者看我前言的C语言文件操作的传送点哦~
上述就大致是C语言中的文件操作了,我们知道,这是语言级别的接口,内部肯定封装了系统接口。那么现在我们想要利用系统接口实现文件操作该如何去做呢?下面我们会开始理解系统调用接口,并且接触到类似于C中定义的FILE结构体的东西,同时也是Linux内核内对文件描述的关键东西-文件描述符fd。
二、文件描述符fd
1.使用系统接口操作文件
我们知道,操作文件无非就是写和读,系统调用同样如此。下面操作文件相关的系统调用函数只有四种(加了一种指向输入或输出流位置的系统调用,C语言接口对应的也有):
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);描述:
pathname:绝对路径/相对路径
flags:类型的选项*
类型的选项:
O_RDONLY 只读
O_WRONLY 只写
O_RDWR 读写
O_CREAT 不存在此文件就创建
O_TRUNC 写入时清空
O_APPEND 追加
......
mode:权限(Linux 用八进制表示)
返回值:返回新的文件描述符,创建失败返回-1.
我知道你此时的脑袋一定很晕,没事,我们一个一个结合着C语言的文件操作来说。
相信路径就不用在陈述了吧。现在关键是落在了类型的选项身上。
我们知道,在C语言中是以w,r,a这些字母开始的,那么这些所谓选项的类型翻译过来好像和C有点类似,但是又不一样,关键是这些都是整数类型(宏定义)呀,而且这些要如何组合在一起发挥作用呢?
位图操作
此时,类型组合就和位图相关了:(百度位图法:位图法就是bitmap的缩写,所谓bitmap,就是用每一位来存放某种状态,适用于大规模数据,但数据状态又不是很多的情况。通常是用来判断某个数据存不存在的)
我们可以用下面这个程序来进行举例,这样我们就可以快速掌握其用法了:
-
#define S_GOOD 0x1 // 学生好选项
-
#define T_GOOD 0x2 // 老师好选项
-
#define L_GOOD 0x4 // 领导好选项
-
-
void test3(int flag)
-
{
-
if (flag &
0x1)
printf(
"学生好\n");
// 001
-
if (flag &
0x2)
printf(
"老师好\n");
// 010
-
if (flag &
0x4)
printf(
"领导好\n");
// 100
-
}
-
-
void test4()
-
{
-
printf(
"----------------------------\n");
-
test3(S_GOOD);
-
printf(
"----------------------------\n");
-
test3(T_GOOD);
-
printf(
"----------------------------\n");
-
test3(L_GOOD);
-
printf(
"----------------------------\n");
-
test3(S_GOOD | T_GOOD);
-
printf(
"----------------------------\n");
-
test3(S_GOOD | L_GOOD);
-
printf(
"----------------------------\n");
-
test3(S_GOOD | T_GOOD | L_GOOD);
-
printf(
"----------------------------\n");
-
}
可以发现,我们可以利用位运算来完成我们对于选项的选择。因为当选项过多的时候,我们发现int4字节32个比特每个比特为1和0均可代表一种状态,我们只需借助位运算判断此状态是否为1就可以确定了,这也是打开文件的类型选项的原理。
那么我们想要执行多种状态的时候只需要|连接起来即可。而mode是指在创建新文件的时候(不创建新的文件使用第一个接口即可),给定权限用的,想具体了解Linux下权限相关知识可以传送到这篇文章哦:【Linux】权限管理_柒海啦的博客-CSDN博客
close关闭文件
close:关闭文件
依赖头文件:
#include <unistd.h>
函数原型:
int close(int fd);
write写入文件
write:写入文件
依赖头文件:
#include <unistd.h>
函数原型:
ssize_t write(int fd, const void *buf, size_t count);
描述:
fd:文件描述符指定的对应写入文件
buf:写入的数据
count:写入的字节数
返回值:成功返回写入的字数,否则返回-1
read读出文件
read:读出文件
依赖头文件:
#include <unistd.h>
函数原型:
ssize_t read(int fd, void *buf, size_t count);
描述:
fd:文件描述符指定的对应写入文件
buf:写入的数据
count:写入的字节数
返回值:成功返回读出的字数,否则返回-1
(注意读入后不会后面字符串自动加\0(C接口是这样的))
lseek指定位置
lseek:指定位置(C语言类似的接口库函数是fseek)
依赖头文件:
#include <sys/types.h>
#include <unistd.h>函数原型:
off_t lseek(int fd, off_t offset, int whence);
描述:
fd:文件描述符指定文件
offset:指定偏移量
whence:指令,从哪里开始偏移:
SEEK_SET
偏移量设置为偏移字节。
SEEK_CUR
偏移量设置为当前位置加上偏移量字节。
SEEK_END
偏移量设置为文件大小加上偏移字节。
当了解了上述系统调用后,我们可以具体举出一些实例来调用这些系统调用来完成我们的操作:
比如我们让其读取test1.txt文件,清理完后写入i like Linux 然后再从文件中读取打印到显示屏上:
-
void test5()
-
{
-
int fd =
open(
"test.txt", O_RDWR | O_CREAT | O_TRUNC,
0666);
// 可读可写 没有此文件创建 打开文件前清空 文件访问权限(最后和掩码要过滤
-
if (fd <
0)
-
{
-
perror(
"open error");
-
return;
-
}
-
const
char* buf =
"i like Linux!\n";
-
ssize_t ret =
write(fd, buf,
strlen(buf));
// 文件描述符 字符缓冲区 写入的字节数大小
-
if (ret <
0 )
-
{
-
perror(
"write error");
-
return;
-
}
-
// 写入后开始读入要求从头开始,此时偏移量为上次写入的最后位置,所以需要用到修正偏移lssek
-
lseek(fd,
0, SEEK_SET);
// 0 从头开始
-
char buff[
1024] = {
0};
-
ret =
read(fd, buff,
1023);
// 保证最后为\0 实际上没有1023个字节的数据 读完即可
-
if (ret <
0)
-
{
-
perror(
"read error");
-
return;
-
}
-
else
if (ret ==
0)
-
{
-
printf(
"end of file\n");
// 读入了0个字符
-
return;
-
}
-
printf(
"%s\n", buff);
-
close(fd);
// 关闭文件
-
-
}
文件和显示屏都完成了输入!
2.fd和再次理解一切皆文件
进程与文件描述符指向内容
明白了上述接口后,fd究竟是什么东西呢?一个int类型,竟然能够指向一个文件吗?
首先理解一下进程是如何打开文件的。
进程想要打开文件,文件必然加载入内存(内存文件)。文件原本在磁盘上存储着(文件 = 属性 + 内容)(磁盘文件)。
一般而言,一个进程可以对多个文件,也就是说有多个文件要载入内存。
那么多个进程呢?这样的话内存内就会存在大量的文件,那么OS就需要把这些数据管理起来(先描述,在管理)。
因此,在内核中:OS为了管理文件,会构建struct file{struct* next / prev // 包含此文件内的全部内容}打开一个就会创建一个具体的对象,然后用双链表将其组织起来。为了方便系统或者用户进行调用,OS用数组来进行存储:struct file* array[32]; 此时这数组里分别就会执行此进程内打开的struct对象。
上述图可以表示文件和进程之间的关联:【PCB struct task_struct -> (成员 -- 一个进程打开的文件信息)files_struct (结构体)-> fd_array(指针数组) 类型就是(struct file)(进程管理)】->【此结构体就是包含的此文件的所有信息。(文件管理)】
总结:fd本质就是一个数组下标。
文件描述符中的0&1&2
当我们随机打开一个文件,加载入内存后会给我们返回一个新的文件描述符,我们可以看看具体的数值下标是多少:
下标是3?难道是从3开始?我们打开多个文件试试:
-
void test6()
-
{
-
int fd1 =
open(
"test.txt", O_RDWR | O_CREAT | O_TRUNC,
0664);
-
int fd2 =
open(
"test1.txt", O_RDWR | O_CREAT | O_TRUNC,
0664);
-
int fd3 =
open(
"test2.txt", O_RDWR | O_CREAT | O_TRUNC,
0664);
-
printf(
"%d\n", fd1);
-
printf(
"%d\n", fd2);
-
printf(
"%d\n", fd3);
-
close(fd1);
-
close(fd2);
-
close(fd3);
-
}
果然是从3开始递增的,这是为何?
还记得一开始讲C接口的时候提到的默认打开三个文件吗?stdin,stdout,stderr。是的,因为编译器默认打开这三个位置,所以fd从0开始,012这三个位置就被这三个文件所占。
我们首先要明确fd的分配规则:
fd分配规则:最小的,没有被占用的文件描述符。
内核里用fd指向文件,那么在C接口进行包装的时候,FILE结构体肯定也对fd进行了包装,如下我们可以利用其进行验证我们的默认打开三个文件的fd:
果然是这样的,FILE结构体力成员_fileno实际上就是封装内核的文件描述符。那么stdin对应的就是0,stdout对应1,stderr对应2。分别就是键盘、显示屏、显示屏。
重定向&追加重定向引入
那么我们这里能否改变一下呢,如果我们提前关闭了默认打开的文件呢?比如关闭显示器stdout这个文件,在执行printf会发生什么后果呢?我们来测试一下:
-
void test8()
-
{
-
close(
1);
-
int fd =
open(
"test.txt", O_RDWR | O_CREAT | O_TRUNC,
0666);
-
if (fd <
0)
-
{
-
perror(
"open error");
-
return;
-
}
-
printf(
"Linux nice!\n");
-
fflush(stdout);
// 目前暂时不关心为什么要刷缓冲区,在后面讲缓冲区的时候在讲
-
close(fd);
-
}
运行程序后,结果显而易见。printf原本输出到显示屏上被偷换到我们打开的test.txt文件上了。
这是因为我们之前编译器默认打开了stdout即显示屏这个尾文件,内核里的文件描述符fd指向它,我们切断1和显示屏的关系,使其指向null,然后打开文件。我们知道文件默认的分配规则,最小的,没有被占用,此时1就没有被占用,所以1就接上了我们打开的这个文件。printf实际上就是fprintf打开的是stdout文件而已,此时stdout->_fileno == 1,所以自然的就输出给了我们对应打开的文件。如下图所示:
、
对于追加重定向同理,只不过一开始不用清理,并且加上条件O_APPEND即可。
-
void test9()
-
{
-
close(
1);
-
int fd =
open(
"test.txt", O_WRONLY | O_CREAT | O_APPEND,
0666);
-
if (fd <
0)
-
{
-
perror(
"open error\n");
-
return;
-
}
-
printf(
"谢谢你!Linux!\n");
-
fflush(stdout);
-
close(fd);
-
}
但是真正的重定向可不是这样的哦,上面只是为我们展示了重定向的原理,下面才是重定向的重头戏呢。
dup2重定向
函数引用头文件:#include <unistd.h>
函数原型:int dup2(int oldfd, int newfd);
解释:
oldfd和newfd均是fd文件描述符。将原本newfd的指向改为指向oldfd指向的内容。
目标位置:oldfd指向的文件。
返回值:如果成功,这些系统调用将返回新的描述符。出现错误时,返回-1,并适当设置errno。
我们可以使用dup2实现上述的两个功能,这样就不用close关闭显示器文件了。
-
void test10()
-
{
-
// 输出重定向
-
int fd =
open(
"test.txt", O_WRONLY | O_CREAT | O_TRUNC,
0666);
-
dup2(fd,
1);
// 让fd指向的文件,重定向到1指向的位置,即让1也指向fd所指向的文件
-
printf(
"dup2 test\n");
-
close(fd);
-
}
-
void test11()
-
{
-
// 追加重定向
-
int fd =
open(
"test.txt", O_WRONLY | O_CREAT | O_APPEND,
0666);
-
dup2(fd,
1);
// 让fd指向的文件,重定向到1指向的位置,即让1也指向fd所指向的文件
-
printf(
"dup2 Linux\n");
-
close(fd);
-
}
一切皆文件
Linux的设计哲学是体现在操作系统的设计层面的。不要忘了,Linux也是C语言写的,那么C语言要如何写面向对象的语言呢?甚至运行时多态?类、成员方法 -- 使用struct以及函数指针来进行设计。
Linux下的外设:
设备 磁盘 显示器 键盘 网卡 显卡 ......
i read() read() read() read() read() .....
o write() write() write() write() write() .....
结合外设的设计集合,以及C语言的特性,总结出一下几点:
1底层不同的硬件,对应不同的操作方法。
2但是上面的设备都是外设,所以每一个设备的核心访问方法都可以是read、write。(IO设备)
结合上述两点,所有的设备,都可以有read、write函数,但是函数的代码实现不一样罢。所以,利用C语言中的struct结构体,创建两个方法指针即可。这样就都统一为了struct file -- Linux皆为文件。实现这些函数的开发就是驱动开发。
文件基础IO未完待续,敬请关注哦~