想象你是一位刚入职的仓库管理员,负责签收总部寄来的“程序搬家箱”。箱子外面贴着一张面单:收件人是谁、货物总量、入口在哪一层;打开箱盖,内侧印着详细的收纳清单——每个隔层放什么、在第几层、占多大空间;而真正进屋安置货物的搬家师傅,手里拿的却是另一张装车清单——哪些隔层要搬上车、分别抬进哪个房间。这个箱子,就是 Linux 系统里所有可执行程序的标准形态——ELF 文件。面单对应文件头,收纳清单对应节头表,装车清单对应程序头表,隔层里的物品是“节”,师傅实际搬起的包裹组是“段”。理解 ELF,就是学会同时读懂“箱内如何收纳”与“货物如何搬进内存”这两套体系。当你训练的模型、部署的推理服务最终都以 ELF 形态跑在 Linux 上时,看懂段、节与符号,才不会在崩溃与性能问题面前束手无策。

岗位关联

学懂 ELF,等于拿到 Linux 程序的“出厂说明书”。AI 工程师排查段错误、OOM、Native 库崩溃时能直接定位问题;做性能剖析时,能看懂采样堆栈背后的段与符号,从“能跑”进阶到“看得穿”。

目录
  1. 可视化演示
  2. ELF 文件总览
  3. 链接视图剖析
  4. 执行视图剖析
  5. 双视图映射
  6. 符号表机制
  7. 重定位原理
  8. 分析工具链
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示系统处理流水线、实时性能指标曲线及多方案对比分析,包含数据流转、吞吐延迟监控与方案评估。

本节配有交互动效(进入视口后自动加载,约 3KB)。

系统可视化 处理流程:输入→处理→模型→输出 性能监控:实时指标 P=L×T

ELF 文件总览

想象你第一次负责签收公司寄来的“程序搬家箱”。箱子上贴着一张超大面单,正面印着:箱子是 32 寸还是 64 寸(位数)、货物总量多少、入口在哪一层(程序入口地址)、收纳清单和装车清单分别贴在箱子什么位置。收件员只需要扫一眼面单,就能决定要不要收下这个箱子。

ELF 文件头正是这张“面单”。它固定在文件最开头,前 4 个字节是著名的魔数——0x7F 45 4C 46,也就是 0x7F 加上 “ELF“ 三个字母。内核在加载程序前先检查这 4 个字节:对不上就拒绝执行,防止把图片、文本误当成可执行文件。文件头还记录了 e_phoff(程序头表偏移)和 e_shoff(节头表偏移),就像面单背面写着”两份清单分别在第几页”。

整个 ELF 文件可视为四大部件的组合:文件头、程序头表、节区数据、节头表。程序头表面向“加载进内存”(段),节头表面向“文件内部分隔”(节),两者之间夹着真正的代码与数据。这一布局在 32 位和 64 位系统上略有差异,但骨架一致。

现在你明白了,拿到任何 ELF 文件,第一件事就是读面单:是 ELF 还是 PNG、是 64 位还是 32 位、入口在哪。面单读懂了,后续开箱、装车、落位才有依据。

ELF文件=ELF文件头+程序头表+节区数据+节头表\displaystyle \text{ELF文件} = \text{ELF文件头} + \text{程序头表} + \text{节区数据} + \text{节头表}
这个公式是 ELF 的“四段式骨架”:面单(文件头)、装车清单(程序头表)、货物本体(节区数据)、收纳清单(节头表)。记住顺序,你就掌握了任何 ELF 文件的总地图。
  1. ELF文件头固定 64(64 位)或 52(32 位)字节,记录魔数、位数、类型、入口地址与两张表的位置。
    e_phoff, e_shoffe\_phoff,\ e\_shoff
  2. 程序头表数组结构,每一项描述一个“段”,告诉加载器把哪块数据搬到内存哪个地址。
    p_type, p_vaddr, p_memszp\_type,\ p\_vaddr,\ p\_memsz
  3. 节区数据文件的主体内容,含 .text 代码、.data 数据、.rodata 常量等。
    sh_offset, sh_sizesh\_offset,\ sh\_size
  4. 节头表数组结构,每一项描述一个“节”的名字、类型、偏移与大小。
    e_shoff+i×e_shentsizee\_shoff + i \times e\_shentsize

