Слегка ржавое EFI-приложение

от автора

После двух твитов, оставленных на прошлой неделе, про мои игры с UEFI и Rust, несколько человек попросили опубликовать заметку, объясняющую как создать UEFI-приложение, полностью написанное на Расте и продемонстрировать тестовое окружение.

Так что сегодняшняя цель — это создание 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 влили — PR/56769.

Я решил всё же переводить статью в оригинальном виде.

Вообще, сейчас полезно сразу читать 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("\EFI\BOOT\BOOTX64.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. Эта информация зависит от версии прошивки, которую вы используете.

прим. переводчика

Вот так запуск выглядит на моём стареньком Самсунг NP535U4C:

В завершение давайте напишем функцию, которая принимает ссылку на таблицу 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());

Конечный результат должен совпадать с выводом, изображённым на КДПВ.

image

И готово! было несложно, правда? Теперь вы можете продолжить реализовывать новые возможности в приложении, вероятно решившись разрабатывать загрузчик или более сложное UEFI приложение.

И ещё одна важная ремарка для отважных духом. Если вы пустились в разработку своей собственной ОС или углубились в изучение технологии, то вы должны отложить в сторону все API, предоставляемые UEFI для взаимодействия с файловой системой, сетью, доступом к PCI-устройствам и т.д., и разработать свои собственные драйвера.

Не ленитесь от использования всех этих предоставленных абстракций!


ссылка на оригинал статьи https://habr.com/ru/articles/575630/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *