磁盘就是一堆 bits (2023)
Dmitry Mazin
"cyberdemon.org is a cool domain"
home / email me / bluesky / mastodon / RSS feed / Telegram channel
磁盘就是一堆 bits
Jul 19, 2023
引言
你有没有听人说过磁盘或内存是“一堆 bits”?
我不确定这个想法的起源,但这是一个非常好的想法。它减少了计算机的神秘感。例如,它排除了我的电脑里住着一个非常扁平的精灵的理论。
不,里面是 bits,编码在电子元件上。
亲密接触 bits
然而,计算机仍然相当神秘。这些 bits _是_什么?它们意味着什么?我们可以玩它们、解析它们、理解它们吗?
在这篇文章中,我将向你展示,是的,我们绝对可以!为了你的娱乐,我将把我的手伸进我的电脑,取出一堆 bits,我们将检查并理解它们。
具体来说,我们应该探索哪些 bits?对于这个练习,让我们剖析一下磁盘支持的文件是如何在磁盘上表示的。
假设我们有一个名为 /data/example.txt
的文件:
$ cat /data/example.txt
Hello, world!
这里有一个大问题:“Hello, world!” 住在哪里?
此外,你可能知道文件具有权限(例如,文件是可执行的)、所有者、创建时间戳等。这些元数据存储在哪里?
我的意思是,从字面上看,存储这些信息的实际 bits 在哪里?让我们找到它们并尝试解析它们。
首先,来一点理论。
文件是如何工作的?
以下内容适用于 Linux 中常用的 ext4 文件系统(事实上,整篇文章都是 ext4 特定的)。这些概念在大多数文件系统中都很常见。
/data/example.txt
到底是什么?我们称之为_目录条目_。目录条目只是一个人类可读的名称——example.txt
。
目录条目存储在磁盘上,但它们不是非常有趣,因为它们只是名称。
名称 命名 某物,对吧?example.txt
命名什么?它命名的东西叫做 inode。
inodes 是 有趣的。当你说“文件存在于磁盘上”时,你真正想说的是“inode 存在于磁盘上”。它们是描述文件的磁盘上的 bits 集合。
一个 inode 存储了几乎所有关于文件的信息,比如我们之前提到的元数据。
我们的理论快完成了。你还应该知道,inodes、文件和目录条目都是所谓的_文件系统_的元素。文件系统是一种软件,它将磁盘上的 bits 转换成你熟悉的文件和目录。
有了这些,我们就可以开始动手了。
探索 inodes
让我们从列出一些 inode 元数据开始我们的探索。为此,我们可以使用 stat
。
$ stat /data/example.txt
File: /data/example.txt
Size: 14 Blocks: 8 IO Block: 4096 regular file
Device: 831h/2097d Inode: 11 Links: 1
Access: (0664/-rw-rw-r--) Uid: ( 1000/ dmitry) Gid: ( 1000/ dmitry)
Access: 2023-07-18 13:53:20.808536879 +0100
Modify: 2023-07-10 15:18:48.199691583 +0100
Change: 2023-07-18 14:52:26.349625767 +0100
Birth: 2023-07-10 15:18:48.199691583 +0100
不要过于努力地理解所有的输出。只需注意,你可以看到文件大小、所有者和时间戳等元数据。你看到的一切都来自 inode (除了名称,它来自目录条目[此外,inode 号码本身])。
探索 inode 的内部
但是我们想看到这个 inode 的原始 bits,对吧?我们如何才能看到原始 bits?
长期内核开发者 Ted Ts’o 维护了一套名为 e2fsprogs 的文件系统调试工具。我们可以使用其中的一个工具,debugfs,来玩转 inode。
debugfs 有一个很棒的命令,可以吐出一个 inode 的原始二进制文件。来自 manpage:
inode_dump filespec
Print the contents of the inode data structure in hex and
ASCII format.
所以,我承诺的原始二进制文件来了。除了,它不会像 0011000 这样的二进制文件。当二进制文件被转换成一种叫做 hexadecimal 的表示形式时,人类更容易阅读二进制文件,所以我们将使用它。
$ sudo debugfs /dev/sdd1
debugfs: inode_dump example.txt
0000 b481 e803 0e00 0000 408b b664 1a99 b664 ........@..d...d
0020 4813 ac64 0000 0000 e803 0100 0800 0000 H..d............
0040 0000 0800 0100 0000 0af3 0100 0400 0000 ................
0060 0000 0000 0000 0000 0100 0000 0082 0000 ................
0100 0000 0000 0000 0000 0000 0000 0000 0000 ................
*
0140 0000 0000 9933 e68b 0000 0000 0000 0000 .....3..........
0160 0000 0000 0000 0000 0000 0000 2349 0000 ............#I..
0200 2000 5e0c 9c76 5b53 fc34 9c2f bc2c c5c0 .^..v[S.4./.,..
0220 4813 ac64 fc34 9c2f 0000 0000 0000 0000 H..d.4./........
0240 0000 0000 0000 0000 0000 0000 0000 0000 ................
*
有道理吗?酷——感谢阅读!
开玩笑的。看,实际上看到 inode 的原始数据有点酷,但我们仍然不知道这个东西_在_磁盘上的哪个位置,也不知道这些 bits 实际意味着什么。
我的 inode 在磁盘上的哪个位置?
所以,让我们在磁盘上找到原始 inode。为此,我们可以再次使用 debugfs:
imap filespec
Print the location of the inode data structure (in the inode table) of the
inode filespec.
所以,让我们找到位置。
debugfs: imap example.txt
Inode 11 is part of block group 0
located at block 73, offset 0x0a00
让我来解读一下:一个文件系统被分成多个块。在我的例子中,一个块是 4,096 字节(这是许多 Linux 发行版的默认值)。所以,这个输出是在说“从文件系统的开头开始,向前走 73 个块,也就是 73*4096 字节”。这有点像告诉我们 inode 所在的街道。门牌号是偏移量:0x0a00
字节。在十进制中,这是 2560 字节 [为什么是 2560?]。
所以,要找到我们的 inode,我们需要从磁盘分区(也就是文件系统的开头)开始,然后跳过 4096 * 73 + 2560 = 301568
字节。
让我们来做吧!让我们从我的磁盘中转储原始 bits,看看它们是否与 debugfs inode_dump
输出匹配。
$ sudo dd if=/dev/sdd1 bs=1 skip=301568 count=256 2>/dev/null | hexdump -C
00000000 b4 81 e8 03 0e 00 00 00 40 8b b6 64 1a 99 b6 64 |........@..d...d|
00000010 48 13 ac 64 00 00 00 00 e8 03 01 00 08 00 00 00 |H..d............|
00000020 00 00 08 00 01 00 00 00 0a f3 01 00 04 00 00 00 |................|
00000030 00 00 00 00 00 00 00 00 01 00 00 00 00 82 00 00 |................|
00000040 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000060 00 00 00 00 99 33 e6 8b 00 00 00 00 00 00 00 00 |.....3..........|
00000070 00 00 00 00 00 00 00 00 00 00 00 00 23 49 00 00 |............#I..|
00000080 20 00 5e 0c 9c 76 5b 53 fc 34 9c 2f bc 2c c5 c0 | .^..v[S.4./.,..|
00000090 48 13 ac 64 fc 34 9c 2f 00 00 00 00 00 00 00 00 |H..d.4./........|
000000a0 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00000100
右边的 ASCII 解释在这里派上用场:从视觉上看,我们可以看到这些东西看起来与 debugfs inode_dump
中的原始 bits 完全相同!我们找到了 inode 在磁盘上的位置!
这比 inode_dump
输出感觉酷多了。在那种情况下,我们要求一位核心内核开发者编写的程序告诉我们 inode 是什么样子的。在这种情况下,我们自己直接在磁盘上找到了信息。
但我们仍然不知道这些 bits 的含义。我们可以解析它们吗?
商业插播
如果你很享受,我可以问你是否愿意通过 RSS feed、Mastodon 或 Telegram channel 关注我吗?谢谢!
理解原始 bits
几个星期以来,我一直在思考这个问题。我们如何让计算机将一堆 bits 变成一个 inode?
然后我突然想到:这正是 struct 的用途!
你可能遇到过 structs。它们有点像动态语言中的对象,只不过令人困惑。
好吧,这就是我现在对 structs 的看法:假设你遇到了一堆 bits。一个 struct 只是对这些 bits 的含义的规范。
有了这个认识,Linux 内核必须在某个地方定义一个 inode 的 struct,对吧?它确实有!
/*
* Structure of an inode on the disk
*/
struct ext4_inode {
__le16 i_mode; /* File mode */
__le16 i_uid; /* Low 16 bits of Owner Uid */
__le32 i_size_lo; /* Size in bytes */
/* ... */
}
为了效果,我再说一遍:这个 struct 正是 ext4 文件系统知道如何解析我们之前看到的 bits 的方式!这是万能钥匙。
它基本上说:前 16 bits 是文件权限,接下来的 16 bits 是所有者,接下来的 32 bits 是文件大小,等等 [因为填充,这有点复杂]。
让我们使用这个 struct 来解析之前的原始 bits!
我们将编写一个小的 C 程序,它将执行以下操作。
- 要求计算机在内存中预留 256 字节(因为这就是一个 ext4_inode struct 的大小)。
- 要求它从
/dev/sdd1/
中的位置 301568 复制 256 字节到该内存中。 - 使用我们的 ext4_inode struct 告诉它如何解析这些字节。
这是上面的 C 程序(精简到它的本质)。
// open the partition file
int fd = open("/dev/sdd1/", O_RDONLY);
// seek to the inode location
lseek(fd, 301568, SEEK_SET);
// initialize the struct and copy 256 bytes from disk to memory
struct ext4_inode candidate_inode;
read(fd, &candidate_inode, sizeof(struct ext4_inode));
// now we can access the fields of the inode!
printf("User: %u", inode->i_uid);
这是完整的程序,带有错误检查。如果你想,你可以构建它并在你自己的电脑上尝试它。
让我们运行它!你兴奋吗?!如果这可行,那就意味着我们已经掌握了 bits。我们已经弄清楚了它们的结构。
$ sudo ./parse /dev/sdd1 301568
Inode: 11 Mode: 0664
User: 1000 Group: 1000 Size: 14
Links: 1 Blockcount: 8
Inode checksum: 0x0c5e4923
耶!我们得到了一个有效的 inode!
为了验证这个输出是否有意义,这里是 debugfs stat example.txt
的输出。看,每个公共字段——重要的是校验和——都匹配!
debugfs: stat example.txt
Inode: 11 Type: regular Mode: 0664 Flags: 0x80000
Generation: 2347119513 Version: 0x00000000:00000001
User: 1000 Group: 1000 Project: 0 Size: 14
File ACL: 0
Links: 1 Blockcount: 8
Fragment: Address: 0 Number: 0 Size: 0
ctime: 0x64b6991a:535b769c -- Tue Jul 18 14:52:26 2023
atime: 0x64b68b40:c0c52cbc -- Tue Jul 18 13:53:20 2023
mtime: 0x64ac1348:2f9c34fc -- Mon Jul 10 15:18:48 2023
crtime: 0x64ac1348:2f9c34fc -- Mon Jul 10 15:18:48 2023
Size of extra inode fields: 32
Inode checksum: 0x0c5e4923
EXTENTS:
(0):33280
对我来说,这非常酷。我们着手在磁盘上找到一个 inode 的原始 bits,找到了它们,然后通过一个将它们加载到内存中并应用一个 struct 的程序来解析它们,然后转身在内存中看到了相同的 bits。
内存也是一堆 bits
现在,我们还没有完成。
一开始,我说磁盘 和内存 都是一堆 bits。我们的程序将原始 inode bits 复制到内存中,对吧?
这意味着我们应该能够在内存中找到这些 bits,并确认它们与来自磁盘的 bits 相同 [关于 struct 填充的说明]!
为此,让我们在一个名为 gdb
的调试器中运行我们的程序(有点像 Python 的 pdb
)。我们将使用它来暂停程序的进程,然后监视进程的内存。
$ sudo gdb parse
(gdb) break 167
(gdb) run /dev/sdd1 301568
(gdb) x/160xb &candidate_inode
0x7fffffffe410: 0xb4 0x81 0xe8 0x03 0x0e 0x00 0x00 0x00
0x7fffffffe418: 0x40 0x8b 0xb6 0x64 0x1a 0x99 0xb6 0x64
0x7fffffffe420: 0x48 0x13 0xac 0x64 0x00 0x00 0x00 0x00
0x7fffffffe428: 0xe8 0x03 0x01 0x00 0x08 0x00 0x00 0x00
0x7fffffffe430: 0x00 0x00 0x08 0x00 0x01 0x00 0x00 0x00
0x7fffffffe438: 0x0a 0xf3 0x01 0x00 0x04 0x00 0x00 0x00
0x7fffffffe440: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffe448: 0x01 0x00 0x00 0x00 0x00 0x82 0x00 0x00
0x7fffffffe450: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffe458: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffe460: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffe468: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffe470: 0x00 0x00 0x00 0x00 0x99 0x33 0xe6 0x8b
0x7fffffffe478: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffe480: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
0x7fffffffe488: 0x00 0x00 0x00 0x00 0x23 0x49 0x00 0x00
0x7fffffffe490: 0x20 0x00 0x5e 0x0c 0x9c 0x76 0x5b 0x53
0x7fffffffe498: 0xfc 0x34 0x9c 0x2f 0xbc 0x2c 0xc5 0xc0
0x7fffffffe4a0: 0x48 0x13 0xac 0x64 0xfc 0x34 0x9c 0x2f
0x7fffffffe4a8: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00
它不是最容易阅读的东西,所以我使用了一个 script 来使 gdb 输出看起来更像 hexdump -C
:
b4 81 e8 03 0e 00 00 00 40 8b b6 64 1a 99 b6 64
48 13 ac 64 00 00 00 00 e8 03 01 00 08 00 00 00
00 00 08 00 01 00 00 00 0a f3 01 00 04 00 00 00
00 00 00 00 00 00 00 00 01 00 00 00 00 82 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
00 00 00 00 99 33 e6 8b 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 23 49 00 00
20 00 5e 0c 9c 76 5b 53 fc 34 9c 2f bc 2c c5 c0
48 13 ac 64 fc 34 9c 2f 00 00 00 00 00 00 00 00
让我们将其与磁盘中的原始 bits 进行比较:
b4 81 e8 03 0e 00 00 00 40 8b b6 64 1a 99 b6 64
48 13 ac 64 00 00 00 00 e8 03 01 00 08 00 00 00
00 00 08 00 01 00 00 00 0a f3 01 00 04 00 00 00
00 00 00 00 00 00 00 00 01 00 00 00 00 82 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
*
00 00 00 00 99 33 e6 8b 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 23 49 00 00
20 00 5e 0c 9c 76 5b 53 fc 34 9c 2f bc 2c c5 c0
48 13 ac 64 fc 34 9c 2f 00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
它们匹配!
(星号只是意味着该行全部为 0。敏锐的观察者还会注意到 gdb
输出中缺少最后的 16 字节的 0 - 我认为是由于编译器优化)。
这表明磁盘上的 bits 和内存中的 bits 是相同的。回想起来,这可能很明显,但我们已经亲眼看到了。
嘿,我的文件内容在哪里?
我知道你在想什么:我们还没有真正看到文件内容!
没错。inode 本身不存储文件内容。它们在其他地方。
请允许我简要解释一下原因。你可以将文件系统看作具有两个组件:一堆用于放置文件内容的盒子,以及一个用于管理这些盒子的数据库。这有点像一个分布式系统,你将记录存储在数据库中(所有元数据),但你将实际的文件 blob 放在 S3 或磁盘之类的东西中。
所以,inode 实际上并不保存内容;它指向它们。
让我们使用 debugfs 从 inode 中解析位置。来自 manpage:
blocks filespec
Print the blocks used by the inode filespec to stdout.
debugfs: blocks example.txt
33280
这说明内容是从文件系统开始的 33,280 个 4 KiB 块 [我们能直接从 inode struct 中获得位置吗?]
让我们在该位置转储磁盘!
$ sudo dd if=/dev/sdd1 skip=33280 bs=4096 count=1 2>/dev/null | hexdump -C
00000000 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 0a 00 00 |Hello, world!...|
00000010 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 |................|
*
00001000
它就在那里!我们的 hello world,从磁盘中新鲜出炉!
我们学到了什么?
所以,我们学到了什么?我们做了什么?
我们从一个常见的说法开始,即磁盘和内存只是一堆 bits。
所以我们开始了一个熟悉这些 bits 的探索之旅。具体来说,是编码磁盘支持的文件的 bits:inodes。
我们非常熟悉这些 bits:我们在磁盘上找到了它们,使用一个将它们加载到内存中并应用一个 struct 的程序来解析它们,然后转身在内存中看到了相同的 bits。
一路走来,我们也了解了一些关于 ext4 文件系统(以及一般的文件系统)的知识!
当我为自己执行这个练习时,这是我在计算机上获得的最具启发性的体验之一。它减少了神秘感。我希望对你来说,神秘感也减少了。
Notes
此外,inode 号码本身
好吧,此外,inode 号码本身(在本例中为 11)也没有存储在 inode 中。相反,它是 inode 在 inode 表中的位置。 (back)
为什么是 2560?
回想一下,这个 inode 的号码是 11。这意味着,在磁盘上,在这个 inode 之前有 10 个 inodes。每个 inode 是 256 字节,所以这些 inodes 占用 2560 字节。 (back)
因为填充,这有点复杂
从技术上讲,编译器会填充 struct,这意味着它会在整个 struct 中插入空的空间。所以,从这个意义上说,struct 并没有_完全_指定 bits 的顺序。但是,鉴于 inode 是在同一台计算机上生成的,它将在该计算机上读取,这意味着 struct 确实是看似随机的 bits 的万能钥匙。 (back)
关于 struct 填充的说明
早些时候,我提到编译器会填充 struct,在字段之间添加额外的字节。这将使内存中的表示形式难以与磁盘上的表示形式进行比较,所以我通过将 __attribute__((__packed__))
附加到 struct 定义来尽可能地阻止填充。这就是为什么在我粘贴的内存转储中,我们只打印了 160 字节 - 这是禁用填充时 sizeof(struct ext4_inode)
的值。 (back)
我们能直接从 inode struct 中获得位置吗?
我们还可以通过 i_block
字段从我们加载到内存中的 inode 中解析位置,但内容是一个稍微隐秘的数组,需要一些代码才能解码。只是调用 debugfs 代码来为我们做这件事更容易。对于好奇的人,这是该数组的样子:
(gdb) p candidate_inode->i_block
$1 = {127754, 4, 0, 0, 1, 33280, 0, 0, 0, 0, 0, 0, 0, 0, 0}
你可以在该数组中看到来自 debugfs 输出的 33280。 (back) 如果你读到这里...
- 关注我的 Telegram channel.
- 关注我的 RSS feed
- 在 Mastodon 或 Bluesky 上关注我
请随意阅读我的其他文章。
- [Demystifying git submodules](https://