Linux下符号版本原理及实现

一、问题

这个就是在一些使用了共享目标文件的可执行文件编译时环境和运行时环境不同(比方说,编译的时候在一个较高版本的环境中编译的,此时使用的C库版本较新,然后将生成的可执行文件放在一个较低版本的系统中运行,此时可能会提示符号版本错误,从而可执行文件加载失败)而导致的可执行文件夹加载失败,这种问题在网络上就可以搜索到,当然我也遇到过,所以看了看这个问题。

二、为什么要引入符号版本

由于以前对windows下的DLL解除比较多,并且linux下的SO文件和linux的DLL是相同的基本思想,所以用DLL做一个类比。以windows的Visual Studio为例,从vs2003到vs2008之间,随着开发环境的变化,每个版本都会提供对应的C库支持,从而在系统的windows\system32文件夹下有msvcrt.dll、msvcrt20.dll、msvcrt40.dll,也就是它是通过不同的文件名来区分不同的动态库版本,从而达到不同的DLL版本之间的兼容和区分,不同的可执行文件可以根据自己的需要选择早期或者较新的DLL进行运行。

这个的问题就是随着版本的升级,为了保证以前可执行文件的兼容,早期的DLL即使现在没有使用,同样必须在系统中保留,从而系统中的DLL文件将会越来越多。现在如果对版本进行了一个较小的修改,比方说一些外部接口的实现进行了较小的修改,但是为了告诉外界这个接口又进行了升级,此时就没有必要在引入一个新的DLL文件,因为事实上如果添加一个新的文件,这个新文件只是修改了一点点内容,而其它的大部分内容都是些相同的,所以就早成系统存储空间的浪费。而如果使用符号版本,则完全可以用一个新的文件覆盖原始文件,只要这个文件可以提供早期接口的多个实现就可以了。简言之,可以将修改限制在符号版本而不是文件版本。

三、数据结构

关于这个问题,在Glibc的主要维护者Ulrich Drepper的文件中有权威而详细的说明,两篇文档

http://www.akkadia.org/drepper/symbol-versioning

http://www.akkadia.org/drepper/dsohowto.pdf

大致来讲,连接器可以接受一个链接脚本作为输入,可以通过连接器的--version-script参数指定一个版本为文件,一个简单的版本文件的例子为来自连接器手册的例子http://docs.redhat.com/docs/en-US/Red_Hat_Enterprise_Linux/4/html/Using_ld_the_GNU_Linker/version.html

VERS_1.1 {    global:     foo;    local:     old*;     original*;     new*;  };    VERS_1.2 {     foo;  } VERS_1.1;    VERS_2.0 {     foo;bar1; bar2;  } VERS_1.2;

 也就是每个大括号之前的表示是一个版本,也就是这个即将生成的so文件中有这么多个版本,只是说这些版本不是用我们通常所说的数值版本号来区分的,而是用字符串来区分的。具体字符串的内容对符号版本来说没有任何意义,只是为了便于维护。然后内核的global和local是这个版本的符号的可见性,从而对版本富豪进行导出控制。其中一个版本最后的版本在Linux下没有任何意义,同样只是为了提示维护者这些版本之间的继承派生关系(这个关系在soloaris中是有意义的,简单来说,就是新版本的符号默认兼容之前的符号)。这样的每个版本用术语来说就是version node,而整个结构可以认为是一个node tree。可以看到,foo可以在三个版本中出现。

这个脚本有下面的意义:

1、告诉连接器在生成的可执行文件中创建指定个数的符号版本定义(具体意义在后面补充)。也就是这是一个版本容器定义类说明,这里定义了很多的版本,相当于一个容器,只有在这里定义了的版本在源代码里才可以引用(或者说符号停泊、栖息在这些版本符号定义中),否则即使在源代码中通过symver定义一个特殊版本的符号也没有意义。如果源代码中定义了符号版本,这个版本必须在version-script中有定义,否则链接错误。

2、导出控制。可以控制其中的符号是否导出,从而对库文件的接口进行限制。这样便于内部接口的变更、修改等,限制外界对该符号的不规范使用。

3、版本树声明。由于这个在Linux下没有实际意义,只是供脚本维护者用来进行源码级注释。

四、源代码中对符号版本的定义

由于符号版本不是C语言的标准用法,所以它使用了汇编器的一个特殊指示,也就是.symver 指示,而GCC中在C语言的内嵌汇编

__asm__(".symver original_foo,foo@");  __asm__(".symver old_foo,foo@VERS_1.1");  __asm__(".symver old_foo1,foo@VERS_1.2");  __asm__(".symver new_foo,foo@@VERS_2.0");

