名词缩写:
- API 应用程序接口Application Program Interface
- ABI 应用系统二进制接口Application Binary Interface
设备驱动是操作系统的一部分,它能够通过一些特定的编程接口便于硬件设备的使用,这样软件就可以控制并且运行那些设备了。因为每个驱动都对应不同的操作系统,所以你就需要不同的 Linux、Windows 或 Unix 设备驱动,以便能够在不同的计算机上使用你的设备。这就是为什么当你雇佣一个驱动开发者或者选择一个研发服务商提供者的时候,查看他们为各种操作系统平台开发驱动的经验是非常重要的。
驱动开发的第一步是理解每个操作系统处理它的驱动的不同方式、底层驱动模型、它使用的架构、以及可用的开发工具。例如,Linux 驱动程序模型就与 Windows 非常不同。虽然 Windows 提倡驱动程序开发和操作系统开发分别进行,并通过一组 ABI 调用来结合驱动程序和操作系统,但是 Linux 设备驱动程序开发不依赖任何稳定的 ABI 或 API,所以它的驱动代码并没有被纳入内核中。每一种模型都有自己的优点和缺点,但是如果你想为你的设备提供全面支持,那么重要的是要全面的了解它们。
在本文中,我们将比较 Windows 和 Linux 设备驱动程序,探索不同的架构,API,构建开发和分发,希望让您比较深入的理解如何开始为每一个操作系统编写设备驱动程序。
1. 设备驱动架构
Windows 设备驱动程序的体系结构和 Linux 中使用的不同,它们各有优缺点。差异主要受以下原因的影响:Windows 是闭源操作系统,而 Linux 是开源操作系统。比较 Linux 和 Windows 设备驱动程序架构将帮助我们理解 Windows 和 Linux 驱动程序背后的核心差异。
1.1. Windows 驱动架构
虽然 Linux 内核分发时带着 Linux 驱动,而 Windows 内核则不包括设备驱动程序。与之不同的是,现代 Windows 设备驱动程序编写使用 Windows 驱动模型(WDM),这是一种完全支持即插即用和电源管理的模型,所以可以根据需要加载和卸载驱动程序。
处理来自应用的请求,是由 Windows 内核的中被称为 I/O 管理器的部分来完成的。I/O 管理器的作用是是转换这些请求到I/O 请求数据包IO Request Packets(IRP),IRP 可以被用来在驱动层识别请求并且传输数据。
Windows 驱动模型 WDM 提供三种驱动, 它们形成了三个层:
- 过滤Filter驱动提供关于 IRP 的可选附加处理。
- 功能Function驱动是实现接口和每个设备通信的主要驱动。
- 总线Bus驱动服务不同的配适器和不同的总线控制器,来实现主机模式控制设备。
一个 IRP 通过这些层就像它们经过 I/O 管理器到达底层硬件那样。每个层能够独立的处理一个 IRP 并且把它们送回 I/O 管理器。在硬件底层中有硬件抽象层(HAL),它提供一个通用的接口到物理设备。
1.2. Linux 驱动架构
相比于 Windows 设备驱动,Linux 设备驱动架构根本性的不同就是 Linux 没有一个标准的驱动模型也没有一个干净分隔的层。每一个设备驱动都被当做一个能够自动的从内核中加载和卸载的模块来实现。Linux 为即插即用设备和电源管理设备提供一些方式,以便那些驱动可以使用它们来正确地管理这些设备,但这并不是必须的。
模式输出那些它们提供的函数,并通过调用这些函数和传入随意定义的数据结构来沟通。请求来自文件系统或网络层的用户应用,并被转化为需要的数据结构。模块能够按层堆叠,在一个模块进行处理之后,另外一个再处理,有些模块提供了对一类设备的公共调用接口,例如 USB 设备。
Linux 设备驱动程序支持三种设备:
- 实现一个字节流接口的字符Character设备。
- 用于存放文件系统和处理多字节数据块 IO 的块Block设备。
- 用于通过网络传输数据包的网络Network接口。
Linux 也有一个硬件抽象层(HAL),它实际扮演了物理硬件的设备驱动接口。
2. 设备驱动 API
Linux 和 Windows 驱动 API 都属于事件驱动类型:只有当某些事件发生的时候,驱动代码才执行——当用户的应用程序希望从设备获取一些东西,或者当设备有某些请求需要告知操作系统。
2.1. 初始化
在 Windows 上,驱动被表示为 DriverObject
结构,它在 DriverEntry
函数的执行过程中被初始化。这些入口点也注册一些回调函数,用来响应设备的添加和移除、驱动卸载和处理新进入的 IRP。当一个设备连接的时候,Windows 创建一个设备对象,这个设备对象在设备驱动后面处理所有应用请求。
相比于 Windows,Linux 设备驱动生命周期由内核模块的 module_init
和module_exit
函数负责管理,它们分别用于模块的加载和卸载。它们负责注册模块来通过使用内核接口来处理设备的请求。这个模块需要创建一个设备文件(或者一个网络接口),为其所希望管理的设备指定一个数字识别号,并注册一些当用户与设备文件交互的时候所使用的回调函数。
2.2. 命名和声明设备
在 Windows 上注册设备
Windows 设备驱动在新连接设备时是由回调函数 AddDevice
通知的。它接下来就去创建一个设备对象device object,用于识别该设备的特定的驱动实例。取决于驱动的类型,设备对象可以是物理设备对象Physical Device Object(PDO),功能设备对象Function Device Object(FDO),或者过滤设备对象Filter Device Object(FIDO)。设备对象能够堆叠,PDO 在底层。
设备对象在这个设备连接在计算机期间一直存在。DeviceExtension
结构能够被用于关联到一个设备对象的全局数据。
设备对象可以有如下形式的名字 \Device\DeviceName
,这被系统用来识别和定位它们。应用可以使用 CreateFile
API 函数来打开一个有上述名字的文件,获得一个可以用于和设备交互的句柄。
然而,通常只有 PDO 有自己的名字。未命名的设备能够通过设备级接口来访问。设备驱动注册一个或多个接口,以 128 位全局唯一标识符(GUID)来标示它们。用户应用能够使用已知的 GUID 来获取一个设备的句柄。
在 Linux 上注册设备
在 Linux 平台上,用户应用通过文件系统入口访问设备,它通常位于 /dev
目录。在模块初始化的时候,它通过调用内核函数 register_chrdev
创建了所有需要的入口。应用可以发起 open
系统调用来获取一个文件描述符来与设备进行交互。这个调用后来被发送到回调函数,这个调用(以及将来对该返回的文件描述符的进一步调用,例如read
、write
或close
)会被分配到由该模块安装到 file_operations
或者block_device_operations
这样的数据结构中的回调函数。
设备驱动模块负责分配和保持任何需要用于操作的数据结构。传送进文件系统回调函数的file
结构有一个 private_data
字段,它可以被用来存放指向具体驱动数据的指针。块设备和网络接口 API 也提供类似的字段。
虽然应用使用文件系统的节点来定位设备,但是 Linux 在内部使用一个主设备号major numbers和次设备号minor numbers的概念来识别设备及其驱动。主设备号被用来识别设备驱动,而次设备号由驱动使用来识别它所管理的设备。驱动为了去管理一个或多个固定的主设备号,必须首先注册自己或者让系统来分配未使用的设备号给它。
目前,Linux 为主次设备对major-minor pairs使用一个 32 位的值,其中 12 位分配主设备号,并允许多达 4096 个不同的设备。主次设备对对于字符设备和块设备是不同的,所以一个字符设备和一个块设备能使用相同的设备对而不导致冲突。网络接口是通过像 eth0 的符号名来识别,这些又是区别于主次设备的字符设备和块设备的。
2.3. 交换数据
Linux 和 Windows 都支持在用户级应用程序和内核级驱动程序之间传输数据的三种方式:
- 缓冲型输入输出Buffered Input-Output它使用由内核管理的缓冲区。对于写操作,内核从用户空间缓冲区中拷贝数据到内核分配的缓冲区,并且把它传送到设备驱动中。读操作也一样,由内核将数据从内核缓冲区中拷贝到应用提供的缓冲区中。
- 直接型输入输出Direct Input-Output 它不使用拷贝功能。代替它的是,内核在物理内存中钉死一块用户分配的缓冲区以便它可以一直留在那里,以便在数据传输过程中不被交换出去。
- 内存映射Memory mapping 它也能够由内核管理,这样内核和用户空间应用就能够通过不同的地址访问同样的内存页。
Windows 上的驱动程序 I/O 模式
支持缓冲型 I/O 是 WDM 的内置功能。缓冲区能够被设备驱动通过在 IRP 结构中的AssociatedIrp.SystemBuffer
字段访问。当需要和用户空间通讯的时候,驱动只需从这个缓冲区中进行读写操作。
Windows 上的直接 I/O 由内存描述符列表memory descriptor lists(MDL)介导。这种半透明的结构是通过在 IRP 中的 MdlAddress
字段来访问的。它们被用来定位由用户应用程序分配的缓冲区的物理地址,并在 I/O 请求期间钉死不动。
在 Windows 上进行数据传输的第三个选项称为 METHOD_NEITHER
。 在这种情况下,内核需要传送用户空间的输入输出缓冲区的虚拟地址给驱动,而不需要确定它们有效或者保证它们映射到一个可以由设备驱动访问的物理内存地址。设备驱动负责处理这些数据传输的细节。
Linux 上的驱动程序 I/O 模式
Linux 提供许多函数例如,clear_user
、copy_to_user
、strncpy_from_user
和一些其它的用来在内核和用户内存之间进行缓冲区数据传输的函数。这些函数保证了指向数据缓存区指针的有效,并且通过在内存区域之间安全地拷贝数据缓冲区来处理数据传输的所有细节。
然而,块设备的驱动对已知大小的整个数据块进行操作,它可以在内核和用户地址区域之间被快速移动而不需要拷贝它们。这种情况是由 Linux 内核来自动处理所有的块设备驱动。块请求队列处理传送数据块而不用多余的拷贝,而 Linux 系统调用接口来转换文件系统请求到块请求中。
最终,设备驱动能够从内核地址区域分配一些存储页面(不可交换的)并且使用remap_pfn_range
函数来直接映射这些页面到用户进程的地址空间。然后应用能获取这些缓冲区的虚拟地址并且使用它来和设备驱动交流。
3. 设备驱动开发环境
3.1. 设备驱动框架
Windows 驱动程序工具包
Windows 是一个闭源操作系统。Microsoft 提供 Windows 驱动程序工具包以方便非 Microsoft 供应商开发 Windows 设备驱动。工具包中包含开发、调试、检验和打包 Windows 设备驱动等所需的所有内容。
Windows 驱动模型Windows Driver Model(WDM)为设备驱动定义了一个干净的接口框架。Windows 保持这些接口的源代码和二进制的兼容性。编译好的 WDM 驱动通常是前向兼容性:也就是说,一个较旧的驱动能够在没有重新编译的情况下在较新的系统上运行,但是它当然不能够访问系统提供的新功能。但是,驱动不保证后向兼容性。
Linux 源代码
和 Windows 相对比,Linux 是一个开源操作系统,因此 Linux 的整个源代码是用于驱动开发的 SDK。没有驱动设备的正式框架,但是 Linux 内核包含许多提供了如驱动注册这样的通用服务的子系统。这些子系统的接口在内核头文件中描述。
尽管 Linux 有定义接口,但这些接口在设计上并不稳定。Linux 不提供有关前向和后向兼容的任何保证。设备驱动对于不同的内核版本需要重新编译。没有稳定性的保证可以让 Linux 内核进行快速开发,因为开发人员不必去支持旧的接口,并且能够使用最好的方法解决手头的这些问题。
当为 Linux 写树内in-tree(指当前 Linux 内核开发主干)驱动程序时,这种不断变化的环境不会造成任何问题,因为它们作为内核源代码的一部分,与内核本身同步更新。然而,闭源驱动必须单独开发,并且在树外out-of-tree,必须维护它们以支持不同的内核版本。因此,Linux 鼓励设备驱动程序开发人员在树内维护他们的驱动。
3.2. 为设备驱动构建系统
Windows 驱动程序工具包为 Microsoft Visual Studio 添加了驱动开发支持,并包括用来构建驱动程序代码的编译器。开发 Windows 设备驱动程序与在 IDE 中开发用户空间应用程序没有太大的区别。Microsoft 提供了一个企业 Windows 驱动程序工具包,提供了类似于 Linux 命令行的构建环境。
Linux 使用 Makefile 作为树内和树外系统设备驱动程序的构建系统。Linux 构建系统非常发达,通常是一个设备驱动程序只需要少数行就产生一个可工作的二进制代码。开发人员可以使用任何 IDE,只要它可以处理 Linux 源代码库和运行 make
,他们也可以很容易地从终端手动编译驱动程序。
3.3. 文档支持
Windows 对于驱动程序的开发有良好的文档支持。Windows 驱动程序工具包包括文档和示例驱动程序代码,通过 MSDN 可获得关于内核接口的大量信息,并存在大量的有关驱动程序开发和 Windows 底层的参考和指南。
Linux 文档不是描述性的,但整个 Linux 源代码可供驱动开发人员使用缓解了这一问题。源代码树中的 Documentation 目录描述了一些 Linux 的子系统,但是有几本书介绍了关于 Linux 设备驱动程序开发和 Linux 内核概览,它们更详细。
Linux 没有提供设备驱动程序的指定样本,但现有生产级驱动程序的源代码可用,可以用作开发新设备驱动程序的参考。
3.4. 调试支持
Linux 和 Windows 都有可用于追踪调试驱动程序代码的日志机制。在 Windows 上将使用 DbgPrint
函数,而在 Linux 上使用的函数称为 printk
。然而,并不是每个问题都可以通过只使用日志记录和源代码来解决。有时断点更有用,因为它们允许检查驱动代码的动态行为。交互式调试对于研究崩溃的原因也是必不可少的。
Windows 通过其内核级调试器 WinDbg
支持交互式调试。这需要通过一个串行端口连接两台机器:一台计算机运行被调试的内核,另一台运行调试器和控制被调试的操作系统。Windows 驱动程序工具包包括 Windows 内核的调试符号,因此 Windows 的数据结构将在调试器中部分可见。
Linux 还支持通过 KDB
和 KGDB
进行的交互式调试。调试支持可以内置到内核,并可在启动时启用。之后,可以直接通过物理键盘调试系统,或通过串行端口从另一台计算机连接到它。KDB 提供了一个简单的命令行界面,这是唯一的在同一台机器上来调试内核的方法。然而,KDB 缺乏源代码级调试支持。KGDB 通过串行端口提供了一个更复杂的接口。它允许使用像 GDB 这样标准的应用程序调试器来调试 Linux 内核,就像任何其它用户空间应用程序一样。
4. 设备驱动分发
4.1. 安装设备驱动
在 Windows 上安装的驱动程序,是由被称为为 INF 的文本文件描述的,通常存储在C:\Windows\INF
目录中。这些文件由驱动供应商提供,并且定义哪些设备由该驱动程序服务,哪里可以找到驱动程序的二进制文件,和驱动程序的版本等。
当一个新设备插入计算机时,Windows 通过查看已经安装的驱动程序并且选择适当的一个加载。当设备被移除的时候,驱动会自动卸载它。
在 Linux 上,一些驱动被构建到内核中并且保持永久的加载。非必要的驱动被构建为内核模块,它们通常是存储在 /lib/modules/kernel-version
目录中。这个目录还包含各种配置文件,如 modules.dep
,用于描述内核模块之间的依赖关系。
虽然 Linux 内核可以在自身启动时加载一些模块,但通常模块加载由用户空间应用程序监督。例如,init
进程可能在系统初始化期间加载一些模块,udev
守护程序负责跟踪新插入的设备并为它们加载适当的模块。
4.2. 更新设备驱动
Windows 为设备驱动程序提供了稳定的二进制接口,因此在某些情况下,无需与系统一起更新驱动程序二进制文件。任何必要的更新由 Windows Update 服务处理,它负责定位、下载和安装适用于系统的最新版本的驱动程序。
然而,Linux 不提供稳定的二进制接口,因此有必要在每次内核更新时重新编译和更新所有必需的设备驱动程序。显然,内置在内核中的设备驱动程序会自动更新,但是树外模块会产生轻微的问题。 维护最新的模块二进制文件的任务通常用 DKMS 来解决:这是一个当安装新的内核版本时自动重建所有注册的内核模块的服务。
4.3. 安全方面的考虑
所有 Windows 设备驱动程序在 Windows 加载它们之前必须被数字签名。在开发期间可以使用自签名证书,但是分发给终端用户的驱动程序包必须使用 Microsoft 信任的有效证书进行签名。供应商可以从 Microsoft 授权的任何受信任的证书颁发机构获取软件出版商证书Software Publisher Certificate。然后,此证书由 Microsoft 交叉签名,并且生成的交叉证书用于在发行之前签署驱动程序包。
Linux 内核也能配置为在内核模块被加载前校验签名,并禁止不可信的内核模块。被内核所信任的公钥集在构建时是固定的,并且是完全可配置的。由内核执行的检查,这个检查严格性在构建时也是可配置的,范围从简单地为不可信模块发出警告,到拒绝加载有效性可疑的任何东西。
5. 结论
如上所示,Windows 和 Linux 设备驱动程序基础设施有一些共同点,例如调用 API 的方法,但更多的细节是相当不同的。最突出的差异源于 Windows 是由商业公司开发的封闭源操作系统这个事实。这使得 Windows 上有好的、文档化的、稳定的驱动 ABI 和正式框架,而在 Linux 上,更多的是源代码做了一个有益的补充。文档支持也在 Windows 环境中更加发达,因为 Microsoft 具有维护它所需的资源。
另一方面,Linux 不会使用框架来限制设备驱动程序开发人员,并且内核和产品级设备驱动程序的源代码可以在需要的时候有所帮助。缺乏接口稳定性也有其作用,因为它意味着最新的设备驱动程序总是使用最新的接口,内核本身承载较小的后向兼容性负担,这带来了更干净的代码。
了解这些差异以及每个系统的具体情况是为您的设备提供有效的驱动程序开发和支持的关键的第一步。我们希望这篇文章对 Windows 和 Linux 设备驱动程序开发做的对比,有助于您理解它们,并在设备驱动程序开发过程的研究中,将此作为一个伟大的起点。
原文发布时间为:2017-11-09
本文来自云栖社区合作伙伴“Linux中国”