基于Linux的Input子系统框架源码分析


1、前言

​   其实写博客最主要有两个目的,一是记笔记,方便自己以后回来复习。二是希望自己写的一些东西给大家一点帮助,毕竟我学习之前也是看了很多人的博客才掌握。在学习这个博客之前,超级无敌建议大家先看下这位大佬写的帖子 [https://www.cnblogs.com/yikoulinux/p/15208238.html](Linux Input 子系统详解) 毕竟个人能力有限,可能很多地方讲解不全面,很多东西建议还是先看这位大佬写的文章。本文很多代码注释和图片都来自与该大佬的文章。

​   之前写过Pincrtl子系统框架的内容,当时把框架里面涉及到的结构体和变量的作用和设计意义都详细的说了一遍。本来Input子系统我也想通过这样的方式,但是学习了这个框架后发现,Pinctrl子系统相比Input子系统来说的确是简单了很多,所以以至于在Input子系统中涉及到很多结构体和变量我也不太清楚具体的作用。所以我只能把我清楚的东西讲出来,当然,还有一部分是自己的猜测。另外,在讲这个框架时,我可能会特别的钻一些牛角尖,比如Input子系统是怎么注册字符设备,创建设备文件,怎么在根文件目录中的/dev目录和/sys目录下构建。其实我研究的这部分可能都已经偏了,所以不感兴趣的话,就可以跳过这部分,我会尽量把这些东西写在一个板块中。

2、Input子系统大致工作流程

​   Input子系统或者说是Input框架,这里面一直说系统,一直说框架,说实话在初学时,我真的感觉很抽象,因为我实在不知道所谓的系统、框架究竟是一个什么东西,不知道这个框架的工作流程。所以我想通过本节将Input子系统主要工作流程大致讲解下,给大家一个全局的概念,了解一个Input子系统中究竟做了什么,里面又到底有哪些东西。

​   在Input子系统中有三个东西,一个是输入设备,一个是输入设备处理器,一个是输入设备关联器(自己定义的)。输入设备指的就是我们常用的鼠标、键盘、按键等一类的设备。而输入设备处理器则是用来管理每一种输入设备,比如有专门管理鼠标设备的处理器,有专门管理键盘设备的处理器,而输入设备处理器已经事先被定义好在内核中。当我们使用一种输入设备时,首先第一件事情就是与内核中被定义好的输入设备处理器依次匹配,如果匹配成功,则表示内核支持该输入设备。但是是怎么匹配的呢?即通过输入设备的相关信息,比如生产厂商、产品类型,支持的事件等。其中生产厂商、产品类型这些可有可无,支持的事件则最为重要。支持的事件指按键、触摸、上传信息等事件,通过该信息基本就可以判断出该设备具备什么功能,从而与输入设备处理器完成匹配。当匹配成功后,输入设备关联器就会将输入设备和输入设备处理器关联起来,这样就可以通过输入设备关联器找到内核中匹配成功的所有输入设备。当然,匹配成功后,还有一件很重要的事情,即创建该输入设备的字符设备,这样就可以在应用层通过read函数获取输入设备上传的信息。不过需要注意一点的是,每种输入设备处理器提供的字符设备文件操作集不一样,毕竟有些输入设备之间上传信息的方式还是有着很大不同的。

​   上传输入设备信息流程,就需要靠上面所说的输入设备关联器了,输入设备关联器关联了一对匹配成功的输入设备的输入设备管理器,当我们使用某个输入设备时,就可以顺势找到该输入设备对应的输入设备处理器,输入设备处理器从而就可以将输入设备上传的信息存放到一个缓冲内存中,最后只需要等待应用层的read函数读取该缓冲即可。

3、Input子系统中的数据结构体

3.1、input_dev

​   input_dev结构体用来描述一个输入设备,这里我只讲解我知道的成员。

3.1.1、name,phys,uniq,id

​   这四个成员含义分别为设备名称,设备在系统中的路径,设备唯一id,input设备id号。可以发现四个成员有个共同点,都是用来描述一个输入设备。所以这四个成员需要我们自己去设置,Input子系统并不提供这些信息。其中name,phys,uniq三个成员会在/sys/class/input目录下相应的设备文件中提供三个文件接口,即通过读取这三个文件就可以获取到输入设备的设备名称,设备在系统中的路径,设备唯一id。这三个文件可读不可写。

​   id这个成员是一个struct input_id类型的结构体,其成员如下:

struct input_id id =	/* input设备id号 */
{
    __u16 bustype;  	/* 总线类型 */  
    __u16 vendor;   	/* 生产厂商 */  
    __u16 product;  	/* 产品类型 */ 
    __u16 version;  	/* 版本 */
}

​   看其结构体成员,可以知道这个结构体用来描述一个输入设备的信息。这个结构体在后面会用来与输入设备处理器完成匹配,通过这些信息,判断这个输入设备是属于哪一种输入设备,比如是属于键盘还是鼠标等。然后鼠标或者键盘的种类也会很多,所以在输入设备处理器中有个struct input_device_id 结构体,该结构体指向一个存放相同输入设备不同种类的设备信息数组。input_dev中的id成员就是与该数组进行依次匹配,来判断是否属于该输入设备处理器。

​   但是,实际很少有通过这个id成员进行匹配,也就是在实际编程中,基本不会去设置这个参数。至于是通过什么信息完成匹配,后面会说到。

3.1.2、evbit,keybit,relbit…

​   标题写不下了,完整的以代码形式提供。

	unsigned long evbit[BITS_TO_LONGS(EV_CNT)];				/* 设备支持的事件类型,主要有EV_SYNC,EV_KEY,EV_REL,EV_ABS等*/	
	unsigned long keybit[BITS_TO_LONGS(KEY_CNT)];			/* 按键所对应的位图 */
	unsigned long relbit[BITS_TO_LONGS(REL_CNT)];			/* 相对坐标对应位图 */
	unsigned long absbit[BITS_TO_LONGS(ABS_CNT)];			/* 绝对坐标对应位图 */
	unsigned long mscbit[BITS_TO_LONGS(MSC_CNT)];			/* 支持其他事件 */
	unsigned long ledbit[BITS_TO_LONGS(LED_CNT)];			/* 支持led事件 */
	unsigned long sndbit[BITS_TO_LONGS(SND_CNT)];			/* 支持声音事件 */
	unsigned long ffbit[BITS_TO_LONGS(FF_CNT)];				/* 支持受力事件 */
	unsigned long swbit[BITS_TO_LONGS(SW_CNT)];				/* 支持开关事件 */

​   这里一共9个数组,也可以说是位图。位图我的理解就是,一个字节中的每个bit位都代表一个事件。例如如果一共有64种按键事件,每一种事件都代表了一个bit位,因为这里的数组类型是long(假设这里long表示4个字节),所以通过BITS_TO_LONGS宏定义将64个bit转成2。因为long型表示4个字节,一共32个bit,64/32=2。

​   evbit和后面的8个数组类型不一样,evbit中的每个bit位表示设备支持的事件类型,而后面8个数组每个bit位表示每种事件类型支持的子事件。比如evbit支持EV_KEY事件,即按键事件,而keybit中每个bit位表示支持哪一个按键,比如是空格键还是确认键。又比如evbit支持EV_ABS事件,即触摸事件(EV_ABS事件中的一种), absbit中每个bit位表示支持上传那种信息,比如上传X坐标或者Y坐标。切记后面8个数组表示的是支持的子事件,而不是上传的值。

3.1.3、hint_events_per_packet,max_vals,vals

​   这三个成员分别表示平均事件数,队列最大的帧数,当前帧中排队的数组。首先我们需要知道的是,输入设备上传信息是以struct input_value 结构类型封装上传。其次是当我们按下输入设备时,按键信息会首先在输入设备中缓存,当接收到EV_SYN/SYN_REPORT同步信号后,才会将缓存的信息上传给输入设备处理器,最后应用层才会读取到信息。最后,为了更加高效的利用内存,会根据当前输入设备支持的事件估计一个最大的缓存空间。以按键为例,当我们按下按键时,就会有一个以struct input_value 结构封装的按键信息存放在输入设备中的缓存中,当我们按下5次,就有5个缓存,直到我们发送EV_SYN/SYN_REPORT同步信号后,该缓存才会被清空。所以,hint_events_per_packet就是用来表示估计的平均事件数,当然实际情况不会出现上面说的例子一样,按下5次按键才发送同步信号一次,一般都是按下一次按键就会发送一次同步信号。只是有时候有些输入设备同时具备几个事件功能,比如触摸屏,有多点触摸事件和按键事件,当触摸按下后,有时会将多点触摸事件和按键事件一起缓存后才会发送一次同步信号。max_vals就是指这个缓存的大小,这个值比hint_events_per_packet大2。vals就指向申请的缓存空间。

3.1.4、repeat_key,timer,rep[REP_CNT]

​   这3个成员分别表示最近一次按键值(用于连击),自动连击计时器,自动连击参数。当我们evbit中使能了REP事件后,将会使能按键的连击功能。即当我们一直按下按键不松手,会给应用层报告连击事件。其实本质就是上传值2。因为我们知道,按键按下上传值0,按键松开上传值1,那么连击就是值2了。这里说的连击不是指双击,而是一直按着。

3.1.5、mt,absinfo

​   这两个成员分别表示多点触控区域和存放绝对值坐标的相关参数数组。这两个成员跟多点触摸代码相关,因为我没看这部分相关代码,所以我就不清楚了。

3.1.6、key,led,snd,sw

​   具体注释如下:

unsigned long key[BITS_TO_LONGS(KEY_CNT)];					/* 反应设备当前的按键状态 */
unsigned long led[BITS_TO_LONGS(LED_CNT)];					/* 反应设备当前的led状态 */
unsigned long snd[BITS_TO_LONGS(SND_CNT)];					/* 反应设备当前的声音状态 */
unsigned long sw[BITS_TO_LONGS(SW_CNT)];					/* 反应设备当前的开关状态 */

​   可以发现这4个成员有个共同点,就是表达的状态就两种,比如按键,分按下和松开,led灯分亮和灭。这4个变量就是用来记录状态的。前面讲到的按键连续按,就利用这key这个成员。

3.1.7、open,close,flush,event

​ 具体注释如下:

int (*open)(struct input_dev *dev);									/* 第一次打开设备时调用,初始化设备用 */
void (*close)(struct input_dev *dev);								/* 最后一个应用程序释放设备事件,关闭设备 */
int (*flush)(struct input_dev *dev, struct file *file);				  /* 用于处理传递设备的事件 */
int (*event)(struct input_dev *dev, unsigned int type, unsigned int code, int value);	/* 事件处理函数,主要是接收用户下发的命令,如点亮led */

​   这是4个函数指针,也可以说是回调函数。它们将会被Input子系统中一些操作调用,但是这4个函数指针默认是没有被定义的,需要我们自己去定义。至于什么时候需要定义什么时候不需要就看自己的需求,目前我只在内核自带的按键驱动中看到定义了open和close两个函数指针。

3.1.8、h_list,node

​   这是两个链表结构,其中node将此input_dev链接到input_dev_list总链表上,input_dev_list链接了当前注册的所有输入设备。h_list链接设备所支持的input handle,前面说了input handle为输入设备关联器,当成功匹配输入设备和输入设备处理器后,就将会生成一个input handle,而该input handle将会被链接到该input_dev的h_list上,这样就可以通过h_list找到该输入设备匹配到的所有输入设备处理器。同理,在输入设备处理器中也有一个h_list,作用和这个一样。

3.1.9、struct device dev

​   这个成员几乎每个设备驱动都包含的有,而且该成员也十分复杂,在这里我只讲该结构体中的struct class *class和struct device_type *type。主要是这个涉及到了/sys/class目录下一些设备文件的生成。

​   先说下/sys/class/input目录怎么来的,这是在input_init(void)函数中通过class_register(&input_class)函数注册了input这个类。其中input_class定义如下;

struct class input_class = {
	.name		= "input",
	.devnode	= input_devnode,
};

​   其中name成员,就是将来/sys/class/input这个文件夹的名字。

​   然后/sys/class/input目录中的各个设备文件比如input1、event1这些,就需要我们去实现注册,比如最常见的device_create 函数。当然这也不是我要讲的重点,我要说的是每个设备文件中的各个属性文件。如果内核驱动代码看多了,会发现频繁有attribute,attribute_group这样的结构体类型,而这些结构体类型就是用来定义设备文件中的各个属性文件的。以input子系统为例,它定义了一个input_dev_attr_groups属性集合,代码定义如下:

static const struct attribute_group *input_dev_attr_groups[] = {
	&input_dev_attr_group,
	&input_dev_id_attr_group,
	&input_dev_caps_attr_group,
	NULL
};

  以上就是属性文件的定义,至于具体怎么实现就不用去纠结了,有兴趣可以看看其它相关的博客。这里我们需要关心的是,它把input_dev_attr_groups赋给了谁,然后怎么生成了这些属性文件。答案就是将它赋给了struct device_type 中的struct attribute_group **groups 成员,可以发现该成员也是struct attribute_group 类型。而struct device_type又属于struct device dev中的成员,所以当我们调用device_create这样一类的注册设备函数,就会在相应的设备文件中生成相应的属性文件。

​   但是想要生成属性文件,不止上面所说的一种赋值方式,还可以将input_dev_attr_groups赋给其它成员生成属性文件。

​   1、赋值给miscdevice.groups。miscdevice是杂项设备的结构体类型,其中groups就是一个attribute_group **类型成员。当我们使用杂项设备时,想要生成属性文件,就可以赋值给该成员。

​   2、赋值给class.dev_groups。class是一个类结构体类型,以前学习过class_create 函数用来创建一个类。如果想生成属性文件,则可以给class中的成员dev_groups赋值,该成员也是一个attribute_group **类型。这样当我们使用device_create函数时(该函数第一个传参变量就是我们创建的class类),就会给该设备创建定义好的属性文件。

​   3、赋值给device.groups。最开始学习驱动时,是通过device_create函数直接注册device,但是看了内核驱动后,发现大部分都是将该函数拆开使用,分成device_initialize(dev)和device_add(dev)两个函数。即先给device进行各种赋值,然后再调用device_initialize函数完成一些通用的初始化,最后再调用device_add函数完设备注册。如果想生成属性文件,就可以在给device赋值阶段,给device的groups成员赋值。该成员也是一个attribute_group **类型。

​   4、通过device.kobj成员。这里需要通过两个函数实现注册,sysfs_create_files和sysfs_create_group,前者是创建一个属性文件,后者是创建多个属性文件。使用方法如下:

/* 用来创建属性文件,即有几个属性,就有几个属性文件 */
sysfs_create_files(dev->kobj, (const struct attribute **)test_kobj_attrs);
/*用来创建文件夹,即一个文件夹里包含了多个属性文件,但是如果没有设置group的name的话,就不会创建文件夹,就有几个属性,就有几个属性文件。*/
sysfs_create_group(dev->kobj, (const struct attribute_group **) &test_kobj_group);

3.2、input_handler

​   input_handler结构体用来描述一个输入设备处理器,这些输入设备处理器已经事先定义好在内核中。内核里面有常见的鼠标处理器和键盘处理器等,但是有一个处理器十分特殊,它就是evdev_handler。该处理器可以匹配各种输入设备,而且该处理器已经逐渐在替代其他输入设备处理器使用,因为随着技术发展,输入设备的种类越来越多,导致定义的输入设备处理器也越来越多,导致Input框架开始杂乱,所以现在越来越倾向用evdev_handler统一所有的输入设备处理器。不过内核现在仍然存在这一些其它常用的输入设备处理器,比如鼠标,键盘等,于是像鼠标这类输入设备进行匹配时,会发现会同时匹配evdev_handler和鼠标设备处理器,即在/dev目录和/sys目录中创建出两个设备节点,比如event0和mice0。

3.2.1、event、events、filter、connect、disconnect

​   具体注释如下:

	void (*event)(struct input_handle *handle, unsigned int type, unsigned int code, int value);		/* 事件处理函数, */ 
	void (*events)(struct input_handle *handle,const struct input_value *vals, unsigned int count);  	/* 事件处理函数, */;
	bool (*filter)(struct input_handle *handle, unsigned int type, unsigned int code, int value);		/* 过滤函数 */	
	bool (*match)(struct input_handler *handler, struct input_dev *dev);							  /* 匹配函数 */
	int  (*connect)(struct input_handler *handler, struct input_dev *dev, const struct input_device_id *id);	/* 连接函数,将事件处理和输入设备联系起来 */;
	void (*disconnect)(struct input_handle *handle) = evdev_disconnect;  							/* 断开该链接 */

​   看了这些函数指针,应该能多多少少明白为啥叫事件处理器了吧,这些函数全是用来处理各种事情的,不过对于不同的输入设备处理器实际定义的函数数量不同,有的可能只定义其中2个函数,有的可能全部定义,这个需要根据不同的输入设备类型而定。其中event和events用来上传输入信息,这是每个输入设备处理器必须定义的函数。filter用来实现过滤字符,比如某个输入设备上传的信息中你只想上传其中的一部分,你就可以定义该函数。match用来实现匹配设备,3.1.2中讲了,输入设备和输入设备处理器之间的匹配,主要是通过输入设备支持的事件来决定。但是如果你定义了match函数,则还需要经过这个函数匹配成功后,才算匹配成功。connect和disconnect用来连接和断开输入设备和输入设备处理器,这2个也是必须被定义的函数,分别在注册输入设备和注销输入设备时被调用。

3.2.2、minor、name

​   这两个成员分别为输入设备处理器的起始次设备号和输入设备处理器的名字。首先我们知道,对于Input子系统,我们已经申请一个主设备号13和1024个次设备号。Input子系统将这1024次设备号分配给每个输入设备处理器,比如evdev_handler占用了64-128之间的此设备号,这样当有输入设备匹配到evdev_handler后,注册的字符设备的次设备号就将会从这个区间选取。

3.2.3、id_table

​   这个就是输入设备与输入设备处理器匹配时所用到的匹配id表。该成员具体如下:

const struct input_device_id *id_table;			/* input_dev匹配用的id */
{
    kernel_ulong_t flags;

    __u16 bustype;
    __u16 vendor;
    __u16 product;
    __u16 version;

    kernel_ulong_t evbit[INPUT_DEVICE_ID_EV_MAX / BITS_PER_LONG + 1];
    kernel_ulong_t keybit[INPUT_DEVICE_ID_KEY_MAX / BITS_PER_LONG + 1];
    kernel_ulong_t relbit[INPUT_DEVICE_ID_REL_MAX / BITS_PER_LONG + 1];
    kernel_ulong_t absbit[INPUT_DEVICE_ID_ABS_MAX / BITS_PER_LONG + 1];
    kernel_ulong_t mscbit[INPUT_DEVICE_ID_MSC_MAX / BITS_PER_LONG + 1];
    kernel_ulong_t ledbit[INPUT_DEVICE_ID_LED_MAX / BITS_PER_LONG + 1];
    kernel_ulong_t sndbit[INPUT_DEVICE_ID_SND_MAX / BITS_PER_LONG + 1];
    kernel_ulong_t ffbit[INPUT_DEVICE_ID_FF_MAX / BITS_PER_LONG + 1];
    kernel_ulong_t swbit[INPUT_DEVICE_ID_SW_MAX / BITS_PER_LONG + 1];

    kernel_ulong_t driver_info = 1;			/* 表示匹配所有input_device */
}

​   可发现里面有很多跟3.1.1和3.1.2中讲到的成员相似,每个输入设备处理器就是设置这些信息,来表明自己支持那些输入设备。

3.2.4、h_list,node

​   这个跟3.1.8类似,这是两个链表结构,其中node将此input_handler链接到input_handler_list总链表上,input_handler_list链接了当前注册的所有输入设备处理器。node的作用跟3.1.8一样。

3.3、input_handle

​   该结构体成员如下:

struct input_handle 
{
	void *private;								/* 数据指针 */
	int open;									/* 打开标志,每个input_handle 打开后才能操作 */
	const char *name;							/* 设备名称 */
    
	struct input_dev *dev;						/* 指向所属的input_dev */
	struct input_handler *handler;				/* 指向所属的input_handler */
    
	struct list_head	d_node;					/* 用于链入所指向的input_dev的handle链表 */
	struct list_head	h_node;					/* 用于链入所指向的input_handler的handle链表 */
};

​   注释已经写的很清楚,这也能明白为什么我叫它为输入设备关联器。其中private成员很重要,它指向了一个struct evdev类型的结构体,该结构体很重要,因为上传事件信息时,需要利用input_handle中的private找到evdev,而evdev 中有个专门存放事件信息的缓冲区。

3.4、evdev

​   evdev指的是event类型的结构体,那么就还有mousedev类型的结构体。这里我以evdev_handler为例。前面我只说了输入设备和输入设备处理器匹配成功后,会有一个输入设备管理器将两者关联起来,但是事情远远没有这么简单。这里我会详细的讲下输入设备和输入设备处理器匹配成功后做了哪些事情。

​   当两者匹配成功后,会调用evdev_handle中的evdev_connect函数,在该函数中会创建一个evdev类型的结构体,而该结构体中就恰好有个input_handle类型的成员,将输入设备和输入设备处理器关联起来使用的就是这个成员。然后创建的evdev类型的结构体被存放在input_handle中的private成员。这样input_handle就可以通过private成员找到evdev,这一步在后面上传输入设备信息时很关键。最后就是注册字符设备和创建设备文件,而evdev中正好就有cdev类型和device类型的成员。

3.4.1、handle

​   该成员在3.3中已讲。

3.4.2、client_list

​   这是一个链表类型,链接了evdev_client类型的结构体成员,该成员会在下节讲到。

3.4.3、cdev

​   这个成员就是学驱动时接触到的字符设备结构体,用来注册一个字符设备。

3.4.4、open,exist

​   这两个成员分别用来表示设备被打开的计数和设备存在的判断。exist成员在调用evdev_connect函数是就被置为了true。而open成员表示有多少个进程打开了该设备,如果为0则表示此时输入设备处于空闲状态。

3.4.5、struct device dev

​   又是这个成员,在3.1.9中已经讲过input_dev中的device,这次再说下evdev中的device有哪些需要注意的点。该成员只说struct device *parent 和dev_t devt两个成员。可以发现这次比3.1.9中多设置了一个devt成员,该成员就是字符设备的设备号,毕竟这个device后面将会用来自动创建设备节点,而创建设备节点有一步就是设置设备号,且与cdev中的devt成员相同。parent 指向了input_dev->dev,这个有什么用呢?首先我们需要知道,在input_dev中创建的设备文件名都是以input开头,而evdev中创建的设备文件以event开头。当设置parent 指向了input_dev->dev后,将会在input设备文件中出现event设备文件。这里就会发现,一个输入设备创建了两个设备文件,且都可以操作同一个输入设备。但是input比event多了很多属性文件,即input不仅可以操作输入设备,还可以通过属性文件获取和修改输入设备信息。

3.5、evdev_client

​   该结构体并不是在输入设备匹配过程中创建,而是在应用层中,使用open函数打开输入设备时创建。前面讲了,输入设备被按下时,会先讲信息存放在input_dev中的缓存中,然后在接收到同步信号后才会把信息上传到另一个缓存中,让read函数读取。而这里说的缓存指的就是evdev_client中的buf成员,该成员用来存放input_dev事件缓冲区。

​   该结构具体成员如下:

struct evdev_client  				
{
    struct list_head node;			/* evdev_client链表项 */		
    unsigned int head;  			/* 动态索引,每加入一个event到buffer中,head++ */
    unsigned int tail;  			/* 动态索引,每取出一个buffer中到event,tail++ */
    unsigned int packet_head;  		/* 数据包头部 */
    spinlock_t buffer_lock; 		/* protects access to buffer, head and tail */
    struct fasync_struct *fasync;	/* 异步通知函数 */
    struct evdev *evdev = evdev;
    int clk_type;
    bool revoked;
    unsigned int bufsize;			/* 缓冲区大小 */	
    struct input_event buffer[];	/* 用来存放input_dev事件缓冲区 */
}
3.5.1、head,tail,packet_head,bufsize,buffer

​   学过数据结构的就清楚了,利用这几个成员实现了一个队列数据结构。即Input子系统中采用了一个队列数据结构来管理缓冲区。

3.5.2、fasync

​   这就是实现异步通知需要成员,也就是说在Input子系统中是支持异步通知的,另外也支持阻塞和非阻塞方式,这些就需要大家去看源码发现了。

4、Input子系统中的关键流程

​   参考最开始发的链接,也可以看该大佬的公众号,一口Linux。

5、Input子系统中的关键函数

​   参考最开始发的链接,也可以看该大佬的公众号,一口Linux。

6、最后

​   有些偷懒,但是确实感觉怎么写也比不上该大佬,就不重复造轮子了。也许还是功力不够,等后面觉得自己功力够的时候,我会写一份属于自己的源码分析博客。后面我会抽时间写下关于Linux中自带的按键驱动和触摸驱动源码分析,这样也正好跟本节学过的Input子系统结合起来。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

努力一点,幸运一点

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值