1. 动态库缺少依赖符号怎么排查

首先要清楚动态库和静态库的区别

image-20250214110446770

以及动态库的编译过程和相关命令

-fPIC是用于生成位置无关代码

位置无关码PIC详解:原理、动态链接库、代码重定位_位置无关代码-CSDN博客

https://www.bilibili.com/video/BV1nh4y1S7TG/

位置无关代码(Position-Independent Code,PIC)是一种能够在内存中任意地址加载并正确执行的代码。它广泛应用于共享库、动态链接库和地址空间布局随机化(ASLR)等场景,以提高灵活性和安全性。以下是其核心要点:


1. 为什么需要PIC?

  • 动态库加载:不同进程可能将同一共享库加载到不同内存地址,PIC避免代码依赖固定基址。
  • 安全性:支持ASLR,随机化代码加载地址,增加攻击者利用内存漏洞的难度。
  • 代码复用:同一份库代码可被多个进程共享,节省内存。

2. 工作原理

代码段(Text Section)

  • 相对跳转/调用:使用相对于当前指令指针(如x86的EIP/RIP)的偏移量,而非绝对地址。
    call printf@PLT    ; 通过PLT跳转,地址在运行时解析
    jmp .L1           ; 相对标签跳转,不依赖加载地址
  • 数据访问:通过全局偏移表(GOT)间接寻址:
    • 编译时生成GOT,存储全局变量和函数的绝对地址。
    • 代码通过GOT + 偏移量访问数据,例如:
      mov rax, [rip + GOT_OFFSET]  ; RIP相对寻址获取GOT条目
      mov rax, [rax]               ; 获取实际数据

关键数据结构

  • GOT(Global Offset Table):存储外部符号的绝对地址,动态链接时由加载器填充。
  • PLT(Procedure Linkage Table):用于延迟绑定函数调用,首次调用时通过动态链接器解析地址。

3. 如何生成PIC?

  • 编译选项
    • **-fPIC**:生成与位置无关的代码,适用于共享库(如Linux的.so)。
    • **-fPIE**:生成位置无关的可执行文件(PIE),支持ASLR。
  • 链接选项:动态链接时需指定库路径(如-shared生成共享库)。

4. 优缺点

  • 优点
    • 支持动态加载和内存随机化。
    • 节省内存(多进程共享代码段)。
  • 缺点
    • 轻微性能损耗(间接寻址和GOT/PLT跳转)。
    • 代码体积略大(需额外指令处理偏移)。