链接视图剖析

打开箱子,箱盖内侧贴着一张“收纳清单”,上面写着:第 1 层放证件(ELF 头)、第 2 层放衣服(.text 代码)、第 3 层放餐具(.data 数据)、角落里还有个“待购买物品占位区”(.bss)。收纳清单的价值在于“分类”——把同类物品搁在一起,方便清点与管理。

节头表(Section Header Table)就是这张收纳清单。它由若干表项组成,每个表项描述一个节:节名(sh_name)是字符串在 .strtab 中的偏移;节类型(sh_type)区分代码(PROGBITS)、数据(PROGBITS)、零初始化(NOBITS);还有文件偏移(sh_offset)与大小(sh_size)。常见的节包括:.text(机器指令)、.data(已初始化全局变量)、.bss(未初始化全局变量,不占文件空间)、.rodata(只读常量)、.symtab(符号表)、.strtab(字符串表)。

链接器在静态链接时,就是拿着这份清单按“类”作业:把所有 .text 合并、所有 .data 合并,然后统一分配地址。这就是 ELF 的“链接视图”——它关心文件内部如何组织,不关心加载时怎么摆放。

箱盖上的收纳清单帮你在装箱阶段“各归其位”——这正是链接器的工作方式:合并同类节、安排地址、生成符号索引。理解了清单,你就理解了文件内部的组织逻辑。

节头位置=e_shoff+i×e_shentsize\displaystyle \text{节头位置} = e\_shoff + i \times e\_shentsize
这条公式告诉你如何找到第 i 个节头:从节头表起点出发,跳过 i 个“表项宽度”。就像收纳清单按行编写,第 i 行就在“清单起始 + i × 每行高度”处。
  1. e_shoff节头表在文件中的起始偏移量,面单上标注的“清单贴在第几页”。
    e_shoff=0x002398e\_shoff = 0x002398
  2. e_shentsize单个节头表项占多少字节,64 位下通常是 64 字节。
    e_shentsize=64e\_shentsize = 64
  3. 索引 i第几个节头,从 0 数起。
    i=0,1,2,…,e_shnum−1i = 0,1,2,\dots,e\_shnum-1
  4. 相乘相加用“起点 + 行号 × 行宽”定位到目标行。
    e_shoff+i×e_shentsizee\_shoff + i \times e\_shentsize

执行视图剖析

搬家当天,师傅站在门口,手里拿的不是收纳清单,而是一张“装车清单”:哪些东西合成一包搬上楼、每个包裹放哪个房间。师傅不关心箱子里哪层是衣服哪层是餐具——他只关心“这包东西多重、往哪抬”。

程序头表(Program Header Table)就是装车清单。每个表项描述一个“段”:p_type 说明段类型,其中 PT_LOAD 表示“需要加载进内存”的段;p_offset 是段在文件中的起点;p_vaddr 是加载到内存后应该出现的位置;p_filesz 与 p_memsz 分别记录段在文件中与在内存中的大小。加载器逐个处理 PT_LOAD 段,把数据从磁盘拷贝到虚拟内存,并设置好页权限。

这里有个关键换算:文件里第 p_offset 字节,搬到内存后变成第 p_vaddr 字节。两者之间是固定的平移关系,因此从“文件中的偏移”可以推出“内存中的虚拟地址”,反过来也可以。段的对齐要求(p_align)通常按页(4KB)对齐,方便内核建立页表映射。

师傅拿着装车清单,几趟就把货物搬完:同一权限的节打包成段,一次映射入内存,进程随即从入口地址开始执行。执行视图的视角从来是“怎么搬、搬去哪”,而不是“箱子内部分了几层”。

