Linux基础IO

文件概念

文件磁盘里,磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的。磁盘属于计算机外围设备(外设),同时兼具输入设备与输出设备的功能。针对磁盘文件开展的所有读写等操作,本质都是对磁盘这个外设进行输入、输出交互,这类交互操作简称为IO操作

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

从系统层面看,所有文件操作的执行主体都是进程,是进程发起了针对文件的各类操作。磁盘这类硬件存储设备,由操作系统全权负责管理。C/C++ 语言的文件库函数并非文件读写的底层实现,它们只是面向用户的便捷封装接口;文件读写的真正底层实现,依赖和文件相关的操作系统的系统调用接口来完成。

系统文件IO

man 2 open 打开手册,open是打开文件的系统调用。

在这里插入图片描述

参数pathname为打开文件路径,参数flags为打开文件操作,参数mode为文权限。

在这里插入图片描述

open的返回值是文件描述符,如果出现错误返回-1并设置错误码。

O_RDONLYO_WRONLYO_RDWRO_APPENDO_CREATO_TRUNC 是打开文件操作的常用选项,这些本质是宏。flags采用的是位图,通过位图的标志位选项来表示这些选项,节省参数。

下面代码,用按位或叠加多个标志位,用按位与判断某个标志位是否开启。

在这里插入图片描述

使用如下代码,调用open,创建并打开文件选项。可以看到log.txt的权限是乱的。

在这里插入图片描述

如果要新建文件,就必须把权限带上。把参数mode设置成八进制数0666,再结合系统权限掩码umask的处理,得到如下权限的log.txt。

在这里插入图片描述

在代码中也可以使用系统调用umask,此时mode与新设定的umask进行运算得出新建文件的权限。如umask(0),新建log.txt的权限如下。

在这里插入图片描述

close 传入文件描述符,关闭文件。

在这里插入图片描述

write 是系统调用,往文件中写入内容。

在这里插入图片描述
在这里插入图片描述

不需要 strlen(msg) + 1 ,字符串末尾带\0是编程语言的规定,在此不必考虑。

把字符串改为 msg = "XXX" ,并将cnt改为1,可以看到write是不会清空log.txt,而是直接覆盖写入。

在这里插入图片描述

这与C语言的文件操作是不一样的,如果我们想要让write清空后写入,调用open时需要加选项 O_TRUNC

在这里插入图片描述

去掉 O_TRUNC ,增加选项 O_APPEND ,实现追加写入。

在这里插入图片描述

write的参数buf的类型是const void*,这意味着可以进行文本写入和二进制写入。

数字 12345678 在内存里是机器二进制存储,不是人类可读的文字。文本编辑器(vim / 记事本)只会把每一个字节当成ASCII 字符解析,很多字节不在可见字符范围,就会出现乱码。

在这里插入图片描述

如下代码能正确写入12345678。

在这里插入图片描述

read 从文件中读数据,如果读取成功返回被读取的字节数,失败返回-1,读到文件末尾时返回0。

在这里插入图片描述

使用示例如下。

在这里插入图片描述

读文件不需要新建等,只用选项 O_RDONLY 即可。从log.txt中读取数据,把数据读到缓冲区buf中,一次读取sizeof(buf)-1个字节。要提前预留一个位置放置\0,使printf能正常打印。

调用open打开4个文件,观察文件描述符fd。

在这里插入图片描述

可以看到fd的值是3、4、5、6,0、1、2是默认打开的标准输入、标准输出、标准错误。系统接口层面只认文件描述符,所以C语言的FILE结构体必然封装了文件描述符。stdin、stdout、stderr是C语言的标准输入流、标准输出流、标准错误流,都是FILE*类型,stdin -> _fileno 可以查看对应的文件描述符。

在这里插入图片描述

