静态编译文件是否真的到处可运行

这里只讨论一个问题,基于x86_64的静态编译文件是否真的到处可运行?例如在centos上静态链接一个程序是否在ubuntu、suse等linux发行版上运行毫无障碍,这个时间维度也不能毫无边界的扩散,首要满足的运行条件是内核支持ELF三种格式,讨论一个程序在0.11版内核上能否运行没有啥意义,不是一个时代的东西。可运行包含两个方面,一个是能否跑起来,另外是能否正确运行。

静态链接程序的生成过程

首先从一个最简单的hello world程序来看,这个是没有任何问题,下面来稍微挖掘一下它背后的原因,理解它为什么没有问题。

首先看一下helloworld程序的静态链接过程,打开一些gcc详细输出的开关来观察一下它的链接生成过程。

$cat hello.c
#include <stdio.h>
int main(int argc, char **argv)
{
	printf("hello world\n");
	return 0;
}
$gcc hello.c -o hello -v --static --save-temps
$ls
hello  hello.c  hello.i  hello.o  hello.s

gcc默认的编译过程是在/tmp下保存中间文件,结束之后直接删除中间文件,现在通过--save-temps告诉gcc将它的中间产物文件保存下来,其中hello.i是预编译的文件,hello.s是经过翻译出来的汇编文件,这两个都是可读的文本文件,而从hello.o和最终的hello程序就是带有ELF格式的文件了,前者是ELF重定位文件,后者是可执行文件,通过objdump -d hello.o来看一下它其中的文本段二进制数据在表达什么。
hello asemble
左侧是反汇编的输出,右侧是hello.s的输出。首先他们两个文本段是完全一模一样的,只是hello.s中还有汇编语言的限定语,指示编译器除了生成代码section之外还生成rodata,.note.GNU-stack的section。但是这些还是完全不够的,例如使用的printf究竟是如何实现的,是如何跳转到main位置处的,继续看如何将重定位文件转变成可执行文件的,又做了哪些事。

下面是链接过程的输出,还可以增加-Wl,-debug参数输出更详细的信息,不过现在我们不深入其中的链接过程。

......
COLLECT_GCC_OPTIONS='-o' 'hello' '-v' '-static' '-save-temps' '-mtune=generic' '-march=x86-64'
 /usr/lib/gcc/x86_64-linux-gnu/5/collect2 -plugin /usr/lib/gcc/x86_64-linux-gnu/5/liblto_plugin.so -plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/5/lto-wrapper
  -plugin-opt=-fresolution=hello.res -plugin-opt=-pass-through=-lgcc -plugin-opt=-pass-through=-lgcc_eh -plugin-opt=-pass-through=-lc
   --sysroot=/ --build-id -m elf_x86_64 --hash-style=gnu --as-needed -static -z relro -o hello /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crt1.o 
   /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbeginT.o -L/usr/lib/gcc/x86_64-linux-gnu/5 
   -L/usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu -L/usr/lib/gcc/x86_64-linux-gnu/5/../../../../lib -L/lib/x86_64-linux-gnu -L/lib/../lib
    -L/usr/lib/x86_64-linux-gnu -L/usr/lib/../lib -L/usr/lib/gcc/x86_64-linux-gnu/5/../../.. hello.o --start-group -lgcc -lgcc_eh -lc --end-group 
    /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/gcc/x86_64-linux-gnu/5/../../../x86_64-linux-gnu/crtn.o

我们看到除了hello.o文件之外,又为程序额外增加了一个.o最终组成了一个静态可执行程序ELF,其中crt是c runtime,提供C运行环境。平时我们看到andriod系统中art(android runtime),jvm为java准备运行环境,C这种被认为很贴近计算机的语言竟然也需要运行环境。crt主要包含对C和C++的支持,包括main函数入口之前的引导,提供基层库,系统调用封装等功能。

  • crt1.o:它包含程序的入口函数_start,保存内核传上来的参数,调用__libc_start_main,初始化libc并且调用main函数。
  • crti.o和crtn.o:支持.init.finit段,一个在段的开头,一个在段的结束位置,这样其他的所有relocate文件的段都会汇合到他们两个中间
  • crtbeginT.o和crtend.o:用于实现C++全局构造和析构的目标文件

