一. 梳理C/C++文件操作及相关结论
1)文件=文件内容+文件属性(元数据)
所以之后我们说的对文件的操作也就可以分为两类:对内容操作 和 对属性操作。
2)想要访问文件要先打开文件,更要先找到文件。
- 文件被谁打开?
进程。所以我们说的程序与文件的关系其实是进程与文件的关系。
- 进程是如何打开文件的?
使用fopen这种函数打开。但是在程序没真正运行起来之前(只是编译了),文件不会真正被打开。文件是动态被打开的,在程序运行起来之后。
- 怎么找到文件?
需要路径+文件名。
但有时候我们在程序中写时是不带路径的,为什么?
这个我们之前也了解过,不是不需要路径。是因为进程启动后会动态维护自己的工作路径,访问任何文件都必须有路径,要么用户自己提供全路径,要么使用进程的cwd+文件名 拼成全路径。
下面例子以写方式打开一个不存在的文件,并且没有提供路径,那么这个文件会在哪里被创建呢。
cpp
#include <unistd.h>
int main()
{
printf("pid: %d\n", getpid());
const char* filename = "log.txt"; // 不带路径
FILE* fp = fopen(filename, "w");
if(fp == NULL)
{
perror("fopen");
return 1;
}
while(1)
sleep(1);
fclose(fp);
return 0;
}


3)进程打开文件的实质是把文件加载到内存
文件原本存放在磁盘上,根据冯诺依曼结构,CPU不能直接和外设交互,所以想对文件做操作必须加载到内存。具体加载的是文件的属性还是内容,取决于进程要访问文件的什么。
4)管理文件
-
Linux系统内存在大量的文件,其中有在内存中被打开的文件;也有磁盘中没有被打开的文件(文件系统部分再讨论这类文件)。
-
也就是说系统中会有大量被打开的文件,OS是一定要对这些文件进行管理的,那么如何管理呢? 先描述,再组织!
系统中一定有一个描述文件的结构体,大致样子:
cpp
struct XXX
{
// 文件属性
// ...
// 连接其他文件的指针
struct XXX *next;
}

二. C语言文件操作的接口

1) 上图只是部分常用的。下面先举一个进行文件操作的例子
以读写形式打开文件才能在写入后不重新关闭文件再打开文件就直接读取。几种打开方式稍后介绍。
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void test02()
{
const char* filename = "log.txt";
// 打开文件(读写形式)
FILE* fp = fopen(filename, "w+");
if(fp == NULL)
{
perror("fopen");
exit(1);
}
// 对文件做写入
int cnt = 1;
while(cnt <= 10)
{
cnt++;
const char* s = "hello laosi\n";
fputs(s, fp);
}
// 此时文件指针移动到了文件末尾,直接读取会读取失败
// 写入完成后,重置文件指针到开头
rewind(fp);
printf("重置后位置: %ld\n", ftell(fp));
// 对文件做读操作
while(1)
{
char buff[128];
if(!fgets(buff, sizeof(buff), fp)) break;
printf("from file: %s", buff);
}
// 关闭文件
fclose(fp);
}
int main()
{
test02();
return 0;
}
2)读写形式

- ① FILE* 是文件指针,也叫文件句柄。通过返回的文件指针可以对文件做操作。
② path 是要操作的文件的路径。
③ mode 是选项,选择打开文件的方式。
2. 打开文件的方式

① r : 以只读方式打开文件,若文件不存在不会新建。文件流位于文件开头。
**② r+ :**以读写方式打开文件,若文件不存在不会新建。文件流位于文件开头。
**③ w :**存在就清空文件,不存在就创建文件用于写入。文件流位于文件开头。
这就可以解释之前的一个现象:我们的输出重定向(>)向文件写入会清空之前的内容。因为输出重定向也是对文件做操作,对文件做操作必须先打开文件,而它的打开方式就是w。
所以 > 文件名 是我们清空文件的一种方式。

④ w+ : 以读写方式打开文件。如果文件不存在,则创建;否则将其清空。文件流位于文件开头。
**⑤ a :**以追加方式打开(在文件末尾写入)。如果文件不存在,则创建。文件流位于文件末尾。
这里就对应到追加重定向(>>),追加重定向就是以a(追加写入)的方式打开文件,从文件末尾开始写,就能实现追加的效果。

⑥ a+ : 以读取和追加方式打开(在文件末尾写入)。如果文件不存在,则创建。初始读取位置为文件开头,但输出始终追加到文件末尾。
3)读写位置

