上一篇博文中主要说明了驱动开发中基本的数据类型,认识这些数据类型算是驱动开发中的入门吧,这次主要说明驱动开发中最基本的模型——NTModel。介绍这个模型首先要了解R3层是如何通过应用层API进入到内核,内核又是如何将信息返回给R3,另外会介绍R3是如何直接向R0层下命令。

API调用的基本流程

一般在某些平台上进行程序开发,都需要使用系统提供的统一接口,linux平台直接提供系统调用,而windows上提供API,这两个并不是同一个概念(之前我一直分不清楚),虽然它们都是系统提供的实现某种功能的接口,但是它们有着本质的区别,系统调用在调用时会陷入到内核态,而API则不是这样,例如对于CreateFile这个我们不能说它是一个系统调用,在这个函数中并没有立即陷入到内核态,而是先进行参数检查,然后通过其他的一系列操作之后调用系统调用而进入到内核,所以它并不是系统调用。

windows在应用层提供了3个重要的动态库,分别是kernel.dll uer32.dll gdi.dll (现在基本上将所有的API都封装到kernell.dll中)

当用户程序调用一个API函数时,在这个API内部会调用用封装到ntdll.dll中以Zw或者Nt开头的同名函数,这些函数主要负责从用户态切换到内核态,这些函数叫做Native API,Native API进入到内核的方式是产生一个中断(XP及以前的版本)和调用sysenter(XP以上的版本),Native API在进入到内核中时会带上一个服务号,系统根据这个服务号在SSDT表中查找到相关的服务函数,最后调用这些服务函数完成相关功能,这个过程可以用下面的图来说明:



下面以CreateFile为例说明具体的调用过程:

1. 应用层调用CreateFile函数

2. 这个函数实际上被封装到了kernel32.dll中,在这个函数中调用NtCreateFile,这就是调用ntdll.dll中的native api ,ntdll.dll中一般又两组函数——以Nt开头,以Zw开头的,这两组函数本身没有什么太大的区别。

3. native api中通过中断 int 2eh(windows 2000 及以下),或者通过sysenter指令(windows xp及以上)进入内核,这种方式称为软中断,在产生中断时会带上一个服务号,根据服务号在ssdt表中以服务号进行查找(类似与8086中的中断机制)

4. 根据SSDT表中记录的服务函数地址,调用相关的服务函数。

5. 然后进入到执行组件中,对于CreateFile的操作,这个时候会调用IO管理器,IO管理器负责发起IO操作请求,并管理这些请求,主要时生成一个IRP结构,系统中有不同的管理器,主要有这样几个——虚拟内存管理器,IO管理器,对象管理器,进程管理器,线程管理器,配置管理器。

6. 管理器生成一个IRP请求,并调用内核中的驱动,来相应这个操作,对于CreateFile来说会调用NtCreateFile函数。

7. 最后调用内核实现部分,也就是硬件抽象层。最后由硬件抽象层操作硬件,完成打开或者创建文件的操作。

NTModel详解

R3与R0互相通信



在驱动程序中,入口是DriverEntry,函数会带入一个DriverObject指针。这个对象中有许多回调函数,它会根据R3层下发的操作调用对应的回调函数,比如应用层调用CreatFile时在驱动层会调用DispatchCreate。这样我们只要写好DispatchCreate就可以处理由R3层下发的CreateFile命令。

上述的一些函数只适用于一般的操作,对于一些特殊的,比如R3层要R0层产生一个输出语句等等,这个特殊的操作是通过DeviceIoControl向R0下发一个控制命令,在R0层根据这个控制码来识别具体是哪种控制,需要R0做哪种操作,函数原型如下:

BOOL DeviceIoControl(
HANDLE hDevice, //驱动的设备对象句柄
DWORD dwIoControlCode, //控制码
LPVOID lpInBuffer, //发往R0层的数据
DWORD nInBufferSize, //数据大小
LPVOID lpOutBuffer, //提供一个缓冲区,接受R0返回的数据
DWORD nOutBufferSize, //缓冲区的大小
LPDWORD lpBytesReturned, //R0实际返回数据的大小
LPOVERLAPPED lpOverlapped //完成例程
);

IRP的简介

