RISCV64应用符号解析的实现机制
Linux应用程序的动态链接
在嵌入式应用开发过程中,可能会遇到一些应用程序的运行错误,例如某个符号不能成功解析:
./testapp: symbol lookup error: ./testapp: undefined symbol: cJSON_Parse
这通常是由依赖动态库版本不一致导致的。出现以上错误时,可能程序已经运行了一段时间,这说明应用依赖外部符号解析是一个动态的过程,并不是在应用启动时完成的;这个功能特点称为Lazy Binding。可以通过配置环境变量LD_BIND_NOW使应用在启动时就完成所有的动态链接符号解析(当然dlopen
解析的除外):
export LD_BIND_NOW=1
本文记录了笔者在RISCV64
Linux环境下分析简单应用的符号动态解析的大致过程,使用的简单应用代码如下:
/* testapp.c */
#include <stdio.h>
#include <string.h>
#include <stdlib.h>typedef int (* pfunc)(FILE *, const char *, ...);
int main(int argc, char *argv[])
{pfunc func;func = fprintf;func(stdout, "`fprintf entry address: %#lx\n", (unsigned long) func);fflush(stdout);func(stdout, "Application will now terminte: %#lx\n", (unsigned long) getpid());fflush(stdout);return 0;
}
应用内部的C语言函数地址
以上代码编译得到testapp
,运行结果如下:
root@OpenWrt:~# ./testapp
`fprintf entry address: 0x10470
Application will now terminte: 0x125b
依笔者开发嵌入式应用的经验,fprintf
函数定义于glibc
库中,而通常动态库代码段的地址会比较高,这一点与上面的运行结果不符。对应用testapp
反汇编可得:
0000000000010470 <fprintf@plt>:10470: 00002e17 auipc t3,0x210474: ba8e3e03 ld t3,-1112(t3) # 12018 <fprintf@GLIBC_2.27>10478: 000e0367 jalr t1,t31047c: 00000013 nop
这说明在应用内部,会对外部引用的(函数)符号创建一小段PLT
代码。接下来我们要分析这个代码段是如何解析并跳转到glibc
中的fprintf
函数中的。
可执行文件ELF的PLT
段和GOT.PLT
段
注意到,上面反汇编得到的fprintf@plt
函数位于plt
代码段,这个代码段是只读的;与之相应的,还存在一个got.plt
段,该段是可读写的(以下结果由IDA反汇编工具得到):
.got:0000000000012008 __TMC_END__: .dword -1 # DATA XREF: .plt:0000000000010458↑r
.got:0000000000012008 # .plt:0000000000010460↑o ...
.got:0000000000012010 qword_12010: .dword 0 # DATA XREF: .plt:0000000000010468↑r
.got:0000000000012018 fprintf_ptr: .dword __imp_fprintf # DATA XREF: fprintf↑r
.got:0000000000012020 __libc_start_main_ptr:.dword __imp___libc_start_main
.got:0000000000012020 # DATA XREF: __libc_start_main↑r
.got:0000000000012028 fflush_ptr: .dword __imp_fflush # DATA XREF: fflush↑r
.got:0000000000012030 getpid_ptr: .dword __imp_getpid # DATA XREF: getpid↑r
.got:0000000000012038 _GLOBAL_OFFSET_TABLE_:.dword _DYNAMIC
.got:0000000000012040 fprintf_ptr_0: .dword __imp_fprintf # DATA XREF: main+10↑r
.got:0000000000012048 stdout_ptr: .dword stdout # DATA XREF: main+8↑r
GLIBC
的动态链接解析后,会将这个表中的fprintf_ptr
写入实际的fprintf
函数在libc.so.6
中实际加载的虚拟地址。在调试分析动态库的解析大致流程前,我们先了解一下plt
代码段的实现。应用程序在链接时,静态链接器会应用文件中写入plt
段,这一点在binutils中的代码可以得到确认:
/* binutils-2.33.1/bfd/elfnn-riscv.c */
/* Generate a PLT header. */
static bfd_boolean riscv_make_plt_header (bfd *output_bfd, bfd_vma gotplt_addr, bfd_vma addr, uint32_t *entry)
{
.../* auipc t2, %hi(.got.plt)sub t1, t1, t3 # shifted .got.plt offset + hdr size + 12l[w|d] t3, %lo(.got.plt)(t2) # _dl_runtime_resolveaddi t1, t1, -(hdr size + 12) # shifted .got.plt offsetaddi t0, t2, %lo(.got.plt) # &.got.pltsrli t1, t1, log2(16/PTRSIZE) # .got.plt offsetl[w|d] t0, PTRSIZE(t0) # link mapjr t3 */entry[0] = RISCV_UTYPE (AUIPC, X_T2, gotplt_offset_high);entry[1] = RISCV_RTYPE (SUB, X_T1, X_T1, X_T3);entry[2] = RISCV_ITYPE (LREG, X_T3, X_T2, gotplt_offset_low);...return TRUE;
}
相应的,笔者反汇编testapp
可以得到以下结果:
Disassembly of section .plt:
0000000000010450 <_PROCEDURE_LINKAGE_TABLE_>:10450: 00002397 auipc t2,0x210454: 41c30333 sub t1,t1,t310458: bb83be03 ld t3,-1096(t2) # 12008 <__TMC_END__>1045c: fd430313 addi t1,t1,-44 # ffffffffffffcfd4 <__global_pointer$+0xfffffffffffea7d4>10460: bb838293 addi t0,t2,-109610464: 00135313 srli t1,t1,0x110468: 0082b283 ld t0,8(t0)1046c: 000e0067 jr t30000000000010470 <fprintf@plt>:10470: 00002e17 auipc t3,0x210474: ba8e3e03 ld t3,-1112(t3) # 12018 <fprintf@GLIBC_2.27>10478: 000e0367 jalr t1,t31047c: 00000013 nop
接下来我们着重调试这两段反汇编代码。
fprintf@plt
的跳转调试
笔者在地址0x10474
加入断点,可见fprintf@plt
的第一条指令执行后,会到当前指令的一个偏移地址(即0x12470
);在它的-1112
字节处是符号fprintf@got.plt
地址(即0x12018
地址,上面被IDA反汇编工具命名为fprintf_ptr
),它应当保存glibc
中函数fprintf
的虚拟地址,但却得到了0x10450
(这是plt
解析的入口地址,对应符号_PROCEDURE_LINKAGE_TABLE_
):
Breakpoint 1, 0x0000000000010474 in fprintf@plt ()
(gdb) i r t3
t3 0x12470 74864
(gdb) x/1xg 0x12470-1112
0x12018 <fprintf@got.plt>: 0x0000000000010450
(gdb) stepi
0x0000000000010478 in fprintf@plt ()
(gdb) i r t3
t3 0x10450 66640`
换句话说,当符号解析完成后,地址0x12018
处应当被写入fprintf
函数的实际地址。fprintf@plt
的第三条指令jalr t1, t3
,会跳转到t3
寄存器中的地址,并将下一条指令地址(即0x1047c
)写入t1
寄存器。这个寄存器在接下来的跳转后会参与计算。接着继续调试,得到以下结果。
(gdb) tbreak *0x10468
Temporary breakpoint 2 at 0x10468
(gdb) c
Continuing.
Temporary breakpoint 2, 0x0000000000010468 in _PROCEDURE_LINKAGE_TABLE_ ()
(gdb) i r t0 t1 t2 t3
t0 0x12008 73736
t1 0x0 0
t2 0x12450 74832
t3 0x3ff7ff37bc 274743637948
(gdb)
这个结果需要结合汇编和注释来说明:
0000000000010450 <_PROCEDURE_LINKAGE_TABLE_>:10450: auipc t2,0x2 # t2 = 指令寄存器偏移,0x12450 (用于GOT.PLT地址访问)10454: sub t1,t1,t3 # 该XXXX@plt的nop指令到PLT的地址偏移10458: ld t3,-1096(t2) # GOT.PLT的第一个8字节,指向 _dl_runtime_resolve函数地址1045c: addi t1,t1,-44 # PLT到fprintf@plt的nop指令偏移量,共44字节10460: addi t0,t2,-1096 # t0 = GOT.PLT 的起始地址,在应用链时确定该固定值10464: srli t1,t1,0x1 # t1 = N * (16 / 2),对于 fprintf@plt,N值为0,16为xxx@plt的大小10468: ld t0,8(t0) # GOT.PLT的第二个8字节,是 `link_map的指针1046c: jr t3 # 跳转到_dl_runtime_resolve函数
上面的汇编注释说明了,GOT.PLT
的前两个8字节分别对应_dl_runtime_resolve
函数地址,以及struct link_map
指针。本文不深入讨论link_map
及其符号解析的过程。这两个指针是在应用启动时,由动态链接器写入的(在ELF文件中,两者为-1和0):
/* glibc-2.29/ysdeps/riscv/dl-machine.h */
auto inline int __attribute__ ((always_inline))
elf_machine_runtime_setup (struct link_map *l, int lazy, int profile)
{/* If using PLTs, fill in the first two entries of .got.plt. */...gotplt[0] = (ElfW(Addr)) &_dl_runtime_resolve;gotplt[1] = (ElfW(Addr)) l;...return lazy;
}
动态链接符号解析后更新fprintf@got.plt
根据上面的调试结果,我们可以直接跟踪0x12018
地址处(fprintf@got.plt
)内存监视,查看何时被更新了:
(gdb) watch *((unsigned long *) 0x12018)
Watchpoint 3: *((unsigned long *) 0x12018)
(gdb) c
Continuing.Watchpoint 3: *((unsigned long *) 0x12018)Old value = 66640
New value = 274742727904
0x0000003ff7fef0d8 in _dl_fixup (l=<optimized out>, reloc_arg=<optimized out>) at dl-runtime.c:149
149 dl-runtime.c: No such file or directory.
(gdb) bt
#0 0x0000003ff7fef0d8 in _dl_fixup (l=<optimized out>, reloc_arg=<optimized out>) at dl-runtime.c:149
#1 0x0000003ff7ff37f2 in _dl_runtime_resolve () at ../sysdeps/riscv/dl-trampoline.S:61
Backtrace stopped: frame did not save the PC
(gdb) info address fprintf
Symbol "fprintf" is at 0x3ff7f154e0 in a file compiled without debugging.
上面的内存监视断点是比较消耗CPU的,因为笔者用的RISCV64
芯片不支持硬件断点。地址0x12018
处被写入了274742727904
,即十六进制的0x3ff7f154e0
,对应着fprintf
函数在libc.so.6
库中的虚拟地址。这样GLIBC
动态链接器就完成了一个函数符号的解析。这里省略了_dl_fixup
两个函数的细节,因为其中的内容太多了,不便展开。下面我们关注一下_dl_runtime_resolve
的实现,因为这段汇编相对简单:
/* glibc-2.29/sysdeps/riscv/dl-trampoline.S */
ENTRY (_dl_runtime_resolve)
...# Update .got.plt and obtain runtime address of callee.slli a1, t1, 1mv a0, t0 # link mapadd a1, a1, t1 # reloc offset (== thrice the .got.plt offset)la a2, _dl_fixupjalr a2mv t1, a0...
END (_dl_runtime_resolve)
在上现对PLT
代码断的注释中,有一行:
10464: srli t1,t1,0x1 # t1 = N * (16 / 2)
这里的N
是指一个xxx@plt
在PLT
代码段中的序号。对于fprintf@plt
来说,它是第一个,即之前的计算结果使得t1
的寄存器为0;16是一个xxx@plt
段的大小,共4条指令16字节。以上代码又提到了:
add a1, a1, t1 # reloc offset (== thrice the .got.plt offset)
即将t1
乘以3后赋给a1
寄存器。为什么要这样操作?因为一个重定位结构体的大小是24个字节:
/* glibc-2.29/elf/elf.h */
typedef struct {Elf64_Addr r_offset; /* Address */Elf64_Xword r_info; /* Relocation type and symbol index */Elf64_Sxword r_addend; /* Addend */
} Elf64_Rela;
结合两者可以得到:
N * (16 / 2) * 3 = N * 24 = N * sizeof(struct Elf64_Rela)
这样就可以通过xxx@plt
在PLT
代码段中的偏移得到与其对应的重定位结构体的偏移了(对应代码中的PLTREL
,即结构体Elf64_Rela
)。不过需要注意的是,PLT
代码断与GOT.PLT
段中的符号表,存在一个顺序对应的关系,否则以下代码不能成立:
/* glibc-2.29/elf/dl-runtime.c */
attribute_hidden __attribute ((noinline)) ARCH_FIXUP_ATTRIBUTE
_dl_fixup (struct link_map *l, ElfW(Word) reloc_arg)
{
...const PLTREL *const reloc= (const void *) (D_PTR (l, l_info[DT_JMPREL]) + reloc_offset); /* reloc_offset = reloc_arg */...
最后,函数符号解析完成后,动态链接器还不忘了调用已解析的函数:
/* glibc-2.29/sysdeps/riscv/dl-trampoline.S */
ENTRY (_dl_runtime_resolve)...# Invoke the callee.jr t1
END (_dl_runtime_resolve)
总结
本文笔者对RISCV64
平台,应用运行时的符号解析做了初步的探究,了解了RISCV64
平台应用的PLT
代码段和GOT.PLT
段的结构。但并没有深入分析ELF
可执行文件的格式及符号解析的查找、比对等功能实现(对应GLIBC
代码中的_dl_fixup
函数),感兴趣的伙伴可以深入调试分析。