Недавно мне посчастливилось взять месячный творческий отпуск без работы. Хотя большую часть этого времени я проводил в разъездах и держался подальше от компьютера, инженеру трудно полностью отключиться. Если я собирался создать что-то для развлечения, это должно было сильно отличаться от моей повседневной работы. Я закончил тем, что создал DOS-совместимую ОС прямо из 80-х.

Что? Почему?

Я всегда был большим фанатом ретро-вычислений. После того, как в прошлом году я начал заниматься написанием эмуляторов, я воскресил часть этого фанатизма, создав серию эмуляторов MOS 65XX - C64, Atari 2600, NES… Размышляя о внутреннем устройстве этих систем, я прочитал о том, как работали ранние ПК IBM. Вместо того, чтобы брать на себя серьезную задачу по созданию эмулятора ПК (я признаю, что реализация инструкций процессора через некоторое время становится довольно невыносимой), я решил написать DOS-совместимую ОС.

Вот краткое руководство / напоминание о том, что означает совместимость с DOS. Еще в 80-х у Microsoft была самая популярная операционная система для ПК: MS-DOS. В то время как такие компании, как Compaq, создавали клоны ПК, аппаратно совместимые с компьютерами IBM, другие компании создавали операционные системы, совместимые по коду с MS-DOS. Некоторые добавили новые конкурентоспособные функции, другие были выпущены с учетом конкретного оборудования. Если вы реализуете те же API, что и MS-DOS, программное обеспечение, написанное для ОС Microsoft, будет работать и на вас. Это было важно, когда сотрудникам вашего офиса требовалось запустить Lotus 1–2–3 на вашей сторонней системе.

Моим первым компьютером был 486-й компьютер, который в конечном итоге работал с шестью точками DOS. Хотя я писал программы QBasic для этой штуки, я определенно не писал на ней никаких сборок, не говоря уже о том, что такое системный вызов. Реализация DOS API была опытом глубокого обучения благодаря документации, которую часто можно найти в старых уголках Интернета.

Итак, что я построил?

После месяца непрерывного кодирования вот что у меня есть на момент написания: ядро, которое реализует примерно половину расширенного DOS API; базовая поддержка драйверов для дисководов, консоли и системных часов; реализация файловой системы FAT-12; командная строка COMMAND.COM, которая использует API-интерфейсы DOS для выполнения основных внутренних команд, списка каталогов и выполнения других программ COM. По большей части это хитроумно и наполовину закончено, но с его помощью достигается первоначальная цель - возможность запускать некоторые программы, созданные для DOS.

Я надеюсь сделать еще много работы: улучшенная поддержка каталогов, одновременная работа с несколькими дисками, конвейерная обработка и перенаправление, поддержка FAT-16 и, возможно, других файловых систем, а также текстовый редактор. Хорошая новость заключается в том, что после моих первоначальных экспериментов я вернулся и реструктурировал ядро ​​ядра, чтобы упростить переключение между дисками, драйверами и файловыми системами, поэтому многие из этих вещей должны быть возможны.

Становиться реальным

Практически каждое сегодняшнее руководство по операционной системе начинается с вывода вашего процессора из реального режима. До того, как процессоры реализовали современные функции ОС, такие как виртуальная память и аппаратная защита, запущенный код был безумно свободным для всех, когда любая программа могла перезаписать что угодно в памяти - это известно как Real Mode, и каждый процессор в семействе x86 начинается с этого. . Вам необходимо указать процессору перейти в защищенный режим, прежде чем вы сможете получить доступ к более чем 1 МБ памяти или использовать какие-либо средства защиты памяти.

Первые версии DOS были написаны для Intel 8086, когда единственным режимом был Real Mode. На протяжении всей жизни MS-DOS каждая версия реализовывалась в реальном режиме для обеспечения совместимости. В результате моя DOS также должна была быть программой в реальном режиме, что противоречит всем руководствам по ОС, написанным за последние 30 лет. Нет ни пейджинга, ни GDT, ни звонков, куда мы направляемся.

