《Linux学习笔记》——文件IO

文章目录

  • 前言
  • C语言操作文件的函数
  • 系统接口
  • 文件描述符
  • 文件系统
  • 动态库和静态库

前言

  在系统角度理解文件:文件=内容+属性,内容和属性都是数据,对于文件的所有操作无外乎对于文件内容操作和对于文件属性操作

  文件在磁盘存放,磁盘是硬件,只有操作系统才有权利访问硬件。用户访问文件先写代码再编译形成可执行程序,该程序运行起来才能访问文件,所以访问文件本质上是进程在访问文件。进程访问文件需要操作系统提供的文件类的系统调用接口来访问。

  系统调用接口比较难,语言上对这些接口进行了封装,为了让接口可以更好的使用,导致了不同的语言有不同的文件访问接口,但是封装的是同一个操作系统接口,而操作系统接口只有一套,这也是学习它的价值。其次,言要实现跨平台性,就需要对操作系统的接口进行封装,如果语言不提供对文件的接口,那么访问文件的操作就必须直接使用操作系统的接口,这就导致了跨平台不兼容的问题,C/C++使用条件编译+动态裁剪的方法,实现跨平台。

  显示器也是硬件,向显示器写入和向文件写入没有任何区别。只不过显示器更加直观,因为Linux下一切皆文件。对于普通文件而言,有读有写,对于显示器有写入,而对于键盘有读取。站在系统的角度,能够被读取或被写入的设备就是文件,狭义上的文件是普通的磁盘文件,广义上的文件也包含了几乎所有的外设。


C语言操作文件的函数

  C语言提供了众多操作文件的函数,例如fopen、fclose、fseek、fprintf、fscanf、fgetc、fputc、fwrite、fread等函数。在操作文件时,经常会遇到一个名词叫做当前路径,当前路径是每当一个进程运行起来的时候,每个进程都会记录自己当前的所处的工作路径。另外要注意,当向文件写入字符串的时候,不需要读取\0,因为它是C语言的规定,文件不需要遵守,只保存有效数据。

  C默认会打开三个输入输出流,分别是stdin, stdout, stderr;仔细观察发现,这三个流的类型都是FILE*,并且fopen返回值类型是文件指针。


系统接口

  操作文件,除了上述C等语言接口,还可以采用系统接口来进行文件访问。并且C语言提供的库函数(libc)必须实现系统调用接口的封装。fopen、fclose、fwrite、fread函数分别调用系统接口open、close、write、read。
《Linux学习笔记》——文件IO_第1张图片

  如上图所示,系统调用接口和库函数的关系,一目了然。所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。


open接口介绍

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

pathname: 要打开或创建的目标文件
flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
mode: 创建文件时,设置文件的权限,但是要考虑umask文件掩码的影响。

参数:
	O_RDONLY: 只读打开
	O_WRONLY: 只写打开
	O_RDWR : 读,写打开
	这三个常量,必须指定一个且只能指定一个
	O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
	O_APPEND: 追加写
	
返回值:
	成功:新打开的文件描述符
	失败:-1

其中,参数是使用位图和宏定义标志位的方式传递给open接口。例如:int fd = open("log.tx",O_WRONLY|O_CREAT );


位图+宏定义标志位案例

#include
#define ONE 0x01    //0000 0001
#define TWO 0x02    //0000 0010
#define THREE 0x04  //0000 0100

void show(int flags)
{
    if(flags & ONE) printf("ONE\n");
    if(flags & TWO) printf("TWO\n");
    if(flags & THREE) printf("THREE\n");
}

int main()
{
    show(ONE);
    show(ONE|TWO);
    show(ONE|TWO|THREE);
}

文件描述符


  当打开一个文件时会惊讶的发现,open的返回值是3,012去哪里了呢?

#include 
#include 
#include 
#include 
int main()
{
 	int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666); 
    printf("open success, fd: %d\n", fd);
    return 0;
}

上面代码输出

open success, fd: 3

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