编程语言为何要封装独立的文件操作接口?不同操作系统提供的底层文件操作系统调用互不兼容,以 C 语言标准库为例:标准库内部预实现了 Linux、Windows 等多操作系统的适配逻辑;程序在不同平台编译运行时,libc通过条件编译机制,仅保留当前系统对应的底层实现,自动屏蔽其他平台的不兼容代码;开发者统一调用 C 语言标准文件 API,库函数内部自动转发适配对应操作系统原生接口;对上层开发者屏蔽底层系统差异,大幅降低跨平台开发成本,赋予代码可移植性

在这里插入图片描述

磁盘中存在文件 log.txt,每调用一次 open() 打开文件,内核就会创建一个独立的 struct file 对象,所有 struct file 通过链表串联统一管理。

  1. 进程控制块 task_struct 内持有指针 struct files_struct *files,指向进程专属文件管理对象;
  2. struct files_struct 内部维护文件描述符表 struct file *fd_array[],本质是指针数组;
  3. 数组下标就是程序中使用的文件描述符 fd,数组内每个指针都对应一个 struct file 对象;
  4. struct file 存储单次打开文件的全部上下文:权限、当前读写偏移、内核文件缓冲区、文件操作函数集、链表指针等。

基于冯・诺依曼硬件架构,磁盘属于外部 IO 硬件,用户程序无权直接操作硬件;对文件的一切读写操作,数据都必须先载入内核缓冲区

  1. 调用 open("log.txt",...):内核新建 struct file,将磁盘 log.txt 的数据按需加载到该对象绑定的内核文件缓冲区
  2. 调用 read(fd, buffer, size):内核根据 fd 作为下标检索文件描述符表,找到对应 struct file,再将内核缓冲区的数据拷贝到用户自定义 buffer
  3. 文件写入、修改操作逻辑同理,所有磁盘 IO 都以内核缓冲区作为中间载体。

文件描述符的分配规则:在数组fd_array当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符

在这里插入图片描述

如图,关闭文件描述符为0的文件,此时fd_array的0下标就空出来了,打开文件log.txt,可以看到它的文件描述符为0。

清理文件myfile、log.txt。标准输出的文件描述符为1,close(1),可以看到屏幕没有打印出 fd: 1 ,文件log.txt中被写入了 fd: 1

在这里插入图片描述

close(1)后,指针fd_array[1]不再指向标准输出,接着调用open打开log.txt,fd_array[1]指向log.txt。

在这里插入图片描述
在这里插入图片描述

此时 stdout -> _fileno 仍是1,所以printf仍往文件描述符为1的文件打印,所以文件log.txt就被printf写入内容。这就是重定向的原理。重定向即更改文件描述符表的指针指向

dup2 能直接实现上述输出重定向过程。

在这里插入图片描述

把fd_array[oldfd]的指针值赋值到fd_array[newfd]。

在这里插入图片描述

关于打印内容的顺序:程序自上而下执行时,printffprintf 属于 C 标准库 IO,当标准输出 fd=1 指向普通文件时 stdout 为全缓冲模式,输出内容仅暂存在用户态缓冲区,不会即时写入文件;而write是无用户缓冲的原生系统调用,执行瞬间就将数据写入内核缓冲区并推进文件读写偏移,因此它输出的hello world先出现在文件最上方;等到所有代码执行完毕、进程退出前,libc 才自动刷新 stdout 缓冲区,按代码书写顺序把三条打印内容追加到文件后方,最终形成文件内先write内容、后三条 printf 内容的文本顺序。

int fd = open("log.txt",O_CREAT | O_WRONLY | O_APPEND); dup2(fd,1) 即可实现追加重定向。

实现输入重定向。从键盘读变成从log.txt读。

在这里插入图片描述

所以,重定向又可以总结为:打开文件的方式 + dup2 。

在myshell中添加重定向。

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

int redir = NONE_REDIR;
char* filename = NULL;

