通用串行总线(USB)是主机和外设设备之间的一种连接

USB的关键点在于搞懂那些连成线,连成片的各个数据结构,其实linux系统总的说来,不就是一个个的数据结构加上一些方法函数所组成的庞大的整体吗?windows虽然不公开源代码,可是其的本质来说,又何尝不是如此呢?其实 linux与windows从本质上,和方法上来说来说,是一样的。

USB是由一棵或者几个点对点的连接构建而成的树,连接是连接设备和集线器(HUB)的丝线电缆。USB主控制器负责询问每一个USB设备是否有数据需要发送。这种配置性成了一个非常简易的即插即用类型的系统,设备可以由主机自动的配置。而需要注意的一点就是,USB一个重要的特性是知担当设备和主控制器之间通信通道的角色,而对所发送的数据没有任何特殊的内容和结构上的要求。而USB协议规范定义了一套任何特定类型的设备都可以遵循的标准,如果设备遵循该标准,就不需要一个特殊的驱动程序,这些不同的定义类型称为 class。

而USB驱动程序存在于不同的内核子系统,和USB硬件控制器之中。USB核心为USB驱动程序提供了一个用于访问和控制 USB硬件的接口,而不必考虑系统当前存在的各种不同类型的USB硬件控制器。USB端点有四种不同的类型,分别具有不同的传输数据的方式。控制,中断,批量,等时,其实,从名字上就可以看出各种方式的意思。

在结构体struct usb_host_endpoint结构体来描述USB端点。

在struct usb_endpoint_descriptor的结构体中包含了真正的端点信息,同时包含了所有的USB特定的数据,这些数据的格式是由设备自己定义的。 struct usb_host_endpoint

{

struct usb_endpoint_descriptor desc;

struct list_head urb_list;//urb结构体回在后面介绍。

void *hcpriv;

unsigned char *extra;

int extralen;

};

struct usb_endpoint_descriptor

{

_ _u8 bLength; //

描述符长度

_ _u8 bDescriptorType; //描述符类型

_ _u8 bEndpointAddress; //端点地址:0~3位是端点号,第7位是方向(0-OUT,1-IN)

_ _u8 bmAttributes; //端点属性:bit[0:1] 的值为00表示控制,为01表示同步,为02表示批量,为03表示中断

_ _le16 wMaxPacketSize; 本端点接收或发送的最大信息包的大小

_ _u8 bInterval;//轮询数据传送端点的时间间隔

//对于批量传送的端点以及控制传送的端点,此域忽略

//对于同步传送的端点,此域必须为1

//对于中断传送的端点,此域值的范围为1~255

_ _u8 bRefresh;

_ _u8 bSynchAddress;

}

而需要注意的字段有:bEndpointAddress、bmAttributes、wMaxPacketSize、bInterval,以下就是关于这些字段的宏的定义和字段相关性介绍。

bEndpointAddress,最高位用来判断传输方向:

#define USB_ENDPOINT_NUMBER_MASK 0x0f

#define USB_ENDPOINT_DIR_MASK 0×80

#define USB_DIR_OUT 0

#define USB_DIR_IN 0×80

bmAttributes

,表示endpoint的类型:

#define USB_ENDPOINT_XFERTYPE_MASK 0×03

#define USB_ENDPOINT_XFER_CONTROL 0

#define USB_ENDPOINT_XFER_ISOC 1

#define USB_ENDPOINT_XFER_BULK 2

#define USB_ENDPOINT_XFER_INT 3

bInterval

,如果该endpoint是interrupt类型的(USB鼠标驱动就是该类型),那么bInterval就表示中断时间间隔,单位毫秒。

=======================================

而USB端点被捆绑为接口,一个USB接口代表了一个基本功能,而每个USB驱动程序控制一个接口。接口的最初状态是在第一个设置,编号为0。

在内核中,struct usb_interface结构体来描述USB接口。USB核心把该结构体传递给USB驱动程序,之后由USB驱动程序来负责控制该结构体。

struct usb_interface {

struct usb_host_interface * altsetting;//一个接口结构数组,下面介绍。

struct usb_host_interface * cur_altsetting;//

unsigned num_altsetting;//altsetting指针所指的可选设置的数量。

int minor;

//如果捆绑到该接口的USB驱动程序使用USB主设备号,这个变量包含USB核心部分分配给该接口的次设备号。这仅在一个成功的usb_register_dev调用之后才有效。

enum usb_interface_condition condition;

struct device dev;

struct class_device * class_dev;

};