静态链接程序的启动过程

一个ELF静态链接程序的加载的大致过程,通过execve系统调用陷入到内核,参数包括可执行程序的路径,环境变量,参数。之后经历权限检查和资源准备,search_binary_handler为其匹配解释器,根据文件头部的magic进行匹配到了ELF解释器,如果它不是动态链接的文件,没有存储解释器的信息也就不需要加载解释器。入口地址就是_start函数,也就是crt1.o中的程序,之后拷贝环境变量,辅助变量、参数到栈顶位置,之后设置PC寄存器为_start的加载地址,然后把所有寄存器信息都存储到内核栈的底部。
当系统调用从内核返回到用户空间中时,从内核栈上弹出用户空间上下文信息恢复到寄存器中继续执行,包括两部分:pc指向_start入口地址,sp指向栈顶位置。这部分是没有问题的,系统调用执行时陷入内核保存用户空间上下文到内核栈上,返回到用户空间时从内核栈中恢复上下文信息,这部分是内核的系统调用处理完成的。

返回到用户空间时,pc指向_start中开始顺序执行程序,而_start中负责初始化运行环境,包括从栈上获取到argc和argv,初始化其他信息,见linux程序启动之ELF,最终跳转到main函数中。对于静态链接程序不需要依赖其他动态库,也不需要进行运行时动态符号链接,一切如我们所设想的运行。

普通的静态链接程序不会依赖动态库,所需的代码在静态链接时基本是全搞定了,所以一个helloworld的静态链接程序运行是没有问题的。

dlopen

下面我们在程序中添加一个gethostbyname接口调用

int main(int arg, char **argv)
{
	printf("hello world");
	gethostbyname("lwn.net");
	return 0;
}

在链接过程中,但是collect2在链接的时候竟然爆出了warning信息,有点懵逼,我在哪,我干了什么??

