Holoscan Sensor Bridge 应用指南(Applications)

原文

本文档覆盖 Applications 章节全部七个web:Applications(应用)、Architecture(架构)、Latency(延迟)、New Sensors(新传感器)、UDDF Drivers(UDDF 驱动)、Hololink Module 应用教程、Hololink Module 设备驱动教程。
代码与命令保留英文原文;文中【图:……】表示原页面此处的插图。


第一章 应用(Applications)

Holoscan 应用通过指定一系列算子(operator)来构建。将一个算子的输出连接到另一个算子的输入(通过 add_flow API),即可配置 Holoscan 的流水线,并规定各个算子何时可以运行。

Holoscan sensor bridge 借助这一框架,提供在 Holoscan 应用中收发数据的算子与对象。此外还有一些算子,用于把特定应用的数据(例如 CSI-2 格式的视频数据)转换成其他标准 Holoscan 算子可以接受的输入格式。为了说明 sensor bridge 应用的工作方式,我们以 IMX274 播放器示例为主线逐步讲解。

1.1 imx274_player

examples/imx274_player.py 中的应用配置了如下流水线。当流水线的一次循环结束时,执行会回到起点,重新采集并处理新数据。

RoceReceiverOp

CsiToBayerOp

ImageProcessorOp

BayerDemosaicOp

HolovizOp

  • RoceReceiverOp 在收到"帧结束"UDP 报文时被唤醒。当它完成时,接收到的帧数据已在 GPU 显存中就绪,同时元数据被发布到应用层。Holoscan sensor bridge 使用 RoCE v2 通过 UDP 传输数据平面流量——这就是接收算子被命名为 RoceReceiverOp 的原因。
  • CsiToBayerOp 知道接收到的数据是 CSI-2 RAW10 图像,并将其转换为 Bayer 视频帧。图像中每个像素的颜色分量被解码并以 uint16 值存储。关于 RAW10 的更多信息,请参阅 MIPI CSI-2 规范
  • ImageProcessorOp 调整接收到的 Bayer 图像的颜色与亮度,使其适合显示。
  • BayerDemosaicOp 将 Bayer 图像数据转换为 RGBA。
  • HolovizOp 在 GUI 上显示 RGBA 图像。

在流水线的每一步中,图像数据都存放在 GPU 显存的缓冲区中。流水线各元素之间只传递指向这些数据的指针,避免了主机与 GPU 显存之间昂贵的内存拷贝。每个算子的功能都由 GPU 加速执行,从而实现极低延迟的运行。

Python 的 imx274_player.py 与 C++ 的 imx274_player.cpp 文件就是以这种方式初始化传感器桥设备、相机和流水线的。为增强可读性,下面省略了一些细节——请务必查看实际示例代码以了解完整内容。

Python:

  import hololink as hololink_module

  def main():
      # 获取 GPU 句柄
      cuda.cuInit(0)
      cu_device_ordinal = 0
      cu_device = cuda.cuDeviceGet(cu_device_ordinal)
      cu_context = cuda.cuDevicePrimaryCtxRetain(cu_device)

      # 监听 sensor bridge 枚举报文;只返回我们要找的那一个
      channel_metadata = hololink_module.Enumerator.find_channel(channel_ip="192.168.0.2")
      # 用该枚举数据实例化一个数据接收对象
      hololink_channel = hololink_module.DataChannel(channel_metadata)

      # 现在可以通信了,创建相机控制器
      camera = hololink_module.sensors.imx274.dual_imx274.Imx274Cam(hololink_channel, ...)

      # 建立 Holoscan 流水线
      application = HoloscanApplication(cu_context, cu_device_ordinal, camera, hololink_channel, ...)
      application.config(...)

      # 连接并初始化 sensor bridge 设备
      hololink = hololink_channel.hololink()
      hololink.start()  # 建立与 sensor bridge 设备的连接
      hololink.reset()  # 将 sensor bridge 驱动到已知状态

      # 将相机配置为 4K、每秒 60 帧
      camera_mode = imx274_mode.Imx274_Mode.IMX274_MODE_3840X2160_60FPS
      camera.setup_clock()
      camera.configure(camera_mode)

      # 运行 Holoscan 流水线
      application.run()  # 通常不会从这个调用返回
      hololink.stop()

C++:

  #include <hololink/core/data_channel.hpp>
  #include <hololink/core/enumerator.hpp>
  #include <hololink/core/hololink.hpp>

  int main(int argc, char** argv)
  {
    // 获取 GPU 句柄
    cuInit(0);
    int cu_device_ordinal = 0;
    CUdevice cu_device;
    cuDeviceGet(&cu_device, cu_device_ordinal);
    CUcontext cu_context;
    cuDevicePrimaryCtxRetain(&cu_context, cu_device);

    // 监听 sensor bridge 枚举报文;只返回我们要找的那一个
    hololink::Metadata channel_metadata = hololink::Enumerator::find_channel(hololink_ip);
    // 用该枚举数据实例化一个数据接收对象
    hololink::DataChannel hololink_channel(channel_metadata);

    // 导入 IMX274 传感器模块与 IMX274 模式
    py::module_ imx274 = py::module_::import("hololink.sensors.imx274");
    py::object Imx274Cam = imx274.attr("dual_imx274").attr("Imx274Cam");

    // 现在可以通信了,创建相机控制器
    py::object camera = Imx274Cam("hololink_channel"_a = hololink_channel, ...);

    // 建立 Holoscan 流水线
    auto application = holoscan::make_application<HoloscanApplication>(...)
    application->config(...)

    // 连接并初始化 sensor bridge 设备
    std::shared_ptr<hololink::Hololink> hololink = hololink_channel.hololink();
    hololink->start(); // 建立与 sensor bridge 设备的连接
    hololink->reset(); // 将 sensor bridge 驱动到已知状态

    // 将相机配置为 4K、每秒 60 帧
    camera.attr("setup_clock")();
    camera.attr("configure")(Imx274_Mode(0));

    // 运行 Holoscan 流水线
    application->run(); // 通常不会从这个调用返回
    hololink->stop();
  }

重要细节:

  • Enumerator.find_channel 会阻塞调用者,直到找到匹配给定条件的枚举报文。如果没有找到匹配的设备,该方法会超时(默认 20 秒)并抛出异常。Holoscan sensor bridge 的枚举报文每秒发送一次。
  • Holoscan sensor bridge 设备为每个数据平面控制器发送枚举报文,目前每个数据平面控制器与设备上的每个以太网接口一一对应。如果一台设备的两个接口都连接到主机,主机会收到来自同一台传感器桥设备的两条不同的枚举报文,每个数据端口一条。
  • 枚举报文发往本地广播地址,而路由器不允许将这些本地广播报文转发到其他网络。因此,主机与传感器桥设备之间必须是本地连接,才能枚举到它。
  • Enumerator.find_channel 返回一个名/值对字典,包含被发现的该数据端口的标识信息,包括 MAC ID、IP 地址、设备内所有可编程组件的版本、设备序列号,以及该数据端口控制器在设备内的具体实例编号。IP 地址可能会变化,但 MAC ID、序列号与数据平面控制器实例是固定不变的。主机不需要主动请求该字典中的任何数据——它们全部由传感器桥设备广播发出。
  • DataChannel 是传感器桥设备上某个数据平面的本地控制器。它包含配置该数据平面上所发送报文目标地址的 API——下文介绍的接收算子会用到这些 API。
  • 在本例中,camera 对象提供了应用层会访问的大部分 API。当应用配置相机时,相机对象知道如何与各种传感器桥控制器对象协作,从而正确地配置 DataChannel
  • 通常一个 Hololink 传感器桥设备上会有多个 DataChannel 实例,Hololink 设备上的许多 API 会作用于该设备上的所有 DataChannel 对象。在本例中,调用 hololink.reset 会复位此设备上的所有数据通道;而在立体 IMX274 配置中,调用 camera.setup_clock 设置的是两路相机共享的时钟。因此应用必须谨慎调用 camera.setup_clock——在第一路相机正在运行时复位时钟(例如为第二个图像传感器复位)会导致未定义的状态。

在调用 application.run 时,Holoscan 会调用应用的 compose 方法,其中包含如下内容:

Python:

  class HoloscanApplication(holoscan.core.Application):
      def __init__(self, ..., camera, hololink_channel, ...):
          ...
          self._camera = camera
          self._hololink_channel = hololink_channel
          ...
      def compose(self):
          ...
          # 创建 CSI 到 Bayer 的转换器。
          csi_to_bayer_operator = hololink_module.operators.CsiToBayerOp(...)

          # 之前的 camera.configure(...) 调用已经设置了图像尺寸
          # 和每像素字节数。这个调用让相机相应地配置转换器。
          self._camera.configure_converter(csi_to_bayer_operator)

          # csi_to_bayer_operator 现在知道了图像尺寸和每像素字节数,
          # 可以计算出接收图像数据的总大小。
          frame_size = csi_to_bayer_operator.get_csi_length()

          # 创建一个填充帧缓冲区的接收对象。接收算子知道如何
          # 配置 hololink_channel 把数据发给我们,并在恰当的时机
          # 给出帧结束指示。
          receiver_operator = hololink_module.operators.RoceReceiverOp(
              hololink_channel,
              frame_size, ...)
          ...
          # 用 add_flow 把算子连接起来:
          ...
          #   receiver_operator.compute() 之后会执行 csi_to_bayer_operator.compute()
          self.add_flow(receiver_operator, csi_to_bayer_operator, {("output", "input")})
          ...

C++:

  class HoloscanApplication : public holoscan::Application {
  public:
      explicit HoloscanApplication(..., py::object camera, hololink::DataChannel& hololink_channel, ...)
          : ...
          , camera_(camera)
          , hololink_channel_(hololink_channel)
          ...
      {
      }

      void compose() override
      {
          ...
          // 创建 CSI 到 Bayer 的转换器。
          auto csi_to_bayer_operator = make_operator<hololink::operators::CsiToBayerOp>(...);

          // 之前的 camera.attr("configure")(...) 调用已经设置了图像尺寸
          // 和每像素字节数。这个调用让相机相应地配置转换器。
          camera_.attr("configure_converter")(csi_to_bayer_operator);

          // csi_to_bayer_operator 现在知道了图像尺寸和每像素字节数,
          // 可以计算出接收图像数据的总大小。
          const size_t frame_size = csi_to_bayer_operator->get_csi_length();

          // 创建一个填充帧缓冲区的接收对象。接收算子知道如何
          // 配置 hololink_channel 把数据发给我们,并在恰当的时机
          // 给出帧结束指示。
          auto receiver_operator = make_operator<hololink::operators::RoceReceiverOp>(
              holoscan::Arg("hololink_channel", &hololink_channel_),
              holoscan::Arg("frame_size", frame_size), ...);
          ...
          // 用 add_flow 把算子连接起来:
          ...
          //   receiver_operator.compute() 之后会执行 csi_to_bayer_operator.compute()
          add_flow(receiver_operator, csi_to_bayer_operator, { { "output", "input" } });
          ...
      }

  private:
      const py::object camera_;
      hololink::DataChannel& hololink_channel_;
  };