虚拟地址=文件偏移−p_offset+p_vaddr\displaystyle \text{虚拟地址} = \text{文件偏移} - p\_offset + p\_vaddr
搬家师傅的换算口诀:物品在箱里的位置(文件偏移),减去“这包货物从箱子的第几排开始抬”(p_offset),再加上“包裹抬进房间后的落脚点”(p_vaddr),就得到它在内存里的门牌号。
  1. p_offset段在文件中的起始偏移量,即这包货物在箱子里从第几排开始。
    p_offset=0x400000p\_offset = 0x400000
  2. p_vaddr段加载后的虚拟地址,即货物抬进内存后放在哪个门牌号。
    p_vaddr=0x0000000000400000p\_vaddr = 0x0000000000400000
  3. 做差再求和文件偏移与 p_offset 的差,等价于虚拟地址与 p_vaddr 的差。
    虚拟地址−p_vaddr=文件偏移−p_offset\text{虚拟地址} - p\_vaddr = \text{文件偏移} - p\_offset

双视图映射

同样是那堆货物,收纳清单按“类别”分,装车清单按“搬运路线”合——一个装车包裹(段)往往包含好几个收纳隔层(节)。这种“多节归一段”的映射,是理解 ELF 双视图差异的钥匙。

典型 ELF 可执行文件有两个 PT_LOAD 段:第一个段包含 ELF 头、程序头表、.text、.rodata,权限为“读 + 执行”(r-x),相当于易碎品轻拿轻放;第二个段包含 .data、.bss,权限为“读 + 写”(rw-),相当于耐压的家当。权限不同,内核就能用页表实现代码段共享、数据段隔离。

映射时还有一个微妙差异:.bss 节不占文件空间(p_filesz 不含它),但加载后要占内存空间(p_memsz 含它)。于是“文件里的大小”总是小于等于“内存里的大小”,差的部分由 .bss 补上,并在加载时填零。这条公式是面试中区分段与节最经典的考点。

现在你能同时看懂两张清单:收纳清单告诉链接器“同类放一起”,装车清单告诉加载器“同权限抬一包”。段与节并非对立,而是同一个文件在“链接”和“执行”两个生命周期里的不同切片。

p_filesz≤p_memsz,Sbss=p_memsz−p_filesz\displaystyle p\_filesz \leq p\_memsz, \qquad S_{\text{bss}} = p\_memsz - p\_filesz
一句话理解:纸面登记(文件大小)比实际占地(内存大小)小,差额就是“到新家才买的书架”(.bss)——它不在行李箱里占体积,但进屋后必须给位置。
  1. p_filesz段在文件中占用的字节数,不含 .bss 这类零初始化区域。
    p_filesz=0x2000p\_filesz = 0x2000
  2. p_memsz段加载进内存后占用的字节数,包含运行时才产生的空间。
    p_memsz=0x3000p\_memsz = 0x3000
  3. 比较大小因为 .bss 只占内存不占文件,所以文件大小不大于内存大小。
    p_filesz≤p_memszp\_filesz \leq p\_memsz
  4. 作差两者的差值正是 .bss 节在加载时需要补零的空间。
    Sbss=p_memsz−p_fileszS_{\text{bss}} = p\_memsz - p\_filesz

符号表机制

每件搬进新家的家具都贴着一张姓名标签:“客厅沙发”“主卧衣柜”“书架(待购)”。有了标签,师傅才知道哪件物品该进哪个房间,邻居之间互相借用东西时也才知道该找谁。符号表就是 ELF 文件里的这套“姓名标签”系统。

符号表(.symtab)中的每一项对应一个 Elf64_Sym 结构:st_name 是符号名在 .strtab 中的偏移;st_info 的低 4 位表示符号类型(函数 STT_FUNC、数据 STT_OBJECT),高 4 位表示绑定属性(STB_LOCAL 仅本文件可见、STB_GLOBAL 全局可见、STB_WEAK 弱符号可被覆盖);st_shndx 记录符号定义在哪个节;st_value 是符号在节内的偏移或最终地址;st_size 是符号占用的字节数。

