操作系统基础IO

一.理解文件

狭义上理解:

我们电脑中的各种文件,它们储存在磁盘中。即狭义上文件指储存在磁盘中的文件 ;

磁盘是永久性储存介质,因此文件在磁盘上的储存是永久性的;

磁盘是外设(既是输入设备也是输出设备);

对磁盘中文件的操作,本质上都是对外设的输入输出,简称IO。

广义上理解:

Linux下一切皆文件。

文件操作:

文件 = 文件属性 + 文件内容 ,空文件也是会占用磁盘空间的;

所有文件操作本质上是对文件属性或内容进行操作。

系统角度:

对文件的操作是谁执行的?用户吗?

并不是,文件储存在磁盘中,而磁盘的的管理者是操作系统,也就是说对文件的操作是操作系统完成的,进一步讲是对文件的操作本质上是进程对文件的操作 ,我们使用的函数都是系统调用的封装。

进程对文件操作前是否要打开文件?一个进程是否只能打开一个文件呢?如何理解打开文件?

对文件的任何操作都必须先打开文件,且一个进程是可以打开多个文件的,那么让系统知道每个文件对应的进程就是必要的(先描述,再组织),因此系统会对被打开的文件创建结构体变量管理起来。也就是说文件打开的标志就是OS为其创建结构体变量。

二.回顾C语言文件操作接口

fopen和fclose
c 复制代码
FILE *fopen(const char *path, const char *mode);

fopen是C语言中用来打开文件的接口,path是打开文件的路径(默认当前工作目录),mode是打开文件的方式,它有6个选项:

r:只读打开文件,对于不存在的文件会打开失败,对于存在的文件其内容会被保留;

r+:只读写打开文件,写入时从文件起始位置写入,对于不存在的文件会打开失败,对于存在的文件其内容会被保留;

w:只写打开文件,写入时从文件起始位置写入,对于不存在的文件会创建该文件,对于存在的文件其内容会被清空;

w+:只读写打开文件,写入时从文件起始位置写入,对于不存在的文件会创建该文件,对于存在的文件其内容会被清空;

a:只写打开文件,写入时从文件末尾位置写入,对于不存在的文件会创建,会保留存在文件的内容;

a+:只读写打开文件,写入时从文件末尾位置写入,对于不存在的文件会创建,会保留存在文件的内容;

它的返回值是FILE类型(库中定义的结构体)的指针,可以将它传入fclose以关闭该文件:

c 复制代码
int fclose(FILE *fp);
fwrite和fread
c 复制代码
size_t fwrite(const void *ptr, size_t size, size_t nmemb,
                     FILE *stream);

fwrite是让数据以二进制的方式写入文件,而fprintf是让数据以字符串的方式写入。(第三部分会细讲)

ptr:要写入文件中的数据的指针;

size:一次性向文件写入多少字节;

nmemb:向文件写入多少次;

stream:要写入的文件;

值得注意的是,'/0'是字符串的终止符是语言层面规定的,在系统层面它就是普通的字符,所以向文件写入字符串时,系统不会遇到'/0'而结束,所以size和nmemb要控制好。

如果写入成功它会返回写入到文件中的字节数,失败返回0;

c 复制代码
size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);

fread会以读二进制的方式将读取到的内容写入指针ptr,fscanf会以读字符的方式将内容写入字符串。所以fwrite和fread、fprintf和fread最好配套使用。

ptr:接收文件读取到的内容;

size:一次向文件读取多少字节;

nmemb:读取多少次;

stream:读取的文件。

如果读取成功它会返回读取到文件中的字节数,失败返回0;

C语言还有很多其他文件操作相关参数,比如控制文件读写位置的rewind等接口,这里不细究。

三.Linux系统文件I/O

我们写程序就是要进行数据处理,那我们至少得能向文件输入数据,文件至少能向我们输出数据,所以我们执行文件时,系统会默认打开三个文件 :

stdin:标准输入流,对应键盘文件;

stdout:标准输出流,对应显示器文件;

stderr:标准错误流,对应显示器文件;

位图传参

位图传参是向函数传一个参数而达到传多个参数效果 的传参方式,他利用了一个参数有多个bit位 这一特点,位图指的就是参数的二进制形式。

