❯ Глоссарий

  1. Программа – текстовый файл, который содержит в себе код на каком-либо языке программирования;

  2. Процесс – абстракция операционной системы, позволяющая следить и управлять ходом выполнения программы;

  3. Ядро – программа, лежащая в основе операционной системы, написанная на системном языке (например на C);

  4. Операционная система – ядро и стандартные пользовательские приложения;

  5. Модуль ядра – программа, которая динамически подгружается в ядро для расширения его функционала. Модуль может быть драйвером, системным вызовом или какой-либо произвольной подсистемой;

  6. Драйвер – программа, которая абстрагирует прикладного программиста/программу от низкоуровневого взаимодействия с железом и предоставляет удобный интерфейс взаимодействия с ним.

❯ Введение: что будет в статье?

Думаю, многие слышали, что в Linux «Всё есть файл». Когда я впервые услышал это от моего друга, я подумал, что он просто издевается, называя вещи не своими именами, но в реальности всё оказалось иначе. Хоть эта концепция и может показаться тривиальной для опытных людей, новичков она вводит в ступор. Да что там новичков, даже более менее опытных программистов, так как весь этот слой абстракций скрывается от них под библиотеками языков программирования, что являются ещё более высоким уровнем абстракции, и они просто о нём не задумываются.

Мы же постараемся откинуть все эти, безусловно нужные и полезные, библиотеки и инструменты и напрямую взглянуть на фундаментальные абстракции UNIX-подобных систем, которые были заложены ещё в прошлом веке и которые во многом определили ход развития программного обеспечения.

Для понимания того, чтобы будет написано, как и во всех моих статьях, не нужно обладать особыми знаниями и опытом, нужно иметь лишь общее представление о том, что такое компьютер, программирование и знать о существовании Linux.

Но всё же, так как темой данной статьи являются обширные возможности ядра и файловой системы Linux, некоторые моменты о процессах (запущенных программах) и ядре операционной системы не будут разбираться на пальцах. Но это не повод для грусти, в моей предыдущей статье мы как раз разбирали эти вещи! Если вы пока не до конца понимаете что такое процесс и каким образом операционная система осуществляет его запуск, а слово ядро наводит на вас ужас, то очень советую прочитать мою предыдущую статью.

Что будет разобрано в этой статье:

  1. Что из себя представляют современные жёсткие диски (SSD) на физическом уровне;

  2. Сколько статуй свободы понадобится, чтобы записать 1 триллион байт;

  3. Каким образом на диске хранится файловая система и как компьютер в ней разбирается;

  4. Поймём, что системные вызовы – это всё, что у нас есть (больше реально ничего нет);

  5. Разберём на пальцах модули ядра и драйверы и даже напишем простой модуль ядра Linux!;

  6. Поймём, какие фундаментальные идеи стоят за концепцией «Всё есть файл» и как это реализуются программно. Приведём простые примеры;

  7. Удивимся тому, что процессы – это файл;

  8. Удивимся тому, что мышь и тачпад – это файл;

  9. Удивимся тому, что интернет соединение – это файл.

Итак, начнём мы как всегда с основ, которые стоит понять (прочувствовать), прежде чем мы перейдём к основной теме, поехали!

❯ Основы, которые стоит понять

❯ Жёсткий диск, информация на нём

Ни для кого не секрет, что наши фото, видео и всевозможные программы хранятся на жёстком диске. Нам сейчас неважно, крутится ли там под корпусом диск под считывающей головкой (HDD) или изменяется уровень заряда транзистора c плавающим затвором (SSD). Для нас важно знать лишь одно – это чудо техники умеет записывать и хранить информацию в течении продолжительного промежутка времени. И нам стоит воспринимать диск как обычный массив байт. Для меня было настоящим открытием, с немалым удивлением, когда я осознал, что сохранив фотографию на ноутбук и положив его после этого в пыльный угол или под кровать, спустя, скажем, 10 лет, я всё ещё смогу его включить и посмотреть на эту фотографию.

Если же мы распечатаем (нарисуем) такую же фотографию на листе бумаги, то ситуация выше нас не сильно впечатлит, хотя бумага и является абстрактно тем же носителем информации в данном контексте. Информация на бумаге, не углубляясь в молекулярную физику, хранится в виде чернил. Информация же на жёстком диске, например SSD, хранится в виде уровня заряда на плавающем затворе транзистора. Транзистор – базовый схемотехнический элемент, плавающий затвор – его ключевой элемент (часть).

Классический транзистор (биполярный или полевой) не хранит информацию, он является бинарным переключателем (пропускаю заряд/не пропускаю заряда), а транзистор с плавающем затвором может хранить информацию и даже больше одного бита. Количество заряженных частиц (электронов) на затворе транзистора и определяет уровень его заряда. Количество бит, которое может хранить один транзистор (ячейка диска) определяется количеством дискретных уровней заряда, на которые мы можем разделить общий диапазон заряда (на картинке изображены 4 ячейки памяти с разным количеством уровней заряда):

PS: В списке литературы будет отличное видео на тему устройства SSD

