共享对象调试与内存问题排查
1. 创建共享对象
从概念上讲,共享对象和程序的唯一区别通常在于共享对象一般没有
main
函数,但这并非硬性要求。你可以创建既能像可执行文件一样被调用,又能动态链接到更大程序中的共享对象,例如动态链接器本身就是这样的共享对象,它被之前提到的
ldd
命令所使用。
创建一个简单的共享对象很容易,只需像构建程序一样操作,但要使用
-shared
和
-fpic
标志。示例命令如下:
$ cc -shared -fpic -o libmylib.so mylib1.c mylib2.c
-
-shared标志用于告诉链接器生成共享对象而非可执行文件。 -
-fpic标志告知编译器生成位置无关代码,这很重要,因为与传统可执行文件不同,共享对象的虚拟地址直到运行时才可知。
将程序与共享对象链接看似简单,示例命令如下:
$ cc -o myprog myprog.o -L . -lmylib
这里使用
-L
选项告知链接器共享库位于当前目录。但问题是,运行时链接器
ld-linux.so
也需要知道在哪里找到这个共享对象。当你尝试运行程序时就会发现问题:
$ ./myprog
./myprog: error while loading shared libraries: libmylib.so: cannot open shared object file: No such file or directory
问题在于系统不知道共享对象的位置。与共享对象链接的程序不包含查找共享对象位置的信息,这是有意为之,因为共享对象在每个系统上都位于特定位置。如果应用程序要在不同系统上运行,不应假设共享对象的位置。相反,每个共享对象都提供一个所谓的
soname
,动态链接器使用它来识别对象。你创建的库没有指定
soname
,这是可选的,因为如果动态链接器不识别
soname
,会回退使用文件名。
2. 定位共享对象
定位共享对象是位于
/lib
目录下的动态链接器
ldlinux.so
的工作。动态链接器总是会在标准路径
/lib
和
/usr/lib
中搜索。如果你想将共享对象存储在其他位置,可以使用环境变量
LD_LIBRARY_PATH
。通常,系统中的共享库分布在多个位置。为避免动态链接器搜索过多路径,系统会在
/etc/ld.so.cache
中保存
soname
和共享对象位置的缓存。这个缓存由
/sbin/ldconfig
程序创建和更新,该程序会搜索
/etc/ld.so.conf
中列出的目录。
每当安装新库时,需要运行
ldconfig
程序来更新缓存。此外,
ldconfig
会创建符号链接,使共享对象文件的文件名与
soname
不同。例如,在 Fedora Core 3 机器上的
hello
程序中,
libc.so.6
指向
libc - 2.3.6.so
文件:
$ ls -l /lib/libc.so.6
lrwxrwxrwx 1 root root 13 Jul 2 16:03 /lib/libc.so.6 -> libc-2.3.6.so
libc.so.6
是编译器遇到的通用
soname
,而
libc - 2.3.6.so
是 GNU C 库包(
glibc
)使用的文件名。原则上,你可以用自己的库替代
glibc
来提供
libc.so.6
,只要使用正确的
soname
并能在路径中找到,动态链接器就会使用它。但实际上,替换
glibc
可能会破坏所有需要
glibc
扩展的 GNU 工具。
下面是定位共享对象的流程:
graph LR
A[启动程序] --> B{动态链接器ldlinux.so}
B --> C{搜索标准路径 /lib 和 /usr/lib}
C -->|未找到| D{检查 LD_LIBRARY_PATH}
D -->|未找到| E{检查 /etc/ld.so.cache}
E -->|未找到| F[报错]
C -->|找到| G[加载共享对象]
D -->|找到| G
E -->|找到| G
3. 覆盖默认共享对象位置
非特权用户可以使用环境变量
LD_LIBRARY_PATH
告知动态链接器在哪里查找共享对象。例如,要让之前的
myprog
程序运行,可以这样做:
$ LD_LIBRARY_PATH=./ ./myprog
这告诉动态链接器在当前目录查找共享对象,即
libmylib.so
所在的位置。对于有
soname
的对象,可以使用
LD_PRELOAD
环境变量来简化输入,示例如下:
$ LD_PRELOAD=libc.so.6 ./hello-world
libc.so.6
是系统上
glibc
的
soname
。这会告诉动态链接器在链接
hello - world
的其他部分之前先链接
libc
。如果你的程序链接了一个重新实现了
libc
中重要函数的库,这种技术会很有用。
LD_PRELOAD
仅适用于
/etc/ld.so.cache
中列出
soname
的库。
这两种技术对于调试共享对象非常有用,因为它们允许你在私有目录中创建共享对象,在那里链接共享对象不会干扰可能正在使用同一共享对象已安装版本的其他进程,你可以放心地调试新的共享对象版本而不用担心导致其他进程崩溃。
4. 共享对象的安全问题
虽然共享对象有很多好处,但如果使用不当,也会带来严重的安全风险。一些共享对象被许多程序共享,例如
libc
,几乎每个系统命令都会使用它。这些对象被许多程序使用,包括许多以
root
权限运行的程序。如果恶意程序员能够破坏一个常用的共享对象,就可能破坏整个系统。
假设你创建了一个具有
setuid root
权限的程序,允许普通用户执行一些日常维护任务。当普通用户运行这个程序时,进程将以
root
权限执行。如果这个程序使用的共享对象位于不安全的位置,恶意程序员理论上可以用恶意软件替换该共享对象。原始程序不会被修改,但在调用这个被劫持的共享对象中的某个函数时,会在不知不觉中破坏系统。
因此,动态链接器会尽力确保此类程序引入的共享对象是安全的。例如,只允许使用标准路径中的对象(忽略
LD_LIBRARY_PATH
),并且所有共享对象必须由
root
拥有且具有只读权限。
5. 处理共享对象的工具
Linux 动态链接器本身就是一个命令行工具,由于历史原因,其手册页列在
ld.so(8)
下,尽管当前发行版中实际使用的程序名是
ld - linux.so.2
。从命令行调用时,链接器接受多个选项并识别许多环境变量,你可以使用这些选项更好地理解程序。这些选项在
ld.so(8)
手册页中有描述,但大多数情况下首选工具是
ldd
,它实际上是一个调用
ld - linux.so.2
的包装脚本,并且有一些更用户友好的选项。
5.1 列出可执行文件所需的共享对象
不使用任何选项时,
ldd
会显示可执行文件所需的所有共享对象,示例如下:
$ ldd hello
linux-gate.so.1 => (0xffffe000)
libm.so.6 => /lib/libm.so.6 (0xb7f1b000)
libpthread.so.0 => /lib/libpthread.so.0 (0xb7f09000)
libc.so.6 => /lib/libc.so.6 (0xb7de0000)
/lib/ld-linux.so.2 (0xb7f4e000)
严格来说,这是文件链接的对象列表,不一定是可执行文件实际需要的对象。例如,这里故意将
Hello World
程序与数学库和
pthread
库链接,但实际上并不需要这两个库:
$ gcc -o hello hello.c -lm -lpthread
与静态库不同,链接器不会从可执行文件中移除共享对象代码。静态库只是一个存档,链接器使用存档提取所需的目标文件,从而能够从可执行文件中消除不需要的目标文件。而当你在命令行指定共享对象时,无论是否必要,链接器都会将其包含在可执行文件中。
可以使用
ldd
命令的
-u
选项查看未使用的依赖项,示例如下:
$ ldd -u ./hello
Unused direct dependencies:
/lib/libm.so.6
/lib/libpthread.so.0
你需要自己思考这些共享对象是如何进入可执行文件的。了解库的命名约定有助于反向推导出命令行选项。许多开源项目使用
pkg - config
工具来创建链接项目的命令行选项,在某些情况下,这些规则可能会引入额外的共享对象。
5.2 为何要关注未使用的共享对象
对于程序链接的每个共享对象,动态链接器必须在对象中搜索未解析的引用并调用初始化例程,这会增加程序启动时间。在快速桌面机器上,未使用的共享对象可能不会增加太多额外时间,但如果数量很多,可能会导致显著延迟。
未使用的共享对象还会消耗资源。无论是否使用,共享对象可能会分配和初始化大量物理内存。如果初始化后未使用,这些内存最终会进入交换分区。不消耗物理内存的对象可能会消耗虚拟内存,即已分配但未初始化的内存。如果从未使用,它不会被交换,也不会消耗物理 RAM,但会限制程序可用的虚拟地址数量。通常,这只在 32 位架构上需要大量数据集(如数 GB 的 RAM)的应用程序中才会成为问题。
一般来说,避免不必要的共享对象是个好主意。特别是在 CPU 速度慢或 RAM 有限的系统上,要格外小心,不要链接不需要的共享库。在现代服务器或桌面系统上,这些问题单独来看可能不是严重问题,但当许多应用程序使用大量不需要的共享对象时,整个系统会受到影响。
5.3 在共享对象中查找符号
有时你可能会下载一些能编译但链接失败的源代码,原因是缺少符号。
nm
、
objdump
和
readelf
命令可用于查看程序符号表。
查找共享对象的 soname
如果你有一个共享对象文件,想在安装前知道它的
soname
,可以使用
objdump
或
readelf
命令查看所谓的
DYNAMIC
部分,示例如下:
$ objdump -x some-obj-1.0.so | grep SONAME
SONAME libmylib.so
$ readelf -a libmylib.so |grep SONAME
0x0000000e (SONAME) Library soname: [libmylib.so]
查找未解析的符号
当链接器抱怨未解析的符号时,可能是由于缺少库或尝试链接错误版本的库。在 C++ 中,问题可能是函数签名更改或拼写错误导致的。
nm
命令可能是最容易使用的,要在目标代码中查找对特定符号的引用,可以使用以下命令:
$ nm -uA *.o | grep foo
-
-u选项将输出限制为每个目标文件中的未解析符号。 -
-A选项显示每个符号的文件名信息,以便在将输出通过管道传递给grep命令时,能看到包含该符号的目标文件。
对于 C++ 代码,还可以使用
-C
选项来解析符号,示例如下:
$ nm -gCA lib*.a | grep foo
libFoolib.a:somefile.o:00000000 T foo(char)
libFoolib.a:somefile.o:00000016 T foo(unsigned char)
objdump
和
readelf
命令也可以完成相同的任务,
objdump
等效命令为:
$ objdump -t
objdump
也有
-C
选项来解析符号名。
readelf
等效命令为:
$ readelf -s
截至版本 2.15.94,
readelf
没有解析符号名的选项。这三个工具都包含在
binutils
包中。
6. 内存问题排查
6.1 双重释放
两次释放指针是一个容易犯的错误,但后果可能很严重。直到最近,
glibc
都不会检查指针,会盲目接受你提供的任何指针。释放指向无效虚拟地址的指针会在该操作发生时引发
SIGSEGV
,这种情况容易发现。而释放指向有效虚拟地址的指针则更难发现。
大多数情况下,被释放的无效指针是之前通过
malloc
调用初始化,但已经通过
free
调用释放过的指针。两次释放指针可能会破坏
glibc
用于跟踪动态内存分配的空闲列表。发生这种情况时,会得到
SIGSEGV
,但可能直到下一次
free
或
malloc
调用时才会出现,这就更难查找问题了。
glibc
的一些特性使查找此类错误更加困难。例如,超过一定大小的内存块使用
mmap
调用分配,而不是传统的堆。传统上,堆是一个随着进程需求增长和收缩的大内存池。
glibc
使用匿名
mmap
分配大块内存,使用传统堆分配小块内存,这会导致不同大小的内存块有不同的失败模式,可能难以解释。
最近版本的
glibc
会检查无效的释放指针,无论在什么情况下,都会导致程序以核心转储的方式终止。
6.2 内存泄漏
当进程分配一块内存,丢弃它,然后忽略释放它时,就会发生内存泄漏。通常,小的泄漏可能无害,程序可以继续正常运行。但随着时间推移,即使是小的泄漏也可能变成问题。一个简单的短期运行的实用程序可以容忍小的泄漏,因为它在退出后会丢弃堆。但一个可能运行数月的守护进程不能容忍任何泄漏,因为泄漏会随着时间累积。
内存泄漏的影响是进程的内存占用会持续增长。当分配的内存未被访问时,泄漏的内存可能只消耗虚拟地址。只要程序没有耗尽用户空间的虚拟地址(在 32 位机器上通常为 3GB),就不会看到任何不良影响。在大多数情况下,泄漏的内存已被程序修改,因此这些页面必须占用物理存储(RAM 或交换分区)。随着未使用的内存页面老化和系统内存需求增加,这些页面会被交换到磁盘。
交换可能是内存泄漏最隐蔽的副作用,因为一个泄漏内存的程序可能会使整个系统变慢。幸运的是,内存泄漏并不难发现。
6.3 缓冲区溢出
当应用程序写入超过内存块末尾的数据,覆盖可能用于其他目的的内存时,就会发生缓冲区溢出。溢出可能导致写入未映射或只读内存,从而引发
SIGSEGV
。缓冲区溢出是一种常见的错误,可能发生在任何类型的内存中:栈、动态或静态。有几种工具可以检测动态内存中的溢出,但检测静态内存和局部变量中的溢出更困难。
处理缓冲区溢出的最佳建议是避免它们。某些标准库函数会增加缓冲区溢出的风险,应避免使用。在大多数情况下,有更安全的替代方案。
flawfinder
是一个很好的工具,它是一个 Python 脚本,可以解析你的源代码,找出危险函数并报告给你。
栈缓冲区溢出
栈缓冲区溢出是一种安全风险。一些攻击者利用商业软件中的溢出漏洞,在原本安全的系统上植入恶意软件。
典型的漏洞涉及一个使用没有溢出检查的函数定义为局部变量的文本输入字段。当输入为纯文本或垃圾数据时,程序可能会崩溃。但聪明的攻击者可以将二进制机器代码输入到文本字段中,使输入缓冲区溢出。通过一些试验和错误,攻击者可以找到合适的字节来让程序运行他的代码并接管进程。
栈缓冲区溢出发生时通常很难查找。典型的特征是
SIGSEGV
后跟一个没有有用回溯信息的核心转储。发生这种情况时,直接进行代码审查,查找已知的问题函数可能比尝试调试更快。
堆缓冲区溢出
当程序使堆缓冲区溢出时,后果并不总是立即显现。当
malloc
使用
mmap
调用分配内存块(用于大块内存)时,会填充请求的块大小,使其成为页面大小的倍数。因此,如果请求的块大小不是页面的整数倍,分配的块空间会包含额外的字节。你的代码可能会超出这些额外字节,但不一定会立即引发错误。
下面是缓冲区溢出的影响及处理方式总结:
| 缓冲区类型 | 溢出后果 | 检测难度 | 处理建议 |
| ---- | ---- | ---- | ---- |
| 栈缓冲区 | 可能导致程序崩溃,存在安全风险 | 较难,典型特征为 SIGSEGV 加无有用回溯的核心转储 | 代码审查查找已知问题函数 |
| 堆缓冲区 | 后果不一定立即显现 | 较难,可能需要复杂的调试工具 | 避免使用易导致溢出的标准库函数,使用 flawfinder 检测 |
7. 总结与建议
7.1 共享对象方面
-
创建与使用
:创建共享对象时,要正确使用
-shared和-fpic标志,以确保生成位置无关的共享对象。在链接程序时,使用-L选项指定共享库的位置,但要注意运行时链接器的查找问题。可通过LD_LIBRARY_PATH和LD_PRELOAD环境变量来覆盖默认的共享对象位置,方便调试。 -
安全问题
:要高度重视共享对象的安全问题,确保共享对象位于安全位置,由
root拥有且具有只读权限,避免恶意攻击。 -
工具使用
:利用
ldd工具查看可执行文件所需的共享对象,使用-u选项找出未使用的共享对象,尽量避免不必要的共享对象链接,以减少程序启动时间和资源消耗。使用nm、objdump和readelf工具查找共享对象的soname和未解析的符号。
7.2 内存问题方面
-
双重释放
:要避免双重释放指针的错误,利用最近版本
glibc的检查机制,及时发现并解决此类问题。 - 内存泄漏 :对于长期运行的程序,要特别注意内存泄漏问题,及时发现并修复泄漏点,避免内存占用不断增长。
-
缓冲区溢出
:尽量避免使用易导致缓冲区溢出的标准库函数,使用
flawfinder工具检测源代码中的危险函数,对于栈缓冲区溢出要及时进行代码审查。
为了更清晰地展示这些问题的处理流程,下面给出一个综合的流程图:
graph LR
A[程序开发与运行] --> B{是否涉及共享对象}
B -->|是| C{创建共享对象}
C --> D[使用 -shared 和 -fpic 标志]
D --> E[链接程序,使用 -L 选项]
E --> F{运行时链接问题}
F -->|有| G[使用 LD_LIBRARY_PATH 或 LD_PRELOAD 调试]
F -->|无| H[正常运行]
B -->|否| I{是否有内存问题}
I -->|是| J{双重释放问题}
J -->|有| K[利用 glibc 检查机制]
J -->|无| L{内存泄漏问题}
L -->|有| M[及时发现并修复泄漏点]
L -->|无| N{缓冲区溢出问题}
N -->|有| O[使用 flawfinder 检测,栈溢出进行代码审查]
N -->|无| H
I -->|否| H
7.3 实际操作建议
以下是一些在实际开发和维护中可以遵循的操作步骤:
1.
共享对象创建与链接
- 编写共享对象代码,如
mylib1.c
和
mylib2.c
。
- 使用以下命令创建共享对象:
$ cc -shared -fpic -o libmylib.so mylib1.c mylib2.c
- 编写主程序代码 `myprog.c` 并编译:
$ cc -c myprog.c
- 链接主程序与共享对象:
$ cc -o myprog myprog.o -L . -lmylib
- 若运行时出现共享对象找不到的问题,使用 `LD_LIBRARY_PATH` 解决:
$ LD_LIBRARY_PATH=./ ./myprog
-
共享对象安全管理
-
确保共享对象位于标准路径
/lib或/usr/lib中。 - 检查共享对象的所有权和权限:
-
确保共享对象位于标准路径
$ chown root:root /path/to/shared_object.so
$ chmod 444 /path/to/shared_object.so
-
内存问题排查
-
对于双重释放问题,使用最新版本的
glibc进行检查。 -
对于内存泄漏问题,可使用工具如
valgrind进行检测:
-
对于双重释放问题,使用最新版本的
$ valgrind --leak-check=full ./your_program
- 对于缓冲区溢出问题,使用 `flawfinder` 检查源代码:
$ flawfinder /path/to/your/source/code
通过以上对共享对象和内存问题的详细分析及相应的处理建议,希望能帮助开发者更好地开发和维护程序,避免常见的错误和安全风险。在实际应用中,要根据具体情况灵活运用这些方法和工具,确保程序的稳定性和安全性。
超级会员免费看
4万+

被折叠的 条评论
为什么被折叠?



