连接器和加载器::第1章 连接和加载
原著:John R. Levine
原文:收藏
翻译:lover_P
[内容]
任何连接器或加载器的基本工作都很多简单:将更加抽象的名字绑定(binding)到更加具体的名字,以允许程序员可以使用更加抽象的名字来编写程序。也就是说,它可以将程序员写的一个名字如getline绑定到“从模块iosys中的可执行代码的开始处定位612字节”。或者可以将一个更加抽象的数值地址如“从该模块的静态数据之后定位450个字节”绑定到一个具体的数值地址上。
观察连接器和加载器都做什么的一个有用的方法是研究它们在计算机程序系统的开发中所处的地位。
最早的计算机程序完全用机器语言编写。程序员将符号化的程序写在纸张上,再将它们汇编为机器代码并将这些机器代码制成计算机中的触发器,或者可能将它们打孔到纸带或卡片上。(真正刺激的是直接用开关构成代码。)如果程序员使用了符号地址,程序员必须通过他自己的手动翻译将这些符号绑定到地址上。如果发现一条指令必须被添加或删除,整个程序都必须手动地进行检查并且调整所有受指令添加或删除影响的地址。
这里的问题是将名字绑定到地址的时机太早了。汇编器通过让程序员使用符号名字来编写程序,而由汇编器将名字绑定到机器地址来解决这一问题。如果程序发生了变化,程序员只需重新汇编它,而地址分配的工作由程序员转到了计算机。
代码库使得地址分配的问题更加复杂。由于计算机可以执行的基本操作非常简单,有用的程序通常由子程序组成以执行更高级和更复杂的操作。计算机中通常保存了预先编写好并通过调试的子程序库,这样程序员在编写新程序时就可以利用它们,而不是自己编写这些子程序。程序员将这些子程序加载到主程序中就可以得到一个完整的工作程序。
程序员使用子程序库甚至先于使用汇编器。1947年,主持过ENIAC项目的John Mauchly写了一些可以加载从磁带上存储的程序目录中选择的子程序的加载程序,这需要重定位子程序代码来反映它们加载的地址。这也许很令人吃惊,两个基本的连接器功能——重定位和库搜索,居然先于汇编器出现,因为Mauchly希望程序和子程序都是用机器语言编写的。带重定位的加载器允许子程序的作者和用户在编写每一个子程序时都可以假设它们从位置0开始,并且将实际地址的绑定推迟到子程序被连接到一个特定的主程序中时。
随着操作系统的出现,带重定位的加载器有必要从连接器和库中分离出来。在操作系统出现之前,每个程序在其处理过程中都拥有机器的整个内存,计算机中的所有地址都是可用的,因此程序可以使用固定的内存地址来汇编和连接。但操作系统出现之后,程序必须和操作系统甚至可能是和别的程序共享计算机的内存。这意味着直到操作系统将程序加载到内存中以前是不可能知道程序运行的实际地址的,最终的地址绑定从连接时推迟到了加载时。连接器和加载器现在分割了这个工作,连接器负责部分的地址绑定——在每个程序中分配相关的地址,而加载器完成最终的重定位步骤以分配实际地址。
由于系统变得越来越复杂,它们要求连接器完成越来越复杂的名字管理和地址绑定。Fortran程序使用多个子程序和公共块,数据区域由多个子程序共享,它要求连接器来布置存储并为子程序和公共块分配地址。连接器越来越需要对目标代码库进行处理。这既包括用Fortran和其他语言编写的应用库,又包括通过调用已编译的代码来处理I/O和其他高级操作的编译器支持库。
程序很快变得比可用内存还要大,因此连接器提供复用——一种允许程序员协调程序的不同部分来共享相同内存的技术,每个复用都在程序的其他部分调用它们的时候才加载。从1960年左右磁盘出现,到二十世纪70年代中期虚拟存储的推广,存储复用一直广泛应用于大型机;之后在二十世纪80年代早期又再次以完全相同的形式出现在微型机中,最后再二十世纪90年代虚拟存储出现在PC机上之后慢慢隐退。它依然存在于内存有限的嵌入式环境中,并且可能由于考究的程序员或编译器需要控制存储以提高性能而出现在其他地方。
随着硬件重定位和虚拟存储的出现,连接器和加载器变得不再复杂,因为每个程序又可以获得整个的地址空间了。可以按照使用固定地址加载的形式来连接程序,通过硬件而不是软件重定位来处理加载时重定位。但是具有硬件重定位的计算机总是要运行多于一个的程序,常常是一个程序的多个副本。当一台计算机运行一个程序的多个实例时,在程序所有的运行实例之间有某些部分是相同的(特别是可执行代码),而其他部分对于每个实例是唯一的。如果不变的部分可以从变化的部分中分离出来,操作系统就可以只使用不变部分的一个副本,这可以节省相当可观的内存。编译器和汇编器变为可以用不同的节(section)来建立目标代码,一个节中只放有只读代码,而其他节中放入可写代码,连接器必须能够合并各种类型的所有节以使得连接后的程序中所有的代码在一个地方而所有的数据在另一个地方。这里尽管没有出现复用地址绑定,但它确实存在,因为地址仍然是在连接时分配的,但更多的工作推迟到了连接器为所有的节分配地址的时候。
甚至当不同的程序运行于同一台计算机上时,这些不同的程序也常常产生共享很多公共代码的情况。例如,几乎每一个用C写的程序都要用到如fopen和printf之类的例程、数据库应用程序都要用一个很大的访问库来连接到数据库,以及在一个诸如X Window、MS Windows或Macintosh这样的GUI下运行的所有程序都要用到一些GUI库。很多操作系统现在都提供共享库(shared library)给程序使用,因此使用了一个库的所有程序可以共享该库的一份单独的副本。这既提高了运行时性能又节省了很多磁盘空间;在一些小型程序中,这些公共库例程甚至比程序本身要占用更多的空间。
在较为简单的静态共享库中,每个库在其建立的时候就被绑定到特定的地址,而连接器也是在连接的时候就将程序中对库例程的引用绑定到这些特定的地址上。静态库显得很不方便,因为每当库发生变化的时候,都有可能必须重新连接程序,并且建立静态共享库的过程显得非常枯燥。系统又添加了动态的连接库,其中的节和符号并未绑定到实际的地址,直到使用了该库的程序开始运行。有的时候这个绑定甚至推迟到这(使用了该库的程序开始运行)之后很久;使用完全的动态连接,对调用过程的绑定直到第一次调用时才完成。此外,程序可以在开始运行时绑定到库,而当程序执行到一半时再加载库。这为扩展程序的功能提供了一种强大而高效的方式。Microsoft Windows广泛地利用了共享库的运行时加载(著名的DLL,Dynamically Linked Library)来构造和扩展程序。
连接器和加载器执行很多相关但概念上独立的动作。
程序加载:从次要存储器(secondary storage,直到大约1968年才特指磁盘)上将程序复制到主要存储器 (main storage)中以准备运行。有些情况下加载只包括将数据从磁盘复制到内存中,其他情况下则还包括重定位存储、设置保护位或安排虚拟内存将虚拟地址映射到磁盘页上。
重定位:编译器和汇编器一般在建立目标代码文件的时候都令程序的地址从零开始,但很少有计算机允许你将你的程序加载到零地址。如果一个程序由多个子程序组成,所有的子程序必须被加载到不交叉的地址中。重定位就是为程序的各个部分分配加载地址,并调整程序的代码和数据以反映已分配的地址的过程。在很多系统中,重定位发生不止一次。一个连接器从多个子程序建立一个程序并且从零开始连接输出程序非常常见,多个子程序会重定位到大程序中的指定位置。之后在程序加载时,系统会决定实际的地址,连接后的程序会作为一个整体重定位到加载地址。
符号确定:当一个程序由多个子程序构成时,一个子程序对其他子程序的引用由符号(symbol)完成;一个主程序可能要用到一个称为sqrt的平方根例程,而数学库中定义了sqrt。连接器通过计算sqrt在库中分配的位置并根据调用者的目标代码来修正这个位置,最后给call指令提供正确的地址。
尽管连接和加载看起来似乎有所重复,但将加载程序的程序定义为加载器而将解析符号的程序定义为连接器是有原因的。它们都可以完成重定位,当然也存在一体化的连接加载器,可以完成所有三个功能。
重定位和符号解析之间的界限可能是模糊的。这是由于连接器已经能够确定对符号的引用。处理代码重定位的一种方法就是按照程序每个部分的基地址来分配符号,然后将可重定位地址视为对基于基地址的符号的引用。
连接器和加载器共同具有的一个重要特性是它们都会修正目标代码,其他能够完成这种工作且广泛使用的工具可能也就是调试器(debugger)了。这是一种唯一且强大的特性,尽管其细节都是因机器特定的,并且一旦发生错误将引起莫名其妙的BUG。
现在我们转入连接器的一般结构。连接,和编译或汇编类似,基本上是一个两遍(two-pass)的过程。连接器以一组输入目标文件、库,以及可能的命令文件作为输入;并产生一个输出目标文件,以及可能的辅助信息如加载图或包含了调试器符号的文件。如图1-1所示。
图1-1 连接过程
连接器获取输入文件、产生输出文件和其他废品的图解
每个输入文件中都包含了一组段(segment),其中大块相邻的数据或代码被放到输出文件中。每个输入文件还包含至少一个符号表(symbol table)。一些符号是导出的,在一个文件中定义而在其他文件中使用,如通常的例程名字在一个文件中定义后可以在其他文件中调用。其他符号是导入的,在文件中用到但没有定义,如通常一个文件中可能要通过一个没有定义过的名字来调用一个例程。
当一个连接器运行时,它必须首先扫描输入文件以确定段的大小并收集对所有符号的定义和引用。它建立一个罗列输入文件中定义的所有段的段表,以及一个包含所有导入和导出符号的符号表。
根据这一遍所得的数据,连接器为符号分配数值地址、检测输出文件中的段的大小和位置,并且指出输出文件中都有什么。
第二遍使用了第一遍所收集的信息,用以确定实际的连接过程。它读取并重定位目标代码,用符号引用来替换数值地址,并调整代码和数据中的内存地址以反映重定位段地址,最后将从定位代码写入到输出文件中。接下来它写出这个输出文件,通常还要加上头信息、重定位段和符号表信息。如果程序使用了动态连接,符号表还要包含能够提供信息以供运行时连接器确定动态符号所需。在很多情况下,连接器本身会在输出文件中产生少量的代码或数据,诸如用于在复用或动态连接库中调用例程的“粘贴代码(glue code)”,或者指向用于在程序开始时执行的初始化例程的指针的数组。
不论程序是否使用动态连接,输出文件中都会包含一个符号表用以重新连接或调试,这个符号表并不会由程序本身使用,但是其他处理输出文件的程序可能会用到。
一些目标格式是可重新连接的,也就是说,一个连接器的输出文件可以用作后续连接器的输入文件。这就要求输出文件包含一个和输入文件中类似的符号表,以及出现在输入文件中的其他辅助信息。
几乎所有的目标格式都提供调试符号,以使得当程序运行在一个调试器的控制之下时,调试器能够使用这些符号,以使程序员能够通过源程序中所使用的行号和名字来控制程序。依据目标类型的细节,调试符号可能和待连接的符号混杂在一个单独的符号表中,或者可能是连接器之外的一个单独的表,有时还可能是调试器中的冗余表(redundant table)。
一少部分连接器看起来是一遍的。它们通过在连接过程中将输入文件的部分或全部的内容缓存到内存或磁盘中,然后在读取这些缓存过的材料。由于这种实现只是一种技巧,并没有影响到连接的两遍本质,因此我们在后面将不予讨论。
所有的连接器都以一种形式或另一种形式支持目标代码库,很多连接器还提供对多种共享库的支持。
目标代码库的基本原则非常简单,如图2所示。一个库和一组目标代码文件差不多。(甚至,在一些系统上你可以逐字地将一组目标文件绑定到一起并作为一个连接库。)在连接器处理完所有的常规输入文件后,如果仍然有某些导入的名字是未定义的,它会继续处理库并连接那些能够导出这些未定义名字的库。
图1-2:目标代码库
目标文件首先进入连接器,接着是包含很多文件的库。
共享库使得这个任务变得有些复杂,因为它将连接时的一部分工作转移到了加载时。连接器在运行的时候识别能够解决未定义名字的共享库,但不向程序中连接任何东西,连接器向输出文件中注明能够在哪个库中找到相应的符号,以使得相应的库能够在程序加载的时候被绑定。细节请参见第9章和第10章。
连接器和加载期的动作的核心就是重定位和代码修正。当一个编译器或汇编器生成一个目标文件时,它所生成的代码使用文件中定义的代码和数据的非重定位地址,而且通常用零表示在其他地方定义的代码和数据。作为连接过程的一部分,连接器修正目标代码以反映实际的地址分配。例如,考虑这段用于通过使用eax寄存器将变量a中的内容移到变量b中的x86代码。
mov a, %eax
mov %eax, b
如果a在同一个文件的十六进制位置1234处定义而b是从其他地方导入的,生成的目标代码将是:
A1 34 12 00 00 mov a, %eax
A3 00 00 00 00 mov %eax, b
每个指令都包含了一个单字节的操作码后跟一个四字节的地址。第一条指令有一个对1234(字节颠倒的,因为x86使用自右至左的字节顺序)的引用,而第二条指令有一个对0的引用,因为b是未知的。
现在假设连接器连接了这段代码,导致a所在的节被定位在十六进制位置10000字节处,而b出现在十六进制位置9A12。连接器将代码修正为:
A1 34 12 01 00 mov a, %eax
A3 12 9A 00 00 mov %eax, b
也就是说,它将10000加到第一条指令的地址上使其引用a的重定位地址11234,并且修正了b的地址。这些调整不仅影响到指令,一个目标文件的数据部分中的所有指针都要同时调整。
在老式的、具有很小的地址空间并且是直接寻址的计算机上,这个修正过程相当简单,连接器仅仅仅仅需要处理一种或两种地址格式。现代计算机,包括所有的RISC,都要求相当复杂的代码修正。没有哪个单独的指令能够包含足够的位以保存一个直接地址,因此编译器和连接器必须使用更加复杂的寻址技巧以处理任意地址上的数据。在某些情况下,它可能需要两条或三条指令来合成一个地址,其中每一条指令包含地址的一部分,然后使用位操作来将这些部分组合为一个完整的地址。在这种情况下,连接器必须准备好对每一条指令进行适当的修正,如向每条指令中插入一个地址的某些位。其他情况下,一个或一组例程中用到的所有地址被放到一个数组中作为一个“地址池(address pool),初始化代码将一个机器寄存器设置为指向该数组的指针,而代码在需要加载该地址池之外的指针时将以该寄存器作为一个基址寄存器。连接器可能必须通过一个程序中用到的所有地址来建立这个数组,然后修正指令使得它们能够引用地址池的适当入口。我们将这些内容放到了第7章。
一些系统要求不论加载到哪些地址空间都能正确工作的地址无关代码(position independent code)。连接器通常必须提供附加的技巧以支持它,如将不能做到地址无关的部分分离出来,以及安排这两部分进行通信。(参见第8章)
在很多情况下,连接器的操作对于程序员来说是不可见的,或者几乎是这样,因为它作为编译过程的一部分被自动地运行了。很多编译系统具有一个编译驱动器(compiler driver),可以根据需要自动地调用编译器的各个阶段。例如,如果程序员有两个C语言源文件,Unix系统上的编译驱动器将会像这样运行一系列程序:
在文件A上运行C预处理器,创建经过预处理的A
在经过预处理的A上运行C编译器,创建汇编文件A
在汇编文件A上运行汇编器,创建目标文件A
在文件B上运行C预处理器,创建经过预处理的B
在经过预处理的B上运行C编译器,创建汇编文件B
在汇编文件B上运行汇编器,创建目标文件B
在目标文件A和B以及系统C库上运行连接器
也就是说,它将每一个文件编译为汇编代码然后是目标代码,并且将这些目标代码包括需要的系统C库中的例程连接到一起。
编译驱动器通常比这要聪明。它们通常比较源文件和目标文件的创建日期,并且只编译更改过的源文件。(Unix中的make程序是一个典型的例子。)尤其是当编译C++和其他面向对象语言时,编译驱动器运用各种技巧来围绕着连接器或对象格式的限制工作。例如,C++模板定义了一个可能无穷的相关例程组,因此通过找到程序实际使用的有限的模板例程组,一个编译驱动器能够不使用模板例程而将程序的目标文件连接到一起、通过读取连接器的错误消息来得知什么是未定义的、调用C++编译器来产生必要的模板例程的目标代码以及重连接。我们将在第11章涵盖了一些这样的技巧。
每个连接器都有一些命令语言来控制连接过程。至少连接器需要包含目标文件和要连接的库的列表。通常还需要一个包含可能的选项的很长的列表:是否要保存调试符号、是使用共享库还是非共享库、使用多种可能的输出格式中的哪一个等等。很多连接器允许通过一些途径来指定连接后的代码所绑定的位置,这在使用一个这样的连接器来连接一个系统内核或其他不需要受操作系统控制的程序时是派得上用场的。在支持多重代码段和数据段的连接器中,连接器命命令语言可以指定段被连接的顺序、对某些段进行特殊对待以及其他一些应用指定的选项。
有四种通用技术用于将命令传递给连接器:
写在命令行中:很多系统具有一个命令行或其等价物,通过它我们可以传递混合了的文件名和选项开关。这是Unix和Windows连接器的常用途径。在那些具有命令行长度限制的系统上,通常可以指示连接器去从一个文件中读取命令并将它们视为是通过命令行读取的。
混合在目标文件中:有些连接器,如IBM大型机连接器,在一个单独的输出文件中可以接受可选的目标文件和连接器命令。这要追溯到打孔卡片的年月,当时的人们积累目标卡片并在一个读卡机上对命令卡片进行手工打孔。
嵌入到目标文件中:有些连接器,特别如Microsoft的,允许将连接器命令嵌入到目标文件中。这允许一个编译器把对于一个文件所需要的连接命令放在这个文件自身中进行传递。例如,C编译器可以传递命令以搜索标准C库。
分离的配置语言:很少一部分连接器具有一个 羽翼丰满的用于控制连接的配置语言。GUN连接器,可以处理数量庞大的目标文件格式、机器架构以及地址空间转换,它有一个复杂的控制语言,能够允许程序员指定要连接的段的顺序、相似段的结合规则、段地址以及范围广阔的其他选项。其他连接器具有不那么复杂的语言来处理特定的特性如程序员定义的复用。
我们以一个很小但真实的连接实例来结束对连接的介绍。图3显示了一对C语言源文件,m.c有一个主程序,调用了名为a的例程,而a.c包含了这个例程,它又调用了库例程strlen和write。
图1-3:源文件
源文件m.c
extern void a(char *);
int main(int ac, char **av)
{
static char string[] = "Hello, world!\n";
a(string);
}
源文件a.c
#include
#include
void a(char *s)
{
write(1, s, strlen(s));
}
主程序m.c在我的Pentium机上被GCC编译为一个165字节的目标文件,具有典型的a.out目标格式,如图4所示。这个目标文件包含一个固定长度的头、16字节的包含只读程序代码的text段和16字节的包含了string的data段。这些之后是两个重定位入口,其中一个标记了在准备调用a时用来将string的地址放到栈顶的pushl指令,令一个标记了用于将控制转移到a中的call指令。符号表导出对_main的定义,导入_a,并为调试器包含了两个其他符号。(每个全局符号都带有一个前导下划线,其原因在第五章中讲述。)注意pushl指令引用了十六进制地址10——string的暂时地址,因为它在同一个目标文件中;而call引用了地址0,因为_a的地址是未知的。
图1-4:m.o的目标代码
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000010 00000000 00000000 00000020 2**3
1 .data 00000010 00000010 00000010 00000030 2**3
Disassembly of section .text:
00000000 <_main>:
0: 55 pushl %ebp
1: 89 e5 movl %esp, %ebp
3: 68 10 00 00 00 pushl $0x10
4: 32 .data
8: e8 f3 ff ff ff call 0
9: DISP32 a
d: c9 leave
e: c3 ret
...
子程序文件a.c被编译为一个160字节的目标文件,如图5所示,具有头、一个28字节的text段并且没有data段。两个入口标记了对strlen和write的调用,符号表导出_a并且导入_strlen和_write。
图1-5:a.o的目标代码
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 0000001c 00000000 00000000 00000020 2**2
CONTENTS, ALLOC, LOAD, RELOC, CODE
1 .data 00000000 0000001c 0000001c 0000003c 2**2
CONTENTS, ALLOC, LOAD, DATA
Disassembly of section .text:
00000000 <_a>:
0: 55 pushl %ebp
1: 89 e5 movl %esp, %ebp
3: 53 pushl %ebx
4: 8b 5d 08 movl 0x8(%ebp), %ebx
7: 53 pushl %ebx
8: e8 f3 ff ff ff call 0
9: DISP32 _strlen
d: 50 pushl %eax
e: 53 pushl %ebx
f: 6a 01 pushl $0x1
11: e8 ea ff ff ff call 0
12: DISP32 _write
16: 8d 65 fc leal -4(%ebp), %esp
19: 5b popl %ebx
1a: c9 leave
1b: c3 ret
为了产生可执行程序,连接器要合并这两个目标文件和一个C程序的标准启动初始化例程,以及C库中必要的例程。产生的部分可执行文件如图6所示。
图1-6:可执行程序选段
Sections:
Idx Name Size VMA LMA File off Algn
0 .text 00000fe0 00001020 00001020 00000020 2**3
1 .data 00001000 00002000 00002000 00001000 2**3
2 .bss 00000000 00003000 00003000 00000000 2**3
Disassembly of section .text:
00001020 :
...
1092: e8 0d 00 00 00 call 10a4 <_main>
...
000010a4 <_main>:
10a4: 55 pushl %ebp
10a5: 89 e5 movl %esp, %ebp
10a7: 68 24 20 00 00 pushl $0x2024
10ac: e8 03 00 00 00 call 10b4 <_a>
10b1: c9 leave
10b2: c3 ret
...
000010b4 <_a>
10b4: 55 pushl %ebp
10b5: 89 e5 movl %esp, %ebp
10b7: 53 pushl %ebx
10b8: 8b 5d 08 movl 0x8(%ebp), %ebx
10bb: 53 pushl %ebx
10bc: e8 37 00 00 00 call 10f8 <_strlen>
10c1: 50 pushl %eax
10c2: 53 pushl %ebx
10c3: 6a 01 pushl $0x1
10c5: e8 a2 00 00 00 call 116c <_write>
10ca: 8d 65 fc leal -4(%ebp), %esp
10cd: 5b popl %ebx
10ce: c9 leave
10cf: c3 ret
...
000010f8 <_strlen>:
...
0000116c <_write>:
...
连接器合并了每个文件中的对应段,因此这里有一个合并了的text段、一个合并了的data段以及一个bss段(初始化为0的段,两个输入文件都没有使用)。每个段都被填充至4K边界以匹配x86页面尺寸,因此text段为4K(减去一个出现在文件中但不是段的逻辑部分的20字节的a.out头),data和bss段也分别是4K。
合并了的text段包含称为start-c的库启动代码;然后是来自m.o的text段,被重定位到10a4;来自a.o的,被重定位到10b4;以及连接自C库的例程,被重定位到text的更高的地址处。合并后的data段这里没有显示,它的合并次序和text段的合并次序相同。由于_main的代码被重定位到十六进制地址10a4,因此这个地址被填到了start-c中的call指令里。在_main例程中,对string的引用被重定位到十六进制地址2024——string在data段中的最终地址,其中的调用地址被修正为10b4——_a的最终地址。在_a中,对_strlen和_write的调用地址也被修正为这两个例程的最终地址。
最后的可执行程序中还包括了很多其他来自C库的例程,这里没有显示,它们直接或间接地由启动代码或_write调用(如出错时调用的错误处理例程)。可执行程序不包含重定位数据,因为文件格式不是可重连接的,而且操作系统会将它加载到一个可知的固定地址处。它还会包含一个符号表以备调试器所用,尽管这个可执行程序并不使用符号而且符号表可以被去除以节省空间。
在这个例子中,连接自库的代码比程序本身的代码要大很多。这很平常,尤其是当程序使用了巨大的图形或窗口库时,这正是促进共享库(参见第9章和第10章)出现的原因。连接后的程序为8K,而同样的程序使用共享库则只有264字节。当然,这只是一个玩具性的例子,但真实的程序也能够同样戏剧性地节省空间。
将连接器和加载器划分为分离的程序有什么好处?在哪些情况下一个组合的连接加载器才是有用的?
过去的50年里产生的几乎所有操作系统都包含了一个连接器。为什么?
在这一章里我们讨论了对汇编或编译过的代码所进行的连接和加载。在一个能够直接解释源语言代码的纯解释型系统中,连接器和加载器是否有用呢?在一个能够将源代码转换为中间表示的系统中,如P-code或Java虚拟机中呢?
[回顶端]