Так что сегодняшняя цель — это создание UEFI-приложения на Расте, которое распечатывает карту памяти, отфильтрованную по доступности для использования (такая память называется традиционной памятью в описании UEFI-спецификаций):
Однако прежде, чем приступить к работе, освежим некоторые понятия.
▍ Скомканное вступление
При включении компьютера аппаратная часть находится в неопределённом состоянии и необходимо выполнить некоторую инициализацию для того, чтобы подготовить систему к предстоящей работе. BIOS, акроним для Basic Input/Output System, появившийся в районе 1975 года и использовавшийся с тех пор, был способом проведения аппаратной инициализации во время процесса загрузки и предоставления сервисов времени выполнения для ОС и программ. Однако BIOS имеет некоторые ограничения и после 40 лет применения заменён на Unified Extensible Firmware Interface (или UEFI для краткости). UEFI нацелен на устранение технических недостатков BIOS.
UEFI — это спецификация, которая определяет программный интерфейс между ОСUEFI-приложением и прошивкой платформы. Intel разработала изначальную Extensible Firmware Interface (EFI), работы над которой были закончены в июле 2005 года. В начале 2006 года Apple одной из первых внедрила технологию на своих Intel Macintosh. В том же самом 2005 году выход UEFI сделал устаревшим EFI 1.10 — последний выпуск EFI. UEFI форум — это индустриальный орган, который управляет UEFI-спецификациями. Интерфейс, определяемый этими спецификациями, включает таблицы данных, которые содержат информацию о платформе, сервисы времени загрузки и выполнения, которые доступны приложениюзагрузчику ОС. Такая прошивка имеет ряд преимуществ перед традиционным BIOS:
- возможность использования более вместительных накопителей при помощи GUID Partition table (GPT)
- независимая от CPU архитектура
- независимые от CPU драйверы
- гибкое пре-ОС окружение, включая сетевые возможности
- модульная архитектура
- совместимость назад и вперёд
Также UEFI предоставляет более продвинутую функциональность для того, чтобы реализовать загрузчик или UEFI-приложение без необходимости глубоких знаний архитектуры.
▍ Окисление — это хорошо
Как говорилось в начале, Раст будет использован для написания UEFI-приложения. Для тех, кто не знает, что это такое: Раст — системный язык программирования, разработку которого спонсирует Mozilla. Она описывает его как «безопасный, конкурентный, практичный язык», поддерживающий функциональную и императивно-процедурную парадигмы. Язык очень похож на Си++ в плане синтаксиса, но создатели Раста намереваются обеспечить в нём лучшую безопасность по памяти при сохранении производительности.
ЯП явился результатом персонального проекта сотрудника Mozilla Грейдона Хоара. Организация стала поддерживать проект в 2009 году, после осознания его потенциала. В 2010 году было публично объявлено о проекте; в том же самом году компилятор, изначально разработанный на OCaml, начали переписывать на Расте с использованием LLVM-backend.
Первая пре-альфа версия компилятора появилась в январе 2012 года, но уже через 3 года, 15 мая 2015 была выпущена первая стабильная версия (теперь известная как редакция 2015). Раст является проектом с открытым сообществом. Такая модель означает, что любой может вкладываться в разработку и в уточнение языка, и этот вклад может быть разным, например, улучшение документации, отправка баг-репортов, предложения RFC на добавление функциональности или изменения программного кода. Язык получил огромную обратную связь по опыту разработки Серво — современного движка для обозревателей с превосходной производительностью и возможностью встроенного применения. В наши дни Раст начинает присутствовать во всех сферах ПО, к примеру, в ПО для управления спутниками, программировании микроконтроллеров, веб-серверов, в обозревателе Firefox и т.д. Раст выигрывал первое место в номинации «наиболее любимый язык программирования» в опросе Stack Overflow Developer в 2016, 2017 и 2018 годах (прим. переводчика — и в 2021).
▍ Ещё два или три момента перед началом
Для того чтобы написать загрузчик, гипервизор или низкоуровневое приложение требуется использовать системный язык программирования. Есть отличная статья с подробным обсуждением этого понятия. Проще говоря, системный ЯП — это язык, позволяющий тонкий контроль над исполнением кода в машине и возможностью изменения любых отдельных байтов в памяти компьютера. И с Растом это возможно.
Во избежание необходимости описывать все UEFI-таблицы будет использован крейт uefi-rs
. Этот крейт облегчает создание UEFI-приложений на Расте. Миссия uefi-rs
в том, чтобы предоставить безопасные и производительные обёртки вокруг UEFI-интерфейсов и позволить разработчикам писать идиоматичный Раст-код.
Наконец, для тестового окружения будут использованы Питон и QEMU вкупе с OVMF. QEMU — это хорошо известный полносистемный эмулятор, позволяющий запускать код для любой машины на любой поддерживаемой архитектуре. OVMF — это основанный на EDK II проект, предоставляющий поддержку UEFI для виртуальных машин (QEMU и KVM). QEMU не содержит в поставке OVMF, так что придётся установить его отдельно на вашу машину, либо взять предсобранные образы из Сети.
Например, такие доступны для загрузки в моём тестовом хранилище.
▍ Начинаем
Без дальнейших промедлений приступаем к работе! Первым делом создадим папку и инициализируем Раст проект в ней:
> mkdir uefi-app && cd uefi-app
> cargo init
Теперь добавим uefi-rs
в качестве зависимости. Чтобы сделать это, просто добавьте следующие строки в ваш Cargo.toml:
uefi = "0.12.0"
uefi-services = "0.9.0"
Если сейчас запустить cargo run
, то Карго соберёт uefi-rs
вместе с нашим приложением.
▍ Рабочий процесс сборкизапуска
Следующий шаг состоит в создании файла целевых параметров и сценария на Питоне для облегчения сборки и запуска UEFI-приложения. В основном, параметры цели описывают выходной двоичный файл, порядок байтов («endianess»), архитектуру, двоичную структуру и функциональности, которые можно использовать при компиляции. Этот файл будет использован build-std
, функциональность Карго для производства крейта core (это основная часть стандартной библиотеки Раста, но без зависимостей, даже от системных библиотек и libc), собранного для другой платформы.
Таким образом, сперва нам необходимо «сказать» карго, чтобы он включил build-std
, через создание файла .cargo/config:
[unstable]
build-std = ["core", "compiler_builtins", "alloc"]
Замечание: чтобы это работало нужно установить ночную сборку Раста так же как и компонент
rust-src
. Это можно сделать при помощи rustup:rustup component add rust-src --toolchain nightly
.
Функциональность mem
из compiler-builtins
не включается автоматически во время сборки с использованием Cargo-функциональности build-std
. Таким образом, мы должны вручную добавить поддержку функций по работе с памятью прописав следующее в файл Cargo.toml:
rlibc = "1.0.0"
И затем добавим крейт в качестве зависимости, чтобы mem*
функции были связаны:
extern crate rlibc;
Далее создадим файл x86_64-none-efi.json со следующим содержимым:
{
"llvm-target": "x86_64-pc-windows-gnu",
"env": "gnu",
"target-family": "windows",
"target-endian": "little",
"target-pointer-width": "64",
"target-c-int-width": "32",
"os": "uefi",
"arch": "x86_64",
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
"linker": "rust-lld",
"linker-flavor": "lld-link",
"pre-link-args": {
"lld-link": [
"/Subsystem:EFI_Application",
"/Entry:uefi_start"
]
},
"panic-strategy": "abort",
"default-hidden-visibility": true,
"executables": true,
"position-independent-executables": true,
"exe-suffix": ".efi",
"is-like-windows": true,
"emit-debug-gdb-scripts": false
}
Я решил всё же переводить статью в оригинальном виде.
Вообще, сейчас полезно сразу читать uefi-rs/BUILDING.md.
Также добавлю ещё одну хорошую, на мой взгляд, ссылку — blog.timhutt/std-embedded-rust. Благодаря ей сформировалась команда сборки — cargo +nightly build -Z build-std=std,panic_abort --target x86_64-unknown-uefi
.
Исполняемый UEFI-файл не что иное, как двоичный формат PE, используемый Windows, но со специальной подсистемой и без таблицы символов; поэтому целевое семейство установлено как windows.
Сейчас нужно создать build.py, реализующий две команды:
build
: эта команда собирает UEFI-приложениеrun
: запускает собранное приложение в QEMU.
#!/usr/bin/env python3
import argparse
import os
import shutil
import sys
import subprocess as sp
from pathlib import Path
ARCH = "x86_64"
TARGET = ARCH + "-none-efi"
CONFIG = "debug"
QEMU = "qemu-system-" + ARCH
WORKSPACE_DIR = Path(__file__).resolve().parents[0]
BUILD_DIR = WORKSPACE_DIR / "build"
CARGO_BUILD_DIR = WORKSPACE_DIR / "target" / TARGET / CONFIG
OVMF_FW = WORKSPACE_DIR / "OVMF_CODE.fd"
OVMF_VARS = WORKSPACE_DIR / "OVMF_VARS-1024x768.fd"
def run_build(*flags):
"Run Cargo-<tool> with the given arguments"
cmd = ["cargo", "build", "--target", TARGET, *flags]
sp.run(cmd).check_returncode()
def build_command():
"Builds UEFI application"
run_build("--package", "uefi-app")
# Create build folder
boot_dir = BUILD_DIR / "EFI" / "BOOT"
boot_dir.mkdir(parents=True, exist_ok=True)
# Copy the build EFI application to the build directory
built_file = CARGO_BUILD_DIR / "uefi-app.efi"
output_file = boot_dir / "BootX64.efi"
shutil.copy2(built_file, output_file)
# Write a startup script to make UEFI Shell load into
# the application automatically
startup_file = open(BUILD_DIR / "startup.nsh", "w")
startup_file.write("EFIBOOTBOOTX64.EFI")
startup_file.close()
def run_command():
"Run the application in QEMU"
qemu_flags = [
# Disable default devices
# QEMU by default enables a ton of devices which slow down boot.
"-nodefaults",
# Use a standard VGA for graphics
"-vga", "std",
# Use a modern machine, with acceleration if possible.
"-machine", "q35,accel=kvm:tcg",
# Allocate some memory
"-m", "128M",
# Set up OVMF
"-drive", f"if=pflash,format=raw,readonly,file={OVMF_FW}",
"-drive", f"if=pflash,format=raw,file={OVMF_VARS}",
# Mount a local directory as a FAT partition
"-drive", f"format=raw,file=fat:rw:{BUILD_DIR}",
# Enable serial
#
# Connect the serial port to the host. OVMF is kind enough to connect
# the UEFI stdout and stdin to that port too.
"-serial", "stdio",
# Setup monitor
"-monitor", "vc:1024x768",
]
sp.run([QEMU] + qemu_flags).check_returncode()
def main(args):
"Runs the user-requested actions"
# Clear any Rust flags which might affect the build.
os.environ["RUSTFLAGS"] = ""
os.environ["RUST_TARGET_PATH"] = str(WORKSPACE_DIR)
usage = "%(prog)s verb [options]"
desc = "Build script for the UEFI App"
parser = argparse.ArgumentParser(usage=usage, description=desc)
subparsers = parser.add_subparsers(dest="verb")
build_parser = subparsers.add_parser("build")
run_parser = subparsers.add_parser("run")
opts = parser.parse_args()
if opts.verb == "build":
build_command()
elif opts.verb == "run":
run_command()
else:
print(f"Unknown verb '{opts.verb}'")
if __name__ == '__main__':
sys.exit(main(sys.argv))
Заметка: я не нашёл, по какой причине исполняемый файл не загружается автоматически с этой версией OVMF, поэтому используется сценарий startup.nsh для облегчения загрузки.
▍ Само приложение
Первым шагом нужно заставить грузиться приложение и войти в бесконечный цикл, предупреждая выход в прошивку.
В Расте ошибки могут быть доведены до паники или аварийного прекращения. Паника случается, когда что-то идёт не так, но в целом можно продолжить работу (такое обычно случается с потоками); аварийное завершение происходит, когда программа переходит в состояние, из которого невозможно восстановление. Наличие обработчика паники обязательно, он реализуется в стандартной библиотеке; но поскольку приложение не зависит от ОС, то и стд не может быть использована. Вместо этого мы используем core часть библиотеки, в которой обработчик отсутствует, так что мы вынуждены реализовывать его самостоятельно. К счастью, uefi-rs
предоставляет одну реализацию оного.
Если вы подметили, то в файле целевых параметров указана передача пары аргументов lld
(компоновщик LLVM), указывающие точку входа (uefi_start
) и подсистему. Так, нам нужно отредактировать main.rs, чтобы импортировать uefi-rs
крейт и определить функцию с именем uefi_start
, содержащую бесконечный цикл:
#![no_std]
#![no_main]
#![feature(asm)]
#![feature(abi_efiapi)]
extern crate uefi;
extern crate uefi_services;
use uefi::prelude::*;
#[entry]
fn efi_main(_image_handler: uefi::Handle, system_table: SystemTable<Boot>) -> Status {
loop {}
Status::SUCCESS
}
Первые две строки обозначают, что наш крейт не имеет функции main и не зависит от стд. Также точка входа помечена аттрибутом entry.
Наконец, после сборки и запуска приложения, QEMU отобразит что-то похожее на картину ниже:
QEMU исполняет UEFI-приложение
Ничего интересного, но т.к. QEMU не перешла в цикл загрузки или выскочила в EFI-оболочку, убеждаемся, что наше приложение вызвано. Следующий шаг заключается в том, чтобы напечатать версию UEFI на экран. Опять же, в rust-rs
уже реализованы вспомогательные функции для этого, поэтому достаточно проинициализировать систему логгирования и использовать макрос info! для распечатки текста на экране или даже на последовательном порту.
Для доступа к макросу info! нужно добавить новую зависимость в Cargo.toml:
log = { version = "0.4.11", default-features = false }
Затем необходимо просто добавить следующий код в главную функцию, перед входом в бесконечный цикл:
uefi_services::init(&system_table).expect_success("Failed to initialize utils");
// reset console before doing anything else
system_table
.stdout()
.reset(false)
.expect_success("Failed to reset output buffer");
// Print out UEFI revision number
{
let rev = system_table.uefi_revision();
let (major, minor) = (rev.major(), rev.minor());
info!("UEFI {}.{}", major, minor);
}
После сборки и запуска приложение выведет что-то вроде INFO: UEFI 2.70. Эта информация зависит от версии прошивки, которую вы используете.
В завершение давайте напишем функцию, которая принимает ссылку на таблицу Boot Services и распечатывает регионы свободной для использования памяти. Сперва нам потребуется включить крейт alloc, чтобы получить доступ к структуре Vec; для этого нужно добавить следующие три строки в начало файла:
#![feature(alloc)]
// (...)
extern crate alloc;
// (...)
use crate::alloc::vec::Vec;
После этого определим константу с размером EFI-страницы, который равен 4KiB независимо от системы.
const EFI_PAGE_SIZE: u64 = 0x1000;
И, собственно, реализуем непосредственно функцию по обходу карты в поисках традиционной памяти и распечатке свободных диапазонов на экран:
fn memory_map(bt: &BootServices) {
// Get the estimated map size
let map_size = bt.memory_map_size();
// Build a buffer bigger enough to handle the memory map
let mut buffer = Vec::with_capacity(map_size);
unsafe {
buffer.set_len(map_size);
}
let (_k, desc_iter) = bt
.memory_map(&mut buffer)
.expect_success("Failed to retrieve UEFI memory map");
let descriptors = desc_iter.copied().collect::<Vec<_>>();
assert!(!descriptors.is_empty(), "Memory map is empty");
// Print out a list of all the usable memory we see in the memory map.
// Don't print out everything, the memory map is probably pretty big
// (e.g. OVMF under QEMU returns a map with nearly 50 entries here).
info!("efi: usable memory ranges ({} total)", descriptors.len());
descriptors
.iter()
.for_each(|descriptor| match descriptor.ty {
MemoryType::CONVENTIONAL => {
let size = descriptor.page_count * EFI_PAGE_SIZE;
let end_address = descriptor.phys_start + size;
info!(
"> {:#x} - {:#x} ({} KiB)",
descriptor.phys_start, end_address, size
);
}
_ => {}
})
}
// (...)
// Call this function inside main
memory_map(&system_table.boot_services());
Конечный результат должен совпадать с выводом, изображённым на КДПВ.
И готово! было несложно, правда? Теперь вы можете продолжить реализовывать новые возможности в приложении, вероятно решившись разрабатывать загрузчик или более сложное UEFI приложение.
И ещё одна важная ремарка для отважных духом. Если вы пустились в разработку своей собственной ОС или углубились в изучение технологии, то вы должны отложить в сторону все API, предоставляемые UEFI для взаимодействия с файловой системой, сетью, доступом к PCI-устройствам и т.д., и разработать свои собственные драйвера.
Не ленитесь от использования всех этих предоставленных абстракций!
Автор: Георгий Шепелёв