5. 平台差异

  • x86-64:天然支持RIP相对寻址,PIC实现高效。
  • x86:需通过ebx寄存器指向GOT基址(如-fPIC生成的代码)。
  • ARM:使用PC相对加载指令(如LDR PC, [PC, #offset])。

6. 示例:动态库编译

# 编译为共享库(使用-fPIC)
gcc -fPIC -shared -o libdemo.so demo.c

# 编译为PIE可执行文件(支持ASLR)
gcc -fPIE -pie -o app app.c

7. 相关概念

  • ASLR:随机化栈、堆、库的加载地址,依赖PIC/PIE增强安全性。
  • 动态链接:运行时解析符号地址,依赖GOT/PLT机制。

GOT 表的存储

GOT 表(Global Offset Table,全局偏移表)本质上存储的是绝对地址,只不过这些绝对地址是放在数据段中的。正因为它是放在数据段,所以可以被动态链接器(如 ld.so)在运行时维护和修改。不同进程在加载同一个共享库时会有不同的数据段,因此它们各自的 GOT 表也是不同的

GOT 表的作用

  • 运行时地址重定位:共享库在运行时会被加载到内存中的不同位置。GOT 表的作用是记录这些动态确定的地址。例如,当程序调用一个外部函数(如 printf)时,GOT 表中会存储 printf 函数在当前进程内存中的实际地址。
  • 延迟绑定支持:动态链接的延迟绑定机制允许函数地址在首次调用时才被解析。GOT 表中的条目初始可能指向一个特殊的动态链接器代码(如 _dl_runtime_resolve),当第一次调用函数时,动态链接器解析该符号的实际地址,并将解析结果填入 GOT 表,后续调用则直接使用 GOT 表中的地址。

与静态库的对比

  • 静态库:在编译时,静态库的代码会被直接嵌入到最终的可执行文件中。因此,静态库中的全局变量和函数的地址在编译和链接时就已经确定了,不需要 GOT 表。
  • 动态库:动态库依赖 GOT 表来动态解析和存储外部符号的地址。代码段中的指令通过相对地址访问 GOT 表中的条目,而 GOT 表中的条目存放了运行时动态库被加载到内存中的实际绝对地址。

总结

  • GOT 表:存放在数据段,记录动态确定的全局变量和函数的绝对地址,由动态链接器在运行时维护。
  • 静态库:代码和符号地址在编译时确定,不需要 GOT 表。

通过使用动态库和 GOT 表,不同的进程可以共享同一个程序代码段,同时能够动态解析和加载外部符号的地址,从而节省内存和磁盘空间。

问:

GOT其实存的也是绝对地址是吗,只不过是放在数据段,因此可以被链接器(ld.so)维护,不同进程有不同的数据段(GOT表),即便访问的是同一个动态库,而GOT表的地址是可知的,里面存放了运行时动态库被加载的位置,代码段中使用的变量其实是通过记录查询GOT表中的条目的相对地址,获取GOT存的绝对地址。

而静态库因为是直接把代码嵌入,所以在编译时就已经确定好了地址(即便是相对于分配的起始地址)是吗,而动态库是在运行时分配的起始地址还要内部偏移(找到用于存储GOT)之后再读取动态库的绝对地址

被链接的动态库在程序运行过程中,可能会发生内存位置的改变

deepseek:

你的理解非常准确!以下是更清晰的总结和补充:


1. GOT(全局偏移表)的本质

  • 存储内容:GOT中存放的是绝对地址,但这些地址在运行时动态确定(由动态链接器ld.so填充)。
  • 位置与隔离性
  • GOT位于数据段(可读写区域),每个进程有独立的GOT副本,即使多个进程加载同一个动态库,它们的GOT内容可能不同(例如库加载基址不同)。
  • 代码段通过相对地址访问GOT条目(如x86-64的RIP相对寻址),再通过GOT中的绝对地址找到实际数据或函数。
  • 示例
    ; 代码段中的指令(相对寻址访问GOT)
    mov rax, [rip + got_offset]  ; 获取GOT条目中的绝对地址
    mov rbx, [rax]              ; 通过绝对地址访问实际数据

2. 静态库的地址确定性

  • 编译与链接方式
  • 静态库(如.a文件)在编译时生成位置相关代码(除非显式启用-fPIC),其代码和数据的地址在链接阶段确定(嵌入到最终的可执行文件中)。
  • 链接时,静态库代码的地址被解析为相对于可执行文件基址的绝对地址(若可执行文件是非PIE的),或基于相对偏移(若可执行文件是PIE)。
  • 内存分配特性
  • 静态库代码被直接嵌入可执行文件,加载到内存时地址固定(除非可执行文件是PIE且系统启用ASLR)。
  • 不同进程使用同一静态库时,会各自拥有独立的代码副本(无法共享内存)。

3. 动态库 vs 静态库的关键区别

特性 动态库(PIC) 静态库
地址绑定时机 运行时(通过GOT动态解析) 链接时(地址直接嵌入可执行文件)
内存共享 多个进程共享同一代码段 每个进程有独立副本
地址无关性 必须使用PIC(通过GOT/PLT) 默认位置相关(除非显式启用PIC/PIE)
ASLR支持 天然支持(代码段可随机加载) 仅当可执行文件为PIE时支持
性能 轻微间接寻址开销 无间接寻址开销

4. 静态库与PIE的兼容性

  • PIE(位置无关可执行文件)
  • 若可执行文件编译为PIE(使用-fPIE -pie),则其基址在运行时随机化(类似动态库)。
  • 此时,静态库代码也需编译为PIC-fPIC),否则无法适应基址随机化。
  • 非PIE可执行文件
  • 静态库代码通常为位置相关,链接时地址固定(如基址0x400000),无法支持ASLR。

5. 动态库的灵活性与代价

  • 灵活性来源
  • 代码段通过相对寻址跳转(如call printf@PLT)。
  • 数据段通过GOT间接寻址(动态解析绝对地址)。
  • 代价
  • 每次访问全局变量或外部函数需额外内存访问(GOT查询)。
  • PLT的延迟绑定机制在首次调用函数时有解析开销。

6. 示例场景

动态库(libdemo.so)加载

># 编译为动态库(强制使用PIC)
>gcc -fPIC -shared -o libdemo.so demo.c

># 使用动态库的可执行文件(支持ASLR)
>gcc -o app app.c -L. -ldemo
  • 运行时:
  • libdemo.so的代码段加载到随机基址(如0x7f8a3d200000)。
  • 进程的GOT中存储libdemo.so中符号的实际地址(如全局变量var的地址为0x7f8a3d201000)。

静态库(libdemo.a)嵌入

># 编译为静态库(默认非PIC)
>gcc -c demo.c -o demo.o
>ar rcs libdemo.a demo.o

># 链接到可执行文件(非PIE,地址固定)
>gcc -o app app.c -L. -ldemo
  • 运行时:
  • 静态库代码嵌入可执行文件,加载到固定基址(如0x400000)。
  • 所有符号地址在链接时确定(如变量var的地址为0x601044)。

7. 总结

  • GOT是动态库实现位置无关的核心机制,存储运行时确定的绝对地址,通过数据段隔离实现多进程兼容。
  • 静态库的地址在链接时固定(除非显式使用PIC+PIE),牺牲了内存共享和ASLR支持,但换取了直接寻址的性能优势。
  • 动态库的PIC静态库的地址确定性体现了灵活性与效率之间的权衡。
# 编译动态库
gcc -shared -fPIC mylib.c -o libmylib.so
# 方案一:链接动态库
gcc main.c -L.-lmylib -o main
# 运行前设置动态库路径
export LD_LIBRARY_PATH=.:$LD_LIBRARY_PATH
./main
# 方案二:链接动态库,指定动态库加载路径
gcc -Wl,-rpath=./ main.c -o main -L./ -lmylib
./main

动态库的制作过程

  1. 静态链接阶段
  • 符号解析: 动态库的符号(变量,函数)被标记为“未在可执行文件中定义”
  • .dynamic段,记录动态库依赖信息
    • .dynamic 段是 ELF(Executable and Linkable Format)文件格式中的一种特殊段,用于存储动态链接和动态加载所需的信息。它是动态链接器(dynamic linker)在程序运行时解析动态库依赖关系、符号绑定和重定位的关键数据结构。
  • 生成重定向条目
  1. 动态加载阶段(见下图)

image-20250214111847059

  1. 具体怎么排查?

当动态库缺少依赖符号时,可能会导致程序运行时出现错误,例如 undefined symbolunresolved symbol。以下是排查和解决动态库缺少依赖符号问题的常见方法:

1. 使用 ldd 检查动态库依赖

  • ldd 是一个用于检查程序或动态库依赖关系的工具。

  • 运行以下命令检查动态库的依赖关系:

bash复制

ldd /path/to/your/library.so
  • 如果有未找到的依赖,会显示 not found,例如:

复制

linux-vdso.so.1 (0x00007ffc9d2db000)
libmissinglib.so => not found
  • 解决方法:

  • 确保缺失的库已安装。

  • 如果库已安装,检查库的路径是否在系统的库搜索路径中(如 /etc/ld.so.confLD_LIBRARY_PATH)。

2. 使用 nm 检查符号

  • nm 命令可以列出目标文件(如动态库)中的符号表,检查是否包含所需的符号。

  • 运行以下命令:

bash复制

nm -C /path/to/your/library.so | grep <symbol_name>
  • -C 参数表示对 C++ 符号进行 demangling(解码)。

  • 如果没有输出,说明动态库中不包含该符号。

  • 示例:

bash复制

nm -C /path/to/libmath.so | grep add

如果没有找到 add 符号,说明动态库中未定义该符号。

3. 使用 objdump 检查符号

  • objdump 是一个用于反汇编目标文件的工具,也可以用来检查符号。

  • 运行以下命令:

bash复制

objdump -T /path/to/your/library.so | grep <symbol_name>
  • -T 参数表示显示动态符号表。

  • 示例:

bash复制

objdump -T /path/to/libmath.so | grep add

如果没有找到 add 符号,说明动态库中未定义该符号。

4. 使用 strings 检查符号

  • strings 命令可以提取二进制文件中的可打印字符串,包括符号名称。

  • 运行以下命令:

bash复制

strings /path/to/your/library.so | grep <symbol_name>
  • 示例:

bash复制

strings /path/to/libmath.so | grep add

如果没有找到 add 符号,说明动态库中未定义该符号。

5. 使用 Dependency Walker(Windows)

  • Dependency Walker 是一个用于分析 Windows 动态链接库(DLL)依赖关系的工具。
  • 下载并运行 Dependency Walker,打开目标 DLL 文件,查看其依赖的模块和符号。
  • 如果有未找到的模块或符号,Dependency Walker 会用红色高亮显示。

6. 使用 dumpbin(Windows)

  • dumpbin 是 Visual Studio 提供的一个工具,用于查看 DLL 的依赖关系和导出符号。

  • 运行以下命令:

cmd复制

dumpbin /exports /path/to/your/library.dll
  • 检查输出中是否包含所需的符号。

7. 检查编译和链接选项

  • 确保在编译和链接动态库时,正确包含了所有依赖的符号。
  • 如果使用了 C++,确保在编译和链接时使用了相同的编译器和标准库版本。

8. 检查库路径和环境变量

  • 确保动态库的路径已添加到系统的库搜索路径中:

  • 在 Linux 中,可以设置 LD_LIBRARY_PATH 环境变量:

    bash复制

    export LD_LIBRARY_PATH=/path/to/your/library:$LD_LIBRARY_PATH
  • 在 Windows 中,可以设置 PATH 环境变量。

9. 重新编译和安装库

  • 如果上述方法都无法解决问题,可以尝试重新编译和安装相关库或程序,并重新构建运行环境。

2. 僵尸进程和孤儿进程

  • 僵尸进程(Zombie Process): 进程已经终止,但其父讲程末对其进行回收(调用wait函数数)。僵尸进程占用系统的进程表项,但不再消耗其他资
    源。操作系统会等待其父进程来获取它的终止状态信息,清除僵尸进程。
  • 孤儿进程(Orphan Process): 父讲程提前终止,子进程继续运行,成为孤儿进程。操作系统会将孤儿进程托管给 init 进程(Linux系统中的PID为的进程),由 init 进程来收养并清理这些孤儿进程。

演示僵尸进程

#include <iostream>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

int main() {
    // 父进程和子进程会同时从 fork() 调用之后的代码开始执行,只是它们的进程 ID 和一些其他属性不同。
    // 子进程是父进程的一个拷贝,包括代码和数据,但它们是两个独立的进程。
    pid_t pid = fork();

    if (pid == -1) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // 子进程
        std::cout << "Child process: PID = " << getpid() << std::endl;
        exit(0); // 子进程正常退出
    } else {
        // 父进程
        std::cout << "Parent process: PID = " << getpid() << ", Child PID = " << pid << std::endl;

        // 父进程不调用 wait() 函数来回收子进程的退出状态
        std::cout << "Parent process is not waiting for child to terminate" << std::endl;

        // 父进程继续运行
        while (true) {
            // 为了展示父进程的运行
            std::cout << "Parent process is still running..." << std::endl;
            sleep(1);
        }
    }
    return 0;
}

