Arch Linux на Xiaomi 12T Pro: стоковое ядро Android, vendor‑модули и девять граблей загрузки

На моём Xiaomi 12T Pro загружается Arch Linux. Это не Termux и не chroot, а полноценная система: штатное ядро телефона, systemd, Wayland. Android остаётся рядом, при включении выбор делается кнопкой громкости. Ниже: как это устроено, что работает и на какие грабли я наступил по дороге. Большинство из них не привязаны к модели и встретятся на любом Qualcomm‑телефоне с GKI.
Скриншоты
Устройство
Xiaomi 12T Pro, он же Redmi K50 Ultra (diting): Snapdragon 8+ Gen 1, Adreno 730, 12 ГБ ОЗУ. Загрузчик разблокирован.
Подход: ядро от Android, система от Arch
Для Snapdragon 8+ Gen 1 нет готового mainline‑порта этой платы: нет ни дерева устройств, ни драйвера панели, ни тача. Собирать ядро из исходников Xiaomi тоже плохая идея: пересобранное ядро ломает ABI vendor‑модулей, и пересобирать тогда придётся всё.
Поэтому схема такая:
ядро — бинарный GKI от Google той же сборки, что стоит на телефоне (совпадает байт в байт, номер сборки виден в
uname -r);драйверы — готовые vendor‑модули из разделов
vendor_bootиvendor_dlkmсамого телефона;всё остальное — чистый Arch Linux ARM.
От Android в системе остаются только ядро, модули и прошивки сопроцессоров. Arch лежит файлом‑образом в /data, сам Android не трогается.
Главное правило: ничего не прошивать, пока всё не работает через fastboot boot. Так система загружается один раз из памяти: если что‑то не так, перезагружаешь телефон, и он снова в Android.
Как грузится
fastboot boot boot.img └─ загрузчик склеивает vendor_ramdisk + наш ramdisk, добавляет bootconfig └─ ядро GKI распаковывает оба архива, наш /init перекрывает андроидовский └─ /init (busybox): модули → /data → USB-сеть → switch_root в rootfs.img └─ systemd, Arch Linux ARM
Звучит просто. На деле почти каждая строчка стоила одной или нескольких перезагрузок вслепую: экрана ещё нет, логов тоже.
Как отлаживаться без экрана и логов
Пока не поднялись ни экран, ни USB, единственный канал связи с телефоном — вернулся он сам или нет. Я собирал initramfs, в котором /init делает одно: «перезагрузись через 5 секунд». Если телефон вернулся в fastboot сам через ~20 секунд, значит, наш init запустился. Если завис, проблема раньше. Дальше /init по кусочку обрастает кодом, и каждая гипотеза проверяется тем же способом.
Когда смонтирован /data, логи пишутся туда, и их можно забрать из Android через adb.
Грабли по порядку
1. Чёрный экран навсегда. Ramdisk производителя сжат lz4 -l (legacy‑формат, без маркера конца потока). Распаковав его, ядро не переключается на gzip нашего архива, и /init просто не появляется. Решение: свой архив тоже сжимать в lz4 legacy.
2. Модули «грузятся», но диска и USB нет. В modules.load от Qualcomm 118 нарушений порядка зависимостей. Android этого не замечает: его libmodprobe читает modules.dep. А голый insmod по списку работает строго по порядку. Пришлось написать рекурсивный загрузчик: сначала зависимости, потом softdep pre, потом сам модуль, потом softdep post.
3. Без softdep USB не поднимется никогда. PHY eUSB2, загруженный раньше своего репитера, не откладывает загрузку, а падает с ошибкой насовсем. Правильный порядок задан только в modules.softdep.
4. Экран «работает», но драйвер выбирает не ту панель. Параметр msm_drm.dsi_display0=… из командной строки ядра доходит до модуля, только если модуль грузит modprobe или libmodprobe: они сами читают /proc/cmdline. Busybox insmod этого не делает. Без параметра драйвер берёт эталонную панель Qualcomm 1080×2340, и кадры не идут. Решение: доставать параметры модуля из cmdline самому.
Загрузчик из пунктов 2–4 целиком (busybox sh):
loadmod() { # $1 = имя модуля (x.ko или x) local n f d sd opts n=$(basename "$1" .ko | tr '-' '_') grep -q "^$n " /proc/modules && return 0 f=$(modfile "$(basename "$1" .ko)") || return 1 # жёсткие зависимости for d in $(grep -E "^(.*/)?$(basename "$f"):" "$MODDIR/modules.dep" | cut -d: -f2); do loadmod "$(basename "$d")" done # softdep pre sd=$(sed -n "s/^softdep $n .*pre: *\(.*\)/\1/p" "$MODDIR/modules.softdep" | sed 's/ *post:.*//') for d in $sd; do loadmod "$d"; done # параметры из cmdline (module.param=value), как делает modprobe opts=$(tr ' ' '\n' < /proc/cmdline | sed -n "s/^$n\.\([^=]*=.*\)/\1/p" | tr '\n' ' ') insmod "$f" $opts || return 1 # softdep post sd=$(sed -n "s/^softdep $n .*post: *\(.*\)/\1/p" "$MODDIR/modules.softdep") for d in $sd; do loadmod "$d"; done
}
5. В GKI нет devtmpfs. Android создаёт узлы в /dev своим ueventd. У нас вместо этого tmpfs и mdev, а в Arch mdev работает демоном. Отдельная история: systemd без devtmpfs не слушает udev, и половина служб, завязанных на устройства, не стартует.
6. USB‑гаджет «поднялся», но компьютер его не видит. Причин две. В ядре есть фиктивный dummy_udc.0, и если взять первый попавшийся UDC, гаджет уедет туда. А контроллер dwc3 от Qualcomm спит, пока ему не сообщат роль порта (в Android это делает сопроцессор, которого у нас на этом этапе нет). Роль device нужно записать до ожидания UDC.
7. Android не видит файлы, созданные Linux. Всё, что Linux пишет на /data, для SELinux в Android остаётся «без метки», и adb не может такие файлы ни прочитать, ни перезаписать. Решение: каталоги и файлы создаются через adb, а Linux только дописывает в уже существующие (> сохраняет inode и метку, mv нет).
8. Телефон «сам выключился». Дважды. Кнопка питания приходит в Linux обычной клавишей, logind видит её и честно выключает систему. HandlePowerKey=ignore нужно поставить раньше, чем кто‑нибудь нажмёт кнопку.
9. pacman не ставит пакеты. pacman 7 требует Landlock, а в GKI его нет. Лечится DisableSandbox в pacman.conf.
Графика
Своего GL‑драйвера под этот GPU в Linux нет, доступен только Vulkan через turnip (драйвер Mesa) с бэкендом KGSL. Поэтому GL для композитора — это zink поверх turnip, плюс небольшой патч, чтобы turnip знал узел дисплея. Разница заметная: vkcube на весь экран на GPU даёт 60 кадров/с при 3% одного ядра, на CPU — 30 кадров/с при 54%.
Двойная загрузка: меню без экрана
Для постоянной работы свой boot.img прошивается в раздел boot. Внутри лежат и наш initramfs, и стоковый ramdisk Android. При включении /init первым делом грузит модули кнопок и вибромотора, телефон коротко вибрирует и три секунды ждёт: Vol+ — Arch, Vol− — Android. Если ничего не нажать, грузится то, что выбирали в прошлый раз. Экрана в этот момент ещё нет, так что меню — это вибрация.

