基础IO

125 阅读15分钟

一.回顾C语言的文件操作函数

在讲解基础IO之前,让我们先用几个例子回顾一下C语言中的文件操作函数。

#include<stdio.h>

int main()
{
    FILE* fp = fopen("log.txt", "w");
    if(!fp)
    {
        perror("fopen\n");
    }

    fputs("hello world\n", fp); //把字符写到文件里
    fclose(fp); //关闭文件流
    return 0;
}

image-20231005211637098

image-20231005212743930

以上程序中我们用fopen打开了一个log.txt的文件(如果没有会自动创造),可以看到编译执行完后我们得到了一个log.txt的文件,里面有刚刚用fputs输入的字符串,接下来我们再读字符串。

int main()
{
    FILE* fp = fopen("log.txt", "r");
    if(!fp)
    {
        perror("fopen fail!\n");
    }
    char buf[20];
    fgets(buf, 20, fp);
    printf("%s\n", buf);
    return 0;
}

image-20231005213530848

我们用fopen打开了一个文件,然后从里面读取20个字节,最终打印出来。(打印两行是因为有两个"\n")

二.再理解当前路径

我们在执行上方代码时,并没有指定要生成的路径,为什么系统能在当前路径下生成呢?我们换个路径再试试看。

image-20231006212142462

当我们在上级目录中再执行第一段代码,上级目录中也生成了log.txt,这也说明了默认程序是在当前目录下执行的,那这是怎么做到的呢?

我们运行一个程序,获取他的pid,看看在proc中对应进程都有什么东西。

image-20231006213037749

image-20231006213102614

我们看到在proc中进程2717存在两个软链接,一个是cwd,即进程运行时所在的路径,另一个是exe,即可执行程序所在路径,这也解释了为什么系统可以知道当前路径在哪里,因为每一个进程运行时就记录了这些信息。

三.认识语言接口和系统接口的关系

操作文件,不同语言都有一套类似的文件操作函数,系统也不例外,接下来我们来认识下常见的系统文件操作函数。

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

int main()
{
    int fd = open("log.txt", O_RDONLY); //用open函数打开一个文件(只读权限),如果没有会自动创建,
    if(fd < 0)  //读取失败
    {
        perror("open fail!\n");
        return 1;
    }

    const char* msg  = "hello vscode\n";
    char buf[1024];
    while(1)
    {
        ssize_t size = read(fd, buf, strlen(msg)); //调用read函数,从fd里读,读到buf中,最后一个参数表示读多少个字节
        if(size > 0)
            printf("%s", buf);
        else
            break; //读完了可以退出
    }

    close(fd); //关闭文件
    return 0;
}

image-20231007141331364

看完上面的代码,大家可能会有疑惑,语言层面的文件操作函数和系统层面的操作函数是什么关系?为什么语言层面使用的是file*指针,而系统层面使用的一个int类型的数据,这个fd表示了什么?

我们先回答第一个问题,即语言层面的文件操作和系统层面的有什么关系,请看下图:

通过这张图,系统接口和语言接口的关系一目了然,也就是说,语言接口最后还是要调用系统接口的,可以说,和系统函数名称类似的函数,如fopen,fwrite等等,实际上都是对系统接口的调用和封装。

四.文件描述符fd

1.引入fd

接下来我们回答第二个问题,即open函数和fopen函数的返回值为什么一个是int类型的数据--fd,另一个是FILE*类型的指针?二者有什么关系?为什么系统接口要用fd呢?

通过上文的图片,我们可以知道语言层面的接口大多对系统接口的封装,所以FILE*指针本质上也是一个fd,为什么需要fd来作为我们打开文件的标识呢?

我们知道Linux下一切皆文件,文件是进程在运行时被打开的,一个进程可以打开多个文件,而系统中存在着大量的进程,也就有可能存在大量被打开的文件,所以操作系统必须对所有的文件进行管理。

根据先描述再组织的原理,操作系统为每个文件建立一个struct file结构体,包含了文件的内容和属性等等信息,最后再把所有的文件struct file用链表存起来。此时,所有的文件都被组织起来,对文件的管理就等于对这张链表的增删查改。

