学习IO基础

一、理解文件

(1)狭义理解

  1. 文件在磁盘里
  2. 磁盘是永久性存储介质,因此文件在磁盘上的存储是永久性的
  3. 磁盘是外设(即是输出设备也是输入设备)
  4. 磁盘上的文件本质是对文件的所有操作,都是对外设的输入和输出简称IO

(2)广义理解

  • Linux下一切皆文件(键盘、显示器、网卡、磁盘......这些都是抽象化的过程)(后面会讲如何去理解)

(3)文件操作的归类认知

  1. 对于0KB的空文件是占用磁盘空间的(会存储文件的属性,属性也是数据)
  2. 文件是文件属性(元数据)和⽂件内容的集合(文件=属性(元数据)+内容)
  3. 所有的文件操作本质是文件内容操作和文件属性操作

**(4)**系统角度

  1. 对⽂件的操作本质是进程(即用户)对文件的操作
  2. 磁盘的管理者是操作系统(OS)
  3. 文件的读写本质不是通过C语言/C++的库函数来操作的(这些库函数只是为用户提供方便),而是通过文件相关的系统调用接口来实现的

补充:

  1. 打开文件就是把文件加载到内存中
  2. 进程可以动态维持当前的工作路径

二、回顾C文件接口

(1)hello.c文件的打开

cpp 复制代码
#include <stdio.h>

int main()
{
 FILE *fp = fopen("myfile", "w");
 if(!fp){
 printf("fopen error!\n");
 }
 while(1);
 fclose(fp);
 return 0;
}

打开的myfile文件在哪个路径下?

  • 在程序的当前路径下,那系统怎么知道程序的当前路径在哪里呢?

可以使用ls /proc/[进程id] -l 命令查看当前正在运行进程的信息:

实践如下1:

  • ps ajx | grep 运行文件名:是 Linux下查看特定进程的常用组合命令(grep是用来过滤结果的文字筛选器)
  • 第一行就是我们过滤出来的结果,就是我们运行的hello文件(进程的PID就是2545600)
  • 第二行是我们运行这个ps ajx | grep命令时,产生的临时进程,可以忽略这里

经过图可知,Linux下还有许多其他进程正在运行,这里我们重点关注两个进程

  • cwd:指向当前进程运行目录的⼀个符号链接
  • exe:指向启动当前进程的可执行文件(完整路径)的符号链接

结论:打开文件,本质是进程打开,所以,进程知道自己在哪里,即便文件不带路径,进程也知道;由此OS就能知道要创建的文件放在哪里

实践如下2:

模拟实现cat命令

我们要打印的内容

实现代码

cpp 复制代码
#include <stdio.h>
#include <string.h>

int main(int argc, char *argv[]) 
{
    if (argc != 2) 
    {
        printf("argv error!\n");
        return 1;
    }
    FILE *fp = fopen(argv[1], "r");
    if (!fp) 
    {
        printf("fopen error!\n");
        return 2;
    }
    char buf[1024];
    while (1) 
    {
        int s = fread(buf, 1, sizeof(buf), fp);
        if (s > 0) 
        {
            buf[s] = 0;
            printf("%s", buf);
        }
        if (feof(fp)) 
        {
            break;
        }
    }
    fclose(fp);
    return 0;
}

运行结果

(2)stdin & stdout & stderr

  • C默认会打开三个输⼊输出流,分别是stdin,stdout,stderr
  • 仔细观察发现,这三个流的类型都是FILE*,fopen返回值类型,文件指针
cpp 复制代码
#include <stdio.h>

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

(3)打开文件的几种方式

cpp 复制代码
r     Open text file for reading. 
      The stream is positioned at the beginning of the file.
 
r+    Open for reading and writing.
      The stream is positioned at the beginning of the file.
 
w     Truncate(缩短) file to zero length or create text file for writing.
      The stream is positioned at the beginning of the file.

w+    Open for reading and writing.
      The file is created if it does not exist, otherwise it is truncated.
      The stream is positioned at the beginning of the file.

a     Open for appending (writing at end of file). 
      The file is created if it does not exist. 
      The stream is positioned at the end of the file.

a+    Open for reading and appending (writing at end of file).
      The file is created if it does not exist. The initial file position
      for reading is at the beginning of the file, but output is always appended to the end                    of the file.

三、文件系统IO

打开文件的方式不仅仅是fopen,ifstream等流式,语言层的⽅案,其实系统才是打开文件最底层的方案。不过,在学习系统文件IO之前,先要了解下如何给函数传递标志位,该方法在系统文件IO接口中会使用到:

(1)接口介绍

cpp 复制代码
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

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