Жёсткий диск (SSD) состоит из миллиардов или триллионов транзисторов. Например, диск на 1 Терабайт (1000 Гигабайт) содержит около 3-х триллионов транзисторов. Но не будем развивать эту тему дальше, просто нужно прочувствовать, насколько искусно человечество подчинило себе электромагнитные явления. И что пока ваш ноутбук пылится под кроватью вместе с мухами и пауками, ваши данные, будь это фотографии, видео, игры, ваш прогресс и ачивки в играх – хранятся в виде электрического заряда на триллионах транзисторов и помешаются в какой-то ничтожный по размерам кусок пространства (диск).

Приведём последний пример на эту тему. Предположим, у нас есть диск на 1 Терабайт и мы полностью забиваем его произвольным текстом. 1 Терабайт – это 1 Триллион байт. 1 символ в кодировке ASCII занимает 1 байт. Получается, у нас получится записать 1 триллион символов. На стандартной странице офисной бумаги формат A4 со стандартным шрифтом 12-14 пунктов помещается примерно от 1500 до 3000 символов. Возьмём за эталон 2000 символов (хотя это число может сильно варьироваться от шрифта и от прочих аспектов оформления текста).

Получается, если мы захотим записать триллион символов (букв) на бумагу, то нам понадобится 500 миллионов листов (1 000 000 000 000 символов / 2 000 символов = 500 000 000 листов). Стандартный лист А4 весит 5 граммов, 500 миллионов листов весят 2 500 000 000 грамм, что есть 2500000 кг, что есть 2500 тонн.

Что такое 2500 тонн? Например, это 12 c половиной статуй свободы, 166 ваших дачных домов (если принять дом за 15 тонн) или пару тысяч легковых машин. Жёсткий диск на 1 Терабайт весит примерно 100 грамм, хотя может и меньше, что уже в 25 миллионов раз меньше, чем объём бумаги, который потребовался бы для записи той же информации.

❯ Файловая система

Разумеется, мы можем просто так забить диск байтами (символами/буквам в кодировке ASCII), но в контексте современных компьютеров это не будет иметь никакого смысла. Диск должен иметь некую разметку, инструкцию/карту для компьютера, чтобы последний мог ориентироваться в данных, записанных на нём. Ровно также и с этой статьёй, если я уберу заголовки, отступы, картинки и абзацы – читать будет совершенно неинтересно и сложно. Да, аналогия не полная, но всё же.

При должном желании человек может прочитать полностью неоформленный текст, а современный компьютер не размеченный диск не сможет. Нет, разумеется, мы можем создать/запрограммировать такой компьютер, который будет просто читать наш 1 Терабайт с диска побайтово и всё, но людям, бизнесу и т.д. – нужна универсальность. Поэтому возможности чтения компьютером диска не ограничиваются на ситуации выше. Нужен способ читать совершенно разные данные и делать это универсально, поэтому «голый» диске (массив нулей, грубо говоря, если принять диск за массив байт) компьютер не распознает, ему нужна разметка, некая системная предзаписанная информация, на которую он может опереться).

Не будем углубляться в разметки и структуры файловых систем, а примем следующую абстракцию, которая в общих чертах отражает происходящее на самом деле:

Наш компьютер не адресует отдельные байты накопителя информации(диска), это было бы слишком накладно и неудобно, вместо этого он адресует диск блоками, например по 512 байт для MBR разметки. В одном или нескольких блоках (на картинке обозначен как «Blocks Library») хранится информация о том, в каких блоках какие файлы лежат. Вот и всё, это и есть разметка диска. Разумеется, в реальности не всё так тривиально, существует великое множество способов организовать информацию на диске под разные потребности и задачи. Нам же удобно воспринимать диск как массив этих самых блоков, или же просто байт (ведь это абстракция, так проще) и понимать, что на диске перед началом его использования (в стандартном понимании) должна быть некая разметка.

По такой схеме и хранятся наши файлы (фотографии, игры и т.д.), разные части файлов лежат в разных блоках. Учёт обычных блоков с пользовательскими данными и доступ к специальным блокам с информацией об обычных блоках ведёт операционная система, а точнее – её ядро. Со стороны обычного пользователя, мы можем получить доступ к этим файлам через командную строку или GUI файловые менеджеры, например через nautilus в Linux или через всем знакомый проводник в Windows :) Ядро операционной системы просто прочитает свои специальные блоки, поймёт в каких блоках лежит наш файл, соберёт его по частям и «отдаст нам».

Но не все файлы в Linux, к которым мы можем получить доступ, хранятся физически (то есть в виде уровня заряда на транзисторах) на жёстком диске. Некоторые существуют только во время работы компьютера, а значит логично предположить, что хранятся они в оперативной памяти (ОЗУ), которая хранит информацию только тогда, когда компьютер включён, она энергозависима. В отличие от SSD, который может годами хранить информацию без внешнего источника питания.

Эти файлы являются просто ресурсами ядра операционной системы (не пугайтесь, мы во всём разберёмся далее), которые были «отображены» в файловую систему, дабы предоставить удобный интерфейс взаимодействия с ними. В этом мы и разберёмся далее в статье.

❯ Системные вызовы это всё, что у нас есть

Так как все пользовательские процессы выполняются в неком окружении, которое мы называем операционной системой, то и доступ ко всем физическим (реальные устройства) и логическим (ресурсы ОС: файловая система, информация о процессах и т.д.) ресурсам предоставляются процессам ядром операционной системы.

Предоставление этих ресурсов реализуется механизмом системных вызовов, вот их полный список, на момент написания статьи их 467 штук. Самые простые их них, например, sys_exit, который компилятор вставляет в конец программ, когда вы пишите return 0; в конце функции main:

#include <stdlib.h>

int main() {
	exit(0); // return 0;
}

Или же системный вызов sys_write, который вызывается внутри функции printf в С, да и вообще внутри любой функции print в языках программирования(и не только print, но об этом позже):

#include <stdio.h>
#include <stdlib.h>

int main() {
	printf("Hello, World!\n");
	exit(0); // return 0;
}

printf является частью стандартной библиотеки C - libc и нужен для форматированного вывода (сокращение от print format) для удобства программистов, но мы можем воспользоваться и более низкоуровневой функцией write, внутри printf именно она и вызывается (на эту тему есть отличная статья на Хабре):

#include <stdio.h>
#include <unistd.h>

char *string = "Hello, World!\n";

int main() {
	write(STDOUT_FILENO, string, 14);
	return 0; // exit(0);
}

Первым аргументом мы передаём дескриптор файла, в который мы хотим осуществить запись, вторым указатель на начало строки и третьим длину строки. В нашем случае мы хотим записать в терминал, поэтому дескриптор файла в первом аргументе функции write будет 1 – дескриптор стандартного потока вывода (не пугайтесь, скоро мы во всём разберёмся). Да, с записью в терминал мы взаимодействуем как и с записью в файл, к концу статьи мы постараемся понять почему это так зачем это нужно, а пока просто возьмём на веру.

STDOUT_FILENO является константой, которая определена в стандартной библиотеке в unistd.h и равна эта константа 1:

/* Standard file descriptors. */
#define STDIN_FILENO  0 /* Standard input. */
#define STDOUT_FILENO 1 /* Standard output. */
#define STDERR_FILENO 2 /* Standard error output. */

Является ли функция write системным вызовом? Нет. Функция write, как ни странно, является просто функцией. Это обёртка над системным вызовом, написанная в стандартной библиотеке языка C.

Тут мы скажем лишь то, что системные вызовы выполняются в неком привилегированном режиме, из которого можно получить доступ к ресурсам системы. В этом режиме и работает ядро операционной системы. Когда пользовательская программа вызывает системный вызов, то говорят, что программа перешла из пользовательского пространства в пространство ядра, что и видно на картинке выше. Это значит, что теперь программа работает в адресом пространстве ядра, то есть с конкретным местом в ОЗУ (виртуальной памяти), в котором располагаются код и данные ядра. Доступ к этим данным возможен только в режиме ядра, это гарантируется аппаратно! Подробнее про системные вызовы, их использование и виртуальную память в моей предыдущей статье.

Из этого можно сделать очень важный вывод: всё, что есть в распоряжении у программ – это системные вызовы. Все программы сводятся к вызовам системных вызовов, ничего другого просто не существует. Всё остальное – это прикладные обёртки, например функции в библиотеке C libc или стандартные функции в любом другом языке программирования. Без системных вызовов программы бы не могли выполнять никакой полезной нагрузки и им было бы доступно ровным счётом ничего. Без них процессы смогли бы выполнять лишь самые тривиальные математические операции и некоторые другие вещи. Именно поэтому когда-то давно и решили написать операционную систему, для удобства!

Поэтому, как мы упоминали выше, у нас лишь 467 (на момент написания статьи количество такое) разных системных вызова и на их основе и строятся всё колоссальные множество стандартных функций, все программы, игры и т.д. Они являются строительными блоками абсолютно для всего, что вы видели, видите и сможете увидеть на экране вашего компьютера.

Всё это придумано для безопасности, так как было бы очень опрометчиво давать пользовательским программам доступ к ресурсам ядра операционной системы. Программы могли бы случайно или специально вывести систему из строя. Например, стереть весь жёсткий диск – всё, теперь у вас в руках просто кусок кремния с платковыми/железным корпусом :) Ведь в таком случае не поможет даже перезагрузка, ведь была не просто нарушена работа ОС (что-то перезаписали в ОЗУ), ОС была и вовсе стёрта, как и вся разметка на диске!

❯ Модули ядра и драйверы

И последняя вещь, которую мы разберём, прежде чем перейти к самому интересному!

Ядро Linux является монолитным, то есть все компоненты ядра (все его подсистемы) располагаются в едином пространстве ядра, в одном месте в памяти (мы уже упоминали о пространстве ядра выше). И если «умирает» одна часть ядра, то умирает и вся система, это и есть монолитное ядро. Но хоть ядро Linux и является монолитным, оно всё же поддерживает загрузку модулей. Зачем?

Ну, например, мы хотим подключить к компьютеру новую модель Raspberry Pi или проcто какую-то диковинную железку. Разумеется, драйверов (в следующем абзаце разберёмся что это) под эту железку у нас изначально не будет. И если бы ядро не поддерживало бы динамическую загрузку новых модулей, нам бы пришлось писать драйвер для этого нового устройства, вставлять его код в код ядра и перекомпилировать ВСЁ ядро заново, это очень сложно, грустно и долго!

Именно поэтому ядро Linux поддерживает динамическую загрузку модулей, чтобы можно было добавлять к ядру новый функционал без перекомпиляции всего ядра и даже без перезагрузки системы!

Так что же такое драйвер и модуль, чем они отличаются и зачем нам это нужно?

