目录

1.知识回顾

2.解释read

3.重定向

重定向的方法

方法1:先close再open

结论: 重定向的原理是修改fd _array元素指向的内容

方法2:使用dup2系统调用

输入/输出重定向

输入和输出重定向符

为自制的shell添加重定向符的解释

重定向会影响进程的程序替换吗?

4.重定向总结

5.重定向应用:将stderr和stdout中的信息分开打印

6.理解一切皆文件

前置知识

分析

理解虚拟文件系统

从"一切皆文件"中看面向对象的思想

结论


1.知识回顾

参见OS30.【Linux】文件IO (2) 文件描述符文章

2.解释read

对于ssize_t read(int fd, void buf[.count], size_t count),表示从fd指向的文件内容中读,将读取的结果存储到数组buf中,可以看到buf是没有类型的,具体解释为什么类型和调用者的目的有关

count参数表示最多读取的字节数,也称为请求的字节数

返回值:

        如果读取没有问题,返回值>0,表示实际读取到的字节数,文件位置会相应向前推进这么多字节. 如果实际读到的字节数少于请求的字节数(返回值<count),不算错误; 如果buf是char*类型,那么返回值可以用来定位字符串的结尾

        如果返回值=0,表示读取到文件的末尾(EOF)

        如果返回值=-1,表示读取错误,会设置errno

例如以下代码:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <string.h>
#define BUF_SIZE 20
int main()
{
    char str[]={"teststring\n"};
    char buf[BUF_SIZE];
	umask(0);
	int fd=open("test.txt",O_WRONLY|O_CREAT|O_APPEND,0666);
    if (fd<0)
    {
        perror("open failed");
        return -1;
    }
    write(fd,str,strlen(str));
    //-1是为\0预留空间
    ssize_t len=read(fd,buf,BUF_SIZE-1);
    if (len>0)
    {
       buf[len]='\0';
    }
    close(fd);
    write(1,str,strlen(str));
	return 0;
}

运行结果:

3.重定向

重定向的方法

方法1:先close再open

之前在OS30.【Linux】文件IO (2) 文件描述符文章提到过规则3: 设置文件的描述符时,从0下标开始,寻找最小的没有用的数组位置,它的下标就是新文件的文件描述符

假设需要重定向fd_array[1]: 从stdin重定向到test.txt文件,那么可以这样写:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <string.h>
int main()
{
    char str[]={"teststring\n"};
    close(1);
	umask(0);
    //如果没有问题,fd应该为1
	int fd=open("test.txt",O_WRONLY|O_CREAT|O_APPEND,0666);
    if (fd<0)
    {
        perror("open failed");
        return -1;
    }
	//向重定向后的fd_array[1]写入字符串
    write(fd,str,strlen(str));
    close(fd);
	return 0;
}

运行结果:没有将字符串写入到显示器文件,这是正常的,因为fd=1重定向到了test.txt文件,即从显示器的文件描述符变成了test.txt的文件描述符,这是输出重定向

可用下图描述:

结论: 重定向的原理是修改fd _array元素指向的内容

需要重定向哪个文件就要覆盖哪个文件描述符

方法2:使用dup2系统调用

上面方法很麻烦,需要先close再open,可以考虑dup2系统调用,不用显式关闭

dup2的参数有点奇怪,int oldfd和int newfd,这两个有什么区别? 

看下面一个例子:

不用先close再open,可以直接拷贝fd_array[fd]的值到另外一个位置,即覆盖拷贝,例如下图:

结合上图,给出oldfd和newfd的含义:newfd为oldfd的一份拷贝,为newfd=oldfd,注意: fd_array[oldfd]并没有修改

在上图中:newfd为1,oldfd为3,那么dup(oldfd,newfd)调用写为dup2(3,1),有以下代码:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <string.h>
int main()
{
    char str[]={"teststring\n"};
	umask(0);
	int fd=open("test.txt",O_WRONLY|O_CREAT|O_APPEND,0666);
    if (fd<0)
    {
        perror("open failed");
        return -1;
    }
    dup2(fd,1);
	//向重定向后的fd_array[1]写入字符串
    write(1,str,strlen(str));
    close(fd);
    close(1);
	return 0;
}

运行结果:

输入/输出重定向

输入和输出重定向符

回顾输入和输出重定向符可参见

OS3.【Linux】基本指令入门(2)
OS4.【Linux】基本指令入门(3)

OS5.【Linux】基本指令入门(4)

为自制的shell添加重定向符的解释

