【Linux系统#5】基础 IO(文件描述符fd & 缓冲区 & 重定向)

#1024程序员节 | 征文#

✨                                                黄粱一梦终须醒,镜花水月总是空      🌏 

📃个人主页island1314

🔥个人专栏:Linux—登神长阶

⛺️ 欢迎关注:👍点赞 👂🏽留言 😍收藏  💞 💞 💞


1. 前言

1-1 狭义理解文件

  • 文件在磁盘里
  • 磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的
  • 磁盘是外设(即是输出设备也是输入设备)
  • 磁盘上的文件 本质是对文件的所有操作,都是对外设的输入和输出 简称10

1-2 广义理解

  • Linux 下一切皆文件(键盘、显示器、网卡、磁盘……这些都是抽象化的过程)(后面会讲如何去理解)

1-3 文件操作的归类认知

  • 对于 0KB 的空文件是占用磁盘空间的
  • 文件是文件属性(元数据)和文件内容的集合(文件=属性(元数据)+内容所有的文件操作本质是文件内容操作和文件属性操作

1-4 系统角度

  • 对文件的操作本质是进程对文件的操作
  • 磁盘的管理者是操作系统
  • 文件的读写本质不是通过C语言/C++的库函数来操作的(这些库函数只是为用户提供方便),而是通过文件相关的系统调用接口来实现的

  1.  文件 = 内容 + 属性

  2. 访问文件之前必须先打开它,为什么要先打开呢?
    1.  访问一个文件的时候,是 进程 在访问它
    2. 当文件没有被打开的时候,是保存在 磁盘

为啥访问一个文件是进程在访问呢?来看一段代码

#include <stdio.h>
int main()
{
    FILE *fp = fopen("log.txt", "w");
    if(fp == NULL)
    {
        perror("fopen");
        return 1;
    }

    const char *message = "hello file\n";
    int i = 0;
    while(i < 5)
    {
        fputs(message, fp);
        i++;
    }

    fclose(fp);
    return 0;
}

结果如下:

我们可以发现:

  1. 程序结束之后,会在当前目录下新建 log,txt 文件
  2. 查看文件时内容已被写入
  3. 这个文件在磁盘中已经被保存好了

那么在程序的当前路径下,系统是怎么知道程序的当前路径在哪呢??

  • 可以使用  ls /proc/[进程 id] -l 命令查看当前正在运行进程的信息:

其中:

  • cwd:指向当前进程运行目录的一个符号链接。
  • exe:指向启动当前进程的可执行文件(完整路径)的符号链接

打开文件,本质是进程打开,所以,进程知道自己在哪里,即便文件不带路径,进程也知道。由此OS就能知道要创建的文件放在哪里。

💫 那么我们现在有个问题,我们编好了代码,这个文件是不是就打开了 -- 没有,因为我们把代码写好之后,这个还只是一个文本,那是不是把代码编译成可执行程序,文件就打开了 -- 答案也是没有的,把原代码编译成可执行程序仅仅是跑起来了。

🌈 那么什么时候文件才真正被打开呢?

  • 当我们的程序运行的时候,执行到 fopen 函数时并且成功之后,文件才会打开。
  • 此时就知道 foepn 就和 malloc 、new 类似, 属于运行时操作,当程序执行完 fopen ,这个文件才会打开。
  • 因此访问一个文件,不是程序在访问,而是进程在访问。

 进程 是在 内存 当中的,进程加载到内存中,最终是由 CPU 去执行,可是进程要进行文件读取操作时,这个文件是在磁盘上的,它们又是咋联系上的呢?

  •  根据 冯诺依曼 体系,一个文件有内容和属性,将来也要被 CPU 所读取,可是进程在内存里,文件在磁盘上的,而CPU 无法直接访问磁盘,就需要先去打开该文件,将文件也加载到内存中,否则进程访问不到,因为 CPU 也访问不到
  • 文件 = 内容 + 属性,因此我们加载到内存的就是 内容 和 属性,我们刚刚讲的都是一个进程可以打开一个文件,此外一个进程也可以打开多个文件。 
  • 由于文件需要加载到内存当中,同时我们的文件数目比进程数更多,进程都需要 OS 管理,那么 OS 对于加载到 内存的文件也需要做管理

结论:访问一个文件之前必须先打开它,根据冯诺依曼,无法访问磁盘上的文件,必须加载到内存上

如何管理文件?

  • 先描述再组织
  • 内核中,文件 = 文件的内核数据结构 + 文件的内容 

结论:我们研究打开的文件,就是在研究 进程 和 文件 的关系

2. 输出重定向

我们上面 fopen 中的 'w' 是 覆盖式写入,会将文件清空之后再写入。这个就类似于 我们之前学的

  

这个 > 就叫作 输出重定向,写入前把文件先清空。

案例:给上面代码加个 字符数组

int main()
{
    FILE *fp = fopen("log.txt", "w");
    if(fp == NULL)
    {
        perror("fopen");
        return 1;
    }
    
    char buffer[1024];
    const char *message = "hello file";
    int i = 0;
    while(i < 5)
    {
        snprintf(buffer, sizeof(buffer), "%s:%d\n", message, i);
        fputs(buffer, fp);
        i++;
    }

    fclose(fp);
    return 0;
}

输出如下:

   

追加写入 -- a

同样在 echo 命令中 我们也可以用 >> 来追加式写入

3. 标准输入输出流 💦

概念补充:任何一个程序在启动之前默认需要打开三个流

  1. stdin : 标准输入  -- 键盘
  2. stdout :标准输出 -- 显示器
  3. stderr : 标准错误 -- 显示器

但是键盘、显示器不是属于硬件嘛,怎么跟文件流有关系,这个和我们之前学的 Linux 下一切皆文件有关(TODO)

一个程序启动时会打开三个流,而其中 C 语言底层所对应的硬件时键盘、显示器,但是它把这个键盘、显示器包装成了文件的样子,最后就可以 File* 的形式来访问文件了。

那么现在有个问题是谁默认打开这三个流的呢?

  • 进程默认会打开这三个输入输出流,毕竟程序只是可执行文件还没有运行,而且访问文件必须先把文件打开,就需要调用 fopen,三个标准输入输出流默认就是进程打开。 同样 C++ 也有三个输入输出流 -- cin、cout、cerr

把打印内容到显示器的 三种方法

#include <stdio.h>

int main()
{
    printf("hello world\n");
    fputs("aaaa", stdout);
    fwrite("bbbb", 1, 4, stdout);
    fprintf(stdout, "cccc");
    return 0;
}

4. open 函数 🖊

4.1 基本概念

  

  • 上面的flags 表示打开文件的标记位,以只读或只写等形式打开,mode 表示创建文件权限 

pathname: 要打开或创建的目标文件

② flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags(本质是个 宏)

参数说明:

  1. O_RDONLY 以只读方式打开文件  
  2. O_WRONLY 以只写方式打开文件  
  3. O_RDWR 以可读写方式打开文件。  
    1.  上述三种旗标是互斥的,也就是不可同时使用,但可与下列的旗标利用OR(|)运算符组合。  
  4. O_CREAT 若欲打开的文件不存在则自动建立该文件。注:需要使用mode选项,来指明新文件的访问权限 
  5. O_EXCL 如果O_CREAT 也被设置,此指令会去检查文件是否存在。文件若不存在则建立该文件,否则将导致打开文件错误。此外,若O_CREAT与O_EXCL同时设置,并且欲打开的文件为符号连接,则会打开文件失败。  
  6. O_TRUNC 若文件存在并且以可写的方式打开时,此旗标会令文件长度清为0,而原来存于该文件的 资料也会消失。  
  7. O_APPEND 当读写文件时会从文件尾开始移动,也就是所写入的数据会以附加的方式加入到文件后面。

③ 参数mode 组合

  此为Linux2.2以后特有的旗标,以避免一些系统安全问题。参数mode 则有下列数种组合,只有在建立新文件时才会生效,此外真正建文件时的权限会受到umask值所影响,因此该文件权限应该为(mode-umaks)

④ 返回值

  若所有欲核查的权限都通过了检查则返回文件描述符,表示成功,只要有一个权限被禁止则返回-1。

4.2 mode -- 权限

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

int main()
{
    open("log.txt", O_WRONLY|O_CREAT);
    return 0;
}

运行上面代码,发现创建了log.txt,但是它的权限是乱码的。这是因为我们在最初的时候并没有给其分配权限,修改如下:

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

此时权限就正常了,但是我们明明指定的权限明明是 666 ,但是这上面为啥显示的是 664 呢,因为系统存在 umask(0002)的默认权限掩码,权限掩码会与我们设置的权限进行位运算。那么我们应该怎么做,才能不让其去掉这个权限呢?如下:

此时将代码中的 umask 设置为对应的 0 后,权限掩码就不会给我们去掉 默认的 umask (0002)了,结果就对上了

注意:权限掩码按照就近原则,如果我们有设置默认权限掩码,就用我们设置的,如果没有,就会使用系统默认的。

4.3 close 和 write 函数

int main()
{
    int fd1 = open("log.txt", O_WRONLY|O_CREAT, 0666);
    if(fd1 < 0)
    {
        perror("open");
        return 1;
    }
    printf("fd1: %d\n", fd1);

    const char* message = "hello world\n";
    write(fd1, message, strlen(message));

    close(fd1);
    return 0;
}

上面是系统调用接口 close 和 write 。fd就是open的返回值。

输出如下:

我们把message里的内容换成aaa,然后直接运行代码。

const char* message = "aaa";

发现之前的内容还在,旧的内容没被完全清空:

  • 这是因为这里的open默认不存在就创建,存在就打开,默认不清空文件

如果我们想像C语言fopen的“w”打开方式一样, 打开就清空文件,就需要再传 O_TRUNC

表示 如果文件已经存在,而且是个常规文件,并以写的方式打开,传入这个选项后,他就会把文件清空。

补充: 我们还可以用  O_APPEND 来对内容进行 追加式写入

5. 系统调用和库函数 

还记得我们上面写的 fd 作返回值嘛,在认识返回值之前,先来认识一下两个概念:系统调用库函数

  1. fopen fclose fread fwrite都是C标准库当中的函数,我们称之为库函数(libc)
  2. open close read write lseek 都属于系统提供的接口,称之为系统调用接口

系统调用接口和库函数的关系,一目了然。
所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。

举个例子:

#include <stdio.h>
#include <unistd.h>
int main()
{
    int a = 12345;
    write(1, &a, sizeof(a));
    return 0;
}

经过输出,我们发现最后输出结果不是 12345

  • 原因: 12345 是整数,但是显示器是个字符设备只认字符

解决如下:

int main()
{
    int a = 12345;
    char buffer[1024];
    snprintf(buffer, sizeof(buffer), "%d", a);
    write(1, buffer, strlen(buffer));
    return 0;
}

🍋 因此我们可以知道直接把数字打印到显示器用系统调用接口是不行的,必须做相关的转化变成相关字符然后依次地达到显示器上。

🍎 那么我们有个问题,我们已经有了对应的 read 接口向显示器写,为啥还需要提供这么多写入接口呢?

因为很多情况下需要把我们内存级别的二进制数据转化成字符串风格,然后通过 write 打印到显示器上,这个就叫作 格式化 的过程,然后由于系统调用,这个需要用户自己来实现,为了方便,就提供了这些接口.


6. 文件描述符 fd

6.1 基本了解

输出如下:

文件描述符就是一个小整数

open 的返回值 fd 是从 3 开始的。因为C语言默认会打开三个输入输出流,

  1. 标准输入stdin
  2. 标准输出stdout
  3. 标准错误stderr 

情况一: write 向 1 输出

可以用write配合文件描述符在显示器上打印 ---

int main()
{
    const char *message = "hello write\n";
    write(1, message, strlen(message)); // 默认提供的
}

// 输出描述:
[lighthouse@VM-8-10-centos File-IO]$ ./filecode
hello write

 情况二:read 向 0 读取

int main()
{
    char buffer[128];
    ssize_t s = read(0, buffer, sizeof(buffer));
    if(s > 0){
        buffer[s - 1] = 0; // 吞掉最后一个换行符
        printf("%s\n", buffer);
    }
    return 0;
}

// 输出描述:
[lighthouse@VM-8-10-centos File-IO]$ ./filecode
abcd
abcd

情况三:把字符串 \0 写入文件

int main()
{
    int fd1 = open("log.txt", O_WRONLY| O_CREAT | O_APPEND, 0666); 
    const char* message = "aaa\n";
    write(fd1, message, strlen(message) + 1);

    close(fd1);
    return 0;
}

我们会发现这样的结果,这个是为什么呢? --》 字符串以 \0 结尾,和文件没有关系。

🍉 文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了 file 结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针 *files, 指向一张表files_struct,该表最重要的部分就是包含一个指针数组,每个元素都是一个指向打开文件的指针!所以本质上,文件描述符就是该数组的下标,只要拿着文件描述符,就可以找到对应的文件 

  • Linux中一切皆文件,所以0,1,2可以代表键盘,显示器。

 在OS内,系统在访问文件的时候,只认文件描述符fd:

  • FILE* 是C语言提供的结构体类型,里面封装着文件fd
  • 所有的C语言上的文件操作函数,本质都是对系统调用的封装

说到了fd,我们就不得不来区分下 FILE  fd 

 FILE 是C库当中提供的一个结构体,而fd 是系统调用,更加接近于底层,因此 FILE 中必定封装了 fd
我们可以来看看 FILE 的结构体:
typedef struct _IO_FILE FILE; 在 /usr/include/stdio.h  它的结构体中有这么一段

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;//fd的封装

可以看到 int_fileno 就是对 fd 的封装,在这一部分的开头有一大段跟缓冲区相关的内容,为什么要诺列出它呢,我们来看个例子

int main()
{
    printf("stdin: %d\n", stdin->_fileno);
    printf("stdout: %d\n", stdout->_fileno);
    printf("stderr: %d\n", stderr->_fileno);
    // stdin、stdout、stderr、file* 必须用到文件描述符
    FILE* fp = fopen("log.txt", "w");
    printf("fp: %d\n", fp->_fileno);

    return 0;
}

  •  FILE* 结构体中就封装着文件描述符fd

补充:fileno 的了解

  • 用来取得参数stream指定的文件流所使用的文件描述
     

学了系统调用,我们可以用系统调用接口,也可以用语言提供的文件方法。但还是推荐使用语言提供的方法。因为系统不同,系统调用的接口可能不一样


6.2 深入了解 fd

int main()
{
    while(1)
    {
        printf("%d\n",getpid());
        sleep(1);
    }
    return 0;
}

// 输出:
31926

我们打开另一个终端,查看该进程下的 proc 目录:

  • 查看该进程的文件夹,cwd就是当前进程的工作路径。exe指向当前可执行程序的二进制文件
  • 里面还有一个目录fd

进入fd目录,可以看到默认的文件描述符0、1、2是打开的。

打开的设备是dev目录下的pts/3,演示如下:

云服务器下, 我们看到的显示器文件一般在 /dev/pts/目录下,即就是我们打开的终端数

6.3 read 和 stat

read 函数

#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
  1. fd:文件描述符
  2. buf:写入的缓冲区
  3. count:写的字符长度,也就是看你需要写多少
  4. 返回值:
    1. 如果顺利write()会返回实际写入的字节数。当有错误发生时则返回-1,错误代码存入errno中

 stat 函数

#include <sys/stat.h>
#include <unistd.h>
int stat(const char *file_name, struct stat *buf);

函数说明: 通过文件名filename获取文件信息,并保存在buf所指的结构体stat中
返回值: 执行成功则返回0,失败返回-1,错误代码存于errno

错误代码:

  • ENOENT 参数file_name指定的文件不存在
  • ENOTDIR 路径中的目录存在但却非真正的目录
  • ELOOP 欲打开的文件有过多符号连接问题,上限为16符号连接
  • EFAULT 参数buf为无效指针,指向无法存在的内存空间
  • EACCESS 存取文件时被拒绝
  • ENOMEM 核心内存不足
  • ENAMETOOLONG 参数file_name的路径名称太长
struct stat {
    dev_t         st_dev;       //文件的设备编号
    ino_t         st_ino;       //节点
    mode_t        st_mode;      //文件的类型和存取的权限
    nlink_t       st_nlink;     //连到该文件的硬连接数目,刚建立的文件值为1
    uid_t         st_uid;       //用户ID
    gid_t         st_gid;       //组ID
    dev_t         st_rdev;      //(设备类型)若此文件为设备文件,则为其设备编号
    off_t         st_size;      //文件字节数(文件大小)
    unsigned long st_blksize;   //块大小(文件系统的I/O 缓冲区大小)
    unsigned long st_blocks;    //块数
    time_t        st_atime;     //最后一次访问时间
    time_t        st_mtime;     //最后一次修改时间
    time_t        st_ctime;     //最后一次改变时间(指属性)
};

使用如下:

int main()
{
    struct stat st;
    int n = stat("log.txt", &st);
    if(n < 0) return 1;
    printf("file size: %lu\n", st.st_size);
    int fd = open("log.txt", O_RDONLY);
    if(fd < 0){
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);

    char* file_buffer = (char*)malloc(st.st_size + 1);
    n = read(fd, file_buffer, st.st_size);
    if(n > 0){
        file_buffer[n] = '\0';
        printf("%s\n", file_buffer);
    }
    return 0;
}

 运行如下:

  • struct stat 是一个内核结构体,可以直接用,stat 的参数2是一个输出型参数,我们把参数传进去后,它会把参数填满然后再传出来
  • read 的参数 1 指读取的文件fd,参数2是将读取到的内容放到该缓冲区中,参数3是要读取的字节数。

    • read的返回值:>0 

    • 当读取到的字节数=0时表示此时已经读取到文件末尾。 

6.4 文件描述符分配规则

还记得 我们上面演示得文件描述符从 3 开始嘛,但是当我们先 close 把文件描述符 0 关掉的时候,又会出现什么情况呢?

int main()
{
    close(0);
    int fd1 = open("log1.txt", O_WRONLY| O_CREAT | O_APPEND, 0666); 
    int fd2 = open("log2.txt", O_WRONLY| O_CREAT | O_APPEND, 0666); 
    int fd3 = open("log3.txt", O_WRONLY| O_CREAT | O_APPEND, 0666); 
    int fd4 = open("log4.txt", O_WRONLY| O_CREAT | O_APPEND, 0666); 
    
    printf("fd1: %d\n", fd1);
    printf("fd2: %d\n", fd2);
    printf("fd3: %d\n", fd3);
    printf("fd4: %d\n", fd4);

    close(fd1);
    close(fd2);
    close(fd3);
    close(fd4);

    return 0;
}

 情况一:把 0 关掉,输出如下:

情况二:把 2 关掉,输出如下:

因此我们可以得到一个结论:

  • 进程打开文件,需要给文件分配新的 id, 文件描述符的分配规则是从最小的,没有被使用的 fd 开始

注意:文件描述符表中遵循最小未使用分配规则,也就是从表中找寻最小的没有被使用的位置进行存储,因此并不保证多次打开会使用同一个文件描述符

  • 在进程中多次打开同一个文件返回的文件描述符不一定是一致的

6.5 两个进程打开同一个文件的理解

经过上面所学我们,可以知道,在一个进程中打开一个文件,会在进程内生成文件的描述信息结构,并将其地址添加到pcb中的文件描述信息数组中,最终返回所在位置下标作为文件描述符

  • 两个进程中分别产生生成两个独立的fd
    • 进程数据独有,各自有各自的文件描述信息表,因此各自打开文件会有自己独立的描述信息添加在各自信息表的不同位置,因此fd各自也相互独立
  • 两个进程可以任意对文件进行读写操作,操作系统并不保证写的原子性
    • 两个进程打开同一个文件,但是各有各的文件描述信息以及读写位置,互不影响,因此多个进程同时读写有可能会造成穿插覆盖的情况(原子性操作,被认为是一次性完成的操作,操作过程中间不会被打断,通常以此表示操作的安全性)
  • 进程可以通过系统调用对文件加锁,从而实现对文件内容的保护
    • 文件锁就是用于保护对文件当前的操作不会被打断,就算时间片轮转,因为已经对文件加锁,其他的进程也无法对文件内容进行操作,从而保护在本次文件操作过程是安全的。
  • 任何一个进程删除该文件时,另外一个进程不会立即出现读写失败
    • 删除文件实际上只是删除文件的目录项,文件的数据以及inode并不会立即被删除,因此若进程已经打开文件,文件被删除时,并不会影响进程的操作,因为进程已经具备文件的描述信息(可以编写代码进行尝试,在文件打开后,外界删除文件,然后看进程中是否还可以继续写入或读取数据)
  • .两个进程可以分别读取文件的不同部分而不会相互影响
    • 如果仅仅是读取文件内容,两个不同进程其实都有自己各自的描述信息和读写位置,因此可以同时读取文件数据而不会受到对方的影响。
  • 一个进程对文件长度和内容的修改另外一个进程可以立即感知
    • 因为文件内容的修改是直接反馈至磁盘文件系统中的,因此当文件内容被修改,其他进程因为也是针对磁盘数据的操作,因此可以立即感知到(可以写代码尝试一个进程打开文件后,等其他进程修改了内容后然后再读取文件数据进行测试)

6.6  理解 一切皆文件

首先,在windows中是文件的东西,它们在linux中也是文件;其次一些在windows中不是文件的东西,比如进程、磁盘、显示器、键盘这样硬件设备也被抽象成了文件,你可以使用访问文件的方法访问它们获得信息;甚至管道,也是文件;将来我们要学习网络编程中的socket(套接字)这样的东西使用的接口跟文件接口也是一致的。

这样做最明显的好处是,开发者仅需要使用一套 API和开发工具,即可调取 Linux 系统中绝大部分的资源。举个简单的例子,Linux中几乎所有读(读文件,读系统状态,读PIPE)的操作都可以用read 函数来进行;几乎所有更改(更改文件,更改系统参数,写PIPE)的操作都可以用 write 函数来进行。

之前我们讲过,当打开一个文件时,操作系统为了管理所打开的文件,都会为这个文件创建一个fie结构体,该结构体定义在 /usr/src/kernels/3.10.0-1160.71.1.el7.x86_64/include/linux/fs.h下,以下展示了该结构部分我们关系的内容

struct file {
	...

    struct inode* f_inode; /* cached value */
	const struct file_operations* f_op;

	...

	atomic_long_t f_count; // 表⽰打开⽂件的引⽤计数,如果有多个⽂件指针指向它,就会增加f_count的值。
	unsigned int f_flags; // 表⽰打开⽂件的权限 
	fmode_t f_mode; // 设置对⽂件的访问模式,例如:只读,只写等。所有的标志在头⽂件<fcntl.h> 中定义
	loff_t f_pos; // 表⽰当前读写⽂件的位置 

	...

} __attribute__((aligned(4))); /* lest something weird decides that 2 is OK */

值得关注的是 struct file 中的 f_op 指针指向了一个 file_operations 结构体,这个结构体中的成员除了struct module*owner 其余都是函数指针。该结构和 struct file 都在fs.h下

struct file_operations {
	struct module* owner;
	//指向拥有该模块的指针; 
	loff_t(*llseek) (struct file*, loff_t, int);
	//llseek ⽅法⽤作改变⽂件中的当前读/写位置, 并且新位置作为(正的)返回值.  
	ssize_t(*read) (struct file*, char __user*, size_t, loff_t*);
	//⽤来从设备中获取数据. 在这个位置的⼀个空指针导致 read 系统调⽤以 -EINVAL("Invalid argument") 失败.⼀个⾮负返回值代表了成功读取的字节数(返回值是⼀个"signed size" 类型, 常常是⽬标平台本地的整数类型).
	ssize_t(*write) (struct file*, const char __user*, size_t, loff_t*);
	//发送数据给设备. 如果 NULL, -EINVAL 返回给调⽤ write 系统调⽤的程序. 如果⾮负, 返回值代表成功写的字节数.
	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);
	//对于设备⽂件这个成员应当为 NULL; 它⽤来读取⽬录, 并且仅对**⽂件系统**有⽤. 
	unsigned int (*poll) (struct file*, struct poll_table_struct*);
	int (*ioctl) (struct inode*, struct file*, unsigned int, unsigned long);
	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*);
	//mmap ⽤来请求将设备内存映射到进程的地址空间. 如果这个⽅法是 NULL, mmap 系统调⽤返回 - ENODEV.
	int (*open) (struct inode*, struct file*);
	//打开⼀个⽂件 
	int (*flush) (struct file*, fl_owner_t id);
	//flush 操作在进程关闭它的设备⽂件描述符的拷⻉时调⽤; 
	int (*release) (struct inode*, struct file*);
	//在⽂件结构被释放时引⽤这个操作. 如同 open, release 可以为 NULL. 
	int (*fsync) (struct file*, struct dentry*, int datasync);
	//⽤⼾调⽤来刷新任何挂着的数据. 
	int (*aio_fsync) (struct kiocb*, int datasync);
	int (*fasync) (int, struct file*, int);
	int (*lock) (struct file*, int, struct file_lock*);
	//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**);
};