而其余的字段,驱动程序不需要考虑。

需要注意的字段有:struct usb_host_interface * altsetting、unsigned num_altsetting、struct usb_host_interface * cur_altsetting、int minor。

struct usb_host_interface

{

struct usb_interface_descriptor desc;

struct usb_host_endpoint *endpoint;

char *string;

unsigned char *extra;

int extralen;

};

USB

接口描述符定义为结构体usb_interface_descriptor。

struct usb_interface_descriptor

{

_ _u8 bLength; //描述符长度

_ _u8 bDescriptorType; //描述符类型

_ _u8 bInterfaceNumber; // 接口的编号

_ _u8 bAlternateSetting; //备用的接口描述符编号

_ _u8 bNumEndpoints; //该接口使用的端点数,不包括端点0

_ _u8 bInterfaceClass; //接口类型

_ _u8 bInterfaceSubClass; //接口子类型

_ _u8 bInterfaceProtocol; //接口所遵循的协议

_ _u8 iInterface; //描述该接口的字符串索引值

}

======================================= USB 接口本身被捆绑为配置。一个USB设备可以有多个配置,而且可以在配置之间切换以改变设备的状态。linux使用usb_host_config来描述 USB的配置,使用usb_device来描述整个USB设备。USB设备驱动程序通常不需要读取或者写入这些结构体中的任何值。在内核代码include/linux/usb.h中有描述。USB驱动程序通常需要把一个给定的usb_interface结构体的数据转换为一个 usb_device结构体。核心回在很多函数调用中都需要该结构体,而interface_to_usbdev就是用于该转换功能的函数。 当需要usb_dev结构体的所有USB调用,将来会变为使用一个struct usb_interface参数,而且驱动程序不再需要去作转换的工作。

USB设备是由不同的逻辑单元组成的,之间的关系如下:

设备通常具有一个或者更多的配置

配置经常具有一个或者更多的接口

接口通常具有一个或者更多的设置

接口没有或者具有一个以上的端点。

usb_device,保存了一个USB设备的信息,包括设备地址,设备描述符,配置描述符……

struct usb_device

{//代表一个USB设备

int devnum; //分配的设备地址,1-127

enum {

USB_SPEED_UNKNOWN = 0, /* enumerating */

USB_SPEED_LOW, USB_SPEED_FULL, /* usb 1.1 */

USB_SPEED_HIGH /* usb 2.0 */

} speed; //设备速度,低速/全速/

高速 struct usb_device *tt; /* usb1.1 device on usb2.0 bus */,事务处理解释器

int ttport; /* device/hub port on that tt */设备所连接的具有事务处理解释器功能的集线器端口

atomic_t refcnt; /* Reference count */引用计数

struct semaphore serialize; //用于同步

unsigned int toggle[2]; /* one bit for each endpoint ([0] = IN, [1] = OUT) */用于同步切换的位图,每个端点占用1位,[0]表示输入,[1]输出 */

unsigned int halted[2]; /* endpoint halts; one bit per endpoint # & direction; [0] = IN,

[1] = OUT */表示端点是否处于停止状态的位图 */

int epmaxpacketin[16]; /* INput endpoint specific maximums */输入端点的最大包长

int epmaxpacketout[16]; /* OUTput endpoint specific maximums */输出端点的最大包长

struct usb_device *parent; //表示设备所连的上游集线器指针

struct usb_bus *bus; // Bus we’re part of */设备所属的USB总线系统

struct usb_device_descriptor descriptor; /* Descriptor 设备描述符*/

struct usb_config_descriptor *config; /* All of the configs指向设备的配置描述符和其所包含的接口描述符,端点描述符的指针 */

struct usb_config_descriptor *actconfig; /* the active configuration 当前的配置描述符指针 */

char **rawdescriptors; /* Raw descriptors for each config */

int have_langid; /* whether string_langid is valid yet *// 是否有string_langid

int string_langid; /* language ID for strings */和字符描述符相关的语言ID

void *hcpriv; /* Host Controller private data 设备在HCD层占用的资源指针,

对USB内核层是透明的 */

/* usbdevfs inode list 设备在usbdevfs中的inode列表*/

struct list_head inodes;

struct list_head filelist;

/*

* Child devices – these can be either new devices

* (if this is a hub device), or different instances

* of this same device.

*

* Each instance needs its own set of data structures.

*只对当前设备是集线器的情况有效

*/

int maxchild; /* Number of ports if hub hub的下游端口数 */

struct usb_device *children[USB_MAXCHILDREN]; // hub所连设备指针

}; ================================================== =================== 由于单个USB物理设备的复杂性,在sysfs中表示该设备也相当复杂。无论是物理USB设备(用usb_device)还是单独USB接口(usb_interface)在sysfs

