下面是对给出的流程图中 ELF 程序中函数调用过程(以 puts 为例)的详细分析。这反映了 ELF 动态链接中 PLT(Procedure Linkage Table)与 GOT(Global Offset Table)协作进行延迟绑定(lazy binding)的机制。当程序第一次调用一个动态库函数(例如 puts)时,会经过一系列跳转和解析步骤,最终将该函数的实际地址填入 GOT,使之后的调用直接跳转到最终函数入口点。

以下是图中所示流程的详细解析步骤(对应图中标注的序号):

  1. call puts@plt: 源代码中当我们调用 puts("hello") 这类函数时,编译后的代码并不会直接调用 puts 的真实地址。取而代之的是对 puts@plt 的调用(call 指令将控制权转移到 .plt 区域中的 puts 函数 stub)。这是因为在编译阶段还不知道动态链接库中 puts 的确切地址。

  2. *jmp putsgot(首次调用前 putsgot 中为 PLT0 的地址): 在 .plt 区域中针对 puts 的入口通常会先从 GOT 中取得函数的实际地址:

jmp *puts@got(%rip)

但在程序刚加载时,puts@got 这个位置并没有存储 puts 的实际函数地址,而是存放指向 .plt0 段的地址(即解析入口点)。因此,该跳转会转向 PLT0 stub,而非直接跳到 puts 函数体。

  1. .got.plt (GOT 表项): GOT 中储存了动态链接中的信息,如:

  • 0x0: .dynamic 段指针

  • 0x8: 指向 link_map 结构的指针,用于运行时链接器了解当前进程加载的动态库信息。

  • 0x10: 存放对应函数(puts)的重定位信息以及将来要填入的实际函数入口地址。

    初始时,puts@got 的值会指向 .plt0 的一段通用解析代码。此处的目的是在函数第一次调用时,对函数进行真正的符号解析和重定位。

  1. .plt0(通用延迟解析入口代码): 当代码跳转到 PLT0 时,会执行类似如下指令序列:

pushq GOT+8        ; 推入 link_map 指针(用于动态解析器,描述已加载库)
jmp *GOT+0x10      ; 跳转至 _dl_runtime_resolve

此时,控制权交给运行时链接器(通常是 ld-linux.so 或者类似的动态加载器内部函数 _dl_runtime_resolve)。这个函数知道如何根据被调用的符号(此处为 puts)在已加载的共享对象中找到正确的地址。

  1. dlruntimeresolve 动态解析_dl_runtime_resolve 会读取压入栈中的信息(如被调用函数索引、对应的 linkmap 信息),然后在已加载的共享库中找到该符号(例如在 libc 中查找 puts),取得 puts 的实际内存地址。

    找到地址后,动态解析器将该函数的真实地址写回到 puts@got 的条目中。这样下次调用同一函数时,jmp *puts@got 将不再跳转到解析入口,而是会直接跳转到已解析好的实际 puts 函数地址。

总结

  • 第一次调用: 程序调用 puts@pltjmp *puts@got(此时指向 PLT0) → PLT0 通过 _dl_runtime_resolve 查找符号地址 → 将真实的 puts 地址回填到 puts@got

  • 后续调用: 下一次再执行到 puts@plt 时,jmp *puts@got 已经是 puts 的真实函数地址,不需要再进行解析,直接跳到真实的 puts 函数执行。

整个过程即实现了“延迟绑定”:只有在函数第一次被调用时才进行符号解析和 GOT 表项回填,从而减少程序启动时的初始解析开销。

知道函数的libc之后,我们可以libcsearcher去查询,或者在线查询