【Linux指南】动静态库系列(八):ELF 加载与进程地址空间:程序还没运行,为什么已经有地址

文章目录

前面我们讲了 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_structvm_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 之前,还需要做很多初始化工作,例如:

  1. 设置初始栈环境。
  2. 准备命令行参数和环境变量。
  3. 初始化 C 运行库。
  4. 处理动态链接相关工作。
  5. 调用全局构造函数。
  6. 最后再进入 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

理解这些内容后,我们就可以继续深入动态库加载与共享机制。

相关推荐
两点王爷2 小时前
常用的 Docker 镜像拉取地址仓库及常用命令详解
运维·服务器·容器
wdfk_prog2 小时前
用 Git Submodule + Sparse Checkout 管理 RT-Thread:内核、BSP、第三方库与业务代码分层实践
运维·缓存·docker·容器·ros
神.秘.人2 小时前
【BIOS/UEFI 与 MBR/GPT 的区别详解】
运维·服务器
第十人i2 小时前
Linux 一键重装系统脚本:reinstall 工具详解
linux·服务器
懂软件的胡子个哥2 小时前
微信二次开发如何接入 AI,真正可用的不是简单自动回复
运维·微信·wechatapi·个人微信号二次开发·微信群管理
海宇数据3 小时前
零信任架构实战:基于海宇公安二要素认证即时版构建自动化对公账户准入网关
运维·人工智能·架构·自动化
jinyishu_3 小时前
Linux基础指令:从Shell原理到权限
linux
蓝胖的四次元口袋3 小时前
MySQL主从复制与Redis哨兵高可用集群实战
运维·主从复制·哨兵模式