当前位置: 首页 > article >正文

<Linux> 基础IO

目录

一、C语言文件IO

1. 基础认知

2. stdin、stdout、stderr

3. 文件接口汇总

4. 文件写入

5. 文件读取

6. 标志位传递 

7. 总结

二、系统文件IO

1. 文件系统调用open

1.1 pathname :

1.2 flags :

1.3 mode:

1.4 open函数返回值:

小结:

2. 访问文件的本质

2.1 文件描述符fd

2.2 文件描述符分配规则

三、 重定向

1. 输出重定向

2. dup

3. 输入重定向

四、数据缓冲区 

1. 语言缓冲区

2. 刷新策略

3. FILE结构体

4. 简单模拟实现

 五、文件系统

1. 磁盘 

2. 磁盘分组 

3. 文件操作的解析

4. 再看目录

5. 软硬链接

5.1 软链接 

删除软链接 

5.2 硬链接 

六、动静态库

1. 静态库

制作库

使用库 

2. 动态库

3. 动态库加载

4. 再谈进程地址空间

5. 动态库的地址


一、C语言文件IO

1. 基础认知

  • 文件 = 内容 + 属性
  • 文件:分为打开的文件和未打开的文件
  • 打开的文件:是进程打开的,因为文件要被打开就必须被加载到内存,操作系统内部一定存在大量的被打开的文件,既如此,操作系统也一定会管理这些被打开的文件,所以先描述再组织!从而管理这些文件,即这些文件都必须有自己的文件打开对象,其中包含文件的诸多属性,本质研究的是进程和文件的关系
  • 未打开的文件:在磁盘中存放,而未被打开的文件非常多,文件需要被分门别类的放置,还可以被我们快速的增删查改

2. stdin、stdout、stderr

        Linux下一切皆文件,Linux下的任何东西都可以看作是文件,显示器、键盘、磁盘、网卡等都可以看作是文件。我们能看到显示器上的数据,是因为我们向“显示器文件”写入了数据,电脑能获取到我们敲击键盘时对应的字符,是因为电脑从“键盘文件”读取了数据。

        需要注意的是,打开文件一定是进程运行的时候打开的,而任何进程在运行的时候都会默认打开三个输入输出流,即标准输入流、标准输出流以及标准错误流(文件),对应到C语言当中就是stdin(键盘文件)、stdout(显示器文件)、stderr(显示器文件)

        C语言当中有标准输入流、标准输出流和标准错误流,C++当中也有对应的cin、cout和cerr,其他所有语言当中都有类似的概念。实际上这种特性并不是某种语言所特有的,而是由操作系统所支持的。

FILE是c语言的自定义类型
extern FILE *stdin;
extern FILE *stdout;
extern FILE *stderr;

        stdin、stdout、stderr的类型都是一个FILE的结构体指针类型,FILE结构体里面封装了fd文件描述符!        

        标准输入流对应的设备就是键盘,标准输出流和标准错误流对应的设备都是显示器

3. 文件接口汇总

C语言文件操作函数:

  • fopen :打开文件
  • fclose  :关闭文件
  • fputc  :写入一个字符
  • fgetc  :读取一个字符
  • fputs  :写入一个字符串
  • fgets  :读取一个字符串
  • fprintf  :格式化写入数据
  • fscanf  :格式化读取数据
  • fwrite  :向二进制文件写入数据
  • fread  :从二进制文件读取数据
  • fseek  :设置文件指针的位置
  • ftell  :计算当前文件指针相对于起始位置的偏移量
  • rewind  :设置文件指针到文件的起始位置
  • ferror  :判断文件操作过程中是否发生错误
  • feof  :判断文件指针是否读取到文件末尾

4. 文件写入

fwrite 函数的第一个参数是要写入的字符串 ,第二个参数是要输出数据的元素个数,第三个参数是每个元素的大小,第四个参数是数据输出的目标位置。该函数调用完后,会返回实际写入目标位置的元素个数,当输出时发生错误或是待输出数据元素个数小于要求输出的元素个数时,会返回一个小于count的数。

        w:在写入之前会对文件进行清空

举例:

这里第二个参数strlen之后不需要再加1,因为这只是C语言的要求,跟Linux文件无关

        fopen以w方式打开文件,如果文件在当前目录下不存在(不写绝对或相对路径),就会默认在当前路径下新建一个文件,这个当前路径就是cwd,我们可以使用指令进行查看

        我们之前学的输出重定向echo "XXXXXXXXX" > log.txt ,根据猜测,它底层调用的函数肯定是带w的,即打开文件先清空!

ls /proc/进程pid

根据之前所学的内建命令cd,我们还可以在main函数内使用chdir修改当前工作路径

可以看到文件被创建在chdir指定的/home/ljs路径下,而不是之前的路径

5. 文件读取

#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;
}
./myproc
hello world
hello world
hello world
hello world
hello world

6. 标志位传递 

flag:比特位方式的标志位传递方式

#define ONE (1<<0) // 1
#define TWO (1<<1) // 2
#define THREE (1<<2) // 4
#define FOUR (1<<3) // 8

void show(int flags)
{
    if(flags&ONE) printf("hello function1\n");
    if(flags&TWO) printf("hello function2\n");
    if(flags&THREE) printf("hello function3\n");
    if(flags&FOUR) printf("hello function4\n");
}



int main()
{
    printf("-----------------------------\n");
    show(ONE);
    printf("-----------------------------\n");
    show(TWO);
    printf("-----------------------------\n");

    show(ONE|TWO);
    printf("-----------------------------\n");
    show(ONE|TWO|THREE);
    printf("-----------------------------\n");
    show(ONE|THREE);
    printf("-----------------------------\n");
    show(THREE|FOUR);
    printf("-----------------------------\n");
}

        这些宏定义选项的共同点就是,它们的二进制序列当中有且只有一个比特位是1,且为1的比特位是各不相同的,这样一来,在show函数内部就可以通过使用 “与” 运算来判断是否设置了某一选项。

7. 总结

打开文件的方式

  • r :Open text file for reading. The stream is positioned at the beginning of the file.
  • r+ :Open for reading and writing. The stream is positioned at the beginning of the file.
  • w :Truncate(缩短) file to zero length(把文件大小截断为0) or create text file for writing. The stream is positioned at the beginning of the file.  先清空再填写
  • w+ :Open for reading and writing. The file is created if it does not exist, otherwise it is truncated. The stream is positioned at the beginning of the file.
  • a :Open for appending (writing at end of file). The file is created if it does not exist. The stream is positioned at the end of the file. 追加
  • a+ :Open for reading and appending (writing at end of file). The file is created if it does not exist. The initial file position for reading is at the beginning of the file, but output is always appended to the end of the file.
        以上是我们之前学的小部分文件相关操作。还有 fseek ftell rewind 等诸多文件函数,在C部分已经有所涉猎,详细细节可自行查阅
        系统调用与库函数是封装关系

