【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)

前言:本期介绍文件基础I/O。


目录

  • 1. 文件回顾
    • 1.1 基本概念
    • 1.2 C语言文件操作
      • 1.2.1 概述
      • 1.2.2 实操
      • 1.2.3 OS接口open的使用(比特位标记)
      • 1.2.4 写入操作
      • 1.2.5 追加操作
      • 1.2.6 只读操作
  • 2. 文件的进一步理解
    • 2.1 提出问题
    • 2.2 文件描述符fd
  • 3. 文件fd的分配规则
  • 4. 重定向
    • 4.1 什么是重定向
    • 4.2 dup2(系统调用的重定向)
      • 4.2.1 追加重定向
      • 4.2.2 输入重定向
      • 4.2.3 模拟实现
  • 5. 如何理解Linux一切皆文件
  • 6. 缓冲区
    • 6.1 C接口打印两次的现象
    • 6.2 理解缓冲区问题
      • 6.2.1 为什么要有缓冲区
      • 6.2.2 缓冲区刷新的策略
      • 6.2.3 缓冲区在哪里
    • 6.3 解释打印两次的现象
    • 6.4 模拟实现
    • 6.5 缓冲区与OS的关系

1. 文件回顾

1.1 基本概念

  1. 空文件也要在磁盘占据空间
  2. 文件 = 内容 + 属性
  3. 文件操作 = 对内容+对属性+对内容和属性的操作
  4. 标定一个问题,必须使用:文件路径+文件名【唯一性】
  5. 如果没有指明对应的文件路径,默认是在当前路径(进程当前路径)进行文件访问
  6. 当我们把fopen、fclose、fread、fwrite等接口写完之后,代码编译之后,形成二进制可执行程序之后,但是没有运行,文件对应的操作有没有被执行呢?没有,对文件的操作的本质是进程对文件的操作,因此没有运行不存在进程,所以不会被执行。
  7. 一个文件要被访问,就必须先被打开!(打开的方式:用户进程+OS)

那是不是所有磁盘的文件都被打开呢?显然不是这样!因此我们可以将文件划分成两种:a.被打开的文件;b.没有被打开的文件 。对于文件操作,一定是被打开的文件才能进行操作,本篇文章只会讲解被打开的文件。

文件操作的本质:进程和被打开文件的关系

1.2 C语言文件操作

1.2.1 概述

  1. 语言(如C++、Java、Python)都有文件操作接口,他们实际上的底层都是相同的函数接口,因为都需要通过OS调用。
  2. 文件在磁盘上,磁盘是硬件,只有操作系统有资格访问,所有人想访问磁盘都不能绕过操作系统,必须使用操作系统调用的接口,即OS会提供文件级别的系统调用接口。
  3. 所以上层语言无论如何变化,库函数底层必须调用系统调用接口。
  4. 库函数可以千变万化,但是底层不变,因此这样能够降低学习成本学习不变的东西。

1.2.2 实操

下面fp按顺序对应以下三个操作依次:写入文件、打印文本信息、追加文本信息到文件中。

#include 
#include 
#include 

int main()
{
	// 没有指明路径就是在当前路径下创建
	// r,w, r+(读写,不存在出错),w+(读写, 不存在创建), a(append, 追加), a+()
	// FILE* fp = fopen(FILE_NAME, "w"); 
	// FILE *fp = fopen(FILE_NAME, "r");
	FILE *fp = fopen(FILE_NAME, "a"); 
	// if(NULL == fp)
	//    {
	//        perror("fopen"); 
	//        return 1;
	//    }
	//
	// char buffer[64];
	// while(fgets(buffer, sizeof(buffer) - 1, fp) != NULL)
	// {
	//     buffer[strlen(buffer) - 1] = 0;	// 消除\n
	//     puts(buffer);
	// }

    int cnt = 5;
    while(cnt)
    {
        fprintf(fp, "%s:%d\n", "hello world", cnt--);
    }

    fclose(fp);
	return 0;
}

注:以w方式单纯的打开文件,c会自动清空内部的数据。

对于C语言调用的fopen打开文件,实际上底层调用的是操作系统的接口open,其他语言也是这样,只不过语言级别的接口是多了一些特性,接下来就看看手册内容:
【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第1张图片

对于flag标记位,一般来说对于C语言,一个int类型代表一个标记位,那如果要传10个标记位呢?对于整型来说,实际上有32个比特位,那是不是可以将每一个比特位赋予特定的含义,通过比特位传递选项,从而实现对应的标记呢?一定是可以的。因此在介绍open函数之前,先来介绍一下标记位的实现:

注意:一个比特位一个选项,不能重复。(标记位传参)