R3与R0的通信是通过IRP进行数据的交换,IRP的定义如下:

typedef struct DECLSPEC_ALIGN(MEMORY_ALLOCATION_ALIGNMENT) _IRP {
CSHORT Type;
USHORT Size;
PMDL MdlAddress;
ULONG Flags; union {
struct _IRP *MasterIrp;
PVOID SystemBuffer;
} AssociatedIrp;
...
IO_STATUS_BLOCK IoStatus;
CHAR StackCount;
CHAR CurrentLocation;
...
PVOID UserBuffer;
...
struct {
union {
struct _IO_STACK_LOCATION *CurrentStackLocation;
...
};
} IRP;

IRP主要分为两部分,一部分是头,另一部分是IRP栈,在上一篇分析驱动中的数据结构时,说过驱动设备时分层的,上层驱动设备完成后,需要发到下层驱动设备,所有驱动设备公用IRP的栈顶,但是每个驱动都各自有自己的IRP栈,它们的关系如下如所示:



_IO_STACK_LOCATION 的结构如下:

typedef struct _IO_STACK_LOCATION {
UCHAR MajorFunction;
UCHAR MinorFunction;
UCHAR Flags;
UCHAR Control;
union {
struct {
PIO_SECURITY_CONTEXT SecurityContext;
ULONG Options;
USHORT POINTER_ALIGNMENT FileAttributes;
USHORT ShareAccess;
ULONG POINTER_ALIGNMENT EaLength;
} Create;
...
struct {
ULONG Length;
ULONG POINTER_ALIGNMENT Key;
LARGE_INTEGER ByteOffset;
} Read;
struct {
ULONG Length;
ULONG POINTER_ALIGNMENT Key;
LARGE_INTEGER ByteOffset;
} Write;
...
struct {
ULONG OutputBufferLength;
ULONG POINTER_ALIGNMENT InputBufferLength;
ULONG POINTER_ALIGNMENT IoControlCode;
PVOID Type3InputBuffer;
} DeviceIoControl;
...
struct {
PVOID Argument1;
PVOID Argument2;
PVOID Argument3;
PVOID Argument4;
} Others;
} Parameters; PDEVICE_OBJECT DeviceObject;
PFILE_OBJECT FileObject;
PIO_COMPLETION_ROUTINE CompletionRoutine;
PVOID Context;
} IO_STACK_LOCATION, *PIO_STACK_LOCATION;

这个结构中有一个共用体,当处理不同的R3层请求时系统会填充对应的共用体。

源代码分析

//设备名
#define DEVICE_NAME L"\\device\\NtDevice"
#define LINK_NAME L"\\??\\NtDevice" //DriverEntry
NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegisterPath)
{
PDEVICE_OBJECT pDeviceObject = NULL;
UNICODE_STRING uDeviceName = { 0 };
UNICODE_STRING uLinkName = { 0 };
NTSTATUS status = 0;
UNREFERENCED_PARAMETER(pRegisterPath);
DbgPrint("Start Driver......\n");
pDriverObject->DriverUnload = UnloadDriver;
//创建设备对象
RtlInitUnicodeString(&uDeviceName, DEVICE_NAME);
status = IoCreateDevice(pDriverObject, 0, &uDeviceName, FILE_DEVICE_UNKNOWN, 0, FALSE, &pDeviceObject);
if (!NT_SUCCESS(status))
{
DbgPrint("create device error!\n");
return status;
} pDeviceObject->Flags |= DO_BUFFERED_IO;
//创建符号连接
RtlInitUnicodeString(&uLinkName, LINK_NAME);
status = IoCreateSymbolicLink(&uLinkName, &uDeviceName);
if (!NT_SUCCESS(status))
{
DbgPrint("create link name error!\n");
return status;
} //注册分发函数
for (ULONG i = 0; i < IRP_MJ_MAXIMUM_FUNCTION + 1; i++)
{
pDriverObject->MajorFunction[i] = IoDispatchCommon;
} pDriverObject->MajorFunction[IRP_MJ_CREATE] = IoDispatchCreate;
pDriverObject->MajorFunction[IRP_MJ_READ] = IoDispatchRead;
pDriverObject->MajorFunction[IRP_MJ_WRITE] = IoDispatchWrite;
pDriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = IoDispatchControl;
pDriverObject->MajorFunction[IRP_MJ_CLOSE] = IoDispatchClose;
pDriverObject->MajorFunction[IRP_MJ_CLEANUP] = IoDispatchClean; return STATUS_SUCCESS;
}