二、系统文件IO

        我们在Linux平台下运行C代码时,C库函数就是对Linux系统调用接口进行的封装,在Windows平台下运行C代码时,C库函数就是对Windows系统调用接口进行的封装,这样做使得语言有了跨平台性,也方便进行二次开发。

        上面的 fopen fclose fread fwrite 都是C标准库当中的函数,我们称之为库函数(libc)。

而, open close read write lseek 都属于系统提供的接口,称之为系统调用接口。系统调用接口和库函数的关系,一目了然。

        所以,可以认为,f#系列的函数,都是对系统调用的封装,方便二次开发。

        文件是在磁盘上的,磁盘是外部设备,所以访问磁盘文件就是访问硬件!

        因为操作系统不相信用户,并且用户也不能跨越操作系统直接操控硬件,所以,所有的库只要是访问硬件设备,必定要封装系统调用!例如,printf、fprintf、fscanf、fwrite、fread、fgets、gets、fopen等等,这些库函数都封装了系统调用接口,fopen封装了open

1. 文件系统调用open

man 2 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);
1.1 pathname :
  • 若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建
  • 若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建。(注意当前路径的含义,就是cwd)
1.2 flags :
  • 打开文件时,可以传入多个参数选项,表示文件的打开方式,用下面的一个或者多个常量进行“或”运算,构成flags。
  • flags是整型,有32比特位,若将一个比特位作为一个标志位,则理论上flags可以传递32种不同的标志位。实际上传入flags的每一个选项在系统当中都是以宏的方式进行定义的:

O_RDONLYO_WRONLYO_RDWR、O_CREAT 在系统当中的宏定义如下

#define O_RDONLY         00
#define O_WRONLY         01
#define O_RDWR           02
#define O_CREAT        0100
  • O_RDONLY: 只读打开
  • O_WRONLY: 只写打开
  • O_RDWR : 读,写打开(这三个常量,必须指定一个且只能指定一个)
  • O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
  • O_APPEND: 追加写
  • O_TRUNC:先清空

        打开文件时,可以传入多个参数选项,当有多个选项传入时,将这些选项用 “或” 运算符隔开。
        例如,若想以只写的方式打开文件,但当目标文件不存在时自动创建文件,则第二个参数设置如下: 

O_WRONLY | O_CREAT

返回值:

  • 成功:新打开的文件描述符
  • 失败:-1

        open第一个参数是要打开的文件路径,第二个参数是什么方式打开,第三个参数是权限。(第一个open函数常用于已创建的文件,第二个open函数常用于未创建的文件) 

1.3 mode:

        open函数的第三个参数是mode,表示创建文件的默认权限

int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);

        如果将mode设置为0666(0表示八进制数),则文件应该以 rw-rw-rw- 的权限被创建出来,如果使用第一种open函数,他没有mode参数,那么文件被创建出来的权限将会是乱码权限,这是因为linux创建文件时,必须告诉linux它是什么权限

所以此时必须使用第二种open函数

为什么other的权限不是rw-呢? 

        这是因为umask掩码存在所引起的,因为文件权限umask初始未0002 (如果让我们写一个touch命令,其实就是open打开文件,再关闭文件,权限指明为0666即可)

1.4 open函数返回值:

        file descriptor:文件描述符,fd,整形int(本质是数组下标)

man 3 close

man 2 write

 

        解释:对fd的文件进行写入 buf 字符串 

将message改短一些,主动修改一下log.txt

覆盖式文件写入,不会对文件清空,所以如果要清空,就要加 O_TRUNC:先清空,再来看

        所以,我么可以采用 O_WRONLY、O_CREAT、O_TRUNC 这三个选项,完成打开文件写入、不存在就创建文件、写入时先清空的逻辑!

那么如何实现追加功能 ?

        O_APPEND 

小结:

        我们刚刚使用的是系统接口,而那些文件函数实则是封装了底层的系统调用接口 。

        不管是任何一个语言,只要是在Linux下跑,它底层一定采用的是同样的接口——对open、read、write、close的封装

  • O_RDONLY: 只读打开
  • O_WRONLY: 只写打开
  • O_RDWR : 读,写打开(这三个常量,必须指定一个且只能指定一个)
  • O_CREAT : 若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权限
  • O_APPEND: 追加写
  • O_TRUNC:先清空
FILE* fp = fopen("log.txt", "w");    //清空写
底层为:
int fd = open("log.txt", O_WRONLY|O_CREAT|O_TRUNC, 0666);

FILE* fp = fopen("log.txt", "a");    //追加写
底层为:
int fd = open("log.txt", O_WRONLY|O_CREAT|O_APPEND, 0666);

2. 访问文件的本质

2.1 文件描述符fd

        访问文件要先打开文件,每一个被打开的文件都要在内核创建 struct file 数据结构,在该数据结构内直接或间接的包含以下属性:在磁盘的什么位置、基本属性(权限、大小、读写位置、哪个用户打开的)、文件的内核缓冲区信息、struct file*next 指针,将所有被打开的文件用双链表链接起来,此时对被打开的文件的操作,就成为了对双链表的增删查改

        每一个进程可以打开n个文件,那么进程如何得知哪个文件是被自己打开的,这是需要被管理的信息,所以在 task_struct 中包含了一个指针 struct files_struct *file,它指向了一个结构体struct files_struct,这个结构体内有一个指针数组 struct file *fd_arry[],这个指针数组指向了每个被该进程打开的文件!也就是说每一个被打开的文件的的 struct file 结构体的地址被存放在指针数组内,以数组下标来管理被该进程代开的文件,此时这个结构体 struct files_struct 被称为文件描述符表。

        所以open的返回值就是该进程打开的文件的struct file在指针数组内的下标值

        所以我们在使用 write 时,传入参数fd,他工作的原理就是将fd传给进程,进程通过struct files_struct* files指针找到文件描述符表,再根据fd数组下标找到数组元素struct file*,再根据该指针找到被该进程打开的文件,进而对文件进行操作

        一个文件可以被多个进程打开,在struct file中有 count 用来计数,这就是引用计数!当有一个指针或引用指向该文件时,count++,每有一个指针或引用被销毁时,count--,对应进程的文件描述符表内下标对应的数据置空即可,如果count不为0,那么就不要释放该结构体,因为别人还在用呢,如果为0,那么系统回收该struct file结构体对象即可

int open(const char *pathname, int flags, mode_t mode);

0,1,2下标去哪里了?

        C语言默认打开FILE类型的三个流,stdin、stdout、stderr,而在Linux下,Linux只认fd,所以stdin、stdout、stderr就分别对应下标0,1,2

        Linux进程默认情况下会有3个缺省打开的文件描述符,分别是标准输入0, 标准输出1, 标准错误2

        一个进程启动,默认就打开0,1,2三个流

        而现在知道,文件描述符就是从0开始的小整数。当我们打开文件时,操作系统在内存中要创建相应的数据结构来描述目标文件。于是就有了file结构体。表示一个已经打开的文件对象。而进程执行open系统调用,所以必须让进程和文件关联起来。每个进程都有一个指针*files, 指向一张表files_struct,该表最重要的部分就是包涵一个指针数组,每个元素都是一个指向打开文件的指针!所以,本质上,文件描述符就是该数组的下标。所以,只要拿着文件描述符,就可以找到对应的文件

