搭建个人免费稳定图床 GitHub+jsDelivr+PicGo+Typora

IMG_256

搭建完个人博客以后,写文章的时候就不可避免需要插入图片,于是就需要使用图床工具把本地图片转换成网络图片再把图片链接分享出来。市面上的图床工具非常多,但很多都需要收费,有些免费的也存在着不稳定的风险。现在有一种基于 GitHub 和 jsDelivr 加速的免费图床。PicGo 是一个用于快速上传图片并获取图片 URL 链接的工具,支持多个图床进行使用,其中当然包括我们现在使用的 GitHub 图床了,它同时支持 Windows、macOS、Linux 平台。GitHub 和 jsDelivr 都是大厂,不用担心跑路问题,也不用担心速度和容量问题,而且完全开源免费,再结合 Typora 编辑器,书写 markdown 格式文章,简直效率神器,快来根据下面教程搭建个人的免费稳定图床吧!

一、新建 GitHub 图床仓库

  1. 登录 GitHub 账户,没有的话就注册下,然后新建 GitHub 仓库。

img

2、填写仓库名【CDN1】,勾选上【Public】和【Add a README file】,最后点击创建。

Snipaste_2021-10-24_15-30-47

3、创建 GitHub 中的 Token (令牌)

点击右上角头像,选中头像列表中的【Settings】,进入【Settings】,点击【Developer Settings】,再点击【Personal access tokens】,接着点击【Generate new token】。

img

img

img

在 Note 中取一个名字,选中 repo 这个框过后直接点击完成(Generate token)

img

最后生成 token,记住这个令牌一定要复制保存,建议保存到记事本里,如果没有保存的需要删除重来一遍。

二、配置 PicGo

1、安装 PicGo

下载地址 https://github.com/Molunerfinn/PicGo/releases ,选择下载与系统匹配的安装包,根据默认勾选安装即可。

2、打开 PicGo,配置图床

设定仓库名:按照【GitHub 用户名 / 图床仓库名】的格式填写

设定分支名:【main】

设定 Token:粘贴 GitHub 生成的【Token】

指定存储路径:默认路径为【img/】,图片将会储存在此文件夹中,而我改成了 img/2021/。

设定自定义域名:它的的作用是,在图片上传后,PicGo 会按照【自定义域名 + 上传的图片名】的方式生成访问链接,放到粘贴板上,因为我们要使用 jsDelivr 加速访问,所以可以设置为【https://cdn.jsdelivr.net/gh/ 用户名 / 图床仓库名】

2

3、进入 PicGo 设置,打开时间戳重命名,避免因图片重名而导致上传失败。

3

4、可以打开上传区测试一下,选择 URL 的图片链接格式,上传完图片后复制链接,直接再浏览器打开就能看到了。

4

三、配置 typora

1、下载 typora:https://typora.io/

111

2、打开【文件】中【偏好设置】,选择【图像】,根据图片中配置进行勾选。最后再找到 PicGo 软件的安装位置,可以上传验证测试下,默认会上传 typora 图标。

01

0203

04

RPM 命令

一、RPM 介绍

1. 什么是 rpm ?

rpm 即 RedHat Package Management,是 RedHat 的发明之一

2. 为什么需要 rpm ?

在一个操作系统下,需要安装实现各种功能的软件包。这些软件包一般都有各自的程序,
但是同时也有错综复杂的依赖关系。同时还需要 解决软件包的版本,以及安装,配置,
卸载的自动化问题。为了解决 这些问题,RedHat 针对自己的系统提出了一个较好的办法
来管理成千 上百的软件。这就是 RPM 管理系统。在系统中安装了 rpm 管理系统以后,
只要是符合 rpm 文件标准的打包程序都可以方便地安装、升级、卸载。

3. 是不是所有的 linux 都使用 rpm ?

任何系统都需要包管理系统,因此很多 linux 都使用 rpm 系统。 rpm 系统是 Redhat Linux 和
Fedora Core 的软件包管理器,但是 Mandriva、SuSE 等 Linux 发行版也都使用 rpm。由于 rpm
的源程序可以在别的系统上进行编译,所以有可能在别的系统上也使用 rpm。除了 rpm,
其他一些系统也有自己的软件包管理程序, 例如 debian 的 deb 包。

4.rpm 包的文件名为什么那么长 ?

rpm 包的文件名中包含了这个软件包的版本信息,操作系统信息,硬件要求等等。
比如 mypackage-1.1-2RH.i386.rpm,其中 mypackage 是在系统中登记的软件包的名字 1.1
是软件的版本号,2 是发行号,RH 表示用于 RH 操作系统。i386 表示用于 intel x86 平台。

5. 软件包文件名中的 i386,i686 是什么意思

rpm 软件包的文件名中,不仅包含了软件名称,版本信息,还包括了适用的硬件架构的信息。

i386 指这个软件包适用于 intel 80386 以上的 x86 架构的计算机 (AI32)
i686 指这个软件包适用于 intel 80686 以上 (奔腾 pro 以上) 的 x86 架构的计算机 (IA32)
noarch 指这个软件包与硬件架构无关,可以通用。

i686 软件包通常针对 CPU 进行了优化,现在通常配置的机器都可以使用 i686 软件包。

6. 不同操作系统发行的 rpm 包可否混用?

对于已经编译成二进制的 rpm 包,由于操作系统环境不同,一般不能混用。
对于以 src.rpm 发行的软件包,由于需要安装时进行本地编译,所以通常可以在不同系统下安装。

二、RPM 包管理的用途

1、可以安装、删除、升级和管理以 rpm 包形式发布的软件;
2、可以查询某个 rpm 包中包含哪些文件,以及某个指定文件属于哪个 rpm 包;
3、可以在查询系统中的某个 rpm 包是否已安装以及其版本;
4、作为开发者可以把自己开发的软件打成 rpm 包发布;
5、依赖性的检查,查询安装某个 rpm 包时,需要哪些其它的 rpm 包。

注:RPM 软件的安装、删除、更新只有 root 权限才能使用;
对于查询功能任何用户都可以操作。

三、rpm 的一点简单用法

rpm 的一般格式:

rpm [选项] [rpm 软件包]

1、初始化 rpm 数据库(可以省略)

rpm –initdb
rpm –rebuilddb % 注:这个要花好长时间

% 注:有时 rpm 系统出了问题,不能安装和查询,大多是这里出了问题。

2、RPM 软件包管理的查询功能:

rpm -q [select-options] [query-options]

RPM 的查询功能是极为强大,是极为重要的功能之一;这里举几个常用的例子,更为详细的具体的,请参考 man rpm

对系统中已安装软件的查询

1)查询系统已安装的软件

语法:rpm -q 软件名

例:rpm -q gaim
% -q 就是 –query,此选项表示询问系统是不是安装了 gaim 软件包;
% 如果已安装会有信息输出;如果没有安装,会输出 gaim 没有安装的信息;

% 查看系统中所有已经安装的包,要加 -a 参数
rpm -qa

% 如果分页查看,再加一个管道 | 和 more 命令
rpm -qa |more

% 如果要查找某个软件包,可以用 grep 抽取出来
rpm -qa |grep mplayer

2)查询一个已经安装的文件属于哪个软件包;

语法: rpm -qf 文件名

注:文件名所在的绝对路径要指出

例:rpm -qf /usr/lib/libacl.la

3)查询已安装软件包都安装到何处;

语法:rpm -ql 软件包名

例:rpm -ql mplayer

4)查询一个已安装软件包的信息

语法: rpm -qi 软件包名

例:rpm -qi mplayer

5)查看一下已安装软件的配置文件;

语法格式:rpm -qc 软件名

例:rpm -qc mplayer

6)查看一个已经安装软件的文档安装位置:

语法格式: rpm -qd 软件名

例:rpm -qd mplayer

7)查看一下已安装软件所依赖的软件包及文件;

语法格式: rpm -qR 软件名

例:rpm -qR mplayer

:可以把几个参数组合起来用,如 rpm -qil mplayer

对于未安装的软件包的查看 : 查看的前提是当前目录下已存在一个.rpm 文件。

1)查看一个软件包的用途、版本等信息;

语法: rpm -qpi file.rpm

例:rpm -qpi mplayer-1.0pre7try2-2.i386.rpm

2)查看一件软件包所包含的文件;

语法: rpm -qpl file.rpm

例:rpm -qpl mplayer-1.0pre7try2-2.i386.rpm

3)查看软件包的文档所在的位置;

语法: rpm -qpd file.rpm

例:rpm -qpd mplayer-1.0pre7try2-2.i386.rpm