Одна из самых странных причуд реального режима - сегментация памяти. В реальном режиме для адресов памяти используются только 16-битные регистры, но вы можете получить доступ к более чем 64 КБ ОЗУ. Это было достигнуто с помощью второго набора регистров, называемых сегментами: один для кода, один для данных и один для стека. Полный 1 МБ памяти можно адресовать с помощью комбинации сегмента и смещения. Сегмент был сдвинут на четыре бита и добавлен к смещению для создания 20-битного адреса.

Сегментированные адреса представлены с использованием четырех шестнадцатеричных цифр для сегмента и четырех шестнадцатеричных цифр для смещения, разделенных двоеточием: 0x2020: 4300. Поскольку каждый сегмент представляет собой 16-байтовое смещение, существует множество различных способов представления одного и того же местоположения. Я буду говорить об этом больше, когда буду говорить о выполнении программ в DOS, а пока скажу, что работа с сегментами - огромная боль при переключении контекстов. К сожалению, API-интерфейсы DOS сильно зависят от сегментов, поэтому в определенные моменты их невозможно избежать.

После того, как были представлены 32-разрядные процессоры, без сегментации можно было адресовать полные 4 ГБ, и с тех пор сегменты практически игнорировались.

Загрузка

Загрузка ПК началась с программы под названием BIOS, которая настраивает процессор и обеспечивает базовый доступ к оборудованию. В настоящее время большинство компьютеров на базе Intel используют UEFI, но для нашей DOS хронологически целесообразно полагаться на BIOS. Прежде чем я взялся за этот проект, я недооценил, насколько функциональными возможностями обладает BIOS. Когда вы находитесь в реальном режиме, вы можете получить доступ к широкому спектру функций диска и видео через программные прерывания.

Когда BIOS находит загрузочный диск, он копирует небольшой фрагмент кода из первого раздела диска в память по адресу 0x7c00 и запускает его. В идеале этот код - загрузчик - скопирует вашу операционную систему с диска в память и перейдет к ней.

Мой загрузчик пытается скопировать многое из того, что делали оригинальные системы DOS. Они загрузили в память первые несколько блоков драйверов оборудования, IO.SYS. Этот первый бит IO.SYS затем загрузит остальную часть себя с диска, настроит систему, загрузит ядро ​​ОС из другого файла и, наконец, запустит командную строку. Для простоты я объединил драйверы и ядро ​​в один исполняемый файл и просто загрузил все сразу из загрузчика.

Ядро

Основная функция ядра - настроить API системного вызова, который программы могут вызывать и запускать какой-либо эффект. Под капотом реализации этих системных вызовов есть много сложностей, но на поверхности это практически все, что делает ядро. Эти системные вызовы запускаются вызовом программного прерывания 0x21 или, как это часто писалось в коде, int 21h. В зависимости от значений других регистров ЦП во время системного вызова DOS определит, какой метод запустить.

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

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

Делаем это с помощью Rust

Все мои эмуляторы были написаны на Rust, и мне показалось, что это хороший язык системного программирования для этого проекта. Для современных операционных систем Rust - фантастический выбор, учитывая его современный набор функций и безопасность памяти, но нацеливание на поведение DOS - это совсем другое дело. Я быстро обнаружил, что многие функции DOS основаны на небезопасном поведении, а некоторые стандартные функции нельзя заставить работать в статическом исполняемом файле в реальном режиме. Тем не менее, свести к минимуму небезопасную поверхность ядра и максимально использовать идиоматический Rust во время этого проекта оказалось забавной задачей.

Мое ядро ​​начиналось, как и многие фантастические руководства по Rust OS: исполняемый файл, созданный без stdlib, без main и внешней точки входа:

#![no_std]
#![no_main]
#[no_mangle]
pub extern "C" fn _start() {
  // kernel code here
}
// Also, we need to override panic behavior
#[no_mangle]
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
  loop {}
}

