【译】读取文件的困难模式 二

原文链接 https://fasterthanli.me/series/reading-files-the-hard-way/part-2

看看最新的模型,感觉有点可疑。每个程序 最终调用同一组函数。这几乎就像是不同的东西 调用这些时会发生。

这些是常规函数吗?我们可以使用调试器单步调试它们吗?

如果我们在 gdb 中运行由 stdio 驱动的 C 程序,并在 read 处中断,我们可以 确认我们确实最终调用了一个 read 函数(称为 __GI___libc_read 在这里,但是哦,好吧):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ gcc -g -O0 readfile-f.c -o readfile-f

$ gdb --silent ./readfile-f
Reading symbols from ./readfile-f...
(gdb) break read
Function "read" not defined.
Make breakpoint pending on future shared library load? (y or [n]) y
Breakpoint 1 (read) pending.
(gdb) r
Starting program: /home/amos/bearcove/read-files-the-hard-way/readfile-f
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, __GI___libc_read (fd=3, buf=0x5555555594a0, nbytes=4096) at ../sysdeps/unix/sysv/linux/read.c:25
25 ../sysdeps/unix/sysv/linux/read.c: No such file or directory.
(gdb) bt
#0 __GI___libc_read (fd=3, buf=0x5555555594a0, nbytes=4096) at ../sysdeps/unix/sysv/linux/read.c:25
#1 0x00007ffff7c88596 in _IO_new_file_underflow (fp=0x5555555592a0) at ./libio/libioP.h:947
#2 0x00007ffff7c86e00 in __GI__IO_file_xsgetn (fp=0x5555555592a0, data=<optimized out>, n=15) at ./libio/fileops.c:1321
#3 0x00007ffff7c7b709 in __GI__IO_fread (buf=0x555555559480, size=1, count=15, fp=0x5555555592a0) at ./libio/iofread.c:38
#4 0x0000555555555288 in main (argc=1, argv=0x7fffffffdb98) at readfile-f.c:16
(gdb)

酷熊的热心小贴士
GDB 是一个开源调试器,可在 Linux、macOS和 Windows 上运行 (有一些限制)。
除了许多其他功能之外,它还允许设置断点和单步执行代码。
我们会大量使用它。

Rust 程序也是如此:

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ rust-gdb --silent target/debug/readfile-rs
Reading symbols from target/debug/readfile-rs...
(gdb) break read
Breakpoint 1 at 0x1ea73: file library/std/src/fs.rs, line 872.
(gdb) r
Starting program: /home/amos/bearcove/read-files-the-hard-way/readfile-rs/target/debug/readfile-rs
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, std::sys::unix::fs::OpenOptions::read () at library/std/src/sys/unix/fs.rs:893
893 library/std/src/sys/unix/fs.rs: No such file or directory.
(gdb) c
Continuing.

Breakpoint 1, __GI___libc_read (fd=3, buf=0x5555555a8ba0, nbytes=220) at ../sysdeps/unix/sysv/linux/read.c:25
25 ../sysdeps/unix/sysv/linux/read.c: No such file or directory.
(gdb) bt
#0 __GI___libc_read (fd=3, buf=0x5555555a8ba0, nbytes=220) at ../sysdeps/unix/sysv/linux/read.c:25
#1 0x000055555557454f in std::sys::unix::fd::FileDesc::read_buf () at library/std/src/sys/unix/fd.rs:136
#2 std::sys::unix::fs::File::read_buf () at library/std/src/sys/unix/fs.rs:1062
#3 std::fs::{impl#5}::read_buf () at library/std/src/fs.rs:737
#4 std::io::default_read_to_end<std::fs::File> () at library/std/src/io/mod.rs:376
#5 0x000055555557297d in std::io::default_read_to_string::{closure#0}<std::fs::File> () at library/std/src/io/mod.rs:430
#6 std::io::append_to_string<std::io::default_read_to_string::{closure_env#0}<std::fs::File>> () at library/std/src/io/mod.rs:338
#7 std::io::default_read_to_string<std::fs::File> () at library/std/src/io/mod.rs:430
#8 std::fs::{impl#5}::read_to_string () at library/std/src/fs.rs:754
#9 0x000055555555c899 in readfile_rs::main () at src/main.rs:9

