01signal.com

访问 Xillybus 的设备文件

简介

本页基于 Xillybus 的 Linux 编程指南。如果想更全面地了解下面这些主题,建议参考这份指南。还有一份类似的Microsoft Windows 指南

如果你还没有做 “Hello, World” 测试,建议先完成这一测试

Xillybus IP 核(IP core)的通信是通过主机上的设备文件进行的。这些设备文件的访问方式与普通文件相同。但与普通文件不同的是,设备文件并不代表存储在磁盘上的数据:对设备文件进行读或写操作时,实际发生的是 I/O 操作。

因此,几乎所有的编程语言都可以访问 Xillybus 的设备文件。也可以使用 Linux 命令行工具达到这个目的。所以有人可能会问:既然会正确地读写文件就会用 Xillybus,那为什么还需要专门讨论这个话题呢?

确实,即使不懂太多 API 细节,也能编写出访问普通文件的程序。但对 I/O 来说,这还不够:与硬件交互时会出现一些普通文件很少遇到的情况。作为一名程序员,有必要了解如何在 API 的帮助下处理这些情况。

因此,本页绝大部分内容其实也适用于访问普通文件。设备文件与普通文件的差别在于,当使用 API 出错时,设备文件没有那么宽容。

对 API 缺乏理解可能导致两类主要困惑:

编程语言与操作系统

在 Linux 中,任何能够访问文件的工具或编程语言都可用于 Xillybus。