void Trimspace(char* cmd,int& end)//从end位置开始去空格
{
    while(isspace(cmd[end]))
        end++;
}
void RedirCheck(char* command)
{
    redir = NONE_REDIR;
    filename=NULL;
    int start=0;
    int end=strlen(command)-1;
    while(start<end) // 让end找到 > 、>> 、<
    {
        if(command[end]=='<')
        {
            command[end++]='\0';
            Trimspace(command,end);
            redir=INPUT_REDIR;
            filename=command+end;
            break;
        }
        else if(command[end]=='>')
        {
            if(command[end-1]=='>')
            {
                command[end-1]='\0';
                redir=APPEND_REDIR;
            }
            else
            {
                redir=OUTPUT_REDIR;
            }
            command[end++]='\0';
            Trimspace(command,end);
            filename=command+end;
            break;
        }
        else
            end--;
    }
}

//...

int Execute()
{
    pid_t id = fork();
    if(id<0)
        exit(1);
    else if(id==0)
    {
        int fd=-1;
        if(redir==INPUT_REDIR)
        {
            fd = open(filename,O_RDONLY);
            if(fd<0)
                exit(1);
            dup2(fd,0);
            close(fd);
        }
        else if(redir==OUTPUT_REDIR)
        {   
            fd = open(filename,O_CREAT | O_WRONLY | O_TRUNC,0666);
            if(fd<0)
                exit(1);
            dup2(fd,1);
            close(fd);
        }
        else if(redir==APPEND_REDIR)
        {
            fd = open(filename,O_CREAT | O_WRONLY | O_APPEND,0666);
            if(fd<0)
                exit(1);
            dup2(fd,1);
            close(fd);
        }
        else
        {}
        execvp(g_argv[0],g_argv);
        exit(1);
    }
    int status=0;
    pid_t rid=waitpid(id,&status,0);
    if(rid>0)
    {
        last_exit_code = WEXITSTATUS(status);        
    }
    return 0;
}

//...

int main()
{
    //初始化环境变量表
    InitEnv();
    //PrintG_env();
    while(1)
    {
        //输出命令行提示符
        PrintCommandLinePrompt();

        //获取命令
        char command[COMMAND_SIZE];
        if(!GetCommand(command,sizeof(command)))
            continue;
        
        //重定向分析 "ls -al > file.txt" --- "ls -al" "file.txt" 
        RedirCheck(command);

        //命令解析
        if(!CommandParse(command))
            continue;
        //PrintG_argv();
        //内建命令与别名
        if(CheckAndExecBuilt()) //内建命令返回true,不创建子进程
            continue;

        //执行命令
        Execute();

    }

    return 0;
}

在这里插入图片描述

运行如下stream.cc,输出重定向的写法应该是 ./stream 1 > log.txt ,我们常常省略文件描述符。

在这里插入图片描述

直接运行程序时标准输出流std::coutstdout与标准错误流std::cerrstderr都会打印到终端屏幕,看似输出效果一致,但二者底层绑定了完全不同的文件描述符,标准输出对应fd=1,标准错误固定对应fd=2,并非共用同一个文件描述符;只是默认场景下fd1与fd2都指向终端设备文件,才造成输出混杂在一起的视觉假象,二者数据流相互独立,使用输出重定向>仅能捕获fd1的内容,无法拦截fd2的错误输出。

在这里插入图片描述

./stream 1> log.normal 2> log.error 正常信息和错误信息分别打印到两个文件。fd应该紧跟>

在这里插入图片描述

系统单独提供标准错误流 stderr,是为了实现标准输出与错误信息分流的能力:程序正常运行的打印内容(printfstd::cout)走标准输出 fd=1,程序报错、异常提示(perrorstd::cerr)走标准错误 fd=2;二者相互独立,支持分开重定向保存,便于区分普通日志与故障信息,排查问题时不会混淆正常输出和报错内容。

将正常信息和错误信息打印到同一个文件中。

在这里插入图片描述

理解“一切皆文件”

Linux中,磁盘、显示器、键盘这样硬件设备和管道、socket套接字等被抽象成了文件,可以使用访问文件的方法访问它们获得信息。这样做最明显的好处是,开发者仅需要使用一套 API 和开发工具,即可调取 Linux 系统中绝大部分的资源。如Linux 中几乎所有读(读文件,读系统状态,读PIPE)的操作都可以用 read 函数来进行;几乎所有更改(更改文件,更改系统参数,写 PIPE)的操作都可以用 write 函数来进行。

