文章目录
文件描述符
- 预备知识:
- 文件 = 文件内容 + 属性(也是数据)
- 文件的所有操作,无外乎是 对内容 和 对属性 的操作
- 文件在磁盘(硬件)上放着,我们访问文件先写代码 — 编译 — exe — 运行 — 访问文件:
- 问:本质是谁在访问文件呢? 答: 进程!!
- 要向硬件写入,只有操作系统有权力!!普通用户也想要写入呢? 必须让os提供接口! 文件类的系统调用接口!!
- 显示器是硬件吗?printf向显示器打印(也是一种写入),你为什么不觉得奇怪呢?----->>> 和磁盘写入到文件,没有本质区别 !!
- linux认为,一切皆文件:
- 感性认识:
对于文件而言: 曾经理解的文件:read、write; 显示器:printf/cout ----> 一种write 键盘:scanf/cin ----> 一种read
普通文件 —>> fopen/fread —>> 你的进程的内存(内部) —>> fwrite —>> 文件中
那么什么叫做文件呢?
答:站在系统的角度,能够被input读取,或者能够output写出的设备就叫做文件!
狭义文件:普通的磁盘文件。
广义上的文件:显示器,键盘,网卡,声卡,显卡,磁盘,几乎所有的外设都可以称之为文件。
1. C文件IO相关操作
先来段代码回顾C文件接口
- func.c写文件 :
#include <stdio.h>
#include <string.h>
int main()
{
FILE *fp = fopen("log.txt", "w");
if (fp == NULL)
{
perror("fopen");
return 1;
}
//进行文件操作
const char *s1 = "hello fwrite\n";
fwrite(s1, strlen(s1), 1, fp);
const char *s2 = "hello fprintf\n";
fprintf(fp, "%s", s2);
const char *s3 = "hello fputs\n";
fputs(s3, fp);
fclose(fp);
return 0;
}
其中strlen不需要加 1, \0 结尾是c语言的规定,文件要保存的是有效数据, \0 只是结束标识符。
当以w模式打开文件时,第一件事是先清空文件然后再打开!
若以a模式打开文件,实则是在结尾追加内容!
- func.c读文件
#include <stdio.h>
#include <string.h>
int main()
{
FILE *fp = fopen("myfile", "r");
if (!fp)
{
printf("fopen error!\n");
}
char buf[1024];
const char *msg = "hello solity!\n";
while (1)
{
// 注意返回值和参数,此处有坑,仔细查看man手册关于该函数的说明
ssize_t s = fread(buf, 1, strlen(msg), fp);
if (s > 0)
{
buf[s] = 0;
printf("%s", buf);
}
if (feof(fp))
{
break;
}
}
fclose(fp);
return 0;
}
- 输出信息到显示器,你有哪些方法
#include <stdio.h>
#include <string.h>
int main()
{
const char *msg = "hello fwrite\n";
fwrite(msg, strlen(msg), 1, stdout); //指定字符串向stdout输出
printf("hello printf\n"); //直接打印
fprintf(stdout, "hello fprintf\n"); //直接向stdoout输出流打印
return 0;
}
显示结果:
什么叫做当前路径?
-
当一个进程运行起来时,这个进程都会记录自己当前所处的工作路径!
-
ps axj查看进程
-
再 ls /proc/ 进程 查看cwd就是当前路径的变化
将查看到的cwd路径与当前pwd路径先对比,是完全一样的!!
2. stdin & stdout & stderr
-
C默认会打开三个输入输出流,分别是
- stdin
- stdout
- stderr
-
仔细观察发现,这三个流的类型都是FILE*, fopen返回值类型,文件指针
总结
打开文件的方式,使用man手册查看:man fopen
如上,是我们之前学的文件相关操作。还有 fseek ftell rewind 的函数,在C部分已经有所涉猎,这里便不再举例。
3. 系统文件I/O
操作文件,除了上述C接口(当然,C++也有接口,其他语言也有),我们还可以采用系统接口来进行文件访问。
在使用系统接口之前,先来了解一下open函数:
接口介绍 :open
int open(const char *pathname, int flags, mode_t mode);
介绍几个常用的常量:
- O_CREAT:在文件打开过程中创建新文件
- O_RDONLY:以只读方式打开文件。
- O_WRONLY:以只写方式打开文件。
- O_RDWR:以读写方式打开文件。
- O_APPEND:在文件末尾追加数据,而不是覆盖现有内容。
- O_TRUNC:如果文件已经存在,将其截断为空文件。
- O_EXCL:与 O_CREAT 一起使用时,如果文件已经存在,则 open() 调用将失败。
- O_SYNC:使文件写操作变为同步写入,即将数据立即写入磁盘。
- O_NONBLOCK:以非阻塞方式打开文件,即使无法立即进行读写操作也不会被阻塞。
这些常量可以通过按位或( | )操作组合使用,以同时指定多个常量,例如,O_RDWR | O_CREAT 可以同时指定读写和创建。
这几个常量被定义在头文件fcntl.h中
fcntl
是 “file control” 的缩写。它是由 “file”(文件)和 “control”(控制)两个单词组合而成的。都是配合open函数使用。
先来直接以代码的形式,实现和上面一模一样的代码:
func.c 写文件:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
umask(0);
int fd = open("myfile", O_WRONLY | O_CREAT, 0644); //0644表示创建的文件的权限
if (fd < 0)
{
perror("open");
return 1;
}
int count = 5;
const char *msg = "hello solity!\n";
int len = strlen(msg);
while (count--)
{
write(fd, msg, len);
// msg:缓冲区首地址, len: 本次读取,期望写入多少个字节的数据。 返回值:实际写了多少字节数据
}
close(fd);
return 0;
}
写入结果:
hello.c读文件
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if (fd < 0)
{
perror("open");
return 1;
}
const char *msg = "hello solity!\n";
char buf[1024];
while (1)
{
ssize_t s = read(fd, buf, strlen(msg)); // 类比write
if (s > 0)
{
printf("%s", buf);
}
else
{
break;
}
}
close(fd);
return 0;
}
读取结果:
还有write read close lseek都是类比C文件的相关接口。
open函数返回值
在认识返回值之前,先来认识一下两个概念: 系统调用 和 库函数
上面的 fopen、fclose、fread、fwrite 都是C标准库当中的函数,我们称之为库函数(libc)。而 open、close、read、write、lseek 都属于系统提供的接口,称之为 系统调用接口
回忆一下操作系统概念的相关图:
系统调用接口和库函数的关系,一目了然。所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。
4. 文件描述符fd
通过对open函数的学习,我们知道了文件描述符就是一个小整数 0 & 1 & 2
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)); //0代表上述的stdin
if (s > 0)
{
buf[s] = 0;
write(1, buf, strlen(buf)); //1代表stdout
write(2, buf, strlen(buf)); //2代表stderr
}
return 0;
}
输出结果:
而现在知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件
5. 文件描述符的分配规则
直接看代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd = open("myfile", O_RDONLY);
if (fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
输出发现是 fd: 3
如果关闭关闭0或者2呢?
再看:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
// close(2);
int fd = open("myfile", O_RDONLY);
if (fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
发现是结果是: fd: 0
或者fd为2可见:
可见文件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符
6. 重定向
那如果关闭1呢?看代码:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
close(1);
int fd = open("myfile", O_WRONLY | O_CREAT, 00644);
if (fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
exit(0);
}
结果终端无任何输出内容:
此时,我们发现,本来应该输出到显示器上的内容,输出到了文件 myfile 当中:
其中,fd=1,这种现象叫做输出重定向。常见的重定向有: > , >> , <
那重定向的本质是什么呢?
- 操作的文件描述符/文件流指针并没有改变,但是实际操作的文件却改变了
- 实现方式就是替换这个描述符对应的文件描述信息
- 实际是描述符的重定向,改变描述符所指向的文件,就改变了数据的流向
7. 使用 dup2 系统调用
函数原型如下:
#include <unistd.h>
int dup2(int oldfd, int newfd);
解读:
- 让 newfd 这个描述符也指向oldfd所指向的文件,这时候 oldfd 和 newfd 都能够操作 oldfd 所指向的文件,两个描述符操作的都是 oldfd 指向的信息
- 因为newfd本身有可能已经打开了文件,但是现在要让它保存oldfd指向的信息,因此就需要先释放掉newfd当前的信息
示例代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int fd = open("./log", O_CREAT | O_RDWR);
if (fd < 0)
{
perror("open");
return 1;
}
close(1);
dup2(fd, 1);
for (;;)
{
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size < 0)
{
perror("read");
break;
}
printf("%s", buf);
fflush(stdout);
}
return 0;
}、
printf是C库当中的IO函数,一般往 stdout 中输出,但是stdout底层访问文件的时候,找的还是fd:1
但此时,fd:1下标所表示内容,已经变成了myfile的地址,不再是显示器文件的地址
所以,输出的任何消息都会往文件中写入,进而完成输出重定向。
8. FILE
因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。
所以C库当中的FILE结构体内部,必定封装了fd。
来段代码在研究一下:
#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 > file
, 我们发现结果变成了:
我们发现 printf 和 fwrite (库函数)都输出了2次,而 write 只输出了一次(系统调用)。
为什么呢?肯定和fork有关!
- 一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
- printf fwrite 库函数会自带缓冲区(进度条例子就可以说明),当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。
- 而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后
- 但是进程退出之后,会统一刷新,写入文件当中。
- 但是fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。
- write 没有变化,说明没有所谓的缓冲。
综上: printf、fwrite库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这里所说的缓冲区,都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。
那这个缓冲区谁提供呢?
printf fwrite 是库函数, write 是系统调用,库函数在系统调用的“上层”, 是对系统调用的“封装”,但是 write 没有缓冲区,而 printf fwrite有,足以说明,该缓冲区是二次加上的,又因为是C,所以由C标准库提供。
如果有兴趣,可以看看FILE结构体
typedef struct _IO_FILE FILE; 在/usr/include/stdio.h
查看指令: ls /usr/include/stdio.h
9. 理解文件系统
我们使用ls -l的时候看到的除了看到文件名,还看到了文件元数据。
每行包含7列:
- 模式
- 硬链接数
- 文件所有者
- 组
- 大小
- 最后修改时间
- 文件名
ls -l 读取存储在磁盘上的文件信息,然后显示出来
其实这个信息除了通过这种方式来读取,还有一个stat命令能够看到更多信息
上面的执行结果有几个信息需要解释清楚:inode
为了能解释清楚inode我们先简单了解一下文件系统
Linux ext2文件系统,上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的block。一个block的大小是由格式化的时候确定的,并且不可以更改。例如mke2fs的-b选项可以设定block大小为1024、2048或4096字节。而上图中启动块(Boot 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节点表:存放文件属性 如 文件大小,所有者,最近修改时间等
- 数据区:存放文件内容
将属性和数据分开存放的想法看起来很简单,但实际上是如何工作的呢?我们通过touch一个新文件来看看如何工作。
为了说明问题,我们将上图简化:
创建一个新文件主要有一下4个操作
-
存储属性
内核先找到一个空闲的i节点(这里是263466)。内核把文件信息记录到其中。
-
存储数据
该文件需要存储在三个磁盘块,内核找到了三个空闲块:300,500,800。将内核缓冲区的第一块数据复制到300,下一块复制到500,以此类推。
-
记录分配情况
文件内容按顺序300,500,800存放。内核在inode上的磁盘分布区记录了上述块列表。
-
添加文件名到目录
新的文件名abc。linux如何在当前的目录中记录这个文件?内核将入口(263466,abc)添加到目录文件。文件名和inode之间的对应关系将文件名和文件的内容及属性连接起来。
10. 理解硬链接
我们看到,真正找到磁盘上文件的并不是文件名,而是inode。
其实在linux中可以让多个文件名对应于同一个inode。
-
abc和def的链接状态完全相同,他们被称为指向文件的硬链接。内核记录了这个连接数,inode 37080904 的硬连接数为2。
-
我们在删除文件时干了两件事情:
1.在目录中将对应的记录删除,
2.将硬连接数-1,如果为0,则将对应的磁盘释放。
11. 软链接
硬链接是通过inode引用另外一个文件,软链接是通过名字引用另外一个文件,在shell中的做法
下面解释一下文件的三个时间:
- Access 最后访问时间
- Modify 文件内容最后修改时间
- Change 属性最后修改时间