pathname: 要打开或创建的⽬标⽂件
flags: 打开⽂件时,可以传⼊多个参数选项,⽤下⾯的⼀个或者多个常量进⾏"或"运算,构成
flags
参数:
             O_RDONLY: 只读打开
             O_WRONLY: 只写打开
             O_RDWR : 读,写打开
             这三个常量,必须指定⼀个且只能指定⼀个
问权限       O_CREAT : 若⽂件不存在,则创建它;需要使⽤mode选项,来指明新⽂件的访
 
O_APPEND:   追加写

返回值:
         成功:新打开的⽂件描述符
         失败:-1
  • mode_t 理解:mode_t是POSIX 标准定义的整数类型(本质是无符号整型,如 unsigned 或 unsigned int),专门用于表示文件权限与文件类型
  • open 函数具体使用哪个,和具体应用场景相关,如目标文件不存在,需要open创建,则第三个参数表示创建⽂件的默认权限,否则,使用两个参数的open

(2)hello.c写文件

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
 umask(0);
 int fd = open("myfile", O_WRONLY|O_CREAT, 0644);
 if(fd < 0){
 perror("open");
 return 1;
 }
 int count = 5;
 const char *msg = "hello bit!\n";
 int len = strlen(msg);
 while(count--){
 write(fd, msg, len);//fd: 后⾯讲, msg:缓冲区⾸地址, len: 本次读取,期望
写⼊多少个字节的数据。 返回值:实际写了多少字节数据 
 }
 close(fd);
 return 0;
}

(3)hello.c读文件

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main()
{
 int fd = open("myfile", O_RDONLY);
 if(fd < 0){
 perror("open");
 return 1;
 }
 const char *msg = "hello bit!\n";
 char buf[1024];
 while(1){
 ssize_t s = read(fd, buf, strlen(msg));//类⽐write 
 if(s > 0){
 printf("%s", buf);
 }else{
 break;
 }
 }
 close(fd);
 return 0;
}

(4)open函数返回值

在认识返回值之前,先来认识⼀下两个概念:系统调用和库函数

  • 上面的fopen fclose fread fwrite都是C标准库当中的函数,我们称之为库函数 (libc)
  • ⽽open close read write lseek 都属于系统提供的接口,称之为系统调用接口
  • 回忆⼀下我们讲操作系统概念时,画的⼀张图

结论:各系列的函数,都是对系统调用的封装,方便⼆次开发

(5)文件描述符

  • 通过对open函数的学习,我们知道了文件描述符就是一个小整数(数组下标)

5.1 0 & 1 & 2

  • Linux进程默认情况下会有3个缺省打开的⽂件描述符,分别是标准输⼊0,标准输出1,标准错误2
  • 0,1,2对应的物理设备⼀般是:键盘,显示器,显示器;
  • 而现在知道,⽂件描述符就是从0开始的小整数;当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示⼀个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和⽂件关联起来。每个进程都有⼀个指针*files,指向⼀张表files_struct,该表最重要的部分就是包含⼀个指针数组,每个元素都是⼀个指向打开⽂件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着⽂件描述符,就可以找到对应的文件。对于以上原理结论我们可通过内核源码验证: 首先要找到 task_struct 结构体在内核中为位置,地址为: /usr/src/kernels/3.10.0- 1160.71.1.el7.x86_64/include/linux/sched.h (3.10.0-1160.71.1.el7.x86_64是内核版本,可使用 uname -a 自行查看服务器配置,因为这个文件夹只有⼀个,所以也不用刻意去分辨, 内核版本其实也随意)
  • 操作系统要操作文件,都需要转化成文件描述符

5.2 文件描述符的分配规则

直接看代码:

实践1:

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

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

实践2:

关闭0或者2,在看

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

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

结论:fd:是0或者fd是2 ,可见,⽂件描述符的分配规则:在files_struct数组当中,找到当前没有被使用的最小的⼀个下标,作为新的⽂件描述符

5.3 重定向

实践1:关闭文件描述符1

cpp 复制代码
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>

int main()
{
     close(1);
     int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
     if(fd < 0)
     {
         perror("open");
         return 1;
     }
     printf("fd: %d\n", fd);
     fflush(stdout);
     close(fd);
     exit(0);
}

此时,我们发现,本来应该输出到显示器上的内容,输出到了文件myfile当中,其中,fd=1;这 种现象叫做输出重定向。常见的重定向有: > ,>> ,<

重定向的本质是什么?

补充:

  1. 重定向本质:更改数组特定下标内的内容
  2. OS改变fd的指向,语言层并不清楚

理解:语言层只知道 fd 编号,但不知道这个编号在内核里指向哪个文件表项 ------ 内核对语言层隐藏了所有底层映射关系

