• 冰冰学习笔记:基础IO


    欢迎各位大佬光临本文章!!!

    还请各位大佬提出宝贵的意见,如发现文章错误请联系冰冰,冰冰一定会虚心接受,及时改正。

    本系列文章为冰冰学习编程的学习笔记,如果对您也有帮助,还请各位大佬、帅哥、美女点点支持,您的每一分关心都是我坚持的动力。

    我的博客地址:bingbing~bang的博客_CSDN博客https://blog.csdn.net/bingbing_bang?type=blog

    我的gitee:冰冰棒 (bingbingsupercool) - Gitee.comhttps://gitee.com/bingbingsurercool


    系列文章推荐

    冰冰学习笔记:《进程程序替换》

    冰冰学习笔记:《进程控制》


    目录

    系列文章推荐

    前言

    1.C语言的文件接口函数

    2.系统调用接口的使用

    2.1open与close函数

    2.1.1open函数的使用方法

    2.1.2mode参数与umask函数 

    2.1.3flags参数的分析 

    2.2write与read函数

    3.fd文件描述符

    4.重定向

    5.缓冲区

    6.minishell的重定向


    前言

            在学习C语言的时候我们学习了C语言给我们提供的各种文件操作函数,让我们可以方便的操作文件的写入与读取。但是每种语言都有自己对文件操作的实现方式,对于操作系统来说难道就需要兼容每一种语言的接口吗?实际上并不是!Linux操作系统只有一套文件操作接口,每种语言在底层都是调用了这套接口来实现各种操作,换言之,语言的文件操作函数是对操作系统接口的上层封装。

            当我们在磁盘上创建一个文件的时候,即使什么也没有写入,此文件也具备空间大小。因为文件=文件内容+文件属性,每个文件被创建后都具备一定的属性。当我们编写程序使用接口函数来操作文件时,本质上是什么在访问文件呢?其实并非是我们的代码访问,而是进程在访问。访问文件实际上是向磁盘这种硬件进行访问,而这种权力只有操作系统有,因此只有当我们的代码加载为对应的进程,并且进程被执行时才能调用相应的系统接口来操作文件。

            既然操作系统已经具备了文件操作的接口函数,为什么每种语言还要对其进行封装呢?原因在于系统的接口使用比较难,不容易学习,语言对其进行封装后,使得参数简单易懂,用户更加方便使用。还有就是如果语言不提供接口函数,那么将不再具备跨平台性,windows系统和Linux系统的接口函数肯定不同,但是语言在对其进行封装时可以使用条件编译让其每种平台都使用对应的系统接口函数,但是上层还是一样的语言接口,用户不需要增加学习负担。

    1.C语言的文件接口函数

            此前我们提到过,Linux认为一切皆文件,那究竟什么是文件?狭义上我们理解的文件就是磁盘上的一个txt文本文档,一个exe进程程序,但是在系统看来,文件不止这些,显示器,磁盘,声卡,网卡等硬件都是文件。也就是说只要满足能够被input读取和output写出的设备都是文件。

            现在我们再次使用C语言的接口函数来看看文件操作的现象,至于具体怎么使用这里不在过多介绍,有兴趣的读者可以前往博主之前的文章《C语言文件操作接口》进行阅读。

            想要对文件操作我们首先要做的是打开文件,然后使用三个函数将字符写入文档中,操作后再关闭文件。

            这里我们发现,在使用C语言的接口时,我们创建文件可以指定文件路径来创建log.txt,也可以不指定路径直接就写一个文件名,此时会默认在当前路径进行创建。

            那么问题来了,不指定路径时的当前路径究竟是哪里,是源文件所处的路径吗?

            执行后,貌似是这样,确实与源文件的路径相同。我们将可执行文件myfile拷贝到上级目录下并执行: 

            我们发现,上级目录并没有源文件filetest.c只有编译后的执行文件,当我们执行后,log.txt创建在了当前文件下。

            所以,当前路径并非源文件所在的路径,而是进程执行时的路径,我们可以使用ls/proc/+进程ID+l 进行查看执行进程的当前路径。

            这里还有一个注意点,就是当我们使用fwrite进行写入时,需要计算写入字符的长度,我们需不需要对其进行+1,多读取一个 '\0' 作为字符的结束标志呢?答案是不需要,因为我们是写入到文件中,'\0'结尾是C语言的实现标准,与文件没有关联,文件只需要保存有效数据。

            C语言的接口函数在使用"w"进行写入操作时,会默认先将文件内容清空,然后在进行写入,如果向在文件末尾进行追加写入需要使用"a"的方式打开文件。所以我们想清空一个文件的时候可以直接使用下列命令:> log.txt,这意味着我们只是以"w"形式打开了这个文件并没有进行其他操作,但是会默认清空文件内容。

            下面我们调用C语言的接口函数来进行文件读取操作,使用fgets函数对log.txt文件的内容进行读取,并将其打印在屏幕上。这里使用fgets函数时,会将读取到的内容自动在后面添加'\0'。

            当我们使用命令行参数进行读取操作时,我们的程序就会变成一个cat命令:

            在学习C语言的时候我们还记得,C语言会默认打开三个输入输出流,stdin,stdout,stderr,这三个分别表示的文件就是键盘,显示器,显示器。这些虽然都是硬件,但是在操作系统中,他们就是一个FILE*的指针。

    2.系统调用接口的使用

            实际上操作系统给我们提供了一套接口函数来进行文件的读写操作,这些函数的使用相对复杂,但是更加接近于底层。其中我们上面使用的C库函数都是调用的下面的这些对应的系统函数。

    2.1open与close函数

            open函数就是C语言fopen函数调用的底层接口,其作用为以某种方式打开一个文件。

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

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

    头文件:

            #include

            #include

            #include

    参数:

            1.参数pathname需要指定打开的文件名,与fopen一致;

            2.flags参数则表明打开文件时以哪种方式执行,对应的参数如下:

            O_RDONLY: 只读打开

            O_WRONLY: 只写打开

            O_RDWR : 读,写打开

            这三个常量,必须指定一个且只能指定一个。

            O_APPEND: 追加写

            O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限

            O_TRUNC:清空目标文件的内容

            3.mode参数为指定创建文件时设定的权限。

    返回值:

            成功:新打开的文件描述符

            失败:-1   

    int close(int fd);

    头文件:

            #include

    参数:

            文件描述符fd

    返回值:

            成功:0

            失败:-1

            从上面的函数介绍中我们也能看到系统函数的复杂性,并且open函数在传递第二个参数的时候还使用了位图结构。

            open函数具体使用哪个则需要看情况,当我们只读或者向已经存在的文件进行写入时,可以使用第一个函数,不用传递mode参数,但是当我们执行写入且需要创建新文件时则需要使用带有mode参数的函数,并且显示的设置创建文件的权限。

            close函数和C语言中的fclose一样,只不过这里参数为int类型的文件描述符。

            下面我们来具体使用一下open函数和close函数:

    2.1.1open函数的使用方法

    (1)使用open函数以读取方式打开文件,并且读取内容后关闭文件

     (2)使用open函数打开已存在的文件,并且写入内容

            此时我们发现,内容虽然写入了,但是并不是在末尾写入,而是在文件开头写入,并且没有清空原来的内容而是直接覆盖了原有的内容。

            这是因为我们没有传递O_TRUNC参数,该参数传递后才会对原有文件进行清空处理。

            此时对原文件进行了清空处理,但是,此时与fopen的参数"w"还不相同,因为在没有文件的时候并不会创建,会报错,显示找不到文件。

    (3)使用open函数打开文件并写入,如果文件不存在则创建

            想要使用创建功能我们还需要传递一个参数,O_CREAT。

    2.1.2mode参数与umask函数 

            通过上文我们发现,自己创建的文件权限怎么和fopen创建的权限不一样呢? open函数创建的文件为拥有者,所属组都具备读写执行的权限,其他人什么权限都没有,而fopen创建的文件则为拥有者,所属组都具备读写操作,其他人只具备读的权限,这是什么原因呢?

            这里就需要引入mode参数了,mode参数则代表我们显示传递的文件权限,但是我们并不能只传入权限,因为文件权限还与mask文件掩码有关(不了解或者遗忘的可以查看博主文章《Linux下的权限理解》)。文件的最终权限=文件起始权限 & ~(文件掩码)。所以我们在进行mode参数传递前还得设置以下文件掩码。

            设置当前创建文件的掩码可以使用umask函数,该函数将主动设置umask。设置成功后,进程中创建的文件采用就近匹配原则,将优先与新设置的掩码进行操作,然后创建文件权限。

    mode_t umask(mode_t mask);

    头文件:

            #include

            #include

    参数:

            拟设置的文件掩码

    返回值:

            系统调用始终成功,并且返回掩码的上一个值

            经过了这些参数的传递后,此时的open函数与fopen函数传入"w"时功能就一致了。

            如果想追加写入,我们只需要将O_TRUNC参数更换成O_APPEND即可。

    2.1.3flags参数的分析 

            我们发现第二个参数传递时采用了"|"操作符,并且传入了多个参数,但是函数参数flags只是一个整形,那么flags参数是如何进行多参数传递的呢?

           flags参数虽然为一个整形,但是它具备32个比特位,这里使用就是按位传参的形式。我们传入的参数只有两个状态,真和假,当我们显示传递时,就代表该状态为真,不传递就意味着该状态为假,所以我们可以用整形的每一位来表示一个参数真或者假。

    例如下面的例子:

    1. #define ONE 0x1
    2. #define TWO 0x2
    3. #define THREE 0x4
    4. #define FOUR 0x8
    5. void test(int flag)
    6. {
    7. if(flag & ONE)
    8. printf("print ONE!\n");
    9. if(flag & TWO)
    10. printf("print TWO!\n");
    11. if(flag & THREE)
    12. printf("print THREE!\n");
    13. if(flag & FOUR)
    14. printf("print FOUR!\n");
    15. }
    16. int main()
    17. {
    18. test(ONE);
    19. printf("------------------------\n");
    20. test(ONE|TWO);
    21. printf("------------------------\n");
    22. test(ONE|THREE);
    23. printf("------------------------\n");
    24. test(ONE|TWO|THREE);
    25. printf("------------------------\n");
    26. test(ONE|TWO|THREE|FOUR);
    27. printf("------------------------\n");
    28. return 0;
    29. }

            我们定义ONE为16进制数字0x1,那么该数字的第一位为1,其他位为0。当我们对其进行传参后,flage接受的参数位"|"操作符连起来的一系列数字,这些数字对应的位都会被设置位1。当if语句进行判定时,采用的是"&"操作符,如果遇到与ONE,或者TWO等参数相同的位,将打印对应的语句。

    2.2write与read函数

            write函数和read函数是操作系统给我们提供的写入和读取的函数,C语言将其封装成了多个函数,如fread,fgets等函数。

    ssize_t read(int fd, void * buf, size_t count);

    ssize_t write(int fd, const void *buf, size_t count);

    头文件:

            #include

    参数:

            fd:指向打开的文件描述符

            buf:读取时指向读取内容放置的空间,写入时指向写入内容存放的空间。

            count:读取或写入的字节数

    返回值:

            读取成功返回的是读取到的字符个数,0代表读取到文件结尾。

            读取失败返回-1。

            写入成功返回写入的字符个数,0表示未写入任何字符。

            写入失败返回-1。

            写入和读取的具体操作在前文已经给出,这里需要注意,当我们读取操作时,存储读取数据的字符空间需要我们显示初始化为"\0"来表示字符结尾,因为read函数并不会自动添加"\0" 来表示字符结束。

            相比于语言方面的接口,系统方面的接口太过复杂,所以我们将来使用最好是使用语言的接口。

    3.fd文件描述符

            前面我们在介绍open函数的时候说他的返回值,如果成功打开文件,将返回文件的文件描述符,那么什么是文件描述符呢?还有这个文件描述符的数值是多少呢?

    下面我们使用open函数打开多个文件并且将fd分别打印出来,结果如下所示:

            怎么没有0,1,2呢?为什么文件描述符是相连的数字呢?

            原因在于 0,1,2表示的是默认打开的那三个输入输出流,0代表stdin,1代表stdout,2代表stderr。所以新打开的是从3开始。其中1和2都表示显示器文件,0表示键盘文件,但是1和2并不相同,我们可以认为是同一个文件被打开两次,一般文件的错误结果会输出到2中,并且进行重定向时影响的是1中的内容与2无关。

            我们可以使用下面的方式进行验证0,1,2:

            可以发现,0,1,2确实代表标准输入输出。 

            不对呀,标准输入输出不是FILE*类型吗,怎么变成 0,1,2了呢?

            FILE*是C语言标准库提供的一种文件指针类型,实际上是对文件描述符的封装,其内部必然封装了fd,因为系统接口不认识FILE*类型只认识fd文件描述符。

            我们可以使用stdin,stdout,stderr这种FILE*的指针找到fileno,并将其进行打印出来,对应的就是0,1,2。这些整数以及FILE*我们通常称其为文件句柄。

            为什么是这种连续的数字呢?因为fd在系统内核中就是一个数组下标,对应下标的数组内容为指向一个struct_file的结构体的指针,该结构体就是对文件信息的描述。

            每个进程都可以打开多个文件,那么对这些文件就需要相应的管理方式,操作系统采用先描述再组织的方式对其进行管理,每个文件的所有信息都使用一个struct_file类型的结构体进行描述,然后将描述出来的结构体串联起来。每个进程对应的文件信息则由进程PCB中的的一个指针指向的struct_file*的数组进行管理,数组下标从0开始,每个下标里面都存放一个struct_file*的指针,对应的都是进程打开的文件结构体,这样我们就能通过文件描述符fd也就是对应的下标来找到进程中相应的文件信息,并对其进行读写操作。

            这里还要注意一点,一个文件可以被多个进程打开,一个进程也可以打开多个文件,被进程打开的文件称为内存文件,没有被进程打开的文件则称为磁盘文件。同一个文件在两个进程里面的文件描述符是不同的,每个进程的文件描述符是独立的。

    4.重定向

            我们在学习Linux指令的时候,学到过三个操作指令,'>','>>','<',这三个指令分别表示输出重定向,追加重定向,输入重定向。那这三个操作指令的底层原理又是什么样的呢?

            我们先看下面的现象,当我们使用fprintf函数向stdout中写入,会将相应的内容输出到显示屏上,可是当我们将文件操作符1关闭后,然后打开文件log.txt时会发现内容没有写入到显示屏上写入到了文件log.txt中。

    注意:重定向后我们使用了fflush刷新了缓冲区才关闭了fd,原因下文解释。

            这是什么情况?怎么给我写到文件中了?我明明是往stdout里面写的呀,难道log.txt的文件描述符和stdout的一样了?当我们打印出文件描述符fd后,确实fd变成了1。

            原因在于当进程打开新文件时,OS会去当前进程中遍历存放文件描述符的数组,将新打开的文件分配到最小的没有被占用的文件描述符中。我们在打开之前将1关闭了,因此原本1中指向的stdout就会断开,log.txt文件将被分配到1中,系统只认识1,不认识stdout,因此会将内容输出到保存在1中的文件指针指向的文件中,所以log.txt就被输入内容了,充当了显示器。

            这怎么这么像输出重定向呀!没错,这其实就是重定向的原理,本质就是操作系统内部更改了fd对应内容的指向。

            进行重定向如果让我们自己关闭对应的文件描述符岂不是太麻烦,因此操作系统给我们提供了一个函数来完成重定向。

    int dup2 ( int oldfd, int newfd );

    头文件:

            #include

    参数:

            oldfd:重定向后的文件描述符

            newfd:重定向前的文件描述符

    返回值:

            成功返回新的文件描述符

            失败返回-1

    例如:dup2(3,1)将原本输入到1中的内容输入到3中。

    注意:调用dup2函数后,若重定向前newfd有打开的文件则会关闭,重定向后,newfd和oldfd都会指向oldfd

            如果想实现追加重定向我们只需要在打开文件的时候将O_TRUNC改为O_APPEND选项,输入重定向则是采用读的方式打开文件然后将0进行重定向到文件中即可。

            那么dup2函数的工作的原理是什么呢?

            dup2函数将会关闭重定向前newfd指向的文件,然后将newfd中的内容拷贝成oldfd中的内容。

            这里我们不仅有这样的疑惑,显示器和普通文件可是两个完全不同的概念,里面的实现方式根本不同,读写方式也不同,为啥可以使用一个结构体就能将其描述完,并且通过更改两个文件描述符指向的内容还能完成重定向呢?

            这里就需要我们再次理解Linux的一切皆文件的设计哲学,Linux是用C语言写的,C语言提供了结构体类型将不同类型的成员联合在一起,因此FILE*指向的结构体中就可以使用函数指针来封装一些不同接口的读写接口函数,我们完全不用关心函数是怎么实现的,因为每个设备都有不同的读写方式,但是我们在为对应的设备创建FILE类型的结构体来将其进行描述的时候完全可以将对应的函数指针指向该设备的实现函数,这样在操作系统和看来,任何设备就不再具备差异,每个设备都是一个FILE结构体类型,统统变成了统一的文件。

    5.缓冲区

            缓冲区的概念我们一直再提,当我们使用打印函数时,如果不在末尾添加'\n'换行符,显示器就不会打印出来,只有进程结束,或者缓冲区满了,或者强制使用fflush进行刷新才会显示,这些内容就是存在于缓冲区中。

            不同的缓冲区具备不同的刷新策略但是所有的设备永远都倾向于全缓冲。对于磁盘这种设备,刷新策略是满刷新,也就是全缓冲,只有当缓冲区满了才会执行写入操作,这样做可以减少外设的访问次数,从而提高效率。当操作系统和外设进行写入或者读取时,数据量的大小不是主要矛盾,外设和操作系统写入之前的准备过程是最耗时的,因此减少访问次数自然就提高了效率。

            显示器由于需要兼顾用户体验和效率,因此一般采用的是行刷新。也就是行缓冲。但是这并不是不能改变,当用户使用fflush强制刷新,或者进程退出都会刷新缓冲区内容。

            那么什么是缓冲区呢?缓冲区实际上就是一段内存空间,那是谁提供的呢?我们先看下面的场景:我们使用三种C语言接口和一种系统接口向显示器打印内容,并在程序结束前使用fork函数创建子进程。

            同样的程序,重定向前显示器打印了4行,重定向后打印了7行,并且C语言提供的接口函数打印了两次,系统接口打印了一次。这是为啥???

            这里我们可以先得出一个结论,那就是缓冲区一定不是操作系统提供的,如果是操作系统提供的,那么操作系统的接口函数也应该打印两次。

            那么缓冲区是C标准库提供的吗?是的!C标准库给我提供了一个用户级缓冲区(OS中还存在一个内核缓冲区)。还是上面的程序,我们使用fflush函数先刷新在创建子进程,此时重定向前和重定向后就会出现同样的结果。

            原因在于,如果是往显示器上打印,显示器的缓冲策略是行刷新,当遇到'\n'时就会刷新到屏幕上,在执行fork函数的时候,这意味着前面的4个打印函数已经执行完了,数据已经刷新,fork函数就没有意义了。

            但是当我们执行了重定向,数据不在向显示器打印,而是向磁盘文件进行输出,磁盘文件的缓冲策略是全缓冲,所以打印函数中的'\n'已经不起作用了,只有当缓冲区满了,或者发生特殊情况才会进行刷新操作。当执行到fork函数的时候,系统接口函数将会自己将数据输送给操作系统,而不会经过缓冲区,C语言提供的打印函数虽然已经执行完了,但是并不代表着数据已经被写入,数据存放在缓冲区中,并且这些数据属于父进程。fork函数执行完后,子进程被创建,会将父进程的代码和数据都拷贝一份。当进程退出时,缓冲区刷新,此时子进程父进程都要执行写入操作,需要将缓冲区的内容写入到文件,因此会发生写时拷贝,所以会出现两份数据。

            看到这里就应该明白我们为什么在使用重定向的时候使用了fflush进行刷新才会输入到文件中了吧,原因在于如果不使用fflush显示刷新,缓冲区的内容只有在进程结束或者缓冲区满了的时候才会刷新到文件中,但是我们在进程结束前关闭了文件描述符fd这就导致缓冲区的内容无法刷新到文件中,因此我们需要在文件描述符关闭之前强制进行刷新。        

            既然缓冲区是C标准库提供的,那么放在那里呢?其实远在天边近在眼前,缓冲区和文件描述符一起被封装在了FILE结构体中。

    6.minishell的重定向

            我们以前写的minishell能否支持重定向功能呢?答案是可以的,我们只需要将接受到的cmdline字符串以重定向的符号为界分割成两部分即可,我们使用'\0'进行分割,'\0'之前的就是重定向的内容,之后的就是重定向的目标文件。

            找的过程很简单,我们从字符串后面往前找,找到一个">"或者"<"就停下来,然后将字符更改为"\0"。追加重定向需要找到两个">"符号,我们需要将前面的">"设为分隔符。设定之后返回分割之后的字符串内容。而前面的字符在解析的时候依然是遇到"\0"解析完毕,命令照样会执行,只不过我们将命令执行后的结果不在放到显示器中而是文件中了。

    查找代码如下:

    1. #define APPEND_REDIR 1
    2. #define INPUT_REDIR 2
    3. #define OUTPUT_REDIR 3
    4. #define NONE_REDIR 0
    5. int _status=NONE_REDIR;
    6. char* check_redir(char* str)
    7. {
    8. assert(str);
    9. char* end=str+strlen(str)-1;
    10. while(end>=str)
    11. {
    12. if(*end=='>')
    13. {
    14. if(*(end-1)=='>')
    15. {
    16. _status=APPEND_REDIR;
    17. *(end-1)='\0';
    18. end++;
    19. break;
    20. }
    21. _status=OUTPUT_REDIR;
    22. *(end)='\0';
    23. end++;
    24. break;
    25. }
    26. else if(*end=='<')
    27. {
    28. _status=INPUT_REDIR;
    29. *end='\0';
    30. end++;
    31. break;
    32. }
    33. else{
    34. end--;
    35. }
    36. }
    37. if(end>=str)
    38. {
    39. return end;
    40. }
    41. else
    42. return NULL;
    43. }

  • 相关阅读:
    谈谈我的「数字文具盒」 - 运行平台
    推荐几个接私活的利器
    Mybaits延迟加载实现原理
    机器学习---决策树分类代码
    ENVI:如何进行图像融合?
    Python毕业论文题目计算机毕业论文django微信小程序校园导航系统
    详解VC++动态链接库中的结构成员规则与调用约定
    Mall脚手架总结(五) —— SpringBoot整合MinIO实现文件管理
    股票量化交易接口的功能逻辑
    梳理自动驾驶中的各类坐标系
  • 原文地址:https://blog.csdn.net/bingbing_bang/article/details/127301324