linux动态库加载的秘密

摘自http://gotowqj.iteye.com/blog/1926734

摘自http://www.360doc.com/content/14/0313/13/12747488_360246417.shtml

 

linux 下有动态库和静态库,动态库以.so为扩展名,静态库以.a为扩展名。二者都使用广泛。本文主要讲动态库方面知识。

   
   基本上每一个linux 程序都至少会有一个动态库,查看某个程序使用了那些动态库,使用ldd命令查看 
  1. # ldd /bin/ls
  2. linux-vdso.so.1 => (0x00007fff597ff000)
  3. libselinux.so.1 => /lib64/libselinux.so.1 (0x00000036c2e00000)
  4. librt.so.1 => /lib64/librt.so.1 (0x00000036c2200000)
  5. libcap.so.2 => /lib64/libcap.so.2 (0x00000036c4a00000)
  6. libacl.so.1 => /lib64/libacl.so.1 (0x00000036d0600000)
  7. libc.so.6 => /lib64/libc.so.6 (0x00000036c1200000)
  8. libdl.so.2 => /lib64/libdl.so.2 (0x00000036c1600000)
  9. /lib64/ld-linux-x86-64.so.2 (0x00000036c0e00000)
  10. libpthread.so.0 => /lib64/libpthread.so.0 (0x00000036c1a00000)
  11. libattr.so.1 => /lib64/libattr.so.1 (0x00000036cf600000)

   这么多so,是的。使用ldd显示的so,并不是所有so都是需要使用的,下面举个例子

main.cpp
  1. #include <stdio.h>
  2. #include <iostream>
  3. #include <string>
  4. using namespace std;
  5. int main ()
  6. {
  7.    cout << "test" << endl;
  8.    return 0;
  9. }
   使用缺省参数编译结果
  1. # g++ -o demo main.cpp
  2. # ldd demo
  3.     linux-vdso.so.1 => (0x00007fffcd1ff000)
  4.         libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x00007f4d02f69000)
  5.         libm.so.6 => /lib64/libm.so.6 (0x00000036c1e00000)
  6.         libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00000036c7e00000)
  7.         libc.so.6 => /lib64/libc.so.6 (0x00000036c1200000)
  8.         /lib64/ld-linux-x86-64.so.2 (0x00000036c0e00000)
   如果我链接一些so,但是程序并不用到这些so,又是什么情况呢,下面我加入链接压缩库,数学库,线程库
  1. # g++ -o demo -lz -lm -lrt main.cpp
  2. # ldd demo
  3.         linux-vdso.so.1 => (0x00007fff0f7fc000)
  4.         libz.so.1 => /lib64/libz.so.1 (0x00000036c2600000)
  5.         librt.so.1 => /lib64/librt.so.1 (0x00000036c2200000)
  6.         libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x00007ff6ab70d000)
  7.         libm.so.6 => /lib64/libm.so.6 (0x00000036c1e00000)
  8.         libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00000036c7e00000)
  9.         libc.so.6 => /lib64/libc.so.6 (0x00000036c1200000)
  10.         libpthread.so.0 => /lib64/libpthread.so.0 (0x00000036c1a00000)
  11.         /lib64/ld-linux-x86-64.so.2 (0x00000036c0e00000)

  看看,虽然没有用到,但是一样有链接进来,那看看程序启动时候有没有去加载它们呢

  1. # strace ./demo
  2.     execve("./demo", ["./demo"], [/* 30 vars */]) = 0
  3.     ... = 0
  4.     open("/lib64/libz.so.1", O_RDONLY) = 3
  5.     ...
  6.     close(3) = 0
  7.     open("/lib64/librt.so.1", O_RDONLY) = 3
  8.     ...
  9.     close(3) = 0
  10.     open("/usr/lib64/libstdc++.so.6", O_RDONLY) = 3
  11.     ...
  12.     close(3) = 0
  13.     open("/lib64/libm.so.6", O_RDONLY) = 3
  14.     ...
  15.     close(3) = 0
  16.     open("/lib64/libgcc_s.so.1", O_RDONLY) = 3
  17.     ...
  18.     close(3) = 0
  19.     open("/lib64/libc.so.6", O_RDONLY) = 3
  20.     ...
  21.     close(3) = 0
  22.     open("/lib64/libpthread.so.0", O_RDONLY) = 3
  23.     ...
  24.     close(3) = 0
  25.     ...
  看,有加载,所以必定会影响进程启动速度,所以我们最后不要把无用的so编译进来,这里会有什么影响呢?
   大家知不知道linux从程序(program或对象)变成进程(process或进程),要经过哪些步骤呢,这里如果详细的说,估计要另开一篇文章。简单的说分三步:
    1、fork进程,在内核创建进程相关内核项,加载进程可执行文件;
    2、查找依赖的so,一一加载映射虚拟地址
    3、初始化程序变量。
  可以看到,第二步中dll依赖越多,进程启动越慢,并且发布程序的时候,这些链接但没有使用的so,同样要一起跟着发布,否则进程启动时候,会失败,找不到对应的so。所以我们不能像上面那样,把一些毫无意义的so链接进来,浪费资源。但是开发人员写makefile 一般有没有那么细心,图省事方便,那么有什么好的办法呢。继续看下去,下面会给你解决方法。
  先使用 ldd -u demo 查看不需要链接的so,看下面,一面了然,无用的so全部暴露出来了吧
  1. # ldd -u demo
  2. Unused direct dependencies:
  3.         /lib64/libz.so.1
  4.         /lib64/librt.so.1
  5.         /lib64/libm.so.6
  6.         /lib64/libgcc_s.so.1
  使用 -Wl,--as-needed 编译选项
  1. # g++ -Wl,--as-needed -o demo -lz -lm -lrt main.cpp
  2. # ldd demo
  3.         linux-vdso.so.1 => (0x00007fffebfff000)
  4.         libstdc++.so.6 => /usr/lib64/libstdc++.so.6 (0x00007ff665c05000)
  5.         libc.so.6 => /lib64/libc.so.6 (0x00000036c1200000)
  6.         libm.so.6 => /lib64/libm.so.6 (0x00000036c1e00000)
  7.         /lib64/ld-linux-x86-64.so.2 (0x00000036c0e00000)
  8.         libgcc_s.so.1 => /lib64/libgcc_s.so.1 (0x00000036c7e00000)
  9. # ldd -u demo
  10. Unused direct dependencies:

 

 