使用 Windows 时,对于常见的编程语言(如 C、C++、C#、Python、Perl 以及 Cygwin 自带的任何语言)都没有问题。不过有些工具(例如 MATLAB)可能会拒绝使用 Xillybus 的设备文件:这些工具检测到设备文件不是普通文件后,会把它当作错误。针对这种情况也有一些变通办法,通常是使用专为底层 I/O 设计的扩展。

下面的讨论基于 C 语言,但这些主题对所有编程语言都适用。

示例代码

Xillybus 网站提供可下载的代码示例。这些示例用 C 语言编写,演示如何正确使用底层 API(low-level API)。

要下载这些示例,请访问你当初下载演示包(demo bundle)的那个网页(即 Xillybus 下载页面XillyUSB 下载页面)。

如果使用 Linux,请下载 Linux 驱动。示例代码就在同一个 .tar.gz 文件中。

如果使用 Windows,请下载 Xillybus 的 Windows 软件包。

无论哪种情况,示例代码都位于 demoapps/ 子目录下。请注意,这段代码中每次 I/O 操作读写的数据量很小,因为它只分配了一个很小的缓冲区(128 字节)。这样做的目的仅仅是为了让代码更简洁。在实际应用中,建议使用更大的缓冲区(32 kBytes 通常是不错的选择)。

Linux 与 Windows 之间的差别虽小,但很重要,尤其是下面几点:

尽管如此,这些示例代码背后的原理完全相同。

带缓冲的文件 I/O

编程语言通常为文件访问提供两套相互独立的 API:一套是高级 API(high-level API),另一套是底层 API(low-level API)。高级 API 更为常用,因为用起来更简单。

例如,在 C 语言中,高级 API 包括 fopen()、fread()、fwrite()、fprintf()、fclose() 等。底层 API 则包括 open()、read()、write()、close() 等。这两套 API 的差别不只是函数名上的一点不同:高级 API 会提供用户空间 RAM 缓冲区,这些缓冲区由 C 运行时库实现。不要把它们与 DMA 缓冲区相混淆,DMA 缓冲区由内核中的驱动控制。

最重要的差别在于 fwrite() 的行为:调用这个函数后,数据可能只是被暂存在用户空间缓冲区里,直到之后某个时刻才会真正传输到 FPGA。实际上,数据可能会一直留在 fwrite() 的缓冲区里,直到文件被关闭。这在 Xillybus 看来很像一个 bug:数据明明已经写入文件,却什么也没有发生。

因此,建议使用底层(无缓冲)API。虽然大多数编程语言都鼓励使用高级 API,但通常还是可以用底层 API 的。如果某个工具或编程语言不支持底层 API,那就改用它能提供的 API。在这种情况下,请记住:你无法控制 I/O 实际发生的时机。不过在多数应用中,这样已经足够了,例如数据采集(data acquisition)。

再说一次,不要混淆带缓冲 I/O 和 Xillybus 的缓冲区。最重要的是,数据绝不会因为 Xillybus 的缓冲区而无限期地卡住。这一点将在下文讨论零长度 write() 时进一步说明。

从设备文件读取数据:基本知识

从设备文件读取数据的示例代码是 streamread.c。这个程序位于 demoapps/ 目录中。不过,下面要展示的代码来自另一个程序:memread.c(位于同一目录)。这个程序原本的目的不同,但其中包含一个名为 allread() 的函数。用这个函数来演示几个主题会更方便。

假设我们用下面这段代码打开了一个文件:

int fd, len;
char *buf;

fd = open("/dev/xillybus_read_32", O_RDONLY);

现在,我们想从文件中读取 @len 个字节到缓冲区里。但你不能接受只读了一部分的结果:我们希望有一个函数,它总是能读取指定数量的数据。

当 allread() 像下面这样被调用时,它就是做这件事的:

allread(fd, buf, len);

这个函数的定义如下:

void allread(int fd, unsigned char *buf, int len) {
  int received = 0;
  int rc;

  while (received < len) {
    rc = read(fd, buf + received, len - received);

    if ((rc < 0) && (errno == EINTR))
      continue;

    if (rc < 0) {
      perror("allread() failed to read");
      exit(1);
    }

    if (rc == 0) {
      fprintf(stderr, "Reached read EOF\n");
      exit(1);
    }

    received += rc;
  }
}

请注意,这个函数总会读取请求的字节数。如果做不到,函数会让程序直接终止。对常规使用场景来说,这可能有些过于“极端”。不妨把 allread() 看作一个简单演示,帮助你理解如何用底层 API 访问文件。

下面来解释一下这个函数。首先值得关注的是这一段:

rc = read(fd, buf + received, len - received);

在第一次循环时,@received 等于 0。因此这一行等价于:

rc = read(fd, buf, len);

read() 尝试从文件描述符(file descriptor,@fd)中读取 @len 个字节,并把数据存入缓冲区(@buf)。

对于 Xillybus 的设备文件:如果请求的数据量(@len 个字节)暂时不可用,read() 会最多等待 10 ms。这段时间过后,函数会返回少于请求数量的数据(但至少有 1 个字节)。如果完全没有数据可用,read() 会无限期地等待,直到 FPGA 送来数据(但也有例外,下面会提到)。这个行为是 Xillybus 驱动特有的(不过仍然符合标准 API 规范)。

如果 read() 成功读到了数据,@rc 就等于实际读到的字节数。这意味着 @rc 是一个正数,循环中所有 if 语句都不会执行。于是接下来执行:

received += rc;

因此,@received 始终保存着到目前为止已读取的字节总数。请注意,@rc 小于 @len(请求的字节数)是完全合法且正常的。

while 循环会一直持续,直到 @received(已经读取的字节总数)达到 @len。这一点体现在 while 语句中:

while (received < len) { ... }

因此,如果有必要,就会继续读取更多数据:

rc = read(fd, buf + received, len - received);

这一次,缓冲区中的起始位置随 @received 一起向后移动,请求的字节数也相应减少相同的量。这些调整只是为了表明这是又一次读取数据的尝试。

当 read() 什么也读不到时

到目前为止,我一直在讲 read() 能成功读取数据时的情况。然而,有三种情况会让 read() 读不到任何数据。每种情况都由对应的一个 if 语句来处理。

POSIX 信号

当你按 CTRL-C 停止一个程序时,操作系统会向该进程发送一个 POSIX 信号。这正是终止程序的机制。使用 kill 命令做同样的事情时也是如此。不过,还有很多其他类型的信号在大多数情况下应该被忽略。

那么,如果进程在执行 read() 调用的过程中收到了信号,会发生什么?按照 Linux 的惯例,read() 必须立即把控制权交还给主程序。如果在收到信号之前 read() 已经读到了数据,那就不会有什么异样:@rc 中会包含已读取的字节数,而且不会留下任何收到过信号的痕迹。

但如果没有新数据到达,@rc 会是负数,且 @errno 等于 EINTR。处理这种情况的标准做法就是代码里展示的那样:当作什么都没发生,然后再试一次。

if ((rc < 0) && (errno == EINTR))
  continue;

这并不表示信号被忽略了:例如,如果信号是因为用户按了 CTRL-C 而产生的,程序仍会照常终止。那是由另一个机制负责的。这个 if 语句的目的是处理那些并不会造成什么严重后果的信号。continue 语句可以确保进程收到这类信号时,不会出现任何奇怪的行为。

例如,如果进程通过 CTRL-Z 被暂停,那么当它恢复执行时,这个 if 语句就是保证程序继续运行的必需代码。此外,还有好几种信号会在没有任何人为干预的情况下到来。

真正的错误

很自然,在尝试读取数据时也可能出错。一旦出错,@rc 会是负数,@errno 的值也不是 EINTR。示例代码的做法很简单:报告这个错误,然后终止程序:

if (rc < 0) {
  perror("allread() failed to read");
  exit(1);
}

EOF

如果 read() 因为已经到达文件结尾而无法再提供数据,它会返回值 0。对于普通文件来说,这自然是成立的。不过 Xillybus 也有能力声明数据流已经结束。其行为是相同的。

相关代码如下:

if (rc == 0) {
  fprintf(stderr, "Reached read EOF\n");
  exit(1);
}

这种情况同样被当作错误,并会导致程序终止:它意味着在读取到请求数量(@len 个字节)的数据之前,就已经遇到了 EOF。只有当 @received 小于 @len 时,才会执行到这个 if 语句。

回想一下,allread() 的设计思路就是始终读取所需数量的数据。如果做不到,这个函数就会让程序停止。

对其他编程语言的适用性

上面的示例代码是用 C 语言写的,但它所展示的几点要点与具体编程语言无关。

向设备文件写入数据

向文件写入数据的底层 API 与读取文件几乎相同。为了演示这一点,请看下面这个名为 allwrite() 的函数,它可以在 streamwrite.c 中找到:

void allwrite(int fd, unsigned char *buf, int len) {
  int sent = 0;
  int rc;

  while (sent < len) {
    rc = write(fd, buf + sent, len - sent);

    if ((rc < 0) && (errno == EINTR))
      continue;

    if (rc < 0) {
      perror("allwrite() failed to write");
      exit(1);
    }

    if (rc == 0) {
      fprintf(stderr, "Reached write EOF (?!)\n");
      exit(1);
    }

    sent += rc;
  }
}

把它与上面展示的 allread() 作一下比较:只有三处不同:

所以从原则上讲,写入和读取没有差别。

需要指出的是,@rc 永远不会是 0,因为向文件写入时不存在 EOF 的概念。按照 POSIX 标准,只有当 write() 被要求写入 0 个字节时,@rc 才可能是 0。而这个 while 循环里永远不会出现这种情况。

总而言之,allwrite() 总是写入所请求的字节数。另一个可能的结果就是进程被终止。换句话说,假设某个设备文件是按下面这种方式打开的:

int fd, len;
char *buf;

fd = open("/dev/xillybus_write_32", O_WRONLY);

把 @buf 中的 @len 个字节写入文件,可以这样调用:

allwrite(fd, buf, len);

上面关于 allread() 的所有说明同样适用于 allwrite(),包括它对其他编程语言的适用性。

零长度 write

允许以 0 字节调用 write()。标准 API 没有规定在这种情况下会发生什么。但显而易见,这意味着不会写入任何数据。

对 Xillybus 的设备文件来说,这种函数调用有特殊的含义:写入 0 个字节意味着请求一次刷新(flush)。为了理解这一点,先来看一下向设备文件写入数据时会发生什么。

假设该设备文件是异步数据流(asynchronous stream)。这个概念在另一个页面有简要说明,更详细的说明见文档

当数据通过 write() 写入设备文件时,Xillybus 驱动会先把数据存入 RAM 缓冲区。其中一部分或全部数据可能会被立即发送到 FPGA。但一般来说,缓冲区中仍可能残留一些数据,而执行函数调用的程序却已经继续运行了。这个机制的目的是提升性能,尤其是在 write() 调用非常频繁的情况下。

那么,驱动 RAM 缓冲区中的数据什么时候才会被送往 FPGA 呢?共有四种可能的情况:

因此,数据不会在驱动的缓冲区中滞留太久。这是因为数据总会在 10 ms 内被送往 FPGA。但在某些应用中,即使这样的延迟也无法接受。这种情况下,可以使用零长度 write() 来请求把剩余数据立即发送出去。

用 C 语言可以这样实现:

write(fd, NULL, 0);

请注意,这里缓冲区地址是 NULL。这是可以的,因为要写入的字节数为 0。不过这个函数调用并不能保证请求一定成功。虽然成功的可能性很大,但下面才是正确的写法:

while (1) {
  rc = write(fd, NULL, 0);

  if ((rc < 0) && (errno == EINTR))
    continue; // Interrupted. Try again.

  if (rc < 0) {
    perror("flushing failed");
    break;
  }

  break; // Flush successful
}

以上这些都是针对异步数据流而言的。如果设备文件是同步数据流(synchronous stream),那么 write() 调用导致的直接结果就是数据会立即被送往 FPGA。此外,write() 会一直等待,直到数据到达 FPGA 后才返回(零长度 write() 则不会这样)。

因此,零长度 write() 只对异步数据流有意义。除非必要,否则不应该使用这个功能,因为它会降低与 FPGA 之间的通信效率。

总结

如前所述,上面写到的内容几乎都适用于对任意文件的访问。只有个别几个主题是 Xillybus 特有的。

请务必遵循这些指导原则,以确保与 FPGA 通信时行为的一致性。如果没有考虑这些问题就编写程序,很可能会时不时地出现故障。这些故障往往看起来像是 FPGA 或驱动的问题。因此,正确的编程技巧可以让你少走很多弯路,省去不少不必要的排查工作。

本页由机器从英文翻译而来。如有疑问,请参阅原文
Copyright © 2021-2026. All rights reserved. (dcc38493)