在 Linux 操作系统中,文件 I/O(输入/输出)是程序与文件系统交互的基础。理解文件 I/O 的工作原理对于编写高效、可靠的程序至关重要。本文将深入探讨系统文件 I/O 的机制。
一种传递标志位的方法
在 Linux 中,文件的打开操作通常使用标志位来指定文件的访问模式。open()
系统调用用于打开文件,其原型如下:
int open(const char *pathname, int flags, mode_t mode);
pathname
:要打开的文件路径。flags
:打开文件时的标志位,指定文件的访问模式和行为。mode
:文件权限,仅在创建新文件时使用。
常见的标志位包括:
参数必须包括以下三个访问方式之一。
- `O_RDONLY`:只读模式。
- `O_WRONLY`:只写模式。
- `O_RDWR`:读写模式。
其他的访问模式。
- `O_CREAT`:如果文件不存在,则创建文件。
- `O_TRUNC`:如果文件存在,则将其长度截断为零。
- `O_APPEND`:每次写入都追加到文件末尾。
标志位的原理:
原理就是位图。不同的访问模式位图上的标记位置不同,传参是通过或操作( | )即可得到需要访问模式的位图所有标记位置。然后再打开或操作文件时就会按照传入的访问模式进行。
文件权限mode
:
新创建文件的最终权限 = mode & ~umask
例如,以下代码以读写模式打开文件 example.txt
,如果文件不存在则创建:
int fd = open("example.txt", O_RDWR | O_CREAT, 0666);
在此,0666
是文件的权限掩码,表示文件所有者、所属组和其他用户均具有读写权限。
hello.c 写文件
在 C 语言中,使用 open()
打开文件后,可以使用 write()
系统调用向文件写入数据。以下是一个示例:
#include <fcntl.h>
#include <unistd.h>
#include <string.h>int main() {int fd = open("example.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if (fd == -1) {// 错误处理return 1;}const char *text = "Hello, Linux!";ssize_t bytes_written = write(fd, text, strlen(text));if (bytes_written == -1) {// 错误处理close(fd);return 1;}close(fd);return 0;
}
向fd
中写入buf
,一次最多count
个。
在此示例中:
open()
以写入模式打开文件example.txt
,如果文件不存在则创建,权限为0666
。write()
将字符串"Hello, Linux!"
写入文件。close()
关闭文件描述符,释放资源。
每次写入字符串不用留
'\0'
的位置,文件本身可以看做数组,如果中间存在'\0'
,则在读取文件时会造成错误。
当向文件内写入内容时,可以进行文本写入和二进制写入,两者的区别写入是语言层面的概念,系统不会关心类型,只要写入内容就会直接写入。
hello.c 读文件
读取文件的过程与写入类似,使用 read()
系统调用从文件中读取数据。示例如下:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>int main() {int fd = open("example.txt", O_RDONLY);if (fd == -1) {// 错误处理return 1;}char buffer[128];ssize_t bytes_read = read(fd, buffer, sizeof(buffer) - 1);if (bytes_read == -1) {// 错误处理close(fd);return 1;}buffer[bytes_read] = '\0'; // 确保字符串以 null 结尾printf("File content: %s\n", buffer);close(fd);return 0;
}
从fd
中读取,拷贝到buf
中,最多读取count
bytes(sizeof(buf) - 1
( - 1是为了在buf
的末尾存贮'\0'
))。
在此示例中:
open()
以只读模式打开文件example.txt
。read()
从文件中读取数据到缓冲区buffer
。close()
关闭文件描述符。
open 函数返回值
区分两个概念:**系统调用**
和**库函数**
。
- 向
fopen``fclose``fread``fwrite
等都是C标准库中的函数,称之为库函数(libc)。open``close``read``write``lseek
等属于系统提供的接口,称之为系统调用接口。
通过上图可以理解库函数和系统调用之间的关系。可以认为f*
系列的函数是对系统调用的封装,方便二次开发。
open()
函数的返回值是一个文件描述符(fd),用于标识打开的文件。成功时返回非负整数,失败时返回 -1
,并设置 errno
以指示错误类型。常见的错误包括:
EACCES
:权限不足。ENOENT
:文件不存在。EINVAL
:无效的标志位。
例如:
int fd = open("example.txt", O_RDONLY);
if (fd == -1) {perror("Error opening file");return 1;
}
文件描述符 fd
文件描述符(fd)是一个非负整数,用于标识进程打开的文件。标准输入、标准输出和标准错误分别对应文件描述符 0、1 和 2。文件描述符的分配规则如下:
- 默认情况下,标准输入、标准输出和标准错误分别占用 0、1 和 2。
- 通过
open()
打开的文件从 3 开始分配。
所以当我们查看在程序中打开的文件的fd
时发现都是3之后的,就是因为在程序运行前就有自动升层的代码在开头打开了三个标准流文件,已经占据了0,1,2。
0 & 1 & 2
0
:标准输入(stdin),通常对应键盘输入。1
:标准输出(stdout),通常对应屏幕输出。2
:标准错误(stderr),用于输出错误信息。
通过6
可知
通过6
中关于FILE
的讲解,当向open
等函数返回值fd
实际上就是进程内管理文件的数组的下标。所以当传入close
等函数调用时就会通过下标来找寻这个文件,然后进行文件操作。
而对于库函数来说,返回值为FILE
,作为将fd
包装好的结构体,在函数内部使用系统调用的时候会自行进行处理。
FILE
FILE
是什么呢?
在 C 语言标准库中,FILE
是一个用于描述文件的结构体,通常由 stdio.h
提供。它提供了一种便捷的接口,让我们可以操作文件而无需直接涉及底层的文件描述符。
FILE
结构体的内部实现
FILE
结构体并不是操作系统原生的,而是由 C 标准库(如 GNU C 库)定义的,它封装了文件的元数据,并提供了缓冲机制以提高 I/O 操作的效率。虽然不同的系统和编译器可能有不同的实现,以下是 FILE
结构体的一种典型实现:
struct _iobuf {char *_ptr; // 指向缓冲区的指针int _cnt; // 缓冲区的剩余字节数char *_base; // 缓冲区的起始位置int _flag; // 文件状态标志(如是否可读、是否可写)int _file; // 文件描述符int _charbuf; // 读缓存区的状态int _bufsiz; // 缓冲区大小char *_tmpfname; // 临时文件名
};
typedef struct _iobuf FILE;
重要字段解释:
_ptr
:指向当前缓冲区位置的指针,文件数据会存储在这里。_cnt
:缓冲区中剩余的可用空间字节数。_base
:缓冲区的起始位置。_flag
:存储文件的状态标志,如文件是否处于读写模式等。_file
:该文件对应的系统级文件描述符,这是最直接的文件标识。_bufsiz
:缓冲区的大小。_tmpfname
:如果文件是临时的,存储其文件名。
FILE
结构体内部使用缓冲机制,这使得每次文件 I/O 操作时,程序并不直接与磁盘交互,而是将数据存入内存中的缓冲区,等缓冲区满时才将数据批量写入磁盘,从而提高 I/O 性能。
缓冲机制具体本文不做解释,之后文章会讲解。
task_struct
和 file_struct
Linux 中的进程是由 task_struct
结构体来描述的。每个进程的 task_struct
中都包含一个 *file
指向一个file_struct
,这个结构体管理着该进程打开的文件。
task_struct
和文件操作的联系
task_struct
结构体代表一个进程。每个进程有自己的文件描述符表,文件描述符表由一个 file_struct
来表示。file_struct
存储了进程打开的所有文件的描述符、文件指针等信息。
struct task_struct {...struct files_struct *files; // 文件描述符表...
};
files_struct
结构体
files_struct
是与 task_struct
相关联的结构体,存储了该进程的文件描述符表(fd_table[]
)。它提供了一个对文件描述符的索引和文件操作的抽象管理。每个进程的 files_struct
都有一个 fd_table[]
数组,这个数组的索引即为文件描述符(fd)。
struct files_struct {atomic_t count; // 引用计数,表示该文件描述符表被多少个进程共享struct fdtable *fdt; // 文件描述符表(fd_table[])spinlock_t file_lock; // 保护文件描述符表的锁
};
fd_table[]
数组与 file_struct
fd_table[]
是一个数组,可以被看做文件描述符表,每个元素对应一个 file
结构体,表示一个文件。文件描述符(fd)就是 fd_table[]
数组的索引值。例如,文件描述符 0 对应标准输入(stdin),文件描述符 1 对应标准输出(stdout),文件描述符 2 对应标准错误(stderr)。
struct fdtable {unsigned int max_fds; // 最大文件描述符数struct file **fd; // 文件描述符数组,fd[i] 为进程打开的文件
};
fd[i]
表示索引为i
的文件描述符指向的文件。max_fds
表示文件描述符表的最大文件描述符数。- 不同的
fd
可以打开同一个文件,引用计数来维护,形成1 : n。
file
结构体
在 Linux 中,file
结构体表示一个打开的文件。它不仅包含了文件的数据指针和操作,还包含了与文件操作相关的状态信息。file
结构体的关键部分包括:
struct file
{属性mode读写位置读写选项缓冲区操作方法struct file *next; // 指向下一个fd的file结构体
}
f_op
:文件操作结构体,包含了对文件的操作方法(如读取、写入、关闭等)。f_pos
:文件的当前偏移量,表示文件指针的位置。f_mode
:文件的访问模式(如只读、只写、读写)。f_count
:引用计数,表示有多少进程引用了这个文件,所以真正的文件关闭指的是引用计数为0的时候。- 文件属性存储于结构体中,文件的内容存在缓冲区中。
文件操作的实质
从文件描述符到内核实现,文件操作的核心机制依赖于 fd_array[]
和 file_struct
。
文件描述符的使用流程
每当一个进程打开文件时,内核会为文件分配一个文件描述符(fd)。这个文件描述符将作为 fd_array[]
数组的索引,指向一个 file
结构体。具体的流程如下:
- 文件打开:进程通过
open()
系统调用请求打开一个磁盘中的文件文件。内核会分配一个新的文件描述符(fd
),并在fd_table[]
中为该进程创建一个指向该文件的file
结构体,属性存于结构体,内容存于结构体指向的缓冲区中。
冯诺依曼体系中,CPU不直接与硬件交互,所以需要通过内存来交互,缓冲区在内存中形成。对文件内容做任何操作,都必须先把文件加载到内核对应的文件缓冲区内,从磁盘到内存的拷贝。
- 文件读写:通过
read()
或write()
系统调用,进程会通过文件描述符访问file
结构体中的数据,并对文件进行操作。read()
本质就是内核到用户空间的拷贝函数。 - 文件关闭:当文件操作完成时,进程通过
close()
系统调用关闭文件。内核会减少文件描述符表中file
结构体的引用计数,若引用计数为 0,则释放该文件描述符的资源。
通过文件描述符与 file
结构体的映射
文件描述符实际上是一个索引,它将用户空间的文件 I/O 操作映射到内核空间的 file
结构体。进程每次对文件进行读写操作时,都会通过文件描述符查找对应的 file
结构体,然后通过 file
中的操作指针(f_op
)调用具体的文件操作函数,如 read()
, write()
或 flush()
。
文件操作的效率
- 缓冲机制:Linux 内核使用缓冲区来提升文件 I/O 的效率。文件数据首先被写入内核缓冲区,只有缓冲区满了或程序显式调用
flush
操作时,数据才会写入磁盘。这样可以减少磁盘 I/O 的频率。 - 文件操作锁:内核使用锁来同步文件操作,确保多个进程对同一文件的访问不会引发冲突。
结论
通过深入分析 FILE
结构体、task_struct
中的 file_struct
以及 fd_array[]
数组的关系,我们能够更清晰地理解 Linux 系统中文件操作的底层机制。文件描述符作为用户空间与内核空间的桥梁,file
结构体封装了对文件的访问接口,而内核通过文件描述符表、缓冲区机制和文件操作锁等技术,保证了高效且可靠的文件 I/O 操作。
编程语言的可移植性
编程语言的可移植性指的是程序能否在不同的平台或操作系统上顺利运行。语言的设计、标准库的实现以及对底层硬件的抽象都直接影响着程序的可移植性。
C 语言的可移植性
C 语言作为一种接近硬件的低级编程语言,直接与操作系统的底层交互。由于各个操作系统有不同的系统调用,C 语言的标准库为不同平台提供了相对一致的接口,使得 C 语言具备一定的可移植性。
不过,C 语言标准库的实现也可能因操作系统而异。比如,Windows 和 Linux 都有 C 语言的实现,但它们的文件 I/O 操作部分会有所不同,Windows 可能使用 CreateFile()
,而 Linux 使用 open()
。为了增强 C 语言的可移植性,开发者常常通过条件编译来区分不同操作系统下的实现。
例如,在 Windows 和 Linux 上都需要实现文件操作的代码:
#ifdef _WIN32
#include <windows.h>
HANDLE hFile = CreateFile("log.txt", GENERIC_WRITE, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
#else
#include <fcntl.h>
#include <unistd.h>
int fd = open("log.txt", O_CREAT | O_WRONLY | O_APPEND, 0666);
#endif
通过使用预处理指令 #ifdef
和 #endif
,程序可以根据不同操作系统选择不同的文件打开方式,从而增加跨平台的可移植性。
语言的可移植性?
除了 C 语言,其他高级编程语言(如 C++、Java、Python、Go、PHP)也通过各自的标准库和虚拟机来增强跨平台的可移植性。
- C++:C++ 通过标准库(如 STL)提供了一套跨平台的接口,使得程序能在不同操作系统上编译和运行。然而,当涉及到直接与操作系统底层交互时,C++ 仍然需要依赖平台特定的系统调用和 API。
- Java:Java 提供了 Java 虚拟机(JVM),使得 Java 程序可以在不同的操作系统上运行。JVM 会屏蔽底层系统的差异,使得 Java 代码具有良好的可移植性。Java 的字节码可以在任何实现了 JVM 的操作系统上运行。
- Python:Python 通过封装了平台特定的调用接口,提供了跨平台的标准库,如
os
、sys
等。Python 程序员通常不需要关心底层操作系统的细节,Python 会处理这些差异。 - Go:Go 语言内置对多平台的支持,编译器可以直接生成不同操作系统和架构的二进制文件,从而确保 Go 程序具有较高的可移植性。
- PHP:PHP 是一种主要用于 Web 开发的语言,它通过 Web 服务器(如 Apache、Nginx)和平台无关的接口(如数据库驱动)使得 PHP 程序具有一定的可移植性。
所以语言的移植性可以总结为:语言在底层库中的使用系统调用的函数针对不同的系统会将系统调用部分更改,更换为不同操作系统的系统调用(条件编译来解决)。
如此在上层使用语言的时候不会感受到差异,因为只是使用语言的语法,底层库的差异在语言层面进行屏蔽,增加了语言的可移植性。
语言增加可移植性让更多人愿意去使用,增加市场占有率。
不可移植性的原因?
- 操作系统依赖:
不同的操作系统有不同的API和系统调用。例如,Linux和windows的文件操作、内存管理、线程处理等API不同。如果现在有一个程序,在编写的时候直接调用了某个操作系统特有的API,它在其他操作系统上就无法工作。必须将调用特有API更换为要在上面执行的操作系统的API才可以正常运行。
- 硬件依赖:
不同平台使用的编译器可能会有不同的行为,或者某些编辑器不支持某些特性。例如,C++中某些编译器特性只在特定的编译器中有效,导致代码在其他平台或编辑器中无法运行。
重定向
文件描述符的分配规则
当进程打开文件时,操作系统会分配一个最小的未使用文件描述符。例如:
int fd = open("example.txt", O_RDONLY);
如果文件描述符 3 未被占用,则 fd
将被赋值为 3。
重定向
重定向的核心原理在于操作文件描述符。文件描述符在file_struct
中的数组中存放管理,通过改变文件描述符的指向,我们可以将输入或输出流重定向到文件、设备或其他流。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h> // 包含close函数的声明int main() {// 关闭标准输出文件描述符1close(1);// 打开(或创建)一个名为"myfile"的文件,以只写方式打开// 如果文件不存在则创建,权限设置为644int fd = open("myfile", O_WRONLY | O_CREAT, 00644);if (fd < 0) {// 如果打开文件失败,输出错误信息并返回1perror("open");return 1;}// 输出文件描述符printf("fd: %d\n", fd);// 刷新标准输出缓冲区,确保输出立即显示fflush(stdout);// 关闭文件描述符close(fd);// 程序正常退出exit(0);
}
已知文件描述符的分配规则和重定向的原理,那么通过以上代码理解。先关闭fd = 1
的文件,也就是标准输出流文件。此时再打开文件时就会按照文件描述符的分配规则,将新打开的文件描述符设置为按照顺序最小的下标,也就是刚关闭fd = 1
。然后当使用printf
进行打印的时候,该函数默认的拷贝到的文件fd
为1
,本来是向显示屏进行打印,实际上因为新文件的占用,将内容拷贝进行新文件中。
这就是重定向,数组的下标不变,更改文件描述符的指针指向。
使用 dup2()
系统调用
在 Linux 中,dup2()
系统调用用于复制一个文件描述符,并将其指向另一个指定的文件描述符。这对于实现输入输出的重定向非常有用。
函数原型:
int dup2(int oldfd, int newfd);
oldfd
:现有的文件描述符。newfd
:目标文件描述符。
功能:
- 将
oldfd
指向的文件复制到newfd
。 - 如果
newfd
已经打开,则先关闭它。 - 返回新的文件描述符
newfd
,如果出错则返回-1
。
示例代码:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>int main() {// 打开文件,获取文件描述符int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);if (fd == -1) {perror("打开文件失败");return 1;}// 将标准输出重定向到文件if (dup2(fd, STDOUT_FILENO) == -1) {perror("重定向标准输出失败");close(fd);return 1;}// 关闭原始文件描述符close(fd);// 现在 printf 的输出将写入 output.txtprintf("这行文本将被写入到 output.txt 文件中。\n");return 0;
}
在上述示例中:
- 我们首先使用
open()
打开output.txt
文件,并获取文件描述符fd
。 - 然后,使用
dup2()
将标准输出(STDOUT_FILENO
)重定向到output.txt
文件。 - 关闭原始的文件描述符
fd
。 - 之后,所有通过
printf()
输出的内容都会写入output.txt
文件,而不是显示器。
在 minishell 中添加重定向功能
#include <iostream>
#include <ctype.h>
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <cstring>
#include <unordered_map>
#include <sys/stat.h>
#include <fcntl.h>#define COMMAND_SIZE 1024 // 命令行最大长度
#define FORMAT "[%s@%s %s]# " // 提示符格式// ================== 全局数据结构声明 ==================
// 1. 命令行参数表
#define MAXARGC 128
char *g_argv[MAXARGC]; // 存储解析后的命令行参数
int g_argc = 0; // 参数个数// 2. 环境变量表
#define MAX_ENVS 100
char *g_env[MAX_ENVS]; // 存储环境变量
int g_envs = 0; // 环境变量数量// 3. 别名映射表(当前代码未完整实现)
std::unordered_map<std::string, std::string> alias_list;// 4. 重定向相关配置
#define NONE_REDIR 0 // 无重定向
#define INPUT_REDIR 1 // 输入重定向 <
#define OUTPUT_REDIR 2 // 输出重定向 >
#define APPEND_REDIR 3 // 追加重定向 >>int redir = NONE_REDIR; // 记录当前重定向类型
std::string filename; // 重定向文件名// ================== 辅助函数声明 ==================
// [省略部分环境获取函数...]// ================== 环境初始化 ==================
void InitEnv() {extern char **environ;// 从父进程复制环境变量到g_env数组for(int i = 0; environ[i]; i++) {g_env[i] = strdup(environ[i]); // 使用strdup复制字符串g_envs++;}// 设置新环境变量(示例)g_env[g_envs++] = strdup("HAHA=for_test");g_env[g_envs] = NULL;// 更新进程环境变量for(int i = 0; g_env[i]; i++) {putenv(g_env[i]);}environ = g_env; // 替换全局environ指针
}// ================== 重定向处理核心函数 ==================
void TrimSpace(char cmd[], int &end) {// 跳过连续空白字符while(isspace(cmd[end])) end++;
}void RedirCheck(char cmd[]) {// 开始前先将文件操作的信息初始化redir = NONE_REDIR;filename.clear();int start = 0;int end = strlen(cmd)-1;// 从命令末尾向前扫描寻找重定向符号while(end > start) {if(cmd[end] == '<') { // 输入重定向cmd[end] = '\0'; // 截断命令字符串end++;TrimSpace(cmd, end); // 跳过空格redir = INPUT_REDIR;filename = cmd + end;break;}else if(cmd[end] == '>') {// 判断是>>还是>if(end > 0 && cmd[end-1] == '>') { // 追加重定向cmd[end-1] = '\0'; // 截断命令字符串end++; // 移动到>后的位置redir = APPEND_REDIR;} else { // 普通输出重定向cmd[end] = '\0';end++;redir = OUTPUT_REDIR;}// 这时end在最后的运算符后面,然后用TrimSpace向后查找文件开头字母TrimSpace(cmd, end);filename = cmd + end; // end为文件名开头字母位置,直接cmd定位到文件名部分break;}else {end--; // 继续向前扫描}}
}// ================== 命令执行 ==================
int Execute() {pid_t id = fork();if(id == 0) { // 子进程int fd = -1;switch(redir) {case INPUT_REDIR:fd = open(filename.c_str(), O_RDONLY);dup2(fd, STDIN_FILENO); // 重定向标准输入break;case OUTPUT_REDIR:fd = open(filename.c_str(), O_CREAT|O_WRONLY|O_TRUNC, 0666);dup2(fd, STDOUT_FILENO); // 重定向标准输出break;case APPEND_REDIR:fd = open(filename.c_str(), O_CREAT|O_WRONLY|O_APPEND, 0666);dup2(fd, STDOUT_FILENO);break;default: // 无重定向不做处理break;}if(fd != -1) close(fd); // 关闭不再需要的文件描述符execvp(g_argv[0], g_argv); // 执行程序exit(EXIT_FAILURE); // exec失败时退出}// 父进程等待子进程int status = 0;waitpid(id, &status, 0);lastcode = WEXITSTATUS(status); // 记录退出状态return 0;
}// ================== 主循环 ==================
int main() {InitEnv(); // 初始化环境变量while(true) {PrintCommandPrompt(); // 打印提示符char commandline[COMMAND_SIZE];if(!GetCommandLine(commandline, sizeof(commandline))) continue;RedirCheck(commandline); // 重定向解析if(!CommandParse(commandline)) continue; // 命令解析if(CheckAndExecBuiltin()) continue; // 内建命令Execute(); // 执行外部命令}return 0;
}
总结
通过深入探讨文件描述符(fd)的使用,以及如何在 C 语言中实现文件的重定向功能,我们可以更好地理解 Linux 系统文件 I/O 的工作原理。掌握这些概念和技术,对于编写高效、可靠的系统级程序具有重要意义。