file_operation 就是把系统调用和驱动程序关联起来的关键数据结构,这个结构的每一个成员都对应着一个系统调用。读取 file_operation 中相应的函数指针,接着把控制权转交给函数,从而完成了Linux设备驱动程序的工作。


介绍完相关代码,一张图总结:

上图中的外设,每个设备都可以有自己的read、write,但一定是对应着不同的操作方法!!但通过struct file 下 file_operation 中的各种函数回调,让我们开发者只用file便可调取 Linux 系统中绝大部分的资源!!这便是“linux下一切皆文件”的核心理解。


7. 重定向 📕

7.1 基本了解

刚刚我们演示的是把 0 和 2 关掉,那我们把 1 关掉会是怎样的呢?

我们却发现连打印结果此时都没有了,原因:

  • 文件描述符 1 是标准输出流,关闭后,就不会在显示器打印了。

但是我们发现log.txt创建出来了,但里面什么东西也没有

然后我们用 fflush 来更新缓冲区,做出如下修改:

fflush(stdout);  // TODO
  • 函数定义:int fflush(FILE *stream);
  • 函数说明:调用fflush()会将缓冲区中的内容写到stream所指的文件中去.若stream为NULL,则会将所有打开的文件进行数据更新
  • 结果如下:

 我们可以发现:本来应该向显示器写入,结果却写到了文件中,我们来看下面的一个图片

