
При разработке или модификации устройств часто возникает необходимость запускать сторонний код: это могут быть пользовательские программы с SD-карты или приложения, созданные другими разработчиками с использованием SDK для вашего устройства. Традиционные подходы к загрузке исполняемых файлов, такие как ELF или PE, требуют глубокого понимания архитектуры: ABI, релокаций, GOT, различий между -fPIE и -fPIC, а также написания скриптов для компоновщика. Это сложно и не всегда оправдано. Изучая SDK для ранних версий Symbian, я обнаружил элегантный и "дешёвый" метод загрузки нативного кода практически на любой микроконтроллер, полностью избегая тонкостей генерации кода для конкретной архитектуры! В этой статье мы: разберёмся, как происходит загрузка программ в Linux, изучим концепцию, предложенную Symbian Foundation, и реализуем её на практике для архитектуры XTensa (которая, кстати, используется в популярных ESP32, хотя её внутреннее устройство остаётся для многих загадкой). Интересно? Тогда погружаемся в детали!
❯ Принцип работы загрузки программ
Для многих разработчиков процесс загрузки исполняемых файлов (EXE) и динамических библиотек (DLL/SO) остаётся "чёрным ящиком" — чем-то, во что не нужно вникать. Отчасти это верно: современные операционные системы автоматически загружают бинарники в память, не требуя от программиста даже понимания, куда именно будет помещена программа или библиотека.

Для общего понимания кратко рассмотрим, как это происходит в Windows и Linux:
1. Система создаёт процесс и загружает секции из ELF/PE-файла в память. Обычная программа использует три основные секции: .text (исполняемый код), .data (инициализированные глобальные переменные) и .bss (неинициализированные глобальные переменные). Каждому процессу выделяется собственное виртуальное адресное пространство, которое изолирует его память от других процессов и абстрагируется от физической структуры памяти. Реализуется это с помощью модуля MMU (Memory Management Unit) процессора.
2. Если программа не использует динамические библиотеки, на этом загрузка может быть завершена. Каждая программа имеет базовый адрес загрузки, относительно которого компоновщик строит все ссылки на код и данные. Фактически, для простейших программ компоновщик просто прибавляет этот адрес (например, 0x100) к каждому абсолютному обращению к памяти.
Однако современные программы используют десятки библиотек, и задать уникальный адрес загрузки для каждой невозможно — рано или поздно произойдёт конфликт. Кроме того, стандарты безопасности в Linux рекомендуют использовать позиционно-независимый код (PIC), чтобы задействовать ASLR (рандомизацию адресного пространства), которая загружает программу в случайное место памяти, усложняя эксплуатацию уязвимостей, основанных на фиксированных адресах.
3. Для решения этих проблем используется динамический компоновщик (линкер), который во время загрузки программы "патчит" её, позволяя разместить в любом месте памяти. Для этого на этапе компиляции создаются специальные секции .rel и .rel.plt, называемые релокациями. Упрощённо, релокация — это запись вида "какой абсолютный адрес в коде нужно исправить" -> "на какое смещение исправить". Простейший пример релокации выглядит так:

Где всего:

