Gaurav Kamathe 发布的文章

这些简单的命令和工具可以帮助你轻松完成分析二进制文件的任务。

“这个世界上有 10 种人:懂二进制的人和不懂二进制的人。”

我们每天都在与二进制文件打交道,但我们对二进制文件却知之甚少。我所说的二进制,是指你每天运行的可执行文件,从命令行工具到成熟的应用程序都是。

Linux 提供了一套丰富的工具,让分析二进制文件变得轻而易举。无论你的工作角色是什么,如果你在 Linux 上工作,了解这些工具的基本知识将帮助你更好地理解你的系统。

在这篇文章中,我们将介绍其中一些最流行的 Linux 工具和命令,其中大部分都是 Linux 发行版的一部分。如果没有找到,你可以随时使用你的软件包管理器来安装和探索它们。请记住:学习在正确的场合使用正确的工具需要大量的耐心和练习。

file

它的作用:帮助确定文件类型。

这将是你进行二进制分析的起点。我们每天都在与文件打交道,并非所有的文件都是可执行类型,除此之外还有各种各样的文件类型。在你开始之前,你需要了解要分析的文件类型。是二进制文件、库文件、ASCII 文本文件、视频文件、图片文件、PDF、数据文件等文件吗?

file 命令将帮助你确定你所处理的文件类型。

$ file /bin/ls
/bin/ls: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=94943a89d17e9d373b2794dcb1f7e38c95b66c86, stripped
$
$ file /etc/passwd
/etc/passwd: ASCII text
$

ldd

它的作用:打印共享对象依赖关系。

如果你已经在一个可执行的二进制文件上使用了上面的 file 命令,你肯定会看到输出中的“ 动态链接 dynamically linked ”信息。它是什么意思呢?

在开发软件的时候,我们尽量不要重造轮子。有一组常见的任务是大多数软件程序需要的,比如打印输出或从标准输入/打开的文件中读取等。所有这些常见的任务都被抽象成一组通用的函数,然后每个人都可以使用,而不是写出自己的变体。这些常用的函数被放在一个叫 libcglibc 的库中。

如何找到可执行程序所依赖的库?这就是 ldd 命令的作用了。对动态链接的二进制文件运行该命令会显示出所有依赖库和它们的路径。

$ ldd /bin/ls
        linux-vdso.so.1 =>  (0x00007ffef5ba1000)
        libselinux.so.1 => /lib64/libselinux.so.1 (0x00007fea9f854000)
        libcap.so.2 => /lib64/libcap.so.2 (0x00007fea9f64f000)
        libacl.so.1 => /lib64/libacl.so.1 (0x00007fea9f446000)
        libc.so.6 => /lib64/libc.so.6 (0x00007fea9f079000)
        libpcre.so.1 => /lib64/libpcre.so.1 (0x00007fea9ee17000)
        libdl.so.2 => /lib64/libdl.so.2 (0x00007fea9ec13000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fea9fa7b000)
        libattr.so.1 => /lib64/libattr.so.1 (0x00007fea9ea0e000)
        libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fea9e7f2000)
$

ltrace

它的作用:库调用跟踪器。

我们现在知道如何使用 ldd 命令找到一个可执行程序所依赖的库。然而,一个库可以包含数百个函数。在这几百个函数中,哪些是我们的二进制程序正在使用的实际函数?

ltrace 命令可以显示运行时从库中调用的所有函数。在下面的例子中,你可以看到被调用的函数名称,以及传递给该函数的参数。你也可以在输出的最右边看到这些函数返回的内容。

