WinIo简介

WinIo简介

一日发现SendInput对某程序居然无效,无奈只好开始研究WinIo。上网查了很多资料,发现关于WinIo模拟鼠标键盘的资料很少,有的也只是支言片语讲的不是很详细,而且大部分都是关于模拟键盘的。自己写了一些程序研究一方,经历了无数次的键盘死锁、鼠标满屏乱飞、复位重启,总算小有结果。现在将研究结果写出来与大家分享。另外,本人的水平有限文中有出错的地方欢迎根贴讨论。

  我已经将主要的模拟功能写在了一个单元文件中:MouseKeyboard.pas,调用该单元文件中的相关函数就可以实现鼠标键盘的模拟。该单元文件的下载和使用方法请参考2楼的内容。在本楼的末尾有一个中英文对译PS/2鼠标键盘协议的下载,这个协议对编写模拟鼠标键盘的程序很有帮助。另外我还提供了一个鼠标移动速度测试程序、一个使用MouseKeyboard.pas的简单示范程序的下载。

一、WinIo简介

  WinIo通过加载一个内核模式的设备驱动程序,利用几种底层编程技巧,使得Windows应用程序可以直接对I/O端口和物理内存进行存取,从而绕过了Windows系统的保护机制。WinIo包含了3个文件:WinIo.dll、WinIo.sys和WINIO.VXD,其中WINIO.VXD驱动程序用在Win95/98系统上,WinIo.sys驱动程序用在WinNT/2000/XP系统上,WinIo.dll提供了功能函数的调用。在 WinIo.dll中有两个函数最重要:InitializeWinIo用来初始化WinIo的驱动程序,必须在调用所有其它功能函数之前调用该函数;ShutdownWinIo用来卸载WinIo的驱动程序,在中止应用函数之前或者不再需要使用WinIo时调用。在初始化完成之后就可以直接读写 I/O端口而不会出现非法操作,本程序就是利用向鼠标键盘硬件端口写入数据来模拟鼠标键盘的操作。

  由于是底层的硬件端口读写,所以必需对硬件的相关协议有所了解,对于鼠标键盘最重要的协议就是PS/2鼠标键盘协议(以下简称PS/2协议)。我这里提供了一个pdf版的中英文对译PS/2鼠标键盘协议,在该协议的前半部主要讲硬件的电气接口协议,但是后半部分的内容对于模拟鼠标键盘非常有用。这个协议可是我在网上翻腾了好久才找到的。协议的下载见本楼末尾。

二、Intel 8042

  Intel 8042或兼容微控制器(以下简称i8042)被用作PC键盘的控制器,虽然名为键盘控制器,但是实际上鼠标也是由其控制的。i8042一般整合在芯片组中。向i8042发送指定的命令和数据就可以模拟鼠标键盘的操作。i8042包含了如下四个寄存器:

一个字节的输入缓冲区:包含从鼠标或键盘读入的字节;只读。
一个字节的输出缓冲区:包含要写到鼠标或键盘的字节;只写。
一个字节的状态寄存器:8个状态标志;只读。
一个字节的控制寄存器:7个控制标志;读写。

  其中前三个寄存器(输入、输出、状态)可以通过$60和$64端口直接存取,读写$60和$64端口所实现的功能如下:

端口 读写 功能
$60  读 读输入缓冲区
$60  写 写输出缓冲区
$64  读 读状态寄存器
$64  写 发送命令

  写$64端口不会写入到任何特定的寄存器中,但是解释为发送命令给i8042。如果命令接收一个参数,则参数被发往$60端口。同样,命令的任何返回结构可以从$60端口读出。i8042的状态标志是从$64端口读出的。它们包含了错误信息、状态信息和输入输出缓冲区里有无数据的指示。这些标志的定义如下:

 Bit7 Bit6 Bit5 Bit4 Bit3 Bit2 Bit1 Bit0
┌──┬──┬──┬──┬──┬──┬──┬──┐
│PERR│TO │MOBF│INH │A2 │SYS │IBF │OBF │
└──┴──┴──┴──┴──┴──┴──┴──┘

  其中标志位OBF最重要(其它标志位的意思请参考PS/2协议),它表示输出缓冲区是否已满,是否可以写入输出缓冲区。0表示输出缓冲区空,1表示输出缓冲区已满。所以在向$60端口写入数据之前要检查该标志位是否已被置0。另外在向$64端口发送命令之前也要检查该标志位,已确保上次的操作已经完成。向指定端口写入数据的程序如下,其中使用了内嵌汇编对端口进行操作。注意:鼠标键盘是慢速设备,每次操作时要有一定的延时:

procedure SetByte(Por,Cod : Byte);
begin
    Sleep(1);
    asm
        PUSH EAX
        PUSH EDX
        //等待状态寄存器标志位OBF置0
        @Loop:
        IN  AL,$64
        AND AL,01b
        JNZ @Loop
        //写入数据
        MOV AL,Cod
        MOV DL,Por
        MOV DH,0
        OUT DX,AL

        POP EDX
        POP EAX
    end;
end;

  发送命令给i8042就是写$64端口。在命令发送后,命令参数写到$60端口。命令中用来模拟鼠标键盘操作的有两条(其它命令请参考PS/2协议):

$D2:写键盘缓冲区,把参数写到输入缓冲区就像从键盘接收到的一样。
$D3:写鼠标缓冲区,把参数写到输入缓冲区就像从鼠标接收到的一样。

  例如:按下“A”键,利用上面的程序可以写成“SetByte($64,$D2); SetByte($60,$1E);”。注意:如果向$60端口发送的数据不只1个字节,那么发送的每个字节前都要发送一条命令,例如:按下“Insert”键的程序为 “SetByte($64,$D2); SetByte($60,$E0); SetByte($64,$D2); SetByte($60,$52);”,要调用SetByte四次。知道了如何向i8042发送命令,下面就可以具体的模拟鼠标键盘。

三、键盘模拟

  键盘的处理器会扫描或监视按键矩阵。如果它发现有键被按下、释放或按住,键盘将发送“扫描码”的数据包到i8042。扫描码有两种不同的类型:“通码 ”和“断码”,当一个键被按下或按住就发送通码;当一个键被释放就发送断码。每个按键被分配了唯一的通码和断码,这样i8042通过查找唯一的扫描码就可以测定是哪个按键。每个键一整套的通断码组成了“扫描码集”。有三套标准的扫描码集分别是第一套、第二套和第三套。i8042缺省支持第一套扫描码。

  一部份键的断码是将通码的最高位置1,但并不是所有的键都这样,而且很多键的扫描码不只有1个字节。所以没有一个简单的公式可以计算扫描码。如果你要知道某特定按键的通码和断码,你将不得不查表获得。例如:“A”键的通码为$1E,断码为$9E,“Insert”键的通码为$E0,$52,断码为$E0,$D2,模拟按键的程序如下:

//按下并放开“A”键
SetByte($64,$D2); SetByte($60,$1E);
SetByte($64,$D2); SetByte($60,$9E);

//按下并放开“Insert”键
SetByte($64,$D2); SetByte($60,$E0);
SetByte($64,$D2); SetByte($60,$52);
SetByte($64,$D2); SetByte($60,$E0);
SetByte($64,$D2); SetByte($60,$D2);

  特别的在PS/2协议中说在第一第二套扫描码里没有“Pause/Break”键的断码。当这个键按下时发送它的通码,当它释放时,什么都没有被发送。在第一套扫描码里Pause键的通码长达6个字节:$E1,$1D,$45,$E1,$9D,$C5。但是我在实际测试中发现Pause键的通码其实是前3个字节:$E1,$1D,$45,后3个字节$E1,$9D,$C5是Pause键的断码。至少在我的键盘上是这样。

  在PS/2协议中已经把所有三套扫描码集中所有的通码和断码做成了表格,具体的内容可以查阅相关的部份。在单元文件 MouseKeyboard.pas中我已经将第一套键盘扫描码定义成常量数组,其中还包括了键对应的字符。单元中有两个函数MKFindKeyCode 和MKFindKeyChar,可以用来对常量数组进行查找。