中均表示为单独的设备。 linux 内核中的USB代码通过一个称为urb(USB请求块)的东西和所有USB设备通信。这个请求块用struct urb来描述。在include/linux/usb.h中。

urb 被用来以一种异步的方式往/从特定的USB设备上的特定USB端点发送/接收数据。USB设备驱动程序可能会为单个端点分配许多urb,也可能对许多不同的端点中用单个的urb,设备中的每个端点都可以处理一个urb队列,所以多个urb可以在队列为空之前发送到同一个端点。一个urb的生命周期如下: 由USB设备驱动程序创建,分配给一个特定的USB设备的特定端点,由USB设备驱动程序递交到USB核心,由USB核心递交到特定设备的特定USB主控制器驱动程序,由USB主控制器驱动程序处理,从设备进行USB传送。当urb结束之后,USB主控制器驱动程序通知USB设备驱动程序。

void *hcpriv; /* 主机控制器私有数据 */

struct urb

{

/*

私有的:只能由USB核心和主机控制器访问的字段 */

struct kref kref; /*urb引用计数 */

spinlock_t lock; /* urb锁 */

int bandwidth; /* INT/ISO请求的带宽 */

atomic_t use_count; /* 并发传输计数 */

u8 reject; /* 传输将失败*/

/*

公共的:可以被驱动使用的字段*/

struct list_head urb_list; /* 链表头*/

struct usb_device *dev; /* 关联的USB设备 */

unsigned int pipe; /* 管道信息 */

int status; /* URB的当前状态 */

unsigned int transfer_flags; /* URB_SHORT_NOT_OK | …*/

void *transfer_buffer; /* 发送数据到设备或从设备接收数据的缓冲区 */

dma_addr_t transfer_dma; /*用来以DMA方式向设备传输数据的缓冲区 */

int transfer_buffer_length;/*transfer_buffer或transfer_dma 指向缓冲区的大小 */

int actual_length; /* URB

结束后,发送或接收数据的实际长度 */

unsigned char *setup_packet; /* 指向控制URB的设置数据包的指针*/

dma_addr_t setup_dma; /*控制URB的设置数据包的DMA缓冲区*/

int start_frame; /*等时传输中用于设置或返回初始帧*/

int number_of_packets; /*等时传输中等时缓冲区数据 */

int interval; /* URB被轮询到的时间间隔(对中断和等时urb有效) */

int error_count; /* 等时传输错误数量 */

void *context; /* completion函数上下文 */

usb_complete_t complete; /* 当URB被完全传输或发生错误时,被调用 */

struct usb_iso_packet_descriptor iso_frame_desc[0];

/*单个URB一次可定义多个等时传输时,描述各个等时传输 */

};

================================================== ====================

创建

urb:struct urb *usb_alloc_urb(int iso_packets,int mem_flags); iso_packets是该urb应该包含的等时数据包数量,如果不创建等时urb,值设置为0

销毁urb:void usb_free_urb(struct urb *urb);

中断urb:void usb_fill_int_urb(struct urb *urb,struct usb_device *dev,unsigned int pipe,void *transfer_buffer,int buffer_length,usb_complete complete,void *context,int intervall);

其中参数的含义:

struct urb *urb,指向需初始化的urb的指针。

struct usb_device *dev该urb所发送的目标USB

unsigned int pipe 该urb所发送的目标USB设备的特定端点。该值使用usb_sndintpipe或者usb_rcvintpipe函数来创建。此时在回头看结构体urb中的对应字段,其中定义了几个方法,分别用于定义4种不同的传输方式:

为设置这个结构的成员, 驱动使用下面的函数是适当的, 依据流动的方向.注意每个端点只可是一个类型.