然而,当我们尝试逐步完成它时……什么也没有。为了探索这些细节,我克隆了 glibc 存储库(因为那是读取函数似乎所在的位置), 并发现了这个:

C code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// in `glibc/sysdeps/unix/sysv/linux/read.c`

/* Read NBYTES into BUF from FD. Return the number read or -1. */
ssize_t
__libc_read (int fd, void *buf, size_t nbytes)
{
return SYSCALL_CANCEL (read, fd, buf, nbytes);
}
libc_hidden_def (__libc_read)

libc_hidden_def (__read)
weak_alias (__libc_read, __read)
libc_hidden_def (read)
weak_alias (__libc_read, read)
酷熊的热心小贴士
glibc 的源代码(如上所示)可以在 此 git 存储库(https://sourceware.org/git/?p=glibc.git;a=summary) 或 此中找到非官方 GitHub 镜像(https://github.com/bminor/glibc)。
由于许多历史和实际原因,libc 是一个非常复杂的软件。还有其他流行的,例如 musl。

我们之所以找不到可读的代码是因为……它完全生活在另一片大陆上:

1

酷熊的热心小贴士
strace中的“s”正是代表了“系统调用(syscall)”。

它不仅仅是两套不同的软件。他们以不同的特权运行。 Linux 内核(及其设备驱动程序)在Ring 0中运行,这里一切都被允许。 然而,用户态应用程序在Ring 3中运行。

2

这个图太经典了,所以我不得不在这里展示它,但我不认为它非常符合直觉。我更喜欢这样:

3

因为你从Ring 0 可以做的事情是你在Ring 3可以做的事情的严格超集Ring 3就像一个监狱。Ring 0 中的任何人都可以访问,但Ring 3只能发送信件(即进行系统调用)。

4

所以内核处理诸如读取和写入之类的事情。但它也处理进程一类的事情。当我们启动应用程序时,它在进程和内核中运行 决定哪个进程运行以及何时运行。它中断进程并恢复它们, 给重要的更高的优先级 - 而且还给人一种错觉,即单个CPU核心以同时做多件事(当它实际上一次只能做一件事情时)。

酷熊的热心小贴士
现实要复杂得多。 CPU 核心做做多种事情 立刻,只是不是以一种容易观察的方式。

内核负责很多,但让我们重点关注文件。 进程具有与其关联的资源 - 例如文件描述符!当我们打开 一个文件(带有 open 系统调用),内核将会:

  • 决定是否允许这样做
  • 询问 VFS 谁负责这条特定路径
  • 保留一个文件描述符,即:
    • 只是一个数字,真的
    • 每个进程唯一的
  • 记下该编号对应于该资源
  • 告诉我们号码是多少

在我们与内核的进一步沟通中,每当我们想要 引用该资源,我们将只使用该号码。

这回答了您读这篇文章时此时可能想到的问题之一:在大多数程序的 strace 输出中,我们看到了对close的调用(这会关闭一个文件描述符-但在我们的示例C程序中,我们从来没有费心去调用它

这是因为内核以其无限的智慧记录了打开的文件描述符,并在进程退出后将它们全部清除

“这一切都有点理论化了”有人在后面低声说道。 “我很高兴 他没有向我们展示内核代码,但是……我们是否应该相信内核真的在清理文件描述符?”

好问题! 是列出特定路径的打开文件描述符的命令, 所以我们可以用一个简单的 C 程序快速验证这一点:

C code
// in open.c

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <fcntl.h>

int main(int argc, char **argv) {
int fd = open("/etc/hosts", O_RDONLY);

printf("Our file descriptor for /etc/hosts is %d\n", fd);
printf("Press enter to exit...\n");
getc(stdin);
}

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ gcc open.c -o open

$ lsof /etc/hosts 2> /dev/null

$ ./open
Our file descriptor for /etc/hosts is 3
Press enter to exit...
^Z
[1]+ Stopped ./open

$ lsof /etc/hosts 2> /dev/null
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
open 1917875 amos 3r REG 8,3 220 16253092 /etc/hosts

$ fg
./open


$ lsof /etc/hosts 2> /dev/null

我们学到了什么?
内核是万能的。它决定进程如何运行、管理访问 到所有设备(包括磁盘),并负责执行安全性。

常规函数调用只是“跳转”。到代码的另一部分。系统调用 不是常规函数调用。它们是Ring 3 之间的安全接口 (用户层,我们的应用程序)和Ring 0(内核)。

进行系统调用涉及将参数写入可访问的地方 用户态,并礼貌地要求内核考虑我们的请求。内核 可以出于各种原因拒绝它:该文件可能不存在,我们可能不存在 有权阅读它等

进行系统调用

我们需要消除潜在的混乱根源。我们看到了一个 read() 函数 在 glibc(大多数 Linux 发行版附带的 C 库)的源代码中, 但它与实际的 read 系统调用不同。

看起来 大多数 Unix 都是用 C 编写的,但是我们可以进行系统调用吗 不使用libc?像这样的事情:

5

嘿,Go 不是 C - Go 使用 libc 进行系统调用吗?让我们找出答案。

这是一个简单的 Go 程序的源代码,该程序打印 /etc/hosts的内容:

Go code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"io/ioutil"
"fmt"
)

func main() {
payload, err := ioutil.ReadFile("/etc/hosts")
if err != nil {
panic(err)
}

fmt.Printf("%s\n", string(payload))
}

它确实有效,但它似乎没有与 libc 链接:

Shell session

1
2
3
4
5
6
7
8
9
10

$ go build main.go

$ ./main | head -3
127.0.0.1 localhost
127.0.1.1 sonic


$ ldd main
not a dynamic executable
酷熊的热心小贴士
ldd是一个“打印共享对象依赖关系”的工具。

ldd 手册页(https://manpage.me/?q=ldd) 有更多信息。

在我们的例子中,确保我们的程序不使用 glibc。 Go 程序通常是静态链接的,除非它们 使用不起眼的包,例如 net 或 os/user。

好吧,我猜 Go 程序通常都是动态的,但你可以修复它(https://dominik.honnef.co/posts/2015/06/statically_compiled_go_programs__always__even_with_cgo__using_musl/)。

尝试在 gdb 中中断 read 也没有任何结果。好吧,我们能确定一下吗 它至少仍然使用 openat 和 read 系统调用?让我们追踪一下:

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ strace -e openat,read,write ./main
openat(AT_FDCWD, "/sys/kernel/mm/transparent_hugepage/hpage_pmd_size", O_RDONLY) = 3
read(3, "2097152\n", 20) = 8
--- SIGURG {si_signo=SIGURG, si_code=SI_TKILL, si_pid=1923780, si_uid=1000} ---
--- SIGURG {si_signo=SIGURG, si_code=SI_TKILL, si_pid=1923780, si_uid=1000} ---
openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 3
read(3, "127.0.0.1\tlocalhost\n127.0.1.1\tso"..., 512) = 220
read(3, "", 292) = 0
write(1, "127.0.0.1\tlocalhost\n127.0.1.1\tso"..., 221127.0.0.1 localhost
127.0.1.1 sonic

# The following lines are desirable for IPv6 capable hosts
::1 ip6-localhost ip6-loopback
fe00::0 ip6-localnet
ff00::0 ip6-mcastprefix
ff02::1 ip6-allnodes
ff02::2 ip6-allrouters

) = 221
+++ exited with 0 +++

确实如此!因此不需要 libc 来进行系统调用。终于解脱了。

我们学到了什么?
尽管在某些方面,Go 是比 C 更高级的语言 (它有一个垃圾收集器,它带有并发原语等), 它不依赖 libc 来进行系统调用。

这与 Node.js 运行时和 Rust 标准库形成对比,后者 两者都使用 libc 进行系统调用。

在 x86_64 上进行 Linux 系统调用

所以我们已经看到,几乎所有语言,无论使用多少层抽象,最终都将会以某种方式进行系统调用。

但是如何进行系统调用呢?到目前为止,我们一直在使用以下语言:

  • 使用 libc 通过包装函数(Node.js、Rust、C)进行系统调用
  • 手动构建系统调用 (Go)

让我们尝试在汇编中自己创建一个系统调用。

酷熊的热心小贴士
汇编是大多数编译程序成为完整可执行文件之前的中间形式之一。

用非常简单的话来说:C 编译器将 C 翻译为汇编,即汇编器 将汇编语言翻译成机器代码,链接器将多个汇编语言粘合在一起 将机器代码片段转换为可执行文件。

我们将使用yasm,因为其他的工具都让我血压升高。我们的代码将位于 readfile.asm 中,并且我们 将使用此 makefile 构建它:

1
2
3
4
5
6
Makefile
.PHONY: all

all:
yasm -f elf64 -g dwarf2 readfile.asm
ld readfile.o -o readfile

yasm 调用将我们的程序集组装成一个目标文件,并且 ld 调用将其链接到完整的可执行文件。

我们将从一个非常简单的程序开始,让我们将其放入readfile.asm:

x86 assembly

1
2
3
4
            global _start ; _start is our entry point - this is its declaration...

section .text ; the text section is where we'll put executable code
_start: xor rdi, rdi ; ...and this is its definition. we just set rdi to 0.
酷熊的热心小贴士
rdi是一个寄存器。

寄存器是 CPU 中的内存位置,可用于多种用途: 临时存储,将参数传递给函数,返回值 函数等。您可以将它们视为全局变量。

每个架构都有自己的一组寄存器。我们在 x86_64,所以我们将使用一些 64 位通用寄存器,如 rax、rdi、rsi。我们会 也可以使用堆栈指针,rsp。

编译和链接都很好,但是当我们运行它时会出现段错误:

Shell session

1
2
3
4
5
6
$ make
yasm -f elf64 -g dwarf2 readfile.asm
ld readfile.o -o readfile

$ ./readfile
Segmentation fault (core dumped)

为了让我们的程序干净地退出,我们需要..进行系统调用。

我们需要进行系统调用的第一件事是它的编号。以 Ubuntu 为例, 你可以在头文件中找到它:

C code
// in /usr/include/x86_64-linux-gnu/asm/unistd_64.h

1
2
3
// (cut)
#define __NR_exit 60
// (cut)

但是你也可以在网上找到一些系统调用表,比如 Filippo’s (https://filippo.io/linux-syscall-table/),可搜索。

我们需要做的第二件事是..数一下我们的幸运星,因为在 x86_64 上, 有一条专门的指令来进行系统调用。 (它被称为syscall)。

所以现在我们只需要将系统调用号 60 放入 rax 寄存器中, 并使用 syscall 指令,我们应该可以:

x86 assembly

1
2
3
4
5
6
            global _start

section .text
_start: mov rax, 60
syscall

Shell session

1
2
3
4
5
6
7
$ make
yasm -f elf64 -g dwarf2 readfile.asm
ld readfile.o -o readfile

$ ldd ./readfile
not a dynamic executable

就像这样,我们在不使用 libc 的情况下进行了系统调用!那并不难。

酷熊的热心小贴士
如果在没有 libc 的情况下进行系统调用并不困难,为什么这么多语言都使用 libc 进行系统调用?

嗯,在 x86_64 上进行 Linux 系统调用很容易。 32 位架构有 进行系统调用的不同方式。其他操作系统已经完全 不同的系统调用集。

您可以在 syscall(2) 的手册页中了解其中一些差异。 您可以使用 man 2 syscall 命令从 Linux 系统中提取它,或者 在线阅读(https://man7.org/linux/man-pages/man2/syscall.2.html#NOTES)

我们可以在汇编中重新实现整个readfile应用程序吗?让我们看看。

日志记录不会像使用 Node.js、Rust、C 或 Go 那样简单。所以我们是 必须稍微依赖调试器。感谢 -g dwarf2 标志 我们传递给 yasm,我们有很好的调试信息:

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ gdb --silent ./readfile
Reading symbols from ./readfile...
(gdb) break _start
Breakpoint 1 at 0x401000: file readfile.asm, line 4.
(gdb) r
Starting program: /home/amos/bearcove/read-files-the-hard-way/readfile

Breakpoint 1, _start () at readfile.asm:4
4 _start: mov rax, 60
(gdb) s
5 syscall
(gdb) s
[Inferior 1 (process 1843089) exited normally]
(gdb)

那么,让我们尝试一下 open 系统调用。它需要与 C 中相同的参数: 首先是一条路径,然后是一组标志。我们将路径存储在 data 部分。

x86 assembly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
            global _start

section .text
_start: mov rax, 2 ; "open" syscall
mov rdi, path ; arg 1: path
xor rsi, rsi ; arg 2: flags (0 = O_RDONLY)
syscall

mov rax, 60 ; "exit" syscall
syscall

section .data
path: db "/etc/hosts", 0 ; null-terminated

酷熊的热心小贴士
您不需要需要精通汇编才能阅读本文 - 最好 甚至接触我们不完全理解的语言。大多数人从来没有接受汇编的“正式培训”,只是多年来学了一些零碎的东西。

如果您想在继续之前了解更多汇编知识,您可能需要 查看 YASM 文档(https://yasm.tortall.net/Guide.html) 或此 首先是这个 NASM 教程(https://cs.lmu.edu/~ray/notes/nasmtutorial/)。

使用 gdb 逐步执行此操作,我们可以确保open 成功, 在另一个终端中使用 lsof:

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# GDB session
$ gdb --silent ./readfile
Reading symbols from ./readfile...
(gdb) starti
Starting program: /home/amos/bearcove/read-files-the-hard-way/readfile

Program stopped.
_start () at readfile.asm:4
4 _start: mov rax, 2 ; "open" syscall
(gdb) s
5 mov rdi, path ; arg 1: path
(gdb)
6 xor rsi, rsi ; arg 2: flags (0 = O_RDONLY)
(gdb)
7 syscall
(gdb)
9 mov rax, 60 ; "exit" syscall
(gdb)

Shell session

1
2
3
4
# In another shell
$ lsof /etc/hosts
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
readfile 1846055 amos 3r REG 8,3 220 16253092 /etc/hosts

我们还可以在生成的二进制文件上使用strace。它显示是否有系统调用 成功或失败,这样效果就很好:

Shell session

1
2
3
4
5
$ strace ./readfile 
execve("./readfile", ["./readfile"], 0x7fffb827f0f0 /* 63 vars */) = 0
open("/etc/hosts", O_RDONLY) = 3
exit(4202496) = ?
+++ exited with 0 +++

糟糕,看起来我们没有以状态代码 0 退出,让我们解决这个问题:

x86 assembly

1
2
3
mov     rax, 60
xor rdi, rdi ; <--- exit with code 0
syscall

这样更好。现在让我们尝试从此文件描述符中读取一些字节。 与所有其他系统调用一样,open 的返回值存储在 rax 中。

我们将使用 rax 进行下一个系统调用,因此我们需要 使用 push 将其保存到堆栈中。另外,我们还需要分配内存 我们的缓冲区 - 让我们在堆栈上分配 16 个字节。

x86 assembly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
global _start

section .text
_start: mov rax, 2 ; "open"
mov rdi, path ;
xor rsi, rsi ; O_RDONLY
syscall

push rax ; push file descriptor onto stack
sub rsp, 16 ; reserve 16 bytes of memory

xor rax, rax ; "read"
mov rdi, [rsp+16] ; file descriptor
mov rsi, rsp ; address of buffer
mov rdx, 16 ; size of buffer
syscall

mov rax, 60 ; "exit" syscall
syscall

section .data
path: db "/etc/hosts", 0 ; null-terminated
酷熊的热心小贴士
堆栈是我们可以用来存储数据的区域。虽然尺寸有限,但是 比堆更容易使用。

为了保留内存,我们只需从 rsp 中减去,该寄存器包含 “堆栈顶部”的地址。

这是一个方便的图表,显示了我们调用 read 之前堆栈发生的情况:

6

到目前为止,运行我们的程序仍然没有打印任何内容,但 strace 让我们知道 一切顺利:

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
$ make
yasm -f elf64 -g dwarf2 readfile.asm
ld readfile.o -o readfile

$ ./readfile

$ strace ./readfile
execve("./readfile", ["./readfile"], 0x7ffc3dfee7d0 /* 60 vars */) = 0
open("/etc/hosts", O_RDONLY) = 3
read(3, "127.0.0.1\tlocalh", 16) = 16
exit(3) = ?
+++ exited with 3 +++

现在让我们使用 write 调用将该缓冲区打印到标准输出。

x86 assembly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
xor     rax, rax    ; "read"
mov rdi, [rsp+16] ; file descriptor
mov rsi, rsp ; address of buffer
mov rdx, 16 ; size of buffer
syscall

; `rax` contains the number of bytes read
; write takes the number of bytes to write via `rdx`
mov rdx, rax ; number of bytes
mov rax, 1 ; "write"
mov rdi, 1 ; file descriptor (stdout)
mov rsi, rsp ; address of buffer
syscall

我们终于看到了一些输出:

Shell session

1
2
3
4
5
6
7
$ make
yasm -f elf64 -g dwarf2 readfile.asm
ld readfile.o -o readfile

$ ./readfile
127.0.0.1 localh

最后我们只需要重复读写,直到read返回0 读取的字节数。 (我们不会进行任何错误检查)。

这是我们的最终程序:

x86 assembly

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
            global _start

section .text
_start: mov rax, 2 ; "open"
mov rdi, path ;
xor rsi, rsi ; O_RDONLY
syscall

push rax ; push file descriptor onto stack
sub rsp, 16 ; reserve 16 bytes of memory

read_buffer:
xor rax, rax ; "read"
mov rdi, [rsp+16] ; file descriptor
mov rsi, rsp ; address of buffer
mov rdx, 16 ; size of buffer
syscall

test rax, rax
; jz means 'jump if zero'
jz exit

mov rdx, rax ; number of bytes
mov rax, 1 ; "write"
mov rdi, 1 ; file descriptor (stdout)
mov rsi, rsp ; address of buffer
syscall

jmp read_buffer

exit:
mov rax, 60 ; "exit"
xor rdi, rdi ; return code 0
syscall

section .data
path: db "/etc/hosts", 0 ; null-terminated
我们学到了什么?
在 Linux x86_64 上进行系统调用涉及将值放入某些寄存器中, 然后使用 syscall 指令。

我们可以使用栈(向下增长)作为临时存储空间。


酷熊的热心小贴士
本文中有关系统调用的信息是仅限于Linux平台的。

例如,Windows 系统调用编号会随着操作系统版本的不同而变化 - 有时 甚至服务包。如果您好奇,看看这个表。

虽然可以在不使用操作系统库的情况下进行系统调用,但它是 并不总是实用。

读书累了?
我们不都是这样吗?换个视频怎么样?

内存映射文件
让我们尝试在另一个程序上使用 strace: ripgrep。

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ strace rg 'localhost' /etc/hosts
(cut)
openat(AT_FDCWD, "/etc/hosts", O_RDONLY|O_CLOEXEC) = 3
statx(3, "", AT_STATX_SYNC_AS_STAT|AT_EMPTY_PATH, STATX_ALL, {stx_mask=STATX_ALL|STATX_MNT_ID, stx_attributes=0, stx_mode=S_IFREG|0644, stx_size=220, ...}) = 0
mmap(NULL, 220, PROT_READ, MAP_SHARED, 3, 0) = 0x7fd3a694a000
write(1, "\33[0m\33[32m1\33[0m:127.0.0.1\t\33[0m\33[1"..., 511:127.0.0.1 localhost) = 51
write(1, "\n", 1
) = 1
write(1, "\33[0m\33[32m5\33[0m:::1 ip6-\33[0m\33"..., 665:::1 ip6-localhost ip6-loopback) = 66
write(1, "\n", 1
) = 1
munmap(0x7fd3a694a000, 220) = 0
close(3) = 0
(cut)

我们识别 openat 系统调用,也识别 statx - 但是..它不使用read。这里发生了什么?

好吧,还记得我们说过内核是全能的监督者吗? 控制用户空间交互的一切?这也适用于内存!

在 Linux 这样的操作系统中,每个进程都有自己的虚拟地址空间(virtual address space)。其中一些通过MMU(全称内存管理单元)内存管理映射到物理内存。

7

酷熊的热心小贴士
物理内存被划分为“页”,以便更容易寻址。页数 通常是 4KiB,但并非总是如此!

例如,Apple Silicon 处理器(例如 M1)有 16KiB 页。

当进程启动时,会为其堆栈保留一些页面。当在堆上分配内存时,例如使用 malloc, glibc 的分配器向内核请求更多页面,并跟踪所有页面分配,以便free()正常工作。

首先,让我们检查每个进程是否确实有一个单独的地址空间。

我们可以制作第一个程序,write.c:

C code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv) {
// allocate 4 bytes
int *ptr = malloc(sizeof(int));
// write a very specific value to it
*ptr = 0xFEEDFACE;
// read back the value, and print the address
printf("Wrote %x to %p\n", *ptr, ptr);
// wait for user input
getc(stdin);
}

该程序很有可能打印出不同的内容 每次都输入地址,但是当我运行它时,它打印出以下内容:

Shell session

1
2
3
4
$ gcc write.c -o write
$ ./write
Wrote feedface to 0x56459a9c7260

我们可以用它来编写第二个程序,read.c:

C code

1
2
3
4
5
6
7
8
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv) {
int *ptr = (int *) 0x56459a9c7260;
printf("Read %x to %p\n", *ptr, ptr);
}

并运行它:

1
2
3
$ gcc read.c -o read
$ ./read
[1] 6429 segmentation fault (core dumped) ./read

发生了什么? 0x56459a9c7260 是 write 虚拟地址中的有效地址 地址空间,但不在 read 中。尝试从中读取数据是一种访问 违规,这会导致内核向我们的进程发送信号,并且 该信号的默认处理程序会终止该进程。

酷熊的热心小贴士
我们在第 1 部分中使用它来让我们的 stdio 支持的 C 程序出现段错误!

访问冲突只是一种缺页(page fault)。当我们尝试读取或写入当前不是的(虚拟)地址时 映射到物理内存就会发生缺页。

这正是mmap背后的技巧。当我们第一次映射一个文件时, 内核可能会急切地将文件的前4K读入自己的缓冲区中, 并设置页表以便(用户态)进程可以直接读取该缓冲区:

8

但是一旦进程读取过去第一个 4K,那就是缺页!

请记住,内核可以做任何事情来响应页面错误:它可能确定这是访问冲突,并向进程发送信号。在这种情况下,它选择简单地履行其承诺“这个虚拟地址范围包含文件的内容”,只是……直到需要它为止。

9

文件的请求部分被实际读取,设置一个新的页面映射:

内核对于一段时间没有被访问过的页,当然可以自由地“调出”页面(或者只要它想要,真的!)。

酷熊的热心小贴士
执行程序时,其映像是内存映射的。

这允许程序在从磁盘完全读取出来之前开始执行,尤其当可执行文件很大,或者 I/O 设备很慢时,这点非常重要。

在汇编中使用mmap

我们可以在汇编程序中使用它吗?我们当然可以!

因为我们不确定mmap需要什么参数(以及要放置哪些寄存器) ),我们将使用这个 x86 的可搜索 Linux 系统调用表 x86_64 通过 @FiloSottile。

首先,像往常一样,我们需要打开文件:

x86 assembly

1
2
3
4
5
_start:     mov     rax, 2          ; "open"
mov rdi, path ;
xor rsi, rsi ; O_RDONLY
syscall

接下来,我们想要找到文件的大小(以字节为单位),因此我们可以通过 它到 mmap。我们将使用 fstat 系统调用来实现此目的,该系统调用需要 fd (a 文件描述符)在寄存器 rdi 中,以及 struct stat __user* statbuf 在寄存器rsi中。

为了帮助我编写下一部分,我编写了一个简单的 C 程序来转储 结构体的大小,以及 st_size 字段的偏移量,以及两个 常量:

C code

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <stddef.h>
#include <sys/stat.h>
#include <sys/mman.h>

int main() {
printf("size of stat struct: %zu\n", sizeof(struct stat));
printf("offset of st_size : %zu\n", offsetof(struct stat, st_size));
printf("PROT_READ = 0x%x\n", PROT_READ);
printf("MAP_PRIVATE = 0x%x\n", MAP_PRIVATE);
}

哪个输出:

1
2
3
4
5
size of stat struct: 144
offset of st_size : 48
PROT_READ = 0x1
MAP_PRIVATE = 0x2

因此,看起来我们需要在堆栈上分配 144 个字节:

x86 assembly

1
2
3
4
5
mov     rdi, rax        ; fd (returned from open)
sub rsp, 144 ; allocate stat struct
mov rsi, rsp ; address of 'struct stat'
mov rax, 5 ; "fstat" syscall
syscall

然后我们可以将文件描述符、文件大小和标志提供给 mmap。 请注意,我们可以指定一个地址(但 NULL 也可以)和一个偏移量 (但是 0 就可以了,因为我们想要整个文件)。

酷熊的热心小贴士
mmap 系统调用需要:

addr in %rdi
len in %rsi
prot(protection)in %rdx
flags in %r10
fd in %r8
off(offset)in %r9

x86 assembly

1
2
3
4
5
6
7
8
9
10
mov     rsi, [rsp+48]   ; len = file size (from 'struct stat')
add rsp, 144 ; free 'struct stat'
mov r8, rdi ; fd (still in rdi from last syscall)
xor rdi, rdi ; address = 0
mov rdx, 0x1 ; protection = PROT_READ
mov r10, 0x2 ; flags = MAP_PRIVATE
xor r9, r9 ; offset = 0
mov rax, 9 ; "mmap" syscall
syscall

最后,我们可以在一个 write 系统调用中写出整个文件:

酷熊的热心小贴士
write 系统调用需要:

fd in %rdi
buf in %rsi
count in%rdx

x86 assembly

1
2
3
4
5
6
mov     rdx, rsi        ; count (file size from last call)
mov rsi, rax ; buffer address (returned from mmap)
mov rdi, 1 ; fd = stdout
mov rax, 1 ; "write" syscall
syscall

我们终于得到它了:

Shell session

1
2
3
4
5
6
7
8
9
10
11
12
13
$ make
yasm -f elf64 -g dwarf2 readfile.asm
ld readfile.o -o readfile

$ strace ./readfile > /dev/null
execve("./readfile", ["./readfile"], 0x7ffffc6ddbf0 /* 60 vars */) = 0
open("/etc/hosts", O_RDONLY) = 3
fstat(3, {st_mode=S_IFREG|0644, st_size=220, ...}) = 0
mmap(NULL, 220, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f11fbe5a000
write(1, "127.0.0.1\tlocalhost\n127.0.1.1\tso"..., 220) = 220
exit(0) = ?
+++ exited with 0 +++

我们学到了什么?
进程的地址空间指的是虚拟内存,那么 通过页表映射到物理内存。当访问未映射的范围时, 它会导致页面错误。

我们可以通过mmap将它们映射到虚拟地址空间,而不是使用`read`读文件。在mmap的范围中read将会使内核实际读那部分文件。

可执行文件在运行时是内存映射的(即使在 Windows 上)。

在下一部分中,我们将看看内核内部,看看如何 文件的组织和读取 - 以及我们如何通过使用来查找和读取它们 这些知识,我们可以绕过尽可能多的抽象。