ifunc

why

最近被问到了一个之前没思考过的问题。memcpy这类内存函数总所周知是cpu-feature bound的,即memcpy会根据cpu特性的实现多个版本。例如各个cpu支持的向量指令不同。现在问题来了,每一次调用memcpy时都要先通过cpuid或者xgetbv等指令先检测支持的指令集,再选择采用哪个版本吗?如果memcpy的数据量较大,那么检测时间或许可以忽略不计,但是数据量较小时就不能忍受了。通常,在程序的运行期间,一个函数引用对应的实现是不会改变的,因此只要解析一次函数调用即可,后续调用不必再解析。可以看出,该需求和plt很类似。

what

如果函数func有多个版本,为了避免每次调用func时都进行一遍版本选择,可以将函数func的符号类型定义为STT_GNU_IFUNC,并且为func实现相应的函数解析器。之后在elf装载时,会进行函数解析,将选择的版本记录下来,以后每一次调用func都会自动跳转到对应的版本。这就是ifunc。

对该类函数的引用是通过R_*_IRELATIVE 重定位间接处理的,该重定位返回运行解析器的结果,即返回指向所选实现的函数指针。

接下来以一个具体的例子来说明整个过程。

第一步,编译glibc

为了更方便调试,手动编译glibc,加入调试信息

