一文搞懂系列——替换动态库,为什么导致运行进程异常
背景
上周我的好同事·金,又遇到了一个crash问题,他是这么描述的:测试同事在验证新版本时,程序正在运行,将新版本的动态库替换到对应目录中,正在运行的程序会出现crash。我的第一反应是,替换磁盘中的动态库,对于正在运行的进程应该是没有影响的。因为,正在运行的进程依赖于旧动态库,即使执行rm lib.so
指令,文件系统发现依然有程序对其引用,也不会立刻从磁盘删除,待进程停止后,没有对该动态库应用时,才会真正从磁盘删除。因为当时比较忙,也就没有关注了。
正所谓天道好轮回,苍天饶过谁。就在今天,我在做演示项目时,好像就遇到了类似的问题,经过细细对比,果然和金描述的一致。心想:既然你又找上了我,那我一定不让你失望。
经过分析排查,查阅资料。发现之前自己的理解比较片面,也学到了很多。希望本次分享能够帮助到遇到相似问题或有兴趣的朋友。本篇内容难度偏高,希望朋友们能够自己操作一遍。
示例
为了方面操作和理解,我写了以下示例代码。
//main.c
#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>
extern int add(int,int);
int main()
{
printf("start sleep\n");
sleep(10);
printf("over sleep\n");
add(5,6);
return 0;
}
//add.c
#include<stdio.h>
int add(int a, int b)
{
printf("a+b\n",a+b);
return (a+b);
}
编译:
gcc -g -fPIC -shared add.c -o libadd.so
gcc -g main.c -o main -L. -ladd
创建lib目录,并将libadd.so
拷贝一份到lib目录,其中,两个libadd.so
内容是完全一样的。最终工程如下:
yihua@ubuntu:~/test/reloadDynamiclibs$ tree
.
├── add.c
├── lib
│ └── libadd.so
├── libadd.so
├── main
└── main.c
1 directory, 5 files
yihua@ubuntu:~/test/reloadDynamiclibs$
运行:其中环境变量动态库加载路径,是期望main
链接lib/libadd.so
。
export LD_LIBRARY_PATH=lib/
./main
正常情况下,输出如下:
yihua@ubuntu:~/test/reloadDynamiclibs$ ./main
start sleep
over sleep
yihua@ubuntu:~/test/reloadDynamiclibs$
问题复现: main
运行时,在sleep的10秒内,执行cp libadd.so lib/libadd.so
将lib/libadd.so
替换。则输出如下:
yihua@ubuntu:~/test/reloadDynamiclibs$ ./main
start sleep
over sleep
Segmentation fault (core dumped)
yihua@ubuntu:~/test/reloadDynamiclibs$
知识点补充
在进入分析前,我们需要了解两个知识点:文件共享的方式、GOT及PLT表。
文件共享方式
一、文件在磁盘中存储的方式
我们知道文件存储在磁盘中,硬盘的最小存储单位叫做"扇区"(Sector)。每个扇区储存512字节。操作系统对磁盘文件存的取最小单位是“块",最常见的是4KB,即连续八个Sector组成一个block。
通常情况下,文件系统会将文件的实际内容和属性分开存放。
- 文件的属性保存在 inode 中(i 节点)中,每个 inode 都有自己的编号。每个文件各占用一个 inode。不仅如此,inode 中还记录着文件数据所在 block 块的编号;
- 文件的实际内容保存在 block 中(数据块),类似衣柜的隔断,用来真正保存衣物。每个 block 都有属于自己的编号。当文件太大时,可能会占用多个block 块。
故文件系统会将磁盘格式化为两个部分inode
和block
。举例:若某文件的inode
节点为3,其中记录着文件实际内容存储在block
1、2、4、5中。那么操作系统读取文件的流程:
- 读取
inode
节点信息,获取文件所占blocks
- 读取
blocks
,获取文件实际内容。
我们可以通过stat
查看文件的inode
信息。如下:
yihua@ubuntu:~/test/reloadDynamiclibs$ stat libadd.so
File: libadd.so
Size: 8344 Blocks: 24 IO Block: 4096 regular file
Device: 801h/2049d Inode: 1713034 Links: 1
Access: (0775/-rwxrwxr-x) Uid: ( 1000/ yihua) Gid: ( 1000/ yihua)
Access: 2023-12-26 01:55:52.528861823 -0800
Modify: 2023-12-26 01:55:23.649023426 -0800
Change: 2023-12-26 01:55:23.649023426 -0800
Birth: -
其中libadd.so
的inode
节点为1713034,实际占用24个block。
二、进程表和内核文件表
每个进程在进程表中都有一个关于文件描述符的记录部分,其中文件描述符中主要记录两点内容。
- 文件描述符。也就是我们常见的fd。
- 文件指针。每个fd都对应一个文件指针,文件指针的作用,是指向内核文件表中的某一项。
内核为所有打开文件维持一张文件表。表中的每一项包含三点内容。
- 文件的状态标志。比如读、写、添写、同步和非阻塞等。
- 当前文件偏移量。
- 指向该文件的v节点表项的指针。v节点包含文件类型、对此文件的各种操作函数的指针、以及文件对应的
inode
。
通过以上两点,我们大致可以得出进程访问文件的关系如下:
注:其中文件表项是每一个进程所独有的,但是v节点是文件独有的,即若存在多个进程打开同一个文件,其流程大致如下:
动态链接PLT和GOT表
PLT(Procedure Linkage Table)及GOT(Global Offset Table)是动态链接中的核心点,本文不会进行更进一步的介绍。若要深入了解,可参考我的专栏《程序员的自我修养》。由于libadd.so
中引用可printf
接口,并且printf
定义在libc.so
中,因此libadd.so
对printf
的引用采用运行时重定位。采用objdump -d libadd.so
命令查看add
函数的反汇编内容。
000000000000060a <add>:
60a: 55 push %rbp
60b: 48 89 e5 mov %rsp,%rbp
60e: 48 83 ec 10 sub $0x10,%rsp
612: 89 7d fc mov %edi,-0x4(%rbp)
615: 89 75 f8 mov %esi,-0x8(%rbp)
618: 8b 55 fc mov -0x4(%rbp),%edx
61b: 8b 45 f8 mov -0x8(%rbp),%eax
61e: 01 d0 add %edx,%eax
620: 89 c6 mov %eax,%esi
622: 48 8d 3d 20 00 00 00 lea 0x20(%rip),%rdi # 649 <_fini+0x9>
629: b8 00 00 00 00 mov $0x0,%eax
62e: e8 dd fe ff ff callq 510 <printf@plt>
633: 8b 55 fc mov -0x4(%rbp),%edx
636: 8b 45 f8 mov -0x8(%rbp),%eax
639: 01 d0 add %edx,%eax
63b: c9 leaveq
63c: c3 retq
可知add
函数并不是直接调用printf
符号,而是printf@plt
符号。其实际访问流程应该是add --> printf@plt --> printf@got --> printf
。
问题分析
一、首先我们要先确认cp libadd.so lib/
做了什么
通过命令前后对比,查看文件的inode值,可发现libadd.so
文件的inode值并没有变,说明该操作将磁盘中文件原始数据重新写了一遍。
yihua@ubuntu:~/test/reloadDynamiclibs$ stat lib/libadd.so
File: lib/libadd.so
Size: 10376 Blocks: 24 IO Block: 4096 regular file
Device: 801h/2049d Inode: 1714531 Links: 1
Access: (0775/-rwxrwxr-x) Uid: ( 1000/ yihua) Gid: ( 1000/ yihua)
Access: 2023-12-26 23:29:55.494489759 -0800
Modify: 2023-12-26 23:29:50.338512140 -0800
Change: 2023-12-26 23:29:50.338512140 -0800
Birth: -
yihua@ubuntu:~/test/reloadDynamiclibs$
yihua@ubuntu:~/test/reloadDynamiclibs$
yihua@ubuntu:~/test/reloadDynamiclibs$ cp libadd.so lib/libadd.so
yihua@ubuntu:~/test/reloadDynamiclibs$ stat lib/libadd.so
File: lib/libadd.so
Size: 10376 Blocks: 24 IO Block: 4096 regular file
Device: 801h/2049d Inode: 1714531 Links: 1
Access: (0775/-rwxrwxr-x) Uid: ( 1000/ yihua) Gid: ( 1000/ yihua)
Access: 2023-12-26 23:29:55.494489759 -0800
Modify: 2023-12-27 00:06:31.073213774 -0800
Change: 2023-12-27 00:06:31.073213774 -0800
Birth: -
yihua@ubuntu:~/test/reloadDynamiclibs$
二、gdb 调试分析
通过gdb调试分析,发现收到错误信号SIGSEGV
,访问非法地址,而0x0000000000000516
也很明显是一个错误地址。
Program received signal SIGSEGV, Segmentation fault.
0x0000000000000516 in ?? ()
(gdb) bt
#0 0x0000000000000516 in ?? ()
#1 0x00007ffff7bd1633 in add (a=5, b=6) at add.c:4
#2 0x00005555555547cf in main () at main.c:12
(gdb)
我们跟着堆栈信息不妨一起分析下,通过disassemble
命令,查看进程的汇编内容。
由堆栈分析:是在执行jmpq *0x200b02(%rip)
指令出现了crash,并且经过打印可以虚拟地址0x7ffff7dd2018
中的值的确是0x0000000000000516
,与现象相符。我们可以通过打断点查看正常情况下,该地址中的值。
这时我们可以得到一个结论:替换运行时的动态库,改变了内存进程中的内存值。
三、思考:为什么修改文件中的磁盘数据,会影响进程中的内存空间呢?
我们可以通过strace
命令查看进程是如何加载libadd.so
动态库的。如下:
由图可知,libadd.so
是通过mmap将文件映射到内存中的,因此当重新写磁盘内容时,会实时修改进程中的对应内存空间。
四、再思考:为什么拷贝前后的libadd.so
一摸一样,还是会导致crash呢?
这和进程加载动态库过程有关,因为内容就比较深刻,不进一步详细分析。不过简单理解进程加载动态库中的过程为:
- 动态链接器将动态库
libadd.so
mmap到内存中。 - 对
libadd.so
中的got表进行重定位,即将上面的0x0000000000000516
地址,更改为加载后的虚拟地址0x7ffff7bd1516
。 - 加载完成。
实际上此时内存中的文件数据,是经过重定位修改后的内容。与磁盘中libadd.so
的原始数据并不一致。若此时,再将磁盘中的原始数据mmap到内存中,上述的步骤二不再执行,因而会出现访问非法内存的情况。
总结
至此,该问题终于解决了。内容较多,希望大家有所收获。
思考题:在知道根因后,我们不妨再想想,如何在程序运行时,替换动态库,却不影响正在程序。
提示:先删除,再拷贝。即rm lib/libadd.so
+ cp libadd.so lib/libadd.so
。不妨试试哦,可以把原理在评论区写出。
若我的内容对您有所帮助,还请关注我的公众号。不定期分享干活,剖析案例,也可以一起讨论分享。
我的宗旨:
踩完您工作中的所有坑并分享给您,让你的工作无bug,人生尽是坦途。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!