目录
五、重定向
5.1 什么是重定向
5.2 系统调用 dup2
5.3 三种重定向测试
5.3.1 输出重定向(>)
5.3.2 追加重定向(>>)
5.3.3 输入重定向(<)
5.4 父子进程拷贝问题
六、如何理解 Linux一切皆文件
七、缓冲区
7.1 认识缓冲区
7.2 缓冲区的刷新策略
7.3 缓冲区的位置
7.4 解释上面的例子
7.5 实现一个简易的 C语言缓冲区
7.6 内核级缓冲区(用户级缓冲区与OS的关系)
五、重定向
5.1 什么是重定向
在上面的文件描述符中,已经测试关掉了 0、2(标准输入流、标准错误流),那如果关掉 1(标准输出流)呢?会发生什么?
注:标准输入流stdin 对应操作位置:键盘,标准输出流stdout 对应操作位置:屏幕,标准错误流stderr 对应操作位置:屏幕
测试代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("open fd: %d\n", fd);
close(fd);
return 0;
}
运行结果
屏幕上什么也没有打印,这是为什么?
我们先看一下文件里面的内容
文件里面的内容也没有,难道打印的内容没有刷新?我们试着刷新缓冲区,进行修改代码
注:缓存区下面讲
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
int main()
{
close(1);//关闭标准输出流
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("open fd: %d\n", fd);
fflush(stdout);//刷新缓冲区
close(fd);
return 0;
}
运行结果,显示器依旧没有内容
再查看一下文件有没有内容
调用 open 打开 log.txt 之前关闭了标准输出,那么其对应的1号 fd 就闲置了出来,而 fd 的分配规则是从小到大依次寻找未被使用的最小值,所以 log.txt 对应的 fd 就为1 ,这没毛病
但是,内容居然打印到了文件里面,而不是打印到显示器上
像这样,printf 本来应该往显示器上打印数据,但是数据却写入到文件中去了,这我们把种特性叫做重定向
重定向的本质是上层使用的文件描述符不变(即数组下标不变),数组里面的内容发生变化,即在内核中更改 文件描述符 指向的 文件对象,使另一个文件对象指向原有的文件对象,从而使原有的文件对象被另一个文件对象覆盖
解释上面的例子:
在文件创建之前,我们关闭了标准输出流,即 close(1) ,那就说明指向标准输出的文件描述符不再指向标准输出。
调用 open 打开 log.txt 之前关闭了标准输出,那么其对应的 1号文件描述符就闲置了出来,而文件描述符的分配规则是从小到大依次寻找未被使用的最小值,并且 0 、1、 2默认被占用,所以 log.txt 对应的 fd 就为1,即文件描述符就为 1
log.txt的文件描述符就为 1,即 fd_array[1] 指向的是新的文件对象,不再 fd_array[1] 不再指向标准输出
注:printf 是往 fd_array[1] 里面的文件对象打数据,即stdout,stdout 则是往显示器上打印数据
例子中到了 printf 打印的时候,printf 往 fd_array[1] 打印数据,fd_array[1] 指向的是 log.txt 这个文件对象,所以 printf 打印的内容打印到了文件里面
如下图:
所以,重定向的本质是上层使用的文件描述符不变(即数组下标不变),在内核中更改文件描述符对应的 struct file* 的地址,这也是重定向的原理
重定向分 输出重定向(>),追加重定向(>>),输入重定向(<)
刚才上面的例子就是输出重定向,把原本输出到屏幕的内容输出到了文件里面,但是上面的例子我们所写的重定向太 low 了,并且这种方式非常麻烦,系统里面有用于重定向的系统调用 dup2,方便我们进行重定向
5.2 系统调用 dup2
man 2 dup2 查看一下详细信息
定义
int dup2(int oldfd, int newfd);
函数功能: dup2会将 fd_array[oldfd] 的内容拷贝到 fd_array[newfd] 当中,即把 fd_array[newfd] 的内容覆盖
头文件:<unistd.h>
oldfd:旧的文件描述符
newfd:新的文件描述符
函数返回值,成功返回 newfd,失败返回-1
5.3 三种重定向测试
5.3.1 输出重定向(>)
测试代码
#include<stdio.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 | O_TRUNC, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd, 1);//把 fd_array[fd] 的内容拷贝到 fd_array[1]
printf("open fd: %d\n", fd);
fflush(stdout);//刷新缓冲区
close(fd);
return 0;
}
运行结果
也可以使用命令进行重定向
5.3.2 追加重定向(>>)
追加重定向只需要在打开文件时去掉 O_TRUNC 选项,加上 O_APPEND 选项即可
测试代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd, 1);//把 fd_array[fd] 的内容拷贝到 fd_array[1]
printf("open fd: %d\n", fd);
const char* msg = "hello world";
write(1, msg, strlen(msg));
fflush(stdout);//刷新缓冲区
close(fd);
return 0;
}
运行结果
5.3.3 输入重定向(<)
输入重定向就是通过 dup2(fd, 0) 系统调用将目标文件 fd 中的内容拷贝到 0 号 fd 中,从而将本该从标准输入 (键盘) 读入的数据转换为从目标文件中读入
测试代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<string.h>
int main()
{
int fd = open("log.txt", O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd, 0);//把 fd_array[fd] 的内容拷贝到 fd_array[0]
char buffer[64];
while(fgets(buffer, sizeof(buffer)-1, stdin) != NULL)
{
buffer[strlen(buffer)] = '\0';
printf("%s", buffer);
}
fflush(stdout);//刷新缓冲区
close(fd);
return 0;
}
运行结果
5.4 父子进程拷贝问题
在进程概念篇章,我们已经知道 子进程是以父进程为模板创建出来的,PCB 肯定会被拷贝一份(即task_struct 会被拷贝),但是 task_struct 里面 files* 指针指向的 files_struct 的表,这张 files_struct 表需不需要拷贝一份?
答案是肯定要的,因为进程具有独立性。假设子进程进行重定向,重定向会改变这张表里面的内容,子进程修改不能影响父进程,所以这张 files_struct 表也会被子进程拷贝一份,因为进程需要具有独立性
那 struct file 需要被子进程拷贝吗?
答案是不需要。struct file 属于文件部分,属于文件系统,与你的进程有什么关系呢,所以 struct file 不需要被子进程拷贝
那 struct file 不被子进程拷贝的话,那父进程和子进程同时指向一个文件,比如 myfile,如果子进程把这个文件关掉了,那父进程还能访问到这个文件吗?
答案是可以。因为每个文件里面都有一个计数器 f_count,这个叫做引用计数。这个计数器记录了有多少个指针指向这个文件,比如 子进程把这个文件关掉,文件并不是真正被关掉了,而是 f_count-1,当这个 f_count 为 0 时,这个文件才会真正被OS关闭,所以父进程依旧可以访问这个文件,这个工作也是OS完成的
进程在进行程序替换的时候,会不会影响曾经进程打开的重定向文件?
答案是不会。进程替换的是物理空间上的代码和数据,task_struct 属于内核数据结构,进行程序替换的时候不影响内核数据结构!
六、如何理解 Linux一切皆文件
之前这个概念是直接给出的,没有进行理解,现在如何理解 Linux下一切皆文件?
在 计算机的软硬件体系结构中我们学到,操作系统是一款管理软件,它通过向下管理好各种软硬件资源 (手段),来向上提供良好 (安全、稳定、高效) 的运行环境 (目的);也就是说,键盘、显示器、磁盘、网卡等硬件也是由操作系统来管理的。
而操作系统管理软硬件的方法是 先描述、再组织,即先将这些设备的各种属性抽象出来组成一个结构体,然后为每一个设备都创建一个结构体对象,再用某种数据结构将这些对象组织起来
同时,每种硬件的访问方法都是不一样的,比如,向磁盘中读写数据与向网卡中读写数据是有明显差异的,所以操作系统需要为每一种硬件都单独提供对应的 Read、Write 方法,这些方法位于驱动层
struct file 结构体中有一个函数指针变量,用于指向具体的 Write 和 Read 方法函数(即指向硬件的读写方法),这样每一个硬件都可以通过自己 struct file 对象中的 writep 和 readp 函数指针变量来找到位于驱动层的 Write 和 Read 方法
注:有些硬件天然不支持读写,它们的读写方法直接设置为空,不影响硬件依旧有读写方法
站在操作系统(OS)的层面来看,操作系统所看到的所有的软硬件设备和文件统一都是 struct file 对象,底层硬件的差异可以直接摒弃,让操作系统以统一的视角来看待这些文件,即 Linux 下一切皆文件
这些一个个 struct file 就构成了多态,同时,struct file 是操作系统当中虚拟出来的一层文件对象,在 Linux 中,我们一般将这一层称为 虚拟文件系统 vfs,通过它,我们就可以摒弃掉底层设备的差别,统一使用文件接口的方式来进行文件操作
七、缓冲区
7.1 认识缓冲区
测试代码
#include<stdio.h>
#include<string.h>
#include<unistd.h>
int main()
{
//C语言接口
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
//系统接口
const char* wString = "hello write\n";
write(1, wString, strlen(wString));
return 0;
}
运行结果,现象1
输出重定向到 log.txt,现象2
在原有的代码末尾使用 fork 创建子进程,子进程什么也不做
#include<stdio.h>
#include<string.h>
#include<unistd.h>
int main()
{
//C语言接口
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
const char* fputsString = "hello fputs\n";
fputs(fputsString, stdout);
//系统接口
const char* wString = "hello write\n";
write(1, wString, strlen(wString));
//创建子进程,子进程什么也不做
fork();
return 0;
}
运行结果,现象3
输出重定向到 log.txt 文件中,现象4
四个现象对比,很明显第四个现象有问题,观察发现,C语言的接口打印都打印了两次,系统接口 write 只打印了一次,这是为什么?
这与缓冲区有关
缓冲区本质上就是段内存,它是内存的一部分
这个缓冲区谁申请的?属于谁的?为什么要有缓冲区?这些问题下面一一解答
为什么要有缓冲区?
先举一个小栗子:
假设你在四川,你的好朋友张三在北京,你想要送一个物品给你的好朋友张三,然后你屁颠屁颠的骑自行车去到北京,来回一趟花费了大量的时间,假设用了一个月,这种方式成本高又浪费时间
又过了一段时间,你又想送物品给张三,舍友提醒你下面有快递站点,可以直接寄送给张三,于是你早上十点就把物品拿给快递站点,让快递站点进行寄送,你直接告诉张三,快递到了记得领快递。这种方式成本低又可以省时间
下午五点你又想起还有东西要给张三,你又去快递站点寄快递,你这是发现你早上寄的快递还没有寄出去,这时候你质问快递员:怎么我早上寄的快递怎么还没有寄出去?快递员耐心给你解释道:快递不会为了你一个人的一件快递就运输一趟,寄快递而是快递积累到一定数量时统一运输
将上述例子类比:
四川相当于内存,你相当于一个进程,北京相当于磁盘,你的好朋友张三就相当于磁盘里的一个文件,你送给张三的物品就相当于一段数据,送给张三就相当于往磁盘里面的文件写数据
你直接骑自行车把物品送给张三就相当于内存直接跟磁盘打交道,内存直接访问磁盘写数据,这种方式效率低并且浪费时间,因为大量的时间都用于访问磁盘上了
缓冲区是存在于内存里面的,快递站点就相当于缓冲区,缓冲区也是达到一定数量才会刷新到磁盘,就如你寄快递一样:快递不会为了你一个人的一件快递就运输一趟,所以缓冲区也不会一有数据就立马刷新,而是会采取一定的刷新策略
在例子中,快递的意义是节省发送者的时间,计算机中,缓冲区的意义是节省进程进行数据 IO 的时间,这就是为什么要有缓冲区
与其理解 fwrite 是将数据写入到文件的函数,不如理解 fwrite 是进行数据拷贝的函数,因为 fwrite 函数只是将数据从进程拷贝到缓冲区中,并没有真正将数据写入到磁盘文件中,是缓冲区刷新才会把数据写入文件中
7.2 缓冲区的刷新策略
上面已经说过,缓冲区也是达到一定数量才会刷新到磁盘,就如你寄快递一样:快递不会为了你一个人的一件快递就运输一趟,所以为了提高 IO 效率,缓冲区也不会一有数据就立马刷新,而是会采取一定的刷新策略
假设内存直接访问磁盘,写入一次数据一共要花费 1s,那么其中 990ms 都在等待磁盘(外设)就绪,只有 10ms 左右的时间在进行数据写入
所以,为了提高 IO 效率,缓冲区一定会结合具体的设备定制自己的刷新策略
缓存区刷新策略有三种:
-
立即刷新,无缓冲
-
行刷新,行缓冲
-
缓冲区满,全缓冲
立即刷新 (无缓冲):
- 缓冲区中一出现数据就立马刷新,IO 效率低,很少使用
行刷新 (行缓冲):
- 每拷贝一行数据就刷新一次,显示器采用的就是这种刷新策略,因为显示器是给人看了,而按行刷新符合人的阅读习惯,同时IO效率也不会太低
缓冲区满 (全缓冲):
- 待数据把缓冲区填满后再刷新,这种刷新方式 IO 效率最高
两种特殊情况
- 用户强制刷新缓冲区
- 进程退出,进程退出都要进行缓冲区刷新
7.3 缓冲区的位置
从现象4 中可以知道:
- 这种现象一定和缓冲区有关
- 缓冲区一定不在内核中(如果在操作系统(内核)中,write也应该打印两次,因为这些 C语言接口的函数,底层调用的是系统接口,系统接口是操作系统提供的,所以缓冲区一定不在内核中)
因此我们之前谈论的所有的缓冲区,都指的是用户级语言层面给我们提供的缓冲区,即语言自己提供的缓冲区
对于C语言来说,缓冲区位于 FILE* 中,FILE 是一个结构体,在这个结构体里面已经说过有 fd(文件描述符),而这个缓冲区也在这个 FILE 结构体里面
所以我们要强制刷新缓冲区的时候,传入的一定是文件指针,关闭文件也是如此,即:fflush(文件指针),fclose(文件指针),这个文件指针就是 FILE* 类型的,即这个文件指针里面包含了 fd 和 缓冲区
在 Linux 下,我们可以在 /usr/include/libio.h 中找到 C语言缓冲区的相关信息, /usr/include/stdio.h 下有缓冲区的 typedef
回答上面的三个问题
- 缓冲区是谁申请的?用户级语言
- 缓冲区属于谁?属于FILE 结构体
- 为什么要有缓冲区?提高进程 IO 的效率
7.4 解释上面的例子
现象1 解释:
- printf、fprintf、fputs 三种C语言接口函数都是向标准输出即显示器中打印数据,而显示器采用的是行缓冲区,同时,我们每条打印语句后面都带有换行符,所以 printf、fprintf、fputs 语句执行后立即刷新缓冲区
- 而 write 是系统调用,没有 C语言的缓冲区,即 write 没有缓冲区,所以也是语句执行后立即刷新
- 所以输出结果是四条语句顺序打印
现象2 解释:
- 我们通过输入重定向指令 > 将本该写入到显示器文件中的数据写入到了磁盘文件中,由于磁盘文件采用全缓冲刷新策略,所以 printf、fprintf、fputs 三条语句执行完毕后数据并不会刷新,因为缓冲区并没有被写满,而是等到进程退出这种特殊情况才会将三条语句刷新到磁盘文件中
- 但此时,write 语句也已经执行完毕,而 write 系统调用没有缓冲区,执行立即写入;所以输出结果是 write 在最前面
现象3 解释:
- 显示器采用行缓冲,所以在 fork 之前 printf、fprintf、fputs 三条语句的数据均已刷新到显示器上了
- 而对于进程数据来说,如果数据位于缓冲区内,那么该数据属于进程,此时 fork 子进程也会指向该数据
- 但如果该数据已经写入到磁盘文件了,那么数据就不属于进程了,此时 fork 子进程也不在指向该数据了
- 所以,这里 fork 子进程不会做任何事情,结果和现象1一样。
现象4 解释:
- 使用重定向指令将本该写入显示器文件的数据写入到磁盘文件中,而磁盘文件采用全缓冲,所以 fork 子进程时 printf、fprintf、fputs 的数据还存在于缓冲区中 (缓冲区没满,同时父进程还没有退出,所以缓冲区没有刷新)
- 也就是说,此时数据还属于父进程,那么 fork 之后子进程也会指向该数据
- 而 fork 之后紧接着就是进程退出,父子进程某一方先退出时会刷新缓冲区,由于刷新缓冲区会清空缓冲区中的数据,为了保持进程独立性,先退出的一方会发生 写时拷贝,然后向磁盘文件中写入 printf、fprintf、fputs 三条数据;
- 然后,后退出的一方也会进行缓冲区的刷新;
- 所以,最终 printf、fprintf、fputs 的数据会写入两份 (父子进程各写入一份),且 write 由于属于系统调用没有缓冲区,所以只写入一份数据且最先写入
7.5 实现一个简易的 C语言缓冲区
(1)myStdio.h
#pragma once
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<errno.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); //第一个参数是打开文件的名字,第二个参数是访问打开模式'w''r''a'
void fwrite_(const void* ptr, int num, FILE_* fp); //
void fclose_(FILE_* fp);
void fflush_(FILE_* fp);
(2)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* error = strerror(errno);
write(2, error, strlen(error));
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)
{
//写入到缓冲区
memcpy(fp->buffer + fp->size, ptr, num);
fp->size += num;
//判断是否刷新
if(fp->fileno & SYNC_NOW)//无缓冲
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0; //清空缓冲区
}
else if(fp->flags & SYNC_FULL) //全缓冲
{
if(fp->cap == fp->size)
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
else if(fp->flags & SYNC_FULL) //行缓冲
{
if(fp->buffer[fp->size-1] == '\n')//不考虑'\0'出现在字符串中间的情况
{
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
}
else
{
//TODO
}
}
void fflush_(FILE_* fp)
{
if(fp->size > 0)
write(fp->fileno, fp->buffer, fp->size);
fp->size = 0;
}
void fclose_(FILE_* fp)
{
fflush_(fp);
close(fp->fileno);
}
(3)main.c
#include "myStdio.h"
int main()
{
FILE_* fp = fopen_("./log.txt", "w");
if(fp == NULL)
{
return 0;
}
//const char* msg = "hello\n";
const char* msg = "hello ";
int cnt = 10;
while(cnt--)
{
fwrite_(msg, strlen(msg), fp);
printf("cnt: %d\n", cnt);
sleep(1);
}
fclose_(fp);
return 0;
}
(4)makefile
7.6 内核级缓冲区(用户级缓冲区与OS的关系)
上面所谈的 C语言文件操作向磁盘文件写入数据的过程是:程序运行,进程通过 fwrite 等函数将数据拷贝到缓冲区中,然后再由缓冲区以某种刷新方式刷新 (写入) 到磁盘文件中;
但实际上缓冲区并不是直接将数据写入到磁盘文件中的(用户级语言级缓冲区在用户部分),这个过程还要经过操作系统处理,然后数据才写入磁盘的文件中
实际上缓冲区并不是直接将数据写入到磁盘文件中而是先将数据拷贝到内核缓冲区
内核缓冲区位于操作系统(内核数据结构)的 struct file 的 file 结构体中
数据最后再由操作系统自主决定以什么样的刷新策略将数据写入到外设(磁盘)中,而这个写入的过程和用户没有关系
也就是说,向磁盘中写入数据都要经过这个三个步骤
- 数据先通过 fwrite 等文件操作接口将进程数据拷贝到语言级的缓冲区里面
- 然后再语言级缓冲区再根据自己的的刷新策略通过 write 系统调用将数据拷贝到 file 结构体中的内核缓冲区中,
- 最后再由操作系统自主将数据(内核级缓冲区也有自己刷新数据的策略)真正的写入到磁盘的文件中,到这一步数据才真正意义上写入磁盘的文件中
注:内核级缓冲区的刷新数据的策略与用户语言级缓冲区刷新数据的策略不一样,内核级缓冲区的刷新数据的策略并不是行缓冲、全缓冲、无缓冲这么简单,而是更复杂
特殊情况:数据被拷贝到内核缓冲区中,这时候操作系统突然宕机了必会出现数据丢失。为了保护数据安全,操作系统提供了一个系统调用函数 fsync,其作用就是将内核缓冲区中的数据立刻直接刷新到外设中,而不再采用操作系统内核缓冲区的刷新策略
man 2 fsync 查看一下
注:内核缓冲区是作为扩展知识,平时理解到用户级语言层面的缓冲区就已经完全足够了
总的来说缓冲区有两种:
用户级语言缓冲区和内核级缓冲区,用户级语言缓冲区就是语言级别的缓冲区,对于C语言来说,用户缓冲区就在 FILE 结构体中,其他的语言也类似
内核级缓冲区属于操作系统层面,刷新策略是按照OS的实际情况进行刷新的,与用户层无关
----------------我是分割线---------------
文章到这里就结束了,下一篇即将更新