4)查看一个软件包的配置文件;

语法: rpm -qpc file.rpm

例:rpm -qpc mplayer-1.0pre7try2-2.i386.rpm

5)查看一个软件包的依赖关系

语法: rpm -qpR file.rpm

例:rpm -qpR mplayer-1.0pre7try2-2.i386.rpm

3、软件包的安装、升级、删除等; 安装和升级一个 rpm 包

语法:
rpm -ivh file.rpm % 这个是用来安装一个新的 rpm 包
rpm -Uvh file.rpm % 这是用来升级一个 rpm 包

% 如果有依赖关系的,需解决依赖关系。
% 如果找不到依赖关系的包,可以用下面的命令强制安装:

rpm -ivh –nodeps –force file.rpm
rpm -Uvh –nodeps –force file.rpm

例:
rpm -ivh –test mplayer-1.0pre7try2-2.i386.rpm
% –test 表示测试,并不真正安装。

rpm -ivh –relocate /=/usr/local/mplayer mplayer-1.0pre7try2-2.i386.rpm
% 为软件包指定安装目录:要加 –relocate 参数
% 安装在指定目录中的程序如何调用呢?
% 通常可执行程序都放在安装目录下的 bin 或者 sbin 目录中。

删除一个 rpm 包

首先查出需要删除的 rpm 包,然后用下面的命令来卸载:

rpm -e 软件包名

例:rpm -e mplayer % 卸载 mplayer
% 如果有其它的 rpm 依赖于该 rpm 包,系统会出现警告。
% 如果一定要卸载,可以用选项 –nodeps 忽略依赖关系。但最好不要这么做。

四、RPM 管理包管理器支持网络安装和查询

rpm [选项] rpm 包的 http 或者 ftp 的地址

比如我们想通过 Fedora Core 4.0 的一个镜像查询、安装软件包。

rpm -qpi http://mirrors.kernel.org/.../RPMS/rsh-0.17-29.rpm
% 查询

rpm -ivh http://mirrors.kernel.org/.../RPMS/rsh-0.17-29.rpm
% 安装

五、对已安装 rpm 包查询的一点补充

可以用 locate 来查询一些软件的安装位置,可能需要先运行 updatedb 来更新已安装软件库

六、从 rpm 软件包抽取文件

rpm2cpio xxx.rpm | cpio -idmv
% i 表示提取文件,v 表示指示执行进程
% d 表示根据包中文件原来的路径建立目录
% m 表示保持文件的更新时间

在讲解大型项目如何被构建之前,我们首先来讨论一个问题,有句话说的很好,梦想总是要有的,万一实现了呢,那么问题来了,要怎么实现呢,这里就涉及到了如何实现目标,

目标是如何实现的

其实很简单,本质上只有两点:

  • 知道最后想要的是什么
  • 为此需要做些什么

有时我们的目标可能不是简单的诸如每天跑五公里之类,比如像通过一门考试,学会一项技能这样的系统性工程。这时我们可能一下子不知道要做些什么,那么这就需要进行任务分解了,即这里的规则就是,把一个大的目标分解为一个个小的目标,如果对于其中一个小的目标还是不够具体,那么就继续将小目标进行分解,直到将每个小目标分解为如每天读懂两个章节,做完十个练习题之类很具体可以马上实施的任务为止。到这时,对于如何实现这个大的目标就很清晰了,只需要严格按照计划去实施就好了。比如对于考研,我们就可以列出如下的计划:

image-20210730102100140

在考研这个例子中我们就按照上述规则将目标进行了分解,每个目标都按如下格式列出:

* 目标 (target): 依赖什么 *

​ * 要怎么做 *

如果 “要怎么做” 还不是一个具体的目标就继续分解,直到分解为类似进程这样的目标,因为像进程这样的目标已经有了具体的实现步骤。最后我们将各个已经实现的小目标汇集起来整个大的目标就实现了。

本质上,一个大型项目的构建过程与此类似。

Make

再大的项目最后生成的都是一个可执行文件,只要是可执行文件就需要依赖各种目标文件,动态库,静态库;静态库同样需要依赖其它目标文件,静态库;而动态库可能又依赖其它目标文件,动态库,静态库,知道了这些又该如何构建呢,我们可以利用上面目标划分的方法规划好构建最终的可执行文件需要哪些原材料,这些原材料又是如何获取的。有了这些规划后,我们就可以依次编译出一些小的目标文件,将这些目标文件链接成静态库,动态库以方便使用。然后再一步步连接目标文件以及各种库从而形成更大的库,最后将几个必要库以及目标文件进行链接从而生成最终的可执行文件。

程序员先驱们确实就是使用这种现在看起来非常原始非常古老的方法进行程序编写的,每个目标文件以及库都是自己手动编译链接出来,然后再将它们链接成更大的库,直到最后生成可执行文件。

这种方法看上去非常简单,但是缺点也很明显,那就是非常繁琐,一旦某个源文件进行了改动,所有依赖此文件的库都需要重新编译链接,手工来完成这项工作是极其枯燥且容易出错的。为解决这个问题,天才的程序员们想出了一个小工具,没错就是 make,从此编译链接这个过程就被 make 自动化了,程序员得以从繁琐的编译链接中解放出来,使用 make 时我们只需要编写规则,也就是告诉 make 最终的可执行文件依赖什么,为此需要做些什么,这些规则类似于上面的目标分解,当编写好这些规则后,然后简单的执行一个命令也就是 make 就可以了。如果某个源文件被修改了,也只需要简单的重新执行一下 make 命令,因为整个过程的规则并没有改变,而 make 也会很聪明的只编译链接那些需要更新的目标文件,库,并重新进行可执行文件的生成。对于那些没有改动的源文件,make 不会重新编译它们。

make 中每一条规则与前面的目标划分非常相似,make 的规则是这样的:

*target: prerequisites*

​ *recipe*

target 也就类似于我们的一个目标;而 prerequisites,即先决条件,也就是依赖什么;recipe,这个就更形象了,即菜谱,也就是上面的要怎么做。make 中的规则保存在了叫做 Makefile 的文件当中 (没错,这个文件的名字就叫做 Makefile),当运行 make 命令时,make 程序会自动找到当前路径下的 Makefile,然后开始执行里面的规则。

有些同学可能为此感到疑惑,这里的 Makefile 其实就是脚本,而 make 读取这个脚本然后根据里面的内容来执行命令,而对于 make 大家也不要觉得很神奇,make 也是一个普通程序,和我们平时使用的程序没什么区别。确定好了 make 需要执行的脚本的名字,这样在运行 make 命令时就少打了几个单词,假如用户可以自定义 make 的执行脚本名字,比如用户创建了一个脚本叫做 foo,那么执行 make 的时候就需要多打一个单词 “make foo”,所以干脆就直接确定好了脚本的名字就叫 Makefile,这样在运行命令时只需要打一个单词 make 就可以了。

这里举个简单的例子,比如我们写了一个 helloworld 程序,将源文件命名为了 helloworld.c,我们想把该源文件编程成一个叫做 hw 的可执行文件,那么一个最简单的 Makefile 就可以写成这样:

hw: helloworld.o

gcc helloworld.o -o hw

helloworld.o : helloworld.c

gcc -c helloworld.c

在这里最终的可执行文件 hw 依赖目标文件 helloworld.o,那么假设我们现在已经有 helloworld.o 了就可以利用命令 gcc helloworld.o -o hw 生成我们需要的可执行文件了。那么 helloworld.o 又该如何获得呢?我们看第二条规则,helloworld.o 依赖 helloworld.c,因为 helloworld.c 已经写好了,所以可以直接用命令 gcc -c helloworld.c 来生成。这样整个目标就达成了。

本质上现在我们使用的各种集成开发环境 (IDE),其自动化编译工具背后的原理和 make 是一样的,比如我们在使用 Visual Studio 时从来没有关心过每个文件是如何被编译链接的,这些 IDE 都为我们代劳了。但是在比如 Linux 环境下进行开发时,这个过程依然是需要程序员了解的。

现在让我们来回答本节提出的问题,也就是大型项目是如何被构建的。

构建大型项目

大型项目中通常会有成百上千甚至上万个源文件,这些源文件统一放在了一个文件夹中方便管理。典型的项目如图所示,圆形代表源文件,其它为文件夹。注意这里仅仅为说明问题,各个公司团队都有自己的代码组织以及命名方式,而且真实项目要比该图复杂的多,但是本质上这里的讨论适用于其它情况。

源码组织方式

通常项目的组织方式如下图所示:

image-20210730102125821

项目源码会被放置在 src 当中,这个例子当中 src 下有两个文件夹,lib 以及 app,lib 用于存放一些工具性的代码,比如这里列举的网络通信以及字符串处理模块,通常 lib 下的代码会被编译成各种库,方便 app 使用。app 中就是各种需要可执行文件 (程序) 的代码了。通常像这里的 lib 以及 app 都会有专门的团队来负责。更大一些的项目,每个 lib 下的子目录比如这里的 net,strings 都会有专门的团队来负责以方便项目的模块化管理。

从这里可以看出一般项目通常会按模块将源文件放入相应的文件夹下进行分类,我们在上一节中简单介绍了 make 的用法,但是那里仅仅需要编译一个源文件 helloworld.c。对于如上图所示的项目,像 make 这一类的编译工具又该如何处理呢?

make 的嵌套执行能力可以解决这个问题。比如对于模块 net,你可以为 net 模块写一个单独的 Makefile,该 Makefile 只用于编译 net 下的源文件,具体的脚本如下所示,只需要简单的两行。

network:

cd net && make

这句话的意思是告诉 make,要想编译网络模块 (network) 需要进到 net 文件夹并且执行 make 命令,当 make 进入到 net 文件夹开始执行 make 时,net 下的 Makefile 就开始被执行了。通过这样一个简单的命令就可以实现 make 的嵌套执行了。make 的这项特性使得每个模块都可以当做独立项目进行维护。

编译工具的这项功能,方便了项目的模块化管理。使得项目中每个模块都可以有独立的编译脚本,比如使用 make 进行编译的话,那么每个模块中都会有单独的 Makefile,比如在文件夹 net,strings 中都有自己的 Makefile。如上图中蓝色部分,其中白色部分为源文件,更清晰的关于 Makefile 的组织方式如下图所示:

image-20210730102155778

这些脚本中定义了如何编译该模块,以及编译该模块需要依赖什么。这些模块的父目录也就是 lib 文件夹下同样也有自己的 Makefile,lib 下的 Makefile 会收集各个子模块的编译结果,然后将其链接成各种库。而对于 app 下面的子目录来说,这些子目录中就是各个可执行文件的源码了,比如这里的 wechat 文件下就是可执行程序微信的源码了,微信中可能会用到 lib 下提供的功能,那么对于 wechat 中的 Makefile 来说,只需要简单的加入对 lib 中所需要的库的依赖就可以了。wechat 的父目录 app 中同样也有 Makefile,这里的 Makefile 就相对简单了,只需要依次执行 QQ,wechat 中的 Makefile 就可以了,因此在 src 目录下简单的运行 make 命令,所有 app 比如 QQ 和 wechat 就都被编译出来了。

接下来我们详细的讲解一下这个过程。请注意一点,接下来讲解的 make 执行过程仅仅是可能的一种实现方式,但是这个示例已足够说明项目的构建过程。

make 的执行过程

在上面的示例中 src 下的 Makefile 是整个编译过程的入口,因此我们进入 src 文件夹开始执行 make 命令。

(1)在 src 目录下,make 首先读取 src 下的 Makefile,./src/Makefile 非常简单,该文件仅仅告诉 make 需要去 app 目录下执行 make 命令。

(2)make 来./src/app 目录下,开始读取该目录下的 Makefile,该文件定义了编译出 QQ,微信的规则,make 首先执行编译 QQ 的规则,该规则告诉 make 编译 QQ 则需要到./src/app/QQ 目录并执行 make 命令。

(3)make 来到./src/app/QQ 目录下,开始读取该目录下的 Makefile,该文件定义了编译 QQ 程序 的规则,make 开始执行这些规则,其中一项规则需要依赖网络模块的库,同时该规则告诉了 make 如果想得到该网络库则需要进入到./src/lib 下执行 make 命令。

(4)make 来到./src/lib 目录下,开始读取该目录下的 Makefile,该文件定义了编译出网络库,字符串处理库的规则,make 首先执行编译网络库的规则,该规则告诉 make 如果想得到该网络库则需要进入到./src/lib/net 下执行 make 命令。

(5)make 来到./src/lib/net 目录下,开始读取该目录下的 Makefile,该文件定义了编译网络库的规则,编译网络库不再依赖任何其它库,make 终于可以安心的开始工作不用再跳来跳去了,make 开始执行该目录下的 Makefile,将一个个源文件编译成目标文件,最后将这些目标文件链接成了静态库 (当然也可以是动态库,依赖编译规则)。make 在./src/lib/net 完成任务后跳转回./src/lib,因为 make 会记住自己是从哪个目录跳转到当前目录的。

image-20210730102227439

(6)make 再次回到./src/lib 下,因为 make 执行完了网络库的编译规则,因此继续往下执行,也就是字符串库的编译规则,该规则告诉 make 如果想得到字符串库则需要进入到 src/lib/strings 下执行 make 命令。

(7)make 来到./src/lib/strings 目录项,开始读取该目录下的 Makefile,该文件定义了编译字符串库的规则,同样,编译字符串库不需要依赖任何其它库,make 开始执行该目录下的 Makefile,将一个个源文件编译成目标文件,最后将这些目标文件链接成了静态库 (当然也可以是动态库)。make 在./src/lib/strings 下完成任务后跳转回./src/lib,因为 make 就是从这个目录跳转到./src/lib/strings 的。

(8)make 回到./src/lib,如果该目录下的 Makefile 还有其它编译规则,则继续上面的过程,如果没有其它规则,则该目录下的编译任务执行完成,make 返回到./src/app/QQ。

(9)make 回到./src/app/QQ 下继续执行被中断的规则,这时 QQ 所依赖的库都已经编译完成,因此 make 可以直接进行链接了,QQ 程序编译链接完成。make 返回到./src/app。

image-20210730102246124

(10)make 来到./src/app 下继续执行被中断的规则,make 开始执行微信程序的编译规则,这里和 QQ 的编译是一样的,唯一一点即如果微信也需要依赖网络库和字符串库,那么当 make 调转到./src/lib 下会发现这些库已经生成了,因此直接返回。当 make 执行完./src/app 下的编译规则后,QQ 和微信程序就都编译完成了。make 返回到./src 后,发现该目录下的 Makefile 执行完毕,因此 make 程序退出,整个编译过程完成。

如果你对这个过程还不是很清楚的话,我们用一个游戏的类比来加深你对整个过程的理解。

相信很多同学都玩过 RPG (角色扮演) 游戏,比如仙剑奇侠传,阴阳师。你可以把大型项目的编译过程想象成玩 RPG 游戏,这类的游戏通常都会有一个主线,若干支线,通常主线的每一关都需要你去某个支线完成任务,例如拿到宝物之类,当你完成支线任务拿到宝物后,你才能回到主线进入到下一关。

image-20210730102319657

在这里,make 程序就好比玩家,游戏里的任务就好比编译脚本 Makefile,主线任务就好比 app 下的 Makefile,支线任务就好比编译 app 所依赖的库或者目标文件,比如这里的 lib 下的 Makefile。

首先玩家 make 进入主线,也就是 app 下,读取主线需要完成的任务 (app 下的 Makefile),主线任务告诉玩家 make 通过其中某一关 (比如编译出可执行文件 app1) 依赖一个支线任务,拿到宝物 (app1 所依赖的 lib 下的某个库),这时玩家 make 开始去支线场景 (进入 lib 文件夹),然后读取支线任务 (读取 lib 下的 Makefile),make 开始在 lib 下打怪升级 (开始编译链接 lib 下源文件并生成相应的库),当 make 完成支线任务拿到宝物 (lib 中编译出来的库) 回到主线任务 (回到 app 下 Makefile 因跳转到 lib 被中断的接下来的编译脚本) 后,才可以继续接下来的通关。

有的同学可能已经发现了,像上面的这种编译实现方式其实是比较混乱的,既然我们 make 给了我们可以将每个模块当做独立项目进行编译的能力,那么对于非应用程序的代码比如这里的 src/lib,我们可以提前编译出来,最后再来编译 src/app 下的代码,这样当依赖某个库时无需再去将该库编译出来。使用上面的编译顺序是为了说明 make 的构建方式是多样的,实际上使用 make 这一类的工具你可以使用任何你想要的编译顺序进行项目构建,本质上写 Makefile 就是写程序,这些程序告诉 make 该如何构建出最后的可执行文件,至于构建程序该以什么样的顺序构建出可执行文件,一切由你做主。这就是 make 这类编译工具的灵活以及强大之处。

