ОС еще не загрузилась, а Python уже работает: зачем мы принесли змею в UEFI

В какой-то момент в жизни инженера появляется задача, которая начинается словами: «Нам бы просто автоматизировать прошивку».
Слово «просто» здесь выполняет декоративную функцию. Примерно как кнопка закрытия дверей в некоторых лифтах: нажимать приятно, на происходящее влияет не всегда.
В департаменте исследований и разработки «Гравитон» мы занимаемся тестированием оборудования. Часть подготовительных действий нужна еще до загрузки операционной системы: получить параметры задания, проверить необходимые условия, организовать обновление прошивки и сохранить результаты диагностики.
Можно каждый раз загружать ОС, узнавать, что делать с устройством, а затем возвращаться в предзагрузочную среду. Но поднимать целую систему ради нескольких правил показалось нам несколько торжественным.
Так мы начали обживаться в UEFI Shell. А некоторое время спустя у нас появился собственный сборщик Python для этой среды.
Дальше — о том, из чего он состоит, какие особенности порта пришлось учитывать и почему наличия файла Python.efi для спокойной жизни недостаточно. Примеры сокращены до обсуждаемого механизма.
Когда Shell становится тесно
Для запуска нескольких EFI-утилит возможностей Shell вполне хватает. Сложности начинаются, когда между запусками появляется содержательная логика: разобрать структурированный ответ, проверить параметры, выбрать допустимое продолжение, сохранить состояние и разобраться с ним после перезагрузки.
Условий становится больше. Затем появляются условия внутри условий. В какой-то момент сценарий смотрит на автора с выражением человека, который обещал повесить полку, а теперь согласовывает перенос несущей стены.
Нам понадобился более удобный язык для этой связующей работы. Python подходит для разбора данных и файлов обмена, а также для анализа результатов диагностики. Аппаратные операции при этом выполняют предназначенные для них EFI-утилиты под управлением Shell.
Получается понятное распределение обязанностей: Python готовит и проверяет задание, Shell организует выполнение аппаратных команд. Неудача при получении параметров остается ошибкой подготовки; до прошивальщика такой сценарий доходить не должен.
Отдельная область применения — проверки некоторых интерфейсов и необходимых возможностей непосредственно в UEFI. Это позволяет получить часть диагностической информации раньше, без промежуточной загрузки ОС. Полный комплекс испытаний устройства остается отдельной задачей.
Как это используется у нас
В нашем проекте Python получает серверное задание по введенному серийному номеру. Ответ с параметрами мы называем cparam. Получение этих правил происходит прямо в UEFI, поэтому загружать ОС только ради них не требуется.
Небольшой фрагмент из этапа подготовки, с сокращенным сообщением ошибки:
run_stage(
"cparam API",
lambda: download_cparam(paths=paths),
require_zero=True,
paths=paths,
)
if not os.path.isfile(paths.CPARAM_FILE):
raise RuntimeError("cparam was not downloaded")
Здесь run_stage записывает начало и завершение этапа, а require_zero=True требует успешного кода возврата от обработчика. paths содержит пути к рабочим файлам.
Этот фрагмент проверяет завершение получения параметров и наличие сохраненного ответа. Дальше отдельно выполняются нормализация данных, проверки задания и подготовка файлов для Shell. Само по себе наличие JSON-файла еще не означает, что можно выполнять аппаратные действия.
После подготовки Shell запускает нужные EFI-утилиты, а результаты сохраняются для продолжения сценария и диагностики.
В этом и практический смысл всей конструкции: знакомым языком можно описывать логику подготовки до загрузки ОС, не превращая каждый новый вариант задания в еще один этаж условий в .nsh.
Из чего собирается Python.efi
За основу мы взяли существующий порт CPython 3.6.8 из проекта edk2-libc.
Здесь участвуют три слоя:
· EDK II — инфраструктура разработки и сборки EFI-приложений.
· edk2-libc — реализация и адаптация библиотек C для UEFI, а также приложения, использующие это окружение.
· Порт CPython — интерпретатор и изменения, необходимые ему в этой среде.
Наш builder подготавливает эти компоненты и собирает отдельное EFI-приложение. Python.efi запускается из UEFI Shell; устанавливать интерпретатор внутрь прошивки материнской платы для этого не требуется.

