本文将对x86平台的Win32位编程基础知识作一个简要的介绍,主要包括:进程空间,虚拟内存,内核态与用户态,模块,线程。实际上,几乎其中每个概念都可以谈很多,并且对于理解CPU、操作系统和应用程序都有很大的帮助。对程序员来说,如果对一些基本概念理解有误的话,处理问题的思路就会有问题。在一些论坛上可以见到不少帖子,问的问题根本不对路,让人完全无法回答——恐怕大多数人都没兴趣去答一些类似“为什么1+1不等于3”之类的蠢问题。所以,了解这些基础知识,也是初学者能够正确理解自己的“问题”的前提。但限于篇幅所限,我只能尽量简短对我认为最关键的部分进行介绍。
同时,有许多看似神秘,或者初学者比较困惑难以理解的问题,正是由于基础知识不牢。在介绍进一步的知识之前,我也必须向初学者介绍这些基础,不然的话,可能根本就看不懂我在说什么。在这篇文章之后,我将带各位开始真正深入到Delphi的程序里,争取能尽快让各位了解到,编译器对一些看起来很平常的语句做了难些复杂的工作,从而真正了解自己的程序在做什么,避免不必要的麻烦。
在开始介绍之前,再最后强调一下:本文所讲的内容,都是基于WinNT内核的操作系统(主要是Win2000之后),与Win9x及更早的Windows操作系统关系不大,而且今天再研究在这些平台上的编程,已经基本上没什么意义了。
当我们从Windows开始菜单或文件夹中启动一个程序的时候,多数情况下是执行硬盘里的一个exe程序,很快程序就会运行起来。但在程序真正开始运行之前,Windows操作系统会做一些工作,并在系统中很容易看到这些改变。
首先,操作系统会为这个exe文件创建一个进程空间,并且为其分配一些资源。然后将可执行文件按照一定规则加载到该进程空间内,操作系统还会根据可执行文件的“说明”,将相应的动态链接库(DLL)映射进程空间内。接下来,操作系统会为该进程空间创建一个主线程,为该主线程分配一些资源。这一切都妥当了之后,Windows才会真正让主线程从这个exe文件指定的位置开始执行代码,程序才真正跑起来。
这些东西看起来似乎有些复杂,但对于程序的安全性来说,是必不可少的。早期的DOS操作系统中,应用程序都是直接与硬件打交道的,比如直接访问物理内存等等。这样虽说程序员可以完全掌握硬件,但同时也产生了不少问题。比如由于每个程序都能直接访问硬件,因此也可以访问其它程序的内容,甚至直接修改其它程序的内存。同时,操作系统的关键代码也可能遭到破坏,一个不当的程序可以导致操作系统死掉。这样带来了大量的安全隐患,而且也会给使用者带来很多麻烦——用过Win9x系统的人,对时常出现的蓝屏应该记忆深刻吧。那么,如果每个程序都能够有一个独立的空间,就像在一个沙盒之中运行就好了,如果它出了问题,操作系统直接干掉它,不会影响其它程序岂不是更好呢?Intel CPU很早就在硬件上提供了支持,可以让CPU进入保护模式(Protected Mode)进行工作,利用权限的控制进行32位程序之间的隔离。在保护模式下,CPU的权限分为4个级别,其中ring 0权限级别最高,ring 3最低。当程序在较低权限级别中执行的时候,无法直接转到或访问高的内容。Windows操作系统使用了其中ring 0和ring 3两个级别,其中操作系统内核工作在ring 0中,普通的用户应用程序工作在ring 3中。这样,应用程序的异常,或是刻意的伤害,都不会直接破坏操作系统内核的正常运转。一旦应用程序出现问题,我们就可以杀掉它,而系统自身几乎不受影响。
由于在x86 Windows平台中,一段代码只会在ring 0和ring 3两个状态下执行,而ring 0只由操作系统内核级别的模块操作,因而一般被称为内核态与用户态。内核态主要负责以下几部分的工作:硬件的操作,内核对象的管理,IO管理,内存管理,线程调度等(实际上这些说法并不很严谨,但讲起来比较麻烦并且也不是本文的重点,所以基本上不影响理解就可以了)。
现在回到前面的内容,进程(Process)是内核对象的一种,也就是操作系统负责为一个程序构造一个独立的环境,这个环境内的资源可以相互直接访问,不同的环境之间则无法直接打交道。那么这个环境包括哪些内容呢?由于CPU要执行代码必须要与内存打交道,所以这个环境里一定要有内存空间。前面提到过,用户态的程序是无法直接与硬件打交道的,内存实际上是由操作系统进行管理的,那么我们平时说的“申请内存”之类的是怎么回事呢?同样,也是通过CPU硬件的支持,操作系统给了每个进程32位的平坦的虚拟地址,也就是4G的虚拟内存空间(Virtual Address Space),每个进程在这个受操作系统保护的空间内运行。为了保证操作系统能够正常的运转,Windows会将操作系统的内核数据与代码映射到每个进程的进程空间中。这样,对于一般情况下的应用程序来说,虚拟内存空间被划分为两个部分:一部分是低2GB地址作为用户空间;而高2GB地址则是系统空间,用户态程序无法直接访问。我们平时说的程序里的内存,实际上指的就是这低2GB的虚拟内存。现在暂时先不讲虚拟内存更细的内容,还是回到前面关于程序初始化的工作中来。为了让文章看起来不那么啰嗦,如无特殊说明,今后我会直接使用“内存”指代“虚拟内存”。
要执行的程序与其它映射到该进程空间里的DLL等一样,称为该进程空间中的一个模块(Module)。这些模块就像积木一样,共同成为进程的组成部分,相互之间是透明的。如果将一个程序比作一间工厂的话,进程就相当于厂房,而各个模块相当于设备。现在厂房和设备有了,不给力的话产品还是生产不出来。线程(Thread)则是CPU实际执行任务的环境,负责执行进程空间里的代码,一个进程空间可以有多个线程。当一个进程创建之后,操作系统会自动为它创建一个主线程,用来执行程序代码。每个线程包含一组状态,其中一些用来记录和恢复CPU的执行状态,另一些则是由程序的自行记录的内容,还有一些则是给系统内核使用的。线程也是内核对象之一,操作系统内核会进行线程调度,按照一定规则给每个线程分配CPU时间。一般情况下,同级别的每个线程都会获得一小段很短的时间片,使各个任务看起来像在“同时”运行,直到执行完毕或是采用某种方式暂停或退出为止。此外,对于我们写程序来说,线程还有一个很重要的组成部分——栈,线程有独立的栈空间。
在计算机科学中,栈(Stack)是一种后进先出(LIFO:Last In First Out)的数据结构,这里的则是指这样的一块内存空间。有的文章或书里也将其称为“堆栈”,但我不推荐这种说法,因为会容易与后面讲到的“堆”弄混。在任何x86程序中,栈空间都是有非常重要的意义的。它是一块固定大小的空间,对于主线程来说,大小可由链接器(Linker)指定,在PE文件头的特定位置进行描述,再由操作系统在创建主线程时进行分配。一般来说,默认指定的大小是1M的一段地址,在初始时指向这段空间较高的地址。在结构化编程语言中,一般情况下,局部变量的空间在栈中分配;很多函数的调用约定要求参数压入栈中传递;并且在调用函数时,CPU会自动将调用方执行到的代码地址压入栈中,用来在函数调用完毕后返回原来的执行位置。参数等数据进入栈空间之后,栈指针同时向更低的地址增长,这个过程叫入栈;在一部分栈空间使用完毕之后,直接增加栈指针使之指向更高的地址,这个过程叫出栈。这样的机制,能够保证最后入栈的数据最先出栈,符合“后进先出”的性质。栈空间是非常宝贵的,并且要小心使用——如果使用栈时超过了栈的大小,就会产生“栈溢出(Stack Overflow)”的错误;一旦栈的内容被不小心被破坏了,造成的结果对整个线程来说可能是灾难性的。可以把栈想象成一个盒子,每次新东西进来之后,都罗在更上面的位置;要拿的时候,只能拿走最上面的东西。现在东西摆放的高度就是栈指针指向的位置,如果东西堆放超过了盒子的高度,出现的问题就是栈溢出。也就是说,栈只能朝一个方向增长,不能增长的方向一端叫栈底;栈指针指向的位置表示目前已经增长到的位置,当前栈指针指向的位置也叫栈顶。
下面,我来通过一个程序片段,来举例说明栈的主要工作原理,代码如下:
function Sum(x1, x2, x3: Integer): Integer stdcall; begin Result := x1 + x2 + x3; end; function foo: Integer; var a, b: Integer; begin a := 100; b := 20; Result := Sum(a, b, 30); end;
接下来,再来看一看在进入Sum前后,栈中都有哪些变化:
这里,左右两部分是进入Sum前后的栈状态,ESP是指向栈顶的栈指针。在Sum执行完毕后,编译器会生成让当前执行位置回到调用者(foo函数)的位置继续执行,并让ESP指向进入Sum前位置的代码。
可能你要问,操作系统是如何知道“栈溢出”的呢,难道每次都要进行检查么?下面对虚拟内存机制再进行一个简要的介绍。简单来说,用户态的2GB空间并不是可以直接使用的,而是通过CPU的硬件支持把虚拟地址划分为不同的页,页有不同的属性,保护模式下的CPU会根据属性的不同进行不同的反应。一般情况下,Win32按照4KB的粒度将内存分页,也就是说,一次性最少会处理4K的地址空间属性。在进程初始化完成之后,除了加载文件的地址和线程的栈地址外,其余的空间默认是未分配的状态。当程序试图访问未分配的空间时,CPU会引发一个访问异常。如果想使用一块空间,则必须通过系统内核划分一块保留空间,这块空间的属性可以设置成只读、完全访问以及更加复杂等一些状态。这些由应用程序向操作系统申请的空间,可能处于内存中的任何地方,被称为堆(Heap)。
现在再回到前面没有交待的一个问题:我们的程序运行在用户态,许多工作要通过内核态才能完成,那么用户态是如何与内核态进行交互的呢?简单来说,一般通过中断(Interrupt)、异常(Exception)或系统调用(System Call)等方式,才能由用户态进入内核态。由用户态进入内核态的时候,CPU和操作系统需要完成一系列复杂的工作,开销相对来说是比较大的。中断一般由硬件触发,异常也有硬件异常和软件异常,系统调用一般则是通过调用API(Application Programming Interface,应用程序接口)函数完成的。在进入内核态后,用户态应用程序会处于挂起状态,直到下次操作系统分配时间片、并把CPU使用权交给该线程为止。需要注意的是,API是一系列操作系统提供的函数库,主要目的是为了方便编程,并不是所有API都会由用户态进入内核态。
正因为由用户态切换到内核态的开销很高,所以一般的编译器都会对内存管理进行封装,使得一次可以划分到较大的地址,然后再从中分配空间给程序员,或者当内存使用完毕后,并不一定立即将空闲的内存交还操作系统,而是可能留给以后再次分配使用。通过这些机制进行内存管理,从而减少进行系统调用的次数,提高程序运行效率。平时我们提到的堆空间,实际上指的是由应用程序的RTL内存管理器(Memory Manager)划分的空间。明白这个概念非常重要,有许多时候虽然程序的逻辑实际上有问题,比如仍然访问一块已经“释放”了的内存,在运行时没有提示出错,但这不代表程序没问题。而且事实上这种“没出错”是件坏事——如果能够马上体现出来,那么就很容易去修正。但错误的经验可能养成错误的习惯,有的时候环境变化了或者是处理的数据不同,程序才会出错,这时再去找问题就很困难了,因为那些错误的经验可能让你根本没有考虑到真正出问题的地方。
现在,关于进程、线程、模块、虚拟地址、用户态与内核态的介绍已经基本上讲完了。接下来,再对PE文件映射到进程空间后的段进行一个简单的介绍,然后再给出一张完整的、关于进程空间的图片,帮助各位理解。
PE文件是Windows中可执行文件使用的一种特殊文件结构,它的全称是可移植执行体(Portable Execute),我们平常见到和使用的exe、dll、ocx等都属于这种文件格式。可以把PE文件看到一个包裹,里面放着一些东西,一般来说,其中最主要的就是我们编译后的程序代码。除了代码以外,还有其它的内容,包括PE文件头、资源文件、文件段等。Windows在加载一个PE文件的时候,会根据文件头的描述,按照一定的规则把PE文件中的各个部分映射到进程空间中。例如,代码一般放在代码段中,代码段的属性一般是只读的,由此保证内存中的代码不会被不小心破坏掉。此外还有其它的段,例如我们在程序中声明的全局变量等会储存在PE文件的一个段中(还包括以前讲过的运行期常量,还有哪些内容会在以后的文章中介绍到),这个段会映射到一段可读写的内存地址当中。前文已经提到,映射后的PE文件,就成为进程空间中的一个模块(Module),所以,每个模块都有映射自身PE文件的内存段。有些文章中提到,全局变量是在堆中的,这是个不正确的表述——堆是由应用程序向系统申请的内存空间,而段空间则是在操作系统加载PE文件时,就已经由内核映射到进程空间中了。
最后,如果有兴趣的话,可以参考《WINDOWS核心编程》的相关章节(内核对象,进程,Windows的内存结构)。