我们知道进程= 程序 + mm_struc +PCB + 页表,其实PCB中还包含了一个指向struct file的指针,struct file中有一个指针数组fd_arrar保存了所有的struct file的位置,fd_arrar,其下标就是我们的文件描述符fd,通过下标fd,我们就可以找到所有的文件了。

2.默认打开的3个文件描述符

回望我们之前写的代码,大家有没有想过,我们没有指定printf函数要输出到哪里,为什么就可以直接在屏幕看到结?这和系统默认打开的3个文件描述符有关,即0,1,2三个文件描述符--分别对应stdin(标准输入),stdout(标准输出),stderr(标准错误),分别对应着键盘,显示器,显示器三个硬件设备。

接下来我们验证下:

image-20231007155558551

我们发现"hello world"确实打印到了屏幕上。

五.系统文件操作函数

1.open

我们在系统调用接口中一般使用open打开一个文件

它的函数原型如下

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

参数1--pathname

它的第一个参数要求我们输入一个字符串

  • 如果我们输入的字符串是一个路径 当需要我们创建文件时 文件会默认在这个路径下创建
  • 如果我们输入的字符串是一个文件名 当需要我们创建文件时 文件会默认在当前路径创建)

参数2--flags

它的第二个参数要求我们输入一个整数

而实际上我们在使用的时候并不会直接输入一个整数 而是会输入一系列的宏并且将它们进行位操作 这是因为我们并不是使用这个整数去标识打开的状态 而是使用这个整数的位去标识

如果我们想要这个文件是可写的状态打开 并且还可以追加 如果不存在就创建它 那么我们就需要使用按位或操作(|)

一般我们常用的选项如下表

参数选项含义
O_RDONLY以只读的方式打开文件
O_WRNOLY以只写的方式打开文件
O_APPEND以追加的方式打开文件
O_RDWR以读写的方式打开文件
O_CREAT当目标文件不存在时创建文件

就如我们上面所说 如果想要多种选项只需要按位或就好了

比如说想要以读写的方式打开文件并且当目标文件不存在时创建文件

我们就可以使用下面的格式

 O_WRONLY | O_CREAT

open的第三个参数

参数3--mode

它的第三个参数要求我们输入一个权限值,表示这个文件创建时不同人拥有的权限,不创建的话就可以不填。

返回值

open函数返回一个文件描述符,如果调用失败,就返回-1。

2.close

我们在系统接口层面使用close关闭文件 它的函数原型如下

  int close(int fd);
//参数fd:要关闭的文件描述符
//返回值:成功关闭返回0,失败返回-1

close函数是十分重要的,在我们使用完fd以后,一定要用close关闭,否则就会造成严重的文件泄漏问题。

了解了open和close这两个函数,我们写段代码来感受下:

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

int main()
{
    int fd = open("log.txt", O_RDONLY); //用open函数打开一个文件(只读权限),如果没有会自动创建,
    if(fd < 0)  //读取失败
    {
        perror("open fail!\n");
        return 1;
    }
    else printf("open success!!\n");


    close(fd); //关闭文件
    return 0;
}

image-20231007192515533

最后确实成功调用了open函数。

3.read

我们在系统接口中使用read函数向文件中读取文件 它的函数原型如下

 ssize_t read(int fd, void *buf, size_t count);
//它的意思是从文件描述符为fd的文件中读取conut个字节的数据存放到buf中

参数含义

  • fd:我们要读取文件的文件描述符
  • buf:存放数据的地址
  • count:读取的字节数

返回值

读取成功,则返回实际读取到的字节数,反之返回-1。

4.write

使用write函数向文件写入,函数原型如下;

ssize_t write(int fd, const void *buf, size_t count);
//从buf这个位置写入count个字符到fd中

参数含义

  • fd:要写入的文件描述符
  • buf:源字符串,从这个字符串写入到fd中
  • count:要写多少个字符

返回值

写入成功返回成功写入的字节数,失败则返回-1。

介绍完这两个函数,我们再写一段代码感受下:

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