c 复制代码
#include<stdio.h>                                                                                                                                                                                                                    
#define ONE_FLAG 1<<0    
#define TWO_FLAG 1<<1    
#define THERR_FLAG 1<<2    
#define FOUR_FLAG 1<<3    
//让它们保存二进制表达上都只有一个1    
void Test(int f)    
{    
    if(f & ONE_FLAG) //ONE_FLAG位图上为1的位置,f在此处也为1    
        printf("ONE\n");    
    if(f & TWO_FLAG)    
        printf("TWO\n");    
    if(f & THERR_FLAG)    
        printf("THERR\n");    
    if(f & FOUR_FLAG)    
        printf("FOUR\n");    
}    
int main()    
{    
    Test(ONE_FLAG);    
    printf("\n");    
    Test(ONE_FLAG | TWO_FLAG);    
    printf("\n");    
    printf("\n");    
    Test(ONE_FLAG | TWO_FLAG | THERR_FLAG | FOUR_FLAG);    
    return 0;    
}    

运行结果:

文件描述符1

在进程中,文件描述符是用来识别该进程打开的文件的符号,用整形来表示,每个文件的文件描述符都不同。详细会在接下来慢慢展开。

关于文件操作的系统调用

打开文件的系统调用:open;

关闭文件的系统调用:close;

将数据写入文件的系统调用:write;

从文件中读取数据的系统调用:read。

open、close

可以看到这里有两个open函数,但是这是C语言写的,不是函数重载,可能是用了某些技术达到了函数重载的效果。

pathname:要打开文件所在的路径,默认是当前工作目录;

flags :如何打开文件(只读、只写...),该参数用很多选项,这些选项都是在库中定义的宏,而且这个参数的传递方式就是上面说的位图传参;

mode:文件权限,使用open可能会创建新文件,这个参数就是用来设置新文件的权限的,如:0666、0656...,当然权限的最终结果需要与权限掩码作用(设置权限与权限掩码异或)。

flags选项;

O_RDONLY:只读;
O_WRONLY:只写;
O_CREAT:文件不存在就创建文件;
O_TRUNC:打开文件时清空文件内容;
O_APPEND:在文件末尾追加内容。

当然还有其他选项,这里只讲这5个。

如果打开文件成功,open就会返回该文件的文件描述符,失败则返回-1。

C语言库中fopen就是对该函数的封装,其选项r、w等就是以上选项的组合。

c 复制代码
    int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0666);//相当于fopen中的w+
    int fd = open("log.txt",O_CREAT | O_WRONLY | O_APPEND,0666);//a+                                                                                                                                                          
    int fd = open("log.txt",O_RDONLY);//r

当要关闭文件时要用函数close :

向其传递文件描述符就可以关闭该文件描述符对应的文件。

write、read

write:

write是将指针指向的内容写入指定文件的函数。

fd:目标文件的文件描述符;

buf:要写入文件的内容,任何指针类型都可以;

count:要写入文件的字节数。

如果写入成功它将会返回写入的字节数(包括0),写入失败返回-1。

C语言中的printf系列函数和fwrite函数都是对write的封装 ,但我们上面说printf系列函数是以文本形式写入文件,fwrite以二进制形式,而它们都是同一个系统调用的封装,这是怎么回事?

实际上,在系统层面,往文件写入内容只有二进制写入这一种方法(将数据以字节为单位写入),语言层面上的两种方法只是语言定义的,本质上都是以二进制方式写入文件。

read:

将文件中的内容读入buf。

fd:目标文件的文件描述符;

buf:储存文件内容的指针;

count:从文件中读的字节数。

要注意read读到换行符也不会停,要控制好count 。

如果读取成功且读到了文件末尾则返回0,如果没到文件末尾就返回读到的字节数;读取失败返回-1。

C语言中scanf系列函数和fread都是对该函数的封装。

实验

open、write、close:

c 复制代码
int main()                                                                                                                       
{                                                                                                                                
    int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0666);                                                                                                                                                                                                                                                                                                                       
    char put[] = "hello open\n";                                                                                                 
    int count = 5;                                                                                                               
    while(count--)                                                                                                               
    {                                                                                                                            
        write(fd,put,strlen(put));                                                                                               
    }                                                                                                                                                                                                                                                       
    close(fd);                                                                                                                                                                                                                                                    
    return 0;                                                                                                                       
}           

