Windows内核-7-IRP和派遣函数

2021/9/6 7:06:52

本文主要是介绍Windows内核-7-IRP和派遣函数,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

Windows内核-7-IRP和派遣函数

IRP以及派遣函数是Windows中非常重要的概念。IRP 是I/O Request Pocket的简称,意思是I/O操作的请求包,Windows中所有User和Kernel之间的交流都会被封装成一个IRP结构体,然后不同的IRP会被派遣到不同的派遣函数里面,通过派遣函数来实现I/O操作。

IRP

typedef struct _IRP {
  CSHORT                    Type;
  USHORT                    Size;
  PMDL                      MdlAddress;
  ULONG                     Flags;
  union {
    struct _IRP     *MasterIrp;
    __volatile LONG IrpCount;
    PVOID           SystemBuffer;
  } AssociatedIrp;
  LIST_ENTRY                ThreadListEntry;
  IO_STATUS_BLOCK           IoStatus;
  KPROCESSOR_MODE           RequestorMode;
  BOOLEAN                   PendingReturned;
  CHAR                      StackCount;
  CHAR                      CurrentLocation;
  BOOLEAN                   Cancel;
  KIRQL                     CancelIrql;
  CCHAR                     ApcEnvironment;
  UCHAR                     AllocationFlags;
  union {
    PIO_STATUS_BLOCK UserIosb;
    PVOID            IoRingContext;
  };
  PKEVENT                   UserEvent;
  union {
    struct {
      union {
        PIO_APC_ROUTINE UserApcRoutine;
        PVOID           IssuingProcess;
      };
      union {
        PVOID                 UserApcContext;
#if ...
        _IORING_OBJECT        *IoRing;
#else
        struct _IORING_OBJECT *IoRing;
#endif
      };
    } AsynchronousParameters;
    LARGE_INTEGER AllocationSize;
  } Overlay;
  __volatile PDRIVER_CANCEL CancelRoutine;
  PVOID                     UserBuffer;
  union {
    struct {
      union {
        KDEVICE_QUEUE_ENTRY DeviceQueueEntry;
        struct {
          PVOID DriverContext[4];
        };
      };
      PETHREAD     Thread;
      PCHAR        AuxiliaryBuffer;
      struct {
        LIST_ENTRY ListEntry;
        union {
          struct _IO_STACK_LOCATION *CurrentStackLocation;
          ULONG                     PacketType;
        };
      };
      PFILE_OBJECT OriginalFileObject;
    } Overlay;
    KAPC  Apc;
    PVOID CompletionKey;
  } Tail;
} IRP;

 

 

IRP这种机制类似于Windows的消息机制,驱动在接受到IRP之后会根据IRP的不同类型分配给不同类型的派遣函数来处理IRP。

IRP不是单独的,只要创建了IRP就会跟着创建IRP的I/O栈,有一个栈是给内核驱动用的:

img

驱动需要调用IoGetCurrentIrpStackLocation函数来获取内驱驱动对应的I/O栈。

例如:

auto stack = IoGetCurrentIrpStackLocation(Irp)

该API返回一个IO_STACK_LOCATION 结构体:

typedef struct _IO_STACK_LOCATION {
  UCHAR                  MajorFunction;
  UCHAR                  MinorFunction;
  UCHAR                  Flags;
  UCHAR                  Control;
  union {
        ...
        ...
        ...
        ...
        ...
  } Parameters;
  PDEVICE_OBJECT         DeviceObject;
  PFILE_OBJECT           FileObject;
  PIO_COMPLETION_ROUTINE CompletionRoutine;
  PVOID                  Context;
} IO_STACK_LOCATION, *PIO_STACK_LOCATION;

这个结构体有两个重要的属性,分别是MajorFunction和MinorFunction,分别记录了IRP的主类型和子类型,操作系统根据MajorFunction来将IRP派遣到不同的派遣函数里面去处理,而还可以继续利用MinorFunction来判断更多的内容,基本上MajorFunction用的比较多。

设置IRP和派遣函数

