Выполняем сторонние программы на микроконтроллерах с Гарвардской архитектурой: как загружать программы без знания ABI?

Часто в процессе разработки собственных устройств или модификации существующих возникает задача выполнения стороннего кода: будь то собственные программы с SD-флешек, или программы, написанные другими пользователями с использованием SDK для вашего устройства. Тема компиляторов и генерации кода довольно сложная: чтобы просто загрузить ELF или EXE(PE) программу, нужно досконально понимать особенности вашей архитектуры: что такое ABI, релокации, GOT, разница между -fPIE и -fPIC , как писать скрипты для ld и т.д.и т.п. Недавно я откопал SDK для первых версий Symbian и на основе решений этой ОС понял, как можно сделать предельно "дешевую" загрузку любого нативного кода на практически любой микроконтроллер, вообще не вникая в детали генерации кода для него! Сегодня мы: выясним, что происходит в процессе загрузки программы с ядром Linux, рассмотрим концепцию, предложенную Symbian Foundation, и реализуем ее на практике для сравнительно малоизвестной архитектуры — XTensa (хотя она используется в ESP32, подробности реализации «под капотом» остаются для многих загадкой). Интересный? Тогда добро пожаловать в разрез!

❯ Как это работает?


Полагаю, что для многих моих читателей реализация процесса загрузки exe-программ и dll-библиотек в память процесса осталась своего рода черным ящиком, куда не нужно вдаваться в подробности реализации.Отчасти это правда: современные операционные системы системы сами обрабатывают процесс загрузки бинарников в память, вообще ничего не требуя от программиста, даже понимания того, куда загружать библиотеку или свою программу.




Для общего понимания давайте кратко разберемся, как загружаются программы в Windows/Linux:

1. Система создает процесс и загружает разделы из ELF/PE в память программы. Обычные программы используют для своей работы 3 раздела: .text (код), .data (неинициализированный сегмент памяти для глобальных переменных), .bss (сегмент памяти для инициализированных переменных). Каждому процессу назначается собственное адресное пространство, называемое виртуальной памятью, которое предотвращает повреждение основной памяти программы, а также не зависит от структуры физической памяти работающей машины. Концепция виртуальной памяти реализуется специальным модулем процессора, называемым MMU.

2. Если в наших программах не использовались какие-либо зависимости в виде динамических библиотек, то процесс загрузки можно завершить здесь: каждая программа имеет свой адрес загрузки, по отношению к которому компоновщик строит связи между обращениями к программному коду/данным. Фактически, для простейших программ компоновщик может просто добавить адрес загрузки программы (скажем, 0x100) к каждому абсолютному доступу к памяти.
Однако современные программы используют десятки библиотек, и указать для всех свой адрес загрузки не получится: кто-то где-то все равно пересечется и наверняка испортит память. Кроме того, современные стандарты безопасности в Linux рекомендуют использовать позиционно-независимый код, чтобы воспользоваться преимуществами ASLR (рандомизация адресного пространства или, проще говоря, возможность загрузки программы в случайное место в памяти, чтобы некоторые уязвимости были связаны с фиксированный адрес загрузки программы больше не будет работать).

3. Поэтому для решения этой проблемы используется так называемый динамический компоновщик, который уже при загрузке программы или библиотеки патчит программу, чтобы ее можно было загрузить в любое место памяти. Для этого на этапе компиляции программы используются данные, полученные от обычного компоновщика: помимо .text, .data и .bss компоновщик создает разделы .rel и .rel-plt, которые называются релокациями. Если объяснять весьма условно, то перемещение — это просто запись вида «какой абсолютный адрес в программном коде надо пропатчить» -> «до какого смещения надо пропатчить». Самый простой ход выглядит так:

Где всего:

.rel-plt используется для разрешения вызовов dll/so: в основном программа обращается к предопределенным в процессе компиляции символам, которые уже в процессе загрузки патчятся к физическим адресам функций из загруженной библиотеки.