Драйвер в общем смысле – это программа, которая взаимодействует с каким-либо физическим устройством и выступает между железом и прикладными программами как посредник/переводчик, который переводит с языка «железного» на язык «программный». Короче, абстрагирует нас, как программистов, от низкоуровневого взаимодействия с устройствами и предоставляется удобный программный интерфейс взаимодействия с ними. Это классический пример, но это не всегда так. Драйвер не обязательно абстрагирует какое-либо физическое устройство, он может абстрагировать и какие-либо ресурсы операционной системы (посмотрим на это позже, в следующем практическом разделе).

Модуль ядра – это просто программа, которая является частью ядра, она может использоваться во многих разных целях. Например, модули нужны для создания новых системных вызовов, драйверов, сетевых подсистем и так далее. Но можно написать модуль, который просто будет выводит «Hello, World!» в логи ядра при его загрузке в ядро и Goodbye, World!" при его выгрузке из ядра . Прошу любить и жаловать:

Мы не будем разбирать тут этот код в деталях (что за printk – print kernel, странные сигнатуры функций и прочие вещи. всё это специфика разработки на стороне ядра), но в будущем я обязательно напишу серию статей, посвящённых написанию модулей, драйверов и системных вызовов в Linux.

Просто в общих чертах разберёмся что происходит на картинке выше:

  1. Был написан некий код, который ядро воспринимает как модуль. Да, это простой код на C, но он немного специфичен;

  2. Справа с помощью команды sudo dmesg -w мы вывели логи ядра;

  3. Далее модуль надо скомпилировать, процесс компиляции тут опущен, так как статья не об этом, но тут нет ничего сложного (ls выводит нам вспомогательные файлы, которые появляются после компиляции). Среди них есть hello.ko – это и есть исполняемый файл нашего модуля. Ровно такой же, как и обычные исполняемые файлы. Но, разумеется, с некоторыми оговорками, которые выходят за рамки данной статьи;

  4. С помощью команды sudo insmod hello.ko подключаем наш модуль к ядру, выполняется функция hello_init. Результат её работы мы видим в логах ядра в первой из выделенных строчек (самые нижние строчки в правом терминале);

  5. С помощью команды lsmod | grep hello проверяем, что наш модуль реально подключён к ядру. Просто lsmod выведет все модули в системе на данный момент;

  6. С помощью команды sudo rmmod hello выгружаем модуль из ядра, выполняется функция hello_exit, результат, опять же, видим в логах ядра;

  7. Теперь lsmod | grep hello ничего не выдаёт. Это значит, что модуль мы успешно выгрузили.

Только что с высоты птичьего полёта мы разобрали «Hello, World!» из мира ядерной разработки. На основе шаблона выше и пишутся драйверы, системные вызовы и прочие вещи.

Из этого можно сделать вывод, что каждый драйвер – это модуль ядра, но не каждый модуль ядра – это драйвер.

Все эти знания понадобятся нам для понимания концепции «Всё есть файл», давайте же перейдём к ней!

❯ Всё есть файл

❯ Что это такое в общих чертах

Мы уже поняли, что системные вызовы составляют полный интерфейс взаимодействия с ядром по части логических и физических ресурсов системы. Идея концепции «Всё есть файл» призвана унифицировать взаимодействие с на первый взгляд разными сущностями/объектами.

Например, для приложения нет особой разницы, пишет ли оно в файл на диске или в сетевое соединение. Что то, что то – представляется файлом, который поддерживает операции read, write, close и прочее. Для клиента, обратиться к серверу на другом конце планеты не сложнее, чем записать файл на диск. Всю «грязную» работу делает ядро операционной системы.

Мы(процесс) можем обратиться через read() к файлу на жёстком диске, к интернет соединению, к какому либо внутреннему буферу произвольного устройства (на картинке выше Arduino), к логическим ресурсам системы (их на картинке обозначает Такс, так зовут пингвина Linux).

Ко всему вышеперечисленному нужен драйвер! Откуда операционная система будет знать какая на диске разметка, какие именно параметры интернет соединения и по какому сетевому протоколу происходит передача данных, какого вида буфер на каком-либо устройстве и в каком формате он хранит данные, какие именно данные и в каком формате получать от операционной системы???

А ей и не надо знать всего этого! Под каждую из этих вещей пишется отдельный драйвер, который реализует стандартный набор функций для работы с файлом(open(), read(), write(), close() и т.д.), внутри которых уже и описывается специфика взаимодействия с конкретным объектом. То есть задача драйвера – подогнать взаимодействие с тем или иным объектом(устройством/ресурсом) под файловый интерфейс.

И правда, если с чтением и записью на диск всё понятно, то, например, о сетевом соединении нужно думать так: write() – отправляем данные по сети, read() – читаем что нам по сети прислали, close() – заканчиваем передачу данных по сети и т.д. Поздравляю, теперь сетевое соединение/взаимодействие – это файл!

Надеюсь, вам уже становится понятно как это работает, но давайте взглянем на то, как это реализовано в ядре и напишем свой небольшой пример. В ядре Linux есть структура под названием file_operations, её то драйвер и заполняет! Это похоже на реализацию интерфейса в других языках, в Си же это делает с помощью указателей на функции в структуре(выглядят как обычная сигнатура функции, только название берётся в скобки и перед ним ставится *):

#include <stdio.h>

struct Animal {
	void (*speak) (char *word);
};

