目录
前言:
我们上篇文章点此跳转末尾提到了文件描述符,本篇文章将会为大家补充文件描述符的有关内容,更加深刻的理解"Linux下一切皆文件"。最后,将会为大家介绍内核缓冲区与重定向的有关内容
一、理解一切皆文件
我们可能很难抽象的去想象,为什么键盘,显示器这些外部设备,能被理解为文件。
我们需要知道,操作系统要描述这些硬件,也是需要先描述再组织的。所以,当打开⼀个⽂件时,操作系统为了管理所打开的⽂件,都会为这个⽂件创建⼀个file结构体:
struct file{
...
struct inode *f_inode;
const struct file_operations *f_op;
...
atomic_long_t f_count;//引用计数
usigned int f_flags;
fmode_t f_mode;
...
}__attribute__((aligned(4)));
而在每一个struct file结构体对象里,都存在一个操作表。这个操作表,实际上就是一个函数指针集合,里面蕴含着这个结构体对象的各种封装的函数调用。
比如前面说的显示器,键盘这些外设,我们说他是文件,就是因为他们都具有一个struct file结构体。这个结构体就类比于人类,键盘和显示器,就类比为人类中的小明和小红个体。比如我说键盘,他就会具有一个独属于键盘的struct file结构体,然后在这个结构体对象里,存在一个操作表(函数指针集合),这个函数指针集合里面通过指针存放着各种的已经被封装好的函数,这些函数,底层又调用的时read,write,open这样的系统调用接口。使得我们从struct file的角度上看,我们是统一调度的读和写。
struct file 做了一层软件的封装,使得所以一切皆文件:
我(进程)只要通过PCB找到文件表示符表,通过一个一个的文件标识符,找到struct file对象,我就能调用读写,完成对底层硬件的访问。这叫做操作系统的VFS虚拟文件系统。
于是操作系统,就这样,可以通过封装好的同名函数,对不同的外设进行操作。上图中的外设,每个设备都可以有⾃⼰的read、write,但⼀定是对应着不同的操作⽅法!!但通过对操作表的各种函数回调,让我们开发者只⽤file便可调取 Linux 系统中绝⼤部分的资源!!这便是“linux下⼀切皆⽂件”的核⼼理解。
这个放在C++里,就是多态的应用。
二、为什么语言喜欢封装
1、方便用户使用
首先我们需要知道文本写入与二进制写入的区别。
计算机系统中,所有数据的本质都是二进制形式存储和传输的,但人类与计算机交互时需要更直观的表达方式,这就产生了文本和二进制两种数据表示形式的差异。
操作系统提供的系统调用(如Linux的read与wirte)本质上只能处理原始的二进制数据流。例如
// Linux系统调用:只能读写二进制字节流 ssize_t write(int fd, const void *buf, size_t count);
#include<stdio.h>
#include<unistd.h>
int main()
{
int a= 12345;
write(1,&a,sizeof(a));
return 0;
}
这个代码的运行结果为:90!
不管90代表什么含义,我们只想知道,为什么它打印出来的不是12345呢?
根本原因在于数据表示方式的错位:write()
系统调用直接输出了整数的原始二进制字节,而终端却将这些字节误解为ASCII字符在显示屏上进行显示。
显示屏是一个字符设备,所以会默认把这些当做ASCII码值,因此会按字节解析:
0x39
→ ASCII字符'9'
0x30
→ ASCII字符'0'
0x00
→ ASCII空字符(终端通常忽略或显示为空格)0x00
→ 同上
因此终端会依次显示:'9'
、'0'
,然后遇到空字符可能停止输出。
但是我们的printf("%d",a);却可以直接打印出12345,这又是为什么呢?
试看这样的打印结果:
int main()
{
int a= 12345;
// write(1,&a,sizeof(a));
printf("%c\n%d\n",a,a);
return 0;
}
输出结果为:
%c打印就与write的打印结果有什么区别呢)?
因为它们都涉及直接解释内存中的二进制数据,而不像 %d
那样进行格式化(所谓格式化,就是:内存级别的二进制数据转化为字符串风格)转换。
printf 做了以下操作:
截断
int
为char
:%c
只取a
的最低 1 字节(因为char
是 1 字节)。如果
a = 12345
(0x00003039
),取最低字节0x39
(即 ASCII'9'
)。
直接输出该字节对应的 ASCII 字符,不会进行数字到字符串的转换。
无论如何,系统调用都不能直接打印整数 。
而是需要做一定的变换:
int main()
{
int a= 12345;
// write(1,&a,sizeof(a));
char buffer[20];
int len = sprintf(buffer, "%d", a); // 整数转字符串
write(1, buffer, len); // 输出字符串
return 0;
}
所以才会有封装:我们需要一个格式化的过程:把内存级别的二进制数据转化为字符串风格在打印在显示器上。只有这样做,才能方便用户的使用。
同样的道理,我们在键盘中输入1234,是输入的整数1234呢?还是一个一个的字符。
肯定是字符!不是我们想要的整数,所以有scanf格式化输入(“%d”,&a)
为什么要&a呢?
scanf的底层是read,printf只需要 a
的值来显示,不需要修改它,但是scanf会修改,所以需要a的地址。
char buffer[100]; read(0, buffer, sizeof(buffer)); // 从标准输入(文件描述符0)读取原始字节
从buffer转换到->%d的过程我们就叫做输入格式化。
所以大家记住了,在操作系统角度上,没有文本的概念只有二进制。文本的概念是我们自己在语言层转化的
2、可移植性
Linux 的 write
和 Windows 的 WriteFile
功能相同,但接口完全不同。所以直接调用系统调用的代码无法跨平台运行。
于是语言需要统一接口,隐藏底层:
// 标准库的 printf 内部实现(伪代码) int printf(const char* format, ...) { #ifdef LINUX write(1, formatted_str, len); #elif WINDOWS WriteConsoleA(1, formatted_str, len); #endif }
用户只需调用 printf
,无需关心底层是 write
还是 WriteConsoleA
。
三、重定向
1、缓冲区
我们上文提到过,每一个文件都有自己的操作表,于此同时,也有自己的内核级缓冲区。
当我们在执行一个write接口时,write会根据PCB找到files_struct,随后通过文件描述符下标,找到对应地址,随后找到当前文件,随后会划分为两个步骤:
1、write会把输入的字符拷贝到文件的内核缓冲区
2、然后再去系统调用中调用file所指向的ops操作集合中所指向的函数方法,把缓冲区数据刷新到外设中
数据从内核缓冲区到外设的刷新 由操作系统控制,而非 write
直接完成
所以我们的write本质上是一个拷贝函数,作用把数据从用户层拷贝到内核缓冲区。
也是因为这样,word文档要求保存,你写只是写到了缓冲区,你点击保存,才是真正从内核刷新到外设。
这是write写入的过程,那么读取呢?修改呢??
本质是一样的。
读取由操作系统决定将数据从外设写入缓冲区,read负责拷贝。(所以容易出现堵塞,例如scanf())。
修改的话,进程肯定是无法在磁盘里修改的
一定是把当前文件的内容加载到内核缓冲区里,读取到用户空间,修改完在写回缓冲区,操作系统再自动刷新到外设,所以修改的本质也是先读取在写入。
为什么要存在这个缓冲区?
因为内存操作太快,而外设速度太慢。
1、合并多次小 I/O 操作
假如我要进行100次IO操作,他们分别进行刷新的话,效率就太慢了。所以我们存在这个缓冲区,如果前99次都在缓冲区缓存住,最后统一做一次刷新,就节省了99次的IO时间。
2、预读优化
缓冲区不仅缓存写入,还预读可能访问的数据(如顺序读取文件时提前加载下一块),当然前提是内存有空闲。
2、重定向
有以下代码:
#include<stdio.h>
#include<unistd.h>
#include <fcntl.h>
int main()
{
int fd1=open("log1.txt",O_WRONLY | O_CREAT |
O_TRUNC
, 0666);int fd2=open("log2.txt",O_WRONLY | O_CREAT |
O_TRUNC
, 0666);int fd3=open("log3.txt",O_WRONLY | O_CREAT |
O_TRUNC
, 0666);int fd4=open("log4.txt",O_WRONLY | O_CREAT |
O_TRUNC
, 0666);printf("%d\n",fd1);
printf("%d\n",fd2);
printf("%d\n",fd3);
printf("%d\n",fd4);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
运行结果:
如果我们在代码最前面增加一个close(0)呢?
打印结果改变了,如果改成2呢?
我们知道进程打开一个文件,需要给文件分配新的fd。从上面我们可以推测,fd的分配规则为:分配最小的且没有被使用的下标位置。
如果我们关闭1呢?
怎么什么都没有打印呢?
按照上面的规则,此时1应该变成了log1.txt的文件描述符,我们关闭了标准输出流,也就是显示屏他打印不出来很正常。
但是为什么也看不了文件里面的内容呢?
再加一个fflush试试:
int main()
{
//close(0);
//close(2);
close(1);
int fd1=open("log1.txt",O_WRONLY | O_CREAT | O_TRUNC , 0666);
int fd2=open("log2.txt",O_WRONLY | O_CREAT | O_TRUNC , 0666);
int fd3=open("log3.txt",O_WRONLY | O_CREAT | O_TRUNC , 0666);
int fd4=open("log4.txt",O_WRONLY | O_CREAT | O_TRUNC , 0666);
printf("%d\n",fd1);
printf("%d\n",fd2);
printf("%d\n",fd3);
printf("%d\n",fd4);
fflush(stdout);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
这样就显示出来了。
为什么没有fflush时,log1.txt中看不到内容?
根本原因是 printf
的输出被缓冲在内存中,尚未写入文件,而程序结束时没有触发缓冲区的自动刷新。
本来应该像显示器文件中写入数据,结果却写入到了文件中?
这,就是重定向!
但是实际上的重定向,应该是把myfile的地址给了1,再把3指向的消除,就完成了重定向。
这个过程由一个函数:dup2完成:dup(3,1)
int main()
{
int fd=open("log.txt",O_WRONLY | O_CREAT | O_TRUNC , 0666);
dup2(3,1);
printf("hello fd:%d\n",fd);
fprintf(stdout,"hello fd:%d\n",fd);
fputs("hello fputs\n",stdout);
const char *message="hello fd\n";
fwrite(message,strlen(message),1,stdout);
return 0;
}
我们把3的文件给了1,所以1是newfd,而3是oldfd。
一个文件可以被多个文件描述符指定,而这,正是为什么结构中含有引用计数的原因:
3、shell里面的重定向
我们知道,在shell里面可以通过<,>,>>完成输出重定向,输入重定向,追加重定向。那么在SHell中的重定向又是如何完成的呢?
首先,我们需要识别并提取重定向相关的信息。
比如从后面一个一个开始循环判断是否出现字符'<'、'>'、'>>'。
若出现,就开始把这行命令划分为两部分,后面的就是我们的文件名。
随后,通过通过 fork()
创建子进程,并在子进程中完成文件描述符的重定向。
使用
open()
打开目标文件。用
dup2()
将标准输入/输出(0
/1
)重定向到文件。
由于进程切换是不会影响重定向的结果,所以子进程哪怕执行其他命令,其他的代码,重定向了当前文件,就还是会输出(输入于)到这个文件 。
总结:
"一切皆文件"。这可不是一句空话,而是贯穿整个操作系统设计的核心理念。
想象一下,你在Linux里操作键盘输入、显示器输出、磁盘读写,甚至操作进程,用的都是同一套API:open、read、write、close。这不是很神奇吗?背后的秘密就在于VFS(虚拟文件系统)这个精妙的设计。每个硬件设备在内核里都被抽象成了一个struct file结构体,就像给每个硬件设备都发了一张"身份证"。
这个struct file也不简单,它里面藏着一个操作表,相当于这个设备的"技能树"。比如键盘有键盘的read方法,显示器有显示器的write方法,但对外都统一叫做read和write。这不就是面向对象里的多态吗?C++程序员看到这里应该会心一笑。
说到系统调用,这里有个很有意思的现象。我们直接调用write输出整数时,终端显示的却是乱码。为啥?因为write是个老实人,它真的就是把内存里的二进制原封不动地扔给终端。而终端这个"死脑筋"又把每个字节当成ASCII码来显示。这时候printf这个"聪明人"就出场了,它会把整数格式化成字符串再交给write,这就是语言层封装的价值。
再说说重定向这个魔术。当我们把标准输出(文件描述符1)指向一个文件时,后续所有printf的输出就都跑到文件里去了。这背后的魔法就是dup2函数,它能把一个文件描述符"变成"另一个。比如dup2(fd,1)之后,1就不再指向显示器,而是指向fd对应的文件了。Shell里的>、<这些符号玩的也是这个把戏。
不得不提的还有内核缓冲区这个"中间商"。write操作其实很懒,它只是把数据从用户空间拷贝到内核缓冲区就完事了,真正的写入操作是由操作系统在后台悄悄完成的。这就好比你在Word里打字,没点保存之前内容其实还在内存里。缓冲区的存在让多次小写入可以合并成一次大写入,效率直接起飞。
最后说说这个设计的精妙之处。通过"一切皆文件"的抽象,Linux把复杂的硬件操作统一成了简单的文件操作。开发者再也不用为每个硬件写不同的代码,一套read/write走天下。这种设计不仅优雅,还极具扩展性——新硬件只要实现那几个标准的文件操作接口,就能无缝融入系统。
如果本文对你有所帮助,感谢你一键三连支持!
如果有疑问或者指正欢迎私信或者评论区留言 !!!