还有一点需要注意的就是,真实的项目中会有很多模块是相互独立的,即这些模块互不依赖,为加快编译速度,make 支持并行编译以充分利用多核的处理能力。

关于大型项目的构建到这里就讲解的差不多了,我们可以看到大型项目的构建其实和我们平时完成一个目标是类似的,先有一个大的目标并将其分解为一个个比较容易实现的小目标,当所有的小目标完成后我们的目的也就是实现了。本质上大型项目的构建与此类似。

程序的运行过程就是 CPU 不断的从内存中取出指令然后执行执行的过程,对于函数调用来说比如我们在 C/C++ 语言中调用简单的加法函数 add,其对应的汇编指令可能是这样的:

call 0x4004fd

其中 0x4004fd 即为函数 add 在内存中的地址,当 CPU 执行这条语句的时候就会跳转到 0x4004fd 这个位置开始执行函数 add 对应的机器指令。

再比如我们在 C 语言中对一个全局变量 g_num 不断加一来进行计数,其对应的汇编指令可能是这样的:

mov 0x400fda %eax

add $0x1 %eax

这里的意思是把内存中 0x400fda 这个地址的数据放到寄存器当中,然后将寄存器中的数据加一,在这里 g_num 这个全局变量的内存地址就是 0x400fda。

好奇的同学可能会问,那这些函数以及数据的内存地址是怎么来的呢?

确定程序运行时的内存地址就是接下来我们要讲解的重点内容,这里先给出答案,可执行文件中代码以及数据的运行时内存地址是链接器指定的,也就是上面示例中 add 的内存地址 0x4004fd 其是链接器指定的。确定程序运行时地址的过程就是这里重定位 (Relocation)。

为什么这个过程叫做重定位呢,之所以叫做重定位是因为确定可执行文件中代码和数据的运行时地址是分为两个阶段的,在第一个阶段中无法确定这些地址,只有在第二个阶段才可以确定,因此就叫做重定位。接下来让我们来看看这两个阶段,合并同类型段以及引用符号的重定位。

编译器的工作

让我们回忆一下前几节的内容,源文件首先被编译器编译生成目标文件,目标文件种有三段内容:数据段、代码段以及符号表,所有的函数定义被放在了代码段,全局变量的定义放在了数据段,对外部变量的引用放到了符号表。

编译器在将源文件编译生成目标文件时可以确定一下两件事:

  • 定义在该源文件中函数的内存地址
  • 定义在该源文件中全局变量的内存地址

注意这里的内存地址其实只是相对地址,相对于谁的呢,相对于自己的。为什么只是一个相对地址呢?因为在生成一个目标文件时编译器并不知道这个目标文件要和哪些目标文件进行链接生成最后的可执行文件,而链接器是知道要链接哪些目标文件的。因此编译器仅仅生成一个相对地址。

而对于引用类的变量,也就是在当前代码中引用而定义是在其它源文件中的变量,对于这样的变量编译器是无法确定其内存地址的,这不是编译器需要关心的,确定引用类变量的内存地址是链接器的任务,链接器在进行链接时能够确定这类变量的内存地址。因此当编译器在遇到这样的变量时,比如使用了外部定义的函数时,其在目标文件中对应的机器指令可能是这样的:

call 0x000000

也就是说对于编译器不能确定的地址都这设置为空 (0x000000),同时编译器还会生成一条记录,该记录告诉链接器在进行链接时要修正这条指令中函数的内存地址,这个记录就放在了目标文件的.rel.text 段中。相应的如果是对外部定义的全局变量的使用,则该记录放在了目标文件的.rel.data 段中。即链接器需要在链接过程中根据.rel.data 以及.rel.text 来填好编译器留下的空白位置

(0x000000)。因此在这里我们进一步丰富目标文件中的内容,如图所示:

image-20210730101533085

生成目标文件后,编译器完成任务,编译器确定了定义在该源文件中函数以及全局变量的相对地址。对于编译器不能确定的引用类变量,编译器在目标文件的.rel.text 以及.rel.data 段中生成相应的记录告诉链接器要修正这些变量的地址。

接下来就是链接器的工作了。

链接器的工作

我们在静态库下可执行文件的生成一节中知道,链接器会将所有的目标文件进行合并,所有目标文件的数据段合并到可执行文件的数据段,所有目标文件的代码段合并到可执行文件的代码段。当所有合并完成后,各个目标文件中的相对地址也就确定了。因此在这个阶段,链接器需要修正目标文件中的相对地址。

在这里我们以合并目标文件中的数据段为例来说明链接器是如何修正目标文件的相对地址的,合并代码段时修正相对位置的原理是一样的。

我们假设链接器需要链接三个目标文件:

  • 目标文件一:该文件数据段定义了两个变量 apple 和 banana,apple 的长度为 2 字节,banana 的长度 4 字节,因此目标文件一的数据段长度为 6 字节。从图中也可以看出 apple 的内存地址为 0,也就是相对地址,即 apple 这个变量在目标文件一的地址是 0,banana 的地址为 2。
  • 目标文件二:该文件的数据段比较简单,只定义了一个变量 orange,其长度为 2,因此该目标文件的数据段长度为 2。
  • 目标文件三:该文件的数据段定义了三个变量 grape、mango 以及 limo,其长度分别为 4 字节、2 字节以及 2 字节,因此该目标文件的数据段长度为 8 字节。

image-20210730101619806

链接器在链接三个目标文件时其顺序是依次链接的,链接完成后:

  • 目标文件一:该数据段的起始地址为 0,因此该数据段中的变量的最终地址不变。
  • 目标文件二:由于目标文件一的数据段长度为 6,因此链接完成后该数据段的起始地址为 6 (这里的起始地址其实就是偏移 offset),相应的 orange 的最终内存地址为 0+offset 即 6。
  • 目标文件三:由于前两个数据段的长度为 8,因此该数据段的起始地址为 8 (即 offset 为 8),因此所有该数据段中的变量其地址都要加上该 offset,即 grape 的最终地址为 8,即 0+offset,mango 的最终地址为 4+offset 即 12,limo 的最终地址为 6+offset 即 14。

从这个过程中可以看到,数据段中的相对地址是通过这个公式来修正的,即:

相对地址 + offset (偏移) = 最终内存地址

而每个段的偏移只有在链接完成后才能确定,因此对相对地址的修正只能由链接器来完成,编译器无法完成这项任务。

当所有目标文件的同类型段合并完毕后,数据段和代码段中的相对地址都被链接器修正为最终的内存位置,这样所有的变量以及函数都确定了其各自位置。

至此,重定位的第一阶段完成。接下来是重定位的第二阶段,即引用符号的重定位。

相对地址是编译器在编译过程中确定了,在链接器完成后被链接器修正为最终地址,而对于编译器没有确定的所引用的外部函数以及变量的地址,编译器将其记录在了.rel.text 和.rel.data 中。

由于在第一阶段中,所有函数以及数据都有了最终地址,因此重定位的第二阶段就相对简单了。我们知道编译器引用外部变量时将机器指令中的引用地址设置为空 (比如 call 0x000000),并将该信息记录在了目标文件的.rel.text 以及.rel.data 段中。因此在这个阶段链接器依次扫描所有的.rel.text 以及.rel.data 段并找到相应变量的最终地址 (这些位置都已在第一阶段确定),并将机器指令中的 0x000000 修正为所引用变量的最终地址就可以了。

到这里链接器的重定位就讲解的这里,作为程序员一般很少会有问题出现在重定位阶段,因此这个阶段对程序员相对透明。请同学们注意一点,这里的分析仅限于目标文件的静态链接。我们知道静态链接下,链接器会将需要的代码和数据都合并到可执行文件当中,因此需要确定代码和数据的最终位置。而对于动态链接库来说情况则有所不同,动态链接库可以同时被多个进程使用,如果动态链接库的机器指令中不可以存在引用变量的最终位置,否则在被多个进程使用时会出现一个进程中使用的数据被其它进程修改。因此动态库下的机器指令都是 PIC 代码,即位置无关代码 (Position-Independent Code)。关于 PIC 的机制原理就不在这里阐述了,对此感兴趣的同学可以关注微信公众号,码农的荒岛求生,我会在那里来讲解。

问题:为什么链接器能确定运行时地址

我们知道只有把可执行文件加载到内存当中程序才可以开始运行。不同的程序会被加载到内存的不同位置。我们从前两节的过程中可以看出,链接器完全没有考虑不同的程序会被加载不同的内存位置被执行。比如对于一个可执行文件我们分别运行两次,如下图所示,因为两个程序数据段变量的地址是一样的,那么程序一的数据会不会被程序二修改呢?