$ ltrace ls
__libc_start_main(0x4028c0, 1, 0x7ffd94023b88, 0x412950 <unfinished ...>
strrchr("ls", '/')                                                                  = nil
setlocale(LC_ALL, "")                                                               = "en_US.UTF-8"
bindtextdomain("coreutils", "/usr/share/locale")                                    = "/usr/share/locale"
textdomain("coreutils")                                                             = "coreutils"
__cxa_atexit(0x40a930, 0, 0, 0x736c6974756572)                                      = 0
isatty(1)                                                                           = 1
getenv("QUOTING_STYLE")                                                             = nil
getenv("COLUMNS")                                                                   = nil
ioctl(1, 21523, 0x7ffd94023a50)                                                     = 0
<< snip >>
fflush(0x7ff7baae61c0)                                                              = 0
fclose(0x7ff7baae61c0)                                                              = 0
+++ exited (status 0) +++
$

hexdump

它的作用:以 ASCII、十进制、十六进制或八进制显示文件内容。

通常情况下,当你用一个应用程序打开一个文件,而它不知道如何处理该文件时,就会出现这种情况。尝试用 vim 打开一个可执行文件或视频文件,你屏幕上会看到的只是抛出的乱码。

hexdump 中打开未知文件,可以帮助你看到文件的具体内容。你也可以选择使用一些命令行选项来查看用 ASCII 表示的文件数据。这可能会帮助你了解到它是什么类型的文件。

$ hexdump -C /bin/ls | head
00000000  7f 45 4c 46 02 01 01 00  00 00 00 00 00 00 00 00  |.ELF............|
00000010  02 00 3e 00 01 00 00 00  d4 42 40 00 00 00 00 00  |..>......B@.....|
00000020  40 00 00 00 00 00 00 00  f0 c3 01 00 00 00 00 00  |@...............|
00000030  00 00 00 00 40 00 38 00  09 00 40 00 1f 00 1e 00  |[email protected]...@.....|
00000040  06 00 00 00 05 00 00 00  40 00 00 00 00 00 00 00  |........@.......|
00000050  40 00 40 00 00 00 00 00  40 00 40 00 00 00 00 00  |@.@.....@.@.....|
00000060  f8 01 00 00 00 00 00 00  f8 01 00 00 00 00 00 00  |................|
00000070  08 00 00 00 00 00 00 00  03 00 00 00 04 00 00 00  |................|
00000080  38 02 00 00 00 00 00 00  38 02 40 00 00 00 00 00  |8.......8.@.....|
00000090  38 02 40 00 00 00 00 00  1c 00 00 00 00 00 00 00  |8.@.............|
$

strings

它的作用:打印文件中的可打印字符的字符串。

如果你只是在二进制中寻找可打印的字符,那么 hexdump 对于你的使用场景来说似乎有点矫枉过正,你可以使用 strings 命令。

在开发软件的时候,各种文本/ASCII 信息会被添加到其中,比如打印信息、调试信息、帮助信息、错误等。只要这些信息都存在于二进制文件中,就可以用 strings 命令将其转储到屏幕上。

$ strings /bin/ls

readelf

它的作用:显示有关 ELF 文件的信息。

ELF( 可执行和可链接文件格式 Executable and Linkable File Format )是可执行文件或二进制文件的主流格式,不仅是 Linux 系统,也是各种 UNIX 系统的主流文件格式。如果你已经使用了像 file 命令这样的工具,它告诉你文件是 ELF 格式,那么下一步就是使用 readelf 命令和它的各种选项来进一步分析文件。

在使用 readelf 命令时,有一份实际的 ELF 规范的参考是非常有用的。你可以在这里找到该规范。

$ readelf -h /bin/ls
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              EXEC (Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x4042d4
  Start of program headers:          64 (bytes into file)
  Start of section headers:          115696 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           56 (bytes)
  Number of program headers:         9
  Size of section headers:           64 (bytes)
  Number of section headers:         31
  Section header string table index: 30
$

objdump

它的作用:从对象文件中显示信息。

二进制文件是通过你编写的源码创建的,这些源码会通过一个叫做编译器的工具进行编译。这个编译器会生成相对于源代码的机器语言指令,然后由 CPU 执行特定的任务。这些机器语言代码可以通过被称为汇编语言的助记词来解读。汇编语言是一组指令,它可以帮助你理解由程序所进行并最终在 CPU 上执行的操作。

objdump 实用程序读取二进制或可执行文件,并将汇编语言指令转储到屏幕上。汇编语言知识对于理解 objdump 命令的输出至关重要。

请记住:汇编语言是特定于体系结构的。

$ objdump -d /bin/ls | head

/bin/ls:     file format elf64-x86-64

Disassembly of section .init:

0000000000402150 <_init@@Base>:
  402150:       48 83 ec 08             sub    $0x8,%rsp
  402154:       48 8b 05 6d 8e 21 00    mov    0x218e6d(%rip),%rax        # 61afc8 <__gmon_start__>
  40215b:       48 85 c0                test   %rax,%rax
$

strace

它的作用:跟踪系统调用和信号。

如果你用过前面提到的 ltrace,那就把 strace 想成是类似的。唯一的区别是,strace 工具不是追踪调用的库,而是追踪系统调用。系统调用是你与内核对接来完成工作的。

举个例子,如果你想把一些东西打印到屏幕上,你会使用标准库 libc 中的 printfputs 函数;但是,在底层,最终会有一个名为 write 的系统调用来实际把东西打印到屏幕上。

$ strace -f /bin/ls
execve("/bin/ls", ["/bin/ls"], [/* 17 vars */]) = 0
brk(NULL)                               = 0x686000
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f967956a000
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=40661, ...}) = 0
mmap(NULL, 40661, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f9679560000
close(3)                                = 0
<< snip >>
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f9679569000
write(1, "R2  RH\n", 7R2  RH
)                 = 7
close(1)                                = 0
munmap(0x7f9679569000, 4096)            = 0
close(2)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++
$

nm

它的作用:列出对象文件中的符号。

如果你所使用的二进制文件没有被剥离,nm 命令将为你提供在编译过程中嵌入到二进制文件中的有价值的信息。nm 可以帮助你从二进制文件中识别变量和函数。你可以想象一下,如果你无法访问二进制文件的源代码时,这将是多么有用。

为了展示 nm,我们快速编写了一个小程序,用 -g 选项编译,我们会看到这个二进制文件没有被剥离。

$ cat hello.c
#include <stdio.h>

int main() {
    printf("Hello world!");
    return 0;
}
$
$ gcc -g hello.c -o hello
$
$ file hello
hello: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=3de46c8efb98bce4ad525d3328121568ba3d8a5d, not stripped
$
$ ./hello
Hello world!$
$


$ nm hello | tail
0000000000600e20 d __JCR_END__
0000000000600e20 d __JCR_LIST__
00000000004005b0 T __libc_csu_fini
0000000000400540 T __libc_csu_init
                 U __libc_start_main@@GLIBC_2.2.5
000000000040051d T main
                 U printf@@GLIBC_2.2.5
0000000000400490 t register_tm_clones
0000000000400430 T _start
0000000000601030 D __TMC_END__
$

gdb

它的作用:GNU 调试器。

好吧,不是所有的二进制文件中的东西都可以进行静态分析。我们确实执行了一些运行二进制文件(进行分析)的命令,比如 ltracestrace;然而,软件由各种条件组成,这些条件可能会导致执行不同的替代路径。

分析这些路径的唯一方法是在运行时环境,在任何给定的位置停止或暂停程序,并能够分析信息,然后再往下执行。

这就是调试器的作用,在 Linux 上,gdb 就是调试器的事实标准。它可以帮助你加载程序,在特定的地方设置断点,分析内存和 CPU 的寄存器,以及更多的功能。它是对上面提到的其他工具的补充,可以让你做更多的运行时分析。

有一点需要注意的是,一旦你使用 gdb 加载一个程序,你会看到它自己的 (gdb) 提示符。所有进一步的命令都将在这个 gdb 命令提示符中运行,直到你退出。

我们将使用我们之前编译的 hello 程序,使用 gdb 来看看它的工作原理。

$ gdb -q ./hello
Reading symbols from /home/flash/hello...done.
(gdb) break main
Breakpoint 1 at 0x400521: file hello.c, line 4.
(gdb) info break
Num     Type           Disp Enb Address            What
1       breakpoint     keep y   0x0000000000400521 in main at hello.c:4
(gdb) run
Starting program: /home/flash/./hello

Breakpoint 1, main () at hello.c:4
4           printf("Hello world!");
Missing separate debuginfos, use: debuginfo-install glibc-2.17-260.el7_6.6.x86_64
(gdb) bt
#0  main () at hello.c:4
(gdb) c
Continuing.
Hello world![Inferior 1 (process 29620) exited normally]
(gdb) q
$

结语

一旦你习惯了使用这些原生的 Linux 二进制分析工具,并理解了它们提供的输出,你就可以转向更高级和专业的开源二进制分析工具,比如 radare2


via: https://opensource.com/article/20/4/linux-binary-analysis

作者:Gaurav Kamathe 选题:lujun9972 译者:wxy 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

了解 Rust 的软件包管理器和构建工具。

Rust 是一种现代编程语言,可提供高性能、可靠性和生产力。几年来,它一直被 StackOverflow 调查评为最受欢迎的语言

除了是一种出色的编程语言之外,Rust 还具有一个称为 Cargo 的构建系统和软件包管理器。Cargo 处理许多任务,例如构建代码、下载库或依赖项等等。这两者捆绑在一起,因此在安装 Rust 时会得到 Cargo。

安装 Rust 和 Cargo

在开始之前,你需要安装 Rust 和 Cargo。Rust 项目提供了一个可下载的脚本来处理安装。要获取该脚本,请打开浏览器以访问 https://sh.rustup.rs 并保存该文件。阅读该脚本以确保你对它的具体行为有所了解,然后再运行它:

$ sh ./rustup.rs

你也可以参考这个安装 Rust 的网页以获取更多信息。

安装 Rust 和 Cargo 之后,你必须 获取 source env 文件中的配置:

$ source $HOME/.cargo/env

更好的办法是,将所需目录添加到 PATH 环境变量中:

export PATH=$PATH:~/.cargo/bin

如果你更喜欢使用软件包管理器(例如 Linux 上的 DNF 或 Apt),请在发行版本的存储库中查找 Rust 和 Cargo 软件包,并进行相应的安装。 例如:

$ dnf install rust cargo

安装并设置它们后,请验证你拥有的 Rust 和 Cargo 版本:

$ rustc --version
rustc 1.41.0 (5e1a79984 2020-01-27)
$ cargo --version
cargo 1.41.0 (626f0f40e 2019-12-03)

手动构建和运行 Rust

从在屏幕上打印“Hello, world!”的简单程序开始。打开你喜欢的文本编辑器,然后键入以下程序:

$ cat hello.rs
fn main() {
    println!("Hello, world!");
}

以扩展名 .rs 保存文件,以将其标识为 Rust 源代码文件。

使用 Rust 编译器 rustc 编译程序:

$ rustc hello.rs

编译后,你将拥有一个与源程序同名的二进制文件:

$ ls -l
total 2592
-rwxr-xr-x. 1 user group 2647944 Feb 13 14:14 hello
-rw-r--r--. 1 user group      45 Feb 13 14:14 hello.rs
$

执行程序以验证其是否按预期运行:

$ ./hello
Hello, world!

这些步骤对于较小的程序或任何你想快速测试的东西就足够了。但是,在进行涉及到多人的大型程序时,Cargo 是前进的最佳之路。

使用 Cargo 创建新包

Cargo 是 Rust 的构建系统和包管理器。它可以帮助开发人员下载和管理依赖项,并帮助创建 Rust 包。在 Rust 社区中,Rust 中的“包”通常被称为“crate”(板条箱),但是在本文中,这两个词是可以互换的。请参阅 Rust 社区提供的 Cargo FAQ 来区分。

如果你需要有关 Cargo 命令行实用程序的任何帮助,请使用 --help-h 命令行参数:

$ cargo –help

要创建一个新的包,请使用关键字 new,跟上包名称。在这个例子中,使用 hello_opensource 作为新的包名称。运行该命令后,你将看到一条消息,确认 Cargo 已创建具有给定名称的二进制包:

$ cargo new hello_opensource
     Created binary (application) `hello_opensource` package

运行 tree 命令以查看目录结构,它会报告已创建了一些文件和目录。首先,它创建一个带有包名称的目录,并且在该目录内有一个存放你的源代码文件的 src 目录:

$ tree .
.
└── hello_opensource
    ├── Cargo.toml
    └── src
        └── main.rs

2 directories, 2 files

Cargo 不仅可以创建包,它也创建了一个简单的 “Hello, world” 程序。打开 main.rs 文件看看:

$ cat hello_opensource/src/main.rs
fn main() {
    println!("Hello, world!");
}

下一个要处理的文件是 Cargo.toml,这是你的包的配置文件。它包含有关包的信息,例如其名称、版本、作者信息和 Rust 版本信息。

程序通常依赖于外部库或依赖项来运行,这使你可以编写应用程序来执行不知道如何编码或不想花时间编码的任务。你所有的依赖项都将在此文件中列出。此时,你的新程序还没有任何依赖关系。打开 Cargo.toml 文件并查看其内容:

$ cat hello_opensource/Cargo.toml
[package]
name = "hello_opensource"
version = "0.1.0"
authors = ["user <[email protected]>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

使用 Cargo 构建程序

到目前为止,一切都很顺利。现在你已经有了一个包,可构建一个二进制文件(也称为可执行文件)。在此之前,进入包目录:

$ cd hello_opensource/

你可以使用 Cargo 的 build 命令来构建包。注意消息说它正在“编译”你的程序:

$ cargo build
   Compiling hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished dev [unoptimized + debuginfo] target(s) in 0.38s

运行 build 命令后,检查项目目录发生了什么:

$ tree .
.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
└── target
    └── debug
        ├── build
        ├── deps
        │   ├── hello_opensource-147b8a0f466515dd
        │   └── hello_opensource-147b8a0f466515dd.d
        ├── examples
        ├── hello_opensource
        ├── hello_opensource.d
        └── incremental
            └── hello_opensource-3pouh4i8ttpvz
                ├── s-fkmhjmt8tj-x962ep-1hivstog8wvf
                │   ├── 1r37g6m45p8rx66m.o
                │   ├── 2469ykny0eqo592v.o
                │   ├── 2g5i2x8ie8zed30i.o
                │   ├── 2yrvd7azhgjog6zy.o
                │   ├── 3g9rrdr4hyk76jtd.o
                │   ├── dep-graph.bin
                │   ├── query-cache.bin
                │   ├── work-products.bin
                │   └── wqif2s56aj0qtct.o
                └── s-fkmhjmt8tj-x962ep.lock

9 directories, 17 files

哇!编译过程产生了许多中间文件。另外,你的二进制文件将以与软件包相同的名称保存在 ./target/debug 目录中。

使用 Cargo 运行你的应用程序

现在你的二进制文件已经构建好了,使用 Cargo 的 run 命令运行它。如预期的那样,它将在屏幕上打印 Hello, world!

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/hello_opensource`
Hello, world!

或者,你可以直接运行二进制文件,该文件位于:

$ ls -l ./target/debug/hello_opensource
-rwxr-xr-x. 2 root root 2655552 Feb 13 14:19 ./target/debug/hello_opensource

如预期的那样,它产生相同的结果:

$ ./target/debug/hello_opensource
Hello, world!

假设你需要重建包,并丢弃早期编译过程创建的所有二进制文件和中间文件。Cargo 提供了一个方便的clean 选项来删除所有中间文件,但源代码和其他必需文件除外:

$ cargo clean
$ tree .
.
├── Cargo.lock
├── Cargo.toml
└── src
    └── main.rs

1 directory, 3 files

对程序进行一些更改,然后再次运行以查看其工作方式。例如,下面这个较小的更改将 Opensource 添加到 Hello, world! 字符串中:

$ cat src/main.rs
fn main() {
    println!("Hello, Opensource world!");
}

现在,构建该程序并再次运行它。这次,你会在屏幕上看到 Hello, Opensource world!

$ cargo build
   Compiling hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished dev [unoptimized + debuginfo] target(s) in 0.39s

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/hello_opensource`
Hello, Opensource world!

使用 Cargo 添加依赖项

Cargo 允许你添加程序需要运行的依赖项。使用 Cargo 添加依赖项非常容易。每个 Rust 包都包含一个 Cargo.toml 文件,其中包含一个依赖关系列表(默认为空)。用你喜欢的文本编辑器打开该文件,找到 [dependencies] 部分,然后添加要包含在包中的库。例如,将 rand 库添加为依赖项:

$ cat Cargo.toml
[package]
name = "hello_opensource"
version = "0.1.0"
authors = ["test user <[email protected]>"]
edition = "2018"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
rand = "0.3.14"

试试构建你的包,看看会发生什么。

$ cargo build
    Updating crates.io index
   Compiling libc v0.2.66
   Compiling rand v0.4.6
   Compiling rand v0.3.23
   Compiling hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished dev [unoptimized + debuginfo] target(s) in 4.48s

现在,Cargo 会联系 Crates.io(这是 Rust 用于存储 crate(或包)的中央仓库),并下载和编译 rand。但是,等等 —— libc 包是怎么回事?你没有要安装 libc 啊。是的,rand 包依赖于 libc 包;因此,Cargo 也会下载并编译 libc

库的新版本会不断涌现,而 Cargo 提供了一种使用 update 命令更新其所有依赖关系的简便方法:

cargo update

你还可以选择使用 -p 标志跟上包名称来更新特定的库:

cargo update -p rand

使用单个命令进行编译和运行

到目前为止,每当对程序进行更改时,都先使用了 build 之后是 run。有一个更简单的方法:你可以直接使用 run 命令,该命令会在内部进行编译并运行该程序。要查看其工作原理,请首先清理你的软件包目录:

$ cargo clean
$ tree .
.
├── Cargo.lock
├── Cargo.toml
└── src
    └── main.rs

1 directory, 3 files

现在执行 run。输出信息表明它已进行编译,然后运行了该程序,这意味着你不需要每次都显式地运行 build

$ cargo run
   Compiling hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished dev [unoptimized + debuginfo] target(s) in 0.41s
     Running `target/debug/hello_opensource`
Hello, world!

在开发过程中检查代码

在开发程序时,你经常会经历多次迭代。你需要确保你的程序没有编码错误并且可以正常编译。你不需要负担在每次编译时生成二进制文件的开销。Cargo 为你提供了一个 check 选项,该选项可以编译代码,但跳过了生成可执行文件的最后一步。首先在包目录中运行 cargo clean

$ tree .
.
├── Cargo.lock
├── Cargo.toml
└── src
    └── main.rs

1 directory, 3 files

现在运行 check 命令,查看对目录进行了哪些更改:

$ cargo check
    Checking hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished dev [unoptimized + debuginfo] target(s) in 0.18s

该输出显示,即使在编译过程中创建了中间文件,但没有创建最终的二进制文件或可执行文件。这样可以节省一些时间,如果该包包含了数千行代码,这非常重要:

$ tree .
.
├── Cargo.lock
├── Cargo.toml
├── src
│   └── main.rs
└── target
    └── debug
        ├── build
        ├── deps
        │   ├── hello_opensource-842d9a06b2b6a19b.d
        │   └── libhello_opensource-842d9a06b2b6a19b.rmeta
        ├── examples
        └── incremental
            └── hello_opensource-1m3f8arxhgo1u
                ├── s-fkmhw18fjk-542o8d-18nukzzq7hpxe
                │   ├── dep-graph.bin
                │   ├── query-cache.bin
                │   └── work-products.bin
                └── s-fkmhw18fjk-542o8d.lock

9 directories, 9 files

要查看你是否真的节省了时间,请对 buildcheck 命令进行计时并进行比较。首先,计时 build 命令:

$ time cargo build
   Compiling hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished dev [unoptimized + debuginfo] target(s) in 0.40s

real    0m0.416s
user    0m0.251s
sys     0m0.199s

在运行 check 命令之前清理目录:

$ cargo clean

计时 check 命令:

$ time cargo check
    Checking hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished dev [unoptimized + debuginfo] target(s) in 0.15s

real    0m0.166s
user    0m0.086s
sys     0m0.081s

显然,check 命令要快得多。

建立外部 Rust 包

到目前为止,你所做的这些都可以应用于你从互联网上获得的任何 Rust crate。你只需要下载或克隆存储库,移至包文件夹,然后运行 build 命令,就可以了:

git clone <github-like-url>
cd <package-folder>
cargo build

使用 Cargo 构建优化的 Rust 程序

到目前为止,你已经多次运行 build,但是你注意到它的输出了吗?不用担心,再次构建它并密切注意:

$ cargo build
   Compiling hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished dev [unoptimized + debuginfo] target(s) in 0.36s

看到了每次编译后的 [unoptimized + debuginfo] 文本了吗?这意味着 Cargo 生成的二进制文件包含大量调试信息,并且未针对执行进行优化。开发人员经常经历开发的多次迭代,并且需要此调试信息进行分析。同样,性能并不是开发软件时的近期目标。因此,对于现在而言是没问题的。

但是,一旦准备好发布软件,就不再需要这些调试信息。而是需要对其进行优化以获得最佳性能。在开发的最后阶段,可以将 --release 标志与 build 一起使用。仔细看,编译后,你应该会看到 [optimized] 文本:

$ cargo build --release
   Compiling hello_opensource v0.1.0 (/opensource/hello_opensource)
    Finished release [optimized] target(s) in 0.29s

如果愿意,你可以通过这种练习来了解运行优化软件与未优化软件时节省的时间。

使用 Cargo 创建库还是二进制文件

任何软件程序都可以粗略地分类为独立二进制文件或库。一个独立二进制文件也许即使是当做外部库使用的,自身也是可以运行的。但是,作为一个库,是可以被另一个独立二进制文件所利用的。到目前为止,你在本教程中构建的所有程序都是独立二进制文件,因为这是 Cargo 的默认设置。 要创建一个,请添加 --lib 选项:

$ cargo new --lib libhello
     Created library `libhello` package

这次,Cargo 不会创建 main.rs 文件,而是创建一个 lib.rs 文件。 你的库的代码应该是这样的:

$ tree .
.
└── libhello
    ├── Cargo.toml
    └── src
        └── lib.rs

2 directories, 2 files

Cargo 就是这样的,不要奇怪,它在你的新库文件中添加了一些代码。通过移至包目录并查看文件来查找添加的内容。默认情况下,Cargo 在库文件中放置一个测试函数。

使用 Cargo 运行测试

Rust 为单元测试和集成测试提供了一流的支持,而 Cargo 允许你执行以下任何测试:

$ cd libhello/

$ cat src/lib.rs
#[cfg(test)]
mod tests {
    #[test]
    fn it_works() {
        assert_eq!(2 + 2, 4);
    }
}

Cargo 有一个方便的 test 命令,可以运行代码中存在的任何测试。尝试默认运行 Cargo 在库代码中放入的测试:

$ cargo test
   Compiling libhello v0.1.0 (/opensource/libhello)
    Finished test [unoptimized + debuginfo] target(s) in 0.55s
     Running target/debug/deps/libhello-d52e35bb47939653

running 1 test
test tests::it_works ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

   Doc-tests libhello

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

深入了解 Cargo 内部

你可能有兴趣了解在运行一个 Cargo 命令时它底下发生了什么。毕竟,在许多方面,Cargo 只是个封装器。要了解它在做什么,你可以将 -v 选项与任何 Cargo 命令一起使用,以将详细信息输出到屏幕。

这是使用 -v 选项运行 buildclean 的几个例子。

build 命令中,你可以看到这些给定的命令行选项触发了底层的 rustc(Rust 编译器):

$ cargo build -v
   Compiling hello_opensource v0.1.0 (/opensource/hello_opensource)
     Running `rustc --edition=2018 --crate-name hello_opensource src/main.rs --error-format=json --json=diagnostic-rendered-ansi --crate-type bin --emit=dep-info,link -C debuginfo=2 -C metadata=147b8a0f466515dd -C extra-filename=-147b8a0f466515dd --out-dir /opensource/hello_opensource/target/debug/deps -C incremental=/opensource/hello_opensource/target/debug/incremental -L dependency=/opensource/hello_opensource/target/debug/deps`
    Finished dev [unoptimized + debuginfo] target(s) in 0.36s

clean 命令表明它只是删除了包含中间文件和二进制文件的目录:

$ cargo clean -v
    Removing /opensource/hello_opensource/target

不要让你的技能生锈

要扩展你的技能,请尝试使用 Rust 和 Cargo 编写并运行一个稍微复杂的程序。很简单就可以做到:例如,尝试列出当前目录中的所有文件(可以用 9 行代码完成),或者尝试自己回显输入。小型的实践应用程序可帮助你熟悉语法以及编写和测试代码的过程。

本文为刚起步的 Rust 程序员提供了大量信息,以使他们可以开始入门 Cargo。但是,当你开始处理更大、更复杂的程序时,你需要对 Cargo 有更深入的了解。当你准备好迎接更多内容时,请下载并阅读 Rust 团队编写的开源的《Cargo 手册》,看看你可以创造什么!


via: https://opensource.com/article/20/3/rust-cargo

作者:Gaurav Kamathe 选题:lujun9972 译者:wxy 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

使用 strace 跟踪用户进程和 Linux 内核之间的交互。

系统调用 system call 是程序从内核请求服务的一种编程方式,而 strace 是一个功能强大的工具,可让你跟踪用户进程与 Linux 内核之间的交互。

要了解操作系统的工作原理,首先需要了解系统调用的工作原理。操作系统的主要功能之一是为用户程序提供抽象机制。

操作系统可以大致分为两种模式:

  • 内核模式:操作系统内核使用的一种强大的特权模式
  • 用户模式:大多数用户应用程序运行的地方 用户大多使用命令行实用程序和图形用户界面(GUI)来执行日常任务。系统调用在后台静默运行,与内核交互以完成工作。

系统调用与函数调用非常相似,这意味着它们都接受并处理参数然后返回值。唯一的区别是系统调用进入内核,而函数调用不进入。从用户空间切换到内核空间是使用特殊的 trap) 机制完成的。

通过使用系统库(在 Linux 系统上又称为 glibc),大部分系统调用对用户隐藏了。尽管系统调用本质上是通用的,但是发出系统调用的机制在很大程度上取决于机器(架构)。

本文通过使用一些常规命令并使用 strace 分析每个命令进行的系统调用来探索一些实际示例。这些示例使用 Red Hat Enterprise Linux,但是这些命令运行在其他 Linux 发行版上应该也是相同的:

[root@sandbox ~]# cat /etc/redhat-release
Red Hat Enterprise Linux Server release 7.7 (Maipo)
[root@sandbox ~]#
[root@sandbox ~]# uname -r
3.10.0-1062.el7.x86_64
[root@sandbox ~]#

首先,确保在系统上安装了必需的工具。你可以使用下面的 rpm 命令来验证是否安装了 strace。如果安装了,则可以使用 -V 选项检查 strace 实用程序的版本号:

[root@sandbox ~]# rpm -qa | grep -i strace
strace-4.12-9.el7.x86_64
[root@sandbox ~]#
[root@sandbox ~]# strace -V
strace -- version 4.12
[root@sandbox ~]#

如果没有安装,运行命令安装:

yum install strace

出于本示例的目的,在 /tmp 中创建一个测试目录,并使用 touch 命令创建两个文件:

[root@sandbox ~]# cd /tmp/
[root@sandbox tmp]#
[root@sandbox tmp]# mkdir testdir
[root@sandbox tmp]#
[root@sandbox tmp]# touch testdir/file1
[root@sandbox tmp]# touch testdir/file2
[root@sandbox tmp]#

(我使用 /tmp 目录是因为每个人都可以访问它,但是你可以根据需要选择另一个目录。)

testdir 目录下使用 ls 命令验证该文件已经创建:

[root@sandbox tmp]# ls testdir/
file1  file2
[root@sandbox tmp]#

你可能每天都在使用 ls 命令,而没有意识到系统调用在其下面发挥的作用。抽象地来说,该命令的工作方式如下:

命令行工具 -> 从系统库(glibc)调用函数 -> 调用系统调用

ls 命令内部从 Linux 上的系统库(即 glibc)调用函数。这些库去调用完成大部分工作的系统调用。

如果你想知道从 glibc 库中调用了哪些函数,请使用 ltrace 命令,然后跟上常规的 ls testdir/命令:

ltrace ls testdir/

如果没有安装 ltrace,键入如下命令安装:

yum install ltrace

大量的输出会被堆到屏幕上;不必担心,只需继续就行。ltrace 命令输出中与该示例有关的一些重要库函数包括:

opendir("testdir/")                                  = { 3 }
readdir({ 3 })                                       = { 101879119, "." }
readdir({ 3 })                                       = { 134, ".." }
readdir({ 3 })                                       = { 101879120, "file1" }
strlen("file1")                                      = 5
memcpy(0x1665be0, "file1\0", 6)                      = 0x1665be0
readdir({ 3 })                                       = { 101879122, "file2" }
strlen("file2")                                      = 5
memcpy(0x166dcb0, "file2\0", 6)                      = 0x166dcb0
readdir({ 3 })                                       = nil
closedir({ 3 })                                         

通过查看上面的输出,你或许可以了解正在发生的事情。opendir 库函数打开一个名为 testdir 的目录,然后调用 readdir 函数,该函数读取目录的内容。最后,有一个对 closedir 函数的调用,该函数将关闭先前打开的目录。现在请先忽略其他 strlenmemcpy 功能。

你可以看到正在调用哪些库函数,但是本文将重点介绍由系统库函数调用的系统调用。

与上述类似,要了解调用了哪些系统调用,只需将 strace 放在 ls testdir 命令之前,如下所示。 再次,一堆乱码丢到了你的屏幕上,你可以按照以下步骤进行操作:

[root@sandbox tmp]# strace ls testdir/
execve("/usr/bin/ls", ["ls", "testdir/"], [/* 40 vars */]) = 0
brk(NULL)                               = 0x1f12000
<<< truncated strace output >>>
write(1, "file1  file2\n", 13file1  file2
)          = 13
close(1)                                = 0
munmap(0x7fd002c8d000, 4096)            = 0
close(2)                                = 0
exit_group(0)                           = ?
+++ exited with 0 +++
[root@sandbox tmp]#

运行 strace 命令后屏幕上的输出就是运行 ls 命令的系统调用。每个系统调用都为操作系统提供了特定的用途,可以将它们大致分为以下几个部分:

  • 进程管理系统调用
  • 文件管理系统调用
  • 目录和文件系统管理系统调用
  • 其他系统调用

分析显示到屏幕上的信息的一种更简单的方法是使用 strace 方便的 -o 标志将输出记录到文件中。在 -o 标志后添加一个合适的文件名,然后再次运行命令:

[root@sandbox tmp]# strace -o trace.log ls testdir/
file1  file2
[root@sandbox tmp]#

这次,没有任何输出干扰屏幕显示,ls 命令如预期般工作,显示了文件名并将所有输出记录到文件 trace.log 中。仅仅是一个简单的 ls 命令,该文件就有近 100 行内容:

[root@sandbox tmp]# ls -l trace.log
-rw-r--r--. 1 root root 7809 Oct 12 13:52 trace.log
[root@sandbox tmp]#
[root@sandbox tmp]# wc -l trace.log
114 trace.log
[root@sandbox tmp]#

让我们看一下这个示例的 trace.log 文件的第一行:

execve("/usr/bin/ls", ["ls", "testdir/"], [/* 40 vars */]) = 0
  • 该行的第一个单词 execve 是正在执行的系统调用的名称。
  • 括号内的文本是提供给该系统调用的参数。
  • 符号 = 后的数字(在这种情况下为 0)是 execve 系统调用的返回值。

现在的输出似乎还不太吓人,对吧。你可以应用相同的逻辑来理解其他行。

现在,将关注点集中在你调用的单个命令上,即 ls testdir。你知道命令 ls 使用的目录名称,那么为什么不在 trace.log 文件中使用 grep 查找 testdir 并查看得到的结果呢?让我们详细查看一下结果的每一行:

[root@sandbox tmp]# grep testdir trace.log
execve("/usr/bin/ls", ["ls", "testdir/"], [/* 40 vars */]) = 0
stat("testdir/", {st_mode=S_IFDIR|0755, st_size=32, ...}) = 0
openat(AT_FDCWD, "testdir/", O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC) = 3
[root@sandbox tmp]#

回顾一下上面对 execve 的分析,你能说一下这个系统调用的作用吗?

execve("/usr/bin/ls", ["ls", "testdir/"], [/* 40 vars */]) = 0

你无需记住所有系统调用或它们所做的事情,因为你可以在需要时参考文档。手册页可以解救你!在运行 man 命令之前,请确保已安装以下软件包:

[root@sandbox tmp]# rpm -qa | grep -i man-pages
man-pages-3.53-5.el7.noarch
[root@sandbox tmp]#

请记住,你需要在 man 命令和系统调用名称之间添加 2。如果使用 man man 阅读 man 命令的手册页,你会看到第 2 节是为系统调用保留的。同样,如果你需要有关库函数的信息,则需要在 man 和库函数名称之间添加一个 3

以下是手册的章节编号及其包含的页面类型:

  • 1:可执行的程序或 shell 命令
  • 2:系统调用(由内核提供的函数)
  • 3:库调用(在程序的库内的函数)
  • 4:特殊文件(通常出现在 /dev

使用系统调用名称运行以下 man 命令以查看该系统调用的文档:

man 2 execve

按照 execve 手册页,这将执行在参数中传递的程序(在本例中为 ls)。可以为 ls 提供其他参数,例如本例中的 testdir。因此,此系统调用仅以 testdir 作为参数运行 ls

execve - execute program

DESCRIPTION
       execve()  executes  the  program  pointed to by filename

下一个系统调用,名为 stat,它使用 testdir 参数:

stat("testdir/", {st_mode=S_IFDIR|0755, st_size=32, ...}) = 0

使用 man 2 stat 访问该文档。stat 是获取文件状态的系统调用,请记住,Linux 中的一切都是文件,包括目录。

接下来,openat 系统调用将打开 testdir。密切注意返回的 3。这是一个文件描述符,将在以后的系统调用中使用:

openat(AT_FDCWD, "testdir/", O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC) = 3

到现在为止一切都挺好。现在,打开 trace.log 文件,并转到 openat 系统调用之后的行。你会看到 getdents 系统调用被调用,该调用完成了执行 ls testdir 命令所需的大部分操作。现在,从 trace.log 文件中用 grep 获取 getdents

[root@sandbox tmp]# grep getdents trace.log
getdents(3, /* 4 entries */, 32768)     = 112
getdents(3, /* 0 entries */, 32768)     = 0
[root@sandbox tmp]#

getdents 的手册页将其描述为 “获取目录项”,这就是你要执行的操作。注意,getdents 的参数是 3,这是来自上面 openat 系统调用的文件描述符。

现在有了目录列表,你需要一种在终端中显示它的方法。因此,在日志中用 grep 搜索另一个用于写入终端的系统调用 write

[root@sandbox tmp]# grep write trace.log
write(1, "file1  file2\n", 13)          = 13
[root@sandbox tmp]#

在这些参数中,你可以看到将要显示的文件名:file1file2。关于第一个参数(1),请记住在 Linux 中,当运行任何进程时,默认情况下会为其打开三个文件描述符。以下是默认的文件描述符:

  • 0:标准输入
  • 1:标准输出
  • 2:标准错误

因此,write 系统调用将在标准显示(就是这个终端,由 1 所标识的)上显示 file1file2

现在你知道哪个系统调用完成了 ls testdir/ 命令的大部分工作。但是在 trace.log 文件中其它的 100 多个系统调用呢?操作系统必须做很多内务处理才能运行一个进程,因此,你在该日志文件中看到的很多内容都是进程初始化和清理。阅读整个 trace.log 文件,并尝试了解 ls 命令是怎么工作起来的。

既然你知道了如何分析给定命令的系统调用,那么就可以将该知识用于其他命令来了解正在执行哪些系统调用。strace 提供了许多有用的命令行标志,使你更容易使用,下面将对其中一些进行描述。

默认情况下,strace 并不包含所有系统调用信息。但是,它有一个方便的 -v 冗余选项,可以在每个系统调用中提供附加信息:

strace -v ls testdir

在运行 strace 命令时始终使用 -f 选项是一种好的作法。它允许 strace 对当前正在跟踪的进程创建的任何子进程进行跟踪:

strace -f ls testdir

假设你只需要系统调用的名称、运行的次数以及每个系统调用花费的时间百分比。你可以使用 -c 标志来获取这些统计信息:

strace -c ls testdir/

假设你想专注于特定的系统调用,例如专注于 open 系统调用,而忽略其余部分。你可以使用-e 标志跟上系统调用的名称:

[root@sandbox tmp]# strace -e open ls testdir
open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libselinux.so.1", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libcap.so.2", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libacl.so.1", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libpcre.so.1", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libdl.so.2", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libattr.so.1", O_RDONLY|O_CLOEXEC) = 3
open("/lib64/libpthread.so.0", O_RDONLY|O_CLOEXEC) = 3
open("/usr/lib/locale/locale-archive", O_RDONLY|O_CLOEXEC) = 3
file1  file2
+++ exited with 0 +++
[root@sandbox tmp]#

如果你想关注多个系统调用怎么办?不用担心,你同样可以使用 -e 命令行标志,并用逗号分隔开两个系统调用的名称。例如,要查看 writegetdents 系统调用:

[root@sandbox tmp]# strace -e write,getdents ls testdir
getdents(3, /* 4 entries */, 32768)     = 112
getdents(3, /* 0 entries */, 32768)     = 0
write(1, "file1  file2\n", 13file1  file2
)          = 13
+++ exited with 0 +++
[root@sandbox tmp]#

到目前为止,这些示例是明确地运行的命令进行了跟踪。但是,要跟踪已经运行并正在执行的命令又怎么办呢?例如,如果要跟踪用来长时间运行进程的守护程序,该怎么办?为此,strace 提供了一个特殊的 -p 标志,你可以向其提供进程 ID。

我们的示例不在守护程序上运行 strace,而是以 cat 命令为例,如果你将文件名作为参数,通常 cat 会显示文件的内容。如果没有给出参数,cat 命令会在终端上等待用户输入文本。输入文本后,它将重复给定的文本,直到用户按下 Ctrl + C 退出为止。

从一个终端运行 cat 命令;它会向你显示一个提示,并等待在那里(记住 cat 仍在运行且尚未退出):

[root@sandbox tmp]# cat

在另一个终端上,使用 ps 命令找到进程标识符(PID):

[root@sandbox ~]# ps -ef | grep cat
root      22443  20164  0 14:19 pts/0    00:00:00 cat
root      22482  20300  0 14:20 pts/1    00:00:00 grep --color=auto cat
[root@sandbox ~]#

现在,使用 -p 标志和 PID(在上面使用 ps 找到)对运行中的进程运行 strace。运行 strace 之后,其输出说明了所接驳的进程的内容及其 PID。现在,strace 正在跟踪 cat 命令进行的系统调用。看到的第一个系统调用是 read,它正在等待文件描述符 0(标准输入,这是运行 cat 命令的终端)的输入:

[root@sandbox ~]# strace -p 22443
strace: Process 22443 attached
read(0,

现在,返回到你运行 cat 命令的终端,并输入一些文本。我出于演示目的输入了 x0x0。注意 cat 是如何简单地重复我输入的内容的。因此,x0x0 出现了两次。我输入了第一个,第二个是 cat 命令重复的输出:

[root@sandbox tmp]# cat
x0x0
x0x0

返回到将 strace 接驳到 cat 进程的终端。现在你会看到两个额外的系统调用:较早的 read 系统调用,现在在终端中读取 x0x0,另一个为 write,它将 x0x0 写回到终端,然后是再一个新的 read,正在等待从终端读取。请注意,标准输入(0)和标准输出(1)都在同一终端中:

[root@sandbox ~]# strace -p 22443
strace: Process 22443 attached
read(0, "x0x0\n", 65536)                = 5
write(1, "x0x0\n", 5)                   = 5
read(0,

想象一下,对守护进程运行 strace 以查看其在后台执行的所有操作时这有多大帮助。按下 Ctrl + C 杀死 cat 命令;由于该进程不再运行,因此这也会终止你的 strace 会话。

如果要查看所有的系统调用的时间戳,只需将 -t 选项与 strace 一起使用:

[root@sandbox ~]#strace -t ls testdir/

14:24:47 execve("/usr/bin/ls", ["ls", "testdir/"], [/* 40 vars */]) = 0
14:24:47 brk(NULL)                      = 0x1f07000
14:24:47 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f2530bc8000
14:24:47 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
14:24:47 open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3

如果你想知道两次系统调用之间所花费的时间怎么办?strace 有一个方便的 -r 命令,该命令显示执行每个系统调用所花费的时间。非常有用,不是吗?

[root@sandbox ~]#strace -r ls testdir/

0.000000 execve("/usr/bin/ls", ["ls", "testdir/"], [/* 40 vars */]) = 0
0.000368 brk(NULL)                 = 0x1966000
0.000073 mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fb6b1155000
0.000047 access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)
0.000119 open("/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3

总结

strace 实用程序非常有助于理解 Linux 上的系统调用。要了解它的其它命令行标志,请参考手册页和在线文档。


via: https://opensource.com/article/19/10/strace

作者:Gaurav Kamathe 选题:lujun9972 译者:wxy 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

二进制分析是计算机行业中最被低估的技能。

想象一下,在无法访问软件的源代码时,但仍然能够理解软件的实现方式,在其中找到漏洞,并且更厉害的是还能修复错误。所有这些都是在只有二进制文件时做到的。这听起来就像是超能力,对吧?

你也可以拥有这样的超能力,GNU 二进制实用程序(binutils)就是一个很好的起点。GNU binutils 是一个二进制工具集,默认情况下所有 Linux 发行版中都会安装这些二进制工具。

二进制分析是计算机行业中最被低估的技能。它主要由恶意软件分析师、反向工程师和使用底层软件的人使用。

本文探讨了 binutils 可用的一些工具。我使用的是 RHEL,但是这些示例应该在任何 Linux 发行版上可以运行。

[~]# cat /etc/redhat-release 
Red Hat Enterprise Linux Server release 7.6 (Maipo)
[~]# 
[~]# uname -r
3.10.0-957.el7.x86_64
[~]# 

请注意,某些打包命令(例如 rpm)在基于 Debian 的发行版中可能不可用,因此请使用等效的 dpkg 命令替代。

软件开发的基础知识

在开源世界中,我们很多人都专注于源代码形式的软件。当软件的源代码随时可用时,很容易获得源代码的副本,打开喜欢的编辑器,喝杯咖啡,然后就可以开始探索了。

但是源代码不是在 CPU 上执行的代码,在 CPU 上执行的是二进制或者说是机器语言指令。二进制或可执行文件是编译源代码时获得的。熟练的调试人员深谙通常这种差异。

编译的基础知识

在深入研究 binutils 软件包本身之前,最好先了解编译的基础知识。

编译是将程序从某种编程语言(如 C/C++)的源代码(文本形式)转换为机器代码的过程。

机器代码是 CPU(或一般而言,硬件)可以理解的 1 和 0 的序列,因此可以由 CPU 执行或运行。该机器码以特定格式保存到文件,通常称为可执行文件或二进制文件。在 Linux(和使用 Linux 兼容二进制的 BSD)上,这称为 ELF 可执行和可链接格式 Executable and Linkable Format )。

在生成给定的源文件的可执行文件或二进制文件之前,编译过程将经历一系列复杂的步骤。以这个源程序(C 代码)为例。打开你喜欢的编辑器,然后键入以下程序:

#include <stdio.h>

int main(void)
{
  printf("Hello World\n");
  return 0;
}

步骤 1:用 cpp 预处理

C 预处理程序(cpp)用于扩展所有宏并将头文件包含进来。在此示例中,头文件 stdio.h 将被包含在源代码中。stdio.h 是一个头文件,其中包含有关程序内使用的 printf 函数的信息。对源代码运行 cpp,其结果指令保存在名为 hello.i 的文件中。可以使用文本编辑器打开该文件以查看其内容。打印 “hello world” 的源代码在该文件的底部。

[testdir]# cat hello.c
#include <stdio.h>

int main(void)
{
  printf("Hello World\n");
  return 0;
}
[testdir]#
[testdir]# cpp hello.c > hello.i
[testdir]#
[testdir]# ls -lrt
total 24
-rw-r--r--. 1 root root 76 Sep 13 03:20 hello.c
-rw-r--r--. 1 root root 16877 Sep 13 03:22 hello.i
[testdir]#

步骤 2:用 gcc 编译

在此阶段,无需创建目标文件就将步骤 1 中生成的预处理源代码转换为汇编语言指令。这个阶段使用 GNU 编译器集合(gcc)。对 hello.i 文件运行带有 -S 选项的 gcc 命令后,它将创建一个名为 hello.s 的新文件。该文件包含该 C 程序的汇编语言指令。

你可以使用任何编辑器或 cat 命令查看其内容。

[testdir]#
[testdir]# gcc -Wall -S hello.i
[testdir]#
[testdir]# ls -l
total 28
-rw-r--r--. 1 root root 76 Sep 13 03:20 hello.c
-rw-r--r--. 1 root root 16877 Sep 13 03:22 hello.i
-rw-r--r--. 1 root root 448 Sep 13 03:25 hello.s
[testdir]#
[testdir]# cat hello.s
.file "hello.c"
.section .rodata
.LC0:
.string "Hello World"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $.LC0, %edi
call puts
movl $0, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-36)"
.section .note.GNU-stack,"",@progbits
[testdir]#

步骤 3:用 as 汇编

汇编器的目的是将汇编语言指令转换为机器语言代码,并生成扩展名为 .o 的目标文件。此阶段使用默认情况下在所有 Linux 平台上都可用的 GNU 汇编器。

testdir]# as hello.s -o hello.o
[testdir]#
[testdir]# ls -l
total 32
-rw-r--r--. 1 root root 76 Sep 13 03:20 hello.c
-rw-r--r--. 1 root root 16877 Sep 13 03:22 hello.i
-rw-r--r--. 1 root root 1496 Sep 13 03:39 hello.o
-rw-r--r--. 1 root root 448 Sep 13 03:25 hello.s
[testdir]#

