系统级别的文件操作
所谓系统级别的文件操作,其实就是Linux中与文件操作有关的系统调用。
1、学习系统调用
Linux有一些文件操作相关的系统调用,这些系统调用往往被封装在了上方库函数中,形成了我们在C语言见到的文件操作库函数(fopen fwrite fprintf ...)
1.1、open
c
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
系统调用open,顾名思义,就是用来打开文件的。
const char *pathname
这个参数,与fopen一样,都是填文件所处的路径,
- 只输入文件名,
open就去"当前路径"下找同名文件; - 输入指定路径,
open就去指定的路径下找同名文件。
返回值

open的返回值,
- 打开文件成功,返回文件描述符(
fd); - 打开文件失败,返回-1,同时设置
errno
int flags
这个参数,其实是一种"标志位",表示的是文件的打开模式。
我们可以观察到,Linux内部提供了一些宏以输入到flags参数位置上:
c
O_RDONLY
O_WRONLY
O_APPEND
O_CREAT
...
要理解这些宏的含义,以及宏如何使用以能够设定文件的打开方式,我们可以先来看一个小demo:
c
#include <stdio.h>
#define ONE 1
#define TWO (1<<1)
#define THREE (1<<2)
#define FOUR (1<<3)
#define FIVE (1<<4)
void Print(int flags)
{
if (flags & ONE)
printf("ONE ");
if (flags & TWO)
printf("TWO ");
if (flags & THREE)
printf("THREE ");
if (flags & FOUR)
printf("FOUR ");
if (flags & FIVE)
printf("FIVE ");
printf("\n");
}
int main()
{
Print(ONE);
Print(ONE | TWO);
Print(ONE | TWO | THREE);
Print(ONE | TWO | THREE | FOUR);
Print(ONE | TWO | THREE | FOUR | FIVE);
return 0;
}

我们通过定义宏和位操作,将多个选项"融合"为一个变量flags,传入Print;Print中再对flags做解析,执行不同的选项。这样我们就做到了用一个变量接收多个选项(标志位)的目标。实际上这样的方法,借助了位图的思想:

O_RDONLY O_WRONLY O_APPEND O_CREAT O_TRUNC等宏,就是只有一个比特位是1的整型值,就像上面 demo 中定义的ONE TWO THREE FOUR FIVE。
我们想以哪些方式打开文件,就将哪些宏处理成一个值(按位或),传入flags;open内部就会进行解析,从而按照我们想要的方式打开文件。比如我们想要按照"创建新文件、只写、追加"的方式打开文件,我们就可以这样传参:
c
open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
标志位,一般表示的是 是 / 否 的问题。比如我们想以"创建新文件、只写"的方式打开文件,那么"创建新文件、只写"的标志位就是"是",其它标志位就是"否"。如果选项有多少就定义多少标志位,那么就会出现变量太多、空间浪费的情况。所以我们可以借助位图的思想,采取"用一个变量接收多个选项",来节省空间、提高效率。
mode_t mode
这个参数,表示的是文件的权限。
只有在文件不存在,需要新建文件的时候,才需要这个参数。因为已经存在的文件,权限是已经确定好了的。
对于不存在的文件,我们就需要新建同名文件。假设我们设置权限为"0666"(对拥有者、所属组、other都是rw):
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
int fd = open("log.txt", O_CREAT | O_WRONLY, 0666);
// 没有 O_CREAT ,且文件不存在的时候, open 会执行失败
// 一定要写 0666 ,不能写 666 。因为权限码是八进制数!
if (fd < 0)
{
perror("open failed!\n");
exit(1);
}
return 0;
}

但是,我们再看一眼log.txt的权限为:

不是 666 ,而是 664 !
想到了什么?权限掩码!文件的最终权限是:
最终权限 = 起始权限 & (~权限掩码)
我们在原来代码基础上,再加一行语句:
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
umask(0);
int fd = open("log.txt", O_CREAT | O_WRONLY, 0666);
if (fd < 0)
{
perror("open failed!\n");
exit(1);
}
return 0;
}

代码中写umask(0),其实是在程序内部将权限掩码设置为了 0000 。我们看到结果是log.txt的权限是 0666 ,明显使用的是权限掩码 0000 。
所以,Linux中设定好的umask,和程序中重新设定的umask,优先采用程序中设定的umask。(就近原则)
1.2、其它系统调用
read ···>
c
ssize_t read(int fd, void *buf, size_t count);
参数:
fd:文件描述符buf:指向接收读取内容的位置(缓冲区)count:接收读取内容位置的大小
返回值:
- 如果成功,返回读取到的字节数;
- 如果失败,返回-1。

write ···>
c
ssize_t write(int fd, const void *buf, size_t count);
参数:
fd:文件描述符buf:指向要写入文件的内容count:写入文件内容的大小
返回值:
- 成功,返回写入的字节数;
- 失败,返回-1。

