现在的位置: 首页 > 综合 > 正文

NT 内核的进程调度分析笔记

2013年04月16日 ⁄ 综合 ⁄ 共 11429字 ⁄ 字号 评论关闭

文章作者:sinister
信息来源:白细胞

Author: sinister
Email:   sinister@whitecell.org


Homepage:http://www.whitecell.org


Date:   2005-11-16

2005-2-15

众所周知 nt kernel 是多任务抢占试方式运行的,在非 SMP 系统上,每个进程分配特
定的CPU 时间片来达到执行目的,这样看上去就象多个任务在同时运行。而 nt kernel
又是以线程调度为核心的,这样线程切换了也就意味着当前进程的切换。系统不断重复
这个过程,来使每个进程得以运行。在介绍流程前,需要先了解几个系统内部结构:KPC
R、ETHREAD、EPROCESS、可以说,涉及到进程调度的函数基本都是对这几个重要结构的
设置与填充。关于各结构的细节,对系统内核有所了解的人自然都很熟悉,我就不再多
废话了。这里是结合进程调度来说。KPCR 这个结构存放的是当前 CPU 所正在处理的各
种信息,其中包括了当前正在运行的线程 ETHREAD 结构。而 ETHREAD 结构又与 EPROC
ESS 结构是相互关联的。这也就是很多核心函数通过 KPCR 启始地址 0xFFDFF000 + 偏
移就能够得到当前正在运行的线程与进程的原因,如 KeGetCurrentThread()、IoGetCu
rrentProcess() 等。这也表明在非 SMP 系统上,某时间段内当前 CPU 处理的进程只
可能有一个。当进行进程调度时系统将根据当前所存活的进程来选择让哪个线程 ETHR
EAD 结构来替换 KPCR 中的 ETHREAD,使其变为正在运行的状态。那么又是如何触发进
程调度请求能让所有进程均得以执行的呢?开始已经提到过,每个进程分配特定的 CPU
时间片来达到执行目的,而系统的 CPU 时钟中断确定了每个进程分配的时间片。也就
是当系统 CPU 时钟中断触发时,产生的进程调度请求。在详细分析调度流程与各函数
分支前,我们先来看一下大致的流程。首先当 CPU 时钟中断触发时,系统将调用 KiDi
spatchInterrupt(),比较当前进程分配的时间片,如用完后会调用 KiQuantumEnd()
根据各线程优先级等信息用特定的调度算法选择新的线程(ETHREAD),然后将其返回
值,一个 ETHREAD 结构作为参数,来调用 SwapContext() 设置 ETHREAD,EPROCESS 中
的各项参数,并替换 KPCR 中的相应结构完成线程切换,调度到另一个进程(EPROCESS)
的线程(ETHREAD)继续执行。(当线程等待某一事件(Event)或信号量(Semaphore)
时会自动放弃当前时间片) 。通过上面的大致分析,我们可以看出与进程调度密切相关
的几个函数,KiDispatchInterrupt() 与 SwapContext(),下面我们将就这两个关键的
调度函数进行详细的分析。

首先来看下 KiDispatchInterrupt() 函数,当调用此函数时,首先得到当前 KPCR 自身
结构与DCP链表头,并比较当前是否有 DPC 正在处理,如果有 DPC 正在处理,则设置
DPC 异常炼。如果没有,则直接跳转到比较 KPCR 中 QuantumEnd 值,QuantumEnd 表示
当前 KPCR 中正在处理的线程时间片总数。此值是是根据当前运行线程 ETHREAD->Quant
um 中的值来填充的,就是说 KPCR 中 QuantumEnd 值不为0并不代表当前线程不允许切
换,此时还需要调用 KiQuantumEnd() 来近一步判断是否允许切换。所以当前值不为0时
则跳转到 KiQuantumEnd() 函数处做进一步判断。KiQuantumEnd() 函数会取当前线程
(ETHREAD)结构中的 Quantum 值来进行判断是否为0,根据线程优先等参数调用 KiFind
ReadyThread() 函数来选择一个新的线程填充 KPCR中的 NextThread ,并将 NextThread
作为函数返回值,跳转到相应地址继续进行线程切换。如果返回值返为空,则表示无法进
行切换,函数返回。但如果 KPCR 中的 QuantumEnd 本身已为0,则表示当前线程 ETHREAD
分配时间片已经用完,可以进行线程换,所以继续比较 KPCR 中是否有下一个线程(偏移
NextThread),如果KPCR 中下一线程(偏移NextThread)偏移为空,则表示没有就绪线程
可切换,直接跳转到返回地址,完成函数调用。如果不为空的话,则可继续进行线程切换。
到此所需的基本参数都已经准备就绪,下面要做的就是把 KPCR 中下一个线程(偏移Next
Thread),替换成 KPCR 中当前线程(偏移CurrentThread),并将 KPCR 中下一线程(
偏移NextThread)清0,调用 KiReadyThread() 就绪刚刚设置好的下一线程(偏移NextTh
read),也就是现在为当前线程 (偏移CurrentThread)。最后调用 SwapContext() 函
数完成最终的切换。

