Для многих разработчиков приложений далеко не секрет, что экосистема Android не предполагает написание полностью нативных приложений: в этой платформе очень многое завязано на Java и без ART можно запустить только простые службы без какого-либо интерфейса. Однако, есть один способ писать практически под «голый» Linux, не перекомпилируя ядро и при этом пользоваться самыми интересными фишками устройства без оверхеда в виде тяжелого Android: ускорение 3D-графики (OpenGLES), микшер звука, ввод с различных устройств, OTG, Wi-Fi и если очень постараться — даже 3G. Это открывает множество разных интересных применений старым устройствам: «железо» смартфонов зачастую гораздо мощнее современных недорогих одноплатников. Сегодня я покажу вам, как написать и запустить программу, которая полностью написанное на C без Android, на No-Name Android-смартфоне практически без модификаций. Интересно? Жду вас в статье!
❯ Что нам нужно знать?
Даже относительно старые устройства флагманского сегмента обладают весьма неплохими характеристиками. Зачастую они гораздо мощнее современных дешевых одноплатников и могут выполнять самые разные задачи: эмуляция консолей, работа в качестве плееров, да даже просто сделать настольные часики самому было бы здорово. Но есть одно но — это Android. Платформа от Google может тормозить даже на достаточно мощном железе, что резко ограничивает потенциально возможные применения подобных гаджетов. Да и многие программисты не особо хотят заморачиваться и учить API Android для реализации каких-то своих проектов.
Но конечно же, есть один способ писать нативные программы, при этом используя все ресурсы смартфона/планшета. Для этого нужно понимание, как работает процесс загрузки на многих Android-гаджетах:
- Первичный загрузчик (BootROM) инициализирует какую-то часть периферии и загружает вторичный загрузчик (U-boot/LK).
- Вторичный загрузчик, используя определенные аргументы (например зажата ли какая-то кнопка) выбирает, с какого раздела грузить ядро системы.
- После загрузки ядра Linux и подключения ramdisk начинается выполнение процессов системы.
Как раз в третьем пункте и лежит ключ к способу, который будем использовать мы. Дело в том, что в смартфоне обычно есть несколько boot-разделов и у каждого свой образ ядра Linux со своим ramdisk. Первый из них — это знакомый моддерам boot.img, который отвечает за загрузку системы и инициализирует железо/монтирует разделы/подготавливает окружение к работе (.rc файлы) и запускает главный процесс Android — zygote. При этом используется собственная реализация init от Android.
Второй, не менее знакомый многим раздел — recovery, отвечает за так называемый режим восстановления, в котором мы можем сбросить данные до заводских настроек/очистить кэши или прошить кастомную прошивку. Вероятно, многие из вас замечали, насколько быстро ваш девайс загружает этот режим, гораздо быстрее, чем загрузка обычного Android. И именно в его реализацию нам нужно заглянуть (я намеренно выбрал бранч версии 2.3 — т.е Gingerbread для простоты):
А recovery оказывается самой обычной нативной программой, написанной на C со своим небольшим фреймворком для работы с графикой и вводом. В процессе загрузки режима recovery, скрипт запускает одноименную программу в /sbin/, благодаря которому мы видим простую и понятную менюшку. Так почему бы не использовать этот раздел в своих целях и не написать какую-нибудь нативную программу самому?
Как я уже говорил выше, в этом режиме доступны многие аппаратные возможности вашего смартфона, за исключением модема. Используя полученную информацию, предлагаю написать наше небольшое приложение под Android-смартфон без Android сами!
❯ Подготавливаем окружение
В первую очередь, хотелось бы отметить, что программы под «голый» смартфон можно писать не только на C/C++. Нам доступен как минимум FPC, который довольно давно умеет компилировать голые бинарники под Android. Кроме того, мы можем портировать маленькие embedded-версии интерпретаторов таких языков, как lua, micropython и duktape (JS).
Однако в случае нативных программ, есть два важных правила, которые необходимо понимать. Во-первых, в Android используется собственную реализацию стандартной библиотеки libc — bionic, в то время как на десктопных дистрибутивах используется glibc. Между собой они не совместимы — именно поэтому вы не можете просто взять и запустить консольную программу для Raspberry Pi, например.
А второе правило заключается в том, что начиная с версии 4.1, Android требует, чтобы все нативные программы были скомпилированы в режиме -fPIE — т. е. выходной код должен не зависеть от адреса загрузки программы в виртуальную память. Для этого достаточно добавить ключ -fPIE, однако учтите, что если вы разрабатываете программу под Android 4.0 и ниже, то fPIE наоборот необходимо убрать — старые версии Android не поддерживают такой способ генерации кода и будут вылетать с Segmentation fault.
Для разработки нам понадобится ndk — там есть все необходимые заголовочники и компиляторы для нашей работы. Я использую ndk r9c, поскольку в свежих версиях Google регулярно может что-то сломать.
ndk-build, к сожалению, здесь работать не будет, поэтому Makefile придется написать самому. Я составил полностью рабочий Makefile, который без проблем скомпилирует валидную программу, вам остаётся лишь поменять NDK_DIR.
NDK_DIR = D:/android-ndk-r11c/
TOOLCHAIN_DIR = $(NDK_DIR)toolchains/arm-linux-androideabi-4.9/prebuilt/windows-x86_64/bin/
GCC = $(TOOLCHAIN_DIR)arm-linux-androideabi-g++
PLAT_DIR = $(NDK_DIR)platforms/android-17/arch-arm/usr/
LINK_LIBS = -l:libEGL.so -l:libGLESv1_CM.so
OUTPUT_NAME = cmdprog
build:
$(GCC) -I $(PLAT_DIR)include/ -L $(PLAT_DIR)lib/ -fPIE -Wl,-dynamic-linker=/sbin/linker $(LINK_LIBS) -static -o $(OUTPUT_NAME) main.cpp micro2d.cpp
После этого пишем простенькую программу, которая должна вывести «Test» и компилируем её.
❯ Деплоим на устройство
Несмотря на то, что грузиться мы будем в режим recovery, нам всё равно будет доступен adb, через который мы сможем запускать и отлаживать нашу программу. Это очень удобно, однако по умолчанию adb включен только в TWRP, который нужно сначала найти или портировать под ваш девайс (на большинство старых брендовых устройств порты есть, на нонейм придется портировать самому — гайды есть в интернете). Под ваше устройство есть TWRP? Отлично, распаковываете recovery.img с помощью так называемой «кухни» (MTKImgTools как вариант):
Открываете init.recovery.service.rc и убираете оттуда запуск одноименной службы (можно просто оставить файл пустым).
Запаковываем образ обратно тем же MTKImgTools и прошиваем флэшером для вашего устройства — в моём случае, это SP Flash Tool (MediaTek):
Заходим в режим рекавери и видим зависшую заставку устройства и звук подключения устройства к ПК. Если у вас установлены драйвера, то вы сможете без проблем зайти в adb shell и попасть в терминал для управления устройством. Теперь можно закинуть программу — прямо в корень рамдиска (записывается программа в ОЗУ, но при переполнении, телефон уйдет в ребут — осторожнее с этим). Пишем:
adb push cmdprog /:
adb shell
chmod 777 cmdprog
./cmdprog
И видим результат. Наша программа запускается и работает!
Это просто отлично. Однако я ведь обещал вам, что мы напишем программу, которая сможет выводить графику и обрабатывать ввод, предлагаю перейти к практической реализации!
❯ Выводим графику
Для вывода графики без оконных систем, мы будем использовать API фреймбуфера Linux, которое позволяет нам получить прямой доступ к массиву пикселей на экране. Однако учтите, что этот способ полностью программный и может оказаться тормозным для вашего приложения: скорость работы прямо-пропорциональна разрешению дисплея вашего устройства. Чем выше разрешение, тем ниже филлрейт. В моём случае, матрица была с разрешением 960x540, 32млн цветов, IPS — очень недурно, согласны?
Фреймбуфер Linux может работать с самыми разными форматами пикселя, имейте это ввиду. На некоторых устройствах может быть 16-битный формат (262 тысячи цветов, RGB565), на моём же оказался 32х-битный с выравниванием по строкам (имейте это также ввиду). 32х битный формат. Работать с ним легко: открываем устройство /dev/graphics/fb0, получаем параметры (разрешение, формат пикселя), делаем mmap для отображения буфера с пикселями на экране в память нашего процесса и выделяем второй буфер для двойной буферизации дабы избежать неприятных мерцаний.
void m2dAllocFrameBuffer()
{
fbDev = open(PRIMARY_FB, O_RDWR);
fb_var_screeninfo vInfo;
fb_fix_screeninfo fInfo;
ioctl(fbDev, FBIOGET_VSCREENINFO, &vInfo);
ioctl(fbDev, FBIOGET_FSCREENINFO, &fInfo);
fbDesc.width = vInfo.xres;
fbDesc.height = vInfo.yres;
fbDesc.pixels = (unsigned char*)mmap(0, fInfo.smem_len, PROT_WRITE, MAP_SHARED, fbDev, 0);
fbDesc.length = fInfo.smem_len;
fbDesc.lineLength = fInfo.line_length;
backBuffer = (unsigned char*)malloc(fInfo.smem_len);
memset(backBuffer, 128, fInfo.smem_len);
printf("Framebuffer is %s %ix%ix%i\n", (char*)&fInfo.id, fbDesc.width, fbDesc.height, vInfo.bits_per_pixel, fInfo.type);
}
Если не сделать предыдущий шаг и запускать нашу программу параллельно с recovery, то они обе будут пытаться друг друга «перекрыть» — эдакий race condition:
После этого пишем простенькие функции для блиттинга картинок (в том числе с альфа-блендингом). В инлайнах и критичных к скорости функциям лучше не делать условия на проверку границ нашего буфера — лучше «отрезать» ненужное еще на этапе просчета ширины/высоты:
__inline void pixelAt(int x, int y, byte r, byte g, byte b, float alpha)
{
if(x < 0 || y < 0 || x >= fbDesc.width || y >= fbDesc.height)
return;
unsigned char* absPtr = &backBuffer[(y * fbDesc.lineLength) + (x * 4)];
if(alpha >= 0.99f)
{
absPtr[0] = b;
absPtr[1] = g;
absPtr[2] = r;
}
else
{
absPtr[0] = (byte)(b * alpha + absPtr[0] * (1.0f - alpha));
absPtr[1] = (byte)(g * alpha + absPtr[1] * (1.0f - alpha));
absPtr[2] = (byte)(r * alpha + absPtr[2] * (1.0f - alpha));
}
absPtr[3] = 255;
}
for(int i = 0; i < image->height; i++)
{
for(int j = 0; j < image->width; j++)
{
byte* ptr = &image->pixels[((image->height - i) * image->width + j) * 3];
pixelAt(x + j, y + i, ptr[0], ptr[1], ptr[2], alpha);
}
}
И загрузчик TGA:
CImage* m2dLoadImage(char* fileName)
{
FILE* f = fopen(fileName, "r");
printf("m2dLoadImage: Loading %s\n", fileName);
if(!f)
{
printf("m2dLoadImage: Failed to load %s\n", fileName);
return 0;
}
CTgaHeader hdr;
fread(&hdr, sizeof(hdr), 1, f);
if(hdr.paletteType)
{
printf("m2dLoadImage: Palette images are unsupported\n");
return 0;
}
if(hdr.bpp != 24)
{
printf("m2dLoadImage: Unsupported BPP\n");
return 0;
}
byte* buf = (byte*)malloc(hdr.width * hdr.height * (hdr.bpp / 8));
assert(buf);
fread(buf, hdr.width * hdr.height * (hdr.bpp / 8), 1, f);
fclose(f);
CImage* ret = (CImage*)malloc(sizeof(CImage));
ret->width = hdr.width;
ret->height = hdr.height;
ret->pixels = buf;
printf("m2dLoadImage: Loaded %s %ix%i\n", fileName, ret->width, ret->height);
return ret;
}
И попробуем вывести картинку:
m2dInit();
test = m2dLoadImage("test.tga");
test2 = m2dLoadImage("habr.tga");
while(1)
{
m2dClear();
m2dDrawImage(test, 0, 0, 1.0f);
m2dDrawImage(test2, tsX - (test2->width / 2), tsY - (test2->height / 2), 0.5f);
m2dFlush();
}
Не забываем про порядок пикселей в TGA (BGR, вместо RGB), меняем канали b и r местами в pixelAt и наслаждаемся картинкой на большом и классном IPS-дисплее:
Производительность отрисовки не очень высокая, однако если оптимизировать код (копировать непрозрачные картинки сразу сканлайнами и убрать проверки в инлайнах), то будет немного шустрее. Google для подобных целей сделали собственный простенький софтрендер — libpixelflinger.
Есть вариант для быстрой и динамичной графики: использовать GLES, который без проблем доступен и из recovery. Однако, насколько мне известно (в исходники драйверов посмотреть не могу), указать фреймбуфер в качестве окна не получится, поэтому в качестве Surface для рендертаргета у нас будет служить Pixmap (так называемый off-screen rendering), которому нужно задать правильный формат пикселя (см. документацию EGL). Рисуем туда картинку с аппаратным ускорением и затем просто копируем в фреймбуфер с помощью memcpy.
❯ Обработка нажатий
Однако, ни о каких GUI-программах не идёт речь, если мы не умеет обрабатывать нажатия на экране с полноценным мультитачем! Благо, даже механизм обработки событий в Linux очень простой и приятный: мы точно также открываем устройство и просто читаем из него события в фиксированную структуру. Эта черта мне очень нравится в архитектуре Linux!
Каждое устройство, которое может передавать данные о нажатиях, находится в папке /dev/input/ и имеет имя вида event. Как узнать нужный нам event? Нам нужен mtk-tpd — реализация драйвера тачскрина от MediaTek (у вашего чипсета может быть по своему), для этого загружаемся в Android и пишем getevent. Он покажет доступные в системе устройства ввода — в моём случае, это event2:
Из event можно читать как в блокирующем, так и не в блокирующем режиме, нам нужен второй. Более того, в них можно инжектить события, что я показывал в статье про создание своей консоли из планшета с нерабочим тачскрином:
// Open input device
evDev = open(INPUT_EVENT_TPD, O_RDWR | O_NONBLOCK);
После этого, читаем события с помощью read и обрабатываем их. На устройствах с резистивным тачскрином, передается просто ABS_POSITION_X, на устройствах с поддержкой нескольких касаний — используется протокол MT. Когда пользователь нажал на экран, посылается нажатие BTN_TOUCH с значением 1, а когда отпускает — соответственно BTN_TOUCH с значением 0. Разные драйверы тачскрина используют разные координатные системы (насколько я понял), в случае MediaTek — это абсолютные координаты на дисплее (вплоть до ширины и высоты). На данный момент, я реализовал поддержку только одного касания, но при желании можно добавить трекинг нескольких нажатий:
void m2dUpdateInput()
{
input_event ev;
int ret = 0;
while((ret = read(evDev, &ev, sizeof(input_event)) != -1))
{
if(ev.code == ABS_MT_POSITION_X)
tsState.x = ev.value;
if(ev.code == ABS_MT_POSITION_Y)
tsState.y = ev.value;
if(ev.code == BTN_TOUCH)
tsState.isPressed = ev.value == 1;
}
tsState.cb(tsState.isPressed, tsState.x, tsState.y);
}
Теперь мы можем «возить» логотип Хабра по всему экрану:
void onTouchUpdate(bool isTouching, int x, int y)
{
if(isTouching)
{
tsX = x;
tsY = y;
}
}
int main(int argc, char** argv)
{
printf("Test\n");
m2dInit();
test = m2dLoadImage("test.tga");
test2 = m2dLoadImage("habr.tga");
printf("Volume: %i %i\n", vol, muteState);
m2dAttachTouchCallback(&onTouchUpdate);
while(1)
{
m2dUpdateInput();
m2dClear();
m2dDrawImage(test, 0, 0, 1.0f);
m2dDrawImage(test2, tsX - (test2->width / 2), tsY - (test2->height / 2), 0.5f);
m2dFlush();
}
return 0;
}
В целом, это уже можно назвать минимально-необходимым минимумом для взаимодействия с устройством и использованию всех его возможностей на максимум без Android. Более того, такой метод заработает почти на любом устройстве, в том числе и китайских NoName, где ни о каких исходниках ядра и речи нет. Теперь вы можете попытаться использовать ваше старое Android-устройство для чего-нибудь полезного без необходимости изучать API Android.
❯ Звук, модем и другие возможности
Для звука нам придётся использовать ALSA — поскольку эта подсистема звука сейчас используется в большинстве устройств на Linux. Судя по всему, тут есть режим эмуляции старого и удобного OSS, поскольку устройства /dev/snd/dsp присутствует. Однако, вывод в него какого либо PCM-потока не даёт ничего, поэтому нам пригодится ALSA-lib.
Другой вопрос касается модема и сети. И если Wi-Fi ещё можно поднять (wpa_supplicant можно взять из раздела /system/), то с модемом будут проблемы — нет единого протокола по общению с ним и кое-где, чтобы его заставить работать, нужно будет немного попотеть. Не стесняйтесь изучать исходники ядра (MediaTek охотно делится реализацией вообще всего — там и RIL, и драйвер общения с модемом) и смотреть интересующие вас фишки!
❯ Заключение
Как мы с вами видим, у старых девайсов все еще есть перспективы стать полезными в какой-либо сфере даже без Android на борту. На тех устройствах, где нет порта Ubuntu или обычного десктопного Linux, всё равно сохраняется возможность писать нативные программы и попытаться приносить пользу.
Не стесняйтесь лезть и изучать вендорские исходники — это даёт понимание, как работают устройства изнутри. Собственно, благодаря такому ежедневному копанию исходников системы и появилась данная статья! :)
Возможно, захочется почитать и это:
- ➤ Сам себе игровая консоль: как я сделал свой «тетрис» с нуля. Что происходит, когда программист встречается с железом?
- ➤ Сам себе экосистема: Как я адаптировал старый смартфон под современные реалии и написал клиенты нужных мне сервисов
- ➤ Пишем свой ROM BIOS
- ➤ Пишем наш первый модуль для ядра Linux
- ➤ Переделываем настенное зеркало во многофункциональное устройство
Комментарии (18)
dlinyj
04.08.2023 08:08+10Прикольный концепт, не знал. Снимаю шляпу.
bodyawm Автор
04.08.2023 08:08+7Я помню ты все плевался от мобильного программинга из-за жабы. Чего теперь скажешь, пора расчехлять 10-летний Fly? :)
Лично мое имхо, апи голого линуха хоть достаточно и низкоуровневое, но его гораздо проще выучить и понять, чем некоторые ведроидовские фишки. Если хочется запилить что-то под эмбед и нужно максимально дешевое железо с готовым КП, экраном, тачскрином и.т.п - то подобная мобилка отличный вариант. На авито их можно найти по 100-200-300 рублей.
Кроме того, в китайцах очень часто прямо на плате разведен UART, еще и подписан частенько, позволяя сделать из них эдакий одноплатник. Но стоит иметь ввиду, что на 6572 драйвер уарта "сломан" и не работает, по крайне мере на ядре 3.4, на 6580 и более ранних все окей
dlinyj
04.08.2023 08:08+1Тут вопрос в том, что заряжается ли он в таком режиме. Мне телефоны не нравятся тем, что они не работают без аккумуляторов.
Но идея классная, да.
bodyawm Автор
04.08.2023 08:08+3Медиатеки и спредтрумы точно заряжаются без каких либо проблем) Если вдругг нет - см. в сторону sysfs, возможно флагом можно включить зарядку.
Если не нужен АКБ - то просто подпаиваешь источник 5в прямо к плюсу и минусу АКБ, медиатековские КП на такое реагируют нормально (но не больше +-0.5в допуск).
Agne
04.08.2023 08:08+3Выключенный смартфон, при подключении, кабеля питания, выдает некоторую реакцию например загорается столбчатый индикатор зарядки на дисплее. А можно ли включить телефон при подключении кабеля питания и перевести в режим рекавери ?
Уточнение, при подключении кабеля питания, запустится загрузчик и запустит вместо рекавери, мою самописную программку.
bodyawm Автор
04.08.2023 08:08+4Это зависит от смартфона, медиатеки точно не запускают ядро при подключении зарядки. Индикацией там занимается вторичный загрузчик - lk.
А вот смартфоны Spreadtrum вполне себе запускают полноценное ядро и позволяют вместо (или вместе) с чарджером запустить что-то свое
bodyawm Автор
04.08.2023 08:08+16Друзья! После сдачи статьи в редакцию, я наконец-то смог поднять модем НЕ запуская RIL и Android. Да, то есть я спокойно мог слать AT-команды в него и позвонить, например, на свой второй телефон.
И конечно же есть нюанс - звуковой тракт реализован полностью через andriod audio, так что HAL ведроида (читаем - блобы) придется подключать. Но возможность поднять модем НЕ запуская остальную систему ЕСТЬ!
Сейчас я хочу реализовать один интересный проект на базе этих находок, но мне нужен компактный смартфон на базе MT6577/MT6580/MT6575/MT6573. Есть у кого такой? Обычно это дешевые Fly/Explay/Qumo/DNS 2012-2014 годов выпуска.
titbit
04.08.2023 08:08+5Собственно сторонние рекавери, такие как TWRP написаны на C/C++ и реализуют многое из описанного здесь, включая вывод на экран, ввод с клавиатуры и т. д. Идеи о том, как получить доступ нативно можно брать оттуда.
p.s. сам один раз писал полностью нативное приложение для андроида (в нем не было classes.dex, а был прямой запуск activity из динамической библиотеки), так что это тоже вариант, и не надо заморачиваться с прошивками и другими вещами.
tormozedison
04.08.2023 08:08+1Тем временем, @blackstrip часто пишет ПО для DOS на смартфоне с DOSBOX, тоже интересный подход.
SalazarMAX
Спасибо за статью! Не хватает описания, как (и можно ли) автоматически запускать приложение при запуске рекавери. Не будешь же после разработки каждый раз команды в ADB вводить?
bodyawm Автор
Приветствую, да, можно. Вместо service /sbin/recovery пишем путь к нашей программе, не забыв выставить права (либо в конфиге нашего unpacker'а, либо прямо в рантайме перед стартом сервиса.