Версия 3.6.8 располагает к археологическим шуткам, но отражает выбранную основу порта. Переход на свежий CPython потребовал бы работы с низкоуровневой адаптацией, сборочными описаниями и stdlib.
При этом сам builder работает на современном host-Python. Интерпретатор, который запускает сборщик, и интерпретатор, который мы собираем, имеют разные версии и разные возможности.
Общая последовательность:

BaseTools — инструменты самого EDK II. Затем скрипт подготовки порта, srcprep.py, формирует рабочее дерево CPython с UEFI-адаптациями. Уже к этому дереву builder применяет необходимые исправления.
В центре процесса находится команда:
build -t GCC -a X64 -b RELEASE -p AppPkg/AppPkg.dsc -D BUILD_PYTHON368
Она выбирает GCC, архитектуру X64, конфигурацию RELEASE и сборочное описание AppPkg с включенным Python 3.6.8. К этому моменту окружение и исходники должны быть подготовлены.
Запуск компилятора занимает в builder сравнительно немного кода. Основная работа — обеспечить подходящие входные данные и аккуратно обработать результат.
Старый порт встречает современный компилятор
Некоторые изменения выглядят настолько скромно, что трудно представить, сколько внимания они потребовали.
Например, определение VPATH:
-#define VPATH L"."
+#define VPATH "."
Префикс L делает литерал широкой строкой. В обсуждаемом месте значение передается в Py_DecodeLocale, который принимает обычную строку const char * и выполняет преобразование сам.
Одной буквы достаточно, чтобы компилятор возразил против наших планов.
Другой пример связан со встроенной zlib. Обычно свойства окружения zlib определяются на этапе configure. При использовании EDK II этот этап не выполняется.
При этом edk2-libc предоставляет unistd.h. Builder добавляет флаг, позволяющий zlib подключить этот заголовок и получить нужные объявления функций:
-DHAVE_UNISTD_H=1
Есть и точечные исправления на границе с API UEFI: например, согласование типа выходного указателя в вызове LocateProtocol. Отдельно добавляется отсутствующее в публичном заголовке объявление tzset.
Все эти изменения полезно выполнять повторяемым образом: уже примененный патч должен распознаваться, а неожиданно изменившийся исходник — останавливать подготовку.
Сокращенный принцип проверки одного из преобразований:
source, count = re.subn(pattern, replacement, source)
if count != 1:
raise RuntimeError("Expected exactly one build configuration entry")
Здесь pattern, replacement и source подготовлены выше. Число замен проверяет наше предположение о структуре исходника.
Здесь показана только проверка числа совпадений. В полном коде повторное применение предусмотрено отдельно: сборщик либо распознает уже исправленный фрагмент, либо использует шаблон, подходящий для обоих состояний.
Если ожидаемой строки больше нет, продолжать сборку с оптимистичным выражением лица довольно рискованно. Будущему отладчику тоже хочется иногда уходить домой.
Почему socket.py еще не означает рабочую сеть
У стандартной библиотеки есть существенная особенность: часть знакомых Python-модулей использует нативные модули интерпретатора.
Например, socket.py опирается на _socket. Наличие Python-файла само по себе не создает соответствующую реализацию на C.
В выбранном порте builder включает исходник и библиотеки, необходимые для нативной сетевой части:
Modules/socketmodule.c
BsdSocketLib
EfiSocketLib
Затем модуль должен присутствовать в таблице встроенных модулей CPython:
{"_socket", PyInit__socket},
Эти строки относятся к разным конфигурационным файлам. Вместе они показывают три необходимые составляющие: код модуля, библиотечные зависимости и регистрацию функции инициализации.
После сборки можно проверять базовые операции:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
sock.settimeout(2)
print("Socket timeout:", sock.gettimeout())
finally:
sock.close()
Этот фрагмент проверяет создание объекта, установку тайм-аута и закрытие. Соединение с сервером он не проверяет.
Под приложением остается целая цепочка:
NIC и его драйвер
↓
сетевые протоколы UEFI, включая SNP
↓
IPv4 и пригодный адрес
↓
TCP4
↓
соединение приложения и ответ сервера
Успешное прохождение одного звена не подтверждает готовность остальных. Даже назначенный IP-адрес еще не отвечает на вопрос, сможет ли конкретное приложение установить TCP-соединение.
С TLS действует похожее правило: файл ssl.py не доказывает наличие рабочего нативного _ssl. Это отдельная возможность порта, которую нужно проверять.
Поэтому слово Success мы читаем вместе с названием операции, которая его напечатала. Обычно у этого слова довольно скромные должностные обязанности.
Файловые пути: Python 3.6 со своими особенностями
В обычном CPython 3.6 появился протокол файловых путей PEP 519: объект может предоставить строковое или байтовое представление пути через fspath().
Простейший пример:
class FilePath(object):
def init(self, value):
self.value = value
def fspath(self):
return self.value
Такой объект должен приниматься поддерживающими протокол файловыми операциями:
path = FilePath("probe.txt")
with open(path, "w", encoding="utf-8") as stream:
stream.write("hello from UEFI")
Но подготовка исходников выбранного UEFI-порта подменяет некоторые файлы stdlib специальными вариантами. Поэтому наличие номера версии 3.6.8 еще не гарантирует все ожидаемые возможности обычного CPython.
В нашем случае builder восстанавливает необходимые файловые механизмы из исходников CPython, находящихся в той же ревизии edk2-libc. UEFI-адаптация os при этом сохраняется.
Это позволяет не зависеть от stdlib машины разработчика. Скопировать оттуда свежий os.py было бы заманчиво, но у него могут оказаться требования к нативным функциям и другим модулям, которых целевой интерпретатор не предоставляет.
Дополнительный сюжет создают имена файловых систем EFI Shell:
fs0:
fs1:
fs12:
Привычный ntpath исторически ориентируется в том числе на обозначения дисков вида C:. Имя UEFI mapping может быть длиннее.
Нам требуется такое поведение:
os.path.splitdrive("fs12:/tests/probe.txt")
# Ожидаемый результат после адаптации:
# ("fs12:", "/tests/probe.txt")
Поэтому builder изменяет разбор обозначения диска и связанные проверки. Они охватывают не только splitdrive, но и соединение путей, нормализацию, строки и байтовые представления.
Здесь легко остановиться слишком рано: os.fspath() уже возвращает правильную строку, а следующий потребитель разбирает ее неверно.
Поэтому проверяем несколько потребителей одного протокола:
объект с fspath()
├─ os.fspath()
├─ операции os.path
└─ нативный open()
Нативный open() использует C API PyOS_FSPath; вызов Python-функции os.fspath() ему для этого не требуется.
Кодировка нужна раньше прикладного кода
У файловой совместимости есть еще один слой — ранняя инициализация кодировки файловой системы.
В используемом порте builder отдельно настраивает ее как UTF-8 на нативном уровне, до загрузки site. Этот модуль участвует в обычном старте Python и может обращаться к файловой системе еще до входа в наше приложение.
Поэтому исправление, подключаемое только в начале прикладного сценария, способно опоздать: интерпретатору нужная настройка понадобилась раньше.
Это отдельная задача от поддержки PathLike. Объект пути может корректно возвращать строку, а преобразование этой строки при обращении к файловой системе все еще требует правильно инициализированного окружения.
Также это не означает перевод всех файлов на UTF-8. При чтении и записи конкретных файлов по-прежнему нужно учитывать их формат, в том числе особенности UTF-16 и наличие маркера порядка байтов (BOM).
Иногда проблема «Python не открыл файл» начинается задолго до строки open().
Два Python и один каталог зависимостей
Дополнительные пакеты builder готовит отдельно от базового интерпретатора. При разрешении зависимостей задается целевая версия Python 3.6.
Иначе современные host-инструменты могут выбрать пакет, который отлично подходит машине разработчика и совершенно не подходит нашему EFI-интерпретатору.
Для дополнительных пакетов мы ограничились pure Python. Подготовленное дерево проверяется на наличие нативных библиотек и расширений, в частности:
native_suffixes = {
".so", ".pyd", ".dll",
".dylib", ".a", ".lib",
}
Готовое расширение для Linux нельзя перенести в UEFI простым копированием.
Но и отсутствие таких файлов — только фильтр. Чистый Python-пакет может рассчитывать на процессы, потоки, системные службы или другие возможности, которых здесь нет.
Еще одна небольшая, но полезная операция — удаление .pyc и pycache из подготовленных зависимостей. Байткод разных версий CPython несовместим, а случайно приехавший кэш машины сборки целевому окружению не нужен.
Исходники остаются. Старшему поколению Python дают возможность прочитать текст самостоятельно.
Кэш: не компилировать змею ради одного пакета
Базовый интерпретатор и дополнительные зависимости меняются с разной частотой. Если мы добавили pure-Python пакет, заново собирать CPython обычно незачем.
Поэтому используются отдельные ключи базового и итогового пакетов:
base_key = artifact_key(
edk2_commit, libc_commit,
TOOLCHAIN, ARCH, TARGET, gcc,
)
final_key = artifact_key(
edk2_commit, libc_commit,
TOOLCHAIN, ARCH, TARGET, gcc,
deps_hash=deps_hash,
)
В ключах учитываются ревизии исходников, параметры сборки, версия GCC и версия схемы кэша. Для итогового окружения добавляется состояние списка зависимостей.
Версия схемы кэша особенно полезна при изменении наших патчей: upstream-ревизии могли остаться прежними, а способ подготовки исходников уже изменился.
Повторный запуск использует завершенные результаты. Обновление исходников выполняется явно. Для работы с уже заполненным кэшем предусмотрен offline-режим.
Такой подход сокращает лишнюю работу и помогает понимать происхождение результата. Сам по себе он не доказывает побайтовую воспроизводимость сборок или идентичность заново разрешенных зависимостей.
Зато наблюдать полную перекомпиляцию ради небольшого Python-пакета приходится значительно реже. Медитативность компилятора хороша, пока она добровольная.
Почему готовый пакет — больше, чем Python.efi
Интерпретатору нужна согласованная стандартная библиотека. Поэтому результатом builder становится окружение с несколькими составляющими:
· Python.efi,
· stdlib и подготовленные site-packages,
· метаданные сборки,
· лицензии использованных компонентов.
В метаданных сохраняются версия builder, версия CPython, ревизии исходников, компилятор и параметры сборки.
Через некоторое время это позволяет ответить на вопрос «откуда взялся этот бинарник?» без коллективного сеанса воспоминаний.
Сама упаковка тоже требует аккуратности. Существование каталога еще не означает, что копирование закончилось. Builder проверяет обязательные файлы и записывает маркер завершения. При переносе пакета используется промежуточный каталог.
Схематично:
копирование во временный каталог
↓
проверка обязательных файлов
↓
маркер завершения
↓
перенос подготовленного каталога в целевое место
Это проверка завершенности упаковки по правилам builder. Она не заменяет запуск интерпретатора и проверку его возможностей.
Собралось — это начало проверки
В проекте есть отдельный runtime-probe, который запускается настоящим Python.efi. Сам builder автоматически его не выполняет.
Probe проверяет импорты, файловые API, объекты путей, кодировки и базовые операции с сокетом. Проверки возможностей интерпретатора идут до подключения прикладного слоя совместимости, чтобы тот не замаскировал недостатки порта.
Пример работы файлового протокола — прямо в UEFI Shell:

Исходник и результат запуска в QEMU/OVMF. FilePath используется для записи и чтения файла. fs12: — входная строка для splitdrive; файл создается на текущем томе FS0:.
У проверки есть несколько уровней:
1. Host-тесты проверяют логику builder и изменения исходников.
2. Запуск EFI-бинарника в VM позволяет проверить поведение самого интерпретатора в UEFI.
3. Проверки на целевой плате нужны для реальных драйверов, NIC, сетевых протоколов и аппаратного сценария.
Например, показанная проверка сокета не охватывает DNS, соединение, передачу данных или HTTP. Для них нужны отдельные проверки.
С прошивкой действует похожее разделение: завершение утилиты — один результат, а подтверждение ожидаемой версии после нового старта системы — следующий.
Поэтому сообщение «собралось» у нас имеет довольно буквальный смысл. Оно сообщает, что собралось.
ОС еще спит, а Python уже занят
Задача начиналась с автоматизации нескольких действий до загрузки ОС. В результате понадобился собственный builder, который готовит интерпретатор, учитывает особенности порта и собирает согласованное окружение.
Самые интересные места оказались на стыках: Python и C-модули, stdlib и EFI-пути, старый порт и современный GCC, машина разработки и целевой интерпретатор.
Теперь, когда мы запускаем наш сценарий из UEFI Shell, операционная система еще не проснулась, а Python уже занят делом.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.