现在,你有了第一个 ELF 格式的文件;但是,还不能执行它。稍后,你将看到“ 目标文件 object file ”和“ 可执行文件 executable file ”之间的区别。

[testdir]# file hello.o
hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

步骤 4:用 ld 链接

这是编译的最后阶段,将目标文件链接以创建可执行文件。可执行文件通常需要外部函数,这些外部函数通常来自系统库(libc)。

你可以使用 ld 命令直接调用链接器;但是,此命令有些复杂。相反,你可以使用带有 -v(详细)标志的 gcc 编译器,以了解链接是如何发生的。(使用 ld 命令进行链接作为一个练习,你可以自行探索。)

[testdir]# gcc -v hello.o
Using built-in specs.
COLLECT_GCC=gcc
COLLECT_LTO_WRAPPER=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/lto-wrapper
Target: x86_64-redhat-linux
Configured with: ../configure --prefix=/usr --mandir=/usr/share/man [...] --build=x86_64-redhat-linux
Thread model: posix
gcc version 4.8.5 20150623 (Red Hat 4.8.5-36) (GCC)
COMPILER_PATH=/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/:/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/:[...]:/usr/lib/gcc/x86_64-redhat-linux/
LIBRARY_PATH=/usr/lib/gcc/x86_64-redhat-linux/4.8.5/:/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../../lib64/:/lib/../lib64/:/usr/lib/../lib64/:/usr/lib/gcc/x86_64-redhat-linux/4.8.5/../../../:/lib/:/usr/lib/
COLLECT_GCC_OPTIONS='-v' '-mtune=generic' '-march=x86-64'
/usr/libexec/gcc/x86_64-redhat-linux/4.8.5/collect2 --build-id --no-add-needed --eh-frame-hdr --hash-style=gnu [...]/../../../../lib64/crtn.o
[testdir]#

