目录
前言
C语言文件操作
stdin & stdout & stderr
系统文件IO
open
close
write
read
文件描述符fd
重定向
dup2
Linux下一切皆文件
缓冲区
简易缓冲区
文件系统
磁盘
创建文件
删除文件
查看文件
软硬链接
软链接
硬链接
动静态库
静态库
动态库
动静态库的总结
前言
本章内容带来IO相关的知识点,在讲解之前我们先来谈一谈什么是IO,比如我们从磁盘中读取文件的内容,或者我们创建一个文件往文件中写入内容并保存到磁盘中,也就是从一个设备读数据到内存中或者从内存中写数据到设备中。
文件操作本质上是进程在访问文件,向硬件中写入需要操作系统的控制,我们要是想写入就需要操作系统为我们提供接口。通常系统调用接口比较难使用,所以各种语言都自己封装了系统调用,但是操作系统的接口只有这一套,所以接下来就来学习一下系统的文件操作。
C语言文件操作
具体的内容可以去看文件操作的那一节:文件操作
看一下细节。
int main() { FILE* fp = fopen("log.txt", "w"); if (fp == NULL) { perror("fopen"); return 1; } // 文件操作 const char* s1 = "fwrite\n"; fwrite(s1, strlen(s1), 1, fp); // 这里注意,不要把s1的\0加上,因为那是c语言的规定,文件要保存有效数据 fclose(fp); return 0; }
当我们以“w”的方式打开文件代表写入,此时他会清空文件,再帮我们写入。
当我们把选项换成“a”,这就代表append追加,这就不会清空文件。
再命令行中的“>”符号,这叫做输出重定向。
可以看到之前的内容就没有了,变成了重定向的内容,他也是类似于“w”的方式。
再来看一下“r”选项打开文件,可以读取文件中的数据到缓冲区中,并输出到屏幕上。
int main() { FILE* fp = fopen("log.txt", "r"); if (fp == NULL) { perror("fopen"); return 1; } // 文件操作 char line[64];// 缓冲区 // fgets是C语言的接口,按行读取,自动在字符结尾添加\0 while (fgets(line, sizeof(line), fp) != NULL) { fprintf(stdout, "%s", line); } fclose(fp); return 0; }
加入命令行参数后就变成了类似cat命令的操作,也可以给简易的shell添加上这个功能。
int main(int argc, char* argv[]) { if (argc != 2) { printf("请输入两个参数:程序+文件名\n"); exit(1); } FILE* fp = fopen(argv[1], "r"); if (fp == NULL) { perror("fopen"); return 1; } // 文件操作 char line[64];// 缓冲区 // fgets是C语言的接口,按行读取,自动在字符结尾添加\0 while (fgets(line, sizeof(line), fp) != NULL) { fprintf(stdout, "%s", line); } fclose(fp); return 0; }
stdin & stdout & stderr
C/C++会默认帮我们打开三个标准输入输出流:stdin、stdout、stderr。
stdin默认为键盘,stdout和stderr默认为显示器,这些都是硬件,但在Linux下,一切皆文件,所以这三个的类型都是FILE*,这是个指针,FILE其实是C标准库提供的结构体
系统文件IO
前面也说过,不止C/C++有文件操作,其他语言也有,但是操作系统也有一套系统接口进行文件访问,上层语言只是进行了封装。
open
参数:
- 第一个pathname就是要打开或创建的目标文件。
- 第二个flags就是一些选项,下面看看这些选项是什么。
选项:给几个常用的选项
- O_RDONLY:只读打开
- O_WRONLY:只写打开
- O_RDWR:读,写打开 上面三个常量,必须指定一个且只能指定一个
- O_CREAT:若文件不存在,则创建它。需要使用mode选项,来指明新文件的访问权
- O_APPEND:追加写
- O_TRUNC:清空文件
知道这些选项是什么,但是是怎么用的呢,下面用一段代码演示一下。
// 用int中不同的bit位就可以标识一种值 #define ONE 0x1 // 0000 0001 #define TWO 0x2 // 0000 0010 #define THREE 0x4 // 0000 0100 void Print(int flags) { if (flags & ONE) printf("one\n"); if (flags & TWO) printf("two\n"); if (flags & THREE) printf("three\n"); } int main() { Print(ONE | TWO); // 0001 | 0010 -> 0011 printf("--------------\n"); Print(ONE | TWO | THREE); // 0001 | 0010 | 0100 -> 0111 return 0; }
所以那些选项也是按照这样的方式传递的。
返回值:open函数的返回值是新打开文件的文件描述符,打开文件失败返回-1。
#include <stdio.h> #include <string.h> #include <unistd.h> #include <stdlib.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main() { int fd = open("log.txt", O_WRONLY); if (fd < 0) { perror("open"); exit(1); } // 打开成功 printf("fd: %d\n", fd); return 0; }
如果只有一个选项就会打开失败,所以在操作系统还需要其他选项。
int fd = open("log.txt", O_WRONLY | O_CREAT);
这次是创建成功了,但是很奇怪,它的权限不对,所以上面有两个open,第一个用于读取,要是创建的时候就要用第二个,它有第三个参数mode,这就是文件的权限,想要给他的权限设置为0666,就要把它传入,当然也要注意umask,系统的默认umask是0002,所以还要在一开始设置当前进程的umask,如何设置也可以用接口。
// ... #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"); exit(1); } // 打开成功 printf("fd: %d\n", fd); return 0; }
总结下来就是读取不用带权限,创建的时候还是带上权限。
close
系统接口中使用close函数关闭文件。
使用close函数时传入需要关闭文件的文件描述符即可,若关闭文件成功则返回0,若关闭文件失败则返回-1。
write
返回值:
- 如果数据写入成功,实际写入数据的字节个数被返回。
- 如果数据写入失败,-1被返回。
int main() { umask(0); int fd = open("log.txt", O_WRONLY | O_CREAT, 0666); if (fd < 0) { perror("open"); exit(1); } // 打开成功 printf("fd: %d\n", fd); const char* s = "I am write\n"; write(fd, s, strlen(s)); // 这里也不要+1 close(fd); return 0; }
我把那串字符串修改了之后重新运行之后,文件中其实只是覆盖了原来的字符,要是想要清空原来的文件内容就要添加选项O_TRUNC,也就是截断的意思。
// ... int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666); if (fd < 0) { perror("open"); exit(1); } // 打开成功 printf("fd: %d\n", fd); const char* s = "aaaa"; write(fd, s, strlen(s)); // 这里也不要+1 // ...
这样就可以变成C语言中fopen的w选项。
这样a选项也就好说了,选项变成O_APPEND就行了。
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0666);
read
返回值:
- 如果数据读取成功,实际读取数据的字节个数被返回。
- 如果数据读取失败,-1被返回。
int main() { umask(0); int fd = open("log.txt", O_RDONLY); if (fd < 0) { perror("open"); exit(1); } // 打开成功 printf("fd: %d\n", fd); char buffer[64]; memset(buffer, '\0', sizeof (buffer)); // 把buffer都初始化为\0 read(fd, buffer, sizeof (buffer)); printf("%s\n", buffer); close(fd); return 0; }
初始化为“\0”是因为read第二个参数是void*,读取后不会添加“\0”,所以要初始化。
文件描述符fd
进程想要访问文件必须先打开文件,一个进程可以打开多个文件,而系统当中又存在大量进程,也就是说,在系统中任何时刻都可能存在大量已经打开的文件,已经打开的文件会被加载到了内存中,这些文件也叫内存文件,反之,没有打开的文件就叫做磁盘文件。那么操作系统就要管理这些打开的文件。
如何管理就是先描述,再组织。操作系统为每个已经打开的文件创建各自的struct file结构体,然后将这些结构体以双链表的形式连接起来,那么操作系统对文件的管理也就变成了对这张双链表的增删改查等操作,在每个节点中不仅有链表的指针,还应该存在着文件的内容+属性,这些信息大部分在磁盘中就保留在文件内部了,加载的时候就从磁盘中把数据加载到内存。
而为了区分已经打开的文件哪些属于某一个进程,我们就还需要建立进程和文件之间的对应关系。task_struct中有一个指针,该指针指向一个file_struct结构体,在结构体中有一个名为fd_array的指针数组,当操作系统建立了一个struct_file的结构体,这个数组就给他分配一个空间,所以文件描述符就是fd,而fd本质上就是一个数组下标,使用read和write的时候要传入文件描述符,通过文件描述符找到这个数组中的指针,进而对文件访问。
Linux下进程默认会打开三个文件描述符,0:标准输入、1:标准输出、2:标准错误。
0,1,2对应的物理设备一般是:键盘、显示器、显示器。
文件描述符的分配规则:分配最小的,没有被占用的。如果我把0号关闭,那么为新文件分配的时候就从最小的0分配。
重定向
通过下面的代码演示一下。
#include <stdio.h> #include <unistd.h> #include <string.h> #include <stdlib.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 | O_TRUNC, 0666); if (fd < 0) { perror("open"); exit(1); } // 打开成功 printf("fd: %d\n", fd); printf("fd: %d\n", fd); printf("fd: %d\n", fd); printf("fd: %d\n", fd); fprintf(stdout, "hello fprintf\n"); const char* s = "hello fwrite\n"; fwrite(s, strlen(s), 1, stdout); fflush(stdout); // 关闭文件 close(fd); return 0; }
通过上面的现象也可以看出,打印的数据没有到显示器上,而是到了磁盘的文件中,这是为什么呢?
上面就说过,0、1、2默认是被打开的,对应的就要打开显示器,所以stdout的文件描述符就是1,所以C语言的接口fprintf认识的就是stdout或者说就是1,我们一开始就关闭了1号文件描述符,把数组下标为1的位置设置为NULL,然后打开了log.txt文件,此时1没有被占用,所以就把下标为1的位置填入log.txt的结构体的地址,log.txt的文件描述符就是1了,但是上层的C语言函数认识的还是1,他们还是继续往1中写入,这样就不能打印到屏幕而是重定向到了文件中。
重定向的本质是在操作系统中更改fd对应的内容,上面演示的这就就叫做输出重定向。
下面来看看输入重定向,既然输出是关闭stdout,并修改输出的位置,那么输入重定向不就是关闭stdin,并修改从哪里输入吗,这就可以从文件中输入。
int main() { close(0); // 打开文件 int fd = open("log.txt", O_RDONLY); if (fd < 0) { perror("open"); exit(1); } printf("fd: %d\n", fd); char buffer[64]; fgets(buffer, sizeof(buffer), stdin); printf("%s\n", buffer); // 关闭文件 close(fd); return 0; }
关闭了0号文件描述符,所以打卡的新文件的文件描述符就变成了0,然后读取了文件中的第一行数据。
还有一种就是追加重定向,更改一下选项就行了。
int main() { close(1); // 打开文件 int fd = open("log.txt", O_WRONLY | O_APPEND | O_CREAT); if (fd < 0) { perror("open"); exit(1); } printf("%d\n", fd); fprintf(stdout, "append success\n"); fflush(stdout); // 关闭文件 close(fd); return 0; }
【注意】:“>”输出重定向修改的只是1号也就是stdout标准输出,所以尽管程序中有两行代码,一行向1号文件描述符中打印,另一行向2号文件描述符中打印,那么使用输出重定向只会使1号文件描述符重定向,2号还是打印到显示器上。
dup2
像上述这种重定向的方法太麻烦了,我们可以直接使用函数来完成重定向。
只需要把想要重定向的文件在数组中拷贝过去,比如我想要输出重定向,重定向到某个文件,那么1就代表标准输出,所以就要改变1的指向,就把3的地址拷贝过去,这样1就指向了重定向的文件。
输入重定向也是一样的,0是标准输入,就要从其他文件输入,就把其他文件的地址拷贝到0的位置。
函数的意思就是把oldfd拷贝给newfd。
int main() { int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC); if (fd < 0) { perror("open"); exit(1); } dup2(fd, 1); fprintf(stdout, "hello dup2\n"); close(fd); return 0; }
Linux下一切皆文件
原来的时候就一直说这句话,但是这句话是为什么呢?
在硬盘中的文件他就是文件,但是像键盘、显示器和网卡这行硬件为什么是文件呢?
这些外设要想访问他就要通过函数,他们也是可以执行IO操作的,也就是read和write,所以他们就具有了自己的IO方法,操作系统也要管理这些硬件,所以先描述,再组织,每个硬件都有了自己的struct_file,想要访问这些硬件的时候就要调用read和write函数,在C语言中是没有成员方法的,所以这就要用到函数指针,所以每个struct_file中就使用函数指针指向每个硬件的方法,所以他们这就是一个个struct_file,所以在Linux下一切皆文件,也叫做VFS(Virtual File System)。
缓冲区
什么是缓冲区呢?
缓冲区就是一块内存空间。
那么为什么要有这块空间呢?
假如我们要往磁盘中写入数据,可以直接从程序向磁盘写入,这叫做写透模式(WT),这种方法比较慢,这种慢是对于磁盘而言的,磁盘就是很慢;所以就有了一块缓冲区,我把要写入的数据先写到缓冲区中,写完了我就可以返回,这叫做写回模式(WB),这种方式就很快,这个快也是程序的相应速度。那么什么时候把缓冲区的数据放到磁盘中呢?
那就要提到缓冲区的一般刷新策略:
- 立即刷新
- 行刷新(行缓冲),遇到\n就把它之前的数据刷新。例如显示器。
- 满刷新(全缓冲),写满了才刷新。例如磁盘。
还有特殊的刷新策略:
- 强制刷新(fflush)
- 进程退出
所以缓冲区的刷新策略就是一般的刷新策略+特殊的刷新策略。
所有的设备几乎都倾向于全缓冲,只有缓冲区满了才刷新,这样就减少了IO操作,与外设的交互次数减少,效率得到提高,因为系统和外设的IO过程是最浪费时间的。但是显示器是直接给我们看的,它既要考虑效率,又要照顾用户体验,所以他可以自定义规则。
我们已经知道了缓冲区的刷新策略,但是是谁提供的这个缓冲区并帮我们维护的呢?下面就来看一下。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main() { // C语言 printf("hello printf\n"); fprintf(stdout, "hello fprintf\n"); const char* s = "hello fputs\n"; fputs(s, stdout); // 系统接口 const char*ss = "hello write\n"; write(1, ss, strlen(ss)); // 创建子进程 fork(); return 0; }
我们先不看这个fork,在fork之前的代码没有问题,就是写入到stdout中,并且也显示了出来,当我们重定向到一个文件中时,C语言的接口打印了两遍,系统接口就打印的了一遍。所以我们可以大概才出这个缓冲区是C标准库给我们提供的。
我们再来看一下这个fork,如果是向显示器中写入,采用的是行刷新策略,在fork之前就已经全部刷新了,所以这时的fork无意义。如果是重定向,向磁盘中刷新采用的就是全刷新了,到fork的时候,虽然代码已经执行完了,只是还没有刷新,这份数据就放在父进程的C标准库提供的缓冲区中,那么这是不是数据呢?那肯定是的,所以创建子进程就要进行写时拷贝,所以进程退出的时候,父子进程都要刷新缓冲区,这样就变成了写入两遍。
我们已经知道缓冲区是C标准库提供的了,那他又在C标准库的哪里呢?当我们fopen成功时,返回的是一个FILE*,这个FILE*就是struct_file typedef一下,这个结构体中就有大部分数据记录缓冲区相关信息的。
简易缓冲区
接下来就来看一下简易的缓冲区,缓冲区把数据刷新到内核,要把内核的数据刷新到磁盘就需要用到这个函数。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <assert.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #define NUM 1024 struct MyFILE { int fd; char buffer[NUM]; int end; // 缓冲区结尾 }; typedef struct MyFILE MyFILE; MyFILE* fopen_(const char* pathname, const char* mode) { assert(pathname); assert(mode); MyFILE *fp = NULL; if (strcmp(mode, "r") == 0) { } else if (strcmp(mode, "r+") == 0) { } else if (strcmp(mode, "w") == 0) { int fd = open(pathname, O_CREAT | O_TRUNC | O_WRONLY, 0666); if (fd >= 0) { fp = (MyFILE*)malloc(sizeof (MyFILE)); memset(fp, 0, sizeof (MyFILE)); fp->fd = fd; } } else if (strcmp(mode, "w+") == 0) { } else if (strcmp(mode, "a") == 0) { } else if (strcmp(mode, "a+") == 0) { } else {} return fp; } void fputs_(const char* message, MyFILE* fp) { assert(message); assert(fp); strcpy(fp->buffer + fp->end, message); fp->end += strlen(message); if (fp->fd == 0) { // 标准输入 } else if (fp->fd == 1) { // 标准输出 if (fp->buffer[fp->end] == '\n') { write(fp->fd, fp->buffer, fp->end); fp->end = 0; } } else if (fp->fd == 2) { // 标准错误 } else { // 其他文件 } } void fflush_(MyFILE* fp) { assert(fp); if (fp->end != 0) { // 把数据写到了内核 write(fp->fd, fp->buffer, fp->end); syncfs(fp->fd); // 将数据写入到磁盘 } } void fclose_(MyFILE* fp) { assert(fp); fflush_(fp); close(fp->fd); } int main() { MyFILE* fp = fopen_("./log.txt", "w"); if (fp == NULL) { printf("open file error\n"); return 1; } fputs_("hello world\n", fp); fclose_(fp); return 0; }
效率提高就是因为缓冲区,通过这些刷新策略减少IO的次数。
文件系统
原来我们说的都是内存文件,接下来我们就来谈一下磁盘文件。
文件是由内容加属性构成,内容就是文件中存储的数据,属性就是文件的信息,例如文件名、文件大小等信息都是文件的属性。在命令行中使用ls命令就可以查看文件的属性。
ls -l -a -i
我们看ls输出数据的第一列使我们没有见过的,这叫做文件的inode,inode保存文件的属性信息,现在可能理解不了,那么我们先来了解一下硬件。
磁盘
磁盘是一种永久性存储介质,他是一种外设,也是一个机械设备,所以IO的时候就相对较慢。与磁盘相对应的就是内存,内存是掉电易失存储介质,所以普通文件都是在磁盘中永久性存储的。
数据就是存放在磁盘中的,因为计算机只认识二进制,所以要在磁盘上每个位置都要存储或修改这个位置的正负性,完成这个工作的就是磁头。
寻找数据就是让磁头帮我们找在磁盘上的哪个位置,而磁盘是以扇区为单位的,标准是512字节,一个文件就是存放在这一个或几个扇区中的,所以想要找到某个文件就要知道它在哪个扇区,而寻找的方案就是:
- 确定在哪一个盘面上,对应的是哪一个磁头(Head)。
- 确定在哪一个柱面上(Cylinder)。
- 确认在哪一个扇区上(Sector)。
所以这就叫做CHS寻址。
【注意】:虽然磁盘扇区的基本单位是512字节,但是操作系统和磁盘进行IO的基本单位是4KB(8*512byte),原因是:
- 512字节太小了,有可能增加IO次数,导致效率降低。
- 如果操作系统使用和磁盘一样的大小,如果磁盘的基本单位改变了,那么操作系统就需要改,一定要让磁盘(硬件)和操作系统(软件)解耦。
假如我们把磁盘扇区的数据都拿出来拼成一条线性的结构。
所以数据存储到磁盘就是将数据存储到该数组,想要访问一个扇区就要找到数组的下标,那么对磁盘的管理就变成了对数组的管理。
但是如果磁盘非常大,那么数组的长度就会很长,所以操作系统为了更好的管理就对磁盘进行了分区,这样就变成了对这些分区的管理,但是分区可能也会很大,所以又对分区进行了分块,分成了一个个块组。
把每个块组管理好了,就把每个分区都管理好了,这样就可以实现对磁盘的管理。
每个分区头部都会包括一个启动块(Boot Block),每次电脑开机时的启动信息都会放在这里。
接下来就来看一下块组中存放的都是什么:
Super Block:存放的是文件系统的属性信息。记录的信息主要有:Data Block和inode的总量、未使用的Data Block和inode的数量、一个Data Block和inode的大小等,所以他是整个分区的属性集,可能它在每个块组中都有一份,目的就是备份上,有需要可以从其他块组中拿到,防止因为物理上的损坏而丢失这些数据。
Data Blocks:软硬IO的基本单位是4KB,这可以称作是一个块大小,Data Blocks就是多个块大小的集合,保存的是文件的内容。
inode Table:inode是一个大小为128字节的空间,保存的是对应文件的属性,inode Table是这个块组内所有文件的inode空间的集合,一个文件就有对应的inode和inode编号。
Block Bitmap:这是一个位图结构,有多少个blocks就有多少个比特位,这个位和block是一一对应的,该位置为1就代表block被占用,反之表示可用。
inode Bitmap:它也是一个位图结构,和inode是一一对应的。
Group Descriptor Table:块组描述符,它保存的是inode使用了多少,block使用了多少。
把每个块组分成这样一个个块,然后每个块组写入相关数据,这样整个分区就写入了文件系统的信息,这就叫做格式化。
在Linux下的inode属性中是没有文件名这种说法的,我们想要找到一个文件就是通过它的inode编号才找到它的,但是一个文件一定是在一个目录下的,我们想要知道这个文件在哪,一定要知道他的路径,找到了这个目录,目录也是个文件,也就自己的data block,在这之中一定有文件名和inode编号的映射关系,他们互为Key值,所以不允许有同名的文件存在。通过文件名拿到inode就可以拿到这个文件了。
创建文件
创建文件要先确定在哪个目录下创建,通过遍历inode位图就可以找到空闲的inode,在inode Table中找到inode,把文件属性填进去。
通过inode编号找到struct inode,这个结构体中存放文件的属性和应该存放block的数组,在数组中有对应的block映射,如果一开始还没有block,或者本来的block已经写满了,这就需要通过block位图找到空闲块,在Data Blocks中找到这块block并写入数据,这就存放的是文件的内容。
之后就让这个文件的inode编号和文件名映射,放到目录的data block中。,
删除文件
删除文件很见到,只需要找到这个文件对应的目录,在目录文件的data block中通过文件名找到文件的inode,然后在块组内对应的inode bitmap和block bitmap中的位置置为0,在删除目录中的文件名和inode的对应关系删掉就可以了,意思就是对该文件无效就可以了。
查看文件
查看文件就是把目录中对应的所有inode,在inode中有这个文件的属性,再通过某种格式化输出的方式打印到显示器中。
【注意】:inode是固定的,data block也是固定的,有没有一种可能inode没了,但是数据块还有;或者inode还有数据块没了。这都是有可能的。
软硬链接
软链接
使用ln(link)指令就可以创建链接,-s就是软链接。
ln -s 想要建立链接的文件 新链接
这分别是软链接的文件和可执行程序的文件,我故意多建了几个目录,把可执行程序放在./1/2/下,而软链接建立在当前目录,可以看出这两个文件的inode是不一样的
软链接又叫做符号链接,软链接文件相对于源文件来说是一个独立的文件,软链接文件有自己的inode,但是该文件只包含了源文件的路径名,所以软链接文件的大小要比源文件小得多。软链接就类似于Windows操作系统当中的快捷方式。
硬链接
使用ln命令不用带选项。
ln 想要建立链接的文件 新链接
可以看到两个文件的inode是一样的,两个文件的硬链接数也变成了2,所以我们可以大致猜出硬链接就是在目录中建立文件与指定inode的映射关系,换言之就是起了一个别名。硬链接数是几就有几个文件跟我关联,就像C++中智能指针的引用计数一样,有一个链接就++,反之就--。
但是这硬链接有什么用呢?
可以看到一个上级目录和..的inode是一样的,这就是硬链接,默认创建一个目录硬链接数就是2,自己的目录名和inode映射,当前目录的.和inode映射就是2,再创建一个目录,这个目录的..和上一级目录的inode又一次映射,所以可以看到有的目录的硬链接数是3。那么看硬链接数-2就知道这个目录下有几个目录。
动静态库
这里又不得不提到那四个步骤:
- 预处理:完成头文件展开、去注释、宏替换、条件编译等,最后形成 .i文件。
- 编译:完成词法分析、语法分析、语义分析、符号汇总等工作,转变为汇编语言,形成 .s文件。
- 汇编:把汇编语言变成可重定位目标二进制文件,.o文件。
- 链接:将生成的各个 .o文件进行链接,最终形成可执行程序。
在这个目录下我已经写好了加法和乘法的头文件(声明)和源文件(实现),并且在main.c中调用了他们。
再使用gcc编译形成可执行文件,这就是我们之前的用法。
在链接的部分,可以把所有的 .c文件变成 .o文件再链接起来(当然 .h文件也是需要的),这样也可以变成可执行程序。
这里只有两个 .o文件(除了main.o),要是以后有更多的 .o文件呢,gcc就要把他们都加上,还可能漏填,那这就太不方便了,那我们就把他们打包,形成静态库。
静态库
使用ar(archive,有归档的意思)指令,r选项代表replace,c选项代表create,前缀必须为lib。
ar -rc lib文件名.a .o文件
制作静态库也可以使用makefile。
我们把myfile文件拿到最外层,这里会有main.c和我们创建的静态库目录,既然静态库写好了,那么应该怎么用呢?有这么几种方法:
- 把我们的库拷贝到系统的路径下(不太建议,会污染别人的库,也不安全),gcc默认搜索头文件的路径是/usr/include,库文件的默认搜索路径是:/lib64 或者 /usr/lib64。所以把头文件和静态库拷贝到对应的目录中就可以了。但这还没完,C语言的库gcc可以找到,我们自己写的库找不到,所以gcc编译的时候就要指定链接的库,使用-l,去掉lib和.a。上述的这些操作就是安装库。
gcc main.c -l文件名
- 如果不使用上述操作就找不到对应的头文件,这时只需要指定对应的头文件目录,使用-I(include 头文件搜索路径),还需要指定的静态库,使用-L(library 库文件搜索路径),最后再使用-l确定是哪个一库。
gcc main.c -I ./myfile/include/ -L ./myfile/lib/ -lmyfile
动态库
下面我们就来看看如何制作一个动态库。
gcc -c -fPIC .c文件 -o .o文件
现在我把myfile_d中的动态库拿出来放到myfile中,那么在myfile中就动静态库都有了。
要使用它还是要按静态库的方法。
gcc main.c -I ./myfile/include/ -L ./myfile/lib/ -lmyfile
我们可以得到以下结论:
- 如果我们只有静态库,gcc只能使用静态链接
- 如果两种库都有,gcc默认使用动态链接
- 如果非要使用静态链接,那么就要在gcc后面加-static选项,所以static的意义就是放弃默认使用动态库的原则,直接使用静态库。
这里为什么还是不能运行呢?并且链接的时候还是not found,接下来就来说一下动态库的加载。
动态库是一个独立的库文件,它是可以和可执行程序分批加载的。如果是静态库,可执行程序(a.out)中就包含了静态库,加载的时候可执行程序和静态库同时被加载到内存。
动态库就不一样了,当代码执行到需要动态库的时候,这就需要加载动态库到内存中,使用哪个方法就把这个方法建立映射到共享区,所以代码要执行的时候就到共享区中区找,调用完了再返回。
这就和静态库不同了,静态库是在自己的代码区调用,它所有的代码都在代码区,所以他占用的空间比较大。
此时如果再有一个进程想要使用库,同样的方法建立映射就可以了。
说了这么多,只是说了动态库是怎么加载的,但是还是没有解决问题啊,我使用gcc的时候也告诉了它去哪里找头文件和库,但是gcc不管加载啊,它不管就是操作系统加载器管,那么下面就来说几种方法操作系统找到这个库:
- 添加动态库的路径到LD_LIBRARY_PATH环境变量下。这种方法导入的环境变量是内存级的,当我们重新打开一个会话这个环境变量就没了。
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:库的路径
ls /etc/ld.so.conf.d/ -l
在这个路径下有一个目录中新建一个文件,在文件中写入库的路径就可以了。 再使用ldconfig让配置的文件生效。以上操作有文件权限不足的时候使用sudo就可以。
- 软链接方式。
sudo ln -s /home/dsh/linux/2024/3m/3.3/myfile/lib/libmyfile.so /lib64/libmyfile.so
动静态库的总结
静态库:
静态库在程序编译链接的时候就把库的代码复制到可执行文件中,之后运行的时候就不需要它了,所以他的可执行程序文件比较大。
优点:
- 使用静态库后程序可独自运行,不在需要库。
- 运行起来相对快
缺点:
- 占用大量空间,如果多个文件都使用了静态库,加载到内存中就会消耗很多空间。
动态库:
动态库是程序在运行的时候才去链接相应的动态库代码,多个程序共享使用库的代码,所以动态库又叫共享库。
优点:
- 节省了磁盘空间,多个进程用到相同动态库时,动态库会通过进程地址空间进行共享。
缺点:
- 依赖动态库,没有动态库无法运行。
这样我们平时写代码的时候就可以使用别人优秀的库了。