habrahabr

Голый Линукс — запуск ядра-одиночки

  • суббота, 9 ноября 2024 г. в 00:00:08
https://habr.com/ru/articles/855804/

Итак, Linux - не операционная система, а только ядро для неё. Всё остальное приходит от проекта GNU (и других). И вот интересно - на что годится ядро само по себе?

Эта статья - очень "начального" уровня. Устроим маленький эксперимент - создадим чистую виртуальную машину и попробуем запустить ядро Linux "без всего". Или почти "без", т.к. нам понадобится загрузчик ОС - и какая-нибудь "пользовательская программа" (её мы сотворим сами). Конечно, продвинутые пользователи Linux такой "эксперимент" могут провести просто отредактировав параметры запуска при включении - но наш рассказ всё же для тех кто почти (или совсем) не в теме :)

Бонусом чуть-чуть коснёмся системных вызовов и пару слов скажем о других ядрах.

Что такое ядро - и как им воспользоваться

На данном этапе нам достаточно представлять что ядро - некая программулина в виде здорового файла. Говоря по-программистски - фреймворк для запуска наших программ - и в то же время большая библиотека системных вызовов и коллекция драйверов.

Иными словами ядро воплощает работу со всеми основными сущностями ОС (в частности, процессами - в которых будут запускаться другие программы) - а также содержит драйвера для работы со всевозможным оборудованием компьютера. Ну, не все возможные на свете драйвера - но для наиболее актуальных и популярных систем. Для дисков, экрана, сетевых интерфейсов.

Поэтому наша цель - запустить ядро и передать управление небольшой демонстрационно программке которая сможет успешно "дёргать" эти системные вызовы. Попутно мы рассмотрим некоторые дополнительные вещи - хотя не актуальные для нашего эксперимента - но о которых полезно иметь представление.

Создайте виртуальную машину

Вообще можно и на физической машине потренироватся, но мы рекомендуем начать с виртуалки. Скачайте VirtualBox (или другой эмулятор, если у вас есть какой-то любимый), создайте новую виртуалку (можно задать ей гигабайт оперативки, а можно и меньше - и диск автоматического размера - нам потребуется совсем немного).

Здесь и далее мы хотя говорим подробно о шагах которые следует выполнить, но всё же избегаем излишне детальных указаний "нажмите такую-то кнопку". Пусть подобные мелочи останутся именно в качестве "упражнения" :) В частности интерфейс VirtualBox довольно интуитивный, да и нагуглить вопросы по ней легко.

пустая виртуалка - кнопка настроек (шестеренка) и запуска (стрелка) сверху
пустая виртуалка - кнопка настроек (шестеренка) и запуска (стрелка) сверху

Итак, машина создана - если вы попытаетесь её запустить, появится чёрный экранчик с сообщением что не найдено устройство с которого можно загрузиться. Оно и понятно - ведь диск пока девственно чист. Нужно записать на него загрузчик. Очевидно для любых операций нужно временно запуститься с какого-нибудь LiveCD. Опять же можете выбрать по своему усмотрению, но я рекомендую (для данной цели) скачать относительно небольшой образ SystemRescueCD. Скачайте образ и подключите в настройках созданной виртуалки в качестве компакт-диска. Перезапустите машину - после загрузки должно появиться загрузочное меню системы на LiveCD:

вообще эта штука может и в хозяйстве пригодиться
вообще эта штука может и в хозяйстве пригодиться

Сейчас мы запустим дефолтный пункт меню (просто нажмите Enter) - но на будущее обратите внимание на пункт "Boot existing OS" - он отменяет загрузку с LiveCD и грузит то что у вас установлено на основном диске - мы будем этим пользоваться чтобы удобнее проверять что получилось.

Подготовка жёсткого диска

Итак нажмите "Boot SystemRescueCD using default options". Начнётся какая-то активность которая рано или поздно закончится надписью "automatic login" и ниже приглашением командной строки в духе [root@systemresccd ~]# - в принципе можно запустить графическую оболочку (startx) но нам это сейчас не нужно.

SystemRescueCD грузится в консольный режим но предлагает запустить оконный интерфейс
SystemRescueCD грузится в консольный режим но предлагает запустить оконный интерфейс

Наш жёсткий диск виден среди девайсов, попробуйте ввести ls /dev/sd* и вы должны обнаружить вероятно диск /dev/sda - он ещё не разбит на разделы (вроде /dev/sda1) - и этим мы сейчас займёмся.

Запустите утилиту fdisk указав ей в качестве параметра обнаруженный диск, то есть fdisk /dev/sda - у неё простой интерфейс, команды однобуквенные - и сразу напоминает что для списка команд можно нажать m (почему-то).

