用户空间字符驱动程序的简单方法
Demand Peripherals, Inc. 公司生产基于 FPGA 的机器人控制器,该控制器为机器人或其他工业控制系统提供 Linux 笔记本电脑或单板计算机本身无法提供的高 I/O 引脚数和精确计时。该公司已经为该控制器构建了 25 多个不同的 FPGA 定义的外围设备,并希望为所有这些设备提供 Linux 设备驱动程序。
在内核中完成 25 个驱动程序虽然是可能的,但所需的时间和精力远远超出公司能够承受的范围。构建内核设备驱动程序的过程会更加复杂,因为 FPGA 卡通过 USB 串行链路连接到 Linux 主机。如图 1 所示的解决方案是让一个守护进程管理 USB 串行端口,并将各种基于 FPGA 的外围设备多路分解到它们自己的设备节点。这些设备节点只不过是垫片,让高级应用程序能够处理每个外围设备的独立设备条目。
客户选择要加载到 FPGA 中的外围设备组合。图 2 显示了一个 BaseBoard4 以及一些可能相当常见的外围设备组合的示例卡。图中所示的系统有八个外围设备,包括一个四通道伺服控制器、一个双 H 桥控制器、一个用于 Parallax Ping))) 测距传感器的四接口、一个基于 RAM 的模式发生器(驱动连接到 LCD 的 48 位移位寄存器的数据线和时钟线)、一个单极步进电机控制器、一个双极步进电机控制器、一个四通道事件或频率计数器(连接到一个 Parallax 光频传感器)和一个双正交解码器。所有这些演示卡的原理图都在 Demand Peripherals 网站上。
图 2 中显示的所有外围设备都可以使用 /dev 目录中的设备节点进行配置和控制。例如,以下 Bash 命令可能是系统中更高级别控制软件的一部分
# Feed wheel quadrature counts to a motor control program cat /dev/dp/quad0 | my_motor_pgm & # Feed the same quadrature counts to a navigation program cat /dev/dp/quad0 | my_navi_pgm & # Set a stepper motor step rate to 1000 echo "1000" > /dev/dp/bstep1/rate # Now step 300 steps echo "300" > /dev/dp/bstep1/count # Monitor distance reported by a Parallax Ping))) cat /dev/dp/ping0/dist & # Set a servo pulse width to 1.5 ms (1500000 ns) echo "1500000" > /dev/servo/servo4
以上命令说明了用户空间驱动程序的三个重要用例中的两个:传感器广播和驱动程序配置。第三个用例是双向传输。
第一个用例是传感器广播,在上面的例子中,实际上是传感器数据的多播。您知道 /dev/input 驱动程序实现了多播机制吗?多个读取器获得来自输入设备的相同事件副本。您可以做一个简单的实验来证明这一点。按 Ctrl-Alt-F2(转到不同的控制台),登录,并运行命令sudo cat /dev/input/mice | od -b。对另一个控制台(例如,Ctrl-Alt-F3)执行相同的操作。现在,稍微移动鼠标并在 F2 和 F3 控制台之间切换。它们都显示相同的内容,不是吗?遗憾的是,Linux 没有像 /dev/input 子系统那样进行多播的通用方法。
对于机器人技术,将传感器读数分发给多个进程的能力尤为重要。例如,连接到车轮的正交编码器需要被电机控制器软件和导航软件看到。电机控制器可能需要知道车轮是否在转动,以了解电机是否停转,而导航软件可能会计算车轮转数以计算机器人的当前位置。
第二个用例是外围设备或驱动程序配置。直流电机控制器需要知道 PWM 脉冲的频率。步进电机需要知道步进速率,SPI(串行外围接口)端口需要被告知时钟频率和操作模式。ioctl() 调用或 sysfs 风格的接口都可以用于驱动程序配置。
配置接口可能有点棘手,因为信息通常不是简单的字节流——它可能包含几个不同的信息片段。ioctl() 接口通常传递一个数据结构用于复杂配置,而 sysfs 接口可能使用空格分隔的 ASCII 编码值列表。Demand Peripherals 使用 ASCII 编码数字方法,因为考虑到驱动程序配置的相对不频繁性,解码和解析文本行的开销并不太大。此外,能够cat一个 sysfs 类型的文件来查看驱动程序配置是很方便的。
第三个用例,双向传输,实际上是最常见的用例。您可能已经熟悉串行端口,这是双向 I/O 最常见的例子。虽然上面的例子中没有包含任何串行端口,但基于 FPGA 的机器人控制器需要用于透明地将数据从一端传递到另一端的外围设备的双向 I/O。这些外围设备包括 FPGA 定义的串行端口和 SPI 端口。您可能更喜欢(就像我们一样)能够进行块读取和写入,直到接口的两端都打开。
我们这个项目的首要要求是尽可能减少程序员的时间投入。这意味着最大限度地减少要编写的代码行数,并避免修改别人的编写不良或完全没有文档的代码。这个要求也意味着我们不尝试将我们的接口隐藏在应用程序库中。因为库是更高级别控制应用程序的一部分,您仍然需要一个守护进程,仍然需要一些通用的 IPC 机制,并且仍然需要记录内部和外部接口。库方法的另一个问题是它通常不是一个库;您可能需要为您想要支持的每种编程语言编写一个库或绑定。使用真正的字符设备而不是库意味着您的客户可以使用他们想要的任何语言进行编程,而不仅仅是您为其编写绑定的语言。
第二个要求是驱动程序安全模型基于文件权限。这意味着所有设备数据和配置接口都应该在文件系统中可见。也就是说,您应该能够对类似 /dev/dp/bstep1/rate 的东西执行chmod 644。使用命名管道和 FUSE(用户空间文件系统)可以满足这个要求。使用伪终端来做到这一点会很棘手。
另一个要求是 select() 在更高级别的控制应用程序和用户空间驱动程序本身中都能工作。这个要求出现的原因是 select() 在大多数应用程序中比线程快得多。嵌入式系统,如机器人或其他工业控制系统,通常运行在最便宜、成本最低的硬件上,并且在机器人的情况下,通常运行在电池供电的情况下。这些约束导致嵌入式 Linux 程序员更喜欢基于 select() 的系统。
FUSE 经常被建议作为实现字符驱动程序的一种方式,但我无法让 select() 在 FUSE 接口的两侧都工作。我喜欢 FUSE;它可以解决许多用户空间驱动程序问题,但在我看来,要求 FUSE(一个文件系统)兼作字符驱动程序是不公平的。毕竟,谁会期望 ext3 或其他内核文件系统具有内置的字符驱动程序呢?
最后一个要求是写入器阻塞,直到读取器出现。命名管道和伪 tty 都允许写入器在阻塞之前写入 4KB。对我们来说重要的是,驱动程序不要用过时的数据填充缓冲区,而更高级别的机器人应用程序必须丢弃这些数据才能获得当前数据。
最后,我们没有找到任何现有的 Linux 工具可以满足我们所有的要求和用例。但是,我们能够找到或创建两个相对简单的设备驱动程序可以做到这一点。图 3 说明了基本思想。

