Linux基础IO—上
重定向
通过上篇的学习,我们了解了文件描述符的分配规则是遍历指针数组,用没有被使用的最小下标作为新的文件描述符,也就是我们可以通过关闭三个标准流文件并使用他们原先所占用的0,1,2描述符。
那我们假设这样一种情况,我们向显示器中打印内容,但在此之前我们将显示器文件,也就是标准输出流关闭,然后再创建并打开一个新的文件,此时我们向显示器中打印的内容会到哪去呢?
int main()
{close(1);int fd = open("myfile", O_WRONLY | O_CREAT, 00644);if (fd < 0){perror("open");return 1;}printf("fd: %d\n", fd);fflush(stdout);close(fd);exit(0);
}
运行之后我们发现,无论运行多少变显示器上都不会打印出内容,但是通过查看文件内容发现本来应该输出到显示器的内容被写到了文件里
这种现象就叫输出重定向!
注意:printf默认输出的文件就是stdout,而进程默认会打开三个标准流,每次都是如此,并且标准输出流文件描述符也总是1。
也就是说,像printf,scanf,fprintf,fscanf这些函数在底层只认识文件描述符,比如stdout对应的文件描述符是1,他们只认识1,而不认识什么stdout,是通过stdout建立起联系的
因此,重定向的本质,其实是在OS内部,更改fd对应的文件指向!
接下来我们再看个追加重定向的例子,也就是先关闭文件描述符为1的文件,再以追加的方式打开一个文件,这时系统自动分配,完成重定向!
int main()
{close(1);int fd = open("myfile", O_WRONLY|O_APPEND|O_CREAT);if (fd < 0){perror("open");return 1;}printf("hello append,my fd is: %d\n", fd);printf("hello append,my fd is: %d\n", fd);printf("hello append,my fd is: %d\n", fd);fflush(stdout);close(fd);exit(0);
}
这就是追加重定向,但我们使用的时候,我们利用的是文件描述符的分配规则来完成的,也就是在打开文件前先一步关闭对应的想重定向的文件,这样的话导致了代码使用时非常不灵活,因此能不能有一种灵活的方法能够随时重定向呢?系统就给我们一个接口:dup2函数——duplicate file description
dup2函数
其实完成重定向的本质就是把fd_array中对应的内容覆盖掉,因为数组下标是固定的,只需要将需要完成重定向的文件描述符覆盖掉即可完成,在系统中就用这样的一个接口来帮助我们完成这件事
画图:占位
没什么比直接阅读man手册实在!
newfd是oldfd的拷贝,也就是后者原本fd_array数组对应的fd下标里的内容如今换成了前者的内容,如果前者原本对应的内容不是有效的内容,则调用失败,而后者的文件并不会被关闭。必要时可以先关闭newfd对应的文件。
例如,我们要以上面举过的例子,我们要将原本输出到显示器上的内容重定向到文件中,那么oldfd对应的应该就是我们打开的文件对应的文件描述符fd,而newfd对应的就是显示器原本对应的内容1,这样子我们可以任意时候打开文件并任意时候完成重定向而不用预先关闭,非常灵活方便!
// test: dup2
int main()
{int fd = open("./log", O_CREAT | O_RDWR,0666);if (fd < 0){perror("open");return 1;}close(1);dup2(fd, 1);for (;;){char buf[1024] = {0};ssize_t read_size = read(0, buf, sizeof(buf) - 1);if (read_size < 0){perror("read");break;}printf("%s", buf);fflush(stdout);}return 0;
}
运行程序后可以明显发现我在键盘上敲入的内容并没有显示到显示器上,而查看文件内容后发现:
已经被重定向到log文件中!
注意:fflush虽然参数是stdout,但由于他只认识文件描述符,而此时1的内容已经被覆盖了,因此每一次写入缓冲区后立马刷新到文件里,实现同步过程,而不是等到缓冲区满了或者程序正常退出时才一次性刷新到文件里。
若调用成功,会返回new description,也就是后者原本占有的文件描述符,失败则返回-1。
myshell添加重定向功能
我们在Linux进程控制一章中,自模拟实现过一个简易的命令行解释器myshell,下面我们可以像bash命令行一样实现重定向 > < 以及 >> 的功能!
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <fcntl.h>
#include <assert.h>#define NUM 1024
#define SIZE 32
#define SEP " "char *g_argv[SIZE];
char sub[SIZE];
char cmd_line[NUM];#define NONE_REDIR 0
#define INPUT_REDIR 1
#define OUTPUT_REDIR 2
#define APPEND_REDIR 3int redir_status = NONE_REDIR;
//新增重定向功能,这里进行命令行解析
char *Checkredir(char *start)
{// ls -a>log.txtassert(start);char *end = start + strlen(start) - 1;while (end >= start){if (*end == '>'){// ls -a>>log.txtif (*(end - 1) == '>'){*(end - 1) = '\0';++end;redir_status = APPEND_REDIR;break;}// ls -a>log.txt*end = '\0';++end;redir_status = OUTPUT_REDIR;break;}else if (*end == '<'){// cat<log.txtredir_status = INPUT_REDIR;*end = '\0';++end;break;}else--end;}if (end >= start){return end;}elsereturn NULL;
}int main()
{while (1){printf("[root@localhost myshell]# ");fflush(stdout);memset(cmd_line, '\0', sizeof cmd_line);if (fgets(cmd_line, sizeof cmd_line, stdin) == NULL)continue;// ls -l -a\n\0 去除\ncmd_line[strlen(cmd_line) - 1] = '\0';char *ret = Checkredir(cmd_line);// 命令行解析: "ls -l -a\0\0" -> "ls" "-l" "-a"// strtok能做到此功能// 将解析出来的命令以及参数一个一个的放进指针数组中while (g_argv[index++] = strtok(NULL, SEP)); // 放入NULL表示还要继续解析上一个解析的字符串// 开始让子进程用进程替换执行用户输入的命令:pid_t id = fork();//先判断是否有重定向if (id == 0){if (ret){// 不为空说明是需要重定向int fd = -1;switch (redir_status){case INPUT_REDIR:fd = open(ret, O_RDONLY);assert(fd != -1);dup2(fd, 0);break;case OUTPUT_REDIR:fd = open(ret, O_WRONLY | O_TRUNC | O_CREAT, 0666);assert(fd != -1);dup2(fd, 1);break;case APPEND_REDIR:fd = open(ret, O_WRONLY | O_APPEND | O_CREAT, 0666);assert(fd != -1);dup2(fd, 1);break;default:perror("redirect error");break;}}// childprintf("以下功能由子进程进行进程替换所实现!\n");// 由于需要调系统程序,因此需要自动搜索环境变量PATHexecvp(g_argv[0], g_argv);exit(1);// 执行失败就退出,不会到下面去,和父进程互不干扰}// father// 父进程进行阻塞等待:int status = 0;pid_t res = waitpid(id, &status, 0);if (res > 0){printf("wait successfully!! exit code:%d\n", WEXITSTATUS(status));}}return 0;
}
效果展示:
stdout和stderr
在Linux基础IO—上中我们学习了默认打开的三个流,分别是stdout,stderr和stdin,这三个流本质上是文件指针,指向的文件分别是外设:显示器与键盘,其中stdout和stderr都是显示器,stdin是键盘,这也就说明,显示器这个文件是被打开了两次的,因为有两个文件指针都指向了他,他们都能向显示器文件做对应的io操作,那他们用法上有什么区别呢?
我们来看代码:
//test: stdout stderr
int main()
{//stdout->1printf("hello printf 1\n");fprintf(stdout,"hello fprintf 1\n");//stderr->2perror("hello printf 1\n");//write->1const char*s1="hello write 1\n";write(1,s1,strlen(s1));//write->2const char*s2="hello write 2\n";write(2,s2,strlen(s2));//cout->1std::cout<<"hello cout 1"<<std::endl;//cerr->2std::cerr<<"hello cerr 2"<<std::endl;return 0;
}
编译运行后输出:
不管是1还是2都能在我们的显示器看到,也证明了stdout和stderr对应的都是显示器
下面我们对程序进行重定向,看看会有什么变化:
重定向后我们发现2号文件仍然向显示器打印了,而1号文件就正常的重定向进了文件里
结论:重定向默认是对1号文件描述符进行重定向
也就是1号和2号分别打开的显示器是不会影响彼此的,其实就是显示器文件是被打开了两次!
若就想让1号和2号的都往同一个文件重定向:
这样就成功让1号和2号都重定向进了文件
如何使用?
一般而言如果程序运行有可能有问题的话,建议使用strerr或者cerr来打印,如果是常规的内容文本,建议使用stdout,cout来打印。
为什么?
因为在库中,有这样一个函数:
errno,被称为错误码,当某个函数调用时失败或者程序出现一些问题时,错误码就会被设置,而使用对应的perror就能向2号stderr中输出被设置的错误码所对应的错误信息,若你不想他直接运行后会在显示器上打印出来,就可以指定2号并重定向进日志文件中,方便管理者查看错误信息。
而perror的底层正是调用了strerror函数,这个函数在上篇有介绍过,不再细说。
void my_perror(const char* msg)
{fprintf(stderr,"%s",msg,strerror(errno));
}
FILE
我们在Linux基础io—上提到过,C语言所提供的文件操作函数,类似于fopen之类的函数,他们对文件的操作是需要用到文件指针FILE的,而我们现在已经学习了系统调用接口是通过文件描述符实现进程与打开文件建立联系的,而FILE文件指针底层正是封装了文件描述符才得以使用,本质上都是对系统接口进行二次封装提高可用性
Linux的设计哲学正是一切皆文件,体现在操作系统的设计软件层面,但其实Linux的底层也是C语言写的。
而我们学习过,文件正是=文件内容+文件属性,想用C语言实现这种具有面向对象思想的模块,只有一个东西能实现,那就是结构体struct。
而面向对象思想中,除了封装有有成员的属性,还有成员方法,但结构体如何实现成员方法呢?可以用函数指针来建立成员方法与成员的联系。
现在我们可以来看一下我们一直所说的文件指针FILE*,其指向的FILE底层究竟是如何实现的:
我们查文档后发现,FILE其实是结构体struct _IO_FILE的别名:
再转到_IO_FILE文件下,我们发现了有一个名为 _fileno的整型,这个整型其实就是我们熟悉的文件描述符fd,而不仅包含了fd,它还包含了该文件fd对应的语言层面的缓冲区结构!!
既然这样,由于底层各式各样的硬件,他们对应的操作方式都不同,但是他们的核心方式都是系统接口,也就是外设想要访问系统亦或是系统想要访问外设,在底层都是通过read,write,open,close来完成的,而这些硬件又被描述组织成文件被封装进了FILE中,这样一来,系统看待文件的方式都统一成为了管理结构体
缓冲区
什么是缓冲区
缓冲区其实就是用户自己提供的一块内存空间——char buffer[SIZE]
他的作用就是用来提高整机效率,提高用户的响应速度
我们知道文件是存在磁盘上的,若每次我们进行文件操作时都要直接往磁盘里加载,他的速度是非常慢的且效率不高,因此我们通过内存来过度,等到必要的时候再统一写入磁盘,这样减少频繁的访问磁盘,明显加速系统速度,这种模式叫做写回模式(write-back)
那缓冲区写入磁盘的时机又是什么时候呢?也就是缓冲区的刷新策略是什么?
缓冲区刷新策略:
- 立即刷新
- 行刷新(行缓冲)-‘\n’
- 满刷新(全缓冲)
特殊情况:
- 用户强制刷新(fflush)
- 进程退出
我们来看一个简单的例子:
int main()
{ printf("hello Linux!");sleep(5); return 0;
}
按照代码逻辑来看,执行情况应该是先打印出来,再停留五秒后退出
但实际执行并不是这样,而是停留了五秒后再打印出来然后退出
其实这就是C语言库提供的缓冲区,若没有达到缓冲区的刷新要求,字符串会暂时保存起来,最后进程要退出了才会达到要求然后刷出来,若需要先刷出来再停留五秒的效果,则需要加上‘\n’告诉缓冲区需要刷新了或者fflush强制刷新
了解完基本概念,我们来看一段奇怪的代码:
// test:buffer:缓冲区
int main()
{const char *msg0 = "hello printf\n";const char *msg1 = "hello fwrite\n";const char *msg2 = "hello write\n";printf("%s", msg0);fwrite(msg1, strlen(msg0), 1, stdout);write(1, msg2, strlen(msg2));fork();return 0;
}
运行结果为:
正常按照顺序打印一次,没毛病,符合预期。但我们稍微改动一下,我们运行程序后,将其重定向到文件中:
这就奇了怪了,同一份代码,只是改变了输出方向,怎么就有不同的结果呢?
我们还观察到,fwrite和printf还出现了两次,而write只出现了一次,怎么就这么巧了?
初步推断:
出现两次可以初步推断这个结果跟程序最后的fork()有关,而write系统调用接口只有一次,其他接口都是两次可以推断罪魁祸首并不会影响系统接口!!!
两个问题:
- 为什么会打印两次?
- 为什么write系统接口只打印了一次?
为什么会打印两次?
我们注意到,我们在打印时,是已经使用了缓冲区的刷新策略—行刷新策略,那么当执行到fork的时候,这时候打印函数已经执行完毕了,按照正常来说,已经是将数据从缓冲区刷出来的,但实际上确并没有刷新!也就是说,执行到fork函数的时候,数据仍然存在C语言库管理的缓冲区中,并且这部分数据是属于父进程的数据。
- 那么为什么会没有刷新呢??这不已经达到刷新策略的要求了吗?
我们需要注意到,我们这次运行时作了不同的动作,也就是进行了输出重定向!
而我们的输出重定向,本质就是将向显示器上打印的东西,转而写入了存储在磁盘上的文件!这时候隐形中的缓冲区刷新策略就改变了,变成了全缓冲,那么对应的行刷新也就没有作用了!
让我们接着来解释为什么会打印两次的问题:
存在缓冲区中的父进程的数据,在执行到fork()函数时,子进程采用写时拷贝的方法来保持进程间的独立性,父子进程共享一份代码和数据,但一旦父子进程的代码和数据需要被修改时,子进程会在物理内存中拷贝一份父进程的数据,并修改自己所持有的数据。真相渐渐水落石出了:
-
父进程刷新缓冲区,本质上是把数据写入系统的过程,也就是写的过程。会发生写时拷贝
-
父进程在刷新缓冲区前,由于子进程的存在,为了不让父进程的刷新影响到子进程,子进程也要拷贝一份一模一样的父进程缓冲区中的数据,因此有两份数据出现了,若提前强制刷新,缓冲区没数据了,子进程自然也就拷贝不到了
为什么write系统接口只打印了一次?
- 因为我们所谈的缓冲区,是C标准库维给我们提供的用户及缓冲区,是属于语言层面的,跟系统层面的不是一个东西,当然不会影响系统接口!但是write在系统内核中有内核对应的缓冲区,也并非直接写到磁盘上的!
那么我们所说的用户级缓冲区他又在哪里呢??
- 在我们前面提到过的FILE结构体,也就是_IO_FILE中,其内部不仅封装了文件描述符,还封装了缓冲区的结构!
我们调用fflush接口时,我们只需要传文件指针,那么他又是怎么知道缓冲区在哪里的,要去哪里刷新呢??
- *fflush中传的参数是FILE ,FILE 指向的FILE结构体内封装有缓冲区结构!
缓冲区总结
一般C库函数写入文件时是全缓冲的,而写入显示器是行缓冲。
printf fwrite 库函数会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。
而我们放在缓冲区中的数据,就不会被立即刷新,甚至fork之后
但是进程退出之后,会统一刷新,写入文件当中。
但是fork的时候,父子数据会发生写时拷贝,所以当你父进程准备刷新的时候,子进程也就有了同样的一份数据,随即产生两份数据。
write 没有变化,说明没有所谓的缓冲
综上:printf fwrite 库函数会自带缓冲区,而 write 系统调用没有带缓冲区。另外,我们这里所说的缓冲区,都是用户级缓冲区。其实为了提升整机性能,OS也会提供相关内核级缓冲区,不过不再我们讨论范围之内。那这个缓冲区谁提供呢? printf fwrite 是库函数, write 是系统调用,库函数在系统调用的“上层”, 是对系统调用的“封装”,但是 write 没有缓冲区,而 printf fwrite 有,足以说明,该缓冲区是二次加上的,又因为是C,所以由C标准库提供。
模拟实现缓冲区
关于我们对缓冲区的现理解,我们简单的模拟一下打开文件后对应分配到的缓冲区的结构,增加理解:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>typedef struct MYFILE_ MYFILE;struct MYFILE_
{int _fd;char buffer[1024];int end; // 缓冲区结尾
};MYFILE *fopen_(const char *pathname, const char *mode)
{assert(pathname && mode);MYFILE *fp = NULL;if (strcmp(mode, "r") == 0){}else if (strcmp(mode, "r+") == 0){}else if (strcmp(mode, "w") == 0){int fd = open(pathname, O_WRONLY | O_CREAT | O_TRUNC, 0666);if (fd >= 0){//创建文件时会分配缓冲区fp = (MYFILE *)malloc(sizeof(MYFILE));memset(fp, 0, sizeof(MYFILE));fp->_fd = fd;}}else if (strcmp(mode, "w+") == 0){}else if (strcmp(mode, "a") == 0){}else if (strcmp(mode, "a+") == 0){}else{// nothing}
}
void fputs_(const char *message, MYFILE *fp)
{assert(message);assert(fp);// hellofputs\0// fputs\0strcpy(fp->buffer + fp->end, message);fp->end += strlen(message);// 刷新策略if (fp->_fd == 0){// stdin}else if (fp->_fd == 1){// stdoutif (fp->buffer[fp->end - 1] == '\n'){ write(fp->_fd, fp->buffer, fp->end);fp->end = 0;}}else if (fp->_fd == 2){// stderror}
}
void fflush_(MYFILE *fp)
{assert(fp);if(fp->end!=0){write(fp->_fd,fp->buffer,fp->end);//向磁盘中写入syncfs(fp->_fd);fp->end=0;}
}void fclose_(MYFILE *fp)
{assert(fp);fflush_(fp);close(fp->_fd);free(fp);
}int main()
{// close(1):debugMYFILE *fp = fopen_("test.txt", "w");assert(fp);fputs_("one\n", fp);sleep(1);fputs_("two", fp);sleep(1);fputs_("three", fp);sleep(1);fclose_(fp);return 0;
}
syncfs函数能够帮助我们将文件磁盘中正式写入
只是简单模拟这个思路,底层真正想做到没有这么简单只为加深理解