在DriverEntry驱动对象里面有一个函数指针数组MajorFunction就是来记录派遣函数的地址,来让派遣函数和IRP一一对应,这个数组里面采用宏定义来将每个 IRP和派遣函数通过数组的索引来一一对应:

#define IRP_MJ_CREATE                   0x00
#define IRP_MJ_CREATE_NAMED_PIPE        0x01
#define IRP_MJ_CLOSE                    0x02
#define IRP_MJ_READ                     0x03
#define IRP_MJ_WRITE                    0x04
#define IRP_MJ_QUERY_INFORMATION        0x05
#define IRP_MJ_SET_INFORMATION          0x06
#define IRP_MJ_QUERY_EA                 0x07
#define IRP_MJ_SET_EA                   0x08
#define IRP_MJ_FLUSH_BUFFERS            0x09
#define IRP_MJ_QUERY_VOLUME_INFORMATION 0x0a
#define IRP_MJ_SET_VOLUME_INFORMATION   0x0b
#define IRP_MJ_DIRECTORY_CONTROL        0x0c
#define IRP_MJ_FILE_SYSTEM_CONTROL      0x0d
#define IRP_MJ_DEVICE_CONTROL           0x0e
#define IRP_MJ_INTERNAL_DEVICE_CONTROL  0x0f
#define IRP_MJ_SHUTDOWN                 0x10
#define IRP_MJ_LOCK_CONTROL             0x11
#define IRP_MJ_CLEANUP                  0x12
#define IRP_MJ_CREATE_MAILSLOT          0x13
#define IRP_MJ_QUERY_SECURITY           0x14
#define IRP_MJ_SET_SECURITY             0x15
#define IRP_MJ_POWER                    0x16
#define IRP_MJ_SYSTEM_CONTROL           0x17
#define IRP_MJ_DEVICE_CHANGE            0x18
#define IRP_MJ_QUERY_QUOTA              0x19
#define IRP_MJ_SET_QUOTA                0x1a
#define IRP_MJ_PNP                      0x1b

这些每一个都有特定的意思,比较常用的有:

IRP_MJ_CREATE   //创建    和CreateFile对应
IRP_MJ_READ     //读取    和ReadFile对应
IRP_MJ_WRITE    //写入    和WriteFile对应
IRP_MJ_CLOSE    //关闭    和CloseFile对应

所有的派遣函数都有一个原型:

typedef NTSTATUS DRIVER_DISPATCH (
_In_ PDEVICE_OBJECT DeviceObject,
_Inout_ PIRP Irp);
//typedef可以省去,名字自己取

IRP传递流程

I/O系统是以设备对象为中心,而不是以驱动对象为中心的。IRP可以在设备对象中传来传去:

img

但是不管是在怎么传递,最后都必须把这个IRP请求结束,给它完成。

完成必备操作:

NTSTATUS SysMonRead(PDEVICE_OBJECT, PIRP Irp) {
    Irp->IoStatus.Status = status;//设置状态
    Irp->IoStatus.Information = count;//统计处理的字节数
    IoCompleteRequest(Irp, IO_NO_INCREMENT);//完成IO操作的必须返回函数
    return status;
}

IoCompleteRequest这个API需要返回一个IRP大家应该没问题,但是第二个参数就比较复杂了,第二个参数是返回之后的线程级,这是因为一个线程在执行IRP的时候会等待。

这里以ReadFile为例,ReadFile函数会调用ntdll中的NtReadFile函数,然后ntdll中的NtReadFile函数又调用内核的NtReadFile函数,然后内核的NtReadFile函数创建关于Read这个类型的IO_MJ_READ类型的IRP再将它发送到内核的对应IRP的派遣函数里面,然后等待IRP对应派遣函数执行完之后再返回。所以需要设置当返回后线程又重新执行了的线程级。

User操作设备对象

前面我们说了,User只能通过符号链接来操作设备对象进行I/O交互,所以User只需要在User的API下面把通常使用的文件路径改成符号链接就好了。

比如:

CreateFile(L"\\\\.\\test", GENERIC_READ, 0, nullptr, OPEN_EXISTING, 0, nullptr)

注意C语言是有转义字符的,所以这个第一个参数看起来怪怪的。

