跳过正文

实现RDMA库的运行时动态加载(对于client和benchmark)

作者
杨全烨
系统软件:操作系统、网络与分布式系统。
目录

两个PR: valkey主库修改 libvalkey上游 我们主要讲述这个在client和benchmark上做RDMA的两个库的动态加载这两个问题,两个库librdmacm libibverbs

之后最好再阅读一下CSAPP关于动态链接的部分,之后还预计出一篇文章好好讲解一下程序的链接,也是一门大学问。 这个真的值得深挖。

背景
#

最早是字节跳动在进行valkey Over RDMA的开发, 但是只做了对于server的解耦, 但是没有对于client和benchmark的解耦, 这就导致了Opensuse,linux的发行版引来了这样的问题:

Description
- enable RDMA support

Comments & History
Marcus Rueckert (darix) created this request 2 months ago
Neal Gompa's avatar
Neal Gompa (Pharaoh_Atem) commented 2 months ago
target maintainer
I'm checking on this, please be patient. :)

Marcus Rueckert (darix) revoked request 2 months ago 
for some reasons it didnt just link the rdma libraries into the module but also into the main binary

Marcus Rueckert's avatar
Marcus Rueckert (darix) commented about 2 months ago
author
source maintainer
question:

in the fedora package you have split out the rdma module. in the newer versions they also link valkey-cli and valkey-benchmark with the rdma tools (probably to also test and connect via rdma) with that in mind I am not sure if we either need to split out the binaries or build valkey like this:

valkey:
owns everything but the binaries

Requires: valkey-implementation = %{version}
Suggests: valkey-no-rdma

%package no-rdma
Provides: valkey-implementation = %{version}
Conflicts: valkey-implementation
Removepathpostfixes: -no-rdma

%package rdma
Provides: valkey-implementation = %{version}
Conflicts: valkey-implementation
Removepathpostfixes: -rdma
and given that the cli and benchmark link the rdma code anyway we can also switch to rdma=yes

Neal Gompa's avatar
Neal Gompa (Pharaoh_Atem) commented about 2 months ago
target maintainer
I'll check on this with upstream, but I think we probably want to still do it the normal module way without doing duplicate builds. This might be something to fix upstream.

Marcus Rueckert's avatar
Marcus Rueckert (darix) commented about 2 months ago
author
source maintainer
i can understand that they also want the client and bencharmk to be able to talk rdma. and the module way i only would understand to avoid the dependency (7.7MB extra libraries for rdma enabled build) but if we cant get around that dependency anymore. we can also merge rdma support into the main package.

Neal Gompa's avatar
Neal Gompa (Pharaoh_Atem) commented about 2 months ago
target maintainer
I'm pretty sure this is a bug, and I'm checking with upstream about this.

Marcus Rueckert's avatar
Marcus Rueckert (darix) commented about 2 months ago
author
source maintainer
also did you see my asahi mail?

Neal Gompa's avatar
Neal Gompa (Pharaoh_Atem) commented about 2 months ago
target maintainer
No, I'll go look for it. Probably was buried over winter stuff.

Opensuse的打包者必须加上这两个库才行,这肯定是不合理的,这使得打包后的文件变得臃肿, 实际上很多人根本用不到这个功能,一张优秀的RDMA网卡异常昂贵,linux打包者在社区提出issue, 希望进行动态加载,这里其实上需要改动的是一个valkey的上游子仓库libvalkey,我们做了这样的修改,把加载改为动态,并且进行解耦.

静态/硬链接是怎样?
#

1. 静态/硬链接(USE_DLOPEN_RDMA=0
#

Makefile编译的时候:

# Makefile 里
RDMA_LDFLAGS=-lrdmacm -libverbs

自动带上了这两个库,链接器会在 valkey_rdma.so 的 ELF .dynamic 段写入:

NEEDED  librdmacm.so.1
NEEDED  libibverbs.so.1 # 我需要这两个库,这是动态加载

进程启动时,ld-linux-x86-64.so.2(动态链接器)在 main 之前 就会:

  1. mmap() 把这两个 .so 映射进进程虚拟地址空间(我们当前进程的虚拟地址空间)
  2. 解析 .rela.dyn / .rela.plt 重定位项
  3. 填充 GOT/PLT,把 rdma_create_id 等符号解析成真实函数地址

什么是GOT(Global Offset Table 全局偏移表)和PLT(Process Linking Table 过程链接表)表项, 为了加快程序启动速度并节省内存,Linux 采用了延迟绑定(Lazy Binding)技术, 即程序启动时不去解析外部函数的真正地址,直到第一次调用该函数时才进行解析.

其流程如下:

  1. 首次调用:当程序第一次调用某个外部函数(如 rdma_conn)时,执行流程会跳转到对应的 PLT 表项
  2. 触发解析:该 PLT 代码会引导程序去寻找并执行动态链接器(librdmam.so)的代码。
  3. 确定地址:动态链接器在共享库中找到该函数的实际内存地址。
  4. 更新 GOT 表:动态链接器将找到的实际地址写入 GOT 表中对应的位置。
  5. 正常执行:函数执行完毕。
  6. 后续调用:当再次调用该函数时,流程会再次到达 PLT,但此时 PLT 会直接读取 GOT 表中已填好的真实地址,一步到位完成跳转,不再需要动态链接器介入。

这种机制常用于代码的安全保护机制与逆向工程中(如 PLT/GOT Hook),通过修改 GOT 表中的目标地址,可以实现对系统函数调用的拦截或替换。

也就是说,一次就需要把GOT和PLT表填完:

高地址
┌─────────────────────────┐
│  valkey_rdma.so  .text  │  你的 RDMA 代码
├─────────────────────────┤
│  librdmacm.so.1  .text  │  rdma_create_id 等
├─────────────────────────┤
│  libibverbs.so.1  .text │  ibv_reg_mr 等
├─────────────────────────┤
│  libc.so ...
低地址

缺点:没有 RDMA 硬件/库的机器上,ld.so 在启动阶段就会失败error while loading shared libraries),即使你从未调用 RDMA