我们知道linux链接so有两种途径:显示和隐式。所谓显示就是程序主动调用dlopen打开相关so;这里需要补充的是,如果使用显示链接,上篇文章讨论的那些问题都不存在。首先,dlopen的so使用ldd是查看不到的。其次,使用dlopen打开的so并不是在进程启动时候加载映射的,而是当进程运行到调用dlopen代码地方才加载该so,也就是说,如果每个进程显示链接a.so;但是如果发布该程序时候忘记附带发布该a.so,程序仍然能够正常启动,甚至如果运行逻辑没有触发运行到调用dlopen函数代码地方。该程序还能正常运行,即使没有a.so.

 

  既然显示加载这么多优点,那么为什么实际生产中很少码农使用它呢, 主要原因还是起使用不是很方便,需要开发人员多写不少代码。所以不被大多数码农使用,还有一个重要原因应该是能提前发现错误,在部署的时候就能发现缺少哪些so,而不是等到实际上限运行的时候才发现缺东少西。

 

  下面举个工作中最常碰到的问题,来引申出本篇内容吧。

写一个最简单的so, tmp.cpp

1.    int test()

2.    {

3.      return 20;

4.    }

  编译=>链接=》运行, 下面main.cpp 内容请参见上一篇文章。

[stevenrao]$ g++ -fPIC -c tmp.cpp

[stevenrao]$ g++ -shared -o libtmp.so tmp.o

[stevenrao]$ mv libtmp.so /tmp/

[stevenrao]$ g++ -o demo -L/tmp -ltmp main.cpp

[stevenrao]$ ./demo

./demo: error while loading shared libraries: libtmp.so: cannot open shared object file: No such file or directory

 

 


 

 

[stevenrao]$ g++ -fPIC -c tmp.cpp

[stevenrao]$ g++ -shared -o libtmp.so tmp.o

[stevenrao]$ mv libtmp.so /tmp/

[stevenrao]$ g++ -o demo -L/tmp -ltmp main.cpp

