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 与主机之间高速传输数据时最大的挑战之一是维持连续流。在涉及数据采集和回放的应用程序中,数据溢出或短缺 会使系统无法正常工作。为避免这种情况,驱动会在主机上分配大量 RAM 缓冲区供其自身使用。这些缓冲区用于补偿应用程序无法处理数据 传输的时间间隙。
Xillybus 允许分配巨大的驱动缓冲区,但此内存必须从操作系统内核 RAM 池中分配。在某些系统上(特别是 32 位系统),即使总可用 RAM 大得多,Linux 操作系统也将此类内存的寻址空间限制在 1 GB 以内。在 RAM 小于 1 GB 的系统(尤其是嵌入式 Linux)中,所有内存 都可能用于驱动缓冲区。
在使用增强型主机驱动时,可以在 64 位系统上分配更大的缓冲区,如以下页面所述:
https://xillybus.com/doc/huge-dma-buffers/
除了 XillyUSB,驱动缓冲区在 Xillybus 驱动加载时(通常在启动早期)分配,并仅在驱动从内核卸载时(通常在系统关闭期 间)释放。当缓冲区很大时,通常意味着内核 RAM 池的很大一部分被驱动缓冲区占用。这是一个相当合理的设置,因为使用这些缓冲区的应 用程序很可能是其运行机器的主要用途。
大缓冲区的一个潜在问题是它们占用连续的物理 RAM 段。这与用户空间程序中分配的缓冲区相反——后者在虚拟地址空间 中是连续的,但可以分散在物理内存中,甚至根本不占用任何物理 RAM。
随着操作系统运行,可用内存池会变得碎片化。这就是为什么 Xillybus 驱动尽快分配其缓冲区,并在不主动使用时仍保留它 们的原因。稍后尝试卸载并重新加载驱动可能因同样原因而失败。
XillyUSB 采用不同的内存分配方法,对物理内存碎片更宽容。这是其驱动在打开设备文件时为缓冲区分配 RAM,并在文件关闭时释放 RAM 的原因之一。
不过仍需采取预防措施以避免内核 RAM 短缺。IP Core Factory 的自动内存分配(“autoset internals”)算法设计为消耗不超 过相关内存池的 50%,例如对于 PC 计算机为 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。相反,它们仅设置内存 页表以反映内存分配。实际物理内存只有在应用程序尝试使用时才分配。这是一种节省资源的绝妙方法,但可能对数据采集应用程序产生灾 难性影响:例如,考虑数据从数据源涌入时会发生什么。应用程序将数据写入刚分配的缓冲区,但每次访问新内存页时,操作系统都需要获 取一个新物理内存页。如果碰巧有空闲物理 RAM,或者有快速释放物理内存的方法(例如已与磁盘同步的磁盘缓冲区),这种内存调配可能 不易察觉。但在缺乏即时物理 RAM 来源的情况下,可能必须进行磁盘操作(RAM 交换到磁盘或刷新磁盘缓冲区),从而导致应用程序停顿 过久。
真正糟糕的是,应对初始数据负载的能力取决于整个系统的状态。因此,一个通常能正常工作的程序可能会突然失败,因为 另一个程序刚好在同一台计算机上执行了数据密集型操作。
自然的解决方案是内存锁定:mlock() 告诉操作系统某块(虚拟)内存必须保留在物理 RAM 中。这会立即强制分配物理内 存,因此如果需要磁盘操作来完成函数调用,则返回可能需要一些时间。
操作系统不愿锁定大块 RAM,因为这会影响其整体性能。在大多数情况下,需要提升 shell 中的某些限制或设置配置文 件。
4.4 fifo.c 演示应用程序概述
在可下载供 Linux 和 Windows 使用的演示应用程序中,有一个名为“fifo.c”的应用程序。它演示了如何使用两个线程实现 RAM FIFO,已在 32 位和 64 位平台上测试通过。
关于演示应用程序的更多信息,请参见 在 Linux 主机上入门 Xillybus。
请注意,与本文档中其他地方不同,本节中的“FIFO”一词指的是主机上的 RAM 缓冲区,而非 FPGA 中的 FIFO。
该程序的目的是测试快速流,其中需要 RAM FIFO 来维持巨大的 RAM 缓冲区。换句话说,如果您需要的缓冲区小于 16 GB 左右,很可能不需要此程序。
它也可以作为修改和适配自定义应用程序的基础。其设计不包含互斥锁,因此从不会有线程仅仅因为另一个线程持有锁而休 眠。当然,当 FIFO 状态需要时(例如从空 FIFO 请求读取),会发生阻塞(休眠)。
这种无需互斥锁的实现需要谨慎使用 API 函数,因为它们不可重入。但这对于读取一个线程、写入一个线程来说没有问 题。
要从设备文件采集数据到磁盘文件,并带有 128 MB 缓冲区,可以输入类似以下命令:
$ ./fifo 134217728 /dev/xillybus_async > dumpfile
如果未提供第二个参数作为文件名,程序将从标准输入读取。
可能需要在 shell 提示符下使用 root 权限通过 ’limit -l’ 提升锁定内存的限制(可能使用 “su - your-username” 以 root 身份将权 限降回普通用户,并保留更新后的限制)。如需永久更改限制,请参考您的 Linux 发行版文档。
该程序创建三个线程:
-
read_thread() 从标准输入(或命令行给出的文件)读取数据并写入 FIFO
-
write_thread() 从 FIFO 读取数据并写入标准输出
-
status_thread() 定期向标准错误输出状态行
第三个线程没有功能意义,可以删除。也可以让主线程承担读取或写入功能之一。例如,在数据采集应用程序中,可以自然 地只启动 read_thread() 将数据从文件描述符移动到 FIFO,但在主应用程序线程中消费 FIFO 中的数据。
4.5 fifo.c 修改说明
如果您想修改程序,请记住以下几点:
-
fifo_* 函数不可重入。当每个线程使用一组其他线程不使用的函数时(这是自然用法),可以安全地使用它们。
-
fifo_init() 函数可能需要时间才能返回,应在打开异步 Xillybus 设备文件之前调用。
-
应用程序中执行读取和写入的线程总是尝试在其 I/O 请求中允许的最大字节数。这在某些情况下可能有问题,例如当 I/O 源 是 /dev/zero 而目标是 /dev/null 时。两者都会在一次尝试中完成整个请求,因此 FIFO 将从完全空变为完全满,然后重复。在这种情况下,更 明智的做法是在调用 I/O 函数时限制请求的字节数。
4.6 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 满则休眠。用户应用程序根据需要写入尽可能多的字节(但不超过 fifo_request_write() 允许的数量),写入从 fifo_request_write() 获得的地 址,然后向 fifo_wrote() 报告其操作。
现在我们详细讲解每个函数。
4.6.1 fifo_init()
fifo_init(struct xillyfifo *fifo, unsigned int size) – 此函数初始化 FIFO 的信息结构并为其分配内存。它还会尝试将 FIFO 的虚拟内存锁定到 物理 RAM,使其准备好快速写入,并防止其被交换到磁盘。
fifo_init() 为大小为 size 字节的缓冲区分配内存。size 可以是任何整数 (即不必是 2 的幂,2^N),但建议是系统认为 int 的倍数。
请注意,此函数可能需要数秒才能返回:请求大量物理 RAM 可能会迫使操作系统将其他进程的 RAM 页交换到磁盘,或强制 刷新磁盘缓存。在这两种情况下,fifo_init() 可能必须等待大量数据写入磁盘后才能返回。
成功时返回零,否则返回非零。
4.6.2 fifo_destroy()
fifo_destroy(struct xillyfifo *fifo) – 在解锁后释放 FIFO 的内存,并释放线程同步资源。此函数应 该在主程序退出时调用,因为即使在当前 Linux 实现中线程同步资源会自动释放,它们的 API 也不能保证这一点。
此函数为 void 类型(因此不返回任何值)。
4.6.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.6.4 fifo_drained()
fifo_drained(struct xillyfifo *fifo, unsigned int req_bytes) – 此函数更改 FIFO 的状态以反映消耗了 req_bytes 字节。如果 fifo_request_write() 因 FIFO 满而休眠,它将被唤醒。
重要
的:
对 req_bytes 没有完整性检查。用户应用程序有责任确保 req_bytes 不大于上次调用
fifo_request_drain() 所返回的 info->bytes。
此函数为 void 类型(因此不返回任何值)。
4.6.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_request_write() 返回零,即使 FIFO 未满(将数据写入永远不会被读取的 FIFO 毫无意义)。
4.6.6 fifo_wrote()
fifo_wrote(struct xillyfifo *fifo, unsigned int req_bytes) – 此函数更改 FIFO 的状态以反映插入了 req_bytes 字节。如果 fifo_request_drain() 因 FIFO 为空而休眠,它将被唤醒。
重要
的:
对 req_bytes 没有完整性检查。用户应用程序有责任确保 req_bytes 不大于上次调用
fifo_request_write() 所返回的 info->bytes。
此函数为 void 类型(因此不返回任何值)。
4.6.7 fifo_done()
fifo_done(struct xillyfifo *fifo) – 此函数是可选的,用于帮助应用程序在读取或写入线程之一完成时优雅退出。它仅在 FIFO 结 构中设置一个标志,并唤醒可能正在休眠的两个线程。通过这样做,如果 FIFO 为空,fifo_request_drain() 将返回零而不是休眠,而 fifo_request_write() 无论如何都将返回零。
这样,这些函数的调用者就知道 FIFO 不再有用,可以采取必要的行动,这很可能是停止线程的执行。
当馈送管道的数据源结束时(例如达到 EOF)或数据消费者不再接收数据时(例如管道断裂),调用此函数。
此函数为 void 类型(因此不返回任何值)。
4.6.8 FIFO_BACKOFF 定义变量
有时不希望让 FIFO 满到最后一个字节。尽管没有明显理由避免这样做,但可能希望在数据写入位置与读取位置之间保持一 个小间隙。
例如,可以将 FIFO_BACKOFF 设置为 8,这样写入 FIFO 的最后一个字节永远不会与第一个有效读取字节共享一个 64 位 字。这是一种相当牵强的预防措施,但代价仅为 8 字节内存。
使用 Xillybus 或 XillyUSB 时不需要此功能。
