附录 B:常见工具的使用方法
分析可执行文件
对于Rust编译器生成的执行程序,可通过各种有效工具进行分析。如果掌握了对这些工具的使用,那么在后续的开发工作中,对碰到的各种奇怪问题就进行灵活处理和解决了。 我们以Rust编译生成的一个简单的“Hello, world”应用执行程序为分析对象,看看如何进行分析。
让我们先来通过 file
工具看看最终生成的可执行文件的格式:
$ cargo new os
$ cd os; cargo build
Compiling os v0.1.0 (/tmp/os)
Finished dev [unoptimized + debuginfo] target(s) in 0.26s
$ file target/debug/os
target/debug/os: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, ......
$
从中可以看出可执行文件的格式为 可执行和链接格式 (Executable and Linkable Format, ELF),硬件平台是 x86-64。在 ELF 文件中, 除了程序必要的代码、数据段(它们本身都只是一些二进制的数据)之外,还有一些 元数据 (Metadata) 描述这些段在地址空间中的位置和在 文件中的位置以及一些权限控制信息,这些元数据只能放在代码、数据段的外面。
rust-readobj
我们可以通过二进制工具 rust-readobj
来看看 ELF 文件中究竟包含什么内容,输入命令:
$ rust-readobj -all target/debug/os
首先可以看到一个 ELF header,它位于 ELF 文件的开头:
|
|
第 8 行是一个称之为 魔数 (Magic) 独特的常数,存放在 ELF header 的一个固定位置。当加载器将 ELF 文件加载到内存之前,通常会查看 该位置的值是否正确,来快速确认被加载的文件是不是一个 ELF 。
第 19 行给出了可执行文件的入口点为
0x5070
。从 20-21 行中,我们可以知道除了 ELF header 之外,还有另外两种不同的 header,分别称为 program header 和 section header, 它们都有多个。ELF header 中给出了其他两种header 的大小、在文件中的位置以及数目。
从 24-27 行中,可以看到有 12 个不同的 program header,它们从文件的 0x40 字节偏移处开始,每个 56 字节; 有64个section header,它们从文件的 0x2D8D0 字节偏移处开始,每个 64 字节;
有多个不同的 section header,下面是个具体的例子:
......
Section {
Index: 14
Name: .text (157)
Type: SHT_PROGBITS (0x1)
Flags [ (0x6)
SHF_ALLOC (0x2)
SHF_EXECINSTR (0x4)
]
Address: 0x5070
Offset: 0x5070
Size: 208067
Link: 0
Info: 0
AddressAlignment: 16
EntrySize: 0
}
每个 section header 则描述一个段的元数据。
其中,我们看到了代码段 .text
需要被加载到地址 0x5070
,大小 208067 字节,。 它们分别由元数据的字段 Offset、 Size 和 Address 给出。。
我们还能够看到程序中的符号表:
Symbol {
Name: _start (37994)
Value: 0x5070
Size: 47
Binding: Global (0x1)
Type: Function (0x2)
Other: 0
Section: .text (0xE)
}
Symbol {
Name: main (38021)
Value: 0x51A0
Size: 47
Binding: Global (0x1)
Type: Function (0x2)
Other: 0
Section: .text (0xE)
}
里面包括了我们写的 main
函数的地址以及用户态执行环境的起始地址 _start
函数的地址。
因此,从 ELF header 中可以看出,ELF 中的内容按顺序应该是:
ELF header
若干个 program header
程序各个段的实际数据
若干的 section header
rust-objdump
如果想了解正常的ELF文件的具体指令内容,可以通过 rust-objdump
工具反汇编ELF文件得到:
$ rust-objdump -all target/debug/os
具体结果如下:
505b: e9 c0 ff ff ff jmp 0x5020 <.plt>
Disassembly of section .plt.got:
0000000000005060 <.plt.got>:
5060: ff 25 5a 3f 04 00 jmpq *278362(%rip) # 48fc0 <_GLOBAL_OFFSET_TABLE_+0x628>
5066: 66 90 nop
Disassembly of section .text:
0000000000005070 <_start>:
5070: f3 0f 1e fa endbr64
5074: 31 ed xorl %ebp, %ebp
5076: 49 89 d1 movq %rdx, %r9
5079: 5e popq %rsi
507a: 48 89 e2 movq %rsp, %rdx
507d: 48 83 e4 f0 andq $-16, %rsp
5081: 50 pushq %rax
5082: 54 pushq %rsp
5083: 4c 8d 05 86 2c 03 00 leaq 208006(%rip), %r8 # 37d10 <__libc_csu_fini>
508a: 48 8d 0d 0f 2c 03 00 leaq 207887(%rip), %rcx # 37ca0 <__libc_csu_init>
5091: 48 8d 3d 08 01 00 00 leaq 264(%rip), %rdi # 51a0 <main>
5098: ff 15 d2 3b 04 00 callq *277458(%rip) # 48c70 <_GLOBAL_OFFSET_TABLE_+0x2d8>
......
00000000000051a0 <main>:
51a0: 48 83 ec 18 subq $24, %rsp
51a4: 8a 05 db 7a 03 00 movb 228059(%rip), %al # 3cc85 <__rustc_debug_gdb_scripts_section__>
51aa: 48 63 cf movslq %edi, %rcx
51ad: 48 8d 3d ac ff ff ff leaq -84(%rip), %rdi # 5160 <_ZN2os4main17h717a6a6e05a70248E>
51b4: 48 89 74 24 10 movq %rsi, 16(%rsp)
51b9: 48 89 ce movq %rcx, %rsi
51bc: 48 8b 54 24 10 movq 16(%rsp), %rdx
51c1: 88 44 24 0f movb %al, 15(%rsp)
51c5: e8 f6 00 00 00 callq 0x52c0 <_ZN3std2rt10lang_start17hc258028f546a93a1E>
51ca: 48 83 c4 18 addq $24, %rsp
51ce: c3 retq
51cf: 90 nop
......
从上面的反汇编结果,我们可以看到用户态执行环境的入口函数 _start
以及应用程序的主函数 main
的地址和具体汇编代码内容。
rust-objcopy
当前的ELF执行程序有许多与执行无直接关系的信息(如调试信息等),可以通过 rust-objcopy
工具来清除。
$ rust-objcopy --strip-all target/debug/os target/debug/os.bin
$ ls -l target/debug/os*
-rwxrwxr-x 2 chyyuu chyyuu 3334992 1月 19 22:26 target/debug/os
-rwxrwxr-x 1 chyyuu chyyuu 297200 1月 19 22:59 target/debug/os.bin
$ ./target/debug/os.bin
Hello, world!
可以看到,经过处理的ELF文件 os.bin
在文件长度上大大减少了,但也能正常执行。
另外,当将程序加载到内存的时候,对于每个 program header 所指向的区域,我们需要将对应的数据从文件复制到内存中。这就需要解析 ELF 的元数据 才能知道数据在文件中的位置以及即将被加载到内存中的位置。但如果我们不需要从 ELF 中解析元数据就知道程序的内存布局 (这个内存布局是我们按照需求自己指定的),我们可以手动完成加载任务。
具体的做法是利用 rust-objcopy
工具删除掉 ELF 文件中的 所有 header 只保留各个段的实际数据得到一个没有任何符号的纯二进制镜像文件:
$ rust-objcopy --strip-all target/debug/os -O binary target/debug/os.bin
这样就生成了一个没有任何符号的纯二进制镜像文件。由于缺少了必要的元数据,我们的 file
工具也没有办法 对它完成解析了。而后,我们可直接将这个二进制镜像文件手动载入到内存中合适位置即可。
qemu 平台上可执行文件和二进制镜像的生成流程
make & Makefile
首先我们还原一下可执行文件和二进制镜像的生成流程:
# os/Makefile
TARGET := riscv64gc-unknown-none-elf
MODE := release
KERNEL_ELF := target/$(TARGET)/$(MODE)/os
KERNEL_BIN := $(KERNEL_ELF).bin
$(KERNEL_BIN): kernel
@$(OBJCOPY) $(KERNEL_ELF) --strip-all -O binary $@
kernel:
@cargo build --release
这里可以看出 KERNEL_ELF
保存最终可执行文件 os
的路径,而 KERNEL_BIN
保存只保留各个段数据的二进制镜像文件 os.bin
的路径。目标 kernel
直接通过 cargo build
以 release 模式最终可执行文件,目标 KERNEL_BIN
依赖于目标 kernel
,将 可执行文件通过 rust-objcopy
工具加上适当的配置移除所有的 header 和符号得到二进制镜像。
我们可以通过 make run
直接在 qemu 上运行我们的应用程序,qemu 是一个虚拟机,它完整的模拟了一整套硬件平台,就像是一台真正的计算机 一样,我们来看运行 qemu 的具体命令:
|
|
qemu
注意其中高亮部分给出了传给 qemu 的参数。
-machine
告诉 qemu 使用预设的硬件配置。在整个项目中我们将一直沿用该配置。-bios
告诉 qemu 使用我们放在bootloader
目录下的预编译版本作为 bootloader。-device
则告诉 qemu 将二进制镜像加载到内存指定的位置。
可以先输入 Ctrl+A ,再输入 X 来退出 qemu 终端。
警告
FIXME: 使用 GDB 跟踪 qemu 的运行状态
k210 平台上可执行文件和二进制镜像的生成流程
对于 k210 平台来说,只需要将 maix 系列开发板通过数据线连接到 PC,然后 make run BOARD=k210
即可。从 Makefile 中来看:
|
|
在构建目标 run-inner
的时候,根据平台 BOARD
的不同,启动运行的指令也不同。当我们传入命令行参数 BOARD=k210
时,就会进入下面 的分支。
第 13 行我们使用
dd
工具将 bootloader 和二进制镜像拼接到一起,这是因为 k210 平台的写入工具每次只支持写入一个文件,所以我们只能 将二者合并到一起一并写入 k210 的内存上。这样的参数设置可以保证 bootloader 在合并后文件的开头,而二进制镜像在文件偏移量 0x20000 的 位置处。有兴趣的读者可以输入命令man dd
查看关于工具dd
的更多信息。第 16 行我们使用烧写工具
K210-BURNER
将合并后的镜像烧写到 k210 开发板的内存的0x80000000
地址上。 参数K210-SERIALPORT
表示当前 OS 识别到的 k210 开发板的串口设备名。在 Ubuntu 平台上一般为/dev/ttyUSB0
。第 17 行我们打开串口终端和 k210 开发板进行通信,可以通过键盘向 k210 开发板发送字符并在屏幕上看到 k210 开发板的字符输出。
可以输入 Ctrl+] 退出 miniterm。