[stevenrao]$ ./demo

./demo: error while loading shared libraries: libtmp.so: cannot open shared object file: No such file or directory

[stevenrao]$ ldd demo

linux-vdso.so.1 => (0x00007fff7fdc1000)

        libtmp.so => not found

 

[stevenrao]$ ldd demo

linux-vdso.so.1 =>  (0x00007fff7fdc1000)

        libtmp.so => not found

   这个错误是最常见的错误了。运行程序的时候找不到依赖的so。一般人使用方法是修改LD_LIBRARY_PATH这个环境变量

   export LD_LIBRARY_PATH=/tmp

[stevenrao]$ ./demo

test

   这样就OK了, 不过这样export 只对当前shell有效,当另开一个shell时候,又要重新设置。可以把export LD_LIBRARY_PATH=/tmp 语句写到 ~/.bashrc中,这样就对当前用户有效了,写到/etc/bashrc中就对所有用户有效了。

   前面链接时候使用 -L/tmp/ -ltmp 是一种设置相对路径方法,还有一种绝对路径链接方法。

[stevenrao]$ g++ -o demo  /tmp/libtmp.so main.cpp

[stevenrao]$ ./demo

  test

[stevenrao]$ ldd demo

        linux-vdso.so.1 =>  (0x00007fff083ff000)

        /tmp/libtmp.so (0x00007f53ed30f000) 

绝对路径虽然申请设置环境变量步骤,但是缺陷也是致命的,这个so必须放在绝对路径下,不能放到其他地方,这样给部署带来很大麻烦。所以应该禁止使用绝对路径链接so。

   

   搜索路径分两种,一种是链接时候的搜索路径,一种是运行时期的搜索路径。像前面提到的 -L/tmp/ 是属于链接时期的搜索路径,即给ld程序提供的编译链接时候寻找动态库路径;而 LD_LIBRARY_PATH则既属于链接期搜索路径,又属于运行时期的搜索路径。

   

   这里需要介绍链-rpath链接选项,它是指定运行时候都使用的搜索路径。聪明的同学马上就想到,运行时搜索路径,那它记录在哪儿呢。也像. LD_LIBRARY_PATH那样,每部署一台机器就需要配一下吗。呵呵,不需要..,因为它已经被硬编码到可执行文件内部了。看看下面演示

[stevenrao] $ g++ -o demo -L /tmp/ -ltmp main.cpp

[stevenrao] $ ./demo

./demo: error while loading shared libraries: libtmp.so: cannot open shared object file: No such file or directory

[stevenrao] $ g++ -o demo -Wl,-rpath /tmp/ -L/tmp/ -ltmp main.cpp

[stevenrao] $ ./demo

test

[stevenrao] $ readelf -d demo

Dynamic section at offset 0xc58 contains 26 entries:

  Tag        Type                         Name/Value

 0x0000000000000001 (NEEDED)             Shared library: [libtmp.so]

 0x0000000000000001 (NEEDED)             Shared library: [libstdc++.so.6]

 0x0000000000000001 (NEEDED)             Shared library: [libm.so.6]

 0x0000000000000001 (NEEDED)             Shared library: [libgcc_s.so.1]

 0x0000000000000001 (NEEDED)             Shared library: [libc.so.6]

 0x000000000000000f (RPATH)              Library rpath: [/tmp/]

 0x000000000000001d (RUNPATH)            Library runpath: [/tmp/]

   看看是吧,编译到elf文件内部了,路径和程序深深的耦合到一起

