3、RDMA编程基础:libibverbs库介绍、设备查询、PD创建、QP创建与修改

好,咱们正式开始动手写RDMA代码了。说实话,很多新手一上来就被各种概念砸晕——保护域、完成队列、工作请求……其实没那么复杂。今天我就带你把这些基础组件一个一个捋清楚。

3.1 libibverbs库:RDMA编程的基石

libibverbs是RDMA编程的核心库。说白了,它就是一套用户态API,让你能直接操作RDMA网卡(HCA)。我刚开始接触时,总觉得它跟socket编程很像,但用起来才发现——完全不是一回事。

这个库主要提供几个能力:

  • 设备发现与查询——找到机器上有哪些RDMA设备
  • 资源管理——创建PD、MR、QP、CQ这些核心对象
  • 数据传输——提交WR(工作请求)、轮询CQ(完成队列)

你想想看,如果没有libibverbs,你就得直接跟内核的verbs驱动打交道,那代码量……嗯,我当年试过一次,写了两天还没调通一个简单的pingpong。

核心头文件:

#include <infiniband/verbs.h>

所有RDMA编程都从这个头文件开始。它定义了ibv_开头的所有数据结构与函数。

3.2 设备查询:找到你的HCA

写RDMA程序的第一步,就是看看机器上有哪些可用的RDMA设备。我习惯用ibv_get_device_list()来获取设备列表。

来看一段最基础的设备查询代码:

#include <stdio.h>
#include <infiniband/verbs.h>

int main() {
    struct ibv_device **dev_list;
    int num_devices, i;

    // 获取设备列表
    dev_list = ibv_get_device_list(&num_devices, NULL);
    if (!dev_list) {
        fprintf(stderr, "获取设备列表失败\n");
        return -1;
    }

    printf("发现 %d 个RDMA设备:\n", num_devices);
    for (i = 0; i < num_devices; i++) {
        printf("  设备 %d: %s\n", i, ibv_get_device_name(dev_list[i]));
    }

    // 释放设备列表
    ibv_free_device_list(dev_list);
    return 0;
}

这段代码很简单,但有个坑——ibv_get_device_list()返回的列表,用完一定要释放。我曾经忘了释放,结果程序跑久了内存泄漏,排查了半天才发现是这里的问题。

小技巧:可以用ibv_get_device_name()获取设备名,比如"mlx5_0"、"mlx5_1"。但注意,这个名称跟ibstat命令看到的端口名不一样,别搞混了。

3.3 打开设备与查询设备属性

找到设备后,下一步就是打开它。用ibv_open_device()来打开,然后可以查询它的详细属性。

struct ibv_context *context;
struct ibv_device_attr device_attr;

// 打开第一个设备
context = ibv_open_device(dev_list[0]);
if (!context) {
    fprintf(stderr, "打开设备失败\n");
    return -1;
}

// 查询设备属性
if (ibv_query_device(context, &device_attr)) {
    fprintf(stderr, "查询设备属性失败\n");
    return -1;
}

printf("设备属性:\n");
printf("  FW版本: %s\n", device_attr.fw_ver);
printf("  最大QP数: %d\n", device_attr.max_qp);
printf("  最大CQ数: %d\n", device_attr.max_cq);
printf("  最大MR大小: %lu\n", device_attr.max_mr_size);

这里有个细节——ibv_open_device()返回的是ibv_context指针。这个context就是后续所有操作的"通行证"。我习惯把它叫做ctx,几乎所有RDMA代码里都能看到这个变量名。

注意:不同厂商的HCA,支持的属性可能不一样。比如Mellanox的卡支持GID表,但有些软RoCE设备就不支持。写代码时最好做兼容性检查。

3.4 PD(保护域)的创建

PD,全称Protection Domain,保护域。它是个啥?你可以把它理解成一个"安全沙箱"。同一个PD内的资源才能互相访问。

创建PD很简单:

struct ibv_pd *pd;
pd = ibv_alloc_pd(context);
if (!pd) {
    fprintf(stderr, "分配PD失败\n");
    return -1;
}

嗯,就一行代码。但它的作用可大了——MR(内存区域)、QP(队列对)、SRQ(共享接收队列)都必须关联到一个PD。不同PD之间的资源不能混用。

我在项目中遇到过一个问题:两个线程各自创建了PD,然后A线程的QP想访问B线程的MR,结果一直报错。查了半天才发现是PD不匹配。所以记住:同一个通信链路的所有资源,必须在同一个PD下创建

PD的作用总结:

  • 隔离不同应用的资源
  • 控制内存访问权限
  • 一个PD可以关联多个QP和MR
  • 不同PD的资源不能互相访问

3.5 QP(队列对)的创建与修改

QP是RDMA通信的核心。它由两个队列组成:发送队列(SQ)和接收队列(RQ)。数据发送走SQ,接收走RQ。

3.5.1 创建QP

创建QP需要先初始化一个ibv_qp_init_attr结构体:

struct ibv_qp_init_attr qp_init_attr;
struct ibv_qp *qp;