Счётчик попыток A/B
Самая неожиданная проблема была здесь. Примерно через семь загрузок в Arch телефон перестал загружаться совсем и остался в fastboot.
Оказалось, загрузчик (ABL) при каждом старте уменьшает счётчик попыток слота, пока система не поставит бит «загрузка успешна». Всё это хранится в атрибутах GPT‑записи boot_a/boot_b:
Биты | Значение |
|---|---|
50 | слот активен |
51–53 | осталось попыток |
54 | загрузка успешна |
55 | слот незагружаемый |
Android ставит бит 54 сам (это делает boot_control HAL), Arch — нет. Когда счётчик доходит до нуля, слот помечается незагружаемым. На месте это лечится fastboot set_active a, навсегда — маленькой службой, которая при каждой загрузке ставит бит 54 и сбрасывает счётчик.
Менять нужно 8 байт атрибутов в первичной GPT и пересчитать два CRC (массива записей и заголовка). Ровно это делает сам ABL. sfdisk и sgdisk здесь не годятся: резервная GPT у Qualcomm лежит не там, где они её ищут, они сочтут её повреждённой и перезапишут. Суть на Python:
SUCCESS, UNBOOTABLE = 1 << 54, 1 << 55
RETRY_SHIFT, RETRY_MASK = 51, 0x7
hdr = bytearray(os.pread(fd, ss, ss)) # заголовок первичной GPT (LBA 1)
elba, n, esz, _ = struct.unpack_from("<QIII", hdr, 72)
ents = bytearray(os.pread(fd, n * esz, elba * ss)) # массив записей
a = struct.unpack_from("<Q", ents, i * esz + 48)[0] # атрибуты записи boot_<слот>
a = (a | SUCCESS) & ~UNBOOTABLE
a = (a & ~(RETRY_MASK << RETRY_SHIFT)) | (RETRY_MASK << RETRY_SHIFT)
struct.pack_into("<Q", ents, i * esz + 48, a)
struct.pack_into("<I", hdr, 88, zlib.crc32(ents)) # CRC записей
hsize = struct.unpack_from("<I", hdr, 12)[0]
h = bytearray(hdr[:hsize]); struct.pack_into("<I", h, 16, 0)
struct.pack_into("<I", hdr, 16, zlib.crc32(h)) # CRC заголовка
Дальше записываются обратно только изменённый сектор записей и заголовок.
Что работает
экран 120 Гц, тач, вибрация, автоповорот и автояркость;
GPU: Vulkan и композитинг на Adreno 730;
Wi‑Fi, Bluetooth, звук;
зарядка, батарея, сон;
камера (фото, ночной режим, видео 4K), аппаратное декодирование видео;
GPS, NPU Hexagon;
USB OTG (мышь, клавиатура, флешки), ИК‑порт.
Сверху стоит Wayland‑оболочка, рассчитанная на сенсорный экран: рабочие столы с виджетами, две шторки, меню приложений, список недавних, экранная клавиатура.
Ещё скриншоты
Windows‑игры
Windows‑игры идут через Wine 11 с патчами для ARM64EC, FEX и WoW64 (их набралось больше двадцати), DXVK работает поверх turnip. Касания работают как мышь, а экранный геймпад изображает контроллер Xbox 360.


Android‑приложения
Android работает в контейнере (LineageOS 20), а его приложения открываются обычными окнами рядом с Linux‑программами.

Итог
Самое ценное, что я вынес: на Qualcomm с GKI не нужно ни mainline‑ядро, ни сборка ядра из исходников производителя. Достаточно ядра и модулей, которые уже лежат в телефоне. Нужно только повторить руками то, что за тебя молча делают ueventd, libmodprobe и boot_control HAL в Android. Почти все грабли выше — как раз про это.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.