5.4 认识系统调用dup2

  • oldfd:意思不是老的fd,要被覆盖掉的fd;而是源fd,我们仍需要复制的有效fd
  • newfd:意思不是新的fd,是目标描述符(你指定的、要被替换的 fd 编号)

实践2:

cpp 复制代码
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main() 
{  
    int fd = open("./log", O_CREAT | O_RDWR);
    if (fd < 0) 
    {      
        perror("open");          
        return 1;  
    }
    close(1);
    dup2(fd, 1);
    for (;;) 
    {      
        char buf[1024] = {0};
        ssize_t read_size = read(0, buf, sizeof(buf) - 1);
        if (read_size < 0) 
        {          
            perror("read");
            break;      
        }      
        printf("%s", buf);      
        fflush(stdout);  
    }  
    return 0; 
}

结论:printf是C库当中的IO函数,⼀般往stdout中输出,但是stdout底层访问⽂件的时候,找的还是fd:1,但此时,fd:1下标所表⽰内容,已经变成了myfifile的地址,不再是显示器文件的地址,所以,输出的任何消息都会往⽂件中写入,进而完成输出重定向

四、理解一切皆文件

背景:首先,在windows中是文件的东西,它们在linux中也是文件;其次⼀些在windows中不是⽂件的东西,比如进程、磁盘、显示器、键盘这样硬件设备也被抽象成了文件,你可以使用访问⽂件的方法访问它们获得信息;甚至管道,也是文件;将来我们要学习⽹络编程中的socket(套接字)这样的东西,使用的接口跟⽂件接口也是⼀致的

  • 这种场景我们可以抽象成如下图:

结论:上图中的外设,每个设备都可以有自己的read、write,但⼀定是对应着不同的操作⽅法!!但通过struct file下file_operation中的各种函数回调,让我们开发者只用file便可调取Linux系统中绝⼤部分的资源!!这便是"linux下⼀切皆文件"的核心理解(C语言实现多态!)

五、缓冲区

(1)什么是缓冲区

缓冲区是内存空间的⼀部分;也就是说,在内存空间中预留了⼀定的存储空间,这些存储空间用来缓冲输⼊或输出的数据,这部分预留的空间就叫做缓冲区;缓冲区根据其对应的是输⼊设备还是输出设备,分为输⼊缓冲区和输出缓冲区

(2)为什么要引入缓冲区机制

  1. 读写⽂件时,如果不会开辟对⽂件操作的缓冲区,直接通过系统调用对磁盘进行操作(读、写等),那么每次对⽂件进行⼀次读写操作时,都需要使用读写系统调用来处理此操作,即需要执行⼀次系统调

    ⽤,执行⼀次系统调用将涉及到CPU状态的切换,即从用户空间切换到内核空间,实现进程上下文的切换,这将损耗⼀定的CPU时间,频繁的磁盘访问对程序的执行效率造成很大的影响

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

  3. 又⽐如,我们使用打印机打印文档,由于打印机的打印速度相对较慢,我们先把⽂档输出到打印机相应的缓冲区,打印机再自行逐步打印,这时我们的CPU可以处理别的事情。可以看出,缓冲区就是⼀块内存区,它用在输⼊输出设备和CPU之间,用来缓存数据;它使得低速的输⼊输出设备和告诉的CPU能够协调⼯作,避免低速的输⼊输出设备占用CPU,解放出CPU,使其能够高效率⼯作

(3)缓冲类型

标准I/O提供了3种类型的缓冲区

  • 全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作;对于磁盘⽂件的操作通常使用全缓冲的方式访问
  • 行缓冲区:在行缓冲情况下,当在输⼊和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作;当所操作的流涉及⼀个终端时(例如标准输⼊和标准输出),使用行缓冲方式。因为标准I/O库每行的缓冲区长度是固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/O系统调用操作,默认行缓冲区的大小为1024
  • 无缓冲区:无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用;标准出错流stderr通常是不带缓冲区的,这使得出错信息能够尽快地显示出来

除了上述列举的默认刷新方式,下列特殊情况也会引发缓冲区的刷新:

  1. 缓冲区满时;
  2. 执行flush语句;
  3. 进程结束