读写方式

IRP和派遣函数是用来进行I/O操作的,I/O操作就是读写,所以很自然的有了这个读写方式的环境。

一般读写方式有三种:缓冲I/O,直接I/O,和其它方式。三种方式对应的Flags分别是DO_BUFFERED_IO、DO_DIRECT_IO和0。都在DeviceObject设备对象里面添加。

例如:

DeviceObject->Flags |= 0;

注意:并不是直接赋值

缓冲I/O

读写操作通常是WriteFile和ReadFile这类API,这类API会要求添加缓冲区指针和缓冲区大小作为函数参数,然后WriteFile/ReadFile将这段内存的数据传递给驱动程序。由于这段缓冲区是用户模式的内存地址,所以直接使用会非常危险,因为Windows是多进程操作系统,有可能在你用的时候就被别的进程改了,所以直接使用非常危险。

而缓冲I/O的原理就是,在内核模式下开辟一个缓冲空间来存储该缓冲区内容,然后读取的时候先放到内核缓冲区里面,再来进行复制和赋值操作。

比如:当调用ReadFile/WriteFile时,操作系统提供内核模式下的一段地址来存放User在WriteFile里面配置的Buffer,然后当IRP请求结束,内核区域的Buffer就会被拷贝到User/Kernel里面。

1 I/O 管理器从非分页池中分配一个与用户缓冲区大小相同的缓冲区。它将指向这个新缓冲区的指针存储在 IRP 的 AssociatedIrp->SystemBuffer 成员中。 (缓冲区大小可以在当前 I/O 堆栈位置的 Parameters.Read.Length 或 Parameters.Write.Length 中找到。)

2 对于写请求,I/O 管理器将用户的缓冲区复制到系统缓冲区。

3 现在才调用驱动程序的调度例程。驱动程序可以直接使用系统缓冲区指针而无需任何检查,因为缓冲区在系统空间中(它的地址是绝对的 - 从任何进程上下文中都一样),并且在任何 IRQL 中,因为缓冲区是从非分页池分配的,所以它不能被调出。

4 一旦驱动完成IRP(IoCompleteRequest),I/O管理器(对于读请求)将系统缓冲区复制回用户的缓冲区(复制的大小由IRP中设置的IoStatus.Information字段决定)司机)。

5 最后,I/O 管理器释放系统缓冲区。

 

图文讲解一下ReadFile的整体流程:(WriteFile以此类推)

 

 

这里是User下一个Buffer缓冲区:

img

操作系统知道是缓冲I/O后开辟了一个内核缓冲区,然后由WriteFile/ReadFile创建的Irp->AssociatedIrp.SystemBuffer来存储记录

img

然后驱动访问系统空间往里面写东西

 

img

操作系统将系统空间的内容拷贝到User缓冲区里。

img

拷贝完了,释放系统空间内存。

img

 

派遣函数中可以通过Irp的Parameters.Read.Length来知道User请求多少字节,也可以通过Parameters.Write.Length来知道要写入多少字节,但是真正执行了多少是通过Irp的IoStatus.Information字段来返回,所以WriteFile/ReadFile函数中各个参数是意义就可以解释通透了。

 

但是这样的缺点是会造成内存空间开销,因为内核空间是公有的大家都得用,而且由于大量数据的复制也会影响效率,所以针对比较小的内容采用缓冲I/O可以采取这种办法,别的还是用直接I/O吧。

//缓冲I/O的例子:
NTSTATUS SysMonRead(PDEVICE_OBJECT, PIRP Irp) {
    auto status = STATUS_SUCCESS;
    auto stack = IoGetCurrentIrpStackLocation(Irp);//得到当前的IRP栈
    auto ulReadLength = stack->Parameters.Read.Length;//得到要读取的字节数
​
​
    //完成IRP操作
    Irp->IoStatus.Status = status;//设置IRP完成状态
    Irp->IoStatus.Information = ulReadLength;//设置实际完成的IRP字节数
    memset(Irp->AssociatedIrp.SystemBuffer, 0xAA, ulReadLength);//拷贝内容到系统地址空间
    IoCompleteRequest(Irp, IO_NO_INCREMENT);//完成IRP操作
    return status;
}