图 3. 两个新驱动程序将应用程序链接到驱动程序守护进程
这个想法是拥有两个非常轻薄的驱动程序,它们位于更高级别的应用程序和用户空间驱动程序之间。这些是真正的驱动程序,并且对于更高级别的软件来说是这样的。应用程序和用户空间驱动程序之间交换的数据尽可能透明地通过内核。甚至流量控制也在应用程序和用户空间驱动程序之间透明地传递。
第一个用例,即多播传感器数据,由 www.linuxtoys.org 上详细描述的“扇出”驱动程序解决。Demand Peripherals 将扇出设备用于正交解码器、红外接收器、超声波测距传感器、PlayStation 控制器接口、事件计数器和所有其他连续采样的传感器。图 4 显示了扇出设备中的基本数据流。

图 4. 简单的多播设备
您可以跳到本文的后面部分以获取和安装扇出驱动程序,或者您可以继续阅读并稍后再试用这些示例。一旦您安装了扇出驱动程序并为其创建了设备节点,您可以使用一些简单的命令来测试它
cat /dev/fanout & cat /dev/fanout & cat /dev/fanout & echo "Hello World" > /dev/fanout
消息出现三次,正如您所期望的那样。扇出驱动程序就像 /dev/input 一样,它保护写入器,而不是读取器。如果读取器跟不上,读取器会收到错误,允许写入器和其他读取器继续不受阻碍地运行。
对于向相反方向流动的数据,您需要类似“扇入”设备的东西——也就是说,保护读取器的东西。命名管道在这方面工作得相当好。
驱动程序配置的低速特性,即第二个用例,使几种方法成为可能。我们采取的方法是编写一个名为 proxy 的驱动程序,它解决了配置用例以及双向传输用例。proxy 的两个定义特征是,在另一端打开进行读取之前,一端无法写入,并且零字节的写入会通过驱动程序传递,并在另一端被视为零字节的读取。第二个特性的有用性最好通过一个例子来说明。考虑用户读取配置参数的当前值的情况
cat /dev/dp/bstep/rate
/dev/dp/bstep/rate 是一个 proxy 设备,当cat打开设备时,另一侧的用户空间驱动程序守护进程会看到可以写入。守护进程写入当前值,然后执行零字节写入(cat 读取/看到这两个写入)。正是零字节写入告诉 cat 它已完成并且可以退出。

