1.c语言中的文件输入输出
在观看本章节的时候,大家可以去看看之前c语言的文件输入输出的有关博客。
好那么废话不多说之间进入本章正题:
我们都知道c程序默认会打开三个输入输出流:
-
stdin:相当于键盘
-
stdout:相当于显示器
-
stderr:相当于显示器
这些输入输出流的类型为FILE* ,其由c语言提供,当作文件来处理
下面我将引进一个概念,其可能在现阶段听起来很突兀,不过随着之后的逐步讲解会越来越感同身受-----一切皆文件法则(脑海中先有这个意识就好)
2.文件的调用接口介绍
在我们介绍之前先回想一下一些我们曾经用过的文件操作细节:
比如fputs函数是向一般文件或者硬件设备写入,其本质是在磁盘上写,由此可推测我们之前所设计到的几乎所有语言的有关文件操作符的底层一点需要使用os的系统调用。
2.1linux下系统调用函数open介绍
open 函数具体使用哪个,和具体应用场景相关,如目标文件不存在,需要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: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。(其实就是位运算,这些选项可以看出#define 提前设置好的)
参数:
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写
返回值:
成功:新打开的文件描述符
失败:-1
2.2write ,read, close, lseek介绍
在Linux下,这几个函数用于文件IO操作:
- write: 将数据写入文件,语法为
write(fd, buf, count)
。其中fd是文件描述符,buf是存放数据的缓冲区,count是要写入的字节数。 - read: 从文件读取数据,语法为
read(fd, buf, count)
。和write类似,读取fd文件描述符的文件,读取的数据存放在buf缓冲区,读取的最大字节数为count。 - close: 关闭文件,语法为
close(fd)
。关闭文件描述符fd对应的文件。 - lseek: 移动文件读写指针,语法为
lseek(fd, offset, whence)
。它可以移动fd文件的读写指针,offset是移动的字节数,whence可以是:
SEEK_SET: 相对文件开始处移动
SEEK_CUR: 相对当前位置移动
SEEK_END: 相对文件结束处移动
具体使用例子:
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
// 打开文件
int fd = open("test.txt", O_RDWR);
// write写入10个字节
char buf[10] = "hello";
write(fd, buf, 10);
// lseek移动5字节
lseek(fd, 5, SEEK_SET);
// 读5个字节
char buf2[5];
read(fd, buf2, 5);
printf("%s\n", buf2); // world
// 关闭文件
close(fd);
}
2.3open的返回值
总结一下上面的内容:上面的 fopen fclose fread fwrite 都是C标准库当中的函数,我们称之为库函数(libc)。
而, open close read write lseek 都属于系统提供的接口,称之为系统调用接口,回顾之前的一张os各部分分层图系统调用接口和库函数的关系,一目了然。
所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。
从上面open的描述中可以知道,其返回值类型是一个int整形
Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2.
0,1,2对应的物理设备一般是:键盘,显示器,显示器
所以输入输出还可以采用如下方式:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
int main()
{
char buf[1024];
ssize_t s = read(0, buf, sizeof(buf));
if(s > 0){
buf[s] = 0;
write(1, buf, strlen(buf));
write(2, buf, strlen(buf));
}
return 0;
}
2.4files_struct
其实所有文件操作本质上是进程去执行一些对应的函数,进程对文件操作时,必须先打开文件,而打开文件的本质是把文件的相关属性加载到内存中。
一个进程往往可以打开多个文件,所以操作系统得去管理,那怎么去管理呢?
我们的老话"先管理,在组织"便派上了用处,原来在每个进程的pcb的结构体中给了一个指针*file指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件
如图:(这里补充说明一点,这个数组当中存的地址实际上是对应文件的虚拟地址)
2.5从硬件层在来看问题
实质上不同硬件的读写方式是不一样的,而为了解决这一问题 ,vfs(虚拟文件系统)便诞生了
vfs不关心到底是什么外设,只是创建一个又一个一模一样的结构体(files_struct)以文件模仿硬件,一切皆文件这个概念在这里再次显现!
3.文件描述符的分配规则
规则:给新文件分配的fd,是从fd_array中找一个最小的,没有被使用的分配出去,作为新的fd。(0,1,2默认被打开) -----不管什么情况牢记这个规则就行了
4.重定向
先来看这样的一段代码:
int main()
{
close(1);
int fd = open("./log.txt",O_CREAT | O_WRONLY , 00644);
printf("hello world\n");
}
会观察到这样的一个现象------所有的文件都没有在屏幕上打印出来,而是写入了log.txt文件内部,这其实就是输出重定向的现象。
为什么呢?
这张图解释的就很好。printf为c语言中的打印本质是向标准输出打印(stdout 类型为FILE* 实质上为语言层上的结构体),可以推测出这个FILE 结构体中一定包含了一个整数,是对应在系统层面的打开这个文件所对应的fd,而我们的close相当于把fd为1的数组里面置换成了nullptr,而此刻在给open打开的文件分配fd的时候,按照文件描述符的分配规则,分配到了1,所以此时printf实际上是在文件当中写入内容。
5.dup2系统调用
上面的例子是在文件未打开的时候用了close并利用文件打开时新分配fd来实现重定向的,那么我们应该如何在文件已经打开后完成重定向呢?
dup系列的函数可以做到这一点。
dup2() 是一个系统调用函数,用于复制文件描述符。它可以将一个已经打开的文件描述符复制到另一个文件描述符,从而使得这两个文件描述符都指向同一个文件、管道或者 socket 等。
dup2() 函数的定义如下:
#include <unistd.h>
int dup2(int oldfd, int newfd);
其中,oldfd 是要被复制的文件描述符,newfd 是要复制到的文件描述符。dup2() 函数返回值为新的文件描述符,如果出现错误则返回 -1。
当 dup2() 调用成功后,newfd 所对应的文件描述符将指向与 oldfd 所对应的文件相同的文件对象。如果 newfd 已经打开,dup2() 函数会先关闭 newfd,然后将其复制为 oldfd。如果 oldfd 和 newfd 相等,则不执行任何操作。
dup2() 函数一般用于重定向标准输入、标准输出和标准错误输出。例如,如果我们想要将命令行程序的标准输出重定向到文件,可以使用 dup2() 函数将标准输出文件描述符(stdout)复制到文件描述符对应的文件上,代码示例如下:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int main() {
int fd = open("output.txt", O_WRONLY | O_CREAT, 0644);
dup2(fd, STDOUT_FILENO);
printf("Hello, world!\n"); // 该语句的输出将被重定向到 output.txt 文件中
close(fd);
return 0;
}
在上述示例中,我们首先通过 open() 函数打开了一个名为 “output.txt” 的文件,并将其文件描述符保存在变量 fd 中。随后,我们使用 dup2() 函数将标准输出文件描述符(STDOUT_FILENO)复制到 fd 对应的文件上,这样,所有输出语句(如 printf())的输出就会被重定向到 “output.txt” 文件中。最后,我们关闭了文件描述符 fd 并退出程序。
需要注意的是,如果在使用 dup2() 函数时 newfd 所对应的文件描述符已经被打开,那么它将会被先关闭,这意味着对于 newfd 所对应的文件的任何更改都将丢失。因此,在使用 dup2() 函数时,应该确保 newfd 不会对应任何重要的文件描述符,或者在调用 dup2() 函数之前先保存 newfd 的状态。
6.对FILE的进一步理解
6.1概念
其实在我们上文当中就讲过这个问题,其本质就是定义了c语言的缓冲区并且拥有fd的一个由c语言提供的结构体。(在c语言层面上使用类似于fwrite,printf这样的函数时,实际上就是在向文件写入时,这些数据会先放在c语言层面的缓冲区上缓存以提高I/O性能)
当进程退出的时候,会刷新FILE内部的数据到OS的内核缓冲区。
6.2文件内核缓冲区(这里其实是个略讲,后面会有详细讲解)
先看一张图:
文件内核缓冲区是操作系统内核中用于文件I/O的缓冲区。具体来说,当进行文件读操作时,内核会将文件的数据块读取到内核缓冲区中,然后在刷新到用户缓冲区中。当进行文件写操作时,数据会先被写入用户缓冲区中,然后再被刷新到内核缓冲区中,最后再被写入磁盘或其他设备中(这两种情况实质就是发生系统调用时所发生的)。为了减少数据刷新的次数和性能损失,C 语言提供了多种缓冲区操作函数,如 fread()、fwrite()、fgets()、fputs() 等,它们可以直接访问用户缓冲区,避免了多次数据的多次刷新。
为什么能减少次数呢?这里给大家举个具体例子:
如果要调用write写10次一次写一个字节,那么就得要从用户态切换回内核态10次,如果此时加上了用户级缓冲区,那么这10次写的字节可以先积累在用户态中然后用户级缓冲区里面,在通过一定条件给文件内核缓冲区
6.3缓冲区的刷新策略(用户->OS)
-
立即刷新(不缓冲),如进程退出时
-
行刷新(行缓冲\n),比如显示器打印
-
全缓冲(缓冲区满了才刷新) 当缓冲区满时,标准库会自动将其内容写入文件。
(默认刷新方式为行缓冲)
6.4缓冲区究竟以一种什么方式存在
在 Linux 下,用户缓冲区和内核缓冲区所分配的内存都是实实在在的物理内存。它们不是虚拟内存,而是直接映射到物理内存中。
当一个进程需要访问文件或设备时,操作系统会将数据从磁盘或设备读取到内核缓冲区中,然后将数据从内核缓冲区复制到用户缓冲区中,最终让进程访问用户缓冲区中的数据。这个过程中,数据的传输是在物理内存中完成的。因此,用户缓冲区和内核缓冲区都是占用物理内存的。
需要注意的是,Linux **中的内存管理机制是基于虚拟内存的。**操作系统会将物理内存分为多个虚拟地址空间,每个进程都有自己的虚拟地址空间。当进程访问内存时,操作系统会将虚拟地址映射到物理地址上,从而让进程能够访问内存。需要补充说明的是,Linux 中的用户缓冲区和内核缓冲区所占用的物理内存是通过操作系统的内存管理机制分配和管理的。在内核中,有专门的内存管理子系统来管理内核空间的内存分配和释放,而在用户空间中,进程的内存分配和释放则由 C 库的 malloc() 和 free() 等内存分配函数来完成。这些函数实际上是通过系统调用 brk() 或 mmap() 来向操作系统请求内存,由操作系统来分配和管理物理内存。因此,用户缓冲区和内核缓冲区所占用的物理内存都是由操作系统来管理的。
6.5几个经典的例子
#include <stdio.h>
#include <string.h>
int main()
{
const char *msg0="hello printf\n";
const char *msg1="hello fwrite\n";
const char *msg2="hello write\n";
printf("%s", msg0);
fwrite(msg1, strlen(msg0), 1, stdout);
write(1, msg2, strlen(msg2));
fork();
return 0;
}
打印结果:
hello printf
hello fwrite
hello write
对进程实现输出重定向 ./hello > file
(相当于把上面这段代码的运行结果写入了名为"file"的文件当中(代码里面相当于在最前面加个close(1))) , 结果变成了:
hello write
hello printf
hello fwrite
hello printf
hello fwrite
为什么会出现这一现象呢?(ps:以下的缓冲区默认为用户级缓冲区)
-
一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
-
printf fwrite 库函数会自带缓冲区(进度条例子就可以说明),当发生重定向到普通文件时,数据
-
的缓冲方式由行缓冲变成了全缓冲。
-
而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后
-
但是进程退出之后,会统一刷新,写入文件当中。
-
但是fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的
-
一份数据,随即产生两份数据。
-
write 没有变化,说明没有带缓冲区。
7.进一步了解文件系统
上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的block。一个block的大小是由格式化的时候确定的,并且不可以更改。
-
Block Group:ext2文件系统会根据分区的大小划分为数个Block Group。而每个Block Group都有着相
同的结构组成。政府管理各区的例子
-
超级块(Super Block):存放文件系统本身的结构信息。记录的信息主要有:bolck 和 inode的总量,
未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的
时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个
文件系统结构就被破坏了
-
GDT,Group Descriptor Table:块组描述符,描述块组属性信息,有兴趣的同学可以在了解一下
-
块位图(Block Bitmap):Block Bitmap中记录着Data Block中哪个数据块已经被占用,哪个数据块没
有被占用
-
inode位图(inode Bitmap):每个bit表示一个inode是否空闲可用。
-
i节点表:存放文件属性 如 文件大小,所有者,最近修改时间等
-
数据区:存放文件内容
从上图可以看出数据区和超级块(记录相关属性)是分开放置的,那么这两板块的内容是通过什么方式来运作的呢?
以上的图片就做了4个步骤:
- 存储属性
内核先找到一个空闲的i节点(这里是263466)。内核把文件信息记录到其中。
- 存储数据
该文件需要存储在三个磁盘块,内核找到了三个空闲块:300,500,800。将内核缓冲区的第一块数据
复制到300,下一块复制到500,以此类推。
- 记录分配情况
文件内容按顺序300,500,800存放。内核在inode上的磁盘分布区记录了上述块列表。
- 添加文件名到目录
新的文件名abc。linux如何在当前的目录中记录这个文件?内核将入口(263466,abc)添加到目录文
件。文件名和inode之间的对应关系将文件名和文件的内容及属性连接起来。
通过上述的理解,下面我们再来理解两个概念:
**硬链接:**我们看到,真正找到磁盘上文件的并不是文件名,而是inode。 其实在linux中可以让多个文件名对应于同一个inode。
**软链接:**硬链接是通过inode引用另外一个文件,软链接是通过名字引用另外一个文件。