我们是如何实现运行时动态加载的?
#

编译时  链接 -lrdmacm -libverbs

  ifeq ($(USE_DLOPEN_RDMA),1)
      CFLAGS += -DDLOPEN_RDMA
      RDMA_LDFLAGS=
  else
      RDMA_LDFLAGS=-lrdmacm -libverbs
  endif

valkey_rdma.so 的 .dynamic 里 没有 RDMA 库的 NEEDED 条目。 进程可以正常启动;只有调用 valkeyInitiateRdma() 时才尝试加载 RDMA 库。

dlopen 在底层做了什么?
#

你可以理解这个就是用来找到so目标文件,然后返回一个句柄,和open类似。

我们的加载逻辑是怎么写的?主要是这个函数太抽象了(ง •̀_•́)ง

static int rdma_dyn_load_libs(void) {
    if (rdmacm_handle && ibverbs_handle)
        return 0;

    const char *rdmacm_candidates[] = {"librdmacm.so.1", "librdmacm.so", NULL};
    const char *verbs_candidates[] = {"libibverbs.so.1", "libibverbs.so", NULL};

    for (int i = 0; !rdmacm_handle && rdmacm_candidates[i]; i++)
        rdmacm_handle = dlopen(rdmacm_candidates[i], RTLD_NOW);
    for (int i = 0; !ibverbs_handle && verbs_candidates[i]; i++)
        ibverbs_handle = dlopen(verbs_candidates[i], RTLD_NOW);

    if (!rdmacm_handle || !ibverbs_handle) {
        fprintf(stderr, "Error: Required RDMA libraries (librdmacm/libibverbs) not found on this system.\n");
        return -1;
    }

    LOAD_SYM(rdmacm_handle, rdma_create_event_channel);
    LOAD_SYM(rdmacm_handle, rdma_destroy_event_channel);
    LOAD_SYM(rdmacm_handle, rdma_create_id);
    LOAD_SYM(rdmacm_handle, rdma_create_qp);
    LOAD_SYM(rdmacm_handle, rdma_destroy_id);
    LOAD_SYM(rdmacm_handle, rdma_bind_addr);
    LOAD_SYM(rdmacm_handle, rdma_resolve_addr);
    LOAD_SYM(rdmacm_handle, rdma_resolve_route);
    LOAD_SYM(rdmacm_handle, rdma_connect);
    LOAD_SYM(rdmacm_handle, rdma_disconnect);
    LOAD_SYM(rdmacm_handle, rdma_get_cm_event);
    LOAD_SYM(rdmacm_handle, rdma_ack_cm_event);
    LOAD_SYM(rdmacm_handle, rdma_getaddrinfo);
    LOAD_SYM(rdmacm_handle, rdma_freeaddrinfo);
    LOAD_SYM(rdmacm_handle, rdma_event_str);

    LOAD_SYM(ibverbs_handle, ibv_alloc_pd);
    LOAD_SYM(ibverbs_handle, ibv_dealloc_pd);
    LOAD_SYM(ibverbs_handle, ibv_create_comp_channel);
    LOAD_SYM(ibverbs_handle, ibv_destroy_comp_channel);
    LOAD_SYM(ibverbs_handle, ibv_create_cq);
    LOAD_SYM(ibverbs_handle, ibv_destroy_cq);
    LOAD_SYM(ibverbs_handle, ibv_reg_mr);
    LOAD_SYM(ibverbs_handle, ibv_dereg_mr);
    LOAD_SYM(ibverbs_handle, ibv_get_cq_event);
    LOAD_SYM(ibverbs_handle, ibv_ack_cq_events);
    LOAD_SYM(ibverbs_handle, ibv_destroy_qp);

    return 0;
}