unsigned int usb_sndctrlpipe(struct usb_device *dev, unsigned int endpoint)

指定一个控制 OUT 端点给特定的带有特定端点号的 USB 设备.

unsigned int usb_rcvctrlpipe(struct usb_device *dev, unsigned int endpoint)

指定一个控制 IN 端点给带有特定端点号的特定 USB 设备.

unsigned int usb_sndbulkpipe(struct usb_device *dev, unsigned int endpoint)

指定一个块 OUT 端点给带有特定端点号的特定 USB 设备

unsigned int usb_rcvbulkpipe(struct usb_device *dev, unsigned int endpoint)

指定一个块 IN 端点给带有特定端点号的特定 USB 设备

unsigned int usb_sndintpipe(struct usb_device *dev, unsigned int endpoint)

指定一个中断 OUT 端点给带有特定端点号的特定 USB 设备

unsigned int usb_rcvintpipe(struct usb_device *dev, unsigned int endpoint)

指定一个中断 IN 端点给带有特定端点号的特定 USB 设备

unsigned int usb_sndisocpipe(struct usb_device *dev, unsigned int endpoint)

指定一个同步 OUT 端点给带有特定端点号的特定 USB 设备

unsigned int usb_rcvisocpipe(struct usb_device *dev, unsigned int endpoint)

指定一个同步 IN 端点给带有特定端点号的特定 USB 设备 void *transfer_buffer

指向缓冲的指针, 从那里外出的数据被获取或者进入数据被接受. 注意这不能是一个静态的缓冲并且必须使用 kmalloc 调用来创建

. int buffer_length

缓冲的长度, 被 transfer_buffer 指针指向.

usb_complete_t complete

指针, 指向当这个urb完成时被调用的完成处理者.

void *context

指向数据块的指针, 它被添加到这个 urb 结构为以后被完成处理者函数获取.

int interval

这个 urb 应当被调度的间隔. 见之前的 struct urb 结构的描述, 来找到这个值的正确单位. 批量urb.void usb_fill_bulk_urb(struct urb *urb,struct usb_device *dev,unsigned int pipe,void *transfer_buffer,int buffer_length,usb_complete_t complete,void *context); 函数参数和usb_fill_int_urb函数都相同.没有interval参数因为bulk urb没有间隔值.unsiged intpipe变量必须被初始化用对自己批量方式的函数调用.

usb_fill_int_urb 函数不设置 urb 中的 transfer_flags 变量, 因此任何对这个成员的修改不得不由这个驱动自己完成.

控制 urb.

void usb_fill_control_urb(struct urb *urb, struct usb_device *dev, unsigned int pipe, unsigned char *setup_packet, void *transfer_buffer, int buffer_length, usb_complete_t complete, void *context); 函数参数和 usb_fill_bulk_urb 函数都相同, 除了有个新参数, unsigned char *setup_packet, 它必须指向要发送给端点的 setup 报文数据. 还有, unsigned int pipe 变量必须被初始化, 使用对 usb_sndctrlpipe 或者 usb_rcvictrlpipe 函数的调用.usb_fill_control_urb 函数不设置 transfer_flags 变量在 urb 中, 因此任何对这个成员的修改必须游驱动自己完成.大部分驱动不使用这个函数,因为使用在"USB传送不用 urb"一节中介绍的同步 API调用更简单.而等时urb则没有特定的初始化函数,所以必须手工的设置。此部分最后再讲。

提交urb,一旦正确创建和初始化之后,则可以提交到USB核心以发送到USB设备,通过int usb_submit_urb(struct urb *urb,int mem_flags)来完成。mem_flags参数等同于传递给kmalloc调用的同样的参数, 并且用来告诉USB核心如何及时分配任何内存缓冲在这个时间. 因为函数usb_submit_urb可被在任何时候被调用(包括从一个中断上下文),mem_flags变量的指定必须正确. 真正只有3个有效值可用。根据何时usb_submit_urb 被调用

: GFP_ATOMIC这个值应当被使用无论何时下面的是真:调用者处于一个urb完成处理者, 一个中断, 一个后半部, 一个tasklet, 或者一个时钟回调.

调用者持有一个自旋锁或者读写锁. 注意如果正持有一个旗标, 这个值不必要.current->state 不是 TASK_RUNNING. 状态一直是TASK_RUNNING 除非驱动已自己改变 current 状态.