список команд появляющийся по команде "m"
список команд появляющийся по команде "m"

Из всего этого многообразия нам нужно немного:

  • создать новую таблицу разделов (сделайте DOS partition table) нажав "o"

  • создать новый раздел (первичный) нажмите "p" и выделите под него весь диск

  • сделайте этот раздел загружаемым (toggle bootable flag) нажав "a"

  • можете проверить получившуюся таблицу нажав "p"

  • и наконец запишите все изменения (и выйдите) нажав "w"

Теперь команда ls /dev/sd* будет сообщать что у вас появился ещё и раздел /dev/sda1 - им мы будем активно пользоваться в дальнейшем.

В частности нужно создать на нём файловую систему - это простая команда
mkfs.ext4 /dev/sda1

Теперь всё готово к записи загрузчика. Ну или почти всё (но об этом чуть позже).

Можно было создать таблицу разделов GPT (не имеющую отношения к модному сейчас ИИ) - но для наших целей это не важно, а более архаичная MBR немного упростит эксперимент.

Загрузчик "extlinux"

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

  • также популярный systemd-boot - но он по-моему требует EFI что создаёт дополнительные ненужные шаги в нашем эксперименте

  • syslinux / extlinux - а вот их мы и возьмём в дело, тем более что они использованы для самого SystemRescueCD

Мы используем extlinux - это версия syslinux для ext4fs, линуксовой файловой системы. Если вы попробуете запустить его из командной строки он выдаст подсказку, которая сообщает среди прочего что диск для установки нужно сперва примонтировать.

У нас есть пустая директория /mnt - давайте туда его и подключим:

mount /dev/sda1 /mnt

Это необязательно, но чтобы не нарушать популярной схемы размещения файлов, давайте создадим папку /boot в корне:

mkdir /mnt/boot

Теперь всё готово к записи загрузчика, используйте команду которую он и сам подсказывает:

extlinux --install /mnt/boot

Он поместит пару файлов в указанный каталог а кроме того (благодаря ключу --install) запишет загрузочный код в начало раздела. К сожалению этого ещё недостаточно для запуска, сейчас мы убедимся.

Можете для любопытства посмотреть что теперь в корневой папке нашего диска и в папке /boot - с помощью команд ls /mnt и ls /mnt/boot сооответственно - когда налюбуетесь, давайте отмонтируем диск (чтобы быть уверенными что всё записалось) командой umount /mnt после чего выполним следующее:

  • в настройках виртуалки (меню Devices) извлеките виртуальный CD

  • в меню Machine нажмите Reset

Машина перезагрузится и снова пожалуется что у вас нет загрузочного девайса!

Это потому что отсутствует загрузочный код в самом первом секторе таблицы разделов (MBR - master boot record). Вставьте виртуальный диск обратно, перезагрузите машину снова в SystemRescue и давайте исправим этот недочёт.

Запись MBR - мелкий штрих

Вообще-то это опционально. Можно обойти проблему стартуя с SystemRescueCD и выбирая пункт "Boot existing OS" - в этом случае "эстафетная палочка" загрузки переходит сразу к нужному разделу диска, минуя MBR. Но всё же потратим пару минут чтобы сделать сразу хорошо.

Где-то в недрах файловой системы лежит файл mbr.bin - в нём как раз код который нужно записать. Найдите его командой

find / -name mbr.bin

у меня он оказался в /usr/lib/syslinux/bios/mbr.bin - запишите его на диск командой cat:

cat /usr/lib/syslinux/bios/mbr.bin > /dev/sda

(просто sda а не sda1 - т.к. это MBR). Теперь если вы повторите эксперимент с перезагрузкой, вы должны увидеть что extlinux запустился - он скажет что не нашёл конфигурационного файла и покажет приглашение boot: - в принципе тут можно вручную указать ядро и параметры загрузки. Но ядра у нас пока нет.

Добавим ядро, а лучше два

Итак, вновь перезагрузите машину в SystemRescue - в дальнейшем не "извлекайте" виртуальный CD а когда требуется попробовать загрузку с жёсткого диска просто используйте пункт "Boot Existing OS" из загрузочного меню.

Примонтируйте жёсткий диск как и раньше и перейдите в директорию /mnt/boot - давайте затащим сюда ядро!