下面来看一下 SwapContext() 函数的实现,上面提到过 SwapContext() 是在 KiDispatch
Interrupt() 函数中调用的,用来完成最终的线程切换。函数首先设置要切换的新线程状
态(NextThread->Status) 为运行状态。接下来判断当前是否有 DCP 列程正在运行(
KPCR 中的 DpcRoutineActive 是否为 0,不为0则表示当前有 DPC 处理,微软规定在进
行线程调度时不允许 DPC 列程运行,否则将系统崩溃,其实这不是必须的,仅仅是微软
的规定而已) 如果有则跳转到调用 KeBugCheck() 函数处,系统崩溃。如果没有则继续
取要切换的新线程(NextThread->DebugActive)调试标志状态,并赋给 KPCR 中 Debug
Active 。保存 ESP 到要被切换的旧线程 (CurrentThread->KernelStack)内核堆栈中,
并将要切换的新线程(NextThread->InitalStack,NextThread->StackLimit)中的堆栈
启始地址与大小赋给 KPCR 中的相应位置。继续取要切换的新线程(NextThread->NpxSta
te)中的 NPX 状态与CR0 的 NPX 状态进行比较,如不相等跳转到重新设置 CR0 处进行
处理。刷新 CR0 后回跳转回来继续下面的运行。(CR0 中的 NPX 位状态是 CPU 通过某
几个指令触发一个异常后进入一个特殊的状态来处理浮点指令,这时是不允许线程切换的,
所以不相同的情况下需要重新刷新 CR0 状态。 )接下来进行模式判断,判断要切换的新
线程(NextThread)是否运行在 V86 模式下,如果是继续调整内核堆栈空间。如果不是则
跳过调整。从 KPCR 中得到KTSS 地址,并将 NPX 标志位保存到 KTSS 中的 ESP0 处(这
样不论是否为 V86 模式下运行的线程都可以共享)。此时各项标志,结构与参数都已就绪,
下面的工作就是要进行具体的切换过程了。首先取要切换的新线程(NextThread->Kernel
Stack)的内核堆栈赋与当前内核堆栈指针ESP,并设置 KPCR 中的用户堆栈(TEB)为要切
换的新线程(NextThread->TEB)的用户堆栈(TEB)。然后将用户堆栈(TEB)放入 KPCR
中 GDT 的相应结构中。比较要被切换的旧线程(CurrentThread->EPROCESS)中的进程,
是否与要切换的新线程(NextThread->EPROCESS)中的进程相等?也就是判断要切换的进
程是否为当前进程,如果是则不刷新当前页目录表(CR3)以及其他相关值,而直接跳转到
添加当前进程切换计数与判断当前线程是否存在 Pending 的 APC 调用处,然后退出,完
成切换。如果要切换的线程不是当前进程,则从要切换的新线程(NextThread->EPROCESS)
中取出当前进程,并从当前进程(EPROCESS->DirectoryTableBase)中得到页目录表来更新
KPCR 中 KTSS 中的 TSSCR3 的值与 CR3 寄存器中的值,(这也就是为什么 CR3 总指向当
前进程页目录地址)继续将当前进程(EPROCESS->IopmOffset)中 IOPM 值赋与 KPCR 中
KTSS 中的 IOPM。再比较当前进程(EPROCESS->LdtDescriptor)中的 LDT 是否为空,如果
不为空则从 KPCR中取得 KGDT 的位置,并从 KGDT 中索引到 LDT,把当前进程(EPROCESS
->LdtDescriptor)中的 LDT 赋与 KPCR 中的LDT。再得到 KPCR 中 KIDT 的位置,把当前
进程(EPROCESS->Int21Descriptor)中的 INT 21中断赋与 KPCR 中 KIDT中的相应位置,
使当前进程可以调用 INT 21 。最后调用 LLDT 使当前所有设置生效。(按理说 NT 内核
中 32 位应用程序是不使用 LDT 的但为什么在线程切换中会有设置LDT的部分呢?这是为
了向下兼容 16 位的应用程序,当调度到一个 16 位的应用程时则会特意为它分配 LDT 并
且使 IDT 中的 INT 21 有效,玩过 DOS 的人都知道 INT 21 是 DOS 下的系统调用,可
以试着运行一个 16 位的 DOS 程序,然后观察下 IDT 表就会发现,原来没有用到的 INT
21 会被设置成一个 16 位的 TrapGate)否则如果为空则设置成不使用 LDT,把要切换的
新线程(NextThread->ContextSwitches)中的切换次数与 KPCR 中的切换总和各加一,
恢复异常链,并比较要切换的新线程(NextThread->KernelApcPending)中的 APC 调用
是否没有完成 ,如果当前 APC 状态没有完成的话则判断当前是否可以处理 APC 调用,
如果不能则设置返回标志为 Pending 完成线程切换的所有工作并返回。否则设置当前
IRQL 为 APC LEVEL 并调用 HalRequestSoftwareInterrupt() 函数来处理 APC Pending
状态,处理完成后清除 Pending 状态,完成线程切换的所有工作并返回。如果当前 APC
状态完成的话,则恢复各寄存器和标志寄存器的值并返回,完成线程切换的所有工作。

