目录
配置
打开usbd_conf.h文件,找到总配置开关,USE_USBD_CDCACM 配置为1。其他的为0,注意:USBD_INT_DEBUG 这个配置是在中断调试的配置,如果要运行这个工程,这个配置一定要配置为0。
设备解析
打开cdc_acm_core.c文件,对应的头文件是cdc_acm_core.h。在打开对应的配置的后,只会有一个设备类的文件生效。
设备描述符
usb_desc_dev cdc_dev_desc = { .header = { .bLength = USB_DEV_DESC_LEN, //描述符长度 0x12 .bDescriptorType = USB_DESCTYPE_DEV, //描述符类型 1:设备描述符 }, .bcdUSB = 0x0200U, //USB版本 0x0200 表示为USB2.0 .bDeviceClass = USB_CLASS_CDC, //设备类的代码,CDC:0x02 .bDeviceSubClass = 0x00U, //设备子类 .bDeviceProtocol = 0x00U, //设备协议 .bMaxPacketSize0 = USBD_EP0_MAX_SIZE,//端点0的最大包大小,USB2.0全速设备中最大为64 .idVendor = USBD_VID, //设备的制造商ID .idProduct = USBD_PID, //设备的产品ID .bcdDevice = 0x0100U, //设备版本 这个自己自定义的 .iManufacturer = STR_IDX_MFC, //有关制造商的描述字符串描述符索引 .iProduct = STR_IDX_PRODUCT, //有关产品的描述字符串描述符索引 .iSerialNumber = STR_IDX_SERIAL, //有关产品唯一序列号的描述字符串描述符索引 .bNumberConfigurations = USBD_CFG_MAX_NUM, //配置描述符的数量 这里为1 };
这里使用框架中自带了标准设备描述符的数据结构定义了一个 设备描述符,并初始化了相关数据,数据中表示的是设备的信息。
.header 描述符头数据结构 bLength
表示这个单独的描述符长度(包含头长度) bDescriptorType
表示描述符的类型
配置描述符
前面我们说过,配置描述符是一个描述符集。就是包含了多个基本描述符的描述符,所以这里的数据结构是不定的,根据使用的配置而定,在这里的话,在头文件定义了配置描述符的数据结构,描述符定义的顺序是有讲究的,基本的配置描述符一定在最前面,接着就是接口描述符,然后就是这个接口下面的一些描述符,比如端点和功能等等。
这里的配置描述符包含了:
一个基本的配置描述符
wTotalLength
整个配置描述符集的总长度,需要提前计算好 bNumInterfaces
这个配置下的接口数量 bConfigurationValue
当前配置描述符的配置索引,设备可以有多个配置 iConfiguration
这个配置的字符串描述符索引,可选 bmAttributes
配置属性,Bit7:保留(默认为1)
Bit6:0-总线供电 1-自供电
Bit5:0-支持远程唤醒 1-不支持远程唤醒
bMaxPower:设备在当前配置下的最大功耗。
接口描述符
这个接口下包含了多个CDC协议下定义的描述符,不是标准的描述,还包含了一个端点描述符用于命令端点。
这是一个标准的接口描述符,在这里可以进一步表示的它的设备类和子类协议以及设备自身自定义的协议,所以USB会有复合设备的概念,因为你一个设备下可以有多个不同协议的接口。
bInterfaceNumber
当前配置中的接口索引 bAlternateSetting
接口号的备注 bNumEndpoints
这个接口下的端点数量 bInterfaceClass
接口的协议类 bInterfaceSubClass
接口的协议子类 bInterfaceProtocol
接口的设备协议 iInterface
描述这个接口的字符串描述符索引,为0就是没有
端点描述符
bEndpointAddress
端点地址,设备中端点的唯一标识,一个字节,最高位表示方向 1:IN方向 0:OUT方向,其余为地址。 bmAttributes
端点类型:控制、中断、批量、等时 wMaxPacketSize
端点的最大数据包大小 bInterval
中断端点的中断间隔 ms为单位
所以在配置描述符集可以看到若干个标准的描述符和协议特定的描述符,总的来说,一个配置描述符至少包含一个基本的配置描述符和一个接口描述符和一个端点描述符,其它都为可选可扩展的。
字符串描述符
字符串描述符可以存在多个,甚至可以多定义都没有关系,它只是一个描述信息,返回主机时是自己操作。
unicode_string
用unicode编码的字符串,一个字符占两字节
语言ID描述符
这是一个标准的描述符,有点类似字符串描述符,所以一般都和字符串描述符放在一起,所有字符串描述符的索引都从1开始,是因为0索引的字符串描述符是语言ID描述符,主机在获取字符串描述符前必须先获取语言ID描述符。
wLANGID
语言ID,
0x0409表示英文
0x0804表示中文
描述符的获取
所以可以看到,这个cdc_desc包含了所有的描述符信息,然后这个实例提供外部使用,接下来我们看一下获取描述符的请求是什么样子。
1.当设备受到主机获取描述符的标准请求后会进入这里,这里可以发现会根据请求数据判定为获取设备描述符、获取配置描述符、获取字符串描述符三个大类进入 std_desc_get[ ]这个函数指针数组的函数。
2.当追溯到最底层函数发现,本质就是返回我们上面描述符集对应的位置的地址。
所以描述符获取的本质就是,我们把实现定义好的数据地址配置到端点0传输缓冲区地址,然后通过端点0把数据发送到主机,这样主机就获取到了设备的信息。
标准设备请求
在框架的标准设备请求器可以看到如下标准请求的定义。
上述都是标准设备请求才会触发的处理,每个处理有对应的函数,其中最常见的就是设置地址,获取描述符,设置配置这三个请求,包括我们这个ACM_CDC设备,枚举阶段,主机在设置完地址后,开始获取大量的描述符,获取完没有问题后,发出设置配置请求后标准的设备请求就结束了。
上面说了获取描述符的过程,接下来是设置配置的过程。
这个函数中可以看出,判定设备处于什么状态进入什么设置,最终调用的函数无非就是这两个
(void)udev->class_core->init(udev, config); 设备类初始化
(void)udev->class_core->deinit(udev, udev->config); 设备类卸载
这两个函数都属于设备类的功能,在设备类文件中定义
设备类初始化函数主要完成除了0地址端点,一般0地址的管理是在框架中的,是不能修改的。剩下所有用到的端点初始化以及挂载事务处理函数,设置通信的数据结构。
卸载就比较简单了,就是去初始化的一部分,不过这个函数一般不会调用就是了,为了结构完整还是把它完成了,如果这个函数被调用,那么设备就不可能正常工作。
设备类的请求
当设置配置完成后,主机会发送一些CDC类的请求,这些请求是进入不了标准设备请求,在请求处理器会进入 USB_REQTYPE_CLASS的请求。
追溯一下这个请求最终调用的是哪个函数,发现调用是设备类功能函数中的设备类请求处理函数,这个函数的定义也在设备类文件中定义。
对于CDC_ACM设备,主机会获取连接代码,连接代码应该包含串口四要素的数据(波特率,数据位,停止位,校验位),然后设置连接代码,最后设置连接状态。
设备的配置
所以在设备类文件中会完成有关描述符以及设备类功能的数据结构设计以及对应的实例,并提供外部声明。
在应用时调用初始化函数时,将实例地址填进去就可以成功初始化并成功枚举。
从上述配置我们可以看出:在当前设置中,一共配置了4个端点(包含0端点),然后字符串描述符的数量为4,端点1和端点2为IN方向端点,端点3为OUT方向端点。
在我们枚举成功后,可以在PC的设备管理器看到我们的设备。
USB串行设备,这个描述是一个默认描述。可以看到COM15是我们的设备,它属于CDC_ACM设备,是虚拟串口,为什么我们描述符的产品信息没有被显示出来?而且USB转串口芯片的设备就可以识别出来不一样的信息?因为我们的设备是加载自带的驱动,自带的CDC驱动,这样我们就不用自己写驱动,用官方的标准驱动,但是CH340驱动是我们另外加载的,所以它才能够显示这些信息。
在上述的端点描述可知,地址1端点是IN方向的批量端点,批量端点的特性是在总线空闲时,主机会一直对端点IN令牌,如果这个端点有数据发送到主机,就可以在IN令牌后发送数据,达到及时响应的需求。但是我用逻辑分析看到,在枚举成功后,只收到了主机的帧起始包,并没有预想的IN令牌。
说明主机在总线上没有活动,那我设备发送数据给主机不是无法发出吗?
在枚举阶段的最后有一个设置连接状态,那个设置就是表示主机和设备建立了连接,但是没有开始通信,所以主机不会对设备的IN批量端点发IN令牌,那什么时候开始建立通信?
在PC上打开串口助手,找到对应的串口号,在打开串口或关闭串口的时候,这个过程用逻辑分析可以看到,主机对设备的0号端点发送了几个设备类请求,获取连接代码、设置连接代码。设置连接状态。
所以可以推断,在打开串口的时候,设置了连接状态为通信状态,然后在用逻辑分析仪看IN方向的批量端点,可以发现主机几乎一直在对IN方向的批量端点发送IN令牌,这个时候发送数据和接收数据都可以正常进行。
通信框架分析
到了这里,对CDC_ACM设备的整个枚举过程就结束了,在配置中我们设置了一个数据接口,数据接口下有两个批量端点,一个IN,一个OUT,这是用于通信数据的端点。
在设备类中定义了一个数据结构,其中有一个64byte的数据缓冲区。在设备类的初始化函数中实例化。
1.检查数据准备好函数
uint8_t cdc_acm_check_ready(usb_dev *udev) { if (udev->class_data[CDC_COM_INTERFACE] != NULL) { usb_cdc_handler *cdc = (usb_cdc_handler *)udev->class_data[CDC_COM_INTERFACE]; if ((1U == cdc->packet_receive) && (1U == cdc->pre_packet_send)) { return 0U; } } return 5U; }
当从主机接收到了数据到数据缓冲区,并且上一次接收的数据已经发出去后,返回0。
2.发送数据函数
void cdc_acm_data_send (usb_dev *udev) { usb_cdc_handler *cdc = (usb_cdc_handler *)udev->class_data[CDC_COM_INTERFACE]; uint32_t data_len = cdc->receive_length; if ((0U != data_len) && (1U == cdc->packet_sent)) { cdc->packet_sent = 0U; usbd_ep_send(udev, CDC_IN_EP, (uint8_t*)(cdc->data), (uint16_t)data_len); cdc->receive_length = 0U; } }
这里只有当 满足if条件的时候才能真正发送数据到主机,但是data_len只有再接收到主机的数据后才不为0,所以这里发送数据框架是只能在数据回显中才能发送出去的。
3.接收数据函数
void cdc_acm_data_receive(usb_dev *udev) { usb_cdc_handler *cdc = (usb_cdc_handler *)udev->class_data[CDC_COM_INTERFACE]; cdc->packet_receive = 0U; cdc->pre_packet_send = 0U; usbd_ep_recev(udev, CDC_OUT_EP, (uint8_t*)(cdc->data), USB_CDC_RX_LEN); }
从OUT端点缓冲区读取数据到设计的通信框架中的数据缓冲区。
4.应用示例
所以目前这个通信设计只能用于这个数据回显场景,其实本质就是用了一些标志位控制了而已,用户修改后可以实现任意的数据收发情景。
usbd_ep_recev(udev, CDC_OUT_EP, (uint8_t*)(cdc->data), USB_CDC_RX_LEN);
usbd_ep_send(udev, CDC_IN_EP, (uint8_t*)(cdc->data), (uint16_t)data_len);
最终调用是端点的数据收发函数,这里可以根据需要自由设计,到了这里就是任意的数据收发了。
发送数据:当调用了端点发送数据函数,会根据数据长度填充到端点缓冲区,一次最多64byte,然后等待IN令牌到来时自动发送之后触发传输完成中断。
if (USBD_EPxCS(ep_num) & EPxCS_TX_ST) { /* clear successful transmit interrupt flag */ USBD_EP_TX_ST_CLEAR(ep_num); usb_transc *transc = &udev->transc_in[ep_num]; if (0U == transc->xfer_len) { if (udev->ep_transc[ep_num][TRANSC_IN]) { udev->ep_transc[ep_num][TRANSC_IN](udev, ep_num); } } else { usbd_ep_send(udev, ep_num, transc->xfer_buf, transc->xfer_len); } }
当数据没有传输完时,继续从传输数据缓冲区搬运数据到端点数据缓冲区,知道数据传输完后调用对应的端点IN事务,这个事务函数在设备类文件中定义。
static void cdc_acm_data_in (usb_dev *udev, uint8_t ep_num) { usb_transc *transc = &udev->transc_in[ep_num]; usb_cdc_handler *cdc = (usb_cdc_handler *)udev->class_data[CDC_COM_INTERFACE]; if (transc->xfer_count == transc->max_len) { usbd_ep_send(udev, EP_ID(ep_num), NULL, 0U); } else { cdc->packet_sent = 1U; cdc->pre_packet_send = 1U; } }
当数据发送完时,最后一个包是一个完整的64byte包,需要补一个0数据包,表示这次传输结束,因为主机会自动检测短包,当收到短包就表示数据结束了,所以完整包后结束数据需要补0数据包,这是一个特殊的数据包,表示数据结束。
这样一来,发送数据的自动处理就设计好了,可以实现自动分包和补包,所以具体需求用户可以修改设备类中功能的设计,来达到自己的需求,中断的处理不建议修改,中断处理设计的非常完美。
接收数据:当调用了端点接收数据函数后,会配置好接收数据的长度和缓冲区,并且使能对应OUT端点的接收,当设备接收到主机数据到端点缓冲区时,将数据从端点缓冲区搬运到数据接收缓冲区,一次最多64byte,搬运完判断是否达到需要的接受量或接收到的数据小于一个包的最大数据,如果达到则调用设备类回调告诉数据接收完成,并返回计数。
usb_transc *transc = &udev->transc_out[ep_num]; uint16_t count = udev->drv_handler->ep_read (transc->xfer_buf, ep_num, (uint8_t)EP_BUF_SNG); //缓存区里的端点缓存区描述符接收计数是硬件设置的 调试软件没有任何意义 //count返回的是这次数据包的长度 每次数据包都会返回 transc->xfer_buf += count; transc->xfer_count += count; if ((transc->xfer_count >= transc->xfer_len) || (count < transc->max_len)) { if (udev->ep_transc[ep_num][TRANSC_OUT]) { udev->ep_transc[ep_num][TRANSC_OUT](udev, ep_num); } } else { udev->drv_handler->ep_rx_enable(udev, ep_num);//使能将触发中断 }
如果单次搬运后并没有达到预计的接收数据量且预计的接收数据量大于等于一个包,则会再次使能端点的接收,当下次数据到来时再继续搬运直到达到预计的接收量。
所以接收数据的分包是需要时间来处理的。当主机发送大于一个包的最大数据量的数据时会自动分包,首先主机发送 数据包1,设备成功接收并应答(设备开始处理这个数据),主机继续发送数据包2,设备在处理数据,并没有使能端点的接收(在收到数据后会自动失能),所以不应答主机,主机就会一直发送数据包2,直到设备应答为止,以此类推实现分包数据并保证设备成功接收。
总结
文章以实现cdc_acm设备为demo进行了虚拟串口的测试,并通过demo解析了整个驱动框架的核心,以及中断处理和请求处理。整个中断的设计是非常合理的,以及请求的处理,但是设备类下的文件实现需要用户自己实现,也可以基于demo进行修改。比如描述符和设备类功能接口下函数。以及通信框架。
cdc_acm的框架相对其他的设备类来说比较复杂,但是可以从这个设备类入手,方便我们后续在此基础上改写为HID键盘设备和WINUSB通用串行总线设备。
虽然不用修改驱动框架和中断事务处理,但是在实现WINUSB设备时,需要优化一下驱动框架的请求处理,因为WINUSB设备类是特定厂商设备,所以在某一个标准设备请求下存在差异,所以这个时候就需要优化,前提是需要得看懂整个框架的处理。