Writing an OS in Rust

Philipp Oppermann's blog

Независимый бинарный файл на Rust

Переведенное содержание: Это перевод сообщества поста A Freestanding Rust Binary. Он может быть неполным, устаревшим или содержать ошибки. Пожалуйста, сообщайте о любых проблемах!

Перевод сделан @MrZloHex.

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

Этот блог открыто разрабатывается на GitHub. Если у вас возникли какие-либо проблемы или вопросы, пожалуйста, создайте issue. Также вы можете оставлять комментарии в конце страницы. Полный исходный код для этого поста вы можете найти в репозитории в ветке post-01.

Содержание

🔗Введение

Для того, чтобы написать ядро операционной системы, нужен код, который не зависит от операционной системы и ее возможностей. Это означает, что нельзя использовать потоки, файлы, кучу, сети, случайные числа, стандартный вывод или другие возможности, которые зависят от ОС или определённого железа.

Это значит, что нельзя использовать большую часть стандартной библиотеки Rust, но остается множество других возможностей Rust, которые можно использовать. Например, итераторы, замыкания, сопоставление с образцом, Option и Result, форматирование строк и, конечно же, систему владения. Эти функции дают возможность для написания ядра в очень выразительном и высоко-уровневом стиле, не беспокоясь о неопределенном поведении или сохранности памяти.

Чтобы создать ядро ОС на Rust, нужно создать исполняемый файл, который мог бы запускаться без ОС.

Этот пост описывает необходимые шаги для создания независимого исполняемого файла на Rust и объясняет, почему эти шаги нужны. Если вам интересен только минимальный пример, можете сразу перейти к итогам.

🔗Отключение стандартной библиотеки

По умолчанию, все Rust-крейты подключают стандартную библиотеку, которая зависит от возможностей операционной системы, таких как потоки, файлы, сети. Она также зависит от стандартной библиотки C libc, которая очень тесно взаимодействует с возможностями ОС. Так как мы хотим написать операционную систему, мы не можем использовать библиотеки, которые зависят от операционной системы. Поэтому необходимо отключить автоматические подключение стандартной библиотеки через атрибут no_std.

Мы начнем с создания нового проекта cargo. Самый простой способ сделать это — через командную строку:

cargo new blog_os --bin -- edition 2018

Я назвал этот проект blog_os, но вы можете назвать как вам угодно. Флаг --bin указывает на то, что мы хотим создать исполняемый файл (а не библиотеку), а флаг --edition 2018 указывает, что мы хотим использовать редакцию Rust 2018 для нашего крейта. После выполнения команды cargo создаст каталог со следующей структурой:

blog_os
├── Cargo.toml
└── src
    └── main.rs

Cargo.toml содержит данные и конфигурацию крейта, такие как название, автор, семантическую версию и зависимости от других крейтов. Файл src/main.rs содержит корневой модуль нашего крейта и функцию main. Можно скомпилировать крейт с помощью cargo build и запустить скомпилированную программу blog_os в поддиректории target/debug.

🔗Атрибут no_std

В данный момент наш крейт неявно подключает стандартную библиотеку. Это можно исправить путем добавления атрибута no_std:

// main.rs

#![no_std]

fn main() {
    println!("Hello, world!");
}

Если сейчас попробовать скомпилировать программу (с помоцью команды cargo build), то появится следующая ошибка:

error: cannot find macro `println!` in this scope
 --> src/main.rs:4:5
  |
4 |     println!("Hello, world!");
  |     ^^^^^^^

Эта ошибка объясняется тем, что макрос println — часть стандартной библиотеки, которая была отключена. Поэтому у нас больше нет возможность выводить что-либо на экран. Это логично, так как println печатает через стандартный вывод, который, в свою очередь, является специальным файловым дескриптором, предоставляемым операционной системой.

Давайте уберем макрос println и попробуем скомпилировать еще раз:

// main.rs

#![no_std]

fn main() {}
> cargo build
error: `#[panic_handler]` function required, but not found
error: language item required, but not found: `eh_personality`

Сейчас компилятор не может найти функцию #[panic_handler] и «элемент языка».