void speak_cat(char *word) {
	printf("%s, Meow!\n", word);
}

void speak_dog(char *word) {
	printf("%s, Bark!\n", word);
}

int main() {
	struct Animal cat;
	cat.speak = speak_cat;
	
	struct Animal dog;
	dog.speak = speak_dog;

	cat.speak("Hello");
	dog.speak("Hello");

	return 0;
}

Вывод программы:

Hello, Meow!
Hello, Bark!

Ой, ООП на C?! По сути – да, но об этом в другой статье. Нас же сейчас интересует лишь то, что данный пример с кошечкой и собачкой полностью отражает концепцию «Всё есть файл». И правда, взгляните на картинку ниже, а потом на предыдущую ещё раз.

Да, они одинаковые, надеюсь вы перешли по ссылке выше в определение структуры file_operations(даю вам второй шанс) и увидели, что это одно и тоже.

Вместо животного в примере выше, мы создаём драйвер, скажем my_driver. Далее пишем функции my_driver_open(), my_driver_read(), my_driver_write(), my_driver_close() специфичные для устройства/ресурса, которое/который абстрагирует наш драйвер. Заполняем указатели на функции open(), read(), write(), close() в экземпляре структуры file_operations нашего драйвера соответственно.

Всё – теперь кошка, собака, весь интернет, любое электронное устройство – всё это есть файл! После описания file_operations в драйвере и загрузку его в ядро, всю «грязную» работу, как мы уже говорили ранее, выполняет ядро.

Теперь, когда мы на самом деле поняли природу этой абстракции, рассмотрим самые популярные лже-файлы, под маской которых скрываются совсем не файлы в стандартном понимании этого слова.

❯ Смотрим на /proc

Директория /proc или procfs(файловая система процессов) монтируется(подключается) к нашей файловой системе в момент запуска компьютера. Данные из папки /proc, как уже было сказано много раз ранее, не содержатся на жёстком диске, они содержатся в ОЗУ!

procfs содержит исчерпывающую информацию о процессах и некоторых других ресурсах системы.

Рассмотрим простую программу на C:

#include <stdio.h>
#include <unistd.h>

int main() {
	printf("My PID: %d\n", getpid());
	while(1);
	return 0;
}

Всё что она делает – это выводит свой PID(Идентификатор процесса в системе) и уходит в бесконечный цикл, чтобы процесс не завершался.

Давайте запустим:

zpnst@debian ~/D/a/pr> gcc main.c
zpnst@debian ~/D/a/pr> ./a.out
My PID: 49489

Теперь наш процесс «висит» в системе. Самое время взглянуть на папку /proc:

Все эти директории относятся к конкретным процессам, в этих папках лежит полная информация о них. Наш процесс имеет PID 49489, зайдём в его папку:

Что мы сделали?

  1. В папке /procзашли в 49489, в ней лежат файлы и папки с информацией о нашем процессе. Они не лежать на диске, это ресурсы ядра. При применении read() к этим файлам ядро просто выдаёт нам соответствующую информацию о процессе. Просто удобный интерфейс, не более!;

  2. Посмотрим на несколько простых файлов. comm показывает имя исполняемого файла, cmdline показывает полную командную, с помощью которой был запущен процесс, включая все аргументы(у нас их не было), io показывает подробную статистику операций ввода/вывода.... и так далее;

  3. В папке fd хранятся файловые дескрипторы процесса. По дефолту их три: stdin(стандартный поток ввода), stdout(стандартный поток вывода) и stderr(стандартный поток ошибок), на них мы посмотрим подробнее в следующем разделе. Когда процесс открывает какой-либо файл, ядро в рамках этого процесса назначает этому файлу дескриптор, через который процесс может взаимодействовать с ним. Да, стандартные потоки ввода/вывода это тоже файлы(посмотрим на это подробнее в следующем разделе).

Давайте откроем обычный файл и проверим папку fd этого процесса в /proc:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main() {
	printf("My PID: %d\n", getpid());
	
	// Открываем файл и получаем его дескриптор с помощью `open()` – оболочки над системным вызовом `sys_open`
	int file_descriptor = open("file.txt", O_RDONLY);
	
	char buffer[13];
	
	// Читаем содержимое файла в буфер с помощью `read()` – оболочки над системным вызовом `sys_read`
	read(file_descriptor, buffer, 13);
	printf("%s\n", buffer);
	
	while(1);
	return 0;
}

Вывод:

zpnst@debian ~/D/a/pr> ls
file.txt  main.c
zpnst@debian ~/D/a/pr> cat file.txt
Hello, World!⏎                                                           
zpnst@debian ~/D/a/pr> gcc main.c
zpnst@debian ~/D/a/pr> ./a.out
My PID: 12927
Hello, World!

А теперь посмотрим на дескрипторы нашего процесса с PID 12927:

Появляется ещё один дескриптор, что является ссылкой на наш файл. А на что такое странное ссылаются стандартные потоки? С этим разберёмся буквально через несколько предложений.

Также, процесс может обратиться к своим данным в procfs по пути /proc/self. Для примера откроем уже знакомый нам /proc/self/cmdline. Там содержится команда, с помощью которой процесс был запущен:

#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>

int main() {
	char buffer[32];
	int file_descriptor = open("/proc/self/cmdline", O_RDONLY);
	read(file_descriptor, buffer, 32);
	printf("%s\n", buffer);
	return 0;
}