四、鼠标模拟

  标准的PS/2鼠标支持下面的输入:X(左右)位移、Y(上下)位移、左键、中键和右键。鼠标以一个固定的频率读取这些输入并更新不同的计数器然后标记出反映的移动和按键状态。有很多PS/2鼠标具有额外的输入比如微软的Intellimouse,它既支持标准输入也支持滚轮和两个附加的按键。

  标准的鼠标有两个计数器保持位移的跟踪:X位移计数器和Y位移计数器。可存放9位的2进制补码并且每个计数器都有相关的溢出标志。它们的内容连同三个鼠标按钮的状态一起以三字节移动数据包的形式发送给i8042。位移计数器表示从最后一次位移数据包被送往i8042后有位移量发生。标准的PS/2鼠标发送位移和按键信息给i8042采用如下的3字节数据包格式:

     Bit7   Bit6   Bit5   Bit4   Bit3 Bit2  Bit1  Bit0
    ┌────┬────┬────┬────┬──┬───┬───┬───┐
Byte 1 │Y 溢出位│X 溢出位│Y 符号位│X 符号位│置 1│中键位│右键位│左键位│
    ├────┴────┴────┴────┴──┴───┴───┴───┤
Byte 2 │            X 左右移位值,补码             │
    ├──────────────────────────────────┤
Byte 3 │            Y 上下移位值,补码             │
    └──────────────────────────────────┘

  位移计数器是一个9位二进制补码整数。它的最高位作为符号位出现在位移数据包的第一个字节里,这些计数器在鼠标读取输入发现有位移时被更新,这些值是自从最后一次发送位移数据包给i8042后位移的累计量(即最后一次包发给i8042后位移计数器被复位),位移计数器可表示的值的范围是-255 到+255。如果超过了范围,相应的溢出位就被设置,并且在复位前计数器不会增减。

  对标准的PS/2鼠标的一个流行的扩展是微软的Intellimouse。它包括支持五个鼠标按键和三个位移轴(左右、上下和滚轮)。这些附加特征要求使用4字节的位移数据包而不是标准3字节数据包。微软的Intellimouse使用4字节的位移数据包格式有两种情况分别如下:

1、三键带滚轮鼠标:

     Bit7   Bit6   Bit5   Bit4   Bit3 Bit2  Bit1  Bit0
    ┌────┬────┬────┬────┬──┬───┬───┬───┐
Byte 1 │Y 溢出位│X 溢出位│Y 符号位│X 符号位│置 1│中键位│右键位│左键位│
    ├────┴────┴────┴────┴──┴───┴───┴───┤
Byte 2 │            X 左右移位值,补码             │
    ├──────────────────────────────────┤
Byte 3 │            Y 上下移位值,补码             │
    ├──────────────────────────────────┤
Byte 4 │            Z 滚动移位值,补码             │
    └──────────────────────────────────┘

  Z位移是二进制补码表示滚轮的自上次数据报告以来的位移。有效值的范围在-8到+7,这意味着数值实际只有低四位有用,高四位仅用作符号扩展位。当Z小于0时表示向上滚动,当Z大于0时表示向下滚动。

2、五键带滚轮鼠标:

     Bit7   Bit6   Bit5   Bit4   Bit3 Bit2  Bit1  Bit0
    ┌────┬────┬────┬────┬──┬───┬───┬───┐
Byte 1 │Y 溢出位│X 溢出位│Y 符号位│X 符号位│置 1│中键位│右键位│左键位│
    ├────┴────┴────┴────┴──┴───┴───┴───┤
Byte 2 │            X 左右移位值,补码             │
    ├──────────────────────────────────┤
Byte 3 │            Y 上下移位值,补码             │
    ├────┬────┬────┬────┬──┬───┬───┬───┤
Byte 4 │ 置 0 │ 置 0 │第5键位 │第4键位 │ Z3 │ Z2 │ Z1 │ Z0 │
    └────┴────┴────┴────┴──┴───┴───┴───┘

  Z0~Z3是二进制补码用于表示从上次数据报告以来滚轮的位移量,有效范围从-8到+7;第4键位:置1表示第4键按下了,置0表示第4键没有按下;第5键位:置1表示第5键按下了,置0表示第5键没有按下。可以看出三键带滚轮鼠标和五键带滚轮鼠标这两种数据包格式是相互兼容的。一般现在使用的鼠标都是带滚轮的,所以使用的都是4字节的数据包格式。调用API函数GetSystemMetrics(SM_CMOUSEBUTTONS)可以返回当前鼠标的按键数,调用GetSystemMetrics(SM_MOUSEWHEELPRESENT)可以返回当前鼠标是否有带滚轮。通过调用这两个API函数可以判断当前使用的是3字节数据包还是4字节数据包。知道了数据包的格式就可以向i8042发送$D3命令模拟鼠标操作,模拟单击鼠标左键的程序如下:

//按下鼠标左键
SetByte($64,$D3); SetByte($60,$09);
SetByte($64,$D3); SetByte($60,$00);
SetByte($64,$D3); SetByte($60,$00);
SetByte($64,$D3); SetByte($60,$00); //三键带滚轮鼠标

//放开鼠标左键
SetByte($64,$D3); SetByte($60,$08);
SetByte($64,$D3); SetByte($60,$00);
SetByte($64,$D3); SetByte($60,$00);
SetByte($64,$D3); SetByte($60,$00); //三键带滚轮鼠标

  现在再来谈谈鼠标移动的模拟,鼠标移动的模拟其实是一个很头痛的问题。如果只是让鼠标随便动动那是很简单,但是要将鼠标在屏幕上的指针移动到指定坐标就不是那么容易的事件了。首先,鼠标位移计数器使用的是平面直角坐标系。也就说当鼠标向左移动时X小于0,当鼠标向右移动时X大于0,当鼠标向下移动时Y 小于0当鼠标向上移动时Y大于0。屏幕上的指针坐标使用的是计算机屏幕坐标系,对应于平面直角坐标系在X轴上是一致的,在Y轴上相差一个正负号。

  其次,决定位移计数器增减数量的参数叫分辨率,缺省的分辨率为:4计数单位每毫米。这就意味着鼠标位移计数器的位移量(以下简称鼠标位移量)同鼠标指针在屏幕上的象素位移量(以下简称指针位移量)并不一样。鼠标位移量同指针位移量的比值与鼠标移动速度的设置有关,通过“控制面板”中“鼠标”选项卡的“ 调整指针移动速度”可以对该值进行设置,这个值保存在注册表HKEY_CURRENT_USER\Control Panel\Mouse \MouseSensitivity中,通过修改注册表可以将这个值设置成带有小数点的,通过调用API函数SystemParametersinfo使用参数SPI_GETMOUSESPEED就可以获取该值。另外,使用第三方鼠标驱动程序也可以设置鼠标的移动速度,一般这类驱动程度都有带鼠标加速功能,这使得鼠标位移量同指针位移量的比值根本无法确定。

  当使用Windows自带的鼠标驱动程序,在鼠标选项卡中将指针移动速度设置在中间位置,注册表中MouseSensitivity的值为10,此时鼠标位移量同指针位移量的比值还是可以计算的,当鼠标位移量小于7时:指针位移量等于鼠标位移量;当鼠标位移量大于等于7时:指针位移量等于鼠标位移量的 2倍。在这种情况下的前20个比值如下,其中n为鼠标位移量,cX、cY为指针位移量:

  n,  cX,  cY
  1,   1,   1
  2,   2,   2
  3,   3,   3
  4,   4,   4
  5,   5,   5
  6,   6,   6
  7,  14,  14
  8,  16,  16
  9,  18,  18
10,  20,  20
11,  22,  22
12,  24,  24
13,  26,  26
14,  28,  28
15,  30,  30
16,  32,  32
17,  34,  34
18,  36,  36
19,  38,  38
20,  40,  40

  例如:让鼠标指针在屏幕上移动的程序如下:

//鼠标上移50象索
SetByte($64,$D3); SetByte($60,$08);
SetByte($64,$D3); SetByte($60,$00);
SetByte($64,$D3); SetByte($60,$19);
SetByte($64,$D3); SetByte($60,$00); //三键带滚轮

//鼠标左移50象索
SetByte($64,$D3); SetByte($60,$18);
SetByte($64,$D3); SetByte($60,$E7);
SetByte($64,$D3); SetByte($60,$00);
SetByte($64,$D3); SetByte($60,$00); //三键带滚轮

  另外我发现一些鼠标对于大的鼠标移动量(大于128或大于170)没有反应。可能是因为没有人可以用手把鼠标移动的这么快,所以不需要。这在模拟鼠标移动过程中是要注意的。由于没有很好的算法,在MouseKeyboard.pas中我使用了一个鼠标移动换算表来计算鼠标的移动量,这个表其实就是一个 255长度的Integer数组,里面记录了每当鼠标移动多少个单位,指针就会移动多少个象素。我还写了几个函数,提供对该表导入导出到ini格式文件中的功能,这样应该可以适应大多数的鼠标配置。我写了一个测试鼠标移动速度的程序,它可以测试当前鼠标位移量同指针位移量的比值,可以根据对角移动、水平移动、垂直移动的比值计算出鼠标移动换算表并保存到ini文件中。这个程序的下载见本楼末尾。