这个函数是驱动的入口函数,类似与main函数或者WinMain函数。在该函数中首先创建一个控制设备对象,并为它创建一个符号链接,因为R3层不能直接通过设备的名称来访问设备,必须通过其符号链接。需要注意,设备名称必须以“\\device”开头,而符号链接需要以“\\??”开头,否则创建设备和符号链接会失败。

然后为这个驱动程序注册分发函数,分发函数保存在DriverObject结构中MajorFunction中,这个时一个数组,元素个数为IRP_MJ_MAXIMUM_FUNCTION,系统为每个位置定义一个宏,我们根据这个宏,在数据中填入对应 的函数指针,系统会根据R3层的操作来调用具体的函数。另外DriverObject中的DriverUnload 保存的是卸载驱动时系统回调用的函数,在这个函数中主要完成资源的释放工作

//UnloadDriver
VOID UnloadDriver(PDRIVER_OBJECT pDriverObject)
{
UNICODE_STRING uLinkName = { 0 };
UNREFERENCED_PARAMETER(pDriverObject);
RtlInitUnicodeString(&uLinkName, LINK_NAME);
IoDeleteSymbolicLink(&uLinkName);
IoDeleteDevice(pDriverObject->DeviceObject);
DbgPrint("Unload Driver......\n");
}

在这个函数中主要释放了之前创建的符号链接和控制设备对象。

NTSTATUS IoDispatchCommon(PDEVICE_OBJECT  DeviceObject, PIRP  pIrp)
{
UNREFERENCED_PARAMETER(DeviceObject);
//向R3返回成功
pIrp->IoStatus.Status = STATUS_SUCCESS;
//向R3返回的数据长度为0,不向R3返回数据
pIrp->IoStatus.Information = 0; //默认直接返回
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}

这个函数是我们注册的默认处理函数,它每步的操作在注释中也写了,需要注意的是在pIrp->IoStatus.Status = STATUS_SUCCESS;语句是向R3返回执行的状态,而最后返回成功是给驱动程序看的。

//IoDispatchRead
NTSTATUS IoDispatchRead(PDEVICE_OBJECT DeviceObject, PIRP pIrp)
{
//处理R3层的读命令,将数据返回给R3层
WCHAR wHello[] = L"hello world";
ULONG uReadLength = 0;
WCHAR *pBuffer = pIrp->AssociatedIrp.SystemBuffer;
ULONG uBufferLen = 0;
ULONG uMin = 0;
PIO_STACK_LOCATION pCurrStack = IoGetCurrentIrpStackLocation(pIrp); UNREFERENCED_PARAMETER(DeviceObject);
uBufferLen = pCurrStack->Parameters.Read.Length;
uReadLength = sizeof(wHello);
uMin = (uReadLength < uBufferLen) ? uReadLength : uBufferLen; RtlCopyMemory(pBuffer, wHello, uMin);
pIrp->IoStatus.Status = STATUS_SUCCESS;
pIrp->IoStatus.Information = uMin;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}

这个函数主要用来处理应用层的ReadFile请求,这个函数主要是将一段字符串拷贝到通信用的缓冲区中,模拟读的操作。,需要注意的是这个地址要根据不同的设备类型来不同的对待,对于DO_BUFFERED_IO类型的设备,是保存在pIrp->AssociatedIrp.SystemBuffer中,对于DO_DIRECT_IO类型的设备,这个缓冲区的地址是MdlAddress。而对于ReadFile这个API来说,应用层在调用这个函数时会给一个缓冲区的大小,为了获取这个大小,首先得到当前的IRP栈,这个操作用函数IoGetCurrentIrpStackLocation可以得到,然后在当前栈的Parameters共用体中,调用Read部分的Length。当得到这个缓冲区大小后,取缓冲区大小和对应字符串的大小的最小值,为什么要这样做?我们不妨考虑如果用缓冲区的长度的话,当这个长度比字符串的长度长,那么在拷贝时就会将字符串后面的一些无用内存给拷贝进去了,一来效率不高,二来这样应用层得到了内核层中内存的部分数据,存在安全隐患,如果我们采用字符串的长度,可能会出现用户提供的缓冲区不够的情况,这样会造成越界。所以采用它们的最小值是最合理的。完成之后返回,这个时候要注意返回的长度这一项需要填上真实拷贝的大小,不然R3是得不到数据的,或者得到的数据不完整。