例如,向stdout、stderr中写入

例如,从stdin读数据

2.2 文件描述符分配规则

文件描述符对应的分配规则是什么? 

        从0下标开始,寻找最小的没有被使用的数组位置,它的下标就是新文件的文件描述符

例如,如果提前close(1),那么新打开的文件fd就是1,并且main函数内的printf 不能像显示器打印,如果close(2),那么新打开的文件fd就是2,并且printf可以向显示器打印,因为1是stdout,2是stderr

此时,log.txt的 fd 会成为1,write会向log.txt 中写入

默认打开三个标准输入输出流文件是C语言的特性吗?

        不是!这是操作系统的特性,进程会默认打开键盘,显示器。在开机时,操作系统就已经打开了键盘和显示器

C语言内的FILE是什么?

        C库自己封装的结构体!里面封装了文件描述符fd,这样就使C库的文件函数可以根据fd调用系统调用函数open、write、read

        同样的C++中的fstream是一个类,里面也封装了fd!所以任何一门语言,想要在系统中访问文件,就必须要包含fd

printf("stdin->fd: %d\n", stdin->_fileno);
printf("stdout->fd: %d\n", stdout->_fileno);
printf("stderr->fd: %d\n", stderr->_fileno);

如果在打印前close掉1下标文件(strout),那么还能向显示器打印字符吗?

 

记录printf返回值,查看错误

返现返回了13,这是printf向显示器打印了13给字符,printf认为它打印成功了,所以返回13

为什么stdout关闭了,fprintf还能向显示器输出?

        因为2号 strerr 同样指向了显示器文件,也可以向显示器打印

        一个文件可以被多个进程打开,在struct file中有 count 用来计数,这就是引用计数!当有一个指针或引用指向该文件时,count++,每有一个指针或引用被销毁时,count--,对应进程的文件描述符表内下标对应的数据置空即可,如果count不为0,那么就不要释放该结构体,因为别人还在用呢,如果为0,那么系统回收该struct file结构体对象即可

        所以此时显示器文件没有被关闭,就可以对显示器输出

1(stdout),2(stderr) 究竟有什么区别?

三、 重定向

1. 输出重定向

cat > log.txt

        1(stdout)被关了,此时 log.txt 的 fd 会成为1(因为文件描述符表会按数组下标空缺顺序给fd),所以 write会向log.txt 中写入,而不向显示器文件写入

        write 它只认fd ,他不管1号文件描述符发生了什么情况,这就是重定向的原理

2. dup

        为了更方便的重定向,不用手动close,我们有dup系统调用来简化

man dup

 

int dup2(int oldfd, int newfd)

newfd 被 oldfd 覆盖,即oldfd是新文件的fd,newfd就是1

原理:将新文件的文件描述符 fd 指向的struct file指针地址拷贝覆盖到 1 号描述符数组元素!即,1号不指向显示器文件,再拷贝之后,1会指向新文件,所以对显示器的输出就会重定向为向新文件输出;同理,输入重定向也是同样的道理

dup2之后,我们可以选择立即关闭 fd 文件,也可以最后关闭,这没有影响,因为此时已将拷贝覆盖到1号中了,3号可以进行关闭

3. 输入重定向

cat < log.txt

         read的第三个参数count指的是期望输入的大小,例如我期望1024字节,但实际上如果我们只输入了4个字节就回车了,那么返回值就是实际获取的字节大小

        此时,read就不会从键盘读取数据,而是从我们输入重定向的 log.txt 文件中读取

既然系统调用可以输入输出重定向,那么C库封装的文件函数可以吗?

 

答案是可以的

如果我们此时将之前的模拟实现的shell中加上重定向功能(本质就是在处理字符串时判断是否有>、>>、< 这三种字符,再获取后面的文件名,并且在程序替换前进行dup2重定向即可),那么我们可能有一些疑惑,进程程序替换会影响重定向吗?

        不会!因为重定向它属于内核数据结构部分,进程程序替换(将新的代码和数据加载到内存,替换掉原代码和数据,再改变页表映射关系)这是内存管理部分,这两者是解耦关系!互不影响!

        即进程历史打开的文件与进行的各种重定向关系都和未来进行的程序替换无关,程序替换不影响文件访问。

程序输出,将正常的打印信息放到normal文件,将错误信息放到err文件

 

./mytest 1>normal.txt 2>err.txt

        意思是,将向1(stdout)输出的重定向到normal.txt,向2(stderr)输出的重定向到err.txt 

 

怎么将1和2都输出到同一个文件?

./mytest > log.txt 2>&1

        这里的 2>&1,原理是1将自己新指向的文件地址拷贝一份给2,所以此时1和2同时指向新的被打开的文件的struct file

综上,我们再次理解,在Linux视角下一切皆文件

        所有的操作计算机的动作,都是以进程的形式操作的!所以访问文件,也是以进程的方式操作的

        操作系统会先描述再组织每一个外设(磁盘、键盘、网卡等等),每一个外设都有各自的驱动,驱动要求每一个外设需提供各自的读写方法。当外设被打开,struct file被创建,该结构体中会有一个指针 f_ops,指针指向一个结构体strcut operator_func,这个结构体中存储的是外设的各种操作方法的函数指针!

        即每一个外设都被视为一个文件,这些底层设备有公共相同的方法函数(例如读写函数),要使用该外设时,操作系统创建 strcut file 和 operation_func 函数指针结构体并初始化,struct file中包含了f_ops指针,指针指向struct operator_func结构体,该结构体中包含了该外设的操作方法集,再将strcut file地址填入文件描述符表,当f_ops指针指向哪一个外设的操作方法集结构体,struct file就调用谁,这也是C++中的封装(结构体)、继承、多态的设计来源。面向对象是历史的必然!

        每当打开一个文件,操作系统创建一个struct file ,f_ops指向该外设的操作方法集结构体struct operator_func,里面存的就是外设的读写等函数的地址,这个软件层系统被称为虚拟文件系统

四、数据缓冲区 

1. 语言缓冲区

有close(1)前提下,为什么write能显示打印结果,而printf、fprintf、fwrite不能显示打印结果?

        printf、fprintf、fwrite都是C语言提供的库函数,它们的缓冲区不在操作系统内部,而是语言层面提供的缓冲区,这些函数的输出会暂存在语言层面提供的缓冲区中,在合适的时机(例如输出字符串中有 \n),系统会调用write系统调用接口将语言层面的缓冲区写入到系统内部的缓冲区。又因为程序结束前close(1),将1号文件描述符对应的文件关闭了,所以C语言的缓冲区没有机会去调用write写入到stdout对应的系统文件缓冲区中,写入失败,所以此时运行程序没有输出,没有显示结果。

        显示器文件的刷新方案是行刷新,如果 printf 等C库函数输出的字符串中有 \n,C标准库会立即识别缓冲区中有 \n,此时 C 语言缓冲区会立即将数据进行刷新到系统级缓冲区。所以,刷新的本质就是将数据通过1 + write写入到内核中!

        write是系统调用接口,它通过文件描述符fd找到struct file中指向的系统缓冲区,向系统缓冲区中写入,这是系统级别的,当程序结束,系统缓冲区自动刷新到磁盘或显示器,所以write可以输出,显示结果

        所以我们可以认为:只要将数据刷新到内核,数据就到硬件中了