3. 虚析构函数和虚函数表

image-20250221084659509

每个带有虚函数的类都会有一个虚函数表,其中存储了指向虚函数的指针。当派生类继承基类并覆盖虚函数时,派生类的虚函数表会替换基类的虚函数指针为派生类的实现。

(非析构)虚函数需要同名才能覆盖派生类对应的虚函数表中的指针,而只要基类定义的是一个虚析构函数,派生类在继承的时候,都会将派生类的虚函数表中的指针覆盖为~Derived()

#include <iostream>
using namespace std;

class Base
{
public:
    int *data;

    Base()
    {
        cout << "Base()" << endl;
        data = new int[10]{0};
    }
    virtual ~Base()
    {
        delete[] data;
        cout << "~Base()" << endl;
    }
};

class Derived : public Base
{
public:
    int *derived_data;
    Derived()
    {
        derived_data = new int[10]{0};
        cout << "Derived()" << endl;
    }
    ~Derived()
    {
        delete[] derived_data;
        cout << "~Derived()" << endl;
    }
};
int main()
{ /* 基类指针删除派生类对象时,如果基类没有虚析构函数,会导致派生类的析构函数没有被调用。 */
    Base *d = new Derived();
    delete d;
}

/* 输出 */
Base()
Derived()
~Derived()  // 因为虚函数表动态绑定到派生类析构函数
~Base()     // 析构函数链式调用