六、相关资料与补充

  • 访问文件都必须要有路径
  1. 用户自己提供
  2. 用户使用进程的cwd
  • 打开文件都需要把文件加载到内存
  • 进程会动态维护自己的工作路径
  • 对进程操作,本质是通过CPU对内存中文件操作
  • 文件的打开方式---底层是一些宏,控制这些行为open()、write()等是系统调用
  • 文件描述符本质是数组下标:仅在文件打开期间有效,关闭文件后标识符被系统回收,可重复分配给其他文件
  • task_struct中有*file指向struct files_struct;struct files_struct中有struct file* fd array:文件描述符表-》本质是数组
  • FILE*就是一个结构体(自带缓冲区)
  • 内建命令
  1. 存在形式:集成于shell中
  2. 执行方式:shell直接调用内置函数
  3. 效率:高(无进程开销)
  4. 对shell环境影响:可直接修改当前shell环境
  5. 查找方式:shell内部解析
  • 外部命令
  1. 存在形式:独立可执行的文件
  2. 执行方式:创建子进程执行
  3. 效率:较低(需fork / exec)
  4. 对shell环境影响:仅影响子进程,不改变父shell
  5. 查找方式:通过$PATH环境变量查找
  • 进程(用户)访问文件,是使用文件描述符访问文件对象
  • 重定向的补充
  1. 本质:修改进程的文件描述符与实际文件 / 设备的关联关系
  2. OS更改1(stdin)的指向,语言层并不清楚---语言层只认数字
  3. 文件描述符分配规则:分配文件描述符中,值最小 && 没有被使用过的fd
  4. dup2接口:本质是拷贝内容
  5. 程序替换,不影响进程打开的文件
  • 缓冲区的补充(系统调用的成本)

Ⅰ 用户缓冲区(刷向内核)(语言级缓冲区)

①用户在内存中手动定义的内存区域(malloc、new)用于临时存储待读写的数据

②核心作用:(1)减少IO次数,避免频繁调用write系统调用

(2)数据预处理,可以使数据先在缓冲区中处理,然后给程序逻辑使用

Ⅱ 语言缓冲区 (刷向内核) (交给OS,不一定给磁盘) (算一个用户级缓冲区的子集)

①编程语言的标准或运行时环境自动创建缓冲区,用于缓冲IO数据,对于程序员透明

②核心作用:(1)减少系统调用,比如printf不会每次都触发write系统调用,而是先存 入语言级缓冲区,满足条件后才能批发进入内核

(2)优化输出效率,尤其是字符型IO,降低开销

Ⅲ 内核文件缓冲区

①操作系统内核在内核内存中维护的缓冲区,用于缓存磁盘、网络等硬件设备数据,

是用户态与硬件中的"中间层"

②核心作用:(1)桥接用户和硬件:硬件IO速度极慢,内核缓冲区将硬件数据缓存到 内存,用户进程读写内存而非直接操作硬件,提升整体效率

(2)数据同步,内核负责将缓冲区的脏数据(已修改但未写入硬件)异步刷 新到磁盘,保证数据一致性

③细节1:只要把数据从用户缓冲区,拷贝到内核文件缓冲区就相当于给了硬件

④细节2:客观上就是写给file对应的文件内核缓冲区---》OS---》自主刷新---》OS ---》磁盘;(OS自己的刷新策略:立即刷新 / OS不忙了(进程比较少), 自主刷新)

  • FILE结构体对象中大部分都有指向/维护结构体的指针
  • read / write函数的本质是拷贝
  • 修改文件对象流程:先加载---再修改---再刷新
  • 缓冲区一定不在OS中,exit(1)的缓冲区是语言级的
  • 澄清
  1. FILE是用户态库封装的结构体,fd是内核引索,FILE内部仅保存fd,而非指向
  2. struct file本身不保存缓冲区,而是通过f_mapping指向address_space,由address_space管理页缓存
  3. 只有"文件类fd"会关联页缓存;而网络fd、管道fd等,内存缓冲区由其他管理,但核心逻辑"fd->内核结构体->内核缓冲区"
相关推荐
__雨夜星辰__2 小时前
TypeScript 入门学习笔记(面向对象 + 常用设计模式)
前端·学习·typescript
北岛寒沫3 小时前
北京大学国家发展研究院 中国经济专题 课程笔记(第三课 人口与劳动力)
经验分享·笔记·学习
observe1013 小时前
arm汇编语言学习
学习
jiayong233 小时前
0基础学习VUE3 第 1 课:项目启动流程
前端·vue.js·学习
woniu_maggie3 小时前
SAP消息号修改处理与应用
后端·学习
今天又在摸鱼3 小时前
学习vue前必要的js语法
前端·vue.js·学习
计算机安禾3 小时前
【数据结构与算法】第6篇:线性表(二):单链表的实现(头插法、尾插法)
c语言·数据结构·学习·算法·链表·visual studio code·visual studio
剑飞的编程思维3 小时前
电商系统三类迭代方案评审重点
学习·系统架构·自动化·运维开发·学习方法
鄭郑3 小时前
Figma学习笔记--02
笔记·学习·figma