exit 是C库提供的接口,_exit 是系统调用,根据上面的知识,这两个为什么一个可以刷新缓冲区,一个不会呢?

        _exit 和close 一样是系统调用接口,它看不到C库缓冲区!它的本质就是直接的把文件描述符对应的文件关掉,进程退出

        exit是C库函数,它可以看到C语言的缓冲区,在eixt 内部会调用 fflush(stdout) 和 _exit,将数据刷新后,再关闭文件,进程退出 (fflush函数底层封装了write系统接口)

2. 刷新策略

那么C缓冲区的刷新情况有哪些呢?(系统级缓冲区由操作系统来维护,我们管不着)

        无缓冲、行缓冲、全缓冲,它们的刷新策略分为为(以C语言为例,每个语言都有各自语言层面的缓冲区):

  • 无缓冲:向C缓冲写入之后,不用等待时机,直接刷新
  • 行缓冲:不刷新,直到遇见 \n   ——  对应显示器显示
  • 全缓冲:C缓冲区满了才刷新  ——  对应普通文件写入
  • 进程退出时也会刷新

C缓冲区根据这些刷新策略,来确定printf、fprintf、fwrite 这些函数调用write刷新缓冲区的时机,但是如果close(1)之后,这些函数都无法调用write函数进行刷新了

为什么要设计语言层面的缓冲区?(输入输出都有缓冲区)

  1. 解决效率问题。如果世界上没有驿站、快递公司,那么如果我想送给远地的朋友一个东西,我要乘交通工具,千里迢迢的送过去,如果相距南北两极,这就极大浪费了我们的时间,可是如果有快递公司,那么我们只需下楼交给驿站,立马就可以干别的事情,这就是缓冲区的意义。对应fwrite、fprintf这些函数,它们只用将数据放到缓冲区,就可以return返回了,不用亲自将数据放到内核缓冲区中。普通快递并不会立即发送,而是等待积压到一定程度,再派送出去,当然还有加急件,这就对应了缓冲区的各种刷新策略
  2. 配合格式化。sacnf、printf 函数是格式化输入输出函数,printf 输出到显示器上的数据都是字符!格式化控制的数据(%d,%c,%p...)会转为字符串的形式再写入到缓冲区中;同理,scanf是将字符串按照格式化控制,进行转化,例如我要输出整数123,其实是输入了字符‘1’,‘2’,‘3’,通过输入缓冲区转为整形。read、write都没有限制 buf 数组是什么类型,他们都是void*类型,这是因为它们都将输入输出的数据看作字符串,而类型转换则是由上层的printf、scanf 内部各自进行解释的

C缓冲区有进数据,也有出数据,就类似河流一样,所以被称为流,即文件流!每个语言都有各自的缓冲区 —— 流

那么C语言的缓冲区在哪里呢? 

        FILE中!C语言的文件操作离不开FILE指针,FILE是一个struct结构体,FILE中封装了文件描述符fd、打开文件的缓冲区字段和维护信息

fprintf(stdout, "hello world\n");

        例如fprintf函数,它就是将字符串hello world放到FILE* 类型的stdout结构体中的一个缓冲区域,缓冲区根据自己的刷新策略,及时的调用write刷新缓冲区!

        C语言中,每一个文件都有各自的缓冲区!它们通过文件描述符fd调用write进行刷新各自缓冲区

3. FILE结构体

typedef struct _IO_FILE FILE;
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 0
	int _blksize;
#else
	int _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
};

FILE对象属于用户还是操作系统呢?缓冲区是属于用户的缓冲区吗?

        属于用户,语言都属于用户。缓冲区也是用户的缓冲区

所以fopen为什么返回FILE*呢?

它底层调用open,并在语言层面malloc(FILE),在库当中就申请好返回

为什么加上fork()后,将可执行程序重定向到 log.txt 文件中就多打印了三行(C函数多打印了)?

        例如fprintf函数,它就是将字符串hello world 放到 stdout 对应的 FILE 结构体中的用户级缓冲区域中,缓冲区根据自己的刷新策略,及时的调用write刷新缓冲区,然后 write 再根据 task_struct 指向的文件描述符表中查找 fd 所指向的显示器文件的缓冲区

 

向显示器输出是行缓冲策略,而一旦重定向到文件后,刷新策略改为全缓冲,把缓冲区写满才刷新

举例:

while :; do cat log.txt; sleep 1; echo "--------------"; done

        结果是write的打印结果先出现在log.txt文件中,三个C库函数在进程退出后才出现,这是因为重定向之后,刷新策略改变为全缓冲,三个字符串并不会使缓冲区爆满,也就表明此时缓冲区不会刷新,所以只有到了进程退出的时候缓冲区才会刷新,三个C库函数打印的字符串才出现在文件中。write是直接写在操作系统的缓冲区中,所以会在打印的第一时间就出现在文件中。

        因为fork之后就是退出进程,fork的子进程与父进程代码和数据共享,这个缓冲区是malloc出来的空间,实际上就是在进程地址空间内的堆空间处,属于各自进程,所以fork之后,子进程也有对应的数据,当进程退出时,用户级缓冲区要刷新,刷新的本质就是修改,所以就会触发写时拷贝,单独为进程拷贝一份缓冲区数据,所以父进程刷新父进程的缓冲区,子进程刷新子进程的缓冲区!数据出现了两份!

那么为什么加上了C库函数输出的字符串加上 \n 再重定向到文件中就不会打印两份数据?

        这是因为 \n 会触发行缓冲,每个C库函数输出到缓冲区后直接调用 write 刷新到系统缓冲区,所以三个C库函数调用完后用户缓冲区是空的,fork之后写时拷贝也还是空的,所以不会多打印数据

C语言的跨平台性、可移植性

        通过条件编译,封装各个操作系统的系统调用接口形成C库函数,针对不同的操作系统进行代码裁剪,从而达到跨平台性

        java 的 jvm 本质就是C/C++写的,java能跨平台是因为 jvm,而 jvm 能跨平台靠的就是C/C++条件编译,它通过C/C++调用各操作系统的系统调用接口

4. 简单模拟实现

        sacnf、printf 函数是格式化输入输出函数,printf 输出到显示器上的数据都是字符!格式化控制的数据(%d,%c,%p...)会转为字符串的形式再写入到缓冲区中;同理,scanf是将字符串按照格式化控制,进行转化,例如我要输出整数123,其实是输入了字符‘1’,‘2’,‘3’,通过输入缓冲区转为整形。read、write都没有限制 buf 数组是什么类型,他们都是void*类型,这是因为它们都将输入输出的数据看作字符串,而类型转换则是由上层的printf、scanf 内部各自进行解释的
 

