我之前在调试一个跑在STM32上的裸机程序时,同组的同事问了个问题——“VSCode里用eide插件调试,编译器让你生成elf还是axf?这两个到底有什么不一样?明明我烧录用的是hex/bin啊。”那一刻我就意识到,很多人其实一直把“编译输出文件”和“程序运行时所在的内存空间”这两件事搞混了。而一旦把“Linux的ELF文件”和“虚拟地址空间”这两条线真正打通,很多看似零碎的bug、启动崩溃、链接报错,都会瞬间变得特别清晰。
这篇文章就是来干这件事的。我会从ELF文件为什么要用“虚拟地址”来组织内容说起,结合Linux用户态进程的虚拟地址空间布局,把编译、链接、加载、运行这条链路里的关键环节逐个拆开。不管你是在服务器上排查段错误,还是在嵌入式板子上调启动代码,理解ELF和虚拟地址空间的关系,都会让你的排查思路直接上升一个层级。
1. 一个让很多人卡住的困惑:ELF里的地址到底是什么地址
先从一个最容易混淆的地方说起。你用readelf -h看到某个可执行文件的入口点地址是0x401010,又看到某个嵌入式固件的.text段加载地址是0x08000000。前者让你联想到进程运行起来的某个内存地址,后者则让你以为是芯片内部Flash的物理地址。这两种理解其实都只对了一半。
1.1 同样一个文件,“烧录地址”和“运行地址”是两码事
嵌入式开发里有个概念叫LMA(Load Memory Address,加载地址)和VMA(Virtual Memory Address,虚拟内存地址)。如果你玩过STM32的标准启动流程就知道,芯片上电后从0x08000000取出复位向量,然后跳转到SystemInit,再把.data段从Flash拷贝到RAM、把.bss段清零——这里的“0x08000000”是Flash的物理映射地址,而程序运行时的栈顶、main函数实际执行的位置,却可以是在RAM里。
于是问题来了:编译器在生成机器码时,并不知道你的代码最终会被放在Flash的哪个物理地址、运行在RAM的哪个物理地址。它只负责告诉你——“当前这段代码我希望将来能运行在0x08000000这个地址附近”。这个地址就是链接器给你安排的VMA。当调试器拿着ELF文件去下载并单步调试时,它会把VMA映射到的地址当作变量和函数所在的真实内存来访问。
在Linux上其实也是一回事,只不过Linux的用户态程序把VMA藏得更深,因为页表的存在让“程序眼里看到的地址”可以直接等于“虚拟地址”,而内核负责把这层虚拟地址翻译成物理内存页。ELF文件里保存的每一段代码或数据地址,本质上都是虚拟地址,不是物理地址。搞清楚这一点,你再看ELF的任何输出,就不会再问“这个地址是内存条上的第几个字节”这种问题了。
1.2 链接器都在忙什么:从符号到“段”
要理解ELF为什么长这样,得先理解编译器的工作流。写C代码时,你引用了一个外部函数printf,编译器并不知道printf最后会落在哪个地址,它只在.o目标文件里留一个“未定义符号”的记录。等到链接器把所有.o和静态库、动态库归拢在一起时,它才会做两件关键的事:
- 第一件,把各个目标文件里的
.text(代码)、.data(已初始化数据)、.bss(未初始化数据)等相同属性的节合并。 - 第二件,给合并后的每个段分配一个具体的虚拟地址范围,并更新所有符号引用,让
call、jmp、mov这些指令的操作数指向真实的地址。
这个过程就是“重定位”。而链接器在分配地址时,依据的是一套脚本,比如GCC的默认链接脚本、嵌入式工程里的.ld连接脚本。脚本里规定了什么段放在前面、什么段放在后面、每个段落在哪个地址区域。于是你在ELF里看到的那些规律性极强的地址,比如x86-64的0x400000附近、ARM的0x8000附近,其实就是链接器对着链接脚本算出来的结果。
没有这个过程,是根本谈不上“地址空间”的。因为一个程序有多少代码、多少数据、需要多大栈、几块只读数据,只有链接时才知道。
1.3 0x08000000这种地址并不是厂商“瞎设定”的
嵌入式芯片的地址空间排布,有着极强的硬件原因。像Cortex-M3/M4,内部Flash被映射到0x08000000,而SRAM被映射到0x20000000。为了让CPU在取指时能直接命中Flash,厂商指定的启动地址就是Flash的映射基地址。链接脚本里为了让main、中断向量表能被硬件正确找到,就必须把VMA设成这些固定地址。
Linux则把这个思路推向了另一个极端。由于有MMU和页表,应用程序根本不需要知道物理内存在哪。链接器只需要遵守一个用户态ABI约定:栈从高地址向下生长,动态库映射在0x7f...区域,可执行文件的代码段放0x400000左右。剩下的所有“物理放哪”,都是内核的事。两种方案一个硬、一个软,但最终ELF文件里呈现出来的,都是一组经过精心安排的虚拟地址。认清这一点,你就跨过了“文件”和“运行时”之间的分界线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 正经拆一次ELF:链接视图与加载视图的世界
我第一次用readelf完整解析ELF文件时,也被“一大堆头信息”吓到过。实际上ELF文件的结构比大多数人想象得要简单,它只做了两套索引,一套给链接器用,一套给加载器用,两者共享ELF文件的开头描述。
2.1 两个视图一根文件
ELF全称是Executable and Linka