几个关键点:

  • receiver_operator 并不知道自己处理的是视频数据。它只被告知要填充的内存区域和数据块的大小。当完整的数据块接收完成时,CPU 会收到通知,流水线处理得以继续。
  • 给定预期的帧大小后,接收缓冲区会分配一块足够容纳接收数据外加附加元数据的 GPU 显存;这块显存的分配方式会满足硬件与后续算子的要求。
  • csi_to_bayer_operator 了解 CSI-2 格式图像数据的内存布局。我们对 camera.configure_converter 的调用让相机能够把图像尺寸与像素深度传达给它;有了这些信息,csi_to_bayer_operator.get_csi_length 就能返回管理这些图像所需的内存块大小。这个内存大小不仅包括图像数据本身,还包括 CSI-2 元数据以及 GPU 显存对齐的要求。因为 CsiToBayerOp 是 GPU 加速的函数,它可能有相机传感器对象并不知晓的特殊内存要求。
  • receiver_operatorholoscan_channel 协作来配置传感器桥数据平面。配置过程会自动处理:把我们的主机以太网地址与 IP 地址、目标内存地址、安全密钥以及帧大小信息设置到传感器桥设备上。
  • 传感器桥设备在 holoscan_channel 对象完成配置后,会开始把接收到的所有传感器数据转发给配置好的接收方。此时我们还没有指示相机开始推流,但接收端已经准备就绪。
  • receiver_operator 跟踪一个 device 参数,在本应用中就是我们的相机。当 receiver_operator.start 被调用时,它会调用 device.start——在我们的 IMX274 实现中,这会指示相机开始推流。

在本例中,receiver_operator 是一个 RoceReceiverOp 实例,它利用了 ConnectX 固件中的 RDMA 加速特性。使用 RoceReceiverOp 时,CPU 只会在该帧最后一个报文到达时看到一次中断——在此之前发送的所有帧数据都在后台被写入 GPU 显存。在没有 ConnectX 设备的系统中,LinuxReceiverOperator 提供相同的功能,但使用主机 CPU 与 Linux 内核来接收到达的 UDP 报文,并由 CPU 将负载数据写入 GPU 显存。它提供与 RoceReceiverOp 相同的功能,但性能要低得多。

1.2 linux_tsn_imx274_player

examples/linux_tsn_imx274_player.py 在标准 Linux IMX274 播放器之上增加了时间敏感网络(TSN)支持。在 Holoscan 流水线启动之前,它会为 FPGA 编程 PTP profile 与域(domain),并在传感器虚拟端口与 EVT 通道上启用 802.1Q VLAN 标记。视频流水线本身与 Linux IMX274 播放器完全相同。

LinuxReceiverOperator

CsiToBayerOp

ImageProcessorOp

BayerDemosaicOp

HolovizOp

TSN 配置顺序

在构造 DataChannel 之前,使用 DataChannel.use_vlan() 把 VLAN 参数写入通道元数据。PTP 则在 reset() 之后直接在 Hololink 对象上编程。随后当接收方调用 configure_roce()configure_coe() 时,VLAN 标记会被自动应用。

# 在构造 DataChannel 之前:
hololink_module.DataChannel.use_vlan(
    channel_metadata,
    vlan_id=args.vlan_id,
    sensor_pcp=args.sensor_pcp,
    evt_pcp=args.evt_pcp,
)
hololink_channel = hololink_module.DataChannel(channel_metadata)

# 在 hololink.reset() 之后:
hololink.configure_ptp(args.ptp_profile, args.ptp_domain)
# 当接收方配置通道时,VLAN 标记会被自动应用。

DataChannel.use_vlan() 会校验 vlan_id 是否在 [1, 4094] 范围内,否则抛出 ValueError。VLAN 配置覆盖两个目标:

  • Sensor VP —— 用给定的 VLAN ID 与 sensor_pcp 为来自虚拟端口的传感器数据流量打标。
  • EVT —— 用相同的 VLAN ID 与 evt_pcp 为 FPGA 事件通知流量打标。

PTP API

Hololink 对象上提供三个用于 PTP 配置的便捷方法:

方法写入的 Profile适用场景
configure_ptp(profile, domain)调用者指定任意 profile,或在运行时切换 profile
configure_gptp(domain)1(gPTP / IEEE 802.1AS)主机上运行 gptp4l
configure_1588(domain)0(IEEE 1588 E2E)主机上运行 ptp4l

configure_gptpconfigure_1588configure_ptp 的薄封装。

主机侧 VLAN 要求

来自 HSB 的带 VLAN 标记的报文会到达 VLAN 子接口(例如 <iface>.<vlan-id>)。需要创建子接口并把传感器桥 IP 分配给它:

sudo ip link add link <iface> name <iface>.<vlan-id> type vlan id <vlan-id>
sudo ip link set <iface>.<vlan-id> up
sudo ip addr add 192.168.0.101/32 dev <iface>.<vlan-id>

如果没有子接口,内核的 VLAN 解复用会把报文投递到子接口,而父接口上的 socket 将看不到它们。

1.3 tao_peoplenet

examples/tao_peoplenet.py 中包含一个使用推理生成视频叠加层的演示应用。Tao PeopleNet 用于确定实时视频流中行人、包和人脸的位置。该示例程序在叠加层上绘制边界框,展示这些目标在视频帧中被检测到的位置。

流水线结构:

live video

overlay

RoceReceiverOp

CsiToBayerOp

ImageProcessorOp

BayerDemosaicOp

ImageShiftToUint8Operator

FormatConverterOp

FormatInferenceInputOp

InferenceOp

PostprocessorOp

HolovizOp

给视频流水线添加推理很简单:只需加入相应的算子与数据流。在本例中,我们使用 HolovizOp 内置的视频混合器来显示推理生成的叠加层。RoceReceiverOp 被指定为始终提供最新接收到的视频帧,因此如果流水线的一次迭代耗时超过一个帧周期,下一次循环将始终处理最新接收到的视频帧。

1.4 body_pose_estimation

人体姿态估计应用以实时视频为输入,使用 YOLOv8 pose 模型进行推理,然后把关键点叠加显示在原始视频上。关键点包括:

[鼻尖、左眼、右眼、左耳、右耳、左肩、右肩、左肘、右肘、左腕、右腕、左髋、右髋、左膝、右膝、左踝、右踝]

此应用的流水线与行人检测应用相同:

live video

overlay

RoceReceiverOp

CsiToBayerOp

ImageProcessorOp

BayerDemosaicOp

ImageShiftToUint8Operator

FormatConverterOp

FormatInferenceInputOp

InferenceOp

PostprocessorOp

HolovizOp

不同之处在于 InferenceOp 使用 YOLOv8 pose 模型,且后处理算子包含针对 YOLOv8 模型的特定后处理逻辑:它接收推理的输出,过滤掉低分数的检测结果,并应用非极大值抑制(NMS),然后再把输出发送给 HolovizOp

1.5 IMX274 立体实时视频演示

可以实例化多个接收算子来支持多路相机的数据输入。在 examples/stereo_imx274_player.py 中,实时视频流水线被实例化了两次,分别对应 IMX274 立体相机板上的每一路相机。在这种情况下,Holoscan 在两条流水线之间轮转执行,并在显示器上提供两个独立的窗口(每个可视化器一个)。每个 receiver_operator 实例都是独立的,并且同时运行。

对于只有一个网络连接的系统,Holoscan Sensor Bridge 可以配置为通过同一条网络连接传输两路相机的数据。HSB 上的 10Gbps 网口带宽不足以支持两路 4K 60FPS 视频流,因此仅支持相机工作在 1080p 模式。examples/single_network_stereo_imx274_player.py 展示了如何配置 HSB 以这种方式工作。与前面一样,即使使用同一个网络接口,每个 receiver_operator 也是相互独立的。

1.6 GPIO 示例应用

此应用演示了如何使用 hololink GPIO 接口,代码位于 hololink/examples 目录下。

hololink GPIO 接口支持编号 0…15 的 16 个 GPIO。这些 GPIO 可以被设置为输入输出,并具有两个逻辑电平值:

  • High(高) —— GPIO 引脚上可测得 3.3V
  • Low(低) —— GPIO 引脚上可测得 0V

下图标示了 hololink 板上的 GPIO 引脚与接地引脚:

【图:GPIO 接口引脚分布】

从图中可以看出:

  • 连接器四角的 4 个引脚是接地引脚(上图中标记为 “G”)
  • 下方两个接地引脚之间的一排引脚是编号 0 到 7 的 GPIO 引脚
  • 上方两个接地引脚之间的一排引脚是编号 8 到 15 的 GPIO 引脚

GPIO 示例应用流程

GPIO 示例是一个由 2 个算子组成的简单应用,如下图所示:

GpioSetOp

GpioReadOp

  • GPIO Set 算子 —— 该算子遍历 16 个 GPIO 引脚,按 5 种不同引脚配置逐一设置它们的方向与电平值:
  1. ALL_OUT_L —— 全部引脚输出低电平
  2. ALL_OUT_H —— 全部引脚输出高电平
  3. ALL_IN —— 全部引脚输入
  4. ODD_OUT_H —— 奇数引脚输出高电平,偶数引脚输入
  5. EVEN_OUT_H —— 偶数引脚输出高电平,奇数引脚输入

该算子的每次循环都会按当前运行的配置设置一个引脚的方向与电平,并把最后修改的引脚编号和当前配置发送给 GPIO Read 算子。当 16 个引脚全部按当前配置设置完毕后,算子会在下一轮循环切换到下一个配置。

  • GPIO Read 算子 —— 该算子读取并显示最后一个被配置引脚的当前值。它会延时 10 秒,以便用户使用万用表或示波器等外部测量设备验证引脚电平与方向。

GPIO 软件接口

