【Linux】文件系统与文件管理
文章目录一. 打开文件描述符表1、什么是打开文件描述符表?2、为什么要有打开文件描述符表?3、打开文件描述符表的和进程的联系在Linux中,内核为每一个打开的文件提供三种数据结构对其进行维护,它们之间的关系决定了在文件共享方面一个进程对了一个进程可能产生的影响。每个进程对应一张打开文件描述符表,这是进程级数据结构,也就是每一个进程都各自有这样一个数据结构。内核维持一张打开文件表,文件表由多个文件表
文章目录
在 Linux 中,内核为每一个打开的文件提供三种数据结构对其进行维护,它们之间的关系决定了在文件共享方面一个进程对另一个进程可能产生的影响。
- 进程级的打开文件描述符表
- 系统级的打开文件表
- 系统的 inode 表
一. 打开文件描述符表
1、什么是打开文件描述符表?
我们知道,每个进程都有一个描述该进程相关属性的数据结构:进程控制块(PCB),Linux 中的 PCB 叫做 task_struct,它的部分源码如下:
注意到 task_struct 的成员中有一个类型为 files_struct 的结构体指针变量 files,我们跳转到这个结构体类型的定义:
所谓打开文件描述符表,实际上就是 files_struct 中的成员 fd_array[NR_OPEN_DEFAULT]。它是一个指针数组,每个元素类型为 file*,即一个打开的文件(file 这一数据结构用来描述一个打开的文件)
fd_array[ ] 的下标有什么意义?
这里的下标编号叫做文件描述符,进程每打开一个文件都会为该文件创建一个 file 类型的结构体,并把该结构体对象的地址填入到 fd_array[ ] 中,填入的那个下标编号是最小并且未被使用的,对应 file_struct 结构体中 next_fd 保存的就是下一个分配的文件描述符,它的值会在调用 open 和 close 时调整,最终使得每次 open 返回的都是当前可用的最小文件描述符。同时还规定每个进程启动的时候,默认会打开三个文件:0是标准输入,1是标准输出,2是标准错误。这意味着如果此时去打开一个新的文件,它的文件描述符会是3,再打开一个文件,它的文件描述符就是4…等以此类推
PS:这里的标准输入、标准输出和标准错误对应的是键盘、显示器、显示器,而不是C语言里的 stdin、stdout 和 stderr,这三个是C语言专门定义的FILE类型的对象:
Linux 系统配置下每个进程最大打开的文件描述符个数?
为了控制每个进程消耗的文件资源,内核会对单个进程最大打开文件数做限制,即用户级限制。可以使用 ulimit -n 命令查看单个进程最大打开文件的个数,Windows 中,32 位系统默认值一般是 1024,64 位系统默认值一般是 65535。
我是 SSH 远程登录的云服务器,这里进程的最大文件描述符个数默认设置为 100001。
当然我们也可以自己去更改进程最大打开文件描述符的个数:
- 临时更改:使用命令 ulimit -SHn xxxx 来修改,其中xxxx就是要设置的数字。重启或断开 XShell 后,会恢复原来的默认值。
- 永久更改:vim 编辑 /etc/security/limits.conf 文件,修改其中的 hard nofile xxxx 和 soft nofile xxxx,其中 xxxx 就是要设置的数字。保存后退出。
用什么方法查看特定进程的打开文件描述符表?
执行如下程序,程序 “mytest” 启动后在当前目录下打开一个文件 log.txt,然后死循环使进程一直处于运行状态。
// 可执行程序名称:mytest
void test()
{
int fd = open("log.txt", O_RDWR|O_CREAT, 0666);
while(1)
{}
}
在打开另一个 Shell,输入命令:pidof mytest 获取进程 mytest 的 pid 号,然后 ll /proc/pid/fd 查看 “mytest” 进程的文件描述符表的使用情况。
这里展现出来的 “mytest” 进程打开文件描述符表中每一个表项都是软连接。
/dev/pts 是远程登陆后创建的控制台设备文件所在的目录。因为我是通过 SSH 远程登录的,所以标准输入,标准输出,标准错误对应的文件描述符 0、1、2 指向虚拟终端控制台 /dev/pts/0 。而我们自己打开的文件 log.txt 的绝对路径被被放置在 3 号文件描述符位置上。
2、为什么要有打开文件描述符表?
在 Linux 系统中一切皆可以看成是文件,文件又分为:普通文件、目录文件、链接文件和设备文件。在操作这些所谓的文件的时候,我们每操作一次就找一次名字,这会耗费大量的时间。所以在 Linux 中规定,每一个文件对应一个索引,这样下次要操作文件的时候,我们直接找到索引就可以拿到了。而文件描述符就是内核为了高效管理这些已经被打开的文件所创建的索引,它是一个非负整数,用于指代被打开的文件,所有执行 I/O 操作的系统调用都通过文件描述符来实现,这个我们下面会介绍到。
3、打开文件描述符表的和进程的联系
每启动一个进程,操作系统都会为其创建一个 task_struct 结构体,在 task_struct 的成员中含有一个类型为 files_struct* 的指针变量 files;files_struct 中又含有一个元素类型为 file* 的指针数组 fd_array,它就是打开文件描述符表,里面存储了每个文件描述符作为索引与一个打开文件相对应的关系。
它们之间的关系如下图所示,文件描述符(索引)就是文件描述符表的下标,数组的内容就是指向一个个打开文件的地址。
二、打开文件表
1、什么是打开文件表?
接着上面的,我们来看看所谓描述打开文件信息的 file 结构体的具体声明:
struct file {
// 记录头结点
union {
struct list_head fu_list;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;// 文件路径,包括目录项以及i-node
#define f_dentry f_path.dentry
#define f_vfsmnt f_path.mnt
const struct file_operations *f_op;// 保存关于文件操作的一系列函数指针
spinlock_t f_lock;
#ifdef CONFIG_SMP
int f_sb_list_cpu;
#endif
atomic_long_t f_count;// 文件打开次数
unsigned int f_flags;// 文件打开时的flag,对应于open函数的flag参数
fmode_t f_mode;// 文件打开时的mode,对应于open函数的mode参数
loff_t f_pos;// 文件偏移量
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;
u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct list_head f_ep_links;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
#ifdef CONFIG_DEBUG_WRITECOUNT
unsigned long f_mnt_write_state;
#endif
};
打开文件表的结构
通过源码发现 file 结构体内有定义一个记录头结点的联合体成员f_u:
可以推测 file 结构体之间是通过链表组织起来的,每一个 file 结构体叫做一个文件表项,它们组合而成的链表叫做打开文件表,这张表是系统级别的,为所有进程共享,但组成该表的每一个文件是进程级的。
存放文件操作函数的结构体
file 结构体中,有一个 struct file_operations* 类型的成员 f_op,这个成员中存储了一系列文件读写操作相关的函数指针,这些操作函数是系统级的:
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 (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);
int (*readdir) (struct file *, void *, filldir_t);
unsigned int (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, 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 (*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);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
};
下面介绍其中几个系统级的文件操作函数:
1.1 打开文件 — open()
作用:以特定方式打开一个文件,系统会为该打开文件创建一个该文件自己的 file 类型的文件表项,并把这个文件表项的地址填入到进程级别的打开文件描述表中,并返回所在该进程的打开文件描述符表的下标标号,即文件描述符。
头文件
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
函数原型(有两种形式,下面我们主要介绍第二种形式)
函数参数:
- pathname:打开文件的名字及所在路径,默认创建在当前路径下
- flags:设置打开文件的方式。
- mode:设置创建文件的权限(rwx)。当 flags 中带有 O_CREAT 时才有效。
返回值:打开成功则返回文件描述符,否则返回 -1。
flags 参数详解:
- O_RDONLY:只读模式
- O_WRONLY:只写模式
- O_RDWR:可读可写模式
上面三种模式在flags参数中不能同时出现但必须选择其中一种,下面的参数是可选的:
- O_TRUNC:打开文件的同时将文件中的内容清除。
- O_APPEND:以后每次写文件时都会先将当前文件偏移量设置到文件末尾,但是读文件时是不影响的。
- O_CREAT:如果文件不存在则创建,此时可以配合传入 mode 参数。来指明新文件的权限。
- O_EXCL:要打开的文件如果存在则出错,必须要和 O_CREAT 参数配合一起使用 。
- O_NOCTTY:如果打开的文件是终端设备,则不将此设备设置为进程的控制终端。
- O_NONBLOCK:如果打开的文件是一个管道、一个块设备文件或一个字符设备文件,则后续的 I/O 操作均设置为非阻塞方式。
- O_SYNC:使每次 write 都等到物理 I/O 操作完成,包括由该 write 操作引起的文件属性更新所需的 I/O。
使用举例:
在当前路径下创建一个名为 “log.txt” 的文件,打开方式为只写,如果不存在的话就新创建,对应拥有者、所属组、其他人的 rwx 权限是 666。
int test()
{
umask(0);
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
cout << "fd:" << fd << endl;// fd:3
return 0;
}
说明1:什么是当前路径?
我们知道,当 open 以写入的方式打开一个文件时,若该文件不存在,则会自动在当前路径创建该文件,那么这里所说的当前路径指的是该程序所存储的路径码?
还是上面的那个程序,我们在当前目录cur_direct下执行它,确实在当前目录下生成了一个文件 log.txt
退回到上级目录,在上级目录中调用 mytest 可执行程序,发现文件也可以在上级目录中生成,可以推测所谓的“打开文件时如果文件不存在,默认在当前目录下这个文件”,这里的当前路径指的是程序执行时所在的那个路径,而不是这个程序本身所存储的路径。
当该可执行程序运行起来变成进程后,我们可以获取该进程的 PID,然后根据 PID 在根目录下的 proc 中查看该进程的信息。
其中我们可以看到两个软链接文件 cwd 和 exe,cwd 就是这个程序执行时所处的路径,而 exe 是该可执行程序本身所存储的路径。
说明2:第二个参数 flags 的具体含义
这个参数对应很多选项,这些选项可以通过或运算组合起来使用,为什么可以这样呢?其实这些参数都是系统定义的宏,它们对应到数值时都是只有一个比特位为 1,其它比特位为 0 的整数,所以理论上可以有 32种参数,这样可以通过它们按位或后的结果来判断那个选项被使用了。
PS:file 结构体中的 f_flags 变量保存着我们打开文件时传入的第二个参数。
说明3:第三个参数 mode 的使用场景
第三个参数是在第二个参数中有 O_CREAT 时才起作用的。若没有 O_CREAT,则第三个参数可以忽略(对应 open() 函数原型的第一种写法,没有第三个参数)。
当创建新文件时,我们可以指定文件的默认权限值 mask 是多少,不指定的话新建文件默认权限值 mask=0666 即 -rw-rw-rw- ,新建目录默认的权限值 mask=0777,即drwxrwxrwx
不论指定与否最终实际创建的文件的权限 = mask & (~umask),这里的umask 是权限掩码,普通用户的权限掩码(umask)默认为 0002,超级用户的默认为 0022。
正确地传入 mode 参数应该是由八进制的四位数字给出的,如 0666 。这里要纠正一个错误,就是之前我认为权限数字前面的 0 代表的是八进制的含义,其实并不是这样的,前面的 0 其实 代表了权限修饰位,也就是 set-user-id 位、set-group-id 位和 sticky 这三位的权限,所以最前面的 0 是一定要写到的,不然会出现错误。
通俗的去理解 umask 的操作原理就是对比新建文件的默认权限,如果umask 对应比特位上是1,那么文件权限中与之对应的那个比特位上的权限就会被去除(如果有的话,没有的话就不用去除)。
为了去除权限掩码的干扰,我们可以通过 umask() 函数来设置 umask 的值为 0,这样我们直接传入的 mode 参数就是实际该文件对应的权限了。
同样 mode 值在 file 结构体中也有保存:
1.2 关闭文件 — close
头文件:#include <unistd.h>
函数原型:int close(int fildes);
作用:将进程中打开文件描述符表对应下标的内容(文件表项地址)剥除,有可能还会清除对应的文件表项。
返回值:关闭文件成功返回 0,失败返回 -1。
close 关闭文件时是否要把对应文件的 file 结构体删除?
前面说过文件表项(file 结构体)是系统级别的,所以可能存在多个进程的打开文件描述符表都指向同一个文件表项,比如子进程继承父进程的文件描述符表时就会有这种情况;或者一个进程的打开文件描述符表的多个文件描述符都存有同一个文件表项的地址,这个可以通过 dup2 重定向来实现,最终一个文件表项到底要不要被清除取决于它还要不要被使用。
文件表项中有一个成员 f_count 用来记录该文件表项被使用的次数,每个进程执行 close() 把打开文件描述符表对应下标的内容剥除之前,先会找到该文件表项的 f_count 使其减一,如果 f_count 减一后变成 0 了,系统就会删除该文件表项。
1.3 文件读取 — read() && 文件写入 — write()
read
头文件:#include <unistd.h>
函数原型:ssize_t read(int fd, void *buf, size_t count);
功能:从fd 指向的文件中读取 count 个字节的数据到 buf 中。
返回值:成功的话返回读取到的字节数,出错返回 -1,如果在调 read 之前已到达文件末尾,则这次 read 返回0。
说明1:关于文件偏移位置
file结构体中有一个成员 f_pos,记录的是文件当前读写到的位置,每次读写后该成员的值都会发生调整。
说明2:关于返回值
返回值类型是 ssize_t,即有符号的 size_t,这样既可以返回正的字节数、0(表示到达文件末尾)也可以返回负值-1(表示出错)。例如,距文件末尾还有30个字节的数据,而我们请求读100个字节,则 read 返回30,同时文件的当前读写位置 f_pos 移到最后,下次 read 将返回 0。
write
头文件:#include <unistd.h>
函数原型:ssize_t write(int fd, const void *buf, size_t count);
功能:从 buf 里写 count 个数据到 fd 指向的文件中
返回值:成功返回实际写入的字节数,出错返回 -1
使用举例:
int test2()
{
// 1、对文件进行写入
umask(0);
int fd = open("log.txt", O_WRONLY|O_CREAT, 0664);
const char* source = "I Can See You\n";
write(fd, source, strlen(source));
close(fd);
// 2、读取文件内容到显示器
fd = open("log.txt", O_RDONLY);
char ch;
while(1)
{
ssize_t s = read(fd, &ch, 1);
if(s <= 0)
break;
write(1, &ch, 1);// 1号文件描述符对应标准输出
}
close(fd);
return 0;
}
1.4 C语言对 Linux 系统调用接口的封装
不同操作系统对文件操作的系统调用接口不同,但对于语言而言为了保证同一套方法能够在不同操作系统上执行,就要去封装其他操作系统的接口。下面我们以C语言的用户操作接口对 Linux 的系统调用接口的封装举例。
Linux 中的文件表项对应的数据结构是struct file,而C语言中描述文件信息的结构体是struct FILE。我们可以在 /usr/include/stdio.h 头文件中找到下面这段代码,也就是说 FILE 实际上就是 struct _IO_FILE 结构体的一个别名:
typedef struct _IO_FILE FILE;
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; //文件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
注意到这里面有很多 IO 缓冲区的信息,关于缓冲区后面会讲到,除此之外还有一个重要的成员叫做 _fileno,这个就是文件描述符,也就是说C语言的 FILE 结构体封装了系统级的文件描述符,这里的系统指的是所有支持C语言的操作系统,像 Linux、Windows 等等。
前面有说过C语言还专门声明了三个FILE类型的结构体成员:stdin、stdout、stderr。他各自的 _fileno 对应的值为 0、1、2 这是定死的。
C语言文件操作函数的底层实现
在C语言中我们打开一个文件用的是 fopen 函数,调用该函数时系统会生成一个该文件对应的FILE结构体,并且会初始化其成员 _fileno 的值,然后返回该 FILE 结构体的指针,C语言文件操作函数都是通过这个 FILE 结构体指针来完成的,具体如何完成的呢?就是拿到成员 _fileno 的值再去调用系统调用接口如 Linux 中的:open、read、write、close等等。
下面部分是C语言提供的文件操作函数,它们的底层实现都是拿到文件描述符再去调用系统提供的文件操作函数接口。
头文件:#include <stdio.h>
FILE *fopen(const char *path, const char *mode);
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb,FILE *stream);
int fclose(FILE *fp);
...
C语言输入输出函数的底层实现
这里的输入输出函数指 printf、fprintf、scanf、fscnaf 等。
头文件:#include <stdio.h>
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int scanf(const char *format, ...);
int fscanf(FILE *stream, const char *format, ...);
学习C语言时我们知道 printf 函数可以把格式化数据打印输出到显示器上,对应到 fprintf 如果第一个参数传入 stdout 也是把格式化数据打印到显示器上,这种操作底层是如何实现的呢?
void test()
{
fprintf(stdout, "hello world\n");//hello world
}
stdout 里的 _fileno 是 1,前面说过在 Linux 中,一个进程运行起来之后会默认打开三个 file 类型文件表项:标准输入(键盘)、标准输出(显示器)和标准错误(显示器)并把他们的地址填入到进程级的打开文件描述符表下标为 0、1、2 的位置中:
fprintf 执行时首先找到 FILE 结构体中的 _fileno,然后调用系统调用接口 write 把 _fileno 作为第一个参数传入,又因为 stdout 中的 _fileno 值为1 ,对应打开文件描述符表中指向的是标准输出,所以最后是把第二个参数的数据写入到了标准输出即显示器中。
1.5 重定向的实现原理
重定向的本质就是修改打开文件描述符表中,某个下标对应的 struct file* 指针所指向的地址。修改方法有两种:
- 间接修改:打开文件之前先关闭想要重定向到的那个文件描述符,这样后面新打开的文件就会分配到刚刚关闭的那个文件描述符了。
- 直接修改:通过 dup2 函数,直接修改文件描述符下标对应的值。
PS:下面介绍的例子都以间接修改的方式来实现重定向。
输出重定向
输出重定向就是把我们本该输出到一个文件的内容输出到另外一个文件。
比如本该输出到显示器上的内容最终输出到了文件 log.txt 中:我们一开始先把 1 号文件描述符所指向的文件(标准输出)关闭,然后再打开一个新的文件 log.txt,这样 log.txt 分配到的文件描述符就是 1,再利用 write 向 1 号文件描述符指向的文件写入内容就不会在写入到显示器上而是写入到 log,txt 中了。
void test()
{
close(1);
umask(0);
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
// 调用c接口的写入函数
printf("hello world");
}
运行程序后屏幕上什么都没输出,再看看 log.txt 中写入了新内容:
追加重定向
追加重定向和输出重定向的唯一区别就是,输出重定向是覆盖式写入数据,而追加重定向是追加式写入数据。
比如我们想在刚刚输出重定向创建出来的文件 log.txt 中追加数据,只需改变传入的选项为:O_WRONLY|O_APPEND即可,因为 log.txt 已经存在,所以可以不用传第三个参数:
void test()
{
close(1);
int fd = open("log.txt", O_WRONLY|O_APPEND);
printf("Im appended data");
}
执行程序后发现,本该输出到显示器上的内容以追加的方式重定向到了 log.txt 中:
输入重定向
输入重定向就是,将我们本应该从一个文件读取数据,现在重定向为从另一个文件读取数据。
比如我们想让本应该从“标准输入”读取数据的 scanf 函数,改为从 log.txt 文件当中读取数据。那么我们可以在打开 log.txt 文件之前将文件描述符为 0 的文件关闭,也就是将“标准输入”关闭,这样一来,当我们后续打开 log.txt 文件时所分配到的文件描述符就是 0,scanf 就会从 log.txt 中读取数据了。
void test()
{
close(0);
int fd = open("log.txt", O_RDONLY);
char str[40];
while (scanf("%s", str) != EOF)
{
printf("%s\n", str);
}
}
执行程序,直接输出从 log.txt 中读取到的内容:
1.6 dup2函数
头文件:#include <unistd.h>
函数原型:int dup2(int oldfd, int newfd);
作用:dup2(fd1, fd2)将会把fd1复制到指定的fd2下,如果fd2是一个已经打开的描述符,dup2会自动的先将其安静的关闭
返回值:调用成功,返回 newfd,否则返回 -1。
PS:可以先使用 close 关闭描述符为 newfd 的文件,不用也行系统最后会自动帮你关闭;如果 oldfd 不是有效的文件描述符,则 dup2 会调用失败,并且此时文件描述符为 newfd 的文件没有被关闭。
比如我们在输出重定向时可以直接用 dup2 替换打开文件描述符表中 1 下标的内容为 fd 下标的内容,这时1下标和fd下标都存的是log.txt文件表项的地址。
void test()
{
umask(0);
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
dup2(fd, 1);
printf("hello world");
}
1.7 C语言提供的缓冲区
在进行文件之间的数据交互时,通常会把交互的数据先存放到缓冲区里,到合适的时候一次性把缓冲区的数据交给运算器和中央处理器去处理完成交互,而不是拿到数据后马上处理,这样的话效率太低了。
数据的缓冲方式分为以下三种:
- 无缓冲:只要接收到数据就立刻刷新到指定文件里。
- 行缓冲:接收到的数据先存放到缓冲区,遇到"\n"或进程终止了才把缓冲区的数据刷新出去,例如 printf 函数就是行缓冲。
- 全缓冲:接收到的数据一律放到缓冲区,直到最后进程终止了才会刷新缓冲区。
这三种缓冲方式里,要保证最高数据处理效率的话,应该是无缓冲最快,即进程终止后把所有数据一次性统一处理。但是现实我们在进行文件间数据交互过程中还需要拿到中间交互的结果,保证中间过程正确才进行下一步交互,所以综合后有了行缓冲。
下面说说缓冲区,前面讲 1.4 C语言对Linux系统调用接口的封装 时有提到s truct FILE 源码中保存有一系列C语言提供的缓冲区的数据,下面我们来探讨C库函数以及 Linux 系统调用接口在进行文件数据交互时的缓冲方式。
void test()
{
// C库接口
fprintf(stdout,"hello printf\n");
// 系统调用接口
write(1, "hello write\n", 12);
fork();
}
执行程序,fprintf 函数和 write 函数的交互数据都打印到了显示器上:
我们重定向把数据输出到 log.txt 这个普通文件中,发现 fprintf 函数的格式化数据输出了两次并且是在write之后输出的:
原因是因为系统调用函数是以无缓冲方式来交互数据的,所以 write 执行时就马上把所有数据刷新到指向文件里了;而 fprintf 输出到标准输出时是行缓冲,重定向后输入到普通文件里,缓冲方式变为全缓冲,这个时候即使有"\n"也不会刷新缓冲区数据,这些数据依然保留在C语言缓冲区当中,fork()后子进程继承一份父进程的缓冲区,在最后两个进程都结束时操作系统强制刷新C语言缓冲区,才会出现先打印write的数据,再打印两份fprintf的格式化数据这种结果。
2、打开文件表的作用?
打开文件表是由一个个文件表项组成的,这些文件表项包含文件读写操作的函数指针、以及读写操作相关的成员变量:记录偏移量的f_pos、记录权限的f_mode、记录文件操作方式的f_flag等等。在对文件进程读写操作时,根据传入的文件描述符在打开文件描述符表中找到相应的文件表项,最后调用文件表项里的读写函数完成文件读写操作。
3、打开文件表与进程的联系
同一个进程的不同文件描述符可以指向同一个文件表项。比如通过dup2函数改变文件描述符的内容:
子进程在创建时会拷贝父进程的打开文件描述符表,因此刚创建子进程时,父子进程是共享文件表项的,如图所示:
三. inode表
1、什么是inode?
文件表项的 file 结构体中有一个 f_path 成员,类型为 struct path,该类型定义如下:
struct path {
struct vfsmount *mnt;
struct dentry *dentry;
};
继续看看struct dentry的定义:
struct dentry {
/* RCU lookup touched fields */
unsigned int d_flags; /* protected by d_lock */
seqcount_t d_seq; /* per dentry seqlock */
struct hlist_bl_node d_hash; /* lookup hash list */
struct dentry *d_parent; /* parent directory */
struct qstr d_name;
struct inode *d_inode; /* Where the name belongs to - NULL is
* negative */
unsigned char d_iname[DNAME_INLINE_LEN]; /* small names */
/* Ref lookup also touches following */
unsigned int d_count; /* protected by d_lock */
spinlock_t d_lock; /* per dentry lock */
const struct dentry_operations *d_op;
struct super_block *d_sb; /* The root of the dentry tree */
unsigned long d_time; /* used by d_revalidate */
void *d_fsdata; /* fs-specific data */
struct list_head d_lru; /* LRU list */
/*
* d_child and d_rcu can share memory
*/
union {
struct list_head d_child; /* child of parent list */
struct rcu_head d_rcu;
} d_u;
struct list_head d_subdirs; /* our children */
struct list_head d_alias; /* inode alias list */
};
特别注意该结构体中有一个成员d_inode,其类型struct inode的定义如下:
struct inode {
umode_t i_mode;// 文件权限
uid_t i_uid; // 拥有者id
gid_t i_gid; // 所属组id
const struct inode_operations *i_op;// 目录操作函数
struct super_block *i_sb;// 指向超级快的指针
spinlock_t i_lock;// 文件锁
unsigned int i_flags;// 文件打开方式
struct mutex i_mutex;
unsigned long i_state;
unsigned long dirtied_when;
// inode表的头结点
struct hlist_node i_hash;
struct list_head i_wb_list;
struct list_head i_lru;
struct list_head i_sb_list;
union {
struct list_head i_dentry;
struct rcu_head i_rcu;
};
unsigned long i_ino;// inode号
atomic_t i_count;// inode打开次数
unsigned int i_nlink;// 文件硬链接数
dev_t i_rdev;
unsigned int i_blkbits;
u64 i_version;
loff_t i_size;// 文件大小
#ifdef __NEED_I_SIZE_ORDERED
seqcount_t i_size_seqcount;
#endif
struct timespec i_atime;// 文件最后被访问的时间
struct timespec i_mtime;// 文件内容最后的修改时间
struct timespec i_ctime;// 文件属性最后的修改时间
blkcnt_t i_blocks;// 块数
unsigned short i_bytes;
struct rw_semaphore i_alloc_sem;
const struct file_operations *i_fop; // 文件操作函数 /* former ->i_op->default_file_ops */
struct file_lock *i_flock;
struct address_space *i_mapping;// 块地址映射
struct address_space i_data;
#ifdef CONFIG_QUOTA
struct dquot *i_dquot[MAXQUOTAS];
#endif
struct list_head i_devices;
union {
struct pipe_inode_info *i_pipe;
struct block_device *i_bdev;
struct cdev *i_cdev;
};
__u32 i_generation;
#ifdef CONFIG_FSNOTIFY
__u32 i_fsnotify_mask; /* all events this inode cares about */
struct hlist_head i_fsnotify_marks;
#endif
#ifdef CONFIG_IMA
atomic_t i_readcount; /* struct files open RO */
#endif
atomic_t i_writecount;
#ifdef CONFIG_SECURITY
void *i_security;
#endif
#ifdef CONFIG_FS_POSIX_ACL
struct posix_acl *i_acl;
struct posix_acl *i_default_acl;
#endif
void *i_private; /* fs or device private pointer */
};
这里的inode就是inode表的组成成分,每一个inode以双链表的形式组织成inode表。inode,全称index node即索引节点,该结构体的功能是描述文件的属性信息。
1.1 从内容上理解inode
在 Linux 操作系统中的任何资源都被当作文件来管理。如目录、光驱、终端设备等等,都被当作是一种文件。从这方面来说,Linux 操作系统中的所有的目录、硬件设备都跟普通文件一样,具有共同的属性。而这些属性信息都保存在inode块中。
属性也称为元信息,如文件的创建、修改时间、文件大小等等,这些基本属性可以通过:ls -l命令查看。但是需要注意的是,有一个属性是不包括在inode中的,就是文件名,至于为什么后面讲到目录时再作说明。
下面我们来分析struct inode中的几个成员:
1、目录文件操作的结构体
inode中有个成员i_op,类型为const struct inode_operations *,定义如下:
struct inode_operations {
struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);
void * (*follow_link) (struct dentry *, struct nameidata *);
int (*permission) (struct inode *, int, unsigned int);
int (*check_acl)(struct inode *, int, unsigned int);
int (*readlink) (struct dentry *, char __user *,int);
void (*put_link) (struct dentry *, struct nameidata *, void *);
int (*create) (struct inode *,struct dentry *,int, struct nameidata *);
int (*link) (struct dentry *,struct inode *,struct dentry *);
int (*unlink) (struct inode *,struct dentry *);
int (*symlink) (struct inode *,struct dentry *,const char *);
int (*mkdir) (struct inode *,struct dentry *,int);
int (*rmdir) (struct inode *,struct dentry *);
int (*mknod) (struct inode *,struct dentry *,int,dev_t);
int (*rename) (struct inode *, struct dentry *,
struct inode *, struct dentry *);
void (*truncate) (struct inode *);
int (*setattr) (struct dentry *, struct iattr *);
int (*getattr) (struct vfsmount *mnt, struct dentry *, struct kstat *);
int (*setxattr) (struct dentry *, const char *,const void *,size_t,int);
ssize_t (*getxattr) (struct dentry *, const char *, void *, size_t);
ssize_t (*listxattr) (struct dentry *, char *, size_t);
int (*removexattr) (struct dentry *, const char *);
void (*truncate_range)(struct inode *, loff_t, loff_t);
int (*fiemap)(struct inode *, struct fiemap_extent_info *, u64 start,
u64 len);
} ____cacheline_aligned;
可见,在该成员变量所指向的数据结构中,包含了许多函数指针,这些函数指针大多针对于目录文件。当然inode里也有针对普通文件操作数据结构:struct file_operations,注意这个类型的对象在file结构体中也有一个。
2、inode号
成员i_ino代表的就是该文件所对应的inode编号,每个文件创建后都有自己inode号,inode号是文件存在的唯一标识。
查看文件inode号的命令:ls -i
3、inode打开次数
前面的file结构体中有一个成员叫做f_count记录的是该文件表项被放入打开文件描述符表的数量,每close该文件表项一次f_count对应减一,直到最后减为0时才会删除file结构体。对应inode中也有一个成员叫做i_count,记录的是该inode被多少个文件表项所保存。
4、软、硬链接
硬链接
一般情况下,文件名和inode号码是"一一对应"关系,每个inode号码对应一个文件名。但是,Linux系统允许,多个文件名指向同一个inode号码。
这意味着,可以用不同的文件名访问同样的内容;对文件内容进行修改,会影响到所有文件名;但是,删除一个文件名,不影响另一个文件名的访问。这种情况就被称为"硬链接"(hard link)。
创建硬链接的命令:ln 源文件 目标文件
运行上面这条命令以后,产生的连接文件与目标文件的 inode 号码相同,都指向同一个 inode。inode 信息中有一项叫做"链接数",记录指向该inode的文件名总数,这时就会增加1。
反过来,删除一个文件名,就会使得inode节点中的"链接数"减1。当这个值减到0,表明没有文件名指向这个inode,系统就会回收这个inode号码,以及其所对应block区域。
我们创建一个新目录后可以直接查看这个新目录的硬链接数:
inode结构体中的i_nlink成员对应记录的就是硬连接数,那么为什么新创建的目录一开始硬链接数就是2呢?
创建目录时,新目录默认会生成两个目录项:“.“和”…”。前者的inode号码就是当前目录的inode号码,算作一个当前目录的"硬链接";后者的inode号码就是当前目录的父目录的inode号码,算作一个父目录的"硬链接"。该目录自己加上该目录下的"."都指向同一个inode所以一开始硬连接数是2。
软连接
除了硬链接以外,还有一种特殊情况。
文件A和文件B的inode号码虽然不一样,但是文件A的内容是文件B的路径。读取文件A时,系统会自动将访问者导向文件B。因此,无论打开哪一个文件,最终读取的都是文件B。这时,文件A就称为文件B的"软链接"(soft link)或者"符号链接(symbolic link)。
这意味着,文件A依赖于文件B而存在,如果删除了文件B,打开文件A就会报错:“No such file or directory”。这是软链接与硬链接最大的不同:文件A指向文件B的文件名,而不是文件B的inode号码,所以文件B的inode"硬链接数"不会因此发生变化。
创建软链接的命令:ln -s 源文文件或目录 目标文件或目录
1.2 从ext2文件系统角度理解inode
磁盘的结构
- 硬盘是由一层层的盘片组成的,结构类似数层圆形房屋
- 每一层盘片(图中三层黄边蓝色大圆盘)都有两面(盘面)可供读写。
- 每一面都有数层同心的圆形磁道(可以理解为盘面上的一些圆形轨道)。
- 而扇区(图中的一段扇形区域)是磁道上的一段圆弧(把磁道看成圆)。
- 不同盘面的每一条磁道都能在其他盘面上找到共面的磁道,而他们所在的公共面,就被称为柱面(图中橙色虚线是面上的一条虚线)。
- 磁盘的读写靠磁头的寻轨、转到扇区、读写数据等动作完成,其物理原理和铁氧体的磁滞有关。
磁盘分区
磁盘分区是告诉操作系统“我这块磁盘在此分区可以访问的区域是A柱面到B柱面之间的块”,这样操作系统就知道它可以在所指定的块内进行文件数据的读、写、查找等操作。磁盘分区即指定分区的起始与结束柱面。一个磁盘可以划分成多个分区,每个分区必须先用格式化工具(如mkfs命令)格式化成某种格式的文件系统,然后才能存储文件,格式化过程中会在磁盘上写一些管理存储布局的信息。一个分区只能格式化成一个文件系统。
分区格式化的原因:每种操作系统所配置的文件属性/权限并不相同, 为了存放这些文件所需的数据,因此就需要将分区进行格式化,以成为操作系统能够利用的『文件系统格式(filesystem)』。
在Windows中通常把磁盘分为C盘、D盘、E盘等
在Linux系统中可以通过df命令查看磁盘空间的使用情况:
- a:显示全部的档案系统和各分割区的磁盘使用情形
- i:显示inode的使用量
- k:大小用k来表示 (默认值)
- h:要以GB或千兆字节显示所有文件系统详细信息和用法
- x:显示不是某一个档案系统的所有分割区磁盘使用量
- T:显示每个分割区所属的档案系统名称
ext2文件系统
EXT2第二代扩展文件系统(second extended filesystem,缩写为 ext2),是Linux内核所用的文件系统,于1993年1月加入Linux核心支持之中。
文件系统是如何运行的呢?这与操作系统的文件数据有关。较新的操作系统的文件数据除了文件实际内容外, 通常含有非常多的属性,例如 Linux 操作系统的文件权限(rwx)与文件属性(拥有者、群组、时间参数等)。 文件系统通常会将这两部份的数据分别存放在不同的区块,属性信息放置到inode中,至于实际数据则放置到 data block 区块中。 另外,还有一个超级区块 (superblock) 会记录整个文件系统的整体信息,包括 inode 与 block 的总量、使用量、剩余量等。
一开始介绍的磁盘分区是上图的第一列结构,接下来我们介绍第二列结构:组结构,它包括启动块和块组。启动块(Boot Block),用来存储磁盘分区信息和启动信息,任何文件系统都不能修改启动块。启动块之后才是ext2文件系统的开始,ext2文件系统将整个分区划分成若干个同样大小的块组(Block Group)。
块组的组成
1、超级块(Super Block)描述整个分区的文件系统信息,如inode/block的大小、总量、使用量、剩余量,以及文件系统的格式与相关信息。超级块在每个块组的开头都有一份拷贝(第一个块组必须有,后面的块组可以没有)。 为了保证文件系统在磁盘部分扇区出现物理问题的情况下还能正常工作,就必须保证文件系统的super block信息在这种情况下也能正常访问。所以一个文件系统的super block会在多个block group中进行备份,这些super block区域的数据保持一致。
超级块记录的信息有:
- 保存了文件系统的大小以及所用块和inode的大小
- 整个文件系统block与inode 的总量
- 未使用与已使用的 inode / block 数量;
- filesystem 的挂载时间、最近一次写入数据的时间、最近一次检验磁盘 (fsck) 的时间等文件系统的相关信息;
- 一个 valid bit 数值,若此文件系统已被挂载,则 valid bit 为 0 ,若未被挂载,则 valid bit 为 1 。
PS:superblock的相关信息可以使用:dumpe2fs 这个命令查询
超级快的作用
当操作系统启动后,系统内核会把超级块中的内容复制到内存中,并周期性的利用内存里的最新内容去更新硬盘上的超级块中的内容。由于这个更新存在 一个时间差,为此内存中的超级块信息与硬盘中的超级块信息往往只有在开机与关机的某个特定时刻是同步的;而在其他时间都是不同步的。假设发生操作系统宕机或者因为断电而造成的意外事故时,内存中的超级块信息没有及时保存到硬盘中,此时文件系统的完整性就会受到破坏。轻者导致刚建立的丢失,重则的话会导致 文件系统瘫痪。遇到这种情况时,以前系统工程师往往需要利用系统提供的sync命令在系统出现故障的那一刻把内存里的内容复制到磁盘上。现在这个过程往往操作系统会自动完成,这也是为什么Linux操作系统要比Windows操作系统稳定的一个重要原因。在操作系统重新启动的过程中,系统内核会对内存和硬盘中的信息进行比较,根据他们之间的差异,给文件系统打上干净或者脏的标签。这个信息也是存储在文件系统的超级块中。
可见超级块如果发生损坏的话,对于文件系统的破坏性非常的大。轻者的话导致某个文件系统无法挂载,重则的话导致整个操作系统崩溃。在Linux操作系统中,除了可以利用sync命令来保证硬盘上的内容决不会比内存里的内容更新慢之外,操作系统会将超级块内容保存到不同块组中。当其中一个超级块出现问题时,操作系统会自动采用另外一个超级块。等到系统运行正常后,系统内容就会把可用的超级块去替换那个故障的超级块,这样文件系统与操作系统就可以正常挂载与启动。否则的话,仅有有一个超级块是可用的,那么这一个坏了整个文件系统就坏了。这种机制在很大程度上提高了超级块的安全性和Linux操作系统的稳定性。
inode源码中超级快的位置
在inode中会存有一个指向超级快的指针:
2、块组描述符表(Group Descriptor Table,简称GDT)存储该块组的描述信息,整个分区分成多个块组就对应有多少个块组描述符。
每个块组描述符存储一个块组的描述信息,如在这个块组中从哪里开始是inode Table,从哪里开始是Data Blocks,空闲的inode和数据块还有多少个等等。
3、块位图(Block Bitmap)用来描述整个块组中哪些块已用哪些块空闲。块位图本身占一个块,其中的每个bit代表本块组的一个block,这个bit为1代表该块已用,为0表示空闲可用。假设格式化时block大小为1KB,这样大小的一个块位图就可以表示1024*8个块的占用情况,因此一个块组最多可以有10248个块。
4、inode位图(inode Bitmap)和块位图类似,本身占一个块,其中每个bit表示一个inode是否空闲可用。 Inode bitmap的作用是记录该块组中Inode区域的使用情况。
5、inode表(inode Table)由一个块组中的所有inode组成。一个文件除了数据需要存储之外,一些描述信息也需要存储,如文件类型,权限,文件大小,创建、修改、访问时间等,这些信息存在inode中而不是数据块中。inode表占多少个块在格式化时就要写入块组描述符中。 在ext2文件系统中,每个文件在磁盘上的位置都由文件系统块组中的一个Inode指针进行索引,Inode将会把具体的位置指向一些真正记录文件数据的block块,需要注意的是这些block可能和Inode同属于一个block group也可能分属于不同的block group。我们把文件系统上这些真实记录文件数据的block称为Data blocks。
6、数据块(Data Block)是用来放置文件内容数据的地方。根据不同的文件类型有以下几种情况:
- 对于普通文件,文件的数据存储在数据块中。
- 对于目录文件,存储内容为该目录下所有文件的文件名和inode编号,这样我们通过拿到该目录下的文件名找到对应的inode号就可以访问该文件的内容和数据了,所以没必要在inode中存储文件名,文件名是存储在目录的数据块中的。
- 对于符号链接,如果目标路径名较短则直接保存在inode中,如果较长则分配一个数据块来保存。
- 设备文件、FIFO和socket等特殊文件没有数据块。
inode源码中的数据块
一个文件使用的数据块和inode结构的对应关系,是通过一个数组进行维护的,该数组一般可以存储15个元素,其中前12个元素分别对应该文件使用的12个数据块,剩余的三个元素分别是一级索引、二级索引和三级索引,当该文件使用数据块的个数超过12个时,可以用这三个索引进行数据块扩充。这个数组对应inode中的成员i_data,除此之外inode中还有一个记录块数的成员i_blocks。
2、为什么要有inode表?
由于inode号码与文件名分离,这种机制导致了一些Linux系统特有的现象。
-
有时,文件名包含特殊字符,无法正常删除。这时,直接删除inode节点,就能起到删除文件的作用。
-
移动文件或重命名文件,只是改变文件名,不影响inode号码。
-
打开一个文件以后,系统就以inode号码来识别这个文件,不再考虑文件名。通常来说,系统无法从inode号码得知文件名。
第3点使得软件更新变得简单,可以在不关闭软件的情况下进行更新,不需要重启。因为系统通过inode号码,识别运行中的文件,不通过文件名。更新的时候,新版文件以同样的文件名,生成一个新的inode,不会影响到运行中的文件。等到下一次运行这个软件的时候,文件名就自动指向新inode,旧版文件的inode则被回收。
3、inode表和进程的联系
磁盘中的每个文件都对应一个inode,每一个文件表项都会指向一个文件的inode,但是同一文件的inode可以对应多个文件表项(当多次调用open打开同一个文件时就会出现这种情况)。不管是同一进程多次打开同一已存在文件(如图中A进程的0号和2号文件描述符对应两个文件表项,但是最终指向同一inode即同一文件),还是不同进程多次打开同一已存在文件(如图中A进程3号文件描述符和B进程的3号文件描述符)。
更多推荐
所有评论(0)