TCM 用户空间设计

设计

TCM 是内核内 iSCSI 目标(服务器)LIO 的另一个名称。现有的 TCM 目标在内核中运行。TCMU(用户空间中的 TCM)允许编写用户空间程序作为 iSCSI 目标。本文档描述了其设计。

现有的内核为不同的 SCSI 传输协议提供了模块。TCM 也将数据存储模块化。目前有用于文件、块设备、RAM 或使用另一个 SCSI 设备作为存储的模块。这些被称为“后端存储”或“存储引擎”。这些内置模块完全作为内核代码实现。

背景

除了将用于承载 SCSI 命令的传输协议(“结构”)模块化之外,Linux 内核目标 LIO 还将实际的数据存储模块化。这些被称为“后端存储”或“存储引擎”。该目标带有后端存储,允许使用文件、块设备、RAM 或另一个 SCSI 设备作为导出的 SCSI LUN 所需的本地存储。与 LIO 的其余部分一样,这些都完全作为内核代码实现。

这些后端存储涵盖了最常见的用例,但并非全部。其他非内核目标解决方案(例如 tgt)能够支持的一个新用例是使用 Gluster 的 GLFS 或 Ceph 的 RBD 作为后端存储。然后,该目标充当翻译器,允许启动器将数据存储在这些非传统的网络存储系统中,同时仍然只使用标准协议本身。

如果目标是用户空间进程,则支持这些很简单。例如,tgt 只需要为每个后端存储提供一个小的适配器模块,因为这些模块仅使用 RBD 和 GLFS 的可用用户空间库。

在 LIO 中添加对这些后端存储的支持要困难得多,因为 LIO 完全是内核代码。与其进行大量工作将 GLFS 或 RBD API 和协议移植到内核,另一种方法是为 LIO 创建一个用户空间直通后端存储,“TCMU”。

优势

除了允许相对容易地支持 RBD 和 GLFS 之外,TCMU 还将允许更轻松地开发新的后端存储。TCMU 与 LIO 回环结构结合使用,类似于 FUSE(用户空间文件系统),但位于 SCSI 层而不是文件系统层。可以将其理解为 SCSI 层的 FUSE。

缺点是需要配置的组件更多,并且可能发生故障。这是不可避免的,但如果我们小心地尽可能保持简单,希望不会致命。

设计约束

  • 良好的性能:高吞吐量,低延迟

  • 如果用户空间

    1. 从未附加

    2. 挂起

    3. 死亡

    4. 行为不端

  • 允许用户和内核实现具有未来的灵活性

  • 合理地节省内存

  • 易于配置和运行

  • 易于编写用户空间后端

实现概述

TCMU 接口的核心是内核和用户空间之间共享的内存区域。在此区域内:有一个控制区域(邮箱);一个无锁的生产者/消费者循环缓冲区,用于传递命令和返回状态;以及一个输入/输出数据缓冲区区域。

TCMU 使用预先存在的 UIO 子系统。UIO 允许在用户空间中开发设备驱动程序,这在概念上与 TCMU 用例非常接近,只是 TCMU 实现的是专为 SCSI 命令设计的内存映射布局,而不是物理设备。使用 UIO 还可以通过处理设备自检(例如,让用户空间确定共享区域大小的方法)以及双向信号机制来使 TCMU 受益。

内存区域中没有嵌入的指针。一切都表示为相对于该区域起始地址的偏移量。如果用户进程死亡并重新启动,且该区域映射到不同的虚拟地址,这允许环仍然工作。

有关结构定义,请参阅 target_core_user.h。

邮箱

邮箱始终位于共享内存区域的开头,包含版本、有关命令环的起始偏移量和大小的详细信息,以及内核和用户空间(分别)用于在环上放置命令以及指示命令何时完成的头尾指针。

version - 1(否则用户空间应中止)

标志
  • TCMU_MAILBOX_FLAG_CAP_OOOC

    指示支持乱序完成。有关详细信息,请参阅“命令环”。

cmdr_off

命令环的起始位置相对于内存区域起始位置的偏移量,用于考虑邮箱大小。

cmdr_size

命令环的大小。这需要是 2 的幂。

cmd_head

由内核修改以指示何时在环上放置命令。

cmd_tail

由用户空间修改以指示何时完成对命令的处理。

命令环

