前言:
本章以一个实际的例子——USB鼠标来讲述如何设计一个USB设备。
本章将穿插USB标准请求、各种标准描述符、报告描述符等重要知识。
本章是本书中最长一章,以后的实例程序都是在此基础上修改而来。
3.1 USB鼠标工程的建立
将第二章中实例复制一份,将文件夹名改为UsbMouse。然后进入UsbMouse文件夹中,将工程名从
TestBoard.uv2改为UsbMouse.uv2。工程的时钟频率改为22.1184。实现了一个程序的基本框架,并且
含有串口、键盘和LED等驱动。
再修改main.c文件,将显示的信息头修改一下,将死循环中的代码删除,只剩下一些初始化代码。
在前的工程中缺少一个写1字节数据的函数。在PDIUSBD12.c文件中,增加一个写1字节数据的函数。
//函数功能:写一字节D12数据。
//入口参数:Value:要写的一字节数据。
void D12WriteByte(uint8 Value)
{
D12SetDataAddr(); //设置为数据地址
D12ClrWr(); //WR置低
D12SetPortOut(); //将数据口设置为输出状态(注意这里为空宏,移植时可能有用)
D12SetData(Value); //写出数据
D12SetWr(); //WR置高
D12SetPortIn(); //将数据口设置为输入状态,以备后面输入使用
}
3.2 USB的断开和连接
1. 如何断开和连接USB
当按下复位按键后,程序重新运行,这时须模拟一个USB拔下的动作,因此在程序的开始处,需要将D12内部
的上拉电阻断开。这可以通过D12的设置模式命令来实现。将上拉电阻断开后,需要再延迟一段时间,以便主机确
认
设备已经
断开连接。然后再将D12的上拉电阻连上,这时主机就会检测到
设备的插入。
D12的设置模式命令(Set Mode):
Set Mode命令的代码是0xF3,它后面跟2字节数据的写入。第一字节是配置字节,第二字节是时钟分频系数。
第一字节和第二字节的详细结构图如下所示。
1)Set Mode命令的第一字节各位介绍:
Bit0:保留,置0。
Bit1:无赖时钟(低频时钟)模式。
该位设置为1表示时钟输出端CLKOUT不会切换到懒时钟模式;
该位设置为0表示时钟输出端将在Suspend引脚变高后1ms切换到懒时钟模式。
懒时钟的频率为30x(1±40%)kHz。该位在USB总线复位时不会被改变。
Bit2:时钟运行。
该位设置为1,表示即使在USB挂起状态下,内部时钟和PLL也保持运行状态;
该位设置为0,表示当时钟不再需要时,内部时钟、晶体振荡器和PLL都将停止运行。
为了能达到USB协议中对总线挂起时严格的电流限制,该位应该设置为0,以节省在挂起状态下的
电流消耗。该位在USB总线复位时不会被改变。
Bit3:中断模式。
该位置1时,表示所有的错误和NAK都将产生中断请求;
该位置0时,表示只有传输正确(对于输出端点,正确接收到数据;对于输入端点,成功发送出数据)
时,才产生中断请求。该位在USB总线复位时,不会被改变。
Bit4:软连接控制。
该位置1,并且Vbus有效(前面说过,Vbus是通过EOT_N检测的)时,就会将上拉电阻连通;
该位置0时,上拉电阻被断开。该位在USB总线复位时,不会被改写。
Bit5:保留,置0。
Bit7~6:端点配置选择。
可以选择模式0~3。这三种具体的模式请参看D12数据手册。
模式0为无等时端点,即端点1和端点2都是普通端点,可作为中断或批量端点。
2)Set Mode命令的第二字节各位介绍:
Bit3~0:时钟分频系数。
假设该值为N,那么CLKOUT端的频率值就是48MHz除以N+1。通过对该值的设置,可以获得不同
频率的CLKOUT时钟输出。USB总线复位不会影响该值。
Bit5~4:保留,置0。
Bit6 :该位必须置1。
Bit7 :仅在SOF时产生中断。该位置1,只有当帧起始(SOF)时,中断信号才产生。
3) 程序中两个字节如何设置:
① 为了方便调试,不考虑节电,时钟设置为使能状态;
② 中断模式选择只有正确传输才产生中断,即成功发送或成功接收到数据后才产生中断;
③ 端点配置选择为模式0,即端点1和端点2都为普通模式,因为这里不需要等时传输;
所以,第一个字节的值在USB连接断开时为0x06;连接时为0x16。
④ 将分频系数设置为最大,即8分频,从而在CLKOUT端得到6MHz的时钟频率;
⑤ 中断可以在任何时刻产生,不需要仅在SOF时产生。
所以,第二字节的值得Bit7为0,因此得出第二字节的值为0x47。
2.如何用代码实现USB断开和连接
增加一个UsbCore.c和UsbCore.h文件,大部分根USB协议相关的代码都放在这里。增加一些调试信息,并用宏
打开和关闭,该宏的定义在config.h中。
//函数功能:USB断开连接函数。
void UsbDisconnect(void)
{
#ifdef DEBUG0
Prints("断开USB连接。\r\n");
#endif
D12WriteCommand(D12_SET_MODE); //写设置模式命令
D12WriteByte(0x06); //设置模式的第一字节
D12WriteByte(0x47); //设置模式的第二字节
DelayXms(1000); //延迟1秒
}
//函数功能:USB连接函数。
void UsbConnect(void)
{
#ifdef DEBUG0
Prints("连接USB。\r\n");
#endif
D12WriteCommand(D12_SET_MODE); //写设置模式命令
D12WriteByte(0x16); //设置模式的第一字节
D12WriteByte(0x47); //设置模式的第二字节
}
进入主函数,完成各种初始化后,先调用断开连接函数来断开USB连接,再调用USB连接函数,将上拉电阻连
通,此时就检测到设备已经插入了。
3.3 USB中断的处理
1. 哪些事件会导致D12中断请求
USB总线复位;D12进入挂起状态;成功接收或发送完数据等。
注:这里在主程序中一直查询中断引脚的电平状态来判断D12是否有中断发生,当然也可以改为终端方式。
2. 如何判断中断源
通过读取D12的中断寄存器来获取。
读中断寄存器的命令为Read Interrupt Register,代码为0xF4。
发送该命令后,可以读取两个字节的数据,第一字节中的内容是端点和总线状态的中断,第二字节的内容只有一
位有效,是与DMA有关的。
本程序不用DMA,所以只保存第一个字节。第一字节详细结构如下图所示。
其中,某位为1,表示该中断源发出了中断请求。
3. 如何处理中断信号
通过判断该寄存器中每一位的值,可以写8个对应的处理函数来处理它们。这8个函数都放在UsbCore.c中。
对中断源的处理代码如下:
while(1) //死循环
{
if(D12GetIntPin()==0) //如果有中断发生
{
D12WriteCommand(READ_INTERRUPT_REGISTER); //写读中断寄存器的命令
InterruptSource=D12ReadByte(); //读回第一字节的中断寄存器
if(InterruptSource&0x80)UsbBusSuspend(); //总线挂起中断处理
if(InterruptSource&0x40)UsbBusReset(); //总线复位中断处理
if(InterruptSource&0x01)UsbEp0Out(); //端点0输出中断处理
if(InterruptSource&0x02)UsbEp0In(); //端点0输入中断处理
if(InterruptSource&0x04)UsbEp1Out(); //端点1输出中断处理
if(InterruptSource&0x08)UsbEp1In(); //端点1输入中断处理
if(InterruptSource&0x10)UsbEp2Out(); //端点2输出中断处理
if(InterruptSource&0x20)UsbEp2In(); //端点2输入中断处理
}
}
然后,每个函数中写上一句输出调试信息。例如在总线复位中增加一条“ USB总线复位”:
#ifdef DEBUG0
Prints("USB总线复位。\r\n");
#endif
4. 如何分析串口调试信息
接着把程序下载到开发板上,通电测试,看具体发生了哪些中断。
串口显示的信息如下:
调试信息分析:
1)从上面显示信息看到,在连接USB之后,主机对设备进行了几次复位操作;
2)然后向端点发送了数据,因为端点0输出已经产生了中断。
3)至于端点0输出了什么数据,需要接收过来看看。
注:右下角弹出了无法识别USB设备的对话框,这是因为程序未返回描述符。
3.4 读取从主机发送到端点0的数据
1. 何时读取端点数据
3.3节中端点0发送了数据过来,并引发了中断,在端点0输出中断处理函数
UsbEp0Out()中调用读取端点缓冲区函数。
2. 如何选择读哪个端点的缓冲区数据
D12的选择端点(select endpoint)命令。
选择端点命令共有6个,分别对应3额端点的输出和输入,命令代码实0x00~0x05,发送哪个命令就选择了哪个端点。
//函数功能:选择端点的函数,选择一个端点后才能对它进行数据操作。
//入口参数:Endp:端点号。
void D12SelectEndpoint(uint8 Endp)
{
D12WriteCommand(0x00+Endp); //选择端点的命令
}
3. 如何读取特定端点的数据
读取D12的数据缓冲区,使用D12的读缓冲(read buffer)命令,它的代码是0xF0,。发送该命令后,就可以连续读数据了。
数据传输协议:1(reserved)+1(len)+n(data)
读取的第一字节是保留位,没有意义,不用理会它。
第二字节的值是接收到的数据的字节数,读取它之后就知道缓冲区内实际接收到了多少字节数据。
第三字节开始是真正的USB数据,将其读出并保存到自己的Buf中。
注:该读取函数有一个入口参数len,表示想要读取的字节数。如果len比实际接收的字节数小,则只读取前面len
字节;如果比实际接收的字节数大,则只读取实际接收的数据。
读取端点数据的代码实现:
//函数功能:读取端点缓冲区函数。
//入口参数:Endp:端点号;Len:需要读取的长度;Buf:保存数据的缓冲区。
//返 回:实际读到的数据长度。
uint8 D12ReadEndpointBuffer(uint8 Endp, uint8 Len, uint8 *Buf)
{
uint8 i,j;
D12SelectEndpoint(Endp); //选择要操作的端点缓冲
D12WriteCommand(D12_READ_BUFFER); //发送读缓冲区的命令
D12ReadByte(); //该字节数据是保留的,不用。
j=D12ReadByte(); //这里才是实际的接收到的数据长度
if(j>Len) //如果要读的字节数比实际接收到的数据长
{
j=Len; //则只读指定的长度数据
}
#ifdef DEBUG1 //如果定义了DEBUG1,则需要显示调试信息
Prints("读端点");
PrintLongInt(Endp/2); //端点号。由于D12特殊的端点组织形式,
//这里的0和1分别表示端点0的输出和输入;
//而2、3分别表示端点1的输出和输入;
//3、4分别表示端点2的输出和输入。
//因此要除以2才显示对应的端点。
Prints("缓冲区");
PrintLongInt(j); //实际读取的字节数
Prints("字节。\r\n");
#endif
for(i=0;i<j;i++)
{
//这里不直接调用读一字节的函数,而直接在这里模拟时序,可以节省时间
D12ClrRd(); //RD置低
*(Buf+i)=D12GetData(); //读一字节数据
D12SetRd(); //RD置高
#ifdef DEBUG1
PrintHex(*(Buf+i)); //如果需要显示调试信息,则显示读到的数据
if(((i+1)%16)==0)Prints("\r\n"); //每16字节换行一次
#endif
}
#ifdef DEBUG1
if((j%16)!=0)Prints("\r\n"); //换行。
#endif
return j; //返回实际读取的字节数。
}
4. 如何清除中断标志
为什么要清除中断标志:
防止一直提示中断发生。
如何清除端点中断标志:
用Read Last Transaction Status命令读取端点最后传输状态后,各端点的中断标志(Bit0~5)被清零。
发生该命令后,可以读1字节数据,数据内容为该端点传输的最后状态。详细结构如下图所示:
Bit0:该位为1表示数据成功接收或发送。
Bit1~4:出错代码,可以用来调试,知道当前的芯片处于怎样的状态,具体参看数据手册。
Bit5:该位为1,表示收到的是建立(setup)过程的数据包。
Bit6:该位为0,表示收到的是DATA0数据包;该位为1表示收到的是DATA1数据包。
Bit7:该位为1,表示前一次状态没有读取,前面的状态已经被覆盖。
其中Bit5在控制传输中很有用,由此可知当前收到的是建立过程的数据包。建立包是控制传输第一个过
程的令牌包,地位很特殊,控制端点必须要接收建立过程的数据包。
如何清除另外两位(Bit6~7):
在读取本寄存器后,被自动清零。
5. 如何清除数据缓冲区
为什么要清除数据缓冲区:
防止不能再接收数据。
如果一个端点接收数据后没有清除端点缓冲区,对于以后发往该端点的数据包(建立过程的数据包除
外,设备必须接收它)
将使用NAK来应答。
如何清除数据缓冲区:
命令Clear Buffer,代码是0xF2。
对于D12的控制端点,接收到建立包后必须要使用命令Acknowledge Setup,才能让Clear Buffer命令和
Validate Buffer命令生效。Acknowledge Setup命令对控制输入和输出端点都要发送,因为Clear Buffer命令是
针对输出端点的,而Validate Buffer命令是针对输入端点的。
这样做的目的是为了保证控制传输建立过程的数据不会丢失,且接着也不会返回错误的数据,
只有等到
处理完了
这个建立过程,并发送Acknowledge Setup命令后,才能使用Clear Buffer命令和Validate Buffer命令。
因此,程序
首先要判断一下,收到的这个数据包是否为建立过程的数据包;
如果是,则在发送Clear
Buffer命令之
前,还需要先发送Acknowledge Setup命令。
通常,先读取端点缓冲区后再清除端点缓冲区,因此这里的清除端点缓冲区函数没有再选择端点,避免
多余的操作。
在调用该函数前,一定要确保当前所选择的端点是需要清除的目标端点。例如,下面的
AcknowledgeSetup()函数就是先对输入端点0操作,再对输出端点0操作,以保证后面使用的清缓冲函数时当
前的目标端点是输出端点0。
清除数据缓冲区代码实现:
//函数功能:清除接收端点缓冲区的函数。
//备 注:只有使用该函数清除端点缓冲后,该接收端点才能接收新的数据包。
void D12ClearBuffer(void)
{
D12WriteCommand(D12_CLEAR_BUFFER);
}
//函数功能:应答建立包的函数。
void D12AcknowledgeSetup(void)
{
D12SelectEndpoint(1); //选择端点0输入
D12WriteCommand(D12_ACKNOWLEDGE_SETUP); //发送应答设置到端点0输入
D12SelectEndpoint(0); //选择端点0输出
D12WriteCommand(D12_ACKNOWLEDGE_SETUP); //发送应答设置到端点0输出
}
6. 端点0输出中断处理函数
进入端点0输出中断后,首先读取最后传输状态;
然后检查Bit5是否为1,如果是1,则说明是建立包,此时读取数据后需要
调用D12AcknowledgeSetup()函数;
如果不是1,则说明只是普通的输出数据包,不用调用D12AcknowledegSetup()
函数,
直接清除缓冲区即可。
将端点0输出中断处理函数如下所示:
函数功能:端点0输出中断处理函数。
入口参数:无。
返 回:无。
备 注:无。
********************************************************************/
void UsbEp0Out(void)
{
#ifdef DEBUG0
Prints("USB端点0输出中断。\r\n");
#endif
//读取端点0输出最后传输状态,该操作清除中断标志
//并判断第5位是否为1,如果是,则说明是建立包
if(D12ReadEndpointLastStatus(0)&0x20)
{
D12ReadEndpointBuffer(0,16,Buffer); //读建立过程数据
D12AcknowledgeSetup(); //应答建立包
D12ClearBuffer(); //清缓冲区
}
else //if(D12ReadEndpointLastStatus(0)&0x20)之else 普通数据输出
{
D12ReadEndpointBuffer(0,16,Buffer);
D12ClearBuffer();
}
}
7. 分析串口调试信息
编译并下载上面程序,通过串口调试助手可以看到返回的调试信息,如下图所示:
1)从上图可以看出,已经成功接收到主机发送过来的8字节数据。
2)在第一次接收到数据后,会停顿一段时间。此时主机一直在请求输入。但程序目前还没有返回数据,
所以D12一直在回答NAK,即没有数据准备好。
3)结果USB主机经过一段时间等待后,终于放弃,发送一次总线复位。
4)然后又重新输出这8字节数据,又等待输入数据……主机共重试3次这种操作,当3次都没读到数据后,
放弃操作。
5)USB端口上不再有数据活动,D12进入挂起状态。
6)计算机端弹出“ 无法识别 ”对话框。