这个例子中定义了foo的四个版本,其中的symver的第一个参数为源代码中真正定义的实现,而之后的foo则为对外公开的版本,也就是可以有不同版本的符号,可以看到,这个foo已经定义了四个版本,其中@@则表示这个是一个默认版本(简单来说,如果一个可执行文件链接的时候foo还没有任何版本控制,但是在运行时foo已经引入了多个版本,则此时的可执行文件可以选择这个@@表示的默认版本符号)。

这些指示就是告诉汇编器及连接器,这里的old_foo就是foo的VERS_1.1版本,如果在so中定义了old_foo,那么这个old_foo就是这个foo的VERS_1.1版本的实现,如果没有定义,可以通过old_foo来引用这个特殊版本的foo符号的定义。

五、ELF文件的相应结构

为了支持符号版本,ELF引入了三种类型的section,分别为

SHT_GNU_versym

这个节是一个符号版本节,准确的说是动态符号版本说明节,因为它和这个so文件中的所有动态符号一一对应。也就是这个so文件的dynsym节有多少项,这个里就有多少项,它们的关系是一一对应的。如果我们想知道某个动态符号它的版本号是什么,就可以在这个节中找到序号相同的项,看该项的内容就可以了。所以说这个节是对动态符号的一个“描述项”,或者说“属性项”,它和dynsym节通过位置之间的一一对应关系来达到描述的效果。

例如对于libc.so,通过readelf -a 显示的动态符号节为

Symbol table '.dynsym' contains 2320 entries:
   Num:    Value  Size Type    Bind   Vis      Ndx Name
     0: 00000000     0 NOTYPE  LOCAL  DEFAULT  UND 
     1: 00000000     0 NOTYPE  WEAK   DEFAULT  UND _IO_stdin_used
     2: 00000000     0 NOTYPE  WEAK   DEFAULT  UND _dl_starting_up

……

  2318: 002e5a10   326 FUNC    GLOBAL DEFAULT   12 vwarn@@GLIBC_2.0
  2319: 0028b3d0    41 FUNC    WEAK   DEFAULT   12 wcpcpy@@GLIBC_2.0

而对应的符号版本节同样有2320项,从而互相一一对应。

Version symbols section '.gnu.version' contains 2320 entries:
 Addr: 000000000021c8de  Offset: 0x0128de  Link: 4 (.dynsym)
  000:   0 (*local*)       0 (*local*)       0 (*local*)      1b (GLIBC_2.1)  
……

  908:   2 (GLIBC_2.0)     7 (GLIBC_2.2)     2 (GLIBC_2.0)    12 (GLIBC_2.5)  
  90c:   3 (GLIBC_2.1)    19 (GLIBC_PRIVATE)   2 (GLIBC_2.0)     2 (GLIBC_2.0) 

 

可以看到,这个结构中每一项是一个halfword结构,所以它本身应该是一个索引标志,也就是通过它来指向一个结构,而这个结构是一个较大的可共享内容,这就是版本号,那么版本号又是如何定义的。

SHT_GNU_verdef
这个节描述了这个so文件所有定义的版本,也就是刚才我们看到的version-script中描述的所有节点,每个节点对应一个版本。这个verdef节就是当有可执行文件来链接这个so的时候,要从这里所有确定需要的符号版本号(一般通过缺省版本实现)。这里的每一个定义的版本节点通过结构中的index互相区分,其中刚才在symver节中看到的索引就是指向这个index的,例如libc.so中的内容

ersion definition section '.gnu.version_d' contains 26 entries:
  Addr: 0x000000000021db00  Offset: 0x013b00  Link: 5 (.dynstr)
  000000: Rev: 1  Flags: BASE   Index: 1  Cnt: 1  Name: libc.so.6
  0x001c: Rev: 1  Flags: none  Index: 2  Cnt: 1  Name: GLIBC_2.0
  0x0038: Rev: 1  Flags: none  Index: 3  Cnt: 2  Name: GLIBC_2.1

……

  0x0350: Rev: 1  Flags: none  Index: 25  Cnt: 2  Name: GLIBC_PRIVATE
  0x036c: Parent 1: GLIBC_2.11
  0x0374: Rev: 1  Flags: none  Index: 26  Cnt: 1  Name: GCC_3.0

其中的index就是symver中数值指向的所以,例如908的2就对应index为2的向,其名字为GLIBC_2.0。

SHT_GNU_verneed
这个节描述了自己需要的但是没有在自己的so模块中定义的版本符号。同样是libc.so的输出