User下的就不用写了吧,hh,偷个懒。

直接I/O

直接I/O用了另一个办法来规避风险。

1 I/O 管理器确保用户的缓冲区有效。

img

2 将其映射到物理内存中,然后将缓冲区锁定在内存中,因此在另行通知之前无法将其调出。这解决了缓冲区访问的问题之一——不会发生页面错误,因此在任何 IRQL 中访问缓冲区都是安全的。

img

3 I/O 管理器构建内存描述符列表 (MDL),这是一种知道缓冲区如何映射到 RAM 的数据结构。该数据结构的地址存储在 IRP 的 MdlAddress 字段中。

img

4 此时,驱动程序调用派遣函数。因为用户的缓冲区被锁定在 RAM 中,不能从任意线程访问。所以当驱动程序需要访问缓冲区时,它必须调用将同一用户缓冲区映射到系统地址的函数。由于该地址被锁进了系统中所以,该地址在任何进程上下文中都是有效的。所以本质上,我们得到了到同一个缓冲区的两个映射。一个来自原始地址(仅在请求者进程的上下文中有效),另一个来自系统空间,始终有效。要调用的 API 是 MmGetSystemAddressForMdlSafe,传递由 I/O 管理器构建的 MDL。返回值是系统地址。

img

5 User或Kernel进行缓冲区修改:

img

 

6结束: 一旦驱动程序完成请求,I/O 管理器删除第二个映射(到系统空间),释放 MDL 并解锁用户缓冲区,因此它可以像任何其他用户模式内存一样正常分页。

img

 

这里全程没有用到拷贝,完完全全就是通过地址映射User->内存,然后Kernel修改内存等同于直接修改User的缓冲区。

直接I/O在用户态下:

直接I/O操作系统会在User下用MDL这个数据结构来存储相关信息:

 

 

大小保存在MDL->ByteCount里,然后这段虚拟内存的首地址在MDL->StartVa里,实际缓冲区的首地址相当于起始地址的偏移地址在ByteOffset里,DDK里面封装了几个宏来方便用

#define MmGetMdlByteCount(Mdl) ((Mdl)->MyteCount)
#define MmGetMdlByteOffset(Mdl) ((Mdl)->ByteOffset)
#define MmGetMdlVirtualAddress(Mdl) (PVOID)((PCHAR)(Mdl)->StartVa)+(Mdl)->ByteOffset)

直接I/O在Kernel下

Kernel下比较简单粗暴直接用了一个宏来得到MDL在内核模式下的地址映射

MmGetSystemAddressForMdlSafe();

然后通过前面缓冲I/O用到的一些Read的length或者Write的Length直接通过这个地址拷贝就行了。

直接I/O例子

 

NTSTATUS TestRead(IN PDEVICE_OBJECT pDevObj,
                                 IN PIRP pIrp) 
{
    KdPrint(("Enter HelloDDKRead\n"));
​
    PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;
    NTSTATUS status = STATUS_SUCCESS;
​
    PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
​
    ULONG ulReadLength = stack->Parameters.Read.Length;
    KdPrint(("ulReadLength:%d\n",ulReadLength));
​
    ULONG mdl_length = MmGetMdlByteCount(pIrp->MdlAddress);
    PVOID mdl_address = MmGetMdlVirtualAddress(pIrp->MdlAddress);
    ULONG mdl_offset = MmGetMdlByteOffset(pIrp->MdlAddress);
    
    KdPrint(("mdl_address:0X%08X\n",mdl_address));
    KdPrint(("mdl_length:%d\n",mdl_length));
    KdPrint(("mdl_offset:%d\n",mdl_offset));
​
    if (mdl_length!=ulReadLength)
    {
        pIrp->IoStatus.Information = 0;
        status = STATUS_UNSUCCESSFUL;
    }else
    {
        //ÓÃMmGetSystemAddressForMdlSafe
        PVOID kernel_address = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress,NormalPagePriority);
        KdPrint(("kernel_address:0X%08X\n",kernel_address));
        memset(kernel_address,0XAA,ulReadLength);
        pIrp->IoStatus.Information = ulReadLength;  // bytes xfered
    }
    
    pIrp->IoStatus.Status = status;
    
    IoCompleteRequest( pIrp, IO_NO_INCREMENT );
    KdPrint(("Leave TestRead\n"));