验证:标准输入0, 标准输出1, 标准错误2

#include 
#include 
#include 
#include 
#include 
int main()
{
	char buf[1024];
	ssize_t s = read(0, buf, sizeof(buf));
	if(s > 0)
	{
		buf[s] = 0;
		write(1, buf, strlen(buf));
		write(2, buf, strlen(buf));
	}
	return 0;
}

输入输出还可以采用以上方式


文件描述符的概念

  文件描述符就是从0开始递增的小整数。当打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来

  每个进程都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件。

  在内核中,OS都要为管理每一个被打开的文件创建file结构体,其中包含了一个被打开文件的所有信息,只要找到了file对象,就找到了一个文件。file结构体再通过某种数据结构组织起来实现文件管理。

《Linux学习笔记》——文件IO_第2张图片


fwrite()是怎么操作文件的

  用户使用C语言提供的fwrite()函数时,一定会传入FILE* 指针,FILE* 中一定包含了fd,并且fwrite()封装了write系统接口,就可以执行write(fd,…)进入了操作系统内部,进程会执行操作系统内部的write方法,并且进程的tast_struct结构体中又包含了文件描述符表*files*files 指向了files_strut结构体,files_strut结构体中包含了file* fd_array[]指针数组,在配合上刚刚传入的fd,通过fd_array[fd]就找了一个内存文件的所有信息struct file,继而就可以进行对于文件的操作。


fd的分配规则

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


重定向

#include 
#include 
#include 
#include 
#include 
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);
}

   如果关闭1号文件描述符,执行上一段代码,可发现,本来应该输出到显示器上的内容,输出到了文件 myfile 当中,因为,myfile文件的fd是1。这就是输出重定向。常见的重定向有: >, >>, <


重定向的原理
《Linux学习笔记》——文件IO_第3张图片
重定向的本质

  重定向的本质其实就是在操作系统内部更改fd对应的内容指向!


重定向的系统调用dup2

dup2的函数原型

#include 
int dup2(int oldfd, int newfd);

  将oldfd拷贝给newfd,最后newfd的指针指向被关闭,newfd指针指向oldfd的指针指向。当进行输出重定向的时候,使用dup2(3,1),让1不指向显示器而指向3对应的文件log.txt,最终3和1号文件描述符的指向同样的内容,导致本来应该向显示器打印的内容变为向log.txt文件进行写入。

#include 
#include 
#include 
int main()
{
	int fd = open("log.txt", O_CREAT | O_RDWR | O_CREAT);
	if (fd < 0) 
	{
		perror("open");
		return 1;
	}
    dup2(fd,1);
	fprintf(stdout,"success\n");
	return 0;
}

Linux下一切皆文件

  Linux下一切皆文件是Linux的设计哲学,体现在操作系统的软件设计层。站在驱动开发的角度,底层不同的硬件(磁盘、显示器、键盘、网卡、显卡),一定对应不同的操作方法(包含I/O),所有的设备都有自己专属的read、write等接口,且代码的实现一定是不同的。Linux是用C语言写的,在描述一个文件的时候使用file结构体来描述,file里面包含了read、write等函数的函数,每个函数指向对应的硬件专属的read、write方法。这样在操作系统上层来看就没有任何硬件的差别,看待所有文件的方式,都统一变为了struct file。而在Linux下一切皆可以被描述为文件,此文件系统属于virtual file system,简称为VFS。


缓冲区

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

  缓冲区的价值:当你上大学的时候,忘记带学生证,你让家人给你送来。这时候有两种选择,一个是直接给你送过来,在计算机的角度也就是将数据直接送达,称为写透模式,或者是让顺丰送来,在计算机的角度也就是将数据间接送达,称为写回模式,此模式快速且成本低,提高用户的响应速度,提高整机效率

  缓冲区的刷新策略:缓冲策略分为一般策略和特殊策略。一般策略包含立即刷新,行刷新和满刷新;特殊情况为用户强制刷新(fflush)和进程退出。一般而言行缓冲的设备文件是显示器,全缓冲的设备文件是磁盘文件。所有的设备都永远倾向于全缓冲,其他刷新策略是结合具体情况做的妥协(当然用户也可以自定义规则),因为缓冲区满了才刷新需要更少此的IO操作,也就是更少次的外设访问,可以提高效率。因为和外设IO时,数据量的大小不是主要矛盾,和外设的准备IO才是最耗费时间的。

  缓冲区的存在位置?

