文章目录
🍊C 语言文件 IO
C 语言文件操作接口
在阅读本篇文章前,需对以下接口进行巩固理解,可参照博主的这篇文章进行学习:C语言文件操作(超详细)。
文件操作函数 | 功能 |
---|---|
fopen | 打开文件 |
fclose | 关闭文件 |
fputc | 写入一个字符 |
fgetc | 读取一个字符 |
fputs | 写入一个字符串 |
fgets | 读取一个字符串 |
fprintf | 格式化写入数据 |
fscanf | 格式化读取数据 |
fwrite | 向二进制文件写入数据 |
fread | 向二进制文件读取数据 |
fseek | 设置文件指针位置 |
ftell | 计算当前文件指针相对于起始位置的偏移量 |
rewind | 设置文件指针到文件的起始位置 |
ferror | 判断文件操作中是否发生错误 |
feof | 判断文件指针是否读取到文件末尾 |
对文件进行写入操作:
#include <stdio.h>
int main()
{FILE *fp = fopen("log.txt", "w");if (fp == NULL){perror("fopen");return 1;}int count = 5;while (count--)fputs("hello world\n", fp);fclose(fp);return 0;
}
运行程序后,在当前路径下就会生成对应文件,文件当中就是我们写入的内容。
对文件进行读操作:
#include <stdio.h>
int main()
{FILE *fp = fopen("log.txt", "r");if (fp == NULL){perror("fopen");return 1;}char buffer[64];for (int i = 0; i < 5; i++){fgets(buffer, sizeof(buffer), fp);printf("%s", buffer);}fclose(fp);return 0;
}
运行程序后,就会将我们刚才写入文件的内容读取出来,并打印在显示器上。
什么是当前路径?
我们知道,当 fopen 以写入的方式打开一个文件时,若该文件不存在,则会自动在当前路径创建该文件,那么这里所说的当前路径指的是什么呢?
例如,我们在 test2 目录下运行可执行程序 myproc,那么该可执行程序创建的 log.txt 文件会出现在 test2 目录下。
那是否意味着这里所说的当前路径是指当前可执行程序所处的路径呢?
这时我们可以将刚才可执行程序生成的 log.txt 文件先删除,然后再做一个测试:回退到上级目录,在上级目录下运行该可执行程序。
这时我们可以发现,该可执行程序运行后并没有在 test2 目录下创建 log.txt 文件,而是在我们当前所处的路径下创建了 log.txt 文件。
该可执行程序运行起来后,我们可以获取该进程的 PID,然后根据该 PID 在 /proc 目录下查看该进程的信息。
在这里我们可以看到两个软链接文件 cwd 和 exe,cwd 就是进程运行时我们所处的路径,而 exe 就是该可执行程序的所处路径。
总结: 实际上,我们这里所说的当前路径不是指可执行程序所处的路径,而是指该可执行程序运行成为进程时我们所处的路径。
默认打开的三个流
都说 Linux 下一切皆文件,也就是说 Linux下的任何东西都可以看作是文件,那么显示器和键盘当然也可以看作是文件。我们能看到显示器上的数据,是因为我们向显示器文件写入了数据,电脑能获取到我们敲击键盘时对应的字符,是因为电脑从键盘文件读取了数据。
为什么我们向显示器文件写入数据以及从键盘文件读取数据前,不需要进行打开显示器文件和键盘文件的相应操作?
需要注意的是,打开文件一定是进程运行的时候打开的,而任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流以及标准错误流,对应到C语言当中就是 stdin、stdout 以及 stderr。其中,标准输入流对应的设备就是键盘,标准输出流和标准错误流对应的设备都是显示器。
查看 man 手册我们就可以发现,stdin、stdout 以及 stderr 这三个流实际上都是 FILE*
类型的。
extern FILE *stdin;extern FILE *stdout;extern FILE *stderr;
当我们的 C 程序被运行起来时,操作系统就会默认使用 C 语言的相关接口将这三个输入输出流打开,之后我们才能调用类似于 scanf 和 printf 之类的函数向键盘和显示器进行相应的输入输出操作。
也就是说,stdin、stdout 以及 stderr 与我们打开某一文件时获取到的文件指针是同一个概念。
注意: 不止是 C 语言当中有标准输入流、标准输出流和标准错误流,C++ 当中也有对应的 cin、cout 和 cerr,其他所有语言当中都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的。
🍋系统文件 I/O
操作文件除了 C 语言接口、C++ 接口或是其他语言的接口外,操作系统也有一套系统接口来进行文件的访问。
相比于 C 库函数或其他语言的库函数而言,系统调用接口更贴近底层,实际上上层语言的库函数都是对系统接口进行了封装。
我们在 Linux 平台下运行 C 代码时,C 库函数就是对 Linux 系统调用接口进行的封装,在 Windows 平台下运行 C 代码时,C 库函数就是对 Windows 系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发。
open
系统接口中使用open函数打开文件,open函数的函数原型如下:
int open(const char *pathname, int flags, mode_t mode);
参数
-
const char *pathname
:这是一个指向以 null 结尾的字符串的指针,表示要打开或创建的文件的路径。- 若 pathname 以路径的方式给出,则当需要创建该文件时,就在 pathname 路径下进行创建。(绝对路径)
- 若 pathname 以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建。(相对路径)
-
int flags
:这是一个整数,用于指定打开文件时所使用的选项。这些选项可以通过逻辑或(|
)操作符组合使用。一些常见的选项包括:参数选项 含义 O_RDONLY 以只读的方式打开文件 O_WRNOLY 以只写的方式打开文件 O_APPEND 以追加的方式打开文件 O_RDWR 以读写的方式打开文件 O_CREAT 当目标文件不存在时,创建文件 注:flags 是整型,有 32 比特位,若将一个比特位作为一个标志位,则理论上 flags 可以传递 32 种不同的标志位。
实际上传入 flags 的每一个选项在系统当中都是以宏的方式进行定义的:
这些宏定义选项的共同点就是,它们的二进制序列当中有且只有一个比特位是 1(O_RDONLY 选项的二进制序列为全 0,表示O_RDONLY 选项为默认选项),且为 1 的比特位是各不相同的,这样一来,在 open 函数内部就可以使用按位与运算(&)来判断是否设置了某一选项。
-
mode_t mode
:这是一个用于设置文件权限的整数,==仅在创建新文件时使用(即当flags
中包含O_CREAT
时),表示创建文件的默认权限。例如,将 mode 设置为 0666,则文件理应创建出来的权限是:
-rw-rw-rw-
。但实际上创建出来文件的权限值还会受到 umask(文件默认掩码)的影响,实际创建出来文件的权限为:mode&(~umask)
。对于普通用户,umask 的默认值一般为 0002,当我们设置 mode 值为 0666 时实际创建出来文件的权限为 0664,也就是:-rw-rw-r–
。若想创建出来文件的权限值不受 umask 的影响,则需要在创建文件前使用
umask
函数将文件默认掩码设置为 0。umask(0); //将文件默认掩码设置为0
返回值
open
函数在成功时返回一个非负整数,称为文件描述符(file descriptor)。- 如果
open
函数失败,它返回-1
,并设置全局变量errno
以指示错误的具体原因。
示例
我们一次打开多个文件,然后分别打印它们的文件描述符。
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{umask(0);int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);printf("fd1:%d\n", fd1);printf("fd2:%d\n", fd2);printf("fd3:%d\n", fd3);printf("fd4:%d\n", fd4);printf("fd5:%d\n", fd5);return 0;
}
运行程序后可以看到,打开文件的文件描述符是从 3 开始连续且递增的。
理解:
这里所谓的文件描述符本质上是一个指针数组的下标,指针数组当中的每一个指针都指向一个被打开文件的文件信息,通过对应文件的文件描述符就可以找到对应的文件信息。
当使用 open 函数打开文件成功时数组当中的指针个数增加,然后将该指针在数组当中的下标进行返回,而当文件打开失败时直接返回 -1,因此,成功打开多个文件时所获得的文件描述符就是连续且递增的。
而Linux进程默认情况下会有 3 个缺省打开的文件描述符,分别就是标准输入 0、标准输出 1、标准错误 2,这就是为什么成功打开文件时所得到的文件描述符是从 3 开始进程分配的。
close
系统接口中使用 close 函数关闭文件,close 函数的函数原型如下:
int close(int fd);
参数
fd
:要关闭的文件描述符。
返回值
- 如果成功,
close
返回0
。 - 如果失败,
close
返回-1
并设置全局变量errno
以指示错误的具体原因。常见的错误包括:EBADF
:fd
不是一个有效的文件描述符,或者它已经被关闭。EINTR
:close
调用被信号中断。EIO
:一个 I/O 错误发生。- 其他可能的错误码,具体取决于操作系统和上下文。
示例
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{umask(0);int fd = open("log.txt", O_RDONLY | O_CREAT, 0666);// ...// read code// ...close(fd);return 0;
}
write
系统接口中使用 write 函数向文件写入信息,write 函数的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
参数
- fd:这是一个整数,表示要写入文件的文件描述符。
- buf:这是一个指向要写入数据的指针。
buf
指向的数据将被写入到fd
指向的文件中。数据的类型可以是任何类型,因为write
函数是以字节为单位进行操作的。 - count:这是一个
size_t
类型的值,表示buf
指向的数据的字节数,即希望写入的数据量。
返回值
- 成功时,
write
返回实际写入的字节数。这个值可能小于count
,这通常发生在非阻塞模式下写入套接字或管道时,或者磁盘已满等情况。 - 失败时,
write
返回-1
,并设置errno
以指示错误的具体原因。例如,errno
可能被设置为EAGAIN
(资源暂时不可用),EPIPE
(管道破裂),EBADF
(无效的文件描述符)等。
示例
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{umask(0);int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);if (fd < 0){perror("open");return 1;}const char *msg = "hello syscall\n";for (int i = 0; i < 5; i++){write(fd, msg, strlen(msg));}close(fd);return 0;
}
运行程序后,在当前路径下就会生成对应文件,文件当中就是我们写入的内容。
read
系统接口中使用 read 函数从文件读取信息,read 函数的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
参数
- fd:这是一个整数,表示要读取文件的文件描述符。
- buf:这是一个指向要存储读取数据的指针。
read
函数会将从fd
读取的数据存储到这个缓冲区中。 - count:这是一个
size_t
类型的值,表示希望读取的字节数,也是buf
缓冲区的大小。
返回值
- 成功时,
read
返回实际读取的字节数。这个值可能小于count
,特别是在文件末尾或非阻塞模式下读取套接字、管道时。 - 如果文件已经到达末尾,
read
返回0
。 - 失败时,
read
返回-1
,并设置errno
以指示错误的具体原因。例如,errno
可能被设置为EAGAIN
(资源暂时不可用,通常用于非阻塞模式),EBADF
(无效的文件描述符),EINTR
(调用被信号中断)等。
示例
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{int fd = open("log.txt", O_RDONLY);if (fd < 0){perror("open");return 1;}char s[1024];// 假设读的数据小于1024字节ssize_t n = read(fd, s, 1024);if (n < 0){perror("read");}s[n] = '\0'; // 读到了 n 个字符,第 n 位置 '\'write(1, s, strlen(s)); // 向文件描述符为 1 的文件(即显示器)写入 s 字符串,相当于打印 s 串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 内存文件?
当文件存储在磁盘当中时,我们将其称之为磁盘文件,而当磁盘文件被加载到内存当中后,我们将加载到内存当中的文件称之为内存文件。磁盘文件和内存文件之间的关系就像程序和进程的关系一样,当程序运行起来后便成了进程,而当磁盘文件加载到内存后便成了内存文件。
磁盘文件由两部分构成,分别是文件内容和文件属性。文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息。
文件加载到内存时,一般先加载文件的属性信息,当需要对文件内容进行读取、输入或输出等操作时,再延后式的加载文件数据。
🍌文件描述符分配规则
尝试连续打开五个文件,看看这五个打开后获取到的文件描述符。
#include <stdio.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <fcntl.h>
int main()
{umask(0);int fd1 = open("log1.txt", O_RDONLY | O_CREAT, 0666);int fd2 = open("log2.txt", O_RDONLY | O_CREAT, 0666);int fd3 = open("log3.txt", O_RDONLY | O_CREAT, 0666);int fd4 = open("log4.txt", O_RDONLY | O_CREAT, 0666);int fd5 = open("log5.txt", O_RDONLY | O_CREAT, 0666);printf("fd1:%d\n", fd1);printf("fd2:%d\n", fd2);printf("fd3:%d\n", fd3);printf("fd4:%d\n", fd4);printf("fd5:%d\n", fd5);return 0;
}
可以看到这五个文件获取到的文件描述符都是从3开始连续递增的,因为文件描述符本质就是数组的下标,而当进程创建时就默认打开了标准输入流、标准输出流和标准错误流,也就是说数组当中下标为0、1、2的位置已经被占用了,所以只能从3开始进行分配。
若我们在打开这五个文件前,先关闭文件描述符为0的文件,此后文件描述符的分配又会是怎样的呢?
close(0);
可以看到,第一个打开的文件获取到的文件描述符变成了 0,而之后打开文件获取到的文件描述符还是从 3 开始依次递增的。
我们可以得出结论:文件描述符是从最小但是没有被使用的fd_array
数组下标开始进行分配的。
🍍重定向
重定向的原理
在明确了文件描述符的概念及其分配规则后,现在我们已经具备理解重定向原理的能力了。看完下面三个例子后,你会发现重定向的本质就是修改文件描述符下标对应的struct file*
的内容。
输出重定向原理
输出重定向就是将我们本应该输出到一个文件的数据重定向输出到另一个文件中。
例如,如果我们想让本应该输出到显示器文件的数据输出到log.txt文件当中,那么我们可以在打开log.txt文件之前将文件描述符为 1 的文件关闭,也就是将显示器文件关闭,这样一来,当我们后续打开log.txt文件时所分配到的文件描述符就是 1。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{close(1); // 关闭显示器文件描述符int fd = open("log.txt", O_WRONLY | O_CREAT, 0666); // 打开文件自动分配到文件描述符1if (fd < 0){perror("open");return 1;}printf("hello world\n");printf("hello world\n");printf("hello world\n");printf("hello world\n");printf("hello world\n");fflush(stdout);close(fd);return 0;
}
运行结果后,我们发现显示器上并没有输出数据,对应数据输出到了 log.txt 文件当中。
注:
- printf 函数是默认向 stdout 输出数据的,而 stdout 指向的是一个 struct FILE 类型的结构体,该结构体当中有一个存储文件描述符的变量,而 stdout 指向的 FILE 结构体中存储的文件描述符就是 1,因此 printf 实际上就是向文件描述符为 1 的文件输出数据。
- C语言的数据并不是立马写到了内存操作系统里面,而是写到了C语言的缓冲区当中,所以使用 printf 打印完后需要使用 fflush 将C语言缓冲区当中的数据刷新到文件中。
追加重定向原理
追加重定向和输出重定向的唯一区别就是,输出重定向是覆盖式输出数据,而追加重定向是追加式输出数据。
例如,如果我们想让本应该输出到显示器文件的数据以追加的方式输出到log.txt文件当中,那么我们应该先将文件描述符为 1 的文件关闭,然后再以追加式写入的方式打开log.txt文件,这样我们就可以将数据追加重定向到了文件log.txt当中。
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{close(1);int fd = open("log.txt", O_WRONLY | O_APPEND | O_CREAT, 0666);if (fd < 0){perror("open");return 1;}printf("hello Linux\n");printf("hello Linux\n");printf("hello Linux\n");printf("hello Linux\n");printf("hello Linux\n");fflush(stdout);close(fd);return 0;
}
运行结果后,我们发现对应数据便追加式输出到了 log.txt 文件当中。
输入重定向原理
输入重定向就是将我们本应该从一个文件读取数据,现在重定向为从另一个文件读取数据。
例如,如果我们想让本应该从键盘文件读取数据的 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[64];while (scanf("%s", str) != EOF){printf("%s\n", str);}close(fd);return 0;
}
运行结果后,我们发现 scanf 函数将 log.txt 文件当中的数据都读取出来了。
注:scanf 函数是默认从 stdin 读取数据的,而 stdin 指向的 FILE 结构体中存储的文件描述符是 0,因此 scanf 实际上就是向文件描述符为 0 的文件读取数据。
标准输出 VS 标准错误
标准输出流和标准错误流对应的都是显示器,它们有什么区别?
我们来看看下面这段代码,代码中分别向标准输出流和标准错误流输出了两行字符串。
#include <stdio.h>
int main()
{printf("hello printf\n"); // stdoutperror("perror"); // stderrfprintf(stdout, "stdout:hello fprintf\n"); // stdoutfprintf(stderr, "stderr:hello fprintf\n"); // stderrreturn 0;
}
运行结果:
这样看起来标准输出流和标准错误流并没有区别,都是向显示器输出数据。但我们若是将程序运行结果重定向输出到文件 log.txt 当中,我们会发现 log.txt 文件当中只有向标准输出流输出的两行字符串,而向标准错误流输出的两行数据并没有重定向到文件当中,而是仍然输出到了显示器上。
实际上我们使用重定向时,重定向的是文件描述符为 1 的标准输出流,而并不会对文件描述符是 2 的标准错误流进行重定向。
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 文件当中。
添加重定向功能到 MyShell 中
注:MyShell 我们在深度剖析Linux进程控制这篇文章中已经实现,这里只对添加的重定向功能进行讲解。
在MyShell当中添加重定向功能的步骤大致如下:
- 对于获取到的命令进行判断,若命令当中包含重定向符号 >、>> 或是 <,则该命令需要进行处理。
- 设置宏和标记数 redir,0 表示命令当中没有重定向指令,1表示命令当中包含输出重定向,2 表示命令当中包含追加重定向,3 表示命令当中包含输入重定向。
- 在获取的整条指令中将重定向位置置
'\0'
,后续格式化整条指令时,只需要分割重定向符号前面的指令。 - 重定向符号后面的字段标识为目标文件名,若 redir 值为 1,则以写的方式打开目标文件;若 redir 值为 2,则以追加的方式打开目标文件;若 redir 值为 3,则以读的方式打开目标文件。
- 若 redir 值为 1 或者 2,则使用 dup2 接口实现目标文件与标准输出流的重定向;若 redir 值为3,则使用 dup2 接口实现目标文件与标准输入流的重定向。
代码实现如下:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctype.h>
#include <sys/stat.h>
#include <fcntl.h>#define NUM 1024
#define SIZE 64
#define NoneRedir 0
#define OutputRedir 1
#define AppendRedir 2
#define InputRedir 3int redir = NoneRedir;
char *filename = NULL;const char *seq = " ";
char cwd[1024];
char enval[1024];
int lastcode = 0;const char *GetUserName()
{const char *uname = getenv("USERNAME");if (uname)return uname;elsereturn NULL;
}const char *GetPWD()
{const char *pwd = getenv("PWD");if (pwd)return pwd;elsereturn NULL;
}int GetUserCommand(char *usercommand, int len)
{printf("%s@Machine:%s$ ", GetUserName(), GetPWD());char *r = fgets(usercommand, len, stdin); // 包括'\n'if (!r)return -1;// "abcd\n" strlen=5 u[5-1=4] = '\n'usercommand[strlen(usercommand) - 1] = '\0'; // 覆盖'\n'return strlen(usercommand);
}void CommandSplit(char *usercommand, char *argv[])
{int argc = 0;argv[argc++] = strtok(usercommand, seq);while (argv[argc++] = strtok(NULL, seq));// debug// for(int i = 0; argv[i]; i++)// {// printf("%d:%s\n", i, argv[i]);// }
}char *GetHomePath()
{char *hpath = getenv("HOME");if (hpath)return hpath;elsereturn NULL;
}void cd(const char *path)
{chdir(path);char tmp[1000];getcwd(tmp, sizeof(tmp));sprintf(cwd, "PWD=%s", tmp);putenv(cwd);
}int DoBulidIn(char *argv[])
{if (strcmp(argv[0], "cd") == 0){char *path = NULL;if (argv[1] == NULL)path = GetHomePath();elsepath = argv[1];cd(path);return 1;}else if (strcmp(argv[0], "export") == 0){if (argv[1] == NULL)return 1;strcpy(enval, argv[1]);putenv(enval);return 1;}else if (strcmp(argv[0], "echo") == 0){if (argv[1] == NULL)return 1;if (argv[1][0] == '$' && strlen(argv[1]) > 1){char *val = argv[1] + 1;if (strcmp(val, "?") == 0) // $?{printf("%d\n", lastcode);lastcode = 0;}else // $PATH $HOME ......{const char *enval = getenv(val);if (enval)printf("%s\n", enval);elseprintf("\n");}}else{printf("%s\n", argv[1]);}return 1;}else if (0){}return 0;
}int Execute(char *argv[])
{pid_t id = fork();if (id < 0)return -1;else if (id == 0){// 子进程if (redir != NoneRedir){int fd = 0;if (redir == OutputRedir){fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0664);// printf("OUT\n"); // debugdup2(fd, 1);}else if (redir == AppendRedir){fd = open(filename, O_WRONLY | O_APPEND, 0664);// printf("APP\n"); // debugdup2(fd, 1);}else if (redir == InputRedir){fd = open(filename, O_RDONLY | O_CREAT, 0664);// printf("IN\n"); // debugdup2(fd, 0);}else{}}execvp(argv[0], argv);exit(1);}else{// 父进程int status = 0;pid_t rid = waitpid(id, &status, 0);if (rid > 0){if (WIFEXITED(status)){lastcode = WEXITSTATUS(status);}}else{// printf("wait fail, error!\n"); // debug}}return 0;
}#define SkipSpace(pos) \do \{ \while (isspace(*(pos))) \++pos; \} while (0)void CheckRedirection(char username[], int len)
{char *start = username, *end = &username[len - 1];while (start < end){if (*end == '>'){if (start < end - 1){if (*(end - 1) == '>') // 追加{redir = AppendRedir;*(end - 1) = '\0';}else // 输出{redir = OutputRedir;*end = '\0';}// printf("start:%s\n", start); // debugfilename = end + 1;SkipSpace(filename);// printf("filename:%s\n", filename); // debug}break;}else if (*end == '<'){redir = InputRedir; // 输入*end = '\0';filename = end + 1;SkipSpace(filename);break;}else{--end;}}
}int main()
{while (1){char usercommand[NUM];char *argv[SIZE];filename = NULL;redir = NoneRedir;// 1. 打印提示符&&获取用户命令字符串获取成功int n = GetUserCommand(usercommand, sizeof(usercommand));if (n <= 0)continue;// 2. 判断是否含有重定向操作符,并设置标记位CheckRedirection(usercommand, strlen(usercommand));// 3. 分割字符串// "ls -a -l" -> "ls" "-a" "-l"CommandSplit(usercommand, argv);// 4. check build-in commandn = DoBulidIn(argv);if (n)continue;// 5. 执行对应的命令Execute(argv);}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 0int _blksize;
#elseint _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当中的缓冲区
我们来看看下面这段代码,代码当中分别用了两个 C 库函数和一个系统接口向显示器输出内容,在代码最后还调用了 fork 函数。
#include <stdio.h>
#include <unistd.h>
int main()
{//cprintf("hello printf\n");fputs("hello fputs\n", stdout);//systemwrite(1, "hello write\n", 12);fork();return 0;
}
运行该程序,我们可以看到 printf、fputs 和 write 函数都成功将对应内容输出到了显示器上。
但是,当我们将程序的结果重定向到 log.txt 文件当中后,我们发现文件当中的内容与我们直接打印输出到显示器的内容是不一样的。
那为什么C库函数打印的内容重定向到文件后就变成了两份,而系统接口打印的内容还是原来的一份呢?
首先我们应该知道的是,缓冲的方式有以下三种:
- 无缓冲。
- 行缓冲。(常见的对显示器进行刷新数据)
- 全缓冲。(常见的对磁盘文件写入数据)
当我们直接执行可执行程序,将数据打印到显示器时所采用的就是行缓冲,因为代码当中每句话后面都有
\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 结构体当中不仅保存了对应文件的文件描述符还保存了用户缓冲区的相关信息。
操作系统有缓冲区吗?
操作系统实际上也是有缓冲区的,当我们刷新用户缓冲区的数据时,并不是直接将用户缓冲区的数据刷新到磁盘或是显示器上,而是先将数据刷新到操作系统缓冲区,然后再由操作系统将数据刷新到磁盘或是显示器上。(操作系统有自己的刷新机制,我们不必关心操作系统缓冲区的刷新规则)
因为操作系统是进行软硬件资源管理的软件,根据下面的层状结构图,用户区的数据要刷新到具体外设必须经过操作系统。
🍎理解文件系统
我们知道文件可以分为磁盘文件和内存文件,内存文件前面我们已经谈过了,下面我们来谈谈磁盘文件。
初识 inode
磁盘文件由两部分构成,分别是文件内容和文件属性。文件内容就是文件当中存储的数据,文件属性就是文件的一些基本信息,例如文件名、文件大小以及文件创建时间等信息都是文件属性,文件属性又被称为元信息。
在命令行当中输入ls -l
,即可显示当前目录下各文件的属性信息。
其中,各列信息所对应的文件属性如下:
在 Linux 操作系统中,文件的元信息和内容是分离存储的,其中保存元信息的结构称之为 inode,因为系统当中可能存在大量的文件,所以我们需要给每个文件的属性集起一个唯一的编号,即 inode 号。
也就是说,inode 是一个文件的属性集合,Linux 中几乎每个文件都有一个 inode,为了区分系统当中大量的 inode,我们为每个 inode 设置了 inode 编号。
在命令行当中输入ls -i
,即可显示当前目录下各文件的 inode 编号。
注意: 无论是文件内容还是文件属性,它们都是存储在磁盘当中的。
磁盘的概念
什么是磁盘?
磁盘是一种永久性存储介质,在计算机中,磁盘几乎是唯一的机械设备。与磁盘相对应的就是内存,内存是掉电易失存储介质,目前所有的普通文件都是在磁盘中存储的。
磁盘在冯诺依曼体系结构当中既可以充当输入设备,又可以充当输出设备。
磁盘的基本概念
磁盘的寻找方案
对磁盘进行读写操作时,一般有以下几个步骤:
- 确定读写信息在磁盘的哪个盘面。
- 确定读写信息在磁盘的哪个柱面。
- 确定读写信息在磁盘的哪个扇区。
通过以上三个步骤,最终确定信息在磁盘的读写位置。
磁盘分区与格式化介绍
线性存储介质
磁盘线性存储介质是指将数据按照顺序存储在磁盘上的介质,如传统的机械硬盘(HDD)等。
数据的读写按照顺序进行,存储数据的介质从一个端口输入数据,经过复杂的控制器,在另一个端口输出数据,输出的数据与输入的数据顺序一致。
磁盘分区
磁盘通常被称为块设备,一般以扇区为单位,一个扇区的大小通常为 512 字节(4 KB)。我们若以大小为 512G 的磁盘为例,该磁盘就可被分为十亿多个扇区。
计算机为了更好的管理磁盘,于是对磁盘进行了分区。磁盘分区就是使用分区编辑器在磁盘上划分几个逻辑部分,盘片一旦划分成数个分区,不同的目录与文件就可以存储进不同的分区,分区越多,就可以将文件的性质区分得越细,按照更为细分的性质,存储在不同的地方以管理文件,例如在 Windows 下磁盘一般被分为 C 盘和 D 盘两个区域。
磁盘格式化
当磁盘完成分区后,我们还需要对磁盘进行格式化。磁盘格式化就是对磁盘中的分区进行初始化的一种操作,这种操作通常会导致现有的磁盘或分区中所有的文件被清除。
简单来说,磁盘格式化就是对分区后的各个区域写入对应的管理信息。
其中,写入的管理信息是由文件系统决定的,不同的文件系统格式化时写入的管理信息是不同的,常见的文件系统有 EXT2、EXT3、XFS、NTFS 等。
EXT2文件系统的存储方案
计算机为了更好的管理磁盘,会对磁盘进行分区。而对于每一个分区来说,分区的头部会包括一个启动块(Boot Block),对于该分区的其余区域,EXT2 文件系统会根据分区的大小将其划分为一个个的块组(Block Group)。
注意: 启动块的大小是确定的,而块组的大小是由格式化的时候确定的,并且不可以更改。
每个组块都有着相同的组成结构,每个组块都由超级块(Super Block)、块组描述符表(Group Descriptor Table)、块位图(Block Bitmap)、inode 位图(inode Bitmap)、inode 表(inode Table)以及数据表(Data Block)组成。
- Super Block: 存放文件系统本身的结构信息。记录的信息主要有:Data Block 和 inode 的总量、未使用的 Data Block 和 inode 的数量、一个 Data Block 和 inode 的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息。Super Block 的信息被破坏,可以说整个文件系统结构就被破坏了。
- Group Descriptor Table: 块组描述符表,描述该分区当中块组的属性信息。
- Block Bitmap: 块位图当中记录着 Data Block 中哪个数据块已经被占用,哪个数据块没有被占用。
- inode Bitmap: inode 位图当中记录着每个 inode 是否空闲可用。
- inode Table: 存放文件属性,即每个文件的 inode。
- Data Blocks: 存放文件内容。
注意:
- 其他块组当中可能会存在冗余的 Super Block,当某一 Super Block 被破坏后可以通过其他的 Super Block 进行恢复。
- 磁盘分区并格式化后,每个分区的 inode 个数就确定了。
如何理解创建一个空文件?
- 通过遍历 inode 位图的方式,找到一个空闲的 inode。
- 在 inode 表当中找到对应的 inode,并将文件的属性信息填充进 inode 结构中。
- 将该文件的文件名和 inode 指针添加到目录文件的数据块中。
如何理解对文件写入信息?
- 通过文件的 inode 编号找到对应的 inode 结构。
- 通过 inode 结构找到存储该文件内容的数据块,并将数据写入数据块。
- 若不存在数据块或申请的数据块已被写满,则通过遍历块位图的方式找到一个空闲的块号,并在数据区当中找到对应的空闲块,再将数据写入数据块,最后还需要建立数据块和 inode 结构的对应关系。
注:一个文件使用的数据块(Data Block)和 inode 结构的对应关系,是通过 inode 中的一个数组进行维护的,该数组一般可以存储 15 个元素,其中前 12 个元素分别对应该文件使用的 12 个数据块,剩余的三个元素分别是一级索引、二级索引和三级索引,当该文件使用数据块的个数超过 12 个时,可以用这三个索引进行数据块扩充。
如何理解删除一个文件?
- 将该文件对应的 inode 在 inode 位图当中置为无效。
- 将该文件申请过的数据块在块位图当中置为无效。
因为此操作并不会真正将文件对应的信息删除,而只是将其 inode 号和数据块号置为了无效,所以当我们删除文件后短时间内是可以恢复的。为什么说是短时间内呢,因为该文件对应的 inode 号和数据块号已经被置为了无效,因此后续创建其他文件或是对其他文件进行写入操作申请 inode 号和数据块号时,可能会将该置为无效了的 inode 号和数据块号分配出去,此时删除文件的数据就会被覆盖,也就无法恢复文件了。
为什么拷贝文件的时候很慢,而删除文件的时候很快?
因为拷贝文件需要先创建文件,然后再对该文件进行写入操作,该过程需要先申请 inode 号并填入文件的属性信息,之后还需要再申请数据块号,最后才能进行文件内容的数据拷贝,而删除文件只需将对应文件的 inode 号和数据块号置为无效即可,无需真正的删除文件,因此拷贝文件是很慢的,而删除文件是很快的。
如何理解目录?
- 都说在 Linux 下一切皆文件,目录当然也可以被看作为文件。
- 目录有自己的属性信息,目录的 inode 结构当中存储的就是目录的属性信息,比如目录的大小、目录的拥有者等。
- 目录也有自己的内容,目录的数据块当中存储的就是该目录下的文件名以及对应文件的 inode 指针。
注意: 每个文件的文件名并没有存储在自己的 inode 结构当中,而是存储在该文件所处目录文件的文件内容当中。因为计算机并不关注文件的文件名,计算机只关注文件的 inode 号,而文件名和文件的 inode 指针存储在其目录文件的文件内容当中后,目录通过文件名和文件的 inode 指针即可将文件名和文件内容及其属性连接起来。
🍏软硬链接
软链接
我们可以通过以下命令创建一个文件的软连接。
ln -s [源文件或目录] [软链接名]
通过ls -i -l
命令我们可以看到,软链接文件的 inode 号与源文件的 inode 号是不同的,并且软链接文件的大小比源文件的大小要小得多。
软链接又叫做符号链接,软链接文件相对于源文件来说是一个独立的文件,该文件有自己的 inode 号,但是该文件只包含了源文件的路径名,所以软链接文件的大小要比源文件小得多。软链接就类似于Windows操作系统当中的快捷方式。
但是软链接文件只是其源文件的一个标记,当删除了源文件后,链接文件不能独立存在,虽然仍保留文件名,但却不能执行或是查看软链接的内容了。
硬链接
我们可以通过以下命令创建一个文件的硬连接。
ln [源文件] [硬链接名]
通过ls -i -l
命令我们可以看到,硬链接文件的 inode 号与源文件的 inode 号是相同的,并且硬链接文件的大小与源文件的大小也是相同的,特别注意的是,当创建了一个硬链接文件后,该硬链接文件和源文件的硬链接数都变成了 2。
硬链接文件就是源文件的一个别名,一个文件有几个文件名,该文件的硬链接数就是几,这里 inode 号为 536079 的文件有 myproc 和 myproc-h 两个文件名,因此该文件的硬链接数为 2。
与软连接不同的是,当硬链接的源文件被删除后,硬链接文件仍能正常执行,只是文件的链接数减少了一个,因为此时该文件的文件名少了一个。
理解:硬链接就是让多个不在或者同在一个目录下的文件名,同时能够修改同一个文件,其中一个修改后,所有与其有硬链接的文件都一起修改了。
我们创建一个普通文件,该普通文件的硬链接数是 1,因为此时该文件只有一个文件名。那为什么我们创建一个目录后,该目录的硬链接数是 2?
因为每个目录创建后,该目录下默认会有两个隐含文件
.
和..
,它们分别代表当前目录和上级目录,因此这里创建的目录有两个名字,一个是dir
,另一个就是该目录下的.
,所以刚创建的目录硬链接数是 2。通过命令我们也可以看到dir
和该目录下的.
的 inode 号是一样的,也就可以说明它们代表的实际上是同一个文件。
小技巧: 目录D
的硬链接数 = 2 + 子目录数。。
软硬链接的区别
- 软链接是一个独立的文件,有独立的 inode,而硬链接没有独立的 inode。
- 软链接相当于快捷方式,硬链接本质没有创建文件,只是建立了一个文件名和已有的 inode 的映射关系,并写入当前目录。
🍐文件的三个时间
在 Linux 当中,我们可以使用命令stat 文件名
来查看对应文件的信息。
这其中包含了文件的三个时间信息:
- Access: 文件最后被访问的时间。
- Modify: 文件内容最后的修改时间。
- Change: 文件属性最后的修改时间。
当我们修改文件内容时,文件的大小一般也会随之改变,所以一般情况下 Modify 的改变会带动 Change 一起改变,但修改文件属性一般不会影响到文件内容,所以一般情况下 Change 的改变不会带动 Modify 的改变。
我们若是想将文件的这三个时间都更新到最新状态,可以使用命令touch 文件名
来进行时间更新。
注意: 当某一文件存在时使用 touch 命令,此时 touch 命令的作用变为更新文件信息。