IRP、IO_STACK_LOCATION、文件三种读写方式(buffer/driect/other)、DeviceIoControl
IRP
上层应用程序和底层驱动程序通信时,应用程序会发出I/O请求,操作系统将I/0请求转化为相应的IRP数据,不同类型的IRP根据类型传递给不同的派遣函数
IRP有两个基本属性,一个是MagorFunction,一个是MinorFunction,分别记录IRP的主类型和子类型,操作系统根据MajorFunction将IRP派遣到不同的派遣函数中,在派遣函数中还可以判断这个IRP属于哪个MinorFunction
一般来说,NT式驱动和WDM驱动都是在DriverEntry中注册派遣函数的,在驱动对象中,有个函数指针数组MajorFunction,每个元素记录了一个函数地址,通过设置这个数组,可以把IRP类型和派遣函数关联起来,对于没有设备的IRP类型,系统默认这些IRP类型与IopInvalidDeviceRequest关联
- NTSTATUS
- IopInvalidDeviceRequest(
- IN PDEVICE_OBJECT DeviceObject,
- IN PIRP Irp
- )
- /*++
- Routine Description:
- This function is the default dispatch routine for all driver entries
- not implemented by drivers that have been loaded into the system. Its
- responsibility is simply to set the status in the packet to indicate
- that the operation requested is invalid for this device type, and then
- complete the packet.
- Arguments:
- DeviceObject - Specifies the device object for which this request is
- bound. Ignored by this routine.
- Irp - Specifies the address of the I/O Request Packet (IRP) for this
- request.
- Return Value:
- The final status is always STATUS_INVALID_DEVICE_REQUEST.
- --*/
- {
- UNREFERENCED_PARAMETER( DeviceObject );
- //
- // Simply store the appropriate status, complete the request, and return
- // the same status stored in the packet.
- //
- if ((IoGetCurrentIrpStackLocation(Irp))->MajorFunction == IRP_MJ_POWER) {
- PoStartNextPowerIrp(Irp);
- }
- Irp->IoStatus.Status = STATUS_INVALID_DEVICE_REQUEST;
- IoCompleteRequest( Irp, IO_NO_INCREMENT );
- return STATUS_INVALID_DEVICE_REQUEST;
- }
在进入DriverEntry之前,操作系统就会把IopInvalidDeviceRequest的地址填满整个MajorFunction数组,IRP和派遣函数的联系如下:
IRP的概念类似于Windows应用程序中的消息,不同的消息会被分发到不同的消息处理函数中,如果没有对应的处理函数,它会进入系统默认的消息处理函数中
IRP类似,文件I/O的相关函数,如CreateFile,ReadFile,WriteFile,CloseHandle会使操作系统产生出IRP_MJ_CREATE,IRP_MJ_READ,IRP_MJ_WRITE,IRP_MJ_CLOSE等不同的IRP,将把IRP传送到相应驱动的相应派遣函数中.
对派遣函数的最简单处理是:把IRP的状态设置为成功,然后结束IRP的请求,并让派遣函数返回成功,结束IRP请求使用函数IoCompleteRequest
- #pragma PAGEDCODE
- NTSTATUS HelloDDKDispatchRoutine(IN PDEVICE_OBJECT pDevObj,
- IN PIRP pIrp)
- {
- KdPrint(("Enter HelloDDKDispatchRoutine\n"));
- NTSTATUS status = STATUS_SUCCESS;
- // 设置IRP完成状态
- pIrp->IoStatus.Status = status;
- // 设置IRP操作了多少字节
- pIrp->IoStatus.Information = 0; // bytes xfered
- // 结束IRP请求
- IoCompleteRequest( pIrp, IO_NO_INCREMENT );
- KdPrint(("Leave HelloDDKDispatchRoutine\n"));
- return status;
- }
IoCompleteRequest的第二个参数是指被阻塞的线程以何种优先级恢复运行,一般情况下,优先级设置为IO_NO_INCREMENT
通过设备链接打开设备
在编写程序时,可以把符号链接的写法稍微改一下,把前面的\??\改为\\.\,如\\.\HellloDDK
- int _tmain(int argc, _TCHAR* argv[])
- {
- // 打开设备句柄,会触发IRP_MJ_CREATE
- HANDLE hDevice = CreateFileA
- ("\\\\.\\HelloDDK",
- GENERIC_READ | GENERIC_WRITE,
- 0, //非共享
- NULL,
- OPEN_EXISTING,
- FILE_ATTRIBUTE_NORMAL,
- NULL
- );
- if (INVALID_HANDLE_VALUE == hDevice)
- {
- printf("Fail to open file handle\n");
- }
- else
- {
- printf("hDevice:0x%08x\n", hDevice);
- }
- CloseHandle(hDevice);
- system("pause");
- return 0;
- }
编写一个更通用的派遣函数
驱动对象会创建一个个的设备对象,并把这些设备对象叠成一个垂直结构,这种垂直结构很像栈,所以被称为设备栈
IRP会被操作系统发送到设备栈的顶层,如果顶层的设备对象的派遣函数结束了 IRP的请求,则这此I/0请求结束,如果没有结束,操作系统会把IRP转发到设备栈的下一层设备处理,如果这个设备的派遣函数依然没有结束IRP请求,则会继续向下层设备转发,
因此,一个IRP可能被转发多次,为了记录IRP在每层设备中做的操作,IRP会有一个IO_STACK_LOCATION数组,数组的元素数应大于IRP穿越过的设备数,每个IO_STACK_LOCATION元素记录着对应设备中做的操作,对于本层设备对应的IO_STACK_LOCATION,可以通过
#define IoGetCurrentIrpStackLocation( Irp ) ( (Irp)->Tail.Overlay.CurrentStackLocation )
来得到,IO_STACK_LOCATION结构中记录了IRP的类型,即IO_STACK_LOCATION中的MajorFunction子域
- PIO_STACK_LOCATION isl = IoGetCurrentIrpStackLocation(pIrp);
- KdPrint(("IO_STACK_LOCATION.MajorFunction:%u\n", isl->MajorFunction));
设备对象有三种读写方式:缓冲区方式读写,直接方式读写,其他方式读写,FLAG分别对应为DO_BUFFERED_IO, DO_DIRECT_IO和0 缓冲区方式
操作系统把应用程序提供缓冲区的数据复制到内核模式下的地址中,这样,无论操作系统怎么切换进程,内核模式地址不会改变,IRP的派遣函数将会对内核模式下缓冲区操作,而不是操作用户模式地址的缓冲区,这样做优点是简单解决了将用户地址传入驱动的问题,缺点是需要用户模式和内核模式之间复制数据,影响了运行效率
以缓冲区方式读写设备,操作系统会分配一段内核模式下的内存,这段内存大小等于ReadFile或WriteFiler指定的字节数,并且ReadFile或WriteFile创建的IRP的AssociatedIrp.SystemBuffer子域会记录这个地址,当IRP请求结束时(一般由IoCompleteRequest函数结束IRP),这段内存会被复制到ReadFile提供的缓冲区中
以缓冲区方式读写设备,都会发生用户模式地址和内核模式地址的数据复制,复制的过程由操作系统负责,用户模式地址由ReadFile或WriteFile提供,内核模式地址由操作系统负责分配和回收
测试代码:
Driver:
- #pragma PAGEDCODE
- NTSTATUS HelloDDKRead(IN PDEVICE_OBJECT pDevObj,
- IN PIRP pIrp)
- {
- KdPrint(("Enter HelloDDKRead\n"));
- NTSTATUS status = STATUS_SUCCESS;
- // 得到当前堆栈
- PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
- // 得到需要读设备的字节数
- ULONG ulReadLength = stack->Parameters.Read.Length;
- KdPrint(("[HelloDDKRead]--ulReadLength:%d\n", ulReadLength));
- // 完成IRP
- pIrp->IoStatus.Status = status;
- // 设置IRP操作了多少字节
- pIrp->IoStatus.Information = ulReadLength;
- // 设置内核下的缓冲区
- memset(pIrp->AssociatedIrp.SystemBuffer, 0xAA, ulReadLength);
- // 完成IRP处理
- IoCompleteRequest(pIrp, IO_NO_INCREMENT);
- KdPrint(("Leave HelloDDKRead\n"));
- return status;
- }
test:
- UCHAR buffer[10] = {0};
- ULONG ulRead;
- BOOL bRet = ReadFile(hDevice, buffer, 10, &ulRead, NULL);
- if (bRet)
- {
- for (int i=0; i<10; i++)
- {
- printf("%02x", buffer[i]);
- }
- }
- // 设置IRP操作了多少字节
- pIrp->IoStatus.Information = ulReadLength;
其实这里可以随意设置,比如设置为90,那么test的ulRead就返回90了:
又比如写驱动,我们可以接管IRP_MJ_WRITE,使用一个扩展结构体保存传入的数据:
- typedef struct _DEVICE_EXTENSION {
- PDEVICE_OBJECT pDevice;
- UNICODE_STRING ustrDeviceName; //设备名称
- UNICODE_STRING ustrSymLinkName; //符号链接名
- CHAR buffer[260];//用来保存写的
- } DEVICE_EXTENSION, *PDEVICE_EXTENSION;
- #pragma PAGEDCODE
- NTSTATUS HelloDDKWrite(IN PDEVICE_OBJECT pDevObj,
- IN PIRP pIrp)
- {
- NTSTATUS status = STATUS_SUCCESS;
- KdPrint(("Enter HelloDDKWrite\n"));
- PDEVICE_EXTENSION pDevExt = (PDEVICE_EXTENSION)pDevObj->DeviceExtension;
- PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
- // 获取存储的长度
- ULONG ulWriteLength = stack->Parameters.Write.Length;
- // 获取存储的偏移量
- ULONG ulWriteOffset = (ULONG)stack->Parameters.Write.ByteOffset.QuadPart;
- if (ulWriteOffset+ulWriteLength > 260)
- {
- status = STATUS_FILE_INVALID;
- ulWriteLength = 0;
- }
- else
- {
- memcpy(pDevExt->buffer+ulWriteLength, pIrp->AssociatedIrp.SystemBuffer, ulWriteLength);
- }
- pIrp->IoStatus.Status = status;
- pIrp->IoStatus.Information = ulWriteLength;
- IoCompleteRequest(pIrp, IO_NO_INCREMENT);
- KdPrint(("Leave HelloDDKWrite\n"));
- return status;
- }
直接方式读写设备
在创建设备后,设置设备属性为DO_DIRECT_IO,缓冲区读写方式是把内存从ring3复制到ring0,而直接读写方式是把ring3的缓冲区锁住,然后把它映射到ring0(用MmGetSystemAddressForMdlSafe可以得到MDL在内核模式下的映射),这样,两者指向的是同一块物理内存.(注意是指向同一块物理地址,虚拟地址一个在ring3,一个在ring0)
操作系统使用内存描述符表(MDL)来记录这段内存,大小为mdl->ByteCount,起始页地址是mdl->StartVa,首地址相对第一个页偏移为mdl->ByteOffset,因此,首地址就是mdl->StartVa+mdl->ByteOffset,注意的是直接方式取的是pIrp的mdl,而ring3取的是_IO_STACK_LOCATION
- if (pIrp->MdlAddress)
- {
- ULONG ulWriteLength = MmGetMdlByteCount(pIrp->MdlAddress);
- ULONG ulWriteOffset = MmGetMdlByteOffset(pIrp->MdlAddress);
- PVOID pWrite = MmGetMdlVirtualAddress(pIrp->MdlAddress);
以MDL_Driver为示例:
HelloDDLRead断下后,
对应代码如下:
- extern "C" NTSTATUS HelloDDKRead(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)
- {
- //MDL的长度应该和读长度相等,否则该操作应该设为不成功
- pIrp->IoStatus.Information = 0;
- status = STATUS_UNSUCCESSFUL;
- }else
- {
- //用MmGetSystemAddressForMdlSafe得到MDL在内核模式下的映射
- 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 HelloDDKRead\n"));
- return status;
- }
windbg单步调试:
- 1: kd> x
- b18f3c84 pDevObj = 0x896c0620 Device for "\Driver\MDL_Driver"
- b18f3c88 pIrp = 0x899b4f68
- b18f3c60 mdl_address = 0x0012ff70
- b18f3c64 pDevExt = 0x896c06d8
- b18f3c68 status = 0n0
- b18f3c6c stack = 0x899b4fd8 IRP_MJ_READ / 0x0 for Device for "\Driver\MDL_Driver"
- b18f3c70 mdl_offset = 0xf70
- b18f3c74 ulReadLength = 0xa
- b18f3c78 mdl_length = 0xa
得到pIrp的地址
- 1: kd> dt 0x899b4f68 _IRP -y MdlAddress
- nt!_IRP
- +0x004 MdlAddress : 0x89655750 _MDL
- 1: kd> dt 0x89655750 _MDL
得到pIrp中MdlAddress的地址
- 1: kd> dt 0x89655750 _MDL
- nt!_MDL
- +0x000 Next : (null)
- +0x004 Size : 0n32
- +0x006 MdlFlags : 0n138
- +0x008 Process : 0x899af020 _EPROCESS
- +0x00c MappedSystemVa : 0xbaf61e40 Void
- +0x010 StartVa : 0x0012f000 Void
- +0x014 ByteCount : 0xa
- +0x018 ByteOffset : 0xf70
注意StartVa,它是一个Ring3下的虚拟地址,通过下面方式得到它在Ring3下的虚拟地址
- ULONG mdl_length = MmGetMdlByteCount(pIrp->MdlAddress);
- PVOID mdl_address = MmGetMdlVirtualAddress(pIrp->MdlAddress);
- ULONG mdl_offset = MmGetMdlByteOffset(pIrp->MdlAddress);
按上面的描述,直 接读写方式是把ring3的缓冲区锁住,然后把它映射到ring0,通过
- //用MmGetSystemAddressForMdlSafe得到MDL在内核模式下的映射
- PVOID kernel_address = MmGetSystemAddressForMdlSafe(pIrp->MdlAddress,NormalPagePriority);
下面显示了用户模式下的0x0012ff70被重新映射为地址0xbaf4bf70,映射以后,驱动程序读写 0xbaf4bf70就相当于读写0x0012ff70了
- 1: kd> x
- b18f3c5c kernel_address = 0xbaf4bf70
- b18f3c60 mdl_address = 0x0012ff70
- 1: kd> db 0x0012ff70 L10 // 读下两个地址,完全一样的内容
- 0012ff70 bb bb bb bb bb bb bb bb-bb bb 54 78 09 58 e8 81 ..........Tx.X..
- 1: kd> db 0xbaf4bf70 L10
- baf4bf70 bb bb bb bb bb bb bb bb-bb bb 54 78 09 58 e8 81 ..........Tx.X..
windbg单步运行过
memset(kernel_address,0XAA,ulReadLength);
再次打印,两份虚拟内存的内容同时变化
- 1: kd> db 0xbaf4bf70 L10
- baf4bf70 aa aa aa aa aa aa aa aa-aa aa 54 78 09 58 e8 81 ..........Tx.X..
- 1: kd> db 0x0012ff70 L10
- 0012ff70 aa aa aa aa aa aa aa aa-aa aa 54 78 09 58 e8 81 ..........Tx.X..
示例下载
这里就有个疑问了,为什么不直接改0x0012ff70的内存呢??
32位Windows中,虚拟内存是4G,前2G是每个进程私有的,也就是在进程切换的时候会变化,后2G是系统的,所以是固定的,既然用户态进程和核心态驱动在同一个进程空间里,是不是只要直接传个内存地址过来,就可以访问了?理论上可以但实际上不行,因为用户态的进程在不断地切换,使驱动运行时没法保证前面的用户态进程是哪个,也就不确定前2G虚拟地址空间的映射情况,那么用户态进程传来的地址也许不是合法的,所以不能直接访问0x0012ff70,所以MDL简单地说就是将同一块物理内存同时映射到用户态空间和核心态空间
其他方式读写设备
如果不设置DO_BUFFERED_IO,也不设置DO_DIRECT_IO,则使用其他读写方式
DeviceIoControl与驱动交互
应用程序可以通过DeviceIoControl操作设备,它会使操作系统创建一个IRP_MJ_DEVICE_CONTROL类型的IRP,然后操作系统会把这个IRP转发到派遣函数,一般用它使应用程序和驱动程序进行通信,如,要对一个设备进行初始化操作,自定一种I/O控制码,然后用DeviceIoControl将这个控制码和请求一起传给驱动程序,在派遣函数中,分别对不同的I/O控制码进行处理
控制码也称IOCTL值,是32位无符号整型,IOCTL需符合DDK的规定,如下:
DDK提供了一个宏CTL_CODE
- #define CTL_CODE( DeviceType, Function, Method, Access ) ( \
- ((DeviceType) << 16) | ((Access) << 14) | ((Function) << 2) | (Method) \
DeviceType:设备对象类型,这个应和创建设备(IoCreateDevice)时的类型相匹配,
Function:0x800到0xFFF,由程序员自己定义
Method:这个是操作模式,
METHOD_BUFFERED:缓冲区方式操作
METHOD_IN_DIRECT:直接写方式操作
METHOD_OUT_DIRECT:直接读方式操作
METHOD_NEITHER:使用其他方式操作
Access:访问权限,如FILE_ANY_ACCESS
- #define IOCTL_TESSAFE_INIT \
- CTL_CODE(FILE_DEVICE_UNKNOWN, 0x921, METHOD_BUFFERED, FILE_READ_ACCESS|FILE_WRITE_ACCESS)
一般建议使用METHOD_BUFFERED, 驱动中最好不要直接访问用户模式下的内存地址
- BOOL DeviceIoControl(
- HANDLE hDevice, //已打开的设备句柄
- DWORD dwIoControlCode, //IO控制码
- LPVOID lpInBuffer, //输入buffer
- DWORD nInBufferSize, //输入大小
- LPVOID lpOutBuffer, // 输出buffer
- DWORD nOutBufferSize, //输出buffer大小
- LPDWORD lpBytesReturned, //实际返回字节数,
- LPOVERLAPPED lpOverlapped//设为NULL
- );
缓存读取的示例代码:
- #define IOCTL_TEST1\
- CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_READ_ACCESS|FILE_WRITE_ACCESS)
- int main()
- {
- HANDLE hDevice = CreateFileA
- ("\\\\.\\DDKTest",
- GENERIC_READ | GENERIC_WRITE,
- 0,
- NULL,
- OPEN_EXISTING,
- FILE_ATTRIBUTE_NORMAL,
- NULL
- );
- if (INVALID_HANDLE_VALUE == hDevice)
- {
- printf("Fail to open device with err:%d\n", GetLastError());
- getchar();
- return 1;
- }
- UCHAR InBuf[100] = {0};
- memset(InBuf, 0x41, 100);
- UCHAR OutBuf[100] = {0};
- DWORD dwOutput;
- DWORD dwOutBuf=100;
- BOOL bRet = DeviceIoControl
- (hDevice,
- IOCTL_TEST1,
- InBuf,
- 100,
- OutBuf,
- dwOutBuf,
- &dwOutput,
- NULL
- );
- if (bRet)
- {
- printf("IOCTL_TEST1 dwOutBuf:%d, dwOutput:%d\n", dwOutBuf, dwOutput);
- for (int i=0; i<(int)dwOutput; i++)
- {
- printf("%02X", OutBuf[i]);
- }
- printf("\n");
- }
- getchar();
- return 0;
- }
- PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(pIrp);
- ULONG cbin = stack->Parameters.DeviceIoControl.InputBufferLength;
- ULONG cbout = stack->Parameters.DeviceIoControl.OutputBufferLength;
- ULONG code = stack->Parameters.DeviceIoControl.IoControlCode;
- ULONG info = 0;
- switch (code)
- {
- case IOCTL_TEST1:
- {
- UCHAR* InBuf = (UCHAR*)pIrp->AssociatedIrp.SystemBuffer;
- memset(InBuf, 0x61, cbin);//全写成a
- stack->Parameters.DeviceIoControl.OutputBufferLength = cbout-2;//随机测试
- info = cbout-1;//随机测试
- }
- break;
- default:
- status = STATUS_INVALID_VARIANT;
- }
- // 完成IRP
- pIrp->IoStatus.Status = status;
- pIrp->IoStatus.Information = info;
- IoCompleteRequest(pIrp, IO_NO_INCREMENT);
结果 如下:
IRP、IO_STACK_LOCATION、文件三种读写方式(buffer/driect/other)、DeviceIoControl相关推荐
- Python对文件的三种打开方式以及with管理上下文
文件的三种打开方式以及with管理上下文 一.文件的三种打开方式 1.1 只读 f = open(r'D:\pycharm\python\123.txt','r',encoding='utf8') d ...
- while和for循环读取大文件三种读取文件方式
目录 While和for循环 读写文件 三种读操作比较 read()读文件 readline()读文件 readlines()读文件 While和for循环 for循环实现猜三次年纪 age = 66 ...
- 计算机文件保存方式,Word文档的三种保存方式
word中有多种保存文档的方式.可保存当前处理的活动文档 (活动文档:正在处理的文档.在 Microsoft word 中键入的文本或插入的图形将出现在活动文档中.活动文档的标题栏是突出显示的.),无 ...
- Python文件的多种读写方式及游标
一:文件的多种读写方式 主方式:w r a 从方式:t b + 了解方式:x u 1.按t(按照字符进行操作): with open("data_1.txt","wt&q ...
- 一篇文章看懂三种存储方式DAS、NAS、SAN
一.DAS.NAS.SAN在存储领域的位置 随着主机.磁盘.网络等技术的发展,数据存储的方式和架构也在一直不停改变,本文主要介绍目前主流的存储架构. 根据服务器类型分为: 封闭系统的存储(封闭系统主要 ...
- Hive metastore三种配置方式
Hive的meta数据支持以下三种存储方式,其中两种属于本地存储,一种为远端存储.远端存储比较适合生产环境.Hive官方wiki详细介绍了这三种方式,链接为:Hive Metastore. 一.本地d ...
- oracle Hash Join及三种连接方式
在Oracle中,确定连接操作类型是执行计划生成的重要方面.各种连接操作类型代表着不同的连接操作算法,不同的连接操作类型也适应于不同的数据量和数据分布情况. 无论是Nest Loop Join(嵌套循 ...
- python中if brthon环境安装包_Ant、Gradle、Python三种打包方式的介绍
今天谈一下Androdi三种打包方式,Ant.Gradle.Python. 当然最开始打包用Ant 很方便,后来转Studio开发,自带很多Gradle插件就用了它,然后随着打包数量越多,打包时间成了 ...
- grub安装的 三种安装方式
1. 引言 grub是什么?最常态的理解,grub是一个bootloader或者是一个bootmanager,通过grub可以引导种类丰富的系统,如linux.freebsd.windows等.但一旦 ...
- Spring Boot项目(Maven\Gradle)三种启动方式及后台运行详解
Spring Boot项目三种启动方式及后台运行详解 1 Spring Boot项目三种启动方法 运行Application.java类中的Main方法 项目管理工具启动 Maven项目:mvn sp ...
最新文章
- 在线作图|2分钟绘制三维PCA图
- parse函数 python_python的parse_args()函数
- HTML5 Canvas白板
- Vue axios 上传图片
- 经典ICP算法的问题
- open、read、write、文件类型
- MyBatis-Plus逆向工程——Generator
- bootstrap日期插件的使用
- HTML5期末大作业:爱宠之家网站设计——蓝色版爱宠之家(5页) 致热爱动物网页设计作品 大学生爱宠专题网页设计作业模板 动物静态HTML网页模板下载
- 及c语言实现 pdf,词法分析及其C语言实现.PDF
- 王阳明:越是多变时,越要学会进化(附个体进化的底层心力逻辑)
- UE4 材质学习 (02-利用UV来调整纹理)
- 【学习求职必备】认真认识一下世界末日那年成立的“华为诺亚方舟实验室”...
- kubernetes1.8.5集群安装(带证书)
- MQTT中topic匹配规则基础
- 迭代次数表达的宇称不守恒现象
- 大数据时代的差旅管理,看蜘蛛差旅如何精细化运作?
- 计算机科学与技术系专业课程关系图
- 03-JavaWeb之JSP
- shell 查看文件大小 du -sh 文件名
热门文章
- VM VirtualBox Centos6.5安装Oracle 11g r2 RAC
- adb 连接某个wifi_adb通过wifi连接android设备的方法(根据网络中大神的提示加上自我摸索得到):...
- 精准测试白皮书2020版
- 头脑王者 物理化学生物
- 组合优化问题的典型事例
- dhcp服务器里的dns怎么修改,dhcp服务器的dns设置方法
- 网易支付分布式事务实战-java课堂笔记
- android project build with ant
- 央视《家有妙招》整理版,值得永远收藏!
- MATLAB篇之层次分析法