GPIO 接口是 hololink 模块中定义的一个类。它导出以下 GPIO 接口:

  1. get_gpio() —— 从 hololink 模块获取一个 GPIO 接口实例
  2. set_direction(pin, direction) —— 设置引脚方向为输入或输出
  3. get_direction(pin) —— 获取引脚当前设置的方向
  4. set_value(pin, value) —— 对方向为输出的引脚,设置其电平为高或低
  5. get_value(pin) —— 对方向为输入的引脚,读取其电平值(高或低)
  • 引脚编号 —— 范围 0 到 15
  • 引脚方向 —— 枚举值:IN-1,OUT-0
  • 引脚电平值 —— 枚举值:HIGH-1,LOW-0

1.7 用于实时采集的 NVIDIA ISP

Jetson 板内置了 ISP(图像信号处理)单元支持,用于处理 Bayer 图像并输出标准色彩空间的图像。该 ISP 在 iGPU 配置的 Jetson Orin AGX 与 Orin IGX 上可用。

examples/linux_hwisp_player.py 中的 ISP 示例应用配置了如下流水线。当流水线的一次循环结束时,执行会回到起点,重新采集并处理新数据。

LinuxReceiverOperator

CsiToBayerOp

ArgusIspOp

HolovizOp

ArgusIspOp 让用户可以通过 Argus API 访问 ISP。该算子接收每像素 uint16(MSB 对齐)的未压缩 Bayer 图像,输出 RGB888 图像。它以 C++ 算子形式提供,并带有 Python 绑定。

在应用层面,ArgusIspOp 可以使用以下必需参数进行配置。下面是一个现有 Python 示例中的片段:

  argus_isp = hololink_module.operators.ArgusIspOp(
      self,
      name="argus_isp",
      bayer_format=bayer_format.value, # RGGB 或其他 Bayer 格式
      exposure_time_ms=16.67,          # 曝光时间(毫秒)。60fps 为 16.67ms
      analog_gain=10.0,                # 最小模拟增益
      pixel_bit_depth=10,              # 每像素输入的有效位深
      pool=isp_pool,
  )

ArgusIspOp 的输入是未压缩为每像素 uint16 的 Bayer 图像。uint16 中的数值应当 MSB 对齐:例如相机传感器输出 Raw10 时,这 10 位应当在 16 位中按 MSB 对齐。目前 ArgusIspOp 支持的输出为 Rec 709 标准色彩空间、经过伽马校正的 RGB888。

在 Jetson Orin AGX 上,上述流水线在 1920x1080 @ 60fps 分辨率下的玻璃到显示(glass-to-display)延迟为 37ms。

关于 ISP 能力与用法的更多问题,请联系 NVIDIA。

1.8 ECam0M30ToF 播放器

ecam0m30tof_player.py 应用演示了 ECam0M30ToF 飞行时间(ToF)相机的使用,展示如何在流水线中处理深度与红外(IR)数据。此应用使用 RoCE 实现高性能数据传输,并支持多种相机模式以适应不同使用场景。

RoceReceiverOp

CsiToBayerOp

ImageShiftAndProcessingOperator

HolovizOp

ImageShiftAndProcessingOperator 是一个为处理深度与 IR 数据而设计的新算子。它执行以下操作:

  • 将深度数据转换为灰度图用于可视化
  • 在组合模式下处理双平面数据(深度 + IR)
  • 执行数据格式转换与归一化

HolovizOp 同时提供主动 IR 与深度数据的渲染,其中深度数据使用 DEPTH_MAP 选项进行 3D 渲染。

1.9 音频播放器(Audio Player)

Hololink 板包含一个 I2S(Inter-IC Sound)音频外设,在 FPGA 与外部音频设备(如 DAC、编解码器或放大器)之间提供数字音频链路。它在 FPGA 一侧使用 32 位 AXI-Stream 接口传输音频采样,并使用 APB 寄存器接口进行配置与状态读取。

在板卡引脚上,该外设驱动标准 I2S 信号:位时钟(BCLK)、字选择/左右声道时钟(LRCLK)、主时钟(MCLK)与串行数据(SDATA)。在内部,可配置的时钟分频器从参考时钟导出 MCLK、BCLK 与 LRCLK,以产生所需的音频采样率。

提供两个示例应用:

  • linux_audio_player.py —— 使用 UDP / Linux socket 向 Hololink 板流式传输音频。
  • audio_player.py —— 使用 RoCE / RDMA 流式传输音频。

两者使用相同的 FPGA I2S 通路,并要求下文所述的相同 WAV 文件格式。