当调用 KiDispatchInterrupt() 函数时,

:u KiDispatchInterrupt l 1000
ntoskrnl!KiDispatchInterrupt
0008:80467DD0 MOV   EBX,[FFDFF01C]
0008:80467DD6 LEA   EAX,[EBX+00000800]

得到当前 KPCR 自身结构与DCP链表头,EAX=DPC,EBX=KPCR

0008:80467DDC CLI
0008:80467DDD CMP   EAX,[EAX]
0008:80467DDF JZ   80467DFE

关中断,并比较当前 DPC 是否为空,如果为空,直接跳转到比较 KPCR 中
QuantumEnd 值和比较是否有下一个线程(ETHREAD)结构

0008:80467DE1 PUSH   EBP
0008:80467DE2 PUSH   DWORD PTR [EBX]
0008:80467DE4 MOV   DWORD PTR [EBX],FFFFFFFF
0008:80467DEA MOV   EDX,ESP
0008:80467DEC MOV   ESP,[EBX+0000081C] 注释: 得到 DPC 堆栈
0008:80467DF2 PUSH   EDX
0008:80467DF3 MOV   EBP,EAX
0008:80467DF5 CALL   804633E7   注释: KiRetireDpcList()函数
0008:80467DFA POP   ESP
0008:80467DFB POP   DWORD PTR [EBX]
0008:80467DFD POP   EBP

设置 DPC 异常链

0008:80467DFE STI
0008:80467DFF CMP   DWORD PTR [EBX+00000870],00   注释: QuantumEnd 线程时间片
0008:80467E06 JNZ   80467E5A

如果 KPCR 中 QuantumEnd 值不为 0 则表示可能当前线程(ETHREAD)时间片没有用完,
跳转到判断当前线程(ETHREAD) 是否可以进行切换。

0008:80467E08 CMP   DWORD PTR [EBX+00000128],00   注释: NextThread (ETHREAD结构)
0008:80467E0F JZ   80467E59

是否有下一个线程,如果 KPCR 中下一线程 NextThread(ETHREAD)偏移为空,则直接跳
转到返回地址,完成函数调用。

0008:80467E11 MOV   EAX,[EBX+00000128]

此时 EAX = NextThread (ETHREAD结构)

0008:80467E17 SUB   ESP,0C
0008:80467E1A MOV   [ESP+08],ESI
0008:80467E1E MOV   [ESP+04],EDI
0008:80467E22 MOV   [ESP],EBP
0008:80467E25 MOV   ESI,EAX
0008:80467E27 MOV   EDI,[EBX+00000124] 注释: CurrentThread (ETHREAD结构)
0008:80467E2D MOV   DWORD PTR [EBX+00000128],00000000
0008:80467E37 MOV   [EBX+00000124],ESI
0008:80467E3D MOV   ECX,EDI
0008:80467E3F CALL   8042F944 注释: KiReadyThread函数

