[software development] 附加基础库ABL原理详解
Tofloor
poster avatar
enforcee
deepin
2024-12-09 19:23
Author

glibc兼容问题一直是困扰GNU/Linux爱好者的一个难点,如果在旧版的系统使用自己安装的较新的应用,就很容易遇到应用无法运行,控制台提示类似的内容:

/lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.xx' not found (required by /path/to/xxx)

实际原因是开发者构建应用时使用的glibc版本高于当前用户运行应用时使用的glibc版本所引发的。如果是反过来,开发者使用旧版系统构建应用,用户在新版系统上运行的话,就不会有问题,因为glibc设计中有对旧版本的兼容能力。遇到这种情况,除了自己更换操作系统外,最妥善的解决办法应该是让开发者(或者有自己构建应用能力的用户)在旧版系统上重新构建软件并打包,但是这相当浪费时间和资源,也要看人家的心情。于是有很多用户决定自己动手升级新版的glibc,结果弄得系统崩溃。这是因为glibc在GNU/Linux系统中是如此重要,几乎所有应用都需要依靠glibc来发挥功能,一旦不慎破坏了glibc,那么系统中所有程序都将全部失灵。尽管这种操作如此危险,依然有不少用户为了体验新版程序冒险升级。glibc兼容问题变成一种梦魇,很多人认为冒险升级glibc是用上新版应用的唯一方法,没有其他路可以走。