继续上一篇《 linux下so动态库一些不为人知的秘密(中) 》介绍so搜索路径,还有一个类似于-path,叫LD_RUN_PATH环境变量, 它也是把路径编译进可执行文件内,不同的是它只设置RPATH。

 [stevenrao] g++ -o demo -L /tmp/  -ltmp main.cpp
 [stevenrao] readelf -d demo
 Dynamic section at offset 0xb98 contains 25 entries:
  Tag        Type                         Name/Value
 0x0000000000000001 (NEEDED)             Shared library: [libtmp.so]
 ....
 0x000000000000000f (RPATH)              Library rpath: [/tmp/]
 
  另外还可以通过配置/etc/ld.so.conf,在其中加入一行
  /tmp/
  这个配置项也是只对运行期有效,并且是全局用户都生效,需要root权限修改,修改完后需要使用命令ldconfig 将 /etc/ld.so.conf 加载到ld.so.cache中,避免重启系统就可以立即生效。
  除了前面介绍的那些搜索路径外,还有缺省搜索路径/usr/lib/ /lib/ 目录,可以通过-z nodefaultlib编译选项禁止搜索缺省路径。
  [stevenrao] $ g++ -o demo -z nodefaultlib  -L/tmp -ltmp main.cpp
  [stevenrao] $  ./demo
   ./demo: error while loading shared libraries: libstdc++.so.6: cannot open shared object file
 
  这么多搜索路径,他们有个先后顺序如下
  1、RUMPATH 优先级最高
  2、RPATH   其次
  3、LD_LIBRARY_PATH
  4、/etc/ld.so.cache
  5、/usr/lib/ /lib/
 
  查看一个程序搜索其各个动态库另一个简单的办法是使用 LD_DEBUG这个环境变量;
  [stevenrao] $ export LD_DEBUG=libs
  [stevenrao] $ ./demo
 
 
 
 
 

链接动态库

如何程序在连接时使用了共享库,就必须在运行的时候能够找到共享库的位置。linux的可执行程序在执行的时候默认是先搜索/lib和/usr/lib这两个目录,然后按照/etc/ld.so.conf里面的配置搜索绝对路径。同时,Linux也提供了环境变量LDLIBRARYPATH供用户选择使用,用户可以通过设定它来查找除默认路径之外的其他路径,如查找/work/lib路径,你可以在/etc/rc.d/rc.local或其他系统启动后即可执行到的脚本添加如下语句:LDLIBRARYPATH =/work/lib:$(LDLIBRARYPATH)。并且LDLIBRARYPATH路径优先于系统默认路径之前查找(详细参考《使用LDLIBRARYPATH》)。

不过LDLIBRARYPATH的设定作用是全局的,过多的使用可能会影响到其他应用程序的运行,所以多用在调试。(LDLIBRARYPATH的缺陷和使用准则,可以参考《Why LDLIBRARYPATH is bad》 )。通常情况下推荐还是使用gcc的-R或-rpath选项来在编译时就指定库的查找路径,并且该库的路径信息保存在可执行文件中,运行时它会直接到该路径查找库,避免了使用LDLIBRARYPATH环境变量查找。

链接选项和路径

现代连接器在处理动态库时将链接时路径(Link-time path)和运行时路径(Run-time path)分开,用户可以通过-L指定连接时库的路径,通过-R(或-rpath)指定程序运行时库的路径,大大提高了库应用的灵活性。比如我们做嵌入式移植时#arm-linux-gcc $(CFLAGS) –o target –L/work/lib/zlib/ -llibz-1.2.3 (work/lib/zlib下是交叉编译好的zlib库),将target编译好后我们只要把zlib库拷贝到开发板的系统默认路径下即可。或者通过-rpath(或-R )、LDLIBRARYPATH指定查找路径。

链接器ld的选项有 -L,-rpath 和 -rpath-link,看了下 man ld,大致是这个意思:

-L: “链接”的时候,去找的目录,也就是所有的 -lFOO 选项里的库,都会先从 -L 指定的目录去找,然后是默认的地方。编译时的-L选项并不影响环境变量LDLIBRARYPATH,-L只是指定了程序编译连接时库的路径,并不影响程序执行时库的路径,系统还是会到默认路径下查找该程序所需要的库,如果找不到,还是会报错,类似cannot open shared object file。

-rpath-link:这个也是用于“链接”的时候的,例如你显示指定的需要 FOO.so,但是 FOO.so 本身是需要 BAR.so 的,后者你并没有指定,而是 FOO.so 引用到它,这个时候,会先从 -rpath-link 给的路径里找。

-rpath: “运行”的时候,去找的目录。运行的时候,要找 .so 文件,会从这个选项里指定的地方去找。对于交叉编译,交叉编译链接器需已经配置 --with-sysroot 选项才能起作用。也就是说,-rpath指定的路径会被记录在生成的可执行程序中,用于运行时查找需要加载的动态库。-rpath-link 则只用于链接时查找。