А где его взять? нетрудно догадаться что как минимум одно должно быть где-то в недрах самого SystemRescueCD - попробуем найти его (оно обычно имеет название начинающееся с vmlinuz:

find / -name vmlinuz*

У меня оно нашлось например где-то в недрах /usr/lib - файл размером около 5 мегабайт. Скопируем его (находясь в /mnt/boot):

cp /usr/lib/.../vmlinuz vmlinuz1

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

Дело в том что Linux (и другие *nix системы) позволяют легко подменять ядра, выбирая нужное при загрузке. Второе ядро я взял из основной ОС на моём ноутбуке (Ubuntu 18.04 кажется). У вас под рукой такой возможности может не быть, но наверняка вы можете найти разные ядра в интернете. Свои я загрузил на гитхаб - так что вы можете воспользоваться прямой ссылкой:

wget https://github.com/RodionGork/bare-linux-experiment/raw/refs/heads/main/vmlinuz64

Естественно, сделайте это в той же папке /mnt/boot чтобы ядра лежали рядом с загрузочными файлами (это необязательно но удобно). Если вы обнаружите что виртуалка не может достучаться в сеть, проверьте настройки сети (в ней) - для запросов в интернет проще всего выбрать NAT.

Внимание: использовать "готовые" ядра затащенные непонятно откуда - плохая идея! По-хорошему нужно взять исходники и вдумчиво скомпилировать ядро нужной версии и с требуемыми настройками. Мы пропускаем этот шаг только для упрощения эксперимента!

Так или иначе, надеюсь запастись ядрами вам удалось и команда ls /mnt/boot показывает наличие файлов vmlinuz1 и vmlinuz64 - попробуем их загрузить!

Не забудьте umount, а теперь перезапускайте машину и выбирайте "Boot existing OS".

В приглашении загрузчика, которое выглядит как boot: пишите /boot/vmlinuz1 например - полный путь до скачанного нами ядра. Жмите Enter.

Через пару секунд активной деятельности на экране появится сообщение c "kernel panic" и "Unable to mount root fs..."

Прекрасно, ядро грузится но зачем-то хочет какую-то "VFS"?

Готовим initrd - виртуальную файловую систему

Дело обстоит так что современные *nix-овые ядра предполагают такой порядок загрузки, что сразу после запуска оно ищет небольшой образ с файловой системой которую можно развернуть прямо в оперативке. А уж остальные файловые системы (на диске и т.п.) подключить потом, проведя разные дополнительные инициализации.

Образ с этой файловой системой передаётся параметром ядра initrd=... (от слов "init root directory" что ли)

Мы подготовим ему такой образ, состоящий из всего одного файла - нашей пользовательской программы! Здесь мы пойдём на ещё один трюк - первая программа которую ядро пытается запустить - это init - некий самый главный процесс ОС. Вот мы и назовём исполнимый файл нашей приложеньки именно так - и разместим в корне.

Далее мы попробуем написать и скомпилировать пару незамысловатых программ - если у вас под рукой нет на чем их скомпилировать - не беда - вы сможете скачать готовые образа initrd в том же репозитории где лежат ядра.

Напишем незамысловатую программу на С - она просто вводит строчки от пользователя и печатает их длину:

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

int main() {
  printf("I'm mini-shell, type in your commands:\n");
  while (1) {
    char ur[1024];
    fgets(ur, sizeof(ur), stdin);
    if (ur[0] < ' ') break;
    printf("%ld - not supported\n", strlen(ur));
  }
  return 0;
}

Программа представляется как "mini-shell" хотя на самом деле конечно никакой это не shell - если вы захотите добавить здесь какие-то полезные команды, придётся их заимплементить. Пока что простим себе этот маленький обман и соберем программу, указав ключ статической компиляции (т.к. никаких динамических библиотек у нас под рукой не будет). Эта программа использует только функции стандартной библиотеки C (которые будут добавлены в исполнимый код) и системные вызовы ядра для ввода и вывода - так что все должно быть в порядке. Назовите файл init.c

gcc --static -o init init.c

Теперь нужно закинуть скомпилированный файл init в виртуалку. В ней во-первых перезагрузитесь снова в SystemRescueCD, примонтируйте диск и перейдите в /mnt/boot - а во-вторых переключите настройки сети на "host network only" (перед этим нужно в настройках самого VirtualBox создать новый host-only адаптер). После эого вы сможете либо приконнектиться из виртуалки к родительской машине по sftp и стянуть файл, либо запустите на родительской машине какой-нибудь веб-сервер (хотя бы python3 -m http.server) и из виртуалки вытяните файл wget-ом. В обоих случаях адрес родительской машины будет что-то в духе 192.168.56.1 (проверьте ifconfig-ом).

Когда вам удалось заполучить скомпилированный файл init в виртуалке, убедитесь что у него присутствует исполнимый флаг (или просто проставьте его для уверенности chmod u+x init) - теперь нам нужна уличная магия собирающая образ с помощью команды cpio:

echo init | cpio -o --format=newc > initrd-c

в результате появится файл initrd-c который мы и собираемся использовать при запуске. Отмонтируем диск, перезагружаемся в "existing OS" и пробуем загрузить ядро указав нужный initrd файл:

boot: /boot/vmlinuz1 initrd=/boot/initrd-c

С большой долей вероятности эта попытка обломится - вы увидите похожий экран с логом загрузки, однако утверждающий что не удалось запустить init. Немного выше по логу возможно будет отыскать конкретную ошибку, например (error -8), как-то так:

Если вы увидите error -13 - это означает "permission denied" - вы забыли сделать файл исполнимым. А вот error -8 про другое "wrong executable file format" - файл собран под 64-битную систему а ядро запускает 32-битную.

Естественно это зависит от того на какой системе и как компилировали файл.

Исправить ситуацию можно двумя способами - либо попробуйте указать другое ядро при запуске (то которое vmlinuz64) - либо возьмите другой initrd-файл - например в репозитории упомянутом выше есть initrd-asm - в нём init собранный маленькой программой на ассемблере (её код там тоже где-то есть для любопытных - своего рода "hello-world"). Если захотите собрать сами (исходник там рядом лежит), используйте команды

as --32 init.c && ld -m elf_i386 -o init a.out

После чего затащите его в виртуалку и запакуйте как и раньше (для удобства предлагаю файл назвать иначе - например initrd32 или initrd-asm).

Что касается этой ассемблерной программы - она лишь развитие примерчика из прошлой статьи про 5 ассемблеров - если вам любопытно углубиться, я обязательно напишу отдельную статейку с разбором этой программульки после которой вы сами сможете писать подобные - для развлечения или в образовательных целях!

Думаю, с нескольких попыток вам повезет :) Вы либо увидите сообщение что "mini-shell" готов к вашим экспериментам - попробуйте вводить строки а когда надоест нажмите Ctrl-C - чтобы узнать что случается если init-процесс в линуксе завершается. Либо увидите сообщение про "nedo-bash".

