Pull to refresh

Пишем ОС на Rust. Настройка среды. Бинарник для «голого» железа

Reading time7 min
Views18K

Настройка среды. "Голый" бинарник, или Исполняемый файл без main()


Первый шаг в написании своей ОСи — создание бинарника, не зависящего от стандартных библиотек, это делает возможным запуск кода без ОС — мы же пишем свою.


Оригинал блога разрабатывается на GitHub. Замечания к оригиналу оставляйте на странице Issues репозитория выше, а к переводу — в личке, комментариях или тут. Код, написанный в этой статье, содержится в ветке post-01.


Вступление


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


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


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


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


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


Сначала создадим проект с помощью Cargo. Это делается с помощью коммандной строки:


cargo new os-in-rust --bin --edition 2018

Я назвал проект os-in-rust (для избегания путаницы с оригинальным блогом), но можно выбрать любое имя. Флаг --bin говорит, что нужен проект, который будет собираться в исполняемый файл, а не в библиотеку. --edition 2018 значит, что нужно использовать именно редакцию Rust 2018. Cargo сгенерирует такую структуру папок:


os-in-rust
├── Cargo.toml
└── src
    └── main.rs

Файл Cargo.toml содержит конфигурацию: название проекта, автор, версию и зависимости. В src/main.rs содержится код, который выполнится, когда мы запустим скомпилированный бинарник. Компилируется он коммандой cargo build, а исполняемый файл будет в папке target/debug.


Атрибут no_std


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


// main.rs
#![no_std]

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

Если попробовать собрать проект, получим ошибку:


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

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


Так что удалим вывод и снова попробуем запустить сборку:


// 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!()


Атрибут panic_handler говорит языку, что эту функцию нужно вызвать, если произошла паника (вызов panic!()). В стандартных библиотеках она есть, но в среде no_std надо её создать самим:


// main.rs

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
  loop {}
}

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


eh_personality


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


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


Элемент языка eh_personality помечает функцию, которая используется для реализации "разматывания" стека вызовов. По умолчанию Rust использует это для вызова деструкторов всех переменных на стеке в случае паники, чтобы освободить всю использованную память. Но это сложный процесс, которому требуются библиотеки, специфические для каждой ОС (libunwind на Linux и структурированная обработка исключений на 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


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


В типичном исполняемом файле Rust исполнение начинается в библиотеке, которая называется crt0, что настраивает среду для исполнения кода на С. Это, помимо прочего, включает в себя создание стека и укладывание параметров в правильные регистры. Потом среда С вызывает среду Rust в месте, помеченным start, Rust инициализируется, и только потом вызывает main().


Наш код мог бы переопределить этот элемент языка, но тогда он имел бы зависимость от crt0, а это нам не надо. Так что переопределять он будет входную точку crt0.


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


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


#![no_std]
#![no_main]

use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Мы удалили функцию main(), так как она все равно не вызывается. Вместо нее надо определить функцию _start:


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

Используя атрибут #[no_mangle], мы выключаем искажение имен, чтобы компилятор назвал функцию _start, а не, например, _ZN3blog_os4_start7hb173fedf945531caE. Это используется компилятором для того, чтобы избежать ошибок переопределения.


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


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


Теперь, если сделать cargo build, мы получим ошибку сборщика.


Ошибки сборщика


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


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


Сборка для "железа"


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


Для различания платформ Rust (да и многие другие инструменты) использует target triples. Чтобы увидеть эту строку для вашего ПК, надо выполнить rustc --version --verbose:


rustc 1.47.0-nightly (576d27c5a 2020-08-12)
binary: rustc
commit-hash: 576d27c5a6c80cd39ef57d7398831d8e177573cc
commit-date: 2020-08-12
host: x86_64-unknown-linux-gnu
release: 1.47.0-nightly
LLVM version: 10.0

Это вывела данная команда на моей системе (Linux x86-64). Параметр, который нас интересует — host. Он говорит, что:


  • система на базе x86-64,
  • ОС: Linux,
  • ABI: GNU

Компилируя для такой платформы, Rust думает, что бинарник будет исполняться на какой-нить операционной системе (в моем случае, Linux) и использует системные библиотеки (libc, libunwind и другие). Чтобы не было ошибок сборщика, надо компилировать под другую целевую платформу.


Примером такой платформы является thumbv7em-none-eabihf, что означает встраиваемая система на базе ARM. Детали нас не интересуют, главное то, что там нет ОС (none). Чтобы иметь возможность компиляции под данную платформу, надо добавить её с помощью Rustup:


rustup target add thumbv7em-none-eabihf

Теперь можно скомпилировать код:


cargo build --target thumbv7em-none-eabihf

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


Это тот способ, который мы будем использовать. Но вместо thumbv7em-none-eabihf мы создадим кастомное описание платформы для x86-64. Детали будут в следующей статье (которую я пока не перевел), а пока можно почитать оригинал. Если не ошибаюсь, эти статьи также переводил m1rko, но не могу найти эту (да и любую).


Заключение


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


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

Для сборки — эта команда:


cargo build --target thumbv7em-none-eabihf

Но есть одно но. Это только минимальный пример. Этот бинарник ожидает некоторые вещи, например, что стек будет инициализирован. Если захотите использовать это где-то, надо доработать.

Tags:
Hubs:
Total votes 38: ↑36 and ↓2+47
Comments14

Articles