1
2
3
4
5
6
#!/bin/bash
mkdir -p build && cd build
rm -rf ./*
CFLAGS="-g -g3 -ggdb -gdwarf-4 -O2" CXXFLAGS="-g -g3 -ggdb -gdwarf-4 -O2" ../configure --enable-debug --prefix=$HOME/glibc
make -j$(($(nproc) - 1))
make install

第二步,编写/编译测试用例

以如下代码为例,跟踪一下memcmp函数的重定位过程

1
2
3
4
5
6
7
8
9
10
11
12
13
// demo
#include <string.h>
#include <stdlib.h>

int main() {
char a[12] = "Hello world";
char b[12] = "Hello homes";

if (rand()%2 == 1)
return memcmp(a, b, 10);

return 0;
}
1
2
gcc try_mem.c -o try_mem -g
patchelf --set-interpreter $(HOME)/glibc/lib/ld-linux-x86-64.so.2 --set-rpath $(HOME)/glibc/lib try_mem # 设置链接器和库搜索路径为刚才编译的debug版本的glibc

第三步,gdb跟踪

1、先start到main函数入口

ifunc_memcmp_debug

2、step到memcmp调用处

可能需要多试几次,因为rand()的结果是随机的。为什么不直接调用memcmp,而是在rand()%2时才调用memmcp呢?因为我们都知道由于plt的存在,在函数第一次被调用时才会真正执行重定位,那么为了避免编译器分析出memcmp这个函数调用一定会被执行导致在加载时直接重定位,我们就加了一个判断条件。

ifunc_memcmp_call_plt

可以看到,调用memcmp其实是调用转到memcmp对应的plt表项。

3、进入memcmp 的plt表项

memcmp的plt表项会查看对应的got.plt表项,got.plt表项中存放的是函数的真正地址。

但是在第一次重定位之前,函数对应的got.plt表项应该是resolver函数地址的,因此第一次查询plt时,实际是执行resolver,将函数的真正地址存放到对应的.got.plt表项中后,之后再调用函数时,直接转到该地址处即可。

ifunc_memcmp_plt

4、跳转到memcmp函数的resolver

ifunc_memcmp_imple

???为什么直接进到memcmp的具体实现了,这说明resolver已经执行过了,什么时候执行的,resolver又是怎么实现的?

5、寻找resolver的调用

既然resolver已经执行过了,并且将函数的真实地址放在.got.plt表项中,那么我们可以watch该.got.plt表项,在第3步可以看到该.got.plt表项的地址是*0x555555557fc8

ifunc_memcmp_call_resolver

通过watch .got.plt表项,可以找到该.got.plt表项被修改的位置:sysdeps/x86_64/dl-machine.h:412(commit:ae515ba530be76d6627740ddc33a3a63f8c7e4f9)。

再往上翻阅代码,可以发现,真正执行resolver的位置如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// sysdeps/x86_64/dl-machine.h
// 在进入main函数之前,动态链接器会进行符号重定位,将符号对应的真实地址放在got表项中。
static inline void __attribute__((always_inline))
elf_machine_rela(struct link_map *map, struct r_scope_elem *scope[],
const ElfW(Rela) *reloc, const ElfW(Sym) *sym,
const struct r_found_version *version,
void *const reloc_addr_arg, int skip_ifunc) {
ElfW(Addr) *const reloc_addr = reloc_addr_arg;
const unsigned long int r_type = ELFW(R_TYPE) (reloc->r_info);

...
{
struct link_map *sym_map = RESOLVE_MAP (map, scope, &sym, version,
r_type);
ElfW(Addr) value = SYMBOL_ADDRESS (sym_map, sym, true); // value为link_map->l_addr + sym->st_value,如果符号非STT_GNU_IFUNC,那么value即为该符号在内存中的真实地址,否则value是该符号重定位的解析函数(resolver)地址

if (sym != NULL
&& __glibc_unlikely (ELFW(ST_TYPE) (sym->st_info) == STT_GNU_IFUNC) // 符号是STT_GNU_IFUNC类型时,调用解析函数选择对应的具体函数实现
&& __glibc_likely (sym->st_shndx != SHN_UNDEF)
&& __glibc_likely (!skip_ifunc))
{
...
value = ((ElfW(Addr) (*) (void)) value) (); // 此时value为sysdeps/x86_64/multiarch/ifunc-memcmp.h:IFUNC_SELECTOR的地址,调用解析函数,根据处理器特性,选择最优的实现版本
}

那么我们在value = ((ElfW(Addr) (*) (void)) value) ();这一行处打上断点,就可以知道memcmp对应的resolver的具体实现了,如下所示。

ifunc_memcmp_enter_resolver

进入resolver,如下所示

ifunc_memcmp_resolver_assemble

这里很奇怪:gdb无法显示resolver对应的源码。

不过没关系,我们仍然可以找到对应的源码:每个ifunc函数都必须实现对应的resolver,因此resolver必定在glibc源码中。这里我们已经有resolver对应的汇编了,这就好办了,objdump glibc,找到汇编对应的源码位置即可,如下所示。

ifunc_memcmp_resolver_objdump

可以看到,memcmp的resolver位于sysdeps/x86_64/multiarch/ifunc-memcmp.h:38

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
static inline void *
IFUNC_SELECTOR (void)
{
const struct cpu_features *cpu_features = __get_cpu_features ();

if (X86_ISA_CPU_FEATURE_USABLE_P (cpu_features, AVX2)
&& X86_ISA_CPU_FEATURE_USABLE_P (cpu_features, MOVBE)
&& X86_ISA_CPU_FEATURE_USABLE_P (cpu_features, BMI2)
&& X86_ISA_CPU_FEATURES_ARCH_P (cpu_features,
AVX_Fast_Unaligned_Load, ))
{
if (X86_ISA_CPU_FEATURE_USABLE_P (cpu_features, AVX512VL)
&& X86_ISA_CPU_FEATURE_USABLE_P (cpu_features, AVX512BW))
return OPTIMIZE (evex_movbe);

if (CPU_FEATURE_USABLE_P (cpu_features, RTM))
return OPTIMIZE (avx2_movbe_rtm);

if (X86_ISA_CPU_FEATURES_ARCH_P (cpu_features,
Prefer_No_VZEROUPPER, !))
return OPTIMIZE (avx2_movbe);
}

return OPTIMIZE (sse2);
}

通过上述代码可以得知,memcmp的resolver是根据指令集测试的结果来选择具体的函数实现的。

how

readelf -s elf 可以查看elf的所有符号。

libc_ifunc

可以看到,strncpy和memcpy等字符串处理函数和内存处理函数的符号类型都是STT_GNU_IFUNC

以下是一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* Dispatching via IFUNC ELF Extension */
#include <stddef.h>
#include <stdio.h>

void foo_c(unsigned *data, size_t len) { printf("%s\n", __FUNCTION__); }
void foo_sse42(unsigned *data, size_t len) { printf("%s\n", __FUNCTION__); }
void foo_avx2(unsigned *data, size_t len) { printf("%s\n", __FUNCTION__); }

int cpu_has_sse42(void)
{
return 0;
}

int cpu_has_avx2(void)
{
return __builtin_cpu_supports("avx");
#if defined(__AVX2__)
return 1;
#endif
return 0;
}

void foo(unsigned *data, size_t len) __attribute__((ifunc ("resolve_foo")));

static void *resolve_foo(void)
{
if (cpu_has_avx2())
return foo_avx2;
else if (cpu_has_sse42())
return foo_sse42;
else
return foo_c;
}

int main(void)
{
foo(NULL, 0);

return 0;
}

TODO

  • 为什么gdb无法显示memcmp的resolver对应的源码?
  • 为什么明明有plt section,_dl_start函数仍然在memcmp第一次执行前就把.got.plt装载好,即提前执行了resolver?

ifunc
http://example.com/ifunc/
作者
Yw
发布于
2024年5月5日
许可协议