前言
上一篇博客当中,我们对 文件 在操作系统当中是 如何就管理的,这个问题做了 详细描述,本篇博客将基于上篇 博客当中的内容进行 阐述,如有疑问,请参考上篇博客:
Linux - 基础IO(Linux 当中的文件,文件系统调用接口,文件描述符)- 上篇-CSDN博客
重定向
文件描述符的 分配规则
我们先来看一个例子:
此时我们先 关闭 0 号文件,也就是 stdin 这个文件,然后在使用 open()系统调用接口来 创建一个新的文件,打印这个新文件的 fd 。输出:
发现新文件的 fd 文件描述符是 0,也就是原本的 stdin 这个文件描述符。
同样,我们把 1 号文件 和 2 号文件都试一试,先是 1 号文件:
输出:
发现并没有输出,因为 printf()函数其中使用的系统调用接口就是 1 号文件的 stdout 这个流。
但其实,输出就是1 。
此时,其实是几输入到文件当中了的,只是当前的进程的 stdout 已经被关闭了,所以进程当中是无法使用 printf()函数打印出信息的:
关闭 2 号文件:
输出:
发现,此时新打开的文件的文件描述符 fd 就是 开始关闭的 2 号文件。
所以,按照以上的 输出,其实我们已经可以得出结论了:
在 Linux 当中的文件描述符的分配规则是:
如果有新的文件要打开,那么就要为这个文件分配一个 新的 没有被其他文件使用过的 文件描述符 fd。分配是按照 : 从 文件描述符表当中的 0 号下标位置处 开始,寻找最小的 还没有使用过的 数组位置,这个位置的下标就是新文件的 fd 文件描述符。
重定向理解 - 重定向的原理
像上述的,把 1 号文件 也就是 stdout 这个显示器文件 给关闭了, 所以我们使用printf()函数就是不能在显示器当中输出数据了。
但是,上述也说过,虽然不会在 显示器当中输入, 但是会在 log.txt 这个新打开的文件当中,把原本应该输出在显示器当中的数据,写入到 log.txt 这个文件当中。
这是为什么呢?
其实看上述的代码你应该也知道了,因为 write()函数是固定像 1 号文件当中写入 数据,但是 1 号文件在 log.txt 这个新文件打开之前就被 关闭了。
也就是说,在 log.txt 这个新文件打开之前, 1 号 fd 文件描述符已经空闲下来了,所以, 新文件就会直接使用 1 号这个 fd 文件描述符。
所以,在后序循环当中 使用 write()函数在 1 号文件当中写入数据,实际是在 log.txt 当中写入数据。
其实,像上述的过程,就是一种重定向。
如上图所示,原本 1 号 文件描述符指向的是 显示器文件,但是现在指向的是 log.txt 这个文件了。
而这个变化的过程, 编译器在执行之时 ,也就是 while()循环当中的代码在执行之时,其实是不知道的,他只知道,现在要向 1 号文件当中写入 数据,但是它不知道 1 号文件,此时是 显示器文件 还是 log.txt 文件,还是其他什么文件,他是不知道的。
而,上述的过程,就是重定向的 原理。
所以,在上图当中的 fd_array [] 数组当中的 每一个元素存储的是 文件对象的起始地址,修改 几号fd ,其实就是修改 fd_array [] 数组当中的 fd 下标位置的 文件对象的地址地址指针。
而,在代码层面,他不管他现在的任务是要在哪一个文件当中写入数据,这个文件在哪,这个文件的地址是多少,这些他都不关系;它只关心,这是几号文件,要在哪一个 fd 的文件的文件当中写入数据。
重定向的系统调用接口
有三个调用接口。我们主要来谈谈 dup2()这个函数:
使用 dup2()函数,就可以不向上述一样显示的使用 close()函数来关闭文件,来实现重定向的 功能,直接使用这个 dup2()函数即可:
使用 dup2()函数,就不用在关闭 1 号文件了当我们在 程序当中已经打开了 某一个文件,创建了这个文件的 文件描述符,那么在这个 文件描述符指向的 文件描述符表的 下标位置,就存储了这个文件的 文件对象的地址。
使用 dup2()函数,直接 把 某一个文件 在 文件描述符表当中存储的 文件对象的地址,直接拷贝到 需要重定向的 在 文件描述符表当中存储的 文件对象的地址 当中。如下图所示:
而 dup2()函数的两个参数:
在上述说过的拷贝的过程当中,谁是 "oldfd" , 谁是 "newfd" 呢?
在上述例子当中, 1 号文件是要被 fd 也就是 log,txt 文件对象地址所拷贝的,1 号 是 被拷贝;fd 是拷贝。
所以,在上述例子当中 1 号是 "newfd"; fd 是 "oldfd" 。
dup2(fd , 1);
dup2() 接口例子测试
输出重定向
还是上述的例子,只不过,此时不使用 close()关闭文件来实现了,而是使用 dup2()函数来实现:
输出:
在原本是空的 log.txt 文件当中,在运行 text 可执行程序之后,log.txt 当中已经被写入了数据。
现在的输出结果和上述 使用 close()关闭文件实现的效果是一样的 。
像上述我使用的是 O_TRUNC ,是先清空 文件当中的内容,然后在从头开始写入数据,对应的就是 ">" 这个输出重定向。
如果我们把 O_TRUNC 换成 O_APPEND,就是追加的方式来写入数据,对应的就是 ">>"这个输出重定向。
( 输入重定向) - 使用 read()系统调用接口 和 dup2()
其中的 count 是我们期望 read()函数读取多少个 字节的内容,返回值 ssize_t(这个是有符号整数) 是实际read()函数读取到多少字节的内容。写入到 buf 当中。
比如,count 期望大小我们填入 1024个字节,fd 文件我们选择 0 号 键盘文件,那么,他就会一直阻塞等待我们在键盘当中输入数据到缓冲区当中读取。
在 open()函数当中,我们可以使用 O_RDONLT 这个参数,代表的意思就是 只读。
输出:
此时,我们使用read()函数在 log.txt 文件当中读取内容,在读取之前,使用 dup2()函数,把 0 号文件(也就是键盘文件 的 fd 值) ,直接替换为 log.txt 文件对象的地址:
此时输出:
此时,运行程序,直接把文件当中的内容给输出出来了。
此时我们使用 O_RDONLT 这个参数,就实现了 "<" 输入重定向的操作。
这不就是cat命令吗?
直接使用 cat 命令,就可以等待 我们在键盘当中的输入,然后把输入内容打印出来:
或者是 使用 "<" 来向文件当中,读取文件数据并打印:
使用 printf()向文件当中写入数据
在这个例子当中,我们把 1 号文件,利用 dup2()函数 把 1 号文件的文件指针修改为 log.txt 文件,此时,printf()函数 fprintf()就在 log.txt 文件当中写入数据了:
在上述输出当中, 在显示器当中没有输出,但是在 log.txt 文件当中已经 有数据了。
所以,在上述,就算我们 close(fd),这个程序同样是会在 log.txt 文件当中的输入数据的,因为,此时 log.txt 文件对象的地址不只是 fd 保存的,还有 1 号文件描述符也是保存了 log.txt 的文件对象的地址。
而且,我们是在 1 号文件当中写入的 ,所以,是不会影响在log.txt 文件当中写入数据的。
shell 当中的 输入/输出重定向 的实现 概述
上述我们已经介绍了 输入/输出重定向 的简单实现,所以,在shell 当中,其实这些 输入/输出重定向 命令是属于 内建命令,直接在内建命令当中 判断 类似 ">>" ">" "<" 这样的字符子串,就可以判断,当前是不是 输入/输出重定向 的操作,就可以执行上述所实现的逻辑。
ls -a -l > myfile / ls -a -l >> myfile / cat < file.txt
向上述的操作,我们可以发现, ">>" ">" "<" 这样的字符子串 都是被空格 分隔开的,我们可以利用 之前在shell 模拟实现当中 分割字符串的操作,来提取出 ">>" ">" "<" 这样的字符子串。从而判断当前是不是 输入/输出重定向 操作。
(对于 shell 的模拟实现,参考这篇博客:Linux - 实现一个简单的 shell-CSDN博客)
其实做的工作非常简单:
在知道了上述的 重定向实现原理之后,其实我们只需要做判断即可:
比如上述判断 ">" 和 ">>" ,如果 当前的字符是 ">" 的话,如果下一个字符还是 ">" 的话,说明是 ">>" ,反之亦然;找到 ">" 或者 "<" 先把这两个字符修改为 '\0' 作为分割,因为 在 ">" 或者 "<" 之前的是 要输入或者要输出的数据,而在 ">" 或者 "<" 后面的是 文件名。
我们使用 isspace()函数来判断空格:
在空格之后就是我们想要的文件名。
找到之后,就要保存 两段的数据 命令 和 文件名
在 shell 父进程 创建子进程的之后 马上 ,就要判断当前是否是 需要执行 重定向操作的(如果是,还有判断 是 哪一个重定向操作):
如上所示,在判断 是哪一个重定向,然后就 按照对应的要求,修改 1 或者0 的其中一个 文件描述符值。
同时,文件打开方式也是区分 不同重定向操作的 步骤。0666 是 八进制 的 666 ,代表 用 open()函数创建的文件,是什么访问权限。(注意要减去 umack)
完整代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
#include <fcntl.h>
#define LEFT "["
#define RIGHT "]"
#define LABLE "#"
#define DELIM " \t"
#define LINE_SIZE 1024
#define ARGC_SIZE 32
#define EXIT_CODE 44
#define NONE -1
#define IN_RDIR 0
#define OUT_RDIR 1
#define APPEND_RDIR 2
int lastcode = 0;
int quit = 0;
extern char** environ;
char commandline[LINE_SIZE];
char* argv[ARGC_SIZE];
char pwd[LINE_SIZE];
char* rdirfilename = NULL;
int rdir = NONE;
// 自定义环境变量表
char myenv[LINE_SIZE];
// 自定义本地变量表
const char* getusername()
{
return getenv("USER");
}
const char* gethostname1()
{
return getenv("HOSTNAME");
}
void getpwd()
{
getcwd(pwd, sizeof(pwd));
}
void check_redir(char* cmd)
{
// ls -al -n
// ls -al -n >/</>> filename.txt
char* pos = cmd;
while (*pos)
{
if (*pos == '>') // 判断当前是 ">" ">>" 还是 "<"
{
if (*(pos + 1) == '>') { // 判断当前是 ">" 还是 ">>"
*pos++ = '\0';
*pos++ = '\0';
while (isspace(*pos)) pos++;
rdirfilename = pos;
rdir = APPEND_RDIR;
break;
}
else { // 是 ">"
*pos = '\0';
pos++;
while (isspace(*pos)) pos++;
rdirfilename = pos;
rdir = OUT_RDIR;
break;
}
}
else if (*pos == '<') // 是 "<"
{
*pos = '\0'; // ls -a -l -n < filename.txt
pos++;
while (isspace(*pos)) pos++;
rdirfilename = pos;
rdir = IN_RDIR;
break;
}
else {
//do nothing
}
pos++;
}
}
void interact(char* cline, int size)
{
getpwd();
printf(LEFT"%s@%s %s"RIGHT""LABLE" ", getusername(), gethostname1(), pwd);
char* s = fgets(cline, size, stdin);
assert(s);
(void)s;
// "abcd\n\0"
cline[strlen(cline) - 1] = '\0';
//ls -a -l > myfile.txt
check_redir(cline); // 在上述打印完 命令行,保存命令之后,用这个函数判断
// 命令当中是否有 重定向操作
}
int splitstring(char cline[], char* _argv[])
{
int i = 0;
argv[i++] = strtok(cline, DELIM);
while (_argv[i++] = strtok(NULL, DELIM)); // 故意写的=
return i - 1;
}
// 这个函数主要是实现 有 shell 父进程创建 子进程的过程
void NormalExcute(char* _argv[])
{
pid_t id = fork();
if (id < 0) {
perror("fork");
return;
}
else if (id == 0) {
int fd = 0;
// 判断当前子进程是否需要执行 重定向的工作
if (rdir == IN_RDIR) // 执行输入重定向
{
fd = open(rdirfilename, O_RDONLY);
dup2(fd, 0);
}
else if (rdir == OUT_RDIR) // 执行 ">"
{
fd = open(rdirfilename, O_CREAT | O_WRONLY | O_TRUNC, 0666);
dup2(fd, 1);
}
else if (rdir == APPEND_RDIR)// 执行 ">>"
{
fd = open(rdirfilename, O_CREAT | O_WRONLY | O_APPEND, 0666);
dup2(fd, 1);
}
//让子进程执行命令
//execvpe(_argv[0], _argv, environ);
execvp(_argv[0], _argv);
exit(EXIT_CODE);
}
else {
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid == id)
{
lastcode = WEXITSTATUS(status);
}
}
}
// 这个函数当中是判断 和 实现一些内建命令的
int buildCommand(char* _argv[], int _argc)
{
if (_argc == 2 && strcmp(_argv[0], "cd") == 0) {
chdir(argv[1]);
getpwd();
sprintf(getenv("PWD"), "%s", pwd);
return 1;
}
else if (_argc == 2 && strcmp(_argv[0], "export") == 0) {
strcpy(myenv, _argv[1]);
putenv(myenv);
return 1;
}
else if (_argc == 2 && strcmp(_argv[0], "echo") == 0) {
if (strcmp(_argv[1], "$?") == 0)
{
printf("%d\n", lastcode);
lastcode = 0;
}
else if (*_argv[1] == '$') {
char* val = getenv(_argv[1] + 1);
if (val) printf("%s\n", val);
}
else {
printf("%s\n", _argv[1]);
}
return 1;
}
// 特殊处理一下ls
if (strcmp(_argv[0], "ls") == 0)
{
_argv[_argc++] = "--color";
_argv[_argc] = NULL;
}
return 0;
}
int main()
{
while (!quit) {
// 1.rdirfilename 用于保存文件名, rdir 保存要输入/输出的数据方式(命令)
rdirfilename = NULL;
rdir = NONE;
// 2. 交互问题,获取命令行, ls -a -l > myfile / ls -a -l >> myfile / cat < file.txt
interact(commandline, sizeof(commandline));
// commandline -> "ls -a -l -n\0" -> "ls" "-a" "-l" "-n"
// 3. 子串分割的问题,解析命令行
int argc = splitstring(commandline, argv);
if (argc == 0) continue;
// 4. 指令的判断
// debug
//for(int i = 0; argv[i]; i++) printf("[%d]: %s\n", i, argv[i]);
//内键命令,本质就是一个shell内部的一个函数
int n = buildCommand(argv, argc);
// 5. 普通命令的执行
if (!n) NormalExcute(argv);
}
return 0;
}
shell 当中的 输入/输出重定向 和 进程替换之间的关系
不知道你有没有发现,我们在 check_redir()函数当中判断,是否需要重定向操作,然后修改对应存储 重定向操作符前后的 命令 和 文件名。
用这两个 存储 重定向操作符前后的 命令 和 文件名 的两个全局变量的值,在 shell 父进程创建子进程 之后 马上,判断当前是否需要执行 重定向的操作。
但是,如果需要执行重定向,那么 在 修改 文件描述符值 之后,说明此时是在 子进程 当中修改的 文件描述符值。
然后我们要进行程序替换,让子进程执行我们在命令行当中输入的命令。
那么,在执行 程序替换之时不会替换掉 这些文件描述符表 当中存储的文件对象的地址么吗?
答案是不会的。
首先你要搞清楚的是,一个进程的 各个文件描述符值 是存储在那里的?
是存储在 struct file_struct 这个结构体 当中的,在这个结构体当中有一个数组,这个数组 就被称作 -- 文件描述符表。在这个数组当中就存储了 这个进程当前所 打开的 各个文件的 文件对象的地址。
所以,这是一个机构体,是被 操作系统做管理的结构体对象,所以这个 struct file_struct 这个结构体 和 进程 的 PCB对象一样,都属于 操作系统当中的 内核结构体。
而 , 我们进行程序替换,替换的是 代码 和 数据,把 原本子进程 从 父进程那里拷贝或者共用的代码,进行直接拷贝 或者 写时拷贝的方式,替换为 我们在命令行当中输入 的命令的 代码,而数据也是跟着一起刷新的。
那么 这些 代码 和 数据是存储在哪里的?是存储在 内存的物理空间当中的,要注意区分。
所以,进程历史打开的所有文件,都在 struct file_struct 这个结构体 当中的 特定数组当中存储了 文件描述符,程序替换不会修改到这个 结构体,所以,程序替换 和 fd 文件描述符 无关。
所以,我们上述在实现之时,才是先把重定向的工作做了,再去程序替换;这样,重定向当中要修改的 文件描述符,和 进程替换无关,那么就不会被修改到。
而且,我们判断 是否需要重定向 的 两个全局变量是在父进程当中存储的,是在创建子进程之前就 已经 完成判断了的,这些数据,在进行程序替换之时都会被替换掉。
stdout 和 stderr 是区别
其实两个都是 显示器文件,都是输出重定向来使用的。
两者我们发现,使用 fprintf()函数都可以在 屏幕上打印 数据。
但是,之所以要 使用 两个显示器文件是因为,一个是 normal 正常的 数据输出;另一个 是 error 错误码输出;他想做到一种分流的 作用,把 正常的数据 和 错误的数据分成种方式输出。
为什么呢?
别忘了,输出不仅仅是在 显示器上输出,还可以在 文件当中输出。
所以,可以用两个文件,一个存储 正常的输出数据,一个存储错误输出数据;而我们在使用两种显示器文件输出数据之时,就可以在两个文件当中进程输出不同的数据了。
比如,此时有些 正常数据 和 一些错误的数据要输出:
那么,我们可以使用不同的 显示器文件来 输出,达到分流的目的:
上述这个命令,就是把 mytext 执行文件的输出结果,把 1 号文件的内容输出重定向到 normal.log 当中;把 2 号文件当中的内容 输出重定向到 arr.log 当中。(其中 1 可以不写(这些简写))
这样在两个文件当中就是不同 输出内容了。
像这样是 先把 1 号文件文件描述符 对应的 文件 当中的内容输入到 all.log 当中,然后把 1 号文件描述符当中的内容写到 2 号文件描述符值当中(2>&1)。
注意,是直接把 1 号文件描述符当中的内容,拷贝到 2 号文件描述符当中,1 号文件描述符当中的内容就是 1 号文件描述符 对应 的文件的 地址。所以,是直接把 1 号文件地址 直接 拷贝到 了 2 号文件描述符当中,相当于是 dup2()函数一样。