Version needs section '.gnu.version_r' contains 1 entries:
 Addr: 0x000000000021de90  Offset: 0x013e90  Link: 5 (.dynstr)
  000000: Version: 1  File: ld-linux.so.2  Cnt: 3
  0x0010:   Name: GLIBC_PRIVATE  Flags: none  Version: 29
  0x0020:   Name: GLIBC_2.3  Flags: none  Version: 28
  0x0030:   Name: GLIBC_2.1  Flags: none  Version: 27

可以看到,glibc需要动态连接器ld-linux.so.2中三个符号版本。当然可能是这三个版本中可能任意多个具体的符号。这里的version是和符号定义节中的index连贯在一起的,从而通过symver节的一个数值就可以确定这个位置对应的动态符号是在本模块定义的还是本模块需要引用的

如何找到这些节

首先它们都是一个独立的section,在ELF中有它们每个section的描述信息,从而binutils就可以通过这个节表的遍历找到这些节显示出来。同样是readelf -a的输出

Section Headers:
  [ 6] .gnu.version      VERSYM          0021c8de 0128de 001220 02   A  4   0  2
  [ 7] .gnu.version_d    VERDEF          0021db00 013b00 000390 00   A  5  26  4
  [ 8] .gnu.version_r    VERNEED         0021de90 013e90 000040 00   A  5   1  4

这些就是从节表结构中直接读出的信息。

但是对于动态连接器来说,它这样查抄效率太低,所以在整个系统的DYNAMIC segment中,为这样的每个节都保留了一个tag。这里要说的是DYNAMIC不是一个section,而是一个program header描述,也就是一个在section之上的单位,这个节就是给动态连接器使用的。这个段中的结构同样非常简单,就是一个

/* Dynamic section entry.  */

typedef struct
{
  Elf32_Sword d_tag;   /* Dynamic entry type */
  union
    {
      Elf32_Word d_val;   /* Integer value */
      Elf32_Addr d_ptr;   /* Address value */
    } d_un;
} Elf32_Dyn;

结构,也就是一个<tag,val>的组合,但是由于val是一个联合,所以他可以是一个指针,也可以是一个数值。对于这里说的符号版本DT_VERNEEDNUM tag对应的就是一个数值,表示了版本定义节中有多少项,而对应的DT_VERNEED tag则是一个指针,指向的就是SHT_GNU_verneed节内容在内存中的位置,这也是最为原始的一个数组定界方法,起始地址 + 数组项数。以上定义来自glibc中定义。

六、readelf对这些内容的处理位置

