Linux文件系统
一、Linux文件系统详解
本大节转自:https://blog.youkuaiyun.com/new0801/article/details/63687127
1、文件系统层次分析
由上而下主要分为用户层、VFS层、文件系统层、缓存层、块设备层、磁盘驱动层、磁盘物理层。
用户层:最上面用户层就是我们日常使用的各种程序,需要的接口主要是文件的创建、删除、打开、关闭、写、读等。
VFS层:我们知道Linux分为用户态和内核态,用户态请求硬件资源需要调用System Call通过内核态去实现。用户的这些文件相关操作都有对应的System Call函数接口,接口调用 VFS对应的函数。
文件系统层:不同的文件系统实现了VFS的这些函数,通过指针注册到VFS里面。所以,用户的操作通过VFS转到各种文件系统。文件系统把文件读写命令转化为对磁盘LBA的操作,起了一个翻译和磁盘管理的作用。
缓存层:文件系统底下有缓存,Page Cache,加速性能。对磁盘LBA的读写数据缓存到这里。
块设备层:块设备接口Block Device是用来访问磁盘LBA的层级,读写命令组合之后插入到命令队列,磁盘的驱动从队列读命令执行。Linux设计了电梯算法等对很多LBA的读写进行优化排序,尽量把连续地址放在一起。
磁盘驱动层:磁盘的驱动程序把对LBA的读写命令转化为各自的协议,比如变成ATA命令,SCSI命令,或者是自己硬件可以识别的自定义命令,发送给磁盘控制器。Host Based SSD甚至在块设备层和磁盘驱动层实现了FTL,变成对Flash芯片的操作。
磁盘物理层:读写物理数据到磁盘介质。

2、文件系统结构与工作原理(主要以ext4为例)
我们都知道,windows文件系统主要有fat、ntfs等,而linux文件系统则种类多的很,主要有VFS做了一个软件抽象层,向上提供文件操作接口,向下提供标准接口供不同文件系统对接,下面主要就以EXT4文件系统为例,讲解下文件系统结构与工作原理:


上面两个图大体呈现了ext4文件系统的结构,从中也相信能够初步的领悟到文件系统读写的逻辑过程。下面对上图里边的构成元素做个简单的讲解:
引导块:为磁盘分区的第一个块,记录文件系统分区的一些信息,引导加载当前分区的程序和数据被保存在这个块中。一般占用2kB,
超级块:超级块用于存储文件系统全局的配置参数(譬如:块大小,总的块数和inode数)和动态信息(譬如:当前空闲块数和inode数),其处于文件系统开始位置的1k处,所占大小为1k。为了系统的健壮性,最初每个块组都有超级块和组描述符表(以下将用GDT)的一个拷贝,但是当文件系统很大时,这样浪费了很多块(尤其是GDT占用的块多),后来采用了一种稀疏的方式来存储这些拷贝,只有块组号是3, 5 ,7的幂的块组(譬如说1,3,5,7,9,25,49…)才备份这个拷贝。通常情况下,只有主拷贝(第0块块组)的超级块信息被文件系统使用,其它拷贝只有在主拷贝被破坏的情况下才使用。
块组描述符:GDT用于存储块组描述符,其占用一个或者多个数据块,具体取决于文件系统的大小。它主要包含块位图,inode位图和inode表位置,当前空闲块数,inode数以及使用的目录数(用于平衡各个块组目录数),具体定义可以参见ext3_fs.h文件中struct ext3_group_desc。每个块组都对应这样一个描述符,目前该结构占用32个字节,因此对于块大小为4k的文件系统来说,每个块可以存储128个块组描述符。由于GDT对于定位文件系统的元数据非常重要,因此和超级块一样,也对其进行了备份。GDT在每个块组(如果有备份)中内容都是一样的,其所占块数也是相同的。从上面的介绍可以看出块组中的元数据譬如块位图,inode位图,inode表其位置不是固定的,当然默认情况下,文件系统在创建时其位置在每个块组中都是一样的,如图2所示(假设按照稀疏方式存储,且n不是3,5,7的幂)
块组:每个块组包含一个块位图块,一个 inode 位图块,一个或多个块用于描述 inode 表和用于存储文件数据的数据块,除此之外,还有可能包含超级块和所有块组描述符表(取决于块组号和文件系统创建时使用的参数)。下面将对这些元数据作一些简要介绍。
块位图:块位图用于描述该块组所管理的块的分配状态。如果某个块对应的位未置位,那么代表该块未分配,可以用于存储数据;否则,代表该块已经用于存储数据或者该块不能够使用(譬如该块物理上不存在)。由于块位图仅占一个块,因此这也就决定了块组的大小。
inode位图:inode位图用于描述该块组所管理的inode的分配状态。我们知道inode是用于描述文件的元数据,每个inode对应文件系统中唯一的一个号(
可通过ls -li命令或者stat <filename>进行查看
),如果inode位图中相应位置位,那么代表该inode已经分配出去;否则可以使用。由于其仅占用一个块,因此这也限制了一个块组中所能够使用的最大inode数量。
inode表:inode表用于存储inode信息。它占用一个或多个块(为了有效的利用空间,多个inode存储在一个块中),其大小取决于文件系统创建时的参数,由于inode位图的限制,决定了其最大所占用的空间。
以上这几个构成元素所处的磁盘块成为文件系统的元数据块,剩余的部分则用来存储真正的文件内容,称为数据块,而数据块其实也包含数据和目录。
了解了文件系统的结构后,接下来我们来看看操作系统是如何读取一个文件的:

大体过程如下:
1、根据文件所在目录的inode信息,找到目录文件对应数据块
2、根据文件名从数据块中找到对应的inode节点信息
3、从文件inode节点信息中找到文件内容所在数据块块号
4、读取数据块内容
到这里,相信很多人会有一个疑问,我们知道一个文件只有一个Inode节点来存放它的属性信息,那么你可能会想如果一个大文件,那它的block一定是多个的,且可能不连续的,那么inode怎么来表示呢,下面的图告诉你答案:

三级寻址
在inode table中会存放多个inode,而每个inode都只存放一个文件名,一个指向与Data Block的指针。这就是Ext2的一级寻址。当文件过大(一个块为4K),会将第一个数据块用作记录其他指向的数据块。这就是二级寻址(第一个数据块存放的是地址,也就是4个字节,一共可以指向1024个地址块,可供寻址范围是4M),三级寻址可供选择寻址空间为16G,四级寻址则为TB级。
到这里,应该都已经非常清楚文件读取的过程了,那么下面再抛出两个疑问:
1、文件的拷贝、剪切的底层过程是怎样的?
2、软连接和硬连接分别是如何实现的?
下面来结合stat命令动手操作一下,便知真相:
(1)拷贝文件:创建一个新的inode节点,并且拷贝数据块内容,会发现,拷贝得到的文件会分配一个新的inode节点。

(2) 剪切文件:同个分区里边mv,inode节点不变,只是更新目录文件对应数据块里边的文件名和inode对应关系;跨分区mv,则跟拷贝一个道理,需要创建新的inode, 因为inode节点不同分区是不能共享的。

(3) 软连接: 创建软连接会创建一个新的inode节点,其对应数据块内容存储所链接的文件名信息,这样原文件即便删除了, 重新建立一个同名的文件,软连接依然能够生效。

(4) 硬链接: 创建硬链接,并不会新建inode节点,只是links加1,还有再目录文件对应数据块上增加一条文件名和inode对应关系记录; 只有将硬链接和原文件都删除之后,文件才会真正删除,即links为0才真正删除。

3、文件顺序读写和随机读写
从前面文章了解了磁盘工作原理之后,也已经明白了为什么文件随机读写速度会比顺序读写差很多,这个问题在windows里边更加明显,为什么呢?究其原因主要与文件系统工作机制有关,fat和ntfs文件系统设计上,每个文件所处的位置相对连续甚至紧靠在一起,这样没有为每个文件留下足够的扩展空间,因此容易产生磁盘碎片,用过windows系统的应该也知道,windows磁盘分区特别提供了磁盘碎片整理的高级功能。如下图:

主要是因为Linux的文件系统会将文件分散在整个磁盘,在文件之间留有大量的自由空间,而不是像Windows那样将文件一个接一个的放置。当一个文件被编辑了并且变大了,一般都会有足够的自由空间来保存文件。如果碎片真的产生了,文件系统就会尝试在日常使用中将文件移动来减少碎片,所以不需要专门的碎片整理程序。但是,如果磁盘空间占用已经快满了,那碎片是不可避免的,文件系统的设计本来就是用来满足正常情况下使用的。如果磁盘空间不够,那要么就是数据冗余了,要么就该换容量更大的磁盘。你可以使用fsck命令来检测一下一个Linux文件系统的碎片化程度,只需要在输出中查看非连续i节点个数(non-contiguous inodes)就可以了。

二、Linux文件系统函数
1、基于inode的函数——stat
inode中记录着 Linux文件的属性,首先,看一下inode的结构:
struct stat {
dev_t st_dev; /* 文件的设备编号 */
ino_t st_ino; /* inode number */
mode_t st_mode; /* 文件的类型和存取权限 */
nlink_t st_nlink; /* number of hard links */
uid_t st_uid; /* user ID of owner */
gid_t st_gid; /* group ID of owner */
dev_t st_rdev; /* device ID (if special file) */
off_t st_size; /* 文件字节数 */
blksize_t st_blksize; /* blocksize for filesystem I/O */
blkcnt_t st_blocks; /* number of 512B blocks allocated */
time_t st_atime; /* time of last access */
time_t st_mtime; /* time of last modification */
time_t st_ctime; /* time of last status change */
};
对inode结构体中三个时间变量的解释:
st_atime:文件最近访问时间;
st_mtime:文件内容最近更改时间;
st_ctime:文件inode值最近改动时间。

函数解释
函数功能:通过文件名filename获取文件信息,并保存在buf所指的结构体stat中;
函数参数:其中path表示文件地址;fd表示文件描述符;buff表示文件信息结构体指针;
返回值:执行成功则返回0,失败返回-1,错误代码存于errno。
给定一个pathname:
stat函数返回一个与此命名文件有关的信息结构;
fstat函数获得已在描述符filedes上打开的文件的有关信息;
lstat函数类似于stat,但是当命名的文件是一个符号连接时,lstat返回该符号连接的有关信息,而不是由该符号连接引用的文件的信息(不跟踪符号链接
)。
2、文件(夹)”确认”函数——access
对于Linux系统下的文件,每个文件或文件夹都有访问权限,在这里access就可以用于查看对应的访问权限。

函数解释
函数功能:确定文件或文件夹的访问权限。即,检查某个文件的存取方式,比如说是只读方式、只写方式等;
函数参数:其中pathname表示文件(夹)地址,mode表示想要查看的文件权限类型:
mode:
R_OK:是否具有读权限
W_OK:是否具有写权限
X_OK:是否具有执行权限
F_OK:测试一个文件是否存在
返回值:如果指定的存取方式有效,则函数返回0,否则函数返回-1。
示例:
#include <stdio.h>
#include <unistd.h>
#include <assert.h>
void accessTest(const char *filename,int mode)
{
int result = access(filename, mode);
char strPrint[50] = { 0 };
switch (mode)
{
case R_OK:
sprintf(strPrint, "File is %sReadable!", result == 0 ? "" : "Not ");
break;
case W_OK:
sprintf(strPrint, "File is %sWritable!", result == 0 ? "" : "Not ");
break;
case X_OK:
sprintf(strPrint, "File is %sExacutable!", result == 0 ? "" : "Not ");
break;
case F_OK:
sprintf(strPrint, "File is %sExist!", result == 0 ? "" : "Not ");
break;
default:
assert(0);
break;
}
printf("%s\n", strPrint);
}
int main(void)
{
char sz[] = "/home/richard/get.html";
printf("Filename:%s\n", sz);
accessTest(sz, R_OK);
accessTest(sz, W_OK);
accessTest(sz, X_OK);
accessTest(sz, F_OK);
return 0;
}
3、修改文件(夹)访问权限——chmod
在Linux下我们用shell命令的chmod 来修改文件(夹)的访问权限,同样,我们也可以用Linux中的系统函数chmod和fchmod来修改文件(夹)的访问权限。