🔗Реализация паники

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

// in main.rs

use core::panic::PanicInfo;

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

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

🔗Элемент языка eh_personality

Элементы языка — это специальные функции и типы, которые необходимы компилятору. Например, трейт Copy указывает компилятору, у каких типов есть семантика копирования. Если мы посмотрим на реализацию этого трейта, то увидим специальный атрибут #[lang = "copy"], который говорит, что этот трейт является элементом языка.

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

Элемент языка eh_personality указывает на функцию, которая используется для реализации раскрутки стека. По умолчанию, Rust использует раскрутку для запуска деструктуров для всех живых переменных на стеке в случае паники. Это гарантирует, что вся использованная память будет освобождена, и позволяет родительскому потоку перехватить панику и продолжить выполнение. Раскрутка — очень сложный процесс и требует некоторых специльных библиотек ОС (например, libunwind для Linux или structured exception handling для Windows), так что мы не должны использовать её для нашей операционной системы.

🔗Отключение раскрутки

Существуют и другие случаи использования, для которых раскрутка нежелательна, поэтому Rust предоставляет опцию прерывания выполнения при панике. Это отключает генерацию информации о символах раскрутки и, таким образом, значительно уменьшает размер бинарного файла. Есть несколько мест, где мы можем отключить раскрутку. Самый простой способ — добавить следующие строки в наш Cargo.toml:

[profile.dev]
panic = "abort"

[profile.release]
panic = "abort"

Это устанавливает стратегию паники на abort (прерывание) как для профиля dev (используемого для cargo build), так и для профиля release (используемого для cargo build --release). Теперь элемент языка eh_personality больше не должен требоваться.

Теперь мы исправили обе вышеуказанные ошибки. Однако, если мы сейчас попытаемся скомпилировать программу, возникнет другая ошибка:

> cargo build
error: requires `start` lang_item

В нашей программе отсутствует элемент языка start, который определяет начальную точку входа программы.

🔗Аттрибут start

Можно подумать, что функция main — это первая функция, вызываемая при запуске программы. Однако в большинстве языков есть среда выполнения, которая отвечает за такие вещи, как сборка мусора (например, в Java) или программные потоки (например, goroutines в Go). Эта система выполнения должна быть вызвана до main, поскольку ей необходимо инициализировать себя.

В типичном исполнимом файле Rust, который использует стандартную библиотеку, выполнение начинается в runtime-библиотеке C под названием crt0 ("C runtime zero"), которая создает окружение для C-приложения. Это включает создание стека и размещение аргументов в нужных регистрах. Затем C runtime вызывает точку входа для Rust-приложения, которая обозначается элементом языка start. Rust имеет очень маленький runtime, который заботится о некоторых мелочах, таких как установка защиты от переполнения стека или вывод сообщения при панике. Затем рантайм вызывает функцию main.

Наш независимый исполняемый файл не имеет доступа к runtime Rust и crt0, поэтому нам нужно определить собственную точку входа. Реализация языкового элемента start не поможет, поскольку он все равно потребует crt0. Вместо этого нам нужно напрямую переопределить точку входа crt0.

🔗Переопределение точки входа

Чтобы сообщить компилятору Rust, что мы не хотим использовать стандартную цепочку точек входа, мы добавляем атрибут #![no_main].

#![no_std]
#![no_main]

use core::panic::PanicInfo;

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Можно заметить, что мы удалили функцию main. Причина в том, что main не имеет смысла без стандартного runtime, которая ее вызывает. Вместо этого мы переопределим точку входа операционной системы с помощью нашей собственной функции _start:

#[no_mangle]
pub extern "C" fn _start() -> ! {
    loop {}
}

Используя атрибут #[no_mangle], мы отключаем искажение имен, чтобы гарантировать, что компилятор Rust сгенерирует функцию с именем _start. Без этого атрибута компилятор генерировал бы какой-нибудь загадочный символ _ZN3blog_os4_start7hb173fedf945531caE, чтобы дать каждой функции уникальное имя. Атрибут необходим, потому что на следующем этапе нам нужно сообщить имя функции точки входа компоновщику.