链接搜索顺序

直接man ld。The linker uses the following search paths to locate required shared libraries:

       1.  Any directories specified by -rpath-link options.
       2.  Any directories specified by -rpath options.  The difference between -rpath and -rpath-link is that directories specified by -rpath options are included in the executable and used at runtime, whereas the -rpath-link option is only effective at link time. Searching -rpath in this way is only supported by native linkers and cross linkers which have been configured with the --with-sysroot option.
       3.  On an ELF system, for native linkers, if the -rpath and -rpath-link options were not used, search the contents of the environment variable "LD_RUN_PATH".
       4.  On SunOS, if the -rpath option was not used, search any directories specified using -L options.
       5.  For a native linker, the search the contents of the environment variable "LD_LIBRARY_PATH".
       6.  For a native ELF linker, the directories in "DT_RUNPATH" or "DT_RPATH" of a shared library are searched for shared libraries needed by it. The "DT_RPATH" entries are ignored if "DT_RUNPATH" entries exist.
       7.  The default directories, normally /lib and /usr/lib.
       8.  For a native linker on an ELF system, if the file /etc/ld.so.conf exists, the list of directories found in that file.
       If the required shared library is not found, the linker will issue a warning and continue with the link.

gcc和链接选项的使用

在gcc中使用ld链接选项时,需要在选项前面加上前缀-Wl(是字母l,不是1,我曾多次弄错),以区别不是编译器的选项。 if the linker is being invoked indirectly, via a compiler driver (e.g. gcc) then all the linker command line options should be prefixed by -Wl, (or whatever is appropriate for the particular compiler driver) like this:

1 gcc -Wl,--start-group foo.o bar.o -Wl,--end-group

This is important, because otherwise the compiler driver program may silently drop the linker options, resulting in a bad link.

用例子说话

 

二进制

对应源码

有一个程序

a.out

main.c

需要加载插件A

libA.so

liba.c

A需要另一个动态库

libB.so

libB1.c 或 libB2.c

本文的关注点就是:到底是哪一个libB.so被加载

目录结构:

/home/debao/ttt/a.out /home/debao/ttt/libA.so /home/debao/ttt/libB.so /usr/lib/libB.so

具体源码

  • main.c ==> ./a.out

 

#include <stdio.h>
#include <dlfcn.h>
typedef int (*funcA)(int, int);
int main()
{
    void * plugin = dlopen("./libA.so", RTLD_LAZY);
    funcA f = (funcA)dlsym(plugin, "funcA");
    printf("main: %d\n", f(3,4));
    return 0;
}
  • liba.c ==> ./libA.so

 

#include <stdio.h>
int funcB(int, int);
int funcA(int a, int b)
{
    printf("hello from funcA\n");
    return funcB(a, b);
}
  • libb1.c ==> ./libB.so

 

#include <stdio.h>
int funcB(int a, int b)
{
    printf("Hello from funcB 1\n");
    return a*b;
}  
  • libb2.c ==> /usr/lib/libB.so

 

#include <stdio.h>
int funcB(int a, int b)
{
    printf("Hello from funcB 2\n");
    return a*b;
}  

编译库文件

  • 编译动态库libB.so

 

$ gcc -shared -fPIC libb2.c -o libB2.so
$ sudo mv libB2.so /usr/lib/libB.so
$ gcc -shared -fPIC libb.c -o libB.so
  • 编译动态库libA.so

 

$ gcc -shared -fPIC liba.c -o libA.so -L. -lB

顺便看看该elf文件的头部信息:

$ readelf libA.so -d

Dynamic section at offset 0xf20 contains 21 entries:
  Tag        Type      Name/Value
 0x00000001 (NEEDED)   Shared library: [libB.so]
 0x00000001 (NEEDED)   Shared library: [libc.so.6]
...

恩,只有库的文件名信息,而没有路径信息。

编译程序

  • 第一次编译运行(什么路径都不加)

 

$ gcc main.c -ldl
$ ./a.out 
hello from funcA
Hello from funcB 2
main: 12

程序:dlopen从当前目录找到libA.so,然后却在/usr/lib/中找到libB.so(没有使用当前目录的libB.so,这是我们需要的么?)

  • 第二次编译运行(使用DT_RPATH)

 

