Доводилось ли вам слышать утверждение, что диск или память — это «просто куча битов?»
Не знаю точно, откуда эта идея пошла, но она вполне разумна и в некоторой степени рассеивает таинственный ореол вокруг компьютеров. Например, она опровергает теорию о том, что внутри моего ПК живёт очень плоский эльф.
Оказывается нет, в нём находятся биты, закодированные в электрических компонентах.
И всё же компьютеры по-прежнему хранят в себе загадочность. Что это за биты? Что они означают? Можем ли мы с ними поиграться, спарсить их, понять?
Далее я покажу вам, что всё это определённо возможно! Ради вашего развлечения я засуну руку в свой ПК, вытащу оттуда кучку битов, и мы их с вами изучим.
Но какие конкретно биты будет лучше изучить? Для этой задачи мы разберём способ представления файла на диске.
Предположим, у нас есть файл /data/example.txt
:
$ cat /data/example.txt
Hello, world!
И здесь возникает большой вопрос: А где находится «Hello, world!»?
Помимо прочего, вы наверняка знаете, что у файлов есть разрешения (например, файл может быть исполняемым), владелец, временная метка создания и так далее. Где же хранятся эти метаданные?
Я имею в виду буквально, где находятся фактические биты, хранящие эту информацию? Давайте их отыщем и попытаемся спарсить.
Но сначала немного теории.
▍ Как работают файлы?
Описанное далее относится к файловой системе ext4, типично используемой в Linux (по факту вся статья относится именно к ext4). Хотя эти принципы применимы к большинству файловых систем.
Что вообще такое /data/example.txt? Это так называемая запись каталога, которая представляет собой просто имя — example.txt.
Записи каталогов хранятся на диске, но ничего особо интересного в себе не несут, так как просто являются именами.
Но ведь имя что-то именует, не так ли? Что же именует example.txt? Именуемый им элемент называется индексный дескриптор (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
Не стремитесь детально понять этот вывод. Просто имейте в виду, что перед вами метаданные, такие как размер файла, имя его владельца и временные метки. Вся эта информация поступила из индексного дескриптора (кроме имени, которое было взято из записи каталога, а также номера самого дескриптора).
▍ Анализ содержимого индексного дескриптора
Но мы хотим увидеть именно биты рассматриваемого дескриптора. Что для этого нужно сделать?
Опытный разработчик ядра Ted TS’o обслуживает набор инструментов отладки для файловых систем под названием e2fsprogs. С помощью одного из этих инструментов, debugfs, мы можем поиграться с индексным дескриптором.
В debugfs есть мощная команда, которая выдаст необработанное двоичное представление дескриптора. Выдержка из мануала:
inode_dump filespec
Выводит содержимое индексного дескриптора в шестнадцатеричном и ASCII форматах.
Итак, ниже я привожу обещанное двоичное представление, правда, не в виде нулей и единиц вроде 0011000, поскольку двоичные данные гораздо проще читать, когда они представлены в шестнадцатеричном виде.
$ 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 ................
*
Всё понятно? Класс — благодарю за внимание!
Шучу, конечно. Видеть фактические сырые данные индексного дескриптора действительно неплохо, но мы по-прежнему не знаем, где на диске он находится, и что эти биты означают.
▍ Где же на диске находится дескриптор?
Итак, займёмся поиском дескриптора на диске. Здесь нам снова поможет debugfs:
imap filespec
Выводит расположение индексного дескриптора (в таблице индексных дескрипторов) по его filespec.
Теперь определим его местоположение.
debugfs: imap example.txt
Inode 11 is part of block group 0
located at block 73, offset 0x0a00
Поясню: файловая система разбита на блоки. В моём случае размер блока составляет 4096 байтов (размер по умолчанию для многих дистрибутивов Linux). То есть этот вывод сообщает, что: «От начала файловой системы нужно пройти 73 блока, то есть 73*4096 байтов». Это в определённом смысле говорит нам, на какой улице находится искомый индексный дескриптор. При этом номером его дома будет смещение: 0x0a00
байтов. В десятичном формате это 2560 байтов (Почему 2560?).
Итак, чтобы найти наш дескриптор, нужно от начала раздела диска (которое также является началом файловой системы) пропустить 4096 * 73 + 2560 = 301 568 байтов.
Так и сделаем. Давайте вытащим сырые биты с диска и посмотрим, совпадут ли они с выводом 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
!
Мы выяснили, где на диске находится индексный дескриптор!
Это уже гораздо круче, нежели просто вывод inode_dump
. В том случае мы попросили программу, написанную разработчиком ядра, сообщить нам, как выглядит дескриптор. Здесь же мы нашли эту информацию прямо на диске сами.
Но нам по-прежнему неизвестно, что эти биты означают. Можно ли их спарсить?
▍ Парсинг голых битов
Я просидел над этим вопросом пару недель. Как заставить компьютер превратить кучу битов в индексный дескриптор?
В итоге до меня дошло: «Ведь именно для этого используется структура (struct)!»
Вы могли встречаться со структурами. Это такие своеобразные объекты из динамических языков программирования, но только с ходу не совсем понятные.
Сейчас я представляю их себе так: предположим, вы рассматриваете кучу битов. В данном случае структура — это просто спецификация, поясняющая значение этих битов.
Значит, ядро Linux должно где-то определять структуру для дескриптора, верно? Так и есть!
/*
* Структура индексного дескриптора на диске
*/
struct ext4_inode {
__le16 i_mode; /* File mode */
__le16 i_uid; /* Low 16 bits of Owner Uid */
__le32 i_size_lo; /* Size in bytes */
/* ... */
}
Дополнительно повторюсь: именно благодаря структуре файловая система ext4 знает, как парсить биты, которые мы видели ранее. Это такой своеобразный универсальный ключ.
По сути, в ней говорится: «Первые 16 бит — это разрешения файла, следующие 16 бит — это его владелец, очередные 32 бита — это размер файла и так далее» (правда, всё немного усложняется дополнением).
Давайте используем эту структуру для парсинга ранее выведенных битов.
Мы напишем на Си небольшую программу, которая будет делать следующее:
- Просить компьютер выделить в памяти 256 байтов (поскольку именно таков размер структуры ext4_inode).
- Просить его скопировать в выделенную память 256 байтов из /dev/sdd1/ по адресу 301568.
- Пояснять ему, как нужно спарсить эти байты, используя структуру
ext4_inode
.
Вот описанная программа на Си (сокращённая до основных моментов):
// открываем файл раздела
int fd = open("/dev/sdd1/", O_RDONLY);
// перемещаем головку привода к местоположению дескриптора
lseek(fd, 301568, SEEK_SET);
// инициализируем структуру и копируем 256 байтов с диска в память
struct ext4_inode candidate_inode;
read(fd, &candidate_inode, sizeof(struct ext4_inode));
// теперь можно обращаться к полям дескриптора!
printf("User: %u", inode->i_uid);
Вот вся программа с проверкой ошибок. Можете собрать её и опробовать на собственном ПК.
Теперь давайте её выполним! Впечатлены?! Если всё работает исправно, значит мы успешно разобрали структуру битов.
$ sudo ./parse /dev/sdd1 301568
Inode: 11 Mode: 0664
User: 1000 Group: 1000 Size: 14
Links: 1 Blockcount: 8
Inode checksum: 0x0c5e4923
Ура! Мы заполучили информацию об индексном дескрипторе!
Чтобы убедиться в верности полученной информации, мы взглянем на вывод 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
Я считаю, что это супер круто. Мы решили отыскать биты индексного дескриптора на диске, нашли их и затем выяснили их значение.
▍ Память — это тоже куча битов
Но на этом ещё не всё.
В начале статьи я сказал, что диски и память представляют собой кучу битов. Наша программа копирует биты индексного дескриптора в память, ведь так?
Значит, у нас должна быть возможность найти эти биты в памяти и убедиться, что это те же биты, которые поступили в неё с диска. (примечание о дополнении структуры).
Для этого мы выполним программу в отладчике gdb (подобен pdb в Python). С помощью него мы будем приостанавливать процесс программы и прослеживать его в памяти.
$ 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
Читать такое непросто, поэтому я использовал скрипт, который сделал вывод 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
Сравним его с битами на диске:
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
Всё совпадает!
Звёздочка просто означает, что строка была заполнена всеми нулями. Наблюдательный читатель также заметит, что последние 16 байтов нулей в выводе gdb отсутствуют — думаю, это следствие выполненной компилятором оптимизации.
Здесь мы видим, что биты на диске и в памяти совпадают. Оглядываясь назад, это может показаться очевидным, но мы пронаблюдали это собственными глазами.
▍ А где же содержимое файла?
Знаю, вы могли подумать: «Мы же не видели фактического содержимого файла».
Верно. Индексный дескриптор не хранит в себе эту информацию. Она находится где-то в другом месте.
Вкратце объясню, почему. Вы можете рассмотреть файловую систему как состоящую из двух компонентов: множества ящиков, куда складывается содержимое файлов, и базы данных, управляющей этими ящиками. Это своеобразная распределённая система, в которой вы храните записи в базе данных (все метаданные), но фактическое содержимое файлов кладёте в хранилища вроде S3 или на диск.
Так что в дескрипторе нет содержимого файла, он лишь на него указывает.
Давайте спарсим эту информацию из нашего дескриптора. Выдержка из мануала:
blocks filespec
Выводит в stdout блоки, используемые спецификацией индексного дескриптора.
debugfs: blocks example.txt
33280
Здесь говорится, что содержимое файла занимает 33,280 блоков по 4 KiB от начала файловой системы. (А можно было получить это расположение напрямую из структуры дескриптора?)
Сделаем дамп соответствующей области диска.
$ 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!» прямиком с диска.
▍ Чему мы в итоге научились?
Итак, чему мы научились и что проделали?
Мы начали с распространённого утверждения, что диск и память — это просто куча битов.
Далее мы поставили задачу познакомиться с этими битами, в частности с теми, которые кодируют дисковые файлы: индексными дескрипторами.
В итоге наше знакомство оказалось очень тесным: мы нашли их на диске, спарсили при помощи программы, которая загрузила их в память и применила к ним структуру, после чего заглянули в соответствующую область памяти и увидели там в точности те же биты, что были на диске.
Параллельно с этим мы немного узнали о файловой системе ext4 (и файловых системах в целом).
Лично для меня этот эксперимент стал одним из самых полезных компьютерных откровений за весь мой опыт. После него загадочность вычислительной сферы для меня немного рассеялась, надеюсь, и для вас тоже.
▍ Сноски
А также номера самого дескриптора. Номер самого дескриптора (в данном случае 11) в нём тоже не хранится. Вместо этого номера в нём указывается позиция в таблице индексных дескрипторов. ↩
Почему 2560? Напомню, что номер дескриптора — 11. Это значит, что на диске ему предшествует 10 других дескрипторов. Каждый дескриптор имеет размер 256 байтов, то есть все они занимают 2560 байтов. ↩
Всё немного усложняется дополнением. Технически компьютер дополняет структуру, то есть вставляет в неё пустое пространство. В результате порядок битов в структуре определяется неточно. Тем не менее, учитывая, что дескриптор был сгенерирован на том же компьютере, на котором будет прочитан, это означает, что структура фактически является универсальным ключом к кажущимся случайными битам. ↩
Примечание о дополнении структуры. Ранее я сказал, что компилятор дополняет структуру, вставляя байты между полями. В результате представление данных в памяти сложно сравнить с их представлением на диске. В связи с этим я максимально сократил дополнение, добавив в определение структуры __attribute__((__packed__))
. Именно поэтому в показанном мной дампе памяти оказалось всего 160 байтов — это sizeof(struct ext4_inode)
, когда дополнение отключено. ↩
А можно было получить расположение прямо из индексного дескриптора? Мы также могли спарсить расположение из дескриптора, который загрузили в память, используя поле i_block
. Но содержимое представляет собой непонятный массив, для расшифровки которого пришлось бы использовать дополнительный код. Было проще обратиться к debugfs, который сделал это всё за нас. Если кому любопытно, то выглядит этот массив так:
(gdb) p candidate_inode->i_block
$1 = {127754, 4, 0, 0, 1, 33280, 0, 0, 0, 0, 0, 0, 0, 0, 0}
Здесь присутствует значение 33280, которое мы видели в выводе debugfs. ↩
Автор: Дмитрий Брайт