五、使用WinIo的优缺点

  使用WinIo的优点是不言而喻的,直接的硬件端口读写使得很多程序无法对其进行屏蔽。它的缺点也很头痛,首先就是兼容性的问题,直接读写硬件端口会使程序在一些配置的计算机上正常运行,而在另一些配置的计算机上无法运行。特别是USB鼠标,我的程序是在PS/2鼠标上测试的,可能对一部份USB鼠标不兼容。其次就是数据冲突的问题,在模拟操作的时候鼠标键盘仍然在工作,如果这时操作鼠标键盘可能会出现失控的现象。特别是鼠标。原因在于:当程序进行模拟操作的时候操作鼠标键盘,程序发送给i8042的数据包可能会与鼠标键盘发送给i8042的数据包相互交错在一起(因为每次只能发送1个字节),从而造成数据包混乱引起鼠标键盘的失控。我在一次测试时就碰了一下鼠标,鼠标指针就在屏幕上乱飞。最后一点缺点就是这种模拟是全局性,在模拟操作的过程中你什么都不能做,其实SendInput也是一样。

六、MouseKeyboard.pas的使用

MouseKeyboard.zip




  在上面的压缩包中包含了4个文件:MouseKeyboard.pas、WinIo.dll、WinIo.sys和WINIO.VXD,后面3个文件是WinIo的相关文件。把这4个文件放到项目同一个文件夹下,将MouseKeyboard添加到单元的uses段中就可以调用 MouseKeyboard.pas中的函数。值得注意的是当你的程序发布时WinIo.dll、WinIo.sys和WINIO.VXD这3个文件要和可执行文件放在同一个文件夹下,程序运行的时候会调用它们。当程序在一个“陌生”的计算机上运行时,最好调用一下MKTestConversion函数测试并保存当前计算机的鼠标移动换算表。使用上楼提供的鼠标移动测试程序生成ini文件也可以。

  在MouseKeyboard.pas中我定义了一个枚举类型的TMKKeyCode,里面有104个项对应不同的按键。我还定义了一个常量记录数组 MKKeyData,里面定义了104键的扫描码、通码长度、断码长度以及键对应的字符。在MouseKeyboard.pas中共有21个函数,使用说明如下:

1、function MKInitMouseKey: Boolean;
  这个函数会初始化WinIo,并对一些内部变量进行初始化,同时这个函数会尝试从当前文件夹中打开一个名为“Mouse.ini”的ini文件,并从文件的“Conversion”节中加载鼠标移动换算表,如果失败这个函数会调用MKInitConversion初始化一个默认的鼠标移动换算表。一般情况下不要调用这个函数,在单元被加载的时候会自动调用这个函数来完成初始化。

2、procedure MKFreeMouseKey;
  释放驱动程序卸载WinIo,一般情况下不要调用这个函数,在单元被卸载的时候会自动调用这个函数来完成卸载释放。

3、function MKSendData(const Data : TMKPacketData; Len : Byte; Typ : TMKDataType): Boolean;
  向指定的键盘鼠标端口发送数据包,返回是否成功。数据包最长只能是4个字节,Len为数据包的字节长度。这个函数内嵌了一个上文提到的SetByte函数用以对端口的字节发送。

4、function MKFindKeyCode(Code : TMKKeyCode): Integer;
  查找指定键码在MKKeyData中的id,找到返回id,没找到返回-1。

5、function MKFindKeyChar(Char : AnsiChar): Integer;
  查找指定字符在MKKeyData中的id,找到返回id,没找到返回-1。

6、function MKKeyDown(Code : TMKKeyCode): Boolean;
  按下指定的键,返回是否成功。

7、function MKKeyUp(Code : TMKKeyCode): Boolean;
  放开指定的键,返回是否成功。

8、function MKKeyPress(Code : TMKKeyCode; Interval : Cardinal): Boolean;
  按下并放开指定的键,返回是否成功。Interval为按下和放开之间的时间间隔,单位毫秒。注意:本函数不支持重复机打,即无论Interval设的多么大都只有一次按键。

