新手必看:如何用objdump和readelf深入分析ELF文件结构(附hello_world.o实例)

📅 发布时间:2026/7/11 4:01:14 👁️ 浏览次数:
新手必看:如何用objdump和readelf深入分析ELF文件结构(附hello_world.o实例)
从零开始用objdump和readelf解剖ELF文件以hello_world.o为例你是否曾好奇自己写下的几行C语言代码在编译之后究竟变成了什么模样那个看似神秘的.o文件里除了你的代码逻辑还藏着哪些不为人知的秘密对于许多刚接触系统底层或逆向工程的开发者来说理解编译后的产物是迈向进阶的关键一步。今天我们就以最经典的hello_world.o为例拿起objdump和readelf这两把“手术刀”亲手解剖一个ELF目标文件看看它的五脏六腑是如何运作的。这篇文章面向所有对程序底层结构感兴趣的初学者无论你是想深入理解编译链接过程还是为后续的调试、逆向分析打下基础这次探索都将是一次扎实的实践。1. 认识我们的“病人”ELF文件与目标文件在动手术之前我们得先了解病人的基本情况。ELF全称Executable and Linkable Format即可执行可链接格式是Linux和许多类Unix系统上二进制文件可执行文件、目标文件、共享库等的标准格式。你可以把它想象成一个结构严谨的集装箱里面分门别类地存放着程序运行所需的一切机器指令、数据、符号信息、重定位表等等。我们今天的“病人”hello_world.o是一个可重定位目标文件。它与最终的可执行文件如a.out关键区别在于“可重定位”这四个字。这意味着文件中的代码和数据地址还没有被最终确定它们通常是基于零地址或某个相对地址编译的需要链接器ld在链接阶段将其与其他目标文件或库合并并分配最终的绝对内存地址。为了有个直观感受我们先创建一个简单的C程序并编译成目标文件// hello.c #include stdio.h int global_init_var 84; // 已初始化的全局变量将进入 .data 段 int global_uninit_var; // 未初始化的全局变量将进入 .bss 段 static int static_var 0; // 初始化为0的静态变量进入 .bss 段 void func(int arg) { printf(Argument is: %d\n, arg); } int main() { static int local_static_var 42; // 已初始化的局部静态变量进入 .data 段 int local_var 10; // 局部自动变量存在于栈上不占目标文件空间 func(local_var global_init_var); return 0; }使用GCC编译它生成目标文件gcc -c hello.c -o hello.o现在我们就得到了一个标准的ELF可重定位目标文件hello.o它将成为我们后续所有分析的样本。2. 第一把手术刀用readelf查看ELF宏观结构readelf命令是专门为解析ELF文件格式而生的工具它能以人类可读的方式展示ELF文件头、段表、节表、符号表等核心结构信息让我们对文件的整体布局有一个宏观的把握。2.1 查看ELF文件头文件的“身份证”ELF文件头位于文件开头包含了描述整个文件属性的元数据。使用readelf -h可以查看readelf -h hello.o输出会类似于下面这样为简洁起见部分细节已简化ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2s complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: REL (Relocatable file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x0 Start of program headers: 0 (bytes into file) Start of section headers: 1168 (bytes into file) Size of this header: 64 (bytes) Size of program headers: 0 (bytes) Number of program headers: 0 Size of section headers: 64 (bytes) Number of section headers: 13 Section header string table index: 10这里有几个关键信息需要解读Magic 文件开头的魔数7f 45 4c 46对应字符\177ELF是ELF格式的标识。ClassELF64表明这是一个64位架构的文件。如果是32位则会显示ELF32。Data2s complement, little endian表示数据以二进制补码、小端字节序方式存储。这是x86/x86-64架构的典型特征。TypeREL明确指出了这是一个可重定位文件Relocatable file。如果是可执行文件这里会是EXEC动态共享库则是DYN。MachineAdvanced Micro Devices X86-64指明了目标机器架构。Entry point address 入口点地址为0。对于可重定位文件这个地址是未定义的要等到链接成可执行文件时才会被确定。Start of section headers节头表Section Header Table在文件中的偏移位置1168字节处。这个表是理解目标文件内容的核心地图。注意ELF文件中有两个容易混淆的概念段Segment和节Section。段是程序执行视图的概念由程序头表描述用于操作系统加载程序节是链接视图的概念由节头表描述用于编译器和链接器处理数据。目标文件.o通常没有程序头表所以上面显示为0只有节头表。2.2 查看节头表文件的“详细目录”节头表是ELF文件的“目录”它列出了文件中所有的“节”Section。每个节都有其特定的类型和用途比如存放代码、数据、符号等。使用readelf -S查看readelf -S hello.o输出会列出所有节的信息包括名称、类型、地址、偏移量、大小等。一个典型的输出可能包含以下关键节节名可能因编译器和平台略有差异节名 (Name)类型 (Type)地址 (Addr)偏移量 (Off)大小 (Size)说明.textPROGBITS00000000000000000x000000400x0000007f程序代码存放机器指令.dataPROGBITS00000000000000000x000000c00x00000008已初始化的全局/静态变量初始值非0.bssNOBITS00000000000000000x000000c80x00000008未初始化或初始化为0的全局/静态变量.rodataPROGBITS00000000000000000x000000c80x0000001e只读数据如字符串常量、const变量.commentPROGBITS00000000000000000x000000e60x00000036编译器版本信息.note.GNU-stackPROGBITS00000000000000000x0000011c0x00000000堆栈执行属性提示.eh_framePROGBITS00000000000000000x000001200x00000058异常处理框架信息用于C等.rela.textRELA00000000000000000x000001780x00000060.text节的重定位信息.symtabSYMTAB00000000000000000x000002000x00000168符号表.strtabSTRTAB00000000000000000x000003680x0000006a字符串表存放符号名等字符串从这个表中我们可以清晰地看到文件是如何被组织的。.text、.data、.bss、.rodata是我们要重点关注的几个核心节。3. 第二把手术刀用objdump进行微观探查如果说readelf给了我们一张地图那么objdump就是我们的显微镜和翻译器它能让我们看到每个节内部的原始字节并将其反汇编成可读的汇编代码或者解析出符号和重定位信息。3.1 探查代码节 (.text)机器指令的真相.text节存放着所有函数的机器指令。我们可以先用objdump -h快速查看各节摘要然后用objdump -s以十六进制和ASCII格式显示节内容最后用objdump -d进行反汇编。查看节摘要objdump -h hello.o这个命令的输出与readelf -S类似但格式更简洁专注于大小和偏移。查看.text节的原始内容objdump -s -j .text hello.o输出会是连续的十六进制字节流。对于初学者直接看这个可能像天书。这就是我们需要反汇编的原因。反汇编.text节objdump -d hello.o这是最激动人心的部分。你会看到类似下面的输出基于之前的示例代码Disassembly of section .text: 0000000000000000 func: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 48 83 ec 10 sub $0x10,%rsp 8: 89 7d fc mov %edi,-0x4(%rbp) b: 8b 45 fc mov -0x4(%rbp),%eax e: 89 c6 mov %eax,%esi 10: bf 00 00 00 00 mov $0x0,%edi 15: b8 00 00 00 00 mov $0x0,%eax 1a: e8 00 00 00 00 callq 1f func0x1f 1f: b8 00 00 00 00 mov $0x0,%eax 24: c9 leaveq 25: c3 retq 0000000000000026 main: 26: 55 push %rbp 27: 48 89 e5 mov %rsp,%rbp 2a: 48 83 ec 20 sub $0x20,%rsp 2e: c7 45 fc 0a 00 00 00 movl $0xa,-0x4(%rbp) 35: 8b 15 00 00 00 00 mov 0x0(%rip),%edx # 3b main0x15 3b: 8b 45 fc mov -0x4(%rbp),%eax 3e: 01 d0 add %edx,%eax 40: 89 c7 mov %eax,%edi 42: e8 00 00 00 00 callq 47 main0x21 47: b8 00 00 00 00 mov $0x0,%eax 4c: c9 leaveq 4d: c3 retq左侧第一列是当前指令在.text节内的偏移地址从0开始。中间列是机器指令对应的十六进制字节码。注意观察callq和mov 0x0(%rip), %edx指令的操作数部分很多都是00 00 00 00。这正是“可重定位”的体现这些是占位符链接器需要根据符号的实际地址来填充这些零。右侧是反汇编得到的ATT语法汇编指令。3.2 探查数据节变量存储的奥秘数据主要分布在.data、.bss和.rodata节。查看.data节已初始化数据objdump -s -j .data hello.o输出可能如下Contents of section .data: 0000 54000000 2a000000 T...*...这里显示了两个四字节整数小端序0x00000054(十进制84) 和0x0000002a(十进制42)。它们分别对应我们代码中的global_init_var和local_static_var。查看.bss节objdump -h -j .bss hello.o你会发现.bss节在文件中的大小Size不为0但它在文件中的偏移量之后没有实际的“内容”Contents。这是因为.bss节在文件中不占据实际空间它仅仅在节头表中声明了程序运行时需要多少未初始化的内存空间在这里是8字节对应global_uninit_var和static_var。这能有效减小目标文件和最终可执行文件的体积。查看.rodata节只读数据objdump -s -j .rodata hello.o输出Contents of section .rodata: 0000 41726775 6d656e74 2069733a 2025640a Argument is: %d. 0010 00 .这里清晰地存储着printf函数中使用的格式字符串Argument is: %d\n。因为它是常量所以被放在只读节试图修改它会导致段错误。3.3 探查符号与重定位链接的基石目标文件之所以能被链接依赖于符号表和重定位表。查看符号表objdump -t hello.o # 或 readelf -s hello.o符号表列出了文件中定义和引用的所有符号函数名、变量名等。你会看到global_init_var、static_var等变量符号以及func、main函数符号。每个符号都有其所在节如.data、.bss、.text、大小、绑定信息GLOBAL/LOCAL和类型OBJECT/FUNC。对于未定义的符号如printf其所在节标记为UND需要在链接时从其他目标文件或库中解析。查看重定位信息objdump -r hello.o # 或 readelf -r hello.o重定位表告诉链接器“在.text节的偏移量0x1b处有一个对符号printf的调用请你在链接时把printf的真实地址填进去。” 这就是链接器工作的核心依据。例如你会在输出中看到对printf和global_init_var的重定位条目。4. 综合实战追踪一个变量的完整生命周期让我们把上面的工具结合起来完成一次完整的侦查。以全局变量global_init_var为例定义在源码hello.c中我们定义了int global_init_var 84;。编译后归属因为它是一个已初始化的全局变量编译器会将其放入.data节。通过readelf -S或objdump -h可以确认.data节的存在和大小。查看存储值使用objdump -s -j .data可以看到.data节起始的4个字节是54 00 00 00小端序的84。符号记录使用objdump -t可以在符号表中找到global_init_var记录其类型为GLOBAL OBJECT位于.data节偏移量为0相对于.data节起始。被引用在main函数中我们使用了global_init_var。反汇编 (objdump -d) 可以看到类似mov 0x0(%rip), %edx的指令但地址是0。重定位需求使用objdump -r查看.text节的重定位信息会发现一条记录指出在main函数中某条指令对应上面mov指令的位置需要引用符号global_init_var。链接时链接器会根据global_init_var最终被分配到的内存地址来修正这条指令中的偏移量。通过这样一条线的追踪你就能清晰地理解一个变量从源代码到二进制指令的完整旅程以及链接器在其中扮演的关键角色。5. 进阶技巧与常见问题排查掌握了基本解剖方法后这些技巧能让你在实战中更得心应手。使用-C选项反汇编如果代码是用C编译的函数名可能被修饰mangled。使用objdump -d -C hello.o可以尝试进行反修饰demangle让函数名恢复可读。结合grep过滤信息当输出信息很多时用grep快速定位。例如objdump -t hello.o | grep main快速找到main函数的符号信息。对比分析编译时尝试不同优化等级-O0,-O1,-O2,-Os然后用objdump -d对比.text节的变化直观感受编译器优化对代码生成的影响。调试未定义符号错误链接时如果报错undefined reference to xxx先用objdump -t your.o | grep xxx检查是否在你自己的目标文件中定义。如果没有再用nm -D /usr/lib/libc.so.6 | grep xxx如果是库函数检查动态库或者确认是否链接了必要的库-l选项。分析段大小异常如果发现可执行文件体积莫名变大可以用size命令快速查看各段大小再用objdump -h和readelf -S详细查看是否有不期望的大数据节可能是某个大型数组或静态数据被错误定义。理解ELF文件结构不是一蹴而就的最好的学习方法就是像今天这样自己写一个小程序然后反复使用objdump和readelf去观察、修改代码、再观察。每次编译选项的改变、代码的细微调整都可能反映在二进制文件的结构上。当你习惯了这种“透过现象看本质”的思考方式无论是进行深度调试、性能分析还是探索系统安全都会拥有一个坚实而强大的起点。