2.1 内核编程的环境

    对于第一次接触内核编程的读者来说,先了解一下内核模式(Kernel mode)编程与在用户模式(User mode)下编写程序的不同之处是非常必要的。它们也可以称为用户态和内核态。虽然同样是用C语言所编写的程序,但是由于执行的方式、执行的环境都大不相同,因此可能出现的结果也不一样。读者需要有一个熟悉的过程。

2.1.1 隔离的应用程序

    对于许多只进行高级语言的用户模式的应用程序编程的读者而言,编写一个“程序”在大多数情况下等价于编写一个可执行的应用程序。在Windows下,这意味着建造一个exe文件。一个exe文件被双击执行后,在Windows系统中,产生一个“进程”(Process)。虽然在高级语言中很多细节被忽略,但是在单个进程内的编程具有以下的特点:
    (1)可以自由使用通用寄存器,不用关心这些寄存器被其他进程修改。换句话说,不同进程(本质上是线程)看似各自拥有一套通用寄存器。
    (2)原则上可以自由使用0~N范围内的内存空间。N的大小取决于操作系统的位数(32位或者64位)。在Windows上实际编程有一些限制,但这些细节暂时忽略,不用关心这些内存空间被其他进程修改。如果读者编写一个程序,对一个变量取地址,然后把这个地址设法传递给其他进程,那么另一个进程看见的地址中的内容是一样的吗?答案当然是否定的,每个进程的用户空间内存是隔离的。
    (3)通过操作系统约定的方式与其他进程共享其他资源,比如网络。大多数操作系统要求进程在使用TCP协议时必须打开一个端口,以避免和其他进程冲突。
    因为有了这些特点,所以在单个进程内编程变得非常容易。编程者只需要定义和使用本进程所需要的资源,并编写代码操作这些资源即可,不需要关心其他进程。
    虽然CPU有一些特性支持进程的隔离,但是更重要的是操作系统应用这些特性给各个进程提供了资源隔离的种种措施。这使应用程序的编程变得简单了,而且也变得更安全。因为大部分独立的应用程序,都不希望被其他应用程序影响。这中间的问题值得读者思考:
    既然一个进程的可执行代码,都加载在这个进程的内存空间范围内,那么那些需要调用操作系统的调用才能实现的功能(比如说读取硬盘。大家应该不记得自己曾经读/写端口,或者做过DMA,去实现读取硬盘的操作,显然驱动程序已经提供了这些功能),所需要的代码,它们位于什么空间呢?也在这个进程的内存空间范围内吗?
    答案当然是肯定的。不大可能有隔离的空间去容纳操作系统内核的代码(包括驱动程序)。
    既然如此,是不是一个应用程序中的代码,就可以直接访问到操作系统的内核代码了呢?当然,如果是这样的话,那么只要编写一个应用程序,就可以直接修改操作系统内核的代码了(既然知道它们在哪里,当然总是可以设法修改它们)。

2.1.2 共享的内核空间

    回到前面的问题。读者已经知道,每个进程的内存空间是相互隔离的(虽然地址的数值看起来都是0~N的一个线性空间)。但是大多数进程都要调用操作系统提供的功能,才能成为一个完整的应用程序。因此,进程的空间实际上被分成了两部分。一部分供进程独立使用,称为用户空间;另一部分容纳操作系统的内核,称为内核空间,或称为系统空间(System space)。具体到可以容纳4GB内存空间的32位Windows系统上,低2GB是用户空间,高2GB是内核空间。
    用户空间是各个进程隔离的,但是内核空间是共享的。也就是说,每个进程看见的高2GB空间范围内的数据,都应该是一样的。这是非常重要的一个观念。
    假设有一个找到操作系统漏洞的应用程序员,能成功地修改高2GB范围内的代码,比如,让硬盘的驱动总是返回失败。那么,其他所有的进程都无法读取硬盘了,这是毫无疑问的。当然一个安全的操作系统是要禁止这种事情发生的。
    内核空间是受到硬件保护的,比如x86架构下R0层(Ring 0)的代码才可以访问内核空间。普通应用程序编译出来之后都运行在R3层,R3层的代码要调用R0层的功能时,一般通过操作系统提供的一个入口(该入口中调用sysenter指令)来实现。这样应用程序既可以调用操作系统内核中提供的功能,又不至于得到随意修改内核的权力。
    因此读者应该理解:本书所编写的内核模块,并非是和普通应用程序一样作为一个进程执行,而是运行在内核空间,成为操作系统的一个模块,最终被所有需要该模块提供功能的应用程序(也可能被操作系统本身)调用。

2.1.3 无处不在的内核模块

    位于高2G空间内的操作系统内核,并非做死的一个巨大程序。因为计算机硬件种类繁多,不可能做出一个能支持所有硬件的巨大内核。
内核是有接口的,微软提供规定的格式,让硬件驱动的编程人员,能按照规定的格式编写“驱动程序”。这些驱动程序能够作为模块加载到内核中,成为内核的一部分,这样内核只要简单地安装驱动程序,就可以适应各种不同的硬件了。
    本书中的大部分例子都是编译成内核模块的,实际上也可以称为驱动程序(Driver)。但是它们大部分并不驱动任何硬件,有人称之为“软件驱动”。但是笔者认为这不够贴切,所以本书使用Linux程序员们的叫法,称之为内核模块(Kernel module)。也许这样要更贴切一些,驱动程序可以看成内核模块的一种(少量的应用层驱动程序除外)。
    内核模块已经位于内核空间,作为R0代码执行,所以不受任何限制,可以任意修改内核。因此许多使用应用程序无法实现的功能,可以通过编写内核模块来实现。
    起初的Windows,似乎并没有打算让应用软件的编程人员来提供内核模块。但是,一些特殊的应用软件的编程人员首先自己突破了这些限制,他们编写了虚拟光驱、防毒软件的实时监控、防火墙等特殊的不驱动任何硬件的内核模块。最终微软也意识到了内核编程对软件也是很有用的,因此在内核中又提供了更多的接口,比如方便进行文件过滤、网络过滤等的新接口,这些是专门提供给非硬件驱动开发的软件编程人员使用的。
    WDK是微软目前提供的最新的驱动开发包,同时也包括了给软件内核编程提供的所有接口和例子。硬件驱动与软件的内核编程这二者目前并没有严格分开,双方的技术和许多代码都是相通的。
    初学者在编写一个内核模块时,常常有的一个疑问就是:这些代码运行在哪个进程的空间中呢?
    内核模块位于内核空间,而内核空间又被所有的进程共享。因此,内核模块实际上位于任何一个进程空间中。但是任意一段代码的任意一次执行,一定是位于某个进程空间中的。这个进程是哪一个?这取决于请求的来源、处理的过程等。PsGetCurrentProcessId函数能得到当前进程的进程号,这个函数的原型如下:
    HANDLE
PsGetCurrentProcessId(
);
    这个函数返回的HANDLE,实际上是一个进程ID。这个数字和我们打开任务管理器时,看到的PID是一样的。
    有些读者会误以为所有内核代码都运行在系统进程内。
    Windows的所谓系统进程是一个名为“System”的进程,是Windows自身生成的一个特殊进程,这个进程在Windows XP下PID始终为4。读者只要调用PsGetCurrentProcessId就会发现内核模块中分发函数调用时,当前进程一般都不是System进程。但是DriverEntry函数被调用时,一般都位于系统进程中。这是因为Windows一般都用系统进程来加载内核模块,并不说明内核代码始终运行在System进程里。


返回