И кажется, что все очень просто, пока в дело не вступает GOT (Global Offset Table) и функции реализации конкретного ABI. И ок, х86 или ARM, там всё объяснено и понятно, а на других архитектурах начинаются проблемы и не всегда очевидно, что и где за что отвечает.

Но чаще всего вам нужно просто скачать небольшую программу, не нуждающуюся в сложных загрузчиках: немного кода, немного данных и все. И здесь у нас есть три варианта:

  1. Напишите полноценный бинарный загрузчик ELF. В некоторых средах ELF может оказаться громоздким, а его реализация может оказаться нетривиальной для всех.

  2. Зарезервируйте в памяти определенный сегмент (пусть он будет от 0xFFF до 0xFFFF) и скомпилируйте нашу программу с адресом загрузки 0xFFF с параметром -fno-pic. В этом случае компоновщик будет генерировать доступ к памяти по абсолютным адресам — если переменная находится по адресу 0xFFF, программа будет обращаться к этому адресу памяти напрямую, без необходимости динамического связывания чего-либо. Именно такой подход использовался во времена ZX Spectrum, Commodore 64 и MS-DOS (где, правда, роль «виртуальной памяти» играла такая особенность 8086, как сегменты). Есть у этого подхода и недостатки: относительная невозможность загрузки нескольких программ одновременно, зарезервированное пространство будет линейно съедать небольшой кусок памяти основной прошивки, нет возможности динамического выделения разделов. Но теоретически такой код будет работать быстрее, чем PIC.

    Проблемы реализации этого метода: иногда приходится заходить в основную систему сборки прошивки и патчить скрипт компоновщика, чтобы он не трогал определённую область памяти. В случае с esp32, например, для этого требуется патч самого SDK и возможный «откол» от основного дистрибутива.

  3. Используйте программу с относительной адресацией, но без сегментов .bss и .data. Самый простой в реализации метод, который к тому же очень экономичен по памяти, позволяет загружать программу где угодно и использовать все возможности динамического распределителя и не требует никакого вмешательства в основную прошивку, за исключением примитивного загрузчика программ. Вот это я и предлагаю рассмотреть подробнее.


Недавно мы сидели в чате сцены ELF (разработка нативных приложений для телефонов Siemens, Sony Ericsson, Motorola и LG с помощью хаков) и думали о том, как можно реализовать сторонний загрузчик приложений на практически неизвестных платформах. Кто-то предложил взять за основу ELF — но есть сложности с реализацией для некоторых платформ, а кто-то предложил написать «бинлоадер» — самодельный бинарный формат, взятый, например, у тех же эльфов.

Заодно я откопал SDK для Symbian и хорошо запомнил, что приложения для этой ОС вообще не поддерживают глобальные переменные. Да, сегмент .data и .bss полностью отсутствует — переменные предлагается хранить в структурах. Почему это делается? Все дело в том, что каждая программа в Symbian представляет собой dll-библиотеку, которую EKA загружает и создает экземпляр CApaApplication. А чтобы иметь возможность загружать dll один раз для всех программ (что актуально и для системных библиотек), ребята полностью выбросили возможность использования глобальных переменных. Но идея интересная!

Однако у этого подхода есть несколько серьезных ограничений:

  • Отсутствие глобальных переменных может стать проблемой при портировании существующего ПО, хотя ничто не мешает вашим программам передавать в каждую функцию структуру с глобальным состоянием, которое при необходимости можно изменить. Кроме того, нет никаких ограничений на использование C++ (кроме необходимости вручную реализовывать new/delete и отсутствия исключений).

  • Отсутствие предварительно инициализированных данных. Это уже может стать относительно серьезной проблемой, которая, однако, имеет свои решения. Например, если вы храните команды для инициализации представления в таблице или некоторые данные калибровки, вы не можете объявить их, просто используя инициализаторы в C. То же самое касается строковых литералов. Здесь есть два варианта: часть таблиц можно поместить в стек (если эти таблицы достаточно малы) или загрузить необходимые данные из бинарника с помощью основной прошивки (например, LoadString и т.п.).


Посмотрим на практике, имеет ли такой подход право на жизнь!

❯ Практическая реализация