系统调用链(Linux / glibc)
#

dlopen() 是 glibc libdl 提供的 API,内部大致会

  1. open() 打开 librdmacm.so.1(沿 LD_LIBRARY_PATH/etc/ld.so.cache 等搜索,这些库可能缓存或者就放在这里)
  2. mmap() 把 ELF 的 PT_LOAD 段映射进当前进程地址空间 这里就是映射过程表
    • 可读可执行段 → .text函数机器码)
    • 可读可写段 → .data / .bss / .got.plt
  3. 解析 ELF 头、program header、dynamic section
  4. 若该库还有 NEEDED(如 libibverbs 依赖其他库),递归加载
  5. 重定位:修正库内对外部符号的引用
  6. 执行 .init / .init_array 构造函数
  7. 返回 handle(glibc 内部通常是 struct link_map *,指向已加载模块的链表节点)

RTLD_NOW 的含义:在 dlopen 返回前 立即 解析该库的所有未定义符号。若缺符号,当场失败。
对比 RTLD_LAZY:第一次调用某函数时才解析(可能延迟到运行中期才暴露错误)。 对 RDMA 这种关键路径,用 RTLD_NOW 是合理选择——启动 RDMA 时一次性 fail-fast。

handle 是什么
#

static void *rdmacm_handle = NULL;
static void *ibverbs_handle = NULL;

这两个 void * 不是文件描述符, 而是 已映射 SO 在动态链接器内部数据结构中的句柄(就是其实已经打开了)。 后续 dlsym(rdmacm_handle, "xxx") 只在这个 SO(及其已加载依赖)的符号表里查找。

你分别 dlopen 两个库,是因为 rdma_* 在 librdmacm.so.1ibv_* 在 libibverbs.so.1,符号分布在不同 ELF 对象里。

dlsym主要做了什么?
#

我们写的宏:

#define LOAD_SYM(handle, name)                                              \
    do {                                                                    \
        void *tmp_sym = dlsym(handle, #name);                               \
        if (!tmp_sym) {                                                     \
            fprintf(stderr, "RDMA Init Error: missing symbol %s\n", #name); \
            return -1;                                                      \
        }                                                                   \
        *(void **)(&rdma_syms.name) = tmp_sym;                              \
    } while (0)

说白了就是在打开的so里面找符号.

dlsym(handle, "rdma_create_event_channel") 的过程
#

  1. 在 handle 对应 SO 的 .dynsym动态符号表) 里按字符串查找
  2. 找到后读 .symtab 条目:类型(FUNC/OBJECT)、绑定、所在段
  3. 结合 重定位结果,算出该符号在 当前进程虚拟地址空间 中的最终地址
  4. 返回 void *——对函数而言,就是 .text 段里第一条指令的虚拟地址 (所以调用的时候就可以跳转执行了)

从 CPU 视角:之后通过函数指针调用时,就是普通的间接跳转

call *(%rax); 跳转到 librdmacm.so 映射进来的 .text 地址

这和 PLT/GOT 静态链接的路径不同——静态链接走 PLT stub → GOT 条目 → 真实函数; 这个方案是 自己维护一张函数指针表,编译期就定死了调用路径。

*(void **)(&rdma_syms.name) = tmp_sym 这行在干什么
#

C 标准不允许 void * 直接赋给函数指针(严格别名规则)。常见写法是 通过 void ** 中转:

// 等价于:rdma_syms.rdma_create_id = (函数指针类型)dlsym(...)
*(void **)(&rdma_syms.rdma_create_id) = tmp_sym; // 这个中转也很细节

实际上就是把找到的函数指针,赋值给我们的自己定义的结构体成员变量

整个流程大概是这样的:

用户程序
valkeyInitiateRdma()                    // rdma.c:1275
   ├─ [DLOPEN_RDMA] rdma_dyn_load_libs()
   │      │
   │      ├─ dlopen("librdmacm.so.1")  → mmap SO, 重定位, 返回 handle
   │      ├─ dlopen("libibverbs.so.1") → 同上
   │      │
   │      └─ 对每个 API: dlsym → 写入 rdma_syms.*
   └─ valkeyContextRegisterFuncs(...)   // 注册 RDMA 连接类型

简单总结一下: 没有改 RDMA 业务逻辑,而是加了一层 运行时 PLT/GOT 替代品: dlopen 把 SO 映射进进程地址空间dlsym 手动完成符号解析struct + 宏 在不改 call site 的前提下把所有 API 调用重定向到函数指针表—— 从而在 无 RDMA 的机器上也能加载 valkey_rdma.so,只有真正初始化 RDMA 时才触碰 librdmacm / libibverbs

也就是没有调用valkeyInitiateRdma的时候,也就不会走这个加载的函数。