看表情包学Linux系统下的文件操作 | 文件系统接口 | 系统调用与封装 | open,write,close 接口 | 系统传递标记位 O_RDWR,O_RDONLY,O_WRONLY...

Posted 柠檬叶子C

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了看表情包学Linux系统下的文件操作 | 文件系统接口 | 系统调用与封装 | open,write,close 接口 | 系统传递标记位 O_RDWR,O_RDONLY,O_WRONLY...相关的知识,希望对你有一定的参考价值。

  🤣 爆笑教程 👉 《看表情包学Linux》👈 猛戳订阅  🔥

 💭 写在前面:本章我们将正式接触系统接口,为斯坦福大学官方 OS 项目 Pintos 做铺垫,系统接口是非常重要的前置知识。本篇主要讲解底层文件系统接口,详细介绍 open 接口和它的 flags 参数 (即系统传递标记位),重点讲解 O_RDWR, O_RDONLY, O_WRONLY, O_CREAT 和 O_APPEND 这些操作模式。flags 标记位这一块的知识点,再一次出现了对 "位图" 的使用,这一块的知识点尤为重要,后期可能会大量涉及这样的设计手法。然后再顺带讲解 close 接口和 write 接口,在讲解这些系统底层文件接口前,我们还需要复习一下 C 语言中的文件操作知识,需要重新理解文件操作,不能仅停留在语言层面,而是要将眼光放宽到操作系统的层面去看待!重新理解当前路径、复习文件读取的接口 (fopen, fgets) 和文件操作模式 (w, r, a, a+) 后,再讲解文件系统接口时就能做到 "心中自有丘壑",能够有参照地通过比对的方式去学习了。通过学习底层文件接口,你就能体会到使用原生系统接口成本有多高了(可自行对比 fopen (C接口) 和 open (底层接口) 之间的使用成本)。系统调用封装把操作系统内核提供的系统调用封装成更容易使用的接口,使得应用程序可以更加轻松地使用操作系统提供的服务,而不需要了解内核的具体实现细节,并且提高了程序的可移植性,使其具有跨平台的能力。所以,对系统接口的封装无疑是一项伟大的工程……由于内容较多需控制篇幅,本篇就不深入地理解这些操作模式和接口了,我们将放到下一章继续讲解。值得注意的是,本章涉及底层文件操作,难免牵扯到进程, 权限, umask 权限掩码, 位图, C基础文件操作等知识点,难度相较于其他章节偏大,如果你还没有储备或掌握这些必要的前置知识,建议阅读或复习下面的文章:

🔗 前置知识:

📜 本章目录:

 Ⅰ. 系统下的文件操作(System File Operation)

0x00 引入:关于文件操作的思考

0x01 当前路径(Current Path)

0x02 文件操作模式(File Operation Mode)

0x03 文件的读取(File Read)

Ⅱ. 文件系统接口(Basic File IO)

0x00 引入:系统调用与封装(Syscall and Wrapper)

0x01 文件打开:open()

0x01 flags 系统传递标记位

0x02 open 接口用法演示 

0x03 文件关闭:close()

0x04 文件写入:write()

   本篇博客全站热榜排名:11


  Ⅰ. 系统下的文件操作(System File Operation)

0x00 引入:关于文件操作的思考

(* 我们将引入一些思考的点,用红色数字标明)

 我们曾经讲过:文件 = 文件内容 + 文件属性  

文件属性也是数据!这意味着,即便你创建一个空文件,也要占据磁盘空间!所以:

文件操作 = 文件内容的操作 + 文件属性的操作

因此,在操作文件的过程中,既改变内容又改变属性的情况很正常,不要把它们割裂开来!

💭 举个例子:当你往文件写入内容时,你的最新的修改时间、以及文件的 Size 大小时间以及属性数据也可能同时变化!所以,一般而言:属性是随着内容的变化而 (可能) 变化的。

 我们一开始学习语言时,要读写一个文件,我们首先要做的事就是打开文件!

那么,所谓的 "打开" 文件,究竟在做什么? ③ 

"打开文件不是目的,访问文件才是目的!"

访问文件时,都是要通过 fread,fwrite,fgets... 这样的代码来完成对文件的操作的,

如果通过这些方式,那么 "打开" 文件就需要 将文件的属性或内容加载到内存 (memory) 中!

因为这是由冯诺依曼体系结构决定的,将来  要执行 fread,fwrite 来对文件进行读写的。

 既然如此……  是不是所有的文件都会处于被打开的状态呢?并不是! 

那没有被打开的文件在哪里?在磁盘上静静地躺着呢! (存储在磁盘中) 

对于文件的理解,在宏观上我们可以区分成 内存文件 (打开的文件) 磁盘文件。 

"如果一个文件从来都没打开,那么它就应该是个纯纯的磁盘文件。"

通常我们打开文件、访问文件和关闭文件,是谁在进行相关操作?

运行起来的时候,才会执行对应的代码,然后才是真正的对文件进行相关的操作。
实际上是  进程在对文件进行操作! 在系统角度理解是我们曾经写的代码变成了进程。

进程执行调度对应的代码到了 fopen,write 这样的接口,然后才完成了对文件的操作。
当我执行 fopen 时,对应地就把文件打开了,所以文件操作和进程之间是撇不开关系。 

🔺 结论:学习文件操作,实际上就是学习 "进程" 与 "打开文件" 的关系。  

 为了后续的讲解,我们先来简单回顾一下 C 语言的文件写入操作。

💬 代码演示: 在一个文件写入 10 行数据 (牛魔王火锅店开业大酬宾,简称牛魔酬宾)

#include <stdio.h>

int main(void)

    FILE* pf = fopen("log.txt", "w"); // 写入
    if (pf != NULL) 
        perror("fopen");
        return 1;
    

    const char* msg = "牛魔酬宾!\\n";
    int count = 1;
    while (count < 20) 
        fprintf(pf, "%s: %d\\n", msg, count++);
    

    fclose(pf);

🚩 运行结果如下:

默认这个文件会在哪里形成?我们看到了,log.txt 默认在 当前路径 形成了。