Вывод:

zpnst@debian ~/D/a/pr> ./a.out
./a.out

Всё верно, запустили мы с ./a.out, значит в cmdline - ./a.out.

То есть процессу даже не обязательно «знать» свой PID, он просто может зайти в /proc/self, а ядро уже само поймёт что это за процесс и какие данные ему выдать. Очень удобно и изящно!

❯ Смотрим на /dev

Директория /dev(devices) содержит файлы, представляющие/абстрагирующие физические устройства и логические ресурсы системы. Мы повторили предложение выше уже много раз за эту статью, давайте же притронемся к этой идее собственными руками!

❯ Терминалы, стандартные потоки ввода/вывода

Как мы говорили ранее, с каждым процессом по умолчанию ядро ассоциирует три файловых дескриптора. Стандартный потока ввода, вывода и ошибок: stdin, stdout и stderr.

Также, ранее мы уже видели, что в папке fd в procfs конкретного процесса указывают они на что-то непонятное, лежащее в папке /dev. Прошу любить и жаловать pts – это псевдотерминал.

Тут мы, признаться честно, ступаем на очень зыбкую почву. Тема терминалов, эмуляторов терминалов, псевдотерминалов, оболочек и всего такого – очень обширна. Этой теме я тоже планирую посвятить целую статью, так как она интересная ещё и с исторической точки зрения. Но для её осознания нужно стойко понимать концепцию "Всё есть файл". И чтобы не превращать данную статью в кашу, рассмотрим только основные концепции, которые затрагивают нашу основную тему.

Псевдотерминал выступает посредником между процессом и терминальной программой(у меня это GNOME Terminal). Псевдотерминал своего рода труба, у него есть два конца:

  1. Master конец – для терминальной программы;

  2. Slave конец – для вашей программы;

  3. Сама труба – символьное устройство /dev/pts/1.

Он нужен для того, чтобы процесс/программа могла общаться с терминальной программой, такой как GNOME Terminal.

Так как мы договорились не закапываться в подробности работы терминалов в Linux, то нам нужно знать лишь одно – для процесса записать что-то в терминал тоже самое, что и записать что-то в обычный файл.

Каждый из потоков ссылается на устройство /dev/pts/1, тогда в чём же разница? Да ни в чём! Я не шучу, это просто формальность, договорённость, что очевидно, так как все три ссылки у потоков ссылаются на один и тот же файл (устройство).

Можно было бы обойтись и одним потоком, но такое разделение оказывается весьма полезным во многих случаях, например вот:

#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main() {
	const char *string = "Hello, Habr!\n";
	write(0, string, strlen(string)); // Пишем в stdin
	write(1, string, strlen(string)); // Пишем в stdout
	write(2, string, strlen(string)); // Пишем в stderr
	return 0;
}

Вывод:

zpnst@debian ~/D/a/pr> ./a.out
Hello, Habr!
Hello, Habr!
Hello, Habr!

Так как все файлы ссылаются на один и тот же псевдотерминал, на одно и то же устройство, следовательно и write() ведёт себя одинаково и при каждом из потоковых дескрипторов просто пишет в терминал.

Но благодаря разделению, вывод можно фильтровать:

zpnst@debian ~/D/a/pr> ls
a.out*  main.c
zpnst@debian ~/D/a/pr> ./a.out > output.txt 2> errors.txt
Hello, Habr!
zpnst@debian ~/D/a/pr> ls
a.out*  errors.txt  main.c  output.txt
zpnst@debian ~/D/a/pr> cat output.txt
Hello, Habr!
zpnst@debian ~/D/a/pr> cat errors.txt 
Hello, Habr!
zpnst@debian ~/D/a/pr> 

Что мы сделали?

  1. Посмотрели содержимое рабочей директории, там пока только текст программы и её исполняемый файл;

  2. Запустили программу, перенаправив стандартный поток вывода в файл output.txt с помощью >, а стандартный поток ошибок в errors.txt с помощью 2>;

  3. Теперь у нас появились файлы output.txt и errors.txt;

  4. Поток ввода мы не трогали, поэтому одна и трёх строк вывелась в терминал;

  5. Вторая строка теперь в output.txt, а третья в errors.txt.

Можем поиграть с устройством псевдотерминала напрямую:

Что мы сделали?

  1. Сначала был открыть лишь один терминал слева. Первый вывод показал нам, что есть только один псевдотерминал с индексом 0, то есть /dev/pts/0;

  2. Далее открываем второй терминал справа и повторно смотрим содержимое папки /dev/pts, появилось второе устройство /dev/pts/1!;

  3. В правом терминале вводим команду echo "Hello, World!", она просто выводит строчку на экран, вызывая write();

  4. Но мы можем вывести строчку и из левого терминала в правый, у нас же есть всё для этого! Устройство, отвечающее за правый терминал /dev/pts/1 и утилита echo. Далее просто выводим несколько раз из левого в правый, перенаправляя вывод в устройством, которое отвечает за правый терминал;

  5. А теперь пробуем вывести в левый терминал через его же устройство, такая команда немного бессмысленная, так как echo в левом терминале и без перенаправления с помощью > вывело бы в левый терминал, который закреплён за устройством /dev/pts/0.

❯ Мышь и тачпад