- 上图中这三个函数都是 C 标准库中用于文件随机访问(移动文件内部位置指针)的核心函数。
① fseek的作用是移动文件流的位置指针,设置文件的读写位置,即改变下一次读写操作发生的位置。
第一个参数:文件指针,你要对哪个文件做操作。
第二个参数:偏移量(字节数),可以为正、负、零。
第三个参数:偏移起始点。有三个宏定义:SEEK_SET:从文件开头开始;SEEK_CUR:从当前位置开始;SEEK_END:从文件末尾开始。
成功返回0,失败返回非零(比如要把文件指针移动到文件开始位置之前)。
② ftell的作用是返回该文件的读写位置(偏移量)。
③ rewind的作用是将读写位置重置到文件开头,并清除文件结束和错误标志。
他没有返回值,常用于需要重新从头读取文件的场景。
- 读写位置 本质就是一个整数,从ftell的返回值也能看出来。我们可以把文件内容看作一个char[]类型的一维数组,不管是什么符号比如\n都是作为一个普通字符存在这个数组中,只不过被解释器解释成换行符。所以这个读写位置其实就是数组下标 。
三. Linux系统的文件操作
不止语言能对文件进行操作,操作系统也可以。用过提供读、写、访问文件的系统调用。
1)open

1. flag--常见的文件打开模式
① O_RDONLY:只读
② O_WRONLY:只写
③ O_RDWR:读写
④ O_CREAT:创建
⑤ O_APPEND:追加写
flag可以通过 或运算 对以上选项做组合,实现一次选择多个选项(比如我不仅要以写方式打开文件还要如果文件不存在就创建,就同时需要O_WRONLY和O_CREAT)。
一个参数可以传多个的原理大致如下面程序,通过位操作,一次性向标志位传递若干个参数,将printf换成我们真正要调的函数即可:
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
// 只有一位为1
#define ONE (1 << 0) // 0000 0001
#define TWO (1 << 1) // 0000 0010
#define THREE (1 << 2) // 0000 0100
#define FOUR (1 << 3) // 0000 1000
void Print(int flag) // 标志位
{
// &:两个都是1才为1,其余都是0
// 只要flag某位为1,则执行对应操作
if(flag & ONE) // 如果最低位为1
{
printf("one\n");
}
if(flag & TWO)
{
printf("two\n");
}
if(flag & THREE)
{
printf("three\n");
}
if(flag & FOUR)
{
printf("four\n");
}
}
void test03()
{
Print(ONE);
printf("\n");
// |:只要有一个为1,最终结果的这一位就为1
Print(ONE|TWO);
printf("\n");
Print(THREE|FOUR);
printf("\n");
Print(TWO|THREE|FOUR);
printf("\n");
}
int main()
{
test03();
return 0;
}

2. mode--指定新建文件权限
可以看到上面我们的open是有两个版本的,如果打开的是存在的文件,那么两个参数的open就够用了。但是不存在的我们要创建它,而新建一个文件是要有权限的,此时就需要第三个参数来指定。
cpp
// open
void test04()
{
// 打开一个不存在的文件要指明权限
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
if(fd < 0)
{
perror("open");
}
}

我们明明设的权限是666怎么最终创建出来的文件权限是664,其实我们之前就见过这个问题,是因为权限掩码。我们指明的只是新建文件的默认权限,最终的权限值要减掉掩码。

如果不响被系统的权限掩码影响可以使用一个系统调用 -- umask。在设置权限之前将掩码清零。
cpp
// open
void test04()
{
umask(0); // 系统调用,掩码清零
// 打开一个不存在的文件要指明权限
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
if(fd < 0)
{
perror("open");
}
}

掩码的采用遵循就近原则 ,系统设置了一个掩码0002,我们自己在代码中用系统调用设置了一个掩码0000,一定是自己的代码设置的更近,采用0000,如果没设置就采用系统的。
3. 返回值

成功时返回新的文件描述符 ,如果出错则返回 -1,并正确设置erron。
文件描述符具体是什么稍后说。
2)close
close是一个系统调用,用来关闭文件。参数是文件描述符。成功返回0,失败返回-1并设置erron。


3)write

cpp
// write
void test05()
{
umask(0);
int fd = open("log.txt", O_WRONLY|O_CREAT, 0666);
if(fd == -1) perror("open");
//const char* msg = "llllssssssaaaaaaaaa"; // 第一次写入
const char* msg = "yy533311"; // 第二次写入
write(fd, msg, strlen(msg)); // 不用管最后的\0,那是语言的规定,和文件没关系
close(fd);
}

和C语言的函数不同,我们现在没有先清空文件再写入,而是直接覆盖式地写入。
如果想先清空,打开文件时要多加一个选项O_TRUNC;如果想以追加的形式写入就再加上一个O_APPEND。这样就实现了库函数同样的功能。