Формат нашего бинарного файла будет невероятно простым: небольшой заголовок в начале файла и просто необработанный дамп сегмента .text, который можно будет экспортировать из полученного эльфа, даже не написав скрипт для компоновщика. Следует учитывать, что 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 и stdlib из сборки, поэтому мы получаем только необходимый код. --section-start устанавливает смещение для загрузки раздела .text равным 0x0 (в идеале это должно быть сделано из скрипта для ld).
objcopy просто копирует нужную нам текстовую часть из полученного ELF.

Как это работает на практике? Давайте разберем выходной двоичный файл и посмотрим, что выдаст cc:

Обратите внимание, что Start вызывает подфункции с помощью инструкции CALLX8, которая, в отличие от обычной версии Immediate CALL8, осуществляет переход относительно текущего адреса в ПК, делая переход полностью независимым от адреса загрузки программы в памяти. А за счет того, что все данные, включая указатель на глобальное состояние, передаются через стек, нет необходимости перемещать сегменты данных.

В результате все, что нужно от бинарного загрузчика, — это загрузить программу в память для инструкций, выделить память для структуры с состоянием программы и передать управление в Start. Каждый!
Особенно в случае с ESP32 у нас есть два возможных решения проблемы загрузки программы в память:

  1. Загрузите программу в IRAM. Такая возможность существует теоретически, но на практике загрузчик ESP32 устанавливает права на чтение и выполнение только для заданной области памяти. Попытка скопировать что-либо приведет к исключению SIGSEGV. Кроме того, сегмент IRAM сравнительно невелик — всего около 200Кб.

  2. Самопрограммирование. Для этого в esp32 есть два механизма — Partition API и SPI Flash API. Я выбрал Partition API из-за простоты реализации.


Для нашей прошивки необходимо будет переразбить флэш-память. Для этого запустите idf.py в менюconfig, перейдите в Таблица разделов -> Пользовательская таблица разделов CSV. Создаем в папке проекта parts.csv, где пишем:

# Таблица разделов ESP-IDF
# Имя, тип, подтип, смещение, размер, флаг
нВС, данные, НВС, 0x9000, 0x6000,
phy_init, данные, phy, 0xf000, 0x1000,
фабрика, приложение, фабрика, 0x10000, 1M,
исполняемый файл, данные, неопределенное, 0x110000, 0x10000

Чтобы загрузить программу, вы можете использовать соответствующий API разделов или parttool.py:

parttool.py --port "COM41" write_partition --имя раздела=исполняемый файл --input "run.bin"

Перейдем к загрузчику программы:

Прошивка ESP32:

сборка idf.py

idf.py флэш-память

экран idf.py

И посмотрите на результат:

Системный вызов 25

Системный вызов 35

Системный вызов 15

Все работает!

❯ Заключение


Как видите, в запуске сторонних программ нет ничего сложного при соблюдении определенных ограничений. Да, у этого подхода есть как серьезные преимущества, так и недостатки, но он справляется со своей задачей и позволяет запускать игры на собственных игровых консолях или сторонние программы на домашних компьютерах. И, конечно же, не забывайте о плагинах! Возможно, в вашем решении нужна возможность расширения функциональности устройства, но нет возможности предоставить исходный код или объектные файлы — тогда этот прием может вам пригодиться.

Наверное, стоит упомянуть еще об одном… очень странном методе, с которым я иногда сталкиваюсь при реализации самодельных компьютеров. Люди пишут. эмуляторы 6502/Z80 :)
И если этот подход +- применим к ESP32, снижение производительности в AVR будет слишком серьезным. Так зачем, если можно использовать все возможности ядра по максимуму?

Всего голосов:
Всего голосов:
[мин]Опрос ГаджетыПрограммированиеC++AvrArduinoEsp32ВстроенныйСделай самСамодельныйEsp8266АссемблерАппаратное обеспечениеМикроконтроллерыДлинный пост 8

Больше интересных статей здесь: Гаджеты.

Источник статьи: Выполняем сторонние программы на микроконтроллерах с Гарвардской архитектурой: как загружать программы без знания ABI?.