open、read、close:

c 复制代码
int main()    
{      
    int fd = open("log.txt",O_RDONLY);    
    char buffer[64];    
    int n = 0;    
    int count = 0;    
    while(1)    
    {    
        n = read(fd,buffer,sizeof(buffer));    
        if(n <= 0)    
            break;    
        buffer[n]= 0;//read不会自动设置'/0'        
        printf("%s",buffer);    
        count++;    
    }    
    printf("%d\n",count);  //检查read读了多少次                                                                                                                                                                                                          
    close(fd);    
    return 0;    
}    
为什么要封装

操作系统是有很多种的,Linux、windows、macos...每个操作系统内的系统调用都是不一样的,如果代码只用系统调用来写,会导致代码在A操作系统上可以运行而不能在B操作系统上运行。但是如果把每个操作系统的系统掉用都封装在库中,运行代码时再根据当前操作系统把其他操作系统的代码段截掉,就可以实现同一代码在不同操作系统下运行。

也就是说,对系统调用的封装是为了让代码有可移植性。

文件描述符2

在每一进程中,每个文件都有着自己的文件描述符,stdin/out/err也有。前面有提到过FILE是C语言库中定义的结构体类型,那我们不难猜出在该结构体中有着储存文件描述符的变量(_fileno)。

c 复制代码
int main()    
{     
    int fd = open("log.txt",O_RDONLY);      
    printf("%d\n",stdin->_fileno);                                                                                                                                                                                                   
    printf("%d\n",stdout->_fileno);    
    printf("%d\n",stderr->_fileno);    
    printf("%d\n",fd);    
    close(fd);    
    return 0;    
}    

可以看到,stdin/out/err分别对应文件描述符0、1、2,而新打开的文件按顺序紧接其后。

操作系统层面的文件管理

打开文件时,OS会为每个文件生成一个结构体file(先描述),然后再用数据结构(如双链表)将这些结构体组织管理起来(再组织)。而PCB中有一个file_struct指针类型的变量files,它指向一个结构体变量,在变量中有一个struct file*类型的数组,它储存在该进程打开的文件地址,而文件描述符实际上就是该数组的下标 。

因为同一个文件可以被多个进程同时打开,所以struct file和PCB一样同时存在于多个数据结构。

文件描述符3

当我们打开一个文件时,系统会为它分配可以分配的最小文件描述符 。

比如说我关闭了stderr,这是文件描述符表中stderr的位置就会空出来,当系统遍历该表时,找到第一个空的位置就是stderr原本的位置,这使系统就会把文件描述符2分配给新打开的文件:

c 复制代码
int main()      
{      
    int fd1 = open("log.txt1",O_CREAT | O_WRONLY | O_TRUNC,0666);      
    printf("%d\n",stdin->_fileno);      
    printf("%d\n",stdout->_fileno);      
    printf("%d\n",stderr->_fileno);      
    printf("%d\n",fd1);      
    printf("\n\n");      
      
    close(stderr->_fileno);      
    int fd2 = open("log.txt2",O_CREAT | O_WRONLY | O_TRUNC,0666);      
    printf("%d\n",stdin->_fileno);      
    printf("%d\n",stdout->_fileno);                                                                                                                                                                                                  
    printf("%d\n",fd1);    
    printf("%d\n",fd2);    
    return 0;    
}          
重定向

在Linux中,我们可以使用>、>>、<这些符号来实现重定向,它实际上就是将原本要读取或写入数据的文件替换为了其他文件,那它底层是怎么做到的呢?

重定向的底层

比如说我要向stdout打印信息,而经过重定向后信息打印在了文件A:

stdout和A储存在文件描述符表中,当发生重定向后,文件A的地址会覆盖stdout的地址。

而即使发生了重定向,文件的文件描述符是不会变的,即stdout的fd还是1,A的还是3。但当你向函数传stdout或其文件描述符,进程在文件描述符中找到的却是取代了stdout位置的A,最后使本来要写入stdout内的信息被写入文件A。

那么stdout被覆盖后会被关闭吗?

