4 高速率连续 I/O

4.1 基础知识

要在主机与 FPGA 之间实现高速率连续数据流,几乎必须遵循以下四项实践:

  • 使用异步流(Asynchronous Stream)

  • 确保驱动缓冲区足够大,以补偿用户空间应用程序 I/O 操作之间的时间间隙

  • 让用户空间应用程序在有数据可用时立即从设备文件读取数据,或者在有缓冲区空间可用时立即向其写入数据

  • 在 FPGA 持续插入或排出数据期间,切勿关闭并重新打开设备文件

如本 网页 所述,XillyUSB 在维持数据持续流动方面还面临额外挑战。

关于如何监控任意时刻驱动缓冲区中持有的数据量,请参见 Xillybus FPGA 设计者指南中“监控缓冲数据量”一节。

上述列表中的第一项(使用异步流)已在第 2 节讨论。第 二项和第三项将在本节余下部分讨论。

要理解第四项,请回想异步流的优势在于数据在 FPGA 与主机之间流动时无需用户空间应用程序干预。当文件关闭时,此流 动即停止。

具体对于从主机到 FPGA 的流而言,关闭文件会强制刷新缓冲区中的所有数据,并且文件仅在刷新完成后(或一秒钟后)才 会关闭。因此,从文件关闭到再次打开文件(以及向文件描述符写入数据)之间存在无数据流动的时间间隙。

至于来自 FPGA 的流,关闭文件会导致从 FPGA 中的应用逻辑到主机中的用户空间应用程序这一管道(即 FPGA 的 FIFO 和驱动缓冲区)中的任何数据丢失。避免这种丢失的唯一方法是在关闭文件之前从该管道中排空所有数据。再次强调,关闭文件与重新打开 文件之间存在无数据流动的时间间隙。

一个常见错误是使用 EOF(文件结束)功能来标记数据块(例如完整的视频帧),从而迫使主机在已知边界处关闭并重新打 开设备文件。然而这会显著增加 FPGA 的 FIFO 溢出的风险。

需要牢记的是,操作系统可能在任何时刻剥夺用户空间应用程序的 CPU(抢占),因此程序中后续函数调用之间可能出现几 毫秒、有时甚至几十毫秒的时间间隙。

4.2 大型驱动缓冲区

在 FPGA 与主机之间以高速率传输数据时最大的挑战之一是维持连续流动。在涉及数据采集(Data Acquisition)和数据回放 (Data Playback)的应用中,数据溢出(Overflow)或不足(Underflow)会使系统无法正常工作。为避免这种情况,驱动程序在主机上为其 自身使用分配大型 RAM 缓冲区。这些缓冲区补偿了应用程序无法处理数据传输的时间间隙。

Xillybus 允许分配巨大的驱动缓冲区,但此内存必须从操作系统内核 RAM 的池中分配。在某些系统(尤其是 32 位系统)上,即使可用的 总 RAM 大得多,Windows 操作系统也会将此类内存的寻址空间限制为 1 GB。在 RAM 少于 1 GB 的系统中,所有内存可用于驱动缓冲 区。

当使用增强型主机驱动程序时,可以在 64 位系统上分配更大的缓冲区,如下页面所述:

https://xillybus.com/doc/huge-dma-buffers/

除 XillyUSB 外,驱动缓冲区在 Xillybus 驱动程序加载时(通常在引导过程的早期)分配,并且仅在驱动程序从内核卸载时 (通常是在系统关闭期间)释放。当缓冲区巨大时,这通常意味着内核 RAM 池的很大一部分被驱动缓冲区占用。这是一个相当合理的设置, 因为使用这些缓冲区的应用程序很可能就是其运行的机器的主要用途。

巨大缓冲区的一个潜在问题是它们占用物理 RAM 的连续段。这与用户空间程序中分配的缓冲区相反,后者在虚拟地址空间 中是连续的,但可以分散在物理内存各处,甚至根本不占用任何物理 RAM。

随着操作系统运行,可用内存池会变得碎片化。这就是为什么 Xillybus 驱动程序尽早分配其缓冲区,并在不活动时也保留它 们的原因。稍后尝试卸载驱动程序并重新加载可能会因相同原因而失败。

XillyUSB 采用不同的内存分配方法,对物理内存碎片更具容忍性。这也是其驱动程序在打开设备文件时为其缓冲区分配 RAM,并在关闭 文件时释放 RAM 的原因之一。

