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

问题的起因在来源于周会上老板提出的一个问题,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之间的具体关系。

alt

图 1

每一个进程在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命令

  1. inode号分配
    • 如果目标文件不存在,分配一个未使用的inode号,在inode表中添加一个新项目;
    • 如果目标文件存在,则inode号采用被覆盖之前的目标文件的inode号
  2. ​在目录中新建一个dentry,并指向步骤1)中的inode;
  3. ​把数据复制到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\n");
    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命令看到一样。

【参考文献】

1.  Linux C编程一站式学习

2. linux cp mv rm ln命令对inode和dentry的影响

3. 为何cp覆盖进程的动态库(so)会导致coredump

4. cp/scp & mv

5. linux下So覆盖导致coredump问题的分析

原文地址:https://www.cnblogs.com/lidabo/p/15509314.html