💖 log.txt 存在磁盘中,当进程启动打开时,就会被加载到内存中。由于我们先关闭了文件描述符1,所以此时 log.txt 的文件描述符就是1。上层的 printf fprintf 都是向 stdout 打印,而 stdout 的描述符是1,OS只认文件描述符,所以最终就向 log.txt 打印了内容。

这个动作我们就叫作 重定向 🍻

  • 重定向的本质:是在内核中改变文件描述符表特定下标的内容,与上层无关 ‼️

🌈 每个文件对象都有对应的内核文件缓冲区,我们写数据都是从上层通过文件描述符1,写到对应的文件缓冲区,然后OS再把内容刷新到磁盘的文件中

  • stdin、stdout、stderr 都是 FILE* 结构体,里面除了封装着fd,还有语言级别的文件缓冲区。所以我们通过 printf / fprintf 不是直接写到OS的内部的缓冲区,而是直接写到语言级别的缓冲区中,然后 C语言 再通过 1 号文件描述符 把内容刷新到OS的内核文件缓冲区中

所以 fflush() 里面是 stdout,这是因为我们是刷新语言级别缓冲区的内容到OS的内核缓冲区中,内核缓冲区的内容由OS进行刷新。

 💦 因此由上面可知,最开始没有的 fflush 的时候,log.txt 文件里面啥也没有,是因为内容在语言级别的缓冲区中,还没执行到 return 语句,冲刷内容到内核缓冲区中,log.txt 就被关闭了。