运行此命令后,你应该看到一个名为 a.out 的可执行文件:

[testdir]# ls -l
total 44
-rwxr-xr-x. 1 root root 8440 Sep 13 03:45 a.out
-rw-r--r--. 1 root root 76 Sep 13 03:20 hello.c
-rw-r--r--. 1 root root 16877 Sep 13 03:22 hello.i
-rw-r--r--. 1 root root 1496 Sep 13 03:39 hello.o
-rw-r--r--. 1 root root 448 Sep 13 03:25 hello.s

a.out 运行 file 命令,结果表明它确实是 ELF 可执行文件:

[testdir]# file a.out
a.out: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.32, BuildID[sha1]=48e4c11901d54d4bf1b6e3826baf18215e4255e5, not stripped

运行该可执行文件,看看它是否如源代码所示工作:

[testdir]# ./a.out Hello World

工作了!在幕后发生了很多事情它才在屏幕上打印了 “Hello World”。想象一下在更复杂的程序中会发生什么。

探索 binutils 工具

上面这个练习为使用 binutils 软件包中的工具提供了良好的背景。我的系统带有 binutils 版本 2.27-34;你的 Linux 发行版上的版本可能有所不同。

[~]# rpm -qa | grep binutils 
binutils-2.27-34.base.el7.x86_64

