1. C语言文件流操作
文件操作函数 | 功能 |
---|---|
fopen | 打开文件 |
fclose | 关闭文件 |
fputc | 写入一个字符 |
fgetc | 读取一个字符 |
fputs | 写入字符串 |
fgets | 读取字符串 |
fprintf | 格式化写入数据 |
fscanf | 格式化读取数据 |
fwrite | 向二进制文件写入数据 |
fread | 向二进制文件读取数据 |
fseek | 设置文件指针位置 |
ftell | 计算当前文件指针之于文件起始位置的偏移量 |
rewind | 设置文件指针到起始位置 |
ferror | 判断文件操作过程中是否发生错误 |
feof | 判断文件指针是否 到达文件末尾 |
🎈fopen
FILE * fopen ( const char * filename, const char * mode );
- filename 文件名(绝对路径/相对路径)
- mode:打开文件规则
文件打开方式 | 含义 |
---|---|
“r” | read:打开文件进行输入操作。该文件必须存在。 |
“w” | write:为输出操作创建一个空文件。如果已存在同名文件,则丢弃其内容,并将该文件视为新的空文件。 |
“a” | append:打开文件以在文件末尾输出。输出操作总是在文件末尾写入数据,并对其进行扩展。忽略重新定位操作(fseek、fsetpos、rewind)。如果文件不存在,则创建该文件。 |
“r+” | read/update:打开一个文件进行更新(输入和输出)。文件必须存在。 |
“w+” | write/update:创建一个空文件并打开它以进行更新(输入和输出)。如果同名文件已经存在,则将丢弃其内容,并且该文件将被视为新的空文件。 |
“a+” | append/uptate:打开一个文件进行更新(包括输入和输出),所有输出操作都在文件末尾写入数据。重新定位操作(fseek、fsetpos、rewind)会影响下一个输入操作,但输出操作会将位置移回文件末尾。如果文件不存在,则创建该文件。 |
使用上面的模式说明符,文件将作为文本文件打开。为了将文件作为二进制文件打开,模式字符串中必须包含“b”字符。这个附加的“b”字符可以附加在字符串的末尾(从而形成以下复合模式:“rb”、“wb”、“ab”、“r+b”、“w+b”、“a+b”),也可以插入在“+”符号之前(“rb+”、“wb+”、“ab+”)。
- 返回值:成功返回文件流,失败返回NULL
🎈fclose
传入文件流,关闭该文件流。成功关闭返回零,失败返回EOF。
int fclose( FILE *stream );
🎈fputc
往文件写入字符,成功则返回该字符,失败返回EOF
int fputc ( int character, FILE * stream );
🎈fgetc
获取当前文件指针指向的字符,获取字符之后,内置的文件指针将指向文件中的下个字符。
int fgetc ( FILE * stream );
若文件指针指向文件末尾,返回EOF,并且feof为真。
若读取失败,返回EOF,并非ferror为真。
🎈fputs
int fputs ( const char * str, FILE * stream );
将str写到文件流stream中,成功返回非零值,失败返回EOF,ferror为真。
🎈fgets
读取stream的字符串到str中,遇到换行空格或文件尾停止读入,str读取后自动在末尾添加’\0’。
char * fgets ( char * str, int num, FILE * stream );
num 表示拷贝到str的最大字符数(字节个数),包含结尾’\0’。通常设为sizeof(str)。
成功返回str,读到文件尾feof为真。没有读到任何字符串(str也不会改变),返回null。读取错误返回null,ferror为真。
🎈fprintf
按格式将字符串写到文件中
int fprintf ( FILE * stream, const char * format, ... );
第一个参数是数据输出的目的地,后面的参数为格式化参数,用法和printf类似。成功返回写入的字符个数,失败返回负数。
char name[]="Alice";
char gender[]="female";
int age="25";
fprintf(pf, "%s %s %d\n", name, gender, age);
🎈fscanf
按格式读取字符串
int fscanf ( FILE * stream, const char * format, ... );
与scanf用法类似,成功返回填充的变量数,返回EOF表示到达文件尾,或者无可读取的数据。
char name[20];
char gender[10];
int age;
fprintf(pf, "%s %s %d\n", &name, &gender, age);
🎈fwrite
ptr往文件中写入二进制数据
size_t fwrite ( const void * ptr, size_t size, size_t count, FILE * stream );
-
第一个参数 ptr 是输出数据的位置;
-
第二个参数 size 是要输出数据的元素个数
-
第三个参数 count 是每个元素的大小;
-
第四个参数是数据输出的目标位置。
该函数调用完后,会返回实际写入目标位置的元素个数,当输出时发生错误或是待输出数据元素个数小于要求输出的元素个数时,会返回一个小于count的数。
fwrite函数的功能就是将ptr位置的,每个元素大小为size的,count个元素,以二进制的形式输出到stream位置。
🎈fread
文件往ptr中读入二进制数据
size_t fread ( void * ptr, size_t size, size_t count, FILE * stream );
-
第一个参数 ptr 是接收数据的位置;
-
第二个参数 size 是要读取的每个元素的大小;
-
第三个参数 count 是要读取的元素个数;
-
第四个参数是读取数据的位置。
函数调用完会返回实际读取的元素个数,若在读取过程中发生错误或是在未读取到指定元素个数时读取到文件末尾,则返回一个小于count的数。
fread函数的功能就是从stream位置,以二进制的形式读取count个每个元素大小为size的数据,到ptr位置。
🎈fseek
调正文件指针的位置
int fseek( FILE *stream, long offset, int origin );
fseek函数的第一个参数文件流,第三个参数是“初始位置”(并非文件信息区的起始位置),第二个参数是文件指针经操作后相对于这个“起始位置”的偏移量,单位为字节。fseek函数如果调用成功,则返回0;若调用失败,则返回一个非0的值。
第三个参数的三个形式
origin参数形式 | 意义 |
---|---|
SEEK_CUR | 文件指针当前位置 |
SEEK_SET | 文件开头 |
SEEK_END | 文件末尾 |
🎈ftell
了解文件指针的当前位置,返回给出相对于起始位置的偏移量
long ftell( FILE *stream );
ftell函数调用成功,则返回文件指针相对于起始位置的偏移量;若调用失败,则返回 - 1。
- 用fseek函数与ftell函数求文件大小
用fseek移到文件末尾,然后求文件指针相对于起始位置的偏移量:
fseek(pf, 0, SEEK_END);//将文件指针置于文件末尾
int FileLen = ftell(pf);//求文件指针相对于文件起始位置的偏移量
🎈rewind
让传入的文件指针返回文件的起始位置。
void rewind( FILE *stream );
🎈ferror
int ferror( FILE *stream );
若使用时没有发生错误,则ferror函数返回0;否则,ferror函数将返回一个非零的值。
if (ferror)
{
printf("文件操作错误\n");
}
🎈feof
int feof( FILE *stream );
读取到文件末尾,则feof函数返回0;否则,feof函数将返回一个非零的值。
我们在gcc环境中,编写读文件和写文件的代码:
新建文件,在文件(磁盘)中写入文件:
#include <stdio.h>
#include <stdlib.h>
int main()
{
FILE* fp;
fp=fopen("./log.txt","w");
if(fp==NULL)
{
perror("fopen failed\n");
exit(1);
}
char* message="hello world\n";
fputs(message,fp);
fclose(fp);
return 0;
}
读取已有文件
#include <stdio.h>
#include <stdlib.h>
int main()
{
FILE* fp;
fp=fopen("./log.txt","r");
if(fp==NULL)
{
perror("fopen failed\n");
exit(1);
}
char buff[64];
while(fgets(buff,sizeof(buff),fp))
{
printf(buff);
}
if(!feof(fp))
{
printf("fgets quit abnormally\n");
}
if(feof(fp))
{
printf("fgets quit normally\n");
}
fclose(fp);
return 0;
}
c语言有三个标准流:stdin(键盘输入),stdout(显示器输出),stderr(显示器输出)
🚩尽管在许多情况下,stdout 和 stderr 都与同一个输出设备(如控制台)相关联,但应用程序可能会区分发送到 stdout 的内容和发送到 stderr 的内容,以防其中一个被重定向。
例如,经常将控制台程序 (stdout) 的常规输出重定向到文件,同时期望错误消息继续出现在控制台中:
标准输出流可以重定向
int main()
{
//重定向 stdout
char message[]="hello\n";
fputs(message,stdout);
}
标准错误流无法重定向
int main()
{
//重定向 stdout
char message[]="hello\n";
fputs(message,stderr);
}
2. 系统 I/O 接口
操作系统专门提供了文件的调用接口:open,write,read,close等,称为低级IO。不同的编程语言将系统的IO接口都封装成自己的一套文件操作库函数——高级IO。上述对文件操作的c语言函数便是对系统接口的封装。
这里将介绍系统的IO接口
open
- 函数作用:按指定方式打开文件
- 函数声明
#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 打开文件选项,传递标志位(比特位)
选项:
以下的选项都是只有一个比特位是1的数字(2的幂),而且不重复
- O_RDONLY: 只读打开
- O_WRONLY: 只写打开
- O_RDWR:读写打开
上面三个选项必须指定且只能指定一个。
- O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
- O_APPEND: 追加写
- O_TRUNC:打开文件先清空文件内容
传递两个以上的选项,使上面的选项按位或
|
即可,比如:O_RDONLY|O_WRONLY
在下方文件中可以查看上述选项的值
/usr/include/bits/fcntl-linux.h
八进制显示:
-
mode 指定文件权限(八进制数,以o开头)
如果没有指定mode,则文件权限为:
int fd=open("./log.txt",O_WRONLY|O_CREAT);
指定mode 文件权限:
int fd=open("./log.txt",O_WRONLY|O_CREAT,644);
-
返回值
- 成功:返回新的文件描述符
- 失败:-1
返回值实验:
int main() { int fd1=open("./log1.txt",O_WRONLY|O_CREAT, 0644); int fd2=open("./log2.txt",O_WRONLY|O_CREAT, 0644); int fd3=open("./log3.txt",O_WRONLY|O_CREAT, 0644); int fd4=open("./log4.txt",O_WRONLY|O_CREAT, 0644); printf("fd1:%d\n",fd1); printf("fd2:%d\n",fd2); printf("fd3:%d\n",fd3); printf("fd4:%d\n",fd4); }
注意:文件描述符从3开始标识,这是因为0,1,2分别被标准流提前占据:
0 — 标准输入流 ,1 — 标准输出流 , 2 - 标准错误流 。
进程对文件进行操作,文件相关的属性信息加载到内存。然而操作系统有大量进程,进程中又可以打开多个文件,所以操作系统需有效管理文件。
write
- 函数作用:写入文件(磁盘)
- 函数声明:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
-
fd 文件描述符
-
buf 内存单元指针,指向欲写入文件的内容的起始位置
-
count 欲写入的字节数
-
返回值
写入成功返回实际写入的字节数,失败返回-1
-
实验
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);
if(fd<0)
{
perror("open failed\n");
exit(1);
}
char buf[]="hello world\n";
ssize_t ret=write(fd,buf,sizeof(buf));
printf("%d bytes have been written into log.txt\n", ret);
close(fd);
return 0;
}
注意写到文件中的字符串不用加’\0’,'、0’表示字符串结束标志位只是c语言的规定,无需写入文件中。
read
- 函数声明
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
- 函数作用
将fd所指向的文件,传送count个字节到buf指针指向的内存区域中。
- 返回值
返回成功读取到的字节数(可能小于count,但并不表示失败的返回,可能提前到达文件末尾),失败则返回-1。
如果count为0,则read不做任何事,并返回0.
- 实验
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
int fd=open("./log.txt",O_RDONLY|O_CREAT);//文件已存在,不涉及创建,无需mode参数
if(fd<0)
{
perror("open failed\n");
exit(1);
}
char buf[128];
int ret=read(fd,buf,sizeof(buf)-1);
if(ret>0)
{
buf[ret]=0;
printf("%d bytes have been read from log.txt\n", ret);
printf(buf);
}
close(fd);
return 0;
}
读入的字符串需要 添加终止标志位’\0’。
3. 文件描述符fd
在Linux中,进程是通过文件描述符而不是文件名来访问文件的,文件描述符实际上是一个整数。
在学习 open 的时候谈到open的返回值是 文件描述符fd(file descriptors)
,因为0 ,1,2在我们创建进程时就已被安排给了标准输入,标准输出和标准错误。所以之后打开的文件的文件描述符从3开始升序标记。
我们知道操作系统可以管理大量的进程,而每个进程又可以打开多个文件。那么操作系统如何管理这些打开的文件?期间文件描述符有起到了哪些作用?让我们来一睹究竟。
struct file 描述已打开的文件
Linux中专门用了一个结构体 file
来保存打开文件的文件位置。系统中每个打开的文件在内核空间都有一个关联的struct file。
<2.4版>
struct file
{
struct list_head f_list; /*所有打开的文件形成一个链表*/
struct dentry *f_dentry; /*指向相关目录项的指针*/
struct vfsmount *f_vfsmnt; /*指向VFS安装点的指针*/
struct file_operations *f_op; /*指向文件操作表的指针*/
mode_t f_mode; /*文件的打开模式*/
loff_t f_pos; /*文件的当前位置*/
unsigned short f_flags; /*打开文件时所指定的标志*/
unsigned short f_count; /*使用该结构的进程数*/
unsigned long f_reada, f_ramax, f_raend, f_ralen, f_rawin;
/*预读标志、要预读的最多页面数、上次预读后的文件指针、预读的字节数以及预读的页面数*/
int f_owner; /* 通过信号进行异步I/O数据的传送*/
unsigned int f_uid, f_gid; /*用户的UID和GID*/
int f_error; /*网络写操作的错误码*/
unsigned long f_version; /*版本号*/
void *private_data; /* tty驱动程序所需 */
};
list_head
让每个struct file结构体组织成一个双向循环链表:
struct list_head {
struct list_head *next, *prev;
};
struct file_operations
是把系统调用和驱动程序关联起来的关键数据结构。这个结构的每一个成员都对应着一个系统调用。读取file_operation中相应的函数指针,接着把控制权转交给驱动中的函数,从而完成了Linux设备驱动程序的工作。
在系统内部,I/O设备的存取操作通过特定的入口点来进行,而这组特定的入口点恰恰是由设备驱动程序提供的。通常这组设备驱动程序接口是由结构file_operations结构体向系统说明的
struct files_struct 结构
目前为止,我们说过一个进程的信息描述包含的结构体有task_struct(PCB),mm_struct(虚拟地址空间)以及页表。
每个进程用一个files_struct结构来记录文件描述符的使用情况,这个files_struct结构记录了用户打开的文件,它是进程的私有数据。
结构体如下:
struct files_struct {
atomic_t count; /* 共享该表的进程数 */
rwlock_t file_lock; /* 保护以下的所有域,以免在tsk->alloc_lock中的嵌套*/
int max_fds; /*当前文件对象的最大数*/
int max_fdset; /*当前文件描述符的最大数*/
int next_fd; /*已分配的文件描述符加1 ,分配给下一个要打开文件*/
struct file ** fd; /* 指向文件对象指针数组的指针 */
fd_set *close_on_exec; /*指向执行exec( )时需要关闭的文件描述符*/
fd_set *open_fds; /*指向打开文件描述符的指针*/
fd_set close_on_exec_init; /* 执行exec( )时需要关闭的文件描述符的初值集合*/
fd_set open_fds_init; /*文件描述符的初值集合*/
struct file * fd_array[32]; /* 文件对象指针的初始化数组*/
};
fd_array[32]
为一个数组,其中数组的元素为指向file结构体的指针。
fd
则是一个指向了 fd_array[32]
的指针。
fd_array[32]中数组的索引(下标)就是文件描述符
数组的第一个元素(索引为0)是进程的标准输入文件,数组的第二个元素(索引为1)是进程的标准输出文件,数组的第三个元素(索引为2)是进程的标准错误文件。
我们简化其中一些信息,来画出下面这幅图
每当我们指定文件描述符执行文件操作时,就能找到进程中fd_array数组的下标所对应的struct file结构体。
于是,我们知道了文件描述符 0 ,1, 2分别对应了标准输入,标准输出和标准错误,那我们便可以直接使用这几个文件描述符来做输入和输出:
- write充当printf,直接向显示器写入
//myfile.c
int main()
{
char buf[]="hello world\n";
write(1,buf,sizeof(buf));//直接写到stdout中,相当于printf
write(1,buf,sizeof(buf));
write(2,buf,sizeof(buf));//错误流也可以直接输出
return 0;
}
那么write 1和2都可以显示,那他们有什么区别呢?我们之后再谈。
- read充当scanf
int main()
{
char buf[128];
int ret=read(0,buf,sizeof(buf));
buf[ret-1]=0;
printf("%s\n",buf);
return 0;
}
文件描述符的分配规则
文件描述符的分配规则:给新文件分配的fd,是从fd_array中找一个最小的,没有被使用的下标,作为新文件的fd。
//myfile.c
int main()
{
close(0);//关掉stdin
int fd=open("./log.txt",O_CREAT|O_WRONLY,0644);//打开一个文件
printf("fd:%d\n",fd);
return 0;
}
发现在关掉stdin(fd==0)后,新建的文件所分配的fd便为最小的下标fd:0。
重定向
在懂得了文件描述符的分配规则后,当我们关掉标准输出流stdout时,就能把原本应输出到显示器上的内容直接输入到后面新打开的文件中了。
我们来试一下输出重定向:
int main()
{
close(1);
int fd=open("./log.txt",O_CREAT|O_WRONLY,0644);
printf("fd:%d\n",fd);
return 0;
}
显然,在执行程序的时候,显示器上没有任何输出,printf应该在屏幕上打印的内容现在输入到入log.txt文件中。
原因是什么呢?
首先需要明确一点,在Linux中一切皆文件,显示器亦是文件,printf的原理就是往stdout中写入内容,stdout的本质是FILE结构体,该结构体中包含了文件描述符名为 _fileno
,值为1 ,我们可以来验证一下:
int main()
{
printf("stdin->_fileno:%d\n",stdin->_fileno);
printf("stdout->_fileno:%d\n",stdout->_fileno);
printf("stderr->_fileno:%d\n",stderr->_fileno);
FILE* fp=fopen("./log.txt","r");
printf("fd->_fileno:%d\n",fp->_fileno);
return 0;
}
现在 fd_array[1]
被重定向给了文件log.txt ,printf才不管文件描述符 1 此刻指向的是谁,他依然完成向fd_array[1]指向的文件(设备)的写入操作。
同理我们再使用fprintf试一下:
- 输入重定向 <
<
输入重定向符,将文件的内容重定向至输出
我们在代码中试一下如何实现:
int main()
{
close(0);//关闭stdin
int fd=open("log.txt",O_RDONLY);
//fd=0
printf("fd:%d\n",fd);
char buf[128];
//将stdin的内容(实为log.txt的内容)读到buf
while(fgets(buf,sizeof(buf),stdin))
{
printf(buf);
}
return 0;
}
- 追加重定向 >>
了解追加重定向:
>
表示将输出到显示器的内容输出到文件中,他会将文件中原本的内容清空,然后输入新的内容,之前也已经说明如何做到。
>>
表示在保有文件原有内容的情况下,再追加新内容。
其实追加也不难,在open文件时添加 O_APPEND
即可:
int main()
{
close(1);
int fd=open("./log.txt",O_CREAT|O_WRONLY|O_APPEND,0644);
printf("fd:%d\n",fd);
fprintf(stdout,"HELLO WORLD\n");
return 0;
}
⚠ 注意:
以上的操作都没有 close(fd),这与缓冲区有关,如果提前关闭fd指向文件,缓冲区内容便无法写入至文件,关于文件缓冲区的概念我后面会讲解。
dup2 系统调用
之前我们进行的重定向操作属实不太灵活,因为要提前关闭标准流然后让最近打开的文件描述符获取fd。
dup2(duplicate)系统调用函数可以帮助我们进行重定向,在已打开某个文件的情况下,让其重定向为某个标准流。
- 函数声明
int dup2(int oldfd, int newfd);
- 函数说明
dup2函数使newfd拷贝oldfd,并提前关闭newfd的文件;
如果oldfd不是有效的文件描述符,函数调用失败,并且newfd不会关闭。
如果oldfd和newfd相同,函数啥也不做,返回newfd。
-
原理:
fd_array[newfd]=fd_array[oldfd],并且在先前切断了当前进程fd_array[newfd]与指向的struct file的联系。
struct file中
f_count
参数表示打开了此文件的进程数(引用计数),一个进程关闭文件便f_count–,如果计数为0,则此文件关闭。 -
实验:
- dup2实现输入重定向
int main()
{
int fd=open("./log.txt",O_RDONLY);
dup2(fd,0);//fd取代了0的指向
char buffer[128];
while(fgets(buffer,sizeof(buffer),stdin))
{
printf(buffer);
}
close(fd);
}
- dup实现输出重定向
int main()
{
int fd=open("./log.txt",O_WRONLY|O_CREAT,0644);
dup2(fd,1);//fd取代了1的指向
printf("I am delivered into log.txt\n");
close(fd);
return 0;
}
- dup实现追加重定向只需在输出重定向基础上,在打开文件时添加O_APPEND即可,这里不再赘述。
- stderr是否可以被重定向呢?
stderr也是输出到显式器上,那么他是否可以被重定向进文件呢?
在命令行的重定向
>
、<
、>>
底层原理
当我们在命令行执行 echo "hello world" > log.txt
时,实际上执行的指令便是如下流程:
bash->fork -> 子进程 -> 打开 log.txt -> dup2(fd,1) -> exec*("echo","echo","hello world",NULL);
⚠ 注意:dup2是复制了struct file 指针,即使close(fd),newfd仍然指向文件,所以缓冲区的数据依旧可以输入到文件中。
4. 文件缓冲
标准输出和标准错误流都写进显示器中
int main()
{
char note1[]="this is stdout!\n";
write(1,note1,sizeof(note1));
char note2[]="this is a stderr!\n";
write(2,note2,sizeof(note2));
return 0;
}
若我们将他们输出重定向至文件中:
发现只有标准输出被重定向进文件中。
标准错误的输出重定向:2>&1
-
关于
2>&1
的含义-
含义:将标准错误输出重定向到标准输出
-
符号>&是一个整体,不可分开,分开后就不是上述含义了。
比如有些人可能会这么想:2是标准错误输入,1是标准输出,>是重定向符号,那么"将标准错误输出重定向到标准输出"是不是就应该写成"2>1"就行了?是这样吗?
如果尝试过,你就知道2>1的写法其实是将标准错误输出重定向到名为"1"的文件里去了
-
写成2&>1也是不可以的
-
-
🚩为什么 2>&1 要放在最后
./buffer > log.txt 2>&1
为什么2>&1一定要写到>log后面,才表示标准错误输出和标准输出都定向到log中?我们不妨把1和2都理解是一个指针,然后来看上面的语句就是这样的:
本来1----->屏幕 (1指向屏幕)
执行>log后, 1----->log (1指向log)
执行2>&1后, 2----->1 (2指向1,而1指向log,因此2也指向了log)
⚠但是,如果将 2>&1 放在前面:
2>&1 ./buffer > log.txt
本来1----->屏幕 (1指向屏幕)
执行2>&1后, 2----->1 (2指向1,而1指向屏幕,因此2也指向了屏幕)
执行>log后, 1----->log (1指向log,2还是指向屏幕)
所以这就不是我们想要的结果。
文件流缓冲
之前在进行重定向时,我没有把fd给close掉,说是因为缓冲区在没有写进文件时直接close会造成写入失败,再看下这个情况:
int main()
{
char note1[]="this is stdout!\n";
write(1,note1,sizeof(note1));
char note2[]="this is a stderr!\n";
write(2,note2,sizeof(note2));
printf("hello printf\n");
fprintf(stdout,"hello fprintf\n");
close(1);//关闭标准输出
return 0;
}
printf和fprintf的内容在close(1)之后,没有输出重定向至log.txt中,但是write的标准输出已被输出重定向至文件。
造成这里的原因涉及到了用户进程缓冲区与内核缓冲区。
用户进程缓冲区与内核缓冲区
- 用户进程缓冲区
用户进程通过系统调用访问系统资源的时候,需要切换到内核态,而这对应一些特殊的堆栈和内存环境,必须在系统调用前建立好。而在系统调用结束后,cpu会从核心模式切回到用户模式,而堆栈又必须恢复成用户进程的上下文。而这种切换就会有大量的耗时。
所以程序在读写文件时,会先申请一块内存数组,称为buffer,然后每次调用read/write,读取/写入设定字节长度的数据,写入buffer。(用较小的次数填满buffer)。之后的程序都是从buffer中获取数据,当buffer使用完后,再进行下一次调用,填充buffer。
所以说:用户进程缓冲区的目的是为了减少系统调用次数,从而降低操作系统在用户态与核心态切换所耗费的时间。除了在进程中设计缓冲区,内核也有自己的缓冲区。
- 内核缓冲区
当一个用户进程要从磁盘读取数据时,内核一般不直接读磁盘,而是将内核缓冲区中的数据复制到进程缓冲区中。
可以认为,read是把数据从内核缓冲区复制到用户进程缓冲区。write是把进程缓冲区复制到内核缓冲区。当然,write并不一定导致内核的写动作,os可能会把内核缓冲区的数据积累到一定量后,再一次写入。这也就是为什么断电有时会导致数据丢失。所以说内核缓冲区,是为了在OS级别,提高磁盘IO效率,优化磁盘写操作。
如图所示:
当我们使用 printf
和 fprintf
等IO函数时,实际上是在用户层面的操作,用户进程缓冲区面对不同的输出设备有着不同的刷新策略:a)输出至屏幕为行缓冲 b)输出至磁盘为全缓冲。
当输出至显示器输出重定向为磁盘时,printf和fprintf等输出屏幕的c语言IO函数的刷新策略改为全缓冲,现在用户缓冲区没有满,我们又把输出流关闭了,那么在我们结束进程时,用户缓冲区的内容并不能通过fd找到相应的内存缓冲区进行写入,那便也无法写入至磁盘。
于是我们为了能够避免这种情况,在关闭文件之前,可以先手动刷新用户缓冲区的内容:
int main()
{
char note1[]="this is stdout!\n";
write(1,note1,sizeof(note1));
printf("hello printf\n"); //stdout
fprintf(stdout,"hello fprintf\n");//stdout
fputs("hello fputs\n",stdout); //stdout
fflush(stdout);//手动刷新
close(1);//关闭文件流
return 0;
}
将文件缓冲区的内容提前刷新至内核缓冲区。
fork()情况下的文件缓冲
依旧是之前的代码,这次不关闭也不手动刷新,即让进程结束时将用户缓冲刷新至内核缓冲区,不过需要在最后添加fork():
int main()
{
char note1[]="this is stdout!\n";
write(1,note1,sizeof(note1));
printf("hello printf\n"); //stdout
fprintf(stdout,"hello fprintf\n");//stdout
fputs("hello fputs\n",stdout); //stdout
fork();
执行输出和输出重定向:
可以看到直接屏幕打印时只有一份的输出,输出重定向至文件中有了两份用户缓冲区的内容,原因在于:
- write是系统调用,没有缓冲区可实时刷新,而屏幕打印为行刷新,到了fork()后,用户缓冲区已没有内容;
- 当重定向至磁盘文件后,刷新策略由行缓冲变成了全缓冲,所以创建子进程后,子进程写时拷贝了文件缓冲区(由C语言提供)的内容,于是当进程结束后刷新进程缓冲区,文件中就有了父子两份的用户缓冲区余下的内容。
- 在fork()前使用fflush()以提前刷新缓冲区。
5. 理解文件系统
/bin 二进制可执行命令。
/dev 设备特殊文件。
/etc 系统管理和配置文件。
/home 用户主目录的基点,比如用户user 的主目录就是/ home/ user。
/lib 标准程序设计库,又叫动态链接共享库。
/sbin 系统管理命令,这里存放的是系统管理员使用的管理程序。
/tmp 公用的临时文件存储点。
/root 系统管理员的主目录。
/mnt 用户临时安装其他文件系统的目录。
/proc 虚拟的目录,不占用磁盘空间,是系统内存的映射。可直接访问这个目录来获取系统信息。
/var 某些大文件的溢出区,例如各种服务的日志文件。
/usr 最庞大的目录,要用到的应用程序和文件几乎都在这个目录下。
Linux虚拟文件系统 VFS
Linux支持多达几十种文件系统,但这些真实的文件系统并不是一下子都挂在系统中的,它们实际上是按需被挂载的。
Linux包含通用文件处理机制——虚拟文件系统(Virtual File System,VFS) 来支持大量的文件管理系统和文件结构。
VFS的信息都来源于“实”的文件系统,所以VFS必须承载各种文件系统的共有属性。另外,这些实的文件系统只有安装到系统中,VFS才予以认可,也就是说,VFS只管理挂载到系统中的实际文件系统。
VFS定义了一个能代表任何文件系统的通用特征和行为的通用文件模型,并向用户提供简单而统一的文件系统接口。而任何文件系统都有一个映射模块,以便将实际文件系统的特征转换为虚拟文件系统统一的特征。
以用户进程通过VFS来发起文件系统调用为例。VFS通过映射函数将系统调用转换为特定文件系统的功能调用[ ext2FS(二次扩展文件系统)]
当进程发起一个面向文件的系统调用时,内核调用VFS的函数,该函数再调用X文件系统的相应函数,这其中通过映射函数实现,而映射函数是X系统在Linux上的一部分。X文件系统将文件系统请求转换到面向设备的指令。
VFS简述
VFS的作用就是采用标准系统调用来读写位于不同物理介质上的不同文件系统,即为各类文件系统提供了一个统一的操作界面和应用编程接口。VFS是一个可以让open()、read()、write()等系统调用不用关心底层的存储介质和文件系统类型就可以工作的粘合层。
VFS的4个主要对象:
- 超级块对象(superblock object):表示一个已挂载的文件系统(如 ext2/3/4)
- 索引节点对象(inode object):表示一个特定的文件
- 目录项对象(dentry object):表示一个特定的目录项
- 文件对象(file object):表示一个与进程相关的已打开文件
超级块对象
超级块用来描述整个文件系统的信息。每个具体的文件系统有各自的超级块,如Ext2超级块和Ext3超级块,它们存放于磁盘上。
当内核在对一个文件系统进行初始化和注册时在内存为其分配一个超级块,这就是VFS超级块。
也就是说,VFS超级块是各种具体文件系统在安装时建立的,并在这些文件系统卸载时被自动删除,可见,VFS超级块只存在于内存中。
简要介绍下超级块结构体中的一些重要属性:
struct super_block
{
struct list_head s_list; /* 指向所有超级块的链表 */
struct file_system_type *s_type; /*所表示的文件系统的类型*/
const struct super_operations *s_op; /* 超级块方法 */
struct dentry *s_root; /* 目录挂载点 */
struct mutex s_lock; /* 超级块信号量 */
struct list_head s_inodes; /* inode链表 */
struct mtd_info *s_mtd; /* 存储磁盘信息 */
...
void *s_fs_info; /*指向具体文件系统的超级块*/
...
}
所有的超级块对象都以双向循环链表的形式链接在一起,每种文件系统都要把自己的信息挂到super_blocks这么一个全局链表上,通过register_filesystem函数将自己的file_system_type挂接到file_systems这个全局变量上。
与超级块对象关联的方法就是超级块操作表,由super_operation描述。文件系统调用kern_mount函数把自己的文件相关操作函数集合表挂到super_operation上。
struct super_operations
{
void (*read_inode) (struct inode *); /* 从磁盘读取某个文件系统的inode */
int (*write_inode) (struct inode *, int); /* 将索引节点写入磁盘,wait表示写操作是否需要同步 */
void (*put_inode) (struct inode *); /* 逻辑上释放索引节点 */
void (*delete_inode) (struct inode *); /* 从磁盘上删除索引节点 */
void (*put_super) (struct super_block *); /* 卸载文件系统时由VFS调用,用来释放超级块 */
void (*write_super) (struct super_block *); /* 用给定的超级块更新磁盘上的超级块 */
...
};
索引结点对象
一个索引节点对应一个文件,文件系统处理文件所需要的所有信息都存放在索引节点的数据结构中。
文件名可随时修改,但是索引节点是唯一的,随文件的存在而存在。
具体的文件系统的索引节点是存放在磁盘上的,是一种静态结构,如果要调用该文件,就必须在打开文件时调入内存填写到VFS的索引节点,故VFS的索引节点为动态节点。
struct inode
{
struct hlist_node i_hash; /* 散列表,用于快速查找inode */
struct list_head i_list; /* 索引节点链表 */
struct list_head i_sb_list; /* 超级块链表超级块 */
struct list_head i_dentry; /* 目录项链表 */
unsigned long i_ino; /* 节点号 */
unsigned int i_nlink; /* 硬链接数 */
uid_t i_uid; /* 使用者id */
gid_t i_gid; /* 使用组id */
struct timespec i_atime; /* 最后访问时间 */
struct timespec i_mtime; /* 最后修改时间 */
struct timespec i_ctime; /* 最后改变时间 */
const struct inode_operations *i_op; /* 索引节点操作函数 */
const struct file_operations *i_fop; /* 缺省的索引节点操作 */
struct super_block *i_sb; /* 相关的超级块 */
struct address_space *i_mapping; /* 相关的地址映射 */
struct address_space i_data; /* 设备地址映射 */
unsigned int i_flags; /* 文件系统标志 */
void *i_private; /* fs 私有指针 */
...
};
与索引节点关联的函数就是索引节点操作表,由inode_operations来描述。
不同的文件系统,未必能与VFS的索引节点的每个函数接口对应,没有实现的函数对应的域应置为NULL。
struct inode_operations
{
/* 创造一个新的磁盘索引节点 */
int (*create) (struct inode *,struct dentry *,int);
/* 在特定文件夹中寻找索引节点,该索引节点要对应于dentry中给出的文件名 */
struct dentry * (*lookup) (struct inode *,struct dentry *, struct nameidata *);
/* 创建硬链接 */
int (*link) (struct dentry *,struct inode *,struct dentry *);
/* 从一个符号链接查找它指向的索引节点 */
void * (*follow_link) (struct dentry *, struct nameidata *);
/* 在 follow_link调用之后,该函数由VFS调用进行清除工作 */
void (*put_link) (struct dentry *, struct nameidata *, void *);
/* 该函数由VFS调用,用于修改文件的大小 */
void (*truncate) (struct inode *);
...
};
目录项对象
每个文件除了索引节点inode外,还有一个目录项dentry数据结构,dentry中有一个d_inode指针指向对应的inode结构。
虽然inode和dentry都是描述文件属性的数据结构,但他们描述的目标不一致。
inode描述文件的物理属性,相对于具体的文件系统,他存放了文件映射硬件的物理地址的信息。,所以每个文件inode是唯一的。
dentry描述的是逻辑信息,文件所存放的目录在逻辑上为一个树状结构,同一文件可以存放在多个目录下,所以dentry不唯一,而且在磁盘上没有对应的映射信息。
由于通过索引节点属性繁杂,定位文件的效率并不高,所以引入目录项的概念。
目录项的三种状态:
- 被使用:对应一个有效的索引节点,并且该对象由一个或多个使用者
- 未使用:对应一个有效的索引节点,但是VFS当前并没有使用这个目录项
- 负状态:没有对应的有效索引节点(可能索引节点被删除或者路径不存在了)
struct dentry
{
atomic_t d_count; /* 使用计数 */
unsigned int d_flags; /* 目录项标识 */
spinlock_t d_lock; /* 单目录项锁 */
int d_mounted; /* 是否登录点的目录项 */
struct inode *d_inode; /* 相关联的索引节点,通过这个索引节点就可以读取到文件数据 */
struct hlist_node d_hash; /* 目录项形成的哈希表 */
struct dentry *d_parent; /* 父目录的目录项对象 */
struct qstr d_name; /* 目录项名称 */
struct list_head d_lru; /* 未使用的链表 */
struct list_head d_subdirs; /* 子目录链表 */
struct list_head d_alias; /* 索引节点别名链表 */
unsigned long d_time; /* 重置时间 */
const struct dentry_operations *d_op; /* 目录项操作相关函数 */
struct super_block *d_sb; /* 文件的超级块 */
void *d_fsdata; /* 文件系统特有数据 */
unsigned char d_iname[DNAME_INLINE_LEN_MIN]; /* 短文件名 */
...
};
与目录项关联的方法就是目录项操作表, 由dentry_operations来描述。
struct dentry_operations
{
/* 该函数判断目录项对象是否有效。VFS准备从dcache中使用一个目录项时会调用这个函数 */
int (*d_revalidate)(struct dentry *, struct nameidata *);
/* 为目录项对象生成hash值 */
int (*d_hash) (struct dentry *, struct qstr *);
/* 比较 qstr 类型的2个文件名 */
int (*d_compare) (struct dentry *, struct qstr *, struct qstr *);
/* 当目录项对象的 d_count 为0时,VFS调用这个函数 */
int (*d_delete)(struct dentry *);
/* 当目录项对象将要被释放时,VFS调用该函数 */
void (*d_release)(struct dentry *);
/* 当目录项对象丢失其索引节点时(也就是磁盘索引节点被删除了),VFS会调用该函数 */
void (*d_iput)(struct dentry *, struct inode *);
char *(*d_dname)(struct dentry *, char *, int);
...
};
一个有效的dentry结构必定有一个inode结构,这是因为一个目录项要么代表一个文件,要么代表一个目录,而目录实际上也是文件。所以,只要dentry结构是有效的,则其指针d_inode必定指向一个inode结构。
但是,一个inode却可能对应着不止一个dentry结构,也就是说,一个文件可以有不止一个文件名或路径名。所以在inode结构中有一个队列i_dentry,凡是代表着同一个文件的所有目录项都通过其dentry结构中的d_alias域挂入相应inode结构中的i_dentry队列。
文件对象
文件对象描述的是进程已经打开的文件。因为一个文件可以被多个进程打开,所以一个文件可以存在多个文件对象。但是由于文件是唯一的,那么inode就是唯一的,目录项也是定的!
进程其实是通过文件描述符来操作文件的,每个文件都有一个32位的数字来表示下一个读写的字节位置,这个数字叫做文件位置。
一般情况下打开文件后,打开位置都是从0开始,除非一些特殊情况。Linux用file结构体来保存打开的文件的位置,所以file称为打开的文件描述。file结构形成一个双链表,称为系统打开文件表。
文件对象结构体的代码已在上文章节 “struct file 描述已打开的文件” 中说明,这里不再赘述。
ext2 文件系统
磁盘布局
我们使用的机械硬盘在本质上使用的是磁盘的技术。
每片盘片有两个面,每个面都有一个磁头。
- 磁道:盘片上不同半径的同心圆
- 扇区:磁道被等分为若干个弧段,每个弧段为扇区,是硬盘读写的基本单位。
- 柱面:不同盘片相同半径磁道组成的圆柱面。
磁盘为块设备,磁盘分区被划分为多个block。
- Block Group :分区单元,每个Block Group结构相同。
- Super Block:存放文件系统自身的结构信息。(block 和 inode的总量,未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了)
- Group Descriptor Table:描述块组属性信息。
- Block Bitmap:块位图,记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用。
- inode Bitmap:inode位图,每个bit位记录inode编号是否空闲可用
- inode Table:inode节点表 存放文件属性:具体包含的信息有inode号,文件的字节数、User ID、Group ID、读、写、执行权限、时间戳(共有三个:ctime指inode上一次变动的时间,mtime指文件内容上一次变动的时间,atime指文件上一次打开的时间)、链接数(软硬链接)、数据block的下标位置,注意没有文件名。
- Data blocks:存放文件内容
df指令显示磁盘使用情况
语法格式: df [参数] [指定文件]
常用参数:
- -a 显示所有系统文件
- -B <块大小> 指定显示时的块大小
- -h 以容易阅读的方式显示
- -H 以1000字节为换算单位来显示
- -i 显示索引字节信息
- -k 指定块大小为1KB
- -l 只显示本地文件系统
- -t <文件系统类型> 只显示指定类型的文件系统
- -T 输出时显示文件系统类型
shell 获取 inode
-
ls -i 显示文件的inode
- inode
- 模式
- 硬链接数
- 文件所有者
- 所属组
- 大小
- 最后修改时间
文件查找过程
以查找上图中的tmp.c为例
Linux中的一个进程在识别一个文件的时候,将文件名传递给VFS层,VFS要根据文件名查找这个文件的索引节点inode,以备后续对该文件的操作。通过文件名查找文件索引节点的过程就叫做路径查找(path lookup)。
内核会从某一个特定的dentry(根inode)开始查起,如果路径名是以‘/’开始,则起始的dentry是current->fs->root;否则,起始的dentry是current-> fs ->cwd。
路径查找开始后,内核从第一个dentry开始,找到它对应的索引节点;然后从磁盘的 inode table 中找到对应的块,在块中再获取下一层目录的inode号,获得索引节点…,反复执行上述操作,直到找到最后的文件。
- 目录也是文件
目录有自己的属性信息,目录的inode table结构当中存储的就是目录的属性信息,比如目录的大小、目录的拥有者等。
目录也有自己的内容,目录的数据块当中存储的就是该目录下的文件名以及对应文件的inode指针。
当执行 ls 指令时,通过该目录文件下所有文件对应inode号找到inode table中的信息再打印到屏幕中。
文件创建过程
- 扫描inode map 找到空闲inode号占用。
- 扫描block map 找到空闲的block。
- 创建的文件信息以及Data block 的下标填入inode table中与inode号生成映射关系。
- 通过inode号找到Data block块,并将文件内容写入。
- 文件名及inode写入目录文件
文件删除过程
- 将该文件对应的inode在inode map当中置为无效(1->0)。
- 将该文件申请过的数据块在block map当中置为无效(1->0)。
这也就解释了为什么删除文件速度很快,只需将inode和block的对应位图置为无效即可,也就是说我们允许对这片区域进行覆盖。
因为此操作并不会真正将文件对应的信息删除,而只是将其inode号和数据块号置为了无效,所以当我们删除文件后短时间内是可以恢复的。(没有被其他文件内容覆盖)
6. 软硬链接
ln命令(link),负责文件在另外一个位置建立一个同步的的链接。
分为两种:
- hard 链接,又称硬链接
- symbolic 链接,又称符号链接(软链接)
语法格式: ln [参数] [源文件或目录] [目标文件或目录]
常用参数:
- -b 为每个已存在的目标文件创建备份文件
- -d 此选项允许“root”用户建立目录的硬链接
- -f 强制创建链接,即使目标文件已经存在
- -n 把指向目录的符号链接视为一个普通文件
- -i 交互模式,若目标文件已经存在,则提示用户确认进行覆盖
- -s 对源文件建立符号链接,而非硬链接
- -v 详细信息模式,输出指令的详细执行过程
ulink [文件] :删除链接文件
符号链接(软链接)
- 符号链接以路径的形式存在,类似于Windows操作系统中的快捷方式。
- 符号链接可以跨文件系统 ,硬链接不可以。
- 符号链接可以对一个不存在的文件名进行链接,硬链接不可以。
- 符号链接可以对目录进行链接,硬链接不可以。
创建软连接 -s :
软链接的使用:
软连接的使用与快捷方式使用相似
软链接应用于执行路径特别深的程序。
软连接是独立的文件,有自己的inode,软连接文件的数据块中保存了源文件的路径+文件名
但是软链接文件只是其源文件的一个标记,当删除了源文件后,链接文件不能独立存在,虽然仍保留文件名,但却不能执行或是查看软链接的内容了。(类比windows,源文件删掉后,快捷方式也只是个图标而已)
硬链接
使用ln指令进行的连接默认为硬链接
可以看到硬链接的文件inode和源文件的inode是一样的,所以硬链接没有独立的inode。创建硬链接本质上是在特定目录下,添加新文件名和已有inode的映射关系,同时该 inode 中的硬链接数+1。
硬链接数:同一inode所连接的文件名个数
注意:当我们创建文件时硬链接数是1,但是创建目录文件时他的初始硬链接数为2
原因在于:进入目录后的 .
就是一个硬链接
当我们再往下新建目录,当前目录文件的硬链接数还会加1,原因是下层目录的 ..
是当前目录文件的一个硬链接
软硬链接的区别:
软链接:
1.软链接,以路径的形式存在。类似于Windows操作系统中的快捷方式
2.软链接可以 跨文件系统 ,硬链接不可以
3.软链接可以对一个不存在的文件名进行链接
4.软链接可以对目录进行链接
硬链接:
1.硬链接,以文件副本的形式存在。但不占用实际空间。
2.不允许给目录创建硬链接
3.硬链接只有在同一个文件系统中才能创建
是否具有独立inode,有独立inode—>软链接,无独立inode->硬链接。
7. 三个时间 ACM
-
stat 文件名
:获取文件属性,其中包含了三个时间。 -
Access time
文件最近的访问时间 -
Modify time
最近一次修改文件内容的时间 -
Changed time
最近一次修改文件属性的时间,注意:修改文件内容也是修改文件属性的一部分。
🚩注意:由于我们对文件的访问频率较高,而修改文件内容和属性的频率较低,所以modify/change time 会实时刷新,而access time经系统优化后会在固定的时间间隔进行刷新。
只修改属性(如修改访问权限),此时只有change time 会改变,其他两个时间不变。
- 时间的运用案例
用于判定源文件与可执行程序谁较新谁较旧,从而指导Makefile是否需要重新编译源文件生成可执行程序。
当可执行文件的 modify time
时间较新时,对源文件进行make就不会再次编译了。
如果源文件的内容经过改动(modify time时间变化),那么源文件的较新,make会重新编译:
-
touch 文件名
,创建文件,对于一个已经存在的文件,会将该文件的所有时间更新。此时即使文件内容没有进行更新,还是可以make:
-end-
青山不改 绿水长流