函数解释
函数功能:修改文件(夹)的访问权限;
函数参数:其中path表示文件(夹)地址;fd表示文件描述符;mode表示想要查看的文件权限类型:
返回值:如果指定的存取方式有效,则函数返回0,否则函数返回-1。
关于文件权限类型值mode_t,可见下表:
modeName | modeValue | modeDescription |
---|---|---|
S_ISUID | 04000 | 文件的执行时设置用户ID(set user-id on execution)位 |
S_ISGID | 02000 | 文件的执行时设置组ID(set group-id on execution)位 |
S_ISVTX | 01000 | 文件的保存正文(粘着位sticky)位(黏住位用于swap分区。) |
S_IRWXU | 00700 | 文件所有者具有读、写、执行权限 |
S_IRUSR(S_IREAD) | 00400 | 文件所有者具可读取权限 |
S_IWUSR(S_IWRITE) | 00200 | 文件所有者具可写入权限 |
S_IXUSR(S_IEXEC) | 00100 | 文件所有者具可执行权限 |
S_IRWXG | 00700 | 用户组具有读、写、执行权限 |
S_IRGRP | 00400 | 用户组具可读取权限 |
S_IWGRP | 00200 | 用户组具可写入权限 |
S_IXGRP | 00100 | 用户组具可执行权限 |
S_IRWXO | 00700 | 其他用户具有读、写、执行权限 |
S_IROTH | 00400 | 其他用户具可读取权限 |
S_IWOTH | 00200 | 其他用户具可写入权限 |
S_IXOTH | 00100 | 其他用户具可执行权限 |
变量名记忆:
S_ISUID: SUID->Set User ID
S_ISGID: SUID->Set Group ID
S_ISVTX: SVTX->SaVe Text Bit
S_IRWXU: RWX(可读可写可执行) U->User
同理,后面的G->Group;O->Other。
变量使用示例:
比如要修改test文件的权限,权限是744,那么可以采用以下几种方法:
chmod(“/home/richard/test”, S_IRWXU|S_IRGRP|S_IROTH);
chmod(“/home/richard/test”, 0744);
chmod(“/home/richard/test”, 484);
第一种方法是将00700和00040和00004进行或运算,最终得到的结果就是0744(八进制表示方法是首位为0,十六进制是0x),八进制的0744就等于十进制的484。所以上面三种方法是等效的。
当我们给chmod函数传递参数时他会将对应的十进制的mode参数转换为相应的八进制进行运算。所以文件的权限为744时传递给函数chmod的参数就应该写成0744(八进制表示方法)或者484(十进制表示方法)。
4、创建文件夹——mkdir

函数解释
函数功能:以mode方式创建一个以参数pathname命名的目录,mode定义新创建目录的权限;
函数参数:其中pathname表示文件(夹)地址;mode表示目录的权限类型;
返回值:如果创建成功,函数返回0,否则函数返回-1。
当然一般创建目录的时候应该给可执行权限,也就是755,否则是无法cd切换目录进去的。
示例:
#include <stdio.h>
#include <sys/stat.h>
int main(char argc, char *argv[])
{
int ok = 0;
if((ok=mkdir(argv[1],0755)) != 0)
{
perror("mkdir %s", argv[1]);
}
return 0;
}
5、创建链接——link


函数解释
函数功能:为oldpath文件创建一个newpath硬链接
;
函数参数:其中oldpath表示已存在文件路径 ,newpath表示目标链接文件地址(不存在的);
返回值:如果创建成功,函数返回0,否则函数返回-1。
当rm删除该链接时,只是删除了目录下的记录项和把inode硬链接计术减1,减为0时,才会真正的删除文件。(文件本身初始时就有一个基于本身的硬链接
)
读取链接
读取到的链接所指向的文件名。不读取文件内容。
删除链接
如果是符号链接,删除符号链接
如果是硬链接,硬链接数减一,当为0时释放inode和数据也块
如果文件硬链接属性为0,但是进程已经打开该文件,并持有该文件描述符,则当文件关闭时,内核才会删除文件。
利用此特性创建临时文件,进程中创建然后马上调用unlink,在文件未关闭之前可以存放临时数据,关闭后文件会被内核销毁。
6、文件(夹)重命名——rename

