文章目录
-
- 一、程序还没运行,为什么会有地址
- 二、逻辑地址、虚拟地址先通俗理解
- [三、ELF Header 中的入口地址](#三、ELF Header 中的入口地址)
- [四、Program Header Table:加载视图](#四、Program Header Table:加载视图)
- [五、Segment 如何初始化进程地址空间](#五、Segment 如何初始化进程地址空间)
- [六、mm_struct 和 vm_area_struct 通俗理解](#六、mm_struct 和 vm_area_struct 通俗理解)
- [七、为什么 FileSiz 和 MemSiz 可能不同](#七、为什么 FileSiz 和 MemSiz 可能不同)
- 八、INTERP:动态链接器在哪里
- [九、_start 为什么先于 main](#九、_start 为什么先于 main)
- [十、__libc_start_main 做什么](#十、__libc_start_main 做什么)
- 十一、动态链接发生在什么时候
- 十二、一个完整观察实验
- 十三、这一篇和动态库有什么关系
- 十四、总结
前面我们讲了 ELF 文件格式、符号表和重定位。链接器生成最终可执行文件后,接下来就轮到操作系统加载它。
这里有一个很有意思的问题:一个 ELF 程序还在磁盘上,没有被加载到内存中,它里面为什么已经有地址?readelf -h 为什么能看到入口地址?objdump -d 反汇编时左边为什么也有地址?
这篇文章就来讲 ELF 加载与进程地址空间的关系。

一、程序还没运行,为什么会有地址
我们先看一个命令:
bash
readelf -h main
输出里会有一项:
text
Entry point address: 0x401050
再反汇编:
bash
objdump -d main | less
你会看到类似:
text
0000000000401050 <_start>:
401050: ...
程序还在磁盘上,为什么已经有 0x401050 这样的地址?
原因是:
text
ELF 文件在生成时,已经按照未来进程地址空间中的布局进行了统一编址。
这个地址不是磁盘物理地址,也不是当前已经存在的物理内存地址,而是程序未来运行时使用的虚拟地址或逻辑地址布局。
二、逻辑地址、虚拟地址先通俗理解
现代操作系统不会让进程直接使用物理内存地址。每个进程看到的是自己的虚拟地址空间。
可以把虚拟地址空间理解为:
text
操作系统给每个进程提供的一套"假想的连续地址世界"。
进程认为自己拥有从低地址到高地址的一整套空间,里面有:
text
代码区
数据区
堆
共享区
栈
内核相关映射
但这些虚拟地址最终要通过页表转换成真实物理地址。
ELF 文件中的地址,描述的是未来进程虚拟地址空间中的布局。
三、ELF Header 中的入口地址
ELF Header 中的 Entry 字段记录程序入口地址。
查看命令:
bash
readelf -h main
重点字段:
text
Entry point address
这个地址通常不是 main 函数,而是 _start。
很多初学者以为程序从 main 开始执行,但实际上 C/C++ 程序入口一般是 _start。
执行链路大致是:
text
内核加载 ELF
-> 跳转到 ELF Header 中记录的入口地址
-> 执行 _start
-> 完成运行库初始化
-> 调用 __libc_start_main
-> 调用 main

所以 main 不是操作系统眼中的第一站。
四、Program Header Table:加载视图
ELF 中有两个重要视图:
text
Section Header Table:链接视图
Program Header Table:加载视图
链接器处理目标文件时更关心 section;操作系统加载可执行程序时更关心 segment。
查看 Program Header Table:
bash
readelf -l main
你会看到类似:
text
Program Headers:
Type Offset VirtAddr FileSiz MemSiz Flags
LOAD ... ... ... ... R E
LOAD ... ... ... ... RW
INTERP ...
其中 LOAD 段表示需要加载到内存的部分。
Flags 表示权限:
text
R:可读
W:可写
E:可执行
例如:
text
R E:代码相关区域,可读可执行
RW:数据相关区域,可读可写
五、Segment 如何初始化进程地址空间
每个 LOAD segment 都描述了一段将来要映射到进程地址空间的区域。
它通常包含:
text
文件偏移 Offset
虚拟地址 VirtAddr
文件中大小 FileSiz
内存中大小 MemSiz
权限 Flags
对齐 Align
操作系统加载程序时,会根据这些信息创建进程地址空间中的内存区域。
例如:
text
某个 LOAD segment:
从 ELF 文件偏移 X 处读取 FileSiz 字节;
映射到进程虚拟地址 VirtAddr;
内存区域大小为 MemSiz;
权限为 R E 或 RW。
这就是为什么课件里会提到:进程的 mm_struct、vm_area_struct 初始化信息来自 ELF 的 segment。
六、mm_struct 和 vm_area_struct 通俗理解
Linux 内核中,每个进程都有自己的地址空间描述。
可以粗略理解:
text
mm_struct:描述整个进程地址空间
vm_area_struct:描述地址空间中的一段连续区域
比如一个进程中可能有多个 VMA:
text
代码段 VMA:可读可执行
数据段 VMA:可读可写
堆区 VMA:可读可写,可增长
共享库 VMA:映射动态库
栈区 VMA:可读可写
ELF 的 Program Header Table 告诉内核:应该创建哪些初始 VMA,它们的起始地址、结束地址、权限是什么。
所以:
text
进程虚拟地址空间不是凭空来的,它的一部分初始布局来自 ELF 的加载信息。
七、为什么 FileSiz 和 MemSiz 可能不同
在 Program Header Table 中,经常会看到:
text
FileSiz
MemSiz
FileSiz 是文件中实际占用的大小,MemSiz 是加载到内存后需要的大小。
它们可能不同,典型原因就是 .bss。
未初始化的全局变量不需要在磁盘文件中保存一堆 0,只需要记录需要多大空间。加载到内存时,操作系统再把对应区域清零。
例如:
c
int g_arr[1024 * 1024];
如果它未初始化,没必要在可执行文件里真的保存 4MB 的 0。
这就是 .bss 能节省磁盘空间的原因。
八、INTERP:动态链接器在哪里
在 readelf -l main 中,你可能看到:
text
INTERP
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
这表示当前程序需要动态链接器参与加载。
对于动态链接程序来说,内核加载可执行文件后,还要让动态链接器加载程序依赖的共享库,完成符号解析和重定位。
这个动态链接器通常是:
text
/lib64/ld-linux-x86-64.so.2
它是动态库运行期加载的关键角色。
九、_start 为什么先于 main
C 程序不是直接从 main 开始。
在 main 之前,还需要做很多初始化工作,例如:
- 设置初始栈环境。
- 准备命令行参数和环境变量。
- 初始化 C 运行库。
- 处理动态链接相关工作。
- 调用全局构造函数。
- 最后再进入
main。
_start 是程序真正的入口点之一。它通常由 C 运行时或链接器提供。
反汇编时可以看到 _start 中会间接调用 __libc_start_main,再由它调用 main。
十、__libc_start_main 做什么
__libc_start_main 是 glibc 提供的重要函数。
它大致负责:
text
初始化运行环境
处理 argc/argv/envp
调用初始化函数
调用 main
处理 main 的返回值
最终退出进程
所以,从用户角度看,程序从 main 开始;从系统加载角度看,main 已经是初始化完成后的阶段。
十一、动态链接发生在什么时候
对于动态链接程序,启动过程中还要加载依赖的 .so。
大致流程可以理解为:
text
内核读取 ELF Header
-> 根据 Program Header Table 加载主程序
-> 找到 INTERP 指定的动态链接器
-> 动态链接器加载依赖的共享库
-> 完成必要的符号解析和重定位
-> 进入程序运行时初始化
-> 调用 main
这也解释了为什么动态库找不到时,程序甚至还没进入 main 就失败。
因为 main 之前,动态链接器就必须先解决依赖库加载问题。
十二、一个完整观察实验
准备一个简单程序:
c
#include <stdio.h>
int g_val = 10;
int g_uninit;
int main()
{
printf("hello %d %d\n", g_val, g_uninit);
return 0;
}
编译:
bash
gcc main.c -o main
观察 ELF Header:
bash
readelf -h main
观察 Program Header:
bash
readelf -l main
观察 section:
bash
readelf -S main
观察入口附近代码:
bash
objdump -d main | less
查看动态依赖:
bash
ldd main
你可以重点观察:
text
Entry point address
INTERP
LOAD segment
Section to Segment mapping
_start
__libc_start_main
十三、这一篇和动态库有什么关系
动态库加载也发生在进程地址空间中。
主程序是 ELF,动态库 .so 也是 ELF。操作系统和动态链接器都要根据 ELF 中的 segment 信息,把它们映射到进程虚拟地址空间里。
所以理解主程序如何加载,是理解动态库如何映射的前提。
下一篇就会进入动态库加载:
text
磁盘上的 libxxx.so
如何进入物理内存?
如何映射到每个进程的共享区?
多个进程为什么可以共享同一份动态库代码?
十四、总结
ELF 程序在磁盘上时就已经记录了未来运行时的地址布局,这些地址属于未来进程的虚拟地址空间视角。
ELF Header 记录入口地址,Program Header Table 描述如何加载 segment,内核据此初始化进程地址空间中的不同 VMA。
程序并不是直接从 main 开始,而是先进入 _start,经过运行库和动态链接相关初始化后,最终才调用 main。
理解这些内容后,我们就可以继续深入动态库加载与共享机制。