int main()
{
    int fd = open("log.txt", O_RDWR); //用open函数打开一个文件(只读权限),如果没有会自动创建,
    if(fd < 0)  //读取失败
    {
        perror("open fail!\n");
        return 1;
    }

    const char* msg  = "hello vscode\n";
    char buf[1024];
    while(1)
    {
        ssize_t size = read(fd, buf, strlen(msg)); //调用read函数,从fd里读,读到buf中,最后一个参数表示读多少个字节
        if(size > 0)
            printf("%s", buf);
        else
            break; //读完了可以退出
    }

    ssize_t count = write(fd, msg, strlen(msg));
    if(count < 0)   //读取失败
    {
        perror("write fail!\n");
    }
    

    close(fd); //关闭文件
    return 0;
}

image-20231007200113824

上述代码中,我们用可读写的权限打开了一个"log.txt"的文件,并把他的数据读到了buf这个字符串,最后用write再写入一段"hello vscode",最后成功了。

六.文件描述符fd的分配规则

我们知道0,1,2分别是标准输入,标准输入,标准错误,那么剩下的文件描述符如何分配呢?按顺序分配?随机分配?接下来我们探寻下文件描述符分配的规律。

int main()
{
    int fd = open("log.txt", O_RDWR); //用open函数打开一个文件,如果没有会自动创建,
    if(fd < 0)  //读取失败
    {
        perror("open fail!\n");
        return 1;
    }
    printf("fd是: %d\n", fd);
    close(fd);
    return 0;
}

image-20231007202146450

以上是正常情况下打开一个文件,它的fd是3,那么如果我们关闭0,1,2当中任意一个fd,会发生什么呢?

int main()
{
    close(2);   //关闭2号fd
    int fd = open("log.txt", O_RDWR); //用open函数打开一个文件(只读权限),如果没有会自动创建,
    if(fd < 0)  //读取失败
    {
        perror("open fail!\n");
        return 1;
    }
    printf("fd是: %d\n", fd);
    close(fd);
    return 0;
}

image-20231007202340749

我们看到,再关闭2号文件描述符后,我们打开log.txt,它的fd就是2,由此可以知道文件描述符的规则为:在files_struct中,找到当前尚未使用的最小下标,作为新的文件描述符。

七.认识重定向

1.引入重定向

int main()
{
    //close(2);   //关闭2号fd
    close(1);   //关闭1号fd
    int fd = open("log.txt", O_CREAT | O_RDWR , 0666); //用open函数打开一个文件,如果没有会自动创建,
    if(fd < 0)  //读取失败
    {
        perror("open fail!\n");
        return 1;
    }
    printf("fd是: %d\n", fd);
    fflush(stdout); //清空输出缓冲区


    close(fd);
    return 0;
}

image-20231007203237504

我们发现,关闭1号文件描述符后,新打开的log.txt文件描述符是1,且我们刷新标准输出的缓冲区后,内容并没有被打到显示器上,而是打进了我们的文件里,这种现象叫做重定向。

同时,和我们上文介绍默认打开的三个文件描述符所说的一样,默认打开的三个,认定的是文件描述符,也就是说,标准输入真正绑定的0号描述符,标准输出真正绑定的是1号描述符,标准错误真正绑定的是2号描述符,只是0,1,2号默认绑定了键盘,显示器,但是到底这三个文件描述符绑定了谁,是不关心的。

2.重定向的类别

重定向一般用箭头符号表示,常见的有这三个:>(stdout重定向到文件里,覆盖),>>(stdout重定向追加到文件里),<(标注输入重定向到文件里)

3.重定向的本质

通过上面的介绍我们已经对重定向的本质隐约的认识,重定向的本质就是让字符流输出到指定的文件当中去。拿stdout举例子,它绑定的是1号描述符,1号描述符绑定谁它不关心,重定向stdout就是让1号描述符指向特定的文件。

那么我们可不可以在不关闭标准输入流的情况下实现重定向呢?有了上面的认识,可以知道,这是完全能做到的事。

4.dup2函数

 int dup2(int oldfd, int newfd);
//dup2可以把oldfd中的内容拷贝到newfd中,成功返回newfd,失败返回-1

接下来我们尝试使用dup2函数;