NTSTATUS IoDispatchWrite(PDEVICE_OBJECT  DeviceObject, PIRP  pIrp)
{
//接受R3的写命令
UNICODE_STRING uWriteString = { 0 };
WCHAR *pBuffer = NULL;
ULONG uWriteLength = 0;
PIO_STACK_LOCATION pStackIrp = IoGetCurrentIrpStackLocation(pIrp); UNREFERENCED_PARAMETER(DeviceObject);
uWriteLength = pStackIrp->Parameters.Write.Length;
uWriteString.MaximumLength = uWriteLength;
uWriteString.Length = uWriteLength - 1 * sizeof(WCHAR);
uWriteString.Buffer = ExAllocatePoolWithTag(PagedPool, uWriteLength, 'TSET');
pBuffer = pIrp->AssociatedIrp.SystemBuffer; if (NULL == uWriteString.Buffer)
{
DbgPrint("Allocate Memory Error!\n");
pIrp->IoStatus.Status = STATUS_INSUFFICIENT_RESOURCES;
pIrp->IoStatus.Information = 0;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_INSUFFICIENT_RESOURCES;
} RtlCopyMemory(uWriteString.Buffer, pBuffer, uWriteLength);
DbgPrint("Write Date: %wZ\n", &uWriteString);
pIrp->IoStatus.Status = STATUS_SUCCESS;
pIrp->IoStatus.Information = 0;
IoCompleteRequest(pIrp, IO_NO_INCREMENT); return STATUS_SUCCESS;
}

这个函数用来处理WriteFile的请求,首先通过IRP中传进来的缓冲区的地址得到这个数据,然后将数据打印出来,通过这种方式来模拟向R3文件中写入数据。