binutils 软件包中提供了以下工具:

[~]# rpm -ql binutils-2.27-34.base.el7.x86_64 | grep bin/
/usr/bin/addr2line
/usr/bin/ar
/usr/bin/as
/usr/bin/c++filt
/usr/bin/dwp
/usr/bin/elfedit
/usr/bin/gprof
/usr/bin/ld
/usr/bin/ld.bfd
/usr/bin/ld.gold
/usr/bin/nm
/usr/bin/objcopy
/usr/bin/objdump
/usr/bin/ranlib
/usr/bin/readelf
/usr/bin/size
/usr/bin/strings
/usr/bin/strip

上面的编译练习已经探索了其中的两个工具:用作汇编器的 as 命令,用作链接器的 ld 命令。继续阅读以了解上述 GNU binutils 软件包工具中的其他七个。

readelf:显示 ELF 文件信息

上面的练习提到了术语“目标文件”和“可执行文件”。使用该练习中的文件,通过带有 -h(标题)选项的 readelf 命令,以将文件的 ELF 标题转储到屏幕上。请注意,以 .o 扩展名结尾的目标文件显示为 Type: REL (Relocatable file)(可重定位文件):

[testdir]# readelf -h hello.o
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 [...]
[...]
Type: REL (Relocatable file)
[...]

如果尝试执行此目标文件,会收到一条错误消息,指出无法执行。这仅表示它尚不具备在 CPU 上执行所需的信息。

请记住,你首先需要使用 chmod 命令在对象文件上添加 x(可执行位),否则你将得到“权限被拒绝”的错误。

[testdir]# ./hello.o
bash: ./hello.o: Permission denied
[testdir]# chmod +x ./hello.o
[testdir]#
[testdir]# ./hello.o
bash: ./hello.o: cannot execute binary file

如果对 a.out 文件尝试相同的命令,则会看到其类型为 EXEC (Executable file)(可执行文件)。

[testdir]# readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
[...] Type: EXEC (Executable file)

如上所示,该文件可以直接由 CPU 执行:

[testdir]# ./a.out Hello World

readelf 命令可提供有关二进制文件的大量信息。在这里,它会告诉你它是 ELF 64 位格式,这意味着它只能在 64 位 CPU 上执行,而不能在 32 位 CPU 上运行。它还告诉你它应在 X86-64(Intel/AMD)架构上执行。该二进制文件的入口点是地址 0x400430,它就是 C 源程序中 main 函数的地址。

在你知道的其他系统二进制文件上尝试一下 readelf 命令,例如 ls。请注意,在 RHEL 8 或 Fedora 30 及更高版本的系统上,由于安全原因改用了 位置无关可执行文件 position independent executable PIE),因此你的输出(尤其是 Type:)可能会有所不同。