4)库函数与系统调用
上面我们通过系统调用能够实现C语言中的文件操作函数的功能:
1. C语言的库函数就是封装了系统调用
我们打印一下用系统调用open打开的文件的文件描述符(open的返回值):


为什么打开一个文件是从3开始的,0、1、2呢?
0、1、2这三个文件描述符分别被标准输入(键盘)、标准输出(显示器)、标准错误(显示器)占用了,这几个文件是默认被打开的。对应着C语言的:

C语言中的这个FILE是什么?
结构体。虽然我们不知道这个结构体中具体的实现,但是我们知道通过它可以对文件做操作,说明他一定封装了文件描述符,因为库函数封装系统调用,而系统调用(如open、close..)只接收0123这种文件描述符,所以FILE一定封装了它。
验证FILE确实封装了文件描述符:打印一下FILE对象的文件描述符对象。


2. fputs、fwrite和write
cpp
void test07()
{
const char* msg = "lllssaaaa\n";
// 直接向文件描述符里写和C语言的fputs有一样的效果
write(1, msg, strlen(msg)); // 往文件描述符为1的文件(实际就是显示器)里写
fputs(msg, stdout);
}

5)文件描述符结构
1. 文件与进程结构

但是一个进程可能打开多个文件,怎么知道某个文件是被那个进程打开的?如何建立进程与被打开文件之间的映射?
通过在进程PCB内保存一个指向文件描述符表的指针,文件描述符表实际是一个文件结构体指针数组,进程每打开一个文件就把这个文件的地址存到数组中。
2. 文件描述符的本质就是这个 文件结构体 指针 数组 的下标

