[Перевод] Слегка ржавое EFI-приложение

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

Так что сегодняшняя цель — это создание UEFI-приложения на Расте, которое распечатывает карту памяти, отфильтрованную по доступности для использования (такая память называется традиционной памятью в описании UEFI-спецификаций):

image-loader.svg


Однако прежде, чем приступить к работе, освежим некоторые понятия.

▍ Скомканное вступление


При включении компьютера аппаратная часть находится в неопределённом состоянии и необходимо выполнить некоторую инициализацию для того, чтобы подготовить систему к предстоящей работе. 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- 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) -> Status {
    loop {}
    Status::SUCCESS
}


Первые две строки обозначают, что наш крейт не имеет функции main и не зависит от стд. Также точка входа помечена аттрибутом entry.

Наконец, после сборки и запуска приложения, QEMU отобразит что-то похожее на картину ниже:

image-loader.svg

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:
ecnt4erkqvjjsi2tyelgz4cvjge.jpeg


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

    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-устройствам и т.д., и разработать свои собственные драйвера.

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

image-loader.svg

© Habrahabr.ru