对于什么是 "当前路径",本章就要把当前路径给讲清楚!

如果对当前路径的理解,仅仅停留于 "当前路径就是源代码所在的路径" 是远远不够的!

0x01 当前路径(Current Path)

我们前面说了,文件的本质实际上是进程与打开文件之间的关系。

 因此文件操作和进程有关系,我们修改一下我们的代码,获取进程 ,查一下进程信息:

#include <stdio.h>
#include <unistd.h>

int main(void)

    FILE* pf = fopen("log.txt", "w"); // 写入
    if (pf == NULL) 
        perror("fopen");
        return 1;
    

    /* 获取进程 pid */
    printf("Mypid: %d\\n", getpid());

    /* 打开文件后,等一等,方便查询 */
    while (1) 
        sleep(1);
    

    const char* msg = "牛魔酬宾!";
    int count = 1;
    while (count <= 10) 
        fprintf(pf, "%s: %d\\n", msg, count++);
    

    fclose(pf);

🚩 运行结果如下:

 getpid 拿到进程  后,得益于 "昏睡指令" while(1)sleep(1);) 

我们的进程就一直在欢快的跑着,再打开一个窗口,通过 $ls proc 指令检视该进程信息:

"这个小夫就是我们的进程,哈哈哈哈哈哈,"

指令后面接上 ,然后我们再加上个 -l 选项,就能把进程信息尽收眼底:

我们重点关注  和  后面链接指向的是可执行程序 mytest,即 路径 + 程序名。

而  (current working directory),即 当前工作目录,记录着当前进程所处的路径!

每个进程都有一个工作路径,所以我们上一节实现的简单  程序可以用 chdir 更改路径。

创建文件时,如果文件在当前目录下不存在,fopen 会默认在当前路径下自动创建文件。

 默认创建在当前路径,和源代码、可执行程序在同一个路径下,因为这取决于 

cwd -> /home/foxny/lesson18

 我们可以来验证一下,使用 chdir 接口更改一下:

#include <stdio.h>
#include <unistd.h>

int main(void)

    /* 主动更改当前进程的 cwd */
    chdir("home/foxny/code");

    FILE* pf = fopen("log.txt", "w"); // 写入
    if (pf == NULL) 
        perror("fopen");
        return 1;
    

    /* 获取进程 pid */
    printf("Mypid: %d\\n", getpid());

    /* 打开文件后,等一等,方便查询 */
    while (1) 
        sleep(1);
    

    const char* msg = "牛魔酬宾!";
    int count = 1;
    while (count <= 10) 
        fprintf(pf, "%s: %d\\n", msg, count++);
    

    fclose(pf);

此时 log.txt 应该不在默认路径 /home/foxny/lesson18 下了,而是在 /home/foxny/code下。

我们再次通过 $ls proc 指令查看信息明细时, 会变成 chdir 对应的路径:

 所以,当前路径更准确的说法应该是:在当前进程所处的工作路径。

" 当前路径指的是在当前进程所处的工作路径 "

只不过默认情况下 (默认路径) ,一个进程的工作路径在它当前所处的路径而已,这是可以改的。

所以我们在写文件操作代码时,不带路径默认是源代码所在的路径,注意是默认!而已!

0x02 文件操作模式(File Operation Mode)

 由文件操作符 (mode) 参数来指定,常用的模式包括:

r:只读模式,打开一个已存在的文本文件,允许读取文件。

r+:读写模式,打开一个已存在的文本文件,允许读写文件。

w:只写模式,打开一个文本文件并清除其内容,如果文件不存在,则创建一个新文件。

w+:读写模式,打开一个文本文件并清除其内容,如果文件不存在,则创建一个新文件。

a:追加模式,打开一个文本文件并将数据追加到文件末尾,如果文件不存在,则创建一个新文件。

a+:读写模式,打开一个文本文件并将数据追加到文件末尾,如果文件不存在,则创建一个新文件。

* 有些我们已经在 C 语言专栏中详细介绍过了,如果对该知识点不熟可以复习一下。
🔗 复习链接:https://foxny.blog.csdn.net/article/details/119715195

 这里我们重点讲一下 a 和 a+

a 对应的是 appending 的首字母,意为 "追加" 。属于写入操作,不会覆盖源文件内容。

💬 代码演示:测试追加效果

#include <stdio.h>

int main() 
   FILE* pf = pf = fopen("test.txt", "a");

   // 写入数据到文件
   fprintf(pf, "Hello, World!\\n");

   fclose(pf);

   return 0;

🚩 运行结果如下:

每次运行都会在 test.txt 里追加,我们多试几次看看:

a(append) 追加写入,可以不断地将文件中新增内容。(这让我联想到了追加重定向)

不同于 w,当我们以 w 方式打开文件准备写入的时候,其实文件已经先被清空了。

  

0x03 文件的读取(File Read)

 文件的读取在 C 专栏中我们也详细讲过了,这里我们只做一个简单的回顾。

我们复习一下文本行输入函数 fgets,它可以从特定的文件流中,以行为单位读取特定的数据:

char* fgets(char* s, int size, FILE* stream);_

💬 代码演示:fgets()

#include <stdio.h>

int main(void)

    FILE* pf = fopen("log.txt", "r"); // 读
    if (pf == NULL) 
        perror("fopen");
        return 1;
    

    char buffer[64];  // 用来存储
    while (fgets(buffer, sizeof(buffer), pf) != NULL) 
        // 打印读取到的内容
        printf("echo: %s", buffer);
    

    fclose(pf);

🚩 运行结果如下:

我们下面再来实现一个类似 $cat 的功能,输入文件名打印对应文件内容。

💬 代码演示:实现一个自己的 cat

#include <stdio.h>

/* 读什么就打什么   mycat */
int main(int argc, char* argv[]) 

    if (argc != 2) 
        printf("Usage: %s filename\\n", argv[0]);
        return 1;
    

    FILE* pf = fopen(argv[1], "r");    // 读取
    if (pf == NULL) 
        perror("fopen");
        return 1;
    

    char buffer[64];
    while (fgets(buffer, sizeof(buffer), pf) != NULL) 
        printf("%s", buffer);
    

    fclose(pf);