但仍应采取措施以避免内核 RAM 短缺。IP Core Factory 的自动内存分配(“autoset internals”)算法设计为消耗不超过相关 内存池的 50%,即 512 MB,这是基于现代 PC 已安装超过 1 GB RAM 的假设。将缓冲区大小手动设置为高达 75% 可能也是安全的。

过度分配缓冲区可能导致系统不稳定。特别是,每当操作系统无法从内核池分配 RAM 时,它可能会看似随机地杀死进 程。

4.3 用户空间中的 RAM 缓冲区

对于需要在 32 位机器上使用大于 512 MB 缓冲区的应用程序,建议在用户空间 RAM 中完成部分缓冲。在 64 位机器上,此 选项很少相关,除非所需的缓冲区大小非常大且不是 2 的幂 (2^N)。例如,通过 Xillybus DMA 缓冲区无法为流提供 62 GB 的缓冲区,但可以通过用户空间 RAM 实 现。

在用户空间应用程序中分配巨大缓冲区似乎可以直观地解决 I/O 连续性问题。确实,当操作系统使应用程序缺乏 CPU 时间 时,此解决方案并无帮助。但如果操作系统的调度器设计得相当好且优先级设置正确,即使在负载较重的计算机上,用户空间应用程序也能 足够频繁地获得其 CPU 时间片。

重要的是要注意缓冲区的首次填充:现代操作系统在用户空间应用程序请求内存时不会分配任何物理 RAM。相反,它们只是 设置内存页表以反映内存分配。实际物理内存仅在应用程序尝试使用时才分配。这是一种节省资源的绝妙方法,但可能对数据采集(Data Acquisition)应用程序产生灾难性影响:例如,考虑当数据开始从数据源涌入时会发生什么。应用程序将数据写入刚刚分配的缓冲区,但每次 访问新的内存页时,操作系统都需要获取一个新的物理内存页。如果碰巧有空闲物理 RAM,或者有快速释放物理内存的方法(例如已与磁盘 同步的磁盘缓冲区),这种内存调配可能不会被注意到。但在缺乏即时物理 RAM 源的情况下,可能必须发生磁盘操作(RAM 交换到磁盘或 刷新磁盘缓冲区),这可能会使应用程序停顿太长时间。

真正糟糕的消息是,承受初始数据负载的能力取决于整个系统的状态。因此,通常能正常工作的程序可能会突然失败,因为 同一台计算机上的另一个程序刚刚做了密集的数据操作。

自然的解决方案是内存锁定:VirtualLock() 告诉操作系统,某个(虚拟)内存块必须保持在物理 RAM 中。这会强制立即分 配物理内存,因此如果需要磁盘操作来完成函数调用,则可能需要一些时间才能返回。

操作系统不愿意锁定大块 RAM,因为这会影响其整体性能。调用 SetProcessWorkingSetSize() 函数以提高 RAM 锁定限制 通常是 VirtualLock() 成功所必需的。

4.4 为什么不直接使用 Windows 管 道?

Windows 标准 API 至少支持两个用于创建管道的函数:CreatePipe() 和 CreateNamedPipe()。这两个函数允许调用者确定缓冲区大小, 因此表面上看它们可以胜任。不幸的是,如 CreatePipe() 和 CreateNamedPipe() 的网页所述,Windows 将请求的缓冲区大小视为建议 值。

4.5 fifo.c 演示应用概述

在可供 Linux 和 Windows 下载的演示应用程序中,有一个名为“fifo.c”的程序。它展示了如何使用两个线程实现 RAM FIFO(First In First Out,先进先出队列),已在 32 位和 64 位平台上测试过。

有关演示应用程序的更多信息,请参见 在 Windows 主机上入门 Xillybus

请注意,与文档中其他地方不同,本节中的“FIFO”一词指的是主机上的 RAM 缓冲区,而不是 FPGA 中的 FIFO。

此程序的目的是测试快速流,这些流需要 RAM FIFO 来维持巨大的 RAM 缓冲区。换句话说,如果您需要小于 16 GB 的缓冲 区,那么您很可能不需要此程序。

它也可以作为修改和适配到自定义应用程序的基础。它设计为无互斥锁,因此没有线程仅仅因为另一个线程持有锁而休眠。 当然,当 FIFO 的状态需要时(例如,从空 FIFO 请求读取),会发生休眠(阻塞)。

这种无互斥锁的实现需要小心使用 API 函数,因为它们不可重入。但对于一个线程读取、一个线程写入来说,这没有问 题。

要以 128 MB 的缓冲区从设备文件采集数据到磁盘文件,运行类似如下的命令:

> fifo 134217728 \\.\xillybus_async > dumpfile