🔥 对重定向更深理解,打个比方:

🌈 假如最开始的时候 1 号文件的内容指向显示器,3 号文件内容指向 log.txt。重定向的本质是将 3 号的内容拷贝给 1 号。所以 1 号就不会再指向显示器了,而是变成指向 log.txt,所以后来往 1 号里写的内容都会变成往 log.txt 里写。

💢 struct file 里还存在一个引用计数,有几个指针指向就是几。如 log.txt 由1号和 3 号指向就是2,显示器就是 0

注意:

如下代码:

int main()
{
    close(1);
    int fd1 = open("log1.txt", O_WRONLY| O_CREAT | O_APPEND, 0666); 
    printf("printf, fd1: %d\n", fd1);
    fprintf(stdout, "fprintf fd: %d\n", fd1);
    //fflush(stdout);  // TODO
    //close(fd1);
    return 0;
}

当我们把close也注释掉之后, log1.txt 中也会有内容,如下:

原因:return的时候,语言级别缓冲区的内容就被冲刷到内核文件缓冲区中,此时 log1.txt 也有内容了

结论:当一个进程在退出的时候,会自动刷新自己的缓冲区(所有的FILE对象内部,包括 stdin、stdout、stderr),fclose() -> c 语言 -> 关闭 FILE 的时候,也会自动刷新