链接器做“符号解析”时,就是拿着所有目标文件的标签册核对:哪个地方引用了 printf,哪个文件定义了 printf,把引用和定义配对。如果只有引用没有定义(st_shndx 为 SHN_UNDEF),且找不到外部定义,链接就会报 “undefined reference“ 错误——就像搬家时发现 tag 上写着”借用邻居的梯子”,但邻居根本没这玩意儿。

标签虽小,却是整个链接时代的“通讯录”:局部符号(LOCAL)只在楼内自用,全局符号(GLOBAL)对外开放,弱符号(WEAK)允许被更强的同名符号顶替。理解了符号表,你才能真正读懂 nm 输出和链接错误信息。

Addr(sym)=sh_addr[st_shndx]+st_value\displaystyle \text{Addr}(\text{sym}) = \text{sh\_addr}[st\_shndx] + st\_value
符号的最终地址 = 它所在节的基地址 + 它在节内的偏移。好比标签上写着“离东墙 40 厘米”(st_value),而这个房间的起点是“东墙第 0 厘米”(sh_addr),两者相加就是家具的精确落点。
  1. st_shndx符号定义所在节的索引,指向节头表中的某个节。
    st_shndx=1(对应.text)st\_shndx = 1 \quad (\text{对应} .text)
  2. sh_addr[st_shndx]取出该节的最终基地址(链接后确定)。
    sh_addr[1]=0x401000\text{sh\_addr}[1] = 0x401000
  3. st_value符号在节内的偏移量,链接前记录、链接后仍保留。
    st_value=0x1149st\_value = 0x1149
  4. 相加基地址加偏移,得到符号的运行时绝对地址。
    Addr=0x401000+0x1149=0x402149\text{Addr} = 0x401000 + 0x1149 = 0x402149

重定位原理

新家装修完毕,师傅开始按图纸精确摆放家具:图纸上写“沙发贴东墙,距墙角 120 厘米”;但施工时发现墙角实际有个柱子,师傅需要根据现场的“当前位置”修正摆放距离。链接器面临的正是这个问题:多个目标文件合并后,每个符号的真实地址直到最后才能确定,此前写进指令里的地址全是“待修正”的占位。

这个修正动作叫“重定位”。链接器在目标文件中留下重定位条目(relocation entries),每个条目记录三件事:r_offset 指向需要修正的位置、r_info 编码了重定位类型与目标符号、r_addend 记录已有的加数。静态链接收尾时,链接器遍历所有重定位条目,按类型(如绝对寻址 R_X86_64_64、PC 相对寻址 R_X86_64_PC32)把占位值替换成真实计算值。

对于最常见的 PC 相对跳转,修正公式是:用符号的真实地址 S 加上指令里预留的加数 A,再减去当前指令所在的位置 P。为什么要减 P?因为 CPU 执行跳转时用的是“从下一条指令的相对偏移”,而链接器只知道自己安排在哪个地址,必须把绝对差换算成相对差。

重定位让“分散打包的目标文件”能最终拼成“一个完整可执行文件”:所有悬空的引用在此刻被焊死。这也是为什么静态链接产物可以脱离文件独立运行——它已经把所有地址都校准到位了。

修正值=S+A−P\displaystyle \text{修正值} = S + A - P
通俗版:沙发最终要放在“符号地址 S”,但师傅是从“当前墙角 P”开始量尺的,还要加上图纸预留的余量 A。三点一算,修正值就出来了。
  1. S(符号值)目标符号经过符号解析后的最终地址,相当于“沙发真正该放的位置”。
    S=0x402149S = 0x402149
  2. A(加数)指令中已有的固定偏移量,是编译器预留的“图纸余量”。
    A=−4A = -4
  3. P(位置)重定位条目所在指令的当前地址,即“师傅现在站的位置”。
    P=0x401189P = 0x401189
  4. 相减修正把绝对目标换算成相对偏移,写入指令字段。
    S+A−P=0x402149−4−0x401189=0xFCS + A - P = 0x402149 - 4 - 0x401189 = 0xFC