Большая часть строительных лесов для создания статической цели была взята из первого раздела Подробного руководства Филиппа Оппермана, с некоторыми другими частями, задействованными, чтобы заставить исполняемый файл работать в реальном режиме. Я написал краткую точку входа в сборку, которая настраивает сегментные регистры и стек перед вызовом функции _start.

Ядро и все подпрограммы построены как статические программы без перемещения. Когда программа компилируется до сборки, адреса статических данных или функций должны быть известны во время выполнения. Современные исполняемые программы обычно могут запускаться в любом месте памяти, потому что операционная система хоста выполняет дополнительную настройку или перезаписывает эти адреса, но мое ядро, работающее с нулевым шаблоном, должно быть статическим. Это привело к некоторым неожиданным проблемам, главная из которых заключалась в том, что я не могу использовать встроенные методы форматирования строк. На самом деле я не хочу их использовать - я пытаюсь сделать свое ядро ​​тонким, так как у него всего 1 МБ общей памяти, - но это означает, что код, который может вызвать панику, не будет компилироваться. Обычно это сводится к двум различным случаям: индексирование массивов / фрагментов по переменной, которая может быть за пределами границ, или деление с помощью переменной, которая может быть равна нулю. Однако это решается путем добавления проверки границ вокруг такого кода, что не так уж и плохо. В конце концов, это как проверка на наличие небезопасного кода во время компиляции, и кто может на это пожаловаться?

В процессе разработки я также столкнулся со странной проблемой, когда код, превышающий определенный размер, не мог быть статически связан - компоновщик отказывался вычислять определенные смещения. В конечном итоге это было связано с тем, как ядро ​​создавалось как исполняемый файл. Поскольку все статично, и я не полагаюсь ни на что конкретное для исполняемых форматов, мне на самом деле не нужно, чтобы ядро ​​было «исполняемым файлом». Я переделал скрипты сборки, скомпилировал ядро ​​как статическую библиотеку, и все работает нормально. Честно говоря, я недостаточно знаю о компоновщике LLVM для дальнейшей отладки, но мое текущее решение на данный момент разблокировало меня.

Стеки, кучи и статика

Безопасность памяти в Rust по большей части сосредоточена на переменных, размещенных в стеке, где они могут быть очищены, когда они выходят за пределы области видимости, и их владение можно отслеживать. Мое ядро ​​не использует пространство кучи или распределитель, потому что все объекты, с которыми я имею дело, являются либо объектами на основе «стека», которые существуют в течение всего времени жизни ядра, либо статическими объектами, расположение которых известно во время компиляции.

Примерно в то время, когда была написана DOS, программы на ассемблере выделяли области статической памяти, которые они использовали бы для хранения переменных. Они могут быть инициализированы или неинициализированы, но они должны быть известны во время компиляции. Это означало, что они не могли быть переменного размера, но вы все равно могли многое делать со статическими объектами и массивами. Между системными вызовами ядру необходимо поддерживать какое-то состояние, например, текущий каталог. Чтобы адресовать их в предсказуемых местах, я использую несколько static mut переменных, эквивалент областей статической памяти из исходных версий DOS. В Rust они считаются небезопасными, поскольку их нельзя использовать совместно между потоками, но в однопоточном ядре их можно использовать без опасений.

Сборка ОС

Я уже немного упоминал о том, как ядро ​​компилируется из исходного кода Rust, но я объясню это более подробно. Код Rust компилируется в статическую программу и объединяется с простой точкой входа в сборку с использованием сценария компоновщика, который отбрасывает все ненужные дополнения. Вспомогательные программы, такие как командная строка, также компилируются и объединяются со своими собственными заголовками сборки. Инициализируется образ гибкого диска FAT-12, и загрузчик копируется в часть первого сектора диска. Наконец, каждый из системных файлов копируется в образ диска с помощью GNU mtools.

Образ дискеты загружается в QEMU, где загрузчик начинает загрузку ядра, драйверов и командной строки:

Следующий

ОБНОВЛЕНИЕ: я продолжил этот пост исследованием внутреннего устройства ядра. В другом посте будут обсуждаться COM-файлы, как их можно написать на Rust и как они могут обращаться к DOS API.

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