​
    return status;
}

其它

其它的I/O读写方式用得非常少,而且很麻烦,这里就不介绍了。

 

IO设备控制操作

除了常用的ReadFile,WriteFile,CreateFile,CloseFile这类操作外,还可以通过另一个API DeviceIoControl来操作设备。DeviceIoControl会创建一个IRP_MJ_DEVICE_CONTROL类型的IRP,别的和其它的IRP以及派遣函数是一样的。

DeviceIoControl和驱动交互

DeviceIoControl除了可以被用来读写还可以用在其它操作上。

BOOL DeviceIoControl(
  HANDLE       hDevice,         //设备对象句柄
  DWORD        dwIoControlCode, //控制码
  LPVOID       lpInBuffer,      //输入缓冲区
  DWORD        nInBufferSize,   //输入缓冲区大小
  LPVOID       lpOutBuffer,     //输出缓冲区
  DWORD        nOutBufferSize,  //输出缓冲区大小
  LPDWORD      lpBytesReturned, //实际返回字节数   这个对应这Irp->IoStatus.Information
  LPOVERLAPPED lpOverlapped     //是否OVERLAP操作
);

dwIoControlCode是I/O控制码,也叫IOCTL值。

Windows有一些内置的I/O控制码可以选用:(在官方文档上可以获取:DeviceIoControl function (ioapiset.h) - Win32 apps | Microsoft Docs

 

同样的我们也可以自己定义,Windows提供了控制码的定义规定:

 

 

 

在ddk头文件里面有一个宏定义方便我们使用:

#define CTL_CODE( DeviceType, Function, Method, Access ) (((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method))
​

DeviceType(31-16):设备对象的类型,这个和IoCreateDevice时设置的设备对象类型一样。

Access(15-14):访问权限,如果没有特殊要求,一般采用FILE_ANY_ACCESS

Funciton:0X000~0X7FF由微软保留,0x800~0xFFF由程序员自己定义

Method:操作模式,以下四种之一:

1 METHOD_BUFFERED: 使用缓冲区方式操作

2 METHOD_IN_DIRECT: 使用直接写方式操作

3 METHOD_OUT_DIRECT: 使用直接读方式操作

4 METHOD_NEITHER: 使用其它方式操作

 

缓冲内存模式IOCTL

DeviceIoControl的缓冲读取和前面的缓冲I/O有一点不一样,前面的流程都一样,都是复制到系统进程的缓冲区里面,然后这个缓冲区地址可以由Irp->AssociatedIrp.SystemBuffer来获取。不一样的是DeviceIoControl会传入输入和输出两个缓冲区,但是两个缓冲区对应的是一个地址,因为如果是输入就是输出到Kernel里,Kernel可以进行操作后,再把这个缓冲区修改了然后作为输出,输出到User里。

首先定义一个自己的IOCTL码

#define IOCTL_TEST CTL_CODE(FILE_DEVICE_UNKNOWN,0X800,METHOD_BUFFERED,FILE_ANY_ACCESS)
​

在内核状态下要使用IOCTL需要添加ntddk头文件,在User下需要添加winioctl.h头文件

NTSTATUS DeviceIoControl(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
    auto stack = IoGetCurrentIrpStackLocation(pIrp);
​
    auto status = STATUS_SUCCESS;
​
    //得到输入缓冲区大小
    auto cbIn = stack->Parameters.DeviceIoControl.InputBufferLength;
​
    //得到输出缓冲区大小
    auto cbOut = stack->Parameters.DeviceIoControl.OutputBufferLength;
​
    //得到IOCTL控制码
    auto code = stack->Parameters.DeviceIoControl.IoControlCode;
    ULONG info = 0;
    switch (code)
    {
        case IOCTL_TEST1:
        {
        //处理输入给内核的缓冲区内容
        UCHAR* InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
        for (ULONG i = 0; i < cbIn; i++)
        {
            KdPrint(("%x\n", InputBuffer[i]));
        }
​
        //处理输出给User的缓冲区内容
        UCHAR* OutputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
        memset(OutputBuffer, 0xAA, cbOut);
        info = cbOut;
        break;
        }
        default:
        {
            status = STATUS_INVALID_VARIANT;
            break;
        }
​
    }
​
    pIrp->IoStatus.Information = info;
    pIrp->IoStatus.Status = status;
​
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
​
}

 

直接内存模式IOCTL

直接内存模式IOCTL也和直接I/O有稍许区别,直接IOCTL中的输入缓冲区就是前面的缓冲内存模式下的缓冲区,直接开辟一个系统缓冲区,但是输出缓冲区就是前面的直接I/O,通过地址映射来得到的地址。所以直接内存模式的IOCTL需要分两种来处理,一种是输入缓冲区当缓冲I/O处理,另一种是输出缓冲区当直接I/O来处理。

这里需要说明一下直接模式的

METHOD_IN_DIRECT: 使用直接写方式操作

METHOD_OUT_DIRECT: 使用直接读方式操作

这两种方式的区别,在调用DeviceIoControl的时候是会指定打开的模式是只读还是只写,还是可读可写,就对应着这两种,如果是只读,那么只有METHOD_IN_DIRECT编写的IOCTL控制代码才会识别才会有用,以此类推。

直接内存模式IOCTL的例子:

1 先创建IOCTL

#define IOCTL_TEST1 CTL_CODE(FILE_DEVICE_UNKNOWN,0X801,METHOD_IN_DIRECT,FILE_ANY_ACCESS)
​

2 编写对应IOCTL的派遣函数

NTSTATUS DeviceIoControl(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
    auto stack = IoGetCurrentIrpStackLocation(pIrp);
​
    auto status = STATUS_SUCCESS;
​
    //得到输入缓冲区大小
    auto cbIn = stack->Parameters.DeviceIoControl.InputBufferLength;
​
    //得到输出缓冲区大小
    auto cbOut = stack->Parameters.DeviceIoControl.OutputBufferLength;
​
    //得到IOCTL控制码
    auto code = stack->Parameters.DeviceIoControl.IoControlCode;
    ULONG info = 0;
    switch (code)
    {
        case IOCTL_TEST1:
        {
            //处理输入给内核的缓冲区内容
            UCHAR* InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
            for (ULONG i = 0; i < cbIn; i++)
            {
                KdPrint(("%x\n", InputBuffer[i]));
            }
​
            //处理输出给User的缓冲区内容
            UCHAR* OutputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
            memset(OutputBuffer, 0xAA, cbOut);
            info = cbOut;
            break;
        }
        case IOCTL_TEST2:
        {
            //当是IOCTL_TEST2的IOCTL时的代码逻辑
            auto InputBuffer = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
            for (int i = 0; i < cbIn; i++)
            {
                KdPrint(("%X\n", InputBuffer[i]));
            }
​
            auto OutputBuffer = (UCHAR*)MmGetSystemAddressForMdlSafe(pIrp->MdlAddress, NormalPagePriority);
            memset(OutputBuffer, 0xAA, cbOut);
            info = cbOut;
            break;
        }
        default:
        {
            status = STATUS_INVALID_VARIANT;
            break;
        }
​
    }
​
    pIrp->IoStatus.Information = info;
    pIrp->IoStatus.Status = status;
​
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
​
}

 

其它内存模式IOCTL

其它模式用的很少,而且很麻烦,这里也不介绍了。

 

 

总结

不管是DeviceIoControl还是ReadFile,WriteFile其实都是用作User和Kernel交互的API,其中的IRP是自带的数据结构体,里面保存了要交互的东西的信息。DeviceIoControl更像输入东西给Kernel让Kernel通过根据输入的内容和控制码来执行命令的一个东西,通过I/O来控制Kernel执行一些代码流程;而ReadFille/WriteFile可能用得更多的是在单纯的交互数据上面。

 



这篇关于Windows内核-7-IRP和派遣函数的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程