6 特定编程技术
6.1 可寻址流(Seekable Streams)
可以将同步 Xillybus 流(Synchronous Stream)配置为可寻址(seekable)。流的位置通过独立的信号线以地址形式呈现给 FPGA 中的应用逻辑,因此访问 FPGA 中的存储器阵列或寄存器变得非常直接,如演示包(demo bundle)和示例代码所示。
此功能特别适用于设置 FPGA 中的控制寄存器。流的同步特性确保在底层 I/O 函数返回之前,FPGA 中的寄存器已经被设 定。
以下代码片段演示了如何将 len 字节的数据写入 FPGA 内存或寄存器空间中地址为 address 的位置(假设这两个变量已预先设置):
int rc, sent;
if (lseek(fd, address, SEEK_SET) < 0) {
perror("Failed to seek");
exit(1);
}
for (sent = 0; sent < len;) {
rc = write(fd, buf + sent, len - sent);
if ((rc < 0) && (errno == EINTR))
continue;
if (rc <= 0) {
perror("Failed to write");
exit(1);
}
sent += rc;
}
还假设 fd 是调用 open() 函数返回的值(文件以写入或读写方式打开),且 buf 指向包含待写入数据的缓冲区。
此示例是第 3.3 节所示示例的扩 展。
本代码中唯一特别之处在于调用 lseek() 函数来设置地址。调用 lseek() 时,第三个参数仅应使用 SEEK_SET。
后续的函数调用会根据 I/O 流的位置自动更新地址,因此在调用 lseek() 函数后,可以进行多次连续写入操作,没有限 制。
对于在 FPGA 中以 16 位或 32 位字访问的流,传递给 lseek() 的地址必须分别是 2 或 4 的倍数。FPGA 应用逻辑接收到的地 址始终是流的 I/O 位置(初始值为 lseek() 给出的值)分别除以 2 或 4 的结果。对于更宽的字,适用相同的对数规则。
tell() 函数可能返回流中的正确位置(即当前地址),但并非可靠信息来源。如有疑问,请再次调用 lseek() 函数。
lseek() 可以类似方式用于读取数据。请参阅演示应用程序包中的 memwrite.c 和 memread.c(及其在 在 Linux 主机上入门 Xillybus中的描述)。
6.2 双向流同步
在某些应用中,需要同步多个流,这些流可能方向相反。例如,一个无线电传输系统可以在主机上实现,从连接到 RF 接收 器的 A/D 转换器接收数字样本。同样,它也可以向连接到 RF 发射器的 D/A 转换器发送数字样本。在这类场景中,通常需要生成用于传输的 数字样本,以便知道传输时间与接收样本之间的关系。知道接收信号的确切时间也可能很重要。
幸运的是,这可以通过简单的 FPGA 逻辑实现。一种解决方案是忽略接收到的数字样本,直到第一个待传输的样本到达 FPGA:
主机首先打开用于从 FPGA 读取样本的流。在此阶段,该流处于空闲状态,因为 FPGA 丢弃了接收样本。然后主机打开用 于向 FPGA 写入传输样本的流,并开始向其中写入数据。当第一个样本到达 FPGA 时,它停止忽略接收到的样本,并开始将它们发送给主 机。
因此,从 FPGA 读取的第一个样本将与写入 FPGA 的第一个样本相匹配。主机上的应用程序仅通过匹配各自流中的位置,即 可将任何传输样本的时间与任何接收样本对应起来。可能需要进行轻微修正以补偿 FPGA 中的延迟以及 A/D 和 D/A 的延迟,但这种延迟是恒 定且已知的。
流必须始终保持连续。如何实现这一点已在第 4 节中讨论 过。
如果仅需维持发送与接收之间的相对时间关系,这种解决方案就足够了。当样本需要与外部事件或其他时间参考同步时,可 以根据需要调整相同的跳过样本原则,以达到预期效果。
监控驱动缓冲区中任意时刻的数据量在第 Xillybus FPGA 设计者指南节中名为“监控缓冲数据量”的部分讨论。
6.3 数据包通信
某些应用需要将数据流划分为长度可变的数据包。建议的解决方案使用两个独立的流,并且不需要数据发送者在开始通过通 道提交数据包本身时就知晓数据包的长度。
固定已知长度数据包的简单情况很容易解决:只需在一个流上一个接一个地传输它们。另一端的接收者只需为每个数据包读 取固定数量的字。这是视频帧采集或视频回放应用中的典型解决方案。
对于可变长度数据包的情况,我们考虑一个上行流(upstream)应用,其中 FPGA 向主机发送字节包。假设 FPGA 仅在最 后一个字节到达时才知晓数据包的长度。
FPGA 端(即发送端)的实现如下:
-
FPGA 将数据包中的所有字节写入第一个 Xillybus 流。
-
FPGA 在写入数据包的第一个字节时复位字节计数器,并为每个后续写入的字节递增该计数器。
-
当数据包的最后一个字节被写入时,FPGA 将计数器的值发送到第二个 Xillybus 流。该值包含数据包的长度(减一)。
此解决方案的一个重要特点是 FPGA 无需在发送前存储整个数据包。它只是将数据在到达时直接传递。
主机上的用户应用程序运行如下循环:
-
从第二个流读取一个字,该字包含下一个数据包中的字节数。
-
如有必要,为请求大小的缓冲区分配内存。
-
从第一个流读取指定数量的字节到为此数据包分配的缓冲区中。
请注意,主机在访问数据之前先获取要读取的字节数,但 FPGA 却是以相反顺序写入这些流的。使用独立的 Xillybus 流允许 这种顺序反转。
当数据包从主机发送到 FPGA 时,适用类似的安排。使用两个流(一个用于数据,一个用于字节计数)的原则保持不变。现 在,FPGA 的应用逻辑有可能在从另一个流获取数据之前,从一个流中读取字节数。
这种安排还可以扩展,在非数据流中传递其他元数据,例如数据包在网络中的目的地或路由(这在第一个字节到达时有时是 未知的)。
6.4 模拟硬件中断
在小型微控制器项目中,通常使用硬件中断来通知软件某件事情已发生,并且软件需要采取相应行动。当软件作为 Linux 的 用户空间进程运行时,硬件中断是不可能的,即使是软件中断(如任何异步事件)处理起来也不那么愉快。
针对基于 Xillybus 的系统的建议解决方案是分配一个专门的流来承载消息。在最简单的形式中,通过在该专门流上发送一个 单字节来模拟硬件中断。
在主机端,用户空间应用程序尝试从该流读取数据。结果,当没有“中断”信号时,应用程序进入休眠(阻塞)状态,直到一 个字节到达并将其唤醒。应用程序处理该事件,然后尝试从专门流中读取另一个字节,从而在必要时再次进入休眠,如此循环。
为了实现主应用程序与中断例程之间的正确交互,可以由独立的软件线程或进程来读取这个专门流。在这种安排下,主代码 正常执行,而读取专门消息流的线程则根据发送的消息休眠或唤醒。
此方法的一个变体是利用传输的字节值来传递有关模拟中断性质的信息。另外,如果实现中有意义,每条消息可以长于一个 字节。
此方法看似浪费逻辑资源,但 Xillybus 最初的设计就是不因增加流而消耗过多逻辑,以使这类解决方案变得合理。
6.5 超时
在某些应用中,希望限制 I/O 操作可能保持阻塞状态的时间,特别是当存在某些硬件故障导致数据流停止的可能性时。
Xillybus 本身经过了广泛测试,以验证它永远不会成为数据以这种方式停止的原因,但数据源和数据消费者可能因各种原因停止。
处理此问题的次优方法是使用 select() 或 pselect() 函数。它们本用于等待多个文件描述符,但也具有超时功能。不建议使用 这些函数,因为其复杂的接口可能成为错误的根源,尤其是在超时旨在捕获的那些特殊情况下。
更自然的方法是使用 Linux 的 alarm 功能:它是一种每个进程的超时机制,当超时到期时,会向进程发送一个信号(软件中 断)。请回忆,信号会强制正在休眠的 read() 或 write() 函数调用立即返回控制权(参见第 3.2 和 3.3 节)。这些函数 返回负值并将 errno 设置为 EINTR。在之前的示例中,这种中断只是一种打扰,但它们对于实现超时仍然有用。
任何进程都可能接收与其功能无关的多个信号。接收信号本身并不是超时条件的指示。有几种方法可以判断,但最安全的方 法是根本不要依赖那个问题:如果 I/O 操作耗时超过一定时间,那就是超时。因此,最直接的策略是测量时间,如下一个示例所示,该示例基 于第 3.2 节中调 用 read() 函数的示例。
此示例的典型包含文件列表稍长:
#include <stdio.h> #include <signal.h> #include <unistd.h> #include <stdlib.h> #include <errno.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <time.h>
此示例特有的声明如下:
struct timespec before, after; double elapsed;
用于读取数据的 while 循环现在开始如下:
while (1) {
if (clock_gettime(CLOCK_MONOTONIC, &before)) {
perror("Failed to get time");
exit(1);
}
alarm(2);
rc = read(fd, buf, numbytes);
if (clock_gettime(CLOCK_MONOTONIC, &after)) {
perror("Failed to get time");
exit(1);
}
在调用 read() 函数前后,使用 clock_gettime() 测量时间。这是测量时间差的首选函数,因为它可以访问单调时间测量(与系 统时钟相反,系统时钟会被系统工具修改)。请注意,此函数可能需要将 -lrt 标志添加到 gcc 的参数中,以 便加载必要的库。
对 alarm() 的调用请求在 2 秒后发送一个信号(参数是秒数)。每个进程只有一个报警定时器,因此必须注意不要覆盖同一 定时器的其他用途,例如某些 Linux 实现中的 sleep()。
随后的代码如下:
elapsed = (after.tv_sec - before.tv_sec);
elapsed += (after.tv_nsec - before.tv_nsec) / 1000000000.0;
if (elapsed >= 2.0) {
fprintf(stderr, "Timed out\n");
exit(1);
}
计算时间差并存储在 elapsed 中。它是一个双精度浮点变量,以避免此简单示例中的字长可 移植性问题。但也可以使用整数。
条件很简单:如果时间测量之间经过了两秒或更多,则为超时。不检查 read() 返回的原因。它可能是信号,也可能是数据最 终到达但已太迟。无论哪种情况,都是错误。
请注意,对 alarm() 的调用是在第一次时间测量之后进行的,因此超时保证使时间差至少为两秒。
while 循环像之前一样继续:
if ((rc < 0) && (errno == EINTR))
continue;
if (rc < 0) {
perror("read() failed");
exit(1);
}
if (rc == 0) {
fprintf(stderr, "Reached read EOF.\n");
exit(0);
}
}
如上所示,信号仍被忽略。如果定时器唤醒了进程,时间差应该揭示超时条件并退出。
请注意,这种实现超时的方法基于 UNIX 信号,这在多线程环境中会变得复杂。如果部署了多个线程,最简单的方法是让其 中一个线程充当其他线程的看门狗。
另请注意,在上面的示例中,超时会导致进程终止,这通过执行此操作的信号处理程序更容易实现。当纠正响应在运行进程 内部执行时,上述方法更合适。
为了更高的超时间隔精度,可以考虑使用 setitimer() 代替。
6.6 协处理 / 硬件加速
协处理(也称 硬件加速)是一种技术,它允许应用程序利用逻辑电路的灵活性,以比 给定处理器更快、更便宜、能耗更低或其他更高效的方式执行特定操作。无论动机如何,高效的数据传输流对于使协处理成为可行的解决方 案至关重要。
需要认识到,基于协处理的应用程序中的数据流与常见的编程数据流有根本区别。为了说明这一差异,我们以需要计算浮点 数平方根的计算机程序为例。
程序员直接的方式是将数字作为参数传递给 sqrt(),调用它,然后等待函数返回。
假设希望在 FPGA 的逻辑电路中计算平方根。一个常见的 错误 是用一个特殊函数替换 sqrt(),该函数将值发送到 FPGA 进行计算,等待完成,然后返回结果。尽管这确实是 sqrt() 的一个简单直接替代,但它很可能比原始的 sqrt() 更慢,且在其他方面效率更低:数据在总线上双向传输所需的时间,加上 FPGA 进行计算所需的 时间,很可能远大于 sqrt() 所需的处理器周期。话虽如此,如果数据流设计得当,在 FPGA 上计算平方根可能会快得多。
为了克服总线和 FPGA 逻辑带来的延迟,需要对软件进行重组。特别是,单线程程序中的任务需要拆分为两个或多个线程 (或进程)。如果无法或不希望使用多个线程,可以利用其他编程技术来模拟多线程的行为,但编程范式仍然是多线程的。
回到 sqrt() 的例子,对该函数的调用被分为两个线程:第一个线程将要计算平方根的数据发送到硬件(或某种表示操作请求 的数据结构)。第二个线程从硬件接收结果,并从算法中的该点继续处理。
单独看一个数据项时,这似乎没有意义,但协处理的动机意味着要处理大量数据项。因此,第一个线程发送一个 数据流 进行计算,第二个线程接收一个 结果流。
这种 流水线 技术最大限度地减少了硬件延迟的影响,因为两个线程实际上都不在等待 这个延迟。相反,延迟影响的是位于两个线程之间的处理项数目——但 吞吐量 仅取决于两个线程和 FPGA 逻辑的处理能力。
以下概念图总结了这一思想。
加速计算 sqrt() 是一个相对简单的例子,但它涵盖了利用协处理时的许多挑战。几乎所有情况下,计算机程序的大部分代码 都需要重写,以便一切由流水线的数据流驱动。
另一个需要注意的问题是,由于 Xillybus 使用 read() 和 write(),因此将多个数据项分组后再写入流向 FPGA 的流中可能是有 益的。同样,尝试在每个 read() 调用中读取超过一个结果项可能会提高性能。其中的原因在于,read() 和 write() 是具有一定开销的系统调 用。如果数据元素很小且以高频率传输,这些系统调用的开销可能相当显著。sqrt() 的例子很好地说明了这一点:双精度浮点数通常长 8 字 节。这么短的 I/O 系统调用效率相当低下,因此将多个双精度浮点数元素合并到一次系统调用中会带来显著的改善。
此外,值得一提的是,并非所有应用都涉及固定长度的数据块。例如,使用协处理计算任意字符串的哈希(如 SHA1)时, 处理的各个数据元素长度可能不同。第 6.3 节针对这一问题提 出了解决方案。
