首页 » 编程语言 » c&c++ » linux下cp,mv进行动态库覆盖问题分析

linux下cp,mv进行动态库覆盖问题分析

 

本文是引用@五牧同学在阿里ata上发表的文章。感觉分析的比较透彻,分享给大家。
问题的起因在来源于周会上钟老板提出的一个问题,cp新的so文件替换老的so,会导致程序core掉。这个问题引起了大家的热烈讨论,其中提及了的名词有inode,dentry,buserror等,比较混乱,由于功力浅薄,当时也没有十分清楚引起core掉的原因。于是乎趁着10.1的休息时间,闲里偷忙,理一理当时的问题,有不对之处,还请大家多多指出。
文章主要分为下面几个部分

  • part1.inode,dentry名词介绍
  • part2.cp,mv操作对inode的影响
  • part3.cp,mv覆盖动态库的区别
  • part4.代码分析验证
  • 希望通过这几个部分的介绍,最终能说清楚这个问题:cp操作新的so文件替换老的so文件,程序会core掉的根本原因是什么?
    part1:inode,dentry名词介绍
    inode索引节点,dentry目录项。从这两个单词的中文意思也能简单猜测下,dentry就像书的目录一样,指向具体的inode号。事实上是不是这样呢,看下具体的介绍。

    inode和dentry都是linux下虚拟文件系统(vfs,vitual file system,图1)的重要概念。inode储存着文件的元信息,比如文件的创建者、文件的创建日期、文件的大小等等,特别注意的是inode中不包括文件名信息,具体包含的内容如下(stat命令可以查看文件的inode信息):

    *文件的字节数
    *文件拥有者的User ID
    *文件的Group ID
    *文件的读、写、执行权限
    *文件的时间戳,共有三个:ctime是inode上一次变动的时间,mtime指文件内容上一次变动的时间,atime指文件上一次打开的时间。
    *链接数,即有多少目录项指向这个inode
    *文件数据block的位置
    

    dentry是directory entry的缩写,直接翻译目录入口是不是更容易理解些;)。dentry中则包含具体的文件名和指向inode的指针等信息,也就是说通过dentry可以找到对应的inode,再通过inode找到文件存储的block位置。这里我画了一个简单的示例图(图1),来说明dentry和inode之间的具体关系。
    t_35661_1381231351_830522434
    每一个进程在pcb中保存着一份文件描述符表,而文件描述符就是这个表的索引,这里进程打开/home/wsl/test文件,文件描述符为3,其中文件描述符表项中又有一个指向已打开文件的指针,已打开的文件在内核中用file结构体表示,包括打开的标志位,读写的位置f_pos,引用计数(f_count)以及指向dentry结构体的指针(f_dentry)等信息。为了减少读盘次数,内核都缓存了目录的树状结构,称为dentry cache,这里面每一个节点都是一个dentry结构体【正如前面介绍的,dentry中保存着文件名信息】。dentry结构体中都有一个指针指向inode结构体,因此只要沿着路径各部分的dentry搜索即可找到进程要访问的文件的inode结构体,从而获取文件的inode信息,进行文件的具体操作。

    简单总结下,*nux系统内部不使用文件名,而是使用inode来识别文件,用户通过文件名打开文件,实际上是首先通过dentry获取文件的inode信息,然后根据读取的inode信息来进行文件的处理。
    part 2:cp,mv,rm操作对inode的影响
    在介绍完inode后,我们来看下cp和mv操作对文件的inode都有什么样的影响。 ​

    snail@ubuntu:~/test$ touch t1 t2 && ls -i t1 t2
    792797 t1  792798 t2
    snail@ubuntu:~/test$ cp t1 t2 && ls -i t1 t2
    792797 t1  792798 t2//将t1 cp成t2,但t2的inode号和原始的t2保持一致
    snail@ubuntu:~/test$ mv t1 t2 && ls -i t2
    792797 t2 //将t1 mv成t2,t2的inode号为原始t1的inode号
    snail@ubuntu:~/test$ cp t2 t3 && ls -i t2 t3
    792797 t2  792846 t3//cp到一个不存在的文件t3,t3为新的inode号
    

    下面是一些测试结论直接来自参考文献2
    cp命令
    inode号分配
    如果目标文件不存在,分配一个未使用的inode号,在inode表中添加一个新项目;
    如果目标文件存在,则inode号采用被覆盖之前的目标文件的inode号
    ​在目录中新建一个dentry,并指向步骤1)中的inode;
    ​把数据复制到block中。
    ​我们接着来看下rm命令对inode会有什么样的影响
    mv命令
    a.如果mv命令的目标和源文件所在的文件系统相同:
    1)使用新文件名建立dentry
    2)删除带有原来文件名的dentry; 【该操作对inode表没有影响(除时间戳),对数据的位置也没有影响,不移动任何数据。(即使是mv到一个已经存在的目标文件,新目录项指源文件inode,会先删除目标文件的dentry)】
    b.如果目标和源文件所在文件系统不相同,就是cp和rm;

    然后我们来看下rm对inode的影响
    ​首先写了一个简单的python脚本,不停的网log文件里面写数据​

    [wsl@inc-search-150-67 tmp]$ cat test.py 
    import time
    file = open('log','w')
    while(1):
        file.write("abc
    ");
        time.sleep(1)
        file.flush()
    file.close()
    

    然后lsof命令查看log文件
    其中29908为进程号,120那一列为文件大小,35为inode号

    [wsl@inc-search-150-67 tmp]$ /usr/sbin/lsof |grep /tmp/log
    python    29908   wsl    3w      REG                8,5        96         35 /tmp/log
    

    最后删除此log文件,继续查看此命令

    [wsl@inc-search-150-67 tmp]$ rm log
    [wsl@inc-search-150-67 tmp]$ /usr/sbin/lsof |grep /tmp/log
    python    29908   wsl    3w      REG                8,5       120         35 /tmp/log (deleted)//节点被标记为deleted
    [wsl@inc-search-150-67 tmp]$ /usr/sbin/lsof |grep /tmp/log
    python    29908   wsl    3w      REG                8,5       232         35 /tmp/log (deleted)//文件大小仍在增加
    [wsl@inc-search-150-67 tmp]$ kill -9 29908
    [wsl@inc-search-150-67 tmp]$ /usr/sbin/lsof |grep /tmp/log
    [wsl@inc-search-150-67 tmp]$ 
    

    我们可以看到log文件被删除后,lsof可以看到此文件被标记为deleted,inode仍然存在,并且在没有kill掉进程的情况下,文件的大小仍在增加,只有进程被kill掉后,才释放掉此inode。先埋下这一观察到的现象,到文章的最后,我们在继续讨论这样的操作会有什么样的影响。

    下面一些是rm命令对文件inode的影响

    rm命令
    1)递减链接计数,从而释放inode号码,这个inode号码可以被重用
    2)把数据块挂到可用空间列表
    3)删除目录映射表中的相关行 但是底层数据实际上没有被删除,只是当数据块被另一个文件使用时,原来的数据才会被覆盖
    简单总结下:
    ​cp命令到一个已经存在的文件,inode号沿用已经存在文件的inode号;
    ​mv命令用新的inode号,也就是mv前的文件的inode号;
    ​rm命令删除的底层数据只有被使用的时候才会被覆盖。

    part3.cp,mv覆盖动态库的区别

    前面两部分是对这一部分的一个简单铺垫。现在我们来看下为什么使用cp对动态库进行覆盖,程序会core掉(或者说可能会core掉?)
    首先我们使用strace命令来跟踪cp命令的执行。【btw:strace命令可以跟踪到一个进程产生的系统调用,包括参数,返回值,执行消耗的时间,调试利器】

    snail@ubuntu:~/test$ ls
    new.so  old.so
    snail@ubuntu:~/test$ cat new.so //new.so内容
    this is new.so
    haha!
    snail@ubuntu:~/test$ strace cp new.so old.so
    //......只列出重要的相关步骤
    open("new.so", O_RDONLY)    = 3
    fstat64(3, {st_mode=S_IFREG|0664, st_size=21, ...}) = 0
    open("old.so", O_WRONLY|O_TRUNC) = 4
    fstat64(4, {st_mode=S_IFREG|0664, st_size=0, ...}) = 0
    read(3, "this is new.sonhaha!n", 32768) = 21
    write(4, "this is new.sonhaha!n", 21) = 21
    read(3, "", 32768)                      = 0
    close(4)                                = 0
    close(3)                                = 0
    //......
    

    可以看到第8行以只读的方式打开了new.so,然后第10行以写加截断(O_WRONLY|O_TRUNC)的方式打开old.so。【O_TRUNC的含义:若文件存在,则长度被截为0,属性不变】,最后将new.so的内容写到old.so中,然后关闭文件。

    这个过程具体的发生的事情如下:

    1.应用程序通过dlopen打开so的时候,kernel通过mmap把so加载到进程地址空间,对应于vma里的几个page.
    2.在这个过程中loader会把so里面引用的外部符号例如malloc printf等解析成真正的虚存地址。
    3.当so被cp覆盖时,确切地说是被trunc时,kernel会把so文件在虚拟内的页清理掉。
    4.当运行到so里面的代码时,因为物理内存中不再有实际的数据(仅存在于虚存空间内),会产生一次缺页中断。
    5.Kernel从so文件中copy一份到内存中去。这时就会发生下面几种情况
    a)如果需要的文件偏移大于新的so的地址范围,就会产生bus error.这个在向宇大神的文章中有详细的介绍(摸我)
    b)如果so里面依赖了外部符号,但是这时的全局符号表并没有经过重新解析,当调用到时就产生segment fault
    c)如果so里面没有依赖外部符号,程序侥幸可以继续运行。
    mv命令新的so到老的so,关键代码就一句,一个重命名的过程,所以旧的so文件的inode号被替换新的so的inode号

    //......
    rename("new.so", "old.so")              = 0
    //......
    

    part4.代码验证分析
    下面就出现的bc两种情况用代码分析验证下。情况a可以参考向宇大神的文章,不在赘述了。

    //test.c
    #include<stdio.h>
    
    void test1(void){
        int j=0;
        printf("test1:j=%dn", j);
        return ;
    }
     
    void test2(void){
        int j=1;
        return ;
    }
    

    执行下面命令生成so文件
    ​gcc -fPIC -shared -o libtest.so test.c -g

    //main.c
    #include <stdio.h>
    #include <dlfcn.h> 
     
    int main()
    {
        void *lib_handle;
        void (*fn1)(void);
        void (*fn2)(void);
        char *error;
        //表示要将库装载到内存,准备使用
        lib_handle = dlopen("libtest.so", RTLD_LAZY);
        if (!lib_handle)
        {
            fprintf(stderr, "%sn", dlerror());
            return 1;
        }
        //获得指定函数(symbol)在内存中的位置(指针)
        fn1 = dlsym(lib_handle, "test1");
        if ((error = dlerror()) != NULL)
        {
            fprintf(stderr, "%sn", error);
            return 1;
        }
        printf("fn1:0x%xn", fn1);
     
        fn1();
     
        fn2 = dlsym(lib_handle, "test2");
        if ((error = dlerror()) != NULL)
        {
          fprintf(stderr, "%sn", error);
          return 1;
        }
     
        printf("fn2:0x%xn", fn2);
     
        fn2();
     
        dlclose(lib_handle);
     
        return 0;
    }
    

    执行命令:gcc -o main main.c -ldl -g
    首先进行测试1,断点设置在27行,fn1()执行之前

    Breakpoint 1, main () at main.c:27
    //这时我们在另外一个终端执行下面的命令
    //cp libtest.so libtest2.so 
    //cp libtest2.so libtest.so
    
    27	    fn1();
    (gdb) s
    test1 () at test.c:4
    4	    int j=0; //没有报错
    (gdb) n
    5	    printf("test1:j=%dn", j);
    (gdb) n
    //出错,因为引用了printf外部函数,而全局符号表并没有经过重新解析,找不到printf函数
    Program received signal SIGSEGV, Segmentation fault.
    0x00000396 in ?? ()
    (gdb) bt
    #0  0x00000396 in ?? ()
    #1  0xb7fd84aa in test1 () at test.c:5
    #2  0x08048622 in main () at main.c:27
    

    下面进行测试2,断点设置在38行,fn2执行之前。
    ​然后在另一个终端执行和测试1相同的cp操作

    Breakpoint 1, main () at main.c:38
    38	    fn2();
    (gdb) s
    test2 () at test.c:10
    10	    int j=1;
    (gdb) n
    12	}
    (gdb) n
    main () at main.c:40
    40	    dlclose(lib_handle);
    (gdb) n
    42	    return 0;
    (gdb) 
    43	}//程序正常结束
    

    从这两个测试例子中,我们可以得到这样的结论:

    当用新的so文件去覆盖老的so文件时候:
    A)如果so里面依赖了外部符号,程序会core掉
    B)如果so里面没有依赖外部符号,so部分代码可以正常运行

    总结:

    整理完这四部分,回到最开始的问题“为什么cp新的so文件替换老的so,程序会core掉的根本原因是什么?”,现在串联起来总结如下。
    1. cp new.so old.so,文件的inode号没有改变,dentry找到是新的so,但是cp过程中会把老的so截断为0,这时程序再次进行加载的时候,如果需要的文件偏移大于新的so的地址范围会生成buserror导致程序core掉,或者由于全局符号表没有更新,动态库依赖的外部函数无法解析,会产生sigsegv从而导致程序core掉,当然也有一定的可能性程序继续执行,但是十分危险。
    2. mv new.so old.so,文件的inode号会发生改变,但老的so的inode号依旧存在,这时程序必须停止重启服务才能继续使用新的so,否则程序继续执行,使用的还是老的so,所以程序不会core掉,就像我们在第二部分删除掉log文件,而依然能用lsof命令看到一样。
    ps.阿里的第一篇博客,以后尝试经常写写博客,把自己的思路理的更加清晰和有逻辑,如有不对的地方,还请大家多多指正。

    技术交流

    原文链接:linux下cp,mv进行动态库覆盖问题分析,转载请注明来源!

    原文链接:linux下cp,mv进行动态库覆盖问题分析,转载请注明来源!

    11