logo

Source未找到原文链接

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](/user/ubuntu):~/test$ touch t1 t2 && ls -i t1 t2
792797 t1  792798 t2
snail[@ubuntu](/user/ubuntu):~/test$ cp t1 t2 && ls -i t1 t2
792797 t1  792798 t2//将t1 cp成t2,但t2的inode号和原始的t2保持一致
snail[@ubuntu](/user/ubuntu):~/test$ mv t1 t2 && ls -i t2
792797 t2 //将t1 mv成t2,t2的inode号为原始t1的inode号
snail[@ubuntu](/user/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](/user/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](/user/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](/user/inc-search-150-67) tmp]$ rm log
[wsl[@inc-search-150-67](/user/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](/user/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](/user/inc-search-150-67) tmp]$ kill -9 29908
[wsl[@inc-search-150-67](/user/inc-search-150-67) tmp]$ /usr/sbin/lsof |grep /tmp/log
[wsl[@inc-search-150-67](/user/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](/user/ubuntu):~/test$ ls
new.so  old.so
snail[@ubuntu](/user/ubuntu):~/test$ cat new.so //new.so内容
this is new.so
haha!
snail[@ubuntu](/user/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

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 
#include 

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命令看到一样。


ccj 于 2015-10-09 21:22 修改
0 回复
需要 登录 后方可回复, 如果你还没有账号你可以 注册 一个帐号。