文件的基础理解
- 空文件也要在磁盘上占据空间。
- 文件 = 文件内容+文件属性。
- 文件操作 = 对内容的操作 + 对属性的操作或者是对内容和属性的操作。
- 标定一个文件,必须使用:文件路径 + 文件名(具有唯一性)。
- 如果没有指明对应的文件路径,默认是在当前路径(进程当前的路径)进行文件访问。
- 当我们把fopen、fclose、fwrite等接口写完之后,代码编译之后,形成二进制可执行程序后,但是没有运行,文件对应的操作有没有被执行呢?没有。对文件操作的本质是:进程对文件的操作!
- 一个文件要被访问,必须先被打开(用户+进程+OS)。用户调用相关函数接口、进程执行函数、OS访问磁盘文件。
- 所以文件操作的本质是:进程和被打开文件之间的关系。
C语言有文件操作的接口,C++、JAVA、Python、php、go等其他语言呢?
同样有,但是操作接口都不一样。
文件存在磁盘上,磁盘又是一个硬件,要访问硬件,只有操作系统能访问,所以要想访问磁盘就不能绕过OS,OS也必定要提供文件级别的系统调用接口。所以,无论上层语言如何变化:
- 库函数可以千变万化,但是底层不变。
- 库函数底层都必须调用系统调用接口。
C文件I/O
FILE * fopen ( const char * filename, const char * mode ); //成功返回指向FILE对象的指针,失败返回NULL。 int fclose ( FILE * stream ); //成功返回0,失败返回EOF。 size_t fwrite ( const void * ptr, size_t size, size_t count, FILE * stream ); size_t fread ( void * ptr, size_t size, size_t count, FILE * stream ); int puts ( const char * str ); int fputs ( const char * str, FILE * stream ); //fputs不会写入其他字符、puts会在自动在末尾附加换行符。 char * fgets ( char * str, int num, FILE * stream ); char * gets ( char * str ); //fgets在结果字符串中包含任何结束换行符、gets结果字符串中不包含任何字符。 ... fseek、ftell、rewind......
写段简单的对文件操作的C语言代码:
int main()
{
FILE* fp = fopen("log.txt","w"); //w 只写方式打开,不存在创建。存在将内容自动清空
// FILE* fp = fopen("log.txt","r"); //r 只读方式打开
// FILE* fp = fopen("log.txt","a"); //a 以追加方式打开
if(NULL == fp)
{
perror("fopen");
return 1;
}
// char buffer[64];
// while(fgets(buffer,sizeof(buffer)-1,fp) != NULL)
// {
// buffer[strlen(buffer)-1] = 0;
// // fputs(buffer,stdout);
// puts(buffer);//会在末尾自动添加\n
// }
int cnt = 5;
while(cnt)
{
fprintf(fp,"%s:%d\n","hello world!",cnt--);
}
fclose(fp);
return 0;
}
打开文件的方式:
系统文件I/O
操作文件,除了上述C接口(其他语言也有),当然还可以采用系统接口来进行文件的访问。
打开文件: #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); int creat(const char *pathname, mode_t mode); pathname:要打开或创建的目标文件 flags:打开文件时,可以传入多个参数选项,用一个或者多个常量进行“或”运算,构成flags。 参数:O_RDONLY: 只读打开。 O_WRONLY: 只写打开。 O_RDWR: 读、写打开。这三个常量,必须指定一个且只能指定一个。 O_APPEND: 追加写。 O_CREAT: 若文件不存在,则创建它。需要使用mode选项,指明新文件的访问权限。 O_TRUNC: 若文件存在,则清空文件内容。 返回值: 成功:返回新打开的文件描述符。 失败:返回-1。 关闭文件: #include <unistd.h> int close(int fd); 写入文件: #include <unistd.h> ssize_t write(int fd, const void *buf, size_t count) //成功返回成功写入的字节个数,失败返回-1。 读取文件: #include <unistd.h> ssize_t read(int fd, void *buf, size_t count); //成功返回成功读取的字节个数,失败返回-1。 //注意:"const void *buf",返回值为void*。在其他语言中,写入文件的类型包括文本类、二进制类,是语言提供的文件读取的分类,但是在OS看来,不管写入的是什么,都是二进制。读也是一样,没有要读入数据的具体类型,就是要读几个字节,具体读到的是什么由自己决定。
写文件:
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
umask(0);//系统默认文件mask为0002,也可以更改(更改的为子进程的mask,父进程shell不受影响)
int fd = open("log.txt",O_WRONLY|O_CREAT,0666);
if(fd<0)
{
perror("open");
return 1;
}
//"hello world:5" hello world是字符串,5、4、3、2、1是整数
//所以要将"hello world:5"... 格式化为字符串
int cnt = 5;
char outBuffer[64];
while(cnt)
{
sprintf(outBuffer,"%s:%d\n","hello world!",cnt--); //将格式化数据转化为字符串存储在outBuffer中
write(fd,outBuffer,strlen(outBuffer));
//向文件写入string时,要不要+1,即strlen(outBuffer)+1,将字符串末尾的"\0"写入 ?
//不需要,以"\0"作为字符串结尾,是c语言规定的,和文件没有关系。文件要的是字符串的有效内容。
}
close(fd);
return 0;
}
读文件:
#include<stdio.h>
#include<string.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
int fd = open("log.txt",O_RDONLY);
if(fd<0)
{
perror("open");
return 1;
}
char buffer[1024];
ssize_t num = read(fd,buffer,sizeof(buffer)-1);
if(num > 0)
{
buffer[num] = 0; //将读取结果最后一个字符置为\0,证明读取到的为字符串。
}
printf("%s",buffer);
close(fd);
return 0;
}
总结:
C库函数接口:fopen、fclose、fwrite、fread、fseek...
系统调用接口:open、close、write、read、lseek...
可以认为库函数接口底层都是封装了系统调用接口,方便二次开发。
文件描述符
文件操作的本质:进程和被打开文件的关系。
进程可以打开多个文件,所以系统中一定会存在大量的被打开的文件,OS要将这些被打开的文件管理起来(以先描述,再组织的方式),OS为了管理对应的打开的文件,必定要为文件创建对应的内核数据结构来标识文件(也就是struct file{ },其中包含了文件的大部分属性)。
所以在操作系统内部就可以把每一个struct file{ }文件用链式结构链接起来,OS只需要找到struct file{ }的起始地址,对文件的管理就变成了对链表的增删查改。
那么,进程和被打开文件之间的关系是怎样维护的呢?
通过对open函数的了解,打开成功返回的是文件的文件描述符,是一个整数。
看下面这段代码:
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
#define FILE_NAME(number) "log.txt"#number //字符串拼接
int main()
{
umask(0);
int fd0 = open(FILE_NAME(1),O_WRONLY|O_CREAT,0666);
int fd1 = open(FILE_NAME(2),O_WRONLY|O_CREAT,0666);
int fd2 = open(FILE_NAME(3),O_WRONLY|O_CREAT,0666);
int fd3 = open(FILE_NAME(4),O_WRONLY|O_CREAT,0666);
int fd4 = open(FILE_NAME(5),O_WRONLY|O_CREAT,0666);
printf("fd:%d\n",fd0);
printf("fd:%d\n",fd1);
printf("fd:%d\n",fd2);
printf("fd:%d\n",fd3);
printf("fd:%d\n",fd4);
close(fd0);
close(fd1);
close(fd2);
close(fd3);
close(fd4);
return 0;
}
运行结果:
fd为什么从3开始呢?0、1、2呢?
C程序默认会打开三个标准输入输出流:
- stdin --> 键盘
- stdout --> 显示器
- stderr --> 显示器
所以输入输出还可以采用下面这种方式:
#include <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <string.h> 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; }
打开文件C语言使用的函数接口为FILE* fp = fopen( ),系统调用接口 int fd = open( )。
FILE其实就是一个结构体。上层库函数要访问文件必须调用底层系统调用接口,而系统调用要访问文件必须要通过文件描述符。那么,自然而然,FILE结构体中必定包含文件描述符这样的字段。C语言不仅封装了系统接口,而且也封装了数据类型。
FILE是一个结构体。stdin、stdout、stderr也是FILE类型的结构体,C程序又默认会打开这三个标准输入输出流,所以我们可以预测,文件描述符0、1、2就对应着默认打开的三个输入输出流。
printf("stdin->fd:%d\n",stdin->_fileno);
printf("stdout->fd:%d\n",stdout->_fileno);
printf("stderr->fd:%d\n",stderr->_fileno);
//...
总结:
- Linux进程默认会打开三个文件描述符fd,标准输入0、标准输出1、标准错误2。
- C语言不仅在访问文件接口方面封装了系统调用接口,FILE*类型的指针还封装了OS内的文件描述符。
那么文件描述符又为什么是0、1、2、3、4 ... 这样连续的小整数呢?
当我们打开文件时,OS要在内存中创建相应的数据结构来描述目标文件。所以就有了struct file{ }结构体,表示一个已经打开的文件对象,而进程执行open系统调用,必须让进程和文件关联起来。每个进程都有一个指针struct files_struct* files,指向一张表stuct files_struct{ }(文件描述符表),该表最重要的部分就是包含一个struct file* fd_arrar[ ]的指针数组,每个元素都是指向被打开文件的指针。
所以,文件描述符,本质就是该数组的下标,只要拿着文件描述符表,就可以找到对应的文件。
文件描述符分配规则
直接看代码:
#include<stdio.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
umask(0);
int fd = open("log.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n",fd);
close(fd);
return 0;
}
输出结果fd:3(0、1、2默认被占用)。
如果0或2号文件:
int main()
{
close(0);
//close(2);
umask(0);
int fd = open("log.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n",fd);
close(fd);
return 0;
}
关闭0:
关闭2:
输出结果分别为fd:0、fd:2。
文件描述符的分配规则为:在struct files_struct结构体的数组(也就是文件描述符表struct file * fd_arry[ ])中,从小到大按顺序找到当前没有被使用的最小的下标,作为新文件的文件描述符。
重定向
如果关闭1号文件呢?
int main()
{
//close(0);
//close(2);
close(1);
umask(0);
int fd = open("log.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n",fd); //默认向stdout打印
//fprintf(stdout,"fd: %d\n",fd); //向stdout打印
close(fd);
return 0;
}
运行结果:
本来应该输出到显示器上的fd:1,却写到了文件log.txt中,这种现象叫输出重定向。
重定向的本质:上层使用的fd不变,在内核中更改fd对应的struct file*对应指向的地址。
系统调用dup2
系统也提供了支持重定向的接口:
#include<unistd.h> int dup2 (int oldfd , int newfd); //makes newfd be the copy of oldfd, closing newfd first if necessary, but note the following。 //注意:是把oldfd文件描述符里的内容,拷贝到newfd文件描述符中。并不是拷贝文件描述符!
常见重定向
> :输出重定向
>> :追加重定向
< :输入重定向
下面使用系统调用dup2分别实现输出重定向、追加重定向、输入重定向。
实现输出重定向:
int main()
{
//close(0);
//close(2);
//close(1);
umask(0);
int fd = open("log.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd,1);//输出重定向
printf("fd: %d\n",fd);
//fprintf(stdout,"fd: %d\n",fd);
return 0;
}
可以看到,本来应该输出到显示器上的,此时写到了log.txt中。
实现追加重定向:
int main()
{
umask(0);
//int fd = open("log.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
int fd = open("log.txt",O_WRONLY|O_CREAT|O_APPEND,0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd,1);
printf("fd: %d\n",fd);
const char* msg = "hello world\n";
write(1,msg,strlen(msg));
write(1,msg,strlen(msg));
close(fd);
return 0;
}
实现输入重定向:
int main()
{
umask(0);
//int fd = open("log.txt",O_WRONLY|O_CREAT|O_TRUNC,0666);
//int fd = open("log.txt",O_WRONLY|O_CREAT|O_APPEND,0666);
int fd = open("log.txt",O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
char line[64];
dup2(fd,0);//输入重定向
while(1)
{
printf("> ");
if(fgets(line,sizeof(line)-1,stdin)==NULL) break;//stdin -> 0
printf("%s",line);
}
//dup2(fd,1);
//printf("fd: %d\n",fd);
//const char* msg = "hello world\n";
//write(1,msg,strlen(msg));
//write(1,msg,strlen(msg));
close(fd);
return 0;
}
没有使用系统调用dup2()之前,从键盘输入:
dup2()输入重定向之后,从log.txt文件输入:
例:myshell中添加重定向功能
【Linux】C语言实现简易的Linux shell命令行解释器-CSDN博客
在shell命令行上,我们可以这样使用重定向:
那么shell是怎么做到的呢?我们可以在自己实现的myshell中添加重定向功能来模拟实现一下。
关于如何实现简易的Linux shell命令行解释器可以看上篇文章:
【Linux】C语言实现简易的Linux shell命令行解释器-CSDN博客
完整代码:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<sys/wait.h>
#include<assert.h>
#include<string.h>
#include<ctype.h>
#include<errno.h>
#define NUM 1024
#define OPT_NUM 64
#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
#define trimSpace(start) do{ while(isspace(*start)) start++; }while(0)
char lineCommand[NUM];
char* myargv[OPT_NUM];//指针数组
int lastCode = 0;
int lastSig = 0;
int redirType = NONE_REDIR;//重定向类型
char* redirFile = NULL;//重定向目标文件
//eg: "ls -a -l > myfile.txt" -> "ls -a -l" "myfile.txt"
//将得到的字符串解析为两部分。顺序扫描/倒序扫描
void commandCheck(char* commands)
{
assert(commands);
char* start = commands;
char* end = commands + strlen(commands);
while(start < end)
{
if(*start == '>')
{
*start = '\0';
start++;
if(*start == '>') //追加重定向
{
//"ls -a >> log.txt"
redirType = APPEND_REDIR;
start++;
}
else{
//"ls -a > log.txt"
redirType = OUTPUT_REDIR;
}
trimSpace(start);
redirFile = start;
break;
}
else if(*start == '<') //输入重定向
{
//"ls -a < log.txt"
*start = '\0';
start++;
//跳过空格
trimSpace(start);
//填写重定向信息
//
redirType = INPUT_REDIR;
redirFile = start;
break;
}
else{
start++;
}
}
}
int main()
{
while(1)
{
redirType = NONE_REDIR;
redirFile = NULL;
errno = 0;
//输出提示符
printf("用户名@主机号 当前路径# ");
fflush(stdout);
//获取用户输入,输入的时候会输入\n
char* s = fgets(lineCommand,sizeof(lineCommand)-1,stdin);
assert(s!=NULL);
//清除最后一个\n
lineCommand[strlen(lineCommand)-1]=0;
//解析字符串
//"ls -a -l > myfile" -> 命令:"ls -a -l" 重定向目标文件:"myfile"
commandCheck(lineCommand);
//printf("test : %s\n",lineCommand);
//切割字符串
//"ls -a -l" -> "ls" "-a" "-l"
myargv[0] = strtok(lineCommand," ");
int i = 1;
//给ls加颜色
if(myargv[0] != NULL && strcmp(myargv[0],"ls") == 0)
{
myargv[i++] = "--color=auto";
}
while(myargv[i++] = strtok(NULL," "));//如果没有子字符串要切割,strtok返回NULL,而恰好myargv[end]也一定要= NULL
if(myargv[0] != NULL && strcmp(myargv[0],"cd") == 0)
{
if(myargv[1] != NULL)
{
chdir(myargv[1]);
}
continue;
}
if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0],"echo") == 0)
{
if(strcmp(myargv[1],"$?") == 0)
{
printf("%d\n",lastCode);
}
else if(strcmp(myargv[1],"$?") != 0)
{
for(int i = 1; myargv[i]; i++)
{
printf("%s ",myargv[i]);
}
printf("\n");
}
else
{
printf("%s\n",myargv[1]);
}
continue;//重新循环,不执行fork创建子进程
}
//测试切割是否成功,条件编译
#ifdef DEBUG
for(int i = 0; myargv[i]; i++)
{
printf("myargv[%d]: %s\n",i,myargv[i]);
}
#endif
//执行命令
pid_t id = fork();
assert(id != -1);
if(id == 0)
{
switch(redirType)
{
case NONE_REDIR:
//nothing to do
break;
case INPUT_REDIR:
{
//打开输入重定向目标文件
int fd = open(redirFile,O_RDONLY);
if(fd < 0)
{
perror("open");
exit(errno);
}
//输入重定向
dup2(fd,0);
}
break;
case OUTPUT_REDIR:
case APPEND_REDIR:
{
umask(0);
int flags = O_WRONLY|O_CREAT;
if(redirType == OUTPUT_REDIR) flags |= O_TRUNC;
else flags |= O_APPEND;
int fd = open(redirFile,flags,0666);
if(fd < 0)
{
perror("open");
exit(errno);
}
//输入、追加重定向
dup2(fd,1);
}
break;
default:
printf("bug?\n");
break;
}
execvp(myargv[0],myargv);
exit(1);
}
int status = 0;
pid_t ret = waitpid(id,&status,0);
assert(ret > 0);
lastCode = ((status>>8) & 0xFF);
lastSig = (status & 0x7F);
}
}
测试:
Linux下一切皆文件
当一个硬件设备或文件被访问时,OS内核会为其分配一个struct file结构体,并将对应的文件操作函数指针指向该设备或文件系统的具体实现。这样,当上层应用程序调用如read()、write()等系统调用时,内核会根据当前的文件描述符找到对应的struct file结构体,并调用其指向的具体函数来直接指向具体方法来实现读写操作。
总结:
通过虚拟的文件系统,上层访问硬件时,就摒弃了底层硬件设备的差异,而统一使用文件接口来进行所有的文件操作。
理解缓冲区问题
看下面这段代码:
#include<stdio.h>
#include<unistd.h>
int main()
{
//C接口
printf("hello printf\n");
fprintf(stdout,"%s","hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString,stdout);
//系统接口
const char* writeString = "hello write\n";
write(1,writeString,strlen(writeString));
//
fork();
return 0;
}
测试运行:
当把fork函数删除之后:
可以看到,当fork函数存在时,当向显示器输出的时候,C语言函数接口和系统函数接口都只打印了一次,而当输入重定向到log.txt文件中,C语言函数接口打印了两次,系统函数接口打印了一次。这种现象跟缓冲区有关。
缓冲区存在意义
缓冲区本质就是一段内存!
所以,缓冲区存在的意义就是:节省进程进行数据IO的时间!
缓冲区刷新策略
缓冲区什么时候将数据发送到磁盘呢?
如果有一块数据,一次写入到外设,还是多次少量的写入到外设效率高呢?
实际上,在进行IO的时候,数据从内存拷贝到外设花费时间的时间很少,大部分的时间都是在等外设准备好。所以,将数据一次写入到外设的效率高,因为只进行了一次IO。
缓冲区会结合具体的设备,制定自己的刷新策略:
- 立即刷新 - - - - 无缓冲
- 行刷新 - - - - 行缓冲 如:显示器
- 缓冲区满刷新 - - - - 全缓冲 如:磁盘文件
两种特殊情况:
- 用户强制刷新
- 进程退出(一搬都要进行缓冲区刷新)
显示器同样是外设,为什么要行刷新呢?
虽然,全缓冲,缓冲区满了之后,再将缓冲区里的数据一次刷新到外设中的效率是最高的,但是,显示器是用给人来看的,按行显示,更符合人的阅读习惯。
缓冲区存放区域
那么缓冲区在哪里呢?由谁提供的呢?
删除fork之后:
根据此现象,可以得知,缓冲区一定不在OS内核中!因为,如果在内核中,write也要被打印两次!(因为库函数fwitre底层调用的是系统调用write)
所以,我们上面所谈论的缓冲区,都指的是用户级语言层面(这里C语言)给我们提供的缓冲区。(当然。OS也会提供内核级缓冲区,不过,我们不讨论)
当我们进行C语言输入输出的时候,默认会打开stdin、stdout、stderr,他们的类型都是FILE * 或者打开文件fopen,进行文件读写fwrite、fread时,都必须要传入FILE*,FILE是一个结构体,FILE中就封装了文件描述符和缓冲区!!!也就是说,我们在C程序上进行所谓的输入输出等操作,最终都会先将数据写进FILE*指向的FILE结构体内的缓冲区中!
所以,当我们要强制刷新缓冲区,fflush(文件指针FILE*),或者关闭文件fclose(文件指针FILE*),都要传入FILE*,原因就是FILE结构体中包含了缓冲区!
如果有兴趣,也可以看一下FILE结构体:
/usr/include/stdio.h
/usr/include/libio.h
基于上述对缓冲区的理解,我们现在再来解释一下,一开始代码fork之后出现的现象。
int main()
{
//C接口
printf("hello printf\n");
fprintf(stdout,"%s","hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString,stdout);
//系统接口
const char* writeString = "hello write\n";
write(1,writeString,strlen(writeString));
//代码结束之前,进行创建子进程
//1、如果我们没有进行输出重定向> ,看到了4条消息
//stdout 默认使用的是行刷新,在进程fork之前,3条函数已经将数据进行打印输出到显示器上(外设),FILE内部,进程内部不存在对应的数据了。
//2、如果我们进程了输出重定向> ,写入文件不再是显示器,而是普通文件,采用的刷新策略是全缓冲,之前的3条C显示函数,虽然带了\n,但是不足以
//将stdout缓冲区写满!数据并没有被刷新。
//执行fork的时候,stdout属于父进程,创建子进程时,紧接着就是进程退出,谁先退出,一定要进行缓冲区刷新(就是修改)
//所以,此时,会发生写时拷贝!数据最终会显示两份。
//3、write为什么没有呢?上面的过程都和write无关,write是系统调用,write访问文件没有FILE结构体,而用的是fd,也就没有C提供的缓冲区。
fork();
return 0;
}
下面我们写一部分代码使用系统调用来封装一下C库的文件操作接口,再理解一下缓冲区以及它的刷新策略是怎样的。
DEMO
myStdio.h:
#pragma once
#include <assert.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#define SIZE 1024
#define SYNC_NOW 1
#define SYNC_LINE 2
#define SYNC_FULL 4
typedef struct _FILE{
int flags; //刷新方式
int fileno;
int cap; //buffer的总容量
int size; //buffer当前的使用量
char buffer[SIZE];
}FILE_;
FILE_ *fopen_(const char *path_name, const char *mode);
void fwrite_(const void *ptr, int num, FILE_ *fp);
void fclose_(FILE_ * fp);
void fflush_(FILE_ *fp);
myStdio.c:
#include "myStdio.h"
FILE_ *fopen_(const char *path_name, const char *mode)
{
int flags = 0;
int defaultMode=0666;
if(strcmp(mode, "r") == 0)
{
flags |= O_RDONLY;
}
else if(strcmp(mode, "w") == 0)
{
flags |= (O_WRONLY | O_CREAT |O_TRUNC);
}
else if(strcmp(mode, "a") == 0)
{
flags |= (O_WRONLY | O_CREAT |O_APPEND);
}
else
{
//TODO
//...
}
int fd = 0;
if(flags & O_RDONLY) fd = open(path_name, flags);
else fd = open(path_name, flags, defaultMode);
if(fd < 0)
{
const char *err = strerror(errno);
write(2, err, strlen(err));
return NULL; // 也就是C为什么打开文件失败会返回NULL
}
FILE_ *fp = (FILE_*)malloc(sizeof(FILE_));
assert(fp);
fp->flags = SYNC_LINE; //演示。默认设置成为行刷新
fp->fileno = fd;
fp->cap = SIZE;
fp->size = 0;
memset(fp->buffer, 0 , SIZE);
return fp; // 也就是为什么C打开一个文件,就会返回一个FILE *指针
}
void fwrite_(const void *ptr, int num, FILE_ *fp)
{
// 1. 写入到缓冲区中
memcpy(fp->buffer+fp->size, ptr, num); //这里我们不考虑缓冲区溢出的问题
fp->size += num;
// 2. 判断是否刷新
if(fp->flags & SYNC_NOW)
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0; //清空缓冲区
}
else if(fp->flags & SYNC_FULL)
{
if(fp->size == fp->cap)
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
else if(fp->flags & SYNC_LINE)
{
if(fp->buffer[fp->size-1] == '\n') // abcd\nefg , 不考虑
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
else{
//....
}
}
void fflush_(FILE_ *fp)
{
if( fp->size > 0) write(fp->fileno, fp->buffer, fp->size);
//TODO...
//fsync(fp->fileno); //将数据,强制要求OS进行外设刷新!
//fp->size = 0;
}
void fclose_(FILE_ * fp)
{
fflush_(fp);
close(fp->fileno);
}
main.c:
#include "myStdio.h"
#include <stdio.h>
int main()
{
FILE_ *fp = fopen_("./log.txt", "w");
if(fp == NULL)
{
return 1;
}
int cnt = 10;
const char *msg = "hello world ";
//const char *msg = "hello world ";
while(1)
{
fwrite_(msg, strlen(msg), fp);
//fflush_(fp);//将数据强制刷新到外设
sleep(1);
printf("count: %d\n", cnt);
if(cnt == 5) fflush_(fp);
cnt--;
if(cnt == 0) break;
}
fclose_(fp);
return 0;
}
写一个监控脚本监视运行一下,看到的现象如下:
缓冲区与OS的关系
缓冲区(用户级)和操作系统OS有什么关系呢?
如果OS将数据从内核缓冲区刷新到外设的时候,OS宕机了呢?
会发生数据丢失。避免方法是,用户层可以直接强制OS将对应的文件内核缓冲区中的数据同步到磁盘。
了解磁盘
上面我们所谈论的文件是被打开的文件,如果一个文件没有被打开呢?该如何被OS管理?
没有被打开的文件,只能静静的在磁盘上放着。磁盘上有大量的文件,也是必须要被“静态”管理的,方便我们随时打开。
磁盘的物理结构
磁盘是计算中唯一的一个机械结构!硬盘是一个机械结构,同时也是外设,所以硬盘访问速度会很慢。但是在企业端,磁盘依旧是存储的主流。
磁盘的存储结构
磁盘在寻址的时候,基本单位不是bit,也不是byte,而是扇区(512byte)。
如何在单面上,定位一个扇区?
如何在磁盘中,定位任何一个扇区?
磁盘的逻辑结构
为什么OS要进行将磁盘逻辑抽象,使用LAB线性地址访问扇区呢?直接使用磁盘的物理地址CHS不可以吗?
- 便于管理。
- 不想让OS的代码和硬件强耦合。
虽然磁盘访问的基本单位为扇区(512byte),但是依旧很小!磁头摆动,盘片旋转找对应的扇区的效率略低,这也就是为什么进程要访问磁盘,大部分的时间都是在等磁盘“准备好”。
所以OS系统内的文件系统会定制的进行多个扇区的读取,一次读取1KB或者2KB或者4KB(常用)为基本单位。哪怕只想读取或修改1个bit位,也必须将4KB加载到内存,进行读取或修改,必要的时候,再写回磁盘。以空间换时间的做法!
理解文件系统
一个磁盘空间大小约几百个GB,例如500GB,那么如何管理这个磁盘呢?
500GB太大了,不好管理,所以先将磁盘进行分区,例如分为100GB、100GB、150GB、150GB四个分区。只需要将100GB的分区管理好,其余分区同样的方法也可以管理好。但是100GB同样很大,相对来说也不好管理,所以再将每个分区进行分组,例如100GB的分区分为20个5GB的分组。这时候只需要将5GB的空间管理好,同样的方法就可以管理好其余分组,同样可以管理好每个分区,进而能够管理好一个次磁盘。(分治的思想)
文件系统是如何管理一个分组的呢?
Linux ext2文件系统,上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的block。一个block的大小是由格式化的时候确定的,并且不可以更改。例如mke2fs的-b选项可以设定block大小为1024、2048或4096字节。而上图中启动块(Boot Block)的大小是确定的。
- Super Block:保存的是整个文件系统的信息。
文件 = 文件属性 + 文件内容。Linux下文件属性和文件内容是分批存储的。
- 文件属性存在inode块中,inode是固定大小。几乎一个文件,一个inode。这些inode为了区分彼此,每一个inode都有自己的ID编号!inode块中包含了几乎这个文件的所有属性,文件名并不在inode中存储。
- data block里存放文件内容,data block随着应用类型的变化,大小也在变化。
如何在一个分组里面保存众多的文件呢?
- inode Table:保存了分组内部所有的可用(已经使用+没有使用)inode。
当创建文件的时候,首先就需要区inode table中查找找没有使用的inode,再将文件属性填到inode中。
- Data Blocks:保存的是分组内部所有文件的数据块(4kb大小为单位),哪些块是属于哪些文件的,也有办法来标识。
当创建文件的时候,然后需要在data block中查找没有被使用的数据块,进而申请数据块,就可以将文件内容写进数据块中。
在创建文件的时候无论是查找没有使用的inode,还是查找没有使用的data block。都需要进行查找操作。
- inode Bitmap:inode对应的位图。每个bit位表示一个inode是否空闲可用(0/1)。
- block Bitmap:数据块对应的位图,记录着data blocks中哪个数据块被占用或没有被占用。
- Group Descriptor Table:块组描述表,对应分组的宏观属性。
例如:一共有多少个分组,每个分组是多大,一共多少个inode/data block,使用了多少个inode/data block,哪些没有被使用等等。
查找一个文件的时候,统一使用的是inode编号。(inode可以跨分组,但是不能跨分区)
首先在inode bitmap中查找inode编号对应的bit位是否为1,为1证明文件是存在的,再去inode table中查找对应的inode,就可以将文件属性查找出来,那么文件内容呢?去data block中查找,data block中有大量的数据块,怎么知道哪个数据块和我要查找的文件有关呢?
inode块中不仅包含着文件的属性,还包含了一个存放着数据块编号的数组。
创建文件,首先在inode map中将inode编号对应的bit位置为1,再去inode table找到对应的inode中将文件属性填写进去,然后将文件数据写到未使用的data block中,再将inode和data block之间建立联系,文件就创建成功了。
删除文件,只需要在inode map中将文件对应的inode编号映射的bit位置为0即可。删除之后,如果不做其他操作,如创建新文件,只要知道删除文件的inode编号,在inode map中将删除文件的indoe编号映射的bit位置1,还可以恢复文件。
在Linux下查找文件,我们也并没有使用inode编号啊!使用的是文件名!
普通文件是一个文件,任何一个文件一定在某一个目录里面。目录也是一个文件,也有自己的属性和内容,即同样有inode和数据块,那么目录的数据块放的是什么呢?
目录的数据块放的就是,当前目录下的文件名和inode的映射关系!
像ls查文件的时候,第一步一定是查找当前目录对应的数据块,将文件的文件名和inode提取出来...所以同一个目录下不可以存在文件名相同的文件!
- 这也就是为什么创建文件的时候为什么一定要有写权限,因为一定要在当前目录的数据块里去写文件名和inode的映射关系!
- 这也就是查看文件的时候为什么一定要有读权限,因为拿到的是文件名,必须得访问目录的数据块,根据文件名去找对应文件的inode!
软硬链接
一个文件对应一个inode,一个inode对应一个文件。
软硬链接的区别
- 是否是具有独立的inode!
- 软链接具有独立的inode,可以被当作独立的文件来看待。
- 硬链接没有独立的inode。
硬链接
可以看到硬链接,与原文件有同样的inode以及属性和内容。
所以建立硬链接,根本就没有创建新文件!因为没有给硬链接分配独立的inode。既然没有创建文件,一定没有自己的属性集合和内容集合,用的一定是别人的inode以及属性和内容!
建立硬链接本质就是在指定的路径下,新增文件名和inode编号的映射关系。
所以当删除文件的时候,其实做了两件事情,1、在目录中将对应的记录删除。2、将硬链接数-1,如果为0,则将文件对应的磁盘空间释放,就彻底的删除了文件。
软链接
在这种情况下,当把软链接链接的目标文件删除之后,软链接就失效了,但是事实上,这个链接的目标文件还是存在的。
所以软链接链接文件并没有使用目标文件的inode来链接文件的,而使用的是目标文件的文件名!
硬链接是通过inode引用另外一个文件,软链接是通过文件名引用另外一个文件。
当把目标文件删除之后,当前目录也就没有对应的目标文件的文件名了,也就是说,软链接有查找这个目标文件的方式。我们在查找文件的时候,使用的是路径。软链接是一个独立的文件,具有独立的inode,也有数据块,它的数据块里面保存的是所指向目标文件的路径。
当把软链接删除之后,并不会影响目标文件。当把软链接链接的目标文件删除之后,软链接就失效了。
所以,在Linux下的软链接就相当于在Windows下的快捷方式!
软链接的应用
快速的访问某个文件或目录。
此时,我要执行mytest应用程序。
这样执行要带很长的一段路径,是不是太麻烦了。我们就可以给mytest建立一个软链接。
硬链接的应用
为什么普通文件的硬链接数是1呢?
因为一个普通文件,本身自己的文件名和自己的inode具有一个映射关系!
为什么这个空目录的硬链接数是2呢?
因为任何一个目录里都有两个隐藏目录,分别为“.”目录和“..”目录。
我们发现,empty目录里隐藏的“.”目录的inode和empty目录的inode相同!“.”目录也叫做当前目录。他们的inode相同,也就意味着,“.”目录就是当前目录empty的一个硬链接。
我们再在空目录empty里建立一个dir目录,再回到上级路径,发现empty的硬链接数变成了3:
原因就是empty目录里的dir目录里隐藏的还有一个“..”目录,它的inode和上级目录empty目录的inode相同,也叫做上级目录。
这也就是为什么“cd ..”能回到上级目录的原因:“..”目录,是上级目录的一个硬链接!
Linux为什么不允许普通用户给目录创建硬链接呢?
防止目录循环引用:
- 硬链接与原文件共享同一个inode号。如果对目录进行硬链接,就可能有多个等效的入口点指向同一个目录,这样会导致目录树出现环形结构。
- 在这种环形结构中,像文件遍历这样的操作可能会陷入无限循环,进一步可能导致文件系统的损坏。例如,如果有两个目录A和B,它们之间存在循环链接,那么当尝试遍历A目录时,就可能导致无限循环。
保护文件系统的结构完整性:
- 允许目录硬链接可能会使inode系统遇到复杂的父子关系以及所有权问题,因为多个父目录可能会指向同一个inode号。
- 这将导致文件系统的维护人员在处理删除目录、重命名目录等操作时,无法有效地处理这种inode的多父问题。
简化文件系统的设计:
- 允许硬链接到目录将引入复杂的处理逻辑,以保证文件系统的一致性,例如更新目录下文件的链接数、处理异常情况等。
- 简单的设计有助于减少bug,提高文件系统的稳定性。
促进了文件系统的健康性和易于维护性。
但是,“.”目录、“..”目录,不就是分别是当前目录和上级目录的硬链接吗?
这两个是特殊情况,是Linux操作系统创建的,但是不允许用户给目录创建硬链接。
文件的三个时间
- Access: 文件最后访问的时间。
- Modify: 文件内容最后修改的时间。
- Change:文件属性最后修改的时间。
注意:
由于我们大多数操作文件的时候都是在读文件,如果每一次读,都要更新文件的访问时间,那么访问就太频繁了,数据在磁盘上,数据更新频繁,就会影响Linux的效率。所以Acess时间不会实时更新。修改文件的内容,同时也会影响文件的属性,比如修改数据、文件大小同时也会发生改变。