当打开一个文件时,操作系统为了管理所打开的文件,都会为这个文件创建一个 struct file 结构体。其中的 f_op 指针指向了一个 file_operations 结构体,这个结构体中的成员除了 struct module* owner 其余都是函数指针

struct file_operations 
{
    //指向拥有该模块的指针。
	struct module *owner;
    
    //llseek 用作改变文件中的当前读/写位置, 并且新位置作为(正数)返回值。
    loff_t (*llseek) (struct file *, loff_t, int);
    
    // 用来从设备中获取数据。在这个位置的一个空指针导致 read 系统调用以
    // -EINVAL("Invalid argument") 失败. 一个非负返回值代表了成功读取的字节数(返回值是一个
    // "signed size" 类型, 常常是目标平台本地的整数类型).
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    
    //发送数据给设备. 如果 NULL, -EINVAL 返回给调用 write 系统调用的程序. 如果非负,
	//返回值代表成功写的字节数.
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    
    //...
};

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

在这里插入图片描述

上图中的外设,每个设备都可以有自己的read、write,但一定是对应着不同的操作方法。通过 struct filefile_operation 中的各种函数回调,让开发者只用file便可调取 Linux 系统中绝大部分的资源。

缓冲区

缓冲区是内存空间的一部分。也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。

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

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

在这里插入图片描述
在这里插入图片描述

观察如上代码和运行结果,关闭标准输出,往log.txt中写内容。printf/fprintf/fputs... 等库函数带有C标准库提供的用户级缓冲区,调用这些库函数输出内容时,会把这些内容先放在这个缓冲区中,当强制刷新或满足刷新条件或进程退出时,才会把这些内容刷新出缓冲区。刷新出这个缓冲区需要fd和系统调用,第一份代码调用 close(fd) ,使进程退出前fd失效,所以log.txt只有 hello write 。注释 close(fd) 或调用 fflush(stdout) 强制刷新可以正常打印。

C语言的 FILE 是通过 typedef _IO_FILE FILE ,其中包含了指向这个缓冲区的指针。vim /usr/include/libio.h

在这里插入图片描述

三种缓冲策略:

  1. 全缓冲(普通磁盘文件默认):缓冲区写满才自动刷新,效率最高;
  2. 行缓冲(stdout 终端显示器默认):遇到换行\n、缓冲区满、读输入时刷新;
  3. 无缓冲(stderr 标准错误):数据直接下发内核,实时输出。

对于内核缓冲区,数据进入内核缓冲即代表交付系统,由操作系统自主调度刷入磁盘硬件;无需程序手动管控。

计算机数据流动的本质:一切皆拷贝

前面代码修改,注释 close(fd) ,在进程结束前调用 fork() ,log.txt会记录两次printf的内容。

在这里插入图片描述

用户缓冲区属于进程内存,fork 复制整个进程地址空间(写时拷贝),连带未刷新的缓存一起复制write 系统调用无用户缓冲,数据直接进入内核,不受 fork 复制缓存影响。stdout 绑定普通文件log.txt时是全缓冲,换行不刷新,缓存会留存到 fork;stdout 绑定终端时是行缓冲,换行自动刷空。进程正常退出会自动 fflush 所有标准 IO 缓存,父子进程分别刷新各自副本,造成内容重复。

所以,如果代码不调用 close(1) 关闭标准输出,而是输出重定向 ./test > log.txt ,此时效果和原理同理。

下面实现自定义MyStdio。模拟实现文件打开、写入、刷新、关闭。

创建文件 mystdio.h ,声明结构体、函数。

#pragma once

#include <stdio.h>

//刷新方式
#define NONE_FLUSH (1<<0) //1不刷新
#define LINE_FLUSH (1<<1) //2行缓冲
#define FULL_FLUSH (1<<2) //4全缓冲