分析工具链

验箱师傅的工具箱里摆着几件顺手家什:面单读卡器(file)、收纳清单阅读器(readelf -S)、装车清单阅读器(readelf -l)、标签扫描器(nm)、反汇编放大镜(objdump -d)。日常排查问题时,这几件工具组合使用,几乎能看穿任意 ELF 文件。

最常用的一套组合拳:先用 file a.out 确认文件类型与位数;再用 readelf -h 看文件头、readelf -l 看程序头(段)、readelf -S 看节头(节);readelf -s 导出符号表,readelf -r 查看重定位条目;nm 专门列出符号及绑定属性;objdump -d 把 .text 反汇编成汇编指令。若想看原始字节,hexdump -C 配合偏移量即可。

发布程序时,“瘦身”也是一门手艺:strip 会把 .symtab 等调试/符号节从文件里摘除。因为运行时根本不需要这些标签,摘除后体积显著减小、逆向难度提高。代价是日后崩溃时,gdb 只能给出地址,无法还原成函数名——所以生产环境通常会额外保存一份带符号的文件备查。

工具链的本质,是把“面单、收纳清单、装车清单、标签册”逐件摊开给你看。熟练运用它们,你就能像老练的验箱师傅一样,在几分钟内摸清一个陌生二进制文件的底细。

Vstrip后=V原始−Vsymtab−V调试节\displaystyle V_{\text{strip后}} = V_{\text{原始}} - V_{\text{symtab}} - V_{\text{调试节}}
搬运完成后,师傅会把不再需要的标签清单收进仓库:程序运行时不需要符号表,删掉它,箱子更轻、更不容易被动手脚。
  1. V_原始strip 之前整个文件的大小,包含符号表与调试节。
    V原始=2.4MBV_{\text{原始}} = 2.4\text{MB}
  2. V_symtab符号表节占用的空间,运行时完全不被加载。
    Vsymtab=0.7MBV_{\text{symtab}} = 0.7\text{MB}
  3. V_调试节.debug_* 系列节占用的空间,仅用于调试器。
    V调试节=0.9MBV_{\text{调试节}} = 0.9\text{MB}
  4. 作差两者之和即 strip 能节省的空间。
    Vstrip后=2.4−0.7−0.9=0.8MBV_{\text{strip后}} = 2.4 - 0.7 - 0.9 = 0.8\text{MB}

AI 应用场景

恶意样本初筛

阶段:训练数据安全治理与恶意样本特征提取

解决的问题:传统安全分析师手工逆向一个 Linux 恶意样本需要数小时甚至数天;AI 检测模型需要先把 ELF 转成结构化特征,才能批量判断文件是否恶意。

具体案例:某安全公司的 AI 检测流水线先对每个样本执行 readelf -S -l,提取节区熵、PT_LOAD 段权限异常、符号表缺失等特征,再送入分类模型。一次捕获的挖矿木马被 strip 且加壳,正是靠“异常的高熵 .text + 无符号表 + 可疑 PT_LOAD 权限”组合特征被模型标记为高风险。

嵌入式模型部署瘦身

阶段:模型部署与边缘设备优化

解决的问题:边缘设备存储与带宽极其有限,编译出的 Native 推理库动辄几十 MB,传输与落盘都吃不消。

具体案例:AIoT 工程师把 NCNN/TNN 推理引擎交叉编译为 ELF 动态库后,依次用 strip 去除符号表、裁剪 .debug 节,so 文件从 40MB 缩小到 6MB;再用 readelf -S 核对关键节仍完整,模型在摄像头设备上照常推理。

推理服务崩溃定位

阶段:模型服务运维与稳定性保障