С vmlinuz64 наш "недо-баш" умеет читать с клавиатуры строчки и сообщает их длину
С vmlinuz64 наш "недо-баш" умеет читать с клавиатуры строчки и сообщает их длину

Конфигурация для загрузчика extlinux

Можно добавить рядом с файлами загрузчика файл конфигурации, чтобы по умолчанию загружалось некоторое выбранное ядро с некоторым выбранным initrd (и прочими опциями если нужно). Для этого в SystemRescueCD примонтируйте диск (в очередной раз) и используя vi или nano создайте файл /mnt/boot/extlinux.conf с примерно таким содержимым:

prompt 1
timeout 100
default testlinux

label testlinux
kernel vmlinuz64
append initrd=initrd-c

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

Заключение

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

Надеюсь вам удалось справиться с этим "упражнением" до конца. Как вы понимаете - оно лишь отправная точка для дальнейших экспериментов. Остались разнообразные интересные вопросы которые можно поисследовать.

Например можно утащить initrd файл с Убунты (он весит около 50мб) - и попробовать подключить его. Вы получите уже более менее рабочую систему - однако убедитесь что надо создать на диске кое-какие папки (вроде /dev) да и подмонтировать его как рутовую систему.

А можно поинтересоваться, почему наш "nedo-bash" хотя выводит сообщение на экран, но ввод осуществляет только будучи запущенным с одним из двух ядер - как будто у другого ядра не включена клавиатура. Впрочем тут лучше вернуться к совету упомянутому выше - не стоит использовать непонятные-незнакомые ядра. Попробуйте собрать своё.

Отдельным направлением может быть эксперимент с другими ядрами - возьмите ядро от Gnu Hurd - или от FreeBSD. Правда компилируя для них программы нужно иметь в виду что у них немного отличающийся формат системных вызовов в сравнении с Linux (так что просто собрав программу на Linux-машине вы возможно не получите то что нужно).

Вообще это отдельная интересная тема - номера системных функций у *nix-овых систем совпадают, у Линукса в том числе - но во-первых в 64-битном линуксе их внезапно перетасовали, во-вторых Линукс делает вызовы передавая параметры в регистрах (а-ля ДОС) - в то время как остальные пушают их "по-сишному" через стек. В общем, тоже нужна отдельная статья!

Пожалуй часть этих вопросов я сам постараюсь осветить в ближайшее время, они достаточно занятны!