一、起因
因为重新安装了一台电脑,要把工作环境都得安装好才行。所以,就按照原来的安装文档,一步步的把相关的框架、依赖库及软件等一步步的安装成功。为了验证安装的结果,把一个在原来机器上编译好的可执行文件直接拷贝到了这台新机器上。满心的以为,相同的环境,启动还不是直接就运行出结果来。
但结果却出乎意料,程序直接报了一个找不到某某动态库(.so),有点奇怪。怀疑是不是依赖库等没安装好,就把源码拷贝过来重新编译,结果编译成功,再运行编译出来的结果,是好的。这种现象以前是经常发现的,虽然不确定一定是哪种原因,但大概范围是确定的,不外乎那几种情况。正如这些原因也是开发者,特别是初学者经常遇到的。就因着这件事情仔细分析一下。
二、原因
针对这种情况,从理论知识和经验并结合此次运行的环境(表面看完全一致),最可能的有以下几种原因:
这种是很多人最容易忽略的低级错误,即库不存在,有可能是未安装,也有可能是未拷贝等等。一定要谨记排除这种低级错误
这种是非常常见的一种问题,有些库或框架的安装没有版本名称,直接就安装上了。但实际依赖的版本库有所变化。特别是在一些高版本的系统上,最可能出现这种情况
这种基本也属于一种低级的错误,安装库时因为各种原因未能安装到指定的路径中,导致无法找到依赖相关。但这种如果是较大的框架都会自动避免,因为它们需要设置环境变量
那么,如果排除上面的这三种原因,还有其它的原因么?答案是肯定的。他们更不容易被发现。主要包括:
这对于大多数人来说,可能认为是一种非常容易辨别的情况。Linux的程序肯定不能在Windows上跑啊。但更细节一些呢?比如32位系统和64位系统呢?比如不同的CPU架构,甚至是相同的CPU架构但不同版本的CPU呢(它们的指令集有可能有细节的不同)?很多开发者可能就忽略过去了。其实还有更细节的,比如Ubuntu和Debain系统,在一些特定的情况下,仍然是无法保证库的直接依赖调用
这个链接器的不同,有版本的不同,也有路径不同,更有可能默认使用的根本不是同一个链接器。这就很可能在程序启动时,无法通过链接器链接相关的依赖库,导致启动错误
即使在环境完全一样的情况下,由于编译器的版本或编译时使用了不同的优化选项,也可能导致编译出来的二进制可执行文件有一些细节的不同。在使用不同的CPU的情况下,有可能导致问题的发生。另外一种更隐藏的情况,程序中使用了延迟加载的库,不到应用时是不会报任何错误的
那么,是不是掌握了上面的几种情况,就可以很好的处理这种现象呢?千万不要大意,还有问题需要处理。
三、分析
为了分析这种现象,再次把可执行文件拷贝到目标机器上,使用ldd命令查看其依赖项:
ldd exe_old #原机器拷贝
ldd exe_new #编译后的新文件
注:可以参考前面的文章,常用的Linux命令如nm,strace等等
此时发现原机器拷贝确实是缺少一个可执行的库,而且其依赖的版本和编译后的新的可执行文件的版本不一样。这就发现了,这个问题确实是上面的提到的最可能的第二种情况。但是不是这就是问题的终结呢?远远不是。
这次解决问题的过程是把源文件拷贝过来后进行了重新编译,再次执行编译出来的可执行文件成功。那么,有没有注意一个细节,编译时依赖的库和运行时依赖的库有什么不同?或者说它们在这两种情况下,需要的有什么不同?
从整体上来看,编译期和运行时对于相同的库,依赖的本质不同在于,前者依赖的是库提供的API接口,后者依赖的是二进制实现的运行时API接口。这也是前面经常提到的二进制兼容的一个重要原因。下面进行一下说明:
编译时依赖的是头文件、静态库或动态库的名字。特别是动态库的名字,往往只是一个软链接,后面可根据不同的情况来链接真实的so的版本,这也是最容易让普通开发者踩坑的地方。如果后面链接的真实的动态库的版本不同,则编译可能会通过,但运行时可能就无法使用了
编译期依赖的是库的版本名称即动态库的名称(so的名称,可能是一个软链接),而运行时必须保证是相同的二进制结构,即真正的加载库(so的真正的文件名称),所以库的版本不同,就意味着二进制的不兼容。
编译期重点是检查API的语法层面是否和头文件匹配,而运行时则重点检查二进制的兼容如内存布局、调用约定等等。典型的就是结构体的对齐、相同类型的内存占用大小等等。一旦发现有所不同,运行就可能出现崩溃
编译时可能让-I(头文件路径)和-L(库文件路径)指定,或者在其它的工具中使用配置变量进行指定。但它只限于编译阶段。到了运行时阶段,它依赖于环境变量如LD_LIBRARY_PATH等或配置文件/etc/ld.so.conf。也有可能是一些硬编码,这都是允许的
编译时要求对依赖的所有的库的头文件和库都要显式的发现和指定,否则会报未定义引用等问题。而运行时,则只需要指定相关的依赖库,它会自动加载自己依赖的库即自动依赖查找。象前面提到的那个音频库无法发声的原因就是如此
四、处理
上面这个看似简单的问题,其实可以引出一大套的原理和技术。只说明一件事,完全相同的环境,不是简单的看上去相同。它不但依赖于表面的系统、平台以及各种软件环境,更有可能因为硬件的细微区别导致一些异常。
处理这种问题的方法,传统的就是使用安装、配置尽量相同的环境(包括软硬件),重新编译处理。但这种方式不但麻烦而且细节需要注意的地方特别多。尤其是一些生产环境往往无法达到开发环境的需求(比如电力、军工等等),这就导致项目的风险急剧增加。
从目前来看,在开发自己的环境中,这大多不是问题,发现问题(特别是借助AI)解决问题就可以了。但在生产上,则由于成本的限制,会有一些比较成熟的解决方案。
有人会问,要想实现所谓“完全相同的环境”有没有办法呢?有,而且还相当简单。Linux中提供了关的命令,即让两张硬盘可以互相刻录,把一张盘上的环境完整的刻录到另外一张盘上,实现真正意义上的完全相同。毕竟是克隆出来的么。在这种情况下,就不用考虑环境是不是有细节不同了
另外一种,就是使用类似Docker这种容器(包括虚拟机或相关的虚拟技术等等),它其实是从软件上提供了一种类似完全刻录的机制。这也是为什么现在都愿意使用云的一个重要原因,避免了大量的环境安装部署的复杂度。
五、总结
大家一定要善于从一个点展开到一系列的技术树上,而不能把目光局限于当前的问题。深挖问题的背后的现象,从一到三,从三到更多。当开发者具备了这种能力,说明你的技术体系已经开始自然的生长起来。加油,与诸君一起!


