一、地址概念和程序如何运行
在多道程序环境中,一个程序要开始执行,首先需要被加载到内存并创建对应的进程。这个过程通常被称为程序的“装入”。一个用户编写的源代码,是如何一步步转变为内存中可执行的指令的呢?这背后主要经历三个关键步骤。
首先是要编译:
这个任务由编译程序(Compiler)完成。它会将用户编写的源代码(如C、C++)翻译成CPU可以直接识别和执行的目标代码。编译的结果是生成若干个目标模块(Object Module),也就是编译后的程序段。
在目标模块中,所有地址的编排都以0作为起始基址。源代码中使用的那些便于人类理解的符号名(例如变量名、函数名),此时都被转换成了具体的地址编号。这样一来,生成的目标程序就占据了一个连续的地址范围,这个由程序逻辑定义的地址空间,我们称之为作业的逻辑地址空间,简称逻辑空间。
在逻辑空间里,无论是指令本身的地址,还是指令要访问的操作数地址,都被称为逻辑地址。简单来说,逻辑地址就是你源代码中的地址,或者编译器帮你转换后、程序视角下的地址。
其次是链接:
编译生成多个目标模块后,它们还是分散的个体。这时,链接程序(Linker)登场了。它的工作是将这一组目标模块,以及它们所依赖的库函数(如 printf)全部“缝合”在一起,形成一个完整的、自包含的装入模块(Load Module)。关于链接器如何具体工作,我们稍后会详细探讨。
最后是装入(地址重定位):
完整的装入模块准备好后,就轮到装入程序(Loader)将其搬运到真实的物理内存中。物理内存,就是我们电脑主板上插着的那一根根内存条的总容量。
物理内存由大量的存储单元构成,每个单元都有一个唯一的编号,这个编号就是内存地址或物理地址。你可以把整个内存想象成一个超大的数组,数组下标(从0到最大容量)就是每个存储单元的物理地址。
这里有个关键问题:我们生成的装入模块,其地址空间虽然统一了,但仍然是基于0起始的逻辑地址(浮动的)。而物理内存中空闲位置的起始地址是随机的。因此,在将程序装入内存时,必须确定其实际占用的物理起始地址,并修改程序中所有与地址相关的代码,这个过程就叫做地址重定位,其本质就是建立逻辑地址到物理地址的映射关系。
整个过程可以总结为下图所示的三个步骤:

二、程序的链接
源程序经过编译得到一组目标模块后,链接程序将它们组合成装入模块。根据链接发生的时机不同,可以分为三种主要方式:
- 静态链接:在程序运行之前,提前将所有目标模块及所需库函数链接成一个完整的可执行文件。这个文件一旦生成就不再改变。
- 装入时动态链接:将编译后的一组目标模块在装入内存的过程中进行链接,即边装入边链接。
- 运行时动态链接:将某些目标模块的链接推迟到程序执行过程中,只有当程序真正需要调用某个模块时,才临时将其加载并链接进来。
1.静态链接方式
我们通过一个例子来理解静态链接需要解决的问题。假设有三个编译后的目标模块A、B、C,长度分别为L、M、N。模块A中调用了模块B,模块B中调用了模块C。
在链接成一个装入模块时,需要解决两个核心问题:
- 修改相对地址:所有目标模块内部使用的都是相对于自身起始地址
0的相对地址。链接后,模块B和C在装入模块中的起始地址分别变成了L和L+M。因此,必须修改模块B和C内部所有的相对地址,为它们加上对应的偏移量。
- 变换外部调用符号:将模块间的调用符号(如
CALL B)转换为具体的相对地址(例如,将调用B的地址指向L)。
经过上述处理,就得到了一个完整的、不再拆分的可执行文件,也就是装入模块。这种预先链接好的方式就是静态链接。

2.装入时动态链接
这种方式下,目标模块是在装入内存的瞬间才被链接起来的。装入程序加载一个目标模块时,如果发现它有外部模块调用(例如模块A调用模块B),就会立刻去找到模块B并装入内存,同时完成地址重定位工作。
装入时动态链接的优点:
- 便于修改和更新:静态链接的可执行文件,要更新其中某个模块非常麻烦。而动态链接的各个模块是独立存放的,更新某个模块只需替换对应的文件即可。
- 便于实现共享:静态链接时,每个程序都要包含所用库的副本,浪费空间。动态链接则允许多个运行中的程序共享内存中的同一个目标模块(如公共库),大大节省了内存。
3.运行时动态链接
这是对装入时动态链接的进一步优化。有些模块(比如错误处理模块)可能在整个程序运行期间都不会被用到。如果一开始就把所有可能用到的模块都链接好,显然是一种浪费。
运行时动态链接的聪明之处在于:它将链接动作推迟到函数调用发生时。当程序执行过程中,第一次尝试调用某个尚未加载的模块函数时,操作系统才临时介入,找到该模块文件,将其加载到内存并进行链接。对于那些从未被调用的模块,它们根本不会占用任何内存资源。这种方式既能加快程序的初始加载速度,又能显著节省内存空间,是现代操作系统中非常流行的技术。
三、程序的装入(地址的变换)
为了便于理解,我们先从最简单的单个装入模块的装入过程讲起。将装入模块放入内存,主要有以下三种方式:
1.绝对装入方式
如果程序员或编译器在编译时,就已经确切知道这个程序未来会放在内存的哪个固定位置(比如从地址R开始),那么编译器可以直接生成使用绝对地址(物理地址)的目标代码。装入程序只需原封不动地将代码和数据放到指定位置即可,无需任何地址转换。
优点:CPU执行速度快,因为地址都是最终的,无需转换。
缺点:
- 灵活性极差。内存使用效率低,能同时运行的程序数量受限。
- 要求程序员或编译器必须精确管理内存布局,在复杂的多程序环境下几乎不可行。因此,这种方式主要用于早期的单道程序系统或对实时性要求极高的特定场景。
2.静态地址重定位(可重定位装入方式)
在多道程序环境下,编译器无法预知程序运行时的内存位置。因此,它生成的目标模块地址通常从0开始(逻辑地址)。静态重定位就是在程序装入内存时,一次性完成所有逻辑地址到物理地址的转换。
例如,一个程序中有指令 LOAD 1, 2500,意思是读取逻辑地址2500处的数据。如果系统决定把该程序装入到物理地址10000开始的内存区域,那么装入程序就需要做两件事:
- 把这条指令本身的地址
1000,加上基址10000,放到物理地址11000处。
- 把指令中的操作数地址
2500,也加上基址10000,变为12500。
这样,当CPU执行物理地址11000处的指令时,它就会去正确的物理地址12500处取数据。

