打开一个文件实际上做什么?

在所有编程语言(至少我使用的)中,必须先打开一个文件,然后才能对其进行读写。

但是这个开放操作实际上是做什么的呢?

典型函数的手册页面实际上没有告诉你任何东西,除了它“打开一个文件进行读写”:

http://www.cplusplus.com/reference/cstdio/fopen/

https://docs.python.org/3/library/functions.html#open

显然,通过使用该函数,您可以知道它涉及到创建某种对象,以方便访问文件。

另一种说法是,如果我要实现一个open函数,它在Linux上需要做什么?

31235 次浏览

在几乎所有高级语言中,打开文件的函数都是对应内核系统调用的包装器。它也可以做其他奇特的事情,但是在当代的操作系统中,打开一个文件必须总是通过内核。

这就是为什么fopen库函数的参数,或Python的open的参数非常类似于open(2)系统调用的参数。

除了打开文件,这些函数通常还会设置一个缓冲区,用于读/写操作。这个缓冲区的目的是确保每当您想读取N个字节时,相应的库调用将返回N个字节,而不管对底层系统调用的调用是否返回更少。

我对实现我自己的功能不感兴趣;只是为了理解到底发生了什么……“超越语言”,如果你喜欢的话。

在类unix操作系统中,成功调用open将返回一个“文件描述符”,它只是用户进程上下文中的一个整数。这个描述符因此被传递给任何与打开的文件交互的调用,在对其调用close之后,描述符就无效了。

重要的是要注意,对open的调用就像一个验证点,在这个验证点上进行各种检查。如果不是所有条件都满足,则调用失败,返回-1而不是描述符,并且错误类型在errno中指出。基本检查包括:

  • 文件是否存在;
  • 调用进程是否有权限以指定的方式打开该文件。这是通过将文件权限、所有者ID和组ID与调用进程的ID相匹配来确定的。

在内核上下文中,进程的文件描述符和物理打开的文件之间必须存在某种映射。映射到描述符的内部数据结构可能包含另一个处理基于块的设备的缓冲区,或者指向当前读/写位置的内部指针。

当你打开一个文件时到底会发生什么,这取决于操作系统。下面我将描述在Linux中发生的事情,因为它可以让您了解当您打开一个文件时会发生什么,如果您对更详细的内容感兴趣,您可以检查源代码。我没有涉及权限,因为这会使这个答案太长。

在Linux中,每个文件都由一个名为索引节点的结构来识别。每个结构都有一个唯一的编号,每个文件只有一个inode号。这个结构存储文件的元数据,例如文件大小、文件权限、时间戳和指向磁盘块的指针,但不存储实际的文件名本身。每个文件(和目录)包含一个文件名条目和用于查找的inode号。当您打开一个文件时,假设您拥有相关的权限,将使用与文件名相关联的惟一inode号创建一个文件描述符。由于许多进程/应用程序可以指向同一个文件,inode具有一个link字段,用于维护指向该文件的链接总数。如果一个文件存在于一个目录中,它的链接数是1,如果它有一个硬链接,它的链接数将是2,如果一个文件被一个进程打开,链接数将增加1。

在它的核心,当打开以供阅读时,实际上没有什么奇特的需要发生。它所需要做的就是检查文件是否存在,并且应用程序有足够的权限来读取它,并创建一个句柄,您可以在这个句柄上向文件发出读取命令。

正是在这些命令上,实际的读取将被分派。

操作系统通常会通过开始一个读操作来填充与句柄相关的缓冲区来开始读取操作。然后,当您实际执行读取操作时,它可以立即返回缓冲区的内容,而不需要等待磁盘IO。

为了打开一个新文件写操作系统将需要在目录中添加一个新(当前为空)文件的条目。再次创建一个句柄,您可以在其上发出写入命令。

你想谈论的任何文件系统或操作系统我都可以。不错!


在ZX Spectrum上,初始化LOAD命令将使系统进入一个紧密的循环,读取音频。

