【Linux】基础IO-文件描述符
【Linux】基础IO
- C语言的文件接口
- 文件的初步理解
- 文件IO的系统接口
- 打开文件
- write
- read
- 文件描述符fd
- 语言层的fd
- 文件描述符的分配规则
- 重定向和缓冲区的理解
- 重定向
- 缓冲区
- 作用
- 刷新策略
- C语言的缓冲区
- 模拟实现重定向
- 检查是否是重定向
- 执行命令
- 0、1、2的作用
C语言的文件接口
这里我们简单回顾一下C语言的文件操作
使用 “w” 模式打开文件 log.txt,如果文件不存在就创建文件;文件存在,打开文件后清空内容
使用 fprintf 向文件中写入字符串
int main()
{
const char* message = "hello Linux!";
FILE* fp = fopen("log.txt", "w");
fprintf(fp, "%s\n", message);
fclose(fp);
return 0;
}
每次使用 w 模式打开文件都会清空文件内容,如果先要追加内容就要使用 a 模式打开文件
FILE* fp = fopen("log.txt", "a");
C语言文件操作:
- 使用 w 模式打开文件时,文件不存在就创建;文件存在就清空内容
- 使用 a 模式打开文件时,会在文件原有内容基础上追加内容
Linux 的重定向操作符与此有异曲同工之妙:
- >:将数据重定向到指定文件,如果文件不存在就创建,存在就清空内容
- >>:将数据追加到指定文件
由此我们可知,重定向也是在打开文件,将数据写入到文件
文件的初步理解
虽然我们可以使用接口来进行文件相关的操作,但是我们真的理解文件吗?
我们通过上面的程序打开了文件,其而程序启动后就会被加载为进程,所以打开文件的本质就是进程打开了文件,是 CPU 执行我们写的代码
而一个进程可以打开多个文件吗?当然可以,就是多调用几次接口嘛。也就是说一个进程可以打开多个文件,而系统中的进程肯定不止一个,那么系统内就会存在大量的文件。所以就需要操作系统将这些文件统一管理起来,学习了进程后,我们猜想可能会有类似于 PCB 的数据结构,管理这些数据结构就是管理文件。关于这一点我们后面就知道了,这里先放一放
当我们创建一个空文件时,它会占用空间吗?会占用,虽然它的内容是空的,但是还是需要空间来储存文件属性的,例如文件创建时间,文件类型等。也就是说文件=属性+内容
文件IO的系统接口
除了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);
参数说明:
pathname:要打开/创建的文件名
flags:标记位,打开文件的方式,为什么是一个 int 呢?——这个 int 是位图,打开方式可以是多个,传递时进行或运算就可以将多种打开方式一并传递给 flags
- O_RDONLY:只读打开
- O_WRONLY:只写打开
- O_RDWR:读写打开
- 这三个常量,必须指定一个且只能指定一个
- O_CREAT:若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
- O_APPEND:追加写
- O_TRUNC:打开时清空文件内容
mode:定义新创建文件的权限,例如要求所有人可以读写:0666
返回值:
失败返回 -1;成功返回文件描述符号,这里先不管,作用类似于C语言的FILE*,用来找到打开的文件
write
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
参数:
fd:文件描述符,可以找到打开的文件
buf:缓冲区首地址,可以理解为要写入的数据的首地址
count:要写入多少字节的数据
返回值:实际写了多少字节数据
现在将我们上面代码中的C语言接口替换为系统调用接口
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <unistd.h>
int main()
{
const char* message = "hello Linux!\n";
// 打开
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
// 写入数据
write(fd, message, strlen(message));
close(fd); // 关闭文件描述符指向的文件
return 0;
}
read
与 write 的使用类似
int main()
{
const char* message = "hello Linux!\n";
// 只读打开
int fd = open("log.txt", O_RDONLY);
char buf[1024];
// 将数据读入到 buf
read(fd, buf, strlen(message));
printf("%s",buf);
return 0;
}
以上就是关于文件的系统接口的简单使用,我们可以感觉到和C语言文件接口的使用很相似。
而实际上语言是给我们用户层面使用的,而用户是不被允许直接修改操作系统的数据的,所以才会有系统调用接口供我们使用,而C语言的文件接口就是封装的系统调用接口
文件描述符fd
可以多打开几个文件,看看这 fd 的值到底是怎么样的:
int main()
{
int fd1 = open("fd1.txt", O_WRONLY|O_CREAT);
printf("%d\n", fd1);
int fd2 = open("fd2.txt", O_WRONLY|O_CREAT);
printf("%d\n", fd2);
int fd3 = open("fd3.txt", O_WRONLY|O_CREAT);
printf("%d\n", fd3);
close(fd1);
close(fd2);
close(fd3);
return 0;
}
可以看到我们打开的三个文件的描述符分别是 3、4、5,难道文件描述符是从 3 开始的吗?——当然不是,文件描述符是从 0 开始的,而 0、1、2 这三个描述符已经被其他文件占用了,它们分别是:
- 0:标准输入,键盘
- 1:标准输出,显示器
- 2:标准错误,显示器
在C语言中,我们知道,当程序启动时,会自动打开三个文件:stdin、stdout、stderr,这就是对标的 0、1、2 三个文件描述符
那么说了这么多,文件描述符它到底是什么呢?这里我们就要回归到操作系统是怎么组织管理文件这个问题了
实际上,打开一个文件后,就会在系统内核中创建一个名为 strcut file 的数据结构,file 中储存着文件的属性
而文件=属性+内容,打开文件的同时,会开辟一块空间来当作文件的缓冲区,而打开文件后,无论是读还是写,都会将文件的内容从硬盘加载到缓冲区,file 会指向这一块空间
系统中不可能只存在一个打开的文件,不同的文件的 file 会相互链接在一起,形成一个链表
这么多的文件,一个进程可能打开很多文件,那么进程如何知道自己打开的是哪一个文件呢?
进程 PCB 中,存在一个指针 struct files_struct* files,指向了一张表 files_struct。这张表最重要的部分就是包含了一个指针数组 struct flie* fd_array[],数组中每个元素都是一个指针,指向了打开文件,这个数组就叫文件描述符表
而数组的下标就是文件描述符,凭借文件描述符就可以找到打开的文件
现在我们理解了什么是文件描述符,那么为什么 0、1、2 这三个东西是默认打开的?它们又不是文件,而是硬件啊?
这些硬件在冯诺依曼体系中属于IO外设,虽然是硬件,但是在 Linux 看来:一切皆文件
一切皆文件,这些外设也可以抽象为一个个数据结构 struct device,其中存储着设备的名称、类型、状态等等。也就是说 device 中存储的是设备的属性,那么就可以被 struct file 指向,因为 file 中存储的就是文件的属性
而想使用这些设备,例如读取键盘的数据,向屏幕写入数据等等,需要特定的方法,这些方法由驱动层提供。而我们想从缓冲区写出数据到设备或者将设备的数据读入到缓冲区,势必离不开这些方法。所以这些方法也应该放到 file 中,这些方法是以函数指针的形式存入到 file 中的,且是一个集合,称作方法集
这样加一层后,虽然每个设备的方法、属性不同,但是都被封装到了 file 中,在 file 层面来看是没有这些不同的
所以现在我们理解了外设可以被加载为文件,我们可以通过一些手段查看一下 0、1、2 指向的设备
先启动一个进程并获取它的 pid
int main()
{
pid_t pid = getpid();
while(1)
{
sleep(1);
printf("pid:%d\n", pid);
}
return 0;
}
然后到 etc/proc/进程id 目录下,可以看到有一个 fd 文件夹,里面就存放着此进程打开的文件的 fd
可以看到,确实有0、1、2 三个文件描述符,它们都指向了一个设备,这是因为我这里的机器是云服务器,所以指向的设备是同一个终端。此时我们可以编写代码,向这个设备写入数据,就会看到终端有数据输出
int main()
{
const char* msg = "hello!\n";
int fd = open("/dev/pts/1", O_WRONLY);
if (fd < 0) return -1;
else{
while(1)
{
sleep(1);
write(fd,msg, strlen(msg));
}
}
return 0;
}
语言层的fd
了解了什么是 fd 之后,我们知道,文件的操作离不开 fd。而其他语言的文件操作是对系统调用接口的封装,所以肯定离不开对 fd 进行封装
例如C语言,打开文件时会返回一个指针 FILE*,后面对文件的操作离不开这个指针。FILE 就是C语言封装的一个结构体,里面包含了文件描述符 fd
我们可以把 FILE* 中的数据打印出来看看
int main()
{
FILE* pf1 = fopen("fd1.txt", "w");
printf("pf1:%d\n", pf1->_fileno);
FILE* pf2 = fopen("fd2.txt", "w");
printf("pf2:%d\n", pf2->_fileno);
FILE* pf3 = fopen("fd3.txt", "w");
printf("pf3:%d\n", pf3->_fileno);
fclose(pf1);
fclose(pf2);
fclose(pf3);
return 0;
}
可以看到,和之前使用系统调用打印出来的文件描述符一样
文件描述符的分配规则
我们已经知道,进程启动时会默认打开三个文件,0、1、2就会被占用,后面打开的文件的描述符都是从 3 开始
如果一开始就把 0、1、2这三个文件关掉,再再打开我们自己的文件,那么文件描述符又会怎么分配呢?
尝试关闭0、2,然后打开文件并打印文件描述符
int main()
{
close(0);
close(2);
int fd1 = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd1 < 0) return 1;
int fd2 = open("log2.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd2< 0) return 1;
printf("this is fd1:%d\n", fd1);
fprintf(stdout, "this is fd2:%d\n", fd2);
close(fd1);
close(fd2);
return 0;
}
从代码运行结果来看,文件描述符的分配规则是这样的:当一个新文件打开时,会从文件描述符表 fd_array 寻找最小的没有被占用的下标,作为新文件的文件描述符
那为什么只关闭0、2,不关闭1呢?因为 1 是标准输出,关闭了就看不到打印结果了。但是我们可以通过别的方式查看:关闭 1 以后,新打开一个文件,理论上这个文件的文件描述符就会分配 1,然后我们向这个文件中写入数据,不就可以查看结果了么
int main() {
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) return 1;
printf("this is fd:%d\n", fd);
fprintf(stdout, "this is fd:%d\n", fd);
fflush(stdout); // 刷新缓冲区
close(fd);
return 0;
}
但是很奇怪的一点是:printf 是向标准输出写入数据,fprintf 我们也规定向标准输出写入数据,但是怎么都写到了 log.txt 中了?
上面已经说过,C语言的文件接口都是封装的系统调用,其中 stdout 是一个结构体FILE对象的指针,FILE结构体封装了文件描述符 fd,且 stdout 的 fd 为1。这样 stdout 就可以通过 fd 找到文件描述符为 1 的文件,但是此时文件描述符 1 指向的文件已经被我们更改了,不再指向标准输出,而是指向文件 log.txt
所以我们向 stdout 中写入数据,stdout 又拿着 fd 找到了文件 log.txt,将数据写入到文件中
那么代码中的 fflush(stdout) 又是什么作用呢?可不可以去掉呢?如果去掉,数据就会丢失
这是为什么呢?——文件在内核中有缓冲区,而C语言也有语言层的缓冲区,通常叫做用户级缓冲区,存在于结构体FILE中。我们使用语言层的文件接口写数据时,默认是向用户级缓冲区写数据,然后达成某些条件时,就会被刷新到内核层的缓冲区,而系统调用的 close 关闭文件时,会将内核缓冲区的数据刷入到硬盘,然后关闭文件
如果使用 close 关闭文件时数据还留在用户级缓冲区没有刷新,然后内核级缓冲区是空的,系统调用将内核缓冲区数据刷新到文件,导致了数据的丢失
重定向和缓冲区的理解
重定向
上面我们把本来要写入到标准输出的数据写到了文件中,这不就是重定向吗?但是这个重定向的实现方式有点挫啊,有没有优雅一点的方法呢?有的,系统中有个接口,可以更改文件描述符指向的内容
#include <unistd.h>
int dup2(int oldfd, int newfd);
描述:
简单来说,就是将 oldfd 的内容拷贝到 newfd,那么 oldfd 和 newfd 都会指向同一个文件。所以说,重定向的本质就是文件描述符下标内容的拷贝
例如,进程中默认打开 0、1、2三个文件,我们又打开自己的文件 log.txt,文件描述符为 fd。如果我们想把写入到标准输出的内容重定向到文件,就可以把文件描述符表中,fd 对应的内容拷贝到 1 号下标中,这样 1 号文件描述符指向的文件就是 log.txt 了,向标准输出写数据就是向 log.txt 写内容
实践一下:
int main() {
// 打开 log.txt
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0) return 1;
// 重定向
dup2(fd, 1);
// 写入数据到 std
printf("this is fd:%d\n", fd);
fprintf(stdout, "this is fd:%d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
缓冲区
作用
为什么语言层也要设置一个用户级缓冲区呢?我不可以直接将数据写到内核缓冲区吗?这不是多此一举吗?其实缓冲区也是有妙用的:
- 解耦:把用户和操作系统解耦,用户只需要将数据写到缓冲区即可,不需要操心怎么把数据搞到内核缓冲区,再刷新到硬盘
- 提高效率:多了一层不是还要拷贝吗?为什么还提高了效率了?——提高的是用户使用的效率
- 用户只需要向缓冲区读写数据即可,效率更高
- 系统调用是有消耗的,越少调用,效率越高。如果先把数据丢到用户级缓冲区,达成某个条件时,直接使用一次系统调用即可将数据刷新到内核缓冲区;内核缓冲区同理,先缓存数据,然后一次刷新到硬盘文件即可,极大减少了频繁使用系统调用的消耗
刷新策略
- 立即刷新:例如C语言的 fflush,系统调用的 fsync,都可以刷新缓冲区
- 行刷新:这个比较特殊,显示器采用的策略就是这样的,因为这样更符合用户的使用习惯
- 满了才刷新:普通文件
特殊情况:
- 程序结束,会自动刷新缓冲区。之前进程说过的 exit 会刷新缓冲区,_exit 则不会
- 强制刷新,类似于立即刷新
此时我们写一个代码,先使用语言层接口向标准输出打印数据,再使用系统调用打印数据。标准输出也就是屏幕,这里采用的刷新策略是行刷新
int main()
{
// C
printf("hello,printf!\n");
fprintf(stdout, "hello, fprintf!\n");
// sysytem call
const char* msg = "hello write!\n";
write(1, msg, strlen(msg));
return 0;
}
可以看到,确实是按照我们输出的顺序一行一行地刷新的。那么我们再将这些数据进行重定向输出到 log.txt 文件看看效果
输出的顺序发生了变化,这是为什么呢?这是因为我们进行了重定向输出,本质还是打开了 log.txt 文件,因为是文件,所以缓冲区刷新策略变为了满了才刷新
printf 和 fprintf 都是语言层的接口,所以默认将数据写入了用户级缓冲区,而 write 是系统接口,直接将数据写入到了内核缓冲区
代码中还有一个细节,没有关闭文件。根据刷新策略,程序结束时,用户级缓冲区的数据会被刷新到内核缓冲区,接着内核缓冲区的数据被刷新到硬盘的文件中。所以打印的顺序发生了变化
这时候我们再来添加一条语句,分别将数据输出到屏幕和文件
int main()
{
// C
printf("hello,printf!\n");
fprintf(stdout, "hello, fprintf!\n");
// sysytem call
const char* msg = "hello write!\n";
write(1, msg, strlen(msg));
fork(); // 创建子进程
return 0;
}
输出到屏幕:
没什么变化,再来看看重定向输出到文件
为什么 printf 和 fprintf 多打印了一遍呢?问题肯定出在 fork() 上,创建了子进程
上面我们说过,在程序结束之前,printf 和 fprintf 一开始是在用户级缓冲区中的。然后父进程创建子进程,此时程序也要结束了,那么父子进程都会刷新用户级的缓冲区,也就是刷新了两次数据到内核缓冲区,所以才会有两对 printf 和 fprintf 的输出
C语言的缓冲区
说了这么多,我们可以看一下C语言的缓冲区长什么样子
在 /usr/include/stdio.h
而 struct _IO_FILE 在 /usr/include/libio.h
模拟实现重定向
把我们之前写的 shell-链接,模拟实现一下重定向的实现
检查是否是重定向
如果输入的命令是这样 ls -a -l > log.txt
在获取到用户输入命令后,需要进行检查是否具有重定向符号
// 获取用户输入命令
// ...
CheckRedir(usercommand);
// 分割命令行字符串
// ...
我们需要获取命令的重定向相关信息,例如重定向的类型,重定向的文件
// 重定向相关
#define No_Redir 0
#define In_Redir 1 // <
#define Out_Redir 2 // >
#define Add_Redir 3 // >>
int redir_type = No_Redir; // 类型
char* filename = NULL; // 重定向文件名
接下来编写 CheckRedir
- 遍历字符串,寻找重定向符号
>
>>
<
- 找到之后,修改相应的重定向类型,然后把重定向符号改为 0,断开命令
- 跳过重定向后面的空格,寻找文件名
void CheckRedir(char cmd[])
{
// 寻找重定向符号
int pos = 0;
int end = strlen(cmd);
while(pos < end)
{
// ls -a -l > log.txt
if (cmd[pos] == '>')
{
if (cmd[pos + 1] == '>')
{
// 追加重定向
redir_type = Add_Redir;
cmd[pos++] = 0;
pos++; // 跳过两个>
// 跳过空格
SkipSpace(cmd, pos);
filename = cmd + pos;
}
else{
// 输出重定向
redir_type = Out_Redir;
cmd[pos++] = 0;
SkipSpace(cmd, pos);
filename = cmd + pos;
}
}
else if (cmd[pos] == '<')
{
// 输入重定向
redir_type = In_Redir;
cmd[pos++] = 0;
// 跳过空格,寻找文件名
SkipSpace(cmd, pos);
filename = cmd + pos;
}
else{
pos++;
}
}
}
这里写一个跳过空格的宏函数
#define SkipSpace(cmd, pos) do{\
while(1)\
{\
if(cmd[pos] == ' ') pos++;\
else break;\
}\
}while(0)
先把下面的代码屏蔽,测试一下:
看上去是没什么问题,那么接下来写执行重定向的命令
执行命令
在子进程执行命令之前,打开相关文件,然后调用 dup2 即可
void ExecuteCmd()
{
pid_t id = fork();
if (id < 0) exit(1);
else if (id == 0)
{
// 重定向,打开文件
if (filename != NULL)
{
if (redir_type == In_Redir)
{
// 输入重定向
int fd = open(filename, O_RDONLY);
dup2(fd, 0);
}
else if (redir_type == Out_Redir)
{
// 输出重定向
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1);
}
else if (redir_type == Add_Redir)
{
// 追加重定向
int fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666);
dup2(fd, 1);
}
}
// child
execvp(gArgv[0], gArgv);
exit(errno); // 执行失败
}
// father
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
// wait sucess
lastcode = WEXITSTATUS(status);
if (lastcode)
printf("%s:%s:%d\n", gArgv[0], strerror(lastcode), lastcode);
}
}
测试:
输出重定向
追加重定向
0、1、2的作用
这里我们总结一下 0、1、2 的作用。我们写的程序不就是对数据进行操作吗,例如计算、存储等。那么这些数据从哪里来,又到哪里去呢?
- 数据从标准输入来,也就是0
- 数据要输出给用户看,也就是输出到标准输出1
那么 2 又有什么作用呢?1 和 2 不都是指向的标准输出吗?
我们写的程序,输出信息一般有两种:正确的和错误的。一般进行打印时,都会打印在屏幕上,如下:
int main()
{
fprintf(stdout, "hello fprintf, stdout\n");
fprintf(stderr, "hello fprintf, stderrt\n");
return 0;
}
如果我们使用>
,将输出信息重定向到 log.txt 文件中,会发生奇怪的现象:
向 stderr 写入的数据并没有输出到文件中,依然输出在了屏幕。这是为什么呢?——因为>
符号默认情况下是标准输出重定向,也就是只把文件描述符表中 1 号下标的内容修改,指向了 log.txt 文件;而 2 号下标的内容依然指向屏幕
那么如何把 2 号下标的内容也重定向呢?正确写法是这样的:
其中 1>log.txt 就不说了,而 2>&1 的意思就是把 1 号下标的内容拷贝给 2 号下标。那么 1、2都会指向 log.txt 文件
还可以这样,把正确信息和错误信息输出到两个不同文件中
C语言的 perror 接口,默认就是向 stderr,也就是系统的 2 中写入数据
到这里我们也可以体会到 2 的作用了,就是可以将程序输出的正确信息和错误信息输入到不同文件,使用重定向即可分离输出