Linux文件操作原理
在Linux环境下,一切皆是文件。无论是我们使用的系统命令,还是各种外设、硬件,都被Linux系统视为文件来管理。
1 C语言库函数原理
C语言作为用户级语言,其库函数当然是不能直接访问外设的。如果需要访问外设,必须通过操作系统提供的系统调用。但是我们常见的 'scanf' 函数、'printf' 函数事实上都访问了如键盘、显示器等硬件设备。这是因为C语言的库函数就是封装了系统调用得到的。
这里用一些常用的接口演示其作用以及如何使用系统调用模拟其实现效果。
#include <stdio.h>
int main()
{
FILE* fp = fopen("log.txt","w");
if(fp==NULL)
{
printf("Open log.txt failed\n");
return 1;
}
fprintf(fp,"hello filesystem\n");
fclose(fp);
return 0;
}
在这个程序中,我以写入模式('w')打开了一个文件('log.txt'),并向这个文件中写入了一段字符串。可以看到,运行程序前,当前目录下并没有名为 'log.txt' 的文件,但运行程序后,系统自动创建了一个名为 'log.txt' 的文件,并成功向其中写入了给定数据。
这是因为 fopen 函数的 'w' 模式会在所要打开的文件不存在时自动创建该文件,若目标文件存在,则清空该文件,从头开始写入。我们可以在上述测试的基础上,将 fpirntf 函数取消后再次编译运行程序,看到如下效果。
#include <stdio.h>
int main()
{
FILE* fp = fopen("log.txt","w");
if(fp==NULL)
{
printf("Open log.txt failed\n");
return 1;
}
fclose(fp);
return 0;
}
可以看见,'log.txt' 文件被清空了。这是由于 fopen 函数封装了 open 系统调用得到的效果。系统调用与库函数不同,是由操作系统提供,可直接访问硬件设备的接口。当进程使用 fopen 函数打开文件时,fopen 函数会调用 open 接口,操作系统会按照要求将目标文件从磁盘中加载到内存,并由文件系统维护。
我们同样可以使用 open 接口实现类似效果,如下例。
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
int fd = open("log.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
if(fd==-1)
{
printf("open log.txt failed\n");
return 1;
}
const char buf[1024] = "hello filesystem\n";
write(fd,buf,strlen(buf));
close(fd);
return 0;
}
以上就是直接使用系统调用模拟之前使用库函数实现的效果。
#include <unistd.h>
#include <fcntl.h>
int open(const char *pathname, int flags, mode_t mode);
这里主要介绍一下示例中使用的 open 接口。open 接口的三个参数分别为文件名、打开方式和文件权限。
示例中的打开方式其实对应了库函数 fopen 的打开方式 'w',在 open 接口中采用的是传入 int 类型的变量,通过 int 变量的各个 bit 位来控制文件打开方式。
使用按位或(' | ')运算合并参数,并使用按位与(' & ')操作检测,即可实现以 bit 位控制打开方式。示例中的宏值事实上就是一系列 2^n 值(只有一个 bit 位为 1),其中 'O_WRONLY' 代表只读;'O_CREAT' 代表若文件不存在,则自动创建文件;'O_TRUNC' 代表打开文件时清空文件。按照这三种方式打开文件即可实现 fopen 函数的 'w' 方式的效果。
第三个参数的文件权限,示例中设置的为 '0666',而实际创建出来的 'log.txt' 文件的权限为 '-rw-rw-r--',即 '0664'。这是由于系统中的权限掩码导致的。这个问题可以通过在程序中使用 umask 系统调用,设置进程的局部权限掩码来解决,或者在创建进程后使用 chmod 等命令修改文件权限。
2 文件描述符
2.1 概念
库函数对系统调用的封装并不只有设置文件打开方式封装更加简便,可以注意到,fopen 函数的返回值是 FILE*类型,而 open 接口的返回值是 int 类型。事实上,open 接口的返回值就是所谓的文件描述符。而 fopen 函数的返回值 FILE* 是一个结构体指针,在 FILE 结构体中则封装了文件描述符、文件属性等内容。
2.2 使用示例
进程调用接口打开文件时,都需要为被打开的文件分配一个文件描述符,我们输出可以看看 'log.txt' 的文件描述符。(示例中输出 FILE 结构体封装中的文件描述符,与使用 open 接口返回的文件描述符是同样的效果)
#include <stdio.h>
int main()
{
FILE* fp=fopen("log.txt","a");
printf("log.txt's fd: %d\n",fp->_fileno);
fclose(fp);
return 0;
}
文件描述符实质上可以理解为一个数组的索引值,其分配原则是从 '0' 开始,分配最小的未分配的索引值。而上述测试中得到打开 'log.txt' 后分配的文件描述符为 '3',说明当前进程在打开 'log.txt' 前已经打开了 3 个文件。而这三个文件事实上是在进程被创建时默认打开的,三个输入输出流文件,依次为:stdin、stdout、stderr。通过以下示例简单验证。
#include <stdio.h>
int main()
{
printf("stdin's fd: %d\n",stdin->_fileno);
printf("stdout's fd: %d\n",stdout->_fileno);
printf("stderr's fd: %d\n",stderr->_fileno);
return 0;
}
这三个流文件可以简单对应到键盘、显示器、显示器。当使用C语言向默认文件描述符为 '1' 的文件中写入内容,事实上就是在向显示器写入,这也是 printf 函数将内容输出到显示器的原理。那么,如果我们使用 close 或 fclose 关闭 '1' 号文件描述符,printf 就无法再向显示器写入数据了。当然,我们也可以使用 fprintf 函数向指定的流文件写入。
#include <stdio.h>
#include <unistd.h>
int main()
{
int i=0;
printf("%d: hello filesystem\n",++i);
close(1);
printf("%d: hello filesystem\n",++i);
fprintf(stderr,"%d: hello filesyste\n",++i);
FILE* fp=fopen("log.txt","r");
fprintf(stderr,"log.txt's fd: %d\n",fp->_fileno);
fclose(fp);
return 0;
}
可以看到,第一次 pirntf 成功被调用并输出,关闭 '1' 号文件描述符('stdout')后 ,第二次 printf 被调用成功(结果第二行的 'i' 值为 '3',说明 printf 中的 '++i' 被执行),但并未输出。此时使用 fprintf 向 stderr 中写入的内容被成功显示。最后测试文件描述符的分配规则是否正确,打开 'log.txt' 并查看其文件描述符,结果为 '1',说明'stdout' 被关闭后,进程的文件描述符列表中索引为 '1' 的位置空缺,因此打开 'log.txt' 时为其分配该位置。
2.3 底层维护原理
进程的数据被存储在内存中,那么进程打开文件的过程中,操作系统就应该将对应的文件加载到内存当中。这里面涉及到的第一个问题是,操作系统在进行文件管理时,不可能每次收到进程访问请求时都遍历内存查找目标文件。这里可以采用类似于进程管理的结构,使用一个类似于PCB的结构来管理文件,将文件的属性、文件描述符、磁盘存储位置等信息都保存在结构中,再采用链表的形式将各个文件的文件结构组织起来。
而从进程的角度,进程只需要关心自己所打开的文件。因此,在进程的PCB中存有其所打开的文件描述符表,当需要访问对应的文件描述符所指向的文件结构,再通过文件结构中的文件地址找到对应的文件内容即可。
当然,不同进程同时打开同一文件的情况也存在,例如默认打开的 'stdin'、'stdout' 和 'stderr' 都会被多个文件打开。只需要在对应的文件结构中加入引用计数,当一个文件每被一个进程打开时,文件结构中的引用计数值 +1,反之关闭时 -1,当最后一个进程关闭该文件时,操作系统即可将其从内存中释放。