#makefile
myfile:main.c Mystdio.c
	gcc -o $@ $^ -std=c99
.PHONY:clean
clean:
	rm -f myfile
//Mystdio.h
#ifndef __MYSTDIO_H__
#define __MYSTDIO_H__

#include <stdio.h>

#define SIZE 1024

//位操作,更方便
#define FLUSH_NOW 1
#define FLUSH_LINE 2
#define FLUSH_ALL 4

typedef struct IO_FILE
{
    int fileno; //文件描述符fd
    int flag;   //刷新策略
    //char inbuffer[SIZE];  //输入缓冲区,这里就不演示了
    //int in_pos;   //控制输入缓冲区下标
    char outbuffer[SIZE];
    int out_pos;
}_FILE;

_FILE* _fopen(const char* filename, const char* flag);
int _fwrite(_FILE* fp, const char* s, int len);
void _fclose(_FILE* fp);

#endif
//Mystdio.c
#include "Mystdio.h"
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
#include <assert.h>
#include <string.h>

#define FILE_MODE 0666  //文件默认创建权限


//"w" "a" "r"
_FILE* _fopen(const char* filename, const char* flag)
{
    assert(filename);
    assert(flag);

    int f = 0;  //打开方式,在.h文件define了
    int fd = -1;

    if (strcmp(flag, "w") == 0)
    {
        f = (O_CREAT|O_WRONLY|O_TRUNC);
        fd = open(filename, f, FILE_MODE);
    }
    else if(strcmp(flag, "a") == 0)
    {
        f = (O_CREAT|O_WRONLY|O_APPEND);
        fd = open(filename, f, FILE_MODE);
    }
    else if (strcmp(flag, "r") == 0)
    {
        f = O_RDONLY;
        fd = open(filename, f);
    }
    else return NULL;

    if (fd == -1) return NULL;

    _FILE* fp =(_FILE*)malloc(sizeof(_FILE));
    if (fp == NULL) return NULL;

    fp->fileno = fd;
    fp->flag = FLUSH_ALL;
    fp->out_pos = 0;

    return fp;

}

int _fwrite(_FILE* fp, const char* s, int len)
{
    memcpy(&fp->outbuffer[fp->out_pos], s, len);
    fp->out_pos += len;

    if (fp->flag & FLUSH_NOW)
    {
        write(fp->fileno, fp->outbuffer, fp->out_pos);
        fp->out_pos = 0;
    }
    else if (fp->flag & FLUSH_LINE)
    {
        if (fp->outbuffer[fp->out_pos - 1] == '\0')
        {
            write(fp->fileno, fp->outbuffer, fp->out_pos);
            fp->out_pos = 0;
        }
    }
    else if (fp->flag & FLUSH_ALL)
    {
        if (fp->out_pos == SIZE)
        {
            write(fp->fileno, fp->outbuffer, fp->out_pos);
            fp->out_pos = 0;    
        }
    }

    return len;
}

void _fflush(_FILE *fp)
{
    if (fp->out_pos > 0)
    {
        write(fp->fileno, fp->outbuffer, fp->out_pos);
        fp->out_pos = 0;
    }
}

void _fclose(_FILE* fp)
{
    if (fp == NULL) return;
    _fflush(fp);
    close(fp->fileno);
    free(fp);
}
//main.c
#include "Mystdio.h"
#include <unistd.h>
#include <string.h>
#define myfile "test.txt"

int main()
{
    _FILE* fp = _fopen(myfile, "a");
    if (fp == NULL) return 1;

    const char* msg = "hello world!\n";
    int cnt = 10;
    while (cnt--)
    {
        _fwrite(fp, msg, strlen(msg));
        sleep(1);
    }

    _fclose(fp);

    return 0;
}

 以全缓冲举例,演示代码效果

 五、文件系统

1. 磁盘 

我们知道了打开的文件管理方式,那么未打开的文件是如何存储的呢?

文件 = 文件内容 + 文件属性 - - > 磁盘上存储文件 = 存文件的内容 + 存文件的属性

文件的内容 —— 数据块

文件属性 —— inode

 Linux中文件的属性和内容是分开存的

  • 磁盘被访问的基本单元是扇区——512字节或4KB
  • 我们可以把磁盘看作由无数个扇区构成的存储介质
  • 要把数据存到磁盘,第一个解决的问题是定位一个扇区:哪一面(定位用哪个磁头),哪一个磁道,哪一个扇区

2. 磁盘分组 

如果磁盘很大,我们会选择分区,分区的概念就是划分磁盘(用结构体维护起始、结束位置即可)每一个分区再进行细分,就可以划分为Block group

        Linux ext2文件系统,上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的block。一个block的大小是由格式化的时候确定的,并且不可以更改。例如mke2fs的-b选项可以设定block大小为1024、2048或4096字节。而上图中启动块(Boot Block)的大小是确定的

Block Group:ext2文件系统会根据分区的大小划分为数个Block Group。而每个Block Group都有着相同的结构组成。例如政府管理各区

Data blocks:存文件内容的区域,以块的形式呈现。常见大小为4KB,一般而言,每一个块只有自己的数据,每一个数据块都有自己的编号

inode:单个文件的所有属性,128字节。一般而言,一个文件对应一个inode。inode内部有文件类型、权限、引用计数、拥有者、所属组、acm时间、blocks数组(一般为15个元素)等等属性,其中blocks数组存放的是文件内容block的编号,前12个数组元素为直接索引,即这12个编号指向的block存储的都是文件内容,后两个数组元素寸放大是两级索引,它们所指向的block存放的不是文件内容,而是更多的block编号索引,这就方便了更大的文件的存放;最后一个数据元素存放的是三级索引,它还可以指向其他的块,但是规定了三级索引指向的块存放的还是block编号,这些block编号指向的block存放更多的文件内容。

Block bitmap:块很多,那么释放和创建是很常见的操作,我们怎么直到哪些块被使用了,哪些没有被使用呢?

        采用位图,比特位的位置和块号映射起来,比特位的内容标识该块有没有被使用。

所以,在删除一个文件时不需要把块(文件内容)清空,直接将位图中映射该块的比特位变为0即可

inode Bitmap:比特位与inode编号映射,比特位的内容标识inode是否有效

Group Descriptor Table计算 bolck inode的总量,未使用的blockinode的数量,不用每次都遍历inode、blocks表,提高效率