函数解释
函数功能:用于重命名文件、改变文件路径或更改目录名称;
函数参数:其中oldname为旧文件名,newname为新文件名;
返回值:如果重命名成功,函数返回0,否则函数返回-1。
重命名文件:
如果newname指定的文件存在,则会被删除。
如果newname与oldname不在一个目录下,则相当于移动文件。
重命名目录:
如果oldname和oldname都为目录,则重命名目录。
如果newname指定的目录存在且为空目录,则先将newname删除。
对于newname和oldname两个目录,调用进程必须有写权限。
重命名目录时,newname不能包含oldname作为其路径前缀。例如,不能将/usr更名为/usr/foo/testdir,因为老名字( /usr/foo)是新名字的路径前缀,因而不能将其删除。
示例:
#include <stdio.h>
#include <fcntl.h>
int main(void)
{
char oldname[100], newname[100];
/* prompt for file to rename and new name */
printf("请告诉我一个文件的完整路径: ");
gets(oldname);
printf("您想修改为: ");
gets(newname);
/* 更改文件名 */
if (rename(oldname, newname) == 0)
printf("已经把文件 %s 修改为 %s.\n", oldname, newname);
else
perror("rename");
return 0;
}
7、改变工作目录——chdir


函数解释
函数功能:用于改变当前进程的工作目录;
函数参数:其中path为想要改变的目标目录;
返回值:如果改变成功,函数返回0,否则函数返回-1。
8、获取当前工作目录——getcwd/getwd/get_current_dir_name



函数解释
函数功能:用于获取当前进程的工作目录;
函数参数:其中buf用于存放工作目录名称数据;size表示设置获取目录名称的长度;
返回值:如果改变成功,函数返回一个指向所获取的目录的字符指针,否则函数返回NULL。
示例:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("Current dir name:%s\n", get_current_dir_name());
char path[100] = { 0 };
printf("Current path:%s;\t%s;\n", getwd(path), path);
char samePath[100] = { 0 };
printf("The same path is:%s;\t%s;\n", getcwd(samePath, 100), samePath); //Return NULL if the directory couldn't be determined or size was too small.
return 0;
}
三、目录操作函数实例——递归遍历目录
示例:
#include <stdio.h>
#include <unistd.h>
void readDir(DIR *dir, const char *path)
{
if (dir == NULL) {
return;
}
char buff[100] = { 0 };
struct dirent *ent;
while ((ent = readdir(dir)) != NULL) {
char type = ent->d_type;
if (type == DT_DIR) {
//如果是目录,就打开进入该目录
if (strcmp(ent->d_name, ".") == 0 ||
strcmp(ent->d_name, "..") == 0) {
continue;
}
if (strlen(path) + strlen(ent->d_name) + 2 > sizeof(buff)) {
printf("The buffer is full!\n");
continue;
}
sprintf(buff, "%s%c%s", path, "\/", ent->d_name); // "/"需要转义
printf("Dirname:%s\n", buff);
DIR *ndir = opendir(buff);
readDir(ndir, buff);
}
else {
sprintf(buff, "%s%c%s", path, "\/", ent->d_name); // "/"需要转义
printf("Filename:%s\n", buff);
}
}
}
int main(void)
{
char path[100] = { 0 };
printf("Current path:%s.\n", getwd(path));
struct stat buf;
int res = stat(path, &buf);
if (res < 0) {
printf("Get path stat error!\n");
exit(1);
}
if (!S_ISDIR(buf.st_mode)) { //判断是否是目录
printf("The path is not a directory!!\n");
exit(1);
}
DIR *dir = opendir(path);
readDir(dir, path);
return 0;
}
本次测试只能够递归显示一级目录,如果有兴趣的话,可以尝试去实现递归显示所有文件,甚至实现shell命令tree的功能。