( 1 ) (1) (1)写文件
#include
int main()
{
FILE *fp=fopen("./log.txt","w");
if(fp==NULL)
{
printf("open failed\n");
return 1;
}
int cnt=5;
while(cnt--)
{
const char *msg="hello world";
fputs(msg,fp);
}
fclose(fp);
}
在这里我们需要明确一个文件被创建出来的过程:
在test.c
文件程序中我们使用文件函数创建文件,需要注意这时文件还没有被创建出来,其只是一行代码。接着我们让源文件生成可执行程序,运行可执行程序,程序变成进程被CPU
调度,当运行到文件部分代码时,系统会根据进程的cwd
在对应目录创建文件。
( 2 ) (2) (2)读文件
#include
int main()
{
FILE *fp=fopen("./log.txt","r");
if(fp==NULL)
{
printf("open failed\n");
return 1;
}
char buffer[64];
while(fgets(buffer,sizeof(buffer),fp))
printf("%s",buffer);
if(!feof(fp))
printf("quit is not normal\n");
else
printf("quit is normal\n ");
fclose(fp);
}
注:以写方式打开文件,若文件不存在会创建文件,而读方式则不会。
在前面我们说过,C
程序默认会打开三个输入输出流 stdin stdout stderr
通过man
指令查看这三个流的信息。
在语言层面可以看出,这三个流的类型都是文件,也就是三个文件流,对应到硬件上就是键盘,显示器,显示器,所以这三个文件也可以看作是键盘文件,显示器文件,显示器文件。还是那个我们一直强调的概念:Linux下一切皆文件
既然流也是文件,那使用fputs函数能否将文件写到标准输出stdout中?
#include
int main()
{
const char*p="hello world\n";
fputs(p,stdout);
}
接下来我们介绍文件的系统调用接口:
头文件:#include
#include
#include
函数原型:int open(const char* pathname,int flags,mode_t mode);
作用:打开文件
返回值:打开成功返回文件描述符,失败返回-1
注:对于文件描述符我们先简单理解:文件描述符就是一个数字,其和文件有着对应的联系。
参数一:要打开文件的路径
参数二:以读/写/追加的方式打开文件。
对于参数二我们可以传入如下这些参数选项,如果想传入多个,那么参数选项之间用|
隔开。
如:
int fd=open(参数一,O_WRONLY|O_CREAT,参数三);
//含义:创建文件并且写文件
O_RDONLY:只读文件
O_WRONLY:只写文件
O_RDWR:读写文件
O_CREAT:创建文件
O_APPEND:在文件中追加内容
对于系统调用接口和语言提供的函数接口我们可以进行一个对比:
同样是打开文件,在语言层面上,如C
语言为例,使用fopen
函数以"w"
的方式打开文件,这时用户可以做到创建文件,并且写文件。
在系统调用接口层面,如果想要创建文件并且写这个文件,那么在参数层面我们就需要传入O_WRONLY|O_CREAT
注:所以说,大部分语言的文件操作接口都是操作系统底层系统调用的封装,语言不可直接越过操作系统访问硬件,所有的语言对文件的操作,都必须贯操作系统,最终都是让操作系统来读写。
在底层原理中,参数二又是如何实现的?
参数二的类型为int
,一个int
由32
个bit
组成。不同的二进制序列可以表示不同的含义。如00001000
可以对应创建文件,00000001
可以对应只写文件。参数二底层实现也正是应用了这一原理:通过特定的一个bit
位置为1
表示不同的操作,事实上O_RDONLY,O_CREAT这些参数
都是宏,其对应特定的数值。所以在函数内部,操作系统可以根据传进来的参数和这些宏进行按位与操作,就可以判断是否要执行这个操作。
在/usr/include/bits/fcntl-linux.h
目录下,我们可以证实这些参数都是一些宏:
参数三:文件的权限,用于设置被创建出来文件的权限。
int fd=open("./log.txt",O_CREAT,0644);
这种设置文件权限的方式同样会受到权限掩码的影响,最终权限=设置权限~权限掩码
注:如果文件已存在就不需要设置权限了。
头文件:#include
函数原型:int close(fd)
作用:关闭文件描述符对应的文件
头文件:#include
函数原型:ssize_t write(int fd,const void *buf,size_t count)
作用:将buf指向的用户缓冲区内容的count个字节数据写入文件描述符指向的文件
返回值:写入成功,返回实际写入的字节个数,写入失败,返回-1
在我们写入文件的过程中,我们需要写入\0吗?
不需要,
\0
是C
语言层面的内容,系统不关心,其只关心写入的内容。文件层面不会将\0
作为字符串结束的标志‘
#include
#include
#include
#include
#include
#include
int main()
{
int fd=open("./log.txt",O_CREAT|O_WRONLY,0644);
if(fd<0)
{
printf("open failed\n");
return 1;
}
const char* buf="Hello linux\n";
write(fd,buf,strlen(buf));
close(fd);
}
注:如果想要使用write
写入内容,在open
的时候对应需要传入参数O_WRONLY
。
头文件:#include
函数原型:ssize_t read(int fd,void *buf,size_t count)
作用:将文件描述符fd指向的文件中count字节的数据读取到用户缓冲区中
返回值:读取成功,返回实际读取的字节个数,读取失败返回-1
#include
#include
#include
#include
#include
#include
int main()
{
int fd=open("./log.txt",O_RDONLY);
if(fd<0)
{
printf("open failed\n");
return 1;
}
const char* buf[1024];
ssize_t s=read(fd,buf,sizeof(buf)-1);
if(s>0)
{
buf[s]=0;
printf("%s",buf);
}
close(fd);
}
需要注意几个细节:
1.通过读的方式打开一个文件:
这里包含了两层含义:这个文件已经存在了,所以不需要设置权限了
2.因为写入文件的时候没有写入\0
,所以在缓冲区的中需要加入\0
作为字符串结束的标志。
文件描述符是什么?
首先,一个进程被调度,这个进程在调度的过程中,可能会打开多个文件,所以进程和文件的关系是1:n
这就导致内存中存在很多的文件,那么操作系统就必须要对这些打开的文件进行管理。如何管理文件呢?依旧是六字真言:先描述,再组织。操作系统管理进程是管理进程的PCB
,根据进程的属性对进程进行管理。文件也是如此,当进程打开文件的时候,操作系统会为打开的文件创建一个结构体struct file
,这个结构体里包含了文件的属性信息,有了结构体以后,文件得到了描述,接着操作系统将打开的文件通过数据结构体连接起来,文件就能得到管理。
struct file
{
//文件的相关属性信息
|
但是系统中可能存在多个进程,打开了多个文件,这么多文件通过数据结构链接在一起,操作系统怎么知道哪些文件是属于对应哪个进程的呢?
PCB
中有一个指针:struct files_struct *file
其指向了一个结构体strcut files_struct
在这个结构体内,包含了一个数组叫做fd_array[]
,其数组类型是struct file*
,这是一个指针数组,数组里存储的是对应描述文件的struct file
的地址,这样进程和文件之间就产生了联系。说了这么多,fd
文件描述符又是什么呢?fd
文件描述符其实就是fd_array[]
数组的下标,从0
开始。所以说文件描述符和文件之间有着对应的关系。
所以,我们最终给出fd文件描述符的本质:文件描述符fd
本质是内核中进程和文件关联的数组的下标
现在我们也就能理解为何使用write
,read
系统调用接口需要传入文件描述符fd
,
当用户传递进fd
以后,进程拿到fd
,进程的PCB
根据指针找到对应的结构体的fd_array[]
数组,通过fd
进行数组索引找到对应文件的struct file
,接着对文件进行具体操作。
文件描述符的本质我们理解了,但是系统又是如何分配文件描述符的呢?
通过一段代码我们可以看下:
#include
#include
#include
#include
int main()
{
int fd1=open("./fd1.txt",O_CREAT,0644);
int fd2=open("./fd2.txt",O_CREAT,0644);
int fd3=open("./fd3.txt",O_CREAT,0644);
int fd4=open("./fd4.txt",O_CREAT,0644);
printf("fd1=%d\n",fd1);
printf("fd2=%d\n",fd2);
printf("fd3=%d\n",fd3);
printf("fd4=%d\n",fd4);
}
这里通过代码创建了4
个文件,通过打印文件描述符发现是从3
开始分配的。前面的0,1,2
呢?
前面我们说了进程创建的时候会默认打开流:标准输入,标准输出,标准错误,对应的文件就是键盘文件,显示器文件,显示器文件。所以进程被创建的时候,其fd_array[]
数组的前三个位置0,1,2
就已经被填入了这三个文件的地址。这也是为什么我们创建的文件是从3
开始分配的。
文件描述符的分配规则:对一个文件要进行操作,就需要打开这个文件,将文件的属性信息和数据加载到内存中,对于打开的文件,操作系统必须对其进行管理,进程通过数组fd_array
和文件产生联系,其中数组下标0,1,2
对应的数组位置默认填入了标准输入(键盘文件),标准输出(显示器文件),标准错误(显示器文件)这三个文件的地址。当我们打开新的文件的时候,操作系统会遍历这个数组,寻找一个最小的没有被使用的数组下标作为该文件的文件描述符。
所以我们可以使用write
系统调用接口直接向1,2
这两个文件描述符中写入数据。
#include
#include
#include
int main()
{
char* buf="hello linux\n";
write(1,buf,strlen(buf));
}
注:从标准输入和标准输出中写入有什么区别呢?在后面我们会再进行解释。
如果把1关掉呢?
#include
#include
#include
#include
#include
int main()
{
close(1);
int fd=open("./log.txt",O_WRONLY,0644);
printf("fd=%d\n",fd);
printf("hello world\n");
}
我们开始分析这段代码:
在这段代码中我们使用了printf
函数,printf
是C
语言中的打印,是向标准输出打印,C
语言中也就是stdout
,我们一直在说stdout
,它究竟是什么呢?
通过man
进行查看
stdout
的类型是FILE*
,FILE*
对于我们很好理解,是一个文件指针,那么FILE
又是什么呢?
我们直接给出解释:
FILE
是C
语言层面上的结构体:
struct FILE{
这个结构体内存储了一个整数,对应在系统层面的,这个文件的打开对应的fd
}
写入操作本质都需要fd
对文件进行操作
printf
是向stdout
写入,而stdout
的结构体里封装了一个fd
,所以说stdout
是根据这个fd
向对应的文件写入,其只认识fd
,不关心fd
现在指向哪个文件。
接着回到代码,前面我们说fd
的分配规则,因为1
号文件描述符被关闭了,所以后面打开的log.txt
文件分配的就是1
号文件描述符,而printf
是向stdout
写入,stdout
内部封装了1
号文件描述符,所以stdout
会将内容最终写入1
号文件描述符对应的文件,也就是log.txt
文件。
注:上层的文件操作里其实都封装了fd
。
在计算机中,硬件和内存之间的交互大都是io
,也就是读写操作。当然有些硬件可能没有读或者没有写,那么该方法就为空。在硬件层面上,硬件之间具有差异,那么不同硬件对应的读写方法一定是不一样的,但是在上层用户中,只需要调用系统接口或者函数就能对这些硬件做到读写操作,这又是如何做到的呢?
前面我们提到过一个概念:操作系统可以根据每个硬件的属性信息做出决策来让驱动程序间接管理硬件,所以在硬件层上有一层驱动层,不同硬件对应的驱动对于读写方法进行定制,当打开一个文件时,就会在在内存中创建一个struct file
,这些个struct file
组成了一层虚拟文件系统,struct file
中存储读和写的函数指针,这两个函数指针指向了对应硬件的驱动程序内的方法,因此在struct file
的上层就可以得出一个概念:一切皆文件,其根本不用关心底层是如何实现不同硬件的读写操作的。
例如:在上层打开了一个文件,这时进程给其分配的fd
为4
,当上层使用write
或者read
等等这样的文件操作时,进程通过fd
数组索引找到对应的文件的struct file
,再解引用write
或者read
里的方法来对文件进行操作。
通过查看Linux
的源码可以进行证实:
在Linux
源码中,打开一个文件系统就会对应创建一个file
结构体,在这个结构体中有一个结构体指针file_operations *fp
指向了一个结构体,在结构体file_operations
中维护了具体的函数指针(指向底层硬件的实现方法)。
概念:本来应该显示到显示器中,但是却被显示到文件内部。
本质:通过修改
fd
和文件的映射关系
#include
#include
#include
#include
#include
int main()
{
close(1);
int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);
printf("fd=%d\n",fd);
printf("hello world\n");
}
我们画一张图帮助理解:
printf
向stdout
写入,stdout
内封装了1
号文件描述符,所以这时printf
原本应该先显示器打印经过重定向以后变成向log.txt
文件打印。
从这也可以看出,stdout只关心封装的fd,并不关心fd指向的是否是显示器文件。
除了上述直接方法,还可以使用系统调用的方法进行重定向
头文件:#include
函数原型:int dup2(int oldfd,int newfd);
作用:将fd_array[]数组oldfd位置的内容拷贝到newfd指向的位置。
#include
#include
#include
#include
#include
int main()
{
int fd=open("./log.txt",O_CREAT|O_WRONLY,0644);
dup2(fd,1);
printf("hello stdout\n");
}
追加重定向的实现很简单,只用在open
系统调用接口的参数二加入O_APPEND
;
int fd=open("./log.txt",O_CREAT|O_APPEND,0644);
概念:应该从键盘中获取的数据变成从文件中获取
#include
#include
#include
#include
#include
int main()
{
int fd=open("./log.txt",O_RDONLY);
char *buffer;
dup2(fd,0);
scanf("%s",buffer);
printf("%s\n",buffer);
}
scanf
是向stdin
内读取数据,stdin
内封装了0
号文件描述符,stdin
不关心0
号文件描述符指向的文件。
在这段代码中0
号文件描述符指向了log.txt
文件,所以原本应该向键盘读入数据经过重定向以后变成从文件内读取数据
注:这里只读取了hello,这是因为scanf的读取是以空格为结束符,所以这里只会读取到hello。
注:FILE
其实是 struct _IO_FILE typedef
出来的。
前面我们提到了FILE*
中的FILE
结构体,通过查看源码发现其中有一个字段:
#if 0
int _blksize;
#else
int _flags2;
int _fileno;
这个fileno
其实就是文件描述符,也就是上层对fd
的封装。
在大致了解了文件管理以后,我们需要回答几个问题:
执行exec*程序替换的时候,会不会影响进程曾经打开的文件
不会。
fork创建子进程以后,会发生什么?
会以父进程为模板,创建自己的struct files_struct
,其中的struct file* fd_array[]
也会以父进程会模板,也就是说父子进行指向的都是相同的文件。
父子进程对文件进行操作会不会写时拷贝?
不会,因为事实上,文件是一个进程打开的,其是操作系统提供的,不属于某个进程,因此不会发生写时拷贝。
一个文件被多个进程打开,操作系统如何知道什么时候需要关闭这个文件呢?
在
struct file
内部有个计数器,一旦其被一个进程打开,计数器就++
,当对应进行关闭文件/进程退出时计数器--
,计数器为0
时操作系统就会将文件关闭。
为什么进程都会默认打开0,1,2
因为
bash
会默认打开了这三个文件,其子进程会进行继承。
标准输出和标准错误都是往显示器上打,两者有什么区别呢?
首先,我们进行验证,标准输出和标准错误都是往显示器上打,也就是输出到1
号文件描述符和2
号文件描述符。
#include
#include
#include
int main()
{
char *buf1="hello 标准输出\n";
char *buf2="hello 标准错误\n";
write(1,buf1,strlen(buf1));
write(2,buf2,strlen(buf2));
}
确实,通过write
系统调用接口,两个字符串的内容分别被显示到了显示器上。
进行重定向操作,将内容重定向至log.txt
文件中,最后发现只有标准输出的内容被显示到了文件中。
原因是因为重定向的本质 :将标准输出原本应该输出到显示器的内容输出到文件中。而标准错误和标准输出没有关系,自然也就不会发生重定向。
所以标准输出和标准错误的区别就是重定向。
注:接下来的涉及的内容都是以C
语言为例。
首先我们需要知道,C
语言中的一些输出函数,如printf
,fprintf
这些接口都是向stdout
写入,stdout
的类型是FILE *
,FILE
是一个结构体,在调用用户级接口的时候,数据并不是直接就写入到硬件上的,而是先写到C
语言缓冲区中(C
语言提供的),接着底层的系统调用接口会将数据刷新到内核缓冲区,操作系统定期将内核缓冲区内的数据写入到硬件上。
首先,我们需要知道,缓冲区分为两种:
1.用户级缓冲区
2.内核级缓冲区
用户在用户层执行的文件操作都是将内容写入用户层缓冲区,用户缓冲区的内容会根据file_no
(下层fd
的封装)被刷新至内核缓冲区,接着操作系统定期将内核缓冲区内的数据写入到硬件上,这就是一次完整的文件操作过程。
对于这个过程,仍然有很多细节需要解剖。
用户级缓冲区存在哪里?
我们直接给出结论:用户级缓冲区存在
FILE
结构体中。
在/usr/include/libio.h
路径下可以进行查FILE
结构体含有的内容
FILE
结构体内既存储了fileno(fd)
,还存储了缓冲区的相关数据。
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; //封装的文件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
使用系统调用接口进行文件操作的时候需要使用fd,用户极缓冲区数据刷新至内核缓冲区也需要fd,两者有什么联系?
实际上,缓冲区不是一个很大的区域,每一个文件都对应有一个属于自己的用户级缓冲区,而这个用户级缓冲区中包含了
fileno(fd)
指向了对应的内核缓冲区,而内核缓冲区也会根据fd
将数据刷新指定的硬件上。
硬件,用户级缓冲区,内核缓冲区三者之间其实是根据fd
紧密联系在一起的。对此,接下来我们会进行验证。
在明白了文件操作这一过程以后,我们需要知道缓冲区的刷新策略。
用户到内核的刷新策略:
1.不刷新。
2.行刷新(行缓冲\n),一般是使用函数对显示器文件进行打印时碰到\n就进行刷新。
3.缓冲区满了,才刷新(全缓冲) ,比如往磁盘文件中写入。
注:这种刷新策略对于操作系统到内核同样适用,上层用户使用的刷新方式如\n,fflush
其实都是将用户极缓冲区的数据刷新至内核缓冲区。
为了对缓冲区的刷新策略有更深的认识,我们来看几段代码:
#include
#include
#include
int main()
{
char *buf1="hello 标准输出\n";
char *buf2="hello 标准错误\n";
write(1,buf1,strlen(buf1));
write(2,buf2,strlen(buf2));
printf("hello linux\n");
}
#include
#include
#include
int main()
{
char *buf1="hello 标准输出\n";
char *buf2="hello 标准错误\n";
write(1,buf1,strlen(buf1));
write(2,buf2,strlen(buf2));
printf("hello linux\n");
close(1);
}
看完这段代码可以得到一个问题:
为什么没有关闭1号文件描述符之前,进行重定向操作,printf
和write
的内容都被重定向至了文件中,关闭1
号文件描述符之后,只有write
的内容被重定向到了文件中。
分析:
首先,语言级别的接口都是将内容先写入到用户级缓冲区内,而系统调用接口是将数据直接写入内核缓冲区中。这里我们进行重定向操作,刷新策略就发生了变化,原本应该写入到显示器的内容被重定向写入到磁盘文件中,这时用户级缓冲区的刷新策略由
行刷新
变成了全缓冲
。在最后我们将1号
文件描述符给close
了,文件描述符被关闭,导致进程退出时无法将对应用户缓冲区的数据刷新至内核缓冲区中,所以最终没有被写入到文件中。
在明白代码一的原因以后,我们需要知道几个问题:
发生重定向以后,为何刷新策略会发生变换?
事实上,在发生重定向以后,上层也是会具有感知的,因此其会发生刷新策略的变换。
在代码中,发生重定向以后,我使用了printf函数的时候带了\n,这时刷新策略不应该变成行刷新吗?
事实上,当发生重定向以后,即使遇到了\n
,也不会发生行刷新。
#include
#include
#include
int main()
{
char *msg="hello write\n";
write(1,msg,strlen(msg));
printf("hello printf\n");
fprintf(stdout,"hello fprintf\n");
fputs("hello fputs\n",stdout);
fork();
}
运行这个程序的结果是很正常的,代码里一共执行了四
条打印语句。
但是为何进行重定向以后,文件里就变成了7
条。
分析:
在之前我们说过,父进程和子进程的代码和数据是共享的,用户缓冲区其实本质还是父进程在内存中的一块内存空间,里面存储的是缓冲区有关的数据,当发生重定向以后,用户缓冲区到内核缓冲区的刷新策略由行刷新变成了全缓冲。当父进程退出时,父进程会将用户缓冲区的数据刷新至内核缓冲区,刷新缓冲区本质其实也是一种写入操作,因此发生写时拷贝,子进程在退出的时候操作系统也会将用户缓冲区的内容刷新至内核缓冲区,最后操作系统会将内核缓冲区的内容输出到硬件上,其本质还是刷新的策略的变换。
为何write的内容没有显示两条?
wirte
是系统调用接口,系统调用接口是将数据直接写入内核缓冲区,内核缓冲区的数据属于内核空间,其和进程没有关系,所以不会发生写时拷贝这一说。
注:fclose
这种语言层面的接口,在关闭文件的时候会将用户缓冲区的内容刷新至内核缓冲区
假设用户层通过文件操作打开了一个文件,用户将数据写入用户缓冲区,而用户层的函数大都是底层系统调用的封装,既然要调用write,read
这类系统调用,那么上层就一定需要传入fd
,那么这时系统调用接口就可以通过fd找到对于的struct file,struct file
里有字段指向了对应的内核缓冲区和底层驱动的函数指针,因此系统调用就可以将数据刷新至内核,并触发驱动层的读写方法,底层的读写方法在适时的时候搭配上刷新策略会定期将数据刷新至硬件上。
注意:实际上,在一些情况下,内核缓冲区的数据不会被刷新至硬件上,因此需要注意缓冲区和硬件并不一定是一一对应的,有些数据只是为了在内存中进行使用,其并不需要进行存储。
总的来说:用户层的数据其实是从内核拷贝到用户的,而内核的数据其实是从用户层拷贝来的。
对于缓冲区这块涉及了大量的知识:对此我们应该先简单进行理解,首先需要知道在Linux
中具有两种模式:用户态和内核态,当调用系统调用或者进行访问内存资源等操作的时候就需要从用户态切换到内核态,用户态和内核态的切换是具有成本的,频繁的切换对系统的速度都是一种消耗。因此用户层的数据一般会先写入用户缓冲区,由此,原本是多次向内核写入操作,因为用户层的缓冲区的存在,可以看作是一次写入,由用户缓冲区刷新至内核,同样的,内核到硬件也是如此。通过缓冲区,可以将多次的写入操作看作一次,减少模式切换和频繁访存的资源消耗。
除了写入操作,在读操作中,缓冲区也起到了很大的作用,用户层想要读入数据,其会先去缓冲区中找是否有这个数据,如果命中,则将数据取出,反之其会将io操作调入进程队列中,进行硬件的资源访问。
验证思路:这里我使用fopen文件接口创建一个文件,并且向这个文件写入内容,并且使用fflush对标准输出进行刷新。让程序sleep 10000s不退出,看看在这个过程中,文件里是否会出现写入的内容。
#include
int main()
{
FILE *fp=fopen("./log.txt","w");
if(fp==NULL)
{
printf("open failed\n");
return 1;
}
int cnt=5;
while(cnt)
{ cnt--;
const char *msg="hello world";
fputs(msg,fp);
}
fflush(stdout);
printf("we are different block\n");
sleep(10000);
fclose(fp);
}
可以看出,程序处于sleep
期间,经过刷新以后,只有printf
打印的内容被显示了出来,printf
是向显示器文件打印,其和log.txt
是不同的文件,所以说每个文件都对应一个属于自己的用户缓冲区。
1.每个文件都有一个属于自己的用户级缓冲区和内核缓冲区。用户级缓冲区是内存中一块空间,这块空间是属于进程的,内核缓冲区同样也是内存中的一块空间,这块空间是属于内核的。不同文件的用户缓冲区和内核缓冲区是不同的。
2.printf是向stdout进行输出,stdout的返回值是FILE*,FILE里存储了fileno和缓冲区。fopen这类文件操作的返回值也是FILE,所以说每个文件都有一个属于自己的FILE,里面存储着缓冲区和一些数据。
3.进程需要通过fileno或者fd找到对应文件的struct file,从而使用strcut fiile扎到对应的内核缓冲区,并且触发底层的读写驱动方法。
在前面说过:用户层接口总是贯穿操作系统的,这种说法其实是不准确的,大多数用户级别接口都是底层系统调用的封装,但是仍然有一些接口不需要用到系统调用接口。
一个机械磁盘由多个盘片组成,一个盘片具有正反两面,每一面上都对应一个读写磁头。
磁道
:盘片中一个同心圆称为一个磁道,一个磁道由若干个扇区组成。
扇区
:磁道中类似扇面的一个小平面
柱面
:不同盘片相同半径同心圆组成的面
机械磁盘如何进行写入/读取操作?
机械硬盘的盘片会进行旋转,旋转的过程中,磁盘臂装置会进行摆动,读写磁头会将数据刻入对应的位置,每次写入磁盘的基本单位是扇区,大小一般是512
字节。
因此我们能够得到硬件层面磁盘定位数据的方式:
1.找到对应盘面
2.找到对应磁道
3.找到对应扇区
硬件层面上的存储结构终归是抽象的,不好理解的,所以我们需要一些辅助帮助理解。
通过磁带来帮助我们理解磁盘这一存储介质
磁带大部分人小时候应该都见过,学校英语听力会使用到,一个磁带如果卷起来,也是圆状的,就像一个盘片一样,但是特殊的是,磁带可以拉长成一整根。因此我们可以将磁盘中的盘片想象成是拉长的磁带,这样就将物理结构体抽象成了线性结构。
因此,将盘片理解为一个大数组,每个空间是512
字节,也就是一个扇区。数组下标就类似虚拟地址,这样做的好处是在操作系统角度只需要管理数组下标这样的虚拟地址,最终在向硬件写入的时候,硬件层/驱动层会将虚拟地址转化成硬件层面的真实地址进行写入。
铺垫了这么多磁盘知识,我们都是为了回答一个问题:
磁盘中中的文件是如何管理的?
在现实生活中,我们一般都会使用到笔记本,实际上笔记本一般就一个磁盘,通过分区的方式将磁盘分成了好几个空间,所以我们的电脑上才会有C盘,D盘,E盘...
。
而每个分区都有一套文件系统来管理分区中的文件,当然不同分区的文件系统可能相同也可能不相同。
注:文件系统就是用于管理磁盘的,我们对磁盘进行格式化的操作其实就是切换文件系统。
我们以其中的一个分区为例,进行介绍磁盘文件的管理:
实际上,分区的文件管理, 就类似国家的管理。国家分为省,市,县,镇,村,每一个区域都有自己的管辖人员进行管理。这种管理模式突出了一个好处:管理区域由大划小,管理成本得到降低。
分区的管理也正是如此。
每个分区都被分成了两个部分:
1.Boot Block
:其存储与启动相关的信息
2.Block group
:存储文件的属性,内容…
BootBlock
在分区内只有一个,而Block group
有很多了,我们介绍其中的一个。
( 1 ) (1) (1)inode table
inode table
中存储了很多的数据块,其中每个数据块叫做inode
,在inode
中存储了对应文件的属性信息。
( 2 ) (2) (2)Data Blocks
Date Blocks
中同样存储了很多的数据块,其中每个数据块叫做Block
,在Block
中存储了对应文件的内容。
( 3 ) (3) (3)Super Block
Super Block
中存储了整个分区空间的使用情况
Super Block中记录了整个分区空间的使用情况,但是每个Group组中都有一个Super Block,这样不会浪费吗?
实际上,这是一种备份操作,如果某个
group
遭到损坏,如磁盘刮花,这时其他的group
中的Super Block
还能正常运转。
( 4 ) (4) (4)Group descriptor table
Group descriptor table
中存储了当前Block group
中,inode
的使用情况,block
的使用情况
( 5 ) (5) (5)innode bitmap
innode bitmap
的中文名称又叫做位图,其是一串二进制序列,用来标识innode table
中innode
的使用情况:
如二进制序列10
(0
代表对应十进制
位置innode
未被使用,1
代表被使用),其就表示innode table
中0
号位置innode
未被使用,1
号位置innode
被使用。因此根据innode bitmap
可以迅速找到未被使用过的innode
。
( 6 ) (6) (6)Blocks bitmap
Blocks bitmap
和innode bitmap
的原理一致,其是用来标识Data Blocks
里Blocks
的使用情况。
在介绍完这些以后,我们需要回答几个问题:
innode中都存储了哪些字段?
注:可以将innode
看作是一个结构体。
struct inode
{
//文件属性
//int inode_number
//int blocks[32]
//int
}
innode number:对应的每个文件都有一个属于自己的
inode number
存储在inode
中,实际上,一个文件的文件名实际上是标识给用户看的,系统层面不关心文件的用户名,只关系其innode number
,innode number
就类似每个文件的身份证,其具有唯一性。
int blocks[32]:这个
blocks
数组是文件内容和属性联系的纽扣,对应文件在Date Blocks
中使用的数据块,都会被写入到blocks[]
中,通过这个数组可以找到文件使用的数据块,这样内容和属性就产生联系。
目录是文件吗?
是的,目的也是文件,既然是文件,那么目录就一定由内容+属性组成,和普通文件一样,目录的属性也是存在
inode table
中的一个inode
里。
目录里存哪些内容呢?
首先,我们需要知道,我们创建文件的操作,一定是在某一个目录下进行创建的。
目录的Blocks
里存储了目录下文件的名字和其innode_number
的映射。
如何创建一个文件
遍历
innode bitmap
,找到一个未使用过的innode
,将文件的属性信息写入innode
中,文件被创建出来,对应也就有了innode_number
,innode_number
和用户名的映射会被写入到目录的block
中。当想要往里写入内容的时候,通过遍历Blocks bitmap
,找到Date blocks
中未使用过的数据块,将数据块和blocks[]
数组产生联系,通过innode
中的blocks[]
数组找到对应的数据块进行写入。
注意:一个文件只有一个innode
,可能有多个block
。
查看文件内容的过程是怎么样的? 如cat log.txt。
根据用户名,在目录的
block
中找到对应innode number
,找到对应的innode
,根据innode
中的blocks[]
数组找到对应数据块,最后将内容打印出来。
如何删除一个文件
根据用户名,在目录的
data blocks
中找到对应innode number
,将innode bitmap
中的对应位置置0
,并且根据blocks[]
,将blocks bitmap
的对应位置为0
。
所以说删除文件速度很快,因为其并不是删除内容,而是将位图对应位置置为0
,这时文件内容和属性并没有被删除,下次有文件使用时,其会被覆盖,因此删除一个文件,短期内是可以恢复的。
在理解的文件系统的基础上,继续进击!这时软硬链接对于我们也就很好理解了。
( 1 ) (1) (1)软链接
软链接的作用类似创建快捷方式:
以windows
为例:
在windows
中,有两种方式可以打开程序:
1.通过快捷方式(图标)打开程序。
2.通过路径+程序名打开程序。
Linux
中的软链接就类似windows
中的快捷方式
格式:ln -s 文件一 文件二
作用:将文件二 作为文件一的软链接
通过一个样例,来展示软链接在Linux
中的具体用法。
在路径/code/jazz/sanber/han/
下我创建了一个可执行程序test
。
源文件代码如下:
#include
int main()
{
printf("Hello");
}
接着在 class12
目录下,我创建了一个软链接
接着我想运行test
可执行程序:
发现使用软链接文件可以直接指向这个程序。
软链接文件的blocks
中其实存储了被链接文件的路径+用户名,所以其就像指针一样指向了这个文件,可以直接运行,这也就是软链接的作用。
依旧还是需要回答几个问题:
如何取消软链接?
格式:unlink 软链接文件名
作用:取消链接关系,并且删除这个软链接文件
删除被链接的文件会发生什么?
如果被链接文件被删除,这时软链接文件也就失效了。
软链接文件和被链接文件是一个文件吗?
注:使用ls -i
后缀就可以看到文件的innode number
可以看出软连接文件和被链接文件不是同一个文件,两者的innode_number
不同。
注意:创建链接的时候后面那个文件必须不存在。
硬链接的创建命令和软链接基本类似。
格式:ln 文件一 文件二
作用:将文件二作为文件一的硬链接
学习了软链接以后,想必大家对于硬链接肯定很容易理解,这里我直接给出结论。
1.在硬链接中,被链接文件和链接文件是同一个文件。创建硬链接,实际上是在文件对应目录的
blocks
中加了一个用户名和innode_number
的映射。
2.在文件的innode
中还有一个字段int res
,几个文件指向他,这个字段就为几,当字段为0
时这个文件就被删除,所以单纯的删除链接文件和被链接文件是并不是删除文件,只有这个字段为0
时,文件才算真正被删除。
3.通过ls -al
可以查看文件的硬链接数
创建一个空目录,这个空目录的默认硬链接个数为2,,为什么?
因为每个目录下都有个.
文件,所以.
用户名和anzai
一共两个用户名指向了这个文件,所以为2
。
如果在一个空目录下,创建一个目录呢?这时这个目录的链接数为几?
3
,因为创建的目录中有个..
文件和当前目录下.
文件都指向了这个文件
在Linux
中一个文件具有三种时间:
(1)Access时间:文件最近一次被访问的时间
(2)Modify时间:文件内容最近一次被修改的时间
(3)Change时间:文件属性最近一次被修改的时间
( 1 ) (1) (1)Modify
通过向文件里写入内容发现myfile
文件的modify
时间确实被更新了。
大家不知道有没有发现,修改文件内容以后,文件的Change
时间也得到了更新,这又是为什么?
因为修改文件的内容,可能会导致文件的大小发生变化,文件的大小也是文件的属性信息,因此Change
得到了更新。
( 2 ) (2) (2)Change
在对文件的权限修改以后,文件的Change
时间也确实得到了更新。
( 3 ) (3) (3)Acess
通过cat
命令查看文件内容,再次查看文件时间,发现Access
时间竟然没有被更新这是为什么?
因为访问文件相比修改文件内容和属性来说频率更高,访问一次文件操作系统不会立即更新Acess
时间,其会在一定时间间隔以后对Access
时间进行更新。
在明白了三种时间以后,我们也就能解释一种现象:
为何makefile
在进行make
操作的时候,如果源文件内容没有发生变化,make
操作只能执行一次。
实际上,能否进行第二次make
操作,是由时间决定的,这里我们给出结论:
如果源文件时间(主要是Modify
和Change
时间)早于目标文件的时间,源文件就可以进行第二次操作。编译器和makefile
就是以此为判断是否支持用户进行make
操作。
这其实很好理解:
最开始一定是先有源文件,才有目标文件的诞生,所以源文件的时间一定是早于目标文件时间的,如果在后来源文件的内容遭到了改变,这时源文件的时间就会晚于目标文件的时间,这时用户就可以进行make
操作。
对此,可以进行验证:
注:touch
命令会更新文件的三种时间:
现在我们也能知道为什么.PHONY
修饰的对象可以重复执行,因为编译器和makefile
不关心这类用户的时间变化。