//定义的控制码
#define IOCTL_BASE 0x800
#define MYIOCTRL_CODE(i) CTL_CODE(FILE_DEVICE_UNKNOWN, (IOCTL_BASE + i), METHOD_BUFFERED, FILE_ANY_ACCESS) #define CTL_PRINT MYIOCTRL_CODE(1)
#define CTL_HELLO MYIOCTRL_CODE(2)
#define CTL_BYE MYIOCTRL_CODE(3)
//IoDispatchControl函数
NTSTATUS IoDispatchControl(PDEVICE_OBJECT DeviceObject, PIRP pIrp)
{
UNREFERENCED_PARAMETER(DeviceObject);
ULONG uBufferLength = 0;
WCHAR *pBuffer = NULL;
ULONG uIOCtrlCode = 0;
PIO_STACK_LOCATION pStack = IoGetCurrentIrpStackLocation(pIrp);
uBufferLength = pStack->Parameters.DeviceIoControl.InputBufferLength;
uIOCtrlCode = pStack->Parameters.DeviceIoControl.IoControlCode;
pBuffer = pIrp->AssociatedIrp.SystemBuffer; switch (uIOCtrlCode)
{
case CTL_BYE:
DbgPrint("Good Bye");
break;
case CTL_HELLO:
DbgPrint("Hello World\n");
break;
case CTL_PRINT:
DbgPrint("%S", pBuffer);
break;
default:
DbgPrint("unknow command\n");
} pIrp->IoStatus.Information = 0;
pIrp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(pIrp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}

这个宏CTL_CODE是微软官方定义的,主要用来将控制码和对应类型的设备进行绑定,主要传入4个参数,第一个是设备的类型,第二个是具体的控制码,需要注意的是:为了与微软官方的控制码区分,自定义的控制码需要在0x800以上。第三个参数是对应控制码传递参数的方式,主要的几种方式与设备对象和R3层传递数据的方式类似,我们在这传入的是METHOD_BUFFERED,表示的是通过内存拷贝的方式将R3的数据传入到R0。最后一个是操作权限,我们给它所有的权限。

在函数中我们根据R3传入的控制码来进行不同的操作。

R3部分的代码

R3部分主要完成的是驱动程序的加载、卸载、以及向驱动程序发送命令。驱动的加载和卸载是通过注册并启动服务和关闭并删除服务的方式进行的,至于怎么操作一个服务,请看本人另外一篇关于服务操作的博客。需要注意的是在创建服务时需要填入服务程序所在的路径,这个时候需要填生成的.sys文件的路径,不要写之前定义的设备名或者符号链接名。

在这主要贴出控制部分的代码:

//打开设备,获取它的设备句柄
HANDLE hDevice = CreateFileA("\\\\.\\NtDevice", GENERIC_WRITE | GENERIC_READ, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (NULL == hDevice)
{
printf("打开设备失败\n");
return;
} //读
CHAR szBuf[255] = "";
ULONG uLength = 0;
ReadFile(hDevice, szBuf, 255, &uLength, NULL);
printf("Read Date:%s\n", szBuf);
//写
WCHAR wHello[] = L"Hello world";
WriteFile(hDevice, wHello, (wcslen(wHello) + 1) * sizeof(WCHAR), &uLength, NULL);
printf("写操作完成");
//向其发送控制命令
WCHAR wCtlString[] = L"C:\\test.txt";
DeviceIoControl(hDevice, CTL_PRINT, wCtlString, (wcslen(wCtlString) + 1) * sizeof(WCHAR), NULL, 0, NULL, NULL);
DeviceIoControl(hDevice, CTL_HELLO, NULL, 0, NULL, 0, NULL, NULL);
DeviceIoControl(hDevice, CTL_BYE, NULL, 0, NULL, 0, NULL, NULL);
printf("控制操作完成");
CloseHandle(hDevice);

要控制一个设备对象,必须先得到设备对象的句柄,获得这个句柄,我们是通过函数CreateFile来得到的,这个时候填入的文件名应该是之前注册的符号链接的名字,在R3中这个名字以“\\\\ .”开头并加上我们为它提供的符号链接名。在调用CreateFile时会触发之前定义的DispatchCreate函数。然后我们通过调用ReadFile和WriteFile分别触发读写操作,最后调用DeviceIoControl函数,发送控制命令,在R3层中也要定义一份与R0中一模一样的控制码。这样就基本实现了R3与R0通信。

有的时候在加载驱动的时候,系统会报错,返回码为2,表示系统找不到驱动对应的文件,这个时候可能是文件的路径的问题,这个时候可以在系统的注册表HKEY_LOCAL_MACHINE\SYSTEM\ControlSet001\services\下,找到我们的驱动(还有可能是在ControlSet002)对应的路径,然后将R3程序拷贝到这个路径下,基本就可以解决这个问题

驱动开发入门——NTModel的更多相关文章

  1. Windows驱动开发入门指引

       1.  前言 因工作上项目的需要,笔者需要做驱动相关的开发,之前并没有接触过相关的知识,折腾一段时间下来,功能如需实现了,也积累了一些经验和看法,所以在此做番总结. 对于驱动开发的开发指引,微软 ...

  2. Windows内核驱动开发入门学习资料

    声明:本文所描述的所有资料和源码均搜集自互联网,版权归原始作者所有,所以在引用资料时我尽量注明原始作者和出处:本文所搜集资料也仅供同学们学习之用,由于用作其他用途引起的责任纠纷,本人不负任何责任.(本 ...

  3. windows 驱动开发入门——驱动中的数据结构

    最近在学习驱动编程方面的内容,在这将自己的一些心得分享出来,供大家参考,与大家共同进步,本人学习驱动主要是通过两本书--<独钓寒江 windows安全编程> 和 <windows驱动 ...

  4. 2019.05.08 《Linux驱动开发入门与实战》

    第六章:字符设备 申请设备号---注册设备 1.字符设备的框架: 2.结构体,struct cdev: 3.字符设备的组成: 4.例子: 5.申请和释放设备号: 设备号和设备节点是什么关系.? 设备驱 ...

  5. wince驱动开发入门

    因为课题前期调研没做好,用的CPU板卡和数据采集卡来自两个部门.加上买的是裸板,自己定制的OS,技术支持不爱搭理.所以给的AI板卡的驱动一直装不上,自己在郁闷中寻找答案,就扎进了wince驱动的知识库 ...

  6. 2013-6-2 [转载自CSDN]如何入门Windows系统下驱动开发

    [序言]很多人都对驱动开发有兴趣,但往往找不到正确的学习方式.当然这跟驱动开发的本土化资料少有关系.大多学的驱动开发资料都以英文为主,这样让很多驱动初学者很头疼.本人从事驱动开发时间不长也不短,大概 ...

  7. 如何正确入门Windows系统下驱动开发领域?

    [作者]猪头三个人网站 :http://www.x86asm.com/ [序言]很多人都对驱动开发有兴趣,但往往找不到正确的学习方式.当然这跟驱动开发的本土化资料少有关系.大多学的驱动开发资料都以英文 ...

  8. windows驱动开发推荐书籍

    [作者] 猪头三 个人网站 :http://www.x86asm.com/ [序言] 很多人都对驱动开发有兴趣,但往往找不到正确的学习方式.当然这跟驱动开发的本土化资料少有关系.大多学的驱动开发资料都 ...

  9. Android深度探索HAL与驱动开发 第三章 Git入门

    Git功能十分复杂,简单来说它使你的开发更为快捷和可控,尤其是在开源项目上展现的友好的交互和回馈. 熟悉一些git指令操作对开发者的帮助可以避免开发者受到一些外在因素打断开发进度,甚至延误项目的che ...

随机推荐

  1. 天津政府应急系统之GIS一张图(arcgis api for flex)解说(三)显示地图坐标系模块

    config.xml文件的配置例如以下: 1 2 <widget left="3" bottom="3" config="widgets/Coo ...

  2. Mybatis 入门之resultMap与resultType解说实例

    resultMap:适合使用返回值是自己定义实体类的情况 resultType:适合使用返回值得数据类型是非自己定义的,即jdk的提供的类型 resultMap : type:映射实体类的数据类型 i ...

  3. or1200处理器的异常处理类指令介绍

    下面内容摘自<步步惊芯--软核处理器内部设计分析>一书 我们在计算机体系结构的学习中知道:中断实质上包含由外部事件引起的硬中断(又称外中断)和由内部预先安排的特定指令或内部异常引起的软中断 ...

  4. Hadoop2.4.1伪分布式安装

    本教程的前提是已经在VMware虚拟机上安装了centos6.5,centos的安装过程这里不再赘述 一.准备Linux环境 1.点击VMware快捷方式,右键打开文件所在位置 -> 双击vmn ...

  5. 超详细 值得收藏 linux CentOS 7 配置Apache服务【转发+新增】

    一.Apache简介 Apache HTTP Server(简称Apache)是Apache软件基金会的一个开放源代码的网页服务器软件,可以在大多数电脑操作系统中运行,由于其跨平台和安全性(尽管不断有 ...

  6. Elasticsearch批处理操作——bulk API

    Elasticsearch提供的批量处理功能,是通过使用_bulk API实现的.这个功能之所以重要,在于它提供了非常高效的机制来尽可能快的完成多个操作,与此同时使用尽可能少的网络往返. 1.批量索引 ...

  7. 自学Python2.1-基本数据类型-字符串str(object)

    Python str方法总结 class str(object): """ str(object='') -> str str(bytes_or_buffer[, ...

  8. Python的下划线_

    1.单下划线(_) 通常情况下,单下划线(_)会在以下3种场景中使用: 1.1 在解释器中: 在这种情况下,"_"代表交互式解释器会话中上一条执行的语句的结果.这种用法首先被标准C ...

  9. Lua 数组排序 table.sort的注意事项

    1. table中不能有nil table.sort是排序函数,它要求要排序的目标table的必须是从1到n连续的,即中间不能有nil. 2. 重写的比较函数,两个值相等时不能return true ...

  10. NoSQL数据库

    NoSQL数据库 1.NoSQL简介 最初表示"反SQL"运动,用新型的非关系型数据库取代关系数据库:现在表示"Not only SQL"关系和非关系型数据库各 ...