Секция .rel.plt используется для разрешения вызовов функций из динамических библиотек: программа обращается к заранее известным символам, которые уже в процессе загрузки заменяются на реальные адреса функций из загруженных библиотек.
Всё кажется относительно простым, пока не сталкиваешься с GOT (Global Offset Table) и особенностями конкретного ABI (Application Binary Interface). Для x86 или ARM всё документировано и понятно, но на других архитектурах начинаются сложности, и не всегда очевидно, что за что отвечает.
Однако часто требуется просто запустить небольшую программу, не нуждающуюся в сложных загрузчиках: немного кода, немного данных — и всё. Здесь есть три основных подхода:
Написать полноценный загрузчик ELF. В некоторых средах формат ELF может быть избыточным, а его реализация — нетривиальной.
Зарезервировать в памяти фиксированный сегмент (например, от 0xFFF до 0xFFFF) и скомпилировать программу с базовым адресом 0xFFF, используя опцию -fno-pic. В этом случае компоновщик будет генерировать обращения к памяти по абсолютным адресам — если переменная находится по адресу 0xFFF, программа будет напрямую обращаться к этому адресу, без необходимости динамического связывания. Именно такой подход использовался в ZX Spectrum, Commodore 64 и MS-DOS (где роль "виртуальной памяти" играли сегменты 8086). Недостатки: сложность одновременной загрузки нескольких программ, фиксированный сегмент "съедает" часть памяти основной прошивки, отсутствие динамического выделения секций. Зато такой код теоретически работает быстрее, чем PIC.
Проблема реализации: часто приходится модифицировать систему сборки основной прошивки и патчить скрипт компоновщика, чтобы он не использовал определённую область памяти. Например, для ESP32 это может потребовать патча самого SDK и отхода от основной версии.Использовать программу с относительной адресацией, но без секций .bss и .data. Это самый простой в реализации метод, который к тому же экономичен по памяти, позволяет загружать программу в любое место, использовать возможности динамического распределения памяти и не требует вмешательства в основную прошивку, кроме написания примитивного загрузчика. Именно этот метод мы и рассмотрим подробнее.
Недавно в чате, посвящённом разработке нативных приложений для старых телефонов (ELF-сцена), мы обсуждали, как реализовать загрузчик сторонних программ на малоизвестных платформах. Кто-то предлагал взять за основу ELF, но это сложно для некоторых архитектур, а кто-то — создать собственный бинарный формат, вдохновлённый тем же ELF.

Тогда же я вспомнил, что, изучая SDK для Symbian, обратил внимание: приложения для этой ОС не поддерживают глобальные переменные. Да, секции .data и .bss в них полностью отсутствуют — переменные предлагается хранить в структурах. Зачем это сделано? Дело в том, что каждое приложение Symbian по сути является DLL-библиотекой, которую загружает ядро EKA, создавая экземпляр класса CApaApplication. Чтобы иметь возможность загружать DLL один раз для всех программ (что актуально и для системных библиотек), разработчики полностью отказались от глобальных переменных. Идея интересная!
Однако у этого подхода есть ограничения:
Отсутствие глобальных переменных может осложнить портирование существующего кода, хотя ничто не мешает передавать в каждую функцию структуру с глобальным состоянием. Кроме того, можно использовать C++ (за исключением необходимости ручной реализации new/delete и отсутствия исключений).
Отсутствие предварительно инициализированных данных. Это может стать проблемой, например, для таблиц команд или данных калибровки, которые нельзя просто объявить с инициализатором в C. То же касается строковых литералов. Решения: разместить небольшие таблицы в стеке или загружать данные из бинарника с помощью основной прошивки (аналогично LoadString в Windows).
Давайте проверим на практике, жизнеспособен ли такой подход!
❯ Практическая реализация загрузчика
Формат нашего бинарного файла будет предельно простым: небольшой заголовок в начале, а затем — сырой дамп секции .text, который можно извлечь из ELF даже без написания скриптов для компоновщика. Учитываем, что ESP32 — микроконтроллер с частично гарвардской архитектурой (раздельные шины данных и инструкций), но он имеет полноценный MMU, позволяющий отображать физическую память в виртуальную, чем мы и воспользуемся!
Заголовок бинарного файла:

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

Концептуально всё просто: GetGlobalStateSize сообщает загрузчику размер структуры для хранения глобального состояния, а Start заменяет функцию main() в нашей программе. Crt0 (стартовый код) не нужен, так как всю необходимую инициализацию выполняет загрузчик ESP32. При желании можно выделить для программы отдельный стек — это повысит надёжность, если программа случайно повредит стек.