解决的问题:Python 训练脚本调用 CUDA 算子库或 libtorch 时偶发段错误,Python 栈只能看到 C++ 层之外的调用,无法定位 Native 崩溃点。

具体案例:AI 基础设施工程师保留带符号的 libtorch.so,通过 coredump 配合 gdb、addr2line 把崩溃地址还原成函数名与行号;若线上 so 已被 strip,则用 nm 结合加载基址推算大致符号。最终定位到某个自定义算子越界写,修复后推理服务恢复稳定。

Python 演示

python65 行
#!/usr/bin/env python3"""极简ELF解析器: 读取运行中的Python解释器二进制本身"""import structimport sys ELF64_SYMBOL = '<IBBHQQ'  def read_cstr(buf, off):    end = buf.find(b'\x00', off)    return buf[off:end].decode('utf-8', errors='replace')  def main():    path = sys.executable    with open(path, 'rb') as f:        data = f.read()    if data[:4] != b'\x7fELF':        sys.exit('不是 ELF 文件: ' + path)    if data[4] != 2:        sys.exit('仅解析 ELF64')    e_type = struct.unpack_from('<H', data, 16)[0]    e_shoff = struct.unpack_from('<Q', data, 0x28)[0]    e_shentsize = struct.unpack_from('<H', data, 0x3A)[0]    e_shnum = struct.unpack_from('<H', data, 0x3C)[0]    e_shstrndx = struct.unpack_from('<H', data, 0x3E)[0]    print('文件      :', path)    print('ELF类型   :', {2: 'EXEC', 3: 'DYN'}.get(e_type, e_type))    print('节表项数  :', e_shnum)     secs = []    for i in range(e_shnum):        off = e_shoff + i * e_shentsize        name, stype, sflags, saddr, soff, ssize = struct.unpack_from('<IIQQQQ', data, off)        secs.append((name, stype, soff, ssize))     _, _, str_off, str_size = secs[e_shstrndx]    strtab = data[str_off:str_off + str_size]    info = []    for name, stype, soff, ssize in secs:        sname = read_cstr(strtab, name)        info.append((sname, stype, soff, ssize))     print('\n=== 关键节区 ===')    for sname, stype, soff, ssize in info:        if sname in ('.text', '.rodata', '.data', '.bss', '.symtab'):            print('%-8s 偏移=0x%08x 大小=0x%x' % (sname, soff, ssize))     symtab = next((s for s in info if s[0] == '.symtab'), None)    if symtab:        _, _, t_off, t_size = symtab        print('\n=== 符号表(前6个GLOBAL函数) ===')        found = 0        for i in range(t_size // 24):            st_name, st_info, st_other, st_shndx, st_value, st_size = struct.unpack_from(                ELF64_SYMBOL, data, t_off + i * 24)            if st_info >> 4 == 1 and st_info & 0xF == 2 and st_name:                print('%-20s 值=0x%016x 大小=%d' % (read_cstr(strtab, st_name), st_value, st_size))                found += 1                if found >= 6:                    break  if __name__ == '__main__':    main()
目的:不借助任何第三方库,仅用 struct 解析当前 Python 解释器这个真实 ELF 文件,列出关键节区与前 6 个全局函数符号。
  1. if data[:4] != b'\x7fELF':检查魔数,确认文件确实是 ELF 格式。
  2. e_shoff = struct.unpack_from('<Q', data, 0x28)[0]在文件头偏移 0x28 处读取 64 位节头表偏移。
  3. struct.unpack_from('<IIQQQQ', data, off)一条语句解出节头的名字、类型、偏移与大小等字段。
  4. secs[e_shstrndx]取出节名字符串表(.strtab)的位置,用于把节名索引翻译成可读名字。
  5. if st_info >> 4 == 1 and st_info & 0xF == 2高 4 位是 GLOBAL 绑定、低 4 位是函数类型,筛选出全局函数符号。
  6. t_size // 24ELF64 符号表项固定 24 字节,用总大小除以单项大小得到符号总数。