GFP_NOIO这个值应当被使用, 如果驱动在块 I/O 补丁中. 它还应当用在所有的存储类型的错误处理补丁中.

GFP_KERNEL这应当用在所有其他的情况中, 不属于之前提到的类别. usb_submit_urb 调用成功, 传递对 urb 的控制给 USB 核心, 这个函数返回 0; 否则, 一个负错误值被返回

. 有3个方法, 一个urb 可被结束并且使完成函数被调用:

urb 被成功发送给设备, 并且设备返回正确的确认. 对于OUT urb, 数据被成功发送, 对于一个 IN urb, 请求的数据被成功收到. 如果发生这个, urb 中的状态变量被设置为 0.

一些错误连续发生, 当发送或者接受数据从设备中. 被 urb 结构中的 status 变量中的错误值所记录.

这个 urb 被从 USB 核心去链. 这发生在要么当驱动告知 USB 核心取消一个已提交的 urb 通过调用 usb_unlink_urb 或者 usb_kill_urb, 要么当设备从系统中去除, 以及一个 urb 已经被提交给它. 停止提交给USB核心的urb

int usb_kill_urb(struct urb *urb);

int usb_unlink_urb(struct urb *urb); 当函数是 usb_kill_urb, 这个 urb 的生命循环就停止了. 这个函数常常在设备从系统去除时被使用, 在去连接回调中.

对一些驱动,应当用usb_unlink_urb函数来告知USB核心去停止urb.这个函数在返回到调用者之前不等待这个urb完全停止.这对于在中断处理或者持有一个自旋锁时停止urb时是有用的,因为等待一个urb完全停止需要USB核心有能力使调用进程睡眠.为了正确工作这个函数要求 URB_ASYNC_UNLINK 标志值被设置在正被要求停止的 urb 中.

=======================================

驱动程序把驱动程序对象注册到USB子系统中,然后再使用制造商和设备标识来判断是否已经安装了硬件。而usb_device_id结构体提供了一列不同类型的驱动程序支持的USB设备。USB核心使用该列表来判断对于一个设备该使用哪一个驱动程序,热插拔脚本使用它来确定当一个特定的设备插入到系统时该自动装载哪一个驱动程序。

usr.include/linux/usb.h, line 348

348 struct usb_device_id{

352 __u16 match_flags;

//

决定设备应当匹配结构中下列的哪个成员. 这是一个位成员, 由在 include/linux/mod_devicetable.h 文件中指定的不同的 USB_DEVICE_ID_MATCH_* 值所定义. 这个成员常常从不直接设置, 但是由 USB_DEVICE 类型宏来初始化.

359 __u16 idVendor;//这个设备的 USB 供应商 ID. 这个数由 USB 论坛分配给它的成员并且不能由任何别的构成.

360 __u16 idProduct;//这个设备的 USB 产品 ID. 所有的有分配给他们的供应商 ID 的供应商可以随意管理它们的产品 ID.

361 __u16 bcdDevice_lo, bcdDevice_hi;

//定义供应商分配的产品版本号的高低范围. bcdDevice_hi 值包括其中; 它的值是最高编号的设备号. 这 2 个值以BCD 方式编码. 这些变量, 连同 idVendor 和 idProduct, 用来定义一个特定的设备版本.

367 __u8 bDeviceClass;

368 __u8 bDeviceSubClass;

369 __u8 bDeviceProtocol;

//定义类, 子类, 和设备协议, 分别地. 这些值被 USB 论坛分配并且定义在 USB 规范中. 这些值指定这个设备的行为, 包括设备上所有的接口.

375 __u8 bInterfaceClass;

376 __u8 bInterfaceSubClass;

377 __u8 bInterfaceProtocol;

// 这些定义了类, 子类, 和单个接口协议, 分别地. 这些值由 USB 论坛指定并且定义在 USB 规范中.

382 unsigned long driver_info;//这个值不用来匹配, 但是它持有信息, 驱动可用来在 USB 驱动的探测回调函数区分不同的设备.

383 };

而初始化这个结构,则用以下的宏:USB_DEVICE(vendor, product)创建一个usb_device_id,可用来只匹配特定供应商和产品ID值. 这是非常普遍用的, 对于需要特定驱动的 USB 设备.