image-20210730101648724

如果你去试一试的话就会发现显然不会有这种问题的。而当可执行文件加载到内存的时候也不会根据程序加载的起始地址再去修改可执行文件中变量的地址 (这样就启动速度就太慢了),那么操作系统又是如何能做到基于同一个可执行文件的两个程序能在各自的内存空间中运行而不相互干扰呢,链接器在可执行文件中确定的到底是不是程序最终的运行地址呢,我会在后面的文章当中给出答案,欢迎同学们关注微信公共账号码农的荒岛求生获取更多内容。

链接器可以操作的最小单元为目标文件,也就是说无论是静态库、动态库、可执行文件,都是基于目标文件构建出来的。目标文件就好比乐高积木中最小的零部件。

给定目标文件以及链接选项,链接器可以生成两种库,分别是静态库以及动态库,如图所示,给定同样的目标文件,链接器可以生成两种不同类型的库,接下来我们分别介绍。

库生成示意图

静态库

假设这样一个应用场景,基础设计团队设计了好多实用并且功能强大的工具函数,业务团队需要用到里面的各种函数。每次新添加其中一个函数,业务团队都要去找相应的实现文件并修改链接选项。使用静态库就可以解决这个问题。静态库在 Windows 下是以.lib 为后缀的文件,Linux 下是以.a 为后缀的文件。

为解决上述问题,基础设计团队可以提前将工具函数集合打包编译链接成为静态库提供给业务团队使用,业务团队在使用时只要链接该静态库就可以了,每次新使用一个工具函数的时候,只要该函数在此静态库中就无需进行任何修改。

你可以简单的将静态库理解为由一堆目标文件打包而成, 使用者只需要使用其中的函数而无需关注该函数来自哪个目标文件(找到函数实现所在的目标文件是链接器来完成的,从这里也可以看出,不是所有静态库中的目标文件都会用到,而是用到哪个链接器就链接哪个)。静态库极大方便了对其它团队所写代码的使用。

静态连接

静态库是链接器通过静态链接将其和其它目标文件合并生成可执行文件的,如下图一所示,而静态库只不过是将多个目标文件进行了打包,在链接时只取静态库中所用到的目标文件,因此,你可以将静态链接想象成如下图 2 所示的过程。

image-20210730090423254

image-20210730090431921

静态库是使用库的最简单的方法,如果你想使用别人的代码,找到这些代码的静态库并简单的和你的程序链接就可以了。静态链接生成的可执行文件在运行时不依赖任何其它代码,要理解这句话,我们需要知道静态链接下,可执行文件是如何生成的。

静态链接下可执行文件的生成

在上一节中我们知道,可以将静态链接简单的理解为链接器将使用到的目标文件集合进行拼装,拼装之后就生成了可执行文件,同时我们在目标文件里有什么这一节中知道,目标文件分成了三段,代码段,数据段,符号表,那么在静态链接下可执行文件的生成过程如图所示:

image-20210730090553480

从上图中我们可以看到可执行文件的特点:

  • 可执行文件和目标文件一样,也是由代码段和数据段组成。
  • 每个目标文件中的数据段都合并到了可执行文件的数据段,每个目标文件当中的代码段都合并到了可执行文件的代码段。
  • 目标文件当中的符号表并没有合并到可执行文件当中,因为可执行文件不需要这些字段。

可执行文件和目标文件没有什么本质的不同,可执行文件区别于目标文件的地方在于,可执行文件有一个入口函数,这个函数也就是我们在 C 语言当中定义的 main 函数,main 函数在执行过程中会用到所有可执行文件当中的代码和数据。而这个 main 函数是被谁调用执行的呢,答案就是操作系统 (Operating System),这也是后面文章当中要重点介绍的内容。

现在你应该对可执行文件有一个比较形象的认知了吧。你可以把可执行文件生成的过程想象成装订一本书,一本书中通常有好多章节,这些章节是你自己写的,且一本书不可避免的要引用其它著作。静态链接这个过程就好比不但要装订你自己写的文章,而且也把你引用的其它人的著作也直接装订进了你的书里,这里不考虑版权问题 :),这些工作完成后,只需要按一下订书器,一本书就制作完成啦。

在这个比喻中,你写的各个章节就好比你写的代码,引用的其它人的著作就好比使用其它人的静态库,装订成一本书就好比可执行文件的生成。

静态链接是使用库的最简单最直观的形式, 从静态链接生成可执行文件的过程中可以看到,静态链接会将用到的目标文件直接合并到可执行文件当中,想象一下,如果有这样的一种静态库,几乎所有的程序都要使用到,也就是说,生成的所有可执行文件当中都有一份一模一样的代码和数据,这将是对硬盘和内存的极大浪费,假设一个静态库为 2M,那么 500 个可执行文件就有 1G 的数据是重复的。如何解决这个问题呢,答案就是使用动态库。

动态库

在前三小节中我们了解了静态库、静态链接以及使用静态链接下可执行文件是如何生成的。接下来我们讲解一下动态库,那么什么是动态库?

动态库(Dynamic Library),又叫共享库(Shared Library),动态链接库等,在 Windows 下就是我们常见的大名鼎鼎的 DLL 文件了,Windows 系统下大量使用了动态库。在 Linux 下动态库是以.so 为后缀的文件,同时以 lib 为前缀,比如进行数字计算的动态库 Math,编译链接后产生的动态库就叫做 libMath.so。从名字中我们知道动态库也是库,本质上动态库同样包含我们已经熟悉的代码段、数据段、符号表。只不过动态库的使用方式以及使用时间和静态库不太一样。

在前面几个小节中我们知道,使用静态库时,静态库的代码段和数据段都会直接打包 copy 到可执行文件当中,使用静态库无疑会增大可执行文件的大小,同时如果程序都需要某种类型的静态库,比如 libc,使用静态链接的话,每个可执行文件当中都会有一份同样的 libc 代码和数据的拷贝,如图所示,动态库的出现解决了此类问题。

image-20210730090956928

动态库允许使用该库的可执行文件仅仅包含对动态库的引用而无需将该库拷贝到可执行文件当中。也就是说,同静态库进行整体拷贝的方式不同,对于动态库的使用仅仅需要可执行文件当中包含必要的信息即可,为了方便理解,你可以将可执行文件当中保存的必要信息仅仅理解为需要记录动态库的名字就可以了,如图所示,同静态库相比,动态库的使用减少了可执行文件的大小。

image-20210730091106648

从上面这张图中可以看出,动态库的使用解决了静态链接当中可执行文件过大的问题。我们在前几节中将静态链接生成可执行文件的过程比作了装订一本书,静态链接将引用的其它人的著作也装订到了书里,而动态链接可以想象成作者仅仅在引用的地方写了一句话,比如引用了《码农的荒岛求生》,那么作者就在引用的地方写上 “此处参考《码农的荒岛求生》”,那么读者在读到这里的时候会自己去找到码农的荒岛求生这本书并查找相应的内容,其实这个过程就是动态链接的基本思想了。

到这里我们就可以回答之前提到过的问题了,helloworld 程序中的 printf 函数到底是在哪里定义的,答案就是该函数是在 libc.so 当中定义的,Linux 下编译链接生成可执行文件时会默认动态链接 libc.so (Windows 下也是同样的道理),使用 ldd 命令就会发现每个可执行文件都依赖 libc.so。因此虽然你从没有看到过 printf 的定义也可以正确的使用这个函数。

动态链接。

我们知道静态库在编译链接期间就被打包 copy 到了可执行文件,也就是说静态库其实是在编译期间 (Compile time) 链接使用的,那么动态库又是在什么时候才链接使用的呢,动态链接可以在两种情况下被链接使用,分别是 load-time dynamic linking (加载时动态链接) 以及 run-time dynamic linking (运行时动态链接),接下来我们分别讲解一下。

  • load-time dynamic linking (加载时动态链接)

首先可能有的同学会问,什么是 load-time 呢,load_time 翻译过来也就是加载时,那么什么又是加载呢?

我们大家都玩过游戏,当我们打开游戏的时候经常会跳出来一句话:“加载中,请稍后。。。” 和这里的加载意思差不多。这里的加载指的是程序的加载,而所谓程序的加载就是把可执行文件从磁盘搬到内存的过程,因为程序最终都是在内存中被执行的。至于这个过程的详解内容我会在接下来的文章《加载器与可执行文件》一文中给大家详细讲解。在这里我们只需要简单的把加载理解为程序从磁盘复制到内存的过程,加载时动态链接就出现在这个过程。

