再谈重定向
我们上一篇对于重定向的原理已经可以说是很清楚了,接下来,我们对重定向的相关知识做以下补充:
我们要认识到:下面才是我们重定向的完整写法:
./a.out 1 > log.txt
也就是让1对应的内容,指向新文件。
但是我们大部分情况是将1进行了省略,默认就是向标准输出文件进行输出。
#include <iostream>
#include <cstdio>int main()
{// 向标准输出文件进行打印:cout,stdout -> 1std::cout << "hello cout" << std::endl;printf("hello printf\n");// 向标准错误进行打印,stderr, cerr -> 2, 显示器文件std::cerr << "hello cerr" << std::endl;fprintf(stderr, "hello stderr\n");return 0;
}
我们对于标准错误,我们的stderr/cerr都是向2号文件描述符打印,我们知道,标准输出和标准错误都是显示器文件,相当于显示器文件被进程打开了2次,一个是对应1下标,一个是对应2下标,其实2也是有指向1指向的标准输出文件的,所以1,2里面的指针都有指向标准输出,所以1,2访问同一个文件,所以会往同一个显示器上打印数据:
接着编译成a.out文件后:
我们的问题是,为什么我们对应的标准输出写到了log.txt当中,而标准错误却依旧在显示器上进行打印呢?
原因是因为,我们今天在进行对应的输出的时候,虽然我们的标准输出和标准错误都指向同一个文件,但是当我们进行对应的重定向时,他的本质是把文件描述符1重定向到新文件log.txt,即把新打开的文件描述符对应内容的的地址拷贝到了1里面,这时候1指向的内容就是log.txt,可是2依旧指向标准错误,在重定向时,>
(或1>
)只将标准输出(文件描述符1)重定向到指定文件,而标准错误(文件描述符2)仍指向终端。
那我们怎么才能够将标准错误进行重定向呢?
所以我们可以根据重定向的完整写法,将标准输出写到log.normal,将标准错误写道log.txt:
在命令 command > log.txt 2>&1
中,>
将标准输出(文件描述符1)重定向到文件 log.txt
。此时,标准输出的内容不再显示在终端,而是写入到文件中。接着,2>&1
表示将标准错误(文件描述符2)的内容重定向到文件描述符1当前指向的位置,也就是 log.txt
。因此,标准输出和标准错误的内容都会被写入到同一个文件 log.txt
中,而不会在终端显示。
要同时将标准输出和标准错误都重定向到同一个文件,需要使用command > log.txt 2>&1
,其中2>&1
表示将文件描述符2(标准错误)的内容重定向到文件描述符1(标准输出)当前指向的位置,即log.txt
。
所以说,为什么会存在一个标准错误呢?为什么会有printf/perror???cout/cerr???
因为把标准输出和标准错误做分离,本质上是占用不同的文件描述符,虽然都指向显示器文件,但未来我们可以通过重定向,把常规消息和错误消息进行分离!这也就方便我们将不同信息写入到不同文件,方便我们日志的形成!
系统文件IO的一些补充:
在操作系统中,每个打开的文件都会关联一个文件对象(file object),这个对象中包含一个关键信息——指向内核文件缓冲区的指针。内核文件缓冲区是内存中的一块区域,用于临时存储文件的数据。无论对文件进行读写操作,还是增删一个比特位,操作系统都会将文件从磁盘加载到内存中的内核文件缓冲区。这个过程的具体实现涉及文件系统的底层机制,但核心思想是:文件操作实际上是在内核文件缓冲区中进行的,而不是直接在磁盘上操作。这样可以提高效率,减少磁盘读写次数,同时保证数据的一致性。
内核文件缓冲区(文件缓存)在 Linux 系统中是通过特定的数据结构和机制来维护的,主要包括以下几个方面:
Page Cache(页面缓存)
页面缓存是文件缓存的核心部分,用于存储文件数据。每个文件的数据块最多对应一个页面缓存项。页面缓存通过两个主要数据结构来管理:
Radix Tree(基数树):用于通过文件内的偏移快速定位缓存项。基数树是一种高效的搜索树结构,能够快速定位文件数据在内存中的位置。
双向链表:内核为每一片物理内存区域维护
active_list
和inactive_list
两个双向链表,用于实现物理内存的回收。这些链表不仅包含文件缓存,还包含其他匿名内存(如进程堆栈等)。Buffer Cache(块缓存)
块缓存是页面缓存的补充,用于管理磁盘块级别的数据。每个页面缓存项可以包含多个块缓存项。具体文件系统(如 ext4 等)通常与块缓存交互,负责在外围存储设备和块缓存之间交换数据。缓存管理机制
预读机制:当应用程序读取文件时,内核会根据预读策略,将文件的多个连续块加载到页面缓存中,以提高后续读取的效率。
写回机制:写入操作会先将数据写入页面缓存,然后由内核在适当的时候(如缓存满、定时刷新或文件关闭时)将数据刷新到磁盘。
替换机制:当内存不足时,内核会根据一定的算法(如 LRU,最近最少使用)选择页面缓存项进行回收。
与文件系统的交互
文件缓存位于虚拟文件系统(VFS)和具体文件系统(如 ext4)之间。VFS 负责与用户空间的数据交换,而具体文件系统则负责与磁盘的数据交换。通过这些数据结构和机制,内核文件缓冲区能够高效地管理文件数据的读写操作,减少对磁盘的直接访问,从而显著提升系统的性能。
以上我们了解一下就行了,并不是我们现阶段所能谈及的!!!(我们后面会谈及!!!)
我们现在只要知道,我们能够从file找到对应的文件的内核文件缓冲区:
struct file {...struct inode *f_inode; /* cached value */ // 指向文件的inodeconst struct file_operations *f_op; // 文件操作函数表...atomic_long_t f_count; // 文件的引用计数unsigned int f_flags; // 打开文件的标志fmode_t f_mode; // 文件访问模式loff_t f_pos; // 当前读写位置...
} __attribute__((aligned(4))); /* 确保结构体对齐 */
但是文件不是还有文件名,大小,ACM时间,权限等等吗?为什么我们对应的file里面为什么没有这些信息的描述呢?
这也是我们后面要谈到的,其实struct file是我们操作系统内打开的文件,但是我们文件相关的一些硬属性,相关属性并没有在该file结构体当中存储,而是在另一个数据结构,这个数据结构称为inode,我们就可以通过file间接的找到文件其他的属性。
自此,关于系统文件IO的补充内容,我们就说到这里。
理解“一切皆文件”
⾸先,在 Windows 中是⽂件的东西,它们在 Linux 中也是⽂件;其次,⼀些在 Windows 中不是⽂件的东西,⽐如进程、磁盘、显⽰器、键盘这样的硬件设备也被抽象成了⽂件,你可以使⽤访问⽂件的⽅法访问它们获得信息;甚⾄管道,也是⽂件;将来我们要学习⽹络编程中的 socket(套接字)这样的东西,使⽤的接⼝跟⽂件接⼝也是⼀致的。
这样做最明显的好处是,开发者仅需要使⽤⼀套 API (应用程序接口)和开发⼯具,即可调取 Linux 系统中绝⼤部分的资源。
很多人听到 “一切皆文件” 这个概念,可能仅仅停留在表面印象,并不真正理解其背后的实现机制。仅仅知道这个结果是不够的,更重要的是了解 Linux 是如何做到把各种事物都抽象成文件的。
操作系统软硬件结构剖析:
- 硬件层:这是整个计算机系统的最底层,包含键盘、磁盘、网卡、显示器等各种各样的硬件设备 。这些硬件是计算机系统运行的物理基础,它们各自有着不同的功能和工作方式。
- 驱动层:在硬件之上,是对应每个硬件设备的驱动程序。驱动程序就像是硬件和操作系统之间的 “翻译官”,它负责将操作系统的指令转换为硬件能够理解的信号,同时也将硬件的状态和数据反馈给操作系统。不同的硬件设备需要不同的驱动程序,因为它们的工作原理和控制方式差异很大。
- 操作系统层:再往上是操作系统,它负责管理计算机的所有软硬件资源,为上层应用提供一个稳定、高效的运行环境。操作系统提供了一系列的系统调用接口,这些接口是应用程序与操作系统内核交互的桥梁。
- 用户级库:基于操作系统提供的系统调用,开发者们构建了各种用户级库。这些库封装了复杂的系统调用细节,为应用程序开发者提供了更方便、更高级的编程接口,使得开发者可以更专注于业务逻辑的实现,而无需过多关注底层系统的复杂性。
操作系统对软硬件资源的抽象与管理:
- 抽象外设读写方法:操作系统需要对众多的软硬件资源进行抽象处理。以各种外设为例,虽然它们的读写方式千差万别,比如键盘通过用户敲击输入数据,磁盘通过磁头读写数据等,但它们都具备一些基本的操作特性,如读写、打开关闭、获取信息等。每个外设对应的驱动程序都需要实现这些基本的操作方法,尽管实现方式不同,但功能是一致的。
- 描述与组织外设:操作系统要对软硬件进行有效的管理,对于外设,首先要进行描述,也就是定义数据结构来表示外设的各种属性和状态,比如设备类型、设备 ID、当前状态等。然后,通过一定的数据结构和算法将这些描述好的外设组织起来,以便能够高效地进行访问和管理。例如,使用链表、树等数据结构来管理设备列表,使得操作系统可以快速定位和操作所需的设备。
举个简单的例⼦,Linux 中⼏乎所有读(读⽂件,读系统状态,读 PIPE)的操作都可以⽤ read 函数来进⾏;⼏乎所有更改(更改⽂件,更改系统参数,写 PIPE)的操作都可以⽤ write 函数来进⾏。我们可以看看:
在 Linux 系统中,文件管理是其核心功能之一。当我们打开一个文件时,操作系统为了高效地管理这个打开的文件,会为其创建一个 file 结构体。这个结构体对于文件的各项操作和属性记录起着关键作用,它定义在/usr/src/kernels/3.10.0 - 1160.71.1.el7.x86_64/include/linux/fs.h
路径下。接下来,让我们深入了解一下这个结构体中部分关键内容:
在struct file
结构体中,有一个值得重点关注的成员 ——f_op
指针。这个指针指向了一个file_operations
结构体,它在文件操作过程中扮演着极为重要的角色。file_operations
结构体和struct file
一样,也定义在fs.h
下,其内容如下:
struct file_operations {struct module *owner;//指向拥有该模块的指针,这有助于系统对模块的管理和资源分配,明确文件操作相关功能所属的模块。loff_t (*llseek) (struct file *, loff_t, int);//llseek方法用作改变文件中的当前读/写位置,并且新位置作为(正的)返回值。当我们需要在文件中进行随机读写时,就会调用这个函数来调整读写位置,返回的新位置信息可以让后续的读写操作在正确的位置上进行。ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);//用来从设备中获取数据。在这个位置的一个空指针导致read系统调用以 -EINVAL("Invalid argument")失败。一个非负返回值代表了成功读取的字节数(返回值是一个"signed size"类型,常常是目标平台本地的整数类型)。当应用程序需要从文件中读取数据时,就会通过这个函数与设备进行交互,获取所需的数据。ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);//发送数据给设备。如果NULL, -EINVAL返回给调用write系统调用的程序。如果非负,返回值代表成功写的字节数。当我们需要向文件中写入数据时,系统会调用这个函数来完成数据的写入操作,并返回写入的字节数,以便程序了解写入操作的结果。ssize_t (*aio_read) (struct kiocb *, const struct iovec *, unsigned long, loff_t);//初始化一个异步读 -- 可能在函数返回前不结束的读操作。异步读操作允许程序在读取数据的同时进行其他任务,提高了系统的并发处理能力,适用于对响应时间要求较高的场景。ssize_t (*aio_write) (struct kiocb *, const struct iovec *, unsigned long, loff_t);//初始化设备上的一个异步写。异步写操作同样可以提高系统的并发性能,在数据写入的过程中,程序可以继续执行其他任务,而不必等待写入操作完成。int (*readdir) (struct file *, void *, filldir_t);//对于设备文件这个成员应当为NULL;它用来读取目录,并且仅对**文件系统**有用。在文件系统中,当需要遍历目录获取文件列表时,就会调用这个函数来实现目录的读取操作。unsigned int (*poll) (struct file *, struct poll_table_struct *);int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long);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 *);//mmap用来请求将设备内存映射到进程的地址空间。如果这个方法是NULL,mmap系统调用返回 -ENODEV。通过内存映射,进程可以直接访问设备内存,提高数据访问的效率,减少数据拷贝的开销。int (*open) (struct inode *, struct file *);//打开一个文件,在文件操作的开始阶段,这个函数负责初始化文件相关的资源和状态,为后续的读写等操作做好准备。int (*flush) (struct file *, fl_owner_t id);//flush操作在进程关闭它的设备文件描述符的拷贝时调用;用于确保文件相关的缓存数据被正确地写入设备,防止数据丢失。int (*release) (struct inode *, struct file *);//在文件结构被释放时引用这个操作。如同open,release可以为NULL。当文件不再被使用时,系统会调用这个函数来释放文件占用的资源,包括内存、文件描述符等。int (*fsync) (struct file *, struct dentry *, int datasync);//用户调用来刷新任何挂着的数据。通过这个函数,用户可以主动要求系统将缓存中的数据写入设备,保证数据的一致性和持久性。int (*aio_fsync) (struct kiocb *, int datasync);int (*fasync) (int, struct file *, int);int (*lock) (struct file *, int, struct file_lock *);//lock方法用来实现文件加锁;加锁对常规文件是必不可少的特性,但是设备驱动几乎从不实现它。文件加锁可以防止多个进程同时对文件进行修改,保证数据的完整性和一致性。ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);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);int (*setlease)(struct file *, long, struct file_lock **);
};
file_operation
结构体是把系统调用和驱动程序关联起来的关键数据结构。它的每一个成员都对应着一个系统调用。当系统接收到一个文件操作相关的系统调用时,会读取file_operation
中相应的函数指针,然后把控制权转交给对应的函数。通过这种方式,完成了 Linux 设备驱动程序的工作,实现了从用户空间的系统调用到内核空间设备驱动程序的映射,确保了文件操作的顺利执行和系统的高效运行。
file结构体是如何体现屏蔽底层设备之间的差异的?
- 统一接口:
file
结构体中有一个指向 file_operations
结构体的指针(f_op
)。file_operations
结构体定义了一系列函数指针,这些函数指针为各种设备操作提供了统一的接口,如 read
、write
、open
、close
等。
对于不同的设备,比如磁盘、键盘、网卡等,虽然它们的硬件特性和操作方式大相径庭,但驱动程序都会按照 file_operations
结构体定义的格式来实现这些函数。例如,磁盘的 read
函数负责从磁盘读取数据,键盘的 read
函数负责获取键盘输入数据,尽管内部实现逻辑不同,但对于操作系统上层来说,调用 read
函数的方式是一致的。这就使得操作系统上层代码在进行设备操作时,无需关心具体设备的底层差异,以统一的方式对不同设备进行操作。
- 抽象设备属性和状态:
file
结构体可以存储与设备相关的一些通用属性和状态信息。虽然不同设备的具体属性和状态有很大差异,但 file
结构体可以通过一定的抽象,将一些共性的信息进行存储和管理。
例如,所有设备都可能有打开、关闭等状态,file
结构体可以记录这些通用的状态信息。操作系统在进行设备操作时,通过 file
结构体来查询和管理这些状态,而不需要针对不同设备去分别处理其独特的状态表示方式。这样,在一定程度上屏蔽了底层设备在状态管理方面的差异。
- 设备无关的文件描述符:
在操作系统中,对设备的操作通常通过文件描述符来进行。文件描述符是一个整数,它是 file
结构体在操作系统中的一个索引。
应用程序通过文件描述符来访问设备,而不需要直接与具体的设备硬件打交道。操作系统通过文件描述符找到对应的 file
结构体,进而调用 file_operations
中的函数来执行相应的操作。这使得应用程序在使用设备时,就像使用普通文件一样,无需了解设备的底层细节和差异,实现了设备操作的透明化。
通过以上这些机制,file
结构体在操作系统中起到了关键作用,有效地屏蔽了底层设备之间的差异,为上层提供了统一、简洁的设备访问接口。
上面这种模式就是:虚拟文件系统(VFS),VFS是操作系统内核中的一个重要组成部分,它提供了一个抽象层,用于统一管理不同类型的文件系统和存储设备。
纯C语言是不支持成员函数的,但支持函数指针,就在结构体套函数指针,这就是C版本的多态!!!struct file就是基类!!!(它通常是一个抽象类,提供了一组方法的声明,但不实现具体的方法。基类的主要作用是定义一个通用的框架,供派生类(子类)实现具体的功能。)
上图中的外设,每个设备都可以有⾃⼰的read、write,但⼀定是对应着不同的操作⽅法!!但通过struct file 下 file_operation 中的各种函数回调,让我们开发者只⽤file便可调取 Linux 系统中绝⼤部分的资源!!这便是“linux下⼀切皆⽂件”的核⼼理解。