尽管我已经开发软件很多年了,但有一个问题一直在我的脑海中萦绕,直到现在,我还没有时间或耐心来真正回答:不管怎么说,什么是二进制可执行文件?
在本例中,我编写了一个极其简单的Rust程序,其中包含一个函数“sum”,用于将两个整数相加,并从main()调用它:
Fn main(){println!(";{}";,sum(5,8));}pub fn sum(a:I32,b:I32)->;I32{a+b}。
我的Rust代码的结构总是“Cargo Way”,所以我可以通过运行Cargo Build来编译我的程序,这将在Target/DEBUG/目录中为我生成一个二进制文件。我已将我的板条箱rbin命名为,因此这是在此位置创建的二进制文件的名称:
~$Cargo编译rbin v0.1.0(/home/mierdin/Code/rbin)在0.15s~$ls-lha目标/调试/rbin-rwxrwxr-x 2 mierdin mierdin 3.1M NOV 3 22:46目标/调试/rbin中完成了开发[未优化+调试信息]目标。
如今,人们很容易将这样的问题视为理所当然,但如果你很好奇,你可能会问:
我的意思是,我们通常都知道它是一个“可执行文件”,因为我们运行它,我们的程序就会发生。但这意味着什么呢?该文件中包含的什么内容意味着我们的计算机自动知道如何运行它?一个三行程序(其中两行是样板函数开始/结束语法)怎么可能占用3兆字节呢?
事实证明,为了为这个极其简单的程序创建可执行文件,Rust编译器必须包含相当多的附加软件才能使其成为可能。
事实证明,这些东西有一种被广泛接受的格式,称为“可执行和可链接格式”(Executable and Linkable Format,简称ELF)!
请注意,我不会在这里全面介绍ELF(还有很多其他资源,我会链接到其中的许多资源)--相反,这是对使用最简单的默认设置的Rust二进制文件中的内容的探索,以及对我感兴趣的内容的一些观察。
ELF是一种广为人知的流行格式,特别是在Linux领域,但还有很多其他格式。像Windows和MacOS这样的操作系统都有自己的格式,这就是为什么当你编译(或简单地下载)软件时,你必须指定你想要运行它的操作系统。尽管执行您的程序的底层机器代码可能在所有这些机器上都是相同的(例如x86_64),但这是正确的。
在上面的ELF维基百科页面的链接中,可以找到ELF格式的一个特殊的视觉分析。我发现自己在写这篇文章的时候,经常引用它:
通常,像这样的可执行格式会在文件的开头指定一个幻数,这样就可以很容易地识别格式。这占据了文件头中的前四个字节。这是一个非常重要的字段,因为除非我们首先能够适当地识别ELF文件,否则我们不能合理地期望对它做任何“ELF-y”操作。我们知道ELF文件中的某些信息应该在哪里,但我们必须首先使用这些字节来确定这是我们可以预期的。
在打印ELF文件中包含的各种有用的元数据和相关信息表时,readelf实用程序非常有用。然而,这个实用程序自然会认为正在读取的文件实际上是一个ELF文件,甚至还提供了一个有用的提示:当在非ELF文件上使用时,非ELF文件的预期“幻字节”没有正确设置,因此它不会尝试读取其余的文件:
~$readelf-l.gitignorereadelf:错误:不是ELF文件-它的开头有错误的幻字节。
一旦确定,就可以使用字节偏移量(即从零开始的字节数)来确定整个文件的其余部分。
对于那些习惯于查看网络数据包捕获的人来说,这听起来应该非常熟悉,因为这正是我们了解数据包头中特定字段位置的方法。以太网帧具有可预测的前导和帧开始分隔符。以太网还有一个名为“Ethertype”的字段,它提供了以太网帧中包含什么协议的线索(这样计算机也可以解析这些字段)。就像以太网有一组标准的字节偏移量来表示各个字段一样,ELF格式为所有字段指定了自己的偏移量,这些偏移量在文件头中提供有用的标识和执行信息,然后指向文件中的其他重要位置。
这个头中有各种有用的信息,但特别是文件头中的e_entry字段指向应该开始执行的偏移量位置。这是程序的“切入点”。我们肯定会在一小段时间内追踪这件事。
我们可以再次使用readelf,这一次是在正确的ELF文件(我们的Rust程序)上,并使用-h标志显示文件头:
~$readelf-h目标/调试/rbin ELF标题:MAGIC:7F 45 4C 46 02 01 01 00 00 00类:ELF64数据:2';S补语,低端版本:1(当前)OS/abi:Unix-System V ABI版本:0类型:DYN(共享目标文件)计算机:Advanced Micro Devices X86-64版本:0x1入口点地址:0x5070程序头开始:64(字节进入文件)节头开始:3195368(字节进入文件)标志:0x0此头的大小:64(字节)程序头的大小:56(字节)程序头的数量:12节头的大小:64(字节)。
因此,“幻数”让我们至少可以解析文件头的其余部分,它不仅包含有关文件的信息,还包含文件其他重要部分的字节偏移量位置。其中之一是“程序头的开始”,它在64个字节之后开始。
程序头表包含允许操作系统分配内存和加载程序的信息。这也称为过程图像。您可以将其视为一系列“指令”,这些指令告诉系统使用内存块执行各种操作,以便为执行此程序做准备。
Readelf实用程序还允许我们使用-l标志读取程序标头:
~$readelf-l目标/调试/rbinElf文件类型为dyn(共享目标文件)入口点0x5070有12个程序头,Starting at offset 64Program Headers:Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000000040 0x0000000000000040 0x00000000000002a0 0x00000000000002a0 R 0x8 INTERP 0x00000000000002e0 0x00000000000002e0 0x00000000000002e0 0x000000000000001c 0x000000000000001c R 0x1[Requesting program interpreter:/lib64/ld-linux-x86-64.so.2]LOAD 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000004ed8 0x0000000000004ed8 R 0x1000 LOAD 0x0000000000005000 0x0000000000005000 0x0000000000005000 0x0000000000030571 0x0000000000030571 R E 0x1000 LOAD 0x0000000000036000 0x0000000000036000 0x0000000000036000 0x000000000000be44 0x000000000000be44 R 0x1000 LOAD 0x0000000000042520 0x0000000000043520 0x0000000000043520 0x0000000000002b18 0x0000000000002cf8 RW 0x1000 DYNAMIC 0x0000000000044740 0x0000000000045740 0x0000000000045740 0x0000000000000230 0x0000000000000230 RW 0x8 NOTE 0x00000000000002fc 0x00000000000002fc 0x00000000000002fc 0x0000000000000044 0x0000000000000044 R 0x4 TLS 0x0000000000042520 0x0000000000043520 0x0000000000043520 0x0000000000000000 0x00000000000000d8 R 0x20 GNU_EH_FRAME 0x000000000003aa8c 0x000000000003aa8c 0x000000000003aa8c 0x0000000000000d84 0x0000000000000d84 R。0x4 GNU_STACK 0x0000000000000000 0x0000000000000000 0x00000000000000 0x00000000000000 0x00000000000000 RW 0x10 GNU_RELRO 0x00000000042520 0x0000000000043520 0x0000000000043520 0x0000000000002ae0 0x00000000002ae0 R 0x1。00 01.interp 02.interp.note.gnu.build-id.note.abi-tag.gnu.hash.dynsym.dynstr.gnu版本.gnu.version_r.rela.dyn.rela.plt 03.init.plt.plt.get.text.fini 04.rodata.debug_gdb_script.eh_Frame_HDR.eh_Frame.gcc_Except_table 05.init_array.fini_array.data.。.note.abi-tag 08.tbss 09.eh_Frame_hdr 10 11.init_array.fini_array.data.rel.ro.Dynamic.get
每种程序头类型对一块内存(段)执行不同的操作。在每个报头旁边可以找到两个64位的十六进制值(毕竟这是一个64位的ELF)。如标题输出顶部所示,顶部的值是标题引用的段(它所在的位置)的内存偏移量。下面的值是文件中该特定段的大小。
每个片段都被进一步细分为几个部分,稍后我们将介绍这些部分。现在,请注意程序头下面的“段到段映射”表。看到他们是怎么编号的了吗?这些数字对应于上面程序头的位置。因此,第一个标头(恰好是PHDR类型)指向段00,第二个标头(恰好是INTERP)指向01,依此类推。
可以在这里找到程序头类型的完整摘要,但是可以在下面找到我们实际程序的每个段的简要说明,以及对应的头类型表示应该如何处理该段:
提供用于动态链接的解释器在系统上的位置。这使我们可以简单地使用系统上的库,而不必将所有这些库编译成二进制文件(这称为静态链接)。
这些段被加载到内存中。请注意,段03设置了E标志,这表明这是我们的可执行代码所在的位置。
提供动态链接信息,例如解释器需要在运行时提供对系统上哪些库的访问。
这通常用于存储该程序中用于与底层操作系统通信的ABI(和版本)之类的内容。
用于显式请求堆栈是可执行的(注意,在上面的输出中,此位未设置)
指定加载(重新定位)后应为只读的内存区域。
我们现在对Rust编译器认为应该包含在程序头表中的内容有了更好的理解-特别是Rust建议我们的计算机如何准备运行我们编译的程序-同样,在最简单的默认情况下。以下是一些启示:
请注意,在readelf的输出中,当我们看到段01的INTERP头类型时,我们看到了所请求的解释器/lib64/ld-linux-x86-64.so.2的预览。你可以自己运行它,它会告诉你一些关于它本身的信息。相当酷!
INTERP和动态头类型的存在意味着动态链接是编译Rust程序时的默认设置,这并不奇怪--很多编译语言强迫您指定是否需要静态链接的二进制文件。
节标题表通常位于ELF文件的末尾附近,其主要工作是提供用于链接目的的信息,但我也发现了解每个节的内容(特别是每个节的大小)很有用:
~$readelf-S目标/调试/rbin有42个节标题,从偏移量0x30c1e8开始:段头:[NR]Name Type Address Offset Size EntSize Flags Link Info Align[0]NULL 0000000000000000 00000000 0000000000000000 00000000000000 0 0 0[1].interp PROGBITS 000000000002e0 000000000000001c 0000000000000000 A 0 0 1[2].note.gnu.build-I note 000000000002fc 000002fc 000002fc 000002fc 00000002fc 000002fc 000002fc 000002fc。0000000000000368 00000368 0000000000000720 0000000000000720 00000000000018 A 6 1 8[6].dynstr STRTAB 00000000000a88 00000a88 0000000000052d 0000000000000000 A 0 0 1[7].gnu版本VERSYM 00000000000fb6 00000000000098 0000000000000002 A 5 0 2[8].gnu版本_r VERNEED 0000000000。PLT PROGBITS 0000000000005020 00005020 0000000000000040 0000000000000010 AX 0 0 16[13].plt.get PROGBITS 00000000005060 0000000000000008 AX 0 0 8[14].文本PROGBITS 00000000005070 00005070 00000000000304f3 00000000000000 AX 0 0 16[15].fini PROGBITS 000。.eh_Frame PROGBITS 000000000003b810 0003b810 000000000049e0 0000000000000000 A 0 0 8[20].gcc_EXCEPT_TABLE PROGBITS 00000000000401f0 000401f0 00000000001c54 0000000000000000 A 0 0 4[21].tbss NOBITS 0000000000043520 00042520 00000000000000d800。0000000000000230 0000000000000010 WA 6 0 8[26].GET PROGBITS 0000000000045970 00044970 00000000000678 0000000000000008 WA 0 0 8[27].数据PROGBITS 00000000046000 00045000 00000000000038 00000000000000 WA 0 0 8[28].bss NOBITS 0000000000046038 00045038 00000000000001e0000000000000。1[33].DEBUG_ABBRV PROGBITS 0000000000000000 0014f193 000000000000102c 00000000000000 0 0 1[34].DEBUG_LINE PROGBITS 00000000000000 001501bf 000000000005f7cd 0000000000000000 0 1[35].DEBUG_FRAME PROGBITS 00000000000000 001af990 000000000001f00 0000
虽然readelf确实有一些用于检查这些节的内容的标志,但我们将改用一个名为objump的工具,它可以更好地显示每个节的内容的细分,包括原始的十六进制操作码和参数,以及解释的程序集:
-d标志指示objump反汇编所有可执行部分。这将在输出中对应的机器代码旁边生成汇编指令。
-M英特尔指定在解释机器代码并将其显示为汇编时应使用英特尔格式。
--insn-width=8是我的审美偏好--我喜欢在一行上显示机器代码,有时它可以是8字节长(缺省值为7)。
剩下的|less通过管道将输出传递给less,它允许我使用箭头键查看输出,并且可以上下移动,轻松地进行搜索。
-S标志交错放置在程序集中的Ruust源代码中,这样我们就可以准确地看到Ruust的哪一行产生了哪行机器代码。我省略了这一点,因为我将自己解释相关的代码行,它使示例看起来更简单,但一定要自己使用这个标志,因为它对我非常有帮助。
在文件头中,我们看到有一个指向内存中的入口点的引用。提醒一下,这是0x5070。一旦分析了文件头和程序头,并将段加载到内存中,计算机将从该位置开始运行指令。因此,让我们滚动到objump输出中的那个位置,并以此作为起点。请注意,这可以在我们之前查看的.text部分中找到:
拆卸部分.text:0000000000005070;_start>;:5070:f3 0f 1e fa endbr64 5074:31 ed xor eBP,eBP 5076:49 89 d1 mov r9,rdx 5079:5e op rsi 507a:48 89 e2 mov rdx,rsp 507d:48 83 e4 f0 and rsp,0xfffffffffff0 5081:50 Push Rx 5082:54 Push RRX。508a:48 8d 0d 3f 04 03 00 Lea RCX,[RIP+0x3043f]#354d0<;__libc_csu_init>;5091:48 8d 3D 38 03 00 00 Lea RDI,[RIP+0x338]#53d0<;Main>;5098:ff 15 92 0b 04调用QWORD PTR[RIP+0x40b92]#45c30
中间一列(从endbr64开始)显示在该行上执行的x86_64指令。最右边的一列包含每个操作的参数。接近尾声时,我们看到Rust编译器提供了一些有用的提示,告诉我们执行下一步将移动到哪里。在地址0x5091处,我们在右侧看到一条带有有趣注释的说明:#53d0<;main>;。这是Rust告诉我们执行移动到内存偏移量0x53d0的线索。向下滚动,我们可以看到它的确切位置:
00000000000053d0>;Main>;:53d0:48 83 EC 18 subRSP,0x18 53d4:8a 05 8f 56 03 00模块,字节PTR[RIP+0x3568f]#3aa69<;__rustc_debug_gdb_script_section__>;53da:48 63 cf movsxd RCX,EDI 53dd:48 8d 3D 0。53e4:48 89 74 24 10 mov QWORD PTR[RSP+0x10],RSI 53e9:48 89 ce mov RSI,RCX 53ec:48 8b 54 24 10 mov RDX,QWORD PTR[RSP+0x10]53f1:88 44 24 0f mov byte PTR[RSP+0xf],al 53f5:e8 e6 fd ff call 51e0<;std:
再一次,我们有另一个提示:#52f0<;RB。
.