binutils-2.21.1\binutils\readelf.c:process_symbol_table (FILE * file)函数的下面处理,其中一些问题可以直接在其中找到原因,而代码也是描述数据结构之间关系最为直观和精确的语言

 for (i = 0, section = section_headers;
    i < elf_header.e_shnum;
    i++, section++)
 {
   unsigned int si;
   char * strtab = NULL;
   unsigned long int strtab_size = 0;
   Elf_Internal_Sym * symtab;
   Elf_Internal_Sym * psym;

   if ((section->sh_type != SHT_SYMTAB
        && section->sh_type != SHT_DYNSYM)

。这里要说的是,其中我们在动态符号节中看到的@Vers之类的符号并不是在符号表中自带的,而是readelf根据上面说的symver节的属性而动态添加的,具体代码为

  if (ivna.vna_other == vers_data)
       {
         printf ("@%s (%d)",

……

if (psym->st_name != ivda.vda_name)
    printf ((vers_data & VERSYM_HIDDEN)
     ? "@%s" : "@@%s",

这里还有一点,就是其中的VERSYM_HIDDEN属性,binutils对该变量的注释为

/* This flag appears in a Versym structure.  It means that the symbol
   is hidden, and is only visible with an explicit version number.
   This is a GNU extension
.  */

#define VERSYM_HIDDEN  0x8000

反过来,如果一个版本符号没有定义这个属性,那么它就是默认符号,也就是假设一个可执行文件链接so的时候,其中的符号还没有使用符号版本,但是运行时的so中符号加上了版本控制,那么运行时so中没有这个VERSYM_HIDDEN属性的符号就可以被使用而不会有链接错误。这个属性页将会影响symver节的说出,在glibc的输出中

Version symbols section '.gnu.version' contains 2320 entries:
 Addr: 000000000021c8de  Offset: 0x0128de  Link: 4 (.dynsym)

……

  048:   3 (GLIBC_2.1)    15 (GLIBC_2.8)     3 (GLIBC_2.1)     3h(GLIBC_2.1)  
  04c:   dh(GLIBC_2.3)    11 (GLIBC_2.4)     2h(GLIBC_2.0)     2 (GLIBC_2.0) 

其中的h就表示这个符号具有VERSYM_HIDDEN属性,也就是说,具有这个属性的符号只能通过带版本号才能索引到。这个readelf的处理位于process_version_sections函数中

      default:
        nn = printf ("%4x%c", data[cnt + j] & VERSYM_VERSION,
       data[cnt + j] & VERSYM_HIDDEN ? 'h' : ' ');

七、杂项

1、默认情况下链接

使用者一般是不关心符号版本的,而且我们不应该让用户为了so的修改而修改源代码。所以一般so的使用者是没有使用符号版本号的。现在假设一个使用者在链接so中的foo的时候,此时foo还没有提供任何的版本控制,也就是没有版本的符号,此时生成了一个可执行文件ver1.exe,此时引用的是libfoo.so中无符号控制的foo函数;之后libfoo.so升级,它对foo函数添加了符号版本。当ver1.exe在升级后的libfoo.so中运行时,它应该使用的就是刚才说的libfoo.so中提供的默认的foo接口,就是源代码中通过 foo@@verx定义的foo实现。

假设ver1.exe同样没有在源代码中使用版本,但是链接的时候libfoo.so中已经对foo进行了版本控制,则此时生成的ver1.exe中将会同样使用@@verx定义的版本,并且这个verx将会体现到ver1.exe的可执行文件中,在具体的就是说这个可执行文件的verneed节中将会对foo的符号强制要求需要foo的verx版本,并且之后如果这个可执行文件运行时libfoo.so中没有verx版本的foo,则无法启动。

2、特殊情况

不论何种原因,假设一个使用者对某个版本的foo实现特别感兴趣,或者强制要求verx版本的foo实现,而不是让连接时使用的libfoo.so的默认foo版本决定自己引用的版本号。比方说libfoo.so中有ver1,ver2,ver3三个foo的实现,并且ver3是默认符号,但是调用者希望使用ver2这个版本,那么就需要在源代码中同样使用symver定义一个符号的版本,通过引用这个符号来要求特定版本的符号。下面例子来自stackoverflow中:

http://stackoverflow.com/questions/2856438/how-can-i-link-to-a-specific-glibc-version

#include <limits.h> 
#include <stdlib.h> 
#include <stdio.h> 
 
__asm__(".symver realpath,realpath@GLIBC_2.2.5"); 
int main() 

    char* unresolved = "/lib64"
    char  resolved[PATH_MAX+1]; 
 
    if(!realpath(unresolved, resolved)) 
        { return 1; } 
 
    printf("%s\n", resolved); 
 
    return 0

可以看到,通过将realpath定义为特定版本的符号来强制要求特定版本符号而不是连接时libfoo.so中的默认符号版本。

八、简单demo

这是一个简单的demo,可以通过make进行修改、测试验证之前提到的内容。另外,对于ELF的格式,可以结合ELF文件说明,看文件的16进制,因为readelf对输出做了修改,例如其中dynsym节中符号名中间加德@或者是@@,这些并不是动态符号本身的内容,而是readelf结合symver节的内容运行时添加的。

 [tsecer@Harry SymVer]$ cat foo.c
#define VerSym(LocalDef,Exp,Ver) __asm__ (".symver "#LocalDef","#Exp"@"#Ver"\n")
#define VerSym1()

#define DefVerSym(LocalDef,Exp,Ver) __asm__ (".symver "#LocalDef","#Exp"@@"#Ver"\n")
int foodef1(int var)
{
 return var * var;
}
int foodef2(int var,int par)
{
 return var + par;
}
int foodef3(int var)
{
 return var + 2 ;
}
VerSym(foodef1,tsecer,Ver1);
VerSym(foodef2,tsecer,Ver2);
DefVerSym(foodef3,tsecer,Ver3);


[tsecer@Harry SymVer]$ cat main.c
#define VerSym(LocalDef,Exp,Ver) __asm__ (".symver "#LocalDef","#Exp"@"#Ver"\n")
#define VerSym1()
int foodef1(int var);
int foodef2(int var,int par);
VerSym(foodef1,tsecer,Ver1);
VerSym(foodef2,tsecer,Ver2);
int main()
{
 return foodef1(1) + foodef2(3,4);
}

[tsecer@Harry SymVer]$ cat vs
Ver1 {
global:
tsecer;
local:
*;
};
Ver2 {
global:
tsecer;
local:
*;
}Ver1;
Ver3 {
global:
tsecer;
local:
*;
} Ver2;

[tsecer@Harry SymVer]$ cat Makefile 
default:
 gcc -Wl,--version-script=vs foo.c -fPIC -shared -o libfoo.so
 readelf -a libfoo.so
 gcc main.c -L. -lfoo -o main.exe -Wl,--Map,map.txt
 readelf main.exe -a

posted on 2019-03-06 20:41  tsecer  阅读(3171)  评论(0编辑  收藏  举报

导航