Linux——基础IO

发布于:2024-12-22 ⋅ 阅读:(14) ⋅ 点赞:(0)

一、对文件的理解

        首先在我们常规的认识中文件都是存储在磁盘中的,磁盘属于永久性存储介质,因此文件在磁盘上的存储是永久性的,也就是说我们存在磁盘里的文件,就算我们的机器突然断电,也是不会消失的。

        我们的操作系统是不能直接对磁盘上的文件进行操作的,我们在想要使用或者修改文件时,就需要把磁盘上的内容加载到内存里,这样操作系统就可以修改内存中的文件,再把修改后的内容回写到磁盘上去,这样才完成了文件的修改。这也就说明了磁盘是一个外部设备,它即是一个输入设备同时也是一个输出设备。所以对磁盘上的文件的所有操作本质上都是对外部设备的输入和输出,简称为IO。

        而站在Linux操作系统的角度下来看文件,那就不仅仅是单纯的把磁盘上的文件看作是文件,而是所有的东西都是文件,比如说键盘、显示器、网卡、磁盘这样外部设备,全都会被Linux操作系统看成是文件。

        想要理解文件,首先一定要对文件的组成有一定的了解,这里就可以先思考一个问题:一个0KB的空文件,他需要占据磁盘空间吗?这个问题的答案是肯定的,因为一个文件它由两个部分组成:文件的内容和文件的属性。0KB指的是这个文件存储的内容是0KB,但是这个文件在创建以后它也有自己的一系列的属性,比如说这个文件的创建时间、最近一次被修改的时间、这个文件的存储的内容的大小等等,这样都属于是文件的属性,这些属性也是文件的一部分也都是需要被记录下来的,所以0KB的空文件也是需要占用磁盘空间的。所以总的来说,对文件的操作不仅是对文件内容的操作,同样也需要对文件的属性进行操作。

         操作系统本身是不会对文件进行操作的,对文件进行操作的是操作系统里的进程,也就是说对文件操作的本质是进程对文件的操作。磁盘属于外部设备,它的管理者是我们的操作系统,我们在使用文件之前都需要打开文件,所以在操作系统内部,需要用一些数据结构来描述这些被打开的文件以便操作系统来进行管理。在我们之前的学习中,我们都是使用C语言或者C++提供的库函数来对文件进行操作的,但是实际上这些库函数并没有实现对文件读写的功能,实际上还是通过文件相关的系统调用接口来实现的。

二、系统文件I/O

        之前我们学习我们学习过的文件打开文件的方式比如说fopen,istream、ostream等方式,都是C语言或者C++库给我们封装好的,这个属于语言层次的打开方式。比如说我们的fopen的返回值是一个FILE*的结构体指针,这个结构体就是C语言提前给我们定义好的,里面存储着这个文件的各种信息,而我们的C语言程序在启动的时候会默认打开三个输入输出流,分别是stdin、stdout和stderr,这三个流的类型都是FILE*。同样在打开方式上,C语言也进行了一次封装,我们常用的r表示以只读方式打开文件、w表示以只写方式打开文件、a表示以追加方式打开文件还有r+、w+、a+,这几种打开方式的具体有什么不同可以参考一下这篇博客:fopen中mode参数 r, w, a, r+, w+, a+ 具体区别。但是总的来说,C语言的库提供的文件操作系列的函数也只是对系统调用接口进行了封装。

        1、标志位的传递

        操作系统中传递标志位采用的是位图的方式,因为这种方式只需要一个参数,就能记录多种标志,并且我们并不需要在意标志位传递的顺序。我们需要检查传递进来的信息对应的标志位上是否有信息,就可以知道需要做什么操作。既可以节省空间,又能提高效率。

        2、系统调用的介绍

write:

        函数原型:ssize_t write (int fd, const void *buf, size_t count);

        fd:写入数据的目标文件的文件描述符

        buf:要写入的数据缓冲区

        count:要写入数据的长度

        当write函数写入数据成功时返回成功写入的数据长度,当写入失败时返回-1

read:

        函数原型:ssize_t read(int fd, void *buf, size_t count);

        fd:要读出数据的目标文件的文件描述符

        buf:要读出的数据缓冲区

        count:要读出数据的长度

        当read函数成功读出数据时返回成功读出数据的长度,当读出失败时返回-1,当读到文件位时返回0。