/tmp/ccVw0xvJ.o: In function `main':
hello.c:(.text+0x28): warning: Using 'gethostbyname' in statically linked applications requires at runtime the shared libraries from the glibc version used for linking

我们翻开glibc的实现来看一下它究竟有什么特殊的,看到gethostbyname的实现究竟有什么特殊的

inet/gethstbynm.c
#define LOOKUP_TYPE struct hostent 
#define FUNCTION_NAME   gethostbyname                           
#define DATABASE_NAME   hosts
#define ADD_PARAMS  const char *name
......
#include <nss/getXXbyYY.c>
=================================================================================
nss/getXXbyYY.c
LOOKUP_TYPE * 
FUNCTION_NAME (ADD_PARAMS)
{
...
     (INTERNAL (REENTRANT_NAME) (ADD_VARIABLES, &resbuf, buffer,                
                    buffer_size, &result H_ERRNO_VAR)                                                      
......
=================================================================================
nss/getXXbyYY_r.c
int INTERNAL (REENTRANT_NAME) (ADD_PARAMS, LOOKUP_TYPE *resbuf, char *buffer,       
               size_t buflen, LOOKUP_TYPE **result H_ERRNO_PARM                                                                                
               EXTRA_PARAMS)                                                       
{
      no_more = DB_LOOKUP_FCT (&nip, REENTRANT_NAME_STRING,                                                                                    
                   REENTRANT2_NAME_STRING, &fct.ptr); 
      status = DL_CALL_FCT (fct.l, (ADD_VARIABLES, resbuf, buffer, buflen,      
                    &errno H_ERRNO_VAR EXTRA_VARIABLES)); 

中间是各种宏层层包裹,就不再一层层拆开看了,总体的意思是,这个接口底层有几种插件,也就是/etc/nsswitch.conf中的配置,根据配置的不同优先级选择地址翻译方式。

hosts:          files mdns4_minimal [NOTFOUND=return] dns myhostname

最终它会根据配置动态加载libnss-dns.so libnss-myhostname.so libnss-mdns4_minimal.so等库

328	nss_load_library (service_user *ni){
	...
344   if (ni->library->lib_handle == NULL)
345     {
346       /* Load the shared library.  */
347       size_t shlen = (7 + strlen (ni->name) + 3
348               + strlen (__nss_shlib_revision) + 1);
349       int saved_errno = errno;
350       char shlib_name[shlen];
351
352       /* Construct shared object name.  */
353       __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
354                           "libnss_"),
355                     ni->name),
356               ".so"),
357         __nss_shlib_revision);
358
359       ni->library->lib_handle = __libc_dlopen (shlib_name);

我们的印象中glibc下面就是内核,只有别人依赖glibc,不会有glibc依赖其他库的方式。而glibc它自己用code告诉,you are wrong!!!
glibc在实际运行的时候根据/etc/nsswitch.conf来通过dlopen的方式加载对应的动态库,当动态库不存在时,这个函数的功能就不正常了。

glibc中提供的一些接口调用会使用nsswitch(Name Service Switch)方式动态选择插件库,包括gethostbyname,getpwent,getservent等,通过man 5 nsswitch可以看到更详细的信息。

不仅局限在glibc中,当程序静态链接一些静态库时,静态库中使用dlopen方式依赖其他动态库,或者程序自身dlopen造成的依赖关系都可能会引起运行时的功能异常或者程序异常退出。

reflink:https://sourceware.org/glibc/wiki/FAQ

对于glibc的nss,有个workaround的方案可以解决nss动态库依赖问题,但是不太被建议使用的。编译glibc时使用--enable-static-nss,并且显式地链接其他nss的动态库,这样所有的依赖的module都编译进去了,运行时通过/etc/nsswitch.conf来控制。

  gcc -static test-netdb.c -o test-netdb \
    -Wl,--start-group -lc -lnss_files -lnss_dns -lresolv -Wl,--end-group

社区不推荐的理由:静态链接违反了nss设计的初衷,根据配置文件动态加载库,这样不会加载不需要的函数。如果/etc/nsswitch.conf配置中使用的方式,例如hosts:myhostname,但是上面并没有链接该库而且也不会使用dlopen方式打开库。所以在这种情况下程序的nss功能和系统的nss功能可能得出不一致的结果,所以不太推荐。

系统调用对内核版本的要求

在编译glibc时,通常都是.configure && make,而在配置阶段通常会有这样的一条信息,glibc对内核版本有最小的限制要求,也就是兼容的最小内核版本是有要求的。

configure:69: checking for kernel header at least 3.2.0  

glibc也是在一直发展的,随着内核的不断开发,它也会增加功能来利用新的内核系统调用。例如glibc2.4兼容的内核最小版本为kernel 2.6.0,pthread的实现默认使用NPTL配置,如果使用早于2.6.0的内核它可能会工作不正常。我个人认为完全向后兼容性肯定是可以做到的,但是代价就是历史的包袱越来越大,一套开发了10年的代码可以想象一下会烂成什么样。为了兼容非常早的内核完全没有意义,所以在某个glibc版本规定了兼容的kernel最小版本,这样可以摆脱兼容代码问题。

在配置阶段脚本探测系统,内核,平台,编译器等的类型和版本信息,根据这些信息声明宏,之后根据生成的宏进行编译选择代码,最后生成的glibc是和host机器密切相关的。主要收到内核的影响有两方面:系统调用和头文件结构和操作定义。
glibc在编译时就确定了很多东西,例如平台相关的头文件,内核版本相关的头文件,库接口具体的实现方式。之后在此基础上静态链接的程序使用的就是这个库中的内容。当迁移到不同的机器上和内核上时,运行环境的差异会影响程序的运行。

uapi的差异

https://sourceware.org/glibc/wiki/FAQ#What_version_of_the_Linux_kernel_headers_should_be_used.3F

linux中有非常多的场景中应用需要访问内核的定义,这些宏定义或者结构分别是在linux发行版中和source,uapi是在3.5之后分离出来的专门提供给应用层使用的,在内核source中位于include/uapi/./arch/xxxx/include/uapi,在发行版头中文件安装位于/usr/include/linux/,在安装不同版本内核的时候需要将对应内核的头文件安装到系统中。

目前机器中安装的内核是4.18,现在某个应用使用drm_syncobj_create对象来进行同步(这里只是举个例子),然后通过静态编译,拿到低版本3.10的centos上运行,它根本理解不了这个结构体的内容,如何解析执行,最后自然是出错的。

我们看一下这个结构的定义是什么时候创建出来的:git blame include/uapi/drm/drm.h

。。。
e9083420b include/uapi/drm/drm.h (Dave Airlie                 2017-04-04 13:26:24 +1000  719) struct drm_syncobj_create {
e9083420b include/uapi/drm/drm.h (Dave Airlie                 2017-04-04 13:26:24 +1000  720)   __u32 handle;
1fc08218e include/uapi/drm/drm.h (Jason Ekstrand              2017-08-25 10:52:25 -0700  721) #define DRM_SYNCOBJ_CREATE_SIGNALED (1 << 0)
e9083420b include/uapi/drm/drm.h (Dave Airlie                 2017-04-04 13:26:24 +1000  722)   __u32 flags;
e9083420b include/uapi/drm/drm.h (Dave Airlie                 2017-04-04 13:26:24 +1000  723) };
。。。

时间还挺新,2017年提交的,下面我们看看具体是那个mainline版本中合并的,通过下面我们发现是4.13内核版本中才有的,3.10的内核根本不知道这个对象究竟是什么东西,它理解不了这个来自未来的对象。

git name-rev --tags e9083420b
e9083420b tags/v4.13-rc1~45^2~27

内核开发人员通常承诺不会破坏较新内核中的遗留功能,尽量保持向后兼容性,即不更改这些宏和结构的定义,只在此基础上进行增加,这样当不支持某种操作能及时返回error信息。

系统调用在不同内核版本上的支持

典型的调用是fexecve,在glibc 2.3.2开始被支持,此时它是通过glibc封装的execve,通过/proc/self/fd/的链接来获取到文件,之后通过execve发起系统调用。而在kernel 3.19之后新增系统调用execveat并且从glibc 2.27开始支持使用这个系统调用,当内核大于3.19时直接使用这个系统调用而不必辗转使用/proc/self/fd/获取文件路径。而在glibc编译时根据内核版本就已经确定了fexecve内的实现方式,假如host机器内核>3.19,编译一个静态链接程序(里面有fexecve调用),到一个内核<3.19的机器上运行,该调用会返回-ENOSYS错误。

finit_module是在3.8之后才被支持,之前只支持init_module,使用finit_module调用在3.8之前的内核版本上运行铁定运行错误。当然这个比较特殊,glibc没有对其进行封装,直接使用的syscall发起的系统调用。

对于glibc和内核的兼容性问题,需要大量的防御性编程,即大量的错误检查和异常处理才能保证系统的正常运行。

结论

动态链接程序在启动时就会根据动态段中的DT_NEED来找依赖库,如果找不到依赖库即刻会报错并退出程序,好处当然是动态库编译时是满足当前硬件和软件配置的,一般不会造成动态运行时的问题。
而静态编译确实使得程序保持独立性,不会出现依赖库问题,但是静态编译并不能真的一切功能运行OK,例如上面提到的glibc的nsswitch需要dlopen其他库,还是会依赖其他动态库;另外不同内核版本uapi的兼容性问题和系统调用接口支持也可能使一个程序在不同的系统上运行异常。
所以静态编译能够解决库的依赖关系,但它能够到处可运行是还是意见比较困难的事,需要仔细处理所有的库调用和系统调用返回值。并且尤其注意dlopen的调用,不管是你自己的代码还是你要链接的库中如果存在dlopen依赖其他动态库,仍然需要小心处理。

未知的问题仍然有很多,未知才精彩。。。

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页