示范程序,组合键Ctrl+A如下:
MKKeyDown(kcLCtrl); //按下Ctrl
MKKeyPress(kcA);    //击键A
MKKeyUp(kcLCtrl);   //放开Ctrl

9、function MKKeyInput(const Text : String; Interval : Cardinal): Boolean;
  模拟键盘输入指定的文本,返回是否成功。文本中只能是单字节字符(#32~#126)、Tab(#9)键和回车键(#13),即可以从键盘上输入的字符,不能是汉字,其它字符会被忽略。Interval为按下和放开键之间的时间间隔,单位毫秒。

10、function MKMouseDown(Code : TMKMouseCode): Boolean;
  按下鼠标的指定键,返回是否成功。

11、function MKMouseUp(Code : TMKMouseCode): Boolean;
  放开鼠标的指定键,返回是否成功。

12、function MKMouseClick(Code : TMKMouseCode; Interval : Cardinal): Boolean;
  单击鼠标的指定键,返回是否成功。Interval为按下和放开键之间的时间间隔,单位毫秒。

13、function MKMouseWheel(cZ : Integer): Boolean;
  滚动鼠标的滚轮,返回是否成功。cZ为鼠标滚轮滚动移位值,非象素值。cZ有效值的范围在-8到+7,当cZ小于0时向上滚动,当cZ大小0向下滚动。

14、function MKMouseMove(cX,cY : Integer): Boolean;
  移动鼠标指针在屏幕上的位置,返回是否成功。cX和cY为鼠标位移计数器的值,非象素值。当cX小于0时向左移动,当cX大小0时向右移动,当cY小于0时向下移动,当cY大于0时向上移动。cX和cY的值的范围是-255到+255。

15、procedure MKInitConversion(var Con : TMKMoveConversion);
  初始化一个鼠标移动换算表,假设鼠标的移动速度设为中等,没有使用其它第三方的鼠标加速驱动程序。

16、function MKTestConversion(var Con : TMKMoveConversion; MaxMove : Integer): Boolean;
  测试并确认鼠标移动换算表,返回是否成功。MaxMove为测试时的鼠标移动的最大值,这个函数会对鼠标进行对角移动,并记录下鼠标指针的实际移动象素量,计算出鼠标移动换算表。在测试期间不能操作鼠标,否则可能会出现鼠标失控,测试不准的情况。

17、function MKConversionToIni(const Con : TMKMoveConversion; IniFile : TIniFile; const Section : String): Boolean;
  将鼠标移动换算表导出到ini文件的指定节中,返回是否成功。

18、function MKConversionFromIni(var Con : TMKMoveConversion; IniFile : TIniFile; const Section : String): Boolean;
  从ini文件的指定节中导入鼠标移动换算表,返回是否成功。

19、procedure MKLoadConversion(const Con : TMKMoveConversion);
  导入一个鼠标移动换算表作为默认换算表。

20、function MKMouseMoveTo(X,Y : Integer; const Con : TMKMoveConversion; MaxMove : Integer; Interval : Cardinal): Boolean;
21、function MKMouseMoveTo(X,Y : Integer; MaxMove : Integer; Interval : Cardinal): Boolean;
  这是重载的两个函数,函数可以将鼠标指针移动到指定位置,并返回是否成功。X和Y为象素值,X和Y的值的范围不能超出屏幕。MaxMove为移动时的 cX和cY的最大值。Interval为两次移动之间的时间间隔。第1个函数使用指定的鼠标移动换算表Con进行移动坐标的计算,第2函数使用默认的鼠标移动换算表进行移动坐标的计算。如果鼠标的移动速度没有设为中等,或者使用其它第三方的鼠标加速驱动程序,可能会使该函数无法将鼠标指针移动到准确的位置,会出现一定的误差。一些程序对鼠标移动速度敏感,当鼠标移动太快时无法对鼠标做出反应,适当的设置MaxMove值可以解决这个问题。

示范程序,拖放到指定位置如下:
MKMouseDown(mcLeft);    //按下鼠标左键
MKMouseMoveTo(780,300); //移动到指定位置
MKMouseUp(mcLeft);      //放开鼠标左键

转载于:https://www.cnblogs.com/honeynm/p/4095420.html

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值