open:

        函数原型: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

        具体使用哪个open函数和具体的使用场景相关,如果目标文件不存在,需要open来创建,第三个参数就表示创建文件时的默认权限,如果不需要创建文件,则使用两个参数的open。

        Linux进程在默认情况下会有三个缺省打开的文件描述符,分别时标准输入0,标准输出1,标准错误2。这三个文件描述符对应的物理设备一般是:键盘、显示器、显示器。

        3、文件描述符

        文件描述符就是从0开始的小整数。当我们打开文件的时候,操作系统在内存中药创建相应的数据结构来描述目标文件,也就是file结构体。应该file结构体就代表了一个已经打开的文件对象。进程执行open系统调用就要让进程和文件关联起来。所以每个进程都有一个指针files,指向一张file_struct的表,这张表最重要的一个部分就是一个指针数组,这个数组里的每个元素都是指向了一个被打开文件的指针。所以本质上来说,文件描述符就是这个指针数组的下标,我们拿着文件描述符,就能从进程的files指向的这种表中取到对应的被打开文件的信息,进而对这个文件进行操作。

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

        前面我们也知道了Linux进程默认打卡的文件描述符有三个,其中1表示标准输出。在了解了文件描述符的分配规则以后,假如我们主动的把1关闭了,那我们再打开一个文件,这个文件的文件描述符会是什么呢?答案肯定就是1。我们之前一些列对屏幕的操作也就是对标准输出的操作,本质上是定位到文件描述符1的位置,此时的1已经被我们新打开的文件替换了,所以此时再执行向屏幕打印信息的操作就不会打印到我们的屏幕上了,就会被打印到我们打开的这个文件当中去了,这个现象就叫做输出重定向。

        比如说C语言库中的printf是一个 IO函数,一般来说它是向stdout中输出信息,stdout在底层访问文件的时候找的还是文件描述符为1的这个文件,但是因为此时文件描述符为1所指向的这个文件信息已经被替换成了我们重新打开的文件,所以输出的时候所有的信息都会向文件中写入。输入重定向和追加重定向的原理也是相同的,只不过是改变的文件描述符对应的文件信息或者是打开文件时的选项不同而已。

        重定向的本质就是在不改变文件描述符的情况下,改变文件描述符对应的文件信息,进而实现改变所操作的文件。

        我们还可以使用dup2系统调用来实现重定向。函数的原型如下:

         #include <unistd.h>
        int dup2(int oldfd, int newfd);
        这个函数的作用是将newfd指向的文件信息改变成oldfd指向的文件信息,如果newfd指向的文件已经被打开,则会先关闭newfd指向的文件;如果newfd和oldfd指向同一个文件,则会直接返回newfd。

三、一切皆文件

        首先在Windows中是文件的东西,在Linux中也是文件;其次一些在Windows当中不是文件的东西,比如说进程、磁盘、显示器、键盘这样的硬件设备也被抽象成了文件,我们可以通过访问文件的方法访问他们获取信息。

        这样做的好处是开发者仅需一套API和开发工具,即可调用Linux系统中绝大部分的资源。比如说Linux操作系统中几乎所有的读操作都可以使用read函数来进行;几乎所有的更改操作都可以使用write函数来进行,无论操作的对象是文件还是一些硬件,都是一样的。

        之前我们说过,当我们打开一个文件的时候,操作系统为了管理所打开的文件,就需要为这个文件创建一个file结构体,这个结构体的部分内容如下:

struct file {
    ...
    struct inode *f_inode; /* cached value */
    const struct file_operations *f_op;
    ...
    atomic_long_t f_count; // 表⽰打开⽂件的引⽤计数,如果有多个⽂件指针指向它,就会增加f_count的值。
    unsigned int f_flags; // 表⽰打开⽂件的权限
    fmode_t f_mode; // 设置对⽂件的访问模式,例如:只读,只写等。所有的标志在头⽂件<fcntl.h> 中定义
    loff_t f_pos; // 表⽰当前读写⽂件的位置
    ...
} __attribute__((aligned(4)));

        值得关注的是struct file中有一个指针f_op指向了一个file_operations的结构体,这个结构体中的成员除了struct module* owner其余都是函数指针