#define MAX 1024

typedef struct IO_FILE
{
    int fileno; //文件描述符fd
    int flag;   //文件打开方式
    int flush_method; //刷新方式
    char outbuffer[MAX]; //缓冲区
    int bufferlen; //缓冲区大小
}MyFile;

MyFile* MyFopen(const char* path,const char* mode);

int MyFwrite(MyFile* pf,const void* str,int len);

void MyFFlush(MyFile* pf);

void MyFclose(MyFile* pf);

创建文件 mystdio.c ,实现函数。

MyFopen() 打开文件,参考 fopen ,传入要打开的文件的路径和操作,通过匹配操作来控制flag,然后使用系统调用 open ,最后返回指向打开文件的MyFile指针。

BuyFile() 打开文件后创建一个指针指向MyFile对象,填入打开文件的文件描述符、打开方式等信息。static修饰 BuyFile() 后把创建 MyFile 对象的底层函数私有化,只能在 mystdio.c 内部调用,禁止外部用户随意调用。

MyFwrite() 把要写入的内容拷贝到MyFile的缓冲区,再判断是否需要行刷新。

MyFFlush() 使用系统调用 write 把MyFile缓冲区的内容写入文件,此时这些内容会先进入内核级缓冲区,再使用系统调用 fsync 强制把在内核缓冲区的内容写入磁盘,最终实现刷新。

MyFclose() 模拟 fclose ,fclose关闭文件后会进行刷新,所以要调用 MyFFlush

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

static MyFile* BuyFile(int fd,int flag)
{
    MyFile* file = (MyFile*)malloc(sizeof(MyFile));
    if(file == NULL)
        return NULL;

    file->fileno = fd;
    file->flag = flag;
    file->flush_method = LINE_FLUSH;
    file->bufferlen = 0;
    memset(file->outbuffer,0,sizeof(file->outbuffer));

    return file;
}

MyFile* MyFopen(const char* path,const char* mode)
{
    int fd = -1;
    int flag = 0;
    if(strcmp(mode,"r")==0)
    {
        flag = O_RDONLY;
        fd = open(path,flag,0666);
    }
    else if(strcmp(mode,"w")==0)
    {
        flag = O_CREAT | O_WRONLY | O_TRUNC;
        fd = open(path,flag,0666);
    }
    else if(strcmp(mode,"a")==0)
    {
        flag = O_CREAT | O_WRONLY | O_APPEND;
        fd = open(path,flag);
    }
    else
    {
        //...
    }
    if(fd<0)
        return NULL;
    return BuyFile(fd,flag);
}

int MyFwrite(MyFile* pf,const void* str,int len)
{
    //拷贝到缓冲区
    memcpy(pf->outbuffer + pf->bufferlen,str,len);
    pf->bufferlen+=len;

    //判断是否行刷新
    if((pf->flush_method & LINE_FLUSH) && (pf->outbuffer[pf->bufferlen-1] == '\n'))
        MyFFlush(pf);

    return 0;
}

void MyFFlush(MyFile* pf)
{
    if(pf->bufferlen<=0) //缓冲区无数据
        return;

    write(pf->fileno,pf->outbuffer,pf->bufferlen); //缓冲区数据写入内核缓冲区

    fsync(pf->fileno);
    pf->bufferlen=0;
}

void MyFclose(MyFile* pf)
{
    if(pf->fileno<0)
        return;
    MyFFlush(pf);
    close(pf->fileno);
    free(pf);
}

测试代码。

#include "mystdio.h"
#include <string.h>
#include <unistd.h>

int main()
{
    MyFile* pf = MyFopen("./log.txt","w");
    if(pf==NULL)
    {
        printf("MyFopen error!\n");
        return 1;
    }

    const char* msg = "Hello MyFile!!!";
    int cnt=10;
    while(cnt--)
    {
        MyFwrite(pf,msg,strlen(msg));
        printf("%s\n",pf->outbuffer);
        sleep(1);
    }
    MyFclose(pf);

    return 0;
}
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