#include 
#include 
#include 
// 每一个宏,对应的数值,只有一个比特位是1,彼此位置不重叠
#define ONE (1<<0)	// 0x1
#define TWO (1<<1)	// 0x2
#define THREE  (1<<2)	// 0x4
#define FOUR (1<<3)	// 0x8

void show(int flags)
{
    if(flags & ONE) printf("one\n");
    if(flags & TWO) printf("two\n");
    if(flags & THREE) printf("three\n");
    if(flags & FOUR) printf("four\n");
}

int main()
{
	show(ONE);
    printf("-----------------------\n");
    show(TWO);
    printf("-----------------------\n");
    show(ONE | TWO);
    printf("-----------------------\n");
    show(ONE | TWO | THREE);
    printf("-----------------------\n");
    show(ONE | TWO | THREE | FOUR);
    printf("-----------------------\n");

}
[hins@VM-12-13-centos file]$ ./myfile
one
-----------------------
two
-----------------------
one
two
-----------------------
one
two
three
-----------------------
one
two
three
four
-----------------------

因此我们再看这个open函数,就明白了是什么含义,就是通过不同的flags,传入不同的标记位,那接下来看看open函数怎么用:

1.2.3 OS接口open的使用(比特位标记)

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

第一个函数是在文件已经存在的基础上使用的,如果不存在源文件,那么就需要用第二个函数,即第二个函数如果文件不存在就会自动创建文件。

#include 
#include 
#include 
#include 
#include 
#include 

// 注意并没有指明路径
#define FILE_NAME "log.txt"

int main()
{
	umask(0);	// 将系统继承给此进程的掩码设置为0,防止影响此进程
	int fd = open(FILE_NAME/*文件路径*/, O_WRONLY/*标记位*/ | O_CREAT, 0666/*权限*/);	// WRONLY:只写
	if (fd < 0)
	{
		perror("open");
		return 1;
	}
	printf("%d\n", fd);
	close(fd);	// close也是系统接口
	return 0;
}
[hins@VM-12-13-centos file]$ ./myfile
3	# 下节会讲这个值
[hins@VM-12-13-centos file]$ ll
total 20
-rw-rw-rw- 1 hins hins    0 Feb 16 21:53 log.txt
# 权限:0666

1.2.4 写入操作

对于C语言来讲,除了打开关闭,还有写入fwrite等函数接口,因此对于OS也存在一个接口:write

【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第2张图片
无论这个buf是什么类别,在OS看来都是二进制!至于这个类别是文本还是图片,都是由语言本身决定的。

int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);	// 将特定的format格式化形成字符串放在str里面
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);

下面是个例子:

int main()
{
	umask(0);	// 将系统继承给此进程的掩码设置为0,防止影响此进程
	int fd = open(FILE_NAME/*文件路径*/, O_WRONLY/*标记位*/ | O_CREAT, 0666/*权限*/);	// WRONLY:只写
	if (fd < 0)
	{
		perror("open");
		return 1;
	}
	int cnt = 5;
	char outBuffer[64];
	while(cnt)
	{
		sprintf(outBuffer, "%s:%d\n", "Hello", cnt--);	// outBuffer相当于写入的缓冲区
		// 以\0作为字符串的结尾,是C语言的规定,与文件无关
		write(fd, outBuffer, strlen(outBuffer)); //向文件中写入string的时候,长度+1是C语言的规定
	}
	printf("%d\n", fd);
	close(fd);	// close也是系统接口
	return 0;
}
[hins@VM-12-13-centos file]$ ./myfile
[hins@VM-12-13-centos file]$ cat log.txt
Hello:5
Hello:4
Hello:3
Hello:2
Hello:1

可以看出,对于C语言中的w,封装了文件接口的标识符:O_WRONLY(写)、O_CREAT(不存在就创建文件)、O_TRUNC(清空文件),以及权限。

1.2.5 追加操作

想要把清空变成追加,只需要将open内部的最后一个清空标识符改成追加的标识符:

int fd = open(FILE_NAME, O_WRONLY | O_CREAT | O_APPEND, 0666);

1.2.6 只读操作

int fd = open(FILE_NAME, O_RDONLY);
int main()
{
	umask(0);	// 将系统继承给此进程的掩码设置为0,防止影响此进程
	int fd = open(FILE_NAME, O_RDONLY);	
	if (fd < 0)
	{
		perror("open");
		return 1;
	}
	char buffer[1024];
    ssize_t num = read(fd, buffer, sizeof(buffer)-1);
    if(num > 0) buffer[num] = 0; // 0, '\0', NULL -> 0
    printf("%s", buffer);
        
	close(fd);	// close也是系统接口
	return 0;
}

小结:

系统调用接口 对应的C语言库函数接口
open fopen
close fclose
write fwrite
read fread
lseek fseek
O_RDONLY //只读打开
O_WRONLY //只写打开
O_RDWR //读写打开
//以上三个常亮,必须且只能指定一个

O_CREAT //若文件不存在则创建文件
O_APPEND //追加写入
// 如果想要多个选项一起,那就使用 | 运算符即可。

对应不同功能的打开方式:

文件使用方式 含义 如果指定的文件不存在
“r”(只读) 为了输入数据,打开一个已经存在的文本文件 出错
“w”(只写) 为了输出数据,打开一个文本文件 建立一个新的文件
“a”(追加) 向文本文件尾添加数据 建立一个新的文件
“rb”(只读) 为了输入数据,打开一个二进制文件 出错
“wb”(只写) 为了输出数据,打开一个二进制文件 建立一个新的文件
“ab”(追加) 向一个二进制文件尾添加数据 出错
“r+”(读写) 为了读和写,打开一个文本文件 出错
“w+”(读写) 为了读和写,建议一个新的文件 建立一个新的文件
“a+”(读写) 打开一个文件,在文件尾进行读写 建立一个新的文件
“rb+”(读写) 为了读和写打开一个二进制文件 出错
“wb+”(读写) 为了读和写,新建一个新的二进制文件 建立一个新的文件
“ab+”(读写) 打开一个二进制文件,在文件尾进行读和写 建立一个新的文件

即库函数接口是封装了系统调用接口的,所有语言的库函数都存在系统调用的影子。

2. 文件的进一步理解

文件操作的本质:进程和被打开文件的关系

2.1 提出问题

进程可以打开多个文件,那是不是意味着系统中一定会存在大量的被打开的文件,被打开的文件要不要被操作系统管理起来呢?答案是一定的。

那么OS如何管理呢? 先描述,再组织。因此操作系统为了管理对应的打开文件,必定要为文件创建对应的内核数据结构标识文件:struct file{}(包含了文件的大部分属性)因此将结构体链式链接,通过找到链表的首地址从而实现对链表内容的增删查改。

#include 
#include 
#include 
#include 
#include 
#include 

// 注意并没有指明路径
#define FILE_NAME(number) "log.txt"#number 
// 将括号内部的值转换成字符串,两个字符串通过#具有自动连接特性