把 KPCR 中下一个线程 NextThread (ETHREAD) 结构,替换成 KPCR 中当前 Current
Thread(ETHREAD)线程结构,并将 KPCR 中下一线程 NextThread (ETHREAD) 偏移清0,
调用 KiReadyThread() 就绪刚刚设置好的下一线程 NextThread(ETHREAD) 结构,现在
为当前线程 CurrentThread (ETHREAD) 结构。

0008:80467E44 MOV   CL,01

设置 APC IRQL 标志,SwapContext() 调用会先设置当前IRQL 为 APC_LEVEL。

0008:80467E46 CALL   80467E70 注释: SwapContext函数
0008:80467E4B MOV   EBP,[ESP]
0008:80467E4E MOV   EDI,[ESP+04]
0008:80467E52 MOV   ESI,[ESP+08]
0008:80467E56 ADD   ESP,0C

调用 SwapContext() 完成线程切换。(__fastcall 调用规范,平衡堆栈)

0008:80467E59 RET 注释: KiDispatchInterrupt 函数调用完毕,返回。

0008:80467E5A MOV   DWORD PTR [EBX+00000870],00000000 注释: QuantumEnd
0008:80467E64 CALL   804306D2             注释: KiQuantumEnd
0008:80467E69 OR   EAX,EAX             注释: EAX = NextThread(ETHREAD)
0008:80467E6B JNZ   80467E17
0008:80467E6D RET 注释: KiDispatchInterrupt 函数调用完毕,返回。

设置 KPCR 中 QuantumEnd 值为 0,并调用 KiQuantumEnd 相应处理,KiQuantumEnd 函数
会取当前线程(ETHREAD)结构中的 Quantum 值来进行判断是否为0,如果可以进行线程切
换,则根据线程优先等参数调用 KiFindReadyThread() 函数来选择一个新的线程填充 KPC
R 中的 NextThread (ETHREAD结构),并将 NextThread (ETHREAD结构) 赋给 EAX,并
跳转到线程切换地址进行切换。如果 EAX 返回为空,则表示无法进行切换,函数返回。

_______________________________________________________________________________________

以上基本把线程调度各分之走完,可以大概看出一个进程交替运行的流程,下面
的 SwapContext 函数完成实际切换。

SwapContext()

EBX = KPCR
ESI = NextThread
EDI = CurrentThread

0008:80467E6E MOV   EDI,EDI
0008:80467E70 OR   CL,CL
0008:80467E72 MOV   BYTE PTR ES:[ESI+2D],02 (ETHREAD->State)

设置要切换的新线程NextThread(ETHREAD)为运行状态。

0008:80467E77 PUSHFD
0008:80467E78 MOV   ECX,[EBX]
0008:80467E7A CMP   DWORD PTR [EBX+0000080C],00
0008:80467E81 PUSH   ECX
0008:80467E82 JNZ   80467F7D

线程切换时不允许有 DPC 列程产生,所以先比较 KPCR 中的
DpcRoutineActive 是否为 0,不为0则表示当前有 DPC 处理,
跳转到 KeBugCheck() 函数处显示蓝屏。

0008:80467E88 MOV   EBP,CR0   注释:EBP = CR0
0008:80467E8B MOV   EDX,EBP   注释:EDX = CR0
0008:80467E8D MOV   CL,[ESI+2C]
0008:80467E90 MOV   [EBX+50],CL

取要切换的新线程NextThread(ETHREAD)调试标志状态,并赋给 KPCR 中
DebugActive 相应标志。

0008:80467E93 CLI
0008:80467E94 MOV   [EDI+28],ESP

将堆栈指针赋给要被切换的旧线程 CurrentThread(ETHREAD)中的 KernelStack。

0008:80467E97 MOV   EAX,[ESI+18]
0008:80467E9A MOV   ECX,[ESI+1C]
0008:80467E9D SUB   EAX,00000210
0008:80467EA2 MOV   [EBX+08],ECX
0008:80467EA5 MOV   [EBX+04],EAX

将要切换的新线程NextThread(ETHREAD)中的初始化堆栈(InitalStack)与
堆栈大小(StackLimit)赋给 KPCR 中的相应位置,以便处理。