如果再把可执行程序 mytest 改名成 cat,$mv mytest cat ,

 我们就实现了一个自己的 cat 代码。

Ⅱ. 文件系统接口(Basic File IO)

0x00 引入:系统调用与封装(Syscall and Wrapper)

当我们向文件写入时,最终是不是向磁盘写入?是!磁盘是硬件吗?就是硬件。

当我们像文件写入时,最后是向磁盘写入。磁盘是硬件,那么谁有资格向磁盘写入呢?

 只能是操作系统!

既然是操作系统在写入,那我们自然不能绕开操作系统对磁盘硬件进行访问。

因为操作系统是对软硬件资源进行管理的大手子,你的任何操作都不能越过操作系统!

" OS:给你随便访问,那岂不是显得我很随便? "

 所有的上层访问文件的操作,都必须贯穿操作系统。

想要被上层使用,必须使用操作系统的相关的 系统调用 (syscall) !

💭 思考:我们来回顾一下我们学习 C 语言的第一个函数接口:

printf("Hello, World!\\n");

如何理解 printf?我们怎么从来没有见过这些系统调用接口呢?

显示器是硬件,我们 printf 的消息打印到了硬件上,是你自己调用的 printf 打印到硬件上的,

但并不是你的程序显示到了显示器上,因为显示器是硬件,它的管理者只能是操作系统,

你不能绕过操作系统,而必须使用对应的接口来访问显示器,我们看到的 printf 一打,

内容就出现在屏幕上,实际上在函数的内部,一定是 调用了系统调用接口 的。

🔺 结论:printf 函数内部一定封装了系统调用接口。

 任何语言都是这样的,用到的接口都是语言提供给你的!正所谓……

" 纵横不出方圆,万变不离其宗!"

所有的语言提供的接口,之所以你没有见到系统调用,因为所有的语言都被系统接口做了 封装

所以你看不到对应的底层的系统接口的差别。为什么要封装?原生系统接口,使用成本比较高。

系统接口是 OS 提供的,就会带来一个问题:如果使用原生接口,你的代码只能在一个平台上跑。

 直接使用原生系统接口,必然导致语言不具备 跨平台性 (Cross-platform) !

如果语言直接使用操作系统接口,那么它就不具备跨平台性,可是为什么采用封装就能解决?

封装是如何解决跨平台问题的呢?很简单:

" 穷举所有的底层接口 + 条件编译 "

🔺 结论:我们学习的接口,C 库提供的文件访问接口,系统调用。它们两具有上下层关系,C 底层一定会调用这些系统调用接口。

  • 解释:不同的语言,有不同的文件访问接口。
  • 系统调用:这就是我什么我们必须学习文件级别的系统接口!

 行文至此,我们已经正式引入了系统调用接口的概念!

0x01 文件打开:open()

打开文件,在 C 语言上是 fopen,在系统层面上是 open

open 接口是我们要学习的系统接口中最重要的一个,没有之一!所以我们放到前面来讲。

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char* pathname, int flags);
int open(const char* pathname, int flags, mode_t mode);

 我们可以看到,相较于 C 的 fopen 来说,这个接口一上来就显得很不友好。

"这特喵的有三个头文件啊,用个 open 要引三个头文件!"

然而,更恐怖的还在后面,有一大坨繁冗而复杂的东西……

我们看到,这个 open 接口一个是两参数的,一个是三参数的,这个我们放到后面解释。

open 接口的 pathname 参数表示要打开的文件名,和 C 语言的 fopen 一样,是要带路径的。

flags 参数是打开文件要传递的选项,即 系统传递标记位,我们下面会重点讲解。

mode 参数,就是 "文件操作模式" 了。但在这里:

" 就变得又臭又长,不经让人感叹 fopen 居然是如此的人性化!"

#if (defined _CRT_DECLARE_NONSTDC_NAMES && _CRT_DECLARE_NONSTDC_NAMES) || (!defined _CRT_DECLARE_NONSTDC_NAMES && !__STDC__)
    #define O_RDONLY     _O_RDONLY
    #define O_WRONLY     _O_WRONLY
    #define O_RDWR       _O_RDWR
    #define O_APPEND     _O_APPEND
    #define O_CREAT      _O_CREAT
    #define O_TRUNC      _O_TRUNC
    #define O_EXCL       _O_EXCL
    #define O_TEXT       _O_TEXT
    #define O_BINARY     _O_BINARY
    #define O_RAW        _O_BINARY
    #define O_TEMPORARY  _O_TEMPORARY
    #define O_NOINHERIT  _O_NOINHERIT
    #define O_SEQUENTIAL _O_SEQUENTIAL
    #define O_RANDOM     _O_RANDOM
#endif

 真是让人心脏骤停…… 我们慢慢来 ~

❓ 思考:在 Linux 下,C 语言中文件不存在,就直接创建它,创建是不是需要权限?

当然是需要的,我们需要给文件设置初始权限,这个 mode 参数就是干这个活的。

我们再来看看这个接口的返回值,居然是个 int,而不是我们 fopen FILE* 

open 的返回值是个 int,返回 -1 表示 error,并设置 errno。

0x01 flags 系统传递标记位

open 接口中的 flags 参数在 OS 底层接口函数中还是很常见的,所以我们要重点讲解。

我们可以输入 man 2 open  看看如何设置 flags 参数,实际上就是设置文件操作模式的。

我们重点关注下面这几个文件操作模式,它们被定义在 <fcntl.h> 头文件中:

如果你是第一次见,可能会感到强烈的 "陌生感",这个 O 是什么鬼,好像还都是宏!

 O 实际上就是 Open 的意思,它们的用途通过名字不难猜:

  •  (open read only) :只读方式打开
  •  (open write only) :只写方式打开
  •  (open read write) :读写方式打开
  •  (open append) :追加方式打开
  •  (open create) :创建 (若文件不存在就创建)
int open(const char* pathname, int flags);

我们称 flags 标记位,并且它是个整数类型(C99 标准之前没有 bool 类型) 