memset(&qp_init_attr, 0, sizeof(qp_init_attr));
qp_init_attr.send_cq = cq;          // 发送完成队列
qp_init_attr.recv_cq = cq;          // 接收完成队列(可以跟发送共用)
qp_init_attr.qp_type = IBV_QPT_RC;  // RC:可靠连接
qp_init_attr.cap.max_send_wr = 16;  // 最大发送WR数
qp_init_attr.cap.max_recv_wr = 16;  // 最大接收WR数
qp_init_attr.cap.max_send_sge = 1;  // 最大发送SGE数
qp_init_attr.cap.max_recv_sge = 1;  // 最大接收SGE数

qp = ibv_create_qp(pd, &qp_init_attr);
if (!qp) {
    fprintf(stderr, "创建QP失败\n");
    return -1;
}

这里有几个关键点:

  • QP类型:RC(可靠连接)最常用,UC(不可靠连接)和UD(不可靠数据报)用得少
  • CQ:完成队列,用来通知你操作完成了。可以SQ和RQ共用同一个CQ
  • cap:容量参数,设太小会导致操作失败,设太大浪费内存

我的经验:max_send_wr和max_recv_wr一般设16或32就够了。除非你确定需要更高的并发,否则别设太大。我曾经设了1024,结果内存占用暴涨,性能反而没提升。

3.5.2 修改QP状态

QP创建后,不能直接用。它需要经过一系列状态转换:

状态 说明 下一步
RESET 初始状态 → INIT
INIT 初始化完成,可以添加接收WR → RTR
RTR 准备好接收数据 → RTS
RTS 准备好发送和接收数据 正常工作
SQD 发送队列 drained 修改属性
SQE 发送队列错误 需要重置
ERR 错误状态 需要重置

状态转换通过ibv_modify_qp()实现。来看一个从RESET到INIT的例子:

struct ibv_qp_attr qp_attr;
int flags;

memset(&qp_attr, 0, sizeof(qp_attr));
qp_attr.qp_state = IBV_QPS_INIT;
qp_attr.pkey_index = 0;
qp_attr.port_num = 1;
qp_attr.qp_access_flags = IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE;

flags = IBV_QP_STATE | IBV_QP_PKEY_INDEX | IBV_QP_PORT | IBV_QP_ACCESS_FLAGS;

if (ibv_modify_qp(qp, &qp_attr, flags)) {
    fprintf(stderr, "QP状态转换失败 (RESET -> INIT)\n");
    return -1;
}

然后从INIT到RTR:

qp_attr.qp_state = IBV_QPS_RTR;
qp_attr.path_mtu = IBV_MTU_1024;
qp_attr.dest_qp_num = remote_qpn;  // 对端QP号
qp_attr.rq_psn = 0;                // 接收端包序列号
qp_attr.max_dest_rd_atomic = 1;    // 最大未完成读/原子操作
qp_attr.min_rnr_timer = 12;        // RNR超时时间

flags = IBV_QP_STATE | IBV_QP_AV | IBV_QP_PATH_MTU |
        IBV_QP_DEST_QPN | IBV_QP_RQ_PSN |
        IBV_QP_MAX_DEST_RD_ATOMIC | IBV_QP_MIN_RNR_TIMER;

if (ibv_modify_qp(qp, &qp_attr, flags)) {
    fprintf(stderr, "QP状态转换失败 (INIT -> RTR)\n");
    return -1;
}

最后从RTR到RTS:

qp_attr.qp_state = IBV_QPS_RTS;
qp_attr.timeout = 14;              // 本地超时时间
qp_attr.retry_cnt = 7;             // 重试次数
qp_attr.rnr_retry = 7;             // RNR重试次数
qp_attr.sq_psn = 0;                // 发送端包序列号
qp_attr.max_rd_atomic = 1;         // 最大未完成读/原子操作

flags = IBV_QP_STATE | IBV_QP_TIMEOUT | IBV_QP_RETRY_CNT |
        IBV_QP_RNR_RETRY | IBV_QP_SQ_PSN | IBV_QP_MAX_QP_RD_ATOMIC;

if (ibv_modify_qp(qp, &qp_attr, flags)) {
    fprintf(stderr, "QP状态转换失败 (RTR -> RTS)\n");
    return -1;
}

避坑指南:我曾经在RTR到RTS转换时忘了设置sq_psn,结果对端一直收不到数据。排查了两天才发现——PSN必须跟对端协商一致。另外,timeout值别设太小,否则网络抖动一下QP就挂了。

3.6 清理资源

程序退出时,记得释放所有资源。顺序很重要:

ibv_destroy_qp(qp);
ibv_dealloc_pd(pd);
ibv_close_device(context);
ibv_free_device_list(dev_list);

先销毁QP,再释放PD,最后关闭设备。顺序反了可能会报错。

本章核心要点:

  • libibverbs是RDMA编程的基础库,所有操作都通过它完成
  • 设备查询用ibv_get_device_list + ibv_open_device
  • PD是资源隔离的边界,同一通信链路的所有资源必须在同一个PD下
  • QP创建后需要经过RESET→INIT→RTR→RTS的状态转换才能使用
  • 状态转换时,flags参数必须包含所有要修改的属性,漏一个就报错

好了,这一章的内容就到这儿。下一章咱们会讲MR(内存注册)和实际的收发操作。到时候就能真正跑起来数据了,想想还有点小激动呢。