当调用 write(3, "aaaaa"); 后系统的工作大致为:
① 在调用write的当前进程PCB找到*file,通过指针拿到文件描述符表。
② 再把write的第一个参数 3 作为数组下标直接索引到表中对应位置,并拿到文件地址(表中存的是被打开的文件地址)。
③ 找到文件后,将函数的第二个参数 "aaaaa" 拷贝到文件的缓冲区中(最终写回磁盘)。
所以,write这类函数都是拷贝函数,只是把数据从用户空间拷贝到文件的缓冲区。
6)为什么语言封装系统接口
-
Linux系统中,想要访问打开的文件,操作系统只认文件描述符fd。所以语言层FILE必须封装fd才能访问文件。
-
既然C语言包括其他所有的语言想进行文件操作都是封装了系统调用以及文件描述符,那为什么不直接在代码中使用系统调用?也就是说为什么语言要进行封装?
**因为直接使用系统调用的代码不具有很好的跨平台性。**系统调用之所以叫系统调用就说明这是操作系统提供的调用的接口,不同的操作系统的系统调用是不同的,如果用Linux的系统调用写死了,那么这份代码拿到Windows和macOS上就无法运行。
- 那么各种语言是如何支持跨平台的?
在各个平台都封装一遍该平台的系统调用,形成不同平台的库,实际底层封装的函数都不同,在哪个平台使用就掉哪个平台对应的库。
四. 理解重定向的本质
1)文件描述符的分配规则
每次分配 文件描述符表(数组)中,值最小并且还没有被使用的文件描述符fd。
2)模拟重定向
1. 输出重定向1
下面代码把本应打印到显示器的内容写入到了log1.txt文件中了,这正是输出重定向的概念。
cpp
void test08()
{
close(1); // 关闭标准输出
int fda = open("log1.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
printf("fda: %d\n", fda);
int fdb = open("log2.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
printf("fdb: %d\n", fdb);
int fdc = open("log3.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);
printf("fdc: %d\n", fdc);
}


原因: printf是C标准库中的函数,默认往stdout里打印;还有一个C标准库中的函数fprintf,printf就相当于把fprintf的第一个参数填为stdout。他们两个最终都是调用系统调用write。系统默认为标准输出stdout分配1,所以最终在封装的write中是往文件描述符 1 里打印。

2. 追加重定向1
cpp
// 模拟实现追加重定向1
void test09()
{
close(1); // 关闭标准输出
// 现在log1.txt就是1了
int fda = open("log1.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);
printf("fda: %d\n", fda);
}

3. 输入重定向1
cpp
// 模拟实现输入重定向
void test10()
{
close(0); // 关闭标准输入
// 此时log1.txt就是0
int fda = open("log1.txt", O_RDONLY);
printf("fda: %d\n", fda);
// 获取输入
char buff[64];
// stdin封装0,他就是0也只认0,不管0是不是标准输入他就从0读
fgets(buff, sizeof(buff), stdin);
printf("%s\n", buff);
}

以上的方法都是先关闭默认打开的0、1,再打开其他文件顶替原本0、1的位置,从而重定向到刚打开的这个文件中。这种方法可以但是太麻烦了,很不常用,系统提供了一种做法。
4. dup2()
dup2(oldfd, newfd) 会将 newfd关闭(如果已打开),然后让 newfd指向 oldfd所打开的文件。
所以 oldfd是源,newfd是目标,将oldfd放到newfd的位置,覆盖newfd原本的内容。

如果希望输出重定向到myfile文件,新文件直接打开到文件结构体指针数组下标为3的位置,再把下标为3的内容(myfile文件的地址)拷贝覆盖到1即可。
下面用这种方式重做上面的重定向模拟:
5. 输入重定向2
cpp
// 输入重定向2
void test11()
{
int fda = open("log1.txt", O_RDONLY);
printf("fda: %d\n", fda);
dup2(fda, 0);
char buff[64];
fgets(buff, sizeof(buff), stdin);
printf("%s\n", buff);
}
bash
[lsy@laosi_host code_io]$ touch log1.txt
[lsy@laosi_host code_io]$ echo "hello laosi" > log1.txt
[lsy@laosi_host code_io]$ cat log1.txt
hello laosi
[lsy@laosi_host code_io]$ ./openfile
fda: 3
hello laosi
[lsy@laosi_host code_io]$
6. 输出重定向2
cpp
// 输出重定向2
void test12()
{
// 让log.txt覆盖标准输出(1)的位置
int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
printf("fd: %d\n", fd);
dup2(fd, 1);
// 输出一些内容全部打印到log.txt中,而不是显示器了
fprintf(stdout, "hello world\n");
fprintf(stdout, "hello world\n");
printf("laosiaaaaaaaa\n");
printf("laosiaaaaaaaa\n");
}
bash
[lsy@laosi_host code_io]$ ls
log1.txt makefile openfile openfile.c
[lsy@laosi_host code_io]$ ./openfile
fd: 3
[lsy@laosi_host code_io]$ ls
log1.txt log.txt makefile openfile openfile.c
[lsy@laosi_host code_io]$ cat log.txt
hello world
hello world
laosiaaaaaaaa
laosiaaaaaaaa
7. 追加重定向2
cpp
// 追加重定向2
void test13()
{
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
printf("fd: %d\n", fd);
dup2(fd, 1);
fprintf(stdout, "hello world\n");
fprintf(stdout, "hello world\n");
printf("laosiaaaaaaaa\n");
printf("laosiaaaaaaaa\n");
}
bash
[lsy@laosi_host code_io]$ make
[lsy@laosi_host code_io]$ ./openfile
fd: 3
[lsy@laosi_host code_io]$ cat log.txt
hello world
hello world
laosiaaaaaaaa
laosiaaaaaaaa
hello world
hello world
laosiaaaaaaaa
laosiaaaaaaaa
五. 为自定义shell增加重定向功能
-
程序替换只是替换当前进程的代码和数据,不会影响当前进程打开的文件。
-
在正式执行程序之前,先解析并检查命令中是否有重定向。如果有,确定是哪种重定向,把左边的命令,和右边的文件名分别解析出来。
只放和重定向部分,有关系的代码。
cpp
// 写成宏函数也可以
//#define TrimSpace(start) do{\
while(isspace(*start)) start++;\
}while(0)
char* TrimSpace(char* start)
{
while(isspace(*start))
{
start++;
}
return start;
}
void ParseRedir(char* commandline)
{
// 每次解析前重新初始化
redir_type = NoneRedir;
filename = NULL;
char* start = commandline;
char* end = commandline + strlen(commandline);
// 找重定向符号
while(start < end)
{
// ls -a -l >> filename
// 如果有,将重定向符号替换为'\0',剩下的左半边留在commandline中只有命令和选项
// 右半边去掉所有空格后只剩下文件名,保存到filename中
// 具体是哪种重定向保存到redir_type中
if(*start == '>')
{
if(*(start + 1) == '>')
{
// 追加重定向
redir_type = AppendRedir;
*start = '\0';
start++;
*start = '\0';
start++;
// 清空右半边所有空格后,保存到filename中
start = TrimSpace(start);
//TrimSpace(start);
filename = start;
break;
}
// 输出重定向
redir_type = OutputRedir;
*start = '\0';
start++;
// 清空右半边所有空格后,保存到filename中
start = TrimSpace(start);
//TrimSpace(start);
filename = start;
break;
}
else if(*start == '<')
{
// 输入重定向
redir_type = InputRedir;
*start = '\0';
start++;
// 清空右半边所有空格后,保存到filename中
start = TrimSpace(start);
//TrimSpace(start);
filename = start;
}
else start++; // 暂时没有重定向
}
}
int ExecuteCommand()
{
// 创建子进程进行程序替换
pid_t id = fork();
if(id == -1)
{
return -1;
}
else if(id == 0)
{
// 子进程
int fd = -1;
if(redir_type == NoneRedir)
{
// Do Nothing
}
else if(redir_type == OutputRedir)
{
// 子进程要进行输出重定向
fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0666);
dup2(fd, 1);
}
else if(redir_type == AppendRedir)
{
fd = open(filename, O_WRONLY | O_CREAT | O_APPEND, 0666);
dup2(fd, 1);
}
else if(redir_type == InputRedir)
{
fd = open(filename, O_RDONLY);
dup2(fd, 0);
}
//execvp(gargv[0], gargv);
execvpe(gargv[0], gargv, genv);
exit(5);
}
else
{
// 父进程
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if(rid > 0)
{
// 等待成功,设置退出码
lastexitcode = WEXITSTATUS(status);
//printf("wait success!\n");
}
}
return 0;
}
int main()
{
// 0. 从配置文件中读取环境变量填充进程的环境变量表
LoadEnv();
char command_line[MAX_SIZE] = {0};
while(1)
{
// 1. 打印命令行字符串
PrintCommandLine();
// 2. 接收用户输入
if(GetCommand(command_line, sizeof(command_line)) == 0)
continue; // 获取失败则重新获取, 成功的话字符串已经存到command_line里了
// 打印数组内容,测试是否接收成功
//printf("%s\n", command_line);
// 3. 检查是否有重定向
ParseRedir(command_line);
// 查看是否正确解析
//printf("command: %s\n", command_line);
//printf("redir type: %d\n", redir_type);
//printf("filename: %s\n", filename);
// 4. 解析字符串
ParseCommand(command_line);
// 5. 判断当前命令是否是内建命令, 该父进程自己执行还是创建子进程去执行
if(CheckBuiltinExecute()) continue;
// 6. 创建子进程执行命令
ExecuteCommand();
}
return 0;
}
bash
[lsy@laosi_host shell_redir]$ ./myshell
[lsy@laosi_host shell_redir]#ls
in.txt makefile myshell myshell.cpp
[lsy@laosi_host shell_redir]#cat < in.txt
输入重定向成功从这个文件读取!
hello world
[lsy@laosi_host shell_redir]#ls -a -l > log.txt
[lsy@laosi_host shell_redir]#cat log.txt
total 44
drwxrwxr-x 2 lsy lsy 4096 May 13 19:42 .
drwxrwxr-x 3 lsy lsy 4096 May 13 15:36 ..
-rw-rw-r-- 1 lsy lsy 58 May 13 15:35 in.txt
-rw-rw-r-- 1 lsy lsy 0 May 13 19:42 log.txt
-rw-rw-r-- 1 lsy lsy 83 May 13 15:34 makefile
-rwxrwxr-x 1 lsy lsy 15576 May 13 19:41 myshell
-rw-rw-r-- 1 lsy lsy 9091 May 13 19:41 myshell.cpp
[lsy@laosi_host shell_redir]#ls -a -l >> log.txt
[lsy@laosi_host shell_redir]#cat log.txt
total 44
drwxrwxr-x 2 lsy lsy 4096 May 13 19:42 .
drwxrwxr-x 3 lsy lsy 4096 May 13 15:36 ..
-rw-rw-r-- 1 lsy lsy 58 May 13 15:35 in.txt
-rw-rw-r-- 1 lsy lsy 0 May 13 19:42 log.txt
-rw-rw-r-- 1 lsy lsy 83 May 13 15:34 makefile
-rwxrwxr-x 1 lsy lsy 15576 May 13 19:41 myshell
-rw-rw-r-- 1 lsy lsy 9091 May 13 19:41 myshell.cpp
total 48
drwxrwxr-x 2 lsy lsy 4096 May 13 19:42 .
drwxrwxr-x 3 lsy lsy 4096 May 13 15:36 ..
-rw-rw-r-- 1 lsy lsy 58 May 13 15:35 in.txt
-rw-rw-r-- 1 lsy lsy 338 May 13 19:42 log.txt
-rw-rw-r-- 1 lsy lsy 83 May 13 15:34 makefile
-rwxrwxr-x 1 lsy lsy 15576 May 13 19:41 myshell
-rw-rw-r-- 1 lsy lsy 9091 May 13 19:41 myshell.cpp
[lsy@laosi_host shell_redir]#