向USB设备发送控制命令,需要初始化一个WDF_USB_CONTROL_SETUP_PACKET 结构体。WDF定义了一些宏专门用来对结构体进行初始化。这些宏定义分别对应于:为Class类型控制命令初始化、为用户自定义类型控制命令初始化、为标准命令类型控制命令初始化,为Status和Feature命令类型控制命令初始化。
设计者用心良苦,定义这么多宏以方便开发者,但因为太多了,所以在我看来反而把问题搞复杂了。
Class命令初始化宏:WDF_USB_CONTROL_SETUP_PACKET_INIT_CLASS
用户自定义命令初始化宏:WDF_USB_CONTROL_SETUP_PACKET_INIT_VENDOR
标准命令初始化宏:WDF_USB_CONTROL_SETUP_PACKET_INIT
下面的Status和Feature命令是标准命令的子集,但使用下面的宏,需要传入的参数更少:
Get Status命令初始化宏:WDF_USB_CONTROL_SETUP_PACKET_INIT_GET_STATUS
Feature命令初始化宏:WDF_USB_CONTROL_SETUP_PACKET_INIT_FEATURE
这些宏定义大同小异,最大的区别是区分了命令类型,即上图中type值5、6比特位所表示的标准类型、类类型、Vendor类型。如果读者觉得记忆这些宏反而很繁琐的话,可以直接避过这些宏定义,而自己手动设置WDF_USB_CONTROL_SETUP_PACKET 结构体。
我在CY001_WDF工程的UsbControlRequest函数中,把对这些宏的运用,发挥得淋漓尽致了。读者学习了上面的内容后,在对照代码,会有更深的体会。
有一点要告诫读者:不要向设备发送设备无法识别和无法处理的控制命令,否则设备会一直挂在那里,这时候,你非要重新插拔设备才能让设备恢复。
下表定义了11个USB标准请求,发送这些命令到CY001设备,会是很安全的。只是要注意在设置Type值的时候,5、6比特应为00b。
请求代码 |
符号名 |
描述 |
可能的接受者 |
0 |
GET_STATUS |
获得状态信息 |
任何 |
1 |
CLEAR_FEATURE |
清除一个双态特征 |
任何 |
2 |
|
(保留) |
|
3 |
SET_FEATURE |
设置一个双态特征 |
任何 |
4 |
|
(保留) |
|
5 |
SET_ADDRESS |
设置设备地址 |
设备 |
6 |
GET_DESCRIPTOR |
取设备、配置,或串描述符 |
设备 |
7 |
SET_DESCRIPTOR |
设置一个描述符(可选) |
设备 |
8 |
GET_CONFIGURATION |
取当前配置索引 |
设备 |
9 |
SET_CONFIGURATION |
设置一个新的当前配置 |
设备 |
10 |
GET_INTERFACE |
取当前的alt接口索引 |
接口 |
11 |
SET_INTERFACE |
使能alt接口设置 |
接口 |
12 |
SYNCH_FRAME |
报告同步帧号 |
(等时)端点 |
下表定义了CY001系统支持的Vendor命令,发送这些命令到CY001设备,会是很安全的。只是要注意在设置Type值的时候,5、6比特应为10b:
请求代码 |
符号名 |
描述 |
可能的接受者 |
0xA0 |
|
固件代码加载与读RAM |
任何 |
0xD1 |
SET_LEDS |
设置LED灯状态 |
任何 |
0xD2 |
SET_7SEG |
设置7段灯状态 |
任何 |
0xD3 |
GET_LEDS |
读取LED灯状态 |
任何 |
0xD4 |
GET_7SEG |
读取7段灯状态 |
任何 |
0xD5 |
START_7SEG |
开始7段灯的秒表实验 |
任何 |
前面已经讲过了,USB中断传输不是真正意义上的中断,而是采用Host轮询的方式来模拟中断。既然是轮询操作,就会涉及到一个轮询频率的问题,即每隔多少时间查询一次。这个时间值被定义在中断端口的端口描述符中,由硬件定义,并由总线驱动参照执行;用户只能参考不能修改。我们以CY001设备的中断端口为例来看看:
;; Endpoint Descriptor
db DSCR_ENDPNT_LEN ;; Descriptor length
db DSCR_ENDPNT ;; Descriptor type
db 01H ;; Endpoint number, and direction
db ET_INT ;; Endpoint type
db 40H ;; Maximun packet size (LSB)
db 00H ;; Max packect size (MSB)
db 0AH ;; Polling interval
它定义的时间间隔是10ms(0xA)。但Windows系统并不真的按照这个值来进行轮询操作,而是参照这个值并根据自己的某个算法,产生另一个近视的值来。WDK帮助文档中有一个USB设备定义值与Windows系统产生值的对照表。这里USB设备定义的10ms对应于Windows系统的轮询时间为8ms。下面是具体的算法:
Windows系统把轮询时间定义为与USB设备定义值最接近的,不大于32的2的幂。
按照这个算法,和10最靠近的2的幂是2的三次方(8)。如果设备定义值处于16-31之间,则最近的轮询时间为2的4次方即16ms;设备定义值如果处于区间[32,255]中,则轮询时间一律都为32ms;设备定义值若大于255视为非法。
总线端口虽然每隔8ms访问一次中断端口,但未必每次都能读取到需要的数据。只有在读取到了有效数据的情况下,总线驱动才会通知上面的功能驱动来处理读取到的数据;否则功能驱动得不到任何通知。
总线驱动轮询USB设备,而上面的功能驱动该怎么做呢?数据是总线驱动得来的,它必须建立某种途径,来获取总线驱动获得的那些数据才行。这样功能驱动也必须在其内部实现一个轮询模块。它轮询的对象不是USB设备,而是底层的总线驱动。下面是WDM下的轮询实现方式,7.4节开始是WDF框架下的轮询实现。
要实现某种循环过程以达到轮询,WDM框架下一般这样做:发送多个(也可以一个)IRP读中断端口;一旦某个IRP返回,就在IRP的完成函数中处理读取到的数据,并在处理完后再次发送此IRP。 下面的流程图表示了此过程。
在实际代码编写过程中,WDM框架下的轮询实现借助于IRP对象的重利用和循环发送,很容易出错,写起来会有一大堆代码,非常麻烦。WDF简化了这部分的工作。它定义了一个新的概念,叫“连续读操作”(Continuous Reader),并把读操作划分成了四个独立进行的任务:初始化连续读,开启读,读数据处理,结束读。至于IRP的循环分发,以及在WDM实现中要做的其他处理工作,则由框架代为操作了。
初始化连续读操作,需要设置WDF_USB_CONTINUOUS_READER_CONFIG 结构体。下面是结构体的定义:
typedef struct _WDF_USB_CONTINUOUS_READER_CONFIG {
ULONG Size;
size_t TransferLength;
size_t HeaderLength;
size_t TrailerLength;
UCHAR NumPendingReads;
PWDF_OBJECT_ATTRIBUTES BufferAttributes;
PFN_WDF_USB_READER_COMPLETION_ROUTINE EvtUsbTargetPipeReadComplete;
WDFCONTEXT EvtUsbTargetPipeReadCompleteContext;
PFN_WDF_USB_READERS_FAILED EvtUsbTargetPipeReadersFailed;
} WDF_USB_CONTINUOUS_READER_CONFIG, *PWDF_USB_CONTINUOUS_READER_CONFIG;
TransferLength:表示每次从中断端口读取数据的最大长度。端口描述符中定义了最大包长度。把TransferLength设置为这个最大包长度,是比较安全的。这样就能保证每次都能够读取到一个完整的包。
NumPendingReads:相当于WDM实现中,发送多少个读IRP到总线驱动。多个IRP形成队列,排队等侯总线驱动从USB设备中断端口读取有效数据。可以设为1,那样的话可能会发生处理不及时而丢包的情况。在有时效要求的情况下,最好设置为大于1的值。但也不用太大,2或者3就足够了,因为中断数据处理不会占用太多时间,IRP返回的数据一旦被处理完,IRP又可以被重新利用了。
EvtUsbTargetPipeReadComplete:数据处理函数。它和WDM中的IRP回调函数不一样,只是IRP回调函数的一个子集。相当于我们在WDM流程图中用红字标志出的那个部分。
EvtUsbTargetPipeReadCompleteContext:作为参数传入EvtUsbTargetPipeReadComplete函数。
EvtUsbTargetPipeReadersFailed:当框架在处理连续读的过程中,遇到错误,就调用这个回调,来进行错误处理。一般应该在这个回调中,对中断端口进行重置,然后重新启动连续读操作。框架的默认处理方发,就是这样的。如果在默认处理之外,没有其他的处理要求,我们可以不定义自己的EvtUsbTargetPipeReadersFailed回调。
框架提供WDF_USB_CONTINUOUS_READER_CONFIG_INIT宏用来初始化WDF_USB_CONTINUOUS_READER_CONFIG结构。这个宏设置WDF_USB_CONTINUOUS_READER_CONFIG结构的TransferLength、EvtUsbTargetPipeReadComplete、EvtUsbTargetPipeReadCompleteContext三个值。
WDF_USB_CONTINUOUS_READER_CONFIG_INIT(&interruptConfig,
InterruptRead,// 回调函数注册。当收到一次读完成消息后,此函数被调用。
pContext, // 回调函数参数
pipeInfo.MaximumPacketSize // 读缓冲区的最大长度
);
status = WdfUsbTargetPipeConfigContinuousReader(pipeInt, &interruptConfig);
if(NT_SUCCESS(status))
pContext->bIntPipeConfigured = TRUE;
else
KDBG(DPFLTR_INFO_LEVEL, "Error! Status: %08x", status)
连续读操作初始化成功后,只要调用WdfIoTargetStart就能启动连续读行为了;调用WdfIoTargetStop可终止连续读行为。下面我们来看看数据处理函数的定义。
如果定义了多个连续读,可以把多个连续读的数据处理函数,定义为同一个回调函数。系统会为回调函数传入管道句柄,以区分是哪个管道的读操作完成了。
VOID
InterruptRead(
WDFUSBPIPE Pipe,
WDFMEMORY Buffer,
size_t NumBytesTransferred,
WDFCONTEXT Context
);
第一个参数是为端口句柄,可用来区分多个连续读操作。
第二个参数为保存读数据的内存句柄。
第三个参数为读缓冲的长度。这个长度一定是一个小于或等于上面所定义的TransferLength的值。 我们的功能驱动从总线驱动那里接收读数据。TransferLength是最大可以接收到的数据长度, NumBytesTransferred是实际接收到的长度,所以NumBytesTransferred只可能比TransferLength小。
第四个参数是在上述WDF_USB_CONTINUOUS_READER_CONFIG结构体中定义的回调参数。
数据处理的工作,首先是从Buffer内存句柄中获取内存缓冲指针。调用WdfMemoryGetBuffer可以从内存句柄中获取缓冲区指针。然后分析获得的缓冲内容并做适当处理。在CY001_WDF工程中,中断端口用来发送鼠标移动信息。用户程序USBKitApp发送监听命令到驱动程序,驱动程序每获得一个有效的鼠标移动信息,就在数据处理函数中向USBKitApp汇报。
CY001_WDF工程中有三个函数与连续读操作有关:
InterruptReadStart:初始化并启动连续读
InterruptReadStop:终止连续读
InterruptRead:读数据操作
读操作的开启与终止,由USBKitApp通过DeviceIOControl发送命令来控制。USBKitApp的读请求被保存在一个手动队列中,并在InterruptRead函数中被处理。
在这个小节,笔者想和大家一起分享关于中断端口效率的实例分析。笔者在起初设计固件代码的时候,先掌握了控制访问的实现,中断访问还没有掌握,就顺势把鼠标查询模块设计为通过控制端口反复查询来或许鼠标动作。我当时的思路是这样的:
UsbKitApp发送循环查询命令--à 1
CY001_WDF向底层连续发送读控制命令—> 2
固件层每当收到鼠标查询命令,就向上层反馈当前鼠标状态信息。 3
这样实现以后鼠标可以正常工作,不足之处在于动作很迟钝,不灵敏。我当时想当然地认为,系统对控制传输的带宽是给予保证的,实现起来不应该比中断模式差。
当我掌握了中断传输的实现以后,就把鼠标模块改为以中断方式实现。数据处理的逻辑随之也改变了,变成下面的两条线:
1. UsbKitApp发送循环查询命令--à
CY001_WDF向底层连续发送中断读命令
2. 固件层主动把最新的鼠标按键信息存储起来,等待Host读取
和上次的流程图相比,也是三个动作,但这次三个动作分成了两条线,并且两条线是互相独立的。这让我联想到可以用一种通俗方式来理解它们:第一条流程图是同步实现;第二条流程图是异步实现。
细看第二个流程图:第一条线是HOST方面的动作,和第一个次流程图中的前两步雷同,对鼠标模块的效率变化不构成太大的影响。第二条线是设备(固件代码)方面的动作,变化比较大,设备从原来的被动查询(第一个流程图的3)变成了主动输出。在主动输出的情况下,设备以100ms的频率为周期不断查询鼠标按键状态,一旦状态发生变化,就立刻把最新信息输出到中断端口缓冲区中(如果在新的安装状态发生的时候,上次的信息尚未被主机读取,则新的信息会把旧内容覆盖)。这样Host每次发来中断读请求,只要端口缓冲区中有数据存在,就能立刻读到需要的数据。如果是控制查询的话,则需要经过查询(Setup阶段),握手确认,数据阶段这些手续。中断方式的效率自然提高了两三倍!
这样看来,中断方式和控制方式的效率变化并不是由于带宽,而是由于内部实现机理的不同。中断查询方式,有两条线在同时工作,控制方式却是单线工作。如果方便的话,这里提到的“线”到可以理解成“线程”了,只不过有一条是运行于固件中。这样,上面说到的“控制方式更类似于同步查询,中断方式则更类似于异步查询”也就很有道理了。
讲到批量传输,我的第一句话是:批量端口和中断端口基本上没有区别。
第二句话是:中断传输有延迟保证,而批量传输没有。
所以,在程序实现上,中断和批量方式的代码实现基本相同,甚至API也都是共用的。以应用来论,系统为中断传输辟出固定的带宽,也就是每个数据帧中,都会包含中断查询的数据;而批量传输的带宽,只在需要的时侯系统才为它开辟的。由于这一点,对时间敏感的设备比如鼠标键盘,都以中断方式实现,因为系统不晓得什么时候会有鼠标或者键盘消息产生,便只能不断查询了。而U盘设备用批量传输方式实现,因为系统什么时候需要读、写数据,系统自己是明白的,也就用不着浪费带宽总是查询了。
读和写代码书写上很类似,以写为例。如果要写某个管道,要将一个Request句柄和一个Memory句柄,关联到这个管道上。使用Request句柄用来跟踪写操作的各个阶段(开始、完成),Memory句柄用来存储需要写到管道的数据。WDF函数WdfUsbTargetPipeFormatRequestForWrite用来完成写管道的准备工作,函数原型如下:
NTSTATUS
WdfUsbTargetPipeFormatRequestForWrite(
IN WDFUSBPIPE Pipe,
IN WDFREQUEST Request,
IN OPTIONAL WDFMEMORY WriteMemory,
IN OPTIONAL PWDFMEMORY_OFFSET WriteOffset
);
第一个参数是管道句柄;第二个参数是Request对象句柄,可以是新建的,也可以是对老Request对象的重用;第三个参数是Memory句柄,内部含有一块含有写内容的缓冲区。第四个参数是缓冲偏移,即从Memory句柄代表的内部缓冲的哪个地方开始写,如果这个参数为NULL,则默认从缓冲的头部开始写。
对应于读,存在另一个函数WdfUsbTargetPipeFormatRequestForRead用来完成读管道的准备工作。函数参数与写操作一样,作用也很相近。
下面是写批量管道的全部代码,同样可用于写中断管道:
VOID BulkWrite(IN WDFQUEUE Queue, IN WDFREQUEST Request, IN size_t Length)
{
NTSTATUS status = STATUS_SUCCESS;
WDFMEMORY hMem = NULL;
WDFDEVICE hDevice = NULL;
WDFUSBPIPE BulkOutputPipe = NULL;
UCHAR* lpBuf;
UNREFERENCED_PARAMETER(Length);
hDevice = WdfIoQueueGetDevice(Queue);
BulkOutputPipe = GetBulkPipe(FALSE, hDevice);// 取得批量输出Pipe。中断Pipe亦可。
if(NULL == BulkOutputPipe){
WdfRequestComplete(Request, STATUS_UNSUCCESSFUL);
return;
}
KDBG(DPFLTR_INFO_LEVEL, "[BulkWrite] size: %d", Length);
status = WdfRequestRetrieveInputMemory(Request, &hMem);
if(!NT_SUCCESS(status))
{
KDBG(DPFLTR_ERROR_LEVEL, "WdfRequestRetrieveInputMemory failed(status = 0x%0.8x)!!!", status);
WdfRequestComplete(Request, status);
return;
}
lpBuf = (UCHAR*)WdfMemoryGetBuffer(hMem, 0);
KDBG(DPFLTR_TRACE_LEVEL, "%c %c", lpBuf[0], lpBuf[1]);
// Format当前的Request,在Request中添加当前写入Pipe的相关信息。
status = WdfUsbTargetPipeFormatRequestForWrite(BulkOutputPipe, Request, hMem, NULL);
if(!NT_SUCCESS(status))
{
KDBG(DPFLTR_ERROR_LEVEL, "WdfUsbTargetPipeFormatRequestForWrite(status 0x%0.8x)!!!", status);
WdfRequestComplete(Request, status);
return;
}
WdfRequestSetCompletionRoutine(Request, BulkWriteComplete, BulkOutputPipe); // 设置完成函数
if(FALSE == WdfRequestSend(Request, WdfUsbTargetPipeGetIoTarget(BulkOutputPipe), NULL))// 发送到批量输出Pipe的Target对象
{
status = WdfRequestGetStatus(Request);
KDBG(DPFLTR_ERROR_LEVEL, "WdfRequestSend failed with status 0x%0.8x/n", status);
WdfRequestComplete(Request, status);
}
}
下面是批量写操作的完成函数。
// Bulk管道写操作的完成函数
//
VOID BulkWriteComplete(IN WDFREQUEST Request, IN WDFIOTARGET Target,
IN PWDF_REQUEST_COMPLETION_PARAMS Params, IN WDFCONTEXT Context)
{
PWDF_USB_REQUEST_COMPLETION_PARAMS usbCompletionParams;
NTSTATUS ntStatus;
ULONG_PTR ulLen;
LONG* lpBuf;
KDBG(DPFLTR_INFO_LEVEL, "[BulkWriteComplete]");
UNREFERENCED_PARAMETER(Context);
UNREFERENCED_PARAMETER(Target);
usbCompletionParams = Params->Parameters.Usb.Completion;
ntStatus = Params->IoStatus.Status;
ulLen = usbCompletionParams->Parameters.PipeWrite.Length;
lpBuf = WdfMemoryGetBuffer(usbCompletionParams->Parameters.PipeWrite.Buffer, NULL);
if(NT_SUCCESS(ntStatus))
KDBG(DPFLTR_INFO_LEVEL, "%d bytes written to USB device successfully.", ulLen);
else
KDBG(DPFLTR_INFO_LEVEL, "Failed to write: 0x%08x!!!", ntStatus);
// 完成操作。本次完成操作将直接通知到发送此请求的用户App程序。
WdfRequestCompleteWithInformation(Request, ntStatus, ulLen);
}
上面仅仅是写操作,读操作代码我就从略了。
最后我结合CY001实际运用一下批量读写。我建议大家一定要结合实际谈技术,有了生动的现场才能考察高深的理论。
我们的实验是这样的,先往CY001起始地址为0处,写入一串字符串(“ChinaHearing CY001”),写入成功后再从CY001的0地址处读内存。如果读到的内容和写入内容一致的话,证明我们的读写操作成功。下面是表明读写都正确的运行结果:
由于CY001没有涉及到等时操作。这里从略。