因此我们之前 close 没有注释的时候,信息出不来是因为:

  • close 刷新是在进程退出之后的,当进程正在进行的时候,此时数据就存在 stdout 缓冲区里面,调用 close 的时候进程没有退出,但是却提前把文件描述符关了,因此就没有打印到显示器上,而用 fflush 可以直接帮我们把数据冲刷到内核文件缓冲区中

7.2 dup2 

刚刚我们演示的操作是最朴素的,是关闭再打开只有一次操作,那么有没有更直接的操作来演示输出重定向操作的呢?

#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
函数功能为将newfd描述符重定向到oldfd描述符,相当于重定向完毕后都是操作oldfd所操作的文件
但是在过程中如果newfd本身已经有对应打开的文件信息,则会先关闭文件后再重定向(否则会资源泄露)
  • dup用来复制参数oldfd所指的文件描述符。当复制成功是,返回最小的尚未被使用过的文件描述符,若有错误则返回-1,错误代码存入errno中。
  • 返回的新文件描述符和参数oldfd指向同一个文件,这两个描述符共享同一个数据结构,共享所有的锁定,读写指针和各项全现或标志位
  • dup2dup 区别 是 dup2 可以用参数newfd指定新文件描述符的数值

    • 若参数newfd已经被程序使用,则系统就会将newfd所指的文件关闭,若newfd等于oldfd,则返回newfd,而不关闭newfd所指的文件。

    • dup2 所复制的文件描述符与原来的文件描述符共享各种文件状态。共享所有的锁定,读写位置和各项权限或 flags 等,

    • 本质是文件描述符下标对应内容的拷贝

  • 如果我们要对标准输出进行重定向,把往显示器打印的内容变成往log,txt打印,根据上面的参数解释,参数的填法应该是dup2(fd,1)。也就是把oldfd留下来,拷贝给newfd

 代码如下:

int main()
{
    int fd = open("log.txt", O_WRONLY| O_CREAT | O_TRUNC, 0666); 
    
    dup2(fd, 1);

    printf("hello fd: %d\n", fd);
    fprintf(stdout, "hello fd: %d\n", fd);
    fputs("hello world\n", stdout);

    const char*message = "hello fwrite\n";
    fwrite(message, 1, strlen(message), stdout);
    
    return 0;
}

  • 运行上面代码,发现不在显示器上打印,而是在log.txt里打印 