Мы также должны пометить функцию как extern "C", чтобы указать компилятору, что он должен использовать соглашение о вызове C для этой функции (вместо неопределенного соглашения о вызове Rust). Причина именования функции _start в том, что это имя точки входа по умолчанию для большинства систем.

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

Если мы выполним cargo build сейчас, мы получим ошибку компоновщика (linker error).

🔗Ошибки компоновщика

Компоновщик — это программа, которая объединяет сгенерированный код в исполняемый файл. Поскольку формат исполняемого файла отличается в Linux, Windows и macOS, в каждой системе есть свой компоновщик, и каждый покажет свою ошибку. Основная причина ошибок одна и та же: конфигурация компоновщика по умолчанию предполагает, что наша программа зависит от C runtime, а это не так.

Чтобы устранить ошибки, нам нужно сообщить компоновщику, что он не должен включать C runtime. Мы можем сделать это, передав компоновщику определенный набор аргументов или выполнив компиляцию для голого железа.

🔗Компиляция для голого железа

По умолчанию Rust пытается создать исполняемый файл, который может быть запущен в окружении вашей текущей системы. Например, если вы используете Windows на x86_64, Rust пытается создать исполняемый файл Windows .exe, который использует инструкции x86_64. Это окружение называется вашей "хост-системой".

Для описания различных окружений Rust использует строку target triple. Вы можете узнать тройку вашей хост-системы, выполнив команду rustc --version --verbose:

rustc 1.35.0-nightly (474e7a648 2019-04-07)
binary: rustc
commit-hash: 474e7a6486758ea6fc761893b1a49cd9076fb0ab
commit-date: 2019-04-07
host: x86_64-unknown-linux-gnu
release: 1.35.0-nightly
LLVM version: 8.0

Приведенный выше результат получен от системы x86_64 Linux. Мы видим, что тройка host — это x86_64-unknown-linux-gnu, которая включает архитектуру процессора (x86_64), производителя (unknown), операционную систему (linux) и ABI (gnu).

Компилируя для тройки нашего хоста, компилятор Rust и компоновщик предполагают наличие базовой операционной системы, такой как Linux или Windows, которая по умолчанию использует C runtime, что вызывает ошибки компоновщика. Поэтому, чтобы избежать ошибок компоновщика, мы можем настроить компиляцию для другого окружения без базовой операционной системы.

Примером такого "голого" окружения является тройка thumbv7em-none-eabihf, которая описывает ARM архитектуру. Детали не важны, важно лишь то, что тройка не имеет базовой операционной системы, на что указывает none в тройке. Чтобы иметь возможность компилировать для этой системы, нам нужно добавить ее в rustup:

rustup target add thumbv7em-none-eabihf

Это загружает копию стандартной библиотеки (и core) для системы. Теперь мы можем собрать наш независимый исполняемый файл для этой системы:

cargo build --target thumbv7em-none-eabihf

Передавая аргумент --target, мы кросс-компилируем наш исполняемый файл для голого железа. Поскольку система, под которую мы компилируем, не имеет операционной системы, компоновщик не пытается компоновать C runtime, и наша компиляция проходит успешно без каких-либо ошибок компоновщика.

Именно этот подход мы будем использовать для сборки ядра нашей ОС. Вместо thumbv7em-none-eabihf мы будем использовать custom target, который описывает окружение для архитектуры x86_64. Подробности будут описаны в следующем посте.

🔗Аргументы компоновщика

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

Аргументы компоновщика

В этом разделе мы рассмотрим ошибки компоновщика, возникающие в Linux, Windows и macOS, и объясним, как их решить, передав компоновщику дополнительные аргументы. Обратите внимание, что формат исполняемого файла и компоновщик отличаются в разных операционных системах, поэтому для каждой системы требуется свой набор аргументов.

🔗Linux

На Linux возникает следующая ошибка компоновщика (сокращенно):

error: linking with `cc` failed: exit code: 1
  |
  = note: "cc" […]
  = note: /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
          (.text+0x12): undefined reference to `__libc_csu_fini'
          /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
          (.text+0x19): undefined reference to `__libc_csu_init'
          /usr/lib/gcc/../x86_64-linux-gnu/Scrt1.o: In function `_start':
          (.text+0x25): undefined reference to `__libc_start_main'
          collect2: error: ld returned 1 exit status

