Linux——基础IO
文章目录
- Linux——基础IO
- 一、概念引入
- 二、文件系统调用接口
- 2.1 C语言中文件接口
- 2.2 系统文件接口
- 2.2.1 参数介绍
- 2.2.2 简易调用系统接口
- 2.3 文件读写接口
- 三、文件描述符——fd
- 3.1 进程与文件的联系
- 3.2 三个默认打开的文件
- 3.3 VFS虚拟文件系统
- 3.4 理解struct file内核对象
- 3.5 fd的分配规则
- 四、重定向
- 五、缓冲区
- 5.1 缓冲区的作用
- 5.2 理解缓冲区
- 5.3 用户缓冲区与内核缓冲区
- 六、文件系统
- 6.1 磁盘物理存储结构
- 6.2 磁盘逻辑存储结构
- 6.3 软硬链接
- 6.3.1 软链接
- 6.3.1 硬链接
- 七、静态库和动态库
- 7.1 静态库
- 7.2 动态库
- 7.3 动态库的加载过程
- 八、虚拟地址空间拓展
一、概念引入
在开始之前我们需要知道如下几个概念:
1. Linux下一切皆文件
2. 文件 = 内容 + 属性
3. 所有对文件的操作可以总结为两点:a.对内容操作 b.对属性操作
4. 内容是数据,属性其实也是数据,都是存储文件,必须既存储内容,又存储属性数据——默认就是在磁盘中的文件
5. 我们要访问一个文件的时候,都是要先把这个文件打开
这里前四点应该都很好理解,我们重点谈谈第五点
当“ 我们 ”要访问一个文件的的时候,这里的“ 我们 ”其实是进程,因为在我们之前C语言的学习中,如何打开文件,对文件进行读写操作,都是写在代码中,只有当将代码编译汇编形成可执行程序,被我们执行起来的时候,文件才会真正被创建,被读写了,而执行可执行程序的过程,本质上其实是之前学的创建进程的过程——所以访问一个文件,本质上是进程在访问
对于文件,默认指的是放在磁盘中的文件,打开这个文件,是为了让文件被访问,而上面我们说到,文件是进程被访问,进程在哪? 进程在内存中,所以如果文件要被访问,也是要被加载到内存的!
加载磁盘上的文件到内存,一定会涉及到访问磁盘设备,谁来做这个工作?
操作系统来做,一个进程通过操作系统,打开文件,所以操作系统一定要给我们提供系统调用接口
一个进程可以打开多个文件吗? 多个进程可以打开多个文件吗?
进程 : 打开的文件 = 1 : n
加载到内存中,被打开的文件,可能会存在多个
操作系统运行中,可能会打开很多文件,操作系统要不要管理被打开的文件呢? 答案肯定是要,但如何管理?
先描述,再组织
一个文件要被打开,一定要先在内核中形成被打开的文件对象
文件按照是否被打开分为:
被打开的文件(在内存中)
没有被打开的文件(在磁盘中)
综上,文件的管理工作分为两部分:
1. 对打开的文件进行管理——研究进程和被打开文件的关系
2. 没有被打开的文件也要在磁盘中进行管理
二、文件系统调用接口
2.1 C语言中文件接口
C语言中给我们提供了一套打开和关闭文件的接口
我们可以简单使用下
w方式打开:文件如果不存在,就创建文件,如果存在,会清空文件内容
等价于在命令行中:
a方式打开:文件如果不存在,就创建文件,从文件结尾处开始写入,不清空文件
等价于在命令行中:
2.2 系统文件接口
我们学习的C语言打开文件的接口,底层一定封装了系统调用接口
open()
close()
return value
2.2.1 参数介绍
const char* pathname
为文件名与文件路径,这个很好理解,与上文C语言提供的fopen()
需要传路径一样
int flags
是一个标志位,代表着文件以怎样的形式打开,以下是部分选项(主要):
O_RDONLY: 只读打开
O_WRONLY: 只写打开
O_RDWR : 读,写打开
上面三个常量,必须指定一个且只能指定一个
O_CREAT : 若文件不存在,则创建它需要使用mode选项,来指明新文件的访问权限
O_APPEND: 追加写
O_TRUNC: 覆盖写
注:这些都是宏,使用每个比特位作为标志位
mode_t mode
当文件不存在,并且需要创建文件的时候,需要告诉OS创建文件的八进制权限码(前面权限中的知识),而这个参数传入的就是创建文件的权限值
return value
返回值是一个int类型的值,这个值叫做fd,关于这个返回值fd,下文中会重点详谈
2.2.2 简易调用系统接口
注意:这里有个文件传参的细节
Linux中,当我们想向一个文件中写入字符串的时候,不需要strlen()+1
,\0
是C语言的规定,不是文件的规定
也就是传参的时候,不需要把C语言中的末尾结束标志也传入文件中
2.3 文件读写接口
上文中介绍了C语言和系统中,打开与关闭文件的接口,并没有提读写的接口
C语言中:
const char *msg = "hello linux \n";
size_t s = fwrite(msg, strlen(msg), 1,fp);
//msg:缓冲区首地址,size: 本次读取,期望写入多少个字节的数据,nmemb:一次写入几个字节,stream:打开文件时返回的文件指针,返回值:实际写了多少字节数据char buf[1024];
size_t s = fread(buf,1,strlen( msg), fp);
//buf:缓冲区首地址,size: 本次读取,期望读入多少个字节的数据,nmemb:一次读入几个字节,stream:打开文件时返回的文件指针,返回值:实际读入了多少字节数据
系统中:
const char *msg = "hello linux \n";
ssize_t s = write(fd, msg, len);
//fd:下文中解释,msg:缓冲区首地址,count: 本次读取,期望写入多少个字节的数据,返回值:实际写了多少字节数据char buf[1024];
ssize_t s = read(fd, buf, strlen(msg));
//fd:下文中解释,buf:缓冲区首地址,count: 本次读取,期望读入多少个字节的数据,返回值:实际读入了多少字节数据
对比C语言和系统中的接口,我们可以发现函数的区别并不大,因为C语言中的接口是封装库函数中的接口实现的
但两者之间还是有些区别,C语言中调用时传入的是打开文件时调用fopen()
返回的FILE* fp
类型的指针,而系统接口中传入的却是调用open()
返回的int fd
,fd
是什么呢?与C语言中的FILE* fp
有什么关联呢?
回答这些问题需要打通语言和系统关于文件部分的理解
三、文件描述符——fd
在引入中我们谈到,文件本质是进程在打开,而进程需要读写文件,文件就必须被加载到内存中,如何加载?
先描述,再组织——为其创建对应的PCB
3.1 进程与文件的联系
而进程又是如何与文件联系起来的呢?
看下图:
现在我们就能回答上文中的问题了
返回值fd是什么?
在进程的pcb中有个
struct files_struct* files
的指针
这个指针指向一个struct files_struct
的结构体
在这个结构体中,有一个数组:file* fd_array[]
这个数组每个变量都是file*
类型的指针,指向从磁盘加载的struct file
当进程打开一个文件的时候,OS就会为其创建struct file
,并把它的地址放到file* fd_array[]
的数组中
并向进程返回放入数组位置的下标
fd是file* fd_array[]
数组中的下标
FILE究竟是什么东西呢?
是一个C语言提供的结构体类型
FILE中必定封装了文件描述符fd,怎么证明?
FILE结构体中也有文件描述符:_fileno
3.2 三个默认打开的文件
我们可以看到,我们打开的文件的描述符fd = 3,那么0,1,2去哪里了呢?
OS/C语言为什么默认要把0,1,2、stdin,stdout,stderr打开呢?
为了让程序员默认进行输入输出代码编写
stdin,stdout我们很好理解,标准输入输出文件,键盘和显示器都需要频繁使用,但stderr是什么,为什么默认输出也是显示器?
重定向部分回答
3.3 VFS虚拟文件系统
根据冯诺依曼体系结构,计算机中的外设不会与cpu直接交互,而是与内存进行数据的交换
我们所知的主要外设有键盘,显示器,磁盘,网卡等等
外设是硬件,硬件的读写方法肯定都是不一样的,而进程在内存中指向一个open()打开一个文件,要将文件进行读写,进程在上层调用read()系统接口进行读取,write()接口进行写入操作,但硬件的读写方法都不一样,进程如何在上层中统一调用呢?
3.4 理解struct file内核对象
struct file中是在内核中创建,用来专门管理被打开的文件的,在其中有三个内容
1. 打开文件的所有属性
2. 文件的操作方法集
3. 内核文件缓冲区(是一段内存空间)
进程在需要进行读写数据的时候,操作系统会将文件的内容与属性加载到内核文件缓冲区中
读数据加载到文件缓冲区中是可以理解的,但为什么写也需要加载呢?
写文件也有可能需要修改文件,因为不知道用户有可能删除文件或者修改文件,需要修改对原来的文件进行修改,所以无论读写,都需要先把数据加载到文件缓冲区中
当我们明白上面这点的时候,就应该理解到,我们在应用层进行数据的读写,本质是将内核缓冲区中的数据,进行来回拷贝!
3.5 fd的分配规则
进程默认已经打开了0,1,2,我们可以直接使用0,1,2进行数据的访问
文件描述符的分配规则是,寻找最小的,没有被使用的数据的位置,分配给指定的打开文件
四、重定向
我们现在已经知道,在进程启动的时候,OS会自动加载三个文件,标准输入,标准输出,标准错误,对应的文件标识符分别是0,1,2,而fd的分配规则是寻找最小的,没有被使用的数据的位置,分配给打开的文件,所以我们看下列代码
执行结果:
可以看到当我们执行上述程序的时候,打印的结果并没有向显示器上打印,而是写入到了log.txt
中
为什么会这样呢?
根据文件默认打开规则和fd的分配规律,当我们调用
close(1)
的时候,显示器文件就被我们关掉了,所以file* fd_array[]
中下标为1的位置是空的,当我们打开log.txt
的时候,最小的值是1,所以log.txt
的文件标识符就是1
printf()
这类打印的函数默认是向stdout
打印,stdout
指向的文件标识符就是1,也就是显示器,但现在log.txt
的文件标识符是1,printf()
函数就向log.txt
文件中写入信息了
上层中指向的fd不变(默认三个文件的fd),但底层fd指向的内容在改变
重定向的本质,其实就是修改特性文件fd的下标内容
系统中提供了替换底层fd的接口
先将newfd关闭,并将newfd替换成oldfd,oldfd被保留下来了
例如:如果要做输出重定向,可以执行dup(fd,1)
解释上文中的问题:stderr是什么,为什么默认输出也是显示器?
stderr是标准错误,是用来输出程序报错的,当错误发生时,默认将错误输出到显示器,对初学者友好
但当工程量比较大的时候,输出和错误都显示到显示器是很不方便的,所以我们可以将标准输出其重定向到日志文件中,方便查看
重定向功能,其实就是我们以前用的>
、>>
、<
了解到重定向的本质,其实我们可以完善一下之前写的myshell,增加重定向功能,此处由于篇幅限制,就不放代码了
上文代码中的fflush()
又是什么鬼呢?
这就需要先了解下文中的缓冲区概念了
五、缓冲区
5.1 缓冲区的作用
假设家在江西的小李想要给远在北京的小胡送一个生日礼物,在不发达的年代,他可能需要自己带着礼物,坐好几天的车到达北京,然后亲手将礼物给小胡,但这太耗费小李的时间了,效率太低!
如今,科技高速发展,物流行业蓬勃兴起,现在每个小区\学校附近都会有菜鸟驿站,小李现在想完成同样的任务,只需要把包裹拿到当地的菜鸟驿站,将快递邮寄给小胡,菜鸟驿站收到一定量的包裹之后统一发送,过了两天后,小胡快递到了,直接去家附近的菜鸟驿站取就行了
对比两种方法,小胡拿到生日礼物可能都需要好几天的时间,但毫无疑问,第二种的效率极高,这里的效率高,指的是对小李而言效率高,小李只需要将快递拿到菜鸟驿站,填好地址,对于小李的任务就完成了,小李就默认认为小胡拿到了礼物!
将这个概念引入到文件操作中,小李其实就是用户,而菜鸟驿站就是缓冲区
用户向文件中写入内容,其实会先将内容加载到缓冲区中暂存数据,因为有缓冲区的存在,我们可以积累一部分在统一发送,提高发送的效率
缓冲区因为能够暂存数据,必定要有一定的刷新方式(一般策略):
1. 无缓冲(立即刷新)
2. 行缓冲(行刷新)
3. 全缓冲(缓冲区满了,再刷新)
特殊情况:
1. 强制刷新
2. 进程退出的时候,一般要进行刷新缓冲区
缓冲区的主要作用是提高效率——提高使用者的效率
5.2 理解缓冲区
我们从代码样例切入
上面代码中前三个是C语言提供的文件写入函数,最后一个是系统文件接口
并且代码中都向显示器中打印
查看结果,是正常的,没什么问题
我们修改代码,在最后加入一个fork函数
执行./exe > log.txt
将程序结果重定向到log.txt
中
发现结果变成我们看不懂的样子,为什么会是这个结果呢?
1. 当我们直接向显示器打印的时候,显示器文件的刷新方式是行刷新,而且代码输出的所有字符串,都有\n, 在fork之前,数据全部已经被刷新,包括systemcall
2. 重定向到log.txt,本质是向磁盘文件中写入(不是显示器了),系统对于数据的刷新方式已经由行刷新,变成了全缓冲
3.全缓冲意味着缓冲区变大,实际写入的简单数据,不足以把缓冲区写满,fork执行的时候,数据依旧在缓冲区中
4.我们目前所谈的"缓冲区",和操作系统是没有关系的,只能和C语言本身有关,是C语言给我们提供的,也就是用户级缓冲区
5. 当进程退出的时候,一般要进行刷新缓冲区,即便数据没有满足刷新条件
C/C++提供的缓冲区,里面一定保存的是用户的数据,属不属于当前进程在运行时自己的数据呢?
属于,但如果我们把数据交给了OS,这个数据就属于OS,不属于我自己的
刷新缓冲区属不属于对数据清空或者"写入"操作呢?
属于,根据我们之前学的,父子进程任何一个对数据进行更改,就会触发写实拷贝机制,所以当fork函数创建子进程之后,任意一个进程在退出的时候,刷新缓冲区,就要发生写时拷贝!
根据上述结果:因为在进程退出的时候需要刷新用户缓冲区,此时父子进程发生了写实拷贝,父进程和子进程都分别刷新了一次缓冲区,这就是输出下属结果的原因
为什么write接口只打印了一次呢?
write是系统调用,没有使用C的缓冲区,而是直接写入到内核缓冲区中!
上文中说的刷新用户缓冲区的本质是什么呢?内核缓冲区又是什么鬼呢?——看下文
5.3 用户缓冲区与内核缓冲区
首先要知道:我们日常用的最多的起始是C/C++提供的语言级别的缓冲区,是给用户用的,所以叫用户缓冲区,我们上文中提到的缓冲区都是
刷新用户缓冲区的本质:
从C/C++提供的语言级别的缓冲区写入OS这个工作叫做刷新
OS也有一个内核缓冲区,我们执行的C语言代码是先将内容写入到C语言提供的缓冲区,由C语言缓冲区统一写入OS中的文件缓冲区,再由OS写入文件!
write()是系统接口,调用后直接写入文件缓冲区
现在再看输出的结果,就很容易明白了
系统调用write率先写入文件缓冲区,而父子进程由于写实拷贝,分别也通过C缓冲区写入文件缓冲区,由于系统调用先到文件缓冲区,所以文件中先写的是system call,这就是输出结果的原理
为什么C/C++语言会提供一个缓冲区呢?
本质上还是为了提高代码执行的效率,如果每次执行printf函数都直接写入到OS的文件缓冲区,就需要一直进行进程和OS的交互,而先写入C缓冲区中,东西本质上还在进程中,当数量够多的时候,或者碰见特殊情况的时候,统一调用接口给OS,这样极大的提高了效率,并且站在printf函数的角度,只要将数据加载到了C缓冲区,printf函数就默认自己已经完成了自己的任务,可以执行下条代码了,不然printf要一直等到数据给了OS才认为任务完成,这样效率很低
C的缓冲区在哪里呢?
在C语言中任何情况下,我们输出输入的时候,都要有一个FILE,FILE是一个结构体,上文中说了FILE里面包含了fd,FILE还肯定提供了一段缓冲区!
现在我们深刻的了解到,C语言中提供的文件接口使用肯定是封装了系统接口,所以我们也可以依照所学的原理,简单实现下这几个C语言提供的文件接口函数
mystdio.h
#pragma once#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <errno.h>#define SIZE 4096#define FLUSH_NONE 1#define FLUSH_LINE (1<<1)
#define FLUSH_ALL (1<<2)typedef struct _myFILE
{int fileno;int flag;char buffer[SIZE];int end;
}myFILE;extern myFILE* my_fopen(const char* path, const char* mode);extern int my_fwrite(const char* s, int num, myFILE* stream);extern int my_fflush(myFILE* stream);extern int my_fclose(myFILE* stream);
mystdio.c
#include "mystdio.h"#define DFL_MODE 0666
myFILE* my_fopen(const char* path, const char* mode)
{int fd = 0;int flag = 0;if(strcmp(mode,"r") == 0){flag |= O_RDONLY;}else if(strcmp(mode,"w") == 0){flag |= (O_CREAT | O_TRUNC | O_WRONLY);}else if(strcmp(mode,"a") == 0){flag |= (O_CREAT | O_WRONLY | O_APPEND);}if(flag & O_CREAT){fd = open(path, flag, DFL_MODE);}else{fd = open(path, flag);}if(fd < 0){errno = 2;return NULL;}myFILE* fp = (myFILE*)malloc(sizeof(myFILE));if(!fp){errno = 3;return NULL;}fp->flag = FLUSH_LINE;fp->end = 0;fp->fileno = fd;return fp;
}int my_fwrite(const char* s, int num, myFILE* stream)
{memcpy(stream->buffer+stream->end, s, num);stream->end += num;//printf("%s\n",stream->buffer);if((stream->flag & FLUSH_LINE) && stream->end > 0 && stream->buffer[stream->end - 1] == '\n'){//printf("my_fwrite\n");my_fflush(stream);}return num;
}int my_fflush(myFILE* stream)
{if(stream->end > 0){// printf("my_fflush\n");write(stream->fileno, stream->buffer, stream->end);//printf("%s end : %d\n",stream->buffer,stream->end);//fsync(stream->fileno);stream->end = 0;}return 0;}int my_fclose(myFILE* stream)
{my_fflush(stream);close(stream->fileno);// free(stream);return 0;
}
至此进程与被打开文件之间的关系我们就搞清楚了
那么未被打开的文件在磁盘中是如何被管理的呢?
六、文件系统
大部分文件都不是被打开的(当前并不需要被访问),都在磁盘中进行保存
没有被(进程)打开的文件,要不要管理呢? 对于这部分文件核心工作是什么?
当然要管理,需要能快速定位某个文件
未被加载到内存中的文件,要能够在磁盘中被快速定位到,为被加载到内存做准备
而如何快读定位到某个文件呢? ——这是我们需要解决的问题
6.1 磁盘物理存储结构
根据磁盘的物理结构,可以由磁头,轨道,扇面,三者确定一个位置(CHS定位法)
确定一个位置就能对所有位置确定,达到随机读写的目的
注意扇区是磁盘中最小的存储单元——512B
但一般八个扇区一起用——4KB
由此,我们可以将磁盘想象成一个线性空间
我们一般将八个扇区一组,组成一个文件块
把一个文件块作为基本单位,并抽象成一个数组
6.2 磁盘逻辑存储结构
如今的磁盘都是较大容量512GB,1TB等等,但这么大的容量,直接管理是不好管理的,就像我国这么大的土地面积,如果直接管理起来是非常麻烦的,所以我们将国家分为省,市,区,县等等,省内有省政府,市内有市政府,进行分治管理
磁盘太大不好管理,所以将磁盘进行分区,实行分区管理
也就是windows 中电脑分为C盘,D盘
Linux ext2文件系统,上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的block,一个block的大小是由格式化的时候确定的,并且不可以更改,例如mke2fs的-b选项可以设定block大小为1024、2048或4096字节,而上图中启动块(Boot Block)的大小是确定的
Data blocks:
数据区,存放文件内容 ,单位是4KB的文件块
当数据过大,需要很多文件块的时候,文件块的内容可能是其他数据块的编号,建立多级映射!
inode table:
是一个struct inode inode_table[N]
数组,数组的每个元素是一个struct inode
类型的结构体,结构体中存放该inode文件属性,如:文件大小,所有者,最近修改时间等
一般情况,一个文件一个inode编号,inode编号表示的每个文件都要在整个分区具有唯一性,也就是inode在一个分区内是唯一的,Linux内核中,识别文件和文件名无关只和inode有关,用户只用文件名,内核只用inode编号,文件名与inode进行映射
在每个group里面都会有一个inode的起始值
inode编号 = start inode_number + struct inode inode_table[N]数组下标
可以间接认为inode的值就是该inode在inode table数组中的下标
ll -li
查看文件inode编号
inode Bitmap:
inode位图,每个bit表示一个inode是否空闲可用
Block Bitmap:
块位图,Block Bitmap中记录着Data Block中哪个数据块已经被占用,哪个数据块没有被占用
Group Descriptor Table:
块组描述符,描述块组属性信息,由很多块组描述符组成,整个分区分成多少个块组就对应有多少个块组描述符。每个块组描述符(Group Descriptor)存储一个块组的描述信息,例如在这个块组中从哪里开始是inode表,从哪里开始是数据块,空闲的inode和数据块还有多少个等等。和超级块类似,块组描述符表在每个块组的开头也都有一份拷贝,这些信息是非常重要的,一旦超级块意外损坏就会丢失整个分区的数据,一旦块组描述符意外损坏就会丢失整个块组的数据,因此它们都有多份拷贝。通常内核只用到第0个块组中的拷贝,当执行e2fsck检查文件系统一致性时,第0个块组中的超级块和块组描述符表就会拷贝到其它块组,这样当第0个块组的开头意外损坏时就可以用其它拷贝来恢复,从而减少损失
Super Block:
存放文件系统本身的结构信息,记录的信息主要有:bolck 和 inode的总量,未使用的block和inode的数量,一个block和inode的大小,最近一次挂载的时间,最近一次写入数据的时间,最近一次检验磁盘的时间等其他文件系统的相关信息,Super Block的信息被破坏,可以说整个文件系统结构就被破坏了,所以超级块在每个块组的开头都有一份拷贝
文件 = 内容 + 属性,而通过上述的管理结构,我们可以看出,内容和属性是分开存的,内容存在块组中,属性在inode table中,也就是struct inode结构体中
对于删除和和新建:
删除一个文件就是将inode位图和对应的块位图中的修改成不存在即可
新建则需要为其创建一个新的inode编号,在块中写入数据,并且修改对应位图
如何理解目录呢?目录的内容存什么?
在自己目录内部保存的文件的文件名和inode的映射关系(包括任何文件)
在Linux中,文件名不属于文件属性,内核只用inode编号
如果我想在一个目录下,新建,删除,修改一个文件对于这个目录我需要什么权限?
w,此时我们可以理解了,新建一个文件,需要向该目录的文件内容中写入新建文件的文件名和inode映射关系,所以需要写权限
inode编号只在分区内有效,那么怎么知道一个文件是属于哪个分区的呢?
一个磁盘被分区格式化之后,Linux中要使用这个分区,要把这个分区要进行挂载mount,所谓挂载,就是将这个分区与一个目录关联起来,在这个目录中创建文件或目录,就是在这个分区创建文件或目录
例如:我的云服务器就只有一个分区,这个分区与/管理,也就是根目录关联
现在我们对找到一个文件的步骤有了比较清楚的认识,对于找文件的任务,肯定是进程下达的,例如:int fd = fopen("./log.txt","r")
- 进程给了我们一个相对路径,再借助进程的环境变量CWD(工作目录),我们就能拿到这个文件的路径
- 通过路径我们就能一层层的从目录的内容中通过文件名映射找到文件的inode编号和对应的group
- 通过inode编号加上对应group的start inode_number就能找到inode table中的 struct inode
- 通过struct inode,文件的属性就成功被拿到了,且用结构体内的int blocks[N]数组,查询所有该文件内容所在的块组编号
- 通过块组编号,就成功拿到了文档的内容
6.3 软硬链接
创建软连接:ln 文件名 文件名
创建硬连接:ln -s 文件名 文件名
注意:用后者链接前者
分别创建软硬链接,得到以下结果
6.3.1 软链接
什么是软连接:
对于软连接,我们可以看到链接文件与原文件的inode是不同的,也就代表这个软连接文件是一个新的文件,这里的软连接可以理解为Windows中的快捷方式,软链接文件指向着原文件的路径
软连接的作用:
一般对于工程比较大的文件来说,可执行程序一般在bin目录下,但每次执行程序都要跑到bin目录下过于麻烦,这时就可以创建软连接,放到合适的目录下
6.3.1 硬链接
什么是硬连接:
对于硬链接,我们可以看到链接文件与原文件的inode是相同的,所以硬链接不是一个新的文件,是在指定目录内部的一组映射关系:文件名<——>inode的映射关系
—个文件什么时候,应该被真正删除?
没有文件名和inode映射时(没有有人用了)
在文件系统层面,目标文件怎么知道没有文件名指向我了呢?
inode内部有引用计数,表明有几个文件名映射关系
可以看到这里硬链接的引用计数变成了2
当我们创建一个空目录的时候,这个目录的默认硬链接的引用计数是2,为什么?
因为存在
.
与..
指向当前目录和上级目录,但用户无法对目录建立硬链接
文件名在目录里面具有唯一性,文件名可以看做成"指针"
七、静态库和动态库
从两个角度理解库:
站在库的制作者角度:库中是没有main函数的,我们也不能把main函数打入库中
站在库的使用者角度:第三方库,gcc默认不认识,我们自己打包的.h和.a (.o的集合)
7.1 静态库
静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中,程序运行的时候将不再需要静态库
我们自己写一个简易的第三方静态库,更好的理解静态库
静态库打包方式:
ar是gnu归档工具,rc表示(replace and create)
ar -rc libmymath.a add.o sub.o
t:列出静态库中的文件,v:verbose 详细信息
ar -tv libmymath.a
将库打包好,与头文件一起 放入text目录下的mymath.lib
将生成的libmymath.a
放入到text/mymath_lib/lib
中
将生成的.h
放入到text/mymath_lib/include
中
在main.c中调用我们写的第三方库中的函数
我们直接用gcc编译是编不过的,因为gcc不认识第三方库
我们加上-lmymath
指定库
发现还是找不到头文件
我们再加上-I mymath_lib/include
指定头文件路径
这时gcc能够找到头文件了,也知道要使用的库是什么,但找不到库在哪
我们再指定库的路径-L mymath_lib/bin
这时就编译成功了
而为什么我们在使用C语言自己的库的时候,不需要带这么多路径呢?
C库中的头文件都放在了
/usr/include
目录下
而库都放在了/lib64
目录下
由于gcc设计出来就是给C语言使用的,所以在编译的时候gcc会自动去这两个目录找
我们也可以将网上的第三方库下载放到这两个目录下,这样在编译的时候只需要带-l
指明链接库的名称就行,不用带-I -L
,因为gcc会自动去这两个目录找
在使用静态库时,当我们编译汇编完成,链接形成可执行程序,将库和头文件都删掉,发现可执行程序还是能跑,说明静态库在编译汇编的时候,本质是将库中编译汇编好的.o文件与main.o一并链接,等于是将库拷贝到了文件里,一并链接起来,生成可执行程序,所以程序运行的时候将不再需要静态库,因为静态库就在里面
7.2 动态库
动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码
一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码
在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)
动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间,操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间
动态库其实也是先将库中的.c文件编译汇编形成.o文件,也就是目标二进制文件,与静态库相同,而不同的是动静态库的打包方式不同
静态库实打实的将.o文件一起链接起来形成可执行程序
动态库给的是一个库函数的地址进行链接
动态库打包方式:
要形成动态库还是一样要把源文件便变成
.o
文件
与静态库不同的是要加-fPIC
选项
打包的时候要用g++
使用-shared
选项
shared: 表示生成共享库格式
fPIC:产生位置无关码(position independent code)
库名规则:libxxx.so
make执行打包,生成文件
与静态库一样
将生成的libmymath.so
放入到text/mymath_lib/lib
中
将生成的.h
放入到text/mymath_lib/include
中
与静态库一样的方式进行编译
成功形成了可执行文件,执行一下
发现执行报错,我们告诉了gcc 库的位置,头文件的位置,还有链接的库,而且已经编译成功了,说明都已经找到了,但为什么执行会报错呢?
因为编译是gcc帮我们编译,而执行程序需要操作系统OS来帮我们完成,由于是动态库,不像静态库本质是将库一起链接形成可执行程序,库就在可执行程序中,而动态库给的是库函数在库中的地址,我们要OS帮我们运行,就势必需要OS知道我们的动态库在哪,这样地址才能用
那么怎么告诉操作系统库在哪里呢?
方法一:直接安装到系统中
将头文件放在
/usr/include
目录下,库放在了/lib64
目录下
方法二:通过使用软连接,查找动态库
原地在该目录下对库创建一个软连接,可以选择将软连接链入
/lib64
目录下
方法三:LD_LIBRARY_PATH,使用环境变量的方式,让系统找到自己的动态库
可以将自己动态库的lib路径写入LD_LIBRARY_PATH中
值得注意的是:
export添加的环境变量,在下一次登录的时候就会没有,如果需要每次连接云服务器都存在,则需要去修改环境变量的配置文件
方法四:直接更改系统关于动态库的配置文件/etc/ld.so.conf.d/
注意:
gcc是默认动态链接的,但个别库如果你只提供.a
,gcc也没有办法,只能局部性的把你指定的.a
,进行静态链接,其他库正常动态链接,但如果加上-static
,就必须要.a
7.3 动态库的加载过程
首先要知道,Linux下的可执行程序都是ELF格式的
我们在使用动态库的时候,OS不仅要将代码和数据加载到内存,还需要将对应的动态库一并加载到内存
程序没有被加载,程序内部有地址吗?变量名,函数名等,编译成为二进制,还有吗?
没被加载,程序内部有地址,而变量名和函数名都没有了,只有地址
编译的时候,生成可执行程序,对代码进行编址,基本遵守虚拟地址空间的那一套
虚拟地址空间,不仅仅是OS里面的棚念
编译器编译的时候,也要按照这样的规则编译可执行程序
这样才能在加载的时候,进行从磁盘文件到内存,在进行映射
也就是可执行程序中也是按照虚拟地址的规则排布的,分为代码区,数据区,堆栈区等等
理解绝对编址和相对编址的概念
这个问题就是高中物理中的参照物的问题,当你选择的参照物不同,你所描述的物体的位置也不同
绝对编址就是从0-FFFFFFFFH结束,以0地址为起点,描述位置
而相对编址则是从某个函数,某个程序,某个段,通过偏移量来描述位置
以基地址+偏移量的方式来描述称为逻辑地址,也可以称为虚拟地址
在以动态库链接生成ELF可执行程序时,加载到可执行程序中的就是相对于某个动态库的偏移地址
运行可执行程序的时候,OS会将用到的动态库加载到内存中(虚拟地址空间)的共享区,并将所有加载到共享区的动态库都管理起来,标注好每个动态库在共享区的地址,进程在执行库函数的时候,拿着已经写好的在库函数的偏移量和OS提供的库地址
通过基地址+偏移量的方式拿到库中函数的位置,进行执行
八、虚拟地址空间拓展
在上文中我们了解到:
编译的时候,生成可执行程序,对代码进行编址,基本遵守虚拟地址空间的那一套
虚拟地址空间,不仅仅是OS里面的棚念
编译器编译的时候,也要按照这样的规则编译可执行程序
这样才能在加载的时候,进行从磁盘文件到内存,再进行映射
并且ELF可执行程序自己会在特定的位置,记录下来自己程序的入口地址entry,供OS读取
如何理解呢?