标记位实际上我们造就用过了,比如定义 flag 变量,设 flag=0,设 flag=1,传的都是单个的。

❓ 思考:但如果我想一次传递多个标志位呢?定义多个标记位?flag1, flag2, flag3...

那我要传 20 个呢,定义 20 个标记位不成?遇到不确定传几个标志位的情况下,该怎么办?

 我们看看写底层的大佬是如何解决的:

👑 方案:系统传递标记位是通过 位图 来进行传递的。

想必大家已经对位图不陌生了,在前几章我们讲解 waitpid status 参数时就介绍过了:

status 参数也是整型,也是被当作一个 "位图结构" 看待的,这里的 flags 也是如此!

当成位图,就是一串整数 ,如此一来标记的可能性就直接超级加倍了。

0000 0000

我们可以让不同的位表示,是否只读,是否只写,是否读写…… 等等等等:

每个 宏标记一般只需要满足有一个比特位是 1,并且和其它宏对应的值不重叠 即可。

让每一个宏对应不同的比特位,在内部就可以做不同的事情,为了大家能够更好的理解,

下面我们自己设计一个接口,仿照系统传递标记位的做法,通过这种思路去实现一下标记位。

💬 代码演示:我们创建一个 test_flag.c

#include <stdio.h>

#define PRINT_A   0x1     // 0000 0001
#define PRINT_B   0x2     // 0000 0010
#define PRINT_C   0x4     // 0000 0100
#define PRINT_D   0x8     // 0000 1000
#define PRINT_DFL 0x0

// open
void Show (
    int flags  /* 传递标志位 */
    )

    if (flags & PRINT_A)     printf("Hello A\\n");
    if (flags & PRINT_B)     printf("Hello B\\n");
    if (flags & PRINT_C)     printf("Hello C\\n");
    if (flags & PRINT_D)     printf("Hello D\\n");
    if (flags == PRINT_DFL)  printf("Hello Default\\n");


int main(void)

    /* 我想打谁,只需要传对应的标记位即可 */
    printf("# PRINT_DFL: \\n");
    Show(PRINT_DFL);

    printf("# PRINT_A: \\n");
    Show(PRINT_A);

    printf("# PRINT_B: \\n");
    Show(PRINT_B);

    printf("# PRINT_A AND PRINT_B: \\n");
    Show(PRINT_A | PRINT_B);

    printf("# PRINT_C AND PRINT_D: \\n");
    Show(PRINT_C | PRINT_D);

    printf("# PRINT_A AND PRINT_B AND PRINT_C AND PRINT_D: \\n");
    Show(PRINT_A | PRINT_B | PRINT_C | PRINT_D);

    return 0;

🚩 运行结果:

💡 说明:通过标记位,可以在内部做对应的事情。打印 A 就打印 hello A,打印 A 和 B 就打印 hello A 和 hello B,现在我们再理解别人给我们传递宏标志位的做法。我们每一个宏所对应的值,在二进制位上互相都是不重叠的,一人用一个比特位。我们调用时要同时打印多个就按位或,内部再做条件判断,检测条件是否成立,这即是系统传参的做法。一个系统调用接口可以穿十几乃至三十几个的标志位,基本是够用的。

0x02 open 接口用法演示 

讲完了 flags 标记位,现在我们可以演示 open 接口的用法了。

int open(const char* pathname, int flags);

💬 代码演示:是用 open() 打开 log.txt 文件没有就创建。

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main(void)

    int fd = open("log.txt", O_WRONLY | O_CREAT);
    if (fd < 0)          // 打开失败
        perror("open"); 
        return 1;
    

    printf("fd: %d\\n", fd);   // 把 fd 打出来看看

    return 0;



💡 代码说明:

① 这里我们选择取名为 fd,而不是我们 fopen 习惯用的 pf/fp,因为 fd 描述文件描述符,这也是我们后面章节要重点讲解的,所以这里取 fd 来接收 open 接口的返回值,也算是预热一下

只写是 O_WRONLY,如果没有对应文件就创建,创建时 O_CREAT,这里我们用 | 把二者相关联可以了。

open 的返回值是 int,如果返回 -1 则表示 error,所以如果 fd < 0 就说明打开失败了,我们礼貌性的 perror 一下(保持我们 fopen 的习惯,这是好习惯)。

 最后,文件打开成功,我们把 fd 的值顺便打印出来看看。

🚩 运行结果如下:

 如果你要创建这个文件,该文件是要受到  权限的约束的!

创建一个文件,你需要告诉操作系统默认权限是什么。

当我们要打开一个曾经不存在的文件,不能使用两个参数的 open,而要使用三个参数的 open!

也就是带 mode_t mode 的 open,这里的 mode 代表创建文件的权限:

int open(const char* pathname, int flags, mode_t mode);  

 修改一下我们的代码,使用带 mode 参数的 open

int main()

    int fd = open("log.txt", O_APPEND | O_CREAT, 0666);  // 八进制表示
    if (fd < 0) 
        perror("open");
        return 1;
    

    printf("fd: %d\\n", fd);

    return 0;

🚩 运行结果如下:


 * 如果你对这里出现的 "权限八进制表示法" 感到疑惑,可以看权限章节,那一章有讲。
🔗 链接:【看表情包学Linux】权限 - Ⅲ 0x03

因为你要创建的文件,你再怎么🐂🍺,你也要听操作系统的!我们来看看 umask

你要 666,操作系统不能给你,因为 umask 是 0002,所以最多只能给你 664。

 那好!我今天非要创建 666!神挡杀神,操作系统挡杀操作系统!

" 没关系,我会出手!umask 限制我?我特喵直接改 umask! "

我们在权限章节介绍过 umask,实际上它也是一个系统级接口。