struct file_operations {
    struct module *owner;
    //指向拥有该模块的指针;
    loff_t (*llseek) (struct file *, loff_t, int);
    //llseek ⽅法⽤作改变⽂件中的当前读/写位置, 并且新位置作为(正的)返回值.
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    //⽤来从设备中获取数据. 在这个位置的⼀个空指针导致 read 系统调⽤以EINVAL("Invalid argument") 失败. ⼀个⾮负返回值代表了成功读取的字节数( 返回值是⼀个"signed size" 类型, 常常是⽬标平台本地的整数类型).
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    //发送数据给设备. 如果 NULL, -EINVAL 返回给调⽤ write 系统调⽤的程序. 如果⾮负, 返回值代表成功写的字节数.
    ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long,loff_t);
    //初始化⼀个异步读 -- 可能在函数返回前不结束的读操作.
    ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long,loff_t);
    //初始化设备上的⼀个异步写.
    int (*readdir) (struct file *, void *, filldir_t);
    //对于设备⽂件这个成员应当为 NULL; 它⽤来读取⽬录, 并且仅对**⽂件系统**有⽤.
    unsigned int (*poll) (struct file *, struct poll_table_struct *);
    int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
    int (*mmap) (struct file *, struct vm_area_struct *);
    //mmap ⽤来请求将设备内存映射到进程的地址空间. 如果这个⽅法是 NULL, mmap 系统调⽤返回 -ENODEV.
    int (*open) (struct inode *, struct file *);
    //打开⼀个⽂件
    int (*flush) (struct file *, fl_owner_t id);
    //flush 操作在进程关闭它的设备⽂件描述符的拷⻉时调⽤;
    int (*release) (struct inode *, struct file *);
    //在⽂件结构被释放时引⽤这个操作. 如同 open, release 可以为 NULL.
    int (*fsync) (struct file *, struct dentry *, int datasync);
    //⽤⼾调⽤来刷新任何挂着的数据.
    int (*aio_fsync) (struct kiocb *, int datasync);
    int (*fasync) (int, struct file *, int);
    int (*lock) (struct file *, int, struct file_lock *);
    //lock ⽅法⽤来实现⽂件加锁; 加锁对常规⽂件是必不可少的特性, 但是设备驱动⼏乎从不实现它.
    ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *,int);
    unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned
    long, unsigned long, unsigned long);
    int (*check_flags)(int);
    int (*flock) (struct file *, int, struct file_lock *);
    ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *,
    size_t, unsigned int);
    ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *,
    size_t, unsigned int);
    int (*setlease)(struct file *, long, struct file_lock **);
};

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

        也就是说我们在Linux中把设备都抽象成了文件,既然是文件,那么在操作系统中就一定会有描述它的file结构体,虽然每个硬件都有自己不同的属性和操作方式,file结构体中一定会有表示不同硬件的方法,但是每个硬件都可以有自己的read、write,同时read和write一定对应着不同的操作方法,所以通过时struct file下file_operations中的各种函数回调,就能实现在使用通过一个结构体file来描述不同的硬件时,通过相同的read、write函数来进行不同的操作。这也就意味着我们只需要使用read、write就能操作Linux中绝大部分我资源了。

四、缓冲区

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

        在读写文件时,如果不会开辟文件操作的缓冲区,直接通过系统调用对磁盘进行操作的话,每对文件进行一次操作,就需要使用一次对应的系统调用来处理此次操作,执行系统调用时会涉及到CPU的状态转化,频繁的访问磁盘会对程序的执行效率造成很大的影响。

        所以为了减少使用系统调用的次数,提供效率,我们就可以采用缓冲机制。比如说我们要从磁盘中读取信息,可以在对磁盘进行操作的时候,将一大块信息从磁盘中读取到我们的缓冲区中,以后需要访问这部分的数据的时候,就不需要使用系统调用,等到缓冲区中的数据取完了以后,再到磁盘中去读取,这样就可以减少读取磁盘的读写次数,其次缓冲区在内存中,计算机对缓冲区的操作速度大大快于磁盘操作,所以缓冲区还可以大大提高计算机的运行速度。

        在标准I/O中提供了三种缓冲区:

        全缓冲区:这种缓冲方式要求填满整个缓冲区后才进行I/O系统调用操作。对于磁盘文件的操作通常使用全缓冲的方式访问。

        行缓冲:在行缓冲情况下,在输入和输出中遇到换行符时,标准I/O库函数将会执行系统调用操作。当所操作的流涉及一个终端时(例如标准输入和标准输出),使用行缓冲方式。因为标准I/O库每行的缓冲区长度时固定的,所以只要填满了缓冲区,即使还没有遇到换行符,也会执行I/O系统调用操作,默认行缓冲区的大小为1024。

        无缓冲区:无缓冲区是指标准I/O库不对字符进行缓存,直接调用系统调用。标准错误流stderr通常是不带缓冲区的。

        除了上述列举的默认刷新方式,缓冲区满以及执行flush语句也会引发缓冲区的刷新。