А теперь взглянем не на логический объект, такой как псевдотерминал, а на физическое устройство – мышь/тачпад. И в самом прямом смысле притронемся к концепции «Всё есть файл» собственными руками:

Мы вывели список всех устройств, потом зашли в устройства ввода и применили утилиту cat к файлу мыши mice. Утилита cat, применённая к обычному файлу, просто выводит его содержимое в терминал, то есть вызывает read() на этот файл(c read() мы уже знакомы).

Но применив cat к файлу мыши мы ничего не увидим... до того момента, как не подвигаем мышью! Попробуйте, это очень прикольно :)

Но что мы видим? Что за скобочки и восьмёрки и куча пустого места? Это просто управляющие коды драйвера мыши, которые терминал пытается интерпретировать как ASCII символы, ведь драйвер мыши (так как мышь – э��о файл), реализует функцию read() в структуре file_operationd (о ней мы говорили выше). И так как cat просто вызывает read() на файл, то никаких ошибок мы не наблюдаем. Мы просто видим, что бы получила программа, которая умеет интерпретировать данные от мыши. Например какой-нибудь графический интерфейс, которому нужно постоянно отрисовывать курсор на экране компьютера при изменении положения мыши на столе или пальца на тачпаде.

Написав простой скрипт на питоне, можно увидеть те самые байты. Запускаем скрипт и двигаем мышкой:

Почему мы читаем по три байта? Первый байт передаёт флаги и нажатую кнопку, второй байт передаёт движение по оси X (относительное перемещение по горизонтали), третий байт передаёт движение по оси Y (относительное перемещение по вертикали). На stackoverflow есть вопрос по этому поводу и код на C для просмотра этих байт. Я же для простоты решил написать на Python.

❯ Сокеты: интернет – это файл :)

Напишем простой TCP сервер на C:

Дисклеймер: этот код поистине ужасен, в нём не осуществляются проверки возвращаемых значений функций на предмет ошибки и он не следует абсолютно никаким «best practises», он нужен лишь для демонстрации! Вот такой вот одноразовый шаблон :) Примеры нормального TCP сервера на C вы можете найти в интернете по первой, а возможно и по второй, ссылке

Данный сервер просто слушает входящие соединения, как только оно было получено, сервер ждёт первое сообщение из этого соединения и завершает свою работу:

Что тут произошло?

  1. В среднем терминале мы запустили сервер и он любезно сообщил на свой PID, а также, процесс сервера будет заблокирован, пока не не получит входящее соединение. Этого мы добились с помощью функции listen() и вывели информацию о том, что сервер слушает соединения в терминал;

  2. В нижнем терминале посмотрим список файловых дескрипторов сервера, теперь, кроме стандартных потоков, там появился сокет! Сокет – это ресурс ядра и управляет он ядром, дескриптор 3 хранит ссылку на этот ресурс. А ядро превращает этот ресурс в файл!

Что тут произошло?

  1. В верхнем терминале с помощью утилиты telnet подключаемся к серверу по локальному адресу и порту 4000. telnet работает по протоколу TCP, это нам и нужно;

  2. Теперь смотрим на нижний терминал, появился дескриптор под номером 4, он и олицетворяет канал связи/соединение между клиентом и сервером, через него они общаются. Если дескриптор (сокет) под номером 3 швейцар, то дескриптор (сокет) под номером 4 – официант. Думаю, аналогия ясна. В контексте сети: 3 – слушающий, 4 – принимающий и передающий.

Что тут произошло?

  1. Наконец-то отправляем сообщение Hello, Server с 41-им ! в верхнем терминале и видим, что в среднем терминале сервера мы его успешно получили!;

  2. Сервер завершил свою работу после получения перового сообщения, как мы и задумывали;

  3. Теперь в нижнем терминале при попытке вывести информацию о дескрипторах сервера у нас ничего не выходит. Разумеется, ведь файл процесса в /proc существует только во время жизни процесса, а процесс сервера уже завершился.

Вот и всё, теперь интернет соединение – это тоже файл. Сообщения от клиента сервер читает с помощью той же функции read(), а если бы он захотел отправить клиенту что-то в ответ, то воспользовался бы функцией write(), передав первым аргументом дескриптор сокета!

❯ Выводы/Заключение

Я очень рад, если вы дошли до сюда, значит я пишу не просто так. Надеюсь, вы узнали для себя что-то новое и вдохновились концепцией «Всё есть файл» в UNIX-подобных операционных системах, а в частности в Linux.

Что мы разобрали в статье?

  1. Мы поняли из чего состоит наш жёсткий диск и насколько искусно и невероятно сложно он сконструирован, а также, поняли каким образом на нём хранится файловая система и информация в целом;

  2. Узнали, что к ядру Linux можно подключать модули, на основе которых и пишутся драйверы, которые притворяются файлами. А точнее будет сказать, инкапсулируют в себе логику физического устройства или логического ресурса и выдают к ним стандартизированный файловый интерфейс взаимодействия;

  3. Рассмотрели множество примеров «необычных файлов» и поняли мощь настолько простых, но настолько изящных и фундаментальных read()/write().

Спасибо и до встречи на Хабре!

❯ Литература

Habr:

  1. Ассемблер: рассматриваем каждый байт «Hello, World!». Как на самом деле работают программы на уровне процессора и ОС