$ gcc main.c -ldl  -Wl,--rpath=.
$ ./a.out 
hello from funcA
Hello from funcB 1
main: 12

恩,使用当前目录的libB.so,很理想的东西

  • 可是,由于DT_RPATH无法被环境变量LD_LIBRARY_PATH覆盖,不是不建议被使用,而是建议使用DT_RUNPATH么?

  • 第三次编译运行(使用DT_RUNPATH)

 

$ gcc main.c -ldl -Wl,--rpath=.,--enable-new-dtags 
$ ./a.out 
hello from funcA
Hello from funcB 2
main: 12

问题重新出现,使用的系统路径中的libB.so 而不是当前目录下的。

程序头部信息

通过下列命令可以查看:

$ readelf -d a.out

为了完整起见,列出前面3次编译的程序的信息:

  • 没有rpath和runpath

 

Dynamic section at offset 0xf20 contains 21 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                     Shared library: [libdl.so.2]
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]
 0x0000000c (INIT)                       0x8048360
...
  • 包含rpath

 

Dynamic section at offset 0xf18 contains 22 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                     Shared library: [libdl.so.2]
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]
 0x0000000f (RPATH)                      Library rpath: [.]
 0x0000000c (INIT)                       0x8048360
....
  • 包含rpath和runpath

 

Dynamic section at offset 0xf10 contains 23 entries:
  Tag        Type                         Name/Value
 0x00000001 (NEEDED)                     Shared library: [libdl.so.2]
 0x00000001 (NEEDED)                     Shared library: [libc.so.6]
 0x0000000f (RPATH)                      Library rpath: [.]
 0x0000001d (RUNPATH)                    Library runpath: [.]

原因

RPATH and RUNPATH给出这个问题的答案:

 

Unless loading object has RUNPATH:
    RPATH of the loading object,
        then the RPATH of its loader (unless it has a RUNPATH), ...,
        until the end of the chain, which is either the executable
        or an object loaded by dlopen
    Unless executable has RUNPATH:
        RPATH of the executable
LD_LIBRARY_PATH
RUNPATH of the loading object
ld.so.cache
default dirs

用它解释第一个程序:

  • libA.so 没有RUNPATH,故而
    • 使用其RPATH (没有)
    • 递归查找其loader直到链条的顶端(可执行程序或被dlopen打开的对象)的RPATH或者遇RUNPATH退出 (没有命中)
    • 可执行程序没有RUNPATH,故而
      • 使用其RPATH (没有)
  • 环境变量LD_LIBRARY_PATH,(没有)
  • libA.so 的RUNPATH (没有)
  • ld.so.cache (没有命中)
  • 默认路径/usr/lib (命中)

用它解释第二个程序:

  • libA.so 没有RUNPATH,故而
    • 使用其RPATH (没有)
    • 递归查找其loader直到链条的顶端(可执行程序或被dlopen打开的对象)的RPATH或者遇RUNPATH退出 (没有命中)
    • 可执行程序没有RUNPATH,故而
      • 使用其RPATH (命中)

用它解释第三个程序:

  • libA.so 没有RUNPATH,故而
    • 使用其RPATH (没有)
    • 递归查找其loader直到链条的顶端(可执行程序或被dlopen打开的对象)的RPATH或者遇RUNPATH退出 (没有命中)
    • 可执行程序有RUNPATH,(继续前行)
  • 环境变量LD_LIBRARY_PATH,(没有)
  • libA.so 的RUNPATH (没有)
  • ld.so.cache (没有命中)
  • 默认路径/usr/lib (命中)

有意思的就是这个程序了,可执行程序的RUNPATH是一个重要的判断条件,却并不被做为这儿搜索路径!!

结束

本文是在kubuntu 11.10下编写测试的。为了尽可能简单,例子也都是认为制造的。而且我们看到,在使用RPATH的时候是正常的,RUNPATH一般来说,被推荐使用,但这儿它却不能正常工作。

所以,当使用RUNPATH时,我们需要明白:某些情况下可能需要设置环境变量 LD_LIBRARY_PATH

 

posted @ 2016-05-31 14:49  LiuYanYGZ  阅读(18822)  评论(2编辑  收藏  举报