当把可执行文件复制到内存后,且在程序开始运行之前,操作系统会查找可执行文件依赖的动态库信息 (主要是动态库的名字以及存放路径),找到该动态库后就将该动态库从磁盘搬到内存,并进行符号决议,如果这个过程没有问题,那么一切准备工作就绪,程序就可以开始执行了,如果找不到相应的动态库或者符号决议失败,那么会有相应的错误信息报告为用户,程序运行失败。比如 Windows 下比较常见的启动错误问题,就是因为没有找到依赖的动态库。Linux 下同样会有类似信息提示用户程序启动失败。

image-20210730101021256

到这里,同学们应该对加载时动态链接应该有一个比较清晰的了解了。从总体上看,加载时动态链接可以分为两个阶段:阶段一,将动态库信息写入可执行文件;阶段二,加载可执行文件时依据动态库信息进行动态链接。

阶段一,将动态库信息写入可执行文件

在编译链接生成可执行文件时,需要将使用的动态库加入到链接选项当中,比如在 Linux 下引用 libMath.so,就需要将 libMath.so 加入到链接选项当中(比如 libMath.so 放到了 /usr/lib 下,那么使用命令 gcc … -lMath -L/user/lib … 进行编译链接),所以使用这种方式生成的可执行文件中保存了依赖的动态库信息,在 Linux 可使用一个简单的命令 ldd 来查看。

阶段二:加载可执行文件时依据动态库信息进行动态链接

由于在阶段一生成的可执行文件中保存了动态库信息,当可执行文件加载完成后,就可以依据此信息进行中动态库的查找以及符号决议了。

通过这个过程也可以清楚的看到静态库和动态库的区别,使用动态库的可执行文件当中仅仅保留相应信息,动态库的链接过程被推迟到了程序启动加载时。

为加深你对加载时动态链接这个过程的理解,我们用一个类比来结束本小节,沿用前几节读书的例子,我们正在读的书中引用了《码农的荒岛求生》以及其它著作,那么加载时动态链接就好比,读者开始准备读这本书的时候(还没有真正的读)就把所有该书当中引用的资料著作都找齐放到一旁准备查看,当我们真正看到引用其它文献的地方时就可以直接在一旁找到该著作啦。在这个类比当中,开始读书前的准备工作就好比加载时动态链接。

接下来我们讲解第二种动态链接,run-time dynamic linking (运行时动态链接) 。

  • run-time dynamic linking (运行时动态链接)

上一小节中我们看到如果我们想使用加载时动态链接,那么在编译链接生成可执行文件阶段时需要告诉编译器所依赖的动态库信息,而 run-time dynamic linking 运行时动态链接则不需要在编译链接时提供动态库信息,也就是说,在可执行文件被启动运行之前,可执行文件对所依赖的动态库信息一无所知,只有当程序运行到需要调用动态库所提供的代码时才会启动动态链接过程。

我们在上一节中介绍了 load-time,也就是程序加载时,那么程序加载完成后就开始程序执行了,那么所谓 run-time (运行时) 指的就是从程序开始被 CPU 执行到程序执行完成退出的这段时间。

所以运行时动态链接这种方式对于 “动态链接” 阐释的更加淋漓尽致,因为可执行文件在启动运行之前都不知道需要依赖哪些动态库,只在运行时根据代码的需要再进行动态链接。同加载时动态链接相比,运行时动态链接将链接这个过程再次推迟往后推迟,推迟到了程序运行时。

由于在编译链接生成可执行文件的过程中没有提供所依赖的动态库信息,因此这项任务就留给了程序员,在代码当中如果需要使用某个动态库所提供的函数,我们可以使用特定的 API 来运行时加载动态库,在 Windows 下通过 LoadLibrary 或者 LoadLibraryEx,在 Linux 下通过使用 dlopen、dlsym、dlclose 这样一组函数在运行时链接动态库。当这些 API 被调用后,同样是首先去找这些动态库,将其从磁盘 copy 到内存,然后查找程序依赖的函数是否在动态库中定义。这些过程完成后动态库中的代码就可以被正常使用了。

相对于加载时动态链接,运行时动态链接更加灵活,同时将动态链接过程推迟到运行时可以加快程序的启动速度。

为了和加载时动态链接作比对,我们继续使用上一小节当中读书的例子,加载时动态链接就好比在开始准备读一本书之前,将该书中所有引用到的资料文献找齐全,而运行时动态链接则不需要这个过程,运行时动态链接就好比直接拿起一本书开始看,看到有引用的参考文献时再去找该资料,找到后查看该文献然后继续读我们的书。从这个例子当中运行时动态链接更像是我们平时读书时的样子。

至此,两种动态链接的形式我们就都已经清楚了,接下来我们看一下动态链接下生成的可执行文件。

动态链接下可执行文件的生成

在静态链接下,链接器通过将各个目标文件的代码段和数据段合并拷贝到可执行文件,因此静态链接下可执行文件当中包含了所依赖的所有代码和数据,而与之对比的动态链接下可执行文件又是什么样的呢?

其实我们在动态库这一节中已经了解了动态链接下可执行文件的生成,即,在动态链接下,链接器并不是将动态库中的代码和数据拷贝到可执行文件中,而是将动态库的必要信息写入了可执行文件,这样当可执行文件在加载时就可以根据此信息进行动态链接了。为方便理解,我们将该信息仅仅认为是动态库都名字,真实情况当然要更复杂一点,这里我们以 Linux 下可执行文件即 ELF 文件为例(这一系列的文章重点关注最本质的原理思想,所以这里讨论的同样适合 Windows 下的可执行文件即 exe 文件)。

在前几节中我们将可执行文件简单的划分为了两段,数据段和代码段,在这里我们继续丰富可执行文件中的内容,如图所示,在动态链接下,可执行文件当中会新增两段,即 dynamic 段以及 GOT(Global offset table)段,这两段内容就是是我们之前所说的必要信息。

image-20210730101148719

dynamic 段中保存了可执行文件依赖哪些动态库,动态链接符号表的位置以及重定位表的位置等信息。关于 dynamic 以及 GOT 段的作用限于篇幅就不重点阐述了。如果你对 GOT 段的具体作用很好奇的话,欢迎关注微信公共账号,码农的荒岛求生。

当加载可执行文件时,操作系统根据 dynamic 段中的信息即可找到使用的动态库,从而完成动态链接。

这里需要强调一点,在编译链接过程中,可以同时使用动态库以及静态库。这两种库的使用并不冲突,那么在这种情况下生成的可执行文件中,可执行文件中包含了静态库的数据和代码,以及动态库的必要信息。

至此,关于静态库,静态链接,动态库,动态链接就讲述到这,那么接下来的问题就是静态库和动态库都有什么样的优缺点。

动态库 vs 静态库

在计算机的历史当中,最开始程序只能静态链接,但是人们很快发现,静态链接生成的可执行文件存在磁盘空间浪费问题,因为对于每个程序都需要依赖的 libc 库,在静态链接下每个可执行文件当中都有一份 libc 代码和数据的拷贝,为解决该问题才提出动态库。

在前几节我们知道,动态链接下可执行文件当中仅仅保留动态库的必要信息,因此解决了静态链接下磁盘浪费问题。动态库的强大之处不仅仅于此,我们知道对于现代计算机系统,比如 PC,通常会运行成百上千个程序(进程),且程序只有被加载到内存中才可以使用,如果使用静态链接那么在内存中就会有成百上千份同样的 libc 代码,这对于宝贵的内存资源同样是极大的浪费,而使用动态链接,内存中只需要有一份 libc 代码,所有的程序(进程)共享这一份代码,因此极大的节省了内存资源,这也是为什么动态库又叫共享库。

动态库还有另外一个强大之处,那就是如果我们修改了动态库的代码,我们只需要重新编译动态库就可以了而无需重新新编译我们自己的程序,因为可执行文件当中仅仅保留了动态库的必要信息,重新编译动态库后这些必要都信息是不会改变的(只要不修改动态库的名字和动态库导出的供可执行文件使用的函数),编译好新的动态库后只需要简单的替换原有动态库,下一次运行程序时就可以使用新的动态库了,因此动态库的这种特性极大的方便了程序升级和 bug 修复。我们平时使用都客户端程序,比如我们常用 QQ,输入法,播放器,都利用了动态库的这一优点,原因就在于方便升级以 bug 修复,只需要更新相应的动态库就可以了。