#include 
#include 
#include 
#include 
#include 
#include 

int main()
{
    // C语言提供的
    printf("hello printf\n");
    fprintf(stdout, "hello fprintf\n");
    const char *s = "hello fputs\n";
    fputs(s, stdout);

    // OS提供的
    const char *ss = "hello write\n";
    write(1, ss, strlen(ss));

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

   当直接执行上述代码时

hello printf
hello fprintf
hello fputs
hello write

   当重定向到文件中,./myfile > log.txt 再打印文件内容cat log.txt

hello write
hello printf
hello fprintf
hello fputs
hello printf
hello fprintf
hello fputs

   同样一个程序向显示器打印输出四行文本,而向磁盘中的普通文件打印时,变成了七行,其中C语言的库函数打印了2次,而系统调用接口只打印了1次。可以证明所谓的缓冲区绝对不是OS提供的,否则上面的代码结果应该相同,所以缓冲区是由C标准库维护的。

   如果向显示器中打印,刷新策略为行刷新,最后执行fork的时候,一定是函数执行完了并且数据已经被刷新了,此时fork没有意义了。而进行向磁盘文件打印的时候,刷新策略变为了全缓冲,此时代码中的’\n’也就没有了意义。fork的时候,代码已经执行完了,但是对应的数据还没有刷新,存在于当前进程的用户空间的C标准库提供的缓冲区中,这部分的数据是父进程的数据,当fork的时候,会发生写时拷贝,此时子进程会拷贝父进程存在于缓冲区的数据存放到自己进程的缓冲区中。而return 0时,进程退出发生强制刷新,所以C语言库函数的打印函数打印了两次。而操作系统接口的库函数会直接直接传给内核空间(内核也有缓冲区),该数据属于内核数据了,子进程无法写时拷贝。

   C语言打开文件,FILE*指针指向的FILE结构体内部不仅仅封装了fd,还封装了语言层的缓冲区结构。


模拟C语言实现缓冲区

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#define NUM 1024

struct MyFILE_{
    int fd;
    char buffer[1024];
    int end; //当前缓冲区的结尾
};

typedef struct MyFILE_ MyFILE;

MyFILE *fopen_(const char *pathname, const char *mode)
{
    assert(pathname);
    assert(mode);
    MyFILE *fp = NULL;
    if(strcmp(mode, "r") == 0)
    {
    }
    else if(strcmp(mode, "r+") == 0)
    {
    }
    else if(strcmp(mode, "w") == 0)
    {
        int fd = open(pathname, O_WRONLY | O_TRUNC | O_CREAT, 0666);
        if(fd >= 0)
        {
            fp = (MyFILE*)malloc(sizeof(MyFILE));
            memset(fp, 0, sizeof(MyFILE));
            fp->fd = fd;
        }
    }
    else if(strcmp(mode, "w+") == 0)
    {
    }
    else if(strcmp(mode, "a") == 0)
    {
    }
    else if(strcmp(mode, "a+") == 0)
    {
    }
    else{
        //什么都不做
    }
    return fp;
}

//C标准库中的实现!
void fputs_(const char *message, MyFILE *fp)
{
    assert(message);
    assert(fp);
    strcpy(fp->buffer+fp->end, message); //abcde\0
    fp->end += strlen(message);
    printf("%s\n", fp->buffer);
    //暂时没有刷新, 刷新策略是用户通过执行C标准库中的代码逻辑,来完成刷新动作
    //这里效率提高,体现因为C提供了缓冲区,那么我们就通过策略,减少了IO的执行次数(不是数据量)
    if(fp->fd == 0)
    {
        //标准输入
    }
    else if(fp->fd == 1)
    {
        //标准输出
        if(fp->buffer[fp->end-1] =='\n' )
        {
            //fprintf(stderr, "fflush: %s", fp->buffer); //2
            write(fp->fd, fp->buffer, fp->end);
            fp->end = 0;
        }
    }
    else if(fp->fd == 2)
    {
        //标准错误
    }
    else
    {
        //其他文件
    }
}

void fflush_(MyFILE *fp)
{
    assert(fp);
    if(fp->end != 0)
    {
        //暂且认为刷新了--其实是把数据写到了内核
        write(fp->fd, fp->buffer, fp->end);
        syncfs(fp->fd); //将数据写入到磁盘
        fp->end = 0;
    }
}

void fclose_(MyFILE *fp)
{
    assert(fp);
    fflush_(fp);
    close(fp->fd);
    free(fp);
}

int main()
{
    MyFILE *fp = fopen_("./log.txt", "w");
    if(fp == NULL)
    {
        printf("open file error");
        return 1;
    }
    fputs_("one: hello world", fp);
    fclose_(fp);
}

缓冲区验证

#include 
#include 
#include 
#include 
#include 
#include 

int main()
{
    close(1);
    int fd = open("log.txt",O_CREAT|O_WRONLY|O_TRUNC,0666);
    if(fd<0)
    {
        perror("open");
        return 0;
    }
    printf("hello world:%d\n",fd); //stdout是一号描述符,数据暂存到stdout的缓冲区中
    //fflush(stdout);                //刷新缓冲区
    close(fd);                     //数据再缓冲区中,一旦把fd关闭了,数据就无法刷新!
    return 0;
}

   通过实验可以看出log.txt中并没有任何的数据,除非刷新缓冲区。


stdout和stderr的区别

   stdout和stderr分别对应fd为1和2,stdout和stderr对应的都是显示器文件,但是它们两个是不同的,可以认为是同一个显示器文件被打开了两次。当做重定向输入的时候,只会重定向1号文件描述符。一般而言,如果程序运行有问题的画, 建议使用stderr或者cerr来打印。如果是常规文本,则使用cout或stdout打印。


文件系统

背景知识

   内存是掉电易失存储型介质,磁盘是永久性存储介质(SSD,U盘,flash卡,光盘,磁带也是永久性存储介质)。磁盘中存在着大量未被打开的磁盘级文件,那么如何进行对于磁盘文件进行分门别类的存储,用来支持更好的存取呢?

   磁盘的物理结构:磁盘是一个外设并且还是计算机中唯一的机械设备,所以磁盘比较慢。磁盘结构有磁盘盘片、磁头、伺服系统、音圈马达等。盘面上会存储二进制数据,由南北极来表示,所谓的向磁盘写入就是磁头放电改变磁盘上的南北极/正负性。

《Linux学习笔记》——文件IO_第4张图片
   磁盘的存储结构:扇区是磁盘存储的基本单位,再物理上把数据写入到磁盘就是要找到对应的扇区(512字节)。首先要找到在哪一个面,在哪一个磁道/柱面上,最后确定在哪一个扇区,这种寻址方式成为CHS寻址,如果有了CHS就可以锁定扇区,那么所有的扇区就都能找到了。

《Linux学习笔记》——文件IO_第5张图片
《Linux学习笔记》——文件IO_第6张图片

   磁盘的抽象结构:磁盘的盘片可以想象为一个线性结构。从而可以把一个磁盘想象为一个数组,想要访问每一个扇区的时候只要知道数组的下标再转化为内CHS即可。将数据存储到磁盘就是将数据存储到数组中,从而对于磁盘的管理也就变为了对于数组的管理。

   在分区后,对于磁盘的管理就是对一个小分区的管理,每一个分区又分为多个块组,对于每个块组实管理也就实现了对于磁盘的管理。

《Linux学习笔记》——文件IO_第7张图片

  • Super Block:存放文件系统本身的结构属性信息。记录的信息主要有:bolck 和 inode的总量,未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息,Super Block的信息被破坏,可以说整个文件系统结构就被破坏了。
  • Data Block:扇区是512字节,但是OS和磁盘IO的基本单位是4KB,因为512字节太小了,可能导致多次IO降低效率,同时也可将硬件和OS进行解耦。Data Block是多个4KB大小的集合,Linux存储文件时是将内容和属性分开存储的,Data Block中保存的都是文件的内容。
  • inode Table:inode一般是一个大小为128自己的空间,保存的是对应文件的属性。inode Table是该块组内,所有文件的inode空间集合,需要标识唯一性,每一个inode块,都需要一个inode编号。一般而言,一个文件对应一个inode和一个inode编号。
  • Block Bitmap:Block Bitmap中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用。使用位图的方式,bit位和特定的block是一一对应的,比特位1表示占用,0表示可用。
  • inode Bitmap:每个bit位表示一个inode是否空闲可用。
  • Group Descriptor Table:块组描述符,描述块组属性信息。

   将块组分割成为上面的内容,并且写入相关的管理数据,每一个快组都这么干,整个分区就被写入了文件系统信息 ! 这也就是格式化的过程。

   一个inode对应一个inode属性节点,一个文件可以由多个block,当有了inode编号就能找到inode属性节点,也就是inode结构体,其中包含一个数组blocks,里面的内容就是该文件使用到了哪些data block块(如果文件太大,data block会存储其他块的块号,形成一种多叉树)。这时,这时就有了inode 所保存的文件属性和block保存的文件内容,从而对于文件进行操作。


inode和文件名

   找到文件首先要找到inode编号进而找到inode节点空间从而拿到了文件的属性和内容。Linux中,inode属性里面没有文件名这样的说法。在同一个目录下,不会存在文件名相同的情况,且目录也是文件,目录也有自己的inode和datablock,目录的datablock存放目录中的文件名和inode编号的映射关系。


软硬链接

   软硬链接的区别在于有没有独立的inode,软链接有独立的inode,也就是说软连接是一个独立的文件,硬链接没有独立的inode,所以其不是一个独立的文件。软链接就如同window下的快捷方式,可以理解为软链接的文件内容是指向文件对应的路径。创建硬链接就是在指定目录下,建立了文件名和指定inode的映射关系,在inode属性中存在硬链接数,使用引用计数的方法记录关联的文件数。默认创建一个目录时,硬链接引用计数是2,是因为自己目录名和自己目录内部的.


动态库和静态库

  • 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库。
  • 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
  • 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接。
  • 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
  • 站在使用库的角度,库的存在可以大大减少开发的周期,提高软件本身的质量。

生成静态库

makefile生成静态库案例:

libhello.a : mymath.o myprint.o
	ar -rc libhello.a mymath.o myprint.o
mymath.o : mymath.c
	gcc -c mymath.c -o mymath.h
myprint.o : myprint.c
	gcc -c myprint.c -o myprint.o

ar是gnu归档工具,rc表示replace and create


使用静态库

gcc main.c -L. -lmymath

-L 指定库路径 -l 指定库名

库搜索路径为

  • 从左到右搜索-L指定的目录。
  • 由环境变量指定的目录 (LIBRARY_PATH)
  • 由系统指定的目录 /usr/lib 和 /usr/local/lib

生成动态库

makefile生成动态库案例:

libhello.so : mymath.o myprint.o
	gcc -shared mymath.o myprint.o -o libhello.so
mymath.o : mymath.c
	gcc -c -fPIC mymath.c -o mymath.o
myprint.o : myprint.c
	gcc -c -fPIC myprint.c -o myprint.o

shared表示生成共享库格式,fPIC表示产生位置无关码


使用动态库

gcc main.o -o main –L. -lhello

l : 链接动态库,只要库名即可
L:链接库所在的路径

你可能感兴趣的:(Linux,linux,io,文件系统)