本文是引用@五牧同学在阿里ata上发表的文章。感觉分析的比较透彻,分享给大家。
问题的起因在来源于周会上钟老板提出的一个问题,cp新的so文件替换老的so,会导致程序core掉。这个问题引起了大家的热烈讨论,其中提及了的名词有inode,dentry,buserror等,比较混乱,由于功力浅薄,当时也没有十分清楚引起core掉的原因。于是乎趁着10.1的休息时间,闲里偷忙,理一理当时的问题,有不对之处,还请大家多多指出。
文章主要分为下面几个部分
希望通过这几个部分的介绍,最终能说清楚这个问题: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之间的具体关系。
每一个进程在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进行动态库覆盖问题分析,转载请注明来源!