数据开始由一个常量音调表示,之后是长/短脉冲序列,其中短脉冲用于二进制0,长脉冲用于二进制1 (https://en.wikipedia.org/wiki/ZX_Spectrum_software)。紧加载循环收集位,直到它填满一个字节(8位),将其存储到内存中,增加内存指针,然后循环回来扫描更多的位。

通常,加载器会读取的第一件事是一个简短的固定格式,表示至少期望的字节数,以及可能的附加信息,如文件名、文件类型和加载地址。在读取这个短报头后,程序可以决定是继续加载数据的主要部分,还是退出加载例程并为用户显示适当的消息。

可以通过接收任意数量的字节来识别文件结束状态(可以是固定数量的字节,在软件中是硬连接的,也可以是在头文件中指出的可变数量)。如果加载循环在一定时间内没有接收到预期频率范围内的脉冲,则抛出错误。


关于这个答案有一点背景知识

所描述的过程是从普通磁带中加载数据——因此需要扫描audio In(它与磁带录音机的标准插头连接)。LOAD命令在技术上与open文件相同——但它在物理上绑定到实际上加载文件。这是因为磁带录音机不是由计算机控制的,你不能(成功地)打开一个文件而不加载它。

提到“紧循环”是因为(1)CPU,一个Z80-A(如果没记错的话),非常慢:3.5 MHz, (2) Spectrum没有内部时钟!这意味着它必须准确地记录每个对象的T-states(指令时间)。单身。指令。在循环中,只是为了保持准确的哔哔声时间 幸运的是,低CPU速度有一个明显的优势,你可以在一张纸上计算周期数,从而计算出它们所花费的真实世界时间

我建议你看一下本指南通过简化版的open()系统调用。它使用下面的代码片段,它代表了打开文件时在幕后发生的事情。

0  int sys_open(const char *filename, int flags, int mode) {
1      char *tmp = getname(filename);
2      int fd = get_unused_fd();
3      struct file *f = filp_open(tmp, flags, mode);
4      fd_install(fd, f);
5      putname(tmp);
6      return fd;
7  }

简单地说,下面是代码逐行执行的操作:

  1. 分配一个内核控制的内存块,并从用户控制的内存中将文件名复制到其中。
  2. 选择一个未使用的文件描述符,您可以将其视为当前打开文件的可增长列表的整数索引。每个进程都有自己的这样的列表,尽管它是由内核维护的;您的代码不能直接访问它。列表中的条目包含底层文件系统将用于从磁盘提取字节的任何信息,例如inode号、进程权限、打开标志等等。
  3. filp_open函数已经实现

    struct file *filp_open(const char *filename, int flags, int mode) {
    struct nameidata nd;
    open_namei(filename, flags, mode, &nd);
    return dentry_open(nd.dentry, nd.mnt, flags);
    }
    

    它有两个作用:

    1. 使用文件系统查找与传入的文件名或路径对应的inode(或者更一般地说,文件系统使用的任何类型的内部标识符)。
    2. 用inode的基本信息创建一个struct file并返回它。该结构体成为我前面提到的打开文件列表中的条目。
    3. 李< / ol > < / >
    4. 将返回的结构体存储(“安装”)到进程的打开文件列表中。

    5. 释放分配的内核控制内存块。
    6. 返回文件描述符,然后可以将其传递给文件操作函数,如read()write()close()。每一个都将控制权移交给内核,内核可以使用文件描述符在进程列表中查找相应的文件指针,并使用该文件指针中的信息实际执行读、写或关闭操作。

    如果你有雄心壮志,你可以将这个简化的例子与Linux内核中open()系统调用的实现进行比较,一个名为do_sys_open()的函数。你应该不难找到相似之处。


    当然,这只是调用open()时发生的事情的“顶层”——或者更准确地说,它是在打开文件的过程中调用的内核代码的最高级别的部分。高级编程语言可能会在此基础上添加额外的层。有很多事情发生在较低的层次上。(感谢Ruslanpjc50的解释。)大致从上到下:

    • open_namei()dentry_open()调用文件系统代码,这也是内核的一部分,以访问文件和目录的元数据和内容。文件系统从磁盘读取原始字节,并将这些字节模式解释为文件和目录树。
    • 文件系统使用块设备层(同样是内核的一部分)从驱动器中获取这些原始字节。(有趣的事实:Linux允许您使用/dev/sda之类的方法从块设备层访问原始数据。)
    • 块设备层调用一个存储设备驱动程序,它也是内核代码,将一个中等级别的指令(如“读取扇区X”)转换为机器代码中的单个输入输出指令。有几种类型的存储设备驱动程序,包括IDE(S) ATASCSI火线等等,对应于驱动器可以使用的不同通信标准。(请注意,命名很混乱。)
    • I/O指令使用处理器芯片和主板控制器的内置功能在连接到物理驱动器的线路上发送和接收电信号。这是硬件,不是软件。
    • 在导线的另一端,磁盘的固件(嵌入式控制代码)解释电信号以旋转盘片和移动磁头(HDD),或读取闪存ROM单元(SSD),或访问该类型存储设备上的数据所需的任何东西。

    它也可以是由于缓存,有些不正确。严肃地说,我漏掉了很多细节——一个人(不是我)可以写好几本书来描述整个过程是如何工作的。但这应该能给你一个概念。

记账,主要是。这包括各种检查,如“文件是否存在?”和“我是否有权限打开此文件进行写入?”。

但这些都是内核的东西——除非你正在实现自己的玩具操作系统,没有太多的东西需要深入研究(如果你是,玩得开心——这是一个很好的学习经历)。当然,您仍然应该了解在打开文件时可能收到的所有错误代码,以便正确地处理它们——但这些通常都是不错的小抽象。

在代码级别上最重要的部分是,它为打开的文件提供了处理,用于对文件进行的所有其他操作。难道不能使用文件名来代替这个任意的句柄吗?当然,但使用手柄也有一些好处:

  • 系统可以跟踪当前打开的所有文件,并防止它们被删除(例如)。
  • 现代操作系统是围绕句柄构建的——你可以用句柄做很多有用的事情,而且所有不同类型的句柄的行为几乎相同。例如,当一个Windows文件句柄上的异步I/O操作完成时,句柄会被发出信号——这允许你阻塞句柄直到发出信号,或者完全异步地完成操作。等待一个文件句柄与等待一个线程句柄(在线程结束时发出信号)、一个进程句柄(在进程结束时发出信号)或一个套接字(在一些异步操作完成时)完全相同。同样重要的是,句柄由各自的进程拥有,因此当一个进程意外终止(或应用程序编写得很差)时,操作系统知道它可以释放哪些句柄。
  • 大多数操作都是位置的——你从文件的最后一个位置read。通过使用句柄来标识文件的特定“打开”,您可以对同一文件拥有多个并发句柄,每个句柄从各自的位置读取。在某种程度上,句柄充当了文件中的一个可移动窗口(也是一种发出异步I/O请求的方法,非常方便)。
  • 句柄比文件名小。句柄通常是指针的大小,通常是4或8字节。另一方面,文件名可以有数百个字节。
  • 句柄允许操作系统移动文件,即使应用程序已经打开它-句柄仍然有效,它仍然指向同一个文件,即使文件名已经更改。

你还可以做一些其他的技巧(例如,在进程之间共享句柄,使用物理文件拥有一个通信通道没有;在unix系统上,文件也用于设备和各种其他虚拟通道,所以这不是严格必要的),但它们并没有真正绑定到open操作本身,所以我不打算深入研究这一点。

基本上,调用open需要找到文件,然后记录它需要的任何内容,以便以后的I/O操作可以再次找到它。这很模糊,但在我能立即想到的所有操作系统上都是如此。具体细节因平台而异。这里已经有许多关于现代桌面操作系统的回答。我已经在CP/M上做了一些编程,所以我将提供关于它在CP/M上如何工作的知识(MS-DOS可能以同样的方式工作,但出于安全原因,目前通常不会这样做)。

在CP/M中,你有一个叫做FCB的东西(正如你提到的C,你可以称它为结构体;它实际上是RAM中包含各种字段的35字节连续区域)。FCB具有用于写入文件名的字段和标识磁盘驱动器的(4位)整数。然后,当你调用内核的Open File时,你把这个结构体的指针放到CPU的一个寄存器中。一段时间后,操作系统返回,结构略有变化。不管你对这个文件做什么I/O操作,你都要把指向这个结构体的指针传递给系统调用。

CP/M用这个FCB做什么?它为自己的使用保留了某些字段,并使用这些字段来跟踪文件,所以您最好不要从程序内部接触它们。“打开文件”操作在磁盘开始处的表中搜索与FCB中同名的文件('?'通配符匹配任何字符)。如果它找到一个文件,它会将一些信息复制到FCB中,包括文件在磁盘上的物理位置,这样后续的I/O调用最终会调用BIOS, BIOS可能会将这些位置传递给磁盘驱动程序。在这个层面上,具体情况有所不同。

简单来说,当您打开一个文件时,您实际上是在请求操作系统将所需的文件(将文件的内容复制到ram中)从辅助存储器加载到ram中进行处理。这背后的原因(加载文件)是因为您不能直接从硬盘处理文件,因为与Ram相比,硬盘的速度非常慢。

open命令将生成一个系统调用,该调用将文件的内容从二级存储(硬盘)复制到主存储(Ram)。

我们“关闭”一个文件,因为修改后的文件内容必须反映到硬盘中的原始文件中。:)

希望这能有所帮助。