Проблема заключается в том, что компоновщик по умолчанию включает процедуру запуска C runtime, которая также называется _start. Она требует некоторых символов стандартной библиотеки C libc, которые мы не включаем из-за атрибута no_std, поэтому компоновщик не может подключить эти библиотеки, поэтому появляются ошибки. Чтобы решить эту проблему, мы можем сказать компоновщику, что он не должен компоновать процедуру запуска C, передав флаг -nostartfiles.

Одним из способов передачи атрибутов компоновщика через cargo является команда cargo rustc. Команда ведет себя точно так же, как cargo build, но позволяет передавать опции rustc, базовому компилятору Rust. У rustc есть флаг -C link-arg, который передает аргумент компоновщику. В совокупности наша новая команда сборки выглядит следующим образом:

cargo rustc -- -C link-arg=-nostartfiles

Теперь наш крейт собирается как независимый исполняемый файл в Linux!

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

🔗Windows

В Windows возникает другая ошибка компоновщика (сокращенно):

error: linking with `link.exe` failed: exit code: 1561
  |
  = note: "C:\\Program Files (x86)\\…\\link.exe" […]
  = note: LINK : fatal error LNK1561: entry point must be defined

Ошибка "точка входа должна быть определена" ("entry point must be defined") означает, что компоновщик не может найти точку входа. В Windows имя точки входа по умолчанию зависит от используемой подсистемы. Для подсистемы CONSOLE компоновщик ищет функцию с именем mainCRTStartup, а для подсистемы WINDOWS - функцию с именем WinMainCRTStartup. Чтобы переопределить названия точки входа на _start, мы можем передать компоновщику аргумент /ENTRY:

cargo rustc -- -C link-arg=/ENTRY:_start

Из разного формата аргументов мы ясно видим, что компоновщик Windows - это совершенно другая программа, чем компоновщик Linux.

Теперь возникает другая ошибка компоновщика:

error: linking with `link.exe` failed: exit code: 1221
  |
  = note: "C:\\Program Files (x86)\\…\\link.exe" […]
  = note: LINK : fatal error LNK1221: a subsystem can't be inferred and must be
          defined

Эта ошибка возникает из-за того, что исполняемые файлы Windows могут использовать различные подсистемы. Для обычных программ они определяются в зависимости от имени точки входа: если точка входа называется main, то используется подсистема CONSOLE, а если точка входа называется WinMain, то используется подсистема WINDOWS. Поскольку наша функция _start имеет другое имя, нам нужно явно указать подсистему:

cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"

Здесь мы используем подсистему CONSOLE, но подойдет и подсистема WINDOWS. Вместо того, чтобы передавать -C link-arg несколько раз, мы используем -C link-args, который принимает список аргументов, разделенных пробелами.

С помощью этой команды наш исполняемый файл должен успешно скомпилироваться под Windows.

🔗macOS

На macOS возникает следующая ошибка компоновщика (сокращенно):

error: linking with `cc` failed: exit code: 1
  |
  = note: "cc" […]
  = note: ld: entry point (_main) undefined. for architecture x86_64
          clang: error: linker command failed with exit code 1 […]

Это сообщение об ошибке говорит нам, что компоновщик не может найти функцию точки входа с именем по умолчанию main (по какой-то причине в macOS все функции имеют префикс _). Чтобы установить точку входа в нашу функцию _start, мы передаем аргумент компоновщика -e:

cargo rustc -- -C link-args="-e __start"

Флаг -e задает имя функции точки входа. Поскольку в macOS все функции имеют дополнительный префикс _, нам нужно установить точку входа на __start вместо _start.

Теперь возникает следующая ошибка компоновщика:

error: linking with `cc` failed: exit code: 1
  |
  = note: "cc" […]
  = note: ld: dynamic main executables must link with libSystem.dylib
          for architecture x86_64
          clang: error: linker command failed with exit code 1 […]