内核通过将 mailbox.cmd_head 增加命令的大小(模 cmdr_size)并将结果放置在环上,然后通过uio_event_notify()向用户空间发送信号来将命令放置在环上。一旦命令完成,用户空间以相同的方式更新 mailbox.cmd_tail 并通过 4 字节的 write() 向内核发送信号。当 cmd_head 等于 cmd_tail 时,环为空 — 当前没有命令在等待用户空间处理。

TCMU 命令是 8 字节对齐的。它们以一个公共标头开头,其中包含“len_op”,这是一个 32 位值,存储长度以及最低的未使用位中的操作码。它还包含 cmd_id 和标志字段,供内核 (kflags) 和用户空间 (uflags) 设置。

当前仅定义了两个操作码,TCMU_OP_CMD 和 TCMU_OP_PAD。

当操作码为 CMD 时,命令环中的条目是一个 struct tcmu_cmd_entry。用户空间通过 tcmu_cmd_entry.req.cdb_off 查找 SCSI CDB(命令数据块)。这是相对于整个共享内存区域的起始位置的偏移量,而不是相对于条目的偏移量。可以通过 req.iov[] 数组访问输入/输出数据缓冲区。iov_cnt 包含 iov[] 中需要描述数据输入或数据输出缓冲区的条目数。对于双向命令,iov_cnt 指定有多少 iovec 条目覆盖数据输出区域,iov_bidi_cnt 指定紧随其后的 iov[] 中有多少 iovec 条目覆盖数据输入区域。与其他字段一样,iov.iov_base 是相对于区域起始位置的偏移量。

完成命令时,用户空间设置 rsp.scsi_status 以及必要时的 rsp.sense_buffer。然后,用户空间将 mailbox.cmd_tail 增加 entry.hdr.length(mod cmdr_size),并通过 UIO 方法(向文件描述符写入 4 个字节)向内核发送信号。

如果为 mailbox->flags 设置了 TCMU_MAILBOX_FLAG_CAP_OOOC,则内核能够处理乱序完成。在这种情况下,用户空间可以以不同于原始顺序的顺序处理命令。由于内核仍然会按照命令在命令环中出现的顺序处理命令,因此用户空间在完成命令时需要更新 cmd->id(即窃取原始命令的条目)。

当操作码为 PAD 时,用户空间仅如上所述更新 cmd_tail — 它是空操作。(内核插入 PAD 条目以确保每个 CMD 条目在命令环内是连续的。)

将来可能会添加更多操作码。如果用户空间遇到它不处理的操作码,则必须在 hdr.uflags 中设置 UNKNOWN_OP 位(第 0 位),更新 cmd_tail,并继续处理其他命令(如果有)。

数据区域

这是命令环之后的共享内存空间。此区域的组织未在 TCMU 接口中定义,用户空间应仅访问由挂起的 iov 引用的部分。

设备发现

除了 TCMU 之外,其他设备也可能正在使用 UIO。不相关的用户进程也可能正在处理不同的 TCMU 设备集。TCMU 用户空间进程必须通过扫描 sysfs class/uio/uio*/name 来查找其设备。对于 TCMU 设备,这些名称的格式如下

tcm-user/<hba_num>/<device_name>/<subtype>/<path>

其中 “tcm-user” 对于所有由 TCMU 支持的 UIO 设备都是通用的。<hba_num> 和 <device_name> 允许用户空间在内核目标的 configfs 树中找到设备的路径。假设通常的挂载点,它位于

/sys/kernel/config/target/core/user_<hba_num>/<device_name>

此位置包含诸如“hw_block_size”之类的属性,用户空间需要知道这些属性才能正确操作。

<subtype> 将是一个用户空间进程唯一的字符串,用于标识 TCMU 设备,该设备期望由某个处理程序支持,<path> 将是一个额外的特定于处理程序的字符串,供用户进程在需要时配置设备。由于 LIO 限制,名称不能包含“:”。

对于所有如此发现的设备,用户处理程序打开 /dev/uioX 并调用 mmap()

mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0)

其中 size 必须等于从 /sys/class/uio/uioX/maps/map0/size 读取的值。

设备事件

如果添加或删除新设备,将通过 netlink 广播通知,使用名为“TCM-USER”的通用 netlink 族名称和一个名为“config”的多播组。 这将包括上一节中描述的 UIO 名称以及 UIO 次设备号。 这应该允许用户空间识别 UIO 设备和 LIO 设备,以便在确定设备受支持(基于子类型)后,它可以采取适当的操作。

其他意外情况

用户空间处理程序进程从未附加

  • TCMU 将发布命令,然后在超时时间段(30 秒)后中止它们。