super block:存放文件系统本身的结构信息,是整个分区的基本使用情况。记录的信息主要有:一共有多少组、每个组的大小、每个组的inode数量、每个组的block数量、每个组的起始inode、文件系统的名称、一个blockinode的大小、最近一次挂载的时间、最近一次写入数据的时间、最近一次检验磁盘的时间等其他文件系统的相关信息、整个分区的划分规则、允许哪些用户访问等信息。Super Block的信息被破坏,可以说整个文件系统结构就被破坏了 ,并且super block不会在每个组中都存在,会导致更新效率低,但是如果只存一份又可能在发生意外时,文件系统直接崩溃(因为没有了各种规范),所以super block会零零散散的分散在少数分组中,以防意外情况,当一个super block损坏,还可以使用其他的备份将文件系统进行修正。

        如果在Linux中删除了一个文件,是能找回的,因为操作系统只是将位图中映射的 bit 为置为0了,只要能找到 inode 编号,去 inode bitmap 中修改bit位为1,再去找 inode 属性中的 blocks 数组,就可以找到文件内容块,再在 block bitmap 中修改映射的bit位为1,这样就恢复了文件,但是恢复操作不简单,在Linux中尽量不要随便删除文件

        如果误删了重要文件之后,就不要乱动电脑了,因为你的操作极有可能占用被删除文件的inode从而覆盖原先数据

格式化:每一个分区在被使用之前,都必须提前将部分文件系统的属性信息提前设置进对应的分区中,方便我们后序使用这个分区或分组 。就是把属性写清楚,inode bitmap、block bitmap都清空,当创建一个新文件时,直接获得inode和一些block,并更新前面的block bitmap、inode bitmap等字段信息

3. 文件操作的解析

 

新建一个文件,系统是如何操作的?

        首先,新建文件有所在路径,根据所在路径,操作系统会找到在磁盘中对应的分区,哪一个分组,找到对应分组后要分配 inode,此时要先看 GDT(Group Descriptor Table),看 inode 使用率情况,如果剩的很多,那么再在inode bitmap中遍历查找最近的没有被使用的编号,为新文件分配一个 inode (如果分配到了第5个inode,分组又是第二个分组,每个分组又是10000间距,那么该inode编号就是10005)再在inode table中找到对应编号的inode,将属性往里填写。

        其次,如果此时还向文件写入了内容,那么会先确认写入量的大小,判断需要多少块,再在block bitmap中遍历找最近的没有被使用的编号并直接将编号填写在对应inode的属性的block数组中,再跳转到对应的块中填写文件内容

        在这个过程中,Data blocks只是被动的接收指令被填入数据

删除一个文件,系统是如何操作的?

        所以如果删除一个文件,先拿到该文件的 inode 编号,再根据 inode Bitmap 看该 inode 是否有效,再去 inode table 中找到对应编号的 inode,读取它的属性,获取 inode 与数据块映射数组,读取所有块号,并在 Block Bitmap 中将这些块编号对应的 bit 位置为0,再将 inode bitmap 中将该 inode 的bit位也置为0,这样就高效的达到了删除效果。不需要专门的将 blocks 全部清空,因为大量的IO会大大降低操作系统效率,并减少设备的使用寿命。删除过程与 inode 、Data blocks无关,并不需要修改它们。

         在覆盖原数据时,不用担心会访问的上一次遗留的数据,因为有结构体在维护能访问的数据范围

查找一个文件,系统是如何操作的?

        先拿到文件对应的 inode 编号 ,从而找到在磁盘的分区、分组,再找到inode bitmap,查看该文件对应的 inode 是否有效存在,如果存在就在inode table中找到对应编号的inode,获取indoe结构体内部的文件属性,如果还要文件内容,那么找到 inode 结构体内的block数组,再在Data block中找到对应块,将数据拼接好返回即可

        例如,cat test.txt这个指令,cat指令拿着 test.txt 的路径找到分区,拿着 inode编号 找到分区对应的分组,再根据inode bitmap查找该inode编号是否有效存在,并在inode table找到对应inode,再根据block数组映射block编号,在Data blocks中找到对应的块,将文件内容加载到内存;同样的,stat test.txt 也是同样的道理

修改一个文件,系统是如何操作的?

        同理,根据路径和 inode编号 找到文件所在分组,根据 inode 修改文件属性,或根据 inode 的数据块映射数组修改文件内容

注意:

  • Linux中,文件属性不包含文件名字,在Linux系统里标识文件用的是inode编号。如果将文件名放到 inode 里,那么我们获取文件名前还是要先获取 inode 才能获取文件名。
  • 目录也是一个文件(Linux下一切皆文件 ),他也有自己独立的inode,有自己的权限属性、字节大小等属性

4. 再看目录

我们怎么知道一个文件的inode编号?

        目录也是一个文件(Linux下一切皆文件 ),他也有自己独立的inode,有自己的权限属性、字节大小等属性,特别的,目录也有数据块,在数据块内部存放的是该目录下文件的文件名与文件对应的inode的映射关系,即如果要向一个目录下的文件进行写入操作,那么获取文件的inode之前要先获取目录的inode,获取目录的inode之后再根据inode的数据块映射数组找到文件名与inode的映射关系,从而找到该文件的inode,才能进行下一步的写入操作

        注意:要获取目录的inode就要向上一级目录找,上一级目录还要向上一级目录找,这就会一直递归到根目录,然后再一路返回才能获取文件所在目录的inode,这个过程需要文件的绝对或相对路径,按照文件路径进行从左往右的解析inode

为什么同一个目录下不能有同命名文件?

        因为目录的 block 中存放的是文件名与文件对应 inode 的映射,是 key—value 模型,不能有重复的 key 值

为什么目录没有w权限,我们无法创建文件

        目录的 inode 有权限限制,没有w权限,即使创建了文件,也无法在目录所在的block中写入映射关系

为什么目录没有r权限,我们无法查看文件

        同样的,目录的 inode 有权限限制,如果该用户没有r权限,目录就不允许被读,那么就无法读取 inode 的数据块映射数组,不能读取该数组就不能获取目录下文件对应的 inode,也就无法根据 inode 获取文件属性、文件内容

为什么目录没有x权限,我们无法进入目录

        在进入目录前,先判断权限即可

小结:目录也被看做文件,整个Linux中,都是以 inode 和 data block 来进行管理的,根据inode中表示的不同文件的属性,从而区分展示目录或操作文件的操作!

5. 软硬链接

//软连接
ln -s file.txt soft-link

//硬连接
ln test.txt hard-link

        图中权限后面的数字1或2指的是硬连接数 ,如果将硬链接test.txt文件删除,那么硬连接数就会变为1。任何一个文件(无论是目录还是文件)都有inode,每一个inode内部都有一个引用计数的计数器,当删除目录下的一个文件时,会先在目录的block中删除映射关系,根据inode编号找到对应分组的inode结构体,将引用计数器--

5.1 软链接 

        软链接是一个独立的文件,因为具有独立的inode。它也有独立的数据块,它的数据块存放的是指向文件的路径!通过路径找到文件分组,这是一种独立性较高的连接,如果原文件被删除,那么软连接的文件就会失效

类似的,我们电脑上的快捷方式的目标本质就是软链接

"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"

软链接应用:

        可以链接一个路径很深的可执行程序

