Linux 基础IO
文件描述符(fd)
文件描述符(File Descriptor)是一个用于标识和操作打开文件的整数值。在UNIX和类UNIX操作系统中,文件描述符是一种抽象概念,用于在进程中访问文件、设备、管道等输入/输出资源。
每个进程在运行时都有一个 文件描述符表 (File Descriptor Table),其中存储了与文件相关的信息。文件描述符表是一个数组,索引从0开始,每个索引对应一个文件描述符。标准输入(stdin)、标准输出(stdout)、标准错误(stderr)通常预先分配了文件描述符0、1和2。
当打开或创建文件时,操作系统会分配一个未使用的文件描述符并返回给进程。进程可以使用文件描述符来执行各种操作,如读取文件内容、写入数据、改变文件的属性等。文件描述符是进程访问文件的关键工具,它允许进程通过标准的I/O操作对文件进行读写。
文件描述符的整数值通常是非负整数。常见的文件描述符值如下:
- 0:标准输入(stdin)
- 1:标准输出(stdout)
- 2:标准错误(stderr)
需要注意的是, 文件描述符在进程中是独立的 ,即 每个进程都有自己的文件描述符表 。不同进程中的文件描述符可以指向相同的文件,但它们的文件描述符值是独立的。因此,同一个文件在不同进程中可以具有不同的文件描述符值。
在使用文件描述符后,应该确保及时关闭不再使用的文件描述符,以释放系统资源。使用 close 系统调用可以关闭文件描述符。
分配规则
在 files_struct 数组当中,找到当前 没有被使用 的 最小 的一个下标,作为新的文件描述符。
验证如下:
close(0); // 关闭 fd = 0
int fd = open("test.txt", O_RDWR);
printf("fd = %d\n", fd); // 0,因为此时 fd = 0 是空闲中最小的
如果将 close(0);
改成 close(2);
,将输出 fd = 2
如果将 close(0);
改成 close(1);
,终端不会有任何输出
为什么?
这就是后面要讲的重定向
open 系统调用
open系统调用是一个在UNIX和类UNIX操作系统中常见的系统调用之一。它用于打开或创建文件,并 返回一个文件描述符 (file descriptor),以便后续的读取、写入或其他文件操作。
open 系统调用的原型如下:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *path, int flags);
int open(const char *path, int flags, mode_t mode);
参数说明:
- path:要打开或创建的文件的路径。
- flags:打开文件的模式和选项,如只读、只写、追加等。
- mode:创建文件时的权限(仅在创建新文件时使用)。
open 系统调用返回的文件描述符可以用于后续对文件的读取、写入或其他操作。文件描述符是一个非负整数,通常是最小的可用文件描述符。
open 系统调用的常见 flags 选项包括:
- O_RDONLY:只读模式打开文件。
- O_WRONLY:只写模式打开文件。
- O_RDWR:读写模式打开文件。
- O_CREAT:如果文件不存在,则创建文件。
- O_APPEND:在文件末尾追加写入。
- O_TRUNC:将文件截断为空。
open 系统调用的返回值表示操作成功与否,若成功则返回文件描述符,若失败则返回 -1,并设置 errno 变量来指示具体的错误原因。
需要注意的是,在使用 open 系统调用打开文件后,应该在不再需要使用文件时,使用 close 系统调用来关闭文件描述符,以释放系统资源。
close 系统调用
close
是一个系统调用,用于关闭一个已打开的文件描述符。它的原型如下:
int close(int fd);
close
系统调用会关闭文件描述符 fd
所引用的文件。关闭文件时会释放与之关联的资源,并且不能再对该文件进行读写操作。
注意:
-
close
调用成功返回0,失败返回-1,并设置相应的错误码。在错误发生时,可以使用errno
全局变量获取具体的错误信息。 -
关闭文件描述符后,不能再对其进行读写操作。如果尝试使用已关闭的文件描述符进行操作,将导致未定义的行为。
-
关闭文件描述符时,操作系统会减少该文件描述符的引用计数。当引用计数降至零时,相关资源(如文件描述符表中的条目)将被释放。
read 系统调用
read
系统调用用于从文件描述符中读取数据。它的原型如下:
ssize_t read(int fd, void *buffer, size_t count);
read
系统调用从文件描述符 fd
引用的文件中读取最多 count
字节的数据,并将其存储到 buffer
指向的缓冲区中。
注意:
-
fd
是一个已打开文件的文件描述符。可以是标准输入(0),标准输出(1),标准错误(2),或者其他通过open
等系统调用获得的文件描述符。 -
buffer
是一个指向存储读取数据的内存缓冲区的指针。调用read
时,读取的数据将被存储到这个缓冲区中。 -
count
是要读取的最大字节数。read
系统调用将尽可能多地读取数据,但不超过count
。 -
read
系统调用返回实际读取的字节数。如果返回值为 0,表示已到达文件末尾(EOF)。如果返回值为 -1,表示读取出错。可以通过检查errno
全局变量获取具体的错误信息。 -
read
系统调用是一个阻塞调用,即在没有可用数据时,会一直等待直到有数据可读。可以通过将文件描述符设置为非阻塞模式(使用fcntl
系统调用)来改变这种行为。 -
read
系统调用是按字节进行读取的,即使指定了较大的count
值,也不保证一次性读取完整的数据块。因此,在循环中多次调用read
来读取所需的数据是常见的做法。
write 系统调用
write系统调用用于将数据从缓冲区写入到已打开的文件或文件描述符。
write系统调用的原型与 read 系统调用类似,具体如下:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
返回值:
-
如果write成功写入了指定的字节数count,它会返回一个非负整数,表示成功写入的字节数。这意味着写入操作完成且没有发生错误。
-
如果write写入的字节数少于指定的count,但没有出现错误,它仍然返回一个非负整数,表示成功写入的字节数。
-
如果write返回0,表示没有数据被写入。这通常发生在写入一个长度为0的空缓冲区时。
-
如果write返回-1,表示发生了错误。这时需要检查errno变量来确定具体的错误原因。常见的错误可能包括文件描述符无效、文件系统已满、I/O错误等。
重定向
文件的重定向是一种操作,通过它可以改变程序的标准输入、标准输出和标准错误的来源或目标。在Unix-like系统中,文件的重定向使用特定的符号来实现。
下面是常见的文件重定向符号:
-
>
:将标准输出重定向到文件,覆盖文件中的内容。例如:command > file.txt
,将命令的输出写入到file.txt中,如果文件不存在则创建,如果文件已存在则覆盖原有内容。 -
>>
:将标准输出重定向到文件,追加到文件末尾。例如:command >> file.txt
,将命令的输出追加到file.txt的末尾,如果文件不存在则创建。 -
<
:将文件作为标准输入。例如:command < input.txt
,将input.txt文件中的内容作为命令的输入。
需要注意的是,文件重定向是由shell来处理的,它将执行命令并处理重定向操作。因此,文件重定向符号在不同的shell中可能会有些差异,具体的用法和行为可能会略有不同。
对 close(1) 的解释
之前我们提到了:如果将 close(0);
改成 close(1);
,终端不会有任何输出
因为此时发生了重定向,即将 标准输出(fd = 1)
重定向到了 test.txt
中
这样对于任何的标准输出,最终都会输出到 test.txt
中
下面这张图很好地解释了重定向的本质
dup2 系统调用
dup2
是一个用于复制文件描述符的系统调用,其原型如下:
int dup2(int oldfd, int newfd);
dup2
系统调用将一个已有的文件描述符 oldfd
复制到另一个文件描述符 newfd
。如果 newfd
已经打开,则首先关闭 newfd
引用的文件,然后将 oldfd
复制到 newfd
。
dup2
的常见用法是将一个文件描述符重定向到标准输入、标准输出或标准错误。通过将 oldfd
设置为已打开文件描述符,newfd
设置为标准输入(0)、标准输出(1)或标准错误(2),可以实现文件描述符的重定向。
例如,我们可以利用 dup2
来实现上面将 标准输出重定向到 test.txt
的操作:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
int test1(void)
{
int fd = open("test.txt", O_RDWR | O_APPEND);
if (fd < 0)
{
perror("open error\n");
return 1;
}
dup2(fd, 1); // 将 1(标准输出)重定向到 fd
char buff[1024];
for (int i = 0; i < 5; ++i) // 读 5 行
{
int bytes = read(0, buff, sizeof(buff) - 1);
if (bytes == -1)
{
perror("read error\n");
return 1;
}
buff[bytes] = '\0';
printf("line %d: %s", i, buff); // 重定向到 test.txt,不会有标准输出
}
return 0;
}
在 myShell 中添加重定向功能
这里只添加了 >
和 >>
两种重定向
auto parse = [&](void) -> int
{
std::string command;
std::getline(std::cin, command);
size_t cur = 0, size = command.size();
size_t curArgPos = 0;
while (true)
{
if(curArgPos == MAX_COMMAND_LENGTH - 1) // 还要留一个空间放 NULL
return PARSE_ERROR;
auto next = command.find(' ', cur);
auto temp = command.substr(cur, next - cur);
// 重定向
if(temp == ">" || temp == ">>")
{
const char *fileName = command.substr(next + 1).c_str();
int fd;
if(temp == ">") // 覆写
fd = open(fileName, O_CREAT | O_TRUNC | O_WRONLY, 0664);
else // 追加
fd = open(fileName, O_CREAT | O_APPEND | O_WRONLY, 0664);
dup2(fd, 1); // 将标准输出重定向到 fd
// close(fd);
break;
}
args[curArgPos] = new char[temp.size()];
strcpy(args[curArgPos++], temp.c_str());
// std::cout << "debug: " << args[curArgPos - 1] << std::endl;
if(next == std::string::npos)
break;
cur = next + 1;
}
args[curArgPos] = NULL;
return PARSE_SUCCESS;
};
FILE
根据上面的介绍以及对 C 语言的库函数(fread、fwrite、fopen、fclose)的了解,我们可以发现:
- 库函数封装系统调用
- 访问文件实质上都是通过 fd 访问的
因此,FILE 结构体里面必定封装了 fd
实例
int main(void)
{
const char *msg0 = "printf\n";
const char *msg1 = "fwrite\n";
const char *msg2 = "write\n";
printf("%s", msg0);
fwrite(msg1, strlen(msg1), 1, stdout);
write(1, msg2, strlen(msg2));
}
输出:
printf
fwrite
write
看起来很正常对吧
那如果我们将标准输出重定向到 test.txt
呢?
Sky_Lee@SkyLeeMBP test % ./cfile > test.txt
Sky_Lee@SkyLeeMBP test % cat test.txt
write
printf
fwrite
可以发现,输出顺序并没有按照预期
为啥?
一般 C 库函数写入文件时是 全缓冲 的,而写入显示器是 行缓冲 。
printf
fwrite
库函数会自带缓冲区,当发生重定向到普通文件时,数据的缓冲方式由行缓冲变成了全缓冲。
而我们放在缓冲区中的数据,就不会被立即刷新,甚至 fork
之后
但是 进程退出之后,会统一刷新,写入文件当中。
简单来说,上面的例子就是先将待输出的内容装在缓冲区,最后一次性吐出来,顺序就有可能与预期不同
再来一个例子,加深理解:
int main(void)
{
const char *msg0 = "printf\n";
const char *msg1 = "fwrite\n";
const char *msg2 = "write\n";
printf("%s", msg0);
fwrite(msg1, strlen(msg1), 1, stdout);
write(1, msg2, strlen(msg2));
fork();
}
与之前不同的是,这次我们 fork
了一个子进程,如果直接输出到终端,结果:
printf
fwrite
write
仍然按照预期
重定向呢?
write
printf
fwrite
printf
fwrite
神奇的事情发生了:write(系统调用)
输出了一次,而 printf
、fwrite(库函数)
竟然输出了两次!
原因就是在 fork
上
当我们 fork
了一个子进程后,由于父进程 即将退出,会刷新缓冲区 ,也就意味着父进程要修改缓冲区的数据,此时,父子进程 发生写时拷贝 ,导致父子进程的缓冲区各有一份数据,最终导致 printf(库函数)
、fwrite(库函数)
输出两次
而 write(系统调用)
输出了一次,说明系统调用没有缓冲区
2024.2.6 补充
printf、fwrite 的缓冲区,是在 用户层次 实现的,即在库中实现
在用户层有一个独立的缓冲区,可以减少对 write 的调用次数(缓冲区到达一定大小,或者用户主动调用 fflush 时,才调用 write)即减少系统调用的次数(涉及变态),提高性能
而 write 并非没有缓冲区,write 也是有一个写缓冲区的,这个缓冲区是 内核级别 的,只是这个刷新的时机由内核去控制,对用户不可见
当然用户也可以主动要求内核刷新 write 缓冲区的内容到磁盘(使用 fsync 或 fdatasync 函数: 这两个系统调用可以让你显式地要求操作系统立即将指定文件的所有待写入数据刷新到磁盘。它们的区别在于 fsync 会同时刷新文件数据和元数据,而 fdatasync 只刷新文件数据),避免数据的丢失,但是会影响性能,通常让 OS 来决定何时刷新是一个比较明智的选择
理解文件系统
可以使用 stat
指令查看某个文件的 Inode 信息:
Sky_Lee@SkyLeeMBP test % stat -x test.cpp
File: "test.cpp"
Size: 3345 FileType: Regular File
Mode: (0644/-rw-r--r--) Uid: ( 501/ Sky_Lee) Gid: ( 20/ staff)
Device: 1,9 Inode: 7567874 Links: 1
Access: Tue Jun 13 11:38:37 2023
Modify: Tue Jun 13 11:38:23 2023
Change: Tue Jun 13 11:38:23 2023
Birth: Tue May 30 18:56:31 2023
Inode
在Linux和类Unix系统中,每个文件和目录都有一个与之关联的inode(索引节点)。inode是文件系统中的一个数据结构,用于 存储文件的元信息 (metadata),而不是文件的实际内容。
inode元信息包括以下内容:
-
文件类型:指示文件是普通文件、目录、符号链接等类型。
-
文件访问权限:指定了文件的所有者、组和其他用户的读、写和执行权限。
-
文件大小:指示文件的大小,以字节为单位。
-
文件所属用户和组:指示文件的所有者和所属组。
-
文件时间戳:记录了文件的三个时间戳:访问时间(atime),修改时间(mtime)和状态更改时间(ctime)。访问时间表示最后一次读取或访问文件的时间,修改时间表示最后一次修改文件内容的时间,状态更改时间表示最后一次更改inode元信息的时间。
-
硬链接计数:指示有多少个目录项指向同一个inode。当创建一个文件时,初始的硬链接计数为1,每创建一个硬链接都会增加这个计数。
-
文件数据块的指针 :指示文件数据在磁盘上的位置
Unix/Linux系统内部不使用文件名,而使用inode号码来识别文件
可以看出,文件的属性与数据是分开存储的
创建一个文件到底做了什么
Sky_Lee@SkyLeeMBP test % touch abc
Sky_Lee@SkyLeeMBP test % ls -i abc
8369476 abc
创建一个新文件主要有一下4个操作:
- 存储属性:内核先找到一个空闲的 inode (这里是8369476),内核把文件信息记录到其中。
- 存储数据:假设该文件需要存储在三个磁盘块,内核找到了三个空闲块:300,500,800。将内核缓冲区的第一块数据复制到 300,下一块复制到 500,以此类推。
- 记录分配情况:文件内容按顺序 300, 500, 800 存放。内核在 inode 上的磁盘分布区记录了上述块列表。
- 添加文件名到目录:内核将入口(8369476,abc)添加到目录文件。文件名和 inode 之间的对应关系将文件名和文件的内容及属性连接起来。
硬链接
之前提到:Unix/Linux系统内部不使用文件名,而使用inode号码来识别文件
实际上,还可以让多个文件对应同一个 inode:
Sky_Lee@SkyLeeMBP test % ln abc def
Sky_Lee@SkyLeeMBP test % ls -1i
8369476 abc
8369476 def
可以看到,abc 与 def 的 inode 值是一样的,它们被称为硬链接,此时,查看 abc 的硬链接数:
Sky_Lee@SkyLeeMBP test % stat -x abc
File: "abc"
Size: 1 FileType: Regular File
Mode: (0644/-rw-r--r--) Uid: ( 501/ Sky_Lee) Gid: ( 20/ staff)
Device: 1,9 Inode: 8369476 Links: 2
Access: Tue Jun 13 15:57:21 2023
Modify: Tue Jun 13 15:57:21 2023
Change: Tue Jun 13 16:50:34 2023
Birth: Tue Jun 13 15:56:50 2023
可以看到,Links 的值为 2
我们对 abc 做出的任何修改都会反应到 def 上,同样的,对 def 做出的任何修改都会反应到 abc 上,这不难理解,因为它们的 inode 值相同,文件的 blocks 肯定也相同,本质上就是同一个文件
当我们删除一个文件时,实际上是让 Links 的值减 1,如果 Links = 0,那么系统将会回收这个 inode,以及对应的 blocks
软链接
软链接类似 windows 上的快捷方式
可以使用 ln -s
来创建软链接:
Sky_Lee@SkyLeeMBP test % ln -s abc softabc
Sky_Lee@SkyLeeMBP test % ls -1i | grep abc
8369476 abc
8370725 softabc
可以看到,abc 与 softabc 的 inode 值不同,说明是不同的文件
软链接和硬链接的区别
-
定义不同
软链接又叫符号链接,这个文件包含了另一个文件的路径名。可以是任意文件或目录,可以链接不同文件系统的文件。
硬链接就是一个文件的一个或多个文件名。把文件名和计算机文件系统使用的 inode 链接起来。因此我们可以用多个文件名与同一个文件进行链接,这些文件名可以在同一目录或不同目录。
-
限制不同
硬链接只能对已存在的文件进行创建,不能交叉文件系统进行硬链接的创建;
软链接可对不存在的文件或目录创建软链接;可交叉文件系统;
-
创建方式不同
硬链接不能对目录进行创建,只可对文件创建;
软链接可对文件或目录创建;
-
影响不同
删除一个硬链接文件并不影响其他有相同 inode 号的文件。
删除软链接并不影响被指向的文件,但若被指向的原文件被删除,则相关软连接被称为死链接(即 dangling link,若被指向路径文件被重新创建,死链接可恢复为正常的软链接)。