只实现这些Linux的重定向符号:

> 输出重定向,会先截断

>> 输出追加重定向

< 输入重定向

之前在OS27.【Linux】自制简单的Shell v.1.0版本文章写过shell v1.0代码,现在修改为v2.0,如下:

先#define3种重定向:

//RDIR是REDIRECT v.重定向的简写
#define IN_RDIR 0 //输入重定向
#define TRUNC_OUT_RDIR 1 //截断式输出重定向
#define APPEND_OUT_RDIR 2 //追加式输出重定向
#define NONE -1 //未重定向

使用重定向符需要确定重定向后的文件,那么就要使用变量存储重定向后的文件名,还有文件的写入方式

char* rdir_file_name=NULL;
int rdir=NONE;//设置写入方式

在get_commandline函数的结尾判断是否有重定向:

void get_commandline()
{
	char* fgets_ret=fgets(commandline,COMMANDLINE_SIZE,stdin);
	assert(fgets_ret != NULL);
	fgets_ret[strlen(fgets_ret)-1]='\0';//去掉换行符
	check_rdir(fgets_ret);
}

check_rdir函数的实现逻辑是: 使用指针变量命令行参数,如果遇到重定向符,需要单独处理:

作用: 按重定向符将输入的字符串分为两部分,再设置重定向宏(IN_RDIR,……)

void check_rdir(char* commandline)
{
	rdir=NONE;//默认未重定向
	char* pos=commandline;
	while(*pos)//循环条件*pos!=\0
	{
		if (*pos=='>')
		{
			//防止越界
			if (pos+1<commandline+strlen(commandline) && *(pos+1)=='>')
			{
				//为>>重定向符号,为追加写入,写入前不会先清空文件
				rdir=APPEND_OUT_RDIR;
				//设置\0,方便split_commandline函数切分字符串
				*pos='\0';
				*(pos+1)='\0';
				//越过已经读取的>>
				pos+=2;
				//避开空格
				while(*pos==' ')
					pos++;
				//剩下文件名
				rdir_file_name=pos;
				return;
			}
			//单个>,为输出重定向,会先清空文件再写入
			rdir=TRUNC_OUT_RDIR;
			//设置\0,方便split_commandline函数切分字符串
			*pos='\0';
			//越过已经读取的>
			pos++;
			//避开空格
			while(*pos==' ')
				pos++;
			//剩下文件名
			rdir_file_name=pos;
			return;
		}
		else if (*pos=='<')
		{
			//单个<,输入重定向
			rdir=IN_RDIR;
			//设置空格,方便split_commandline函数切分字符串
			*pos=' ';
			//越过已经读取的<
			pos++;
			//避开空格
			while(*pos==' ')
				pos++;
			//剩下文件名
			rdir_file_name=pos;
			return;
		}
		else
			pos++;	
	}
}

修改execute_program()函数,使其支持重定向:

主要做两件事:

1.添加open函数打开文件的方式

2.调用重定向函数dup2

void execute_program()
{
	pid_t fork_ret_id=fork();
	if (fork_ret_id<0)
	{
		perror("fork failed");
		return;
	}
	else if (fork_ret_id==0)//子进程执行命令
	{
		umask(0);
		//需要两个参数argv[0]和rdir_file_name
		if (rdir==IN_RDIR)
		{
			int fd=open(rdir_file_name,O_RDONLY,0666);
			dup2(fd,0);//修改fd_array[0的指向
		}
		else if (rdir==APPEND_OUT_RDIR)
		{
			int fd=open(rdir_file_name,O_CREAT|O_WRONLY|O_APPEND,0666);
			dup2(fd,1);
		}
		else if (rdir==TRUNC_OUT_RDIR)
		{
			int fd=open(rdir_file_name,O_CREAT|O_WRONLY|O_TRUNC,0666);
			dup2(fd,1);
		}
		else//rdir==NONE
		{
			//什么都不做
		}
		execvpe(argv[0],argv,environ);
		printf("%s: command not found\n",argv[0]);
		exit(-1);
	}
	else //Shell获取到了子进程的PID
	{
		int status;
		pid_t waitpid_ret_id=waitpid(fork_ret_id,&status,0);//阻塞等待
		if (waitpid_ret_id<0)
		{
			perror("waitpid failed");
			return;
		}
		if (fork_ret_id==waitpid_ret_id)
		{
			lastcode = WEXITSTATUS(status);
		}
	}
}