其实当文件打开时OS创建的结构体内会有一个变量ref,用来计数当前文件被几个文件描述符中的指针指着,只有ref归0时,文件就会自动关闭 。如这上面的stdout就会被自动关闭。要注意的是,当进程fork子进程后,父子进程共享文件描述符表,这时也会把子进程内的计数加上。

重定向函数

重定向函数有两个,分别为dup、dup2。

dup
c 复制代码
int dup(int oldfd);

dup() uses the lowest-numbered unused descriptor for the new descriptor.

dup会将oldfd对应的文件指针拷贝到文件描述表中下标最小且为空的位置,但原本文件的文件描述符不会改变,只不过是多了一个指针指向它。

如果成功它会返回拷贝到的新下标的位置,失败返回0。

dup2
c 复制代码
int dup2(int oldfd, int newfd);

dup2() makes newfd be the copy of oldfd, closing newfd first if necessary, but note the following

dup2需要传入两个文件描述符oldfd和newfd,它会将文件描述符表中oldfd位置对应的指针拷贝给newfd对应的位置,但原本在这两个位置上的文件的文件描述符不会改变。原来在newfd位置上的文件不会关闭,如果需要得自行关闭 。

要注意oldfd和newfd到底该传哪个文件描述符!

如果成功它会返回newfd,失败返回0。

cpp 复制代码
int main()    
{    
    int fd = open("log.txt",O_CREAT | O_WRONLY | O_TRUNC,0666);     
    char put[] = "hello dup\n";    
    int n = dup2(fd,1);    
    printf("%d %d\n",fd,n);    
    printf("%s",put);                                                                                                                                                                                                                                                                                                                                                                                                    
    close(fd);                                                                                                                                                                                                                                                                                                                                                                
    return 0;                                                                                                                                                                             
}   
stdout、stderr

标准输出流和错误流都指的是显示器文件:

cpp 复制代码
#include<cstdio>    
#include<iostream>    
int main()    
{
    //往标准输出流输出                                                                                                                                                                                                               
    printf("hello printf\n");
    std::cout<<"hello std::cout"<<std::endl;

    printf("\n");
    printf("\n");
    //往标准错误流输出
    perror("hello perror");                 
    std::cerr<<"hello std::cerr"<<std::endl;
    return 0;           
}     

它们都打印在显示器文件。

重定向补充

当我们将上面代码的输出重定向写入log.txt时,会发现文件内只有本应往标准输出流输入的数据:

这是因为这个指令只默认重定向了标准输出流(1),它的完整写法应该是:

值得注意的是,当显示写文件描述符时,它和重定向符号之间不能有空格。

那如果我们想把往标准错误流输出的信息也打印进其他文件怎么办?

像这样,显示器文件没有打印任何东西,显然是成功将stdout/err重定向了,但奇怪的是log.txt文件中只有本来往标准错误流打印的信息。这是因为这条命令会两次打开log.txt,相当于这样:

c 复制代码
int fd1 = open("log.txt",..|TRUNC,...);
dup2(fd1,1);
close(fd1);
int fd2 = open("log.txt",..|TRUNC,...);
dup2(fd2,2);
close(fd2);

所以这样是没法让两种信息全输入进同一文件的。

有两种写法可以做到:

1.第二次使用追加重定向:

这里也是open了两次,但第二次是追加选项,没有清空文件。

2.文件描述符复制重定向

相当于:

c 复制代码
int fd1 = open("log.txt",..|TRUNC,...);
dup2(fd1,1);
close(fd1);
dup2(1,2);
为何区分

经过上面重定向的演示我们可以知道,虽然stdout/err指的是同一文件,当可以通过重定向把它们分别写入不同文件,这样我们就可以更好的拿到错误信息。

四.理解"一切皆文件"

Linux中一切皆文件并不是说所有东西本质都是文件,而是把一部分设备和内核对象抽象成文件,让进程可以用统一的文件描述符和系统调用接口去访问它们;但这只是用户空间访问接口的抽象,不代表硬件本体是文件,也不能把它们都当成普通文件来用。

它是如何抽象的呢?拿硬件举例子:

当系统要打开硬件设备时,它会为要使用的设备用结构体device描述起来并用数据结构将它们组织管理起来。同时还会为他们创建file结构体,也会将它们组织管理起来,我们将这组织起来的结构称为VFS:虚拟文件系统 。在每个file结构体中有一系列函数指针,它们用来指向对应硬件的接口(尽管不同硬件的接口实现大不相同,也用相同类型的指针指向 ),这样就PCB->文件描述符表->file结构体->接口 这条链来对硬件进行操作。