如果未提供第二个参数作为文件名,程序将从标准输入读取。

程序创建三个线程:

  • read_thread() 从标准输入(或命令行中给出的文件)读取数据并将其写入 FIFO

  • write_thread() 从 FIFO 读取数据并写入标准输出

  • status_thread() 反复向标准错误输出状态行

第三个线程没有功能意义,可以删除。也可以让读/写功能之一在主线程中运行。例如,在数据采集应用程序中,可以自然地 只启动 read_thread() 将数据从文件描述符移动到 FIFO,而在主应用程序的线程中消费来自 FIFO 的数据。

4.6 fifo.c 修改说明

如果您想修改该程序,请记住以下几点:

  • fifo_* 函数不可重入。当每个线程使用一组其他线程不使用的函数时,使用它们是安全的(这是自然的使用方式)。

  • 函数 fifo_init() 可能需要时间才能返回,应在打开异步 Xillybus 设备文件之前调用。

  • 应用程序中读取和写入的线程总是尝试其 I/O 请求中允许的最大字节数。在某些情况下可能会出现问题,例如当 I/O 源是 /dev/zero 而目标是 /dev/null 时。两者都会在一次尝试中完成整个请求,因此 FIFO 将经历从完全空 (empty) 到完全满 (full) 并再次循环。在这 种情况下,更明智的做法是限制对 I/O 函数的调用中请求的字节数。

4.7 RAM FIFO 函数

除了修改 fifo.c 示例外,还可以从源代码中采用一组函数。

fifo.c 文件中有一个明显的 FIFO API 函数区域。这些函数可通过遵循示例并根据下面的函数描述,在自定义应用程序中使用。

重要 的:
尽管 fifo_* 函数旨在用于多线程环境,但这些函数不可重入。这意味着一个线程应调用 与从 FIFO 读取相关的函数,而另一个线程应执行写入,因此每个线程调用其独立的函数集。

除了初始化器、销毁器和线程连接辅助函数外,API 还有四个用于读取和写入的函数,每个方向两个。这些函数实际上都不 访问 FIFO 中的数据;它们仅维护 FIFO 的状态并提供执行读取、写入、内存复制等操作所需的信息。

预期的执行过程如下:从 FIFO 读取的线程调用 fifo_request_drain() 函数,该函数返回有关可读取多少字节的信息,以及一 个可以从中读取数据的指针。如果 FIFO 为空 (empty),线程将休眠,直到数据到达。

然后用户应用程序对所指的数据进行任何所需的操作。在消费完部分或全部数据(写入文件、复制数据、运行某些算法等) 之后,它调用 fifo_drained() 函数通知 FIFO API 实际消费了多少字节。API 释放 FIFO 中相应的内存部分。如果写入线程因 FIFO 满 (full) 而 正在休眠,则将其唤醒。

请注意,读取线程不要求特定数量的字节。相反,fifo_request_drain() 告诉应用程序可以消费多少字节,然后应用程序报告 它在 fifo_drained() 中选择消费了多少。

至于相反的方向,采用类似的方法:写入线程调用 fifo_request_write() 函数。该函数返回可写入 FIFO 的字节数,如果 FIFO 满 (full) 则休眠。用户应用程序根据需要写入尽可能多的字节(但不能超过 fifo_request_write() 允许的数量)到从 fifo_request_write() 获取的 地址,然后向 fifo_wrote() 报告其所做的操作。

下面我们详细介绍每个函数。

4.7.1 fifo_init()

fifo_init(struct xillyfifo *fifo, unsigned int size) – 该函数初始化 FIFO 的信息结构并为 FIFO 分配内存。它还尝试将 FIFO 的虚拟内存锁定到 物理 RAM,使其准备好进行快速的即时写入,并防止其被交换到磁盘。

fifo_init() 为大小为 size 字节的缓冲区分配内存。size 可以是任何整数 (即不必是 2 的幂2^N),但建议是系统视为 int 的倍数。

请注意,此函数可能需要几秒钟才能返回:请求大量物理 RAM 可能会迫使操作系统将其他进程的 RAM 页交换到磁盘,或强 制刷新磁盘缓存。在这两种情况下,fifo_init() 可能必须等待大量数据写入磁盘后才能返回。

成功时返回零,否则返回非零。

4.7.2 fifo_destroy()

fifo_destroy(struct xillyfifo *fifo) – 在解锁后释放 FIFO 的内存,并释放线程同步资源。主程序退出时应调 用此函数,因为尽管在当前 Windows 实现中线程同步资源会自动释放,但其 API 并不保证这一点。