不要忘了main函数的每次循环前都要使rdir_file_name为NULL(注: rdir已在check_rdir函数的一开始就清空了)

测试输入重定向:

arguments.txt写入以下内容:

abc def 
ghi 
jkl

运行结果:

测试输出重定向:

创建一个test.txt空文件,键入以下指令:

cat arguments.txt > test.txt

运行结果:

再创建一个append.txt文件,里面含有"mno"字符串

再输入以下指令:

cat append.txt >> test.txt

运行结果:

再输入以下指令:

cat append.txt > test.txt

运行结果:先清空再写入

重定向会影响进程的程序替换吗?

换句话说,重定向后执行程序替换会影响文件的访问吗?

分析: 不会. 如果有重定向符号,自制的shell会先进行重定向,然后执行进程的程序替换(即执行execvpe函数)

重定向的本质是修改文件描述符的指向,而进程的程序替换只对代码和数据进行替换,不影响内核数据结构,即不会影响文件描述符

父进程做了重定向工作,父进程fork子进程 然后程序替换,子进程也有对应的重定向

结论:进程历史打开的文件与进行的各种重定向关系都和未来进行程序替换无关,那么程序替换只改进程部分,并没有修改内核数据结构

总而言之,文件操作和内存管理是解耦关系

4.重定向总结

重定向的本质是改变文件描述符原有的指向

输入重定向:从显示器读转而从其他文件中读
输出重定向:输出到显示器读转输出到其他文件

5.重定向应用:将stderr和stdout中的信息分开打印

原理stderr和stdout与fd_array的关系是这样的:

可以这样重定向:

这样就能将正常输出的日志和错误输入的日志分开了,利于定位问题,例如以下代码

#include <fcntl.h>
#include <sys/stat.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
    umask(0);
    int normal_log_fd=open("normal.log",O_CREAT|O_WRONLY|O_APPEND,0666);
    dup2(normal_log_fd,1);
    int error_log_fd=open("error.log",O_CREAT|O_WRONLY|O_APPEND,0666);
    dup2(error_log_fd,2);
    if (normal_log_fd==-1)
    {
        perror("open failed");
        return -1;
    }
    

    fprintf(stdout,"normal message\n");
    fprintf(stderr,"error message\n");
    return 0;
}

运行结果:

当然也有其他写法:

#include <stdio.h>
int main()
{
    fprintf(stdout,"normal message\n");
    fprintf(stderr,"error message\n");
    return 0;
}

准备好normal.log和error.log这两个空文件,使用以下指令:

./a.out 1>normal.log 2>error.log 
#也可以省去1,写成 ./a.out >normal.log 2>error.log 

作用是将fd_array[1]重定向到normal.log,将fd_array[2]重定向到error.log

也能做到同样的效果:

练习题:运行结果是什么?

./a.out 1>all.log 2>&1
#也可以省去1,写成 ./a.out >all.log 2>&1

答: 将stdout和stderr中的字符串都写入all.log中,指令中的"2>&1"表示将fd=1指向的文件里面的内容写到fd=2指向的文件里面

6.理解一切皆文件

“别管它背后是什么(硬盘、键盘、打印机、显卡、网络socket),在我(操作系统)看来,它都是一个你可以读写操作的‘文件’。你只需要学会open(), read(), write(), close(), ioctl()这几个简单的操作,就可以和绝大多数硬件、资源打交道。”

回顾在OS16.【Linux】冯依诺曼体系结构和操作系统的浅层理解文章提到的图:

前置知识

在系统中有许多不同种类的设备(例如磁盘、显示器、键盘、网卡等外设),那么每一个设备(大部分是外设)都要提供读写方法,便于操作系统与设备交互

显然不同设备的方法的内部实现必定不相同,但方法大致分为两类: 读方法和写方法

读方法和写方法可以放到设备的结构体(是由操作系统创建的内核数据结构)中,有下图:

由于"Linux一切皆文件"的思想,那么打开设备就是在打开文件(open系统调用),需要借助进程来完成

分析

打开文件要借助进程来完成,操作系统会为打开的文件创建struct file内核数据结构,其中有file_operations结构体的指针:

struct file 
{
    //......
	const struct file_operations	*f_op;
    //......
} __randomize_layout
  __attribute__((aligned(4)));	/* lest something weird decides that 2 is OK */

(完整代码参见OS30.【Linux】文件IO (2) 文件描述符)

而file_operations结构体内部含有各种文件操作函数的指针,可以理解为方法集对象,查看linux内核v6.16.4中struct file_operations的定义,在include/linux/fs.h中:

struct file_operations {
	struct module *owner;
	fop_flags_t fop_flags;
	loff_t (*llseek) (struct file *, loff_t, int);
	ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
	ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
	ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
	ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
	int (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *,
			unsigned int flags);
	int (*iterate_shared) (struct file *, struct dir_context *);
	__poll_t (*poll) (struct file *, struct poll_table_struct *);
	long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
	long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
	int (*mmap) (struct file *, struct vm_area_struct *);
	int (*open) (struct inode *, struct file *);
	int (*flush) (struct file *, fl_owner_t id);
	int (*release) (struct inode *, struct file *);
	int (*fsync) (struct file *, loff_t, loff_t, int datasync);
	int (*fasync) (int, struct file *, int);
	int (*lock) (struct file *, int, struct file_lock *);
	unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
	int (*check_flags)(int);
	int (*flock) (struct file *, int, struct file_lock *);
	ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
	ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
	void (*splice_eof)(struct file *file);
	int (*setlease)(struct file *, int, struct file_lease **, void **);
	long (*fallocate)(struct file *file, int mode, loff_t offset,
			  loff_t len);
	void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
	unsigned (*mmap_capabilities)(struct file *);
#endif
	ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
			loff_t, size_t, unsigned int);
	loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
				   struct file *file_out, loff_t pos_out,
				   loff_t len, unsigned int remap_flags);
	int (*fadvise)(struct file *, loff_t, loff_t, int);
	int (*uring_cmd)(struct io_uring_cmd *ioucmd, unsigned int issue_flags);
	int (*uring_cmd_iopoll)(struct io_uring_cmd *, struct io_comp_batch *,
				unsigned int poll_flags);
	int (*mmap_prepare)(struct vm_area_desc *);
} __randomize_layout;

现将file_operations中的函数指针简化为两个,方便后续理解:

struct file_operations
{
    type (*write)(...);
    type (*read)(...);
}

file_operations中的读函数必定会涉及到读设备,写函数必定会涉及到写设备,那么可以用下图表示:

和进程的task_struct进行对接:

那么读函数的调用过程为:

//可封装为系统调用read
struct task_struct->struct file_struct* files->fd_array[fd]->struct file_operations* f_op->type (*read)(...)->read_device(...)

写函数同理,这里省略

结论: 1.表面上通过read系统调用来读设备,通过write系统调用来写设备,但内部调用了针对不同设备的读方法和针对不同设备的写方法

2.因此一切皆文件相当于操作系统在文件层面上封装了一层文件对象(struct file),方便对文件的统一操作

3.文件对象含有文件操作对象的指针,后者指向各种操作方法,而操作方法的内部又会和设备驱动打交道,会读写设备

4.上层不关心内部的具体实现,统一使用系统调用来操作文件

理解虚拟文件系统

struct file这一层就被称为虚拟文件系统(Virtual File System,简称VFS):

★虚拟文件系统屏蔽了底层的设备的差异,各种资源统一包装成一种共同的形态——文件,让用户以统一的视角看待各种文件

在《Linux基础教程 (第2版)》(孟庆昌、路旭强等编著)书中 第11章 Linux内核简介 描述了VFS系统的结构:

Linux系统可以支持多种文件系统,为此,必须使用一种统一的接口,这就是虚拟文件系统(VFS——Virtual File System).通过VFS将不同文件系统的实现细节隐藏起来,因而从外部看上去,所有的文件系统都是一样的.

图11.7示出VFS和实际文件系统之间的关系.从图中可以看出,用户程序(进程)通过有关文件系统操作的系统调用进入系统空间,然后经由VFS才可使用Linux系统中具体的文件系统。就是说,VFS是建立在具体文件系统之上的,它为用户程序提供一个统一、抽象、虚拟的文件系统界面.这个抽象的界面主要由一组标准、抽象的文件操作构成,以系给调用的形式提供给用户程序,如 read()、write()、lseek()等. 所以,VFS 必须管理所有同安装的文件系统.它通过使用描述整个VFS的数据结构和描述实际安装的文件系统的数据结构来管理这些不同的文件系统.

从"一切皆文件"中看面向对象的思想

从此图理解继承和多态:

struct file_operation统一为读和写,但内部实现上不同设备的读写方法不同,类似于多态

而struct file又包含了file_operation,类似于继承

结论

所谓一切皆文件就是指计算机操作系统将一切计算机的可用资源都映射成文件形式向使用者提供统一的操作方式

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