更多的系统调用,我们可能会在接下来的学习中遇到。
1.3、利用所学系统调用,简单模拟fopen的打开模式,进而读写文件
w模式:
- 文件不存在,新建文件;
- 清空文件历史内容;
- 从文件的开始处写入(数组下标
index = 0处开始)。
用open模拟是这样的:
c
open("log.txt", O_CREAT | O_TRUNC | O_WRONLY, 0666);
用系统调用完成:仿照fopen中的w模式,打开文件做写入。
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
int fd = open("log.txt", O_CREAT | O_TRUNC | O_WRONLY, 0666);
if (fd < 0)
{
perror("open");
exit(1);
}
const char *str = "Hello world.\n";
int cnt = 9;
while (cnt--)
write(fd, str, strlen(str));
close(fd);
return 0;
}
a模式:
- 文件不存在,新建文件;
- 不会清空文件历史内容;
- 从文件末尾开始写入。
用open模拟是这样的:
c
open("log.txt", O_CREAT | O_APPEND | O_WRONLY, 0666);
用系统调用完成:仿照fopen中的a模式,打开文件做追加写入。
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
int fd = open("log.txt", O_CREAT | O_APPEND | O_WRONLY, 0666);
if (fd < 0)
{
perror("open");
exit(1);
}
const char *str = "Hello world.\n";
int cnt = 10;
while (cnt--)
write(fd, str, strlen(str));
close(fd);
return 0;
}
r模式就是读取文件的模式,可以将读取到的内容输入到指定位置(缓冲区)。
用open模拟是这样的:
c
open("log.txt", O_RDONLY);
用系统调用完成:仿照fopen中的r模式,打开文件做内容读取。
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// if (argc != 2)
// {
// printf("Usage: %s filename\n", argv[0]);
// }
int fd = open("log.txt", O_RDONLY);
if (fd < 0)
{
perror("open");
exit(1);
}
char inbuffer[100001];
// while (1)
// {
// int n = read(fd, inbuffer, sizeof(inbuffer) - 1);
// if (n > 0)
// {
// inbuffer[n] = 0; // 最后一个位置放 \0
// printf("%s", inbuffer);
// }
// else if (0 == n)
// {
// printf("end of file!\n");
// break;
// }
// else
// {
// perror("read");
// exit(2);
// }
// }
int n = read(fd, inbuffer, sizeof(inbuffer) - 1);
inbuffer[n] = 0; // 最后一个位置放 \0
printf("%s", inbuffer);
close(fd);
return 0;
}
2、文件描述符(fd)
上面学习open等系统调用时,我们都见到了文件描述符fd。
事实上,用户无法直接访问操作系统里的内核数据结构,必须通过系统调用。而与文件操作相关的系统调用,几乎都需要文件描述符fd:
open打开文件成功后,返回该文件的文件描述符;write需要传入文件描述符参数;read需要传入文件描述符参数;- ......
那么,我们就可以粗浅地下一个定论:文件操作的过程中,操作系统只认文件描述符fd。
还记得吗?一些上方的C语言库函数,底层其实是封装了系统调用。
- 文件操作相关的C语言库函数,封装了文件操作相关的系统调用------函数的封装
- C函数能够使用的、描述文件的数据结构,肯定也要系统能够使用的、描述文件的数据结构------数据结构的封装
我们知道,系统能够使用的、描述文件的数据结构是文件描述符fd。那么C函数能够使用的、描述文件的数据结构,是什么呢?FILE
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
FILE *pf = fopen("log.txt", "w");
printf("%d\n", pf->_fileno); // FILE封装了fd
fclose(pf);
int fd = open("log.txt", O_RDONLY);
printf("%d\n", fd);
close(fd);
return 0;
}