图 5. 用于在两个应用程序之间传递数据的设备
proxy 驱动程序的一个缺点是它要求客户构建和安装内核模块。虽然动态内核模块支持可以提供帮助,但即使这并不困难,许多客户也会对此感到害怕 [请参阅“探索动态内核模块支持 (DKMS)”,LJ,2003 年 9 月:www.linuxjournal.com/article/6896]。对于不想处理构建模块并且不需要 select() 支持的客户来说,FUSE 是一种很好的方法。
驱动程序配置的另一种方法是使用常规文件系统文件和 inotify 工具来提醒用户空间驱动程序正在请求新的配置。通过将配置文件保存在具有持久存储的卷上,您可以获得两个不错的功能。第一个是您不需要提供任何其他配置存储——文件本身就是持久存储。另一个不错的功能是驱动程序立即以正确的配置启动,并且不需要从外部源初始化。Inotify 与 select() 配合良好,并且在许多情况下都很理想,但请注意几个问题。如果您的驱动程序在使其可用之前修改了配置参数,则可能会出现竞争条件。声卡驱动程序通常具有这种行为——您可以将采样率设置为 45KHz,但驱动程序可能会将其四舍五入到最接近的标准值 44.1KHz。如果您使用 inotify 执行类似的操作,则可能会有一个窗口,在此窗口中,读取器会获得配置参数的错误值。另请注意,您可能需要重建内核以包含 inotify 支持。
如果将配置读取与配置写入分开,您还可以使用 UNIX 套接字进行驱动程序配置。问题是当用户空间驱动程序守护进程接受套接字打开请求时,新套接字既可读又可写。守护进程无法判断用户是尝试写入新的配置值还是尝试读取现有配置值。解决此问题的一种方法是为每个驱动程序配置参数设置两个套接字,一个套接字用于读取当前值,另一个套接字用于设置值。
有几种很好的方法可以将双向数据流添加到您的用户空间驱动程序中。proxy 驱动程序立即提供了这一点,并且是我们机器人项目的选择。另一种方法是使用 UNIX 套接字。套接字与 select() 配合良好,并且它们的权限映射到文件系统,但它们不容易与 echo、cat 和命令行管道一起使用。此外,如果您使用 UNIX 套接字进行双向传输,那么在描述您的系统时,您真的不应该将它们称为“设备驱动程序”。
扇出和 proxy 模块相当容易构建和安装。确保您的内核的内核头文件可用。这两个驱动程序都在 www.linuxtoys.org/usd/usd.tar.gz 中的 tarball 中。下载驱动程序 tarball,然后解压 tarball、构建和安装驱动程序
tar -xzf proxy.tar.gz cd proxy make sudo make install
您如何以及在何处安装模块和创建设备节点是个人偏好的问题。例如,您可以将以下内容添加到您的 rc.local 启动脚本,或将等效命令放在 udev 规则文件中
modprobe fanout FANOUTMAJOR=`grep fanout /proc/devices | awk '{print $1}'` mknod /dev/fanout c $FANOUTMAJOR 0 mknod /dev/fanout1 c $FANOUTMAJOR 1 mknod /dev/fanout2 c $FANOUTMAJOR 2 mknod /dev/fanout3 c $FANOUTMAJOR 3 mknod /dev/fanout4 c $FANOUTMAJOR 4 chmod 666 /dev/fanout* modprobe proxy PROXYMAJOR=`grep proxy /proc/devices | awk '{print $1}'` mknod /dev/proxy c $PROXYMAJOR 0 mknod /dev/proxy1 c $PROXYMAJOR 1 mknod /dev/proxy2 c $PROXYMAJOR 2 mknod /dev/proxy3 c $PROXYMAJOR 3 mknod /dev/proxy4 c $PROXYMAJOR 4 chmod 666 /dev/proxy*
机器人的启动脚本略有不同,因为我们希望设备节点名称反映它所服务的设备。例如,双正交解码器可能会创建具有以下内容的扇出设备节点
mknod /dev/dp/quad0 c $FANOUTMAJOR 0 mknod /dev/dp/quad1 c $FANOUTMAJOR 1
源 tarball 在 demo 目录中包含一些简单的演示程序。程序 pxtest2.c 展示了如何使用 proxy 设备来配置用户可见的字符串,而 pxtest2 的工作原理是接受一个短字符串并在请求时将其回显。如上所述,驱动程序通常必须限制或以其他方式修改用户设置的配置值。pxtest2 程序通过将输入中的每个(非换行符)字符加一来演示这种处理。您可以使用以下命令运行 pxtest2
gcc -o pxtest2 pxtest2.c ./pxtest2 /dev/proxy & echo 111aaa222 > /dev/proxy cat /dev/proxy # output of the cat command should be 222bbb333
我们构建用户空间设备驱动程序的临时方法具有一些不错的特性。它没有添加大量内核代码,也不需要任何用户空间库。它在您可能需要的任何地方都支持 select(),并且它对流数据具有良好的流量控制。
扇出和 proxy 也有一些缺点。来自扇出设备的数据流是字节对齐的,这使得它不适合需要发送二进制数据块的应用程序。例如,扇出驱动程序不能用于模拟新的 /dev/input 设备。Demand Peripherals 通过发送以换行符结尾的 ASCII 文本行来解决这个问题。如果您需要多字节传输,您可以向扇出驱动程序添加 ioctl(),以设置来自数据源的原子读取的字节计数。
如果您喜欢 /dev/proxy 的简单性,但确实需要 ioctl() 支持,您可以为每个 proxy 设备分配两个次要设备号,将其添加到 proxy 驱动程序中。将偶数次要设备号用于数据接口,将奇数次要设备号用于 ioctl() 接口。您的配置可能如下所示
mknod /dev/proxy_data c $PROXYMAJOR 0 mknod /dev/proxy_ctrl c $PROXYMAJOR 1
您对 proxy 驱动程序的添加将必须序列化传递给 ioctl() 请求和从 ioctl() 请求传递的数据,并且您的用户空间驱动程序守护进程将必须打开这两个设备才能将 ioctl() 请求与数据流请求分开处理。
我们使用扇出和 proxy 为基于 FPGA 的机器人控制器添加设备驱动程序,但它们实际上是相当通用的。您可以使用扇出和 proxy 解决哪些 Linux 问题?
Bob Smith 是一名专注于嵌入式 Linux 的顾问。您可以通过 bsmith@linuxtoys.org 与他联系。