文件描述符(下)
一、重定向
1.1 输出重定向
测试代码:
int main(){
close(1); //关闭标准输出
int fd = open("./log.txt", O_WRONLY | O_CREAT | O_TRUNC , 0666); //打开文件log.txt
if(fd < 0)
{
perror("open");
return 1;
}
//向stdout输出内容
fprintf(stdout, "fd = %d\n", fd);
const char* str = "hello stdout!\n";
fputs(str, stdout);
fwrite(str, strlen(str), 1, stdout);
fflush(stdout); //刷新stdout输出缓冲区
close(fd); return 0;
}
运行结果:原本应该打印在显示器上的内容被写入到了log.txt
重定向的原理:
- 文件描述符fd的分配规则:将最小的没有被占用的文件描述符分配给新打开的文件。
- 测试中,我们先将fd为1的文件(即stdout)关闭,然后又打开了log.txt。根据fd的分配规则,log.txt的fd被分配为1。
- 而stdout所指向的FILE结构体中的成员_fileno(即fd)仍为1,对应文件log.txt。
- 此时,再通过stdout输出字符串,就会输出到1号文件log.txt。
- 重定向的本质:更改文件描述符fd对应指向的文件对象
注意:close不会刷新缓冲区。需要在最后关闭文件前调用fflush手动刷新缓冲区。
1.2 输入重定向
测试代码:
int main(){
close(0);
int fd = open("./log.txt", O_RDONLY);
if(fd < 0)
{
perror("open");
return 1;
}
char str1[64];
char str2[64];
char str3[64];
fscanf(stdin, "%s", str1);
fgets(str2, sizeof(str2)-1, stdin);
memset(str3, '\0', sizeof(str3));
fread(str3, sizeof(str3)-1, 1, stdin);
printf("%s%s%s",str1, str2, str3);
close(fd);
}
运行结果:原本应该从键盘获取输入,现在却读取了log.txt中的内容。
1.3 追加重定向
int main(){
close(1);
int fd = open("./log.txt", O_WRONLY | O_CREAT | O_APPEND , 0666);
if(fd < 0)
{
perror("open");
return 1;
}
fprintf(stdout, "you can see me!\n");
fflush(stdout);
close(fd);
}
运行结果:
注意:
- close不会刷新缓冲区。需要在最后关闭文件前调用fflush手动刷新缓冲区。
- 追加重定向后,缓冲区的刷新策略由行缓冲变为全缓冲。在close之前如果没有fflush,缓冲区重的数据就不会刷新到文件中。
1.4 系统调用dup2
重定向的过程:
- 先试用open打开新文件,获取文件的文件描述符fd(oldfd)。
- 调用dup2(oldfd, newfd)进行重定向。
- 如果oldfd是一个有效的文件描述符,先关闭newfd所指向的文件。
- 再将oldfd对应的struct file地址拷贝给newfd,这样就完成了重定向。
myshell实现重定向功能:
enum REDIR_FLAGS{
NONE_REDIR,
INPUT_REDIR,
OUTPUT_REDIR,
APPEND_REDIR
};
char cmd_line[1024]; //用于接收存储整条命令
char* cmd_param[32]; //将整条命令拆解成一个个参数
char env_buffer[64]; //环境变量缓冲区
int redir_flag = NONE_REDIR; //重定向标记
//****获取并解析重定向命令****
char* CheckRedir(char **cmd_param, int sz){
int i = 0;
char *filepath = NULL;
for(i = 0; i<sz; ++i)
{
if(strcmp(cmd_param[i], ">") == 0)
{
redir_flag = OUTPUT_REDIR; //设置重定向标记
filepath = cmd_param[i+1]; //指针指向文件路径
cmd_param[i] = NULL; //将命令行参数截断
break;
}
else if(strcmp(cmd_param[i], ">>") == 0)
{
redir_flag = APPEND_REDIR;
filepath = cmd_param[i+1];
cmd_param[i] = NULL;
break;
}
else if(strcmp(cmd_param[i], "<") == 0)
{
redir_flag = INPUT_REDIR;
filepath = cmd_param[i+1];
cmd_param[i] = NULL;
break;
}
}
return filepath;
}
//shell运行原理:父进程接收并解析命令,创建子进程执行命令,父进程等待。
int main(){
//0.命令行解释器是常驻内存进程,不退出
while(1)
{
//1.打印提示信息:[root@localhost myshell]#
printf("[root@localhost myshell]# ");
fflush(stdout); //stdout是行缓冲策略,没有'\n'需要强刷
//2.获取用户输入(包括指令和选项):"ls -a -l -i"
memset(cmd_line, '\0', sizeof(cmd_line));
if(fgets(cmd_line, sizeof(cmd_line), stdin) == NULL) continue;
if(strcmp(cmd_line, "\n") == 0) continue; //处理空命令
cmd_line[strlen(cmd_line)-1] = '\0'; //将换行符替换为'\n'
//3.命令行字符串解析:"ls -a -l" --> "ls" "-a" "-l"
cmd_param[0] = strtok(cmd_line, " ");
int i = 1;
while((cmd_param[i++] = strtok(NULL, " "))); //多加一层圆括号防止高亮
//****获取并解析重定向命令****
char *filepath = CheckRedir(cmd_param, i-1);
//4.内置命令:让父进程(shell)自己执行的指令,又叫内建命令
//内建命令本身就是shell中的一个函数调用
if(strcmp(cmd_param[0], "cd") == 0)
{
if(cmd_param[1] != NULL)
chdir(cmd_param[1]);
continue;
}
if(strcmp(cmd_param[0], "export") == 0)
{
if(cmd_param[1] != NULL)
{
strcpy(env_buffer, cmd_param[1]);
putenv(env_buffer);
}
continue;
}
//5.创建子进程执行命令:
int id = fork();
if(id == 0)
{
printf("I'm child process! pid:%d ppid:%d\n", getpid(), getppid());
//****实现子进程的输入输出重定向****
if(filepath != NULL)
{
int fd = -1;
switch(redir_flag)
{
case INPUT_REDIR:
printf("INPUT_REDIR\n");
//程序替换不会替换掉进程打开的文件,可以在程序替换前进行重定向操作。
fd = open(filepath, O_RDONLY);
dup2(fd, 0);
break;
case OUTPUT_REDIR:
printf("OUTPUT_REDIR\n");
fd = open(filepath, O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1);
break;
case APPEND_REDIR:
printf("APPEND_REDIR\n");
fd = open(filepath, O_WRONLY | O_CREAT | O_APPEND, 0666);
dup2(fd, 1);
break;
default:
break;
}
}
//进程程序替换
execvp(cmd_param[0], cmd_param);
exit(1);
}
int status = 0;
int ret = waitpid(-1, &status, 0);
if(ret > 0)
{
if(WIFEXITED(status))
{
printf("normal exit! child_pid:%d exit_code:%d\n", ret, WEXITSTATUS(status));
}
else
{
printf("abnormal exit! child_pid:%d exit_signal:%d\n", ret, status&0x7F);
}
}
else if(ret < 0)
{
printf("Waiting failed!\n");
}
printf("--------------------------------------------------------------\n");
}
}
运行结果:
注意:
- dup2进行重定向后,在close关闭文件之前会自动刷新缓冲区,不需要手动fflush。
- 程序替换不会替换掉进程打开的文件,可以在程序替换前进行重定向操作。
二、Linux虚拟文件系统
Linux系统的设计哲学:一切皆文件!是通过Linux的**VFS(virtual file system)**虚拟文件系统来实现的:
系统角度:
Linux是C语言实现的,用C语言可以实现面向对象吗?当然可以,我们可以在struct结构体中封装指向方法的函数指针!
以文件对象struct file为例:
//struct file可以这样实现
struct file{
//属性
int size; //大小
mode_t mode; //模式
int user; //拥有者
int group; //所属组
//......
//方法,函数指针
int (*readp)(int fd, void*buffer, int size);
int (*writep)(int fd, void*buffer, int size);
//......
}
-
利用面向对象的编程方法,struct file结构体中就可以封装对应设备文件的属性和操作方法了!
-
操作系统会为不同的设备文件创建统一的struct file结构,从操作系统的角度看,已经没有任何硬件上的差别了。
提示:早期的面向对象编程就是通过C语言实现的。后来随着面向对象称成为程序设计的主流,才慢慢出现了C++,Java等专门用于面向对象的语言。
驱动角度:
-
底层不同的硬件,一定对应不同的操作方法!但是根据冯诺依曼体系结构,所有外设都应具备IO操作。也就是说,每个设备的核心访问函数一定是 read/write。
-
所以每个硬件的驱动程序都应该提供自己的read和write方法。但代码的具体实现一定不同。
-
操作系统要求所有硬件的驱动程序以统一的格式提供read和write等函数的接口,其格式必须与struct file中的函数指针对应相同。
-
在打开文件(各种硬件设备)初始化struct file时,就可以将对应方法的函数指针指向驱动程序提供的具体实现函数。
三、文件缓冲区
3.1 基本概念
什么是缓冲区?
缓冲区实际是一段内存空间,用于临时存放外设的读写数据。
为什么要有缓冲区?
向外设写入数据有两种方式:
1. 写透模式(WT):不加以缓存,将数据直接写入到外设。这样写入速度较慢,成本较高!
2. 写回模式(WB):先将数据拷贝到缓冲区,再基于某种刷新策略将数据写入到外设。这样的方法能够 减少IO次数,提高读写效率,加快对用户的响应速度!
缓冲区的刷新策略:
1. 立即刷新
2. 行刷新(行缓冲):通常,行缓冲的设备文件为显示器。当读取到’\n’时,将换行符之前的内容写入到外设。
3. 满刷新(全缓冲):通常,全缓冲的设备文件为磁盘。当缓冲区写满时,将缓冲区中所有的内容写入到外设。
4. 特殊情况:1.用户强制刷新(fflush) 2.进程退出刷新
提示:
- 对外设进行IO操作时,数据量的大小不是主要矛盾;等待和预备IO的过程是最耗费时间的。
- 其实所有设备缓冲区的刷新策略都倾向于全缓冲,全缓冲的IO次数更少,读写效率更高。
- 但也需要根据具体情况作出妥协和让步:显示器上的内容是直接给用户看的,一方面要照顾效率,一方面还用顾及用户体验。行缓冲是折中后的最佳方案。
- 在一些特殊场景下,我们可以通过强制手段(fflush)自定义刷新策略。
3.2 用户缓冲区 & 内核缓冲区
测试代码:
#include <stdio.h>
#include <unistd.h>
int main(){
//C语言提供的
printf("hello printf!\n");
fprintf(stdout, "hello fprintf\n");
fputs("hello fputs\n", stdout);
//系统调用
char buf[] = "hello write!\n";
write(1, buf, sizeof(buf)-1);
fork(); //创建子进程
return 0; //父子进程各自退出
}
运行结果:直接运行程序,显示器(stdout)上打印的内容正确;但如果输出重定向到磁盘文件log.txt,发现C语言接口输出了两次,而系统调用只有一次输出。
解释原因:
- 在向显示器(stdout)输出内容时,缓冲区的刷新策略是行缓冲。也就是说,在fork之前父进程缓冲区中的数据已经刷新到了显示器。
- 当输出重定向到磁盘文件log.txt时,缓冲区的刷新策略是全缓冲。在执行fork时,父进程缓冲区中的数据还没有刷新。
- fork创建子进程,父子共享缓冲区。当父子进程任意一方退出,刷新缓冲区(写入)时,就会进行写时拷贝。
- 所以父子进程各自退出,分别将各自缓冲区中的数据刷新写入到log.txt中。因此,C语言接口输出了两次。这也证明了用户缓冲区是C标准库实现的。
C标准库为我们提供了用户级文件缓冲区。那么,文件缓冲区在哪里定义呢?
- 在C语言中:FILE结构体里面除了定义了_fileno(文件描述符),还定义了该文件对应的各种读/写缓冲区结构。
- 在C++中:cin/cout作为输入输出流对象(类),内部也一定封装了文件描述符和各种缓冲区结构。
注意:
- 操作系统也有自己的内核缓冲区,系统调用write并不是直接将数据写入到外设,而是先拷贝到内核缓冲区,然后再进行写入。
- 内核缓冲区不属于进程而属于内核数据。准确的说,内核缓冲区是文件file结构体中一个名为
address_space
的树状结构。因此创建子进程,刷新缓冲区不会进行写时拷贝。
3.3 模拟实现文件类接口,用户缓冲区
#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#define NUM 1024
struct MYFILE{ //模拟C标准库提供的FILE结构
int fd; //文件描述符
char buffer[NUM]; //缓冲区
int end; //有效内容结尾,'\0'的位置
};
typedef struct MYFILE MYFILE;
MYFILE *_fopen(const char *pathname, const char *mode){
assert(pathname!=NULL);
assert(mode!=NULL);
MYFILE *fp = NULL;
//以只写模式为例
if(strcmp(mode, "w") == 0)
{
int fd = open(pathname, O_WRONLY | O_CREAT | O_TRUNC, 0666);
if(fd >= 0)
{
//打开成功,分配空间并初始化MYFILE结构体
fp = (MYFILE*)malloc(sizeof(MYFILE));
fp->fd = fd;
memset(fp->buffer, '\0', sizeof(fp->buffer));
fp->end = 0;
}
}
//下面是其他打开方式
else if(strcmp(mode, "w+") == 0){
//......
}
else if(strcmp(mode, "r") == 0){
//......
}
else if(strcmp(mode, "r+") == 0){
//......
}
else if(strcmp(mode, "a") == 0){
//......
}
else if(strcmp(mode, "a+") == 0){
//......
}
else{
//不进行任何操作
}
return fp; //返回MYFILE*
}
void _fputs(const char *message, MYFILE *fp){
assert(message != NULL);
assert(fp != NULL);
//将字符串拷贝到缓冲区
strcpy(fp->buffer+fp->end, message); //从end位置开始填充缓冲区
fp->end += strlen(message);
fprintf(stderr, "buffer:%s", fp->buffer); //for debug
//根据不同的设备文件选择不同的刷新策略
switch(fp->fd)
{
case 0:
//标准输入:无
break;
case 1:
//标准输出:行缓冲
if(fp->buffer[fp->end-1] == '\n') //如果最后一个字符是'\n'
{
fprintf(stderr, "flush:%s", fp->buffer); //for debug
write(fp->fd, fp->buffer, fp->end); //将用户缓冲区中的数据刷新到内核缓冲区
syncfs(fp->fd); //将内核缓冲区中的数据刷新到外设
fp->end = 0;
}
break;
case 2:
//标准错误:行缓冲
break;
default:
//磁盘文件:全缓冲
break;
}
fprintf(stderr, "\n"); //for debug
}
void _fflust(MYFILE *fp){
assert(fp != NULL);
if(fp->end > 0)
{
write(fp->fd, fp->buffer, fp->end); //将用户缓冲区中的数据刷新到内核缓冲区
syncfs(fp->fd); //将内核缓冲区中的数据刷新到外设
fp->end = 0;
}
}
void _fclose(MYFILE *fp){
assert(fp != NULL);
_fflust(fp); //刷新缓冲区
close(fp->fd); //关闭文件
free(fp); //释放MYFILE空空间
}
int main(){
close(1); //for debug 测试行缓冲
MYFILE *fp = _fopen("./log.txt", "w");
if(fp == NULL)
{
perror("_fopen");
return 1;
}
_fputs("hello one!", fp); //for debug 测试行缓冲
_fputs("hello two!\n", fp);
_fputs("hello three!", fp);
_fputs("hello four!\n", fp);
//for debug 父子进程写时拷贝缓冲区
//_fputs("hello world!\n");
//fork();
_fclose(fp);
}
注意:
- 在行缓冲测试中,由于我们暂时不知道如何打开标准输出文件。所以:先将1号文件关闭,再打开log.txt完成重定向,是为了占用1号文件描述符进行行缓冲测试;输出到strerr,是为了将结果打印到显示器方便观察。
- 缓冲区的刷新策略也并不是通过文件描述符确定的,上面的代码仅供测试。
- C标准库提供的具体的文件类接口实际是相当复杂的,我们只能通过这种简单的模拟帮助大家理解缓冲区和各种文件接口的工作原理。
测试一:行缓冲测试
测试二:父子进程写时拷贝缓冲区
四、补充内容
4.1 stdout 和 stderr
测试代码:分别向stdout,stderr输出
int main(){
//C
fprintf(stdout, "hello stdout 1\n");
perror("hello stderr 2");
//Linux系统调用
char *str1 = "hello write 1\n";
write(1, str1, strlen(str1));
char *str2 = "hello write 2\n";
write(2, str2, strlen(str2));
//CPP
std::cout << "hello cout 1" << std::endl;
std::cerr << "hello cerr 2" << std::endl;
return 0;
}
运行结果:
解释:
- 文件描述符1,2对应的都是显示器文件。但他们两个是不同的,对应指向的文件对象也不同。
- 可以理解为:同一个显示器文件被打开了两次。
- 常规的文本内容,建议使用stdout,cout进行输出。
- 如果程序运行出错,建议使用stderr,cerr进行输出。
新玩法:
-
将标准输出和标准错误分别重定向到不同的文件:
-
将标准输出和标准错误重定向到同一个文件:
-
利用重定向拷贝文件:
注意:
2>err.txt
和2>&1
中间没有空格!1前要加&!
4.2 perror
测试代码:模拟实现perror,打开一个不存在的文件
void _perror(const char* msg){
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
}
int main(){
int fd = open("./log.txt", O_RDONLY);
if(fd < 0)
{
perror("open");
_perror("open");
return errno;
}
close(fd);
return 0;
}
运行结果:
提示:
- 全局变量errno,表示错误码(退出码)。使用时需包含头文件<errno.h>
- 函数perror,打印提示信息并跟上退出码errno对应的错误信息。使用时需包含头文件<stdio.h>
- 函数strerror,将退出码errno转换成对应的字符串描述。使用时需包含头文件<string.h>