常见误区

误区我刚学 ELF 时觉得 segment(段)和 section(节)只是叫法不同,反正都是把文件分成几块。

正解两者服务于完全不同的视图:节(section)是“链接视图”的概念,按内容分类(代码、数据、符号);段(segment)是“执行视图”的概念,按加载权限分组(读+执行、读+写)。一个段常常包含多个节,例如第一个 PT_LOAD 段里既有 .text 也有 .rodata。用搬家比喻说:节是箱内隔层(怎么收纳),段是搬上楼的大包裹(怎么运输),二者描述的是同一批货物的两种组织方式。

为何常见:因为“段”和“节”在日常中文里意思相近,且 readelf 输出里二者位置接近,初学者很容易把两套表混为一谈。

误区我以为 .bss 既然在文件里列为一个节,就一定有对应的磁盘空间,程序体积大是它造成的。

正解.bss 是 NOBITS 类型的节,只记录“将来需要多大空间”,在文件里不占任何字节。加载时,内核为它在内存中留出空间并填零。它就像搬家登记表上的“书架(到新家再买)”——不占行李箱体积,但进屋后必须占一面墙。

为何常见:因为从 readelf -S 看到的 .bss 有偏移和大小的值,容易误以为那些字节真实存在于文件中,实际 offset 常指向文件末尾之后。

误区程序能跑就行,strip 去掉符号表应该完全不痛不痒。

正解strip 确实不影响程序运行,因为加载器不读 .symtab;但它删除的是“运行时诊断能力”。一旦线上程序段错误,没有符号表,gdb 只能给出裸地址,addr2line 也还原不出函数名。生产环境应保留带符号的副本,部署时再 strip,否则排查崩溃会非常痛苦。

为何常见:因为大多数人第一次接触符号表是在“体积优化”场景,看到 strip 后程序照常运行,就误以为符号表毫无价值。

误区ELF 开头的 0x7F 45 4C 46 只是一段固定的花哨标记,删掉或改掉无所谓。

正解魔数是内核与加载器识别文件类型的第一道关卡。执行程序前,内核先读这 4 字节,不匹配就直接返回“格式错误”;动态链接器同样靠它校验共享库文件。它相当于证件上的照片和钢印——没有它,门卫根本不让你进。

为何常见:因为正常工作文件里从未见它“起作用”,只有当你用 file 命令或手动改坏一个文件时,才会发现识别机制对它的依赖。

误区动态库需要在运行时解析符号,所以重定位是动态链接的事,静态链接一步到位不需要。

正解静态链接同样依赖重定位,只不过发生时机在“构建时”而非“运行时”。多个目标文件合并时,各个符号的最终地址尚未确定,链接器必须遍历重定位条目(readelf -r 可见)把占位值修正为真实地址。动态链接只是把一部分重定位推迟到进程启动时完成。

为何常见:因为初学者通常先学“可执行文件直接能跑”,再看动态库加载才遇到符号解析,容易把重定位与动态链接绑定在一起。

面试题

Q1. ELF 文件开头的 4 字节魔数是什么?

单选题 · easy

  • A.0x7F 45 4C 46← 正确答案
  • B.0x89 50 4E 47
  • C.0xFF D8 FF E0
  • D.0x25 50 44 46

答案:A。0x7F 加上 “ELF“ 三个字母的 ASCII 码(45 4C 46)构成 ELF 魔数。B 是 PNG 魔数,C 是 JPEG 魔数,D 是 PDF 魔数。就像面单上的快递单号,内核凭它认出”这是个程序搬家箱”。

Q2. 描述 ELF 链接视图与执行视图的核心表分别是?

单选题 · mid

  • A.节头表、程序头表← 正确答案
  • B.程序头表、节头表
  • C.符号表、重定位表
  • D.重定位表、符号表

