上一小节认识了USB 的描述符后,这一节就来讲如何从 USB 设备获取它们。我列出了具体的代码,包括获取设备描述符、配置描述符和 String 描述符。看过代码后,大家会觉得在 WDF 中做这些操作,动作非常简洁,堪称舒心。
首先看获取设备描述符,一行代码足矣。
USB_DEVICE_DESCRIPTOR UsbDeviceDescriptor;
WdfUsbTargetDeviceGetDeviceDescriptor(
IN pContext->UsbDevice, // WDF设备对象
OUT & UsbDeviceDescriptor // 返回的设备描述符
);
接下来看获取配置描述符。配置描述符囊括了USB 配 置所要用到的全部信息:设备描述(区别于设备描述符)、类描述、接口描述、端点描述。和设备描述符的定长不同的是,由于不同的设备其配置布局,包含的接口 与端点数不尽相同,故而配置描述符的长度是不定的。应该先取得配置描述符的长度,根据长度分配内存缓冲,然后二次获取设备描述符内容。
// 首先获得配置描述符的长度。它是变 长 的,包含了所用接口描述符、端点描述符。
status =
WdfUsbTargetDeviceRetrieveConfigDescriptor(pContext->UsbDevice, NULL, &size);
if(!NT_SUCCESS(status) && status != STATUS_BUFFER_TOO_SMALL)
break;
// 输出缓冲区不够长
if(OutputBufferLength < size)
break;
// 再次调用,正式取得配置描述符。
status =
WdfUsbTargetDeviceRetrieveConfigDescriptor(pContext->UsbDevice, pBufferOutput, &size);
最后我们看String 描述符的情况。 USB 设备的字符串描述符也是由设备固件定义,数量不限,甚至可以没有。它用来表述设备厂商( Vendor )对本设备的描述,这包括设备的制造商名称,产品名称,产品序列号,甚至包括接口的描述(不过 Windows 系统似乎不支持这个特性)。不同的字符串通过从 0 开始递增的 String ID 来区分。另外值得一提的是, USB 协议允许字符串描述符支持多国语言,这样同一个 String ID 可以对应于一个以上的描述符。 String ID 为 0 的字符串描述符专门用来描述 USB 设备说支持的语言(用 Language ID 表示,比如英语的 ID 为 0x0904 )。这样主机可以通过获取设备的 0 号字符串来分析它所支持的语言种类,并获取相应语言版本的字符串描述符。
和配置描述符一样,字符串描述符的长度不确定。我们也是分两次调用,第一次调用获取描述符长度,然后分配内存缓冲区,再二次调用获取描述符内容。下面是CY001_WDF 工程中 GetStringDes 函数的实现,我们可以看到语言 ID 是怎么在这里起到作用的(可惜 CY001 的固件代码目前还只支持英语一种语言,呵呵):
NTSTATUS GetStringDes(
USHORT shIndex, // String ID
USHORT shLanID, // 语言 ID
VOID* pBufferOutput,
ULONG OutputBufferLength, ULONG* pulRetLen, PDEVICE_CONTEXT pContext)
{
NTSTATUS status;
USHORT numCharacters;
PUSHORT stringBuf;
WDFMEMORY memoryHandle;
KDBG(DPFLTR_INFO_LEVEL, "[GetStringDes] index:%d", shIndex);
ASSERT(pulRetLen);
ASSERT(pContext);
*pulRetLen = 0;
// 由于 String 描述符是一个变长字符数组,故首先取得其长度
status = WdfUsbTargetDeviceQueryString(
pContext->UsbDevice,
NULL,
NULL,
NULL, // 传入空字符串
&numCharacters,
shIndex,
shLanID
);
if(!NT_SUCCESS(status))
return status;
// 判读缓冲区的长度
if(OutputBufferLength < numCharacters){
status = STATUS_BUFFER_TOO_SMALL;
return status;
}
// 再次正式地取得 String 描述符
status = WdfUsbTargetDeviceQueryString(pContext->UsbDevice,
NULL,
NULL,
(PUSHORT)pBufferOutput,// Unicode字符串
&numCharacters,
shIndex,
shLanID
);
// 完成操作
if(NT_SUCCESS(status)){
((PUSHORT)pBufferOutput)[numCharacters] = L'/0';// 手动在字符串末尾添加 NULL
*pulRetLen = numCharacters+1;
}
return status;
}
获得了这些描述符之后,我们就可以通过对它们的分析,得到USB 设备的详细信息了。比如设备的版本( 1.1 还是 2.0 ),有几个接口,接口中的端点数,端点的类型(控制、批量、中断或等时)。
运行CY001 开发板的 UsbKitApp.exe ,点击最上面的三个按钮,可以获得并打印出这些描述符的信息。如下图所示:
描述符按钮:
打印信息:
做惯了WDM 驱动的人都知道,驱动初始化在入口函数,设备初始化在 AddDevice 函数,这确是不刊之论。 WDF 框架中,驱动初始化我们已经讲了它的入口函数。然则设备初始化,到底怎么做呢?它是否还是对应到 AddDevice 函数?回答是 NO 。
WdfDriverCreate 调用已经指明了,设备初始化在自定义的PnpAdd函数中完成。大家稍微上翻一两页,就能看到定义PnpAdd函数的地方,不妨再写出来:
WDF_DRIVER_CONFIG_INIT(&config, PnpAdd);
调用 WDF_DRIVER_CONFIG_INIT 宏,并不强制你一定要传入一个有效的函数指针,如果传入NULL指针也是能过去的,只是设备就没有地方可以初始化了。
回过头来讨论PnpAdd函数,大家肯定脑子里已经在想,它和AddDevice是什么关系呢?看看它的函数申明先:
typedef NTSTATUS
(*PFN_WDF_DRIVER_DEVICE_ADD)(
IN WDFDRIVER Driver ,
IN PWDFDEVICE_INIT DeviceInit
);
第一个参数是驱动对象,就是DriverEntry 函数中被初始化的那个。
第二个参数,是WDFDEVICE_INIT 结构体。这个结构体颇为复杂, WDF 未能给出它的具体定义,只是暴露出了一系列 API 用来初始化这个结构体。具体来说,它涉及到了设备初始化的方方面面,甚至更多。比如定义设备名、设备缓冲方式的定义,属于正常的设备对象属性;而注册 PNP 和 Power 回调函数,则已经超出了传统的设备对象属性范围,越界到驱动对象里去了(这些回调函数,更像是驱动对象的分发函数或者分发函数的变体。 WDF 框架对 PNP 和 Power 管理有非常大的变动,内部机理,谁也不晓得,我们顺其自然罢了)。
WDFDEVICE_INIT结构体的初始化 API 颇为丰富。分成了三个系列。对应于普通设备对象(简称 Devcice )的初始化,专门针对功能设备对象(简称 FDO )的初始化,和专门针对物理设备对象(简称 PDO )的初始化。总共加起来大概有 30 来个。对 USB 设备驱动而言,要用到的只是前两者系列 API 。物理设备对象的初始化 API 一般由总线驱动或更底层的驱动使用,生成的物理设备对象,将被上层功能驱动所挂载。
一一弄明白这些API 接口,很是一件烦心事。好在这些 API 的定义到时很 Readable ,有时候看看名称到也能够猜到一二。我下面尽量多分析几个。
PnpAdd函数所收到的这个 WDFDEVICE_INIT 结构体,是已经被初始化过的。最明显的一个理由是,通过它,可以调用 FDO 初始化 API 获得许多设备信息。比如:获取物理设备对象、获取注册表中的硬键、软键(也就是 Hardware 键和 Software 键)、获取物理设备对象的属性(设备 ID 、兼容 ID 等)。这些 API 列于下:
WdfFdoInitAllocAndQueryProperty
WdfFdoInitOpenRegistryKey
WdfFdoInitQueryProperty
WdfFdoInitWdmGetPhysicalDevice
这些API 的具体的使用方法很简单,确实起到了简化操作的目的。 WDF 文档中都有示例代码的。注意,这些 API 必须在 WdfDeviceCreate 被调用之前调用。因为一旦 WdfDeviceCreate 被调用后, WDFDEVICE_INIT 结构的内容可能就已经变了甚至不存在了。
FDO初始化 API 中剩下的三个,两个是为过滤驱动准备的( WdfFdoInitSetEventCallbacks 和 WdfFdoInitSetFilter ),一个为总线驱动准备( WdfFdoInitSetDefaultChildListConfig ),我们就不用管它们了。
回头来看Devcice 初始化系列 API 。这里面涉及最多的是设置 Pnp 和 Power 属性、回调的 API ,由此也可见这两者的复杂程度。 CY001 中用到了一个 WdfDeviceInitSetPowerPolicyEventCallbacks ,下面会讲到 。
另一类是类型注册,用来在注册表中修改物理设备安装属性的(包括Type 、 GUID 、特性等)。它们使得设备即使在被安装后,也能改变它的 class ID 、 device type 这些安装时设定的设备属性。这确实是一件很实惠的事情。拿 CY001 为例,用 inf 文件安装好后,它的给定类 ID 是: {9048DC75-B91C-4392-925A-44A7269D6BD4} ,类名称是: CY001 Sample 。打开设备管理器,正如下图所能看到的:
但如果我在PnpAdd 函数中,调用 WdfDeviceInitSetDeviceType 函数并传入参数 FILE_DEVICE_SERIAL_PORT ,那下次再看到CY001 的时候,它的位置就会列于串口设备下面去了。
说一说这些API 的内部机理吧。 Windows 的安装( Setup )模块是一套挺复杂的东西,我就不多嘴多舌了。对于已经在系统中安装好的设备,它们的信息是统一被列在注册表中 Enum 和 Class 下的,也就是大家所说的硬件键和软件键。系统的 Setup 系统,正是从这些地方保存并查找设备的。而我们现在所讲到的这一系列的 API ,其工作就是修改设备对象这两个键的位置与值,这样 Setup 系统下次就会把它当成另外一个人看了。
这些API 列于下:
WdfDeviceInitSetCharacteristics // 比如软盘设备: FILE_FLOPPY_DISKETTE
WdfDeviceInitSetDeviceClass // 比如系统设备类: GUID_DEVCLASS_SYSTEM
WdfDeviceInitSetDeviceType // 比如改成串口类型 FILE_DEVICE_SERIAL_PORT
WdfDeviceInitSetExclusive // 独占打开,即一次只能创建设备对象的一个实例
//(对应于应用程序的Handle)
下面具体讲,如何进行USB 设备初始化、配置。
4.1 初始化过程
以前写USB 驱动,程序员大倒苦水,原因之一是 USB 设备的配置太麻烦了。这不禁让我想起了写文件过滤驱动的时候,里面有一个卷设备挂载操作,反反复复,这般那般,简直没完没了。代码还没开始写呢,脑子先被他转晕了。还好 USB 的设备配置任务虽然重(我指的是代码多),但总算都是些基本概念,不用太难为自己的脑细胞。
从USB 设备插入 PC 主机开始,到它能被操作系统识别,要经过一些特定的过程,枚举如下:
a) 设备插入主机后,USB 设备进行复位操作,将物理地址置 0 。
b) 主机检测到有物理设备接入,便通过地址查找的方式,查找地址为0 的 USB 设备;找到后,向 USB 设备发送请求,获取它的设备描述符。
c ) 主机分析设备描述符,并根据实际情况,为新插入设备重新分配一个物理地址(非0 );并把这个新地址,通过 Set Address 命令发送给设备。
d ) 设备收到并保存新地址,此后当主机查询设备的时候,USB 设备即当以此新地址来回应查询请求。
e ) Set Address成功后,主机向刚分配地址的 USB 设备再次发送请求,获取设备描述符。
f ) 获取设备描述符成功后,主机发送请求获取配置和报告描述符。
g ) 根据获取的描述符,主机配置此USB 设备。
h ) 配置完成,设备正常工作。
上面的这个过程,凡是讲PNP 管理器的书籍,大抵都会讲。我这里仅仅简单列一下,详细透彻的说明,大家去找书看,《 Windows Internal 》就讲得非常详细。 a->d 这四个步骤,是设备被系统识别的过程,是由系统(总线驱动或其他的系统模块)和 USB 设备交互完成的。 e->g 这三个步骤由功能驱动负责来做。
我上面也说过了,以前用WDM 来完成这五个步骤,是比较烦难的。弄弄就是一大堆代码,虽然没有什么灵活机变的地方,但很容易一不小心就搞错了。在这篇文档中,我为了比较可能会举一些 WDM 的示例代码。但我主要想指给大家的路,是一条用 WDF 铺出的林中碎石密径,轻快、干净还漂亮。所以会有好多 WDF 代码示例,教你走,领着看。
提到设备对象,让人一下子就想到DEVICE_OBJCET 结构体。更有些人还会立刻想到《 Undocument Windows 2k 》 里面列出的关于这个结构体每个成员的详细解释。设备对象是最基本的内核对象之一。设备对象未必都对应到一个物理设备。好多“设备”都是存在于逻辑上的,比 如“卷”设备;还有一些设备对象,则连逻辑设备也不是,比如每个驱动都可能会有一个控制设备对象,它们纯粹只是一个“结构体”而已。
但对于代表物理设备的物理设备对象而言,系统通过操作这些对象,起到了实际控制物理设备本身的作用。
从结构体本身而言,DEVICE_OBJCET 够底层,够强大,够 Undocument 。另外,它还够难理解,够难使用,够易出错。用好它的人够厉害,用坏它的人,嗯,够不幸。处于对无数不幸人士的体贴, WDF 提供了封装对象 WDFDVICE 。对于 WDFDEVICE ,它完全 undocument (别沮丧),但无比易用,几乎不会出错。
哦,不要忘了,WDF 除了 WDFDVICE 外,还进一步又封装了一个 WDFUSBDEVICE 对象。从从属关系来说, WDFUSBDEVICE 已经是 DEVICE_OBJCET 的孙子辈了。对于 USB 驱动,这个对象真是太好用了!
WDF对象封装得过于严实。到目前为止,我还不晓得有谁破译出它们内部的定义。这种情况下,黑客们大概是不太欢喜的。
调用WDF 驱动初始化函数后,框架就为驱动对象生成一个 WDFDEVICE 对象。这个对象句柄在 XXX 函数中作为参数传入。可以不保存这个句柄,因为我们需要根据这个对象句柄,生成 WDFUSBDEVICE 对象,只要保存后者就可以了。
要找一个可用来保存自有数据的地方。WDF 为每个框架对象都设计了一个特殊的“环境变量”——不仅仅是这里讲到的设备对象,而是所有框架对象——正可用来保存这些数据。这个“环境变量”,用起来有点像 WDM 设备对象中的设备扩展。但用起来要麻烦很多。
首先要申明“环境变量”的类型和大小。根据大小,框架为设备对象申请一块内存。
其次定义一个函数指针,通过这个函数可以获取“环境变量”。这可真麻烦。但这也是没有办法,因为框架对象是完全密封的,没有办法像设备扩展指针一样直接获取。这项技术说起来还是挺有趣的,我非要给大家说个明白不可。
注:我们使用KMDF 框架进行编程,一般不直接使用原始的 WDM 对象。在这里,我们把 WDM 对象称作原始对象( RAW ),而把 KMDF 对象称作封装对象( Wrapped )。只要愿意,可以对 RAW 对象进行各种形式的封装。大家初学的时候遇到这些东西会感觉比较麻烦,但熟悉之后却能带来编程上的便利,它们都带有定义良好的接口。
我们要找到一个保存WDFUSBDEVICE 对象句柄的地方。 WDF 设备的“环境变量”,相当于 WDM 驱动中的设备扩展,是一个理想的地方。
// 创建WDFUSB 设备
status = WdfUsbTargetDeviceCreate(Device, WDF_NO_OBJECT_ATTRIBUTES, &DeviceContext->UsbDevice);
if(!NT_SUCCESS(status))
{
KDBG(DPFLTR_INFO_LEVEL, "WdfUsbTargetDeviceCreate failed with status 0x%08x/n", status);
return status;
}
上例中调用 WdfUsbTargetDeviceCreate 时候的Device 句柄,是初始化的时候由系统创建的。这个句柄代表了一个 WDFDEVICE 对象,也就是说系统其实已经为我们创建了一个 WDF 设备对象了,我们现在在它的基础上再封装出一个 WDF USB 设备对象。
创建WDF 设备对象是比较简单的,复杂的地方在于设置初始化结构体。我们可以分两个步骤来实现初始化: 1. 注册 PNP 、 Power 回调函数; 2. 设备命名;
对于设备驱动来讲,PNP 、 Power 分发是顶顶重要的,这一点和过滤驱动不同。如何处理好 PNP 、 Power 分发,是设备驱动开发过程中很头疼的事情。不仅事繁,而且事艰。 WDF 框架顶好的一个优点就是为所有的 PNP 、 Power 分发写了默认处理方法。这样我们只要注册少量感兴趣的回调函数,即能将它们轻松处理了。
// 注册PNP与Power回调函数。
WDF_PNPPOWER_EVENT_CALLBACKS_INIT(&pnpPowerCallbacks);
pnpPowerCallbacks.EvtDevicePrepareHardware = PnpPrepareHardware; // 在此为设备驱动申请系统资源
pnpPowerCallbacks.EvtDeviceReleaseHardware = PnpReleaseHardware;
pnpPowerCallbacks.EvtDeviceSurpriseRemoval = PnpSurpriseRemove; // 异常移除
pnpPowerCallbacks.EvtDeviceRelationsQuery = PnpRelation;
pnpPowerCallbacks.EvtDeviceD0Entry = PwrD0Entry; // 进入D0电源状态(工作状态),比如初次插入、或者唤醒
pnpPowerCallbacks.EvtDeviceD0Exit = PwrD0Exit; // 离开D0电源状态(工作状态),比如休眠或设备移除
WdfDeviceInitSetPnpPowerEventCallbacks(DeviceInit, &pnpPowerCallbacks); // 注册回调
// 读写请求中的缓冲区访问方式。默认为Buffered,还包括Direct和Neither。
WdfDeviceInitSetIoType(DeviceInit, WdfDeviceIoBuffered);
上面代码的全部任务,就是初始化结构体对象 WDFDEVICE_INIT 。WDK文档没有给出这个结构体的定义。但有一系列的宏或者方法,被定义了用来对它进行设置。上面的代码仅用到了其中的两个。这个结构体也是相当复杂的,大家还是结合WDK自己参透吧。
这一小节乃是从《创建设备》节中分出来的,为了醒目的缘故。
和WDM 驱动一样,设备对象是可以选择被命名的。就是说,设备对象可以被命名,也可以不命名,由程序员自己决定。命名的目的是为了能够被识别和使用,如果无此需要则命名可不必进行。
功能设备对象总是需要命名的,因为功能驱动是用来被User 程序使用的。
>>>>>>>>>>>>>>>>>>>>>>>>>>>
附:《查看WDM 设备对象名》
如果扯得远一点,我想和读者交流一下怎么在知道了一个设备对象地址后,手动查看这个设备对象名(首先要确认是否有用设备名)。 这部分内容纯属附加,不感兴趣的朋友 可 绕过。
假设现在知道了某个设备对象的地址为0xe1016b a0 ,我们可以通过下面的步骤手动查看它的设备名称(仅在XP 下测试):
1. 打开WinDBG ,运行在 local kernel 模式下。在控制窗口中输入命令 :
dt nt!_object_header 0xe1016b a0 -0x18
这时候提示画面会出现内核结构体OBJECT_HEADER (未文档的结构体)的内容。 XP 下 OBJECT_HEADER 的大小为 0x18 字节,并且其位置正好就在 DEVICE_OBJECT 上面。所以我们通过上面的 WinDBG 命令,可以得到一个正确的 OBJECT_HEADER 结构体内容。
2. 找到结构体中成员变量NameInfoOffset 的位置,看他的值。现在我们可以根据这个值判断设备对象是否有名字:如果 NameInfoOffset 值为 0 ,说明这个对象未被命名;否则,就是拥有一个名称的,并且保存其名称的地方,就在 OBJECT_HEADER 上面某处( NameInfoOffset 即为偏移)。
我假设你看到的内容和我下面的截图是一样的:
我们可以根据这个值,找到系统保存对象名称的地方。在控制窗口中运行这个命令:
dd 0xe1016b a0 -0x18-0x10
得到一串内存数值后,第三个DWORD 值,就是保存设备名称的缓冲区地址。
上图是我电脑中运行后的结果,第三个DWORD 内容为 0xe1016ba0 。
3. 再运行db 命令,查看地址 0xe1016ba0 所指示 缓冲区 中的 内容 :
lkd> db 0x e1016ba0
0x e1016ba0 XXXXXXXXXX CY001_0.... //找到的设备名称
0xe1016bb0 .......................... ........................
成功!
>>>>>>>>>>>>>>>>>>>>>>>>>>>
我们在CY001_WDF 驱动程序中,为设备命名形如“ CY001_X ”这样的名称,末位 X ,是区间 [0, 8] 的整数。因为不知道某个名字是否已经在系统中存在,所以需要一个循环尝试的过程。通过判断 WdfDeviceCreate 调用返回的错误值是否为STATUS_OBJECT_NAME_COLLISION ,可以知道当前尝试的名称是否在系统中引起了名字冲突;如果发生冲突,我们就需要重新尝试。最多尝试到名称“ CY001_8 ”,如果 CY001_8 也已经注册了,就让驱动初始化失败。这样的话,我们的驱动目前最多支持同时 8 个 CY001 设备连接到系统中。
// 目前驱动支持同时 8 个实例,即可以同时有 8 个开发板链接在 PC 上,驱动对它们给予并行支持。
// 不同的设备,各以其名称的尾数( 0-7 )相别,并将尾数作为设备的 ID 。
// 下面的操作中,我们为当前设备寻找一个未使用的 ID 。
for(nInstance = 0; nInstance < MAX_INSTANCE_NUMBER; nInstance++){
wcsDeviceName[nLen-1] += nInstance;// 修改末尾的数字,使从 0 至 7 。
// 调用 WdfDeviceInitAssignName 接口,尝试着为当前设备命名;
// 此函数在系统中查找此名称是否唯一,如已存在则返回失败,否则以成功返回。
status = WdfDeviceInitAssignName(DeviceInit, &DeviceName);
// 创建 WDF 设备。上面所做的设置在这一步方能发挥到实质性作用。
status = WdfDeviceCreate(&DeviceInit, &attributes, &device);
if(!NT_SUCCESS(status))
{
if(status == STATUS_OBJECT_NAME_COLLISION)// 名字冲突
KDBG(DPFLTR_ERROR_LEVEL, "Invalid name: %wZ", &DeviceName);
else
{
KDBG(DPFLTR_ERROR_LEVEL, "WdfDeviceCreate failed with status 0x%08x!!!", status);
return status;
}
}else{
KdPrint(("Found valid name: %wZ", &DeviceName));
break;// 成功即退出
}
}
一旦命名成功,那么对应的名称就会出现在系统名称空间中。使用WinOBJ 工具,就能在 Device 子目录下看到了。
下面来看看WDF 环境下如何为设备创建符号链接或设备接口。(省略)