我最早被ELF和地址空间这两个词整懵,是在刚接触Linux下程序链接和加载的时候。拿一个编译好的二进制,用readelf打开,里面一会儿是Section(节),一会儿是Segment(段),链接时看的是节,运行时用的却是段,搞不清谁说了算。后来又遇到一个更实际的问题:同一个二进制在A机器上跑得好好的,拷到B机器上直接报cannot execute或者启动秒崩,查到最后,问题出在入口点、加载地址和动态链接器这三件事上。这三件事,恰恰全都被ELF文件里的结构决定了,而程序运行时的所有状态,又都发生在进程的地址空间里。所以“ELF与地址空间”不是两个独立的知识点,它们是一件事的两张脸:一张是文件在磁盘上的静态编码,一张是运行时内存里的动态布局。这篇文章我就从实操角度,把这两张脸之间的映射关系完全拆开来讲,适合正在学系统编程、排查加载崩溃问题、或者想真正看懂readelf和objdump输出的人。不需要你有多深的底层基础,但至少自己动手编译运行过C程序,接下来我会一步步把关键字段、映射规则和常见坑都亮出来。
1. 程序能跑起来,靠的不是节头表而是程序头表
1.1 文件里其实有“两张地图”
接触过ELF格式的人,大概率都听过两个结构:Section Header Table(节头表)和Program Header Table(程序头表)。它们同时存在于同一个文件里,描述的东西却完全不是一回事。打个比方:节头表是仓库的“物资清单”,按用途把数据分类存放,哪些是代码(.text)、哪些是只读数据(.rodata)、哪些是全局变量(.data),以及它们的符号名、调试信息;程序头表则是一份“运输路线图”,只关心哪些东西需要被装车运到“内存”这个目的地,以及到了之后应该放在哪个地址、需要什么权限、占据多大空间。
链接阶段用的是节头表,把多个目标文件的节合并、重定位、生成最终布局;但到了加载阶段,内核根本不看节头表。内核只根据程序头表里的每个条目,把相应的字节映射到虚拟地址空间。这也是很多新手第一次用strip删掉节头表后发现程序还能正常跑的原因——节头表在运行时不是必需品,程序头表才是。如果你把程序头表损坏了,那这个文件基本就废了,内核会在加载阶段直接拒绝它。
1.2 从readelf -h看ELF头的三个关键字段
随便拿一个编译好的二进制,执行readelf -h,你会看到类似下面的输出:
ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x1060 Start of program headers: 64 (bytes into file) Start of section headers: 13960 (bytes into file) Size of program headers: 56 (bytes) Number of program headers: 13ELF头一共64字节,从Magic开始。7f 45 4c 46是固定魔数,对应ASCII就是DEL E L F;第5个字节02表示64位,01表示小端序。这些基础信息之后,真正需要盯住的字段有三个:
Type:当前文件的类型,常见值是ET_REL(可重定位的目标文件)、ET_EXEC(固定地址的可执行文件)、ET_DYN(共享对象或PIE可执行文件)。到了现代发行版,默认编出来的可执行文件几乎都是ET_DYN,原因稍后专门说。Entry point address:入口点的虚拟地址。CPU从内核态返回用户态后,跳转的就是这个地址。Start of program headers和Number of program headers:程序头表的位置和大小。加载器解析程序头表,就是从这个偏移开始读取一串固定长度的结构体。
1.3 ET_EXEC与ET_DYN的地址观差异
同样是可执行文件,ET_EXEC和ET_DYN在“地址观”上有根本区别。ET_EXEC在链接期就把虚拟地址写死了。典型32位时代的可执行文件,入口点常常是0x8048340,加载器必须严格按照文件里写的地址把它们放进内存,没有商量的余地。而ET_DYN生成的是一个位置无关的代码体,文件里记录的地址只是一个“相对基址”,实际加载到哪个地址由内核或动态链接器在运行时决定。这也是为什么你会在64位机器上看到PIE程序的入口点显示成0x1060这种小数字——它不是最终运行地址,最终地址是某个随机基址加上这个偏移。
提示:判断一个二进制到底是不是PIE,最快的办法就是看
Type。如果显示DYN,它大概率是PIE;显示EXEC,则说明编译时用了-no-pie。排查段错误时,这个信息经常能帮你快速判断是不是地址写死导致的问题。
我自己排查过不少崩溃问题,第一件事永远是用readelf -h照一下入口点和类型,再用readelf -l看程序头表。前者告诉我“文件想从哪开始跑”,后者告诉我“文件想怎么被铺进内存”。这两个问题搞清楚了,加载类的问题基本就解决了一半。
2. 从文件到内存:PT_LOAD是怎么把二进制“铺”进地址空间的
2.1 一次典型编译后readelf -l输出怎么看
现在动手看真实输出。我准备了一个很普通的C程序,只做一件事:打印一个全局变量的地址。编译命令是gcc -o demo demo.c,然后执行readelf -l demo,那里有一节Program Headers值得逐行看:
Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8 INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x0005d8 0x0005d8 R 0x1000 LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x0001e1 0x0001e1 R E 0x1000 LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x0001a0 0x0001a0 R 0x1000 LOAD 0x002da0 0x0000000000002da0 0x0000000000002da0 0x000150 0x000248 RW 0x1000 DYNAMIC 0x002db8 0x0000000000002db8 0x0000000000002db8 0x0001a0 0x0001a0 RW 0x8 GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10 GNU_RELRO 0x002da0 0x0000000000002da0 0x0000000000002da0 0x000150 0x000150 R 0x1这部分输出是理解“加载”的关键。LOAD类型的段,正是真正会通过mmap映射到进程地址空间的东西。其他段(PHDR、INTERP、DYNAMIC等)服务于动态链接和加载过程本身,它们通常落在某个LOAD段的范围内,对应同一块文件字节的多种语义。
这个输出里有4个LOAD段,从权限上分就是三类:第一个是只读的(R),包含ELF头、程序头表、只读数据;第二个是可读可执行(R E),也就是代码段,.text就在这里;第三个是只读(R),通常存放只读数据;第四个是读写(RW),对应.data和.bss。你看最后一个LOAD段的FileSiz是0x150,但MemSiz是0x248,这个差值里藏着一个重要的知识点,下一小节说。
2.2 地址、偏移和页对齐:为什么必须满足那个不等式
我们以这一段为例:
LOAD 0x002da0 0x0000000000002da0 0x0000000000002da0 0x000150 0x000248 RW 0x1000Offset是文件偏移0x2da0,VirtAddr是虚拟地址0x2da0,看起来两者相等,但因为Align是0x1000,实际映射时不能简单认为文件偏移等于虚拟地址。加载器做的是:把文件从0x2da0处开始、长度为0x150的字节,映射到虚拟地址0x2da0开始的区域。同时满足一个关键的不等式:
VirtAddr % Align == Offset % Align也就是说,虚拟地址与文件偏移在对齐粒度上必须是同余的。这保证了一个LOAD段在跨越页边界时,文件内容与虚拟内存的对应关系不会被“搓得错位”。如果这个条件不满足,加载器会直接报错,程序无法启动。
在满足同余的前提下,映射操作实际是以页为单位的。mmap的粒度是页(x86-64下通常是4KB,也就是0x1000),所以即使文件中只有一小段需要映射,内核也会映射整个页。多余的部分自动补零。这里就出现了一个非常常见的现象:多个相邻的LOAD段,明明文件里是连续的信息,但映射到内存后,页边界处常常会有“重叠”或“交叠映射”。很多人在/proc/self/maps里看到两个相邻区域起始地址差一个页大小,会疑惑文件偏移明明只有几十个字节的差,为什么要浪费一个页。这是加载器的正常行为,不是内存泄露。
2.3 BSS:不占文件却占内存的特殊区段
回到刚才那个FileSiz=0x150而MemSiz=0x248的段。文件里只有0x150字节的真实数据,但加载到内存后,这个段要占0x248字节的空间。多出来的0x248 - 0x150 = 0xf8个字节,就是.bss的内容。
.bss段存放的是未初始化或初始化为零的全局变量和静态变量。C语言规范里说,未显式初始化的全局变量会被自动清零。为了实现这一点,链接器不需要在文件里为这0xf8字节存一堆零,那样太浪费磁盘。它只需要在程序头表里把MemSiz做得比FileSiz大,加载器在映射完文件内容后,会把多出来的那部分内存清零。
注意:这部分内存是匿名映射,不依赖文件。所以你在文件系统里看这个二进制大小,和它在内存里实际占用的虚拟内存大小,从来就不是一回事。一个只有几KB的静态链接程序,运行时虚拟内存可能是几十KB,就是这个原因。
这一点对排查“为什么程序启动后内存占用比二进制文件大很多”的问题特别有帮助。以后别再怀疑是内存泄露了,先看看是不是.bss段在作祟。可以用readelf -S查看节表,通常能看到:
[XX] .bss NOBITS 0000000000004040 003040 000008 00 WA 0 0 8NOBITS这个类型说明它在文件里不占空间。任何用size命令看到的bss数值,都代表运行时才产生的内存需求。
3. 地址空间的整体格局:0x400000与0x555555…都是从哪来的
3.1 进程虚拟内存的“标准户型图”
我曾经被一个问题困扰很久:为什么有的程序入口地址是0x400000,有的是0x555555554000,还有的是0x8048000?后来明白,这些数字背后对应的是不同的可执行文件类型和编译选项。
非PIE的64位可执行文件,默认加载基址是0x400000。也就是说,第一个LOAD段从虚拟地址0x400000开始放,入口点算出来通常是0x400xxx附近。而PIE程序由于需要地址随机化,加载基址不是固定的,常见值是0x555555554000附近,但每次运行都会变。这个0x555555554000本身并不特殊,它只是ASLR在x86-64上选出来的典型基址之一。
从整个进程的角度看,虚拟地址空间的标准布局大致是这样的,从低到高排:
- 低地址区域:PIE程序基址、或者非PIE程序的固定基址
- 再往上一点:共享库映射区,
ld-linux和libc.so通常在这里 - 中间偏下:堆(通过
brk或mmap扩展) - 很高地址:线程栈、环境变量、参数向量
- 最高区域:内核空间(用户态不可访问)
把这个布局对应到代码运行时的感受就是:全局变量在一个相对固定的低地址区,局部变量在高地址的栈上,动态分配的堆在两者之间。这也是为什么栈向下增长、堆向上增长,两者在极端情况下会在中间相遇。
3.2 ASLR下的三块随机基址
ASLR(地址空间布局随机化)是现代系统的标配,但它在不同场景下随机化的对象不一样。对于PIE程序,二进制本身的加载基址是随机的;对于动态链接器,它的映射基址是随机的;对于栈、堆、mmap基址,同样也是随机的。这三块随机彼此独立。
有趣的是,非PIE程序的加载基址不受ASLR影响,因为它必须加载到一个固定地址。所以为了提高安全性,现代发行版默认把系统里几乎所有可执行文件都编成PIE。你可以在自己的机器上验证:
gcc -no-pie -o demo_nopie demo.c gcc -o demo_pie demo.c连续运行多次,分别观察它们的启动地址。demo_nopie的入口地址固定不变,demo_pie的加载基址每次都不同,但相对偏移始终一致。这就是PIE的本质:地址本身随机,结构相对固定。
3.3 用/proc/self/maps把理论对回现实
理论讲再多,不如直接看一眼。在C程序里写几行代码,运行时读取/proc/self/maps,然后打印出来。典型输出是这样的:
555555554000-555555555000 r--p 00000000 08:01 123456 /path/to/demo 555555555000-555555556000 r-xp 00001000 08:01 123456 /path/to/demo 555555556000-555555557000 r--p 00002000 08:01 123456 /path/to/demo 555555557000-555555558000 rw-p 00002000 08:01 123456 /path/to/demo 7ffff7a00000-7ffff7bc6000 r--p 00000000 08:01 789012 /lib/x86_64-linux-gnu/libc.so.6 7ffff7bc6000-7ffff7c46000 r-xp 001c6000 08:01 789012 /lib/x86_64-linux-gnu/libc.so.6 7ffff7c46000-7ffff7d4c000 r--p 00246000 08:01 789012 /lib/x86_64-linux-gnu/libc.so.6 7ffff7d4c000-7ffff7d50000 rw-p 0034c000 08:01 789012 /lib/x86_64-linux-gnu/libc.so.6 7ffff7d50000-7ffff7d54000 rw-p 00000000 00:00 0 7ffff7d86000-7ffff7da9000 rw-p 00000000 00:00 0 7ffff7da9000-7ffff7dad000 r--p 00000000 00:00 0 7ffff7dad000-7ffff7db0000 r-xp 00000000 00:00 0 7ffff7db0000-7ffff7db7000 r--p 00001000 00:00 0 7ffff7db7000-7ffff7dbd000 rw-p 00002000 00:00 0 7ffff7dbd000-7ffff7dc0000 r--p 00003000 00:00 0 7ffff7dc0000-7ffff7dc1000 r-xp 00000000 00:00 0 7ffff7dc1000-7ffff7dc5000 r--p 00001000 00:00 0 7ffff7dc5000-7ffff7dc9000 rw-p 00002000 00:00 0 7ffff7dc9000-7ffff7dcb000 r--p 00003000 00:00 0用这个输出去对前面的理论,一切都对上了。同一个可执行文件在 maps 里有多个区域,权限分别是r--p、r-xp、r--p、rw-p,而且每两个区域之间地址差正好是0x1000。它们来自同一个文件的不同LOAD段,文件偏移从0递增。读到这些,你就能直观理解“一个程序是由多个映射区域拼起来的”,而不是一个单一的连续块。
这里有个容易忽略的细节:普通maps输出很容易分辨主程序、libc和匿名映射。但我实际调试时更喜欢看/proc/self/smaps,因为它还包含每个区域的RSS、PSS和KernelPageSize信息,对排查某个特定段是不是真的占了物理内存非常有用。比如说,一个巨大的.bss段在 maps 里可能显示为rw-p匿名区域,但smaps里的RSS很小,说明它还没有真正被触碰过。这也是Linux内存管理“按需分配”的直接表现。
4. 动态链接与重定位:PIE时代,加载时到底谁在改地址
4.1 从PT_INTERP说起:ld.so怎么接管启动
回到程序头表输出里的INTERP段。它只有一行:
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]这个INTERP段指定了动态链接器的路径。当你执行一个动态链接的可执行文件时,内核加载完程序头表里的LOAD段后,发现存在INTERP段,于是先把动态链接器本身映射进内存,再把控制权交给它。真正的程序入口点并不是0x1060,而是动态链接器内部的某个入口。动态链接器要完成的事情包括:读取程序头表、加载所有依赖的共享库、处理重定位、初始化各种依赖关系,最后才跳转到真正的入口点。
这也是为什么在GDB里启动一个动态链接程序,你会在_start之前看到一堆ld-linux的调用。很多只学过静态链接原理的人第一次看到这种场景会困惑:怎么程序还没到main就已经跑了那么多代码?那些代码全是动态链接器在干活。
4.2 GOT/PLT和重定位表的角色
动态链接的核心问题是:一个共享库被加载到随机地址后,代码里所有引用外部函数和全局变量的地方,怎么找到真实地址?答案就是重定位和GOT/PLT这两个机制的组合。
GOT(全局偏移表)是一张数据表,存放外部符号的真实地址;PLT(过程链接表)是一段段跳转指令。以调用printf为例,动态链接器加载libc.so后,会把printf的真实地址写入GOT中对应的槽位。程序里调用printf的指令,不直接包含printf的地址,而是跳转到PLT中某个桩,再通过GOT间接跳转。这种设计让共享库里的代码不需要因为加载地址不同而逐字节修补指令。
重定位信息在ELF里存储为.rela.dyn和.rela.plt两个节。readelf -r可以看到具体内容:
Relocation section '.rela.dyn' at offset 0x4d8 contains 8 entries: Offset Info Type Sym. Value Symbol's Name 000000003dc8 000000000008 R_X86_64_RELATIVE 0 000000003dd0 000000000008 R_X86_64_RELATIVE 0 000000004028 000000000004 R_X86_64_JUMP_SLOT 0000000000000000 printf@GLIBC_2.2.5每一行都表示加载器需要修改某个地址处的值。R_X86_64_RELATIVE是PIE下最常见的一种类型:它告诉动态链接器,把一个基于加载基址计算出来的绝对地址写进去。R_X86_64_JUMP_SLOT对应GOT中函数地址的填充,也就是printf的跳转槽。理解这些条目的意义后,当你用objdump -d看到程序里充满jmp *got(%rip)之类的指令时,就不会觉得奇怪了。
提示:静态链接的程序没有
INTERP段,也没有.rela.plt。如果你怀疑某个二进制应该是静态的,但readelf -l里出现了INTERP,那它实际上是动态的,只是因为链接方式的原因把依赖库“嵌”进了启动流程之外的地方。
4.3 动态库加载失败的一般排查:依赖、命名空间与ELF类型
动态链接是运行时才发生的事情,所以它的错误也都在运行时才暴露。最常见的报错有几种:
cannot open shared object file: No such file or directory:找不到依赖库。用ldd看依赖列表,往往能发现某个.so路径指向一个不存在的文件。relocation error: symbol not found:库里引用的符号在已加载的依赖里找不到。这通常是版本不匹配,比如用老版本头文件编译的新程序链接了旧版库。- 加载后秒崩:可能是
.rela.dyn里的重定位项被错误处理,或者库本身加载地址冲突。
我实际排查过一种场景:宿主程序启动后通过dlopen动态加载一个业务插件,结果一直报“找不到文件”,但文件明明就在指定目录下。后来发现,插件依赖的另一个版本库被宿主程序提前加载了,和插件需要的版本冲突。这种问题在ELF层面看,就是重定位时符号解析到了“错误”的库上。用LD_DEBUG=libs,files可以清楚看到动态链接器的解析过程,比瞎猜有效得多。
另外还有一个和ELF类型相关的坑:如果你想加载的.so文件实际上不是共享对象,而是静态归档库(.a),或者它的e_type写的是ET_REL,那dlopen同样会失败。确认一个文件到底是哪种类型,永远用readelf -h看Type,不要只看扩展名。
5. 手写解析器与实测:把ELF头读到字节级别
5.1 用Python脚本解析ELF头与程序头表
理解了概念之后,我习惯再写一点“玩具代码”把结构体变成看得见的数据,这比任何文档都直观。下面是一个极简的Python脚本,只解析ELF头和程序头表:
import struct import sys def parse_elf(path): with open(path, 'rb') as f: data = f.read(64) magic = data[:4] if magic != b'\x7fELF': raise ValueError('not an ELF file') ei_class = data[4] # 1=32bit, 2=64bit ei_data = data[5] # 1=little endian, 2=big endian fmt = '<' if ei_data == 1 else '>' if ei_class == 2: # Elf64_Ehdr e_type, e_machine, e_version = struct.unpack(fmt + 'HHI', data[16:24]) e_entry, e_phoff, e_shoff = struct.unpack(fmt + 'QQQ', data[24:48]) e_phentsize, e_phnum = struct.unpack(fmt + 'HH', data[54:58]) else: # Elf32_Ehdr e_type, e_machine, e_version = struct.unpack(fmt + 'HHI', data[16:24]) e_entry, e_phoff, e_shoff = struct.unpack(fmt + 'III', data[24:36]) e_phentsize, e_phnum = struct.unpack(fmt + 'HH', data[42:46]) print(f'Type=0x{e_type:04x} Machine=0x{e_machine:04x} Entry=0x{e_entry:x}') print(f'phoff={e_phoff} phentsize={e_phentsize} phnum={e_phnum}') if e_phoff == 0 or e_phnum == 0: print('no program header') return with open(path, 'rb') as f: f.seek(e_phoff) phdrs = f.read(e_phentsize * e_phnum) for i in range(e_phnum): off = i * e_phentsize if ei_class == 2: p_type, p_flags = struct.unpack_from(fmt + 'II', phdrs, off) p_offset, p_vaddr, p_paddr = struct.unpack_from(fmt + 'QQQ', phdrs, off + 8) p_filesz, p_memsz, p_align = struct.unpack_from(fmt + 'QQQ', phdrs, off + 32) else: p_type, p_offset, p_vaddr, p_paddr, p_filesz, p_memsz, p_flags, p_align = ( struct.unpack_from(fmt + 'IIIIIIII', phdrs, off) ) print(f'seg{i}: type=0x{p_type:x} flags={p_flags} off=0x{p_offset:x} ' f'vaddr=0x{p_vaddr:x} filesz=0x{p_filesz:x} memsz=0x{p_memsz:x} align=0x{p_align:x}') if __name__ == '__main__': parse_elf(sys.argv[1])用这个脚本去解析任意ELF文件,你会发现它与readelf -h和readelf -l的字段完全对得上。这个脚本很短,但已经把“文件里的ELF头到底是什么样”这个问题彻底落地了。你可以改一改输出,增加一个过滤条件,例如只打印PT_LOAD段,或者判断文件是不是PIE,把它变成自己常用的诊断工具。
5.2 同一份源码三种编译方式的对比
为了把前面讲的ET_EXEC、ET_DYN和共享库三者的区别刻进脑子里,我经常用同一份源码做对比实验。以Hello World为例:
#include <stdio.h> int global_a = 1; int global_b; int main() { printf("addr of global_a: %p\n", &global_a); printf("addr of main: %p\n", main); return 0; }分别执行三次编译:
gcc -no-pie -o demo_nopie demo.c gcc -fPIE -pie -o demo_pie demo.c gcc -fPIC -shared -fPIE -o libdemo.so demo.c然后逐一查看:
readelf -h demo_nopie | grep -E 'Type|Entry' readelf -h demo_pie | grep -E 'Type|Entry' file demo_nopie demo_pie libdemo.so可以看到demo_nopie的Type是EXEC,入口点是一个很大的绝对地址,运行多次地址不变;demo_pie的Type是DYN,入口点是一个相对偏移,运行多次基址随机变化;libdemo.so的Type也是DYN,但它多了-shared编译产生的共享对象特征,没有入口点概念,也无法作为可执行文件直接运行。
还有一个细节值得观察:demo_nopie的readelf -l里,第一个LOAD段的VirtAddr通常从0x400000开始;而demo_pie的VirtAddr从0x0开始。这个差别直接对应加载器处理方式的不同。非PIE程序每个load段的虚拟地址是绝对地址,加载器必须精确映射到那个位置;PIE程序的虚拟地址只是一个相对偏移,加载器会在运行时选一个基址,然后做“加基址”处理。
5.3 踩过的坑与排错经验
最后我想分享几个实际踩过的坑,它们都和“ELF与地址空间”直接相关。
第一个坑:用strip删过头之后用gdb调试,符号和源码全丢了,但程序还能跑。这个我在前面解释过,因为节头表不是运行必需的。但如果你还需要调试器给你展示源码行号和变量名,千万别在调试版上跑strip --strip-all,至少留一个.symtab和.debug_*节。
第二个坑:在32位系统上把一个编译成ET_DYN的共享库硬塞到低地址区域,结果加载失败。原因与mmap的MAP_FIXED有关。当动态链接器想把某个库映射到一个固定地址时,如果那个地址已经被占用,它会报错而不是自动换地方。这在嵌入式交叉编译场景里很常见,解决方式一般是用-Wl,-Ttext-segment=xxx重新链接,或者在linker script里定义好布局。
第三个坑:使用dlopen时,被加载的库内部依赖了一个没有被导出的符号。这不是ELF格式的错,而是链接可见性问题。用objdump -T查看动态符号表,确认需要的符号是否在.dynsym中导出。如果目标库编译时用了-fvisibility=hidden或-Wl,--exclude-libs,ALL,外部就看不到这些符号,dlopen后调用必然失败。
提示:排查动态符号相关问题时,
nm -D和objdump -T是你的左膀右臂。nm -D只看动态符号,不显示静态符号。如果一个库在nm -D里找不到你需要的函数,却能在nm(静态符号)里找到,多半是export列表被故意收紧了。
第四个坑,也是最容易让人懵的:同一个“二进制”在机器A上运行正常,机器B上报段错误,而且地址每次都变。这种情况八成和ASLR或库版本有关。我会先在B机器上执行readelf -l对比两个机器上同一个文件的程序头表,确认是不是文件传输损坏;如果文件一致,再关闭ASLR(setarch -R)重跑一次,看崩溃地址是否固定。固定了,说明是地址相关的问题;不固定,说明是代码运行时的逻辑问题,跟加载器无关。
这套排查链路我用了很多年,从readelf -h到readelf -l,再到readelf -d看动态段,再到/proc/self/maps和gdb配合,整套流程下来,ELF和地址空间之间的关系会变得非常清楚。到了这个程度,再回看那些0x400000、0x555555554000和入口点的数字,你就不会觉得它们是玄学,而是能自己算出来、自己定位问题的“已知量”了。