int main()
{
	umask(0);	// 将系统继承给此进程的掩码设置为0,防止影响此进程
	int fd0 = open(FILE_NAME(1), O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd1 = open(FILE_NAME(2), O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd2 = open(FILE_NAME(3), O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd3 = open(FILE_NAME(4), O_WRONLY | O_CREAT | O_APPEND, 0666);
    int fd4 = open(FILE_NAME(5), O_WRONLY | O_CREAT | O_APPEND, 0666);

	printf("fd: %d\n", fd0);
    printf("fd: %d\n", fd1);
    printf("fd: %d\n", fd2);
    printf("fd: %d\n", fd3);
    printf("fd: %d\n", fd4);
    
	close(fd0);
    close(fd1);
    close(fd2);
    close(fd3);
    close(fd4);
    
}

创建多个文件并打印其返回值:

[hins@VM-12-13-centos file]$ ./myfile
fd: 3
fd: 4
fd: 5
fd: 6
fd: 7
[hins@VM-12-13-centos file]$ ll
total 24
-rw-rw-rw- 1 hins hins    0 Feb 17 11:47 log.txt1
-rw-rw-rw- 1 hins hins    0 Feb 17 11:47 log.txt2
-rw-rw-rw- 1 hins hins    0 Feb 17 11:47 log.txt3
-rw-rw-rw- 1 hins hins    0 Feb 17 11:47 log.txt4
-rw-rw-rw- 1 hins hins    0 Feb 17 11:47 log.txt5
-rw-rw-r-- 1 hins hins   64 Feb 16 21:25 Makefile
-rwxrwxr-x 1 hins hins 8512 Feb 17 11:47 myfile
-rw-rw-r-- 1 hins hins 2295 Feb 17 11:46 myfile.c

现在就有个问题:为什么从3开始连续变化呢?

首先我们需要了解三个标准的输入输出流:stdin(键盘),stdout(显示器),stderr(显示器)

FILE* fp = fopen();

这个FILE实际上是一个结构体,而对于上面的三个输入输出流,实际上也是FILE的结构体:

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

对于这个结构体必有一个字段–>文件描述符,下面就看一下这个文件描述符的值是什么:

2.2 文件描述符fd

通过对open函数的学习,我们知道了文件描述符就是一个小整数,即open的返回值。

int main()
{
    printf("stdin->fd: %d\n", stdin->_fileno);
    printf("stdout->fd: %d\n", stdout->_fileno);
    printf("stderr->fd: %d\n", stderr->_fileno);
    ......
[hins@VM-12-13-centos file]$ ./myfile
stdin->fd: 0
stdout->fd: 1
stderr->fd: 2
fd: 3
fd: 4
fd: 5
fd: 6
fd: 7

因此这也就解释了为什么文件描述符默认是从3开始的,因为0,1,2默认被占用。我们的C语言的这批接口封装了系统的默认调用接口。同时C语言的FILE结构体也封装了系统的文件描述符。

那为什么是0,1,2,3,4,5……呢?下面就来解释:
【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第3张图片
PCB中包含一个files指针,它指向一个属于进程和文件对应关系的一个结构体:struct files_struct,而这个结构体里面包含了一个数组叫做struct file* fd _array[]的指针数组,因此如图前三个0、1、2被键盘和显示器调用,这也就是为什么之后的文件描述符是从3开始的,然后将文件的地址填入到3号文件描述符里,此时3号文件描述符就指向这个新打开的文件了。

再把3号描述符通过系统调用给用户返回,所以在一个进程访问文件时,需要传入3,通过系统调用找到对应的文件描述符表,从而通过存储的地址找到对应的文件,文件找到了,就可以对文件进行操作了。因此文件描述符的本质就是数组下标

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

3. 文件fd的分配规则

#include 
#include 
#include 
#include 
int main()
{
    int fd = open("myfile", O_RDONLY);
    if(fd < 0){
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    close(fd);
    return 0;
}

输出发现是fd: 3

关闭0或2,再看

#include 
#include 
#include 
#include 
int main()
{
    close(0);	// fd: 0
    //close(2);	// fd: 2
    int fd = open("myfile", O_RDONLY);
    if(fd < 0){
        perror("open");
        return 1;
    }
    printf("fd: %d\n", fd);
    close(fd);
    return 0;
}

【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第4张图片

因此,我们知道了,文件fd的分配规则就是将这个array数组从小到大,按照循序寻找最小的且没有被占用的fd,这就是fd的分配规则。

4. 重定向

4.1 什么是重定向

对于上面的例子,我们关闭了文件描述符0和2对应的文件吗,那么如果关闭1呢?

#include 
#include 
#include 
#include 
#include 
int main()
{
	umask(0);
    close(1);
    int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
    if(fd < 0){
        perror("open");
        return 1;
    }
    printf("open fd: %d\n", fd);	// 往stdout输出
    fprintf(stdout, "fd :%d\n", fd);// 与上面打印是一样的功能
    close(fd);
    return 0;
}

运行发现什么都没输出,这时我们刷新一下缓冲区,发现输出到文件上了。

fflush(stdout);

明明应该输出到显示器上,为什么会变成文件呢?
【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第5张图片
如图,可以看到由于文件描述符1所连接的stdout被关闭,文件的fd发现1的位置是空着的,于是将这个新创建的文件log.txt与对应的指针进行连接:

重定向的本质:上层用的fd不变,在内核中更改fd对应的struct file*的地址

常见的重定向有:>(输入), >>(追加), <(输出)。

4.2 dup2(系统调用的重定向)

int dup2(int oldfd, int newfd);
// newfd的内容最终会被oldfd指向的内容覆盖

dup2的返回值也就是fd的文件描述符,失败返回-1

接下来修改一下刚刚的代码

...
// close(1);
dup2(fd, 1);
...

cat log.txt,输出:
open fd: 3
open fd: 3

可以发现,这样操作简化了刚才的操作,另外,fd的值也不会被改变。

输出重定向演示完了,那我们就可以实现我们刚才提到的三个重定向剩下的追加、输入重定向了。

4.2.1 追加重定向

int main()
{
    umask(0);
    int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
    if(fd < 0){
        perror("open");
        return 1;
    }
    dup2(fd, 1);
    
    printf("open fd: %d\n", fd);	// 往stdout输出
    fprintf(stdout, "fd :%d\n", fd);// 与上面打印是一样的功能
    
    const char *msg= "hello world";
    write(1, msg, strlen(msg));

    fflush(stdout);
    close(fd);
    return 0;
}

cat log.txt,输出:
open fd: 3
open fd: 3
hello world

4.2.2 输入重定向

int main()
{
    umask(0);
    int fd = open("log.txt", O_RDONLY);
    if(fd < 0){
        perror("open");
        return 1;
    }
    char line[64];

    while(1)
    {
        printf("> "); 
        if(fgets(line, sizeof(line), stdin) == NULL) break; //stdin->0
        printf("%s", line);
    }

    fflush(stdout);
    close(fd);
    return 0;
}
[hins@VM-12-13-centos file]$ ./myfile
>hello
hello
>world
world

注:在Linux中,Ctrl+D表示文件结尾。

上面是从键盘中读取,如果不想从键盘读,我们可以重定向到向指定文件中读取:

dup2(fd, 0);	// 输入重定向
[hins@VM-12-13-centos file]$ ./myfile
open fd: 3
open fd: 3
hello world

4.2.3 模拟实现

接下来,我们完善上期的myshell.c,把这三个重定向加上,完整版本如下:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define NUM 1024
#define OPT_NUM 64

#define NONE_REDIR   0
#define INPUT_REDIR  1
#define OUTPUT_REDIR 2
#define APPEND_REDIR  3

#define trimSpace(start) do{\
            while(isspace(*start)) ++start;\
        }while(0)

char lineCommand[NUM];
char *myargv[OPT_NUM]; //指针数组
int  lastCode = 0;
int  lastSig = 0;

int redirType = NONE_REDIR;
char *redirFile = NULL;

// "ls -a -l -i > myfile.txt" -> "ls -a -l -i" "myfile.txt" ->
void commandCheck(char *commands)
{
    assert(commands);
    char *start = commands;
    char *end = commands + strlen(commands);

    while(start < end)
    {
        if(*start == '>')
        {
            *start = '\0';
            start++;
            if(*start == '>')
            {
                // "ls -a >> file.log"
                redirType = APPEND_REDIR;
                start++;
            }
            else
            {
                // "ls -a >    file.log"
                redirType = OUTPUT_REDIR;
            }
            trimSpace(start);	// 过滤空格
            redirFile = start;
            break;
        }
        else if(*start == '<')
        {
            //"cat <      file.txt"
            *start = '\0';
            start++;
            trimSpace(start);
            // 填写重定向信息
            redirType = INPUT_REDIR;
            redirFile = start;
            break;
        }
        else
        {
            start++;
        }
    }
}


int main()
{
    while(1)
    {
        redirType = NONE_REDIR;
        redirFile = NULL;
        errno = 0;

        // 输出提示符
        printf("用户名@主机名 当前路径# ");
        fflush(stdout);

        // 获取用户输入, 输入的时候,输入\n
        char *s = fgets(lineCommand, sizeof(lineCommand)-1, stdin);
        assert(s != NULL);
        (void)s;
        // 清除最后一个\n , abcd\n
        lineCommand[strlen(lineCommand)-1] = 0; // ?
        //printf("test : %s\n", lineCommand);
        
        // "ls -a -l -i" -> "ls" "-a" "-l" "-i" -> 1->n
        // "ls -a -l -i > myfile.txt" -> "ls -a -l -i" "myfile.txt" ->
        // "ls -a -l -i >> myfile.txt" -> "ls -a -l -i" "myfile.txt" ->
        // "cat < myfile.txt" -> "cat" "myfile.txt" ->
        commandCheck(lineCommand);
        // 字符串切割
        myargv[0] = strtok(lineCommand, " ");
        int i = 1;
        if(myargv[0] != NULL && strcmp(myargv[0], "ls") == 0)
        {
            myargv[i++] = (char*)"--color=auto";
        }

        // 如果没有子串了,strtok->NULL, myargv[end] = NULL
        while(myargv[i++] = strtok(NULL, " "));

        // 如果是cd命令,不需要创建子进程,让shell自己执行对应的命令,本质就是执行系统接口
        // 像这种不需要让我们的子进程来执行,而是让shell自己执行的命令 --- 内建/内置命令
        if(myargv[0] != NULL && strcmp(myargv[0], "cd") == 0)
        {
            if(myargv[1] != NULL) chdir(myargv[1]);
            continue;
        }
        if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0], "echo") == 0)
        {
            if(strcmp(myargv[1], "$?") == 0)
            {
                printf("%d, %d\n", lastCode, lastSig);
            }
            else
            {
                printf("%s\n", myargv[1]);
            }
            continue;
        }
        // 测试是否成功, 条件编译
#ifdef DEBUG
        for(int i = 0 ; myargv[i]; i++)
        {
            printf("myargv[%d]: %s\n", i, myargv[i]);
        }
#endif
        // 内建命令 --> echo

        // 执行命令
        pid_t id = fork();
        assert(id != -1);

        if(id == 0)
        {
            // 因为命令是子进程执行的,真正重定向的工作一定要是子进程来完成
            // 如何重定向,是父进程要给子进程提供信息的
            // 这里重定向会影响父进程吗?不会,进程具有独立性
            switch(redirType)
            {
                case NONE_REDIR:
                    // 什么都不做
                    break;
                case INPUT_REDIR:
                    {
                        int fd = open(redirFile, O_RDONLY);
                        if(fd < 0){
                            perror("open");
                            exit(errno);
                        }
                        // 重定向的文件已经成功打开了
                        dup2(fd, 0);
                    }
                    break;
                case OUTPUT_REDIR:
                case APPEND_REDIR:
                    {
                        umask(0);
                        int flags = O_WRONLY | O_CREAT;
                        if(redirType == APPEND_REDIR) flags |= O_APPEND;
                        else flags |= O_TRUNC;
                        int fd = open(redirFile, flags, 0666);
                        if(fd < 0)
                        {
                            perror("open");
                            exit(errno);
                        }
                        dup2(fd, 1);
                    }
                    break;
                default:
                    printf("bug?\n");
                    break;
            }

            execvp(myargv[0], myargv); // 执行程序替换的时候,会不会影响曾经进程打开的重定向的文件?不会
            exit(1);
        }
        int status = 0;
        pid_t ret = waitpid(id, &status, 0);
        assert(ret > 0);
        (void)ret;
        lastCode = ((status>>8) & 0xFF);
        lastSig = (status & 0x7F);
    }
}

注:文件是共享的,不会因为进程不同而权限不同,因为文件是磁盘上的,与进程之间是独立的。即当子进程被创建并且发生写时拷贝时,原来的文件并不会再次被拷贝一次。

5. 如何理解Linux一切皆文件

【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第6张图片
我们利用虚拟文件系统就可以摒弃掉底层设备之间的差别,统一使用文件接口的方式进行文件操作。

【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第7张图片

文件的引用计数: Linux文件引用计数的逻辑

6. 缓冲区

6.1 C接口打印两次的现象

#include  
#include  
#include
int main()                              
{                                       
    //C接口                             
    printf("hello printf");              
    fprintf(stdout, "hello fprintf\n");         
    const char* fputsString = "hello fputs\n";  
    fputs(fputsString, stdout);         

    //系统接口                              
    const char* wstring = "hello write\n";  
    write(1, wstring, strlen(wstring));  
    return 0;                                                                  
}            
[hins@VM-12-13-centos buffer]$ ./myfile
hello printf
hello fprintf
hello fputs
hello write

当在代码最后添加一个fork()后:

[hins@VM-12-13-centos buffer]$ ./myfile > log.txt
[hins@VM-12-13-centos buffer]$ cat log.txt
hello write
hello printf
hello fprintf
hello fputs
hello printf
hello fprintf
hello fputs

直接运行仍是正常的现象,但当重定向到log.txt中,C接口的打印了两次,这是什么原因呢?

6.2 理解缓冲区问题

缓冲区本质就是一段内存

6.2.1 为什么要有缓冲区

下面有个场景:
小陈和一个网友相聊甚欢,一天小陈想给对方一份特产,但是小陈人在广州,对方在四川,如果小陈亲自去送,会占用小陈大量的时间,而且也不现实,所以为了不占用小陈自己的时间,就把包裹送到快递公司让其送到对方手里。

现实生活中,快递行业的意义就是节省发送者的时间,而对于这个例子来说,广州就相当于内存,发送者小陈相当于进程,包裹就是进程需要发送的数据,四川就相当于磁盘,对方就是磁盘上的文件,那么可以看成这样:
【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第8张图片
在冯诺依曼体系中,我们知道内存直接访问磁盘这些外设的速度是相对较慢的,即正如我们所举的例子一样,小陈亲自送包裹会占用他大量的时间,因此顺丰同样属于内存中开辟的一段空间,将我们在内存中已有的数据拷贝到这段空间中,拷贝函数就直接返回了,即小陈接收到顺丰的通知就离开了。在执行你的代码期间,顺丰对应的内存空间的数据也就是包裹就会不断的发送给对方,即发送给磁盘。而这个过程中,顺丰这块开辟的空间就相当于缓冲区

那么缓冲区的意义是什么呢?——节省进程进行数据IO的时间。

在上述的过程中,拷贝是什么,我们在fwrite的时候没有拷贝啊?因此我们需要重新理解fwrite这个函数,与其理解fwrite是写入到文件的函数,倒不如理解fwrite是拷贝函数,将数据从进程拷贝到“缓冲区”或者外设中!

那我们送的包裹何时会发送出去呢?即我们的数据什么时候会到磁盘中呢?这就涉及到缓冲区刷新策略的问题:

6.2.2 缓冲区刷新的策略

还是上面的情景,小陈的包裹送到了顺丰,但是当小陈再次来到顺丰邮寄另一个包裹时,发现之前的包裹还在那里放着,于是小陈感到不满,而工作人员此时解释道:我们的快递是通过飞机运的,如果只送你这一件包裹,路费都不够!因此可以看出,快递不是即送即发,也就是说数据不是直接次写入外设的

那么如果有一块数据,一次写入到外设,还是少量多次的效率高呢?

一定是一次写入最高。一块数据写入到外设,需要外设准备,如果多次写入外设,每一次外设进行的准备都会占用时间,而积攒到一定程度一次发送到外设,外设的准备次数就会大幅减少,效率也会提高。因此,为了在不同设备的效率都是最合适的,缓冲区一定会结合具体的设备,定制自己的刷新策略:

  1. 立即刷新,无缓冲
  2. 行刷新,行缓冲(显示器)\n就会刷新,比如_exit和exit
  3. 缓冲区满,全缓冲 (磁盘文件)

当然还有两种特殊情况

  1. 用户强制刷新:fflush
  2. 进程退出 ——>进程退出都要进行缓冲区刷新

6.2.3 缓冲区在哪里

文章开始时我们提到了C语言接口打印两次的现象,毫无疑问,我们能够从中获得以下信息:

  1. 这种现象一定和缓冲区有关
  2. 缓冲区一定不在内核中(如果在内核中,write也应该打印两次)

因此我们之前谈论的所有的缓冲区,都指的是用户级语言层面给我们提供的缓冲区。这个缓冲区在stdout,stdin,stderr->FILE* ,FILE作为结构体,其不仅包括fd,缓冲区也在这个结构体中。所以我们自己要强制刷新的时候,fflush传入的一定是文件指针,fclose也是如此,即:fflush(文件指针),fclose(文件指针)

通过查看:vim /usr/include/libio.h

typedef struct _IO_FILE FILE;/usr/include/stdio.h

【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第9张图片
因此我们所调用的fscanf,fprintf,fclose等C语言的文件函数,传入文件指针时,都会把相应的数据拷贝到文件指针指向的文件结构体中的缓冲区中。

即缓冲区也可以看做是一块内存,对于内存的申请:无非就是malloc new出来的。

小结:

缓冲区(语言级别)是用户申请的(底层通过malloc/new);缓冲区属于FILE结构体;为了节省进程进行IO的时间

6.3 解释打印两次的现象

有了缓冲区的理解,现在就足以解释打印两次的现象:

由于代码结束之前,进行创建子进程:

  1. 如果我们不进行重定向,看到四条消息
    stdout默认使用的是行刷新,在进程进行fork之前,三条C函数已经将数据进行打印输出到显示器上(外设),也就是说FILE内部的缓冲区不存在对应的数据。

  2. 如果进行了重定向>,写入的就不是显示器而是普通文件,采用的刷新策略是全缓冲,之前的三条C显示函数,虽然带了\n,但是不足以将stdout缓冲区写满!数据并没有被刷新,而在fork的时候,stdout属于父进程,创建子进程时,紧接着就是进程退出!无论谁先退出,都一定会进行缓冲区的刷新(就是修改缓冲区)一旦修改,由于进程具有独立性,因此会发生写时拷贝,因此数据最终会打印两份。

  3. write函数为什么没有呢?因为上述的过程都与write无关,write没有FILE,用的是fd,没有C对应的缓冲区。

因此如上就是对于现象的解释。

6.4 模拟实现

那缓冲区和OS有什么关系呢?下面就通过写一个demo实现一下行刷新:touch myStdio.h;touch myStdio.c;touchmain.c

# Makefile
main:main.c myStdio.c
	gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
	rm -f main
// myStdio.h
#pragma once
#include
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

#define SIZE 1024
#define SYNC_NOW    1
#define SYNC_LINE   2
#define SYNC_FULL   4

typedef struct _FILE{
    int flags; //刷新方式
    int fileno;
    int cap; //buffer的总容量
    int size; //buffer当前的使用量
    char buffer[SIZE];
}FILE_;


FILE_ *fopen_(const char *path_name, const char *mode);
void fwrite_(const void *ptr, int num, FILE_ *fp);
void fclose_(FILE_ * fp);
void fflush_(FILE_ *fp);
// myStdio.c
#include "myStdio.h"

FILE_ *fopen_(const char *path_name, const char *mode)
{
    int flags = 0;
    int defaultMode=0666;

    if(strcmp(mode, "r") == 0)
    {
        flags |= O_RDONLY;
    }
    else if(strcmp(mode, "w") == 0)
    {
        flags |= (O_WRONLY | O_CREAT |O_TRUNC);
    }
    else if(strcmp(mode, "a") == 0)
    {
        flags |= (O_WRONLY | O_CREAT |O_APPEND);
    }
    else
    {
        //TODO
    }
    int fd = 0;

    if(flags & O_RDONLY) fd = open(path_name, flags);
    else fd = open(path_name, flags, defaultMode);
    if(fd < 0)
    {
        const char *err = strerror(errno);
        write(2, err, strlen(err));
        return NULL; // 为什么打开文件失败会返回NULL
    }
    FILE_ *fp = (FILE_*)malloc(sizeof(FILE_));
    assert(fp);

    fp->flags = SYNC_LINE; //默认设置成为行刷新
    fp->fileno = fd;
    fp->cap = SIZE;
    fp->size = 0;
    memset(fp->buffer, 0 , SIZE);

    return fp; // 为什么你们打开一个文件,就会返回一个FILE *指针
}

void fwrite_(const void *ptr, int num, FILE_ *fp)
{
    // 1. 写入到缓冲区中
    memcpy(fp->buffer+fp->size, ptr, num); //这里我们不考虑缓冲区溢出的问题
    fp->size += num;

    // 2. 判断是否刷新
    if(fp->flags & SYNC_NOW)
    {
        write(fp->fileno, fp->buffer, fp->size);
        fp->size = 0; //清空缓冲区
    }
    else if(fp->flags & SYNC_FULL)
    {
        if(fp->size == fp->cap)
        {
            write(fp->fileno, fp->buffer, fp->size);
            fp->size = 0;
        }
    }
    else if(fp->flags & SYNC_LINE)
    {
        if(fp->buffer[fp->size-1] == '\n') // abcd\nefg , 不考虑
        {
            write(fp->fileno, fp->buffer, fp->size);
            fp->size = 0;
        }
    }
    else{

    }
}

void fflush_(FILE_ *fp)
{
    if( fp->size > 0) write(fp->fileno, fp->buffer, fp->size);
    fsync(fp->fileno); //将数据,强制要求OS进行外设刷新!
    fp->size = 0;
}

void fclose_(FILE_ * fp)
{
    fflush_(fp);
    close(fp->fileno);
}
// main.c
#include "myStdio.h"

int main()
{
    FILE_ *fp = fopen_("./log.txt", "w");
    if(fp == NULL)
    {
        return 1;
    }
    int cnt = 10;
    const char *msg = "hello world ";
    while(1)
    {
        fwrite_(msg, strlen(msg), fp);
        fflush_(fp);
        sleep(1);
        printf("count: %d\n", cnt);
        //if(cnt == 5) fflush_(fp);
        cnt--;
        if(cnt == 0) break;
    }
    fclose_(fp);

    return 0;
}

6.5 缓冲区与OS的关系

【Linux详解】——文件基础(I/O、文件描述符、重定向、缓冲区)_第10张图片

我们所写入到磁盘的数据hello world是按照行刷新进行写入的,但并不是直接写入到磁盘中,而是先写到操作系统内的文件所对应的缓冲区里,对于操作系统中的file结构体,除了一些接口之外还有一段内核缓冲区,而我们的数据则通过file结构体与文件描述符对应,再写到内核缓冲区里面,最后由操作系统刷新到磁盘中,而刷新的这个过程是由操作系统自主决定的,而不是我们刚才所讨论的一些行缓冲、全缓冲、无缓冲……,因为我们提到的这些缓冲是在应用层C语言基础之上FILE结构体的刷新策略,而对于操作系统自主刷新策略则比我们提到的策略复杂的多(涉及到内存管理),因为操作系统需要考虑自己的存储情况而定,因此数据从操作系统写到外设的过程和用户毫无关系。

所以一段数据被写到硬件上(外设)需要进行这么长的周期:首先通过用户写入的数据进入到FILE对应的缓冲区,这是用户语言层面的,然后通过我们提到的刷新的策略刷新到由操作系统中struct file*的文件描述符引导写到操作系统中的内核缓冲区,最后通过操作系统自主决定的刷新策略写入到外设中。如果OS宕机了,那么数据就有可能出现丢失,因此如果我们想及时的将数据刷新到外设,就需要一些其他的接口强制让OS刷新到外设,即一个新的接口:int fsync(int fd),调用这个函数之后就可以立即将内核缓冲区的数据刷新到外设中,就比如我们常用的快捷键:ctrl + s

总结:

因此以上我们所提到的缓冲区有两种:用户缓冲区和内核缓冲区,用户缓冲区就是语言级别的缓冲区,对于C语言来说,用户缓冲区就在FILE结构体中,其他的语言也类似;而内核缓冲区属于操作系统层面,他的刷新策略是按照OS的实际情况进行刷新的,与用户层面无关。


OK,以上就是本期知识点“文件基础”的知识啦~~ ,感谢友友们的阅读。后续还会继续更新,欢迎持续关注哟~
如果有错误❌,欢迎批评指正呀~让我们一起相互进步
如果觉得收获满满,可以点点赞支持一下哟~

❗ 转载请注明出处
作者:HinsCoder
博客链接: 作者博客主页

你可能感兴趣的:(Linux详解,linux,运维,服务器,学习,经验分享)