地址是在何时被确定的?编译,汇编,链接,运行时?
地址是在何时被确定的?编译,汇编,链接,运行时?
结论
编译阶段如果能在当前翻译单元找到实体(即函数有函数体,变量有定义),编译器会在汇编生成的 .o 文件中生成一个可重定位的偏移地址(不是最终地址)。这个偏移地址要在链接时或运行时(ASLR/PIE)才能变成真正的内存地址;
编译阶段如果只能得到声明(没有函数体/变量定义),编译器会生成一个未定义的重定位条目,留到链接时去其他目标文件中寻找
| 阶段 | 确定的地址类型 |
|---|---|
| 编译/汇编 | 只确定当前文件内的偏移量(相对于段的偏移),并标记哪些地方需要外部符号。 |
| 链接 | 负责静态地址绑定:确定全局变量、本文件函数、跨文件函数在中的相对虚拟地址(RVA)。 |
| 运行时 | 负责动态地址绑定:加载器决定exe加载基址(ASLR)、栈上局部变量的具体位置、DLL函数的真实入口地址。 |
假设你的项目有 main.obj 和 other.obj。
main.obj里面有:.text(代码段)、.data(已初始化数据段)、.bss(未初始化数据段)。other.obj里面也有:.text、.data、.bss。
链接器的做法:把这两个 .obj 文件拆开,把所有的 .text 块粘成一个大 .text 段,把所有的 .data 块粘成一个大 .data 段,依次类推。
定义在本文件内的函数/全局变量(静态链接)
- 时机:链接时(静态重定位)
- 原理:编译/汇编阶段,编译器不知道最终
main.exe会被加载到内存的哪个基地址(是0x400000还是0x500000?)。 - 在
.o文件中:地址暂时写为0x00000000或相对于当前段的偏移量(Offset)。 - 链接时:链接器把所有
.o和库打包,确定每个段的起始地址,然后修正(重定位)这些偏移量,算出最终的虚拟地址(例如0x00401000)。 - 例外(PIE/ASLR):如果开启了地址随机化(Windows默认开启),链接器算出的地址是相对地址(RVA),最终的绝对地址要在运行时由操作系统加载器加上一个随机基址才能确定。
跨文件调用的函数(外部符号 extern)
- 时机:链接时(必填坑)
- 原理:编译
main.c时,编译器只检查func()的声明对不对。它根本不管func实现在哪,直接生成一条call 0x00000000(占位符),并在目标文件里记录一个“重定位表”条目,写着“这里需要外部符号func的地址”。 - 链接时:链接器把
main.obj和other.obj合在一起,找到了func的真正位置,于是把main.obj里那个0x00000000的占位符 修改成 正确的地址。
动态链接库(DLL)中的函数(如 printf)
- 时机:运行时(动态链接)
- 原理:你在代码里写了
printf,编译时只看到头文件声明。链接时,链接器找到的是printf在导入表(Import Table)里的一个桩(Stub),而不是真正的地址。 - 运行时:当
main.exe被操作系统加载时,加载器会读取导入表,把printf所在的 DLL(如ucrtbase.dll)加载进内存,然后将真正的内存入口地址回填到你的调用代码中
函数内部的局部变量(栈变量 int a;)
- 时机:运行时(动态分配)
- 原理:函数被调用前,它根本没内存地址。只有在运行时,CPU执行到
push ebp / mov ebp, esp时,才在栈顶(Stack)划出一块空间。这个地址是运行时栈指针(ESP/RSP)减去一个偏移量算出来的。编译时只能确定“相对于栈基址的偏移”,绝对地址只有在程序跑起来的那一瞬间才确定
拓展
链接过程做的事:
- 符号解析:把各
.o文件中的函数调用和变量引用关联到定义 - 地址重定位:给每个符号分配最终的内存地址
- 合并段:把各文件的
.text、.data等段合并
静态链接 vs 动态链接:
- 静态链接(
.a/.lib):库代码直接复制进可执行文件,体积大但独立运行。找到虚拟地址 - 动态链接(
.so/.dll):运行时才加载库,体积小、可共享,但依赖库文件。运行时才有地址
嵌入式裸机一般用静态链接,Linux嵌入式常用动态链接。
例子

这 5 个字节的机器码 e8 da ff ff ff,在生成 .o 文件的这一刻,就已经被编译器写死了! 链接器绝对不会去改动它。

1. 破解机器码 21:e8 da ff ff ff
e8:这是 x86-64 架构中call指令的机器码,表示“相对偏移调用”。da ff ff ff:代表偏移量,这是一个小端序(Little-Endian)存储的 32 位有符号整数。转换成十进制是-38。- 21:文件里的当前指令地址
2. 编译器是怎么算出跳转目标 0的?
CPU 执行 call 指令时,它的计算逻辑是:
跳转目标地址 = 当前指令地址 + 指令长度(5字节) + 偏移量(
da ff ff ff即 -38)
我们来代入验证:
- 当前指令地址:
0x21 - 指令长度:
5字节 - 偏移量:
-38(十六进制0xFFFFFFDA)
计算结果:0x21 + 5 + (-38) = 0x26 - 38 = 0x00(因为 38 的十六进制是 0x26)
结果正好是 0,也就是 helper 函数的起始地址!
3. 为什么编译器敢在 .o 里就把数字写死?
因为 helper 函数和 main 函数被编译在同一个 .o 文件内。()
- 编译器在生成机器码时,已经计算好了
helper在.text段中的位置(偏移量0x0)。 - 编译器也计算好了
main中的call指令在.text段中的位置(偏移量0x21)。 - 两者的距离差
0 - 0x26 = -38是一个编译期常量。
所以,编译器直接在生成的 .o 文件里写入了 e8 da ff ff ff。链接器拿到这个 .o 文件后,看到这条指令里的偏移量已经算好了,它会直接跳过,不再做任何修改!
而在main.c里面调用在对应模块文件的.h声明.c定义的这种函数,预处理后之后只有声明在main.c文件里面,找不到函数实体,所以编译时也不知道具体的位置
预处理,编译,汇编()时能找到函数实体,就可以确定地址,就像上面的

跨文件调用,找不到函数实体,就只有占位符,没有地址


只有链接后,才会跨文件确定地址

整个过程可以理解成:
源代码
│
▼
预处理
│
#include / #define
│
▼
编译器
│
┌───────────┴───────────┐
│ │
已知的局部信息 未知的外部符号
│ │
▼ ▼
生成局部偏移 记录 relocation
│ │
└───────────┬───────────┘
▼
.o
│
▼
链接器
│
┌───────────┼───────────┐
▼ ▼ ▼
符号解析 地址布局 重定位
│ │ │
└───────────┼───────────┘
▼
ELF / EXE
│
▼
操作系统加载
│
ASLR / DLL / SO
│
▼
实际运行地址1. 编译/汇编阶段
确定当前翻译单元内部可以确定的布局和相对位置,并为无法确定的引用留下重定位信息。
2. .o 文件
**.o**不是一个已经完全确定地址的最终程序,而是一个“带符号和重定位信息的可重定位目标文件”。
3. 链接阶段
链接器负责跨目标文件进行符号解析、地址布局和重定位,形成最终程序映像。
4. 运行阶段
操作系统加载器可能进一步根据加载基址、ASLR、动态库等因素确定实际运行时地址。
所以不要再简单记:
编译 = 地址
链接 = 地址
运行 = 地址而应该记成:
编译/汇编
↓
“相对位置”
链接
↓
“整个程序中的位置”
运行
↓
“这次运行时的实际位置”