答案:A。节头表描述“箱内隔层”(链接视图),程序头表描述“装车包裹”(执行视图)。B 把顺序颠倒了;C、D 中的符号表与重定位表是辅助表,不负责描述视图骨架。

Q3. 典型 ELF 可执行文件中两个 PT_LOAD 段的权限组合是?

单选题 · mid

  • A.可读可执行、可读可写← 正确答案
  • B.可读可写、可写可执行
  • C.可执行可写、可读可写
  • D.只读、可读可写

答案:A。第一个 PT_LOAD 段装 .text/.rodata,权限为读+执行(r-x);第二个段装 .data/.bss,权限为读+写(rw-)。写+执行同时出现是危险信号,恶意样本常因此被识别。

Q4. 关于 .bss 节的说法哪些是正确的?

多选题 · mid

  • A.存放未初始化全局变量← 正确答案
  • B.不占用文件存储空间← 正确答案
  • C.加载到内存后清零← 正确答案
  • D.属于可执行代码段

答案:ABC。.bss 是 NOBITS 节,只登记内存大小、不占磁盘;运行时由内核分配并填零。它属于数据段而非代码段,因此 D 错误。就像“到新家再买的书架”,不占行李箱、进屋后占墙。

Q5. R_X86_64_PC32 重定位的地址修正公式是?

单选题 · hard

  • A.S + A - P← 正确答案
  • B.S + P - A
  • C.A - S - P
  • D.P + A + S

答案:A。S 是符号最终地址、A 是加数、P 是重定位位置。PC 相对寻址需要把绝对目标换算成“当前指令处的相对偏移”,所以用 S 加 A 再减 P。其余组合来自符号与位置的混排。

Q6. 关于 ELF 符号绑定类型 st_bind 的说法哪些是正确的?

多选题 · hard

  • A.LOCAL 符号仅本目标文件可见← 正确答案
  • B.GLOBAL 符号可供其他模块引用← 正确答案
  • C.WEAK 符号可被强符号覆盖← 正确答案
  • D.符号绑定与符号类型概念相同

答案:ABC。LOCAL 限本文件、GLOBAL 全局可见、WEAK 可被同名强符号覆盖,三种绑定语义各自独立。D 错误——绑定(st_bind)描述“可见范围”,类型(st_type)描述“是函数还是数据”,是两个维度。

Q7. ELF64 文件头的字节大小是?

单选题 · easy

  • A.52 字节
  • B.64 字节← 正确答案
  • C.32 字节
  • D.128 字节

答案:B。ELF64 文件头固定 64 字节,ELF32 则是 52 字节。这 64 字节就是那张“面单”,足够装下魔数、位数、类型、入口地址与两张表的位置。

Q8. 符号表项中 st_shndx 字段的含义是?

单选题 · mid

  • A.符号所在节的索引← 正确答案
  • B.符号在节内的偏移
  • C.符号的全局可见性
  • D.符号的哈希校验值

答案:A。st_shndx 用于定位“符号定义在哪个节”,st_value 才是节内偏移,st_info 的高 4 位编码可见性。索引 + 偏移相加,才能算出符号的最终地址。

Q9. 想查看可执行文件中所有节的信息应使用哪个命令?

单选题 · easy

  • A.readelf -S 命令← 正确答案
  • B.readelf -h 命令
  • C.readelf -s 命令
  • D.readelf -r 命令

答案:A。-S 显示节头表(linker view),-h 显示文件头,-s 显示符号表,-r 显示重定位条目。记忆口诀:S 是 Sections,s 是 symbols。

Q10. 静态链接器在最终链接时完成的工作包括哪些?

多选题 · hard

  • A.解析符号引用与定义← 正确答案
  • B.按段合并输入节区← 正确答案
  • C.计算修正确切地址← 正确答案
  • D.运行时下载动态依赖

答案:ABC。静态链接的三大步是符号解析(配对引用与定义)、节合并(同类节拼成段)、重定位(计算并写入修正地址)。D 属于动态链接/运行时加载器的工作,静态产物不需要下载依赖。