A 内部机制:流的实现方式
A.1 引言
尽管使用 Xillybus 并不需要理解其实现细节,但一些设计者出于好奇或为了验证某个解决方案的可行性,仍然希望了解其底 层工作原理。
本节概述了基于 DMA 缓冲区创建连续流的主要技术。这些内容适用于 PCIe/AXI 接口的 Xillybus,但不适用于采用其他机制 的 XillyUSB。
Xillybus 的设计目标是让底层机制对用户透明,并且在很大程度上用户无需关心这些机制。在阅读下面的技术细节时,请牢记这一 点——这些内容对于将 Xillybus 作为 IP 核使用而言很可能是不必要的。本部分更侧重于“它是如何工作的”,而非用户需要了解的事项。
下文分为两个主要部分,分别介绍上游(upstream)流和下游(downstream)流。由于两个方向采用的技术类似,其中一 个部分的内容很大程度上是另一部分的重复。
为简便起见,除非另有说明,描述均围绕异步流(asynchronous streams)展开。文件结束信号以及非阻塞 I/O 选项不在此 讨论。
A.2 “经典”DMA vs Xillybus
传统上,硬件与软件之间的数据传输采用若干固定大小的缓冲区。数据被组织成固定长度的缓冲区,这些缓冲区可能被完全 填满,也可能未被填满。每当一个缓冲区准备就绪时,会向对端发送某种信号。例如,如果硬件已完成向某个缓冲区的写入,它可以向处理 器发送中断,通知软件数据已准备好进行处理。软件消费完数据后,会通知硬件该缓冲区可以再次写入,通常是通过写入某个内存映射寄存 器来实现。通常情况下,双方以循环(round-robin)方式访问这些缓冲区。
Xillybus 在 FPGA 端和软件端都向用户接口呈现连续流传输。在底层,Xillybus 使用传统的循环模式,背后是一组 DMA 缓冲区。
然而,下文描述的技术用于制造连续流的假象,使用户可以忽略底层数据传输的存在。特别是,即使应用程序以固定大小的 数据块发送数据,也无需将 DMA 缓冲区大小与应用程序数据匹配,具体原因如下文所述。
A.3 FPGA 到主机(上游方向)
A.3.1 概览
下图展示了 FPGA 到主机(上游方向)的数据流。阴影区域表示各自存储元件中尚未被消费的数据。
在此示例中显示了四个 DMA 缓冲区,但实际数量可以在 IP Core Factory 中配置。
数据分三个阶段流向主机,详情如下。
A.3.2 阶段 #1:应用逻辑到中间 FIFO
FPGA 中的用户应用逻辑将数据推入连接用户应用逻辑与 Xillybus IP 核的 FIFO。对于何时推送数据或推送多少数据没有要求,只需注意 FIFO 的“full”(满)信号以避免溢出。
A.3.3 阶段 #2:中间 FIFO 到 DMA 缓冲 区
在此阶段,Xillybus IP 核将数据从 FIFO 复制到主机 RAM 中的 DMA 缓冲区。为实现这一点,该 IP 核使用某种总线主控接 口(PCIe、AXI4 等)直接将数据写入主机内存,无需主机处理器干预。
主机 RAM 中分配有一组 DMA 缓冲区。每个 DMA 缓冲区的生命周期与许多类似场景相同:初始时,所有 DMA 缓冲区都为 空,且概念上属于硬件。硬件以循环方式向缓冲区写入数据:当某个缓冲区写入完成后,它会通知主机该缓冲区已可供使用(即缓冲区被移 交给主机),然后继续写入下一个缓冲区。主机随后可以消费移交给它的缓冲区中的数据,消费完毕后通知硬件该缓冲区可以再次写入(主 机将缓冲区返还给硬件)。
阶段 #2 的数据流由 FIFO 的“empty”(空)信号以及 DMA 缓冲区池中的空闲空间控制。当 Xillybus IP 核检测到 FIFO 的“empty”信号为低(即 FIFO 非空),且某个 DMA 缓冲区中还有空间时,它就从 FIFO 中取出数据并写入 DMA 缓冲区。当 FIFO 再次变为 空,或者所有 DMA 缓冲区都已写满时,IP 核的内部状态机会暂时停止取数据,然后从 DMA 缓冲区中上次中断的位置继续。
当数据流暂停时,IP 核可能会忙于其他活动,例如为其他流复制数据(即排空另一个中间 FIFO)。因此,从 FIFO 的“empty”信号变为低到恢复取数据之间可能存在随机延迟。这个延迟会变化,但总的来说,IP 核保证一个 512 字的 FIFO 不会发生溢出(只 要平均速率在限制范围内)。
每个 DMA 缓冲区可以在完全填满后移交给主机,也可以在部分填充时提交给主机。部分填充缓冲区的提交条件稍后详述 (第 A.3.5 节),因为它们需要理解软件的一些行为。
同步流(synchronous streams)的情况与此非常相似,区别在于 Xillybus IP 核会等待主机明确请求一定量的数据之后,才 会从中间 FIFO 中取数据。
A.3.4 阶段 #3:DMA 缓冲区到用户软件应 用程序
此阶段由主机上的 Xillybus 驱动程序通过响应 read() 系统调用(或在 Microsoft Windows 上是对应的 IRP)来实现。根据成 熟的 API,read() 请求包含一个由用户应用程序提供的缓冲区,以及该缓冲区的大小,这也是可读取的最大字节数。该函数调用可以在读取了 最大字节数(完全满足)或更少字节后返回。
驱动程序首先检查已移交给它的 DMA 缓冲区,以确定 DMA 缓冲区中是否有足够的数据来完全满足 read() 请求。如果是, 它将数据复制到用户缓冲区,可能将 DMA 缓冲区返还给硬件,然后从系统调用返回。
否则,标准的 read() 函数调用 API 允许驱动程序要么返回少于请求字节数的数据,要么等待(休眠)任意时间。驱动程序的 设计是,既不会过于频繁地返回少量数据(这会导致大量每次读取很少字节的 read() 调用,从而浪费 CPU 周期),也不会引入不必要的延 迟。两难之处在于:如果 DMA 缓冲区的数据少于 read() 函数调用所需的数据量,该怎么办?是返回部分数据,还是等待(以及等待多 久)?
所选择的策略是:最多等待 10 ms 以获取更多数据,然后返回当前可用的数据(如果没有数据可用,则根据标准 API 要求无 限期等待)。这样既能保证较快的响应时间,又能将开销限制在每秒 100 次 read() 调用以内(如果 read() 调用者始终请求多于可用数据的情 况)。
但这并不意味着 read() 函数调用一定有 10 ms 的延迟:如果用户空间应用程序预先知道应该有多少字节就绪,它可以将请求 的字节数设得不超过这个数。通过这样做,它可以确保微秒量级的延迟。
然而,有一个棘手之处:主机知道已移交给它的 DMA 缓冲区,但可能存在一个部分填充的 DMA 缓冲区,主机并不知道它。 因此,如果考虑到这个部分填充的 DMA 缓冲区,实际上可能有足够的数据完全满足一个 read() 函数调用。
为了正确处理这种情况,驱动程序会检查缺少的字节数是否能被放入一个部分填充的缓冲区中。如果确实如此,它会通知硬 件需要多少数据才足够。然后驱动程序开始 10 ms 的等待。这给了硬件一个机会,如果它确实能使 read() 函数调用完全完成,就可以立即发 送一个部分填充的缓冲区。
如果部分填充的缓冲区达到了所需的量(可能立即实现),硬件就将其移交给主机,主机随后立即完成 read() 函数调 用。
当 10 ms 周期结束时,驱动程序返回它当前拥有的尽可能多的数据。如果根本没有数据,驱动程序会向硬件发送请求,要求 硬件传递它拥有的任何部分填充的缓冲区。这样做的目的是尽早返回数据,因为 10 ms 周期已经结束。
在所有情况下,当某个 DMA 缓冲区被完全消费后,驱动程序会将其返还给硬件(即通知硬件可以再次写入)。
关于同步流的说明:流程在原则上相同,区别在于调用 read() 函数时 DMA 缓冲区中永远没有数据可用。这是因为硬件只有 在被指示的情况下才允许从 FPGA 的 FIFO 复制数据。因此,同步流的 read() 函数调用涉及通知硬件应该复制多少数据。等待机制保持不 变:先等待 10 ms,然后要求任何部分填充的缓冲区。
A.3.5 部分填充缓冲区的提交条 件
从上述内容可以推导出提交部分填充缓冲区的情况,在此列出以方便参考。
一般规则是:如果硬件被告知提前提交将导致 read() 函数调用立即返回,则部分缓冲区会被移交给主机。这发生在以下三种 情况之一:
-
主机当前正在处理一个 read() 函数调用,当当前部分填充的缓冲区移交后,该调用将完全满足。
-
read() 函数调用已请求零字节,且已达到时间限制(即 10 ms)。
-
仅针对同步流:当硬件已完成主机所请求的数据量获取时。
注意,当 FIFO 变空时,它本身不是提交 DMA 缓冲区的理由。
A.3.6 示例
让我们考虑一个简单的 8 位异步流案例。假设一个流开始时没有任何数据,然后 FIFO 被填入一个元素(即一个字节)。主 机上的应用程序随后调用 read() 函数,请求一个字节。可能的事件链如下:
-
Xillybus IP 核检测到“empty”信号变低,因此从 FIFO 中取出一个字节,之后 FIFO 再次变空。
-
该字节通过 DMA 写入 DMA 缓冲区的第一个位置。主机未收到通知,因为缓冲区未满。
-
主机上发起一个 read() 函数调用,请求一个字节。
-
驱动程序没有可从中取数据的 DMA 缓冲区:唯一包含数据(一个字节)的 DMA 缓冲区仅被硬件知晓。
-
驱动程序检测到所需数据量小于一个 DMA 缓冲区的大小,因此告诉硬件如果有至少一个字节,则提交一个部分填充的缓冲 区。
-
驱动程序开始 10 ms 的休眠,等待事件发生。
-
硬件立即响应,将部分填充的缓冲区移交给主机。
-
驱动程序立即唤醒,将请求的一个字节复制到 read() 函数调用提供的缓冲区中,然后返回。
这个简单示例演示了 read() 函数调用如何几乎立即返回,即使数据量远小于 DMA 缓冲区。
让我们再看一个例子,有一个小的不同:read() 函数调用请求两个字节,但 FIFO 中只写入了一个字节。事件序列如 下:
-
Xillybus IP 核检测到“empty”信号变低,因此从 FIFO 中取出一个字节,之后 FIFO 再次变空。
-
该字节通过 DMA 写入 DMA 缓冲区的第一个位置。主机未收到通知,因为缓冲区未满。
-
主机上发起一个 read() 函数调用,请求两个字节。
-
驱动程序没有可从中取数据的 DMA 缓冲区:唯一包含数据(一个字节)的 DMA 缓冲区仅被硬件知晓。
-
驱动程序检测到所需数据量小于一个 DMA 缓冲区的大小,因此告诉硬件如果有至少两个字 节,则提交一个部分填充的缓冲区。
-
驱动程序开始 10 ms 的休眠,等待事件发生。
-
硬件不做任何事,因为 DMA 缓冲区中只有一个字节,但请求的是两个字节。
-
10 ms 后驱动程序唤醒,手里没有数据。它向硬件发送请求,要求硬件尽快提交一个部分填充的缓冲区(除非缓冲区为空)。
-
硬件立即响应,将部分填充的缓冲区移交给主机。
-
驱动程序立即唤醒,将请求的一个字节复制到函数调用者的缓冲区中,然后返回。
第二个示例展示了请求两个字节而实际只有一个时的后果:函数调用在 10 ms 后才返回,只返回一个字节。不过请注意,在 大多数实际场景中,这种延迟并不引人注意。
A.3.7 实际结论
-
即使应用层数据总是由 N 字节的块组成,也没有理由以任何方式调整 DMA 缓冲区的大小。用户应用程序软件只需确保 read() 函数调用精确请求所需的数据量,部分缓冲区机制就能确保在数据被推入 FPGA FIFO 时函数调用返回,且延迟非常低。
-
即使对于连续数据流,也可以通过使用小缓冲区进行 read() 函数调用来降低延迟,代价是增加操作系统开销。无论 DMA 缓 冲区大小如何,延迟仅取决于数据速率和 read() 函数调用中请求的字节数。减小 DMA 缓冲区大小没有帮助,因为如果无法完全满足 read() 函数调用,它将继续等待最多 10 ms。
-
如果 10 ms 的延迟是可接受的,则无需进行优化,因为除非完全没有数据可以返回,否则 read() 函数调用保证在此时间后返 回。
A.4 主机到 FPGA(下游方向)
A.4.1 概览
下图展示了主机到 FPGA(下游方向)的数据流。阴影区域表示各自存储元件中尚未被消费的数据。
在此示例中显示了四个 DMA 缓冲区,但实际数量可以在 IP Core Factory 中配置。
与之前类似,数据分三个阶段从主机流向 FPGA,详情如下。
A.4.2 阶段 #1:用户软件应用程序到 DMA 缓冲区
此阶段由主机上的 Xillybus 驱动程序通过响应 write() 系统调用(或在 Microsoft Windows 上是对应的 IRP)来实现。根据成 熟的 API,write() 系统调用请求包含一个由用户应用程序提供的缓冲区,以及该缓冲区的大小,这也是可写入的最大字节数。该函数调用可以 在写入最大字节数(完全写入)或更少字节后返回。
主机 RAM 中分配有一组 DMA 缓冲区。每个 DMA 缓冲区的生命周期与许多类似场景相同:初始时,所有 DMA 缓冲区都为 空,且概念上属于主机。主机以循环方式向缓冲区写入数据:当某个缓冲区写入完成后,它会通知硬件该缓冲区已准备好(即缓冲区被移交 给硬件),然后继续写入下一个缓冲区。硬件随后可以消费缓冲区中的数据,消费完毕后通知主机该缓冲区可以再次写入(硬件将缓冲区返 还给主机)。
Xillybus 驱动程序通过尝试将尽可能多的数据复制到 DMA 缓冲区中来响应 write() 函数调用。当某个 DMA 缓冲区被完全填满时,它会被 移交给硬件,即主机通知硬件该缓冲区可以被消费,并保证在硬件返还缓冲区之前不会再次写入。
如果在 DMA 缓冲区空间耗尽之前驱动程序成功地写入了至少一个字节,则 write() 函数调用返回已写入的字节数。否则,它 将无限期等待(通过休眠,即“阻塞”),直到有 DMA 缓冲区可供写入,然后尽可能多地写入数据并返回。
注意,如果某个 DMA 缓冲区被部分填充,write() 函数调用结束时它不会被移交给硬 件,因此可能存在一个 DMA 缓冲区中有数据但硬件不知道的情况。“刷新”(flush)操作会提交一个部分填充的缓冲区,它在以下四种情况之 一发生时进行:
-
显式刷新,通过调用 write() 函数并写入零字节来实现。该 write() 函数调用立即返回(即它不等待 FPGA 消费数据)。
-
在最后一次 write() 函数调用后 10 ms,自动刷新(autoflush)被触发。
-
当文件关闭时,会发生一次刷新。在这种情况下,close() 函数调用会等待最多一秒钟,直到数据被 FPGA 完全消费后才返 回。
-
在同步流中,每次 write() 函数调用都以 flush() 结束,该 flush() 会无限期等待,直到数据被 FPGA 完全消费。
注意,使用零长度缓冲区的 write() 函数调用会强制进行一次显式刷新,确保所有已写入的数据对 FPGA 可用。但是,它并 不向应用程序软件提供数据何时被 FPGA 消费的指示。如果需要这种同步,应使用同步流。
A.4.3 阶段 #2:DMA 缓冲区到中间 FIFO
在此阶段,Xillybus IP 核将数据从主机 RAM 中的 DMA 缓冲区复制到 FPGA 中的 FIFO。为实现这一点,该 IP 核使用某种 总线主控接口(PCIe、AXI4 等)直接从主机内存读取数据,无需主机处理器干预。
阶段 #2 的数据流由 FIFO 的“full”(满)信号以及属于 FPGA 的 DMA 缓冲区池中的数据可用性控制。当 Xillybus IP 核检测 到 FIFO 的“full”信号为低(即 FIFO 未满),且某个 DMA 缓冲区中有数据就绪时,它就从 DMA 缓冲区中取出数据并写入 FIFO。当 FIFO 再 次变满,或者 DMA 缓冲区为空时,IP 核的内部状态机会暂时停止取数据,然后从 DMA 缓冲区池中上次中断的位置继续。
当数据流暂停时,IP 核可能会忙于其他活动,例如为其他流复制数据(即填充另一个中间 FIFO)。因此,从 FIFO 的“full”信 号变为低到恢复数据复制之间可能存在随机延迟。这个延迟会变化,但总的来说,IP 核保证一个 512 字的 FIFO 足够深。
硬件当然知道部分填充的 DMA 缓冲区,并跟踪每个缓冲区中包含多少数据。
A.4.4 阶段 #3:中间 FIFO 到应用逻 辑
FPGA 中的用户应用逻辑从连接用户应用逻辑与 Xillybus IP 核的 FIFO 中取出数据。对于何时取出数据或取出多少数据没有要求,只需注 意 FIFO 的“empty”(空)信号以避免下溢(underflow)。
A.4.5 一个示例
让我们考虑一个简单的 8 位异步流案例。假设一个流开始时没有任何数据,然后主机上的应用程序向设备文件写入了一个字 节。
事件序列如下:
-
驱动程序被调用 write() 函数,请求写入一个字节。
-
由于流中尚无数据,DMA 缓冲区中显然有空间。因此驱动程序将该字节复制到第一个 DMA 缓冲区中并返回。
-
之后 10 ms 内没有任何事情发生。
-
10 ms 后自动刷新机制被触发,导致驱动程序将该 DMA 缓冲区移交给硬件,并附带信息表明其中包含一个字节。
-
Xillybus IP 核从 DMA 缓冲区读取该字节并写入中间 FIFO。
-
应用逻辑可以随时从 FIFO 读取该字节。
A.4.6 实际结论
-
即使应用层数据总是由 N 字节的块组成,也没有理由以任何方式调整 DMA 缓冲区的大小。用户应用程序软件只需在每个块 末尾通过 write() 函数请求零字节来触发一次数据刷新。这样就能实现微秒量级的延迟。
-
即使对于连续数据流,也可以通过使用小缓冲区进行 write() 函数调用并随后立即调用一次 flush(即零字节的 write() 函数调 用)来降低延迟,代价是增加操作系统开销。无论 DMA 缓冲区大小如何,延迟仅取决于数据速率以及两次刷新请求之间的数据量。
-
如果预先知道总是在某个数据块之后发生刷新,从而没有任何 DMA 缓冲区被填充到超过某个水平,那么减小 DMA 缓冲区大 小可能是有意义的。不过,这样做唯一的优势是节省主机上的一点 RAM,而这点 RAM 很可能微不足道。
-
如果 10 ms 的延迟是可接受的,则无需进行优化,因为自动刷新机制会在无活动 10 ms 后启动。