[testdir]# readelf -h /bin/ls
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)

使用 ldd 命令了解 ls 命令所依赖的系统库,如下所示:

[testdir]# ldd /bin/ls
linux-vdso.so.1 => (0x00007ffd7d746000)
libselinux.so.1 => /lib64/libselinux.so.1 (0x00007f060daca000)
libcap.so.2 => /lib64/libcap.so.2 (0x00007f060d8c5000)
libacl.so.1 => /lib64/libacl.so.1 (0x00007f060d6bc000)
libc.so.6 => /lib64/libc.so.6 (0x00007f060d2ef000)
libpcre.so.1 => /lib64/libpcre.so.1 (0x00007f060d08d000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007f060ce89000)
/lib64/ld-linux-x86-64.so.2 (0x00007f060dcf1000)
libattr.so.1 => /lib64/libattr.so.1 (0x00007f060cc84000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f060ca68000)

libc 库文件运行 readelf 以查看它是哪种文件。正如它指出的那样,它是一个 DYN (Shared object file)(共享对象文件),这意味着它不能直接执行;必须由内部使用了该库提供的任何函数的可执行文件使用它。

[testdir]# readelf -h /lib64/libc.so.6
ELF Header:
Magic: 7f 45 4c 46 02 01 01 03 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - GNU
ABI Version: 0
Type: DYN (Shared object file)

size:列出节的大小和全部大小

size 命令仅适用于目标文件和可执行文件,因此,如果尝试在简单的 ASCII 文件上运行它,则会抛出错误,提示“文件格式无法识别”。

[testdir]# echo "test" > file1
[testdir]# cat file1
test
[testdir]# file file1
file1: ASCII text
[testdir]# size file1
size: file1: File format not recognized

现在,在上面的练习中,对目标文件和可执行文件运行 size 命令。请注意,根据 size 命令的输出可以看出,可执行文件(a.out)的信息要比目标文件(hello.o)多得多:

[testdir]# size hello.o
text data bss dec hex filename
89 0 0 89 59 hello.o
[testdir]# size a.out
text data bss dec hex filename
1194 540 4 1738 6ca a.out

但是这里的 textdatabss 节是什么意思?

text 节是指二进制文件的代码部分,其中包含所有可执行指令。data 节是所有初始化数据所在的位置,bss 节是所有未初始化数据的存储位置。(LCTT 译注:一般来说,在静态的映像文件中,各个部分称之为 section ,而在运行时的各个部分称之为 segment ,有时统称为段。)

比较其他一些可用的系统二进制文件的 size 结果。

对于 ls 命令:

[testdir]# size /bin/ls
text data bss dec hex filename
103119 4768 3360 111247 1b28f /bin/ls

只需查看 size 命令的输出,你就可以看到 gccgdb 是比 ls 大得多的程序:

[testdir]# size /bin/gcc
text data bss dec hex filename
755549 8464 81856 845869 ce82d /bin/gcc
[testdir]# size /bin/gdb
text data bss dec hex filename
6650433 90842 152280 6893555 692ff3 /bin/gdb

strings:打印文件中的可打印字符串

strings 命令中添加 -d 标志以仅显示 data 节中的可打印字符通常很有用。

hello.o 是一个目标文件,其中包含打印出 Hello World 文本的指令。因此,strings 命令的唯一输出是 Hello World