删除软链接 
rm soft-link    //删不干净
unlink soft-link    //常用
5.2 硬链接 

        硬链接不是一个独立的文件,因为它没有独立的inode。硬链接本质上是在目录的block内新增一条映射关系,新的文件名与重复的inode,即 hard-link 与test.txt 指向同一个文件

硬链接的应用

        每一个目录都有隐藏文件 . 和 .. ,其中 . 进行了硬链接,它指向了本目录

 bin的硬链接数是3的原因:本身一个、目录下 . 是一个、目录的目录下的 .. 是一个

 同样的,我们可以根据根目录的硬链接数,减去2就是它的子目录数量,18 - 2 = 16个子目录

 

Linux系统不允许对目录硬链接,只能软链接,因为硬链接会导致查找文件时发生环路问题 

        那为什么系统就可以使用 . 和 .. 进行硬链接?因为系统是老大哥,在查找文件时不会查找 . 和 .. 文件 ,所以不会

补充:当应用层进程调用系统调用,向fd文件写入数据时,通过task_struct找到files_struct的文件描述符表,根据该表找到被该进程打开的文件struct file,要补充的是strcut file结构体只包含了少部分inode属性,所以该结构体还指向了struc inode(里面包含了inode的大部分属性),还指向了文件缓冲区,文件缓冲区是一个多叉字典树,通过映射page页向物理内存写入数据,最后操作系统再根据IO队列,依次向磁盘写入

六、动静态库

  • 静态库(.a):程序在编译链接的时候把库的代码链接到可执行文件中。程序运行的时候将不再需要静态库

  • 动态库(.so):程序在运行的时候才去链接动态库的代码,多个程序共享使用库的代码。

  • 一个与动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码

  • 在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接(dynamic linking)

  • 动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间

1. 静态库

制作库

我们提供的函数给别人用有两种选择:

  • 把源文件直接给他
  • 把我们的源代码打包成库 (库 + 头文件)再发给他。并且头文件不能省略不给,因为它相当于是函数的说明,不然用户不会用。打包就是将各个 .c 源文件编译成 .o 文件并全汇总到一个 libXXX.a 格式命名的文件中。最后再用户的 main.c 文件再编译为 .o 文件,两者链接即可编译成功
//生成静态库
[root@localhost linux]# ar -rc libmymath.a add.o sub.o 
//ar是gnu归档工具,rc表示(replace and create)
  • gcc默认是动态链接,如果要静态链接需要在gcc后面加 -static 选项
  • 静态库都要以 libXXX.a 格式命名,其中 XXX 才是库的名称
  •  -static其实是一种建议,如果只有静态库那么编译器也只能静态链接,如果只有动态库那么也只能动态链接,而且还能混合链接

ar 命令是一个生成静态库的命令,将所有的 .o 文件打包成一个文件

-rc 表示 repalce and create,如果 lib 文件内没有该 .o 文件就添加,有就替换

gcc -c 时不指明目标文件,会直接生成同名的.o后缀文件

使用库 

发布:创建一个目录,里面放有头文件和库函数

 

        如果别人使用我们的库,那么直接将 lib 文件夹给他就行了

我们创建一个 test 目录,表示用户使用情况

gcc main.c -I ./lib/include/ -L ./lib/mymathlib/ -lmymath
-L 指定库路径
-l 指定库名
  • -I (大写i)表示指定路径的头文件,因为编译器只会在当前目录或库中去找,而我们的头文件在子目录下,所以编译器找不到,所以需要我们指定路径去找
  • -L同理,指定路径说明我们的库文件在哪
  • 但是光有-L不够,因为该路径下可能有多个库,所以还需要 -l(小写L) 指定名字(这个名字必须去头lib,去尾.a,这样操作才是名字),并且一般 l 后面紧跟库名。头文件不用再指明是因为在 main 函数内部已经 include 指明名字了。如果需要链接多个库,可以多个-l(小写L)
  • gcc、g++本来就认识C、C++库,所以之前我们不用手动指定名称。除了操作系统提供的库(fork、wait等)、语言提供的库这两种库,我们使用的其他的库gcc一律不认识,都被认为第三方库(例如公司的库、网上下载的库等),这种情况一律都要使用 -l (小写L)指定我们库的名称

如果不想每次编译都这么长,我们可以将头文件和库文件都拷贝到默认的系统路径下,但是最后还是要加上-l指定名称,因为是第三方库。但是不建议将我们瞎写的库放到系统库中,可能会污染其他库文件

sudo cp lib/include/mymath.h /usr/include/
sudo cp lib/mymathlib/libmymath.a /lib64/
gcc main.c -lmymath

        两个sudo cp是一种库安装操作,将头文件拷贝到系统头文件默认路径,将库文件拷贝到系统库文件默认路径 

也可以使用软链接,在user/include/中添加软连接也需要使用 -l 指定名字

sudo ln -s /home/ljs/.../lib/include /usr/include/myinc
sudo ln -s /home/ljs/.../lib/mymathlib/libmymath.a /lib64/libmymath.a

所以我们使用的C、C++,包含头文件就能使用例如string,这是因为我们在下载C、C++时它的库跟着就被安装到了编译器,以 .a.so 形式存起来

2. 库搜索路径

1. 从左到右搜索-L指定的目录。

2. 由环境变量指定的目录 (LIBRARY_PATH)

3. 由系统指定的目录

        /usr/lib

        /usr/local/lib

2. 动态库

这次我们将同时编译静态和动态两个库,并打包生成到 mylib 目录。编译两个可执行 使用伪目标 .PHONY(之前讲过)

  • ar命令生成静态库,gcc命令生成动态库,因为gcc默认就可以把源文件打包成动态库,这就是gcc默认生成动态库的原因
  • 打包时加上 -shared 表明生成的不是可执行程序,因为此时这两个.o文件内都没有main函数(并且库里面本身就不能去写main函数)
  • 文件的可执行权限:这个文件是否会以可执行程序加载到内存中,动态库默认带x权限,静态库没有x 权限,动态库不是不能执行,只是不能自己单独执行,需要别人用它
生成动态库

shared: 表示生成共享库格式

fPIC:产生位置无关码(position independent code)

库名规则:libxxx.so

示例: 
[root@localhost linux]# gcc -fPIC -c sub.c add.c [root@localhost linux]# gcc -shared -o libmymath.so *.o 

[root@localhost linux]# ls add.c add.h add.o libmymath.so main.c sub.c sub.h sub.o

gcc时指定的路径只是告诉了编译器,编译完成后编译器脱手不管了,所以动态库在哪里也得告诉系统!

        系统在加载时会在默认路径加载动态库,所以使用c、c++不受影响

解决:

1. 拷贝.so文件到系统默认的库路径 /lib64   /usr/lib64/ 目录下

2. 在系统默认的库路径 /lib64  /usr/lib64 下建立软链接

3. 将自己库所在路径添加到系统的环境变量 LD_LIBRARY_PATH

