1.引入
在Linux第一章提到过, 在Linux中,一切皆文件,而文件由文件内容和文件属性组成,在C语言中可以 使用相应的接口打开文件,例如 fopen 函数
文件最开始在磁盘中,但是因为磁盘的速度远低于CPU的执行速度,根据冯诺依曼体系结构,CPU与内 存进行交互,所以可以推出文件要被程序读取,就需要与程序加载到内存变成进程一样,文件也需要加 载到内存,而加载到内存中的文件包括其内容和属性,内容可以类比为进程的代码和数据,而因为操作 系统也需要管理加载到内存的文件,所以文件属性也被存储到一个结构中,可以类比为进程的PCB
所以,在研究Linux文件系统部分主要研究两种文件:
-
加载到内存的文件
-
存储在磁盘中的文件
2.回顾C语言文件操作
在C语言中,常见的处理文件的步骤如下:
-
打开文件: fopen 函数
-
读取/写入内容: fwrite 函数或者 fread函数
-
关闭文件: fclose 函数
示例代码如下:
#include <stdio.h>
#include <string.h>
int main()
{
// 以写的方式打开
FILE* fp = fopen("test.txt", "w");
// 向文件中写数据
const char* content = "hello linux\n";
fwrite(content, 1, strlen(content), fp);
// 关闭文件
fclose(fp);
return 0;
}
在上面的代码中,使用fopen
函数以w
的方式打开当前目录下名为test.txt
的文件,通过fwrite
函数向文件中写入一个字符串,最后调用fclose
函数关闭文件
在C语言部分学到过,一切以w
方式打开的文件,不论是否向该文件写入数据,都会优先清空文件中的数据,而如果指定的文件不存在,不论之后是否会写入都会先创建文件
除了w
方式以外,还有一个a
方式,以a
方式打开的文件,不论是否向该文件写入数据,都不会清空数据,如果需要写入,则是在文件已有的内容之后进行追加,同样如果指定的文件不存在,不论之后是否会写入都会先创建文件
除了上面的操作性知识回顾以外,在C语言中也学到,默认情况下,程序在启动时默认会开启三个流:
stdin
:标准输入流,一般认为是从键盘文件读取stdout
:标准输出流,一般认为是写入显示器stderr
:标准输出流,一般认为是写入日志文件或者写入显示器
2.1系统调用接口
在操作系统基础部分提到过,操作系统上层存在一个系统调用接口部分,实际上C语言提供的文件操作函数都是语言级的函数,对于不同的操作系统,系统调用接口部分也会提供不同的函数供上层调用,为了更加便捷,C语言针对操作系统封装了对应的系统接口形成对应的函数,例如在Linux中,C语言的fopen实际上封装的就是Linux系统接口open函数
2.1.1 open函数
根据Linux的操作手册,下面是open 函数的两种函数原型:
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
参数解释:
pathname
:文件路径名flags
:打开文件的方式mode
:创建文件是,文件所拥有的权限
需要注意的是,对于第一种方式,因为没有参数可以传递文件创建时的权限,所以一般不会用第一种方式创建文件,更多得可能还是使用第二种方式
返回值解释:
两个函数都返回文件描述符,该值唯一代表一个加载到内存的文件
在Linux中,flags有下面几种常用的值,这些值都是被定义的宏:
-
O_RDONLY:只读模式
-
O_WRONLY:只写模式
-
O_RDWR:读写模式
-
O_TRUNC:覆写模式
-
O_CREAT:文件不存在时创建文件
-
O_APPEND:追加模式
注意,上面的所有模式只有给出的文字描述中的一种效果,没有其他效果
在给open函数的flags参数传递实参时,如果只传递五种模式的其中一种,一个参数完全可以胜任,但是如果想一次传递多个模式,比如以只写并且文件不存在时创建文件模式打开,此时就涉及到两个模式,一个形参如果通过直接赋值的形式,则无法同时识别两个模式。为了解决这个问题,实际上在Linux中,这个flags是个32个比特位的位图结构,此时传递参数就可以按照位运算的方式传递,在open函数中,只需要判断位图中为1的部分就可以知道指定了哪些模式,下面以一个例子帮助理解这一个思路:
-
定义一些宏模拟上面的模式
// 1左移0位,结果还是1(二进制位01)
#define AONE (1 << 0)
// 1左移1位,结果是2(二进制位10)
#define ATWO (1 << 1)
// 1左移2位,结果是4(二进制100)
#define ATHREE (1 << 2) -
创建函数,参数设置为一个整数,内容为打印指定模式
void print(int flag)
{
// 与运算取出二进制中的1判断指定模式是否选择
if (flag & AONE)
{
printf("AONE模式\n");
}
if (flag & ATWO)
{
printf("ATWO模式\n");
}
if (flag & ATHREE)
{
printf("ATHREE模式\n");
}
}
测试:
#include <stdio.h>
int main()
{
// 1种模式
print(AONE);
printf("************\n");
// 2种模式,将为0的比特位置为1
print(AONE | ATWO);
printf("************\n");
// 3种模式
print(AONE | ATWO | ATHREE);
}
输出结果:
AONE模式
************
AONE模式
ATWO模式
************
AONE模式
ATWO模式
ATHREE模式
2.1.2.read
函数和write
函数
在Linux中,read
函数和write
函数读和写的系统调用接口,其原型如下:
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
参数解释:
-
fd:文件描述符,对应open函数返回值
-
buf:指向指定数组,对于read函数来说,代表存入读取到的内容的起始地址;对于write函 数来说,代表待输出的内容的起始地址
-
count:代表期望内容个数,对于read函数来说,代表最大读取内容的个数;对于write函数 来说,表示最大输出内容的个数
返回值解释: 两个函数均返回实际的内容个数,对于read函数来说,代表实际读取内容的个数;对于write函数来 说,表示实际输出内容个数
2.1.3 close 函数
int close(int fd);
参数解释:fd
代表文件描述符,与open
函数的返回值对应
返回值解释:0代表关闭成功,小于0代表失败
2.2模拟C语言接口
有了上面的铺垫,现在考虑open
函数的使用模拟w
的方式,根据前面C语言中w方式的描述:以写模式打开并且不存在指定文件时创建该文件,若指定文件中有内容就清除文件内容,需要使用到只写模式、文件不存在时创建模式和覆写模式
先观察三个模式依次搭配的特点:
-
O_WRONLY
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>int main()
{
// 以只写模式打开文件
int fd = open("test.txt", O_WRONLY);
// 向文件中写入
const char* content = "hello linux\n";
int num = write(fd, content, strlen(content));
printf("%d\n", num);
// 关闭文件
close(fd);return 0;
}
如果此时将写入的内容变短为3个字符,观察下面代码的运行结果:
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
// 以只写模式打开文件
int fd = open("test.txt", O_WRONLY);
// 向文件中写入
const char* content = "bye";
write(fd, content, strlen(content));
// 关闭文件
close(fd);
return 0;
}
可以看到,只写模式打开一个有内容的文件默认不会清除原始内容,而是覆盖写
O_TRUNC和O_WRONLY
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
// 以只写模式打开文件
int fd = open("test.txt", O_WRONLY | O_TRUNC);
// 向文件中写入
const char* content = "hello\n";
write(fd, content, strlen(content));
// 关闭文件
close(fd);
return 0;
}
可以看到加了O_TRUNC宏后,就可以达到打开文件后不论文件是否有内容都会先清空再写入内容
O_TRUNC、O_WRONLY和O_CREAT
前面两个选项都只展示了在有指定文件的情况下正常运行,但是C语言的fopen函数以w方式打开 指定文件,当该文件不存在会自动创建,所以此时就需要在系统调用接口加上O_CREAT,例如下面 的代码:
删除当前目录的test.txt文件后执行上面的代码:
可以看到会自动创建test.txt文件再写入指定的内容
此时就简单实现了C语言中的w方式打开的效果,但是此时创建的test.txt文件与直接使用 touch创建的文件有点出入
2.3 open函数与文件权限
前面使用到了open函数的第一个版本,现在考虑open函数第二个版本,这个函数可以传递第三个参 数,该参数表示文件创建时拥有的权限,传递权限八进制值
先观察使用第一个版本创建出的文件拥有的权限
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
int id = open("test.txt", O_CREAT);
close(id);
return 0;
}
运行程序结果如下:
可以看到创建出来的文件所拥有的权限是错乱的,尤其是除了所有者以外的权限,为了避免出现这个情况,就需要使用第二个版本的open函数
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
int id = open("test.txt", O_CREAT, 0666);
close(id);
return 0;
}
结果如下:
可以看到此时文件权限就是正常的,但是因为存在文件权限掩码umask,所以其值并不是代码中设置的 0666(对应为-rw-rw-rw-),当前系统的文件权限掩码可以通过umask指令查看,默认为0002
如果不希望在程序中创建的文件所拥有的权限受到系统umask影响,可以在创建文件之前使用umask函 数设置初始的文件权限掩码为0:
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
umask(0);
int id = open("test.txt", O_CREAT, 0666);
close(id);
return 0;
}
运行后观察到当前就是程序中指定的0666权限:
3.文件描述符
前面的系统调用接口函数中,四个函数均涉及到了文件描述符,该描述符在Linux中是对每一个加载到内 存的唯一标识,打印指定的文件观察open函数返回值:
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
// 以只写模式打开文件
int fd = open("test.txt", O_WRONLY | O_TRUNC | O_CREAT);
printf("%d\n", fd);
// 关闭文件
close(fd);
return 0;
}
输出结果:
3
当open函数打开文件成功时返回-1,否则返回打开的文件对应的文件描述符,为了知道何 为文件描述符,这里就需要深入了解在Linux具体是如何描述和管理文件的
首先,有了前面学习进程的基础可以知道每一个进程需要被管理就需要先描述再组织,而描述对应的就 是进程PCB(task_struct),组织就是双向链表,对于文件也是如此,描述对应的就是struct file,组织也是双向链表
但是,如果直接让进程访问文件结构file就会增加耦合度,导致操作系统的管理工作会变得繁重,所以 在内存中,进程结构在单独的一个区域,文件结构也在单独的一个区域,而为了进程可以访问到文件, 进程结构中就存在一个结构体指针struct files_struct *files,该结构体指针类型是struct files_struct *,所谓的files_struct就是将文件和进程建立连接的结构,该结构中存在一个属 性:struct file * fd_array[NR_OPEN_DEFAULT],该数组的每一个成员就是指向每一个文件 file结构的指针,因为是数组,所以可以通过下标访问指定的元素,在当前这个条件下,访问到的就是 指向每一个文件file结构的指针
所以所谓的文件描述符,就是fd_array数组的下标,示意图如下:
此时就会有第二个问题:为什么在程序中打开的文件默认下标是3,错误是-1,中间的0、1和2表示什 么?
前面提到,在C语言程序启动时,会自动加载3种文件流:
-
stdin:标准输入流,一般认为是从键盘文件读取
-
stdout:标准输出流,一般认为是写入显示器
-
stderr:标准输出流,一般认为是写入日志文件或者写入显示器
在Linux手册中,这三个流的原型如下:
#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
可以看到三者均是FILE类型,这个类型实际上是语言级上的封装,因为在Linux中文件描述符是访问文 件的唯一方式,所以C语言的接口想访问文件就必须要访问到指定文件的文件描述符,所以FILE结构中 一定可以有文件描述符属性,根据下面代码可以验证(下面的代码中_fileno对应的就是文件描述 符):
#include <stdio.h>
int main()
{
printf("%d\n", stdin->_fileno);
printf("%d\n", stdout->_fileno);
printf("%d\n", stderr->_fileno);
return 0;
}
输出结果:
0
1
2
所以,之所以显式打开的文件对应的文件描述符为3是因为默认打开的三个文件占用了fd_array
数组前面三个空间
4.如何理解Linux下一切皆文件
前面提到多次「在Linux下一切皆文件」,对于保存在计算机硬盘中的文件来说,说其是文件再合适不 过,但是对于硬件来说,如果再说硬件是文件难免有些不妥,但是如果硬件不属于文件,那么就不会出 现「在Linux下一切皆文件」的表述,所以硬件为什么在Linux下硬件也算文件
要理解为什么在Linux下硬件也算文件,需要先回顾操作系统的作用。在开始进程部分之前,提到过操作 系统实际上是一个管理者的身份,上层提供调用接口,下层通过调用接口中的具体实现操控硬件,此处 为了理解硬件也算文件,就需要研究下层中接口的实现
此处不讨论接口中具体的代码实现,只讨论这整个过程是如何形成的
据冯诺依曼体系结构,除了内存、CPU以外,其他设备均称为外设,也称为输入输出设备,而这些设备在接入计算机时,需要在计算机中安装驱动,而安装驱动的本质就是为了将对应的硬件信息加载到属于硬件的信息表中,而操作系统为了管理这些表,就需要创建一个结构体,这一个过程符合「先描述,再组织」的「描述」,此处的每一个结构体都是通过双向链表进行连接,这一个过程符合「先描述,再组织」的「组织」。有了硬件信息的结构体和对应的数据结构,操作系统就可以开始通过管理这一个硬件信息数据结构来管理硬件,这一个过程就可以理解为实现「硬件即文件」的初步过程
但是上面的过程只是完成了硬件信息之间的联系,这些联系只能做到简单的增删查改,操作系统不可能 一直停留在添加设备和删除设备的行为中,这也没有意义。上文提到这些硬件本身都属于输入输出设 备,最基本的行为就是输入和输出,即I/O行为,所以每一个硬件都有属于自己的一套I/O行为方法,尽管 有的只有输出,有的只有输入,而因为方法的实现不同,导致操作硬件的方法就不同,在C语言中,不允 许在结构体中定义方法(也称函数),所以每一个设备的方法都独立于设备结构外,此时如果进程需要 调用就会显得不方便(调用顺序: task_struct -> file_struct -> file 访问到指定硬件信息,单 独调用指定方法传参),为了简化这一步骤,考虑将指定方法的地址作为 file 结构的成员(即结构体中 存储函数指针),此时进程调用即可通过 file 结构调用指定文件,而不需要再访问指定的硬件结构以及 单独调用指定的方法,所以此时每一个结构体的属性和方法就可以通过 file 结构来进行管理和访问,最 终做到了「硬件即文件」
在上面的过程中,使用 file 结构描述所有文件,包括硬件视为文件在内的系统称为Linux下的虚拟文件 系统(Virtual File System,简称VFS),有了前面的介绍,现在基本介绍一下虚拟文件系统的作用:虚 拟文件系统是操作系统的文件系统虚拟层,在其下是实体的文件系统。虚拟文件系统的主要作用在于让 上层的软件,能够用单一的方式,来跟底层不同的文件系统沟通。在操作系统与之下的各种文件系统之 间,虚拟文件系统提供了标准的操作接口,让操作系统能够很快的支持新的文件系统
简单理解这个作用就是让每一个进程访问每一个文件都认为是同一种文件,而不是每一个文件都是独立 的个体
补充:在上面的描述中提到一个点:file结构体中保存了函数指针和文件属性字段,而其他文件只需要 实现自己对应的函数即可,这个过程非常像C++面向对象三大特性中的一大特性:多态,多态的基本形 式为父类提供方法名但不实现,由具体的子类去实现,所以上面C语言中的思路也可以理解为是C语言中 实现多态的一种方式
Linux 2.6.0内核源代码中的file_operations结构体如下:
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*aio_read) (struct kiocb *, char __user *, size_t, loff_t);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*aio_write) (struct kiocb *, const char __user *, size_t, loff_t);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, struct dentry *, int datasync);
int (*aio_fsync) (struct kiocb *, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*readv) (struct file *, const struct iovec *, unsigned long, loff_t *);
ssize_t (*writev) (struct file *, const struct iovec *, unsigned long, loff_t *);
ssize_t (*sendfile) (struct file *, loff_t *, size_t, read_actor_t, void __user *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
};