YouTube:

  1. Все ли является файлом в Linux?

  2. Как работают SSD? Как ваш смартфон хранит данные? Branch Education на русском

  3. How a Single Bit Inside Your Processor Shields Your Operating System's Integrity

  4. Первый модуль ядра на C и инструменты для его разглядывания • Live coding


Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале

Комментарии (21)


  1. d1nckache
    08.09.2025 10:22

    Огонь! Иниересная тема


  1. sunUnderShadow
    08.09.2025 10:22

    Побольше бы таких статей на хабре


  1. atues
    08.09.2025 10:22

    К списку литературы. Есть, хотя и старенькая, но бомбическая книга Андрея Робачевского по Unix. Из новых мне понравилась https://dmkpress.com/catalog/computer/os/978-5-97060-932-3/


    1. zpnst Автор
      08.09.2025 10:22

      Первый раз вижу, спасибо! Посмотрю


  1. unreal_undead2
    08.09.2025 10:22

    Не упомянули ioctl(), с которым юниксовый файл является по сути объектом с произвольным интерфейсом.


    1. zpnst Автор
      08.09.2025 10:22

      Да, решил не перегружать. Отличная домашняя работа для тех, кто зайдёт почитать комментарии ;)


    1. digrobot
      08.09.2025 10:22

      ioctl() портит всю красивую абстракцию, потому что в него пихают всё, что не уместилось в read() и write() =)


      1. zpnst Автор
        08.09.2025 10:22

        Ну да, его многие не любят)


        1. unreal_undead2
          08.09.2025 10:22

          Включая авторов Unix, выкинувших его в Plan 9.


          1. zpnst Автор
            08.09.2025 10:22

            Plan 9 очень интересная система, надо бы по ней тоже статью написать, спасибо за идею!


      1. mirwide
        08.09.2025 10:22

        Что не так с ioctl? Далёк от системного программирования, на неоптный глаз он выглядит удобно. Если во write перепутать fd можно сделать коллапс вселенной. В ioctl просто получим ошибку, за счёт того что в команде присутствует код устройства. Тип операции там тоже есть, те его цель не запихнуть всё что не запихнулось, а небольшая абстракция для систематизации апи.


        1. Dima_Sharihin
          08.09.2025 10:22

          ioctl - это "потекший" интерфейс - то есть сознательный отказ от соглашений, потому что между вызывающим и вызываемым практически нет никаких проверок.
          и не зная с чем вы работаете - вы никогда не поймете как нужно работать.
          Да, для сокетов, tty и прочих есть соглашения, но в общем виде - соглашения нет.

          Но с точки зрения ABI классно, да)


        1. unreal_undead2
          08.09.2025 10:22

          Тип операции там тоже есть

          Просто некий unsigned long, который в разных устройствах может означать разные вещи. Параметры операции - так и вовсе список из переменного числа произвольных аргументов.


  1. GospodinKolhoznik
    08.09.2025 10:22

    Почему в Linux «Всё есть файл»?

    Поймём, что системные вызовы – это всё, что у нас есть (больше реально ничего нет);

    А системные вызовы это не файл ))


    1. zpnst Автор
      08.09.2025 10:22

      Разумеется, думаю, это очевидно из описания в статье. А “Everything is a file” - это официальное название https://en.m.wikipedia.org/wiki/Everything_is_a_file :)


      1. GospodinKolhoznik
        08.09.2025 10:22

        Это я так душно пошутил. Меня всегда забавляли утверждения в духе "всё есть объект" или "всё есть функция" и я в таких случаях целенаправлено ищу то, что не является объектом в Java или функцией в Haskell, например.


        1. makartarentiev
          08.09.2025 10:22

          Или table в lua?)


  1. sergeyns
    08.09.2025 10:22

    когда я осознал, что сохранив фотографию на ноутбук и положив его после этого в пыльный угол или под кровать, спустя, скажем, 10 лет, я всё ещё смогу его включить и посмотреть на эту фотографию.

    Я бы не был столь уверен, особенно в случае SSD дисков... И просто ноут включить не сможете (батарея сдохла), да и с SSD может много чего интересного произойти (с тем самым плавающим затвором, а точнее с зарядом..)


    1. zpnst Автор
      08.09.2025 10:22

      Согласен, 10 лет для рядового SSD - это большой срок, но всё же… это был лирический раздел статьи ;)


  1. Cubus
    08.09.2025 10:22

    А можно не городить скрипты на Питоне, а сделать true unix way:

    $ sudo cat /dev/input/mouse1|xxd -c 3 -g 1

    Получится примерно так:

    00000000: 28 01 fe  (..
    00000003: 28 02 fe  (..
    00000006: 28 02 fe  (..
    00000009: 28 00 ff  (..
    0000000c: 28 01 ff  (..
    0000000f: 28 00 ff  (..
    00000012: 28 02 fe  (..
    00000015: 28 02 fe  (..
    00000018: 28 00 ff  (..
    0000001b: 28 02 fe  (..
    0000001e: 28 01 ff  (..
    00000021: 28 01 ff  (..
    00000024: 08 02 00  ...
    00000027: 08 03 00  ...
    0000002a: 08 02 01  ...
    0000002d: 08 02 01  ...
    00000030: 08 02 00  ...
    00000033: 08 02 01  ...
    00000036: 08 01 00  ...
    00000039: 08 01 01  ...



    1. zpnst Автор
      08.09.2025 10:22

      да, очень лаконично и красиво!