- Windows内核编程
- (美)帕维尔·约西福维奇
- 2570字
- 2021-07-09 20:28:53
3.1 内核编程的一般准则
开发内核驱动程序需要Windows驱动程序开发工具包(WDK),其中包含了开发所需的头文件和库文件。内核API由C函数组成,本质上与用户模式应用开发很像。不过,这两者之间还是有很多不同之处。表3-1总结了用户模式编程与内核模式编程之间的一些重要差别。
表3-1 用户模式和内核模式开发之间的差别
3.1.1 未处理的异常
如果用户模式下出现的异常没有被程序所捕获,会造成进程过早中止。另一方面,内核模式代码这种隐含可信的代码,是无法从未处理的异常中恢复的。这样的异常会造成系统崩溃,出现烦人的蓝屏死机(BSOD)(在比较新的Windows版本里,崩溃屏幕有更多的颜色)。BSOD的出现初看像是一种惩罚,但它其实是一种基本的保护机制。其理念是:此时允许代码继续执行可能会给Windows系统造成不可逆转的损坏(比如删除重要的文件或者弄坏注册表),这些损坏可能导致系统无法启动。因此在此时立即停止一切,从而阻止可能的损坏,是一种更好的选择。我们会在第6章详细地讨论BSOD。
所有这些都至少能得出一个简单的结论:内核代码必须非常小心地、一丝不苟地编写,绝不能跳过任何细节和错误检查。
3.1.2 终止
当某个进程终止时,不管是因为正常结束、未处理的异常,还是因为外部代码中止了它,这个进程什么都不会泄漏:所有的私有内存会被释放,所有的句柄会被关闭,等等。当然,提早关闭句柄可能会造成数据的丢失,例如在将数据写到磁盘之前就关闭文件句柄,但是不会有资源泄漏,内核保证了这一点。
另一方面,内核驱动程序并不能提供这样的保证。如果驱动程序在依然保留着分配的内存或者打开的内核句柄时卸载了,那么这些资源不会被自动释放,只有下一次系统重启时才会被释放。
为什么会这样?内核不能跟踪驱动程序分配的内存和使用的资源,因此在驱动程序卸载时,这些资源能自动释放吗?
理论上来说,这的确是能做到的(虽然目前内核并不跟踪资源的使用情况)。真正的问题在于内核试图去做这种清理是件危险的事。内核无法知道驱动程序是不是有意泄漏那些资源。例如,一个驱动程序分配了一些缓冲区,把它们传递给另一个协作的驱动程序。第二个驱动程序可能会使用这些内存缓冲区并最终释放它们。如果在第一个驱动程序卸载时,内核想要释放这些缓冲区,第二个驱动程序就会在访问刚刚被释放的缓冲区时产生一个访问违例,从而导致系统崩溃。
再次强调,妥善地做好自己的清除工作,是内核驱动程序的责任,没有别的东西会去做这件事。
3.1.3 函数返回值
在典型的用户模式代码里,API函数的返回值有时候会被忽略。开发者在某种程度上会乐观地认为被调用的函数不大会失败。这种做法可能合适也可能不合适,视不同的函数而定。在最坏的情况下,会产生未处理的异常,从而导致进程崩溃,但系统会保持完整无损。
忽略从内核API返回的值会更加危险(请参看3.1.2节),通常必须避免这么做。就算是看上去完全“无辜”的函数也会由于未曾预料的原因而失败。所以这里的黄金法则是—永远都要检查内核API的返回状态值。
3.1.4 IRQL
中断请求级别(IRQL)是一个重要的内核概念,我们会在第6章详加讨论。就此处而言,一般情况下处理器的IRQL是0,特别是当用户模式代码正在执行时,处理器的IRQL永远都是0。在内核模式下,大多数时间IRQL依然是0,但并非永远是0。大于0的IRQL造成的影响将在第6章中讨论。
3.1.5 C++用法
在用户模式编程中,C++用了很多年,并且跟用户模式API调用结合在一起时,C++工作得很好。在内核代码中,微软从Visual Studio 2012和WDK 8开始官方支持C++。当然,并非必须使用C++,但是C++通过使用叫作资源申请即初始化(Resource Acquisition Is Initialization,RAII)的惯用法,在资源清理方面有一些好处。我们会相当多地使用这一惯用法来确保没有资源泄漏。
C++作为一种编程语言,几乎全部内容都能用在内核代码里。不过,内核里面没有C++运行时,因此一些C++特性就没法用了:
- 不支持
new
和delete
操作符,使用它们会导致编译失败。这是由于它们的正常操作是从用户模式堆分配内存,而在内核模式里这显然毫无意义。内核API里有接近于malloc
和free
这些C函数的“替代”函数,我们会在本章的后面部分讨论它们。然而,用类似于用户模式的C++的方式重载这些操作符,并调用内核的分配和释放函数这是可以的。在本章后面我们也会介绍怎么做。 - 非默认构造函数中的全局变量将不会被调用—没有哪里的代码会去调用它们。这些情况可以用下面这些方法来避免:
-
- 避免把代码放到构造函数中,而是创建一些
Init
函数,并显式地从驱动程序代码(比如DriverEntry
)中调用。 - 仅仅把类指针定义成全局变量,然后动态分配其实例。编译器会生成正确的代码调用构造函数。这需要有一个假设,即
new
和delete
操作符已经像本章后面描述的那样被重载了。
- 避免把代码放到构造函数中,而是创建一些
- C++异常处理的那些关键字(
try
、catch
、throw
)无法通过编译。这是因为C++的异常处理机制需要它自己的运行时,而在内核中没有这个运行时。异常处理只能通过结构化异常处理(SEH)—内核的异常处理机制来实现。我们会在第6章详细观察SEH。 - 在内核中不能用标准C++库。虽然标准库里大部分内容是基于模板的,但它还是无法编译。这是因为它依赖于用户模式库及其语义。也就是说,C++模板作为一个语言特性,在内核里可以使用,例如,能够用于为用户模式库里像
std::vector<>
、std::wstring
等类型创建内核的替换类型。
本书中的示例代码用到了一些C++,其中用得最多的特性有:
nullptr
关键字,表示一个空指针。auto
关键字,在声明和初始化变量时进行类型推断,对减少字面上的混乱、节约键入次数以及集中于代码关键部分很有用。- 在需要时会使用模板。
- 重载
new
和delete
操作符。 - 构造函数和析构函数,特别是在构造RAII类型时。
严格地说,驱动程序可以用纯C来写,不会有任何问题。如果读者喜欢这么写,请用.c而不是.cpp作为源文件的后缀,这样编译时会自动调用C的编译器。
3.1.6 测试和调试
用户模式代码的测试通常是在开发者的机器上进行的(如果所有的依赖项都能满足的话)。调试则一般是把一个调试器(大多数时候是Visual Studio)附加到一个运行的进程上(或者多个进程上)。
内核模式的测试一般在另一台机器上进行,通常会在一个运行在开发者机器上的虚拟机上。这能确保万一出现了BSOD,开发者的机器不会受影响。调试内核代码则必须用另一台机器来运行驱动程序。这是因为在内核模式下,一个断点的触发将会停下整个系统而不只是单个进程。这意味着需要在开发者的机器上运行调试器,用第二台机器(通常还是用虚拟机)执行驱动程序代码。这两台机器需要用某种机制连接起来,以便数据在宿主机(运行调试器的那台)和目标机之间传输。我们会在第5章细究内核调试。