优点:实现简单,无需硬件额外支持。
缺点:
- 一旦装入,程序就不能在内存中移动了,因为所有地址都已经“写死”为物理地址。
- 要求分配给程序的必须是一块连续的内存空间,无法利用内存中的碎片。
3.动态地址重定位(动态运行时装入方式)
静态重定位解决了多道程序同时运行的问题,但程序在运行中仍无法移动。动态重定位则将地址转换的时机再次推迟——不是在装入时,而是在每条指令执行前的那一刻。
它需要一个硬件支持:重定位寄存器(也叫基址寄存器)。这个寄存器里存放着程序在内存中的起始物理地址。
程序装入内存时,完全不做任何地址修改,代码中的地址依然是逻辑地址。当CPU需要访问内存(无论是取指令还是读写数据)时,它会自动将指令中的逻辑地址与重定位寄存器中的基址相加,实时得到物理地址。
优点:
- 程序可以在内存中任意搬迁,只需更新重定位寄存器的值即可,这非常有利于操作系统进行内存整理(紧缩)。
- 程序可以由多个不连续的内存块组成,只要为每个块设置对应的基址寄存器即可,实现了非连续分配。
缺点:需要硬件(MMU,内存管理单元)支持,增加了系统复杂性。
四、Windows NT动态链接库
动态链接库是上述动态链接思想在Windows系统中的具体实现,它极大提升了代码复用和模块化水平。
1. 构造动态链接库
一个DLL的创建通常涉及以下文件:
- 源文件 (.c/.cpp):包含函数的具体实现代码。
- 模块定义文件 (.def):指明哪些函数需要暴露给外部使用(导出),以及需要从其他DLL引入哪些函数。现在更多使用
__declspec(dllexport/dllimport) 关键字在源码中直接声明。
- 编译后生成
.obj 文件,链接时结合 .exp(导出文件)最终生成 .dll 文件和引入库 .lib 文件。
2. DLL的装入方法
方法一:装入时动态链接
这是最常见的方式。程序员在编码时像调用普通函数一样调用DLL函数(称为引入函数)。链接器在生成可执行文件时,会利用 .lib 引入库文件,在可执行文件中建立一个导入地址表。当程序被加载时,操作系统会找到所有依赖的DLL,将其映射到进程地址空间,并修正导入地址表中的函数指针,使其指向DLL中函数的真实入口地址。

下图直观展示了调用DLL函数时,是如何通过导入地址表跳转到正确位置的:

方法二:运行时动态链接
这种方式给予程序更大的灵活性。程序员在代码中通过 LoadLibrary、GetProcAddress、FreeLibrary 等API函数来“手动”管理DLL。
LoadLibrary:将指定的DLL加载到当前进程的地址空间。
GetProcAddress:根据函数名获取DLL中某个函数的入口地址(指针)。
FreeLibrary:减少DLL的引用计数,当计数为零时将其从内存卸载。
这种方式下,程序在编译链接时不需要 .lib 引入库文件。
HINSTANCE hInstLibrary; //模块句柄定义
DWORD (WINAPI *InstallStatusMIF)(char*, char*, char*, char*, char*, char*, char*, BOOL); //函数指针定义
if (hInstLibrary = LoadLibrary("ismif32.dll")) // 1. 加载DLL到内存
{
// 2. 获取DLL中特定函数的地址
InstallStatusMIF = (DWORD (WINAPI *)(char*,char*,char*, char*, char*, char*, char*, BOOL))
GetProcAddress(hInstLibrary, "InstallStatusMIF");
if (InstallStatusMIF)
{
// 3. 通过函数指针调用DLL中的函数
if (InstallStatusMIF("office97", "Microsoft", "Office 97", "999.999", "ENU", "1234", "Completed successfully", TRUE) != 0)
{
// 调用成功后的处理
}
}
FreeLibrary(hInstLibrary); // 4. 卸载DLL
}
理解程序的编译、链接与装入机制,是深入掌握操作系统内存管理和软件运行原理的基石。希望这篇梳理能帮助你构建起清晰的知识脉络。