windows中的进程和线程 您所在的位置:网站首页 Windows进程90个正常吗 windows中的进程和线程

windows中的进程和线程

2024-06-09 20:43| 来源: 网络整理| 查看: 265

今天咱们就聊聊windows中的进程和线程

2016-09-30

 

在讨论windows下的进程和线程时,我们先回顾下通用操作系统的进程和线程。之所以称之为通用是因为一贯的本科或者其他教材都是这么说的:

1、进程是系统分配资源的最小单位。

2、线程是处理器调度的最小单位。

3、一个进程可以包含很多线程,且这些线程共享进程内的所有资源。

然后又有大致三种线程模型:进程模型、用户级线程、内核级线程,三种模型如图所示

把线程模型按严格意义上的划分就是这样,但是事实上操作系统在真正运行过程中使用的要远比这些复杂的多,但是这些就想事网络中的OSI模型一样,虽然在使用的时候有不少问题,但是作为学习仍然是很经典的一种提法。

那么为什么上面三种模型都不能满足需求呢?

先说进程模型,进程模型下,没有线程的概念,即不论是资源的分配还是CPU的调度都是基于进程的。这种情况下完成一个很小的任务也需要创建一个进程,但是进程的生命周期从创建到调度再到销毁需要消耗比较多的资源。为了更加充分的利用资源,这里就提出了线程的概念,在这种情况下,用户级线程和内核级线程可以说各有优劣。

  首先说用户级线程,CPU的调度和资源的分配仍然是按照进程来走,线程只存在于用户空间,内核无法感知。这样用户就可以根据需要自己定义线程调度算法,线程的切换因为不需要模式的切换,故比较高效。其缺点就是对应用程序的要求比较高,虽然现在高级语言都有线程池的概念,但是这种情况下一个致命缺点就是如果进程内一个线程被阻塞,那么整个进程就会被阻塞,这很显然无法充分利用多处理器的优势;而如果一个线程异常,那么整个进程也就完蛋了。这就像一颗老鼠屎,坏了一锅粥的思想。而内核级线程就不同了,其CPu根据线程进行调度,这样用户应用程序就可以专心做自己的事情,不用考虑调度算法。但是这样用户就无法自定义调度算法。这种情况下线程的管理全部由内核接管。线程的创建、调度、回收都有内核处理。创建和回收暂且不说,线程的调度由于是内核管理,其必然会涉及到用户模式到内核模式的切换,而在此被调度就需要从内核模式切换回用户模式。也就是说一个线程从被运行->就绪->运行,至少需要两次模式切换,这样虽然相对于进程来讲,可以避免在不同进程切换时更多的上下文的保存(同一进程中的线程切换就可以避免),但是要不就说时代在发展,社会在进步呢,工程师总要一点点榨取CPU以便获取最大的性能,有句话说没有最好只有更好,所以即使是这种开销也是无法忍受的。

 

windows中的进程 和线程

windows中严格意义上来说算是内核级的线程模型。在windows中进程和线程有明确的界限,进程就是进程,只是分配资源的单位,有自己独立的数据结构。而线程就是线程,作为CPU调度的基本单位,也有自己的数据结构。这点和LInux有着本质的区别, 但是今天我们不讨论具体的数据结构!

windows中的进程涉及到两个结构EPROCESS和KPROCESS两个结构EPROCESS是执行体层的对象而KPROCESS是内核层的对象。相对应的ETHREAD是执行体对象,而KTHREAD是内核层对象。关于执行体层和内核层,这点参考《windows内核原理与实现》相关章节。

熟悉这些结构的都应该清楚,内核层对象EPROCESS和ETHREAD都是相应执行体对象的首个内嵌结构,即实际上进程和线程的内核层对象和执行体层对象在内核中的地址是相同的。

内核部分实现的基本是和进程或者线程本身相关的属性,而执行体层更多的注重于管理。

 下面还是从线程的创建开始一步步观察下windows创建线程是如何被一步步建立起来的,并看看线程包含了哪些东西

 

创建一个线程首先从用户程序调用用户函数CreateThread开始,该函数直接调用了函数CreateRemoteThread,CreateRemoteThread函数主要完成以下工作:

a)创建用户空间堆栈b)初始化CONTEXT结构体c)初始化OBJECT_ATTRIBUTES结构体,此结构体在创建线程对象的时候使用。d)调用NtCreateThread,进入内核空间。

需要注意这里Context结构作为参数传递给NtCreateThread,创建系统线程时,该参数为空。用户线程则在初始化系统堆栈的时候复制Context内容到堆栈。

看NtCreateThread

NTSTATUS NtCreateThread( __out PHANDLE ThreadHandle, __in ACCESS_MASK DesiredAccess, __in_opt POBJECT_ATTRIBUTES ObjectAttributes, __in HANDLE ProcessHandle, __out PCLIENT_ID ClientId, __in PCONTEXT ThreadContext, __in PINITIAL_TEB InitialTeb, __in BOOLEAN CreateSuspended ) /*++ Routine Description: This system service API creates and initializes a thread object. Arguments: ThreadHandle - Returns the handle for the new thread. DesiredAccess - Supplies the desired access modes to the new thread. ObjectAttributes - Supplies the object attributes of the new thread. ProcessHandle - Supplies a handle to the process that the thread is being created within. ClientId - Returns the CLIENT_ID of the new thread. ThreadContext - Supplies an initial context for the new thread. InitialTeb - Supplies the initial contents for the thread's TEB. CreateSuspended - Supplies a value that controls whether or not a thread is created in a suspended state. --*/ { NTSTATUS Status; INITIAL_TEB CapturedInitialTeb; PAGED_CODE(); // // Probe all arguments // try { if (KeGetPreviousMode () != KernelMode) { ProbeForWriteHandle (ThreadHandle); if (ARGUMENT_PRESENT (ClientId)) { ProbeForWriteSmallStructure (ClientId, sizeof (CLIENT_ID), sizeof (ULONG)); } if (ARGUMENT_PRESENT (ThreadContext) ) { ProbeForReadSmallStructure (ThreadContext, sizeof (CONTEXT), CONTEXT_ALIGN); } else { return STATUS_INVALID_PARAMETER; } ProbeForReadSmallStructure (InitialTeb, sizeof (InitialTeb->OldInitialTeb), sizeof (ULONG)); } CapturedInitialTeb.OldInitialTeb = InitialTeb->OldInitialTeb; if (CapturedInitialTeb.OldInitialTeb.OldStackBase == NULL && CapturedInitialTeb.OldInitialTeb.OldStackLimit == NULL) { // // Since the structure size here is less than 64k we don't need to reprobe // CapturedInitialTeb = *InitialTeb; } } except (ExSystemExceptionFilter ()) { return GetExceptionCode (); } Status = PspCreateThread (ThreadHandle, DesiredAccess, ObjectAttributes, ProcessHandle, NULL, ClientId, ThreadContext, &CapturedInitialTeb, CreateSuspended, NULL, NULL); return Status; }

 

 

该函数在做有些常规检查后就直接调用了另个函数PspCreateThread

NTSTATUS PspCreateThread( OUT PHANDLE ThreadHandle, IN ACCESS_MASK DesiredAccess, IN POBJECT_ATTRIBUTES ObjectAttributes OPTIONAL, IN HANDLE ProcessHandle, IN PEPROCESS ProcessPointer, OUT PCLIENT_ID ClientId OPTIONAL, IN PCONTEXT ThreadContext OPTIONAL,//用户空间线程运行上下文 IN PINITIAL_TEB InitialTeb OPTIONAL, IN BOOLEAN CreateSuspended, IN PKSTART_ROUTINE StartRoutine OPTIONAL, IN PVOID StartContext ) /*++ Routine Description: This routine creates and initializes a thread object. It implements the foundation for NtCreateThread and for PsCreateSystemThread. Arguments: ThreadHandle - Returns the handle for the new thread. DesiredAccess - Supplies the desired access modes to the new thread. ObjectAttributes - Supplies the object attributes of the new thread. ProcessHandle - Supplies a handle to the process that the thread is being created within. ClientId - Returns the CLIENT_ID of the new thread. ThreadContext - Supplies a pointer to a context frame that represents the initial user-mode context for a user-mode thread. The absence of this parameter indicates that a system thread is being created. InitialTeb - Supplies the contents of certain fields for the new threads TEB. This parameter is only examined if both a trap and exception frame were specified. CreateSuspended - Supplies a value that controls whether or not a user-mode thread is created in a suspended state. StartRoutine - Supplies the address of the system thread start routine. StartContext - Supplies context for a system thread start routine. --*/ { HANDLE_TABLE_ENTRY CidEntry; NTSTATUS Status; PETHREAD Thread; PETHREAD CurrentThread; PEPROCESS Process; PTEB Teb; KPROCESSOR_MODE PreviousMode; HANDLE LocalThreadHandle; BOOLEAN AccessCheck; BOOLEAN MemoryAllocated; PSECURITY_DESCRIPTOR SecurityDescriptor; SECURITY_SUBJECT_CONTEXT SubjectContext; NTSTATUS accesst; LARGE_INTEGER CreateTime; ULONG OldActiveThreads; PEJOB Job; AUX_ACCESS_DATA AuxData; PACCESS_STATE AccessState; ACCESS_STATE LocalAccessState; PAGED_CODE(); CurrentThread = PsGetCurrentThread (); if (StartRoutine != NULL) { PreviousMode = KernelMode; } else { PreviousMode = KeGetPreviousModeByThread (&CurrentThread->Tcb); } Teb = NULL; Thread = NULL; Process = NULL; if (ProcessHandle != NULL) { // // Process object reference count is biased by one for each thread. // This accounts for the pointer given to the kernel that remains // in effect until the thread terminates (and becomes signaled) // Status = ObReferenceObjectByHandle (ProcessHandle, PROCESS_CREATE_THREAD, PsProcessType, PreviousMode, &Process, NULL); } else { if (StartRoutine != NULL) { ObReferenceObject (ProcessPointer); Process = ProcessPointer; Status = STATUS_SUCCESS; } else { Status = STATUS_INVALID_HANDLE; } } if (!NT_SUCCESS (Status)) { return Status; } // // If the previous mode is user and the target process is the system // process, then the operation cannot be performed. // if ((PreviousMode != KernelMode) && (Process == PsInitialSystemProcess)) { ObDereferenceObject (Process); return STATUS_INVALID_HANDLE; } //创建ethread对象 Status = ObCreateObject (PreviousMode, PsThreadType, ObjectAttributes, PreviousMode, NULL, sizeof(ETHREAD), 0, 0, &Thread); if (!NT_SUCCESS (Status)) { ObDereferenceObject (Process); return Status; } RtlZeroMemory (Thread, sizeof (ETHREAD)); // // Initialize rundown protection for cross thread TEB refs etc. // ExInitializeRundownProtection (&Thread->RundownProtect); // // Assign this thread to the process so that from now on // we don't have to dereference in error paths. // Thread->ThreadsProcess = Process; Thread->Cid.UniqueProcess = Process->UniqueProcessId; CidEntry.Object = Thread; CidEntry.GrantedAccess = 0; //从句柄表中分配一个CidEntry Thread->Cid.UniqueThread = ExCreateHandle (PspCidTable, &CidEntry); if (Thread->Cid.UniqueThread == NULL) { ObDereferenceObject (Thread); return (STATUS_INSUFFICIENT_RESOURCES); } // // Initialize Mm // Thread->ReadClusterSize = MmReadClusterSize; // // Initialize LPC // KeInitializeSemaphore (&Thread->LpcReplySemaphore, 0L, 1L); InitializeListHead (&Thread->LpcReplyChain); // // Initialize Io // InitializeListHead (&Thread->IrpList); // // Initialize Registry // InitializeListHead (&Thread->PostBlockList); // // Initialize the thread lock // PspInitializeThreadLock (Thread); KeInitializeSpinLock (&Thread->ActiveTimerListLock); InitializeListHead (&Thread->ActiveTimerListHead); if (!ExAcquireRundownProtection (&Process->RundownProtect)) { ObDereferenceObject (Thread); return STATUS_PROCESS_IS_TERMINATING; } //如果ThreadContext不为null表明这是一个用户线程,否则是系统线程 if (ARGUMENT_PRESENT (ThreadContext)) { // // User-mode thread. Create TEB etc // Status = MmCreateTeb (Process, InitialTeb, &Thread->Cid, &Teb); if (!NT_SUCCESS (Status)) { ExReleaseRundownProtection (&Process->RundownProtect); ObDereferenceObject (Thread); return Status; } try { // // Initialize kernel thread object for user mode thread. // Thread->StartAddress = (PVOID)CONTEXT_TO_PROGRAM_COUNTER(ThreadContext); #if defined(_AMD64_) Thread->Win32StartAddress = (PVOID)ThreadContext->Rdx; #elif defined(_X86_) Thread->Win32StartAddress = (PVOID)ThreadContext->Eax; #else #error "no target architecture" #endif } except (EXCEPTION_EXECUTE_HANDLER) { Status = GetExceptionCode(); } if (NT_SUCCESS (Status)) { Status = KeInitThread (&Thread->Tcb, NULL, PspUserThreadStartup, (PKSTART_ROUTINE)NULL, Thread->StartAddress, ThreadContext, Teb, &Process->Pcb); } } else { Teb = NULL; // // Set the system thread bit thats kept for all time // PS_SET_BITS (&Thread->CrossThreadFlags, PS_CROSS_THREAD_FLAGS_SYSTEM); // // Initialize kernel thread object for kernel mode thread. // Thread->StartAddress = (PKSTART_ROUTINE) StartRoutine; Status = KeInitThread (&Thread->Tcb, NULL, PspSystemThreadStartup, StartRoutine, StartContext, NULL, NULL, &Process->Pcb); } if (!NT_SUCCESS (Status)) { if (Teb != NULL) { MmDeleteTeb(Process, Teb); } ExReleaseRundownProtection (&Process->RundownProtect); ObDereferenceObject (Thread); return Status; } PspLockProcessExclusive (Process, CurrentThread); // // Process is exiting or has had delete process called // We check the calling threads termination status so we // abort any thread creates while ExitProcess is being called -- // but the call is blocked only if the new thread would be created // in the terminating thread's process. // if ((Process->Flags&PS_PROCESS_FLAGS_PROCESS_DELETE) != 0 || (((CurrentThread->CrossThreadFlags&PS_CROSS_THREAD_FLAGS_TERMINATED) != 0) && (ThreadContext != NULL) && (THREAD_TO_PROCESS(CurrentThread) == Process))) { PspUnlockProcessExclusive (Process, CurrentThread); KeUninitThread (&Thread->Tcb); if (Teb != NULL) { MmDeleteTeb(Process, Teb); } ExReleaseRundownProtection (&Process->RundownProtect); ObDereferenceObject(Thread); return STATUS_PROCESS_IS_TERMINATING; } OldActiveThreads = Process->ActiveThreads++; InsertTailList (&Process->ThreadListHead, &Thread->ThreadListEntry); KeStartThread (&Thread->Tcb); PspUnlockProcessExclusive (Process, CurrentThread); ExReleaseRundownProtection (&Process->RundownProtect); // // Failures that occur after this point cause the thread to // go through PspExitThread // if (OldActiveThreads == 0) { PERFINFO_PROCESS_CREATE (Process); if (PspCreateProcessNotifyRoutineCount != 0) { ULONG i; PEX_CALLBACK_ROUTINE_BLOCK CallBack; PCREATE_PROCESS_NOTIFY_ROUTINE Rtn; for (i=0; iInheritedFromUniqueProcessId, Process->UniqueProcessId, TRUE); ExDereferenceCallBackBlock (&PspCreateProcessNotifyRoutine[i], CallBack); } } } } // // If the process has a job with a completion port, // AND if the process is really considered to be in the Job, AND // the process has not reported, report in // // This should really be done in add process to job, but can't // in this path because the process's ID isn't assigned until this point // in time // Job = Process->Job; if (Job != NULL && Job->CompletionPort && !(Process->JobStatus & (PS_JOB_STATUS_NOT_REALLY_ACTIVE|PS_JOB_STATUS_NEW_PROCESS_REPORTED))) { PS_SET_BITS (&Process->JobStatus, PS_JOB_STATUS_NEW_PROCESS_REPORTED); KeEnterCriticalRegionThread (&CurrentThread->Tcb); ExAcquireResourceSharedLite (&Job->JobLock, TRUE); if (Job->CompletionPort != NULL) { IoSetIoCompletion (Job->CompletionPort, Job->CompletionKey, (PVOID)Process->UniqueProcessId, STATUS_SUCCESS, JOB_OBJECT_MSG_NEW_PROCESS, FALSE); } ExReleaseResourceLite (&Job->JobLock); KeLeaveCriticalRegionThread (&CurrentThread->Tcb); } PERFINFO_THREAD_CREATE(Thread, InitialTeb); // // Notify registered callout routines of thread creation. // if (PspCreateThreadNotifyRoutineCount != 0) { ULONG i; PEX_CALLBACK_ROUTINE_BLOCK CallBack; PCREATE_THREAD_NOTIFY_ROUTINE Rtn; for (i = 0; i < PSP_MAX_CREATE_THREAD_NOTIFY; i++) { CallBack = ExReferenceCallBackBlock (&PspCreateThreadNotifyRoutine[i]); if (CallBack != NULL) { Rtn = (PCREATE_THREAD_NOTIFY_ROUTINE) ExGetCallBackBlockRoutine (CallBack); Rtn (Thread->Cid.UniqueProcess, Thread->Cid.UniqueThread, TRUE); ExDereferenceCallBackBlock (&PspCreateThreadNotifyRoutine[i], CallBack); } } } // // Reference count of thread is biased once for itself and once for the handle if we create it. // ObReferenceObjectEx (Thread, 2); if (CreateSuspended) { try { KeSuspendThread (&Thread->Tcb); } except ((GetExceptionCode () == STATUS_SUSPEND_COUNT_EXCEEDED)? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) { } // // If deletion was started after we suspended then wake up the thread // if (Thread->CrossThreadFlags&PS_CROSS_THREAD_FLAGS_TERMINATED) { KeForceResumeThread (&Thread->Tcb); } } AccessState = NULL; if (!PsUseImpersonationToken) { AccessState = &LocalAccessState; Status = SeCreateAccessStateEx (NULL, ARGUMENT_PRESENT (ThreadContext)?PsGetCurrentProcessByThread (CurrentThread) : Process, AccessState, &AuxData, DesiredAccess, &PsThreadType->TypeInfo.GenericMapping); if (!NT_SUCCESS (Status)) { PS_SET_BITS (&Thread->CrossThreadFlags, PS_CROSS_THREAD_FLAGS_DEADTHREAD); if (CreateSuspended) { (VOID) KeResumeThread (&Thread->Tcb); } KeReadyThread (&Thread->Tcb); ObDereferenceObjectEx (Thread, 2); return Status; } } Status = ObInsertObject (Thread, AccessState, DesiredAccess, 0, NULL, &LocalThreadHandle); if (AccessState != NULL) { SeDeleteAccessState (AccessState); } if (!NT_SUCCESS (Status)) { // // The insert failed. Terminate the thread. // // // This trick is used so that Dbgk doesn't report // events for dead threads // PS_SET_BITS (&Thread->CrossThreadFlags, PS_CROSS_THREAD_FLAGS_DEADTHREAD); if (CreateSuspended) { KeResumeThread (&Thread->Tcb); } } else { try { *ThreadHandle = LocalThreadHandle; if (ARGUMENT_PRESENT (ClientId)) { *ClientId = Thread->Cid; } } except(EXCEPTION_EXECUTE_HANDLER) { PS_SET_BITS (&Thread->CrossThreadFlags, PS_CROSS_THREAD_FLAGS_DEADTHREAD); if (CreateSuspended) { (VOID) KeResumeThread (&Thread->Tcb); } KeReadyThread (&Thread->Tcb); ObDereferenceObject (Thread); ObCloseHandle (LocalThreadHandle, PreviousMode); return GetExceptionCode(); } } KeQuerySystemTime(&CreateTime); ASSERT ((CreateTime.HighPart & 0xf0000000) == 0); PS_SET_THREAD_CREATE_TIME(Thread, CreateTime); if ((Thread->CrossThreadFlags&PS_CROSS_THREAD_FLAGS_DEADTHREAD) == 0) { Status = ObGetObjectSecurity (Thread, &SecurityDescriptor, &MemoryAllocated); if (!NT_SUCCESS (Status)) { // // This trick us used so that Dbgk doesn't report // events for dead threads // PS_SET_BITS (&Thread->CrossThreadFlags, PS_CROSS_THREAD_FLAGS_DEADTHREAD); if (CreateSuspended) { KeResumeThread(&Thread->Tcb); } KeReadyThread (&Thread->Tcb); ObDereferenceObject (Thread); ObCloseHandle (LocalThreadHandle, PreviousMode); return Status; } // // Compute the subject security context // SubjectContext.ProcessAuditId = Process; SubjectContext.PrimaryToken = PsReferencePrimaryToken(Process); SubjectContext.ClientToken = NULL; AccessCheck = SeAccessCheck (SecurityDescriptor, &SubjectContext, FALSE, MAXIMUM_ALLOWED, 0, NULL, &PsThreadType->TypeInfo.GenericMapping, PreviousMode, &Thread->GrantedAccess, &accesst); PsDereferencePrimaryTokenEx (Process, SubjectContext.PrimaryToken); ObReleaseObjectSecurity (SecurityDescriptor, MemoryAllocated); if (!AccessCheck) { Thread->GrantedAccess = 0; } Thread->GrantedAccess |= (THREAD_TERMINATE | THREAD_SET_INFORMATION | THREAD_QUERY_INFORMATION); } else { Thread->GrantedAccess = THREAD_ALL_ACCESS; } KeReadyThread (&Thread->Tcb); ObDereferenceObject (Thread); return Status; }

 

首先说下该函数的几个参数,

ThreadHandle是一个输出参数,创建成功会包含创建线程的句柄。

DesiredAccess包含了对新线程的访问权限。

OBjectAttributes是可选参数,代表了线程的属性。

ProcessHandle指向一个进程的句柄,创建好的线程将运行在该进程的环境中。

ProcessPointer指向所属进程的Eprocess对象,该参数在创建系统线程时指向PsInitialSystemProcess对象,在创建用户线程的时候为null。

ClientId返回新线程的ClientID结构。

ThreadCOntext提供了新线程的执行环境,它代表了用户线程的初始环境,如果为null则表示为系统线程。

InitialTeb参数为新线程的Teb结构提供初始值,系统线程没有用户空间堆栈也没有Teb,所以如果创建的是系统线程,则该参数为null

CreateSuspended参数指明新线程创建后是否被挂起,如果为true,则创建新线程后不会立刻运行,而必须等到显示调用NtResumeThread函数让线程运行。

StartRoutine参数指定了系统线程启动函数地址,所以用户线程情况下,该参数为null。

StartContext参数指定了系统线程启动函数执行环境,用户线程下同样为null。

下面分析下具体的代码:

1、创建并初始化EThread对象

之前有提到,Ethread对象时线程的执行体对象,且第一个字段就是Kthread对象,windows采用的内核线程模型,每个线程都有一个这样的结构。而初始化主要是设置线程的所属进程,以及设置CID中进程ID,从全局句柄表PspCidTable分配一个可用的项来表示此线程,并返回线程ID,然后就是初始化线程的一些链表。

2、判断参数ThreadContext是否为空。

正如前面所提到的,这里若非空就表示创建的是用户线程,那么就需要创建一个Teb,这里调用了MmCreateTeb函数。此函数后面再说。然后就是一个try,尝试为用户线程初始化内核线程对象Ethread,实际上是初始化了Thread的startaddress为ThreadContext中的RiP,并将ThreadContext中的eax设置到线程的win32StartAddress域,eax保存的是用户指定的线程执行函数。然后调用KeInitThread函数对线程进行初始化,这里主要就是根据进程的属性对线程做设置,包括优先级,亲和性,serviceTable等,注意这里设置的内核层thread对象Kthread.

3、如果上面参数为空

就表示这是一个内核线程,那么设置TeB=NULL.设置thread->startAddress=参数startRoutine,最后仍然是调用KeInitThread函数初始化线程

4、接下来就设置进程的活动线程数加一,调用InsertTailList把线程假如到进程的线程链表中,这里是站在执行体层的角度,操作的是EProcess和Ethread。

5、判断CreateSuspended参数,为true则把进程挂起。

6、最后经过一系列的设置,如线程的创建时间,引用计数等就调动keReadyThread设置线程就绪态,然后就准备被调度执行了。

其实上面说的还不是很详细,不少细节都忽略了,但是大体上几个重要步骤基本就是这样。理解了这些,windows线程也就基本了解了!!

 

感觉还是有很多不足的地方,后面再慢慢补充吧,不想写了!!

 



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有