1. 前缀是怎么来的:先搞懂Windows内核的分层逻辑
1.1 从用户态到内核态:一条系统调用的完整路径
先聊一个基础问题:Windows内核那么大,为什么偏偏要搞前缀这种东西?答案其实就藏在系统调用的路径里。
你写一个普通的Win32程序,调CreateFile,这个API在kernel32.dll里经过一层包装后,会进入ntdll.dll的NtCreateFile,然后通过syscall指令陷入内核。内核接收到这条指令后,由系统服务分发层查一张表(也就是大家常说的SSDT,System Service Descriptor Table),找到对应的内核函数NtCreateFile并跳转过去。这个函数在执行体(Executive)层完成核心逻辑,过程中可能需要调用底层的内存管理器(Mm)、对象管理器(Ob)、I/O管理器(Io)等子系统的能力,最后硬件相关部分由HAL(硬件抽象层)兜底。
这一条链路里,每一层都有自己的“业务范围”,如果函数名不区分归属,你根本没法判断一个函数到底运行在哪一层、负责干什么。想象一下,你打开一个大型项目的源码,里面所有函数都叫do_something,那将是灾难。Windows内核的做法是:用前缀给每个子系统的函数“盖章”,看到Ke你就知道它是内核核心层的东西,看到Mm你就知道它跟内存管理有关,看到Io就知道它处理I/O请求。
所以,前缀不是微软工程师闲着没事定的命名规范,而是整个内核架构的“路标系统”。读内核代码、写驱动程序、分析蓝屏dump,第一步永远不是逐行读逻辑,而是先看函数前缀,把调用者所在的子系统层级定位出来。这一步做对了,后面的事情会顺很多。
另外一个容易被忽略的点是:前缀名称大多是瑞士德语或系统名词的缩写,比如Ke来自Kernel,Mm来自Memory Manager,Ex来自Executive。它们不是随机字母,而是有明确含义的缩写。熟悉这些缩写,等于掌握了一套内核源码的“速记语言”。
1.2 把前缀当成一张“内核地图”
我整理了一份最常用的前缀清单,你在任何内核模块里都会反复见到它们:
| 前缀 | 所属子系统 | 管辖范围举例 | 典型函数 |
|---|---|---|---|
| Nt/Zw | 系统服务分发层 | 系统调用入口、参数校验 | NtCreateFile、ZwQuerySystemInformation |
| Ke | 内核核心层 | 调度、IRQL、自旋锁、DPC、事件 | KeWaitForSingleObject、KeSetEvent |
| Ex | 执行体 | 内存池、快速互斥锁、工作线程、定时器 | ExAllocatePool2、ExAcquireFastMutex |
| Io | I/O管理器 | 驱动对象、设备对象、IRP、设备栈 | IoCreateDevice、IoCallDriver |
| Mm | 内存管理器 | 虚拟内存、锁定页面、MDL | MmProbeAndLockPages、MmCopyVirtualMemory |
| Ob | 对象管理器 | 对象引用、句柄、回调 | ObReferenceObject、ObRegisterCallbacks |
| Ps | 进程/线程管理器 | 进程、线程、通知回调 | PsCreateSystemThread、PsGetCurrentProcess |
| Se | 安全引用监控 | 权限、令牌、审计 | SeAccessCheck、SePrivilegeCheck |
| Cm | 配置管理器 | 注册表内核接口 | CmRegisterCallback、CmGetBoundTransaction |
| Rtl | 运行时库 | 字符串、内存、排序、加密辅助 | RtlInitUnicodeString、RtlEqualUnicodeString |
| Hal | 硬件抽象层 | 总线、中断、时钟 | HalGetInterruptVector、HalReadDmaCounter |
| FsRtl | 文件系统运行时库 | 文件系统过滤、缓存管理 | FsRtlAllocatePool、FsRtlIsNameInExpression |
这张表我有段时间直接贴显示器边上,写驱动时遇到不认识的情况先扫一眼,能省不少查资料的功夫。后面几节,我会挑几组容易混淆、坑最多、也最能体现“前缀学问”的详细拆解。
2. 最容易看花眼的一对:Nt 与 Zw
2.1 网上流传的说法为什么错了一半
凡是搞过一段时间Windows内核的人,基本都听过一句话:“用户态调用Nt前缀,内核态调用Zw前缀。”这句话流传极广,可惜它只说对了一半,而且恰恰是这“一半”误导了很多人。
真实情况是:在内核态的ntoskrnl.exe导出里,NtXxx和ZwXxx指向的是同一个函数体。你打开WinDbg,输入x nt!NtCreateFile和x nt!ZwCreateFile,搜到的地址大概率是同一个。那为什么还要分两个名字?
关键在于调用时的“前一模式”(PreviousMode)不同。系统服务分发层在处理系统调用时,会记录调用者来自用户态还是内核态。如果你在内核态直接调用NtCreateFile,系统会认为“这是一个内核态调用者”,于是跳过一些针对用户态的参数校验(比如用户传入的指针需要探针和异常处理);而如果你调用ZwCreateFile,微软的实现在语义上等价于“从内核态发起一次完整的系统调用”,它会把这个调用重新包装成一次系统服务分发,让参数校验和句柄解析都走完整流程。
一句话总结:Zw前缀版本会让你严格遵守“像用户态调用一样的”安全校验链路;Nt前缀版本则偏向于底层直通实现。这也是为什么WDK官方文档里,给驱动开发者的建议是“在驱动内部,请调用Zw版本,而不是Nt版本”。
我在实际开发中见过一个低级但真实的坑:某驱动在DriverEntry里调用NtOpenFile打开配置文件,结果在开启Driver Verifier的测试机上直接蓝屏。排查结果是参数是用户态传入的内存指针,驱动里调Nt版本时没有让系统做主动参数校验,指针一非法就崩了。后来把Nt改成Zw,同样的代码跑得稳稳当当。也就是从那一刻起,我才对这两组前缀的差异留下深刻印象。
2.2 用户态看到的Nt与内核态看到的Nt,不是同一个东西
这里还有一个特别容易混淆的点:你在用户态程序里调用ntdll.dll导出NtCreateFile,和你在内核态看到NtCreateFile,名字一样,但它们本质上是两个层面的东西。
用户态的NtCreateFile是一个真正的系统调用入口stub,它负责把参数整理好,执行syscall指令进入内核。内核态里的NtCreateFile则是对应这个系统调用号的实现函数。这两者之间通过系统服务号建立联系,但你在源码层面看到的是同一个名字。如果初学阶段分不清这个区别,很容易在分析调用栈、设置内核断点时搞错位置。
举个具体例子:你用WinDbg双机调试时,如果要断在NtCreateFile上,得确认断的是ntdll!NtCreateFile还是nt!NtCreateFile。前者是用户态入口,断下去会看到很多应用层的调用;后者是内核函数体,断下去能看到完整的系统服务分发之后的内核逻辑。两者的调用栈完全不一样,调试策略也不一样。
再补一个经典差异:Zw前缀版本在系统服务分发时会把PreviousMode设置为KernelMode,Nt前缀版本则会保持UserMode。PreviousMode直接影响后续一系列行为,比如ProbeForRead是否执行、句柄表访问权限是否受限、异常是否转换为状态码返回,等等。很多“同样的函数名,不同结果”的诡异问题,源头都在这。
我在一次分析某安全软件驱动时见过这样的调用链:驱动通过ZwQueryInformationProcess获取进程信息,结果在部分系统上返回STATUS_ACCESS_DENIED。我们查了很久,最后发现不是函数本身的问题,而是调用前没有切换到正确的进程上下文,导致句柄解析失败。这类问题表面上是“Zw/Nt选错了”,本质上是对调用层级的理解不到位。
所以,关于Nt和Zw,我的建议很简单:
在内核驱动代码里,除非你能明确说出“为什么需要保留用户态语义的参数校验”,否则一律用Zw前缀版本。这是我踩过坑之后的基本纪律。
3. 一张表看懂最常用的内核前缀
3.1 核心类前缀:Ke 与 Ex,职责分工的经典范例
Ke前缀来自Kernel,是Windows内核最底层的那一拨函数,主要管线程调度、中断级(IRQL)、同步原语、DPC、时钟等。你把Ke理解成内核的“基础设施部”就行了,它不关心你是哪个驱动,只负责保证CPU和线程的这些底层机制正常运转。比如KeWaitForSingleObject就是等待一个内核对象进入 signaled 状态,KeSetEvent则是把事件对象设为触发态,KeQuerySystemTime用来获取系统时间,KeDelayExecutionThread让当前线程睡眠一段时间。
Ex前缀来自Executive,属于执行体层,它向上提供服务,向下调用底层的Ke和Mm。Ex最常见的场景是内存池分配:ExAllocatePool2负责从非分页池或分页池中分配一块内存,ExAcquireFastMutex用于获取快速互斥锁,ExQueueWorkItem把一个工作项排进系统工作线程。它们和Ke的区别在于:Ex更偏向“给上层子系统提供通用能力”,而Ke更偏向“直接接触CPU和调度的硬核机制”。
在实际代码里,这两类前缀经常配合使用。比如你要创建一个内核定时器,用ExCreateTimer创建,但等待定时器对象触发时会用KeWaitForSingleObject;你分配了一块非分页内存,后续用KeAcquireSpinLock保护它不被并发访问。所以在读代码时,看到Ke和Ex交替出现,基本就能判断出这段代码在做“底层同步 + 资源管理”的活。这也是为什么很多新手在跟读代码时蒙圈:单看一个函数没法理解逻辑,得把同一套前缀家族的函数串起来看。
3.2 子系统前缀:Io、Mm、Ob、Ps、Se、Cm,每个前缀是一个部门
再往上一层,就是各类子系统前缀,它们各自管理一个“业务部门”。
Io前缀属于I/O管理器,是所有驱动打交道最频繁的子系统。一个驱动想要创建设备对象,调用IoCreateDevice;想要处理上层下来的请求包,会收到IRP;想要把一个IRP转交给下层的驱动,调用IoCallDriver;想要根据设备名字找到设备对象,调用IoGetDeviceObjectPointer。看到Io前缀,你要意识到这段代码正在跟设备栈、IRP或者驱动对象打交道。凡是出现IRP的地方,几乎一定绕不开Io前缀的函数。
Mm前缀属于内存管理器。它负责虚拟地址和物理地址的转换、用户缓冲区的锁定和映射、MDL(内存描述符列表)管理等。比如MmProbeAndLockPages用来检测并锁定一段用户缓冲区,防止它被换出物理内存,DMA操作前经常要用;MmMapLockedPagesSpecifyCache把锁定页面映射到内核地址空间;MmCopyVirtualMemory在内核态和用户态地址空间之间复制数据。看到Mm前缀,就预示这段代码要碰内存页表或缓冲区相关的高危操作,IRQL限制也要特别小心。
Ob前缀属于对象管理器。Windows内核中一切皆为对象:设备对象、文件对象、线程对象、事件对象。ObReferenceObject增加对象引用计数,ObDereferenceObject释放引用,ObRegisterCallbacks则用来注册对象操作回调——这是很多安全软件拦截进程句柄操作的关键API。看到Ob,基本是在对某个内核对象的生命周期或句柄操作做手脚。
Ps前缀属于进程/线程管理器。PsGetCurrentProcess获取当前进程的EPROCESS结构,PsCreateSystemThread创建内核系统线程,PsSetCreateProcessNotifyRoutine注册进程创建通知回调,PsLookupProcessByProcessId根据PID找到进程对象。看到Ps,你就在跟进程、线程的生命周期管理打交道。
Se前缀属于安全引用监控器。它负责权限检查、令牌操作、审计等安全策略。SeAccessCheck检查是否授予特定访问权限,SePrivilegeCheck检查令牌是否拥有某特权,SeTokenIsAdmin告诉你当前令牌是不是管理员。绝大多数驱动都不会直接跟Se打交道,但一旦出现,它往往意味着这段代码在做权限判断或令牌提升/降权操作。
Cm前缀属于配置管理器,实际就是内核里的注册表接口。CmRegisterCallback注册注册表操作回调,CmGetBoundTransaction与事务相关。看到Cm,先想一下:它要读注册表吗?它要拦注册表修改吗?
3.3 看后缀判断风险等级:WithTag、Unsafe、NoFail 的含义
前缀负责定位“谁家的函数”,后缀负责告诉你“这个函数有哪些附加行为和风险”。这一点在内核编程里尤其重要,因为同一个功能家族,不同后缀版本的语义天差地别。
拿内存分配举例。老一代的API叫ExAllocatePoolWithTag,四个参数:PoolType、NumberOfBytes、Tag,其中PoolType要手动指定NonPagedPool还是PagedPool。新一代API叫ExAllocatePool2,只有三个参数:PoolFlags、NumberOfBytes、Tag,PoolFlags是位掩码,区分分页池和非分页池,还额外支持对齐、加密等选项。为什么微软改名?因为旧API有一个隐患:某些情况下调用者忘了指定正确的池类型,导致在错误的内存段分配,但系统不会立即报错,只是后续访问时触发蓝屏。新API通过强制显式声明来降低这类低级错误。
后缀里最常见的WithTag意味着分配内存时要求带一个四字符标签,这个标签在Driver Verifier检测和内存泄漏排查里特别有用,Windbg的!pool命令能直接按标签过滤内存块。如果你在代码里看到裸的ExAllocatePool不带Tag,基本可以判断这段代码的作者偷懒,或者是从很老的内核代码里抄过来的。
再看Unsafe后缀。带Unsafe的函数往往是性能优化版,跳过了一部分安全检查或参数校验。新手看到MmMapLockedPagesSpecifyCache可能觉得参数多很复杂,但其实它还有一个MmMapLockedPages旧版接口,而文档里会明确提示尽量不要用新版不安全的函数。Unsafe并不是说“必然崩”,而是说“责任在调用者”。如果你没把握,就不要碰这些后缀。
NoFail后缀则代表该函数承诺不会失败——要么内存分配必定成功,要么内部会做兜底处理。这类函数不适合在苛刻的低内存条件下复用,因为它背后可能隐藏着自旋等待或者高IRQL重试逻辑。
读函数名时,养成“前缀定子系统,后缀定风险”的习惯,能让你在一堆陌生API里快速判别哪些是安全调用、哪些需要额外小心。
4. 从函数前缀延伸到内核调用链:读一个函数的“前中后”
4.1 前缀与IRQL的关系:在哪个层级用哪个函数
内核编程里一个绕不开的概念是IRQL(中断请求级别)。Windows在单CPU上通过提升IRQL来禁止某些中断和抢占,整个内核的同步设计都是围绕IRQL展开的。前缀正好能帮你快速判断一个函数是否可以安全地在当前IRQL下调用。
最常见的三个级别:PASSIVE_LEVEL是普通线程执行环境,几乎一切操作都行;DISPATCH_LEVEL是调度器级别,到这一层就不能访问分页内存了,也不能等待,因为调度器本身被禁用;DIRQL(设备IRQL)是更高级别的中断处理环境,只能做极有限的原子操作。
对应到函数前缀:MmProbeAndLockPages、MmCopyVirtualMemory这类需要访问用户态分页内存的函数,必须在PASSIVE_LEVEL调用;KeAcquireSpinLock可以在DISPATCH_LEVEL调用,但再往上就有限制;KeWaitForSingleObject在DISPATCH_LEVEL只能等待非分页内存中的对象,且不能用超时参数等于无限。IoCallDriver的IRQL限制则取决于设备栈的具体实现,有时在DISPATCH_LEVEL也可以发送IRP,但某些总线驱动会要求PASSIVE_LEVEL。
我见过很多崩溃案例,本质上都是低级错误:一个驱动在DPC例程里(运行在DISPATCH_LEVEL)直接调用MmProbeAndLockPages去访问用户缓冲区,结果一访问分页内存就触发页面错误,系统直接蓝屏。这类问题光看代码逻辑很难发现,因为逻辑本身完全正确,只是运行环境不符合IRQL要求。所以,每个使用内核API的开发者都应该养成查文档“IRQL”章节的习惯,再结合函数前缀判断当前上下文合不合法。
4.2 用前缀快速定位内核崩溃与调试信息
内核调试时,用函数前缀做“第一轮筛选”是最省时间的办法。举例说明,一次蓝屏后,你用WinDbg敲!analyze -v,系统会自动分析出调用栈。这时候不要急着看每一行干了什么,先扫一遍函数名前缀:
- 如果栈里出现
ExAllocatePool2、ExFreePoolWithTag,基本能确认是内存池操作出了问题,优先检查是不是池标签冲突、重复释放、或者分配大小异常。 - 如果出现
KeWaitForSingleObject、KeReleaseSpinLock,优先怀疑是死锁、IRQL升级失败、或者锁未正确初始化。 - 如果出现
IoCallDriver、IoCompleteRequest,优先检查是不是IRP被重复完成、设备栈卸载后还继续发送请求。 - 如果出现
ObReferenceObject、ObDereferenceObject,数值往往是引用计数没配对,导致对象提前释放或泄漏。 - 如果出现
MmProbeAndLockPages、MmUnlockPages,常常是缓冲区被锁定后没有解锁,或者锁定的页面集合被意外修改。
有一次分析某加速器驱动的崩溃,调用栈上全是Ob前缀的函数。追了一把发现驱动在进程退出回调里调用ObReferenceObject获取进程对象,但没有检查返回的指针是否为NULL——很多进程对象在回调阶段其实已经进入销毁流程,强行引用必然崩溃。这种问题,如果第一眼看到前缀就知道是对象生命周期管理的事,排查方向就能立刻锁定到回调注册和引用计数上,而不用从头到尾把整个驱动读一遍。
调试时还有个实用技巧:在WinDbg里用x nt!Nt*Query*查看所有以Nt开头且包含Query的系统服务函数,或者用x nt!*Pool*查看所有与池相关的函数。通过前缀和关键字组合搜索,能快速把一个子系统的函数家族完整列出来——这比翻网页搜索高效得多。比如你想确认某个文件系统过滤驱动到底用了哪些FsRtl函数,直接x 驱动模块!FsRtl*,导出的调用关系一目了然。
5. 常见问题与避坑记录
5.1 写驱动时最容易踩的几个前缀相关的坑
第一个坑是“驱动里直接调用NtCreateFile而不是ZwCreateFile”。前面说过,Nt版本会跳过部分参数校验,容易在开启Driver Verifier的环境里被当场抓包。你如果写的驱动需要适配不同Windows版本,官方驱动样本里清一色都是Zw前缀,照做不会有错。
第二个坑是“混用旧API和受管API”。Windows 10 2004之后,微软把ExAllocatePoolWithTag等旧API标记为弃用,新驱动如果还继续用,在Win11上可能直接编译不通过,或者运行时报错。正确的做法是用新版本ExAllocatePool2,并且标记池类型时看清楚POOL_FLAG_NON_PAGED和POOL_FLAG_PAGED的区别。我在迁移一个老驱动时就被这个坑卡了一整天:旧代码在非分页上下文用了PagedPool,运行几分钟后在随机时刻崩溃,后来把所有分配点统一改成ExAllocatePool2(POOL_FLAG_NON_PAGED, ...),问题立刻消失。
第三个坑是“忽略函数前缀对应的IRQL限制”。内核API的文档页里都会写IRQL: PASSIVE_LEVEL或<= DISPATCH_LEVEL。但实际开发中很容易在写回调函数时忘记当前环境。比如定时器DPC、完成例程中调用某些需要PASSIVE_LEVEL的函数,编译时不会报错,运行时才蓝屏。排查这类问题可以开启Driver Verifier的IRQL Check选项,它能在触发违规时直接提示你具体是哪个函数越级了。
第四个坑是“对句柄和对象指针傻傻分不清”。前缀为Zw的函数大多操作句柄,前缀为Ob的函数操作的是对象指针本身。你调ZwOpenFile拿到的是一个句柄,后续用这个句柄操作文件;但你在回调里从EPROCESS中获取句柄表时,得到的是一个HANDLE,需要结合ObReferenceObjectByHandle把它转换成对象指针。如果混淆这两类操作,就会出现“句柄表被释放后还在用句柄”的经典崩溃。
5.2 如何快速把不认识的函数“猜透”
读内核代码时遇到不认识的前缀,正确的做法不是立刻上网搜,而是先按“前缀+动词+对象+后缀”的公式拆解。
以IoCreateDeviceSecure为例:前缀Io告诉你这是I/O管理器的函数,动词Create表示创建,对象Device表示创建设备对象,后缀Secure表示安全描述符相关。拆解完你会发现,即使不知道参数列表,你也能猜出它的大致作用:创建一个带安全描述符的Windows设备对象。
再比如MmGetSystemRoutineAddress:前缀Mm(内存管理器)、动词Get(获取)、对象SystemRoutineAddress(系统例程地址),连起来就是“获取系统例程的地址”。这个函数常被驱动用来动态解析其他内核导出函数,避免直接链接。
拆解不出来的情况,再去查MSDN和WDK的头文件。微软提供的wdm.h、ntddk.h头文件里,所有函数都有注释,而且头文件本身按子系统分区,通过前缀搜索能顺藤摸瓜找到相关的一组函数。
在WinDbg里还有一种“土方法”:x nt!前缀*关键字*。比如我想找“所有Ex开头的、和处理工作项相关的函数”,就输入x nt!Ex*Work*,系统会把所有符合条件的函数名列出来。这个方法对排查谁实现了某个功能特别有用,比在源码里crtl+f快多了。
5.3 版本兼容问题:为什么微软老爱给函数改名改参数
很多新手不理解,为什么好好的ExAllocatePoolWithTag不用了,非要改成ExAllocatePool2。原因有几个。
第一是安全性。旧API的PoolType参数太灵活,调用者可以随意传NonPagedPool或PagedPool,但忘了传NonPagedPoolExecute之类带执行权限的池类型,导致DEP(数据执行保护)直接拦下代码页。新API把池类型映射成POOL_FLAG位掩码,显式区分NON_PAGED、PAGED、EXECUTE等属性,开发者必须明确告诉系统自己要什么,出错的概率自然降低。
第二是工具链可观测性。新API强制带Tag和PoolFlags,Driver Verifier可以基于这些元数据做更精确的内存污染检查、泄漏追踪。旧API在这方面的信息量少,微软内部排查问题也费劲。
第三是为新特性铺路。比如POOL_FLAG_SESSION、POOL_FLAG_RAISE_ON_FAILURE这类选项就是新版才支持的。如果你想让自己写的驱动在未来新Windows版本上继续编译运行,及时跟进新函数是必须的。
实践中的迁移做法是用条件编译:
#if NTDDI_VERSION >= NTDDI_WIN10_VB pBuffer = ExAllocatePool2(POOL_FLAG_NON_PAGED, size, 'TAG1'); #else pBuffer = ExAllocatePoolWithTag(NonPagedPool, size, 'TAG1'); #endif这样一个驱动源码可以同时兼容Win7到Win11的不同SDK版本,编译时通过宏自动选择API。我在维护老项目时常用这个模式,既能保持老系统兼容,又能在新系统上使用新API的特性和安全校验。
6. 把前缀知识落到实际调试里:一个完整示例
聊了这么多概念,最后用一个实际场景收个尾。
假设你收到一个崩溃dump,WinDbg显示BugCheck 0xA(IRQL_NOT_LESS_OR_EQUAL),调用栈摘录如下:
nt!KeWaitForSingleObject MyDriver!MySyncRoutine+0x3f MyDriver!DeviceControl+0x1a2 nt!IofCallDriver nt!IopXxxControlFile第一步,用前缀判断:KeWaitForSingleObject是内核核心层的等待函数,IofCallDriver是I/O管理器向下分发IRP的入口,IopXxxControlFile是IO控制分发的内部函数。这说明崩溃发生在驱动处理DeviceIoControl请求的路径上,并且驱动在分发例程里做了等待操作。
第二步,看细节:KeWaitForSingleObject在IRQL不等于PASSIVE_LEVEL时调用会失败。既然BugCheck是IRQL_NOT_LESS_OR_EQUAL,大概率是驱动在DISPATCH_LEVEL级别的完成例程里错误地调用了阻塞等待操作。此时该做的不是重写整个驱动,而是把那一段等待逻辑移动到工作线程中执行,或者在调用前判断当前IRQL并做相应处理。
这个排查流程里,前缀承担了两个关键作用:一是从函数名直接看出崩溃所在子系统,不用逐行翻代码;二是根据前缀对应的IRQL语义,快速建立“这个函数能不能在这个上下文里被调用”的判断。没有前缀这套命名体系,内核调试的学习曲线会陡峭得多。
内核编程永远绕不开“上下文”三个字。前缀帮助你看清上下文,后缀帮助你看清风险,剩下的就是耐心和经验了。
我在实际工作中养成的习惯是:拿到一段内核代码,先扫一眼所有函数前缀,把每个子系统的调用频率统计一遍,然后优先阅读频率最高的那一两条链路。这个方法帮我快速理解了很多老驱动的整体结构,也让我能很快识别出哪些地方是不规范的危险调用。你也可以试试。