int main()
{
    int fd = open("log1.txt", O_RDWR | O_CREAT, 0666);
    if(fd < 0)
    {
        perror("open fail!\n");
        return 1;
    }

    int flag = dup2(fd, 1); //重定向
    if(flag < 0)    perror("dup2 fail\n");

    fputs("hello dup2\n", stdout);  //把字符串放到stdout里
    return 0;
}

image-20231007212235213

可以看到,1号文件描述符被重定向到log1.txt里。

八.认识缓冲区

1.引入缓冲区

我们知道fopen等C语言函数的返回值是一个FILE*指针,而系统接口的返回值是fd,所以在FILE结构体内部也一定封装了fd,来段代码我们感受下:

int main()
{
    const char* msg0 = "hello printf\n";
    const char* msg1 = "hello fwrite\n";
    const char* msg2 = "hello write\n";

    printf("%s", msg0);
    fwrite(msg1, strlen(msg1), 1, stdout);
    write(1, msg2, strlen(msg2));

    fork(); //创建子进程
    return 0;
}

image-20231007213913282

此时正常输出,接下来我们把结果重定向试一试:

image-20231007214505588

如图,重定向后,我们发现,write打印了1次,printf和fwrite打印了两次,这是为什么?

要理解这个现象,我们先了解下缓冲区的概念。

2.什么是缓冲区?

缓冲区就是我们常说的缓存,属于内存的一部分。它依据对应的输入设备和输出设备把内存的一部分空间分为输入缓冲区和输出缓冲区。

3.为什么要有缓冲区

简单地说,为了减少对磁盘的读写次数,提高系统的IO效率。我们在上文中画了一张有关语言接口和系统接口的图,我们知道语言接口是要调用系统接口的,调用程序是需要时间的,而频繁地调用势必会影响系统的效率,因此为了减少系统调用,库函数根据访问文件的需要,设计了不同的缓冲区,减少IO的次数,提高效率。

4.缓冲区类型

  • 全缓冲:填满IO缓存后,再进行实际的IO操作,例如磁盘的读写
  • 行缓冲:输入输出在遇到换行符以后执行真正的IO操作,例如键盘的输入
  • 不缓冲:即没有缓冲区,直接显示数据,例如标准错误

知道这些概念后,就可以解答一开始出现的现象了。

我们知道了写入显示器是行缓冲,而C语言库函数都是全缓冲,fwrite这类库函数自带缓冲区,而重定向到普通文件后,数据的刷新方式就变成了全缓冲,且我们放在缓冲区的数据是不会立即刷新的,刷新的时间甚至在fork之后,不过在进程退出时,会统一刷新。

问题在于我们fork之后,父子进程会发生写时拷贝,所以当父进程准备刷新时,子进程也有同样的一份数据,所以fwrite和printf会打印两次,write没有刷新就说明它没有缓冲区。(其实内核为了提高IO效率,实也有自己的缓冲区,不过这就不在今天的讨论内)

九.初识文件系统

1.inode

Linux下一切皆文件,而这么多的文件肯定也需要我们管理起来,而这就衍生出了文件系统,实际上文件系统是一种抽象机制,,它提供了⼀种在ᏺ上保存信息⽽且⽅便以后读取的⽅法。这种⽅法可以 使⽤户不必了解存储信息的⽅法、位置和实际⼯作⽅式等有关细节,那我们想想,不论是win还是Linux,我们在不同的目录下都可以创建相同名字的文件,那系统怎么辨别它们呢?

在Linux中,OS是依靠inode来识别文件的。文件一共有两部分信息:

  • 文件内容:即文件中存储的数据
  • 文件属性:即文件的大小,文件的创建时间,文件类型等等,这些也可以称作文件的元信息

Linux中,文件内容和文件属性是分开存储的,文件元信息实际上是依靠inode来保存的.

ls -i  //查看文件inode

image-20231008155115771

2.认识磁盘

在我们的外设中,存储设备一般可以分为两种--磁盘和内存,内存是容易失电丢失存储信息的,而磁盘可以永久保存信息。

image-20231008160032223

我们在磁盘上寻找一个文件,大致可以分为3步--确定在哪个盘面,确定在哪个柱面,确定在哪个扇区。