动态库的优点不止于此,我们知道动态链接可以出现在运行时(run-time dynamic link),动态链接的这种特性可以用于扩展程序能力,那么如何扩展呢?你肯定听说过一样神器,没错,就是插件。你有没有想过插件是怎么实现的?实现插件时,我们只需要实现几个规定好的几个函数,我们的插件就可以运行了,可这是怎么做到的呢,答案就在于运行时动态链接,可以将插件以动态的都方式实现。我们知道使用运行时动态链接无需在编译链接期间告诉链接器所使用的动态库信息,可执行文件对此一无所知,只有当运行时才知道使用什么动态库,以及使用了动态库中哪些函数,但是在编译链接可执行文件时又怎么知道插件中定义了哪些函数呢,因此所有的插件实现函数必须都有一个统一的格式,程序在运行时需要加载所有插件(动态库),然后调用所有插件的入口函数(统一的格式),这样我们写的插件就可以被执行起来了。

动态库都强大优势还体现在多语言编程上。我们知道使用 Python 可以快速进行开发,但 Python 的性能无法同 C/C++ 相比 (因为 Python 是解释型语言,至于什么是解释型语言我会在后面码农的荒岛求生系列文章当中给大家详细讲解),有没有办法可以兼具 Python 的快速开发能力以及 C/C++ 的高性能呢,答案是可以的,我们可以将 C/C++ 代码编译链接成动态库,这样 python 就可以直接调用动态库中的函数了。不但 Python,Perl 以及 Java 等都可以通过动态库的形式调用 C/C++ 代码。动态库的使用使得同一个项目不同语言混合编程成为可能,而且动态库的使用更大限度的实现了代码复用。

了解了动态库的这么多优点,那么动态库就没有缺点吗,当然是有的。

首先由于动态库是程序加载时或运行是才进行链接的,因此同静态链接相比,使用动态链接的程序在性能上要稍弱于静态链接,这时因为对于加载时动态链接,这无疑会减慢程序都启动速度,而对于运行时链接,当首次调用到动态库的函数时,程序会被暂停,当链接过程结束后才可以继续进行。且动态库中的代码是地址无关代码(Position-Idependent Code,PIC),之所以动态库中的代码是地址无关代码是因为动态库又被成为共享库,所有的程序都可以调用动态库中的代码,因此在使用动态库中的代码时程序要多做一些工作,这里我们不再具体展开讲解到底程序多做了哪些工作,对此感兴趣当同学可以参考 CSAPP(深入理解计算机系统)。这里我们说动态链接的程序性能相比静态链接稍弱,但是这里的性能损失是微乎其微的,同动态库可以带来的好处相比,我们可以完全忽略这里的性能损失,同学们可以放心的使用动态库。

动态库的一个优点其实也是它的缺点,即动态链接下的可执行文件不可以被独立运行(这里讨论的是加载时动态链接,load-time dynamic link),换句话说就是,如果没有提供所依赖的动态库或者所提供的动态库版本和可执行文件所依赖的不兼容,程序是无法启动的。动态库的依赖问题会给程序的安装部署带来麻烦,在 Linux 环境下尤其严重,以笔者曾参与开发维护的一个虚拟桌面系统为例,我们在开发过程中依赖的一些比较有名的第三方库默认不会随着安装包发布,这就会导致用户在较低版本 Linux 中安装时经常会出现程序无法启动的问题,原因就在于我们编译链接使用都动态库和用户 Linux 系统中都动态库不兼容。解决这个问题的方法通常有两种,一个是用户升级系统中都动态库,另一个是我们讲需要都第三方库随安装包一起发布,当然这是在取得许可的情况下。

在了解了动态库的优缺点后,接下来我们来看一下静态库。

静态链接是最古老也是最简单的链接技术。静态链接都最大优点就是使用简单,编译好的可执行文件是完备的,即静态链接下的可执行文件不需要依赖任何其它的库,因为静态链接下,链接器将所有依赖的代码和数据都写入到了最终的可执行文件当中,这就消除了动态链接下的库依赖问题,没有了库都依赖问题就意味着程序都安装部署都得到了极大都简化。请大家不要小看这一点,这对当今那些拥有海量用户的后端系统来说至关重要,比如类似微信这种量级的系统,其后端会部署在成千上万台机器上,这么多的机器其系统的安装部署以及升级会给运维带来极大挑战,而静态链接下的可执行文件由于不依赖任何库,因为部署非常方便,仅仅用一个新的可执行文件进行覆盖就可以了,因此极大的简化了系统部署以及升级。笔者之前所在的某电商广告后端系统就完全使用静态链接来简化部署升级。

而静态库的缺点相信大家都已经清楚了,那就是静态链接会导致可执行文件过大,且多个程序静态链接同一个静态库的话会导致磁盘浪费的问题。

到这里关于静态库和动态库的讨论就告一段落了,相信大家对于这两种链接类型都有了清晰都认知。接下来让我们稍作休息,开始链接器的下一个重要功能,重定位。

什么是链接器 (Linker)

维基百科的定义为:

链接器 (英语:Linker),是一个程序,将一个或多个由编译器或汇编器生成的目标文件外加库,链接为一个可执行文件。

在 Unix-like 系统上常用的链接器是 GNU ld。目标文件是包括机器码和链接器可用信息的程序模块。简单的讲,链接器的工作就是解析未定义的符号引用,将目标文件中的占位符替换为符号的地址。链接器还要完成程序中各目标文件的地址空间的组织,这可能涉及重定位工作。大多数现代操作系统都提供动态链接和静态链接两种形式。

为便于理解,可将链接器的定义分解成如下三点:

  • 首先是链接器的本质,链接器本质上也是一个程序,和我们经常使用的普通程序没什么不同。
  • 接器的输入是编译器编译好的目标文件(object file)。
  • 链接器的输出,链接器在将目标文件打包处理后,生成或者可执行文件,或者库,或者目标文件。

从这个定义中能够看出,链接器的作用有点类似于我们经常使用的压缩软 WinRAR (Linux 下是 tar),压缩软件将一堆文件打包压缩成一个压缩文件,而链接器和压缩软件的区别在于链接器是将多个目标文件打包成一个文件而不进行压缩。

符号决议 (Symbol Resolution)

所有的应用程序都是链接器将所需要的一个个简单的目标文件汇集起来形成的,可以将这个过程想象成拼图游戏,每个拼块就是一个简单的目标文件:

拼图

1、拼图游戏当中的每个拼块都依赖于其它拼块提供的拼接口,这就好比我们写的程序模块依赖于其它模块提供的编程接口,比如我们在 list.c 中实现了一种特定的链表数据结构,其它模块需要使用这种链表,这就是模块间的依赖。而链接器其中一项任务就是要确保提供给链接器进行链接的目标文件集合之间依赖是成立的(也就是说,不会出现在被依赖的模块中链接器找不到需要的接口),这就是要讲到的符号决议 (Symbol Resolution)。

2、我们在拼图游戏当中通常都是将一整幅图按组成部位一部分一部分拼接好,然后将这些比较完整的大的组成部分拼接成最后一整幅图。这就好比链接器会首先将程序每个模块当中目标文件集合链接成库,然后再将各个库进行链接最终形成可执行程序。这就是可执行程序的生成过程。

3,链接器还有一项任务是无法用这个拼图游戏来类比的,但是这项重要的任务对程序员不可见,作为程序员几乎不会在这个过程遇到问题,这项任务就是重定位

链接器的工作过程

通过拼图这个游戏的类比,我们给出链接器的工作过程:

  1. 首先,链接器对给定的目标文件或库的集合进行符号决议以确保模块间的依赖是正确的。
  2. 其次,链接器将给定的目标文件集合进行拼接打包成需要的库或最终可执行文件。
  3. 最后,链接器对链接好的库或可执行文件进行重定位。

接下来详细的讲解下每一个过程。首先讲解链接器的符号决议过程。在这个过程当中,链接器需要做的工作就是确保所有目标文件中的符号引用都有唯一的定义。要想理解这句话我们首先来看看一个典型的 c 文件里都有些什么。

c 源文件中都有什么

c程序示例图

如图所示是一个典型的 c 源文件,该文件中的变量可以划分为两类:

  • 全局变量:比如 x_global_uninit,x_global_init,fn_c。只要程序没有结束运行,全局变量都可以随时使用。注意,用 static 修饰的全局变量比如 y_global_uninit,其生命周期也等同于程序的运行周期,只是这种全局变量只能在所被定义的文件当中使用,对其它文件不可见。

  • 局部变量:比如 y_local_uninit,y_local_init,局部局部变量的生命周期和全局变量不同,局部变量变量只能在相应的函数内部使用,当函数调用完成后该函数中的局部变量也就无法使用了。因为局部变量只存在于函数运行时的栈帧当中,函数调用完成后相应的栈帧被自动回收 (该内容涉及到程序运行时的内存模型)。