Собираем программу:
xtensa-esp32-elf-cc.exe test.c -fno-pic -nostdlib -nostartfiles -Wl, --section-start=.text=0x0
xtensa-esp32-elf-objcopy.exe --only-section=.text --output-target двоичный файл a.out run.bin
Опция -fno-pic отключает генерацию кода, зависимого от GOT; -nostdlib и -nostartfiles исключают crt0 и стандартную библиотеку, оставляя только наш код. --section-start устанавливает смещение для загрузки секции .text равным 0x0 (в идеале это делается в скрипте для ld).
Утилита objcopy просто копирует нужную текстовую секцию из полученного ELF.
Как это работает на практике? Посмотрим на выходной бинарный файл и дизассемблированный код:

Обратите внимание: Start вызывает подфункции с помощью инструкции CALLX8, которая, в отличие от CALL8 с непосредственным значением, осуществляет переход относительно текущего значения счётчика команд (PC), делая его полностью независимым от адреса загрузки программы в памяти. А поскольку все данные, включая указатель на глобальное состояние, передаются через стек, нет необходимости перемещать секции данных.
В результате загрузчику нужно всего лишь: загрузить программу в память инструкций, выделить память для структуры состояния и передать управление на функцию Start. Вот и всё!
Для ESP32 есть два возможных способа загрузки программы в память:
Загрузить программу в IRAM (внутреннюю RAM). Теоретически возможно, но на практике загрузчик ESP32 устанавливает права только на чтение и выполнение для определённых областей памяти. Попытка записи вызовет исключение SIGSEGV. Кроме того, IRAM относительно мал — около 200 КБ.
Самопрограммирование флеш-памяти. В ESP32 для этого есть два механизма — Partition API и SPI Flash API. Я выбрал Partition API из-за простоты реализации.
Для нашей прошивки необходимо переразметить флеш-память. Запускаем idf.py menuconfig, переходим в Partition Table -> Custom partition table CSV. Создаём в папке проекта файл partitions.csv:
# Таблица разделов ESP-IDF
# Имя, тип, подтип, смещение, размер, флаг
nvs, data, nvs, 0x9000, 0x6000,
phy_init, data, phy, 0xf000, 0x1000,
factory, app, factory, 0x10000, 1M,
executable, data, undefined, 0x110000, 0x10000
Для загрузки программы можно использовать API разделов или утилиту parttool.py:
parttool.py --port "COM41" write_partition --имя раздела=исполняемый файл --input "run.bin"
Переходим к коду загрузчика программы:

Сборка прошивки ESP32:
idf.py build
idf.py flash
idf.py monitor
Смотрим результат:
Системный вызов 25
Системный вызов 35
Системный вызов 15
Всё работает!
❯ Выводы и заключение
Как видите, запуск сторонних программ не так сложен, если принять определённые ограничения. Да, у этого подхода есть как преимущества (простота, экономия памяти, независимость от адреса загрузки), так и недостатки (отсутствие глобальных переменных, сложности с инициализированными данными), но он отлично справляется со своей задачей. Он позволяет запускать игры на самодельных консолях или сторонние программы на домашних компьютерах. И не забывайте о плагинах! Если вашему устройству требуется расширяемость, но нет возможности предоставлять исходный код или объектные файлы, этот метод может стать отличным решением.
Стоит упомянуть ещё один... необычный метод, с которым я иногда сталкиваюсь в сообществе самодельных компьютеров. Люди пишут эмуляторы процессоров, например, 6502 или Z80, и запускают код через них :)
Хотя такой подход в принципе применим к ESP32, на менее производительных микроконтроллерах, таких как AVR, падение производительности будет слишком значительным. Зачем эмулировать, если можно использовать все возможности ядра по максимуму?
Больше интересных статей здесь: Гаджеты.
Источник статьи: Выполняем сторонние программы на микроконтроллерах с Гарвардской архитектурой: как загружать программы без знания ABI?.