7.3 追加重定向

把文件打开的方式改成 O_APPEND即可。

因此我们可以知道:输出重定向和追加重定向没有区别,只是打开的方式不一样而已。

7.4 输入重定向

先实现一段从标准输入读的代码

int main()
{
    char buffer[2048];
    size_t s = read(0, buffer, sizeof(buffer));
    
    if(s > 0)
    {
        buffer[s - 1] = 0;
        printf("stdin redir: \n%s\n", buffer);
    }

    return 0;
}

运行如下:

我们加上输入重定向的代码到 main 函数的最上面几行

// 输入重定向:需要文件存在且可读
int fd = open("log.txt", O_RDONLY);
dup2(fd, 0); 

🔥 原因:拿 fd 新打开文件输入地址来覆盖 0 ,覆盖 0 之后,由于可是当前已经执行 log.txt 文件了,所以最后读数据会去 log.txt 去读,这就是 输入重定向

🍒 因此我们得到一个结论:其实就是新打开一个文件,然后把 流 做一下 dup2 重定向,在内核当中做一个文件内容的拷贝,拷贝后续代码不变,就会自动更改读取数据的数据源,这也就是 重定向

  1. 输出重定向(>)   :也就是关闭fd为1下标所指向的内容
  2. 输入重定向(<)   :同理就是关闭fd为0下标所指向的内容
  3. 追加重定向(>>) :后面多一个追加选项

8. 缓冲区 📑

8.1 基本概念

  • 缓冲区就是一段内存空间。
    • 也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。
  • 缓冲区由 C语言 维护就叫语言级缓冲区,由 OS 维护就叫内核级缓冲区
  • 缓冲区根据其对应的是输入设备还是输出设备,分为输入缓冲区和输出缓冲区
  • 缓冲区存在的意义:OS为语言考虑,语言为用户考虑。给上层提供高效的IO体验,间接提高整体效率。

8.2 缓冲区的刷新策略

标准 I/O 提供了3种类型的缓冲区。

  • 全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行1/0系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。
  • 行缓冲区:在行缓冲情况下,当在输入和输出中遇到换行符时,标准I0库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准I/0库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/0系统调用操作,默认行缓冲区的大小为1024。
  • 无缓冲区:无缓冲区是指标准 I/O 库不对字符进行缓存,直接调用系统调用。标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来。除了上述列举的默认刷新方式,

下列特殊情况也会引发缓冲区的刷新:

  1. 缓冲区满时;
  2. 执行flush语句;

这个刷新策略在内核和用户级别的缓冲区都能用。这里介绍用户级别的 

int main()
{
    // C 库函数
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    const char* message = "hello fwrite\n";
    fwrite(message, 1, strlen(message), stdout);

    // 系统调用
    const char *w = "hello write\n";
    write(1, w, strlen(w));

    return 0;
}

 运行上面代码,第一次在显示器上打印,第二次重定向到文件打印。发现打印的顺序不同

 

原因如下:

  • 在显示器上打印是行刷新策略,write系统调用没有带缓冲区,就按语句顺序打印,所以第一次打印按顺序。库函数 printf 、fprintf、fwrite 最后都是通过 write 系统调用刷新。
  • 第二次是重定向到普通文件,此时刷新策略变成全缓冲,执行 printf 、fprintf、fwrite 语句时,内容都在缓冲区中,write直接输出,然后程序结束自动把缓冲区刷新,才打印出 printf 、fprintf、fwrite

将上面代码改成如下,让其产生子进程

直接运行的结果跟上面的一样,但是当输入到文件中,结果就与上面不一样了,如下:

 原因如下:

  • 因为是行刷新,所以执行到 fork() 时,缓冲区没内容了。
  • 如果重定向到普通文件,此时是全缓冲, printf 和 fprintf 的内容都在语言级缓冲区中,write是直接写到 内核级缓冲区 中,所以 write 打印在最前面且只打印一次。
  • 到了 fork 产生子进程之后,父子进程都有了语言及缓冲区的内容,所以程序结束时,父子进程的缓冲区的内容都被刷新,就打印两次 printf 和 fprintf 

解释:

  • 因为我们进行调用时,走到 fork 时,重复打的都是 C  库函数,和系统调用没有关系,只有 C 语言的库函数被重复打了两次,走到 fork 时上面的函数的确都调完了,但是并不代表 你把数据已经拷贝到了系统内部,虽然我们带的 \n(行刷新),但是由于 重定向了,此时刷新方案变成了全刷新,缓冲区都没写完,因此都写到了 stdout 缓冲区里面,但是没有刷新,后面调用 write 直接写到 OS 里面,此时缓冲区里面就有 三行内容,然后再 fork 时候,父子自己各自结束,各自执行自己的 fflush ,然后彼此之间互不影响,因此 C 库函数就调用了两次
  • 而系统调用本身这个缓冲区属于文件,已经写到 OS 就不管了,就不会受对应的 fflush 影响,因为其已经在缓冲区内部了,因此只会调用一次

那为啥执行结果却只有四行呢?

因为直接打印的时候是向显示器文件打,显示器文件打的是行刷新,刷新出 \n 的内容,此时再进行 fork ,当前缓冲区的内容已经被刷新完了,没有刷的了

比如当我们去掉换行符,没有行刷新输出如下:

int main()
{
    printf("hello printf ");
    fprintf(stdout, "hello fprintf ");
    const char* message = "hello fwrite ";

    fwrite(message, 1, strlen(message), stdout);

    // 系统调用
    const char *w = "hello write\n ";
    write(1, w, strlen(w));

    // 创建子进程
    fork();

    return 0;
}

 

8.3 为什么要引入缓冲机制

读写文件时,如果不会开辟对文件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对文件进行一次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行一次系统调用,执行一次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗一定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响。

为了减少使用系统调用的次数,提高效率,我们就可以采用缓冲机制

  • 比如我们从磁盘里取信息,可以在磁盘文件进行操作时,可以一次从文件中读出大量的数据到缓冲区中,以后对这部分的访问就不需要再使用系统调用了,等缓冲区的数据取完后再去磁盘中读取;
  • 这样就可以减少磁盘的读写次数再加上计算机对缓冲区的操作大大快于对磁盘的操作,故应用缓冲区可大大提高计算机的运行速度。

又比如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把文档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备占用CPU,解放出CPU,使其能够高效率工作。