目标文件里有什么

编译器的任务就是把人类可以理解的代码转换成机器可以执行的机器指令,源文件编译后形成对应的目标文件。源文件被编译后生成的目标文件中本质上只有两部分:

  • 代码部分:你可能会想,一个源文件中不都是代码吗,这里的代码指的是计算机可以执行的机器指令,也就是源文件中定义的所有函数。比如上图中定义的函数 fn_b 以及 fn_c。

  • 数据部分:源文件中定义的全局变量。如果是已经初始化后的全局变量,该全局变量的值也存在于数据部分。

到目前为止,可以把一个目标文件简单的理解为由两部分组成,代码部分中保存的是 CPU 可以执行的机器指令,这些机器指令来自程序员所定义的函数,编译器将这些定义的函数翻译成机器指令并存放在目标文件的代码部分。数据部分存放的是机器指令所操作的数据。因此目前,可以简单的将目标文件理解为一个只有两部分的文件,如图所示:

目标文件组成示意图

你可能会好奇函数中定义的局部变量为什么没有放到目标文件的数据段当中,这是因为局部变量是函数私有的,局部变量只能在该函数内部使用而全局变量时没有这个限制的,所以函数私有的局部变量被放在了代码段中,作为机器指令的操作数。

编译器在编译过程中遇到外部定义的全局变量或函数时,只要编译器能找到相应的变量声明就会在心里默念 “all is well, all is well (一切顺利)“,从这里可以看出编译器的要求还是很低的,至于所使用变量的定义编译器是不会费力去四处搜索,而是愉快的继续接下来的编译。注意,这里再次强调一下,编译器在遇到外部定义的全局变量或者函数时只要能在当前文件找到其声明,编译器就认为编译正确。而寻找使用变量定义的这项任务就被留给了链接器。链接器的其中一项任务就是要确定所使用的变量要有其唯一的定义。虽然编译器给链接器留了一项任务,但为了让链接器工作的轻松一点编译器还是多做了一点工作的,这部分工作就是符号表 (Symbol table)。

什么是符号表 (Symbol table)

之前提到,虽然编译器很不厚道的给链接器留了一项任务,但是编译器为了链接器工作的轻松一点还是做了一点事情,这就是符号表。那符号表中保存的是什么呢,符号表中保存的信息有两部分:

  • 该目标文件中引用的全局变量以及函数
  • 该目标文件中定义的全局变量以及函数

以上图中的代码为例,编译器在编译过程中每次遇到一个全局变量或者函数名都会在符号表中添加一项,最终编译器会统计出如下所示的一张符号表:

符号表示意图

  • z_global 以及 fn_a 是未定义的,因为在当前文件中,这两个变量仅仅是声明,编译器并没有找到其定义。剩余的变量编译器都可以在当前文件中找到其定义。
  • fn_b 以及 fn_c 为当前文件定义的函数,因为在代码段。
  • 剩余的符号都是全局变量,因此放在了数据段。

有同学可能会问,为什么全局变量 y_global_uninit ,y_global_init 以及函数 fn_b 不可被其它目标文件引用,这是因为这些变量用 static 修饰过了,在 C 语言中经 static 修饰过的函数的函数以及变量都是当前文件私有的,对外部不可见,这里一定要注意。所以 static 这个关键字的用法就是,如果你认为一个变量只应该被当前文件使用而不暴露给外部,那么你就可以使用 static关键字修饰一下。

本质上整个符号表只是想表达两件事:

  • 我能提供给其它文件使用的符号
  • 我需要其它文件提供给我使用的符号

这里还有一个问题就是,编译器将统计的这张符号表放在哪里了呢?

符号表存放在哪里

在目标文件里有什么这一小节中,我们将一个目标文件简单的划分了两段,数据段和代码段,现在我们要向目标文件中再添加一段,而符号表也被编译器很贴心的放在目标文件中,因此一个目标文件可以理解为如图所示的三段,而符号表中的内容就是上一节当中编译器统计的表格。

image-20210729220335315

有了符号表,链接器就可以进行符号决议了。

符号决议的过程

在上一节符号表中,我们知道符号表给链接器提供了两种信息,一个是当前目标文件可以提供给其它目标文件使用的符号,另一个其它目标文件需要提供给当前目标文件使用的符号。有了这些信息链接器就可以进行符号决议了。如图所示,假设链接器需要链接三个目标文件:

链接三个目标文件示意图

链接器会依次扫描每一个给定的目标文件,同时链接器还维护了两个集合,一个是已定义符号集合 D,另一个是未定义符合集合 U,下面是链接器进行符合决议的过程:

1,对于当前目标文件,查找其符号表,并将已定义的符号并添加到已定义符号集合 D 中。

2,对于当前目标文件,查找其符号表,将每一个当前目标文件引用的符号与已定义符号集合 D 进行对比,如果该符号不在集合 D 中则将其添加到未定义符合集合 U 中。

3,当所有文件都扫描完成后,如果为定义符号集合 U 不为空,则说明当前输入的目标文件集合中有未定义错误,链接器报错,整个编译过程终止。

上面的过程看似复杂,其实用一句话概括就是只要每个目标文件所引用变量都能在其它目标文件中找到唯一的定义,整个链接过程就是正确的。

如果你觉得上面的解释比较晦涩的话,你也可以将链接符号决议这个过程想象成如下的游戏:

新学期开学后,幼儿园的小朋友们都带了礼物要和其它的小朋友们分享,同时每个小朋友也有自己的心愿单,每个小朋友都可以依照自己的心愿单去其它的小朋友那里拿礼物,整个过程结束后,每个小朋友都能拿到自己想要的礼物。

在这个游戏当中,小朋友就好比目标文件,每个小朋友自己带的礼物就好比每个目标文件的已定义符号集合,心愿单就好比每个目标文件中未定义符号的集合。

举例说明 undefined reference

假设我们写了一个 math.c 的数字计算程序,其中定义了一个 add 函数,该函数在 main.c 中被引用到,那么很简单,我们只需要在 main.c 中 include 写好的 math.h 头文件就可以使用 add 函数了,如图所示:

引用示意图

但是由于粗心大意,一不小心把 math.c 中的 add 函数给注释掉了,当你在写完 main.c、打算很潇洒的编译一下时,出现了很经典的 undefined reference to add(int, int) 错误,如图所示:

引用报错示意图

这个错误其实是这样产生的:

  1. 链接器发现了你写的代码 math.o 中引用了外部定义的 add 函数 (不要忘了,这是通过检查目标文件 math.o 中的符号表得到的信息),所以链接器开始查找 add 函数到底是在哪里定义的。
  2. 链接器转而去目标文件 math.o 的目标文件符号表中查找,没有找到 add 函数的定义。
  3. 链接器转而去其它目标文件符号表中查找,同样没有找到 add 函数的定义。
  4. 链接器在查找了所有目标文件的符号表后都没有找到 add 函数,因此链接器停止工作并报出错误 undefined reference to `add (int, int)’,如上图所示。

因此如果你很清楚链接器符号决议这个过程的话就会进行如下排查:

  1. main.c 中对 add 函数的函数名有没有写正确。
  2. 链接命令中有没有包含 math.o,如果没有添加上该目标文件。
  3. 如果链接命令没有问题,查看 math.c 中定义的 add 函数定义是否有问题。
  4. 如果是 C 和 C++ 混合编程时,确保相应的位置添加了 extern “C”。

一般情况下经过这几个步骤的排查基本能够解决问题。

所以当你再次看到 undefined reference 这样的错误的是时候,你就应该可以很从容的去解决这类问题了。

参考资料来自于公众号《码农的荒岛求生》,此处整理仅供个人学习。

mount 命令介绍

用于加载文件系统到指定的加载点。此命令的最常用于挂载 cdrom,使我们可以访问 cdrom 中的数据,因为你将光盘插入 cdrom 中,Linux 并不会自动挂载,必须使用 Linux mount 命令来手动完成挂载。

mount 命令常用参数

-t 指定挂载类型
-a 加载文件 “/etc/fstab” 中描述的所有文件系统

示例

挂载 /dev/cdrom 到 /mnt:

1
[root@linuxcool ~]# mount /dev/cdrom /mnt

启动所有挂载:

1
[root@linuxcool ~]# mount -a

挂载 nfs 格式文件系统:

1
[root@linuxcool ~]# mount -t nfs /123 /mnt  
0%