这里的file结构体其实就是C语言版的多态,即使其内部成员相同,但函数实现不同。

五.缓冲区

什么是缓冲区

缓冲区就是内存的一段空间 。

拿生活来举例子:

如果没有快递站会怎么样?快递到了你就只能第一时间去拿,如果你没空又得麻烦快递员等,这样对双方都不利。但有了快递站就解决了这个问题,无论你是否有时间,快递到了快递员直接将它寄存在快递站,你有时间去拿就好,这样都有利于对方。

在这个例子里,快递站就相当于缓冲区,你是操作系统,快递员是用户。

来看这个代码:

为什么前者没把库函数打印的信息写入文件,后者却可以?

这就是缓冲区的存在导致的,我们也不难推出缓冲区和库有关。

其实有两种缓冲区:用户层缓冲区和内核级缓冲区。

当我们用库函数如printf、fprintf、fwrite(观察这些函数的参数,不难推测出C语言层缓冲区在结构体FILE中 )等将信息写入文件时,信息会先被拷贝进语言层缓冲区,然后在满足特定情况下语言层缓冲区将被刷新,信息被拷贝进文件内核缓冲区,最后文件内核缓冲区内的信息再被OS拷贝给文件,只有完成了这一系列操作,数据才被真正写入文件。

我们也可以看出,计算机内的数据流动本质上就是拷贝。

导致上面那种情况的原因就是数据在缓冲区中,要刷新时文件已经关闭了,无法再写入。

刷新缓冲区的情况

对于内核级缓冲区:

内核中的情况复杂多变,OS会根据实际情况刷新缓冲区,我们只需要将数据交给内核缓冲区(OS)就相当于交给文件(没关闭)了。

对于语言层缓冲区:

1.强制刷新;

使用函数强制刷新缓冲区,如fflush()。

2.进程退出时;

当进程退出时会自动刷新缓冲区。

3.刷新条件满足时;

3.1 立即刷新(无缓冲,写透模式);

3.2 缓存满了(全缓冲),该方法效率最高,普通文件一般都采用该形式;

3.3 行刷新(\n,行缓存),该条件只对显示器用:

来看看这个代码:

cpp 复制代码
int main()    
{    
    //使用库函数    
    printf("hello printf\n");    
    fprintf(stdout,"hello fprintf\n");    
    const char* s = "hello fwrite\n";    
    fwrite(s,strlen(s),1,stdout);    
    printf("\n");    

    //使用系统调用    
    const  char* put = "hello write\n";    
    write(1,put,strlen(put));    
    int id = fork();    
    if(id == 0)                                                                                                                                                                                                                      
        exit(0);    
    wait(NULL);    
    return 0;    
}    

将它重定向写入文件:

会发现用库函数打印的数据被拷贝了2份。语言层的缓存区是在用户空间的(FILE结构体),所以当父进程fork创建子进程后,父子进程将共享一块语言层缓冲区,但它们中任一进程要结束时,会使用fflush刷新缓冲区,这时就会触发写时拷贝,操作系统会将还没退出的进程的语言层缓冲区拷贝给要退出的进程,后退出的进程又会刷新一遍缓冲区。这就导致向文件写入了两份内容。

为什么要有缓冲区

系统调用是需要花费时间的,如果没有缓冲区,那你每一次输入都需要使用系统调用,这会花费大量时间。但如果有了缓冲区,你可以将多次输入的数据用一次系统调用写入文件,这大大减少的时间消耗。也就是说缓冲区可以提高使用者的效率。

六.模拟实现极简stdio.h

文件mystdio.h:

c 复制代码
#include<stdio.h>    
#define MAXBUFFER 1024    
#define NONE_FULSH 0    
#define LINE_FLUSH 1    
#define PULL_FLUSH 2    
typedef struct IO_FILE    
{    
    int fileno;//文件描述符    
    int flags;//读写状态    
    char buffer[MAXBUFFER];    
    int bufferlen;    
    int flush_method;//刷新方法    
}MYfile;    
    