UDP / Linux socket 路径(linux_audio_player.py

linux_audio_player.py 应用演示了如何把主机上 WAV 文件中的音频采样流式传输到 Hololink 板,并通过基于 UDP 的传输路径在 I2S 接口上播放出来。

该应用构建了一条简单的 Holoscan 流水线,将音频采样打包并通过 UDP 发送到 Hololink 设备:

AudioPacketizerOp

UdpTransmitterOp

  • AudioPacketizerOp 从主机上的 WAV 文件读取音频数据,将其切分为固定大小的块(由 chunk_size 控制),并以面向 UDP 的报文格式(IB 风格头部 + CRC)把这些块发布到 Holoscan 流水线中。
  • UdpTransmitterOp 把每个音频块作为 UDP 报文发送到配置好 IP 地址的 Hololink 设备(默认端口 4791)。

在启动 Holoscan 应用之前,linux_audio_player.py 通过写一组设备寄存器来配置 Hololink 设备以启用 I2S 发送(函数 enable_i2sset_tx_af_aeset_tx_pause)。这会设置发送 FIFO 阈值与暂停行为,使到达的 UDP 音频流能够在 I2S 接口上播放出来。

可以从 examples 目录运行该应用:

python3 linux_audio_player.py \
  --hololink <HOLINK_IP_ADDRESS> \
  --wav-file </path/to/file.wav> \
  [--chunk-size 192]

RoCE 路径(audio_player.py

audio_player.py 应用是音频播放器的 RoCE 版本,使用 RoceTransmitterOp 替代 UdpTransmitterOp。它通过 RoCE 链路传输相同的 WAV 音频格式,依赖 RDMA 协议栈提供组帧与可靠性。

Holoscan 流水线为:

AudioPacketizerOp

RoceTransmitterOp

  • AudioPacketizerOp 同样读取 WAV 文件并产生固定大小的音频负载(此配置下不带 IB 头部或 CRC)。
  • RoceTransmitterOp 把每个负载缓冲区通过配置好的 RoCE 连接发送到 Hololink 板,随后样本被送入与 UDP 示例相同的 I2S 播放通路。

examples 目录可以运行:

python3 audio_player.py \
  --hololink <HOLINK_IP_ADDRESS> \
  --wav-file </path/to/file.wav> \
  [--chunk-size 192] \
  [--ibv-name <IB_DEVICE>] \
  [--ibv-port <PORT>] \
  [--ibv-qp <QP_NUM>] \
  [--queue-size <N>]

注意:当前的 I2S/FPGA 音频通路与应用是为固定音频格式设计的。WAV 文件必须使用以下音频参数:

  • 声道:立体声
  • 采样率:48 kHz
  • 采样位宽:24 位
  • 比特率:2304 kbps

1.10 子帧处理应用(Sub-Frame Processing Applications)

子帧处理是一项让高分辨率传感器数据帧在到达时被逐步处理的功能,而不必等待完整帧接收完毕。通过使用更小的子帧缓冲区,它可以降低处理大帧时的内存需求。

什么是子帧处理?

在传统的基于整帧的处理中,必须先接收到完整的一帧,才能开始任何处理或显示。对于高分辨率传感器(例如 60fps 的 4K 相机、高密度激光雷达点云或雷达数据),这会引入显著的延迟,因为整个帧必须先被缓冲下来,然后才能开始处理。

子帧处理把每一帧划分为多个水平条带(子帧),它们在到达时被独立处理。每个子帧包含原始帧中一组连续的行。例如,一幅 2160 行高的图像帧可以被划分为每 540 行一个的子帧,即每个完整帧包含 4 个子帧。同样的概念也适用于其他可以划分为水平条带的传感器数据类型。

子帧处理流水线

子帧处理有两种显示模式,取决于是否需要与显示同步的采集:

子帧合成器模式(例如 IMX274 示例):子帧先被累积成完整帧缓冲区,然后再显示。采集时序独立于显示。

Holoscan Application

Holoscan Sensor Bridge

Sensor Data Source

Sensor

ReceiverOp

CsiToBayerOp

ImageProcessorOp

BayerDemosaicOp

SubFrameCombinerOp

HolovizOp

子帧可视化器模式(例如 VB1940 示例):子帧在到达时被直接合成到显示上。采集通过 FPGA PTP/PPS 与显示刷新同步,从而最小化端到端延迟。

Holoscan Application

Holoscan Sensor Bridge

Sensor Data Source

FPO event + PTP/PPS

Sensor

ReceiverOp

CsiToBayerOp

ImageProcessorOp

BayerDemosaicOp

SubFrameVisualizerOp

各算子的子帧行为

CsiToBayerOp

sub_frame_rows 参数被设置为非零值时:

  • 子帧累积:到达的报文被累积到子帧大小的缓冲区中
  • 元数据处理:第一个子帧报文可能包含一个被跳过的头部(start_byte_
  • 子帧发射:只有当累积出一个完整的子帧时才将其发射出去
  • 偏移计算:把基于字节的偏移转换为基于行的偏移,供下游算子使用
  • 帧编号:根据每帧的子帧数量,把接收方的帧号换算为完整帧号
ImageProcessorOp

子帧处理会影响白平衡计算:

  • 直方图累积:直方图在一帧的所有子帧上累积
  • 白平衡时序:当新帧开始时(通过帧号变化检测)计算白平衡增益
  • 增益应用:把上一帧的白平衡增益应用到当前子帧
  • 逐子帧处理:光学黑校正与直方图生成在每个子帧上独立执行
  • 乱序处理:乱序到达的子帧通过累积进现有直方图来处理,不重新计算白平衡增益
SubFrameCombinerOp

把子帧组合成完整帧:

  • 帧跟踪:跟踪预期的帧号以检测丢失的子帧
  • 行累积:把来自各子帧的行累积到完整帧缓冲区中
  • 发射:只有在所有子帧都接收完毕后才发射完整帧
  • 错误处理:对丢失或乱序到达的子帧发出警告
SubFrameVisualizerOp

以与显示同步的相机采集方式累积并可视化子帧。与 SubFrameCombinerOp 不同,子帧在到达时被直接合成到显示上,算子不会等待完整帧再渲染。

一个专用的后台线程监听显示的**首像素输出(FPO,First Pixel Out)**事件——该事件在每次显示刷新时、垂直消隐间隔的起点触发一次。每次 FPO 事件发生时,线程通过测量流水线延迟(从 FPGA 触发到第一个子帧到达 compute() 的时间)来计算下一次采集的最优 FPGA 相机触发时刻,然后调度下一次采集,使采集到的帧恰好在下一次垂直消隐之前完成渲染。

这种同步使用 FPGA 的 PTP/PPS 单脉冲输出:每次 FPO 事件调用一次 set_delay(),把采集相位推进一个显示周期,从而产生与显示刷新相位锁定的连续 60 Hz 触发节拍。

关键行为:

  • 流水化采集:第 N 帧正在渲染时采集第 N+1 帧,使采集速率与显示刷新率保持一致。
  • 缓存流水线延迟:如果 FPO 触发时仍有帧在途,则复用上次测量的流水线延迟,以避免破坏 FPGA 触发相位的稳定性。
  • 相机过慢检测:如果连续两次 FPO 事件之间 render_start_time 没有变化,则记录一条警告日志,表明相机速率低于显示速率。采集调度无论如何都会继续。
  • FPO 回退:如果 WaitForDisplayEvent 超时,fpo_available_ 被置为 falsecompute() 切换到回退渲染路径——直接交换缓冲区,而不依赖 FPO 驱动的交换。

参数:

  • ptp_synchronizer:来自 ptp_pps_output(1)PtpSynchronizer 指针。显示同步所需;若为 nullptr,采集将非同步运行。默认:nullptr
  • full_frame_height:以行数表示的完整帧高度。正确合成子帧所必需。
  • fullscreen:全屏模式运行。默认:false
  • use_exclusive_display:使用独占显示模式(更低延迟,需要独占显示器)。默认:false
  • display_name:独占显示模式的显示器名称。默认:""
  • display_width:显示器宽度(像素)。默认:1920
  • display_height:显示器高度(像素)。默认:1080
  • display_framerate:显示帧率,单位 Hz×1000(例如 60 Hz 为 60000)。默认:59950
  • window_title:窗口模式的窗口标题。默认:"Sub-Frame Visualizer"

配置

通过在 CsiToBayerOp 中设置 sub_frame_rows 参数启用子帧处理:

  • sub_frame_rows = 0:禁用子帧处理(默认,完整帧模式)
  • sub_frame_rows > 0:启用子帧处理,每个子帧为指定的行数

重要约束: sub_frame_rows 必须能整除帧高。

子帧大小的选择应基于:

  • 网络报文大小:应与预期的报文大小对齐,以尽量减少不完整的子帧
  • 内存约束:更小的子帧每个缓冲区占用更少内存,但处理一个完整帧需要更多缓冲区
  • 显示刷新率:子帧大小影响数据在显示上呈现的平滑程度
  • 延迟要求:更小的子帧可以通过更早开始处理来降低端到端延迟,但这一收益必须与处理更多子帧带来的额外开销相权衡
  • 传感器数据特性:不同类型的传感器可能因其数据结构与处理需求而适合不同的子帧大小

限制与注意事项

  • 乱序到达:由于网络状况,子帧可能乱序到达。算子通过跟踪帧号与子帧偏移来处理这种情况。
  • 子帧丢失:如果子帧丢失,最终的帧可能不完整。SubFrameCombinerOp 会对丢失的子帧发出警告并输出部分帧。
  • 白平衡精度:白平衡增益根据上一帧的直方图计算,可能与当前帧的光照条件不完全匹配。

示例:子帧 IMX274 播放器

子帧 IMX274 播放器示例(sub_frame_imx274_player.pysub_frame_imx274_player.cpp)演示了不带显示同步的完整子帧处理流水线:

  1. 接收器:接收包含部分帧数据的网络报文
  2. CSI 到 Bayer:把报文累积成子帧并转换为 Bayer 格式
  3. 图像处理器:处理子帧并为白平衡累积直方图
  4. 去马赛克:把 Bayer 子帧转换为 RGBA
  5. 子帧合成器:在显示前把子帧组合成完整帧

示例:子帧 VB1940 播放器

子帧 VB1940 播放器示例(sub_frame_vb1940_player.cpp)演示了使用 SubFrameVisualizerOp 进行与显示同步的子帧采集与可视化:

  1. 接收器:通过 ConnectX RoCE 接收来自 VB1940 相机的网络报文
  2. CSI 到 Bayer:把报文累积成子帧并转换为 Bayer 格式
  3. 图像处理器:应用光学黑校正与白平衡
  4. 去马赛克:把 Bayer 子帧转换为 RGBA
  5. 子帧可视化器:在子帧到达时将其合成到显示上,同时 FPO 线程调度下一次与显示刷新同步的 FPGA 相机采集

SubFrameVisualizerOp 使用 ptp_pps_output(1)(单脉冲 PPS 模式)触发 FPGA 相机。每次 FPO 事件发出一次 set_delay() 调用,每拍把采集相位推进一个显示周期。这产生了与显示相位锁定的 60 Hz 采集节拍,实现了最小的流水线延迟。

关于运行子帧示例的说明,请参阅入门指南中的「子帧处理示例」一节。


第二章 架构(Architecture)

Holoscan sensor bridge 设备在传感器设备与 GPU 加速的 Holoscan 应用之间提供高速接口。对连接到传感器桥设备的外设的控制,通过传感器桥板上的 I2C、SPI 或本地总线接口完成。与这些外设的交互使用网络报文进行,这些报文被称为控制平面(control plane)。从高速传感器采集的数据由 FPGA 收集,并通过 UDP 转发回主机,这些报文被称为数据平面(data plane)。

Holoscan sensor bridge 主机软件提供了管理控制平面报文的对象,用于控制 I2C、SPI 与本地总线事务。其他主机软件对象提供网络接收算子,用于配置和引导数据平面流量接收到的数据。对于配备 ConnectX SmartNIC 设备的系统,接收到的数据平面流量可以通过 RDMA 透明地写入 GPU 显存。对于没有 ConnectX 设备的系统,也有一个使用 Linux Sockets API 的网络接收对象,提供相同的功能,但没有报文接收卸载所带来的高性能。

Holoscan sensor bridge 软件还包含用于图像格式转换与图像信号处理的附加算子。

2.1 应用-传感器工作流程示例:实时视频

作为传感器桥主机软件架构的入门,我们逐步讲解 examples/imx274_player.py 的主要环节。在这个例子中,一台 IMX274 立体相机单元以 60FPS 提供 4K 或 1080p 的实时视频流。

应用通过传感器对象上的 API 进行交互

应用通常实例化并使用提供设备专用 API 的传感器对象。例如,相机对象可以提供设置曝光的 API:

Python:

  camera.set_exposure(1000)

C++:

  camera->set_exposure(1000);

要实例化相机对象,应用代码通常会:

  • 使用 Enumerator.find_channel 枚举本地系统可见的传感器桥设备。find_channel 接受用于过滤所接收报文的参数;当找到匹配条件的枚举报文时,返回一个包含被枚举设备元数据的字典。

Python:

  channel_metadata = hololink_module.Enumerator.find_channel(channel_ip=args.hololink)

C++:

  hololink::Metadata channel_metadata = hololink::Enumerator::find_channel(hololink_ip);

当从给定 IP 地址观察到枚举数据时,关于所找到设备的信息会被返回到 channel_metadata 变量中。

  • 使用 channel_metadata 构造一个 DataChannel 对象。该对象把接收到的数据与 GPU 显存缓冲区关联起来。

Python:

  hololink_channel = hololink_module.DataChannel(channel_metadata)

C++:

  hololink::DataChannel hololink_channel(channel_metadata);
  • 使用 hololink_channel 构造我们的相机传感器对象:

Python:

  camera = hololink_module.sensors.imx274.dual_imx274.Imx274Cam(hololink_channel, ...)

C++:

注意在这个例子中 IMX274 传感器是用 Python 实现的,下面的代码展示了如何在 C++ 中创建这个 Python 对象。

  py::module_ imx274 = py::module_::import("hololink.sensors.imx274");
  py::object Imx274Cam = imx274.attr("dual_imx274").attr("Imx274Cam");
  py::object camera = Imx274Cam("hololink_channel"_a = hololink_channel, ...);

构造相机实例并不会真正与传感器桥设备交互——我们只是保存设备通信信息以备后用。HoloscanApplication 的构造函数运行时需要这个相机实例,因此我们有动机较早地创建这个对象。

  • 一个传感器桥设备上通常有多个 DataChannel 实例;许多 API 会作用于与特定传感器桥设备关联的所有数据通道实例。为了复位传感器桥设备——这是保证设备处于已知状态的唯一方法——我们需要获取底层 Hololink 实例的句柄。这块板上的所有 DataChannel 实例在这里都会返回同一个 Hololink 实例。调用 hololink.reset 会复位所有挂接的数据通道实例。

Python:

  hololink = hololink_channel.hololink()
  hololink.reset()

C++:

  std::shared_ptr<hololink::Hololink> hololink = hololink_channel.hololink();
  hololink->reset();
  • 在我们的示例应用中,需要初始化相机时钟并配置相机将发送的图像格式。注意在 IMX274 立体相机中,同一个时钟驱动两路相机设备,因此必须小心,确保不会在另一路相机正在使用时初始化相机。

Python:

  camera.setup_clock()
  camera_mode = imx274_mode.Imx274_Mode.IMX274_MODE_3840X2160_60FPS
  camera.configure(camera_mode)

C++:

  camera.attr("setup_clock")();
  py::object camera_mode = Imx274_Mode(0);
  camera.attr("configure")(camera_mode);

在我们的 IMX274 演示中,camera.setup_clockcamera.configure 会调用传感器桥设备的 I2C 控制器对象,以写入正确的设备寄存器组。

  • 现在我们可以启动应用流水线了。

Python:

  application.run()

C++:

  application->run();

除非流水线被显式停止,否则这个 application.run 调用永远不会返回。

  • application.run 首先调用每个算子的 start 方法。

    RoceReceiverOpLinuxReceiverOperatorstart 方法都会调用 camera.start(camera 是传入构造函数 device 参数的设备对象)。相机在 start 被调用时,会被配置为开始发送视频数据。

  • 然后 application.run 进入循环,执行流水线,调用每个算子的 compute 方法。网络接收算子的 compute 方法会阻塞,直到完整的一帧数据被接收到它初始化时指定的内存块中。

我们的相机对象通过传感器桥设备上 I2C 总线的读写来操作设备寄存器。假设我们的相机连接在总线使能 bus enable==0 的传感器桥 I2C 控制器上(我们把这个地址存在常量 hololink_module.CAM_I2C_BUS 中)。相机对象可以通过调用 hololink.get_i2c 获取一个 Hololink.I2c 对象的句柄,该对象提供产生 I2C 事务的 API。如果相机本身响应的 I2C 总线地址是 0x34(我们称之为 CAM_I2C_ADDRESS),那么它可以这样支持一个 camera.set_register 方法:

class Imx274Cam:
    def __init__(
        self,
        hololink_channel,
        i2c_bus=hololink_module.CAM_I2C_BUS,
        ...
    ):
        self._hololink = hololink_channel.hololink()
        self._i2c = self._hololink.get_i2c(i2c_bus)
    ...
    def set_register(self, register, value):
        ...
        self._i2c.i2c_transaction(
            CAM_I2C_ADDRESS,
            write_data,
            read_byte_count,
        )
    def set_exposure(self, value):
        self.set_register(..., value)

所有类型的传感器对象都可以用这种方式支持:I2C、SPI 或传感器桥本地总线的接口,都可以通过 Hololink 实例上的 API 来控制。

DataChannel 枚举与 IP 地址配置

传感器桥设备中的每个数据平面实例每秒发出一次 UDP 枚举报文;主机用这些报文来定位可访问的设备。Enumerator.find_channel 方法收集并解码这些报文,并据此生成作为 channel_metadata 返回的字典。Holoscan sensor bridge 使用本地广播 MAC ID(FF:FF:FF:FF:FF:FF)发送这些报文。路由器不允许把这些报文转发到其他网络,因此只有本地连接的主机才能收到它们。你的主机必须与传感器桥设备处于同一网络中才能通信。

Holoscan sensor bridge 的枚举报文基于 BOOTP 协议;与 BOOTP 一样,它们提供了一种重新配置该 HSB 设备 IP 地址的机制。如果主机希望重新配置设备的 IP 地址,它会发送一条应答报文,携带要分配给该数据平面控制器的新 IP 地址。传感器桥演示容器中包含一个名为 hololink 的命令行工具,可用于给传感器桥设备分配新的 IP 地址:

hololink-set-ip b0:4f:13:e0:20:4c 192.168.100.250

你的 MAC-ID 与 IP 地址会不同;可以给出任意数量的 mac-id 与 ip-address 对组成的列表。默认情况下,这会启动一个永久运行的进程:当收到 IP 地址与配置值不同的枚举请求时,它会发送一条应答,把配置的地址分配给那个数据通道。在复位传感器桥设备时,以守护进程方式运行它非常重要:

  • 应用代码在新 IP 地址上建立连接
  • 应用执行 hololink.reset
  • 设备复位并回退到默认 IP 地址
  • 应用代码看到使用默认 IP 地址的枚举——它会忽略它
  • hololink-set-ip 看到携带非新 IP 地址的枚举报文时,它会发送一条携带新 IP 地址配置的应答
  • Holoscan sensor bridge 更新其 IP 地址。此后枚举数据将使用新地址发送
  • 应用代码随后看到新 IP 地址上的枚举
  • 应用重新连接并完成复位请求

枚举请求与应答报文遵循 RFC951 规范,不同之处在于:枚举请求由传感器桥设备在 UDP 端口 12267 上发出,应答由主机发往 UDP 端口 12268。

关于主机网络配置的具体信息,请参阅 support/notes 中的「Holoscan sensor bridge IP 地址配置」一节。

Holoscan sensor bridge 数据通道使用 RoCE v2 RDMA write 与 RDMA write immediate 请求

ConnectX SmartNIC 固件支持在无需 CPU 干预的情况下处理经过认证的 RoCE v2 请求。传感器桥设备利用这一点,通过生成 RDMA write 与 RDMA write with immediate 请求把数据平面内容发送到主机。DataChannel 为传感器桥设备配置目标网络地址、认证密钥,以及单个报文大小与整体数据帧大小。配置完成后,传感器桥设备会以单个报文大小值为负载大小,用 RDMA write 请求发送接收到的传感器数据。这些请求被 ConnectX 接收后,会被直接写入 GPU 或系统内存——这些写操作完全从 CPU 卸载。当接收到的字节总数达到数据帧大小后,会使用 RDMA write-immediate 请求发送一个特殊的元数据报文。这个 write-immediate 请求会为 CPU 调度一次中断,用于标记接收到的数据已就绪、可以进行后续处理——RoceReceiverOp.compute 在调用 get_next_frame 时等待的正是这个中断。

Holoscan SDK 元数据与 HSB

HSB 设备会在每个接收到的数据帧之后发送一块元数据。关于这些数据的内容与用途的详细信息,请参阅「HSB 延迟」一章。


第三章 延迟(Latency)

3.1 Holoscan SDK 元数据与 HSB

HSB 设备会在每个接收到的数据帧之后发送一块元数据。该元数据包括:

  • frame_number:FPGA 发出的、来自此传感器的数据帧计数
  • timestamp_stimestamp_ns:当前帧的第一批数据到达 FPGA 时的 PTP 时间戳
  • metadata_smetadata_ns:元数据报文发出时记录的 PTP 时间戳——它恰好紧跟在接收到的数据帧的最后一个字节之后

当主机 PTP 支持配置正确时,该时间与主机时间的同步误差在 1 微秒以内。HSDK 算子可以使用 Holoscan SDK 提供的 API 访问这些元数据。注意:

  • 这些时间戳可与通过 clock_gettime(CLOCK_REALTIME, &timespec) API 读取的时钟值相比较。
  • 接收算子会基于各自实现的独特特性产生不同的元数据集。例如,RoceReceiverOp 特有的数据可能不会出现在 LinuxReceiverOperator 提供的元数据中。应用可以选择使用 metadata.get("parameter_name", 0) 访问元数据,以便在框架没有设置某个特定名称的元数据时提供一个有用的默认值。
  • 务必在初始化时调用应用的 is_metadata_enabled(true) 方法;否则每个算子只会看到一个空的元数据结构。

3.2 测量传感器数据延迟

examples/imx274_latency.py 中,你可以看到一条记录额外时间戳、并用这些时间戳生成延迟报告的流水线:

  • operator_soperator_ns 由网络接收算子之后的算子记录。这是流水线算子真正能够访问到接收传感器数据的时刻。
  • completed_scompleted_ns 由示例流水线中的最后一个算子在可视化完成之后记录。

接收算子还会记录 received_sreceived_ns,记录的是 CPU 因帧结束中断而被唤醒的时刻。这发生在独立于流水线的一个后台线程中。下面列出的时间是通过把 (name)_s(name)_ns 组合成一个浮点秒值计算出来的。显示的时间值都是典型值,但会有所变化。

【图:HSB 延迟时间线】

  • frame_end - frame_start 是传感器把完整一帧数据传入 FPGA 所需的时间。对于 4K RAW10 模式的 IMX274,典型值为 15.8ms。
  • received - frame_end 显示 CPU 在后台线程中因帧结束指示而唤醒所需的时间。在配备加速网络的 IGX 上,典型值为 120µs。
  • operator - received 是下一个流水线算子开始用当前接收到的数据执行所需的时间。在 IGX 上,如果流水线空闲,典型值约为 1ms。
  • completed - operator 是流水线其余部分完成所需的时间。在 IGX 上运行朴素的示例 ISP 与可视化器时,典型值约为 2.4ms。

因此,示例应用显示:每个视频帧有近 16ms 的数据采集时间,加上近 4ms 的处理时间,总延迟在 20ms 以下。在这个应用中,帧以 60FPS 交付,意味着每个新帧以 16ms 的间隔开始;在当前帧处理进行的同时,下一帧的接收在后台并行进行。


第四章 新传感器(New Sensors)

Hololink 软件提供了控制连接到 Hololink 设备的传感器所需的工具。支持一个新传感器,通常就是创建一个对象——它带有所需设备功能的方法——然后在你的应用算子中使用这些 API。我们以一个示例 MyCamera 对象来演示:它有一个寄存器(0x100),我们用它读取设备版本。

import logging

import hololink as hololink_module


class MyCamera:
    CAMERA_I2C_BUS_ADDRESS = 0x34

    def __init__(self, hololink_channel, hololink_i2c_controller_address):
        # 获取这些控制器的句柄,但先不真正与它们通信
        self._hololink = hololink_channel.hololink()
        self._i2c = self._hololink.get_i2c(hololink_i2c_controller_address)

    def get_version(self):
        VERSION = 0x100
        return self.get_register(VERSION)

    def get_register(self, register):
        # write_buffer 将包含我们要读取的寄存器的
        # 大端 2 字节地址。
        write_buffer = bytearray(10)  # 必须至少为 2
        serializer = hololink_module.Serializer(write_buffer)
        serializer.append_uint16_be(register)
        # 把 write_buffer 发送给外设,
        # 并返回从它读回的数据。reply 将是
        # 一个 4 字节的缓冲区,出问题时为 None
        read_byte_count = 4
        reply = self._i2c.i2c_transaction(
            self.CAMERA_I2C_BUS_ADDRESS,
            serializer.data(),  # 等同于 write_buffer[:serializer.length()]
            read_byte_count
        )
        # deserializer 从 reply 中取数据;
        # reply 为 None 时会抛出异常
        deserializer = hololink_module.Deserializer(reply)
        # 取出一个以大端格式存储的无符号 32 位值
        r = deserializer.next_u32_be()
        return r

有了它,我们就可以创建一个读取这个版本寄存器的简单程序:

def main():
    # 获取我们连接的 Hololink 端口的句柄。
    channel_metadata = hololink_module.Enumerator.find_channel(channel_ip="192.168.0.2")
    hololink_channel = hololink_module.DataChannel(channel_metadata)
    # 实例化相机本身;CAM_I2C_BUS 是我们的相机所挂接的
    # I2C 控制器对应的总线使能设置
    camera = MyCamera(hololink_channel, hololink_module.CAM_I2C_BUS)
    # 建立与 hololink 设备的连接
    hololink = hololink_channel.hololink()
    hololink.start()
    # 读取设备版本。
    version = camera.get_version()
    logging.info(f"{version=}")

在调用 hololink.start 之后,网络控制平面即可用于通信。传感器对象应当遵循以下模式:

  • 一个 configure 方法,使用控制平面设置传感器产生的数据。该方法由应用代码调用。
  • 一个 start 方法,由数据接收算子在启动时调用,用于配置传感器开始产生数据。
  • 一个 stop 方法,在数据接收方关闭时调用,用于停止传感器数据流。
  • 一个 configure_converter 方法,让传感器对象能够配置应用流水线中的下一个元素。该方法由应用层在搭建流水线时调用。
class MyCamera:
    ...
    def configure(self, mode, ...):
        # 通过向相机寄存器写入适当的值,
        # 把相机配置为我们要用的模式。
        self.set_register(...)
        self.set_register(...)
        ...

    def start(self):
        # 让相机开始向外推流。
        self.set_register(...)

    def stop(self):
        # 让相机停止推流。
        self.set_register(...)

    def configure_converter(self, converter):
        converter.configure(self._width * self._height ...)
    ...

configure_converter 方法让传感器能够与流水线的下一层协调,传感器的原始数据在那里被处理。例如,在 CSI-2 视频应用中,原始视频数据带有 CSI-2 元数据帧封装,并以编码格式(如 RAW10)存储。由于转换器是 GPU 加速的处理,它在为接收数据分配内存时很可能有额外的考量必须纳入(例如 GPU 显存缓存对齐的要求)。在我们的视频应用中,传感器知道原始图像的尺寸,而转换器可以在此基础上加上 CSI-2 帧封装数据所需的空间。此后,转换器对象就知道如何为网络接收方最优地分配内存了。

应用层在调用 sensor.configure_converter 之后,就可以请转换器帮忙分配 GPU 显存。这块显存随后会被传给网络接收算子。

当相机被配置为在数据平面上发送流量后,应用就可以实例化一个 RoceReceiverOp(或 LinuxReceiverOperator),把数据平面流量接收到 GPU 显存的特定区域。最后,当 application.run 完成配置并调用我们接收算子的 start 方法时,它会调用 camera.start,从而命令相机开始发送视频数据。


第五章 UDDF 驱动(UDDF Drivers)

当通过 SIPL 访问 Holoscan Sensor Bridge 时——例如在受支持的 AGX Thor 平台上使用 SIPLCaptureService / SIPLCameraOutputOp 和/或运行 sipl_player 示例应用——所使用的传感器驱动由基于统一设备驱动框架(UDDF,Unified Device Driver Framework)编写的外部驱动库提供;这些驱动并不由 HSB 仓库提供。

NVIDIA 的参考 UDDF 驱动随 JetPack 以预编译二进制形式安装,其源代码也包含在 SIPL Camera SDK 包中。例如,对于 JetPack 7.2 随附的 vb1940 参考驱动:

  • 预编译驱动库安装在 /usr/lib/nvsipl_uddf/libnvuddf_eagle_library.so
  • 该库的源代码位于 /usr/src/jetson_sipl_api/sipl/uddf/samples/drivers/eagleAIO

注意,如果 SIPL Camera SDK(以及上述源代码)尚未安装,请从 JetPack 下载页面下载并解压 Camera SIPL 包。

sipl_player 示例应用包含 JSON 配置文件(位于 examples/sipl_config/),用于把应用配置为使用已安装的 UDDF 驱动库;在全新的 JetPack 安装上这些配置开箱即用。关于如何使用这些配置与相应驱动运行该 SIPL 应用的示例,请参阅入门指南中的「Leopard imaging VB1940 Eagle 播放器示例」一节。

5.1 更新 UDDF 驱动

如果参考 UDDF 驱动需要更新——无论是修改传感器编程,还是更新静态链接进驱动的 Hololink 代码——都可以从驱动源代码重新编译并安装:

mkdir eagle_uddf && cd eagle_uddf
cmake /usr/src/jetson_sipl_api/sipl/uddf/samples/drivers/eagleAIO/
make -j
sudo make install

如上所述,这些 UDDF 驱动通过一份静态链接的 Hololink 核心源码副本与 HSB 交互(见 /usr/src/jetson_sipl_api/sipl/uddf/samples/drivers/eagleAIO/hololink/core)。如果需要更新某个 UDDF 驱动的 Hololink 代码,可以用 HSB 仓库中最新的 Hololink 核心代码(例如来自 src/hololink/core)覆盖 UDDF 驱动中的对应版本,然后使用上面的命令重新构建/安装。

注意,Hololink 的兼容性仅保证到与正在使用的 JetPack 发布版本相对应的 Hololink 标签(即 JetPack_7.2);那是最初复制到 UDDF 驱动代码时的代码版本。更新版本的 Hololink 可能无法原样兼容,可能需要对 UDDF 驱动代码进行其他修改。

5.2 添加新的 UDDF 驱动

由于 HSB 仓库不包含任何 UDDF 驱动,添加新的 UDDF 驱动也不属于本文档的范围。上面讨论和使用的 Eagle VB1940 参考驱动旨在提供一个 UDDF 驱动实现样例,应当作为任何新驱动实现的基础。

不过需要指出的是,UDDF 驱动中包含的 HsbTransportDriver 类充当 UDDF 驱动的 Hololink 客户端,负责与 HSB 的所有交互。对驱动内 hololink::core 的任何更新都可能导致 HsbTransportDriver 需要相应的修改。

关于 SIPL 以及如何编写和使用 UDDF 驱动的更多信息,请参阅 Jetson Linux 开发者指南中的 Camera Development using CoE 一节。


第六章 Hololink Module 应用教程(Hololink Module, Application Tutorial)

6.1 概述

跟随本教程构建一个 IMX274 相机播放器,看看 hololink_module 应用如何发现、配置一块 Holoscan Sensor Bridge 板卡并从中取流。与使用旧版 API 的应用不同,使用新 hololink_module 的应用可以透明地与不同的 HSB 设备通信。

应用以服务(service)的形式访问所需能力,而不是直接构造设备类。服务通过 get_service 按类型请求,通常以标识特定板卡的枚举元数据为键:

Python:

  hololink = hololink_module.HololinkInterfaceV1.get_service(metadata)

C++:

  auto hololink = hololink::module::HololinkInterfaceV1::get_service(metadata);

正是这层间接性,让一个应用无需重新编译就能驱动不同的板卡——甚至同一板卡的不同修订版。「背景」一节解释了其原理;本页的其余部分先构建一个可用的播放器。

6.2 IMX274 播放器教程

本教程构建一个接收 IMX274 相机视频并显示出来的播放器。完整程序随源码发布,名为 imx274_module_tutorial.py(Python)与 imx274_module_tutorial(C++);下面的每个代码片段都取自其中。

配置

你需要一块连接了 IMX274 相机的 Holoscan Sensor Bridge 板卡,可通过其默认地址 192.168.0.2 访问。IMX274 是一对立体相机;本教程在 192.168.0.2 上驱动设备上的第一路相机。本教程假定你已按「主机设置」与「构建」两章所述构建并运行了演示容器。程序的设置都是硬编码的,因此命令行上无需传入任何参数。

应用流水线

播放器是一条由五个算子组成的 Holoscan 流水线。数据从左向右流动:

receiver -> csi_to_bayer -> image_processor -> demosaic -> holoviz

虽然数据流从接收算子开始,但如果不先知道一些来自 csi_to_bayer 的配置(具体来说是接收缓冲区大小),我们就无法构造它。因此先构建 CsiToBayerOp,针对相机完成配置,再读回帧大小:

Python:

  csi_op = hololink_module.operators.CsiToBayerOp(
      self,
      name="csi_to_bayer",
      allocator=csi_to_bayer_pool,
      cuda_device_ordinal=self._cuda_device_ordinal,
  )
  # 配置转换器会告诉我们每个接收帧有多大。
  self._camera.configure_converter(csi_op)
  frame_size = csi_op.get_csi_length()

C++:

  auto csi_to_bayer_operator = make_operator<hololink::module::operators::CsiToBayerOp>(
      "csi_to_bayer",
      holoscan::Arg("allocator", csi_to_bayer_pool),
      holoscan::Arg("cuda_device_ordinal", cuda_device_ordinal_));
  // 配置转换器会告诉我们每个接收帧有多大。
  camera_->configure_converter(csi_to_bayer_operator);

  const size_t frame_size = csi_to_bayer_operator->get_csi_length();

RoceReceiverOp 根据枚举元数据自行配置,在枚举到的通道上监听数据。device_start / device_stop 回调在推流前后启动和停止相机:

Python:

  receiver = hololink_module.operators.RoceReceiverOp(
      self,
      name="receiver",
      enumeration_metadata=self._metadata,
      frame_context=self._cuda_context,
      frame_size=frame_size,
      device_start=self._camera.start,
      device_stop=self._camera.stop,
  )

C++:

  auto receiver_operator = make_operator<hololink::module::operators::RoceReceiverOp>(
      "receiver",
      holoscan::Arg("enumeration_metadata", metadata_),
      holoscan::Arg("frame_context", cuda_context_),
      holoscan::Arg("frame_size", frame_size),
      holoscan::Arg("device_start", std::function<void()>([this] { camera_->start(); })),
      holoscan::Arg("device_stop", std::function<void()>([this] { camera_->stop(); })));

另外三个算子完成整条流水线:

  • ImageProcessorOp 是一个 hololink_module 算子,用 GPU 对 Bayer 数据(如 RGGB)提供朴素的 ISP 处理。
  • BayerDemosaicOp 是一个标准 Holoscan 算子,把 Bayer 网格插值为 RGBA。
  • HolovizOp 是一个标准 Holoscan 算子,用于显示结果。

把算子连接起来:

Python:

  self.add_flow(receiver, csi_op, {("output", "input")})
  self.add_flow(csi_op, image_proc, {("output", "input")})
  self.add_flow(image_proc, demosaic, {("output", "receiver")})
  self.add_flow(demosaic, visualizer, {("transmitter", "receivers")})

C++:

  add_flow(receiver_operator, csi_to_bayer_operator, { { "output", "input" } });
  add_flow(csi_to_bayer_operator, image_processor_operator, { { "output", "input" } });
  add_flow(image_processor_operator, demosaic, { { "output", "receiver" } });
  add_flow(demosaic, visualizer, { { "transmitter", "receivers" } });

应用初始化

主程序负责找到板卡、配置板卡,然后运行上述流水线。

获取适配器(adapter)——应用通过它发现并连接到板卡——并等待板卡宣告自己的存在。wait_for_channel 会阻塞,直到给定 IP 的板卡出现,并返回其枚举元数据:

Python:

  adapter = hololink_module.Adapter.get_adapter()
  metadata = adapter.wait_for_channel(HOLOLINK_IP, DISCOVERY_TIMEOUT)

C++:

  auto& adapter = hololink::module::Adapter::get_adapter();
  hololink::module::EnumerationMetadata metadata
      = adapter.wait_for_channel(HOLOLINK_IP, DISCOVERY_TIMEOUT);

用该元数据构造相机驱动。传感器驱动不是一个服务:它被直接链接进你的应用,因此与该传感器紧密耦合。 驱动会在内部从元数据中解析出它所需的服务:

Python:

  camera = hololink_module.sensors.imx274.Imx274Cam(metadata)

C++:

  auto camera = std::make_shared<hololink::module::sensors::imx274::Imx274Cam>(
      metadata);

启动板卡。获取控制平面服务,start() 它以打开通向板卡的 socket,并 reset() 使板卡进入已知状态。框架从不会自行改变设备状态,因此必须调用 reset() 才能确保板卡处于已知配置。然后配置相机:

Python:

  hololink = hololink_module.HololinkInterfaceV1.get_service(metadata)
  # start() 打开控制平面 socket;没有它,任何设备 I/O 都无法工作。
  hololink.start()
  hololink.reset()
  # configure() 会写设备寄存器,因此必须在 reset() 之后运行。
  camera.configure(CAMERA_MODE)
  camera.set_digital_gain_reg(0x4)

C++:

  auto hololink = hololink::module::HololinkInterfaceV1::get_service(metadata);
  // start() 打开控制平面 socket;没有它,任何设备 I/O 都无法工作。
  if (hololink->start() != HOLOLINK_MODULE_OK) {
      throw std::runtime_error("HololinkInterface::start failed");
  }
  if (hololink->reset() != HOLOLINK_MODULE_OK) {
      throw std::runtime_error("HololinkInterface::reset failed");
  }
  // configure() 会写设备寄存器,因此必须在 reset() 之后运行。
  camera->configure(CAMERA_MODE);
  camera->set_digital_gain_reg(4);

运行应用。当它退出时,调用 stop() 停止控制平面,以停止推流并关闭 socket:

Python:

  app = HoloscanApplication(cu_context, cu_device_ordinal, metadata, camera)
  app.run()
  hololink.stop()

C++:

  auto application = holoscan::make_application<HoloscanApplication>(
      cu_context, cu_device_ordinal, metadata, camera);
  application->run();
  hololink->stop();

构建并运行应用

运行已安装的示例。连接好相机、板卡在 192.168.0.2 可达时,会打开一个 Holoviz 窗口并显示实时视频:

Python:

python3 examples/imx274_module_tutorial.py

C++:

$BUILD_DIR/examples/imx274_module_tutorial

6.3 背景(Background)

服务让一个应用无需重新编译就能适配不同板卡——以及同一板卡的不同修订版:

  • 服务是有版本的:版本号包含在类型名中,例如 HololinkInterfaceV1。接口一旦发布就永不改变;新能力以下一个版本号发布(例如 HololinkInterfaceV2,通常是 V1 的扩展)。模块也会继续发布旧版本,因此已部署的应用可以继续工作。
  • 每块板卡的服务在一个设备专用的共享库中实现。
  • 设备枚举(bootp)提供设备 UUID。该 UUID 与一个 “compat-id” 共同定位到为这块板实现服务的共享对象。
  • 共享对象通过在运行时绑定其虚方法来提供所请求的接口。
  • 请求一个板卡未实现的接口会抛出异常——除非你传入 allow_null=true,它告诉框架你的应用会自行处理服务缺失的情况。

第七章 Hololink Module 设备驱动教程(Hololink Module, Device Driver Tutorial)

7.1 引言

构建自己的基于 HSB-IP 的设备的用户,可以通过本教程学习如何构建一个 hololink_module 驱动。应用使用这些驱动访问板卡上的功能——既包括通用服务,也包括你的特定设备独有的功能。下面介绍典型的设备开发工作流程。

在你的设备中实例化 HSB-IP 模块:

  • 为你的设备生成一个新的 UUID(例如用 uuidgen)。
  • 更新配置参数,例如指明你的设备有多少个传感器接口和网络端口。
  • 添加或调整你的设备特有的外设。
  • 确保每台设备的参数(如 MAC ID 与序列号)配置正确。

设备部署完毕、主机正确连接后,使用 hololink-enumerate 命令应该能看到合理的数据。例如,一台 HsbLite 设备会产生如下报文,每个网络端口一条:

# hololink-enumerate
mac_id=3A:31:1D:1E:24:AA hsb_ip_version=0x2606 fpga_crc=0x0 ip_address=192.168.0.2 fpga_uuid=889b7ce3-65a5-4247-8b05-4ff1904c3359 serial_number=10060032828115 interface=enP5p3s0f0np0 board=hololink-lite
mac_id=3A:31:1D:1E:24:AB hsb_ip_version=0x2606 fpga_crc=0x0 ip_address=192.168.0.3 fpga_uuid=889b7ce3-65a5-4247-8b05-4ff1904c3359 serial_number=10060032828115 interface=enP5p3s0f1np1 board=hololink-lite

构建驱动的典型工作流程包括:

  • 找出你的设备与标准 HsbLite 配置的差异。所有不变的部分都可以复用现有的 HsbLite 实现。
  • 把模块模板复制到你的设备的新目录 hololink_module/module/<your-device>。使用本教程构建的模块 hololink_module/module/tutorial 作为模板。
  • 用你设备的名称和 UUID 更新构建指令。
  • 用你的配置参数(例如传感器接口与网络端口数量)更新软件。
  • 按需覆写任何 HsbLite 服务实现。
  • 为任何新特性创建服务。
  • 构建并安装你的模块。
  • 如果你的设备支持的传感器配置已被某个示例应用支持,你大概可以不加修改地使用它,甚至不需要重新编译那个应用的代码。
  • 对于你的新传感器或新特性,构建一个演示该特性的示例应用。做法请参阅「IMX274 Module 教程」一章。
  • tests 目录中为你的设备添加测试;这样你的用户就可以继续使用你的验证套件。

设备验证完成后,你可以公开发布你的更新供客户使用。随着 HSB-IP 与主机软件新版本的发布,你的模块驱动应当能继续原样工作;关于何时需要更新哪些内容的指引,见下文「版本管理」一节。

7.2 Tutorial 设备

hololink_module/module/tutorial 是本教程使用的虚构实现。它与 HsbLite 相同,只有以下差异:

  • UUID 为 d3061b3b-85b0-4096-ba57-296d2418477f
  • 三个传感器端口:0 和 1 是相机,2 是 IMU。
  • 一个网络接口。
  • 一个寄存器(TUTORIAL_DEVICE_STATUS0x42348000),其中一位(STATUS_LED_BIT)控制一个 LED 的亮灭。

下面展示如何基于 HsbLite 构建一个模块、应用传感器端口配置,并为设置该板载 LED 提供 API。

7.3 Tutorial 模块

这个模块是一个可以复制来构建你自己设备的示例:把文中所有的 “tutorial” 一词替换为你的设备名称。

模块源码在 hololink_module/module/tutorial/。上层 hololink_module/CMakeLists.txt 负责注册它——保持这些 add_subdirectory 调用按字母顺序排列:

add_subdirectory(module/tutorial)

hololink_module/module/tutorial/CMakeLists.txt 调用 add_hololink_module,它把模块构建为名为 hololink_<uuid>_<compat-id>.so 的共享对象:

add_hololink_module(
    NAME tutorial
    UUID d3061b3b-85b0-4096-ba57-296d2418477f
    SOURCES
        module_entry.cpp
)

默认情况下 compat-id 来自框架,因此你始终是为那个特定版本的 HSB-IP 构建模块;见下文「版本管理」。

hololink_module/module/tutorial/module_entry.cpp 保存实现——完整细节请查阅源码树中的文件;下面自上而下讲解关键部分。

模块唯一导出的入口点 hololink_module_init 构建发布者(publisher)。当应用加载模块时,主机调用它来初始化库:

extern "C" hololink_module_services_t
hololink_module_init(const hololink_module_init_t* init)
{
    auto publisher = std::make_shared<TutorialDevicePublisher>();
    g_publisher = publisher;
    return publisher->setup(init);
}

TutorialDevicePublisher 负责满足应用的 get_service 请求——通常是把请求转给它的 HsbLite 父类。你的设备专用行为就放在这里;对于基础模块而言,只有板卡名称和它的传感器布局:

class TutorialDevicePublisher : public HsbLitePublisher {
protected:
    std::string module_name() const override { return "tutorial"; }

    void publish_channel_configuration() override
    {
        auto impl = std::make_shared<TutorialDeviceChannelConfigurationV1>();
        ServicePublisher<HsbLiteChannelConfigurationV1>(shared_from_this())
            .publish("", impl);
    }
};

module_name() 仅用于文档与日志。

publish_channel_configuration() 发布 TutorialDeviceChannelConfigurationV1,它重新定义了 use_sensor——即我们如何把传感器与网络端口配置交还给框架。它并非某个设备独有:同一个实例服务于发现的每一台设备:

class TutorialDeviceChannelConfigurationV1
    : public HsbLiteChannelConfigurationV1 {
public:
    void use_sensor(EnumerationMetadata& metadata, int64_t sensor_number) override
    {
        constexpr int64_t TOTAL_SENSORS = 3;
        if (sensor_number < 0 || sensor_number >= TOTAL_SENSORS) {
            throw std::runtime_error(
                "While selecting a Tutorial device sensor: sensor_number "
                + std::to_string(sensor_number)
                + " is out of range (Tutorial device supports 0.."
                + std::to_string(TOTAL_SENSORS - 1) + ")");
        }
        // 一个物理数据平面(0)被所有传感器共享,每个传感器一个 SIF。
        hsb_lite_sensor_metadata(metadata, sensor_number,
            /*data_plane=*/0, /*sifs_per_sensor=*/1);
    }
};

基础模块只需要这些——功能全部基于 HSB-IP,因此不需要更多驱动工作。用 cmake 把模块构建到 /tmp 下的构建目录;模块 .so 会被放置到 $BUILD_DIR/lib/hololink/modules/ 下,应用会在那里找到它:

BUILD_DIR=/tmp/hololink-build
cmake -S . -B "$BUILD_DIR" -G Ninja
cmake --build "$BUILD_DIR" --target tutorial

应用是动态加载 hololink 模块驱动的,因此你无需重新编译应用就能让它们适配你的新设备——应用在枚举时观察到你的新 UUID,并用它找到你的模块。此时,不加修改地运行 module_imx274_playerpython3 examples/module_linux_imx274_player.py 就应该可以工作。

7.4 板卡专用扩展

现在现有应用已经能跑了,接下来添加你的设备特有的功能。对 Tutorial 设备而言就是状态 LED,通过一个定制(bespoke)服务暴露出来。

TutorialDeviceInterfaceV1 声明应用可以调用的 API。把它放在模块的公共头文件 hololink_module/module/tutorial/include/hololink/module/tutorial/tutorial_device.hpp 中,以便应用包含它:

class TutorialDeviceInterfaceV1
    : public ConfigurableService<TutorialDeviceInterfaceV1> {
public:
    static constexpr const char* type_id = "tutorial_device.v1";

    static std::string locator_id(const EnumerationMetadata& metadata)
    {
        return "serial=" + metadata.get<std::string>("serial_number");
    }

    virtual ~TutorialDeviceInterfaceV1() = default;

    /* 打开(true)或关闭(false)板卡的状态 LED。 */
    virtual hololink_module_status_t set_status_led(bool on) = 0;
};

type_idget_service 用来返回正确类型的标识;它必须对每个类唯一。locator_id 构造用于查找某台设备的缓存实例的键——一个以 ; 分隔的 name=value 字符串(这里只有 serial=<serial_number>)。

声明了公共接口 TutorialDeviceInterfaceV1(任何包含此头文件的应用都可见)之后,我们再来看它的实现 TutorialDeviceV1——它只在模块内部可见。

configure() 把这个实例为某台特定设备设置好——框架为每个序列号维护一个独立实例,因此不同设备各自拥有自己的实例。设置完成后,对 set_status_led 的调用即可控制板载 LED。该 LED 以下拉方式接线:清除 TUTORIAL_DEVICE_STATUS 的第 0 位点亮它,置位则熄灭它。

// device_status 块:第 0 位控制一个状态 LED。LED 以下拉方式接线,
// 因此清除第 0 位点亮它,置位第 0 位熄灭它。
static constexpr uint32_t TUTORIAL_DEVICE_STATUS = 0x42348000;
static constexpr uint32_t STATUS_LED_BIT = 1u << 0;

/* 具体的 Tutorial 设备状态服务。configure() 解析出板卡已启动的
 * HololinkInterfaceV1 控制平面(应用先获取并 start() 它);
 * 每次寄存器访问都经由它完成。 */
class TutorialDeviceV1
    : public tutorial::TutorialDeviceInterfaceV1,
      public Service<TutorialDeviceV1> {
public:
    static constexpr const char* type_id = "tutorial_device.impl.v1";

    void configure(const EnumerationMetadata& metadata) override
    {
        const std::string hololink_id = HololinkInterfaceV1::locator_id(metadata);
        hololink_ = HololinkInterfaceV1::get_service(this->module(), hololink_id.c_str());
    }

    hololink_module_status_t set_status_led(bool on) override
    {
        // 下拉:清除第 0 位点亮 LED,置位则熄灭。
        return on ? hololink_->and_uint32(TUTORIAL_DEVICE_STATUS, ~STATUS_LED_BIT)
                  : hololink_->or_uint32(TUTORIAL_DEVICE_STATUS, STATUS_LED_BIT);
    }

private:
    std::shared_ptr<HololinkInterfaceV1> hololink_;
};

TutorialDevicePublisher::construct_overrides 是构造 TutorialDeviceV1 实例的正确位置:

class TutorialDevicePublisher : public HsbLitePublisher {
protected:
    // ...

    bool construct_overrides(
        const std::string& instance_id,
        const std::string& type_id) override
    {
        if (!Publisher::has_type_id<TutorialDeviceV1>(type_id)) {
            return false;
        }
        auto impl = std::make_shared<TutorialDeviceV1>();
        ServicePublisher<TutorialDeviceV1>(shared_from_this())
            .publish(instance_id, impl);
        return true;
    }
};
  • Publisher::has_type_id<TutorialDeviceV1>(type_id) 检查这是否是我们处理的类型。
  • ServicePublisher<TutorialDeviceV1>(shared_from_this()).publish(instance_id, impl) 为每个不同的 instance_id(通常是设备序列号)注册一个实例。同一块板可能出现在多个网络端口上,这把那些接口映射回同一个对象。
  • 一个对象发布后,除非它被失效(例如设备断开),否则不会再被要求重新构建;之后的调用从 publish 维护的缓存开始。
  • 返回 true 表示对象已构建,无需再查找其他处理器。

如果你的应用需要板卡上的特定功能——比如上面的 set_status_led 方法——它必须获取 TutorialDeviceInterfaceV1 的句柄。这样你的应用就与你的设备紧密耦合了:把它运行在另一台设备上时,当它试图获取那个不受支持的 TutorialDeviceInterfaceV1 实现时会抛出异常。

7.5 状态 LED 应用

这是一个完全没有数据平面的小应用——它只通过定制服务和反应器(reactor)驱动 LED。完整程序随源码发布为 example_module_tutorial.cpp

发现板卡,启动控制平面,获取定制服务:

    // 通过 bootp 枚举找到板卡并获取其元数据。
    auto& adapter = hololink::module::Adapter::get_adapter();
    hololink::module::EnumerationMetadata metadata
        = adapter.wait_for_channel(HOLOLINK_IP, DISCOVERY_TIMEOUT);

    // 启动控制平面;每次寄存器访问都经由它完成。
    auto hololink = hololink::module::HololinkInterfaceV1::get_service(metadata);
    if (hololink->start() != HOLOLINK_MODULE_OK) {
        throw std::runtime_error("HololinkInterface::start failed");
    }
    if (hololink->reset() != HOLOLINK_MODULE_OK) {
        throw std::runtime_error("HololinkInterface::reset failed");
    }

    // 获取 Tutorial 设备的定制状态 LED 服务。
    auto device
        = hololink::module::tutorial::TutorialDeviceInterfaceV1::get_service(metadata);

反应器的闹钟(alarm)是一次性的,因此回调会重新武装自己,实现每秒闪烁一次;反应器在自己的线程上运行它:

    auto reactor = hololink::module::ReactorV1::get_service(
        adapter.host_publisher()->self_module());
    auto state = std::make_shared<bool>(false);
    auto tick = std::make_shared<hololink::module::ReactorV1::Callback>();
    *tick = [device, state, reactor, tick]() {
        *state = !*state;
        device->set_status_led(*state);
        reactor->add_alarm_s(BLINK_PERIOD_S, tick);
    };
    reactor->add_alarm_s(BLINK_PERIOD_S, tick);

闪烁由反应器线程驱动,因此主线程只是永远阻塞:

    // 反应器线程驱动闪烁;此程序永远运行。
    for (;;) {
        sleep(60);
    }

7.6 背景(Background)

  • 模块初始化。 主机调用模块唯一导出的符号 hololink_module_init,它返回模块的服务回调,使主机能够把 get_service 请求路由给它。
  • 服务缓存。 每个模块缓存它已发布的服务。有些是单例(每模块一个);其他是设备专用的,以 instance_id——通常是序列号——为键,因此在多个端口上看到的同一块板会解析到同一个对象。
  • HsbLite 是核心实现。 它实现了 HSB-IP 的主机侧行为;设备专用模块几乎可以覆写它的任何部分。

功能视角

当一台 HSB 设备上电时,它会周期性地发布枚举报文,通常以 1 Hz 发送。该枚举报文包含一个标识设备的 UUID 和一个 compat-id 字段,后者提供关于版本兼容性的具体信息。

应用使用 hololink_module::Adapter 监听这些 bootp 报文。每条枚举报文被反序列化为一个称为枚举元数据(enumeration metadata)的结构。

收到的 UUID 与 compat-id 字段用于定位与这台设备关联的 hololink 模块。这个模块是一个(共享对象形式的)驱动,通常命名为 hololink_<UUID>_<compat-id>.so。找到后,它被加载并初始化,收到的枚举元数据被传给它。此时,模块可以按需调整或增强枚举数据。

处理后的枚举元数据被交还给应用。如果主机没有可加载的兼容模块,来自反序列化网络报文的枚举元数据将被(不加修改地)发送给应用。

应用使用这份枚举元数据来获取特定服务。例如,要复位板卡,应用调用 HololinkInterfaceV1::reset。要获取指向 HololinkInterfaceV1 对象的指针,使用 HololinkInterfaceV1::get_service(enumeration_metadata)

    auto hololink = hololink::module::HololinkInterfaceV1::get_service(metadata);

这个 get_service 调用取回的是可能经过设备模块定制的实现——因此对 reset 的调用会执行那台特定设备所需的操作。(注意,并非所有服务都通过枚举元数据查找;有些服务用其他条件查找。)

发布者的 construct_service 链在首次获取时才惰性构造服务;覆写它调用的任何方法,即可用你自己的实现替换 HsbLite 实现。如有必要,你甚至可以覆写 construct_service 本身。

应用的所有部分都依赖这些服务。例如,要把设备的数据平面配置为使用 RoCE 报文,RoceReceiverOp 会获取 RoceDataChannelInterfaceV1 并调用它的配置方法。不使用 RoCE 信令的应用不会获取这个对象;因此并非所有设备都需要支持它。如果应用请求一个模块不支持的服务,程序会终止,并显示该服务不被此设备支持的错误消息。

有些 API 是设备专用的,例如激活只在该设备上实例化的某个特性或组件。设备模块可以定义一个设备专用接口(例如 HsbLiteInterfaceV1),用 API 访问和控制这些功能。想要使用这些特性的应用可以获取这个设备专用服务并调用它的方法——但要认识到这些调用会把应用与该设备紧密耦合。

版本管理

compat-id 命名了一组特定的寄存器、外设及其行为;任何具有相同 compat-id 的 HSB-IP 都是寄存器兼容的。如果 HSB-IP 寄存器发生破坏性变更,compat-id 会被更新,从而保证已部署的系统不会尝试使用不兼容的版本。

模块导出的服务是有版本的。这让你以后可以构建新模块(例如支持新特性);只要模块仍然产出与应用所需接口版本匹配的服务,应用就能继续工作。


(全文完)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值