实际上,FILE是一个结构体,其中包含了文件描述符fd这个成员变量。即FILE封装了文件描述符fd。
我们将回答下面四个问题,来进一步理解文件描述符fd。
2.1、为什么从3开始?0、1、2哪去了?
小贴士1
对文件进行操作,需经过"读、改、写"三大步骤:
- 读:将文件加载到内存中,打开文件,读取文件内容;
- 改:进行对文件内容的修改;
- 写:按照实际需求,刷新文件内容,将磁盘上的文件做更新,然后关闭文件。
一个系统可以同时创建多个进程,那么一个进程能否同时打开多个文件?答案是可以的。
c
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
int fda = open("loga.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
int fdb = open("logb.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
int fdc = open("logc.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
printf("%d\n", fda);
printf("%d\n", fdb);
printf("%d\n", fdc);
close(fda);
close(fdb);
close(fdc);
return 0;
}
打开多文件的同时,我们很好奇:比如我们同时打开了三个文件loga.txt logb.txt logc.txt,那么打开这三个文件后获得的文件描述符,分别是多少呢?

分别是:3、4、5
那么问题来了:文件描述符fd,从 3 开始?0、1、2 哪去了?
c
#include <stdio.h>
int main()
{
printf("%d\n", stdin->_fileno);
printf("%d\n", stdout->_fileno);
printf("%d\n", stderr->_fileno);
return 0;
}

还记得吗:程序运行(进程创建)的时候,默认打开了三个流:
stdin:标准输入流stdout:标准输出流stderr:标准错误流
查阅这三个流:
c
//SYNOPSIS
#include <stdio.h>
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;
这三个流,本质都是FILE*量,那么底层一定也封装有文件描述符fd。
2.2、为什么是连续的?
在 2.1 中我们发现,文件描述符 0、1、2 分别对应stdin stdout stderr。又因为进程创建的时候,默认打开了这三个流。所以我们可以合理猜测,接下来进程打开文件(流)的时候,文件描述符就是从 3 开始。
我们可以进一步猜测:文件描述符,也许是数组下标?
2.3、为什么可以表示文件?
就像操作系统需要对多个进程进行管理一样,操作系统也需要对多个文件进行管理------先描述,再组织。
所以Linux内部,一定有一个数据结构,描述文件:

文件 = 属性 + 内容,那么文件结构体file里一定会包括属性和内容:

我们很容易能发现,结构体file中也有内嵌的链接字段struct list_head。所以创建出来的多个文件就能以内嵌指针的方式连接在一起:

文件与文件之间倒是联系起来了。但是进程如何找到文件呢?
实际上,进程PCB中,保存了一个数据结构files_struct:

深入探讨files_struct,发现其中还包含一个数组:
c
struct files_struct {
// ...
struct file * fd_array[NR_OPEN_DEFAULT]; // 数组
};
数组fd_array的类型,就是文件结构体file的指针。

那么一切就明朗了:
打开文件后,文件结构体file的指针就会存入数组fd_array:

进程想要打开指定的文件,只需拿着下标,在数组fd_array中找到对应的file*指针,就能找到指定的文件,这就是系统调用open:

进程想要通过read读取文件,只需要拿着下标,找到对应的文件,进而找到文件的内容即可;进程想要通过write向文件写入内容,只需要拿着下标,找到对应的文件,进而找到文件的内容,对文件内容进行修改即可。
进程想要关闭文件,只需
- 刷新文件内容到磁盘上;
- 释放文件结构体
file的空间; - 将
fd_array对应下标存储的file*指针置空。
到这里,我们就可以回答"文件描述符fd为什么可以表示文件":
- 进程通过文件描述符fd,访问
file*数组对应下标位置上存储的file*指针,进而访问到文件结构体file,最终访问到文件的内容和属性。
2.4、fd的本质
有了 2.3 的认识,我们就可以回答fd的本质是什么:
- 文件描述符fd,本质上就是一个数组下标(index)。fd是进程PCB中,类型为
files_struct的结构体(指针形式存在)存储的fd_array数组的下标。
其中,我们称struct file *fd_arrar[];为打开文件表。
2.5、fd的分配规则
对于"fd是数组fd_array的下标"这一结论,有人就会有疑问:
- 如果关闭了一个文件,再新打开一个文件 ,那么这个新打开的文件,文件
file*指针存放在fd_array的哪个位置?- 找到当前最大的已经存入指针的下标,然后直接放到它的下一位?
- 找到未存入指针的最小的下标,然后放进去?
我们可以做一个实验:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0); // 关闭1号stdin流
int fd = open("log.txt", O_CREAT, 0666);
printf("log.txt: %d\n", fd);
close(fd);
return 0;
}

答案是后者。
所以文件描述符fd的分配规则是:
- 新打开文件,统一分配
fd_array中未存file*指针的 ,下标最小的fd给该文件。
3、将系统调用与C语言进行关联
C语言函数对系统调用进行了封装,C语言定义的FILE对文件描述符fd进行了封装。那么为什么要"封装"呢?直接用系统调用实现方法不就行了吗?
原因是:封装,使得语言具有了跨平台性。
不同平台的系统调用是不一样的,比如Linux的系统调用就和windows的系统调用不一样。C语言为了能够让更多的人使用,这些人包含,
- Linux开发者
- windows开发者
- 其它系统开发者
就必须考虑到"不同平台的系统调用不同"这一点。所以C语言对于一种上方的库函数,会实现不同系统不同系统调用封装而成的版本,比如printf函数会实现Linux系统调用封装成的版本,和windows系统调用封装成的版本,以及其它系统系统调用封装成的版本。
C语言这么做,完善了C语言本身的生态,使得C语言至今仍是世界上使用最广泛的语言。而一些语言,比如汇编语言,由于没有做好跨平台性,已逐渐不再被更多人使用,直至淘汰。