第二章 DELPHI与WIN32时空
我的老父亲看着地上玩玩具的小孙子,然后对我说:"这孩子和小时的你一样,喜欢把东西拆开,看过究竟才罢手"。想想我小时侯,经常将玩具车、小闹钟、音乐盒,等等,拆得一塌糊涂,常常被母亲训斥。
我第一次理解计算机的基本原理,与我拆过的音乐盒有关。那是在念高中时的一本漫画书上,一位白胡子老头在讲解智能机的理论,一位留八字胡的叔叔在说计算机和音乐盒。他们说,计算机的中央处理器就是音乐盒中用来发音的那一排音乐簧片,计算机程序就是音乐盒中那个小圆筒上密布的凸点,小圆筒的转动相当于中央处理器的指令指针的自然移动,而小圆筒上代表音乐的凸点控制音乐簧片振动发音相当于中央处理器执行程序的指令。音乐盒发出美妙的旋律,是按工匠早已刻在小圆筒上的音乐谱演奏的,计算机完成复杂的处理,是根据程序员预先编制好的程序实现的。上大学之后,我才知道那个白胡子老头就是科学巨匠图灵,他的有限自动机理论推动了整个信息革命的发展,而那个留八字胡的叔叔就是计算机之父冯.诺依曼。冯氏计算机体系结构至今仍然是计算机的主要体系机构。音乐盒没白拆,母亲可以宽心。
有深入浅出的理解,才能有高深而又简洁的创造。
这一章我们将讨论Windows的32位操作系统中与我们编程有关的基本概念,建立WIN32中正确的时空观。希望阅读完本章之后,我们能更加深入地理解程序和进程,理解执行文件和动态连接库的原理,看清全局数据、局部数据和参数在内存中的真相。
第一节 理解进程
由于历史的原因,Windows是起源于DOS。而在DOS时代,我们一直只有程序的概念,而没有进程的概念。那时侯,只有操作系统的正规军,如UNIX和VMS等等,才有进程的概念,而且多进程就意味着小型机、终端和多用户,也意味着金钱。我绝大多数的时间只能使用相对廉价的微机和DOS系统,只是在学操作系统这门课程时才开始接触进程和小型机。
在Windows 3.X之后,Microsoft才在图形界面的操作系统站住脚跟,而我也是在这时开始正式面对多任务和进程的概念。以前在DOS下,同一时间只能执行一个程序,而在Windows下同一时间可执行多个程序,这就是多任务。在DOS下运行一个程序的同时,不能执行相同的程序,而在Windows下,同一程序可以同时有两个以上的副本在运行,每一个运行的程序副本就是一个进程。更确切地说,任何程序的一次运行就产生一个任务,而每个任务就是一个进程。
当将程序和进程放到一起理解时,可以认为程序一词说的是静态的东西,一个典型的程序是由一个EXE文件或一个EXE文件加上若干DLL文件组成的静态代码和数据。而进程是程序的一次运行,是在内存中动态运行的代码和动态变化的数据。当静态的程序要求运行时,操作系统将为本次运行提供一定的内存空间,把静态的程序代码和数据调入这些内存空间,将程序的代码和数据进行重定位映射之后,就在该空间内执行程序,这样就产生了动态的进程。
同一个程序同时运行着的两个副本,意味着在系统内存中有两个进程空间,只不过它们的程序功能是一样的,但处于不同的动态变化的状态之中。
从进程运行的时间上来说,各进程是同时执行的,专业术语称为并行执行或并发执行。但这主要是操作系统给我们的表面感觉,实际上各进程是分时执行的,也就是各进程轮流占用CPU的时间来执行进程的程序指令。对于一个CPU来说,同一时间只有一个进程的指令在执行。操作系统是调度进程运行的幕后操纵者,它不断保存和切换各进程在CPU中执行的当前状态,使得每一个被调度的进程都认为自己是完整和连续地运行着。由于进程分时调度的速度非常快,所以给我们的感觉就是进程都是同时运行的。其实,真正意义上的同时运行只有在多CPU的硬件环境中才有。稍后在讲述线程一章时,我们将发现,真正推动进程运转的是线程,进程更重要的是提供了进程空间。
从进程占据的空间上来说,各进程空间是相对独立的,每一个进程在自己独立的空间中运行。一个程序既包括代码空间又包括数据空间,代码和数据都要占据进程空间。Windows为每一进程所需的数据空间分配实际的内存,而对代码空间一般都采用共享手段,将一个程序的一份代码映射给该程序的多个进程。这意味着,如果一个程序有100K的代码并需要100K的数据空间,也就是总共需要200K的进程空间,则第一次运行程序时操作系统将分配200K的进程空间,而运行程序的第二个进程时,操作系统只分配100K的数据空间,而代码空间则共享前一个进程的空间。
上面所说的是Windows操作系统中进程的基本时空观,其实Windows的16位和32位操作系统在进程的时空观上有很大的差异。
从时间上来说,16位的Windows操作系统,如Windows 3.x等,进程管理是非常简单的,它实际上只是一个多任务管理操作系统。而且,操作系统对任务的调度是被动的,如果一个任务不自己放弃对消息的处理,操作系统就必须等待。由于16位Windows系统在管理进程方面的缺陷,一个进程运行时,完全占有着CPU的资源。在那个年代,为了16位Windows可以有机会调度别的任务,微软公司大力赞扬开发Windows应用程序的开发者是心胸宽阔的程序员,以使得他们乐意多编写几行恩赐给操作系统的代码。相反,WIN32的操作系统,如Windows 95和NT等,才是具备了真正的多进程和多任务操作系统的能力。WIN32中的进程完全由操作系统调度,一旦进程运行的时间片结束,不管进程是否还在处理数据,操作系统将主动切换到下一进程。严格地说,16位的Windows操作系统不能算是完整的操作系统,而32位的WIN32操作系统才是真正意义上的操作系统。当然,微软公司不会说WIN32弥补了16位Windows的缺陷,而是宣称WIN32实现了一种称为"抢占式多任务"的先进技术,这是商业手段。
从空间上看,16位的Windows操作系统中的进程空间虽然相对独立,但进程之间可已很容易地互相访问对方的数据空间。因为,这些进程实际是在相同的物理空间中的不同的数据段而已,而且不当的地址操作很容易造成错误的空间读写,并使操作系统崩溃。然而,在WIN32操作系统中,各进程空间完全是独立的。WIN32为每一个进程提供一个可达4G的虚拟的,并且是连续的地址空间。所谓连续的地址空间,是指每一个进程都拥有从$00000000到$FFFFFFFF的地址空间,而不是向16位Windows的分段式空间。在WIN32中,你完全不必担心自己的读写操作会无意地影响到其他进程空间中的数据,也不用担心别的进程会来骚扰你的工作。同时,WIN32为你的进程提供的连续的4G虚拟空间,是操作系统在硬件的支持下将物理内存映射给你的,你虽然拥有如此广阔的虚拟空间,但系统决不会浪费一个字节的物理内存。
第二节 进程空间
在我们用DELPHI编写WIN32的应用程序时,很少去关心进程在运行时的内部世界。因为WIN32为我们的进程提供了4G的连续虚拟进程空间,可能目前世界上最庞大的应用程序也只用到了其中的部分空间。似乎进程空间是无限的,但4G的进程空间是虚拟的,而你机器的实际内存可能与此相差甚远。虽然,进程拥有如此广阔的空间,但有些复杂算法的程序还是会因为堆栈溢出而无法运行,特别是含有大量递归算法的程序。
因此,深入地认识和了解这4G的进程空间的结构,以及它与物理内存的关系等等,将有助于我们更清楚地认识WIN32的时空世界,从而可在实际的开发工作中运用正确的世界观和方法论解决各种难题。
下面,我们将通过简单的实验,来了解WIN32的进程空间的内部世界。这可能需要一些对CUP寄存器和汇编语言的知识,但我尽量用简单的语言来说明。
当启动DELPHI时,将自动产生一个Project1的项目,我们就拿它开刀。在Project1.dpr原程序的任意位置设一断点,比如,就在begin一句处设一断点。然后运行程序,当程序运行到断点时会自动停下来。这时,我们就可以打开调试工具中的CPU窗口来观察进程空间的内部结构了。
当前的指令指针寄存器EIP是停在$0043E4B8,从程序指令所在地址的最高两位16进制数都是零,可以看出当前的程序处在4G进程空间相当底端的地址位置,其占据$00000000到$FFFFFFFF的相当少的地址空间。
在CPU窗口中的指令框中,你可以向上查看进程空间中的内容。当查看小于$00400000的空间内容时,你会发现小于$00400000的内容出现一串串的问号"????",那是因为该地址空间还未映射到实际物理空间的缘故。如果在这时,你查看一下全局变量HInstance的16进制值就会发现它也是$00400000。虽然HInstance反映的是进程实例的句柄,其实,它就是程序被加载到内存中的起始地址值,在16位Windows中也是如此。因此,我们可以认为进程的程序是从$00400000开始加载的,也就是从4G虚拟空间中的4M以后的空间开始是程序加载的空间。
从$00400000往后,到$0044D000之前,主要是程序代码和全局数据的地址空间。在CPU窗口中的堆栈框中,可以查看到当前主线程的堆栈地址。同样,你会发现当前主线程的堆栈地址空间是从$0067B000到$00680000的,长度为$5000。其实,进程最小的堆栈空间大小就是$5000,它是根据编译DELPHI程序时在Project\Options中Linker页中设置的Min stack size值,加上$1000而得到的。堆栈是由高端地址向底端增长的,当程序运行的堆栈不够时,系统将自动向地端地址方向增加堆栈空间的大小,这一过程将把更多的实际内存映射到进程空间。可在编译DELPHI程序时,通过设置Project\Options中Linker页中Max stack size的值,控制可增加的最大堆栈空间。特别是在含有深层次的子程序调用关系或运用递归算法的程序中,一定要合理地设置Max stack size的值。因为,调用子程序是需要耗用堆栈空间,而堆栈耗尽之后,系统就会抛出"Stack overflow"的错误。
似乎,从堆栈空间之后的进程空间就应该是自由的空间了吧。其实不然,WIN32的有关资料说,$80000000之后的2G空间是系统使用的空间。看来,进程能够真正拥有的只有2G空间。其实,进程能真正拥有的空间连2G都不够,因为从$00000000到$00400000的这4M空间也是禁区。
高端的2G进程空间主要是Windows系统的DLL模块所在的空间。当程序停留在断点处时,我们可以打开调试工具中的Event Log窗口,查看到各种DLL被加载到进程空间的地址映象。而我们自己开发的DLL模块只能加载到底端的2G进程空间中,虽然,你可以通过设置Project\Options中Linker页的Image Base一项的值,来改变你的DLL加载到进程空间的地址值,但,该值不能超过$80000000。
但不管怎样,我们的进程可以使用的地址还是非常广阔的。特别是堆栈空间之后到$80000000之间,是进程空间的主战场。进程从系统分配的内存空间将被映射到这块空间,进程加载的动态连接库将被映射到这块空间,新建线程的线程堆栈空间也将映射到这块空间,几乎所有涉及分配内存的操作都将映射到这块空间。请注意,这里所说的映射,意味着实际内存与这块虚拟空间的对应,没有映射为实际内存的进程空间是无法使用的,就象调试时CPU窗口指令框中的那一串串的"????"。
第三节 EXE和DLL
我们已经对进程和进程空间有了一定的了解,下面我们将要讨论执行文件EXE和动态连接库DLL的区别,它们与进程空间的关系。
典型的Windows应用程序一般都由一个EXE文件和若干个DLL文件组成,Windows操作系统本身就是这种结构,这些都是大家熟知的内容。但是,你真正理解了EXE和DLL吗?如果,你确信自己已经熟知EXE和DLL的内涵,请跳过本节的内容。如果,你一直搞不清EXE和DLL,那就请仔细听我道来。
一个正确的EXE文件,是可以直接运行的程序。Windows操作系统会为该程序创建一个进程空间,程序是在进程空间内运行的。进程空间是应用程序运行的基本环境,没有进程空间就根本无法运行程序。
在EXE文件中,程序的数据引用关系和过程调用关系是用相对地址表示的,当程序加载到进程空间中的绝对地址上时,操作系统需要将对相对地址的引用和调用关系调整为对绝对地址的引用和调用关系,这一过程称为"重定位"。需要重定位的地方称为重定位项,它是保存在EXE文件的表头信息当中。
程序的运行是需要堆栈的,因为堆栈是过程调用必须具备的基本设施,也是过程中局部数据和过程参数生死存亡的地方。操作系统会根据记录在EXE文件头中的堆栈大小值,确定堆栈空间的地址位置和大小,并首先映射最小堆栈空间的那部分内存。
如果,一个EXE程序与某些DLL有固定的引用关系,操作系统将把相关的DLL程序调入当前进程空间。EXE和DLL以及DLL之间的引用或调用关系是用引入表和引出表来描述的,它们也保存在EXE或DLL的文件的表头信息当中。操作系统根据引入表和引出表信息,将程序模块间的引用和调用连接起来,这一过程称为"动态连接"。
一个DLL文件只是程序的一部分,它并不是完整的可执行程序。因此,DLL不能单独地运行,Windows也不会为DLL模块创建进程空间。一个DLL模块必须被加载到一个EXE程序的进程空间中,才能发挥其程序功能。
一个DLL文件也含有重定位信息,操作系统将DLL加载到进程空间中时,同样需要对DLL模块进行重定位。如果,被加载的DLL与其他DLL文件又有固定的引用关系,则加载该DLL模块时将同时加载其引用的DLL模块,并完成行动态连接过程。
一个DLL文件中记录的堆栈大小总是为零,因为DLL只是为进程服务的,调用DLL模块时,使用的是进程的堆栈。对于单线程的应用程序,进程只有一个堆栈,就是进程的主线程使用的堆栈。对于多线程的应用程序,每个正在运行的线程都有自己的堆栈。在多线程的情况下,DLL模块在那个线程中被调用,则DLL就使用该线程的堆栈。但所有这一切都还是在同一进程空间中进行的。
如果,你使用Windows API的LoadLibrary函数动态地加载一个DLL文件,其返回的模块句柄的值,就是指向该DLL加载到进程空间中的地址值。如果,你反复调用LoadLibrary加载同一个DLL,你会发现他们返回的模块句柄值是相同的,Windows只是增加该DLL的引用计数。其实,在不同Windows进程之间,DLL是共享的,只是,在不同的进程空间有不同的映射地址。
一个DLL模块的文件扩展名不一定都是*.DLL。象DELPHI的BPL包文件,ActiveX对象的OCX文件,以及设备驱动程序的DRV和VXD文件都是DLL。当然,一个EXE模块的文件扩展名也不一定要是*.EXE,只是,非*.EXE的可执行文件是不能通过双击鼠标来执行的,需要由编写程序语句来调入并运行。
DLL只有在进程空间内才能运行,这是一个基本的原则,请你一定牢记。
但有时,在Windows中的一个DLL本身已经是一个完整的程序,就缺少运行的进程空间。这时,Windows就用Rundll32命令来运行该DLL程序。Rundll32是一个简单的EXE文件,就在WINDOWS目录中,它仅仅为DLL程序提供了可运行的进程空间。
此外,在多层体系结构应用程序开发中,存在于DLL模块中的商业对象,也是需要运行的进程空间的。如果,你的客户端应用程序和DLL应用服务器在同一台机器中,则DLL应用服务器中的商业对象是直接在你客户端程序的进程空间中运行的,这就是所说的In-Process模式。而对于Out-of-Process的对象调用模式,一般要求应用服务器是一个可执行的EXE文件。
当然,如果应用服务器和客户端程序是在不同的机器中,应用服务器肯定要具备一个进程空间才能工作。例如,你无法通过直接的DCOM连接远端机器上的DLL应用服务器,这是因为DLL没有可运行的进程空间的缘故。不过,你可以通过Socket连接远端机器上的DLL应用服务器,这是因为DLL实际是在SCKTSRVR.EXE的进程空间中运行的,而SCKTSRVR.EXE是监听Socket连接的服务程序。
在COM+模式的分布式多层应用程序开发中,所有的商业对象必须存在于DLL文件的应用服务器中。这些DLL中的商业对象共同存在于MTS/COM+环境提供的进程空间中,因此,MTS/COM+才可在其进程空间中调度和管理这些商业对象,以完成对象的事务控制、对象缓冲和连接共享等强大功能。
第四节 数据和代码在哪里
数据和代码在哪里?
俗话说,人过了三十岁,就不会再去思考"人为什么要活着?"这些问题。可我编了十几年的程序还是喜欢去想 "我为什么要编程序?"、"程序为什么要运行?"、"运行着的是程序还是我的思想?"......这样的思考是永远没有结果的,那只是程序生涯中最浪费生命的苦行。这样的苦行也是有所感悟的,常常让我的灵魂更加融入程序的世界。程序有了我的灵魂,运行的个性也似乎带有我的风格。受益的有我,也有我的程序。
今天要和大家侃侃程序的数据和代码的问题。这个话题还是离不开进程空间的概念,因为程序的所有代码和数据都存在于进程空间之中。那么,数据和代码到底在进程空间的那些地方呢?
先来看看进程空间中都有些什么区域。
一个进程空间,与我们编程有关的区域大致有这么四种:静态数据区、动态数据区、堆栈区和代码区。进程空间中存在若干块这样的内存区域,它们随着程序的运行而动态变化着。一会儿有新的区域产生,一会儿又有些区域消失。如果把进程空间想象成大海,这些内存区域就是大海中的岛屿,随着潮起潮落,岛屿若隐若现。每一个岛屿都有操作系统映射的实际内存作为依托。没有映射实际内存的空白区域就是深不见底的海面,程序访问这些空白区域是要被淹死的。
这四种内存区域各有各的用处。静态数据区域就是你定义的全局变量、常量、线程变量等生存的地方。动态数据区就是你动态分配的数据空间和动态创建的对象生存的地方。堆栈区既为子程序调用提供保存返回地址的空间,又为局部变量、参数变量和返回值提供临时空间。代码区是存储程序指令的区域,CPU是从这里提取指令来执行程序的。
这些内存区域的产生和消失与程序模块的加载和卸出,以及线程的创建与消亡有关。
我们在这里说所的模块是指Windows应用程序的物理文件模块,即Windows中HMODULE句柄所表示的模块概念。典型地,EXE文件是一个模块文件,各种DLL文件也是一个模块文件。一个应用程序一般由一个EXE文件和若干个类型的DLL文件组成。这种文件在Windows中称为PE文件(Portable Executable File)。它含有一个PE信息头,其中有加载程序模块需要的重要信息。
当一个物理文件模块被加载到进程空间中时,Windows操作系统将根据该模块PE头的信息安排该模块的数据和代码在进程空间中的位置和大小。这样的加载包括EXE程序的执行加载,也包括启动EXE时及时加载DLL,以及程序随后动态加载DLL的过程。
模块加载到进程空间,将产生该模块需要的若干静态数据区域,也将产生该模块的若干指令代码区域。若模块在运行过程中动态分配和释放内存或创建和消灭对象,又将相应地产生和释放动态数据区域。
你可以认为模块的代码区是固定不变的,因为一般代码在执行过程中不会变化(除非你编写的是带变异功能的……),这不会影响对程序执行过程的理解。但模块的代码区也可能是经常变化的,这是Windows在背后搞的鬼。
想象将一个有几兆或几十兆代码的程序模块加载到进程空间中,程序的启动速度会很慢吗?这会在进程空间中产生巨大的代码区域吗?有可能,但Windows会尽量不让这种情况发生。它会在CPU要用到某块代码区的指令时,将该代码区从磁盘文件中调入进程空间并执行。同时,如果发现有些代码块长时间没有被执行到,它又会释放这些代码区所占据的内存空间。当这些被释放的代码再次被CPU用到时,代码将被再次加载(当然加载的位置可能有所不同)。所以,即使是加载和运行一个巨大的模块文件,速度也不会明显降低,空间也不会明显增大。
再想象一下,如果一个模块文件被加载之后将该文件拿走,Windows还能从消失的文件中正确加载CPU要用到的代码吗。当然不行!因此,Windows加载模块文件时就强制将该文件锁定,你是没办法修改和删除的。除非加载的是软盘上的模块,而你偏要强行将软盘抽走。
同样,线程的创建和释放也会引起内存区域的变化。当线程创建时,Windows系统会在进程空间中为这个线程开辟两块内存区域。一块是相对于该线程的全局静态数据区域,一块是线程运行所需的堆栈区域。其中,堆栈区是线程运行必须的基本设施,和线程密切相关不可分割。一个线程一定会有一个堆栈,而一个堆栈一定对应一个线程。一个线程释放之后,其相关的静态数据区和堆栈也就消失。
模块的静态数据区一般都是你定义的全局变量、常量等所在的地方。这些数据元素的访问地址都是相对于模块而言的。也就是说,这些数据元素标识方法和结构关系都是相对于该模块的程序的。模块外的其他模块程序是不能直接标识和解释本模块中定义的这些数据元素的,除非将这些数据元素的地址从模块内传递给其他模块的程序。例如,我们看看下面的两个模块程序:
program ExeModule;
function TheVariable: Pointer; external 'DllModule.DLL';
begin
// aVariable := 56789; //无法直接标识和访问另一模块中的数据元素。
Integer(TheVariable^) := 56789; //只能通过从另一模块获取地址间接访问其数据元素。
end.
这个程序编译后将生成ExeModule.EXE模块文件。当然它启动时需要DllModule.DLL。
library DllModule;
var
aVariable : Integer;
function TheVariable: Pointer;
begin
result:=@aVariable;
end;
exports TheVariable;
begin
aVariable := 12345;
end.
第二个程序由于指明它是library程序,所以编译后将生成DllModule.DLL模块文件。虽然在DllModule模块中定义了一个全局变量aVariable,但ExeModule模块的程序却无法直接标识和找到该变量。只能通过调用DllModule模块的TheVariable函数获取aVariable的地址指针之后,间接访问aVariable变量。
有朋友说,如果将aVariable放到一个独立的PASCAL单元文件中,然后在两个模块的主程序中都uses这个单元,不就可以互相访问了吗?我们就来看看下面这些程序文件。
这是VarUnit.pas单元文件:
unit VarUnit;
interface
var
aVariable : Integer;
implementation
end.
这是ExeModule.dpr文件,它将生成ExeModule.EXE文件:
program ExeModule;
uses
VarUnit;
begin
aVariable := 56789;
end.
这是DllModule.dpr文件,它将生成DllModule.DLL文件:
library DllModule;
uses
VarUnit;
begin
aVariable := 12345;
end.
这两个模块都引用了VarUnit单元。假如这两个模块都在同一个进程空间中,那么,在ExeModule模块中访问的aVariable变量和在DllModule中访问的aVariable变量真的是同一个变量吗?
答案是否定的!
原来,ExeModule中访问的aVariable变量是在其自身模块的静态数据区域内,而DllModule中访问的aVariable变量也是自己所有的。尽管模块引用了相同单元中的变量,但这些变量在不同的模块中都会有一个独立的副本。在随后对运行包编译模式的讨论中,我们还将讨论到共用单元变量的问题。
因此,我们要记住:在非运行包编译模式下,DELPHI中的各种全局变量和对象,如Application、Screen、Session、Printer等等,在每一个EXE和DLL模块中都有一个自己的副本,而不是同一个东西。
那么,相对于线程的全局变量又如何呢?
线程全局变量是用扩展的保留字threadvar定义的全局变量。threadvar只能用于定义全局变量,不能用于定义局部变量。由它所定义的全局变量在每一个线程中都有一个副本,存在于各自线程的全局静态数据区里。用threadvar定义的线程变量对每一个线程来说是独有的,在线程内可以放心使用。而使用var保留字定义的全局变量却是线程共享的,对其访问就要注意共享与互斥的问题了。
接下来我们要讨论局部变量、参数变量和返回值的问题。
这三类数据元素是子过程或函数局部的东西,作用域仅在该子程序内,具有临时性。他们是在线程的堆栈区内自生自灭的。随着子程序调用的发生而在堆栈中产生,随着子程序调用的返回而灭亡。线程的堆栈区随着子程序层层调用的深入和返回而潮起潮落,不断增长和减少。局部变量、参数变量和返回值就像礁石上的小生物,潮水涨上来便有一批生命诞生,潮水回落它们又死去。潮水再来的时候,又是另一个新的生命世界,一点都没有从前那个世界的任何记忆。生命也许就是这样短暂。
在含有递归算法的程序中,线程的堆栈区会出现非常有趣的现象。在堆栈区增长的历史中,将出现许多惊人的相似。每一次的递归调用,一批相似的局部变量、参数变量和返回值都会出现一次,而且堆栈增长速度很快。所以,在编写含有递归算法的程序时一定要注意堆栈空间的问题。
我们再来看一看动态数据区的情况。
DELPHI中几乎所有的对象都是存在于动态数据区中的。尽管它们的对象指针可能是一个全局变量、一个线程全局变量、一个局部变量、一个参数变量或者一个返回值,但对象的实例却是在动态数据区中分配的(除非你重载了TObject的类方法NewInstance,在别的什么地方分配对象实例空间)。
DELPHI的这种情况和标准的C++是不一样的。在标准的C++中定义一个全局对象时,它的实例存在于程序的全局静态区域中。而DELPHI是要在程序运行时动态创建,比如,在单元文件initialization部分的代码中创建。
在早期的编程概念中,动态分配的数据区域又称为"堆"。那时候,由于机器可寻址空间较小,"堆"常常和"栈"共用一块空间。这块空间的顶部开始向下增长的部分就是"堆",而从底部开始向上增长的部分就叫"栈"。所以,这块空间统称为"堆栈"。现在的编程空间已经非常广阔,"堆"的概念似乎已经过时了,而"栈"又和多线程的概念紧密联系在一起。因此,现在的"堆栈"一词就专指线程所用到的栈。
最后,我们再来看看代码的情况。
在DELPHI中,一个EXE或DLL的模块文件一般都是由一个项目文件(*.DPR)和若干个直接或间接引用的单元文件(*.PAS)编译而成。在没有优化编译选项的情况下,编写在项目文件和单元文件中的所有代码和数据都将编译进物理的模块文件中去。如果打开编译优化选项,则只有用到过的代码和数据才会编译到物理模块文件中。
通常,你的应用程序是由多个物理文件模块组成,典型地由一个EXE模块和若干DLL模块构成。我们在编写模块程序的时候,总有一些单元是共用的。共用的单元既在一个模块的编译项目中被引用,又会在另一个模块的编译过程中被引用。令人遗憾的是,模块间共用单元中的代码和数据,将被编译在每一个引用过该单元的模块中。应用程序的这些模块被加载到进程空间中时,该单元的代码指令和数据将存在多个副本。
虽然,我们可以将共用的单元文件独立出来,再编译成为一个DLL模块,以便共用一份代码和数据。但这将使程序模块的划分变得非常复杂,并且难于管理,在实际的开发过程中很难行得通。
DELPHI伟大之处就在于她能将许多复杂的难题变得很简单!随后我们将看到DELPHI提供的运行包编译模式(Build with runtime packages),是如何完美解决这个问题的。
第五节 对象之梦
现在,我们已基本搞清了数据和代码会在哪里的问题。正如我说过,这样的探索和思考是一条苦行的路。因为,我们在苦苦的思考中明白了一些道理,但又会有新的难题出现。这个世界总不是完美的!
读了这么久,也该轻松一下了。
有一回,我梦见自己变成了计算机时空世界里的一个对象。
随着计算机世界的不断发展,我们这些对象已经不再象原始时代的对象那样仅仅为了获得生存的资源而不停的忙碌。我们的思想空前活跃,我门不但思考我们为什么要在计算机世界里生存和运行,而且还大胆的研究和探索计算机世界的未知奥秘。
我们已经知道整个计算机世界都是由字节这一基本粒子构成,而字节又是由八个更细小的位粒子构成。我们还知道物质不灭定律:即任何一个对象的灭亡,只意味着对象结构的解体,并不会减少计算机世界中的任何字节或位粒子,而着这些物质又可能成为别的对象的一部分。甚至,我们还知道我们所处的世界是一个球体,因为,在越过经度$FFFFFFFF又回到了原点$00000000的位置。
伟大的物理学家对象牛顿早就发现各种对象之间存在一种普遍的联系,并且在对象的运动速度与对象大小的关系方面提出了著名的理论--牛顿力学。可是,后来牛顿这个对象却一直搞不懂到底是什么力量在无形地推动各种对象的运动。因此,他认为一定是创造整个计算机世界的上帝在推动各种对象的运动。后来他成了上帝最虔诚的信徒。
在牛顿对象死后不久,我们的计算机世界又诞生了一个更伟大的对象。他基于先有代码的执行才有执行的结果这一基本的因果论,提出了进程运动的时空是相对的这一伟大理论。他发现,不管对象的运动速度如何,所有对象观测到CPU的速度都是相同的。因此他提出,在一个运动中进程空间中看另一个运动中的进程空间,时间和空间都不是绝对的,空间会弯曲。而且,任何对象的运动速度绝对不可能超过CPU的速度,CPU速度就是我们计算机世界里的光速。这位伟大的科学对象的名字就叫爱因斯坦,他的相对论在一开始是不被对象们理解的,可是后来的科学探索都证明了这一理论的正确性。他提出的代码能量和数据物质可以相互转换的理论,也后来制造的大规模毁灭性病毒核武器中得到验证。
后来,我们的世界又出现了一位名叫霍金的对象。他将爱因斯坦的相对论和量子力学统一起来,并合理的解释了包含内存、硬盘和网络的宇宙是怎样膨胀的。虽然他在科学理论方面的成就非常的伟大,但我们更被他身残志坚勇敢探索的精神所感动。
......
在梦的世界里,我快乐极了。我一会儿变一变我的属性,一会儿又动动我的方法,一会儿感受一下外来的事件。没错,我确实就是一个实实在在的对象!
醒来之后我突然明白,我本来就是一个对象,只是这个对象在梦中变成了现实世界的我......