用户空间处理程序进程被杀死

  • 仍然可以重新启动并重新连接到 TCMU 设备。 命令环被保留。 但是,在超时时间段过后,内核将中止挂起的任务。

用户空间处理程序进程挂起

  • 内核将在超时时间段后中止挂起的任务。

用户空间处理程序进程是恶意的

  • 该进程可以很容易地破坏它控制的设备的处理,但不应该能够访问其共享内存区域之外的内核内存。

编写用户直通处理程序(带有示例代码)

处理 TCMU 设备的用户进程必须支持以下内容

  1. 发现和配置 TCMU uio 设备

  2. 等待设备上的事件

  3. 管理命令环:解析操作和命令,根据需要执行工作,设置响应字段(scsi_status 和可能的 sense_buffer),更新 cmd_tail,并通知内核工作已完成

首先,考虑为 tcmu-runner 编写插件。 tcmu-runner 实现了所有这些,并为插件作者提供了更高级别的 API。

TCMU 的设计使得多个不相关的进程可以单独管理 TCMU 设备。 所有处理程序都应确保仅根据已知的子类型字符串打开其设备。

  1. 发现和配置 TCMU UIO 设备

    /* error checking omitted for brevity */
    
    int fd, dev_fd;
    char buf[256];
    unsigned long long map_len;
    void *map;
    
    fd = open("/sys/class/uio/uio0/name", O_RDONLY);
    ret = read(fd, buf, sizeof(buf));
    close(fd);
    buf[ret-1] = '\0'; /* null-terminate and chop off the \n */
    
    /* we only want uio devices whose name is a format we expect */
    if (strncmp(buf, "tcm-user", 8))
      exit(-1);
    
    /* Further checking for subtype also needed here */
    
    fd = open(/sys/class/uio/%s/maps/map0/size, O_RDONLY);
    ret = read(fd, buf, sizeof(buf));
    close(fd);
    str_buf[ret-1] = '\0'; /* null-terminate and chop off the \n */
    
    map_len = strtoull(buf, NULL, 0);
    
    dev_fd = open("/dev/uio0", O_RDWR);
    map = mmap(NULL, map_len, PROT_READ|PROT_WRITE, MAP_SHARED, dev_fd, 0);
    
    
    b) Waiting for events on the device(s)
    
    while (1) {
      char buf[4];
    
      int ret = read(dev_fd, buf, 4); /* will block */
    
      handle_device_events(dev_fd, map);
    }
    
  1. 管理命令环

    #include <linux/target_core_user.h>
    
    int handle_device_events(int fd, void *map)
    {
      struct tcmu_mailbox *mb = map;
      struct tcmu_cmd_entry *ent = (void *) mb + mb->cmdr_off + mb->cmd_tail;
      int did_some_work = 0;
    
      /* Process events from cmd ring until we catch up with cmd_head */
      while (ent != (void *)mb + mb->cmdr_off + mb->cmd_head) {
    
        if (tcmu_hdr_get_op(ent->hdr.len_op) == TCMU_OP_CMD) {
          uint8_t *cdb = (void *)mb + ent->req.cdb_off;
          bool success = true;
    
          /* Handle command here. */
          printf("SCSI opcode: 0x%x\n", cdb[0]);
    
          /* Set response fields */
          if (success)
            ent->rsp.scsi_status = SCSI_NO_SENSE;
          else {
            /* Also fill in rsp->sense_buffer here */
            ent->rsp.scsi_status = SCSI_CHECK_CONDITION;
          }
        }
        else if (tcmu_hdr_get_op(ent->hdr.len_op) != TCMU_OP_PAD) {
          /* Tell the kernel we didn't handle unknown opcodes */
          ent->hdr.uflags |= TCMU_UFLAG_UNKNOWN_OP;
        }
        else {
          /* Do nothing for PAD entries except update cmd_tail */
        }
    
        /* update cmd_tail */
        mb->cmd_tail = (mb->cmd_tail + tcmu_hdr_get_len(&ent->hdr)) % mb->cmdr_size;
        ent = (void *) mb + mb->cmdr_off + mb->cmd_tail;
        did_some_work = 1;
      }
    
      /* Notify the kernel that work has been finished */
      if (did_some_work) {
        uint32_t buf = 0;
    
        write(fd, &buf, 4);
      }
    
      return 0;
    }
    

最后说明

请注意返回 SCSI 规范定义的代码。 这些代码与 scsi/scsi.h 头文件中定义的某些值不同。 例如,CHECK CONDITION 的状态代码为 2,而不是 1。