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 feedMastodonTelegram 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 程序,它将执行以下操作。

  1. 要求计算机在内存中预留 256 字节(因为这就是一个 ext4_inode struct 的大小)。
  2. 要求它从 /dev/sdd1/ 中的位置 301568 复制 256 字节到该内存中。
  3. 使用我们的 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) 如果你读到这里...

请随意阅读我的其他文章。