文章目录
- 前言
- 文件是什么
- Linux 文件操作
- 文件描述符
- I/O 重定向
- 缓冲区
- 文件操作简易模拟实现
- shell 实现的补充(增加重定向功能)
前言
所有语言的运行时系统都提供执行 I/O 的较高级别的工具。例如,ANSI C 提供标准 I/O 库,包含像
printf
和scanf
这样执行带缓冲区的 I/O 函数。C++ 语言用它的重载操作符 <<(输入)和 >>(输出)提供了类似的功能。在 Linux 系统中,是通过使用由内核提供的系统级 Unix I/O 函数来实现这些较高级别的 I/O 函数的。大多数时候,高级别 I/O 函数工作来良好,没有必要直接使用 Unix I/O。那么为什么还要麻烦地学习 Unix I/O 呢?
- 了解 Unix I/O 将帮助你理解其他的系统概念。I/O 是系统操作不可或缺的一部分,因此,我们经常遇到 I/O 和其他系统概念之间的循环依赖。例如,I/O 在进程的创建和执行中扮演着关键的角色。反过来,进程创建又在不同进程间的文件共享中扮演着关键角色。因此,要真正理解 I/O,你必须理解进程,反之亦然。
- 有时你除了使用 Unix I/O 以外别无选择。在某些重要的情况中,使用高级 I/O 函数不太可能,或者不太合适。例如,标准 I/O 库没有提供读取文件元数据的方式,例如文件大小或文件创建时间。另外,I/O 库还存在一些问题,使得用它来进行网络编程非常冒险。
本文将介绍 Unix I/O 和标准 I/O 的一般概念,并且向你展示在 C 程序中如何可靠地使用它们。这部分的学习将为我们随后学习网络编程和并发性奠定坚实的基础。
文件是什么
“Linux 下一切皆文件”
一个 Linux 文件就是一个 m 个字节的序列。
所有的 I/O 设备(例如网络、磁盘和终端)都被模型化为文件,而所有的输入和输出都被当作对相应文件的读和写来执行。这种将设备优雅地映射为文件的方式,允许 Linux 内核引出一个简单、低级的应用接口,称为 Unix I/O,这使得所有的输入和输出都能以一种统一且一致的方式来执行
Linux 文件操作
打开文件:open
NAME
open, creat - open and possibly create a file or device
SYNOPSIS
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
int creat(const char *pathname, mode_t mode);
参数 flags
必须包括以下访问模式之一:
-
O_RDONLY
:只读 -
O_WRONLY
:只写 -
O_RDWR
:读写
flags
参数也可以是一个或者更多位掩码的或,为写提供给一些额外的指示:
-
O_CREAT
:如果文件不存在,就创建它的一个截断的(truncate)(空)文件。 -
O_TRUNC
:如果文件已经存在,就截断它。 -
O_APPEND
:在每次写操作前,设置文件位置到文件的结尾处。
mode 参数指定了新文件的访问权限位。这些位的符号名字如下表:
掩码 | 描述 |
S_IRUSR S_IWUSR S_IXUSR | 使用者(拥有者)能够读这个文件 使用者(拥有者)能够写这个文件 使用者(拥有者)能够执行这个文件 |
S_IRGRP S_IWGRP S_IXGRP | 拥有者所在组的成员能够读这个文件 拥有者所在组的成员能够写这个文件 拥有者所在组的成员能够执行这个文件 |
S_IROTH S_IWOTH S_IXOTH | 其他人(任何人)能够读这个文件 其他人(任何人)能够写这个文件 其他人(任何人)能够执行这个文件 |
作为上下文的一部分,每个进程都有一个 umask
,它是通过调用 umask
函数来设置的。当进程通过带某个 mode
参数的 open
函数调用来创建一个新文件时,文件的访问权限被设置为 mode & ~ umask
关于文件权限,也可以使用八进制表示,文件权限复习
例子:
以只写方式打开log.txt,如果没有这个文件,就创建一个,以此创建的文件权限为666:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int main()
{
umask(0); // 如果省略此句,则默认使用系统掩码
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
fd: 3
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ll
total 28
-rw-rw-rw- 1 CegghnnoR CegghnnoR 55 Oct 19 13:23 log.txt
最后,进程会通过调用 close
函数关闭一个打开的文件
#include <unistd.h>
int close(int fd);
// 返回:若成功则为 0,若出错则为 -1
关闭一个已关闭的描述符会出错。
读写文件:resd、write
NAME
read - read from a file descriptor
SYNOPSIS
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
// 返回:若成功则为读的字节数,若 EOF 则为 0,若出错则为 -1
NAME
write - write to a file descriptor
SYNOPSIS
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
// 返回:若成功则为写的字节数,若出错则为 -1
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
umask(0);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
int cnt = 0;
const char* str = "hello file\n";
while (cnt < 5)
{
write(fd, str, strlen(str));
++cnt;
}
close(fd);
return 0;
}
C语言以只写方式打开文件会自动清空文件原有内容,而系统调用不会,默认会将新的内容直接覆盖式地写在原数据上,如果使用系统调用也想在打开文件时清空原有数据,那么可以加一个标志 O_TRUNC
。另外,添加标志O_APPEND
表示追加,即将文件位置定义到最后,新的内容写在文件末尾。
ssize_t
和size_t
的区别你可能已经注意到了,
read
函数有一个size_t
的输入参数和一个ssize_t
的返回值。那么这两种类型之间有什么区别呢?在 x86-64 系统中,size_t
被定义为unsigned long
,而ssize_t
(有符号的大小)被定义为long
,read
函数返回一个有符号的大小,而不是一个无符号大小,这是因为出错时它必须返回 -1,有趣的是,返回一个 -1 的可能性使得 read 的最大值减小了一半。
文件描述符
上述代码我们都打印了 fd,也就是 open
的返回值,结果都是 3。那么 fd 究竟是什么呢?
open 返回值:
RETURN VALUE
open() and creat() return the new file descriptor, or -1 if an error occurred (in which case, errno is set appropriately).
从 man 手册中的描述可以发现,如果 fd < 0,说明打开失败,fd >= 0 则打开成功。
open
函数返回的一个小的非负整数,叫做描述符,它在后续对此文件的所有操作中标识这个文件。内核记录有关这个打开文件的所有信息。应用程序只需记住这个描述符。
- 为什么从 3 开始,0, 1, 2 在哪?
Linux shell 创建的每个进程开始时都有三个打开的文件:标准输入(描述符为 0)、标准输出(描述符为 1)、标准错误(描述符为2)
如下,我们可以查看它们的用来记录文件描述符的结构体成员_fileno
int main()
{
printf("stdin: %d\n", stdin->_fileno);
printf("stdout: %d\n", stdout->_fileno);
printf("stderr: %d\n", stderr->_fileno);
return 0;
}
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
stdin: 0
stdout: 1
stderr: 2
例子:
read
从文件描述符读取:
NAME
read - read from a file descriptor
SYNOPSIS
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
如下,从文件描述符0,也就是标准输入读取到字符数组buffer,然后打印:
int main()
{
char buffer[1024];
ssize_t s = read(0, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = '\0';
printf("echo: %s", buffer);
}
return 0;
}
结果:
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
hello
echo: hello
I/O 重定向
如下图,每一个进程都有一个指针files,指向一个顺序表,顺序表中存放的就是文件指针,其下标就是对应的描述符,这个表叫作描述符表
下列代码输出的是什么?
int main()
{
int fd1, fd2;
fd1 = open("foo.txt", O_RDONLY, 0);
close(fd1);
fd2 = open("baz.txt", O_RDONLY, 0);
printf("fd2 = %d\n", fd2);
return(0);
}
答案:
第一次调用 open
会返回描述符 3,比在下标为 3 的位置创建文件指针,调用 close
函数会释放描述符 3,最后对 open
的调用还是返回描述符 3,因此程序输出的是 “fd2 = 3”。
在上述知识的基础上,这里引出一个新的函数,dup
NAME
dup, dup2, dup3 - duplicate a file descriptor
SYNOPSIS
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
我们重点学习 dup2,它有两个参数,代表的含义为:将描述符 oldfd
的 file*
拷贝给 newfd
的 file*
。
例子
int main()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd < 0)
{
perror("open");
return 0;
}
dup2(fd, 1); //将刚刚打开的文件log.txt里的file*复制到1,也就是标准输出的位置。
fprintf(stdout, "打开文件成功,fd: %d\n", fd); // 向标准输出写文件
fflush(stdout);
close(fd);
return 0;
}
打开文件 log.txt,其描述符为 3,将它的文件指针复制给描述符 1(标准输出)对应的文件指针,此时描述符 1 和 3 对应的文件都是 log.txt,标准输出文件由于引用计数为 0 会被自动释放。
运行结果
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
[CegghnnoR@VM-4-13-centos 2022_10_19]$ cat log.txt
打开文件成功,fd: 3
可以看到,我们写的内容本来应该通过标准输出打印出来,现在却写到了 log.txt 文件里面。像这样的行为,我们就称 将标准输出重定向到磁盘文件 log.txt。
此外还有追加重定向,输入重定向,操作类似,这里不再赘述。
关于标准输出和标准错误的重定向:
#include <stdio.h>
int main()
{
printf("hello printf\n");
perror("hello perror\n");
return 0;
}
如上程序,直接运行会打印这两行信息,这说明无论是标准输出还是标准错误默认都是往屏幕上输出信息
[CegghnnoR@VM-4-13-centos 2022_9_30]$ ./a.out
hello printf
hello perror
: Success
而如果使用 >
重定向到一个文件,则只会打印标准错误的信息,标准输出信息被写入磁盘文件
[CegghnnoR@VM-4-13-centos 2022_9_30]$ ./a.out > log.txt
hello perror
: Success
[CegghnnoR@VM-4-13-centos 2022_9_30]$ cat log.txt
hello printf
所以这里的重定向仅仅是把标准输出重定向了,./a.out > log.txt
的完整写法应该是 ./a.out 1 > log.txt
即将文件描述符1重定向到 log.txt,如果要把标准错误也重定向到磁盘文件,可以像下面这样写
[CegghnnoR@VM-4-13-centos 2022_9_30]$ ./a.out > stdout.txt 2>stderr.txt
[CegghnnoR@VM-4-13-centos 2022_9_30]$ cat stdout.txt
hello printf
[CegghnnoR@VM-4-13-centos 2022_9_30]$ cat stderr.txt
hello perror
: Success
我们建议把标准输出和标准错误信息放到两个文件。
如果你想把它们混合到一个文件,可以像下面这样想写
[CegghnnoR@VM-4-13-centos 2022_9_30]$ ./a.out > all.txt 2>&1
[CegghnnoR@VM-4-13-centos 2022_9_30]$ cat all.txt
hello perror
: Success
hello printf
缓冲区
在写进度条的时候我们认识到了缓冲区的存在
使用 printf
函数打印字符,如果没有 \n
的话其内容并不会马上被打印出来。
而系统接口 write
并非如此,
int main()
{
printf("hello printf");
const char* msg = "hello write";
write(1, msg, strlen(msg));
sleep(5);
return 0;
}
如上代码,hello write 会被立刻打印出来,而 hello printf 在5秒后才被打印出来。
那么缓冲区在哪呢?
首先排除 write
,其次,printf
只是个函数,无法留存缓冲区。另外可以注意到我们 C 语言的 fprintf
和 fputs
如果要向标准输出打印数据,就需要传参数 stdout
,printf
其实也隐含了这个参数,而 write
没有,那么缓冲区只可能在 *stdout
里了,stdout
是FILE*
类型,FILE
是个结构体,其内部可以声明一个数组作为缓冲区。
如果在 *stdout
缓冲区有数据的情况下,将文件描述符 1 关掉,那么 stdout
会被直接释放,缓冲区内的数据再也不会刷新出来了。
int main()
{
printf("hello printf");
sleep(3);
close(1);
return 0;
}
以上程序 3 秒后什么也没有打印出来
刷新策略问题:
- 常规:
- 无缓冲(立即刷新)
- 行缓冲(逐行刷新)
- 全缓冲(缓冲区写满刷新)
- 特殊:
- 进程退出
- 用户强制刷新
一般会根据文件类型确定刷新策略,比如对显示器文件采用行刷新策略,对块设备对应的文件,磁盘文件采用全缓冲刷新。
奇怪的现象:
int main()
{
const char* str1 = "hello printf\n";
const char* str2 = "hello fprintf\n";
const char* str3 = "hello fputs\n";
const char* str4 = "hello write\n";
// C库函数
printf(str1);
fprintf(stdout, str2);
fputs(str3, stdout);
// 系统接口
write(1, str4, strlen(str4));
fork();
return 0;
}
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile
hello printf
hello fprintf
hello fputs
hello write
[CegghnnoR@VM-4-13-centos 2022_10_19]$ ./myfile > log.txt
[CegghnnoR@VM-4-13-centos 2022_10_19]$ cat log.txt
hello write
hello printf
hello fprintf
hello fputs
hello printf
hello fprintf
hello fputs
为什么直接打印出来是 4 条信息,而重定向却是 7 条信息?
答案
直接打印是 4 条信息很好理解,因为是代码一共 4 条信息,并且是行刷新策略。
重定向到磁盘文件却打印了 7 条信息,注意到除了 write
,其他信息都打印了两次,明显和缓冲区有关,这里重定向到了磁盘文件,那么刷新策略会变成全缓冲刷新,所以在父进程中后三条信息都会被保存在缓存区里,当 fork
时,子进程继承父进程的数据,最后父子进程退出,两个进程的缓冲区都会刷新,发生写时拷贝,所以后三条信息会输出两次,最后一共是 7 条信息。
文件操作简易模拟实现
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include <stdlib.h>
#define NUM 1024
#define NONE_FLUSH 0x0
#define LINE_FLUSH 0x1
#define FULL_FLUSH 0x2
typedef struct MyFILE
{
int _fileno; // 描述符
char _buffer[NUM]; // 缓冲区
int _end; // 缓冲区末尾
int _flags; // 打开文件的模式
}MyFILE;
MyFILE* my_fopen(const char* filename, const char* method)
{
assert(filename);
assert(method);
int flags = O_RDONLY;
// 此处详细实现“w”和“a”模式,其余类似
if (strcmp(method, "r") == 0)
{}
else if (strcmp(method, "r+") == 0)
{}
else if (strcmp(method, "w") == 0)
{
flags = O_WRONLY | O_CREAT | O_TRUNC;
}
else if (strcmp(method, "w+") == 0)
{}
else if (strcmp(method, "a") == 0)
{
flags = O_WRONLY | O_CREAT | O_APPEND;
}
else if (strcmp(method, "a+") == 0)
{}
int fileno = open(filename, flags, 0666);
if (fileno < 0) return NULL;
MyFILE* fp = (MyFILE*)malloc(sizeof(MyFILE));
if (fp == NULL) return fp;
memset(fp, 0, sizeof(MyFILE));
fp->_fileno = fileno;
fp->_flags |= LINE_FLUSH;
fp->_end = 0;
return fp;
}
void my_fflush(MyFILE* fp)
{
assert(fp);
if (fp->_end > 0)
{
write(fp->_fileno, fp->_buffer, fp->_end);
fp->_end = 0;
syncfs(fp->_fileno);
}
}
void my_fwrite(MyFILE* fp, const char* start, int len)
{
assert(fp);
assert(start);
assert(len > 0);
strncpy(fp->_buffer + fp->_end, start, len); // 将数据写到缓存区
fp->_end += len;
// 此处重点实现行刷新
if (fp->_flags & NONE_FLUSH)
{
}
else if (fp->_flags & LINE_FLUSH)
{
if (fp->_end > 0 && fp->_buffer[fp->_end - 1] == '\n')
{
write(fp->_fileno, fp->_buffer, fp->_end);
fp->_end = 0;
syncfs(fp->_fileno);
}
}
else if (fp->_flags & FULL_FLUSH)
{
}
}
void my_fclose(MyFILE* fp)
{
my_fflush(fp);
close(fp->_fileno);
free(fp);
}
int main()
{
MyFILE* fp = my_fopen("log.txt", "w");
if (fp == NULL)
{
printf("my_fopen error\n");
return 1;
}
const char* s = "hello my file\n";
my_fwrite(fp, s, strlen(s));
printf("消息立即刷新");
sleep(3);
const char* ss = "hello my file";
my_fwrite(fp, ss, strlen(ss));
printf("写入了一个不满足刷新条件的字符串\n");
sleep(3);
const char* sss = "hello my file";
my_fwrite(fp, sss, strlen(sss));
printf("写入了一个不满足刷新条件的字符串\n");
sleep(3);
const char* ssss = "end\n";
my_fwrite(fp, ssss, strlen(ssss));
printf("写入了一个满足刷新条件的字符串\n");
sleep(3);
const char* sssss = "aaaaaaaa";
my_fwrite(fp, sssss, strlen(sssss));
printf("写入了一个不满足刷新条件的字符串\n");
sleep(1);
my_fflush(fp);
sleep(3);
my_fclose(fp);
}
shell 实现的补充(增加重定向功能)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <assert.h>
#include <ctype.h>
#define SEP " "
#define NUM 1024
#define SIZE 128
#define DROP_SPACE(s) do { while (isspace(*s)) s++; }while(0)
char command_line[NUM];
char* command_args[SIZE];
char env_buffer[NUM];
#define NONE_REDIR -1
#define INPUT_REDIR 0
#define OUTPUT_REDIR 1
#define APPEND_REDIR 2
int g_redir_flag = NONE_REDIR;
char* g_redir_filename = NULL;
int ChangeDir(const char* new_path)
{
chdir(new_path);
return 0;
}
void PutEnvInMyShell(char* new_env)
{
putenv(new_env);
}
void CheckDir(char* commands)
{
assert(commands);
//[start, end)
char* start = commands;
char* end = commands + strlen(commands);
while (start < end)
{
if (*start == '>')
{
if (*(start + 1) == '>')
{
// 追加重定向
*start = '\0';
start += 2;
g_redir_flag = APPEND_REDIR;
DROP_SPACE(start);
g_redir_filename = start;
break;
}
else
{
// 输出重定向
*start = '\0';
start++;
DROP_SPACE(start);
g_redir_flag = OUTPUT_REDIR;
g_redir_filename = start;
break;
}
}
else if (*start == '<')
{
// 输入重定向
*start = '\0';
start++;
DROP_SPACE(start);
g_redir_flag = INPUT_REDIR;
g_redir_filename = start;
break;
}
else
{
start++;
}
}
}
int main()
{
g_redir_flag = NONE_REDIR;
g_redir_filename = NULL;
while (1)
{
// 1.显示提示符
printf("[张三@我的主机名 当前目录]# ");
fflush(stdout);
// 2.获取用户输入
memset(command_line, '\0', sizeof(command_line)*sizeof(char));
fgets(command_line, NUM, stdin);
command_line[strlen(command_line) - 1] = '\0'; // 去除\n
CheckDir(command_line);
// 3.字符切分 "ls -a -l -i" -> "ls" "-a" "-l" "-i"
command_args[0] = strtok(command_line, SEP);
int index = 1;
if (strcmp(command_args[0], "ls") == 0)
command_args[index++] = (char*)"--color=auto";
while (command_args[index++] = strtok(NULL, SEP));
if (strcmp(command_args[0], "cd") == 0 && command_args[1] != NULL)
{
ChangeDir(command_args[1]);
continue;
}
if (strcmp(command_args[0], "export") == 0 && command_args[1] != NULL)
{
strcpy(env_buffer, command_args[1]);
PutEnvInMyShell(env_buffer);
continue;
}
// 创建进程,执行
pid_t id = fork();
if (id == 0)
{
int fd = -1;
switch(g_redir_flag)
{
case NONE_REDIR:
break;
case INPUT_REDIR:
fd = open(g_redir_filename, O_RDONLY);
dup2(fd, 0);
break;
case OUTPUT_REDIR:
fd = open(g_redir_filename, O_WRONLY | O_CREAT | O_TRUNC);
dup2(fd, 1);
break;
case APPEND_REDIR:
fd = open(g_redir_filename, O_WRONLY | O_CREAT | O_APPEND);
dup2(fd, 1);
break;
default:
printf("error\n");
break;
}
// child
// 程序替换
execvp(command_args[0], command_args);
exit(1);
}
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if (ret > 0)
{
printf("等待子进程成功:sig: %d, code: %d\n", status & 0x7F, (status >> 8) & 0xFF);
}
}
return 0;
}