export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/home/ljs/..../mylib/lib
gcc main.c -lmymath

        由于我们添加的环境变量在xshell重启后自动取消,所以如果想版我们的库永久保存,就在~/.bash_profile脚本配置文件内 export 我们的环境变量即可

4. 在 /etc/ld.so.conf.d 路径下创建一个 自己的动态库路径 conf 文件,把动态库路径写入文件,再使用 ldconfig 指令刷新 conf 文件即可

实际情况,我们用的库都是别人成熟的库,都采用直接安装到系统的方法

3. 动态库加载

        动态库在进程运行时是要被加载的。常见的动态库会被所有的可执行程序(动态链接)使用,所以动态库又被称为共享库。

动态库在系统加载之后,会被所有进程共享,这是怎么做到的呢?

        动态库文件也是文件,它被放在磁盘中,当一个进程的代码例如执行到printf函数,那么他就需要调用动态库中 printf 的实现,所以此时将磁盘中动态库文件加载到内存,并通过该进程的页表,映射到进程地址空间的堆栈共享区,所以此时代码中的printf会跳转到堆栈共享区调用函数然会再返回调用 printf 函数处,这是动态库的第一次加载。

        一个进程的代码可能会加载多个动态库,这么多的动态库被凌乱的加载到内存这是操作系统不能容忍的,所以操作系统会先描述、再组织动态库文件!

        如果另一个进程的代码也使用到了该动态库中的函数定义,那么因为该动态库已经被加载到内存里,所以操作系统会先辨别该动态库文件是否已经被加载到内存,如果已经被加载了,那么也是通过该进程的页表,映射到进程地址空间的堆栈共享区中,代码中带哦用动态库函数处会跳转到共享区然后返回原调用处,所以动态库只是第一次加载有些慢,之后就不用被再次加载,这就是共享库

我们知道一个全局变量errno,用于记录进程错误退出码,那么动态库也可能有各种全局变量,该动态库被映射到堆栈共享区,会影响全局变量的使用吗?

        不会,因为当两个进程都使用共享的动态库内的全局变量时,会触发写时拷贝        

4. 再谈进程地址空间

程序没有加载前,内部有地址的概念吗?

         有地址概念,因为编译器也要考虑操作系统,它在编译形成可执行程序时分段编译,已经为每一行代码分配虚拟地址(在磁盘上时被称为逻辑地址),可执行程序未加载到内存时的地址被称为逻辑地址,如今与虚拟地址、线性地址是一个概念,即编译器在编译时就已经形成了虚拟地址了

        反汇编每个指令都有长度,逻辑地址是存在的但是可以不出现,可以根据长度和起始的地址来编号。

程序加载后的地址

        当可执行程序未被加载到内存时,它本身就有entry地址(逻辑地址),即执行代码的入口地址,程序运行时进程创建各种数据结构,并且进程将入口地址交给CPU的EIP寄存器,然后CPU开始读取正文代码,根据代码入口的虚拟地址查找页表时,发现没有对应的物理地址,所以触发缺页中断,从磁盘以内存的page单位大小写到内存中,此时每一条代码都有了各自的物理地址,然后再将物理地址填写到页表,形成虚拟到物理的地址映射。

        然后CPU开始执行,根据EIP(入口地址)和每一条代码的长度开始偏移的执行代码。如果读到了例如反汇编call指令,我们应该清楚它call的是虚拟地址,所以他就会在正文代码去call,再通过页表找物理地址,如果没有就触发缺页中断,再在页表建立映射关系,如此循环往复

        所以,进程在设计时就考虑了地址空间,编译器在编译时也考虑到了虚拟地址空间,二者互相协同结合

 5. 动态库的地址

        动态库可以放在物理内存任何地方,但是在进程地址空间中必须要被放在堆栈共享区。

        编译生成可执行程序时就为代码、函数指定了虚拟地址(线性地址),printf 指向的地址是固定的,例如0x11223344,所以动态库被加载到内存后,被映射到共享区的地址也必须是0x11223344!

但是一个进程可能打开很多动态库,那么我们怎么能保证每一个动态库都能在指定的固定地址呢?可能会被其他动态库占用该地址

        库可以在共享区中任意位置加载。库内部函数不采用绝对编址,只表示是每个函数在库中的偏移量,所以此时库函数可以随意在共享区放置,然后正文代码调用库函数后,会拿着动态库的起始地址(操作系统管理动态库时已经记录了)和所调用库函数在库函数内的偏移量,去共享区找到对应的库函数绝对地址! 

        所以 gcc 在编译时的 fPIC 产生位置无关码的作用:直接用偏移量进行对库中函数进行编址。

系统是如何判断是哪个动态库?

        我们在编译时就已经制定了,并且使用指令ldd也可以看到我们所使用的函数在动态库中的偏移量,操作系统是知道我们所需要访问的库是哪一个库

        事实上,在编译时调用的库函数已经将地址改为库文件起始地址(变量,在加载后才实例化)+偏移量

静态库为什么不谈加载,不谈与位置无关?

        静态库直接就拷贝到程序里的,在静态链接时,库直接拷贝到正文代码段,虚拟地址也编址好了。相当于库中的方法就是我自己写的方法,所以方法都在了就不谈偏移量了,直接用绝对地址就可以了,因为虚拟地址已经编好了,并且是固定的


http://www.kler.cn/a/302393.html

相关文章:

  • Vue.js 项目创建流程
  • SpringMVC学习笔记(二)
  • 【VIM】vim 常用命令
  • 2024 年 Apifox 和 Postman 对比介绍详细版
  • 大数据新视界 -- 大数据大厂之 Impala 性能飞跃:动态分区调整的策略与方法(上)(21 / 30)
  • DevOps工程技术价值流:加速业务价值流的落地实践与深度赋能
  • 利用物化视图刷新同步表记录
  • 从概念到现实,国际数字影像产业园如何打造数字文创产业标杆?
  • Android 开发避坑经验(2):深入理解Fragment与Activity交互
  • 宽哥之家小程序任务脚本
  • 服务器深度解析:五大关键问题一网打尽
  • CentOS 7 上安装 Docker
  • 【Three.js】实现护罩(防御罩、金钟罩、护盾)效果
  • 【PGCCC】PostgreSQL重做日志内幕!如何掌握事务日志记录的“黑魔法”
  • 9月13日星期五今日早报简报微语报早读
  • ssm“健康早知道”微信小程序 LW PPT源码调试讲解
  • P1544 三倍经验 (记忆化搜索)
  • SpringBoot 整合 Guava Cache 实现本地缓存
  • 算法day23| 93.复原IP地址、78.子集、90.子集II
  • 数据库安全性控制
  • 深入MySQL的索引实践及优化
  • 【开源风云】从若依系列脚手架汲取编程之道(四)
  • 【自然语言处理】实验一:基于NLP工具的中文分词
  • yolov5实战全部流程
  • 【Hot100】LeetCode—64. 最小路径和
  • GaN挑战Si价格底线?英飞凌推出全球首个12英寸GaN晶圆技术