USB_DEVICE_VER(vendor, product, lo, hi)创建一个 struct usb_device_id, 用来在一个版本范围中只匹配特定供应商和产品 ID 值.

USB_DEVICE_INFO(class, subclass, protocol)创建一个 struct usb_device_id, 可用来只匹配一个特定类的 USB 设备.

USB_INTERFACE_INFO(class, subclass, protocol)创建一个 struct usb_device_id, 可用来只匹配一个特定类的 USB 接口.

对于一个简单的 USB 设备驱动, 只控制来自一个供应商的一个单一 USB 设备, struct usb_device_id 表可定义如

:

static struct usb_device_id skel_table [] = {

{ USB_DEVICE(USB_SKEL_VENDOR_ID, USB_SKEL_PRODUCT_ID) },

{ } /* Terminating entry */

};

MODULE_DEVICE_TABLE (usb, skel_table); 至于 PCI 驱动, MODULE_DEVICE_TABLE 宏有必要允许用户空间工具来发现这个驱动可控制什么设备. 但是对于 USB 驱动, 字符串 usb 必须是在这个宏中的第一个值.

======================================= 而注册USB驱动程序则需要用到usb_driver,这是所有驱动程序都必须要创建的主要结构体,必须由USB驱动程序来填写,包括很多回调函数和变量,它们向USB核心代码描述了USB驱动程序。

struct usb_driver{

struct module *owner

//指向驱动的模块拥有者的指针.USB核心使用它正确地引用计数USB 驱动, 以便不被在不合适的时刻卸载.这个变量应当设置到THIS_MODULE 宏.

const char *name;

//指向驱动名子的指针.必须在内核USB驱动中是唯一的并且通常被设置为和驱动的模块名相同.在/sys/bus/usb/drivers/ 之下, 当驱动在内核中时.

const struct usb_device_id *id_table

/*指向struct usb_device_id表的指针,包含这个驱动可接受的所有不同类型USB设备的列表.如果这个变量没被设置,USB驱动中的探测回调函数不会被调用. 如果你需要你的驱动给系统中每个 USB 设备一直被调用, 创建一个只设置这个 driver_info 成员的入口项

:

static struct usb_device_id usb_ids[] = {

{.driver_info = 42},

{}

};

*/ int (*probe) (struct usb_interface *intf,const struct usb_device_id *id);//指向 USB 驱动中探测函数的指针. 这个函数(在"探测和去连接的细节"一节中描述)被 USB 核心调用当它认为它有一个这个驱动可处理的 struct usb_interface.一个指向 USB 核心用来做决定的 struct usb_device_id 的指针也被传递到这个函数. 如果这个 USB 驱动主张传递给它的 struct usb_interface, 它应当正确地初始化设备并且返回 0. 如果驱动不想主张这个设备, 或者发生一个错误, 它应当返回一个负错误值.

void (*disconnect) (struct usb_interface *intf);//指向 USB 驱动的去连接函数的指针. 这个函数(在"探测和去连接的细节"一节中描述)被 USB 核心调用, 当 struct usb_interface 已被从系统中清除或者当驱动被从 USB 核心卸载.

int (*ioctl) (struct usb_interface *intf, unsigned int code,void *buf);// 指向 USB 驱动的 ioctl 函数的指针. 如果它出现, 在用户空间程序对一个关联到 USB 设备的 usbfs 文件系统设备入口, 做一个 ioctl 调用时被调用. 实际上, 只有 USB 集线器驱动使用这个 ioctl, 因为没有其他的真实需要对于任何其他 USB 驱动要使用.

int (*suspend) (struct usb_interface *intf, pm_message_t message);//指向 USB 驱动中的悬挂函数的指针. 当设备要被 USB 核心悬挂时被调用.

int (*resume) (struct usb_interface *intf);//指向 USB 驱动中的恢复函数的指针. 当设备正被 USB 核心恢复时被调用.

};

创建一个有效的struct usb_driver结构体只需要初始化五个字段:

static struct usb_driver skel_driver =

{

.owner = THIS_MODULE,

.name = "skeleton",

.id_table = skel_table,

.probe = skel_probe,

.disconnect = skel_disconnect,

};最好细细的读一读ldd3上面给出的一个例子,那个例子仅仅是框架,目的是理解一个架构。

Logo

瓜分20万奖金 获得内推名额 丰厚实物奖励 易参与易上手

更多推荐