0008:80467EA8 XOR   ECX,ECX
0008:80467EAA MOV   CL,[ESI+31]
0008:80467EAD AND   EDX,-0F
0008:80467EB0 OR   ECX,EDX
0008:80467EB2 OR   ECX,[EAX+0000020C]
0008:80467EB8 CMP   EBP,ECX
0008:80467EBA JNZ   80467F75

取要切换的新线程NextThread(ETHREAD)中的 NPX 位与 CR0 的 NPX 位
进行比较,如不相等跳转到重新设置 CR0 处进行处理。

0008:80467EC0 TEST   DWORD PTR [EAX-1C],00020000
0008:80467EC7 JNZ   80467ECC

判断当前是否为 V86模式,如果不是直接跳到取得 KTSS 处。

0008:80467EC9 SUB   EAX,10

如果是 V86 模式则继续调整内核堆栈空间。

0008:80467ECC MOV   ECX,[EBX+40]   注释:ECX = KPCR->KTSS
0008:80467ECF MOV   [ECX+04],EAX
0008:80467ED2 MOV   ESP,[ESI+28]  
0008:80467ED5 MOV   EAX,[ESI+20]
0008:80467ED8 MOV   [EBX+18],EAX   注释:EAX = TEB

从 KPCR 中得到 KTSS 地址,并将 NPX 位保存到 KTSS 中的 ESP0 处,取
要切换的新线程NextThread(ETHREAD)的内核堆栈(KernelStack )赋与
ESP,并设置 KPCR 中的用户堆栈(TEB) 为要切换的新线程NextThread
(ETHREAD)的用户堆栈(TEB)。

0008:80467EDB STI
0008:80467EDC MOV   ECX,[EBX+3C] 注释:ECX = KPCR->GDT
0008:80467EDF MOV   [ECX+3A],AX
0008:80467EE3 SHR   EAX,10
0008:80467EE6 MOV   [ECX+3C],AL
0008:80467EE9 SHR   EAX,08
0008:80467EEC MOV   [ECX+3F],AL
0008:80467EEF MOV   EAX,[EDI+44]
0008:80467EF2 CMP   EAX,[ESI+44]
0008:80467EF5 JZ   80467F20

将用户堆栈(TEB)放入 KPCR 中 GDT 的相应结构中。比较要被切换的
旧线程 CurrentThread(ETHREAD)中的 EPROCESS,与要切换的新线
程NextThread(ETHREAD)中的 EPROCESS 是否相等?也就是判断要切换
的是否为当前进程,如果是则不设置当前页目录表(CR3)以及其他相关
值,而直接跳转到添加当前进程切换计数与判断当前线程是否存在 Pending
的 APC 调用处,然后退出,完成切换。

0008:80467EF7 MOV   EDI,[ESI+44] 注释:EDI = NextThread->EPROCESS
0008:80467EFA XOR   EAX,EAX
0008:80467EFC MOV   GS,AX
0008:80467EFF MOV   EAX,[EDI+18] 注释:EAX = EPROCESS->DirectoryTableBase
0008:80467F02 MOV   EBP,[EBX+40] 注释:EBP = KPCR->KTSS
0008:80467F05 MOV   ECX,[EDI+30] 注释:EDI = EPROCESS->IopmOffset
0008:80467F08 MOV   [EBP+1C],EAX
0008:80467F0B MOV   CR3,EAX   注释:CR3 = EPROCESS->DirectoryTableBase
0008:80467F0E MOV   [EBP+66],CX
0008:80467F12 XOR   EAX,EAX
0008:80467F14 CMP   [EDI+20],AX  
0008:80467F18 JNZ   80467F47
0008:80467F1A LLDT   AX
0008:80467F1D LEA   ECX,[ECX+00]

如果要切换的线程不是当前进程,则从要切换的新线程NextThread(ETHREAD)
中取当前进程(EPROCESS),并从当前进程(EPROCESS)中得到页目录表来更
新KPCR->KTSS中的TSSCR3的值与 CR3 寄存器中的值,继续将当前进程(EPROCESS)中
IOPM 值赋与 KPCR-KTSS 中的 IOPM。再比较当前进程(EPROCESS)中的 LDT 是否为
空,如果不为空则跳转到设置 LDT 处执行。否则设置 LDT 为空。