9. 手搓 shell 重定向补充 🍻

🍉 看了这么多,那我们可不可以 命令行参数,重定向方式用 符号表示,然后在程序中做判断用哪个重定向,然后再把文件以特定形式打开

[lighthouse@VM-8-10-centos File-IO]$ ./filecode > log.txt

9.1 重定向命令分析

如下:

// 全局遍历 与 重定向有关
#define NoneRedir 0
#define InputRedir 1
#define OutputRedir 2
#define AppRedir 3

int redir = NoneRedir;
char *filename = nullptr;

// " "file.txt 从左向右扫描不是空格的字符
// do while(0) 套壳
#define TrimSpace(pos) do{\
    while(isspace(*pos)){\
        pos++;\
    }\
}while(0)

上面为啥会用 do while(0) 来封装 宏

目的是为了解决宏定义在使用时可能引发的一些问题,例如宏定义中的分号和大括号的使用。 }while (0),将你的代码写在里面,里面可以定义变量而不用考虑变量名会同函数之前或者之后的重复。 ,允许在宏定义中使用局部变量。 总而言之, do {} while (0) 的作用是为了解决宏定义在使用时可能引发的一些问题,确保宏定义可以作为单个语句使用,并且在逻辑上看起来像是一个语句。

 函数实现如下:

void ParseCommandLine(char command_buffer[], int len) // 3. 分析命令
{
    (void)len; // 避免不使用的时候告警
    // 虽然定义的是全局默认为0,但是由于这些工作都是重复去做的,为保证安全性,需要局部初始为0
    memset(gargv, 0, sizeof(gargv));
    gargc = 0;

    // 重定向
    redir = NoneRedir;
    filename = nullptr;

    printf("command start: %s\n", command_buffer);

    // "ls -a -b -c -d " > hello.txt
    // "ls -a -b -c -d " >> hello.txt
    // "ls -a -b -c -d " < hello.txt
    int end = len - 1;
    while(end >= 0)
    {
        if(command_buffer[end] == '<') // 输入重定向
        {
            redir = InputRedir;
            // 拿到干净的文件名
            command_buffer[end] = 0;
            filename = &command_buffer[end] + 1;
            TrimSpace(filename); // 跳过空格部分   
            break;
        }
        else if(command_buffer[end] == '>')
        {
            if(command_buffer[end - 1] == '>') // 追加重定向
            {
                redir = AppRedir;
                command_buffer[end] = 0;
                command_buffer[end - 1] = 0;
                filename = &command_buffer[end] + 1;
                TrimSpace(filename);
                break;
            }
            else // 输出重定向
            {
                redir = OutputRedir;
                command_buffer[end] = 0;
                filename = &command_buffer[end] + 1;
                TrimSpace(filename);
                break;
            }
        }
        else
        {
            end--;
        }
    }

    // 拆分读取的字符串
    // "ls -a -l -n"
    const char *sep = " "; //分隔符

    for(char* ch = strtok(command_buffer, sep); (bool) ch; ch = strtok(nullptr, sep)) 
    {
        gargv[gargc++] = ch;
    }
}

....

// 测试代码如下:
int main()
{
    InitEnv(); // 初始化环境变量表
    char command_buffer[basesize];
    while(true) // 不断重复该工作
    {
        PrintCommandLine(); // 1. 命令行提示符

        // command_buffer -> output(输出型参数),把 ls -a -l 看作一个字符串
        if(!GetCommandLine(command_buffer, basesize))   // 2. 获取用户命令
        {
            continue;
        }
        //printf("%s\n", command_buffer); //测试

        // ls -a -b -c 解析每个指令 > "ls" "-a" "-b" "-c" 拆成一个一个字符串
        // 重定向格式
        ParseCommandLine(command_buffer, strlen(command_buffer)); // 3. 分析命令
        
        // 检测
        printf("redir: %d\n", redir);
        printf("filename: %s\n", filename);
        printf("command end: %s\n", command_buffer);

        if(CheckAndExecBuiltCommand())
        {
            continue;
        }

        ExecuteCommand(); // 4. 执行命令
    }
    return 0;
}

 输出如下:

现在我们就完成了基本的分析,接下来就可以在后续执行代码之前来进行我们的重定向,需要实现对子进程的重定向(因为命令是由子进程来做),需要解决程序替换对重定向的影响

9.2 重定向命令执行

// 在 shell 中
// 有些命令,必须由子进程来执行
// 有些命令,不能由子进程来执行,由shell 自己执行 --- 内建命令
bool ExecuteCommand() // 4. 执行命令
{
    // 让子进程进行执行
    pid_t id = fork();
    if(id < 0) return false;
    if(id == 0)
    {       
        // 1. 重定向应该让子进程自己做
        // 2. 程序替换会不会影响重定向

        if(redir == InputRedir) // 输入重定向
        {
            if(filename)
            {
                int fd = open(filename, O_RDONLY);
                if(fd < 0) // 子进程打开失败
                {
                    exit(2);
                }
                dup2(fd, 0);
            }
            else
            {
                exit(1);
            }
        }
        else if(redir == OutputRedir) // 输出重定向
        {
            if(filename)
            {
                int fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
                if(fd < 0) // 子进程打开失败
                {
                    exit(4);
                }
                dup2(fd, 1);
            }
            else
            {
                exit(3);
            }
        }
        else if(redir == AppRedir) // 追加重定向
        {
            if(filename)
            {
                int fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
                if(fd < 0) // 子进程打开失败
                {
                    exit(6);
                }
                dup2(fd, 1);
            }
            else
            {
                exit(5);
            }
        }
        else
        {   
            // 没有重定向,Do Nothing
        }

        // 子进程
        // 1. 执行命令
        //execvp(gargv[0], gargv);
        execvpe(gargv[0], gargv, genv); // 把我们的环境变量传递给子进程了

        // 2. 退出
        exit(1); // 要进行程序替换,只有子进程失败,才会 exit。

    }

    int status = 0;
    pid_t rid = waitpid(id, &status, 0); // 阻塞等待
    if(rid > 0)
    {
        if(WIFEXITED(status)) // 等待成功获取退出信息
        {
            lastcode = WEXITSTATUS(status);
        }
        else
        {
            lastcode = 100; //非正常退出
        }
        return true;
    }
    return false;
}

运行结果如下:

10. 封装简单库

mystdio.h 封装

#pragma once

#define SIZE 1024