MYfile* MYfopen(const char* path,const char* method);    
void MYclose(MYfile* file);    
int MYfwrite(const void* ptr,MYfile* file,int count);                                                
void MYfflush(MYfile* file); 

文件mystdio.c:

c 复制代码
#include "mystdio.h"                                
#include<string.h>                                           
#include<unistd.h>                                  
#include<sys/stat.h>                                                  
#include<sys/types.h>    
#include<fcntl.h>
#include<stdlib.h>                                   
MYfile* BuyFile(int fd,int flag)     
{                                            
    MYfile* file = (MYfile*)malloc(sizeof(MYfile));
    if(!file)                               
    {                                                
        perror("error mlloc");                        
        return NULL;                                                                 
    }                                        
    file->fileno = fd;               
    file->flags = flag;                                 
    memset(file->buffer,0,sizeof(file->buffer));
    file->bufferlen = 0;   
    file->flush_method = LINE_FLUSH;//默认行刷新
    return file;                    
}                                                            
MYfile* MYfopen(const char* path,const char* method)
{                                                                     
    int flag = 0;        
    if(strcmp(method,"r") == 0)
        flag = O_RDONLY;
    else if(strcmp(method,"r+") == 0)
        flag = O_RDONLY | O_WRONLY | O_TRUNC;
    else if(strcmp(method,"w") == 0)
        flag = O_CREAT | O_WRONLY | O_TRUNC;
    else if(strcmp(method,"w+") == 0)
        flag = O_CREAT | O_RDONLY | O_WRONLY |O_TRUNC;
    else if(strcmp(method,"a") == 0)
        flag = O_CREAT | O_WRONLY | O_APPEND;
    else if(strcmp(method,"a+") == 0)
        flag = O_CREAT | O_RDONLY | O_WRONLY | O_APPEND;
    else 
        return NULL;
    int fd = open(path,flag,0666);
    if(fd < 0)
        return NULL;
    return BuyFile(fd,flag);


}
void MYclose(MYfile* file)
{
    if(!file)
        return;
    MYfflush(file);                                                                                                                                                                                                                  
    close(file->fileno);
    free(file);
}
int MYfwrite(const void* ptr,MYfile* file,int count)
{
    int old_count = count;
    while(count + file->bufferlen > MAXBUFFER)
    {
        int sub = MAXBUFFER - file->bufferlen;
        memcpy(file->buffer+file->bufferlen,ptr,sub);
        ptr = (char*)ptr + sub;
        count -= sub;
        MYfflush(file);
    }
    memcpy(file->buffer + file->bufferlen,ptr,count);
    file->bufferlen += count;
    if(file->flush_method == LINE_FLUSH && file->buffer[file->bufferlen - 1] == '\n')
        MYfflush(file);
    return old_count;

}
void MYfflush(MYfile* file)
{
    if(file->bufferlen == 0) return;
    int n = write(file->fileno,file->buffer,file->bufferlen);
    (void)n;
    fsync(file->fileno);//将fd对于的内核级缓存区的内容强制刷新到文件中
    file->bufferlen =  0;
}
相关推荐
zbyyd1 小时前
Linux驱动:UART串口通信、GT9147触摸屏与IIO-ADC驱动开发
linux·驱动开发·arm
zbyyd2 小时前
Linux驱动:SPI子系统与ADXL345三轴加速度传感器驱动开发
linux·驱动开发·arm
coward912 小时前
全志和瑞芯微平台反编译设备树
linux·嵌入式硬件
zbyyd2 小时前
Linux驱动开发:Misc混杂设备与设备树实战
linux·驱动开发
业余程序员plus2 小时前
RDMA-Verbs-软件架构(二)
linux·rdma·rocev2·mlx5·verbs·uverbs·libibverbs
坤坤子吖3 小时前
Linux 线程同步与互斥——条件变量与线程同步
linux·开发语言·c++·笔记·学习
张文君3 小时前
vmware克隆后ubuntu26.04 ip怎么更改
linux·运维·服务器
2402_882893863 小时前
Linux进程控制(上):fork、写时拷贝、进程终止与进程等待,一篇吃透!
linux·进程·fork·写时拷贝
小此方4 小时前
Linux网络(二十九):从内网穿透到P2P打洞:NAT转换、代理机制与局域网通信的底层全景解析
linux·网络·p2p