0008:80467F20 INC   DWORD PTR [ESI+4C]
0008:80467F23 INC   DWORD PTR [EBX+000005C0]
0008:80467F29 POP   ECX
0008:80467F2A MOV   [EBX],ECX
0008:80467F2C CMP   BYTE PTR [ESI+49],00
0008:80467F30 JNZ   80467F36
0008:80467F32 POPFD
0008:80467F33 XOR   EAX,EAX
0008:80467F35 RET 注释: SwapContext 函数调用完毕,返回。

把要切换的新线程NextThread(ETHREAD)中的 ContextSwitches 与 KPCR 中的
KeContextSwitches 各加一,这两个值表示进行线程切换的次数。恢复异常链,
并比较要切换的新线程NextThread(ETHREAD)中的 KernelApcPending,如果当前
APC 状态是 Pending 的话则跳转到处理 APC Pending 地址继续。不为 Pending 的
话,则恢复各寄存器和标志寄存器的值并返回,完成线程切换的所有工作。

0008:80467F36 POPFD
0008:80467F37 JNZ   80467F3C
0008:80467F39 MOV   AL,01
0008:80467F3B RET

判断当前是否可以处理 APC 调用,如果不能则设置返回标志为 Pending 完成线程切换
的所有工作并返回。否则跳转到处理软中断地址继续。

0008:80467F3C MOV   CL,01   注释: IRQL = APC LEVEL
0008:80467F3E CALL   [HAL!HalRequestSoftwareInterrupt]
0008:80467F44 XOR   EAX,EAX
0008:80467F46 RET

设置当前 IRQL 为 APC LEVEL 并调用 HalRequestSoftwareInterrupt() 函数来处理
APC Pending 状态,处理完成后清除 Pending 状态,完成线程切换的所有工作并返回。

0008:80467F47 MOV   EBP,[EBX+3C]   注释: EBP = KPCR->KGDT
0008:80467F4A MOV   EAX,[EDI+20]   注释: EAX = EPROCESS->LdtDescriptor
0008:80467F4D MOV   [EBP+48],EAX
0008:80467F50 MOV   EAX,[EDI+24]
0008:80467F53 MOV   [EBP+4C],EAX
0008:80467F56 MOV   EAX,00000048
0008:80467F5B MOV   EBP,[EBX+38]   注释: EBP = KPCR->KIDT
0008:80467F5E MOV   ECX,[EDI+28]   注释: ECX = EPROCESS->Int21Descriptor
0008:80467F61 MOV   [EBP+00000108],ECX
0008:80467F67 MOV   ECX,[EDI+2C]
0008:80467F6A MOV   [EBP+0000010C],ECX
0008:80467F70 LLDT   AX
0008:80467F73 JMP   80467F20

从 KPCR 中取得 KGDT 的位置,并从 KGDT 中索引到 LDT,把当前进程(EPROCESS)中的
LDT 赋与 KPCR 中的 LDT。再得到 KPCR 中 KIDT 的位置,把当前进程(EPROCESS)中的
INT 21中断赋与 KPCR 中 KIDT 中的相应位置,使当前进程可以调用 INT 21 。最后调用
LLDT 使当前所有设置生效后跳转回添加线程切换次数与判断当前 APC Pending 处继续运
行。

0008:80467F75 MOV   CR0,ECX
0008:80467F78 JMP   80467EC0

重新设置 CR0 的 NPX 位,并向上跳转到判断是否为 V86 模式处继续。

0008:80467F7D PUSH   000000B8
0008:80467F82 CALL   ntoskrnl!KeBugCheck
0008:80467F87 RET

调用蓝屏函数,系统崩溃重新启动。

笔记是今年春节利用放假时间写的,当时分析到一半才发现原来 WIN2K 源代码中已
经包含了此部分,无奈已经把汇编进行了简单的注释,索性就这样写下去。错误之处再
所难免,还望得到您的指正。

参考资源: Windows 2000 源代码
感谢 FlashSky,SoBeIt 与我探讨。

WSS(Whitecell Security Systems),一个非营利性民间技术组织,致力于各种系统安全技术的研究。坚持传统的hacker精神,追求技术的精纯。
WSS 主页:http://www.whitecell.org/


WSS 论坛:http://www.whitecell.org/forums/

抱歉!评论已关闭.