我们现在就是要 666,我们只需要调用 umask(),然后传 0umask(0

就可以让权限掩码暂时不听按操作系统的默认权限掩码,而用你设置的!

int main()

    umask(0);   // umask现在就为0,听我的,别听操作系统的umask了
    
    // 额... 好吧,okok
    int fd = open("log.txt", O_APPEND | O_CREAT, 0666);
    if (fd < 0) 
        perror("open");
        return 1;
    

    printf("fd: %d\\n", fd);

    return 0;

🚩 运行结果如下:

实际上,umask 命令就是调用这个接口的。

umask 设为 0,可以让我们以确定的权限打开文件,比如服务器要打开一个日志文件,权限就必须要按照它对应的权限设置好,不要采用系统的默认权限,可能会出问题。

0x03 文件关闭:close()

在 C 语言中,关闭文件可以调用 fclose,在系统接口中我们可以调用 close 来关闭:

#include <unistd.h>

int close(int fd);

我们输入 man 2 close 查看一下手册:

该接口相对 open 相对来说比较简单,只有一个 fd 参数,我们直接看代码:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>  // 需引入头文件

int main(void)

    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
        perror("open"); 
        return 1;
    
    printf("fd: %d\\n", fd); 

    close(fd);  // 关闭文件

    return 0;

🚩 运行结果如下:

0x04 文件写入:write()

文件打开和文件关闭都有了,我们总要干点事吧?现在我们来做文件写入!

在 C 语言中我们用的是 fprintf, fputs, fwrite 等接口,而在系统中,我们可以调用 write 接口:

#include <unistd.h>
ssize_t write(int fd, const void* buf, size_t count);

📚 write 接口有三个参数:

  • fd:文件描述符
  • buf:要写入的缓冲区的起始地址(如果是字符串,那么就是字符串的起始地址)
  • count:要写入的缓冲区的大小

💬 代码演示:向文件写入 5 行信息

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>  // 需引入头文件

int main(void)

    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if (fd < 0) 
        perror("open"); 
        return 1;
    
    printf("fd: %d\\n", fd); 
    
    // 向文件写入 5 行信息
    int cnt = 0;
    const char* str = "牛魔酬宾!\\n";
    while (cnt < 5) 
       write(fd, str, strlen(str));
       cnt++;
    

    close(fd);

    return 0;

🚩 运行结果如下:

⚡ 顺便教一个清空文件的小技巧: > 文件名 ,前面什么都不写,直接重定向 + 文件名:

$ > log.txt

📌 [ 笔者 ]   王亦优
📃 [ 更新 ]   2023.3.17
❌ [ 勘误 ]   /* 暂无 */
📜 [ 声明 ]   由于作者水平有限,本文有错误和不准确之处在所难免,
              本人也很想知道这些错误,恳望读者批评指正!

📜 参考资料 

C++reference[EB/OL]. []. http://www.cplusplus.com/reference/.

Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. .

百度百科[EB/OL]. []. https://baike.baidu.com/.

比特科技. Linux[EB/OL]. 2021[2021.8.31 xi

看表情包学Linux初识文件描述符 | 虚拟文件系统 (VFS) 初探 | 系统传递标记位 | O_TRUNC | O_APPEND

爆笑教程《看表情包学Linux》👈 猛戳订阅!​​​​​

💭 写在前面:通过上一章节的讲解,想必大家已对文件系统基本的接口有一个简单的了解,本章我们将继续深入讲解,继续学习系统传递标志位,介绍 O_WRONLY, O_TRUNC, O_APPEND 和 O_RDONLY。之后我们就正是打开文件描述符 fd 的大门了,之前我们所探讨讲解的系统文件操作,都是为了给文件描述符做铺垫的,可见这这一块知识点是相当的重要。话不多说,让我们正式开始本章的学习!

📜 本章目录:

Ⅰ. 系统传递标记位

0x00 引入:O_WRONLY 没有像 w 那样完全覆盖?

0x01 O_TRUNC 截断清空(对标 w)

0x02  O_APPEND 追加(对标 a)

0x03 O_REONLY 读取

Ⅱ. 文件描述符(fd)

0x00 引入:open 参数的返回值

0x01 文件描述符的底层理解

0x02 理解:Linux 下一切皆文件

0x03 初识 VFS(虚拟文件系统)

0x04 回头看问题:fd 的 0,1,2,3... 

   本篇博客全站热榜排名:未上榜


Ⅰ. 系统传递标记位

0x00 引入:O_WRONLY 没有像 w 那样完全覆盖?

 语言在  模式打开文件时,文件内容是会被清空的,但是 O_WRONLY 好像并非如此?

💬 代码演示:当前我们的 log.txt 内有 5 行数据,现在我们执行下面的代码:

int main(void)

    umask(0);
    // 当我们只有 O_WRONLY 和 O_CREAT 时
    int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
    if (fd < 0) 
        perror("open"); 
        return 1;
    
    printf("fd: %d\\n", fd); 
    
    // 修改:向文件写入 2 行信息
    int cnt = 0;
    const char* str = "666\\n";  // 修改:内容改成666(方便辨识)
    while (cnt < 2) 
       write(fd, str, strlen(str));
       cnt++;
    

    close(fd);

    return 0;

🚩 运行结果如下:

❓ 疑点:O_WRONLY 怎么没有像 w 那样完全覆盖???

我们以前在  语言中,w 会覆盖把全部数据覆盖,每次执行代码可都是会清空文件内容的。 

而我们的 O_WRONLY 似乎没有全部覆盖,曾经的数据被保留了下来,并没有清空!

其实,没有清空根本就不是读写的问题,而是取决于有没有加 O_TRUNC 选项!

因此,只有 O_WRONLY 和 O_CREAT 选项是不够的:

  • 如果想要达到 w 的效果还需要增添 O_TRUNC
  • 如果想到达到 a 的效果还需要 O_APPEND

 下面我们就来介绍一下这两个选项!

0x01 O_TRUNC 截断清空(对标 w)

 在我们打开文件时,如果带上 O_TRUNC 选项,那么它将会清空原始文件。

如果文件存在,并且打开是为了写入,O_TRUNC 会将该文件长度缩短 (truncated) 为 0。

也就是所谓的 截断清空 (Truncate Empty) ,我们默认情况下文件系统调用接口不会清空文件的,

但如果你想清空,就需要给 open() 接口 带上 O_TRUNC 选项:

💬 代码演示:open() 达到 fopen"w" 模式的效果

int main(void)

    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
    if (fd < 0) 
        perror("open"); 
        return 1;
    
    printf("fd: %d\\n", fd); 
    
    // 向文件写入 2 行信息
    int cnt = 0;
    const char* str = "666\\n";
    while (cnt < 2) 
       write(fd, str, strlen(str));
       cnt++;
    

    close(fd);

    return 0;

🚩 运行结果如下:

然而  语言的 fopen 函数,只需要浅浅地标上一个 "w" 就能搞定了:

fopen("log.txt", "w");

调一个 w 就以写的方式打开了,不存在会自动创建,并且会完全覆盖原始内容,是如此的简单!

它对应的底层 open 调用,调用接口所传入的选项就是 O_WRONLY, O_CREAT, O_TRUNC

由此可见, 的 fopen 是多么的好用!open 不仅要传这么多选项,而且属性也要设置:

open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
fopen("log.txt", "w");

0x02  O_APPEND 追加(对标 a)

上一章我们复习了 a 模式, 语言中我们以 a 模式打开文件做到追加的效果。

现在我们用 open,追加是不清空原始内容的,所以我们不能加 O_TRUNC,得加 O_APPEND

int fd = open("log.txt", O_WRONLY | O_CREATE | O_APPEND, 0666);

💬 代码演示:open() 达到 fopen"a" 模式的效果

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main(void)

    umask(0);
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
    if (fd < 0) 
        perror("open"); 
        return 1;
    
    printf("fd: %d\\n", fd); 
    
    // 向文件写入 2 行信息
    int cnt = 0;
    const char* str = "666\\n";
    while (cnt < 2) 
       write(fd, str, strlen(str));
       cnt++;
    

    close(fd);

    return 0;

🚩 运行结果如下:

 我们再来对照  语言的 fopen,想做到这样的效果只需要一个 "a"

open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
fopen("log.txt", "a");

实际上,系统级别的接口本来就是被文件接口封装的,fopen 是系统级文件接口的底层实现。

我们的 a, w, r... 在底层上实际上就是这些 "O_" 组合而成的,使用系统接口麻烦吗?

当然麻烦!要记这么多东西,当然还是 C 语言用的更爽了,一个字母标明文件模式就行了。

0x03 O_REONLY 读取

如果我们想读取一个文件,那么这个文件肯定是存在的,我们传 O_RDONLY 选项:

int main()

    umask(0);

    int fd = open("log.txt", O_RDONLY);
    if (fd < 0) 
        perror("open");
        return 1;
    

    printf("fd: %d\\n", fd);

    char buffer[128];
    ssize_t s = read(fd, buffer, sizeof(buffer) - 1);
    if (s > 0) 
        buffer[s] = '\\0';  // 最后字符串序列设置为 '\\0' 
        printf("%s", buffer);
    


    close(fd);   
    
    return 0;

🚩 运行结果如下:

Ⅱ. 文件描述符(fd)

0x00 引入:open 参数的返回值

int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);

我们使用 open 函数举的例子中,一直是用一个叫做 fd 的变量去接收的。

fopen 中我们习惯使用 fp pf 接收返回值,那是因为是 fopen 的返回值  FILE* 是文件指针,

file pointer 的缩写即是 fp,所以我们就习惯将这个接收 fopen 返回值的变量取名为 fp pf

那为什么接收 open 的返回值的变量要叫 fd 呢?

这个 fd 究竟是何方神圣?我们现在就揭开其神秘面纱,一睹芳容!它就是……

open 如果调用成功会返回一个新的 文件描述符 (file descriptor) ,如果失败会返回 -1 

  •  :失败 (success)
  •  :成功 (failed)

💬 代码演示:我们现在多打开几个文件,观察 fd 的返回值

int main(void)

    int fd_1 = open("log1.txt", O_WRONLY | O_CREAT, 0666);
    int fd_2 = open("log2.txt", O_WRONLY | O_CREAT, 0666);
    int fd_3 = open("log3.txt", O_WRONLY | O_CREAT, 0666);
    int fd_4 = open("log4.txt", O_WRONLY | O_CREAT, 0666);
    int fd_5 = open("log5.txt", O_WRONLY | O_CREAT, 0666);

    printf("fd_1: %d\\n", fd_1); 
    printf("fd_2: %d\\n", fd_2); 
    printf("fd_3: %d\\n", fd_3); 
    printf("fd_4: %d\\n", fd_4); 
    printf("fd_5: %d\\n", fd_5); 
    
    close(fd_1);
    close(fd_2);
    close(fd_3);
    close(fd_4);
    close(fd_5);

    return 0;

🚩 运行结果如下:

我们发现这 open 的 5 个文件的  (返回值) 分别是  ,那么问题了来了:

  为什么从 3 开始,而不是从 0 开始?0, 1, 2 去哪了?

  • 0标准输入(键盘,stdin)
  • 1标准输出(显示器,stdout)
  • 2标准错误(显示器,stderr)

 系统接口认的是外设,而  标准库函数认的是:

#include <stdio.h>

extern FILE* stdin;
extern FILE* stdout;
extern FILE* stderr;

系统调用接口!那么 stdin, stdout, stderr 和上面的 0,1,2 又有什么关系呢?

 想解决这个问题,我们得先说说

我们知道,FILE* 是文件指针,那么  是什么呢?它是  库提供的结构体。

只要是结构体,它内部一定封装了多个成员!

虽然  用的是 FILE*,但是系统的底层文件接口只认 ,也就是说:

 标准库调用的系统接口,对文件操作而言,系统接口只认文件描述符。

" 文件操作的系统接口属于是六亲不认,只认 fd "

 因此, 内部必定封装了文件操作符  !

下面我们来验证一下,先验证 0,1,2 就是标准

💬 代码验证:0 是标准输入 (stdin)

int main(void)

    // 验证 0,1,2 就是标准 I/O
    char buffer[1024];
    ssize_t s = read(0, buffer, sizeof(buffer) - 1);

    if (s > 0) 
        buffer[s] = '\\0';

        printf("echo: %s", buffer);
    

🚩 运行结果如下:

 

💬 代码验证:stdout 标准写入(1) 和 stderr 错误写入(2) :

int main(void)

    const char* s = "Hello, write something\\n";
    write(1, s, strlen(s));  // 1:向标准输入写入
    write(2, s, strlen(s));  // 2:向标准错误写入

🚩 运行结果如下:

(1 和 2 的区别我们放到后面再讲)

 至此,我们证明了 ——

每次我们打开文件虽然打开的是 3,但是可以像 3,4,5,6…… 去写,默认系统就会帮我们打开:

0 (标准输入, stdin) ,1 (标准输出, stdout),2 (错误输出, stderr) 

下面我们要做的是,验证一下 0,1,2 和 stdin, stdout 和 stderr 的对应关系。

根据我们目前的分析, 本来就是一个结构体, 因为系统只认 

所以  语言本身调用的一定是系统结构,这就直接决定了不管怎么封装,底层必须有 

💬 代码验证:下面我们就来证明  的存在,证明 stdin, stdout 和 stderr 的对应关系

int main(void)

    printf("stdin: %d\\n", stdin->_fileno);
    printf("stdout: %d\\n", stdout->_fileno);
    printf("stderr: %d\\n", stderr->_fileno);

🚩 运行结果如下:

" 这……就是透过现象看本质!"

函数接口的对应:fopen / fclose / fread / fwrite    open / close / read / write

数据类型的对应:(FILE*FILE) → 

🔺 结论:我们用的  语言接口一定封装了系统调用接口!

  这个 0, 1, 2, 3, 4, 5……,是不是有点像数组下标?

" 咳咳……不是有点像,它就是数组下标!"

刚才返回  的,用的都是系统接口,是操作系统提供的返回值。

 既然操作系统能给你,那说明操作系统内部是有的。

文件描述符的值为什么是 1,2,3,4,5... ?为了理解这个问题,我们需要做大量的铺垫!

0x01 文件描述符的底层理解

💭 逻辑推导:进程:内存文件的关系 → 内存 → 被打开的文件实在内存里面的

一个进程可以打开多个文件,所以在内核中,进程与打开的文件之比为:

所以系统在运行中,有可能会存在大量的被打开的文件 → OS 要对这些被打开的文件进行管理!

OS 如何管理这些被打开的文件呢?还是我们老生常谈的那句话:

先描述,再组织!

所以对我们来说,一个文件被打开不要片面的认为只是对文件内容动动手脚!

它还要 在内核中创建被打开文件的内核数据结构 —— 先描述

struct file 
    // 包含了你想看到的文件的所有大部分 内容 + 属性
    
    struct file* next;
    struct file* prev;
;

* 注:上面的代码是便于理解的,可不是内核真正的代码,真的可远比这复杂得多!

如果你在内核中打开了多个的文件,那么系统会在内核中为文件创建一个 struct file 结构。

可以通过 next prev 将其前后关联起来(内核的链表结构有它自己的设计,这里我们不关注)

既然你打开了一个文件,就会创建一个 struct file,那么你打开多个文件,

系统中必然会存在大量的 struct file,并且该结构我们用链表的形式链接起来:

 如此一来,对被打开的文件的管理,就转化成为了对链表的增删改查!

"这一幕怎么有些似曾相识?我们之前讲进程好像就是这么讲的!task_struct!"

进程与打开的文件之比为 ,进程能打开这么多文件,那么:

进程如何和打开的文件建立映射关系?打开的文件哪一个属于我的进程呢?

在内核中,task_struct 在自己的数据结构中包含了一个 struct files_struct *files (结构体指针):

struct files_struct *files;

而我们刚才提到的 "数组" 就在这个 file_struct 里面,该数组是在该结构体内部的一个数组。

struct file* fd_array[];

该数组类型为 struct file* 是一个 指针数组,里面存放的都是指向 struct file 的指针!

" 指向 struct file 的指针!是不是恍然大悟?这不就是文件的 stuct file 结构么?没错!"

数组元素映射到各个被打开的文件,直接指向对应的文件结构,若没有指向就设为 NULL

 此时,我们就建立起了 "进程" 和 "文件" 之间映射关系的桥梁。

🔍 看图理解:在内核中实现的映射关系

 如此一来,进程想访问某一个文件,只需要知道该文件在这张映射表中的数组下标。

上面这些就是在内核中去实现的映射关系了!这个下标 0,1,2,3,4 就是对应的文件描述符  !

我们调用的 open / read / write / close 接口都需要 

" 可以理解为买彩票,由于关系复杂就不给大家讲故事了,自行理解"

 选号:当我们 open 打开一个新的文件时,先创建 struct file,然后在当前的文件描述表中分配一个没有被使用的下标,把 stuct file 结构体的地址填入 struct file* 中,然后通过 open 将对应的  返回给用户,比如 3,此时我们的  变量接收的 open 的返回值就是 3 了。

 兑奖:后续用户再调用 read, write 这样的接口一定传入了对应的 ,找到了特定进程的 files,在根据对应的  索引到指针数组,通过 sturct file* 中存储的 struct file 结构体地址,找到文件对象,之后就可以对相关的操作了。

🔺 总结:其本质是因为它是一个数组下标,系统中使用指针数组的方式,建立进程和文件之间的关系。 返回给上层用户,上层用户就可以调用后续接口 (read, write...) 来索引对应的指针数组,找到对应文件,这就是  为什么是 0,1,2... 的原因了!

0x02 理解:Linux 下一切皆文件

我们上面说的 0,1,2 → stdin, stdout, stderr → 键盘, 显示器, 显示器,这些都是硬件啊?

也用你上面讲的 struct file 来标识对应的文件吗?在解答这个问题之前,我们需要讲清楚:

" Linux 下一切皆文件 "

一切皆文件这个话题在之前的章节我们已经提过了,但是当时由于知识点尚未展开,没法讲解。

现在我们到了去讲解这个概念的时侯了,希望大家可以尝试去理解 "Linux 下一切皆文件" 。

 在这之前我们先说个题外话,其实  语言也是可以模拟面向对象的!

💬 代码演示: 中用 struct 模拟面向对象

struct file 
    // 对象的是属性
    // 函数指针
    void *(readp)(struct file* filep, int fd ...);
    void *(writep)(struct file* filep, int fd...);
;

void read(struct file* filep, int fd...) 
    // 逻辑代码


void write(struct file* filep, int fd...) 
    // 代码

C++ 本身就是从 C 语言衍生出来的,并不是 "万丈高楼平地起" 的。

是大量工程实战后不断积累的产物,所以 C++ 的面向对象实际上在 C 中也能实现。

我们举个例子:我们在计算机中,有各种硬件:键盘、显示器、磁盘、网卡、其他硬件...

对我们现阶段而言,这些设备我们统一称之为 "外设",下面我们来看图。

🔍 看图理解:注意,下图的 "上层" 是刚才演示的 "映射关系图" 

深灰色层:对应的设备和对应的读写方法一定是不一样的。

黑色层:看见的都是 struct file 文件(包含文件属性, 文件方法),OS 内的内存文件系统。

红色箭头:再往上就是进程,如果想指向磁盘,通过  找到对应的 struct file,根据对应的 file 结构调用读写方法,就可以对磁盘进行操作了。如果想指向对应的显示器,通过 fd 找到 struct file……最后调用读写,就可以对显示器操作了…… 以此类推。

虽然指针指向的是差异化的代码,但是在 深灰色层,我们看到的都是 struct file 文件对象!

在这一层我们 以统一的视角看待所有的设备,往上我们就看作 "一切皆文件" !

也就是说:如果想打开一个文件,打开之后把读写方法属性交给 OS,

在内核里给该硬件创建 stuct file,初始化时把对应的函数指针指向具体的设备,

在内核中存在的永远都是 struct file,然后将 struct file 互相之间用链表关联起来。

站在用户的角度看,一个进程看待所有的文件都是以统一的视角看待的,

所以当我们访问一个 file 的时候,这个 file 具体指向底层的哪个文件或设备,

这完全取决于其底层对应的读写方法指向的是什么方法!

 这操作是不是感觉很熟悉!?

多态?C++ 中运行时多态用的虚表和虚函数指针,那不就是函数指针么?

"上层使用同一对象,指针指向不同的对象,最终就可以调用不同的方法"

这令人拍手叫绝的操作,你可以理解为:多态的前身

📚 补充:上面画的图,在往上走,就回到了内核的映射关系了:

 这里的 struct file 指向的硬件设备是谁,就取决于底层的硬件是怎么设计的了。

通过操作系统层做了一层软件封装,达到了这样的效果。

底层叫硬件,而 具体的硬件读写方法是驱动干的,具体的硬件读写是驱动程序要做的,

OS 只管跟外设要求其提供读写方法,最终 OS 在内核中给它们抽象成 struct file

把它们都看作文件,然后通过函数指针指向具体的文件对应的设备,就完成了 "一切皆文件" !

0x03 初识 VFS(虚拟文件系统)

上面说的这种设置一套 struct file 来表示文件的内存文件系统的操作,

我们称之为  (virtual file system) ,即 虚拟文件系统 。 

虚拟文件系统(VFS)是 Linux 内核中非常有用的一个方面,因为它为文件系统提供了一个通用的接口抽象。VFS 在 SCI 和内核所支持的文件系统之间提供了一个交换层。

0x04 回头看问题:fd 的 0,1,2,3... 

至此,我们梳理完了。现在我们再回过头看 fd 的 1,2,3,4... 就能有一个清楚的认识了。

现在我们再我们最开始的问题,想必大家已经做到 "知其然知其所以然" 了!

为什么从 3 开始,而不是从 0 开始?0, 1, 2 去哪了?

💡 stdin,stdout,stderr 和 0,1,2 是对应关系,因为 open 时默认就打开了,这也是为什么我们默认打开一个新的文件,fd 是从 3 开始的而不是 0 开始的真正原因!

"突然茅塞顿开,上一章打印出 fd 是 3 的疑惑终于解决了!"

  0, 1, 2, 3, 4……,是不是有点像数组下标?

💡 不是有点像,它其实上就是数组下标!fd 0,1,2,3,4...  在内核中属于进程和文件的对应关系,是用数组来完成映射的,这些数字就是数组的下标。read, write, close 这些接口都必须用 0,1,2,3,4 来找到底层对应的 struct file 结构,进而访问到底层对应的读写方法 (包括相关的属性,缓冲区等) 。

📌 [ 笔者 ]   王亦优
📃 [ 更新 ]   2023.3.24
❌ [ 勘误 ]   /* 暂无 */
📜 [ 声明 ]   由于作者水平有限,本文有错误和不准确之处在所难免,
              本人也很想知道这些错误,恳望读者批评指正!

📜 参考资料 

C++reference[EB/OL]. []. http://www.cplusplus.com/reference/.

Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. .

百度百科[EB/OL]. []. https://baike.baidu.com/.

以上是关于看表情包学Linux系统下的文件操作 | 文件系统接口 | 系统调用与封装 | open,write,close 接口 | 系统传递标记位 O_RDWR,O_RDONLY,O_WRONLY...的主要内容,如果未能解决你的问题,请参考以下文章

看表情包学Linux冯诺依曼架构 | 理解操作系统 | 基于 Pintos 实现新的用户级程序的系统调用

看表情包学Linux冯诺依曼架构 | 理解操作系统 | 基于 Pintos 实现新的用户级程序的系统调用

看表情包学Linux冯诺依曼架构 | 理解操作系统 | 基于 Pintos 实现新的用户级程序的系统调用

看表情包学Linux冯诺依曼架构 | 理解操作系统 | 基于 Pintos 实现新的用户级程序的系统调用

看表情包学Linux冯诺依曼架构 | 理解操作系统 | 基于 Pintos 实现新的用户级程序的系统调用

看表情包学Linux进程优先级 | 查看系统进程 | 优先级修改 | 进程的切换 | 竞争性与独立性 | 并行并发的概念 | 环境变量