Linux第七讲:基础IO
- 1.什么是文件
- 2.文件操作的复习
- 2.1文件基本操作复习
- 2.2将信息输出到显示器,你有哪种方法
- 2.3stdin、stdout、stderror
- 2.4细节问题讲解
- 3.系统文件IO
- 3.1open函数使用
- 3.1.1理解标志位
- 3.1.2权限问题
- 3.1.3write和read接口介绍
- 3.1.4谈谈fd以及封装
- 3.1.5常见标志位的使用
- 3.2系统级别文件访问接口底层的运行原理(3.1.4问题一解释)
- 4.重定向
- 4.1文件描述符的分配规则
- 4.2文件重定向原理(dup2接口)
- 4.3向自定义shell中添加重定向操作
- 5.标准错误
- 6.理解“Linux下一切皆文件”
- 7.缓冲区
- 7.1缓冲区的概念
- 7.2缓冲区的作用
- 7.3深入理解缓冲区
- 8.libc库(C标准库)的简单实现
要理解系统级别的IO,我们要先做一些预备知识:
1.什么是文件
1.狭义上讲,文件被保存在磁盘中,而磁盘是外设,既是输入设备,又是输出设备,而磁盘对外设的输入和输出,简称IO
2.广义上讲,Linux下,一切皆文件,包括键盘、显示器、网卡、磁盘都是抽象化的过程,这些以后会讲
3.文件包括内容 + 属性,所以对于0kb的空文件,也是会占用磁盘空间的,因要要存储文件的属性,而所有对于文件的操作,本质就是对文件内容和文件属性的操作
4.从系统角度上讲,我们写的fopen、fclose函数,只有当进程运行,执行到fopen/fclose代码时,才会打开文件,对文件进行操作,所以对文件的操作,本质是进程对文件的操作,而要对文件进行操作,就必须要通过操作系统,我们写的fopen等操作,底层其实封装了操作系统对于文件的系统调用
5.当进程运行时,会打开很多的文件,也会关闭很多的文件,所以操作系统要把打开的文件管理起来,先描述,在组织,进行管理!
文件分为“内存级”文件(加载到内存,也就是被打开的文件)和“磁盘级”文件,我们下面讲的全部都是针对于内存级文件,磁盘级文件的讲解在后面会加以说明
2.文件操作的复习
2.1文件基本操作复习
int main()
{
FILE* fp = fopen("log.txt", "w");
if(fp == NULL)
{
perror("fopen");
exit(2);
}
const char* msg = "hello world!\n";
int count = strlen(msg);
while(count--)
{
//每次向fp中写入1个msg长度的字符,从msg中写入
fwrite(msg, 1, strlen(msg), fp);
}
//
rewind(fp);
char buf[1024];
while(1)
{
//每次从fp中读取1次,读取msg长度的字符,写入到buf中
size_t s = fread(buf, 1, strlen(msg), fp);
if(s > 0)
{
buf[s] = '\0';
printf("%s", buf);
}
if(!feof(fp)) break;//feof可以检测是否读取到了文件结尾
}
//
fclose(fp);
return 0;
}
上面的代码中,文件是以写入状态打开的,所以并不能执行读操作,但是我们需要知道读操作的操作过程
有了上面的知识,我们可以实现简单的cat命令:
int main(int argc, char*argv[])
{
if(argc != 2)
{
printf("argc error\n");
return 1;
}
FILE* fp = fopen(argv[1], "r");
if(!fp)
{
perror("fopen!\n");
exit(2);
}
char buf[1024];
while(1)
{
int s = fread(buf, 1, sizeof(buf), fp);
if(s > 0)
{
buf[s] = '\0';
printf("%s", buf);
}
if(feof(fp)) break;
}
fclose(fp);
return 0;
}
这样就可以快速拿到文件中的内容了:
2.2将信息输出到显示器,你有哪种方法
int main()
{
printf("hello printf\n");
const char* msg = "hello fwrite\n";
fwrite(msg, 1, strlen(msg), stdout);
fprintf(stdout, "hello fprintf\n");
return 0;
}
2.3stdin、stdout、stderror
C语言默认会打开三种流,stdin、stdout、stderror,而这三种流的类型都是FILE*指针,分别指向标准输入设备、标准输出设备,stderror后面再讲,它们是全局的,所以默认会打开,这个后面再讲
2.4细节问题讲解
当我们输出重定向向log文件中进行字符输出时,发现log文件中的内容被清空了,这是因为fopen的w操作会先将文件中的内容清空:
所以说,输出重定向操作想要将内容输出到文件中,首先要打开文件,而底层封装的是fopen的w操作,所以首先会进行文件数据的清空,我们可以使用追加操作使得文件内容不被清空:
int main()
{
FILE* fp = fopen("log.txt", "a");//a选项为追加
if(!fp) exit(2);
const char* msg = "追加数据\n";
fprintf(fp, "%s", msg);
fclose(fp);
return 0;
}
对于输出重定向,也有追加操作,可见底层封装的是fopen a接口:
3.系统文件IO
下面我们看一下系统级别的IO:
3.1open函数使用
要学会open函数的使用,首先需要了解该函数的参数:
3.1.1理解标志位
open函数的使用如下:
3.1.2权限问题
上面的代码运行之后,生成的文件的权限是乱码,这是因为open接口的第三个参数需要传入权限,我们没传,我们对代码进行修改:
int main()
{
int fp = open("log.txt", O_CREAT | O_WRONLY, 0666);
if(fp < 0) exit(1);
return 0;
}
这里的权限就变了,但是我们不是传入的是一个0666权限码吗,为什么设置的权限是0664呢,这是因为有mask权限掩码的存在,这个之前讲过,自己复习,我们需要使用umask对mask进行设置:
int main()
{
umask(0);
int fp = open("log.txt", O_CREAT | O_WRONLY, 0666);
if(fp < 0) exit(1);
return 0;
}
3.1.3write和read接口介绍
write和read是系统级别的写入和读取接口,我们先来介绍write:
ssize_t write(int fd, const void *buf, size_t count);
1.fd:文件描述符,open函数打开文件成功的返回值
2.buf:要写入的数据的指针
3.count:要写入的字节数
所以说,系统底层都是进行二进制写入/读取的,语言只是进行了封装,fputs和fprintf函数底层会将传入的int类型的数据转化为字符数据,再写入到文件中,而fwrite会直接将int类型的二进制表示写入到文件中
然后我们再来看read:
ssize_t read(int fd, void *buf, size_t count);
1.fd:文件描述符
2.buf:读取的数据存放的位置
3.count最大读取的数据大小
int main()
{
umask(0);
int fd = open("log.txt", O_RDONLY, 0666);
if(fd < 0) exit(1);
char buf[1024];
memset(buf, 0, sizeof(buf));
read(fd, buf, sizeof(buf));
printf("%s", buf);
return 0;
}
3.1.4谈谈fd以及封装
我们知道open函数打开文件成功的返回值是fd,那么fd究竟是什么呢?:
然后我们需要谈谈封装,stdin、stdout、stderr是C语言提供的标准流,而C++也有自己的标准流:cin、cout、cerr,Python、Go等语言都有,那么封装的意义是什么呢?:
对于不同的系统:Windows、MacOS、Linux等,底层的实现是不同的,那么操作系统对于文件的操作也是不同的,所以就需要使用条件编译来对不同的系统做出不同的操作,比如:在Linux下,C语言的printf执行的是write1接口,在Windows下,执行的是write2接口,这样对于不同的平台,只要是使用该语言,都能够实现运行,标准流也是如此,也需要访问文件,也需要进行条件编译调用不同的接口,而实现可移植性的根本目的在于让语言被更多人使用,增加市场占有率!
3.1.5常见标志位的使用
1.O_CREAT:如果没有找到目标文件,默认创建一个目标名称文件
2.O_WRONLY:write only,只写
3.O_RDONLY:read only,只读
4.O_APPEND:追加
5.O_TRUNC:truncate,清除
对于清除标志位,我们需要做一个特殊情况的讲解:
int main()
{
umask(0);
int fp = open("log.txt", O_CREAT | O_WRONLY, 0666);
if(fp < 0) exit(1);
const char* msg = "hello world!\n";
write(fp, msg, strlen(msg));
write(fp, msg, strlen(msg));
write(fp, msg, strlen(msg));
write(fp, msg, strlen(msg));
write(fp, msg, strlen(msg));
return 0;
}
我们先向文件中写入5串字符
int main()
{
umask(0);
int fp = open("log.txt", O_CREAT | O_WRONLY, 0666);
if(fp < 0) exit(1);
const char* msg = "12345\n";
write(fp, msg, strlen(msg));
return 0;
}
当我们再次写入时,会发现,12345竟然覆盖式地写入到了文件中,这是因为没有传入清除标志位,需要进行传入:
int fp = open("log.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
3.2系统级别文件访问接口底层的运行原理(3.1.4问题一解释)
4.重定向
4.1文件描述符的分配规则
我们知道了标准流分别占据了0、1、2三个位置,那么我们删除这三个标准流,会发生什么呢?:
1.删除stdin:
int main()
{
close(0);
umask(0);
int fd1 = open("log1.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
printf("fd1 = %d\n", fd1);
return 0;
}
输出结果为:fd1 = 0
int main()
{
close(2);
umask(0);
int fd1 = open("log1.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
printf("fd1 = %d\n", fd1);
return 0;
}
什么也没输出,但是结果被保存在了log1.txt文件中
int main()
{
close(2);
umask(0);
int fd1 = open("log1.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
printf("fd1 = %d\n", fd1);
return 0;
}
结果为:fd1 = 2
输出结论:文件描述符的分配原则是:最小的,没有被使用的,作为新的fd给用户,而关闭了fd2,也就是stdout,那么log1.txt就变成了fd2,所以输出的结果就被保存到了log1.txt文件中了!
4.2文件重定向原理(dup2接口)
我们上面并没有关闭fd1,当我们讲fd1关闭时,我们看看会发生什么:
int main()
{
//close(0);
close(1);
//close(2);
umask(0);
int fd1 = open("log1.txt", O_CREAT | O_WRONLY | O_TRUNC, 0666);
printf("fd1 = %d\n", fd1);
close(fd1);
return 0;
}
结果就是,文件中什么也没有,也没有打印出任何信息,这个情况下面讲缓冲区时再说
文件重定向操作利用的想法和文件描述符的分配原则的思想很相似,这里我们要认识一个dup2接口:
4.3向自定义shell中添加重定向操作
知道了重定向原理,我们需要向自定义shell中添加重定向操作:
//5.指令的执行
//重定向的状态
#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3
int redir = NONE_REDIR;//假设开始时没有重定向
std::string filename;//保存需要进行重定向时,进行重定向的文件名称
int Execute()
{
pid_t id = fork();
if(id == 0)
{
int fd = -1;
if(redir == INPUT_REDIR)
{
fd = open(filename.c_str(), O_RDONLY);
if(fd < 0) exit(0);
dup2(fd, 0);
close(fd);
}
else if(redir == OUTPUT_REDIR)
{
fd = open(filename.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd < 0) exit(1);
dup2(fd, 1);
close(fd);
}
else if(redir == APPEND_REDIR)
{
fd = open(filename.c_str(), O_WRONLY | O_CREAT | O_APPEND, 0666);
if(fd < 0) exit(2);
dup2(fd, 1);
close(fd);
}
else
{}
execvp(g_argv[0], g_argv);
exit(1);
}
int status = 0;
pid_t rid = waitpid(-1, &status, 0);
if(rid > 0)
{
lastcode = WEXITSTATUS(status);
}
return 0;
}
//3.重定向分析
//删除cmd数组中,end位置之后的空格
void TrimSpace(char cmd[], int& end)
{
while(isspace(cmd[end])) end++;
}
void RedirCheck(char cmd[])
{
//重定向操作的实现,首先要分析是哪种重定向操作
//然后要找出重定向操作的文件名称
//对于不同的重定向操作,进行不同的代码实现
redir = NONE_REDIR;
filename.clear();
int start = 0;
int end = strlen(cmd)-1;
while(start <= end)
{
if(cmd[end] == '<')
{
//<
cmd[end++] = 0;
TrimSpace(cmd, end);
redir = INPUT_REDIR;
filename = end+cmd;
break;
}
else if(cmd[end] == '>')
{
if(cmd[end-1] == '>')
{
//>>
cmd[end-1] = 0;
redir = APPEND_REDIR;
}
else
{
//>
redir = OUTPUT_REDIR;
}
cmd[end++] = 0;
TrimSpace(cmd, end);
filename = end+cmd;
break;
}
else
{
end--;
}
}
}
int main()
{
Init_ENV();
while(true)
{
//1.输出命令行提示符
PrintCommandPrompt();
//2.获取用户输入的命令
char commandline[COMMAND_SIZE];
if(!GetCommandLine(commandline, sizeof(commandline))) continue;//如果获取失败,重新获取
//3.重定向分析
RedirCheck(commandline);
//4.命令行解析
CommandPrase(commandline);
//Print();
//5.对于内建命令,要进行特殊处理
if(CheckAndExecBuiltin()) continue;//如果是内建命令的话,就不需要下面的指令执行了
//6.指令的执行
Execute();
}
return 0;
}
但是这里有一个问题:echo “XXX” > log.txt命令无法正常执行,因为echo是内建命令,对于内建命令的重定向,不是要更改shell的标准流吗,那么这不就出现问题了吗:
5.标准错误
我们之前着重说的都是stdin和stdout,那么stderr是个什么东西呢?:
int main()
{
//向标准输出中进行打印
std::cout << "hello cout" << std::endl;
printf("hello printf\n");
//向标准错误中进行打印
std::cerr << "hello err" << std::endl;
fprintf(stderr, "hello stderr\n");
return 0;
}
我们看到,当我们运行程序时,stderr和stdout打印的信息都在显示器上,其实stdout和stderr指向的是同一个文件指针,这么作的目的在于:可以根据重定向操作,将常规信息和错误信息进行分离!,这样就可以只拿到错误信息/常规信息了,分离的方式为:
如果我们向将err和out信息打印到同一个文件,通用做法为:
6.理解“Linux下一切皆文件”
在windows下是文件的东西,在Linux下也是文件,在windows下不是文件的东西,在Linux下也会被抽象成文件,比如进程、磁盘、显示器、键盘等硬件设备也被抽象成了文件:
7.缓冲区
7.1缓冲区的概念
我们之前留有一个问题:为什么将fd关闭了之后,就不会向文件中输出任何东西了呢?
我们由此引入缓冲区的概念:
所以说,当我们将fd关闭之后,而进程却没有结束,不满足缓冲区的刷新规则,当进程结束之后需要进行缓冲区刷新时,由于fd已经关闭,所以不知道向哪里进行刷新,所以文件就拿不到想要的数据!
7.2缓冲区的作用
那么我们为什么需要缓冲区呢?:
1.系统调用是有成本的,如果我们每次打印数据,都是直接向硬盘中进行打印的话,那么多次对硬盘的访问操作是非常消耗效率的,而如果我们先将数据写入到缓冲区中,缓冲区达到一定的数据存储之后再将数据通过一次系统调用写入到硬盘中,那么效率就会大大提升
2.对于上层用户、使用者来说,操作系统只需要使用一次系统调用,将需要读取的数据先放在缓冲区中,用户使用时直接在缓冲区中拿数据,比每次访问缓冲区拿数据要方便的多
3.而且,当向缓冲区中写入数据时,写入完成,就可以进行函数返回了,如果直接向硬件中进行写入时,如果有多个进程都需要向同一个硬件中写入,就需要进行等待,浪费不必要的时间
4.所以说,缓冲区的存在,既提高的使用者的效率,又提高了系统对文件的读写效率
7.3深入理解缓冲区
int main()
{
//库函数的打印
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* msg1 = "hello fwrite\n";
fwrite(msg1, strlen(msg1), 1, stdout);
//系统调用
const char* msg2 = "hello write\n";
write(1, msg2, strlen(msg2));
fork();
return 0;
}
当我们在进程结束之前创建子进程,然后再将输出内容重定向到文件中,可以看出,多打了很多东西,我们解释一下:
而如果直接向显示器中打印的话,行刷新,遇到\n刷新,所以就会打印4串字符:
8.libc库(C标准库)的简单实现
我们并不能像C标准那样实现非常复杂的标准库,但是我们可以进行简单实现,重点是在于理解库缓冲区、函数调用的底层机制、刷新缓冲区的刷新机制:
#include "mystdio.h"
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
//1.打开文件
//设计为静态函数好处:
//1.外部文件无法调用BuyFile函数
//2.避免命名冲突
static MyFile* BuyFile(int fd, int flag)
{
MyFile* f = (MyFile*)malloc(sizeof(MyFile));
f->fileno = fd;
f->flag = flag;
f->bufferlen = 0;
f->flush_method = LINE_FLUSH;
memset(f->outbuffer, 0, sizeof(f->outbuffer));
return f;
}
MyFile* MyFopen(const char* file, const char* mode)
{
int fd = -1;
int flag = 0;
if(strcmp(mode, "r"))
{
flag = O_RDONLY;
fd = open(file, flag);
}
else if(strcmp(mode, "w"))
{
flag = O_CREAT | O_WRONLY | O_TRUNC;
fd = open(file, flag, 0666);
}
else if(strcmp(mode, "a"))
{
flag = O_CREAT | O_APPEND | O_WRONLY;
fd = open(file, flag, 0666);
}
else
{}
if(fd < 0) return NULL;
return BuyFile(fd, flag);
}
//2.写入文件
int MyFwrite(MyFile* file, void* str, int len)
{
//1.写入其实就是一个拷贝
memcpy(file->outbuffer+file->bufferlen, str, len);
file->bufferlen += len;
//2.检查是否需要进行缓冲区的刷新
if((file->flush_method & LINE_FLUSH) && file->outbuffer[file->bufferlen-1] == '\n')
{
MyFFlush(file);
}
return 0;
}
//3.缓冲区的刷新
void MyFFlush(MyFile* file)
{
if(file->bufferlen <= 0) return;
//缓冲区的刷新其实是将数据从用户中拷贝到内核文件缓冲区中
int n = write(file->fileno, file->outbuffer, file->bufferlen);
(void)n;
file->bufferlen = 0;
}
void MyFclose(MyFile* file)
{
if(file->fileno < 0) return;
MyFFlush(file);
close(file->fileno);
free(file);
}
这样的简单实现还是有一点小问题,在MyFclose函数中,我们的刷新策略是:使用write进行刷新,但是write的作用其实是将数据从库缓冲区拷贝到文件内核缓冲区,至于是否正常刷新到显示器中,还是要看操作系统,所以我们需要使用fsync函数进行刷新,该函数可以直接将数据从缓冲区刷新到磁盘:
#include "mystdio.h"
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
//1.打开文件
//设计为静态函数好处:
//1.外部文件无法调用BuyFile函数
//2.避免命名冲突
static MyFile* BuyFile(int fd, int flag)
{
MyFile* f = (MyFile*)malloc(sizeof(MyFile));
f->fileno = fd;
f->flag = flag;
f->bufferlen = 0;
f->flush_method = LINE_FLUSH;
memset(f->outbuffer, 0, sizeof(f->outbuffer));
return f;
}
MyFile* MyFopen(const char* file, const char* mode)
{
int fd = -1;
int flag = 0;
if(strcmp(mode, "r"))
{
flag = O_RDONLY;
fd = open(file, flag);
}
else if(strcmp(mode, "w"))
{
flag = O_CREAT | O_WRONLY | O_TRUNC;
fd = open(file, flag, 0666);
}
else if(strcmp(mode, "a"))
{
flag = O_CREAT | O_APPEND | O_WRONLY;
fd = open(file, flag, 0666);
}
else
{}
if(fd < 0) return NULL;
return BuyFile(fd, flag);
}
//2.写入文件
int MyFwrite(MyFile* file, void* str, int len)
{
//1.写入其实就是一个拷贝
memcpy(file->outbuffer+file->bufferlen, str, len);
file->bufferlen += len;
//2.检查是否需要进行缓冲区的刷新
if((file->flush_method & LINE_FLUSH) && file->outbuffer[file->bufferlen-1] == '\n')
{
MyFFlush(file);
}
return 0;
}
//3.缓冲区的刷新
void MyFFlush(MyFile* file)
{
if(file->bufferlen <= 0) return;
//缓冲区的刷新其实是将数据从用户中拷贝到内核文件缓冲区中
int n = write(file->fileno, file->outbuffer, file->bufferlen);
(void)n;
file->bufferlen = 0;
}
void MyFclose(MyFile* file)
{
if(file->fileno < 0) return;
fsync(file->fileno);
close(file->fileno);
free(file);
}