目录
一、 重新谈论文件
- 空文件也要在磁盘上占据空间;
- 文件 = 文件内容 + 文件属性;
- 文件操作 = 对内容 or 对属性 or 对内容和属性;
- 要标定一个问题必须使用 文件路径 + 文件名 (唯一性);
- 如果没有指明对应的文件路径,默认是在当前路径进行文件访问;
- 可执行程序没运行之前文件对应的操作是没有被执行的,对文件的操作本质上是进程对文件的操作;
- 一个文件要被访问,就必须先被打开;
文件操作的本质就是 进程和被打开的文件之间的关系
那么我们知道语言是有文件操作接口的,且每个语言的操作接口都不一样。但是文件是在磁盘上的,要想访问磁盘这个硬件就不可能绕开操作系统,就要使用操作系统提供的文件级别的系统调用接口,所以无论上层语言如何变化,库函数底层必须调用系统调用接口。
文件调用接口
库函数调用接口:fopen / fclose / fwrite / fread / fseek
系统调用接口 :open / close / write / read / lseek
这里展示open接口,write read close lseek ,类比C文件相关接口
open
#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);
pathname: 要打开或创建的目标文件
flags: 打开文件时,可以传入多个参数选项,用下面的一个或者多个常量进行“或”运算,构成flags。
mode:文件的权限值,如0666,会受到默认权限掩码的影响,如果想使用自己设置的权限值,可加umask(0),更改默认的权限掩码
参数:
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
这三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写
O_TRUNC:清空文件内容
返回值:
成功:新打开的文件描述符
失败:-1
库函数接口是由系统接口封装得来的,而系统调用接口必须用文件描述符。
二、 如何理解文件
进程可以打开多个文件吗?当然可以!那么系统中一定会存在大量的被打开的文件,这些被打开的文件是要被操作系统管理起来的,怎么管理呢?操作系统会为文件创建对应的 内核数据结构标识文件,它里面包含了文件的大部分属性。
1.文件描述符
文件描述符是一个连续小整数,默认从3开始,因为前三个被操作系统占用了,0是stdin - 标准输入(键盘),1是stdout - 标准输出(显示器),2是stderr - 标准错误(显示器)。像这种连续的小整数我们称为数组,为什么是数组,因为操作系统要管理文件,每个文件都会有所对应的结构体对象,而进程和文件之间的关联关系是在内核当中,通过文件描述符表将进程和文件关联起来的。进程里有个指针指向文件描述符表,文件描述符表里包含了一个指针数组,里面的每个元素都是一个指向打开文件的指针,所以文件描述符的本质就是数组下标。
2.文件描述符的分配规则
我们知道文件描述符默认从3开始,那么我们关闭0或2呢?
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int main()
{
//close(0);
//close(2);
umask(0);
int fd = open("myfile.txt", O_RDONLY | O_CREAT | O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
close(fd);
return 0;
}
可以看到默认fd=3,如果关闭0,fd=0,如果关闭2,fd=2,如果两个一起关闭,那么fd还是0。由此可以得出文件描述符的分配规则是在files_struct数组当中,找到当前没有被使用的最小的一个下标,作为新的文件描述符。
3. 重定向
如果关闭1呢?
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
close(1);
int fd = open("myfile", O_WRONLY | O_CREAT | O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
printf("fd: %d\n", fd);
fflush(stdout);
close(fd);
exit(0);
}
可以看到本来应该输出到显示器上的内容,输出到了文件 myfile.txt 当中,其中,fd=1。这种现象叫做输出 重定向。
常见的重定向有:
> :输入
>> :追加
< :输出
重定向的本质是上层用的fd的值不变,在内核中更改fd对应的struct file* 的地址。
4. dup2 系统调用
为了支持重定向,系统当中存在一个 dup 的系统调用,这里用 dup2 比较频繁,它的作用是在两个文件描述符之间进行拷贝,拷贝的不是fd的值,而是它里面的内容。
4.1 输出重定向
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
int main()
{
int fd = open("myfile", O_WRONLY | O_CREAT | O_TRUNC,0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd,1);
printf("fd: %d\n", fd);
//fflush(stdout);
close(fd);
exit(0);
}
4.2 追加重定向
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
int main()
{
int fd = open("myfile", O_WRONLY | O_CREAT | O_APPEND,0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd,1);
printf("fd: %d\n", fd);
//fflush(stdout);
const char* str = "hello world\n";
ssize_t ret = write(fd,str,strlen(str));
if(ret < 0)
{
perror("read");
exit(0);
}
close(fd);
exit(0);
}
4.3 输入重定向
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
int main()
{
int fd = open("myfile", O_RDONLY,0666);
if(fd < 0)
{
perror("open");
return 1;
}
dup2(fd,0);
printf("fd: %d\n", fd);
char str[101] = {0};
ssize_t ret = read(fd,str,sizeof(str)-1);
if(ret < 0)
{
perror("read");
exit(0);
}
close(fd);
exit(0);
}
4.4 给简易的shell添加重定向功能
printf是C库当中的IO函数,一般往 stdout 中输出,但是stdout底层访问文件的时候,找的还是fd:1, 但此时,fd:1 下标所表示内容,已经变成了myfile的地址,不再是显示器文件的地址,所以,输出的任何消息都会往文件中写入,进而完成输出重定向。
1 #include <stdio.h>
2 #include <stdlib.h>
3 #include <unistd.h>
4 #include <ctype.h>
5 #include <sys/types.h>
6 #include <sys/stat.h>
7 #include <fcntl.h>
8 #include <sys/wait.h>
9 #include <assert.h>
10 #include <string.h>
11 #include <errno.h>
12
13 #define NUM 1024 //字符串最大长度
14 #define OPT_NUM 64 //最多存在64个选项
15
16 #define NONE_REDIR 0
17 #define INPUT_REDIR 1
18 #define OUTPUT_REDIR 2
19 #define APPEND_REDIR 3
20
21 #define trimSpace(start) do{\
22 while(isspace(*start)) ++start;\
23 }while(0)
24
25 char linecommand[NUM];
26 char* myargv[OPT_NUM];
27 int lastcode = 0;//退出码
28 int lastsig = 0;//退出信号
29
30 int redirType = NONE_REDIR;
31 char* redirFile = NULL;
32
33 void commandCheck(char* commands)
34 {
35 assert(commands);
36 char* start = commands;
37 char* end = commands + strlen(commands);
38
39 while(start < end)
40 {
41 if(*start == '>')
42 {
43 *start = '\0';
44 start++;
45 if(*start == '>')
46 {
47 // "ls -a >> file.log"
48 redirType = APPEND_REDIR;
49 start++;
50 }
51 else
52 {
53 // "ls -a > file.log"
54 redirType = OUTPUT_REDIR;
55 }
56 trimSpace(start);
57 redirFile = start;
58 break;
59 }
60 else if(*start == '<')
61 {
62 //"cat < file.txt"
63 *start = '\0';
64 start++;
65 trimSpace(start);
66 // 填写重定向信息
67 redirType = INPUT_REDIR;
68 redirFile = start;
69 break;
70 }
71 else
72 {
73 start++;
74 }
75 }
76 }
77 int main()
78 {
79 while(1)
80 {
81 redirType = NONE_REDIR;
82 redirFile = NULL;
83 errno = 0;
84 //输入提示符
85 printf("用户名@主机名 当前路径# ");
86 fflush(stdout);
87
88 //获取用户输入
89 char* input = fgets(linecommand,sizeof(linecommand)-1,stdin);
90 assert(input != NULL);
91 (void)input;
92 //清除最后一个\n
93 linecommand[strlen(linecommand)-1] = 0;
94 //printf("test : %s\n",linecommand);
95
96 commandCheck(linecommand);
97 //切割字符串,即把命令和参数从一个字符串中切割出来依次放进myargv中
98 myargv[0] = strtok(linecommand," ");
99 int i = 1;
100 if(myargv[0] != NULL && strcmp(myargv[0],"ls") == 0)//给ls指令添加颜色
101 {
102 myargv[i++] =(char*) "--color=auto";
103 }
104
105 while(myargv[i++] = strtok(NULL," "));
106
107 //是cd命令就让shell切换到输入的路径,不创建子进程
108 if(myargv[0] != NULL && strcmp(myargv[0],"cd") == 0)
109 {
110 if(myargv[1] != NULL)
111 chdir(myargv[1]);
112 continue;
113 }
114 if(myargv[0] != NULL && myargv[1] != NULL && strcmp(myargv[0],"echo") == 0)
115 {
116 if(strcmp(myargv[1],"$?") == 0)
117 {
118 printf("%d,%d\n",lastcode,lastsig);
119 }
120 else
121 {
122 printf("%s\n",myargv[1]);
123 }
124 continue;
125 }
126
127 //创建子进程
128 pid_t id = fork();
129 assert(id != -1);
130
131 if(id == 0)//子进程
132 {
133 // 因为命令是子进程执行的,真正重定向的工作一定要是子进程来完成
134 // 如何重定向,是父进程要给子进程提供信息的
135 // 这里重定向会影响父进程吗?不会,进程具有独立性
136 switch(redirType)
137 {
138 case NONE_REDIR:
139 // 什么都不做
140 break;
141 case INPUT_REDIR:
142 {
143 int fd = open(redirFile, O_RDONLY);
144 if(fd < 0){
145 perror("open");
146 exit(errno);
147 }
148 // 重定向的文件已经成功打开了
149 dup2(fd, 0);
150 }
151 break;
152 case OUTPUT_REDIR:
153 case APPEND_REDIR:
154 {
155 umask(0);
156 int flags = O_WRONLY | O_CREAT;
157 if(redirType == APPEND_REDIR) flags |= O_APPEND;
158 else flags |= O_TRUNC;
159 int fd = open(redirFile, flags, 0666);
160 if(fd < 0)
161 {
162 perror("open");
163 exit(errno);
164 }
165 dup2(fd, 1);
166 }
167 break;
168 default:
169 printf("bug?\n");
170 break;
171 }
172 execvp(myargv[0],myargv);
173 exit(-1);
174 }
175 else
176 {
177 int status= 0;
178 pid_t ret = waitpid(id, &status, 0);
179 assert(ret > 0);
180 (void)ret;
181 lastcode = ((status>>8)& 0xFF);
182 lastsig = (status & 0x7F);
183 }
184 }
185 return 0;
186 }
三、FILE
因为IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。 所以C库当中的FILE结构体内部,必定封装了fd。
1. 缓冲区
如何理解缓冲区呢?缓冲区本质就是一段内存, 那么是谁申请的呢?又属于谁呢?为什么要有这个东西呢?一个进程要在内存中往磁盘文件里写数据,如果直接写的话太慢了,浪费大量的时间。那么缓冲区就出现了,缓冲区是在内存中开辟的一块空间,进程通过内存把数据拷贝给缓冲区再由缓冲区往磁盘文件里写入,进程把数据拷贝完之后就去做后面的事情了,因为缓冲区一定会把数据写入磁盘文件,那么缓冲区存在的意义是节省进程进行数据IO的时间。
1.1 缓冲区刷新策略
如果有一块数据,一次性写入一定比多次少批量写入的效率高。
缓冲区会结合具体的设备,定制自己的刷新策略:
- 立即刷新 - 无缓冲 (暂时不考虑,使用场景很少)
- 行刷新 - 行缓冲 —>显示器(一般是给人看的,如果刷新一长段,会看的很痛苦)
- 缓冲区满 - 全缓冲 —>磁盘文件 (效率最高,直接满了就写入)
特殊情况:
- 用户强制刷新
- 进程退出刷新缓冲区(操作系统一般都会进行强制刷新)
缓冲区在哪里?指的是什么缓冲区?
看下面的代码,为什么输出到显示器中就是四条语句,而重定向到文件里就是7条呢?为什么仅仅只是调用了fork就出现这种情况?那么这种情况一定和缓冲区有关,通过运行状况我们知道缓冲区一定不在内核中,如果在内核中write也应该打印两次。由此得出我们谈论的缓冲区都是指用户级语言层面给我们提供的缓冲区。这个缓冲区在FILE结构体当中,它里面包含了 fd 和 一个缓冲区。
那么如何解释fork的问题呢?
1.没有重定向输入之前(>)看到的是4条消息,stdout默认使用的是行刷新,在进程fork之前,3条C函数已经将数据输出到显示器上了,你的FILE内部,进程内部就不存在对应的数据啦。
2.进行了重定向输入,写入的文件不再是显示器,而是普通文件,采用的刷新策略是全缓冲,之前的3条c显示函数,虽然带了\n,但是不足以stdout缓冲区写满!数据并没有被刷新!执行fork的时候,stdout属于父进程,创建子进程时, 紧接着就是进程退出!谁先退出,谁就进行缓冲区刷新(缓冲区刷新的本质就是修改),修改会发生写时拷贝,所以数据会显示两份!
3. write为什么没有呢?上面的过程都和wirte无关,wirte没有FILE,而用的是fd,就没有C提供的缓冲区。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
int main()
{
// C接口
printf("hello printf\n");
fprintf(stdout, "hellp fprintf\n");
const char *fputsString = "hello fputs\n";
fputs(fputsString, stdout);
// 系统接口
const char *wstring = "hello write\n";
write(1, wstring, strlen(wstring));
fork(); // fork
return 0;
}
四、理解文件系统
上面所说的都是文件被打开的状态下,操作系统进行管理。那么文件没有被打开呢?该如何对操作系统进行管理呢?没有被打开的文件当然是在磁盘上静静地放着呢!那么磁盘上面大量的文件也必须要被静态管理,方便我们随时打开,那么被谁管理呢?就是文件系统啦。
1.磁盘的存储
那文件是怎么通过磁盘进行存储呢?首先分三部分:
1.磁盘的物理结构
磁盘是我们计算机中唯一一个机械结构,虽然现在很多电脑或笔记本都用固态硬盘了,但是由于硬盘容量大又比较便宜,所以使用的人或者公司依旧很多。
下图是磁盘拆开的样子:主要是由盘片,磁头,控制电机和硬件电路组成。
硬盘是一个机械结构加外设,所以他的访问速度会比较慢(相比较内存来讲他很慢)
2.磁盘的存储结构
那么文件怎么存储到磁盘上呢?主要是通过磁头摆动确定磁道,通过磁道确定对应的扇区,再把数据存储到扇区里。 磁盘在寻址的时候,基本单位是一个扇区(512byte),一般的磁盘每个扇区的存储容量是相同的,那么扇区有大有小,存储数据怎么能相同呢?那是因为存储的密度不同。磁盘在寻址的时候磁头不是单个的移动寻找扇面,他们是一体的,比如8个磁头一起到2号磁道那个柱面上,读写那个柱面的一摞8个扇面,他不是一个一个扇面读写的。磁盘中定位任何一个扇区采用硬件级别的定位方式:CHS定位法。
3.磁盘的逻辑结构
磁盘在物理上是圆形的,但是我们可以把他想象成线性结构,就比如磁带,卷起来是圆形的,展开就是线性的。
那么磁盘也一样,磁盘的盘面由一个个磁道构成的,且这些磁道都是同心圆,我们可以将磁盘结构抽象为线性结构,然后把他当成数组来存储数据:
我们将磁盘从逻辑上看成一个数组,数组的一个下标就是一个扇区,多个扇区可以组成一个磁道,多个磁道组成一个盘片,多个盘片组成磁盘。由此可以得出数组中的一个下标就可以定位磁盘中的一个扇区,那么对磁盘的管理也转变为了对数组进行管理;在操作系统内部,我们将这种地址称为 LBA 地址。
那么操作系统如何拿LBA地址去磁盘中找对应的扇区呢?如下图例子:操作系统将LBA地址转化为CHS定位,定位到对应的扇区
那么操作系统为什么要对CHS进行逻辑抽象呢?直接使用CHS不行吗?
1.便于管理
2. 不想让操作系统的代码和硬件强耦合,无论磁盘的物理结构怎样迭代更新发生变化,操作系统还是通过LBA地址转化进行定位。
2.文件系统
2.1 局部性原理
虽然磁盘访问的单位是512字节,但是依旧还是太小了。为了减少IO次数,提高效率,操作系统内的文件系统会定制的进行多个扇区的读写,比如:1KB,2KB,4KB(一般是4KB,经过验证4KB效率较高且最合适)。内存是被划分为很多个4KB大小的空间(叶框),磁盘中的文件尤其是可执行文件也是按照4KB大小划分好的(页帧)。
如果你要读取或者修改的数据小于4KB,操作系统也必须将4KB的数据载入到内存中,但是你读取或修改的时候只能看到你要的那部分内容,如果需要,操作系统还会将载入到内存的数据,重新写回磁盘中。
2.2 硬盘的分区与分组
我们的电脑中一般都只有一块磁盘,这个磁盘的大小可能是500G,但是在电脑中却能显示有C/D/E..这么多块空间,那是为什么呢?这个就像是我们国家国土960多万平方千米,14亿多人口,直接统一管理是不是特别费劲,但是分成很多个省,每个省各管理各的,最后国家把治理任务分到省,省再把任务分到市里,市再分到区或县里,层层往下分区,分组,就变得比较好管理了。当然磁盘也一样,500G分成3块或4块区域,区再分成很多组,这样文件系统就好管理硬盘了。
2.3 具体管理方法
下图为磁盘文件系统图,可以看到硬盘分为很多个Block模块(分区),模块的到小是由格式化的时候确定的,且大小不可被改变。Boot Block为启动模块。
而每个Block模块又继续向下分组:
Super Block:超级块,属于整个分区,保存的是整个分区的信息,每个Block模块中都会有一个 Super Block。那么他保存的是整个分区的信息,为什么不将他和Boot Block 一样单独存放呢?主要原因是他相当与一个备份,比如他这份数据损坏了,也可以从其他分组中拷一份过来。
注:super block 不是每个分组里面必备的数据,有些分组里面并没有 super block。
Group Descriptor Table:块组描述符表,描述对应分组宏观的属性信息。
Block Bitmap:数据块对应的位图结构,里面每个比特位都对应着 data blocks 的一个下标,比特位为0表示该下标对应的 data block未被使用,为1表示该 data block 已被占用。
inode Bitmap:inode对应的位图,里面每个比特位都对应着 inode table 的一个下标,比特位为0表示该下标对应的 inode 未被使用,为1表示该 inode 已被占用。
inode Table:保存了分组内部所有文件的 inode 块(已用+未用)。
Data block:保存了分组内部所有文件的数据块。
文件= 内容+属性
文件内容就是Data block,他的大小随着应用类型的变化而变化,
文件属性是inode,inode是固定大小的(128/256byte),而且每个文件都会对应的inode,inode几乎包含了文件的所有属性,除了文件名,他并不在inode中存储。那么inode为了分辨彼此,每个inode都有自己的ID。可以通过ll -i选项查看
2.4 文件操作
新建文件
- 在了解了一个分组的具体组成之后,如何新建文件也显而易见了 – 在 inode bitmap 里面查找为0的比特位编号,将该比特位置1,然后将文件的所有属性填写到 inode table 对应编号下标的空间中;再在 block bitmap 中查找一个/多个为0的比特位编号,将其置为1,然后将文件的所有内容填写到 data blocks 对应编号下标的空间中;最后再修改 super block、GDT 等位置中的数据。同时,需要将新文件文件名与 inode 的映射关系写入到目录的 data block 中。
获取文件 inode
- 在 Linux 中,查找文件统一使用 inode 编号,但是我们平时只使用过文件名,从没有使用过 inode,那么操作系统是如何将文件名与 inode 一一对应的呢?答案是通过目录,目录和普通文件不同,目录的内容是下级目录或者普通文件,所以目录的 data block 里面存储的是当前目录下文件名与 inode 的映射关系。
- 所以当我们在某一个目录下使用文件名查找文件时,操作系统会读取目录 data block 里面的数据,找到文件名对应的 inode 编号,找不到就提示 文件不存在。而当我们在目录下新建文件/文件夹时,操作系统会向目录 data block 里面写入新文件与 inode 的映射关系。这也是为什么在目录下读取文件信息需要 r 权限,在目录下新建文件需要 w 权限的原因。
读取文件属性
- 先通过目录 data block 得到文件的 inode 编号,然后在 inode bitmap 查看对应编号比特位是否为1,检查 inode 有效性,然后从 inode table 中读取对应 inode 中的文件属性。
- 注:inode 编号可以跨分组,但不可以跨分区,即同一分区内 inode 是统一编号的。
读取文件内容
- 首先需要通过 inode 读取文件信息,然后通过 inode 结构体的内容查找 data block 编号,再到 block bitmap 中查找对应比特位是否是否为1,检查有效性,最后在从 data block 中读取文件内容。
struct inode { int id; mode_t mode; int uid; int gid; int size; //... int blocks[15]; };
- 注:一般来说,blocks 里面前12个元素存放的都是一个 block 编号,代表 data blocks 里面的一块空间,但是最后三个元素不同,虽然它们存放的也是一个 block 编号,但 data blocks 对应 block 编号中存放的内容却很特殊,blocks[12] 指向的 data block 中存放的是一级索引,即其中存放的是一个指针数组,存放的是 data block的地址,指向多个 data block;blocks[13] 指向的 data block 中存放的是二级索引,即其中存放的内容是指针数组的地址,类似于 blocks[12];以此类推,blocks[14] 里面存放的是三级索引。这样,即使该文件很大,操作系统也能够成功读取文件的内容。
删除文件
- 只需要将inode Bitmap和block bitmap 中对应的比特位由1置0,至此文件就删除了。后面新文件的文件属性和文件内容直接覆盖原来已删除文件的属性和内容。
恢复文件
- 在理解了删除文件的原理之后,我们就明白文件删除之后是可以恢复的:操作系统包含了文件的日志信息,会将文件名与 inode 的映射关系保存在一个临时的日志文件里,我们通过这个日志文件找到误删文件的 inode,然后将 inode bitmap 里面对应的比特位重新置为1,再通过 inode 结构体中的 blocks 成员找到所有的数据块,再将 block bitmap 中对应比特位置为1即可。
- 不过这一切的前提是原文件的 inode 没有被新文件使用 – 如果新文件使用了原文件的 inode,那么对应的 inode table 以及 data block 里面的数据都会被覆盖,所以文件误删之后最好的做法就是什么都别做,避免新建文件将原文件的 inode 占用。
2.5 软硬链接
理解硬链接
由上图得知软硬链接大不相同,那么软硬链接的根本区别是什么呢?
软连接有自己的inode,可以当做独立文件看待,硬链接则没有独立的inode,那么这里有个问题,如何理解硬链接呢?或者说建立硬链接究竟做了什么?从图中可以得知,建立硬链接根本没有创建新文件,为什么呢?因为没有给硬链接分配独立的inode,那么既然没有创建文件,那么硬链接一定没有自己的属性集合和内容集合,用的一定是链接的那个文件的inode和内容。那么由此可以得知建立硬链接就是在特定的路径下,新增一个文件名和inode编号的映射关系。
建立好后我们发现一个文件的硬链接数发生了变化,同样的往文件里写一些内容后,硬链接也会跟着文件更改文件大小。
那么我们知道硬链接就是原本文件的一个映射关系,那么把原来的文件删除后会怎么样呢?
看下图,我们发现删除文件,硬链接除了硬链接数发生了变化,其他的什么也没变,也依旧可以正常读取内容。软连接的问题暂时先不去管他,后面说。
通过上面所说的可以引申出一个问题,就是一个文件怎样才算真正被删除?当一个文件的硬链接数变成0的时候,这个文件才算真正被删除。
理解软连接
我们说回软连接,通过上图可以看到,文件被删除后软连接变红了,由此可以得出软连接不是用inode链接文件的,那么软连接怎么去链接的文件呢?
通过上图可以看到,我们读取软连接后发生了错误,那么新创建一个同名的文件后发现软连接又好了,但是和硬链接的属性却不一样了,这是为什么呢?因为软连接首先是一个独立的文件,其次它连接的方式是在自己的数据块中存储了所指向目标文件的路径,也就是说硬链接是创建一个映射关系,而软连接只是存储了目标文件的路径,且它只认文件名不认inode。注:这个软连接其实相当于windows下的快捷方式。
硬链接的使用场景
通过下图可以看到,为什么目录和普通文件的硬链接数不同呢?
首先呢,一个普通文件有文件名和自己的inode,他们本身就是有映射关系的,所以普通文件会有一个硬链接数。那么目录为什么有两个呢?我们选项中加入-a后发现多了两个隐藏文件,往这个file目录中再创建一个目录后发现,file目录的硬链接数发生了变化,我们进入到file目录中发现也有两个隐藏文件,那么这两个隐藏文件到底是什么呢?
这里一个点相当于是当前目录,也就是file目录,可以看到他们两的inode编号是相同的,而两个点则是上级目录也就是test1目录,他们两的inode编号是一样的,这样就可以理解为什么cd .. 就可以返回上一个目录了。
进入mylog目录后发现一个点就是mylog自己,而两个点就是file目录,进行cd .. 后又回到了上一级目录。
看下图 ,我们想对目录创建硬链接,却发现不允许给目录创建硬链接,那么这里有个问题为什么Linux不允许普通用户给目录建立硬链接呢?这里肯定会有人问了,. 和..不是给目录建立的硬链接吗?为什么你操作系统不让我们给目录建立硬链接,那是因为操作系统充分相信它自己,他相信自己建立的硬链接不会出错,而它不相信你,如果你给目录随便建立硬链接,这样就可能导致系统在遍历目录时先入无线的循环当中,无法定位到要访问的目录中。
拓展:文件的三个时间
通过stat可以获取文件的一些属性,这其中包含文件的三个时间:
Access:文件最近被读取的时间,(频繁的读取并不会频繁的更改这个时间,如果只是读取那么它会隔一段时间更改一次)。
Modify:文件内容最近被修改的时间。
Change:文件属性最近被修改的时间。
五、动静态库
1.概念
- 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静 态库
- 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。
- 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码, 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)。 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
2.什么是库
库本质就是.o文件的集合,那么什么是.o文件呢?.o又是怎么得来的呢?
我们带着问题继续往下看,首先 .o 文件是一个可重定位目标二进制文件。
上图是一个程序实现的过程,通常我们要运行这样一个可执行文件,需要函数的声明和定义,以及一个main函数。那么如果我们想把这个可执行程序给别人使用,但是又不想让他看到我们的源文件,怎么办呢?
那么我们可以用源文件(.c)生成一个可重定位目标二进制文件(.o),然后将.o 文件和.h(头文件)文件给使用者,让他用你的代码进行链接就行,如下图,将文件给了别人后,使用者也可以正常编译执行,但是他看不到我们的源代码,.o文件是二进制文件别人也看不懂,这样可以很好的保护自己的源代码。注:只提供.o文件(方法的实现)是不行的,使用者还得知道你有哪些方法(也就是.h文件)
如果我们作为库的提供者,肯定是不希望自己的源代码随随便便就给别人看的,但是又想给别人使用,所以早期给别人提供函数时通常只提供头文件 (有哪些方法) 和 经过汇编后得到的可重定向二进制目标 .o 文件 (方法的实现)。
那么这时候问题就来了,如果需要提供的.o文件特别多呢,那同时对应的.h文件也要相应的提供,使用者光编译文件都累死了,而且一不小心还会有漏掉的。所以当时就尝试着将所有的“.o文件”打包压缩成为一个文件,然后再提供给对方,这样就简单许多,这个文件我们称为库文件。但是由于打包工具和打包方式的不同,库文件又分动态库和静态库。
3.静态库和静态链接
静态库的制作
前面说了,制作库就是把所有的“.o文件”打包到一个文件中,那么静态库怎么制作呢?
用ar(Linux中的归档工具) 加选项 -rc,依赖.o 文件生成静态库(.a),那么现在光有静态库还不行呀,还需要有一批对应的头文件,所以我们可以做一个发布版本,将头文件也拷贝到对应路径下。
静态库的使用
现在库的发布版本就出来了,我们可以将库打包压缩发到被人或者上传到yum上供别人下载使用
拿到打包的文件,解压后就可以得到我们所需要的文件了。
那么我们拿到库文件后直接遍历就可以运行了吗?当然不行,编译器在找头文件时会到默认路径下和当前路径下找,可是那头文件不就在当前路径下吗?头文件虽然在当前路径下,但是由于它放的太深了,所以编译器找不到,要让头文件和源文件处在相同一路径下才可以。
这里头文件编译器都找不到,那么静态库它也找不到,所以这里我们采用让编译器去指定路径去找,
从上图可以看到,通过指定路径找到了头文件和库文件,并且可执行程序也可以正常运行。这里有个疑问,为什么头文件不需要指定名称,而库文件却需要?
那是因为在main.c 文件中已经包含了所需要的头文件,编译器自然会根据你需要的头文件到指定的路径下去找,但是编译器并不知道你需要那个库文件,万一那个路径下的库文件很多,你不说它当然不知道用哪个了,所以这里需要使用者指定库文件名称。
那为什么库文件名称是这个样子的?
大家的思维不要被前面的名称带跑偏了,库文件的名称是去掉前面的 lib和后面的.a/.so,中减那部分才是库的名称。
虽然可执行程序正常运行了,但是这里还有点问题,我这个库不是静态的吗?怎么依赖库中找不到,且可执行程序还是动态链接的。造成这种原因有三点:
1.、Linux 默认使用动态链接,这是针对动静态库都存在的情况下说的,如果只存在静态库,那么 Linux 也只能使用静态链接,同样,如果只存在动态库,即使指明 static 选项也只会使用动态链接;
2.一个可执行程序的形成可能不仅仅只依赖一个库,如果静态库也依赖,动态库也依赖,那这个可执行程序整体是动态链接的,有静态库的地方也会拷贝,但如果只有静态库,那编译器也只能使用静态库了。
这个可执行程序的形成也依赖了C语言的库,所以这里显示的是动态链接的,但是我们的静态库、编译器也会用静态的方式去链接的。
除了上面的方式,我们还可以将头文件和库文件拷贝到系统头文件及库文件路径下 (本质上就是安装),这样我们只需要把额外使用的库名称加上就可以用了。注:测试完成后记得删除对应目录下的文件,避免污染系统库,删除时小心别删错了。
4. 动态库
动态库的制作
shared: 表示生成共享库格式(归档)
fPIC:产生位置无关码
动态库的制作和静态库稍有不同,生成动态库的.o文件需要加 fPIC 选项,动态库归档用gcc + shared命令形成.so文件。
归档后就和静态库的流程是一样的了,可以打包,上传,下载,解压....等等操作,这里跳过这些流程。这里我们继续跟静态库一样,需要到指定的路径找头文件和库文件及其名称,这些操作完成以后可执行程序确实出来了,但是为什么编译器说找不到.so这个库文件?
这是因为我们把库文件的路径和名称只告诉了gcc,而gcc将可执行程序形成后,后面的事情就与它无关了,但是动态库是程序运行起来后才去链接的。而操作系统和shell 并不知道库文件的路径,所以我们还需要在程序运行时告诉操作系统动态库的位置。那么怎么解决这个问题呢?
程序在执行时操作系统会去两个地方查找动态库,一个是默认路径下,另一个是环境变量 $LD_LIBRARY_PATH 中,所以我们可以将库文件路径添加到这两个地方。
通过上图可以看到配置环境变量以后可执行程序也能用了,那么注意:配置环境变量只在本次登录内有效,退出后就没有了。要想使其永久有效,我们用另一种方法:
将库文件路径写入到配置文件 “/etc/ld.so.conf.d/” 路径中,即在该目录下新建一个文件,然后将库文件的路径写入其中,最后使用 ldconfig 更新缓存即可。
注:在这个路径下普通用户无法进行操作,所以没有权限的就别试了。
刷新后动态库也链接上了,程序也能执行了,退出再进入也可以正常使用。
还有一种就是在当前路径下给库文件建立一个软连接,这个也是可以使用且永久有效的。也可以在系统库文件目录下给当前的动态库建立一个软连接,这两个都可以。
动态库的加载
首先我们要知道静态库是不需要加载的,静态链接是在文件进行链接时直接将静态库中的代码拷贝到代码段中,最终形成可执行程序。而程序运行后就与静态库无关了,所以静态库不需要加载。
虽然静态库不需要加载,但是有个问题, 如果多个进程调用同一个静态库,由于每个进程的代码段中都存在该静态库的代码,那么程序加载后物理内存中也会存在多份静态库代码,然后通过页表映射到不同进程的地址空间代码段处,容易造成物理内存浪费。
这里涉及到一个前面的知识,fPIC:产生位置无关码,这个大家肯定会有疑惑,位置无关码是个什么东西?干什么的?
举个例子:一名运动员在百米跑道的位置是50米,而在40米处有个路灯,那么运动员的绝对位置是在百米跑道的50米处,如果跑道发生变化,不是100米了,而是其他的米数,那么运动员的绝对位置就发生了变化,而相对位置还是在路灯前10米处。静态库拷贝到代码段中的代码就是属于绝对位置,而这个fPIC产生的数据就是相对位置。
那么动态库是怎么加载的呢?
动态链接是将动态库中指定函数文件的地址写到可执行程序中,这个文件是通过fPIC产生的,所以可执行程序中存放的是文件在动态库中的偏移地址。
程序运行后,操作系统会将磁盘中的可执行程序加载到内存中,然后创建进程地址空间,建立页表映射,开始执行代码,当执行到库函数时,操作系统发现该函数链接的是一个动态库的地址,且该地址是一个外部地址,操作系统就会暂停程序的运行,开始加载动态库;
那么怎么加载动态库呢?动态库加载到哪里呢?
操作系统会将磁盘中动态库加载到内存中,然后通过页表将其映射到该进程的地址空间的共享区中,然后立即确定该动态库在地址空间中的地址,即动态库的起始地址,此时动态库就加载完成了;
加载完成后继续执行代码,此时操作系统就可以根据库函数中存放的地址,即该函数文件在动态库中的偏移量,再加上动态库的起始地址得到该函数文件的地址,然后跳转到共享区中执行函数,执行完毕后跳转回来继续执行代码段后面的代码。这就是完整的动态库的加载过程。
动态库可以避免静态库内存空间浪费的问题,即使多个进程用同一个动态库,动态库也只需要加载一次,即动态库被加载到某个进程的共享区后,其他进程也想使用这个动态库,可以根据自己存放的偏移地址通过页表跳到该进程的共享区执行函数,执行完毕后再跳回自己的进程地址空间,动态库自始至终就只被加载了一次。