macOS официально не поддерживает статически скомпонованные исполняемые файлы и по умолчанию требует от программ компоновки библиотеки libSystem. Чтобы переопределить это поведение и скомпоновать статический исполняемый файл, передадим компоновщику флаг -static:

cargo rustc -- -C link-args="-e __start -static"

Этого все равно недостаточно, так как возникает третья ошибка компоновщика:

error: linking with `cc` failed: exit code: 1
  |
  = note: "cc" […]
  = note: ld: library not found for -lcrt0.o
          clang: error: linker command failed with exit code 1 […]

Эта ошибка возникает из-за того, что программы на macOS по умолчанию ссылаются на crt0 ("C runtime zero"). Она похожа на ошибку под Linux и тоже может быть решена добавлением аргумента компоновщика -nostartfiles:

cargo rustc -- -C link-args="-e __start -static -nostartfiles"

Теперь наша программа должна успешно скомпилироваться на macOS.

🔗Объединение команд сборки

Сейчас у нас разные команды сборки в зависимости от платформы хоста, что не идеально. Чтобы избежать этого, мы можем создать файл с именем .cargo/config.toml, который будет содержать аргументы для конкретной платформы:

# in .cargo/config.toml

[target.'cfg(target_os = "linux")']
rustflags = ["-C", "link-arg=-nostartfiles"]

[target.'cfg(target_os = "windows")']
rustflags = ["-C", "link-args=/ENTRY:_start /SUBSYSTEM:console"]

[target.'cfg(target_os = "macos")']
rustflags = ["-C", "link-args=-e __start -static -nostartfiles"]

Ключ rustflags содержит аргументы, которые автоматически добавляются к каждому вызову rustc. Более подробную информацию о файле .cargo/config.toml можно найти в официальной документации.

Теперь наша программа должна собираться на всех трех платформах с помощью простой cargo build.

🔗Должны ли вы это делать?

Хотя можно создать независимый исполняемый файл для Linux, Windows и macOS, это, вероятно, не очень хорошая идея. Причина в том, что наш исполняемый файл все еще ожидает различных вещей, например, инициализации стека при вызове функции _start. Без C runtime некоторые из этих требований могут быть не выполнены, что может привести к сбою нашей программы, например, из-за ошибки сегментации.

Если вы хотите создать минимальный исполняемый файл, запускаемый поверх существующей операционной системы, то включение libc и установка атрибута #[start], как описано [здесь] (https://doc.rust-lang.org/1.16.0/book/no-stdlib.html), вероятно, будет идеей получше.

🔗Итоги

Минимальный независимый исполняемый бинарный файл Rust выглядит примерно так:

src/main.rs:

#![no_std] // don't link the Rust standard library
#![no_main] // disable all Rust-level entry points

use core::panic::PanicInfo;

#[no_mangle] // don't mangle the name of this function
pub extern "C" fn _start() -> ! {
    // this function is the entry point, since the linker looks for a function
    // named `_start` by default
    loop {}
}

/// This function is called on panic.
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Cargo.toml:

[package]
name = "crate_name"
version = "0.1.0"
authors = ["Author Name <author@example.com>"]

# the profile used for `cargo build`
[profile.dev]
panic = "abort" # disable stack unwinding on panic

# the profile used for `cargo build --release`
[profile.release]
panic = "abort" # disable stack unwinding on panic

Чтобы собрать этот исполняемый файл, его надо скомпилировать для голого железа, например, thumbv7em-none-eabihf:

cargo build --target thumbv7em-none-eabihf

В качестве альтернативы, мы можем скомпилировать его для хост-системы, передав дополнительные аргументы компоновщика:

# Linux
cargo rustc -- -C link-arg=-nostartfiles
# Windows
cargo rustc -- -C link-args="/ENTRY:_start /SUBSYSTEM:console"
# macOS
cargo rustc -- -C link-args="-e __start -static -nostartfiles"

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

🔗Что дальше?

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



Комментарии

Instead of authenticating the giscus application, you can also comment directly on the on GitHub. Just click the "X comments" link at the top — or the date of any comment — to go to the GitHub discussion.

Пожалуйста, оставляйте комментарии на английском по возможности.