image

Для многих разработчиков приложений далеко не секрет, что экосистема Android не предполагает написание полностью нативных приложений: в этой платформе очень многое завязано на Java и без ART можно запустить только простые службы без какого-либо интерфейса. Однако, есть один способ писать практически под «голый» Linux, не перекомпилируя ядро и при этом пользоваться самыми интересными фишками устройства без оверхеда в виде тяжелого Android: ускорение 3D-графики (OpenGLES), микшер звука, ввод с различных устройств, OTG, Wi-Fi и если очень постараться — даже 3G. Это открывает множество разных интересных применений старым устройствам: «железо» смартфонов зачастую гораздо мощнее современных недорогих одноплатников. Сегодня я покажу вам, как написать и запустить программу, которая полностью написанное на C без Android, на No-Name Android-смартфоне практически без модификаций. Интересно? Жду вас в статье!

Что нам нужно знать?


Даже относительно старые устройства флагманского сегмента обладают весьма неплохими характеристиками. Зачастую они гораздо мощнее современных дешевых одноплатников и могут выполнять самые разные задачи: эмуляция консолей, работа в качестве плееров, да даже просто сделать настольные часики самому было бы здорово. Но есть одно но — это Android. Платформа от Google может тормозить даже на достаточно мощном железе, что резко ограничивает потенциально возможные применения подобных гаджетов. Да и многие программисты не особо хотят заморачиваться и учить API Android для реализации каких-то своих проектов.

image

Но конечно же, есть один способ писать нативные программы, при этом используя все ресурсы смартфона/планшета. Для этого нужно понимание, как работает процесс загрузки на многих Android-гаджетах:

  1. Первичный загрузчик (BootROM) инициализирует какую-то часть периферии и загружает вторичный загрузчик (U-boot/LK).
  2. Вторичный загрузчик, используя определенные аргументы (например зажата ли какая-то кнопка) выбирает, с какого раздела грузить ядро системы.
  3. После загрузки ядра Linux и подключения ramdisk начинается выполнение процессов системы.

Как раз в третьем пункте и лежит ключ к способу, который будем использовать мы. Дело в том, что в смартфоне обычно есть несколько boot-разделов и у каждого свой образ ядра Linux со своим ramdisk. Первый из них — это знакомый моддерам boot.img, который отвечает за загрузку системы и инициализирует железо/монтирует разделы/подготавливает окружение к работе (.rc файлы) и запускает главный процесс Android — zygote. При этом используется собственная реализация init от Android.

image

Второй, не менее знакомый многим раздел — recovery, отвечает за так называемый режим восстановления, в котором мы можем сбросить данные до заводских настроек/очистить кэши или прошить кастомную прошивку. Вероятно, многие из вас замечали, насколько быстро ваш девайс загружает этот режим, гораздо быстрее, чем загрузка обычного Android. И именно в его реализацию нам нужно заглянуть (я намеренно выбрал бранч версии 2.3 — т.е Gingerbread для простоты):

image

А recovery оказывается самой обычной нативной программой, написанной на C со своим небольшим фреймворком для работы с графикой и вводом. В процессе загрузки режима recovery, скрипт запускает одноименную программу в /sbin/, благодаря которому мы видим простую и понятную менюшку. Так почему бы не использовать этот раздел в своих целях и не написать какую-нибудь нативную программу самому?

Как я уже говорил выше, в этом режиме доступны многие аппаратные возможности вашего смартфона, за исключением модема. Используя полученную информацию, предлагаю написать наше небольшое приложение под Android-смартфон без Android сами!

Подготавливаем окружение


В первую очередь, хотелось бы отметить, что программы под «голый» смартфон можно писать не только на C/C++. Нам доступен как минимум FPC, который довольно давно умеет компилировать голые бинарники под Android. Кроме того, мы можем портировать маленькие embedded-версии интерпретаторов таких языков, как lua, micropython и duktape (JS).

Однако в случае нативных программ, есть два важных правила, которые необходимо понимать. Во-первых, в Android используется собственную реализацию стандартной библиотеки libc — bionic, в то время как на десктопных дистрибутивах используется glibc. Между собой они не совместимы — именно поэтому вы не можете просто взять и запустить консольную программу для Raspberry Pi, например.

image

А второе правило заключается в том, что начиная с версии 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 как вариант):

image

Открываете init.recovery.service.rc и убираете оттуда запуск одноименной службы (можно просто оставить файл пустым).

image

Запаковываем образ обратно тем же MTKImgTools и прошиваем флэшером для вашего устройства — в моём случае, это SP Flash Tool (MediaTek):

image

Заходим в режим рекавери и видим зависшую заставку устройства и звук подключения устройства к ПК. Если у вас установлены драйвера, то вы сможете без проблем зайти в adb shell и попасть в терминал для управления устройством. Теперь можно закинуть программу — прямо в корень рамдиска (записывается программа в ОЗУ, но при переполнении, телефон уйдет в ребут — осторожнее с этим). Пишем:

adb push cmdprog /:
adb shell
chmod 777 cmdprog
./cmdprog

И видим результат. Наша программа запускается и работает!

image

Это просто отлично. Однако я ведь обещал вам, что мы напишем программу, которая сможет выводить графику и обрабатывать ввод, предлагаю перейти к практической реализации!

Выводим графику


Для вывода графики без оконных систем, мы будем использовать 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:

image

После этого пишем простенькие функции для блиттинга картинок (в том числе с альфа-блендингом). В инлайнах и критичных к скорости функциям лучше не делать условия на проверку границ нашего буфера — лучше «отрезать» ненужное еще на этапе просчета ширины/высоты:

__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-дисплее:

image

image

Производительность отрисовки не очень высокая, однако если оптимизировать код (копировать непрозрачные картинки сразу сканлайнами и убрать проверки в инлайнах), то будет немного шустрее. Google для подобных целей сделали собственный простенький софтрендер — libpixelflinger.

Есть вариант для быстрой и динамичной графики: использовать GLES, который без проблем доступен и из recovery. Однако, насколько мне известно (в исходники драйверов посмотреть не могу), указать фреймбуфер в качестве окна не получится, поэтому в качестве Surface для рендертаргета у нас будет служить Pixmap (так называемый off-screen rendering), которому нужно задать правильный формат пикселя (см. документацию EGL). Рисуем туда картинку с аппаратным ускорением и затем просто копируем в фреймбуфер с помощью memcpy.

Обработка нажатий


Однако, ни о каких GUI-программах не идёт речь, если мы не умеет обрабатывать нажатия на экране с полноценным мультитачем! Благо, даже механизм обработки событий в Linux очень простой и приятный: мы точно также открываем устройство и просто читаем из него события в фиксированную структуру. Эта черта мне очень нравится в архитектуре Linux!

Каждое устройство, которое может передавать данные о нажатиях, находится в папке /dev/input/ и имеет имя вида event. Как узнать нужный нам event? Нам нужен mtk-tpd — реализация драйвера тачскрина от MediaTek (у вашего чипсета может быть по своему), для этого загружаемся в Android и пишем getevent. Он покажет доступные в системе устройства ввода — в моём случае, это event2:

image

Из 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;
}

image

В целом, это уже можно назвать минимально-необходимым минимумом для взаимодействия с устройством и использованию всех его возможностей на максимум без Android. Более того, такой метод заработает почти на любом устройстве, в том числе и китайских NoName, где ни о каких исходниках ядра и речи нет. Теперь вы можете попытаться использовать ваше старое Android-устройство для чего-нибудь полезного без необходимости изучать API Android.

Звук, модем и другие возможности


Для звука нам придётся использовать ALSA — поскольку эта подсистема звука сейчас используется в большинстве устройств на Linux. Судя по всему, тут есть режим эмуляции старого и удобного OSS, поскольку устройства /dev/snd/dsp присутствует. Однако, вывод в него какого либо PCM-потока не даёт ничего, поэтому нам пригодится ALSA-lib.

Другой вопрос касается модема и сети. И если Wi-Fi ещё можно поднять (wpa_supplicant можно взять из раздела /system/), то с модемом будут проблемы — нет единого протокола по общению с ним и кое-где, чтобы его заставить работать, нужно будет немного попотеть. Не стесняйтесь изучать исходники ядра (MediaTek охотно делится реализацией вообще всего — там и RIL, и драйвер общения с модемом) и смотреть интересующие вас фишки!

Заключение


Как мы с вами видим, у старых девайсов все еще есть перспективы стать полезными в какой-либо сфере даже без Android на борту. На тех устройствах, где нет порта Ubuntu или обычного десктопного Linux, всё равно сохраняется возможность писать нативные программы и попытаться приносить пользу.

Не стесняйтесь лезть и изучать вендорские исходники — это даёт понимание, как работают устройства изнутри. Собственно, благодаря такому ежедневному копанию исходников системы и появилась данная статья! :)



Возможно, захочется почитать и это:


Комментарии (18)


  1. SalazarMAX
    04.08.2023 08:08
    +8

    Спасибо за статью! Не хватает описания, как (и можно ли) автоматически запускать приложение при запуске рекавери. Не будешь же после разработки каждый раз команды в ADB вводить?


    1. bodyawm Автор
      04.08.2023 08:08
      +4

      Приветствую, да, можно. Вместо service /sbin/recovery пишем путь к нашей программе, не забыв выставить права (либо в конфиге нашего unpacker'а, либо прямо в рантайме перед стартом сервиса.


  1. dlinyj
    04.08.2023 08:08
    +10

    Прикольный концепт, не знал. Снимаю шляпу.


    1. bodyawm Автор
      04.08.2023 08:08
      +7

      Я помню ты все плевался от мобильного программинга из-за жабы. Чего теперь скажешь, пора расчехлять 10-летний Fly? :)

      Лично мое имхо, апи голого линуха хоть достаточно и низкоуровневое, но его гораздо проще выучить и понять, чем некоторые ведроидовские фишки. Если хочется запилить что-то под эмбед и нужно максимально дешевое железо с готовым КП, экраном, тачскрином и.т.п - то подобная мобилка отличный вариант. На авито их можно найти по 100-200-300 рублей.

      Кроме того, в китайцах очень часто прямо на плате разведен UART, еще и подписан частенько, позволяя сделать из них эдакий одноплатник. Но стоит иметь ввиду, что на 6572 драйвер уарта "сломан" и не работает, по крайне мере на ядре 3.4, на 6580 и более ранних все окей


      1. dlinyj
        04.08.2023 08:08
        +1

        Тут вопрос в том, что заряжается ли он в таком режиме. Мне телефоны не нравятся тем, что они не работают без аккумуляторов.


        Но идея классная, да.


        1. bodyawm Автор
          04.08.2023 08:08
          +3

          Медиатеки и спредтрумы точно заряжаются без каких либо проблем) Если вдругг нет - см. в сторону sysfs, возможно флагом можно включить зарядку.

          Если не нужен АКБ - то просто подпаиваешь источник 5в прямо к плюсу и минусу АКБ, медиатековские КП на такое реагируют нормально (но не больше +-0.5в допуск).


  1. Agne
    04.08.2023 08:08
    +3

    Выключенный смартфон, при подключении, кабеля питания, выдает некоторую реакцию например загорается столбчатый индикатор зарядки на дисплее. А можно ли включить телефон при подключении кабеля питания и перевести в режим рекавери ?

    Уточнение, при подключении кабеля питания, запустится загрузчик и запустит вместо рекавери, мою самописную программку.


    1. bodyawm Автор
      04.08.2023 08:08
      +4

      Это зависит от смартфона, медиатеки точно не запускают ядро при подключении зарядки. Индикацией там занимается вторичный загрузчик - lk.

      А вот смартфоны Spreadtrum вполне себе запускают полноценное ядро и позволяют вместо (или вместе) с чарджером запустить что-то свое


      1. Agne
        04.08.2023 08:08
        +2

        Есть Samsung Galaxy Ace 4 Neo SM-G318H/DS на Spreatrum SC7727S у него всего 512мб озу, андроид 4.4 тормозит.

        А так глядишь можно что-нибудь запустить полезное.

        Тулчейн для Rust может подсказать кто.


  1. bodyawm Автор
    04.08.2023 08:08
    +16

    Друзья! После сдачи статьи в редакцию, я наконец-то смог поднять модем НЕ запуская RIL и Android. Да, то есть я спокойно мог слать AT-команды в него и позвонить, например, на свой второй телефон.

    И конечно же есть нюанс - звуковой тракт реализован полностью через andriod audio, так что HAL ведроида (читаем - блобы) придется подключать. Но возможность поднять модем НЕ запуская остальную систему ЕСТЬ!

    Сейчас я хочу реализовать один интересный проект на базе этих находок, но мне нужен компактный смартфон на базе MT6577/MT6580/MT6575/MT6573. Есть у кого такой? Обычно это дешевые Fly/Explay/Qumo/DNS 2012-2014 годов выпуска.


    1. 15432
      04.08.2023 08:08
      +1

      Есть THL W3+


      1. bodyawm Автор
        04.08.2023 08:08

        Похоже на то что нужно)


  1. xakep666
    04.08.2023 08:08
    +7

    Возможно еще вот этот проект https://efidroid.org/ вам будет интересен.


  1. titbit
    04.08.2023 08:08
    +5

    Собственно сторонние рекавери, такие как TWRP написаны на C/C++ и реализуют многое из описанного здесь, включая вывод на экран, ввод с клавиатуры и т. д. Идеи о том, как получить доступ нативно можно брать оттуда.

    p.s. сам один раз писал полностью нативное приложение для андроида (в нем не было classes.dex, а был прямой запуск activity из динамической библиотеки), так что это тоже вариант, и не надо заморачиваться с прошивками и другими вещами.


  1. Rusrst
    04.08.2023 08:08
    +4

    Спасибо, было интересно!


  1. tormozedison
    04.08.2023 08:08
    +1

    Тем временем, @blackstrip часто пишет ПО для DOS на смартфоне с DOSBOX, тоже интересный подход.


    1. bodyawm Автор
      04.08.2023 08:08
      +1

      Пора блекстрипу портировать досбокс на f и сделать "почти" x86 :))


  1. insecto
    04.08.2023 08:08

    Тут должна быть ссылка на https://postmarketos.org/