原文链接 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 # 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 # 6 std::io::append_to_string<std::io::default_read_to_string::{closure_env # 7 std::io::default_read_to_string<std::fs::File> () at library/std/src/io/mod.rs:430 # 8 std::fs::{impl # 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 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。
我们之所以找不到可读的代码是因为……它完全生活在另一片大陆上:
酷熊的热心小贴士
strace中的“s”正是代表了“系统调用(syscall)”。
它不仅仅是两套不同的软件。他们以不同的特权运行。 Linux 内核(及其设备驱动程序)在Ring 0
中运行,这里一切都被允许。 然而,用户态应用程序在Ring 3
中运行。
这个图太经典了,所以我不得不在这里展示它,但我不认为它非常符合直觉。我更喜欢这样:
因为你从Ring 0
可以做的事情是你在Ring 3
可以做的事情的严格超集 。Ring 3
就像一个监狱。Ring 0
中的任何人都可以访问,但Ring 3
只能发送信件(即进行系统调用)。
所以内核处理诸如读取和写入之类的事情。但它也处理进程一类的事情。当我们启动应用程序时,它在进程和内核中运行 决定哪个进程运行以及何时运行。它中断进程并恢复它们, 给重要的更高的优先级 - 而且还给人一种错觉,即单个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 3 r 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?像这样的事情:
嘿,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 mainimport ( "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 之前堆栈发生的情况:
到目前为止,运行我们的程序仍然没有打印任何内容,但 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(全称内存管理单元)内存管理映射到物理内存。
酷熊的热心小贴士
物理内存被划分为“页”,以便更容易寻址。页数 通常是 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) { int *ptr = malloc (sizeof (int )); *ptr = 0xFEEDFACE ; printf ("Wrote %x to %p\n" , *ptr, ptr); 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读入自己的缓冲区中, 并设置页表以便(用户态)进程可以直接读取该缓冲区:
但是一旦进程读取过去第一个 4K,那就是缺页!
请记住,内核可以做任何事情来响应页面错误:它可能确定这是访问冲突,并向进程发送信号。在这种情况下,它选择简单地履行其承诺“这个虚拟地址范围包含文件的内容”,只是……直到需要它为止。
文件的请求部分被实际读取,设置一个新的页面映射:
内核对于一段时间没有被访问过的页,当然可以自由地“调出”页面(或者只要它想要,真的!)。
酷熊的热心小贴士
执行程序时,其映像是内存映射的。
这允许程序在从磁盘完全读取出来之前开始执行,尤其当可执行文件很大,或者 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 上)。
在下一部分中,我们将看看内核内部,看看如何 文件的组织和读取 - 以及我们如何通过使用来查找和读取它们 这些知识,我们可以绕过尽可能多的抽象。