[Технолонг] Ваши ИИ так не смогут! Пишем один «exe», который работает на 3-х разных ОС без перекомпиляции
Нет, это не шутка и не кликбейт, такое действительно возможно! Недавно я задался вопросом: а можно ли написать для ARM программу на C, которая будет бесшовно работать сразу на 4-х операционных системах без необходимости перекомпиляции для разных платформ и ABI? Моей целью была возможность писать кроссплатформенные эльфы для мобильных телефонов из нулевых и попытаться портировать на них эмуляторы ретро-консолей. Погрузившись в документацию на исполняемые форматы, я пришёл к выводу, что да — это возможно и смог реализовать такую программу на практике без виртуальных машин!
❯ Зачем и почему?
Давным-давно, в далёком 2001 году, мир увидел легендарный японский телефон — Sony CMD-J70. Ещё до создания совместного подразделения с Ericsson, Sony выпускала достаточно занимательные девайсы, которые привлекали внимание не только рядовых пользователей, но и моддеров всех мастей. Уже через пару лет после выхода, телефон реверсили и моддили все кому не лень: кто-то менял графику, кто-то писал патчи, а со временем написали даже PRGLoader — загрузчик внешних «экзешников», позволявший запускать на телефоне произвольный софт, написанный на ассемблере!
Сейчас сложно себе представить, но в те годы это был нереальный отвал башки: на большинстве телефонов были доступны разве что Java/Mophun-приложения, которые обладали ограниченным функционалом и уж тем более не позволяли лезть в дебри прошивки телефона, а здесь были программы которые буквально позволяли делать с телефоном всё что захочешь: светомузыку из подсветки, кастомные игры, полноценная многозадачность, живые обои на главный экран... всё это было доступно только на куда более дорогих смартфонах с Symbian и Windows Mobile на борту!
Недавно мы с вами вспоминали о легендарном Siemens M55 и узнали, что у него находится под капотом. Несмотря на диковинную архитектуру Infineon C166, даже под этот телефон разрабатывались патчи и была написана как минимум одна кастомная игра. Но рассвет моддинг-сцены Siemens произошёл с выходом платформы S-Gold на базе стандартного ядра ARM926EJ-S, когда в ~2004 году энтузиасты полностью взломали алгоритм генерации BootKEY для загрузчика, а затем в 2006 году реализовали полноценный эльфлоадер, который позволял загружать программы написанные на C и скомпилированные обычным компилятором ADS. В отличии от бинлоадера для CMD-J70, «эльфятник» позволял угонять функции RTOS для создания потоков и привносил в бюджетные телефоны полноценную вытесняющую многозадачность с настоящим диспетчером задач и возможностью запуска несколько программ одновременно:
Энтузиасты раскапывали прошивку в дизассемблере, изучали её и пытались понять как работают различные её подсистемы. Результатом стало появление нативного клиента почты с предком пуш-уведомлений, аськи (NatICQ), порты самых разных эмуляторов ретро-консолей и даже полная программная поддержка MP3 в тех телефонах, где её отродясь не было! И представьте себе, почти все эти программы можно было свернуть и продолжить работу в браузере или, например, Card Explorer'е!
Но если вы думаете что одними телефонами Siemens энтузиасты были едины, то вы ошибаетесь — ведь круче были только «моторолки»! В 2004-году, недорогая Motorola E398 с двумя громкими динамиками, светомузыкой и поддержкой MicroSD-флэшек, стала настоящим бестселлером и привлекла к себе не меньше энтузиастов, чем Siemens. Ребята сплотились на форуме MotoFan, нашли уязвимость в загрузчике и хакнули верификацию RSA-подписи у прошивок, позволив не только модифицировать Seem'ы (что-то типа EEPROM), но и создавать для телефона кастомные прошивки — монстрпаки, которые прибавляли громкость и без того не самым тихим динамикам и в различных аспектах изменяли главное меню устройства. Со временем, @Andy51 и ещё несколько энтузиастов реализовали эльфлоадер (EP1) для E398, раскопали прошивку и написали много полезного софта, время от времени переключаясь на Linux-телефоны от Motorola...
Вероятно многие читатели подумают мол «было и было, мой айфон/сяоми может запускать любой произвольный софт и эти ухищрения давным-давно неактуальны...». Но как бы не так: про моторолки и сименсы не просто так всё чаще вспоминают, у них до сих пор есть активное моддерское коммьюнити, которое продолжает пилить для них кастомный софт и далее колупать прошивку. Всё тот же @EXL портировал крутой софтрендер для E398 и в 2025 году наконец-то взломал C350, @Azq2 пилит аппаратный эмулятор Infineon S-Gold, и многие другие гики делают свой вклад в моддинг сцену уже не таких мейнстримных, но отнюдь не устаревших устройств!
Однако порог вхождения для написания эльфов достаточно высокий: нет никакой отладки кроме printf, любая ошибка в приложении приводит к зависанию или ребуту устройства (на сименсах с характерным «пик»), а API напрямую импортируется из прошивки телефона и может быть достаточно комплексным — ни о каких кроссплатформенных эльфах и речи не идет. Поэтому в какой-то момент мне стало интересно: а возможно ли написать такой эльфлоадер, который за своим рантаймом будет прятать детали реализации работы с аппаратной начинкой телефона и при этом загружать один и тот же бинарник на всех поддерживаемых платформах без особых патчей и изменений? Принявшись за изучение ABI ARM и спецификации Elf, я начал дизассемблировать и изучать самые маленькие тестовые программы...
❯ Формат ELF, ABI ARM и тулчейн
Начнём с самого простого: что же такое эти самые эльфы? Elf — формат исполняемых файлов, широко применяемый как в мире Unix-систем, так и в embedded-устройствах. Самые распространенные тулчейны — GCC и clang/llvm, по умолчанию собирают программы именно в этом формате и по своей сути, это прямой аналог .exe (PE) файлов из Windows. Помимо кода, Elf также содержит в себе множество секций и различных данных, при этом разработчики формата старались сделать его настолько гибким, чтобы его можно было использовать на любых архитектурах: от x86, до risc-v.
Каждая программа состоит из так называемых секций — участков кода, данных и метаданных, необходимых для её загрузки в память. Среди секций простой программы можно выделить как минимум четыре основных:
.text — хранит в себе код программы и обычно записывается в память с флагами MMU R X (чтение и выполнение)
.data — преинициализированные данные, имеет флаги R W (чтение и запись). Например, заполненная структура в C:
.bss — не инициализированные данные, иными словами глобальные переменные, которые при старте программы должны быть забиты нулями. Имеет те же флаги, что и .data.
.rodata — различные константы: строковые, const-преинициализированные массивы, а также структуры и т.п., имеет только флаг R и на системах с MMU попытка запись в эту секцию повлечет SIGSEGV.
За загрузку всех этих секций отвечает загрузчик Elf в ядре ОС. Однако это справедливо только для простых программ, которые загружаются в фиксированный адрес виртуальной памяти и которые не используют внешние библиотеки (.so, аналог в Windows — .dll). Поскольку адрес загрузки для всех библиотек предсказать невозможно, разработчики ABI придумали позиционно-независимый код (PIC и его производное — PIE), который может загружаться в любую область памяти и оттуда выполняться.
Реализация PIC может достигаться тремя разными способами:
Первый способ заключается в использовании глобальной таблицы смещений (GOT) и релокаций. Релокации — специальные данные в Elf, которые позволяют переместить программу в другой адрес путём патчинга адресов в секции .got «на лету»: иными словами, сам код (.text) остаётся позиционно-независимым (дабы библиотеку можно было загрузить один раз и использовать во множестве процессов) и обращается к GOT относительно PC, но в самом GOT (который представляет из себя массив void* addresses[]) указатели на остальные сегменты находятся так, будто программа загружается по смещению 0x0. Задача динамического линкера — посчитать абсолютный адрес для всех указателей в GOT: в простейшем случае, это got[address] += baseAddress.
Релокации могут затрагивать сразу literal pools в обход GOT, если архитектура предусматривает их наличие.
- Релокацией занимается динамический линкер также именуемый как интерпретатор в мире Unix (тот самый ld.so, что часто «not found» :) ), а самих релокаций есть много разных видов в зависимости от архитектуры процессора. В ARM чаще всего встречается R_ARM_REL32
- Второй способ заключается в том, что мы компилируем программу так, будто она должна загружаться по фиксированному адресу 0x0 — то есть без PIC, однако просим линкер (--emit-relocs) создать информацию о всех обращениях к памяти в виде всё тех же релокаций. Вместо R_ARM_REL32, линкер создаёт релокации R_ARM_ABS32, которые можно разрешить обычным сложением.
С таким подходом количество релокаций кратно увеличивается, однако из-за отсутствия GOT немного повышается быстродействие программы (вместо трёх LDR для загрузки слова из памяти нужно всего два: из Literal pool в регистр и затем из фактической памяти).
- Третий способ поддерживается не везде, но в ARM он является одним из самых распространенных в embedded-среде: код собирается с флагами /rwpi и /ropi полностью не зависит ни от GOT, ни имеет каких либо релокаций. Вместо этого, для адресации базового адреса программы он использует выделенный регистр R9, который загрузчик должен заполнить адресом, куда он загрузил программу (mov r9, textSectionBase). Такой подход теоретически чуточку быстрее, чем GOT, но медленнее второго подхода из-за необходимости добавлять сложение регистра с PC перед каждым фетчем из памяти, а также сохранения/восстановления контекста при вызове API-функций.
Поскольку в телефонах MMU обычно не используется, эльфлоадеры загружают программы по тому адресу, что им выделяет системный аллокатор памяти и вынуждены использовать PIC. Чаще всего используются релокации (как минимум на Siemens и Motorola), на некоторых платформах используется второй подход с использованием регистра R9.
Для большей гибкости, я решил выбрать второй подход и построить свой эльфлоадер поверх уже существующих загрузчиков, обернув API прошивок в ряд собственных стандартизированных функций: работа с дисплеем, вводом, файлами, а также звуком. При этом эльфы должны собираться современным компилятором clang с поддержкой C99, чтобы была возможность легко портировать современные single-header программы по типу эмуляторов, да и в целом не писать код на манер Ansi C, когда переменную нигде нельзя объявить кроме начала блока.
Далее я сутками игрался с компиляторами и пытался заставить выдать их подходящий для моих целей код и по итогу написал скрипт для линкера, который для простоты загрузки файла объединяет все секции в один .text (таким образом остаётся всего один Program Header):
И следующий набор опций для компилятора, который устанавливает архитектуру и целевой процессор, ABI для FPU, включает генерацию релокаций и отключает выравнивание в линкере для выходного файла (иначе файлы забиты нулями и весят целых 64Кб:
Когда компилятор наконец-то начал выдавать корректный код, я принялся писать сам эльфлоадер. За качество кода и отсутствие нормальной структуры не ругайте — это эмбеддед, тут можно ;))
На входе лоадеру поступает адрес загруженного в память эльфа и его длина. Задача эльфятника — верифицировать заголовок и убедится что он собран с подходящими параметрами:
Проанализировать таблицу заголовков с служебной информацией о том, что находится по тому или иному смещению в файле: загружаемая секция, таблица символов или строк, а затем загрузить все секции в участок памяти, который нам выдал аллокатор. На MMU-системах адрес должен быть выровнен по размеру страницы, иначе система не даст выдать страницам флаг EXEC!
Далее найти секцию с таблицей символов и с строками, где содержатся имена символов:
А затем найти функцию ElfMain, которая служит точкой входа и пропатчить таблицу импортированных функций! На этом, загрузка эльфа завершена — можно вызывать Main!
В Elf уже есть механизм импорта функций из сторонних библиотек, который называется Platform Linkage Table. Для импорта функций прошивки, эльфлоадер Siemens использует инструкцию для вызова программного прерывания - SWI (ближайший аналог - int 21h, int 10h из x86), Motorola же патчит thunk-функции на лету, которые сами вызывают настоящую функцию:
А я решил поступить несколько изящнее. В моем эльфятнике, функции импортируются с помощью специального макроса, который создаёт переменную-указатель на функцию, который изначально располагается в секции .functions. При этом с помощью ключевого слова asm, символу присваивается иное имя — с префиксом SYS_, которое означает то, что загрузчик эльфа должен пропатчить адреса функций на реальные (которые предварительно зарегистрированы в рантайме) в процессе загрузки программ и таким образом, избежать thunk-функций и позволить оптимизатору легко выкидывать указатели на неиспользуемые функции:
Лучший способ отладить эльфлоадер — в QEMU с GDB под Linux. Однако я решил время не терять и отлаживал его сразу на настоящем смартфоне с Windows Mobile. А раз WM стал первой поддерживаемой платформой — на нем мы с вами и реализуем рантайм.
Портируем на Windows Mobile (CE)
Поскольку всю жизнь я сижу в основном на Windows, а WinAPI в CE практически полностью копирует десктопную версию, никаких проблем с портированием рантайма не возникло. Единственный выбор который передо мной встал: стоит ли прокидывать stdlib из хост-системы в «эльфятник», или же воспользоваться реализацией newlib в clang/gcc. В процессе портирования на другие платформы выяснилось, что нормально libc реализован, по сути, только на Windows, во все остальных реализациях были лишь самые основные функции по типу malloc, free, memcpy, strcmp и т.п. Поэтому я решил не городить велосипеды и прокинул из хост-системы лишь аллокатор - т.е. malloc и free:
Далее я сразу решил, что платформозависимые функции для работы с дисплеем использовать не буду и из хост-системы мне нужен будет лишь указатель на фреймбуфер, а блиттинг, рисование текста и прочие операции я реализую сам. На первый взгляд может показаться что это единственное верное решение, однако на практике в некоторых телефонах (Motorola E398, Razr V3) активно использовались 2D GPU от ATI и Nvidia, которые рисуют (BitBLT) изображение значительно быстрее любой программной реализации.
Ниже представлена черновая реализация без преобразования пиксельформатов (поскольку на подавляющем числе телефонов использовался 565) и поддержки прозрачности через колоркей. Её можно оптимизировать до быстрого копирования по сканлайнам через memcpy:
С точки зрения отрисовки текста, нативные функции платформ тоже предоставляют крутые фичи по типу сглаживания, поддержки не-моноширинных шрифтов, множества кодировок, а также различные типы выравнивания. Но здесь встаёт вопрос с портативностью таких решений: разные рендеры шрифтов оперируют по разному и не все из них используют в качестве системы координат пиксели. Соответственно, я пошёл по олдовому «эмбеддерскому» пути и сделал обычные битмапные шрифты, которые (пока) статически слинкованы с самим эльфятником.
Отладив эльфлоадер, я написал небольшую тестовую программу для вывода картинки и текста
И получил следующий результат:
Ой, тут endianess пикселей (которые хранятся как halfword) сбился, давайте попробуем ещё раз:
Вот теперь всё работает! Пришло время портировать эльфятник на весьма необычную платформу, о потенциалах моддинга которой знают единицы...
❯ Портируем на MRP/MRE
И имя этой платформе, вернее даже двумя платформам — MRP и WRE. Эти платформы использовались на бюджетных китайских телефонах с 2007 по 2016 год, и иногда встречаются и по сей день. Встретить их можно было везде: легендарная Nokla TV E71/E72, клоны 6700, бюджетные телефоны Fly/Explay/DEXP и даже в оригинальных телефонах Nokia на платформе S30+ (например 230)!
И хотя люди часто считали такие устройства бесполезными в плане установки сторонних приложений, многие ранние «нонейм»-телефоны поддерживали запуск нативных программ через небольшой костыль — установку специального «загрузчика» dsm_gm.mrp и ввод комбинации *#220807# в номеронабиратель. Конечно, знали об этом костыле единицы и в 2010 году MediaTek решила сделать свою платформу под названием MRE (MAUI Runtime Environment), приложения для которой можно было запускать прямо из проводника без установки! SDK для обеих платформ сейчас свободно лежит в сети.
Обе платформы, по сути, занимаются тем же самым, чем и мой эльфятник — прокидывают нативные функции MMI (оболочка телефона) в приложения и для загрузки позиционно-независимых программ используют третий подход с регистром R9, который обязательно необходимо где-то хранить и восстанавливать. Изначально мой эльфятник использовал такой же подход, из-за чего я написал отдельный костыль для «свичинга» контекстов, причем восстановление R9 я делал в отдельной функции из-за бага ассемблера в IAR:
Но я не учел то, что MMI хоть и построены по event-based принципу, в них нельзя так просто взять и сделать while(true) {}, а необходимо использовать таймеры, что влечет за собой постоянные костыли с свичингом контекстов, что по итогу только снижает производительность. По итогу я перешел на релокации и реализовал проброс таймеров.
Во всем остальном, MRP и MRE простые как табуретка, никаких проблем с пробросом ввода и графики не возникло:
И вот, наша программа уже запускается на двух совершенно разных ОС без каких либо проблем!
❯ А если что-то посложнее Hello, world?
Наверняка у читателя возникнет вопрос мол «окей, твой эльфятник может и способен запускать простые программы, но как насчет чего-то посложнее?». И конечно же, для тестов я решил портировать не абы что, а целый эмулятор NES! В конце-концов, одна из целей разработки такого эльфятника — возможность запускать Java-игр и эмуляторов на многих кнопочных телефонах из нулевых.
Какое то время назад, я обнаружил весьма шустрый эмулятор NES от неизвестного разработчика из Китая. Код был неважного качества, никаких копирайтов в нём не было. Но поскольку сам эмулятор был быстрый (быстрее, наверное, только vNesC, который является прямым source-портом Java-эмулятора vNes на C), я отвязал его от целевой платформы и превратил в небольшую библиотеку для легкого портирования на любые платформы путем вызова всего нескольких функций:
И, соответственно, базовый порт на наш эльфятник выглядит примерно так:
А вот и результат:
❯ Заключение
Вот так и можно написать программу, которая бесшовно работает на трёх разных операционных системах, которые не имеют ничего общего друг с другом! На первый взгляд всё это кажется сложным, однако на практике очень просто и интересно! Нужно лишь взять дизассемблер в зубы и немножечко изучить то, что выдаёт компилятор...
Если вам понравилась статья и вы хотите меня поддержать, у меня есть Boosty, а также виджет доната в комментариях на DTF. А ещё мне можно отправить какое-нибудь интересно железо: устройства на WinCE/WinMobile, китайские кнопочники, китайские подделки на iPhone/Samsung из начала 2010-х, ретро-ПК железо - всё это я очень люблю и ценю :)