[testdir]# strings -d hello.o 
Hello World

另一方面,在 a.out(可执行文件)上运行 strings 会显示在链接阶段该二进制文件中包含的其他信息:

[testdir]# strings -d a.out
/lib64/ld-linux-x86-64.so.2
!^BU
libc.so.6
puts
__libc_start_main
__gmon_start__
GLIBC_2.2.5
UH-0
UH-0
=(
[]A\A]A^A_
Hello World
;*3$"

objdump:显示目标文件信息

另一个可以从二进制文件中转储机器语言指令的 binutils 工具称为 objdump。使用 -d 选项,可从二进制文件中反汇编出所有汇编指令。

回想一下,编译是将源代码指令转换为机器代码的过程。机器代码仅由 1 和 0 组成,人类难以阅读。因此,它有助于将机器代码表示为汇编语言指令。汇编语言是什么样的?请记住,汇编语言是特定于体系结构的;由于我使用的是 Intel(x86-64)架构,因此如果你使用 ARM 架构编译相同的程序,指令将有所不同。

[testdir]# objdump -d hello.o
hello.o: file format elf64-x86-64
Disassembly of section .text:
0000000000000000
:
0:  55              push %rbp
1:  48 89 e5        mov %rsp,%rbp
4:  bf 00 00 00 00  mov $0x0,%edi
9:  e8 00 00 00 00  callq e

e:  b8 00 00 00 00  mov $0x0,%eax
13: 5d              pop %rbp
14: c3              retq

该输出乍一看似乎令人生畏,但请花一点时间来理解它,然后再继续。回想一下,.text 节包含所有的机器代码指令。汇编指令可以在第四列中看到(即 pushmovcallqpopretq 等)。这些指令作用于寄存器,寄存器是 CPU 内置的存储器位置。本示例中的寄存器是 rbprspedieax 等,并且每个寄存器都有特殊的含义。

现在对可执行文件(a.out)运行 objdump 并查看得到的内容。可执行文件的 objdump 的输出可能很大,因此我使用 grep 命令将其缩小到 main 函数:

[testdir]# objdump -d a.out | grep -A 9 main\>
000000000040051d
:
40051d: 55              push %rbp
40051e: 48 89 e5        mov %rsp,%rbp
400521: bf d0 05 40 00  mov $0x4005d0,%edi
400526: e8 d5 fe ff ff  callq 400400
40052b: b8 00 00 00 00  mov $0x0,%eax
400530: 5d              pop %rbp
400531: c3              retq

请注意,这些指令与目标文件 hello.o 相似,但是其中包含一些其他信息:

  • 目标文件 hello.o 具有以下指令:callq e
  • 可执行文件 a.out 由以下指令组成,该指令带有一个地址和函数:callq 400400 <puts@plt> 上面的汇编指令正在调用 puts 函数。请记住,你在源代码中使用了一个 printf 函数。编译器插入了对 puts 库函数的调用,以将 Hello World 输出到屏幕。

查看 put 上方一行的说明:

  • 目标文件 hello.o 有个指令 movmov $0x0,%edi
  • 可执行文件 a.outmov 指令带有实际地址($0x4005d0)而不是 $0x0mov $0x4005d0,%edi

该指令将二进制文件中地址 $0x4005d0 处存在的内容移动到名为 edi 的寄存器中。

这个存储位置的内容中还能是别的什么吗?是的,你猜对了:它就是文本 Hello, World。你是如何确定的?

readelf 命令使你可以将二进制文件(a.out)的任何节转储到屏幕上。以下要求它将 .rodata(这是只读数据)转储到屏幕上:

[testdir]# readelf -x .rodata a.out

Hex dump of section '.rodata':
0x004005c0 01000200 00000000 00000000 00000000 ....
0x004005d0 48656c6c 6f20576f 726c6400 Hello World.

你可以在右侧看到文本 Hello World,在左侧可以看到其二进制格式的地址。它是否与你在上面的 mov 指令中看到的地址匹配?是的,确实匹配。

strip:从目标文件中剥离符号

该命令通常用于在将二进制文件交付给客户之前减小二进制文件的大小。

请记住,由于重要信息已从二进制文件中删除,因此它会妨碍调试。但是,这个二进制文件可以完美地执行。

a.out 可执行文件运行该命令,并注意会发生什么。首先,通过运行以下命令确保二进制文件没有被剥离(not stripped):

[testdir]# file a.out
a.out: ELF 64-bit LSB executable, x86-64, [......] not stripped

另外,在运行 strip 命令之前,请记下二进制文件中最初的字节数:

[testdir]# du -b a.out
8440 a.out

现在对该可执行文件运行 strip 命令,并使用 file 命令以确保正常完成:

[testdir]# strip a.out
[testdir]# file a.out a.out: ELF 64-bit LSB executable, x86-64, [......] stripped

剥离该二进制文件后,此小程序的大小从之前的 8440 字节减小为 6296 字节。对于这样小的一个程序都能有这么大的空间节省,难怪大型程序经常被剥离。

[testdir]# du -b a.out 
6296 a.out

addr2line:转换地址到文件名和行号

addr2line 工具只是在二进制文件中查找地址,并将其与 C 源代码程序中的行进行匹配。很酷,不是吗?

为此编写另一个测试程序;只是这一次确保使用 gcc-g 标志进行编译,这将为二进制文件添加其它调试信息,并包含有助于调试的行号(由源代码中提供):

[testdir]# cat -n atest.c
1  #include <stdio.h>
2
3  int globalvar = 100;
4
5  int function1(void)
6  {
7    printf("Within function1\n");
8    return 0;
9  }
10
11 int function2(void)
12 {
13   printf("Within function2\n");
14   return 0;
15 }
16
17 int main(void)
18 {
19   function1();
20   function2();
21   printf("Within main\n");
22   return 0;
23 }

-g 标志编译并执行它。正如预期:

[testdir]# gcc -g atest.c
[testdir]# ./a.out
Within function1
Within function2
Within main

现在使用 objdump 来标识函数开始的内存地址。你可以使用 grep 命令来过滤出所需的特定行。函数的地址在下面突出显示(55 push %rbp 前的地址):

[testdir]# objdump -d a.out | grep -A 2 -E 'main>:|function1>:|function2>:'
000000000040051d :
40051d: 55 push %rbp
40051e: 48 89 e5 mov %rsp,%rbp
--
0000000000400532 :
400532: 55 push %rbp
400533: 48 89 e5 mov %rsp,%rbp
--
0000000000400547
:
400547: 55 push %rbp
400548: 48 89 e5 mov %rsp,%rbp

现在,使用 addr2line 工具从二进制文件中的这些地址映射到 C 源代码匹配的地址:

[testdir]# addr2line -e a.out 40051d
/tmp/testdir/atest.c:6
[testdir]#
[testdir]# addr2line -e a.out 400532
/tmp/testdir/atest.c:12
[testdir]#
[testdir]# addr2line -e a.out 400547
/tmp/testdir/atest.c:18

它说 40051d 从源文件 atest.c 中的第 6 行开始,这是 function1 的起始大括号({)开始的行。function2main 的输出也匹配。

nm:列出目标文件的符号

使用上面的 C 程序测试 nm 工具。使用 gcc 快速编译并执行它。

[testdir]# gcc atest.c
[testdir]# ./a.out
Within function1
Within function2
Within main

现在运行 nmgrep 获取有关函数和变量的信息:

[testdir]# nm a.out | grep -Ei 'function|main|globalvar'
000000000040051d T function1
0000000000400532 T function2
000000000060102c D globalvar
U __libc_start_main@@GLIBC_2.2.5
0000000000400547 T main

你可以看到函数被标记为 T,它表示 text 节中的符号,而变量标记为 D,表示初始化的 data 节中的符号。

想象一下在没有源代码的二进制文件上运行此命令有多大用处?这使你可以窥视内部并了解使用了哪些函数和变量。当然,除非二进制文件已被剥离,这种情况下它们将不包含任何符号,因此 nm 就命令不会很有用,如你在此处看到的:

[testdir]# strip a.out
[testdir]# nm a.out | grep -Ei 'function|main|globalvar'
nm: a.out: no symbols

结论

GNU binutils 工具为有兴趣分析二进制文件的人提供了许多选项,这只是它们可以为你做的事情的冰山一角。请阅读每种工具的手册页,以了解有关它们以及如何使用它们的更多信息。


via: https://opensource.com/article/19/10/gnu-binutils

作者:Gaurav Kamathe 选题:lujun9972 译者:wxy 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出