目录
库函数I/O接口
在之前 C 语言进阶中,我们学习了文件的许多操作,包括fopen
、fclose
、fread
、fwrite
、fseek
等等,这部分在这里就不做赘述,如果还有小伙伴不懂得可以先看看我的那篇博客:点此跳转
系统调用I/O接口
open
函数
int open(const char* pathname, int flags, mode_t mode);
- 行为:打开指定文件,返回文件描述符;
pathname
:要打开文件的路径名;flags
:操作标志位,由下面这些选项按位或组成;- 以下三个必选其一:
O_RDONLY
:只读;O_WRONLY
:只写;O_RDWR
:可读可写;
- 以下几个为可选项:
O_CREAT
:若文件不存在则创建新文件;O_EXCL
:与选项O_CREAT
搭配使用,若文件已经存在则报错,例如大部分 APP 当打开一个时,再次双击打开时会提示已存在;O_TRUNC
:打开普通文件时,若该文件已存在且可写,那么会清空文件内容;O_APPEND
:向文件输入时总是从文件末尾开始写入;
- 以下三个必选其一:
mode
:在创建新文件时,所要指定的默认使用权限,因此当打开的文件不存在时,则该参数需要写,否则不用写;mode 以四位数字表示,第一位为 0,后三位为三个八进制数,该权限与通常会被umask
修改,所以一般新建文件的权限为 mode & ~umask (也就是 mode 按位与上默认权限掩码取反的值);umask
:在 shell 中敲击该命令则会显示默认权限掩码是多少,并且还可以通过该指令修改默认权限掩码,但是如果我们为了创建一个符合自己需求的文件使用权限而将整个 shell 的默认权限掩码都修改了,这样并不合适,所以我们在代码中可以使用umask(四位数字)
函数,只对该代码设置特定的权限掩码,从而不影响外面的设置;
- 返回值:成功则返回一个非负整数,该非负整数称为文件描述符,就相当于文件的操作句柄,失败返回 -1;
write
函数
ssize_t write(int fd, const void* buf, size_t count);
- 行为:通过文件描述符找到打开的文件,再进行内容的写入;
fd
:通过打开文件所返回的文件描述符;buf
:要写入文件的数据所存放的空间地址;count
:要写入文件的数据长度,以字节为单位;- 返回值:成功写入则返回实际写入的数据长度,以字节为单位,失败则返回 -1;
read
函数
ssize_t read(int fd, void* buf, size_t count);
- 行为:从文件中读取数据放入到指定的内存空间中;
fd
:通过打开文件所返回的文件描述符;buf
:要读出文件的数据所存放的空间地址;count
:要读出文件的数据长度,以字节为单位,该长度不能大于 buf 存放空间的长度;- 返回值:成功读取则返回实际读取到的数据长度,以字节为单位,失败则返回 -1;
lseek
函数
off_t lseek(int fd, off_t offset, int whence);
- 行为:按照需求跳转打开文件后进行操作的位置;
fd
:通过打开文件所返回的文件描述符;offset
:相对起始参照位置的偏移量,该值如果是负数则代表向前,如果是正数则代表了向后,然后从该位置进行读写操作;whence
:起始参照位置,也就是自己规定的起点,SEEK_SET
—起始位置,SEEK_CUR
—当前位置,SEEK_END
—末尾位置;- 返回值:成功跳转则返回相对于文件起始位置的偏移量,失败则返回 -1;
close
函数
int close(int fd);
- 行为:关闭通过文件描述符所代表的文件;
fd
:通过打开文件所返回的文件描述符;- 返回值:成功关闭则返回 0,失败则返回 -1;
文件描述符
零碎概念
file
结构体:在 Linux 中,当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件,最后组织成了struct file
结构体,该结构体我们称之为文件描述信息;files_struct
结构体:当在一个进程中执行open
系统调用打开一个文件时,必须让进程和文件关联起来,每个进程都有一个指针files_struct*
,该指针指向一张表files_struct
,表中最重要的部分就是包涵一个指针数组,数组的每个元素都是一个指向打开文件的文件描述信息的指针;- 文件描述符
fd
:是file_struct
中指针数组的下标,通过这个下标就能拿到数组中的指针,然后由该指针找到要操作的文件描述信息,进而就可以访问文件;
fd
分配规则
从上面的图片中,我们能大概了解到当进程打开一个文件之后,进程与文件之间的关系主要是通过文件描述符来连接起来的,而我们通过open
的返回值也能获取到文件描述符—fd;
如果我们将 fd 打印出来,那么就会发现,一般情况下该值永远是大于 2 的,并且如果只打开了一个文件的话,那么该值基本是 3,这是因为一个程序在运行之后,就会默认打开三个文件:标准输入、标准输出、标准错误,因此这三个文件分别占用了指针数组的 0、1、2 三个下标,下面进行代码演示:
#include<stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main(){
int fd = open("myfile", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
如果我们在打开文件之前,就先使用close
函数关闭了文件描述符 0 号,然后再打开一个文件,那么打开该文件返回的 fd 是等于 0 的,同样的如果关闭了文件描述符 2 号,然后再打开一个文件,那么打开该文件返回的 fd 是等于 2 的,这就说明了,文件描述符的分配规则是:最小未使用数组下标,也就是说,从 0 号开始,哪个未使用就为打开的文件分配那个文件描述符,下面进行代码演示:
#include<stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main(){
close(2);
int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
重定向
原理
通过改变文件描述符所对应的文件描述信息,进而改变了要操作的文件,实现了数据流向的改变;说得通俗一点就是,我们在编写代码时,如果将 1 号文件描述符所指向的文件关闭的话,那么当我们打开一个新文件 test.txt 时,1 号文件描述符所指向的文件将会改变为 test.txt,此时原本将会打印在终端上的内容都会写入到 test.txt 文件中,从而实现了数据输出重定向;
不过需要注意的是,标准输出 stdout 默认屏幕终端为行缓冲,重定向文件和重定向管道为全缓冲;也就是说如果是正常在终端打印的话,那么打印的内容会在遇到换行或者程序结束而输出到终端上,但是如果是重定向的文件和管道的话,那么内容需要手动刷新缓冲区或者程序结束才写入文件,具体怎么实现实时输出重定向写入,请参考这篇博客:Linux 输出流重定向缓冲设置
另外需要注意的是,如果是代码中实现了重定向写入,那么就要分两种情况(这是我在写代码时发现的,也不知是否正确,但是理论上是这么回事):
- 如果是关闭 1 号文件,然后在创建一个新文件使其成为 1 号文件,通过此种方式实现重定向输出,那么就只有手动刷新缓冲区才可写入,否则就算程序结束也无法写入;
- 如果是由
dup2
函数实现的代码中的重定向写入,那么手动刷新缓冲区或者程序结束都可写入文件;
#include<stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main(){
close(1);
int fd = open("myfile", O_WRONLY|O_CREAT, 00644);
if(fd < 0){
perror("open");
return 1;
}
printf("fd: %d\n", fd);
//这里属于代码中关闭 1 号文件实现的输出重定向,因此需要加刷新文件缓冲区,否则就算程序结束也无法写入
fflush(stdout);
close(fd);
return 0;
}
dup2
函数
int dup2(int oldfd, int newfd);
- 概念:除了上面的关闭文件外,我们也可以使用特定的函数来实现在代码中输出重定向;
- 功能:拷贝 oldfd 下标中的指针信息,并将其赋给 newfd 下标中的指针,这样一来看似是向 newfd 中输入,其实是向 oldfd 中输入;
newfd
:现如今所打开操作的文件的文件描述符;oldfd
:需要被重定向文件的文件描述符;- 下面写了一个简单的代码,主要功能是读取标准输入的内容,然后再将内容重定向输出到 myfile 文件中;
#include<stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
int main(){
int fd = open("myfile", O_CREAT | O_RDWR);
if (fd < 0) {
perror("open");
return 1;
}
//重定向输出
dup2(fd, 1);
char buf[1024] = {0};
//从标准输入读取数据
ssize_t read_size = read(0, buf, sizeof(buf) - 1);
if (read_size < 0) {
perror("read");
}
printf("%s", buf);
//刷新文件缓冲区可有可无,如果程序运行结束了,数据会自然写入文件,如果需要实时写入,那么就需要该刷新文件缓冲
fflush(stdout);
close(fd);
return 0;
}
系统调用接口与库函数
概念
前面我们说过,库函数是在系统调用接口的基础上进行了内容的增加并封装,实现了更多特定功能场景的特殊需求,因此库函数需要实现,最终还是要借助系统调用接口,例如fopen
库函数的返回值是 FILE* 指针,而open
系统调用接口返回的是整型变量 fd 文件描述符,当使用 FILE*进行操作时,实际上就是在操作 fd;
缓冲区
库函数在原有基础上增加了一些功能,其中就包括缓冲区;由于 CPU 的处理速率远大于外设的输入输出速率,这样一来输入一个数据或者输出一个数据就进行一次 CPU 与外设的交互,CPU 的效率会大大降低的;于是出现缓冲区来解决这一问题,缓冲区其实就是一块内存,用来存放一定量的数据,等数据存放的差不多时或需要时再进行 CPU 与外设的交互,以便提升 I/O 性能,提升 CPU 运行效率;
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main (){
printf("printf");
fprintf(stdout, "fprintf");
fwrite("fwrite", 6, 1, stdout);
write(1, "write", 5);
sleep(3);
return 0;
}
在上面的代码中,需要打印或者写入的字符串都是没有\n
换行符的,而从运行结果我们可以看出,write
函数在第一时间内就将内容输出,其他几个函数在程序结束之后才将内容输出,这说明了write
函数并没有缓冲区,所以内容会在执行的第一时间输出,而其他几个由于没有\n
或者刷新缓冲区函数,所以在程序运行结束之后才刷新缓冲区,将内容输出;
库函数是由缓冲区机制的,而系统调用接口是没有缓冲区机制的,所以在今后的使用中,我们需要根据具体的情况进而做出适当地选择,我么也可以通过手动刷新缓冲区进而实现和库函数类似的功能,一些具体的手动刷新缓冲区的方法大家可以自行上网查阅,或者参考此篇博客:Linux 输出流重定向缓冲设置
shell模拟中加入重定向功能
在前面的进程操作博客中,当学习到程序替换知识时,我们编写了一个简单的代码——shell 的简单模拟实现,现在,我们了解到重定向概念,因此在我们原先的基础上也增加该功能,使其更加完善;
-
简单描述一下实现的步骤:
-
1.捕捉键盘输入,并设置标志位 flag;
2.解析输入内容是否包含重定向字符,遇到>
则替换为\0
,且在此前面的参数为命令参数,在此后面的参数为重定向方式以及重定向文件名程;
3.解析输入信息,得到命令名称以及运行参数;
4.创建子进程;
5.按照>
的个数,选择打开文件的参数选项,对子进程的标准输出进行重定向;
6.在子进程中进行程序替换,如果替换失败则子进程退出;
7.父进程等待子进程退出;
注意:在进程 pcb 中,有个
files_sttruct
结构体,这是进程打开的文件的各项描述信息,在进行程序替换时,意味着进程会调度一个新的程序运行,初始化虚拟地址空间,进程中原先的代码数据都替换为新的代码和数据了,但是程序替换并不会修改files_struct
结构体中的内容,因此程序替换之前打开的文件并不会随着程序替换而关闭,原先重定向信息也不会改变;
#include<string.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
#include<fcntl.h>
int main(){
//循环进行指令的输入
while(1){
//设置接收数组
char buf[1024] = {0};
//模仿 shell 的格式输出指令提示符
printf("[user@bogon ~]$ ");
//上一行代码由于换行的话格式就不好看了,所以不能使用 \n,所以可以使用缓冲区刷新函数
fflush(stdout);
//获取标准输入中的一行字符
fgets(buf, 1023, stdin);
buf[strlen(buf) - 1] = '\0';
//设置记录>个数的标志位
int flag = 0;
//找到文件名的标志位
int end = 0;
//取文件名
char* str = NULL;
//遍历输入信息,截断命令参数和重定向信息
for(int i = 0; buf[i] != '\0'; i++){
//如果找到>,那么就将其变为\0,并且flag++
if(buf[i] == '>'){
buf[i] = '\0';
flag++;
}else if(flag != 0 && end == 0 && buf[i] != ' '){
//如果在找到>之后,且第一次遇到字符时,那么该字符串就是文件名,将其提取出来
str = &buf[i];
end++;
}else if(flag != 0 && end == 1 && buf[i] == ' ')
//在文件名末尾的后一个位置赋上\0
buf[i] = '\0';
}
//设置 esec 替换程序函数所需的参数
int argc = 0;
char* argv[32] = {0};
char* it = buf;
//将从标准输入获取的字符放入到参数数组 argv 中
while(*it != '\0'){
//如果遇到的不是空格,那么就将其首地址放入到 argv 中
if(*it != ' '){
argv[argc++] = it;
//放入后,我们需要将其单个包装起来,也就是将一串没有空格的字符变成字符串,那就是在其后放置 \0
while(*it != '\0' && *it != ' ')
it++;
*it = '\0';
}
//更新变量
it++;
}
//exec 函数第二个参数的最后一个是 NULL
argv[argc] = NULL;
//cd 命令是内置指令,因此不能随意使用,所以在遇到该命令之后将操作所在的目录换为需要的即可
if(strcmp("cd", argv[0]) == 0){
chdir(argv[1]);
continue;
}
//创建子进程
pid_t pid = fork();
if(pid < 0){
perror("fork faild");
continue;
}else if(pid == 0){
//在子进程中判断是否需要重定向
int fd = 1;
//如果只有一个>,那么就是覆盖重定向
if(flag == 1)
fd = open(str, O_RDWR | O_CREAT | O_TRUNC, 0664);
//如果有两个>,那么就是追加重定向
else if(flag == 2)
fd = open(str, O_RDWR | O_CREAT | O_APPEND, 0664);
//文件打开失败,那么就输出错误信息
if(fd < 0){
perror("open error");
exit(-1);
}
//输出重定向
dup2(fd, 1);
//程序替换
execvp(argv[0], argv);
perror("exec faild");
exit(-1);
}
//父进程等待子进程退出,防止变成僵尸进程
wait(NULL);
}
return 0;
}
静态库与动态库
连接方式
- 动态链接:链接动态库生成可执行程序,并没有把库中函数的实现指令直接拿过来写入可执行程序中,而是在可执行程序中记录了库中函数的符号信息表,在执行程序的时候需要去加载动态库到内存中;
- 优点:程序小,运行时动态库被加载到内存中;可共享,可以多个程序使用同一份内存中库函数的代码,减小冗余;便于模块代码替换;
- 适用:一些模块化、便于功能替换的接口适用动态库;
- 缺点:依赖强,运行需要依赖动态库,若动态库不存在,则无法运行;
- 静态链接:链接静态库生成可执行程序,直接将库中我们用到的函数的实现代码指令,写入到了可执行程序文件中;
- 优点:无依赖,不需要任何外物就可直接运行;
- 适用:功能改动小,并且只有当前程序使用的时候使用静态库;
- 缺点:程序大,可执行程序中会加载很多函数;冗余高,若多个程序使用同一个库函数,则这个库函数会被包含多份;
生成方式
静态库
- 将每个原码文件,先编译汇编成为机器指令代码;
gcc -c xxx.c -o xxx.o
:先将.c
的程序代码使用 gcc 编译器生成为.o
链接文件;
- 将用到的机器指令代码打包到一起;
ar -cr lib库名称.a xxx.o ...
:再使用该命令将多个链接文件生成为静态库文件,静态库文件的名字是固定格式的,以 lib 开头,再以 .a 结尾;
ar -tv lib库名称.a
:查看静态库中的目录列表,-t
—列出静态库中的文件,-v
—显示详细信息;
动态库
- 将每个原码文件,先编译汇编成为机器指令代码;
gcc -fPRC -c xxx.c -o xxx.o
:先将.c
的程序代码使用 gcc 编译器生成为.o
链接文件,加上-fPRC
选项是为了生成位置无关代码;
- 将用到的机器指令代码打包到一起;
gcc --shared xxx.o ... -o lib库名称.so
:再使用该命令将多个链接文件生成共享库格式,动态库文件的名字是固定格式的,以 lib 开头,再以 .so 结尾;
使用方式
生成可执行程序时连接使用
gcc xxx.c -o xxx.exe -l库名称
:在将程序代码使用 gcc 编译器生成可执行程序时链接库文件,连接方式为-l
选项后直接接库文件名称,此处库文件名称为去掉前后缀之后的那部分;- 在使用该方式之前,需要注意 gcc 编译器会到指定路径下去找对应的库文件,也就是说使用某个库文件,那么在指定的路径下应该包含该库文件,一般默认路径为——
/usr/lib64
,那么完成这一目的的方法有以下三种:- 将库文件放到指定路径下:
cp libxxx.a /usr/lib64
; - 设置环境变量
LIBRARY_PATH
:export LIBRARY_PATH=$LIBRARY_PATH:库所在路径; - 使用 gcc 的
-L
选项指定库文件所在路径:gcc xxx.c -o xxx.exe -L库文件所在路径 -l库文件名称
;
- 将库文件放到指定路径下:
运行可执行程序时加载使用(仅针对动态链接的动态库)
- 生成可执行程序时连接的是动态库,那么在运行时会出现问题,要想解决就需要在运行前使用下面两种方法:
- 库文件需要放到指定的路径下:
cp libxxx.so /usr/lib64
; - 设置环境变量
LD_LIBRARY_PATH
:export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:库所在路径
;
- 库文件需要放到指定的路径下:
文件系统
存储方式
在 Linux ext2 文件系统中,上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,它分区被划分为一个个的 block,一个 block 的大小是由格式化的时候确定的,并且不可以更改,其中各项信息如下:
- Block Group:ext2 文件系统会根据分区的大小划分为数个 Block Group,而每个 Block Group 都有着相同的结构组成,被分为多个区块,每个区块中记录着不同的信息,下面为各区块含义;
- Super Block(超级快):存放文件系统本身的结构信息,记录的信息主要有:bolck 和 inode 的总量、未使用的 block 和 inode 的数量、一个 block 和 inode 的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息,Super Block 的信息被破坏,可以说整个文件系统结构就被破坏了;
- Group Descriptor Table(块组描述符):描述块组属性信息,有兴趣的小伙伴可以自己上网解一下;
- Block Bitmap(块位图):Block Bitmap 中记录着 Data Block 中哪个数据块已经被占用,哪个数据块没有被占用;
- inode Bitmap(inode 位图):每个 bit 表示一个 inode 是否空闲可用;
- inode table(inode 节点表):存放文件属性如:文件大小、所有者、权限、最近修改时间、在磁盘哪些区块存放等等;
- date blocks(数据区):存放文件数据内容;
inode
概念
在系统中文件存储在磁盘区块,而关于文件的具体信息是记录在 inode 节点表中的,每一个 inode 节点都记录了文件的:大小、权限、时间…所存储的磁盘区块号等等;
每一个 inode 节点都对应了一个 inode_id,通过 inode_id 我么就可以拿到一个 inode 节点信息,而每一个文件在目录下的存放包括两个信息:inode_id + 文件名称,通常我们使用ls
命令只能查看到文件名称,但是我们可以通过以下方式查看到每一个文件对应的 inode_id:
ls -i
:查看当前目录下的文件,并且显示它们的 inode_id;ls -i 文件名
:查看当前目录下指定文件的 inode_id;stat 文件名
:查看指定文件的详细信息,其中一项就包括了 inode_id;
文件创建
- 存储属性
内核先找到一个空闲的 inode 节点,这里假设找到的节点的 inode_id 为 263466,内核把文件信息记录到其中; - 存储数据
假设某个文件需要存储在三个磁盘块,内核找到了三个空闲块:300、500、800,将内核缓冲区的第一块数据复制到 300,下一块复制到 500,以此类推; - 记录分配情况
文件内容按顺序 300,500,800 存放,内核在inode上的磁盘分布区记录了上述块列表; - 添加文件名到目录
假设新的文件名为 abc.txt,内核将入口(也就是inode_id + 文件名称:263466,abc.txt)添加到目录文件,文件名和 inode_id 之间的对应关系将文件名和文件的内容及属性连接起来;
建立链接
硬链接
通过上面的知识,我们可以了解到,真正找到磁盘上文件的并不是通过文件名,而是通过 inode,其实在 linux 中可以让多个文件名对应于同一个 inode,而这种方式我们称之为两个文件间的硬链接,可以通过下面的命令实现:
ln 源文件名称 新创建文件名称
:创建一个新文件,使得新文件与源文件(已存在文件)之间建立硬链接关系;
建立链接之后,源文件与新文件的链接状态完全相同,他们被称为指向同一份文件的硬链接,内核记录了这个连接数为 2。
我们在删除文件时干了两件事情:1.在目录中将对应的记录删除,2.将硬连接数 -1,如果为 0,则将文件存放的对应的磁盘释放;
软链接
硬链接是通过同一个 inode 引用不同的文件来实现,软链接则是通过不同的 inode 来存放源文件的访问路径来实现,因此源文件被删除,那么软链接就失效了,软链接可以通过以下命令来实现:
ln -s 源文件名称 新创建文件名称
:创建一个新文件,使得新文件与源文件之间建立软链接关系;