目录
系统文件I/O
操作文件除了C语言接口、C++接口或是其他语言的接口外,操作系统也有一套系统接口来进行文件的访问。
相比于C库函数或其他语言的库函数而言,系统调用接口更贴近底层,实际上这些语言的库函数都是对系统接口进行了封装。
我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发。
open
在Linux操作系统中,open
是一个系统调用,用于打开文件。它是一个底层接口,提供了对文件系统的访问功能,通常在C语言中使用。open
系统调用可以以多种模式打开文件,如只读、只写、读写等,还可以指定一些额外的标志来控制文件的行为。
open
函数的原型
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
int open(const char *pathname, int flags, ...);
- pathname:要打开的文件的路径。
- flags:打开文件的模式和选项(可以组合多个标志)。
- mode:文件的权限(在创建文件时使用,通常是八进制数),这是一个可选参数,仅在使用
O_CREAT
标志时需要指定。
常用的标志
O_RDONLY
:以只读模式打开文件。O_WRONLY
:以只写模式打开文件。O_RDWR
:以读写模式打开文件。O_CREAT
:如果文件不存在,则创建文件。需要提供第三个参数 mode 来指定文件权限。O_EXCL
:与O_CREAT
一起使用,如果文件已存在,则返回错误。O_TRUNC
:如果文件存在且可写,则将其长度截断为零。O_APPEND
:以追加模式打开文件。写操作将数据附加到文件的末尾。O_NONBLOCK
:以非阻塞模式打开文件。O_SYNC
:以同步模式打开文件。
返回值
- 成功时返回文件描述符,这是一个非负整数。
- 失败时返回 -1,并设置
errno
以指示错误类型。
示例代码
以下是一些示例代码,展示了如何使用 open
函数:
只读模式打开文件
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd;
fd = open("example.txt", O_RDONLY);
if (fd == -1) {
perror("无法打开文件");
return 1;
}
// 文件操作代码...
close(fd);
return 0;
}
以读写模式打开文件,不存在则创建
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd;
fd = open("example.txt", O_RDWR | O_CREAT, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("无法打开文件");
return 1;
}
// 文件操作代码...
close(fd);
return 0;
}
以追加模式打开文件
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd;
fd = open("example.txt", O_WRONLY | O_APPEND);
if (fd == -1) {
perror("无法打开文件");
return 1;
}
// 向文件追加数据
const char *data = "这是一个追加的数据行。\n";
write(fd, data, strlen(data));
close(fd);
return 0;
}
错误处理
open
调用失败时会返回 -1,并设置全局变量 errno
来指示错误类型。常见错误包括:
EACCES
:权限不足,无法访问文件。EEXIST
:使用O_CREAT | O_EXCL
标志时文件已存在。ENAMETOOLONG
:文件名过长。ENOENT
:文件不存在,并且未指定O_CREAT
标志。ENOTDIR
:路径中的某个组件不是目录。
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <errno.h>
int main() {
int fd;
fd = open("example.txt", O_RDONLY);
if (fd == -1) {
perror("无法打开文件");
printf("错误代码: %d\n", errno);
return 1;
}
// 文件操作代码...
close(fd);
return 0;
}
总结
open
系统调用是文件操作的基础,它提供了多种模式和选项来满足不同的需求。在实际应用中,理解和正确使用 open
是编写健壮和高效文件操作代码的关键。如果需要更高层次的文件操作,可以使用 C 标准库中的 fopen
和 fclose
函数,它们提供了更易用的接口,但底层仍然依赖于 open
系统调用。
close
系统接口中使用close函数关闭文件,close函数的函数原型如下:
int close(int fd);
使用close函数时传入需要关闭文件的文件描述符即可,若关闭文件成功则返回0,若关闭文件失败则返回-1。
打开、读取、写入并关闭文件示例:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int fd;
char buffer[128];
ssize_t bytes_read, bytes_written;
const char *data = "Hello, World!\n";
// 打开文件以读写模式,如果文件不存在则创建
fd = open("example.txt", O_RDWR | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("无法打开文件");
return 1;
}
// 将数据写入文件
bytes_written = write(fd, data, strlen(data));
if (bytes_written == -1) {
perror("写入文件错误");
close(fd); // 关闭文件以释放资源
return 1;
}
// 将文件偏移量设置为文件开头
if (lseek(fd, 0, SEEK_SET) == -1) {
perror("定位文件开头错误");
close(fd);
return 1;
}
// 从文件中读取数据
bytes_read = read(fd, buffer, sizeof(buffer) - 1);
if (bytes_read == -1) {
perror("读取文件错误");
close(fd);
return 1;
}
// 确保缓冲区以null结尾
buffer[bytes_read] = '\0';
printf("读取到的内容:\n%s\n", buffer);
// 关闭文件
if (close(fd) == -1) {
perror("关闭文件错误");
return 1;
}
return 0;
}
write
系统接口中使用write函数向文件写入信息,write函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
我们可以使用write函数,将buf位置开始向后count字节的数据写入文件描述符为fd的文件当中。
- 如果数据写入成功,实际写入数据的字节个数被返回。
- 如果数据写入失败,-1被返回。
对文件进行写入操作示例:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int fd;
const char *data = "Hello, World!\n";
// 打开文件以写入模式,如果文件不存在则创建
fd = open("example.txt", O_WRONLY | O_CREAT | O_TRUNC, S_IRUSR | S_IWUSR);
if (fd == -1) {
perror("无法打开文件");
return 1;
}
// 将数据写入文件
ssize_t bytes_written = write(fd, data, strlen(data));
if (bytes_written == -1) {
perror("写入文件错误");
close(fd);
return 1;
}
printf("写入了 %zd 字节到文件\n", bytes_written);
// 关闭文件
close(fd);
return 0;
}
read
系统接口中使用read函数从文件读取信息,read函数的函数原型如下 :
ssize_t read(int fd, void *buf, size_t count);
我们可以使用read函数,从文件描述符为fd的文件读取count字节的数据到buf位置当中。
- 如果数据读取成功,实际读取数据的字节个数被返回。
- 如果数据读取失败,-1被返回。
对文件进行读取操作示例:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
int fd;
char buffer[128];
ssize_t bytes_read;
// 打开文件以只读模式
fd = open("example.txt", O_RDONLY);
if (fd == -1) {
perror("无法打开文件");
return 1;
}
// 从文件中读取数据
bytes_read = read(fd, buffer, sizeof(buffer) - 1);
if (bytes_read == -1) {
perror("读取文件错误");
close(fd);
return 1;
}
// 确保缓冲区以null结尾
buffer[bytes_read] = '\0';
// 打印读取的数据
printf("读取到的内容:\n%s\n", buffer);
// 关闭文件
close(fd);
return 0;
}
文件描述符fd
文件是由进程运行时打开的,一个进程可以打开多个文件,而系统当中又存在大量进程,也就是说,在系统中任何时刻都可能存在大量已经打开的文件。
因此,操作系统务必要对这些已经打开的文件进行管理,操作系统会为每个已经打开的文件创建各自的struct file结构体,然后将这些结构体以双链表的形式连接起来,之后操作系统对文件的管理也就变成了对这张双链表的增删查改等操作。
而为了区分已经打开的文件哪些属于特定的某一个进程,我们就还需要建立进程和文件之间的对应关系。
进程和文件之间的对应关系是如何建立的?
我们知道,当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct、mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系。
而task_struct当中有一个指针,该指针指向一个名为files_struct的结构体,在该结构体当中就有一个名为fd_array的指针数组,该数组的下标就是我们所谓的文件描述符。
当进程打开log.txt文件时,我们需要先将该文件从磁盘当中加载到内存,形成对应的struct file,将该struct file连入文件双链表,并将该结构体的首地址填入到fd_array数组当中下标为3的位置,使得fd_array数组中下标为3的指针指向该struct file,最后返回该文件的文件描述符给调用进程即可。
因此,我们只要有某一文件的文件描述符,就可以找到与该文件相关的文件信息,进而对文件进行一系列输入输出操作。
注意: 向文件写入数据时,是先将数据写入到对应文件的缓冲区当中,然后定期将缓冲区数据刷新到磁盘当中。
什么叫做进程创建的时候会默认打开0、1、2?
0就是标准输入流,对应键盘;1就是标准输出流,对应显示器;2就是标准错误流,也是对应显示器。
而键盘和显示器都属于硬件,属于硬件就意味着操作系统能够识别到,当某一进程创建时,操作系统就会根据键盘、显示器、显示器形成各自的struct file,将这3个struct file连入文件双链表当中,并将这3个struct file的地址分别填入fd_array数组下标为0、1、2的位置,至此就默认打开了标准输入流、标准输出流和标准错误流。
磁盘文件 VS 内存文件?
当文件存储在磁盘当中时,我们将其称之为磁盘文件,而当磁盘文件被加载到内存当中后,我们将加载到内存当中的文件称之为内存文件。磁盘文件和内存文件之间的关系就像程序和进程的关系一样,当程序运行起来后便成了进程,而当磁盘文件加载到内存后便成了内存文件。
磁盘文件由两部分构成,分别是文件内容和文件属性。文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息。
文件加载到内存时,一般先加载文件的属性信息,当需要对文件内容进行读取、输入或输出等操作时,再延后式的加载文件数据。
文件描述符的分配规则
-
标准文件描述符: 在Linux上,标准输入、标准输出和标准错误的文件描述符分别为0、1和2,与UNIX系统相同。
-
文件描述符限制: 在Linux中,文件描述符的数量受到进程的资源限制(RLIMIT_NOFILE)的限制。你可以使用ulimit命令来查看和设置文件描述符的限制。
-
文件描述符的分配: 当程序启动时,操作系统会为其分配三个标准文件描述符,并为打开的文件或其他I/O资源分配额外的文件描述符。这些描述符的分配规则与UNIX系统相同,通常从3开始依次递增。
-
/proc文件系统: 在Linux系统上,你可以通过/proc/[PID]/fd目录来查看一个进程的文件描述符。每个文件描述符都表示为一个符号链接,指向相应的文件或I/O资源。
-
文件描述符的复制和重定向: 在Linux中,可以使用dup、dup2和fcntl等系统调用来复制、重定向文件描述符。这些系统调用可以用于将一个文件描述符复制到另一个文件描述符,或者将文件描述符重定向到不同的文件或I/O资源上。
重定向
重定向的原理
在明确了文件描述符的概念及其分配规则后,现在我们已经具备理解重定向原理的能力了。看完下面三个例子后,你会发现重定向的本质就是修改文件描述符下标对应的struct file*的内容。
输出重定向的原理
例如,如果我们想让本应该从“键盘文件”读取数据的scanf函数,改为从log.txt文件当中读取数据,那么我们可以在打开log.txt文件之前将文件描述符为0的文件关闭,也就是将“键盘文件”关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是0。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
close(0);
int fd = open("log.txt", O_RDONLY | O_CREAT, 0666);
if (fd < 0){
perror("open");
return 1;
}
char str[40];
while (scanf("%s", str) != EOF){
printf("%s\n", str);
}
close(fd);
return 0;
}
dup2
要完成重定向我们只需进行fd_array数组当中元素的拷贝即可。例如,我们若是将fd_array[3]当中的内容拷贝到fd_array[1]当中,因为C语言当中的stdout就是向文件描述符为1文件输出数据,那么此时我们就将输出重定向到了文件log.txt。
在Linux操作系统中提供了系统接口dup2,我们可以使用该函数完成重定向。dup2的函数原型如下:
int dup2(int oldfd, int newfd);
函数功能: dup2会将fd_array[oldfd]的内容拷贝到fd_array[newfd]当中,如果有必要的话我们需要先使用关闭文件描述符为newfd的文件。
函数返回值: dup2如果调用成功,返回newfd,否则返回-1。
使用dup2时,我们需要注意以下两点:
如果oldfd不是有效的文件描述符,则dup2调用失败,并且此时文件描述符为newfd的文件没有被关闭。
如果oldfd是一个有效的文件描述符,但是newfd和oldfd具有相同的值,则dup2不做任何操作,并返回newfd。
例如,我们将打开文件log.txt时获取到的文件描述符和1传入dup2函数,那么dup2将会把fd_arrya[fd]的内容拷贝到fd_array[1]中,在代码中我们向stdout输出数据,而stdout是向文件描述符为1的文件输出数据,因此,本应该输出到显示器的数据就会重定向输出到log.txt文件当中。
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0){
perror("open");
return 1;
}
close(1);
dup2(fd, 1);
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
return 0;
}
代码运行后,我们即可发现数据被输出到了log.txt文件当中。
添加重定向功能到minishell
说明: 该minishell是在进程控制当中实现的命令行解释器myshell的基础上实现的。
在myshell当中添加重定向功能的步骤大致如下:
1.对于获取到的命令进行判断,若命令当中包含重定向符号>、>>或是<,则该命令需要进行处理。
2.设置type变量,type为0表示命令当中包含输出重定向,type为1表示命令当中包含追加重定向,type为2表示命令当中包含输入重定向。
3.重定向符号后面的字段标识为目标文件名,若type值为0,则以写的方式打开目标文件;若type值为1,则以追加的方式打开目标文件;若type值为2,则以读的方式打开目标文件。
4.若type值为0或者1,则使用dup2接口实现目标文件与标准输出流的重定向;若type值为2,则使用dup2接口实现目标文件与标准输入流的重定向。
代码实现如下:
#include <stdio.h>
#include <fcntl.h>
#include <ctype.h>
#include <pwd.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#define LEN 1024 //命令最大长度
#define NUM 32 //命令拆分后的最大个数
int main()
{
int type = 0; //0 >, 1 >>, 2 <
char cmd[LEN]; //存储命令
char* myargv[NUM]; //存储命令拆分后的结果
char hostname[32]; //主机名
char pwd[128]; //当前目录
while (1){
//获取命令提示信息
struct passwd* pass = getpwuid(getuid());
gethostname(hostname, sizeof(hostname)-1);
getcwd(pwd, sizeof(pwd)-1);
int len = strlen(pwd);
char* p = pwd + len - 1;
while (*p != '/'){
p--;
}
p++;
//打印命令提示信息
printf("[%s@%s %s]$ ", pass->pw_name, hostname, p);
//读取命令
fgets(cmd, LEN, stdin);
cmd[strlen(cmd) - 1] = '\0';
//实现重定向功能
char* start = cmd;
while (*start != '\0'){
if (*start == '>'){
type = 0; //遇到一个'>',输出重定向
*start = '\0';
start++;
if (*start == '>'){
type = 1; //遇到第二个'>',追加重定向
start++;
}
break;
}
if (*start == '<'){
type = 2; //遇到'<',输入重定向
*start = '\0';
start++;
break;
}
start++;
}
if (*start != '\0'){ //start位置不为'\0',说明命令包含重定向内容
while (isspace(*start)) //跳过重定向符号后面的空格
start++;
}
else{
start = NULL; //start设置为NULL,标识命令当中不含重定向内容
}
//拆分命令
myargv[0] = strtok(cmd, " ");
int i = 1;
while (myargv[i] = strtok(NULL, " ")){
i++;
}
pid_t id = fork(); //创建子进程执行命令
if (id == 0){
//child
if (start != NULL){
if (type == 0){ //输出重定向
int fd = open(start, O_WRONLY | O_CREAT | O_TRUNC, 0664); //以写的方式打开文件(清空原文件内容)
if (fd < 0){
error("open");
exit(2);
}
close(1);
dup2(fd, 1); //重定向
}
else if (type == 1){ //追加重定向
int fd = open(start, O_WRONLY | O_APPEND | O_CREAT, 0664); //以追加的方式打开文件
if (fd < 0){
perror("open");
exit(2);
}
close(1);
dup2(fd, 1); //重定向
}
else{ //输入重定向
int fd = open(start, O_RDONLY); //以读的方式打开文件
if (fd < 0){
perror("open");
exit(2);
}
close(0);
dup2(fd, 0); //重定向
}
}
execvp(myargv[0], myargv); //child进行程序替换
exit(1); //替换失败的退出码设置为1
}
//shell
int status = 0;
pid_t ret = waitpid(id, &status, 0); //shell等待child退出
if (ret > 0){
printf("exit code:%d\n", WEXITSTATUS(status)); //打印child的退出码
}
}
return 0;
}
FILE
FILE当中的文件描述符
因为库函数是对系统调用接口的封装,本质上访问文件都是通过文件描述符fd进行访问的,所以C库当中的FILE结构体内部必定封装了文件描述符fd。
首先,我们在/usr/include/stdio.h头文件中可以看到下面这句代码,也就是说FILE实际上就是struct _IO_FILE结构体的一个别名。
typedef struct _IO_FILE FILE;
而我们在/usr/include/libio.h
头文件中可以找到struct _IO_FILE
结构体的定义,在该结构体的众多成员当中,我们可以看到一个名为_fileno
的成员,这个成员实际上就是封装的文件描述符。
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno; //封装的文件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */
#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
/* char* _save_gptr; char* _save_egptr; */
_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};
现在我们再来理解一下C语言当中的fopen函数究竟在做什么?
fopen函数在上层为用户申请FILE结构体变量,并返回该结构体的地址(FILE*),在底层通过系统接口open打开对应的文件,得到文件描述符fd,并把fd填充到FILE结构体当中的_fileno变量中,至此便完成了文件的打开操作。
而C语言当中的其他文件操作函数,比如fread、fwrite、fputs、fgets等,都是先根据我们传入的文件指针找到对应的FILE结构体,然后在FILE结构体当中找到文件描述符,最后通过文件描述符对文件进行的一系列操作。
FILE当中的缓冲区
首先我们应该知道的是,缓冲的方式有以下三种:
1.无缓冲。
2.行缓冲。(常见的对显示器进行刷新数据)
3.全缓冲。(常见的对磁盘文件写入数据)
当我们直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有\n,所以当我们执行完对应代码后就立即将数据刷新到了显示器上。
而当我们将运行结果重定向到log.txt文件时,数据的刷新策略就变为了全缓冲,此时我们使用printf和fputs函数打印的数据都打印到了C语言自带的缓冲区当中,之后当我们使用fork函数创建子进程时,由于进程间具有独立性,而之后当父进程或是子进程对要刷新缓冲区内容时,本质就是对父子进程共享的数据进行了修改,此时就需要对数据进行写时拷贝,至此缓冲区当中的数据就变成了两份,一份父进程的,一份子进程的,所以重定向到log.txt文件当中printf和puts函数打印的数据就有两份。但由于write函数是系统接口,我们可以将write函数看作是没有缓冲区的,因此write函数打印的数据就只打印了一份。
这个缓冲区是谁提供的?
实际上这个缓冲区是C语言自带的,如果说这个缓冲区是操作系统提供的,那么printf、fputs和write函数打印的数据重定向到文件后都应该打印两次。
这个缓冲区在哪里?
我们常说printf是将数据打印到stdout里面,而stdout就是一个FILE*
的指针,在FILE结构体当中还有一大部分成员是用于记录缓冲区相关的信息的。
//缓冲区相关
/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
也就是说,这里的缓冲区是由C语言提供,在FILE结构体当中进行维护的,FILE结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息。
操作系统有缓冲区吗?
操作系统实际上也是有缓冲区的,当我们刷新用户缓冲区的数据时,并不是直接将用户缓冲区的数据刷新到磁盘或是显示器上,而是先将数据刷新到操作系统缓冲区,然后再由操作系统将数据刷新到磁盘或是显示器上。(操作系统有自己的刷新机制,我们不必关系操作系统缓冲区的刷新规则)。
因为操作系统是进行软硬件资源管理的软件,根据下面的层状结构图,用户区的数据要刷新到具体外设必须经过操作系统。