为了破除包括glibc问题在内的一些谣言,也想让一些出问题的应用程序在我的电脑上运行起来,我曾经花了一段时间研究各种动态库问题,最终发现其实GNU/Linux的动态库使用也十分灵活,glibc问题也有不需要修改系统的解决方法(原帖:https://bbs.deepin.org/post/256081)。其实可能这些内容本来就是GNU/Linux开发者的基础知识,但是由于用户学习使用操作系统的方式从来都是由表及里的,很难接触到这些底层原理,才诞生了这些偏见。

当然在那篇文章中提到的关于glibc问题的解决方法仍然有些复杂,之后我又借助轻量级容器应用bubblewrap设计了一个简单程序来方便用户和打包者使用,就是附加基础库Additional Base Lib(https://gitee.com/spark-store-project/additional-base-lib)。本来是纯粹当作给我提出的解决方案做试验了,但是真的解决了许多用户的困难,因此随后又花了一段时间改善功能。但是由于一些原因我没办法继续更新这个小工具,所以来写一篇文章来尽可能详细地解释这个问题,以免后来者再误入歧途。

什么是库?

随着应用程序的设计越来越复杂,要方便管理,要逻辑清晰,要满足多个开发者合作的需要,要后续添加和改变功能容易,想开发一个复杂的应用,就得拆成几部分来完成。不考虑脚本或者其他类型的程序,只看最常见的二进制程序的话,包含入口的程序,就是当用户想运行应用时所启动的程序,我们称为可执行文件(Executable)。另一些程序并非由用户去主动运行,而是由其他程序来利用其中包含的功能,我们称为库文件(Library)。程序想使用库中包含的功能,就要进行链接(Link),而链接又分为静态链接和动态链接,静态链接库是在应用程序构建的时候完成链接的,库也融合进可执行文件里了,所以用户平常是见不到的。而动态链接库(Dymanic Link Library/DLL),是在运行程序后才进行链接的,动态库也是独立于可执行程序之外的文件,因此用户最常见的库就是这种。

在类Unix系统中,二进制程序的格式被称为elf(Excutable and Linkable Format),可执行文件通常被存放在名为bin这样的目录,不使用扩展名,而动态库被存放在lib这样的目录,动态库通常命名前面加上lib,后面扩展名为.so(Shared Object)后面可以再加一个点数字表示api版本。

使用库的好处:

  1. 重复利用代码,减少开发成本。多个程序可以同时使用一个库的功能,如果重复利用动态库的话,可以节约存储空间。
  2. 动态库可以使用时加载,如果不使用时就可以卸载节约内存。
  3. 对于不开源的软件来说可以保密,只要把头文件和编译好的库给对方,对方就可以使用库中的功能,对方也不知道源代码。
  4. 动态库可以设计为插件的模式扩展应用的功能,通过加载不同的动态库就可以切换不同的功能。

不过另一方面,主流的GNU/Linux的设计大量依靠动态库,尽管减少了存储和网络成本,但是也让系统和应用环环相扣,有一处出现问题会造成多个应用同时出现故障,这是比较违反用户直觉的。现代一些新设计的打包技术则是尽量规避系统与应用之间、应用与应用之间的耦合来减少问题,不过那就不是本文的重点了。

对于常见的动态库问题,缺库在系统软件源中安装对应的软件包即可,如果遇到与系统动态库不兼容的问题,只需要在其他系统中下载对应的库放到一个地方,然后用LD_LIBRARY_PATH环境变量指定动态库搜索路径,让程序优先加载指定的库,具体参见之前的文章。

想了解库加载顺序的话,可以使用 man ld.so阅读手册,或者直接上网搜索相关资料。

glibc问题的特殊之处

那么glibc不兼容问题能套用上述方法,通过搬运一个新版本的libc.so.6和其他glibc动态库解决吗?答案是否定的。其根本原因在于动态库链接器ld.so和libc.so.6基本是耦合的,如果用非原装的libc.so.6,ld.so就无法工作,那么任何程序都不能运行。

那么你应该会想,只需要再搬运一个对应的新版ld.so就能解决问题了吧。结果是,真就能解决问题,就是这么容易。

那么ld.so到底是何方神圣呢?实际上这个名字只是一个简称,在不同架构上,他的名称和路径不同。x86-64架构上,他是 /lib64/ld-linux-x86-64.so.2 。ld.so本身也是glibc中的一个组件,他被称为动态库链接器,也可以叫elf文件的解释器(interpreter),他表面上的功能是为程序加载动态库,但实际上他的工作模式是类似bash运行shell脚本、python运行python脚本一样,elf文件是由ld.so运行的。因此运行任何可执行程序都会先运行ld.so,其重要性不言而喻。

就如同我们会在shell脚本的第一行写上 #!/bin/bash 来提示系统选择哪个命令作为解释器运行,elf文件内也有一个 .interp 字段指示所选择的ld.so。不过用平常的方法不太容易读取和修改,用patchelf工具会更加方便。如此,我们便得到了解决glibc兼容问题的两个途径。

第一种是采用patchelf工具修改elf文件内部的解释器字段使其指向自己搬来的ld.so,然后运行程序时再用LD_LIBRARY_PATH提供配对的libc.so.6和其他需要的动态库。

第二种则是用解释器的原始定义,直接调用自己搬运的ld.so运行程序,即: /路径/ld-linux-** <要运行的命令>...,同时使用LD_LIBRARY_PATH提供配对的libc.so.6和其他需要的动态库。

ABL的工作

以上方法能解决glibc兼容问题,但是也比较麻烦,面对比较复杂的程序可能需要改的地方很多,也不利于保持应用的纯洁。于是采用bubblewrap便是一个比较不错的方案。bubblewrap是一个轻量化的容器应用,由于不需要授予root权限也能运行,不需要另外下载镜像,能实现在当前根目录上替换文件等等特性,是解决兼容问题的利器。flatpak做跨发行版运行应用,容器技术也是选用了bubblewrap。过去社区里的打包家也曾用bubblewrap做过一些伪装系统的小技巧。实际上最早的ABL就是如此简单,就是搬运了高版本的glibc文件(把自带的动态库都放在系统库目录中additional-base-lib目录里),然后用可以算是一条命令的脚本创造了一个和本机根目录相同,只不过替换了ld.so和libc.so.6两个文件的容器,然后用LD_LIBRARY_PATH寻找其他的glibc动态库。安装之后,如果遇到不兼容的情况出现,只需要在相关的命令前面加上ablrun和一个空格,就能神奇地消除glibc不兼容问题。

不过为了满足更多系统和应用的需求,这个项目逐渐扩充,虽然脚本增长了不少,但核心仍然和之前没什么区别。

打包脚本详解

一开始我是通过手动下载debian的几个glibc相关包然后手动拆包打包来制作ABL,不过后来发现经常这样做比较麻烦,所以我做了 make-deb.sh 用于自动下载、解包、获取信息、生成deb包。每次debian更新后,只需要替换掉下载链接,基本上自动打包就没什么问题。之后我又为采用rpm包管理的操作系统设计了 make-rpm.sh 。之后在不同CPU架构上想用ABL,只要debian或fedora支持,都可以用来快速生产。

这两个脚本从头到脚的内容:

  1. 下载三个包,分别是glibc,glibc的可执行程序(主要取的是ldd,这是一个glibc自带用于分析动态库加载情况的脚本,由于我曾经注意到不同版本的glibc,ldd有些许不兼容,所以也放到ABL里)和libstdc++(这也是另一个常见的不兼容问题,属于gcc项目的c++基础库,虽然解决这种问题只需要LD_LIBRARY_PATH,但是glibc过时的系统几乎都有libstdc++的问题,所以也带上了)。不过在debian和fedora中,两者的包名略有区别。前者是叫libc6、libc6-bin、libstdc++6,后者是glibc、glibc-common、libstdc++。
  2. 先将deb包解包,直接放在当前目录。deb包需要先解包读取信息比较容易一些。而rpm不需要解包直接读取即可。检查一下几个包的版本、架构、是否对应。
  3. rpm包的打包要求比较严格,需要在 ~/rpmbuild/BUILD 目录中进行,创建路径,将rpm包解包。
  4. 获取一些信息,包括glibc的版本、架构等。因为两个平台规范不同,比如说dpkg中我的平台叫amd64,但是到了rpm就叫x86-64。为了避免麻烦,我就直接采用各个平台中从第一个glibc包中读取到的数值。
  5. 获取动态库目录。这个代表了要把ABL的库安装到哪里,其实这里是比较复杂的,和架构有关,debian自己设计了一套字符串,比如amd64对应的是x86_64-linux-gnu,所以动态库的目录是 /usr/lib/x86_64-linux-gnu,ABL就会把库安装到 /usr/bin/x86_64-linux-gnu/additional-base-lib 里。这个字符串可以用命令 dpkg-architecture -A <架构简称>看到。而rpm平台就简单得多,32位系统就是 /lib ,64位系统就是 /lib64
  6. 由于debian trixie开始了usrmerge,之前glibc的各个动态库是放在 /lib 路径,之后的是在 /usr/lib 。所以增加了个检测。这个只在打包时需要找到解包出来的各种动态库时用,反正ABL最后都会安装到上面说的路径里。
  7. 获取ld.so的位置。实际上无论是debian或fedora还是其他GNU/Linux,这里应该都是一套规范,但是我为了防止出问题,还是用patchelf读取libc.so.6的interpreter值。
  8. 由于rpm打包比较严格,需要先制作控制文件spec,还要执行很多自动的步骤,而不是像debian一样像压缩包一样一打就行。这里先对rpm生成spec文件。我关闭了自动的strip功能,因为不知为什么在执行一些架构时会产生问题,另外把压缩方式也改成旧版系统也能接受的gz。
  9. 创建ablrun脚本。其实大部分内容就在ablrun_part里面,这里由于各个平台的差异,动态库目录和ld.so需要打包时获取然后写入ablrun脚本里,然后把ablrun_part拼接到后面就行了。
  10. 把各个文件复制过去。这里问题是,由于解包里面有不少链接是写的绝对路径,如果直接复制实际上不是复制解包出来的文件,而是系统自带的文件了。所以我写了个rooted readlink函数,作用是限制到当前路径中获取链接对应的文件位置。后面我又用了个奇葩技巧,因为ld.so的名称和路径都根据架构有变化,比如说debian amd64,如果我只把ld.so放到 /usr/bin/x86_64-linux-gnu/additional-base-lib/里 ,我就得在脚本里面记录两个变量,一个是ABL自带的ld.so的位置 /usr/bin/x86_64-linux-gnu/additional-base-lib/ld-linux-x86-64.so.2,一个是ld.so的默认位置 /lib64/ld-linux-x86-64.so.2 。最后所以实际上我把ld.so放到 /usr/bin/x86_64-linux-gnu/additional-base-lib/lib64/ld-linux-x86-64.so.2 里面,这样我只需要记录一个变量 /lib64/ld-linux-x86-64.so.2,然后把他和动态库目录拼接一下就是ABL自带的ld.so位置。(还有一个是单用cp只能移动到已有的文件夹,又不清楚变量里面到底是lib还是lib64,又懒得截取字符串一个一个创建,所以先拿ld.so的路径用 mkdir --parent 建成了个文件夹,然后用rm删掉,这么一操作就保留了父级文件夹,然后再cp过去。)
  11. 把所有文件整理好后,打包。deb包也用gz压缩,防止旧版本安装不了。

ablrun详解

在打包过程中会生成ablrun脚本,也是实际使用时执行的文件。其实除了开头的两个变量是打包时生成的,其他内容全在ablrun_part里。

从头到脚的内容入下:

  1. 打包时放入的两个变量,分别是ABL动态库位置和ld.so默认位置。
  2. 如果没有输入命令,就显示用法提示和信息,退出。
  3. 变换LD_LIBRARY_PATH,把ABL的动态库位置加入进去。
  4. 用readlink获取ld.so和libc.so.6的真实位置。有时候他们在系统中是链接文件,但是bwrap把文件绑定到链接文件会报错,所以知道需要链接指向的目标位置。
  5. 获取bwrap是否为setuid,下面会详细讲解。
  6. 获取内核的最大user namespace数量,下面会详细讲解。
  7. 定义了四种ablrun的函数,分别为ablrun_normal、ablrun_setuid、ablrun_nocap、ablrun_nocap_noreplace。其实这都是为不同系统状态而准备的,与一开始计划中一套方案摆平所有问题相去甚远。重点在于旧版内核因为某些(可能是安全)原因虽然支持user namespace,但是默认关闭,而这是支持bubblewrap不提权容器的核心功能,因此在这些系统中bubblewrap本来是用不了的,但是bubblewrap的开发者想出了一种解决方法,就是在这些系统中将bwrap设置上setuid权限,这样所有用户运行bwrap时都能自动以root权限运行。但是这样做又同时让bwrap有了防止越权举动的责任,所以在这种状态下,bwrap不允许添加任何一项caps权限(Linux的一种权限机制),如果添加权限就会报错。所以这种情况使用ablrun_nocap模式,不过就是自带容器的程序(如AppImage,electron开发的程序等)是无法使用的。在这种系统中,如果使用这个命令 sudo bash -c "echo 1 > /proc/sys/user/max_user_namespaces" ,就能启用Linux的user namespace功能,这种情况下ablrun就会自动切换成另外一种模式,让那些应用可以运行。另外,如果使用root权限运行ablrun,也会使用ablrun_nocap模式。
  8. 如果是较新的内核,user namespace是默认启用的,通常这种系统安装的bwrap是普通权限的。这时会使用ablrun_normal模式,这种情况会给bwrap带上 CAP_SYS_ADMIN 权限,这是想运行自带容器的程序(如AppImage,electron开发的程序等)必须的权限。应该所有应用都能运行。
  9. 一种很奇葩的状况是,Linux的user namespace默认启用,但是bwrap又被安装上了setuid权限,这其实是不太正常的情况。deepin有一个版本就是这样,另外还有就是旧版内核用了我说的命令手动启用了user namespace,对于这种情况,我想出了个很有意思的方案,就是ablrun_setuid模式,这里我在正常的bwrap外面又套了一层几乎完全透明的bwrap,但是由于bwrap内部失去了setuid权限的关系,内层的bwrap就可以正常添加 CAP_SYS_ADMIN 权限了,就能允许自带容器的程序运行。
  10. 在禁用user namespace的系统上,为了能运行AppImage,我专门设计了一套方法,当识别到AppImage时,不会将AppImage放在bwrap里运行,而是先用AppImage内置的挂载命令(会将AppImage的内容挂载到系统上,同时输出实际挂载位置。程序会一直运行,如果结束掉进程就会自动卸载),再用bwrap运行AppImage内容中的AppRun,和其他系统直接运行AppImage的结果是差不多的。前三种方法ablrun_normal、ablrun_setuid、ablrun_nocap中,我使用了bash的exec,这样运行bwrap后ablrun脚本就会自动退出,节约一些内存,还能让进程管理器看着干净很多。但是在运行AppImage的特殊方法中,必须等待主程序退出后,ablrun需要先终止挂载命令才能退出,所以这种情况会多出来几个进程数。检测文件是否为AppImage用的是xdg-mime,这个命令有图形界面的系统一般都有。

希望后来者补全的功能

ABL只采用bwrap绑定ld.so和libc.so.6,其他动态库使用LD_LIBRARY_PATH寻找,在大多情况下是完全没有问题的,但是有些不太讲武德的开发者把LD_LIBRARY_PATH在运行脚本里面写死了,这样ABL自带的其他动态库就没有作用。其实一开始设计的方案完全是为了证明我的想法(即只需要绑定ld.so和libc.so.6即可解决glibc兼容问题,其他动态库都可以通过LD_LIBRARY_PATH解决),并没有考虑太多,但是后来的版本为了尽量防止出错,我觉得有必要把所有自带的动态库挂载上。

如果ABL带的glibc库版本还不如系统自带的glibc版本高,那样的话用ablrun运行反而会出现glibc兼容问题(不用反而会正常)。本来我觉得这不是个问题,毕竟应用能运行还为啥要安装ABL,但是后来发现星火商店不少应用都在默认加ablrun,这种情况也不是完全没可能发生。需要做一个glibc版本判别功能,如果系统自带的版本更高一些就自动不使用ABL。其实只要把libc.so.6当作可执行程序运行,就能看到版本号了。

Reply Favorite View the author
All Replies
wlly-lzh
deepin
2024-12-09 21:48
#1

神乎其技!

Reply View the author
北冥夜未央
deepin
Ecological co-builder
2024-12-09 22:11
#2

这才是真正的大佬,我虽然没有完全弄懂操作方法,解决思路大体还是看清楚了,有时间自己动手试试。

Reply View the author
神末shenmo
deepin
Spark-App
2024-12-09 23:00
#3

再看一遍还是感叹,魔法

Reply View the author
neko
deepin
Ecological co-builder
2024-12-10 00:09
#4

好文!ABL非常好用!

Reply View the author
小小怪冲啊!
deepin
2024-12-10 00:13
#5

看了个寂寞

Reply View the author
Oli
deepin
2024-12-10 01:45
#6

好用心

Reply View the author
聪明蛋
deepin
2024-12-10 07:48
#7
The user is banned, and the content is hidden.
乾豫恒益
deepin
2024-12-10 08:08
#8

搬个椅子,好好学习一下。。。

Reply View the author
把一切操作变成GUI
deepin
Backbone of ecological co-construction group
2024-12-10 09:54
#9

喜欢看这种干货

Reply View the author
coder潘
deepin
2024-12-10 10:39
#10

精品,拜读一下,看能不能和当前的distrobox的互补下,感谢大佬

Reply View the author
W2J
deepin
2024-12-10 12:35
#11

大神讲的明明白白。

ablerun=bwrap三件套封装(ld/libc6/stdc++),解决dist-time滞后版本的兼容性最大问题。

ablrun只需要定时维护(URL,pseudo-env)这些对deb/rpm run-time二次封包脚本。

Reply View the author
神末shenmo
deepin
Spark-App
2024-12-11 14:49
#12
coder潘

精品,拜读一下,看能不能和当前的distrobox的互补下,感谢大佬

abl主打轻量,主要是为了无损性能运行单纯是libc版本问题的软件,多为appimage,优势是性能几乎无任何折损,劣势是只能解决libc版本问题

distrobox主打大而全,主要是为了能提供一个完整的运行环境,能够运行大多数的应用,解决大部分的兼容问题,劣势是部署复杂而且启动容器比较缓慢

ACE 兼容环境是二者的结合,利用轻量的bwrap容器提供了一个完整的运行环境,优势是如同abl一样的几乎无任何折损的性能,秒速启动,同时也有distrobox的大而全的运行环境,劣势是缺乏完整的权限管理体系,容器内无法进行提权操作且无独立daemon(但这也算是一种优势?不需要常驻后台,打开应用才开启,关闭之后就退出,干干净净)

Reply View the author
coder潘
deepin
2024-12-11 14:51
#13
神末shenmo

abl主打轻量,主要是为了无损性能运行单纯是libc版本问题的软件,多为appimage,优势是性能几乎无任何折损,劣势是只能解决libc版本问题

distrobox主打大而全,主要是为了能提供一个完整的运行环境,能够运行大多数的应用,解决大部分的兼容问题,劣势是部署复杂而且启动容器比较缓慢

ACE 兼容环境是二者的结合,利用轻量的bwrap容器提供了一个完整的运行环境,优势是如同abl一样的几乎无任何折损的性能,秒速启动,同时也有distrobox的大而全的运行环境,劣势是缺乏完整的权限管理体系,容器内无法进行提权操作且无独立daemon(但这也算是一种优势?不需要常驻后台,打开应用才开启,关闭之后就退出,干干净净)

了解,之前关注过ace和bwrap技术,当时没时间,结合abl和bwrap还是很牛,感谢,有空看看

Reply View the author