#define FLUSH_NONE 0
#define FLUSH_LINE 1
#define FLUSH_FULL 2

struct IO_FILE
{
    int flag; // 刷新方式
    int fileno; // 文件描述符
    char outbuffer[SIZE];
    // 缓冲区
    int cap; // 容量
    int size; // 大小
    // TODO
};


typedef struct IO_FILE mFILE;

mFILE *mfopen(const char *filename, const char*mode); //打开文件的操作
int mfwrite(const void *ptr, int num, mFILE *stream);
void mfflush(mFILE *stream);
void mfclose(mFILE *stream);


先实现第一个操作,如下:

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

mFILE *mfopen(const char *filename, const char*mode)
{
    int fd = -1;
    if(strcmp(mode, "r") == 0)
    {
        fd = open(filename, O_RDONLY);
    }
    else if(strcmp(mode, "w") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
    }
    else if(strcmp(mode, "a") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
    }
    if(fd < 0) return NULL;

    mFILE *mf = (mFILE*)malloc (sizeof(mFILE));
    if(!mf) 
    {
        close(fd);
        return NULL;
    }    

    mf->fileno = fd;
    mf->flag = FLUSH_LINE;
    mf->size = 0;
    mf->cap = SIZE;

    return mf;
}

然后编译生成 .o 文件,如下:

[lighthouse@VM-8-10-centos stdio]$ gcc -c my_stdio.c

🌈 然后我们再新建一个文件夹,将我们的头文件和之前生成 .o 文件移到当前目录下,然后在当前目录下新建 main.c 文件,用给定的东西来进行以下操作:

#include "my_stdio.h"
#include <stdio.h>

int main()
{
    mFILE *fp = mfopen("./log.txt", "w");
    printf("%d, %d, %d, %d\n", fp->fileno, fp->flag, fp->cap, fp->size);

    return 0;
}

🔥 然后生成 .o 文件,然后将两个 .o 文件链接生成可执行程序:

🍉 运行可执行程序,结果如下:

完整my_stduo,c函数实现

#include "my_stdio.h"
#include <string.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>

mFILE *mfopen(const char *filename, const char*mode)
{
    int fd = -1;
    if(strcmp(mode, "r") == 0)
    {
        fd = open(filename, O_RDONLY);
    }
    else if(strcmp(mode, "w") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
    }
    else if(strcmp(mode, "a") == 0)
    {
        fd = open(filename, O_CREAT | O_WRONLY | O_APPEND, 0666);
    }
    if(fd < 0) return NULL;

    mFILE *mf = (mFILE*)malloc (sizeof(mFILE));
    if(!mf) 
    {
        close(fd);
        return NULL;
    }    

    mf->fileno = fd;
    mf->flag = FLUSH_LINE;
    mf->size = 0;
    mf->cap = SIZE;

    return mf;
}

void mfflush(mFILE *stream)
{
    if(stream->size > 0) // 缓冲区里面有内容
    {
        write(stream->fileno, stream->outbuffer, stream->size);

        // 刷新到外设
        stream->size = 0;
    }
}

int mfwrite(const void *ptr, int num, mFILE *stream)
{
    // 1. 拷贝
    memcpy(stream->outbuffer + stream->size, ptr, num);
    stream->size += num;

    // 2. 检测缓冲区是否要刷新
    if(stream->flag == FLUSH_LINE && stream->size > 0 && stream->outbuffer[stream->size - 1] == '\n')
    {
        mfflush(stream);
    }
    return num;
}

void mfclose(mFILE *stream)
{
    // 刷新之前,先判断缓冲区是否有内容
    // 将内容刷新到缓冲区
    if(stream->size > 0)
    {
        mfflush(stream);
    }
    close(stream->fileno); // 进行文件刷新
}

 测试:

#include "my_stdio.h"
#include <string.h>
#include <stdio.h>
#include <unistd.h>

int main()
{
    mFILE *fp = mfopen("./log.txt", "a");
    if(fp == NULL)
    {
        return 1;
    }
    int cnt = 3;
    while(cnt)
    {
        printf("write: %d\n", cnt);
        char buffer[64];
        snprintf(buffer, sizeof(buffer), "hello message, number is : %d\n", cnt);
        cnt--;

        mfwrite(buffer, strlen(buffer), fp);

        sleep(1);
    }
    mfclose(fp);
}

当我们把 snprintf 中 的 \n 去掉,加上一行代码,我们可以看看下面的过程:

从上面我们可以知道:说明我们在多次写入时,没有写到内核级缓冲区,而是写到了 my_file 结构当中,但是我们可以自己用fflush()强制刷新,

我们也可以把 fsnyc 写到我们实现 my_stdio.c 文件的 mfflush 中来实现这个效果

​​​​​​​

  • fsync将文件描述符 FD 所引用的文件的所有修改的核心数据(即修改后的缓冲区缓存页)传输(“刷新”)到该文件所在的磁盘设备(或其他永久存储设备)。调用会阻塞,直到设备报告传输已完成。它还会刷新与文件关联的元数据信息。
  • 调用 fsync并不一定能确保包含该文件的目录中的条目也已到达磁盘。为此,还需要在目录的文件描述符上显式 调用fsync。
  • fsync会确保一直到写磁盘操作结束才会返回。
void mfflush(mFILE *stream)
{
    if(stream->size > 0) // 缓冲区里面有内容
    {
        // 写到内核文件的文件缓冲区中!!
        write(stream->fileno, stream->outbuffer, stream->size);

        // 刷新到外设
        fsync(stream->fileno);
        stream->size = 0;
    }
}


小结 📖  

我们这篇博客主要讲了关于 文件描述符 和 缓冲区的概念,大家可以多多理解,方便我们后面的学习

  • 文件描述词是Linux编程中的一个术语。当一个文件打开后,系统会分配一部分资源来保存该文件的信息,以后对文件的操作就可以直接引用该部分资源了。文件描述词可以认为是该部分资源的一个索引,在打开文件时返回。在使用fcntl函数对文件的一些属性进行设置时就需要一个文件描述词参数。
  • 缓冲区就是一段内存空间。由 C语言 维护就叫语言级缓冲区,由 OS 维护就叫内核级缓冲区

【*★,°*:.☆( ̄▽ ̄)/$:*.°★* 】那么本篇到此就结束啦,如果我的这篇博客可以给你提供有益的参考和启示,可以三连支持一下 !!

​​​​​​​

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值