此函数为 void 类型(因此无返回值)。

4.7.3 fifo_request_drain()

fifo_request_drain(struct xillyfifo *fifo, struct xillyinfo *info) – 提供指向从 FIFO 读取数据的指针作为 info->addr,并告知从该指针开始可以读取多少字节,存储于 info->bytes 中。

info 结构不得与用于 fifo_request_write() 函数调用的结构相同。每个 线程应为该结构维护其自己的局部变量。

重要 的:
返回的字节数并不表示 FIFO 中剩余可供读取的数据量:它也可能反映直到 FIFO 内存 缓冲区末尾的字节数。因此当指针接近缓冲区末尾时,数值可能显著变小。

该函数还设置 fifo->position 以指示 FIFO 的当前读取位置,其值为 0 到 size-1 之间, 其中 size 是提供给 fifo_init() 的值。非零的 fifo->slept 表示调用时 FIFO 为空 (empty)。

该函数返回允许读取的字节数(与 info->bytes 相同)。但如果已调用 fifo_done() 函数且 FIFO 为空,则 fifo_request_drain() 返回零。

4.7.4 fifo_drained()

fifo_drained(struct xillyfifo *fifo, unsigned int req_bytes) – 此函数更改 FIFO 的状态以反映已消费了 req_bytes 字节。如果 fifo_request_write() 因 FIFO 满 (full) 而正在休眠,则将其唤醒。

重要 的:
对 req_bytes不做健全性检查。用户应用程序有责任确保 req_bytes 不大于上一次调用 fifo_request_drain() 返回的 info->bytes。

此函数为 void 类型(因此无返回值)。

4.7.5 fifo_request_write()

fifo_request_write(struct xillyfifo *fifo, struct xillyinfo *info) – 提供指向向 FIFO 写入数据的指针作为 info->addr,并告知从该指针开始可以写入多少字节,存储于 info->bytes 中。

info 结构不得与用于 fifo_request_drain() 函数调用的结构相同。每个 线程应为该结构维护其自己的局部变量。

重要 的:
返回的字节数并不表示 FIFO 中剩余可供写入的空间:它也可能反映直到 FIFO 内存缓 冲区末尾的字节数。因此当指针接近缓冲区末尾时,数值可能显著变小。

该函数还设置 fifo->position 以指示 FIFO 的当前写入位置,其值为 0 到 size-1 之间, 其中 size 是提供给 fifo_init() 的值。非零的 fifo->slept 表示调用时 FIFO 为满 (full)。

该函数返回允许写入的字节数(与 info->bytes 相同)。但如果已调用 fifo_done() 函数,即使 FIFO 未 满,fifo_request_write() 也返回零(向永远不会被读取的 FIFO 写入数据毫无意义)。

4.7.6 fifo_wrote()

fifo_wrote(struct xillyfifo *fifo, unsigned int req_bytes) – 此函数更改 FIFO 的状态以反映已插入了 req_bytes 字节。如果 fifo_request_drain() 因 FIFO 为空 (empty) 而正在休眠,则将其唤醒。

重要 的:
对 req_bytes不做健全性检查。用户应用程序有责任确保 req_bytes 不大于上一次调用 fifo_request_write() 返回的 info->bytes。

此函数为 void 类型(因此无返回值)。

4.7.7 fifo_done()

fifo_done(struct xillyfifo *fifo) – 此函数为可选使用,有助于在任一线程(读取或写入)完成时优雅地退出应用程序。它仅设 置 FIFO 结构中的一个标志,并唤醒两个线程(如果它们正在休眠)。通过这样做,如果 FIFO 为空 (empty),fifo_request_drain() 将返回零 而不是休眠,而 fifo_request_write() 将无条件返回零。

这样,这些函数的调用者就知道 FIFO 不再有用,并可以采取必要的行动,这很可能是停止线程的执行。

当为管道提供数据的数据源已结束(例如达到 EOF)或数据消费者不再接收(例如管道破裂)时,调用此函数。

此函数为 void 类型(因此无返回值)。

4.7.8 FIFO_BACKOFF 宏定义变量

有时不希望让 FIFO 完全填满至最后一个字节。尽管没有明显理由避免这样做,但可能希望在数据写入位置与读取位置之间 保持一个小的间隔。

例如,可以将 FIFO_BACKOFF 设置为 8,这样写入 FIFO 的最后一个字节永远不会与第一个有效读取字节共享一个 64 位 字。这是一种牵强的预防措施,但仅以 8 字节内存的低成本实现。

使用 Xillybus 或 XillyUSB 时不需要此功能。