Документацію написано англійською.

HakiOS: подробные заметки по разработке

Это прежний README целиком — история работы, измерения и устройство каждой части. Краткое введение теперь в README.md.

HakiOS — iOS 16.5 на POCO X5 Pro 5G (redwood). Открытое ядро Apple XNU (xnu-8796.121.2 — ядро iOS 16.5), свой загрузчик и свои замены закрытых драйверов, под которыми работает немодифицированный userland iOS 16.5 от iPhone X: launchd → backboardd → SpringBoard → приложения. Работает в двух местах:

  • POCO X5 Pro 5G (redwood, Snapdragon 778G) — ничего не прошивается: UEFI грузится через fastboot boot, вся система живёт в ОЗУ, после перезагрузки телефон снова в Android;
  • QEMU (virt, aarch64) — то же самое на любом Linux-компьютере, без телефона.

Графика по умолчанию — программный рендер CoreAnimation, поэтому всё небыстро: на телефоне 15–40 кадров/с на четырёх ядрах. Отрисовка самим Adreno телефона уже работает в опытном виде — см. раздел «GPU».

Что работает и чего нет

Работает Не работает
Экран блокировки, разблокировка свайпом, домашний экран с полным набором значков (две страницы), стандартные обои iOS 16 Сотовая сеть, Bluetooth
Системные жесты: свайп снизу — «домой» и переключатель приложений, свайп от левого края — «назад», Пункт управления Без gpu=1 (программный рендер; в QEMU всегда) часы экрана блокировки просто белые, без «вибрации» под цвет обоев, и верх обоев под ними не затемнён
Запуск приложений: Настройки (вглубь), Часы, Календарь, Фото, Заметки и др.; расширения приложений (виджеты «Погода» и «Календарь» на домашнем экране рисуют своё содержимое — на телефоне; в QEMU им не хватает скорости) Metal у приложений (они рисуют через него сами; WebGL, видоискатель «Камеры», эффекты снимка). Но уже не «не предполагается»: наш передний конец читает шейдеры, которые Apple скомпилировала для Metal, и исполняет их на Adreno — 54 из 456 прошли на GPU телефона: 7 отрисовкой (шесть текстурных вернули точный тексель) и все 47 вычислительных ядер корпуса диспатчем. 71 операция air.* сходится с LLVM на всех 45 697 случаях, 61 из них проверена на самом Adreno; слово состояния сэмплера не разобрано, то есть фильтрация и адресация самой Apple не проверены. См. docs/air.md
Сенсорный экран телефона (Goodix GT9916), мультитач, пробуждение касанием Без cpus=N работает одно ядро процессора
Несколько ядер процессора (cpus=8: все семь, что не заняты загрузчиком; малые — после больших)
Кнопки: на телефоне настоящие — питание (боковая кнопка iPhone) и громкость; в QEMU — клавишами Зарядка под нашим ядром (телефон только разряжается)
Большое ядро на 2,4 ГГц, загрузка до экрана блокировки ~20 с (телефон)
Safari: открывает страницы (на телефоне — через его Wi-Fi), его службы — из криптекса App, как на iPhone (см. «Только APFS, как на iPhone»)
Снимки экрана и запись экрана с телефона
Сохранение данных: то, что iOS записала в /private/var, переживает перезагрузку телефона — архивом в разделе darwin (var_store=1 ufs_write=1, см. «Том данных между загрузками»)
Звук iOS и в QEMU (см. ниже; «Сейчас играет» — с оговоркой), и на самом телефоне: динамики играют то, что играет iOS (adsp=1 sound=4, по умолчанию на 30 дБ тише уровня Android), приложения записывают с его главного микрофона (adsp=1 mic=4: «Диктофон» пишет и рисует волну); громкость кнопок и Пункта управления — тоже Из микрофонов поднят только нижний. Звук внутри сеанса съёмки — отдельный путь, и его нет (см. строку про камеру)
Камера телефона в самой Camera.app (cam=5, см. cam=N в «На телефоне»): задняя и фронтальная, живой предпросмотр, цвета по тюнингу самих камер, автофокус и фокус касанием, зум, снимки в HEIC Видео и Live Photo: звук сеанса съёмки идёт не через микрофон ядра, а через посреднический плагин аудио-HAL, которого на этом телефоне нет — узел звука отвечает 'nope' (inject_media.c)
Sileo и префикс Procursus — dpkg и apt, установка пакетов из сети (tools/mk-jb.sh, см. «Sileo») App Store рисует свой интерфейс (20 октября, ветка services): карточка «Welcome to the App Store», выбор про персонализированную рекламу и сам магазин с панелью вкладок Today, Games, Apps, Arcade, Search. Это JetEngine, рисующий тот документ, приход которого мы видели целиком, — без Apple ID, SIM и активации, по Wi-Fi телефона. Содержимое вкладки Today не приходит: магазин пишет в теле «Cannot Connect to App Store» с кнопкой Retry (). И это не сеть — за содержимым запрос не уходит вовсе: таблица задач показывает те же пять запросов (мешок трижды, jetpack дважды, все 200), а среди всех ошибок, созданных в приложении за сеанс, нет ни одной из NSURLErrorDomain. Ничего сетевого не падало, потому что ничего сетевого и не затевалось. Зато 24 раза создаётся NSCocoaErrorDomain 4099 — это NSXPCConnectionInterrupted, а не 4097 (Invalid): собеседника не «не нашли», он умер посреди разговора, поэтому и строк failed lookup нет ни одной. След сошёлся: за тот же прогон умирает ровно одна служба — com.apple.fairplayd.H2, двадцать шесть раз, каждый раз exit(0) и «Service only ran for 0 seconds»; appstored при этом жив весь прогон (его собственные отчёты идут 315, 330, 345 … 510 с, часы ни разу не сбрасываются). Цепь целиком: запрос за содержимым требует подписи FairPlay → служба подписи умирает мгновенно → соединение рвётся → запрос не изготавливается → «Cannot Connect to App Store», ни одного обращения в сеть. Это и есть то, что надо починить, чтобы «Сегодня» загрузилась, и задача ограниченная: удержать fairplayd.H2 живым. Разбор самого демона статикой сузил вопрос и снял первого подозреваемого: в usr/sbin/fairplayd.H2 не импортируются ни __mac_syscall, ни sysctl, ни exit, а в его коде нет ни одной инструкции svc — значит и те 12 запросов к AMFI, и 33 sysctl из трассы принадлежат dyld и libSystem, а не его решению; отказ же MAC от ядра убивал бы процесс, а не давал чистый ноль. Уходит он возвратом из main (арифметика обфускатора даёт ровно 0), рано — до IOKit и до регистрации своей службы, а 26 смертей за прогон это KeepAlive в его plist, то есть одна и та же смерть в цикле. Точную ветку статикой не достать: бинарь защищён обфускатором FairPlay со сглаженным потоком управления и зашифрованной таблицей переходов. Зато он зовёт MGCopyAnswer и SecTaskCopyValueForEntitlement через границу образов, то есть подмена dyld их видит — это и есть следующий прибор. Прежняя запись здесь говорила, что магазин остаётся на индикаторе загрузки и за содержимым не ходит ни разу: и то и другое было верно по факту и неверно по смыслу — приложение стояло на первом экране приветствия и ждало нажатия, а под «LOADING» была карточка, которую никто не нажал. После «Continue» и «Turn Off» запросов становится пять вместо четырёх. Мешок настроек из подозреваемых выбыл: из 48 запрошенных ключей 31 отвечен, 17 нет, и ни один из семнадцати не про содержимое (метрики, TestFlight, пороги онбординга, таймауты)
Вход в учётную запись Apple и iCloud: упирается не в сеть и не в пароль, а в отсутствие Secure Enclave. mobileactivationd не может создать ключ устройства (0xe00002c7, kIOReturnUnsupported — наша заглушка MiniAppleKeyStore умеет keybag и не умеет UIK), поэтому нет записи активации и устройственного сертификата, поэтому apsd не получает push-токен (copyTokenForDomain push.apple.com (null)), поэтому akd не делает check-in. Каждое звено измерено в журналах. При этом сеть и мешок GrandSlam работают: akd ходит на gsa.apple.com уже сейчас, без учётной записи. Вторая опора, которой нет: личность устройства выдумана загрузчиком — серийный номер RDW00POCOX5P, MLB и ECID тоже наши. MobileGestalt у нас настоящая, из образа, и отвечает по дереву устройств, которое строит загрузчик: ключи семейства UDID она соберёт и выдаст — складно и устойчиво, но из выдуманного, — а HasSEP, secureElementID, EffectiveProductionStatusSEP и прочее вернут nil, потому что таких узлов мы не публикуем вовсе
SSH на телефон по ключу (RC_JOBS=rc.jobs.ssh, OpenSSH из префикса; init при каждой сборке префикса строит /var/jb/etc/pwd.db, без которой ни одна программа префикса не видит root)

Это стенд для исследования, а не прошивка: сеанс длится заданное число секунд, потом ядро возвращается в загрузчик и отдаёт журнал.

Быстрый старт: QEMU, без сборки ядра

В prebuilt/ лежат собранные из этого же репозитория ядро, загрузчик и наши программы, так что нужен только образ системы. Файлов iOS в репозитории нет — IPSW iOS 16.5 для iPhone X вы берёте сами, скрипт только распаковывает из него нужное.

Нужно: Linux x86-64, ~12 ГБ свободного места (готовое занимает ~8 ГБ), от 4 ГБ свободной памяти. Пакеты (Arch):

sudo pacman -S --needed git python 7zip lz4 gcc qemu-system-aarch64 qemu-ui-gtk edk2-aarch64

(7-Zip нужен версии 23 или новее — он читает APFS; lz4 — библиотека liblz4.)

git clone git@github.com:vcvkk/darwin-redwood.git
cd darwin-redwood
sysimage/unpack.sh ~/iPhone10,3,iPhone10,6_16.5_20F66_Restore.ipsw   # ваш IPSW: распаковывает нужное
REST=1 userland/build.sh      # собирает образы APFS: userland/root.apfs и userland/system.apfs (2,5 ГБ)
mkdir -p logs
qemu/run-ios.sh 1200 -display gtk > logs/qemu.log     # сеанс на 20 минут

Через 2–3 минуты в окне появится экран блокировки.

Сколько это занимает. Измерено 20 октября на этой машине (Linux x86-64, 12 потоков, NVMe), проходом по этим самым командам из чистого клона: sysimage/unpack.sh — 1 мин 45 с (тогда скрипт ещё сам качал образы; теперь IPSW вы кладёте сами, и остаётся только распаковка); REST=1 userland/build.sh — 1 мин 52 с; QEMU до экрана блокировки — ещё 2–3 минуты. То есть около восьми минут от пустого каталога до iOS, если канал отдаёт порядка 25 МБ/с; сборка ядра и загрузчика в это не входит и не нужна — они в prebuilt/.

  • Мышь с зажатой левой кнопкой — палец. Разблокировка и выход из приложения — свайп вверх от самого нижнего края (как на iPhone X), «назад» — свайп от левого края.
  • Клавиши: H — «Домой», L — блокировка/питание, ↑/↓ — громкость, F11 (держать) — пять пальцев.
  • То же из скрипта (без окна, -display gtk можно не указывать): qemu/touch.py swipe 0.5 0.985 0.5 0.3 250, qemu/touch.py tap 0.615 0.71, qemu/touch.py key home (координаты — доли экрана), снимок экрана — qemu/screendump.sh shot.png.
  • Первый аргумент run-ios.sh — длительность сеанса в секундах; по её истечении ядро возвращается в загрузчик и QEMU завершается. Журнал ядра и демонов идёт в stdout.
  • Несколько ядер процессора: QEMU_SMP=4 CMDLINE="cpus=4" qemu/run-ios.sh 1200 … (QEMU_SMP — сколько ядер у машины, cpus= — сколько из них берёт ядро системы; без cpus= оно работает на одном). До экрана блокировки тогда полторы минуты вместо двух с половиной, и всё заметно отзывчивее. Программа cpu_test (из userland/rc.jobs) пишет в журнал число процессоров и, через четыре минуты, сколько из них на деле работало одновременно.
  • Сразу после появления SpringBoard система ещё занята (регистрирует приложения): подождите секунд 30–40, иначе жесты «растягиваются» и не распознаются.

Звук

Звуком в iOS заведует mediaserverd; в него встаёт наше устройство вывода (HakiosAudio.driver), которое iOS принимает за собственный динамик iPhone, и то, что она играет, приходит в ядро (/dev/hakios-audio):

REST=1 userland/build.sh
AUDIO=pipewire qemu/run-ios.sh 1200 -display gtk > logs/qemu.log    # или AUDIO=pa, alsa, sdl; AUDIO=файл.wav — в файл

Для вывода на звуковую карту компьютера QEMU нужен свой модуль (в Arch — пакет qemu-audio-pipewire, qemu-audio-pa и т. п.); без AUDIO звук играется «в никуда», но внутри iOS всё происходит так же. Проверено в QEMU записью: тон из своей программы через очередь звука и системные звуки (блокировка, сигнал) доходят до ядра; громкость действует (кнопки и ползунок в Пункте управления меняют уровень, индикатор громкости появляется). Под эмуляцией цепочка обработки звука iPhone не успевает за реальным временем, и запись идёт с пропусками. Модуль «Сейчас играет» в Пункте управления в QEMU пуст, пока mediaserverd не перезапустится хотя бы раз (так же и без нашего устройства); после перезапуска показывает «Не исполняется» с кнопками. RC_JOBS=rc.jobs.sound добавляет в образ сценарий, который сам проигрывает тон и несколько системных звуков (userland/src/sound_test.c); когда звук смолкает, ядро пишет в журнал строку hakios-audio: N s written at R frames a second, M s of it sound, peak P% of full scale.

На телефоне с adsp=1 sound=4 то, что играет iOS, звучит из обоих динамиков: через звуковой процессор (порт TDM, поток, путь к порту) и два усилителя Awinic, которые включаются, только пока есть звук (параметр sound=N ниже; RC_JOBS=rc.jobs.speakers проигрывает тон, звук блокировки, щелчки клавиатуры и фанфары). То, что iOS играет, можно и забрать: параметр ядра rec_sound=N сохраняет первые N секунд звука в памяти, после сеанса ./xl sound out.wav достаёт их (в QEMU — pmemsave по адресу из строки hakios-audio: recording … at 0x…, затем tools/sound.py); с rec_sound_fed=1 — то, что ушло к усилителям. На телефоне звук проверен на слух. Защиты динамиков, которая у Android работает в звуковом процессоре (алгоритм Awinic с калибровкой), здесь нет; вместо неё отсчёты по пути к усилителям проходят «сторож» ядра (drivers/audio/hakios.guard.c): сначала усиление sound_gain (по умолчанию и самое большее 12 дБ: обработка самой iOS для динамика iPhone X держит даже полный тон на максимуме громкости на 12,6–14,3 дБ ниже полной шкалы), затем фильтр верхних частот 200 Гц, три средних мощности на динамике — за 0,2, 3 и 60 с, пределы на 3, 6 и 9 дБ ниже мощности синуса полной шкалы на уровне Android (громкость опускается так, чтобы ни одно не вышло за свой; средние падают и при выключенных усилителях, как динамик остывает) — и ограничитель пиков на 3 дБ ниже полной шкалы на уровне Android. Раз в секунду читаются температура усилителей и напряжение батареи, как их видят сами усилители, и по таблице «монитора» драйвера Android из той же конфигурации громкость снижается на холоде и при разряженной батарее. Проверка на телефоне: sound_below=160 (−20 дБ) — сторож работает, у усилителей ни одной ошибки; −10 дБ и уровень Android — ещё впереди. По умолчанию, пока громче на слух не проверено, sound_below=240 (−30 дБ). RC_JOBS=rc.jobs.loud — самое тяжёлое: тон полной шкалы 60 с прямо в /dev/hakios-audio мимо обработки iOS, потом тон через iOS на максимуме громкости; sound_guard_as=0 вместе с sound_below=720 показывает в журнале и записи, что сторож делает на уровне Android, без единого звука. tools/guard_test.c — сторож на этой машине.

Основной микрофон телефона (adsp=1 mic=3, пока только в ядре): аналоговый микрофон на ADC1 кодека WCD938x, его отсчёты идут по SoundWire (ведущий TX цифрового кодека LPASS) в дециматор 1 макроса TX, оттуда через порт codec DMA TX_CDC_DMA_TX_3 и звуковой процессор в поток, который ядро читает (48 кГц, 16 бит, один канал). Всё — как это делает Android при записи с микрофонов трубки: значения из живого дерева устройств, путей микшера Xiaomi и ядра Xiaomi (drivers/audio/hakios.mic.c). Шина SoundWire — как у Android на пути «handset-mic»: там включены два АЦП (ADC1 и ADC4), и шина идёт на 9,6 МГц; таблицы Android для одного ADC1 (шина 4,8 МГц) переносят только половину того, что ADC1 выдаёт в режиме «low power», и вместо комнаты слышен громкий тон (mic_main=1). Уровень каждой секунды идёт в журнал, а с rec_sound=N rec_sound_mic=1 отсчёты попадают в запись для ./xl sound. Проверено на телефоне: тихая комната — около −73 дБ полной шкалы; с mic_tap=1 haptic=5 моторчик вибрации работает со 2-й по 3-ю секунду, уровень поднимается на 6–9 дБ, а в записи появляется его резонанс 175,5 Гц (на 20 дБ выше фона) с нечётными гармониками — микрофон слышит, и частота отсчётов верна.

Замечания:

  • Память гостя — 7 ГБ (ядру достаётся то же окно 4,2 ГБ, что и на телефоне), отображённые из разрежённого файла qemu/ram.img (образ системы лежит в нём по нужному смещению). На btrfs/xfs это reflink и места не занимает, на ext4 образ копируется (ещё 2,5 ГБ). Один запущенный QEMU занимает 2,5–3 ГБ памяти компьютера.
  • Сеть (опытно, только в QEMU). QEMU_NET=1 qemu/run-ios.sh … добавляет машине сетевую карту virtio, за которой стоит «пользовательская» сеть самого QEMU (DHCP, DNS и выход в сеть компьютера, без прав root); ядро делает из неё интерфейс en0. Сам по себе интерфейс iOS не настраивает: образ, собранный с RC_JOBS=rc.jobs.net, при загрузке просит об этом штатный configd (userland/src/net_test.m), после чего у системы есть адрес, работают DNS (через mDNSResponder) и HTTPS (CFNetwork). Штатные службы тут же начинают ходить на серверы Apple (в первые минуты около 30 МБ данных Карт), а приложения Apple — Погода, App Store, Подкасты — данных всё равно не получают. Раньше здесь было написано, что им нужна служба fairplayd, которой в образе нет; это оказалось неверно. С корнем на флеше fairplayd в образе теперь есть (mkjobs.py --root-volume), и на телефоне проверено: ненайденных обращений к com.apple.fairplayd больше нет ни одного, но и работать она не остаётся — запускается, тут же выходит с кодом 0, ничего не сказав в журнал, и launchd поднимает её снова раз в 10 секунд (KeepAlive, 22 запуска за сеанс; ни одной аварии по сигналу — это не падение, а отказ работать). Скорее всего потому, что ей нужен драйвер ядра: пользовательский клиент IOKit com_apple_driver_FairPlayIOKitUserClient, который она называет в своём праве com.apple.security.iokit-user-client-class, а такого драйвера здесь нет. Здесь стояло «витрины пусты не из-за неё» — это неверно, и способ, которым ошибка получилась, важнее самой ошибки. Рассуждение было такое: в том же сеансе Погода и её виджет не спрашивали fairplayd вообще (им не хватало com.apple.kvsd — 61 обращение, это syncdefaultsd — и com.apple.locationd.synchronous/.registration), а App Store не хватало ровно одной службы — com.apple.appstored.xpc.request. Но это сказано по ненайденным обращениям, а служба, которая запускается и тут же выходит, ненайденной не считается ни разу: launchd её нашёл и поднял, клиент послал сообщение и ждёт ответа. Тем способом, которым нашлась apsd, это не находится в принципе. Изнутри appstored видно обратное (см. «App Store» ниже): поток AMS стоит в Mach-вызове _AMSFairPlayGetDeviceType, ответа нет, и за этим одним ожиданием стоит очередь из семнадцати. Витрина и мешок — следствие. А вот следующий шаг из абзаца выше остаётся верным и становится главным: fairplayd уходит, потому что ей нужен драйвер com_apple_driver_FairPlayIOKitUserClient, которого здесь нет. В Настройках Wi-Fi остаётся «Выкл.» (это не Wi-Fi). По умолчанию сети нет, и без QEMU_NET=1 ничего не меняется.
  • Связка ключей (Keychain). Работает так, как если бы у устройства не было код-пароля и оно всегда было разблокировано: программы записывают в неё пароли и читают их обратно (wifid — пароли сетей, подключённых из Настроек). Это не защищённая связка: Secure Enclave нет, ключи классов защиты заменяет обратимое преобразование в ядре (iokit/Kernel/MiniAppleKeyStore.cpp), и всё записанное лежит в /private/var, то есть в памяти; без var_store=1 оно пропадает при следующем старте (см. «Том данных между загрузками»). Проверка: /redwood/bin/keychain_test (подписывается ldid с группой доступа к связке; без ldid связка такой программе отказывает).
  • Wi-Fi телефона (опытно): wifi=1. Радио телефона подключается к одной сети WPA2 с паролем, и iOS выходит через него в интернет: адрес по DHCP, DNS, HTTPS. Нужны прошивка радио в fw-wifi/ (см. wifi_step=N в таблице параметров ядра), адрес радио одной строкой в fw-wifi/mac и файл fw-wifi/network — имя сети первой строкой, пароль второй (оба файла в git не попадают; userland/build.sh кладёт второй в малый образ, на командную строку ядра он не идёт, в журнал пишутся только длины). Затем tools/mk-ath11k.sh, ./xnu-build.sh, GL=1 REST=1 userland/build.sh, и к обычной командной строке сеанса добавляется одно слово: XL_CMDLINE="touchscreen=1 cpufreq=1 bootcpu=7 cpus=8 gpu=1 wifi=1" ./xl run. Радио поднимается рядом с загрузкой системы и её не задерживает (сеть есть через 12 секунд после старта ядра), интерфейс — тот же en0; настроить его просит configd задание com.hakios.wifi, которое есть в каждом образе и без wifi=1 ничего не делает. Без прошивки ядро говорит об этом одной строкой и идёт дальше; без файла сети радио поднимается, но ни к чему не подключается. В Safari при наборе в строке поиска появляются подсказки поисковика — они приходят уже по этой сети; страницы Safari открывает и рисует (yandex.ru с поиском, погодой и новостями дзена, example.com). Чёрными они были, пока экран считался экраном широкой гаммы: у iPhone X в дереве устройств «artwork-display-gamut» = P3, MobileGestalt по нему отвечает HasExtendedColorDisplay = да, и WebKit рисует плитки страницы в поверхности по десять бит на цвет ('w30r', 'b3a8'). Таких наших поверхностей нет (ядро даёт вместо них BGRA, оконный сервер только её и показывает), и CoreGraphics в них не рисовал — плитки оставались нулями. Теперь загрузчик пишет в дерево sRGB, как и есть у нашего кадрового буфера, и WebKit рисует в BGRA (xnuloader/loader.c, dt_identity(); проверка — RC_JOBS=rc.jobs.safari). Цветные эмодзи и китайский шрифт PingFang (130 и 74 МБ) целиком в образ системы не помещаются, и их знаки выходили квадратиками с вопросом; теперь sysimage/unpack.sh кладёт их в образ уменьшенными (tools/font_trim.py): эмодзи — без самых крупных картинок (остаются 40, 64 и 96 точек; 59 МБ), PingFang — с обычным и полужирным начертаниями упрощённого, традиционного и гонконгского письма (26 МБ). Проверка — RC_JOBS=rc.jobs.fonts (font_test: к какому шрифту система идёт за эмодзи, китайским, японским и корейским и сколько точек он рисует, сколько из них цветных; с wifi=1 — китайская Википедия и страница эмодзи в Safari). Настройки: в образе работает и wifid, служба Wi-Fi самой iOS, а ядро отвечает на её запросы (drivers/wifi/hakios.apple80211.c). Раньше его добавлял только WIFID=1; теперь он в образе по умолчанию, потому что там, где радио нет вовсе, он ничего не стоит: в QEMU wifid поднимается, сам с собой разговаривает о кодах стран и сидит — 114 запущенных процессов против 119 без него, ни одной аварии, ни одного перезапуска, — а на вопросы, которые система всё равно задаёт, отвечает: com.apple.wifi.manager (это имя wifid регистрирует сам, в задании его нет) упал с 30 ненайденных обращений за сеанс до 3, com.apple.private.corewifi-xpc — с одного до нуля, а всего ненайденных обращений в сеансе стало 305 вместо 331. Оставить его за бортом — WIFID=0. (Считать по журналу запуска QEMU надо, убрав повторы: qemu/run-ios.sh выводит журнал launchd дважды.) С радио (wifi=1) при работающем wifid в строке состояния есть значок Wi-Fi, в Настройках Wi-Fi включён, подключённая сеть названа, под ней список сетей, которые слышит радио. Ядро отвечает и на просьбу подключиться, которую wifid шлёт, когда в Настройках выбрали сеть и ввели пароль, и на отключение; неверный пароль ядро возвращает wifid как неверный пароль, и после него сеть из файла подключается снова. Выключатель Wi-Fi в Настройках выключает радио и включает его снова, и сеть из файла подключается опять. У подключённой сети стоит галочка — и когда сеть подключило само ядро (при старте, после выключателя, после неверного пароля). Подключение из Настроек пальцем проверено на телефоне: сеть выбрана, пароль набран на экранной клавиатуре — галочка; неверный пароль — штатное сообщение iOS о неверном пароле. Чтобы радио при старте ждало выбора в Настройках, а не подключало сеть из файла, ядру дают wifi_file=0. С WIFID=0 Wi-Fi в Настройках по-прежнему «Выкл.». Скорость: 20 МБ по HTTPS за 2–3 секунды (55–90 Мбит/с; канал шириной 80 МГц, если точка доступа его даёт, прерывания радио и блочные подтверждения — всё это wifi=1 включает само, а wifi_irq=0, wifi_wide=0, wifi_ba=0 выключают по одному). Чего нет ещё: WPA3, открытых сетей, экономии энергии.
  • Несколько QEMU сразу: QEMU_INSTANCE=1 qemu/run-ios.sh … (у каждого номера свои порты; touch.py, screendump.sh, stop.sh понимают ту же переменную). qemu/stop.sh завершает QEMU досрочно.
  • Прошивка UEFI для aarch64 ищется там, куда её кладёт Arch. В Debian/Ubuntu (пакет qemu-efi-aarch64): QEMU_EFI=/usr/share/AAVMF/AAVMF_CODE.fd QEMU_VARS=/usr/share/AAVMF/AAVMF_VARS.fd qemu/run-ios.sh … (проверялось только на Arch).
  • QEMU без KVM эмулирует ARM программно: всё заметно медленнее, чем на телефоне.

На телефоне

Нужен именно POCO X5 Pro 5G (redwood) с разблокированным загрузчиком, adb и fastboot на компьютере (sudo pacman -S android-tools), и образы из предыдущего раздела (sysimage/unpack.sh, REST=1 userland/build.sh).

Прошивка самого телефона. В репозитории её нет и быть не может: она вендорская и берётся с того самого аппарата, на котором всё будет работать, из его Android (подробности — в разделах по каждому устройству):

adb pull /vendor/firmware/a660_{gmu.bin,sqe.fw,zap.mdt,zap.b00,zap.b01,zap.b02} fw-gpu/      # GPU (gpu=1)
adb pull /vendor/firmware_mnt/image/wpss.{mdt,b02,b03,b04,b05,b06,b07} fw-wifi/              # Wi-Fi (wifi=1)
adb pull /vendor/firmware_mnt/image/adsp.{mdt,b02,…,b36} fw-audio/                           # звук (adsp=1)
adb pull /vendor/firmware/aw8697_haptic.bin fw-haptic/                                       # вибрация

Чего нет — то молча не работает, и это главная ловушка при переносе на другой аппарат: без fw-gpu/ ядро не поднимает Adreno, всё рисует программный рендер, и сказано об этом будет только глубоко в журнале телефона (adreno: THE GPU IS NOT STARTED). Что попало в образ, userland/build.sh печатает строкой build.sh: fw/: N files from ../fw-*: … absent: … — на неё и смотрите. Wi-Fi нужны ещё fw-wifi/mac (адрес радио одной строкой) и fw-wifi/network (имя сети и пароль).

./xl enter            # Android → fastboot → наш загрузчик в ОЗУ (fastboot boot), он поднимает свой USB-канал
./xl ramdisk          # залить образ системы в ОЗУ телефона (2,5 ГБ, ~80 с) — один раз на сеанс загрузчика
XL_SECS=900 XL_CMDLINE="touchscreen=1 cpufreq=1 bootcpu=7 cpus=8" ./xl run     # 15 минут iOS
./xl screenshot shot.png      # что осталось на экране после возврата
./xl reboot                   # обратно в Android

Резидентный образ живёт в ОЗУ телефона между сеансами — и между сессиями. ./xl ramdisk заливает его один раз, и дальше он остаётся там, пока телефон не сброшен, сколько бы ./xl run ни прошло и кто бы их ни запускал. Поэтому перед своим прогоном заливайте свой образ, даже если «ничего не меняли»: в памяти может лежать чужой и совсем другого вида. Признак — iOS не стартует вовсе и в журнале /sbin/launchd.ios: No such file or directory; это означает именно несовпадение резидентного образа с командной строкой (например, 4 МБ флеш-рутового flashboot.apfs там, где root_apfs= не задан и ожидался system.apfs на 2,4 ГБ), а не плохой образ — заливать в ответ на это другой образ наугад значит лечить симптом. Ни записка на $XDG_RUNTIME_DIR/darwin-hakios-phone.note, ни flock на .run этого состояния не описывают: они отвечают «кто держит телефон» и «идёт ли прогон сейчас», а резидентный образ переживает и то и другое.

Образ системы — контейнер APFS, сжатый zlib так же, как Apple сжимает корень iOS (5,4 ГБ файлов в 2,5 ГБ); его и прочие образы ядро читает тем же драйвером APFS, что и том iOS на флеше (см. «Только APFS, как на iPhone»). Для графики телефона — образ с GL=1 и gpu=1 в командной строке, для звука и батареи — adsp=1 (не battery=1: с ним ядро не спрашивает батарею у звукового процессора, и iOS показывает не тот заряд).

./xl run ждёт конца сеанса и кладёт журнал в logs/; запускать можно сколько угодно раз, образ системы в ОЗУ остаётся. ./xl без аргументов печатает все команды. Пока идёт сеанс, из другого терминала работают ./xl screenshot (снимок экрана на ходу), ./xl log (журнал ядра на данный момент), ./xl state и ./xl stop — завершить сеанс сейчас. XL_FOLLOW=5 ./xl run каждые 5 секунд сохраняет журнал в logs/…-run.log.live: если телефон сбросится без единого слова, останется всё, что ядро успело сказать.

Журнал загрузчика хранит первые 256 КБ и последний мегабайт, а середину выбрасывает, сообщая об этом одной строкой («the log between the first 256 KB and the last 1 MB was dropped»). Это важно для всякого сеанса, который что-то печатает сам: с REST=1 iOS насыпает больше мегабайта os_log за две минуты, и если ваш вывод был в начале, он уедет ровно в выброшенную середину — из 225 запущенных программ в журнале нашлись первые 27. XL_FOLLOW от этого не спасает: к моменту, когда он забирает журнал, середина уже выброшена. И фильтр не спасает — он убирает строки, которых уже нет.

Проверять надо две вещи, и обе однозначны: размер файла и маркер по его точному тексту. Предел — 1 466 58x байт (256 КБ + 1 МБ + строка маркера), и журнал такого размера обрезан; маркер the log between the first 256 KB and the last 1 MB was dropped стоит в обрезанном журнале ровно один раз и в целом не стоит вовсе. Четыре прогона одного и того же набора на 225 модулей, все собранные с QUIET=1:

окно размер журнала маркеров модулей в журнале
XL_SECS=300 1 466 579 1 2
XL_SECS=120 1 466 582 1 28
XL_SECS=30 1 185 596 0 225
XL_SECS=30 1 166 253 0 225

Грепать надо точный текст маркера, а не was dropped: в ядре есть своя строка buttons: reading the PMK8350's power-on block: the transfer was dropped, и свободный греп считает её тоже — так получаются «2 маркера» в целом журнале и «4» в обрезанном, из чего можно заключить (и мы с другой сессией заключили), будто число маркеров ничего не значит. Оно значит: ноль или один.

Укладываться в предел помогают два рычага, и ни одного из них по отдельности не достаточно: короткое окно (XL_SECS) и QUIET=1 при сборке, который отправляет в никуда собственный поток launchd — те самые тысячи строк com.apple.xpc.launchd|…, — оставляя демонам их вывод в консоли. С QUIET=1, но окном 120 с вывод всё равно теряется (таблица выше); без QUIET=1 он теряется и на 40 с, потому что поток определяется числом служб (REST=1 — это ~350 служб), а не длительностью. Нужно и то и другое.

Два подвоха дают один и тот же симптом «моих строк нет в журнале», и различать их надо по признаку, а не по догадке:

  • в журнале есть /sbin/launchd.ios: No such file or directory — iOS не стартовала вовсе, резидентный образ не тот (см. выше про ./xl ramdisk). Задание не выполнялось;
  • в журнале есть маркер выброса, а размер равен пределу — iOS стартовала, задание отработало, вывод не доехал. Повторять с меньшим окном и QUIET=1, а не заливать наугад другой образ.

Управление как на iPhone X: свайп вверх от нижнего края — разблокировка и «домой», погасший экран будится касанием. Закончить сеанс раньше времени — приложить к экрану пять пальцев на полторы секунды (или ./xl stop с компьютера): ядро вернётся в загрузчик (кнопки телефона заняты своим делом: питание гасит и будит экран, как на iPhone). На экране при этом остаётся последний кадр.

Прочитайте до запуска:

  • В постоянную память ничего не прошивается, с одним исключением: загрузчик пишет свой текстовый журнал в начало сырого раздела logdump (отладочный раздел Xiaomi; Android им в обычной жизни не пользуется). Если есть root — сохраните его заранее: adb shell su -c 'dd if=/dev/block/by-name/logdump of=/sdcard/logdump.img'.
  • UEFI собран из драйверов прошивки OS1.0.3.0.UMSMIXM. С другой версией xbl он может не завестись — тогда телефон просто не выйдет на связь; выход — зажать питание на 10–15 секунд.
  • Ядро напрямую трогает железо (регуляторы через RPMh, GPIO, частоту процессора). Проверено ровно на одном экземпляре. На свой риск.
  • Под нашим ядром телефон не заряжается и греется (одно ядро на 2,4 ГГц постоянно рисует). Между длинными сеансами возвращайте его в Android (./xl reboot).
  • Сенсоры в redwood бывают двух видов: Goodix GT9916 и FocalTech FT3680. Драйвер есть только для Goodix.
  • Если ядро зависло, сторожевой таймер гипервизора сам перезагрузит телефон в Android (до минуты). Если нет — питание на 10–15 секунд.

Параметры ядра (XL_CMDLINE, в QEMU — переменная CMDLINE):

Параметр Что делает
touchscreen=1 включить сенсорный экран телефона (touch_flip=1|2|3 — отразить оси). На этом телефоне в нём Goodix GT9916. Для меньшей задержки (пальцем ещё не проверено, поэтому не по умолчанию): touch_poll_us=1000 — смотреть на линию прерывания каждую миллисекунду вместо 8; touch_spi_hz=5000000 — шина на 5 МГц, как в дереве устройств Android (отчёт читается за 0,14 мс; при 1 МГц — дольше); touch_rate=480 — команда самой микросхемы (игровой режим Xiaomi, 0xC0) отчитываться 480 раз в секунду вместо 240. Микросхема её подтверждает; итог видно в строке goodix: so far … раз в минуту
touch_lat=1 замер задержки касания и ровности кадров (RC_JOBS=rc.jobs.fps: листание домашнего экрана, Поиск, Пункт управления, открытие Настроек, бросок списка, закрытие — трижды; tools/frames.py ЖУРНАЛ ЖУРНАЛ.live* — кадров в секунду и доля кадров дольше 16,7 и 33 мс по каждому жесту, куда ушло время медленных; XL_FOLLOW=8 XL_FOLLOW_KEEP=1 сохраняет все промежуточные журналы, в которых есть то, что конечный теряет). Вместе с glslow=МКС — строка о каждом вызове OpenGL ES дольше МКС (о текстурах — с размером), с gpu_log=1 — о каждой посылке работы GPU (tl: Q: сколько буферов, сколько из них от 4 МБ) и о том, сколько GPU на неё потратил (tl: q); SpringBoard пишет tl: a о каждом обороте своего главного цикла. Замер задержки касания: строки tl: на каждом этапе (ядро выложило отсчёт, оконный сервер прочёл и отдал его системе событий, кадр нарисован, отдан драйверу экрана, GPU закончил, кадр в памяти панели), всё во времени mach_absolute_time. RC_JOBS=rc.jobs.latency (с rec_secs=200 rec_ring=1 rec_step=4 rec_mb=256) через 100 с после запуска водит «пальцем» вверх-вниз по списку Настроек (rwtouch zigzag), tools/latency.py ЖУРНАЛ logs/recording.raw по развороту пальца в кадрах записи считает задержку от отсчёта касания до кадра в памяти панели и раскладывает её по этапам. Первые 1–2 минуты после запуска все четыре ядра заняты виджетами, searchd и прочими: замер в это время мерит их, а не путь касания (ustacks=top ustacks_at=S ustacks_ms=MS показывает, кто занимал процессор)
buttons=0 не читать кнопки телефона. Вместе с touchscreen=1 они включены: питание — боковая кнопка, громкость вверх и вниз (опрос микросхемы питания раз в 16 мс, только чтение). Не держать питание дольше 5 с: 10–15 с сбрасывают телефон
autounlock=0 экран блокировки остаётся, пока его не смахнут вверх, как на iPhone. По умолчанию SpringBoard сам убирает его через 10 с (на случай, если сенсор ещё не ожил)
bootcpu=7 запустить ядро на самом большом ядре процессора (загрузчик остаётся на нулевом и наблюдает)
cpus=N использовать N ядер процессора: то, на котором ядро запущено, и ещё N−1 ближайших к нему по номеру (после 7 — 6, 5, 4, потом малые 3, 2, 1); их запускает само ядро через PSCI. cpumask=M — выбрать по позиции в этом порядке (бит 0 — загрузочное). Без параметра — одно ядро. На телефоне ядру достаётся не больше семи: нулевое занято загрузчиком, который наблюдает за сеансом. Малые ядра (Cortex-A55, в 2,5 раза медленнее больших) загрузчик помечает в дереве устройств (small-core), и планировщик берёт их, только когда все большие заняты: каждое малое — пара своему большому, как второй поток ядра с гиперпотоками у XNU, но без его ограничений, а поток с малого ядра переводится на большое, как только то освободилось. small_cores=0 — все ядра одинаковы, как было раньше (тогда cpus=8 заметно медленнее cpus=4); small_cores=1 — пары ровно как у гиперпотоков XNU. Малые ядра запускаются через 28 с после остальных, когда экран блокировки уже готов (small_cores_after=S, 0 — сразу). Нужен загрузчик от 15 октября (./xl reload): со старым малые ядра не помечены. Лучший вариант — cpus=8
cpuload=MS каждые MS миллисекунд строка tl: u о том, насколько было занято каждое ядро процессора (в процентах; tools/frames.py показывает это по жестам)
cpufreq=1 поднять частоту до максимума (иначе 300 МГц) всем группам ядер, в которых есть наши
rec_secs=N записывать экран N секунд (rec_start=S, rec_mb=M, не больше 640); потом ./xl recording out.mp4. rec_ring=1 — по кругу: когда память кончилась, запись идёт поверх самых старых кадров, и к концу сеанса в ней последнее (для того, что случается раз за долгий сеанс: сеанс кончают сразу после — пятью пальцами); rec_step=N — каждый N-й пиксель вместо третьего (4 — в 1,8 раза больше кадров). Память на запись отнимается у системы на весь сеанс: с rec_mb=640 в долгом сеансе iOS убивала процессы ради памяти, вплоть до SpringBoard (респринги), с 256 — нет. XL_REC_KEEP=1 ./xl recording оставляет саму запись в logs/recording.raw; tools/recording.py logs/recording.raw out.mp4 --frames DIR раскладывает её на кадры с именами по времени ядра в миллисекундах — тому же, что в строках glframes=1
rec_sound=N сохранить первые N секунд звука, который играет iOS (тишина между звуками места не занимает); потом ./xl sound out.wav. С rec_sound_fed=1 — то, что пропустил к усилителям сторож динамиков, с rec_sound_mic=1 — отсчёты микрофона (mic=3)
speaker_probe=N только телефон, ещё ни разу не запускалось: посмотреть на звуковое железо, ничего не включая. 1 — только чтение (состояние выводов, имена питаний у RPMh, один регистр тактового генератора); 2 — ещё и спросить у усилителей динамиков, кто они, по I2C, для чего на время перенастраиваются два вывода (GPIO 12 и 13). Ответы — строки speaker: в журнале
-v текст загрузки на экране (по умолчанию у xl run, если XL_CMDLINE не задана)
applog=1 / applog=2 stderr приложений в журнал / плюс os_log всех процессов
applog_name=ИМЯ[,ИМЯ] вместе с applog=1: os_log только тех программ, чьи имена файлов начинаются с одного из ИМЁН (так читают расширения приложений — виджеты, обои: их запускает launchd по просьбе других процессов, описания задания у нас для них нет)
launchd_disable_all_timeouts=1 параметр самого launchd: не убивать расширение приложения, которое не отметилось за 5 секунд после запуска («Extension is hanging on launch. Killing.»). Нужен только в QEMU, где при загрузке полсотни расширений стартуют одновременно на эмулируемом процессоре; на телефоне таких убийств нет
ustacks=ИМЯ ustacks_at=С через С секунд после запуска напечатать, где стоит каждый поток процесса ИМЯ (ustacks_ms=МС — повторять каждые МС, ustacks_count=N — N раз, ustacks_threads=N — только первые N потоков, ustacks_tname=ТЕКСТ — только потоки, в имени которых есть ТЕКСТ: тогда строки идут и в журнал загрузчика, с именем потока и временем; ustacks_tname=* — все потоки, и тоже в журнал загрузчика); имена функций — qemu/symstack.py ЖУРНАЛ ИМЯ. ustacks=top — кто сколько занимал процессор
surftrace=N surftrace_of=ИМЯ первые N поверхностей IOSurface (по умолчанию 24 при запуске) в журнал; с surftrace_of — только тех программ, чьи имена начинаются с ИМЕНИ (для WebKit — com.apple.WebKit), и строки идут в журнал загрузчика: что программа просила (ключи), какой порт сделала, кто нашёл поверхность по порту (оконный сервер — для слоя) и что в ней было в ту минуту и когда её не стало (сколько пикселей не нули)
systrace=1 печатать неудачные системные вызовы и аварии процессов (systrace_name=ИМЯ — только процессов с этим именем)
fbscale=2 рисовать в 2 пикселя на точку вместо 3 и растягивать на панель: в 2,25 раза меньше работы программному рендеру (на телефоне пик 28 кадров/с вместо 19), картинка мягче, сверху появляется красная служебная полоса SpringBoard
fb_filter=0 масштабировать картинку на панель «ближайшим соседом», как раньше. По умолчанию каждый пиксель панели смешивается из четырёх соседних пикселей картинки (билинейно): 1125×2436 на 1080×2400 «ближайший сосед» выбрасывает каждый 25-й столбец и каждую 67-ю строку, и буквы и наклонные линии там, где они попадают на выброшенные, выходят чуть кривыми. Цена — около 9 мс на кадр в потоке драйвера экрана вместо 4 (отдельный поток, частота кадров та же), если считает одно ядро
fb_threads=N столько ядер вместе масштабируют кадр на панель (по умолчанию 3: кадр в памяти панели через 3 мс вместо 9–10; fb_threads=1 — как было)
fb_queue=1 драйверу экрана может ждать один кадр, как раньше. По умолчанию два: CoreAnimation не задерживается в SwapEnd до следующего тика своих часов (задержанный тик сдвигает её часы, и кадр выходит через 19–20 мс вместо 16,7); rc.jobs.fps: 28 % кадров дольше 16,7 мс с двумя, 33 % с одним
ddr=0 не голосовать за пропускную способность памяти. По умолчанию (вместе с cpufreq=1) ядро через RPMh голосует, как драйвер межсоединений Linux под нагрузкой GPU: контроллер памяти (MC0) и общий узел с системным кэшем (SH0) — на максимум, память включена для процессора (ACV). Все ядра вместе получают от памяти 15 ГБ/с записи вместо 12,4 (membench=2), кадров дольше 16,7 мс 28 % вместо 34–36 %: Пункт управления 59 кадров/с вместо 46, открытие Настроек 37 вместо 29, Поиск 45 вместо 36. При возврате в загрузчик (любом, и после паники) голос опускается до ddr_rest=N (по умолчанию 1024): с любым голосом от 4 до 4096 ядра получают те же 12–12,4 ГБ/с, что до первого голоса, а без голоса вовсе — 0,2 ГБ/с (память на нижнем пределе, USB загрузчика 8 МБ/с вместо 30), так что у прошивки был свой голос от нашего имени. ddr_vote=N — голос N вместо 16383. ddr=1 — только прочесть и напечатать BCM из базы команд и то, что лежит в слотах команд RPMh
membench=1 / membench=2 скорость памяти для одного ядра / для всех наших ядер сразу (сумма — то, что даёт память на своей частоте)
caenv=ИМЯ=ЗНАЧЕНИЕ (сколько угодно раз) в окружение оконного сервера: переключатели самой CoreAnimation (CA_…)
fb_beat=0 кадр уходит на панель сразу, как GPU его закончил, а не по собственному такту драйвера 60 Гц (такт держит ровный шаг, но кадр ждёт его в среднем 5–7 мс; панель всё равно обновляется 120 раз в секунду и к такту не привязана)
ca_vbl=1 (опыт) CoreAnimation ведёт кадры по уведомлениям драйвера экрана о такте (CA_FORCE_TIMER=1 в backboardd), а не по своим часам; на ровность кадров не повлияло
machine_name=0 сообщать hw.machine = arm64 вместо iPhone10,3 (как было раньше; тогда iOS не находит ни одного ресурса с суффиксом ~iphone, и, например, Пункт управления теряет раскладку)
privs=0 привилегии ядра (priv_check_cred) процессам не от root — только PRIV_WORK_INTERVAL, как раньше. По умолчанию заглушка песочницы (bsd/kern/kern_minimac.c) даёт привилегию процессу, у которого в подписи есть право, что к ней относится, — так делает и песочница iOS; имена прав — те, что носят программы самой iOS: dasd — «vm-pressure-level», symptomsd и aggregated — com.apple.private.network.statistics, mediaserverd и mDNSResponder — com.apple.private.network.socket-delegate (без него NECP писал «does not hold the necessary entitlement to delegate network traffic»), и так далее, сорок привилегий. Права читаются у текущего процесса, поэтому только когда спрашивают о его собственных полномочиях: проверки, которые NECP делает за trustd и сетевой процесс WebKit в потоке ядра, по-прежнему получают отказ. Отказ, как и раньше, пишется в журнал один раз на привилегию
xpcproxy_idversion=1 процесс, которым становится xpcproxy (так launchd запускает службы XPC), при exec получает новую версию идентичности, как в XNU. По умолчанию он сохраняет версию xpcproxy, как если бы launchd запустил службу сам — это и значит «elide xpcproxy», о чём launchd пишет, но здесь всё равно запускает xpcproxy (bsd/kern/kern_exec.c). RunningBoard держит маркер аудита такого процесса из времени до exec и нового не берёт: каждая его проверка прав для такого процесса кончалась ошибкой (SecTaskLoadEntitlements error 3 — тысяча раз в минуту при открытом Safari), а веб-процессы Safari не получали утверждений («target is not running or doesn't have entitlement com.apple.runningboard.assertions.webkit»). С версией xpcproxy — ни одной такой ошибки
wfi=0 wfe_mode=0 простаивающее ядро процессора не засыпает, а крутится в цикле, как всегда было с одним ядром (с cpus=N простой — это WFI; на случай, если под гипервизором телефона он поведёт себя плохо)
return_cpu=N, return_panic=1 для проверки возврата в загрузчик с несколькими ядрами: вернуться по таймеру с процессора N (по номеру в ядре: 0 — загрузочный) и/или через панику
gpu=1 рисовать графическим процессором телефона (образ с GL=1, прошивки в fw-gpu/; см. раздел «GPU»). Без него, без прошивок и в QEMU всё рисует программный рендер CoreAnimation
virtio_net=1 (QEMU; QEMU_NET=1 добавляет его сам) сетевой интерфейс en0 на сетевой карте virtio. net_names=1 — писать в журнал каждое имя, которое система спрашивает у DNS, и каждое начатое TCP-соединение; net_trace=N — строка о каждом из первых N кадров
adreno=N опыт с GPU телефона: первые N шагов его пробуждения (питание, такты, первые регистры), каждый шаг пишет в журнал, что собирается тронуть и что прочёл; шаги и остальные параметры описаны в начале drivers/gpu/hakios.adreno.c
wifi=1 Wi-Fi телефона, всё сразу (см. «Wi-Fi телефона» выше): процессор радио, драйвер, подключение к сети из fw-wifi/network, и так до конца сеанса; рядом с загрузкой системы, в журнале — только итог (wifi_loud=1 — со всеми подробностями шагов)
wifi_file=0 с wifi=1: сеть из fw-wifi/network при старте не подключается, радио ждёт, пока сеть выберут в Настройках (wifid в образе есть по умолчанию)
wifi_irq=0, wifi_ba=0, wifi_wide=0 с wifi=1: без прерываний радио (его кольца опрашиваются раз в миллисекунду, как раньше), без блочных подтверждений (точка доступа шлёт кадры по одному — скорость впятеро ниже), без широкого канала (только 20 МГц)
wifi_step=N то же по шагам, как это выяснялось: первые N шагов, и ядро ждёт, пока они пройдут. 1–5 только читают (общая память процессоров, почтовый ящик процессора питания, RPMh, шины питания, выводы), 6–11 кладут прошивку процессора Wi-Fi в его память, заводят в общей памяти нашу половину его сигнальных битов (SMP2P) и запускают его через TrustZone, 12–14 поднимают связь с ним (GLINK, канал IPCRTR) и QRTR поверх неё: процессор Wi-Fi объявляет свои службы, среди них службу прошивки Wi-Fi; 15 — разговор с этой службой, какой драйвер ведёт до того, как тронет радио (нужны описания сообщений из ath11k: tools/mk-wlfw.sh, затем пересобрать ядро): прошивка принимает сообщения ath11k, файл платы и базу регионов телефона, с wifi_cal=1 калибруется и через 7 секунд говорит «готова»; с wifi_mission=1 прошивка получает настройку и переходит в рабочий режим; 16 — чтение нескольких регистров радио (wifi_radio_read=1): в рабочем режиме они отвечают, вне его чтение кончается ошибкой шины, и шаг его не делает (adb pull файлов wpss.mdt, wpss.b02…wpss.b07 из /vendor/firmware_mnt/image в каталог fw-wifi/; в git они не попадают, userland/build.sh кладёт их в малый образ). Каждый шаг пишет в журнал, что собирается тронуть и что прочёл. wifi_keep=1 оставляет процессор Wi-Fi и связь с ним работать, пока идёт сеанс. С wifi_lx=1 шаг 15 ведёт сам драйвер ath11k из Linux (см. «Драйвер Wi-Fi» ниже; ядро собирается после tools/mk-ath11k.sh): его qmi.c проходит весь разговор с прошивкой до рабочего режима, со своей настройкой и списком теневых регистров, а регистры радио не трогает. С wifi_lx=2 wifi_smmu=2 потоку радио в SMMU даётся контекст с нашими таблицами страниц (wifi_smmu=2 в одиночку — только это, с возвратом как было), ce.c и hal.c драйвера заводят в регистрах радио кольца его «копировальных машин», htc.c подключает по ним службу WMI, и прошивка по WMI рассказывает о радио (одно, две антенные цепи, 2,4 и 5 ГГц) и через четверть секунды после запуска шины сообщает, что готова к командам («unified ready»); собственного адреса у прошивки нет (нули). Прерываний при этом нет: кольца опрашиваются раз в миллисекунду. С wifi_scan=1 драйвер после этого регистрирует радио, заводит интерфейс в режиме станции и просит прошивку просканировать эфир — только слушая, в эфир ничего не уходит: 26 каналов 2,4 и 5 ГГц за четыре секунды, и в журнале появляются точки доступа вокруг с каналами и уровнем сигнала (имена и адреса чужих сетей — только в журнале запуска на вашей машине). Адрес радио у прошивки свой не записан: он лежит одной строкой в fw-wifi/mac (в git не попадает), и xl run передаёт его ядру параметром wifi_mac. С wifi_join=1 драйвер подключается к сети из fw-wifi/network (WPA2 с паролем): находит её точку доступа, проходит аутентификацию, ассоциацию и четырёхстороннее рукопожатие, отдаёт ключи прошивке и становится тем, что стоит за en0; с wifi_keep=1 он остаётся работать, пока идёт сеанс, без него — сворачивается, когда шаги кончились (wifi_hold=S — на S секунд позже). Остановленный без спроса (ядро, которое до этого не дошло) второй раз без перезагрузки телефона не запускается — такой запуск сбрасывал телефон, и драйвер от него сам отказывается; шаги и остальные параметры описаны в начале drivers/wifi/hakios.wifi.c
adsp=1 аудиопроцессор телефона (ADSP: звук, а у Xiaomi ещё и зарядка), шаг 1 звука: прошивка adsp.mdt и adsp.b02…adsp.b36 с разделов телефона (/vendor/firmware_mnt/image, в каталог fw-audio/, в git не попадает; userland/build.sh кладёт её в образ системы в /fw, откуда ядро читает её, когда корень смонтирован, — после её появления образ системы пересобирается с SYSTEM=1; для adsp_step=N, который идёт до монтирования, — в малый образ с ADSP=1, и только без GL=1: вместе они не помещаются в 64 МБ, что берёт загрузчик) кладётся в его память, RPMh просят о его шинах питания LCX и LMX, пока он не попросит о них сам, TrustZone его запускает, через 41 мс он говорит «готов», поднимается GLINK (канал IPCRTR) и QRTR поверх него: процессор — узел 5 — объявляет 12 служб и открывает свои каналы, среди них apr_audio_svc; на нём — APR, протокол звуковых служб: служба ядра процессора отвечает, что звуковые службы готовы, и перечисляет их (9, среди них AFE, ASM, ADM — то, через что пойдёт звук). Рядом с загрузкой системы и рядом с wifi=1; в конце сеанса его просят остановиться и выключают. adsp_step=N — то же по шагам (первые N из 11, ядро ждёт), подробности в начале drivers/audio/hakios.adsp.c
sound=N с adsp=1, шаг 3 звука, путь к динамикам, как его строит Android (числа — из живого дерева устройств телефона, его путей микшера и его ядра): sound=1 — ножки TDM (GPIO 97–100) отданы интерфейсу, звуковой процессор включает его тактовую частоту (3,072 МГц), группу и порт 0x9000 (PRI_TDM_RX_0) и запускает его; по ножкам видно, что тактовый сигнал и синхронизация кадров идут. sound=2 — ещё и поток: наша память видна процессору через SMMU (поток 0x1801), путь ADM к порту, поток ASM, и в него 3 с пишется тон 1 кГц −20 дБ полной шкалы; процессор забирает его с частотой 48 кГц. Усилители выключены. sound=3 — ещё и усилители: настраиваются из профиля «Music» конфигурации Android (/vendor/etc/aw882xx_acf.bin телефона в fw-audio/, в git не попадает, userland/build.sh кладёт её в /fw образа системы), каждая запись читается обратно, при любом расхождении или сбое оба сразу выключаются; громкость плавно поднимается до sound_below восьмых долей дБ ниже уровня Android (по умолчанию 240, то есть на 30 дБ тише; выше уровня Android — никогда), тон звучит 3 с, потом всё затихает, выключается и возвращается как было. sound=4 — то, что играет iOS (/dev/hakios-audio), до конца сеанса: порт и поток работают всё время (без звука в них идёт тишина), усилители настраиваются один раз и выключены, а когда в записи iOS появляется не тишина, включаются и быстро (как у драйвера Android) выводятся на sound_below; после трёх секунд тишины снова выключаются. Звук доходит до усилителей через 160 мс после записи, усилители готовы через 65 мс. У динамика в модуле HAL есть регулятор громкости, его значение уходит в ядро (ioctl HAKIOS_AUDIO_SET_VOLUME) и задаёт уровень усилителей ниже потолка (0 — усилители не включаются), но iOS ставит его только раз, на максимум, а громкость кнопок и Пункта управления применяет к самим отсчётам (программная громкость для динамика iPhone X в таблицах VirtualAudio); при громкости 0 iOS пишет тишину, и усилители тоже не включаются. Проверка — RC_JOBS=rc.jobs.volume. По пути к усилителям — сторож (см. выше): sound_gain=N (восьмые доли дБ, 0–96), sound_guard_as=N (для проверки: сторож считает усилители на N ниже уровня Android и сдерживает, как сдерживал бы там). sound_secs=S (не больше 3 с тоном), sound_pause=MS. Подробности в начале drivers/audio/hakios.sound.c и drivers/audio/hakios.speaker.c
haptic=N моторчик вибрации: Awinic AW86927 на I2C (шина QUP SE9, GPIO 36/37, адрес 0x5a; сброс — GPIO 35), линейный мотор с резонансом 175,5 Гц; всё — из живого дерева устройств Android, его журнала и регистров (drivers/haptic/hakios.haptic.c). haptic=1 — только читать: из загрузчика Android сброс держит чип выключенным, он не отвечает. haptic=2 — сброс отпущен, как это делает драйвер Android, регистры прочитаны и сравнены с Android. haptic=3 — ещё и настройка Android (каждая запись читается обратно; аналоговые регистры — за разблокировкой 0x5b) и волновые формы Android (/vendor/firmware/aw8697_haptic.bin телефона в fw-haptic/, в git не попадает; userland/build.sh кладёт его в fw/ малого образа) в ОЗУ чипа, с проверкой чтением. haptic=4 — ещё и одна короткая форма (haptic_effect=1…10) один раз, с усилением Android. haptic=5 — моторчик для iOS до конца сеанса: всё, что iOS играет на своём устройстве «Actuator» (системные отклики и вибрация звонка, CoreHaptics приложений, отклики UIKit — например, меню иконки по долгому нажатию), звуковой модуль HAL отдаёт в /dev/hakios-haptic, и ядро играет по нему формы Android: каждую волну iOS — коротким толчком (форма 1), а длинную — повтором формы 11, пока iOS играет, но не дольше 1 с, после чего мотор столько же отдыхает; усиление — по громкости волны iOS: от трети полной шкалы и выше — усиление Android (0x80), ниже — до пяти восьмых его у самых тихих, и никогда не выше Android (волны iOS для Taptic Engine почти всегда гораздо тише полной шкалы, и в прямой пропорции большинство откликов выходило на десятой доле усиления Android — их было не почувствовать). Проверка обычных касаний: RC_JOBS=rc.jobs.haptic2 (долгое нажатие на иконку, переключатель в Настройках). Без haptic=5 iOS играет на «Actuator» как обычно, но в мотор ничего не идёт. Проверка: RC_JOBS=rc.jobs.haptic. Любая неисправность, о которой говорит чип, несовпадение при чтении или неотвеченный байт — чип снова в сброс до конца сеанса; не дольше 1 с на эффект; в конце сеанса и при панике — сброс
torch=N фонарик: светодиодный модуль вспышки PM8350C (SPMI, ведомый 2, регистры 0xee00; задний светодиод — канал 0), значения Android: его фонарик горит на 125 мА через канал 0 (прочитано у включённого фонарика Android), шаг тока 12,5 мА, порядок записей — драйвера Xiaomi leds-qti-flash.c (drivers/camera/hakios.torch.c). torch=1 — только читать: канал арбитра, тип модуля, регистры, температурная тревога PMIC. torch=2 — ещё и зажечь один раз на 125 мА на torch_secs (по умолчанию 2 с). torch=3 — фонарик для iOS: /dev/hakios-torch принимает уровень 0…1000 (тысячные тока Android, не выше 125 мА); загрузчик с torch=N добавляет в дерево /product/camera со свойством flash айфона (кнопка фонарика в Пункте управления), а libhakios.media.dylib отдаёт FigFlashlight в mediaserverd своё «устройство» вместо отсутствующей камеры (userland/src/inject_media.c). Пределы: записи — только в шесть регистров этого модуля, каждая читается обратно, канал арбитра перепроверяется; не дольше torch_max_s подряд (по умолчанию 30 с, не больше 120), потом темно, пока iOS не попросит снова; 10 раз в секунду — флаги неисправности и перегрева модуля, температурная тревога PMIC и температура батареи (не выше 45 °C; из battmgr при adsp=1 или из battery=1) — что-то не так, и фонарик гаснет до конца сеанса; в конце сеанса и при панике — погашен. Проверка: RC_JOBS=rc.jobs.torch (torch_test — как программа, через AVFlashlight; потом кнопка в Пункте управления)
cam=N камеры: все четыре — макро, широкая, фронтальная и сверхширокая (`cam_sensor=macro
mic=N с adsp=1, основной микрофон (drivers/audio/hakios.mic.c, значения Android). mic=1 — только посмотреть: звуковой процессор голосует за блоки LPASS и даёт тактовые частоты макроса TX (19,2 МГц), регистры макросов TX и VA, ведущего SoundWire, ножек LPASS и сброса кодека читаются; без всех четырёх ни один регистр не трогается. mic=2 — ещё и кодек поднят настолько, чтобы ответить по SoundWire: сброс (GPIO83) отпущен, как это делает Android, ножки ведущего (LPASS GPIO 0, 1, 2, 14) — в активное состояние Android, тактовые частоты макроса TX, ведущий запущен, устройство TX кодека перечислено и его номер прочитан. mic=3 — ещё и запись с микрофона mic_secs секунд (по умолчанию 10, не больше 600): настройка кодека, как в wcd938x_init_reg(), смещение микрофона 2750 мВ, ADC1 в режиме «low power», «ADC1 Volume» 6, дециматор 1 и порт codec DMA, шина SoundWire как на пути Android «handset-mic» (выше). Каждая запись читается обратно, любое расхождение или отказ звукового процессора — всё останавливается. Для проверки: mic_input=0 (дециматор слушает ноль — точная тишина), mic_adc=0, mic_test=1 (без смещения), mic_test=2 (вход ADC1 удержан), mic_main=1, mic_tap=1 с haptic=5. mic=4 — микрофон для iOS: путь до кодека поднимается, только пока iOS записывает (пока открыт /dev/hakios-mic, кольцо на 341 мс, чтение никогда не ждёт и держится в 40 мс за звуковым процессором), а в плагине звука HakiosAudio.driver есть устройство «Codec» с одним входом и источником данных imic («Bottom»), из которого VirtualAudio, как на iPhone, делает «iPhone Microphone» (Built-In Microphone). Приложения записывают с него: «Диктофон» пишет и рисует волну (RC_JOBS=rc.jobs.voicememos), record_test (AVAudioRecorder) — 6,00 с из 6,00, на частоте мотора 175,5 Гц с haptic=5 уровень растёт с −68 до −29 дБFS (RC_JOBS=rc.jobs.mic, с sound=4 sound_below=720 — сначала беззвучная проверка динамиков). Программа без интерфейса, записывающая в фоне, получает оранжевую точку, но не плашку записи в строке состояния, и MediaSafetyNet в mediaserverd, как на iPhone, через девять секунд такой записи останавливает mediaserverd; записи record_test поэтому короче и с паузой между ними. В конце и при выключении звукового процессора всё возвращается как было: кодек снова в сбросе, ножки и регистры как были
battmgr=0 с adsp=1 батарея не спрашивается. Без него (по умолчанию) ядро раз в 10 с спрашивает у звукового процессора, который у этого телефона и ведёт батарею с зарядкой (как Android — qti_battery_charger, Linux — qcom_battmgr поверх pmic_glink), по каналу PMIC_RTR_ADSP_APPS процент заряда, зарядку, напряжение, ток, температуру, заряд в мА·ч, ёмкость, циклы и что даёт порт USB — только читает, ни пределов, ни токов не задаёт — и показывает это iOS так же, как батарея iPhone: службой HakiosBattery класса IOPMPowerSource (drivers/power/hakios.battery.cpp). Её читает powerd, и дальше всё делает сама iOS: батарея с процентом и молнией в строке состояния, «58% Charged» на экране блокировки, виджет «Батареи», Настройки → Аккумулятор (переключатель «Заряд в процентах» работает; в образе он включён); перехваты батареи в SpringBoard при этой службе уступают iOS. Сверено с Android (dumpsys battery и /sys/class/power_supply/battery сразу после): 56 % и 58 %, «заряжается», напряжение, температура, заряд, ёмкость 3677 из 5000 мА·ч, 890 циклов совпадают. Номера свойств — этой прошивки: до 9-го как у Linux, дальше на один больше; battmgr=2 пишет в журнал все. Те же числа — в kern.hakios.battery_*. С battery=1 (путь загрузчика через UEFI) не включается. Историю заряда и расход по приложениям iOS 16 берёт из журнала энергии, который ведёт aggregated (com.apple.powerlog.plxpclogger.xpc и другие службы; база CurrentPowerlog.PLSQL); раньше он был в списке не запускаемых (userland/mkjobs.py), и страница «Аккумулятор» вечно крутила колёсико. Теперь она открывается сразу: в первые минуты — «As you use your iPhone, battery usage will be reported here» (так iOS говорит, пока данных о расходе нет), через час — график уровня заряда за 24 часа и список приложений («Connected to Charger», пока телефон на зарядке); экран всё это время iOS считала выключенным («Screen On 0m»), отчего — ещё не разобрано. Расход в процентах по приложениям iOS считает из энергии частей системы на кристалле, которую на iPhone сообщают счётчики IOReport («Energy Model»); их здесь нет. Не показывается износ в «Состоянии аккумулятора» (powerd ждёт серийный номер батареи, а его прошивка не даёт; вместо цифры колёсико). Проверка — RC_JOBS=rc.jobs.battery (battery_test), RC_JOBS=rc.jobs.battery2 (страницы Настроек, в конце battery_test powerlog: что журнал энергии собрал — таблицы, строки, время), RC_JOBS=rc.jobs.battery3 (то же после часа работы приложений)

Несколько ядер на телефоне проверены (2, 4 и 7 ядер запускаются и чисто возвращаются в загрузчик). На одном и том же сценарии cpus=4 (четыре больших ядра: 7, 6, 5, 4) дал 929 кадров вместо 671 на одном ядре, пики 35 кадров/с вместо 19, SpringBoard готов на 7 секунд раньше. Все семь (cpus=8) раньше были хуже четырёх: планировщик не знал, что ядра 1–3 малые, и сажал на них интерфейс (84 % кадров дольше 16,7 мс против 46 %, копирование кадра на панель 15 мс вместо 4). Теперь малые ядра помечены (см. cpus=N в таблице), и cpus=8 не медленнее cpus=4 ни в чём: по кадрам вровень (rc.jobs.fps, трижды, с голосом за память и двумя кадрами в очереди: 28–29 % кадров дольше 16,7 мс и 6 % дольше 33 мс у обоих), экран блокировки готов через 25,2–25,3 с после старта ядра (у cpus=4 — 25,3–25,5), а вся работа запуска кончается раньше (36 с против 38). Для этого малые ядра запускаются через 28 с после остальных (small_cores_after=S, 0 — сразу): когда они работали с самого начала, экран блокировки был готов на 1,5 с позже (они делят с большими память и кэш, пока все семь заняты запуском). Паника «Ticket spinlock … timeout» значила бы, что ядрам тесно во времени (в QEMU загрузчик сам увеличивает эти сроки в 20 раз): помогает ml-timeout-global-scale=4.

GPU

iOS на телефоне может рисовать графический процессор — Adreno 642L, через открытый драйвер Mesa freedreno и наш драйвер в ядре (: треугольник, экран блокировки, домашний экран).

Как это устроено:

  • ядро (drivers/gpu/hakios.adreno.c, drivers/gpu/hakios.gpu.cpp) поднимает Adreno — прошивка управляющего процессора (GMU), питание 3D-ядра, zap-шейдер через TrustZone, микрокод — и отдаёт его userland'у через клиент IOKit с подмножеством интерфейса msm DRM (буферы, отправка команд, ожидание);
  • tools/mk-mesa.sh собирает Mesa 25.0.7 под userland iOS (freedreno и программный softpipe, минута сборки);
  • userland/src/eagl.c встаёт за системный EAGLContext: в QuartzCore 16.5 есть рендер через OpenGL ES, на который сервер окон переходит сам, если нет Metal.

Запуск (прошивки GPU один раз снять со своего телефона: adb pull /vendor/firmware/a660_{gmu.bin,sqe.fw,zap.mdt,zap.b00,zap.b01,zap.b02} в каталог fw-gpu/; в git они не попадают):

tools/mk-mesa.sh
GL=1 REST=1 userland/build.sh
XL_CMDLINE="touchscreen=1 cpufreq=1 bootcpu=7 cpus=8 gpu=1" ./xl run

gpu=1 — всё, что нужно сказать: ядро само запускает GPU и само выгружает и загружает zap-шейдер в TrustZone (adreno_zap=0 больше не нужен; прежняя длинная строка adreno=21 … adreno_keep=1 работает как раньше). Без gpu=1, без прошивок и в QEMU библиотека OpenGL ES не загружается вовсе, рисует программный рендер CoreAnimation; если GPU не запустился, в журнале одна строка adreno: THE GPU IS NOT STARTED: … с причиной.

Состояние — опытный режим, не по умолчанию. GPU рисует прямо в память поверхностей (IOSurface экрана и полноэкранных слоёв — общая с GPU, без копий); готовый кадр ждёт GPU и уходит на панель в потоке драйвера экрана, по его собственному такту 60 Гц. Прокрутка Настроек на телефоне: 56–58 кадров/с, 95–97 % кадров с шагом 16,7 мс (программный рендер на четырёх ядрах — около 28–35). Короткий сценарий (разблокировка, прокрутка, Часы) — 1383 кадра против 929. Известные дефекты: нет размытой подложки под листом FaceTime; кадр может рваться (копирование в панель не привязано к развёртке; панель обновляется 120 раз в секунду). Чтение кадра из шейдера (EXT_shader_framebuffer_fetch, им CoreAnimation рисует тени подписей) в freedreno исправлено заплаткой в tools/mesa/freedreno-darwin.patch; glfetch=0 прячет расширение для сравнения. У рендера OpenGL ES в QuartzCore 16.5 нет шейдеров для половины из девяноста «функций» CoreAnimation (у Metal есть все); то, чему они нужны, он рисует вовсе без программы. Три такие функции userland/src/eagl.c рисует своими программами по арифметике программного рендера: полоску «домой», цветовую матрицу над уже нарисованным (vibrantColorMatrix — ею сделаны часы экрана блокировки и затемнение обоев под ними) и цветовую матрицу над предумноженными пикселями (ею же — кнопки связи в Пункте управления, которые без неё были белыми кругами). Карточки в переключателе приложений затемняются не затемнением, а тем же матовым материалом: для каждой собирается своя подложка (уменьшение вчетверо, копии по краям, кривая светлоты, цветовая матрица) и кладётся через маску скруглённого прямоугольника.

Фиолетовые карточки позади передней оказались не этим. Их красило одно-единственное рисование: поверх уже затемнённой карточки ложилась картинка 846×1827, вся одного цвета (179, 77, 179), с силой 14,9 %. Само затемнение при этом верное — два рисования плоского чёрного при 11,4 %, то самое, что и ожидаешь увидеть. Цепочка такая: переключатель сам просит наложение обоев (-[SBAppSwitcherPageView _createOrDestroyWallpaperOverlayView]), PaperBoardUI наполняет его у поставщика изображений обоев (SBWallpaperEffectView → PBUIFakeBlurView → PBUIWallpaperView → PBUISnapshotReplicaView → UIImageView), а настоящей картинки обоев здесь нет: живые обои рисует PosterBoard в другом процессе и отдаёт их размещённым слоем, своего растра у SpringBoard нет, и никто его не печёт — после трёх с половиной минут покоя в /var/mobile/Library шестьдесят каталогов и ни одного PaperBoard, PosterBoard или Wallpaper. Поставщик отдаёт заглушку, CoreAnimation сжимает её в ASTC и рисует. Поэтому наложение здесь не создаётся вовсе (switcher_wallpaper_overlay в src/inject_sb.c): оттенок чужого цвета хуже, чем никакого. Карточки стали [18 18 18] вместо [42 26 42]. Когда появится настоящая картинка обоев для PaperBoardUI, этот перехват надо убрать; а до тех пор switcher_wallpaper=1 среди параметров ядра возвращает наложение таким, каким его просит iOS, — чтобы тот, кто возьмётся за обои, сразу видел, доходит ли его картинка, не пересобирая образ. Проверено по пути и отброшено: YCbCr снимков (мы сохраняем их в JPEG, карточка — две плоскости), фильтрация таблиц половинной точности, разбор ASTC (обои — тоже ASTC и разбираются верно; у картинки заглушки все просмотренные блоки «пустые», с одним цветом), работа с группами (glgroup=0), чтение кадрового буфера шейдером (glfetch=0).

Отказ рендера от функции — это пустая запись в его таблице исходников. У рендера OpenGL ES есть таблица из одного фрагментного шейдера на функцию (без сдвига 0x1d5b54790, по номеру функции), и те функции, которых у него «нет», — просто пустые записи в ней: оттуда же отвечает function_supported, оттуда же берёт исходник GLContext::create_shader. Если заставить рендер ответить «да», не заполнив запись, create_shader отдаёт пустоту в strdup, и оконный сервер умирает в strlen — это и происходило с glyes=40 (обработчик отказа в src/inject.c печатает теперь адрес и цепочку кадров, потому что отчёты о крушениях сюда не доходят). Заполненная запись падения убирает совсем: тридцать с лишним за прогон превращаются в ноль. glsb3=1 кладёт в запись 40 («sb3», однопроходное размытие) шейдер по образцу «ds4» — те же четыре места, без весов. Рендер переписывает его в свой диалект (sampler2DRect становится sampler2D), компилирует и рисует им: картинка меняется на уровень-другой по всему экрану там, где прежде не менялась вовсе.

Тени при этом не прибавляется, и её там и не было. Функцию взяли в работу потому, что прежняя запись здесь утверждала, будто тень под карточкой переключателя не рисуется, — но это было рассуждение «функция отказана, значит чего-то нет», а не наблюдение. Против того же кадра, нарисованного программным рендером CoreAnimation, край карточки совпадает до уровня: 18, 17, 17, дальше карточка; мягкого спада нет ни там, ни там. Действие функции нулевое и по второй причине: две группы по четыре числа, которые ей даёт function_uniform, порядка 5·10⁻¹⁹ — для размытия это ноль, и четыре выборки совпадают. Однопроходное размытие CoreAnimation и просит ровно тогда, когда радиус слишком мал для многопроходного.

Четвёртую — уменьшение картинки в восемь раз, первый шаг размытия подложки — тоже: шестнадцатью выборками вместо одной. Цвет вершин при этом брать не надо: свои шейдеры уменьшения у CoreAnimation его не трогают — это непрозрачность слоя, и накладывает её оно само там, где слой ложится на экран. Пока он домножался здесь, снятая подложка выходила в непрозрачности того слоя, который её просил (0,45 у полупрозрачного минуса на дрожащей иконке), и всё, что делалось из неё дальше — размытие, кривая светлоты, цветовая матрица, — работало по слишком тёмной картинке: минус выходил крашеным под то, что за ним, вместо светло-серого (RC_JOBS=rc.jobs.jiggle — долгое нажатие на домашнем экране; сравнение с программным рендером: тот же прогон без gpu=1). Размытие с переменным радиусом (полоса под строкой состояния в Поиске, в Картах, в библиотеке приложений и на экране виджетов: чем выше, тем сильнее размыто то, что под ней) рендер OpenGL ES делает, четыре раза очищая текстуру и рисуя в неё из неё же самой: уменьшенные вдвое уровни он кладёт туда, где в OpenGL ES их нет, и что из этого выйдет, решает драйвер (полоса бывала чёрной). Здесь у каждой такой картинки своя цепочка текстур, каждая вдвое меньше предыдущей: четыре шага уменьшения рисуются в них, а последний шаг читает их по маске CoreAnimation (радиус r — с уровня log2 r, между двумя соседними смешиванием). Частота кадров от этого не изменилась. vblur=0 возвращает прежнюю замену — полоса остаётся неразмытой копией того, что под ней; vblur=2 для проверки закрашивает полосу по маске (красное — полный радиус, синее — нулевой). Сценарий RC_JOBS=rc.jobs.vblur проходит по всем этим местам. Шейдеры CoreAnimation считаются с полной точностью (lowp и mediump в них определены как highp): 16-битного mediump у Adreno не хватало на таблицу цветов градиента — фон виджета «Погода» шёл полосами; glprecision=0 возвращает как написано. Кадры с недостающими частями — обои без иконок, Пункт управления без половины модулей, прямоугольники из другого кадра, чёрные прямоугольники на экране блокировки — на кадр-другой, а то и через кадр, при каждом открытии Пункта управления, Центра уведомлений, Поиска и приложений (заметно с тех пор, как экран стал sRGB). Кадр рисовался прямо в память поверхности, которую потом показывает драйвер экрана, а CoreAnimation читает рисуемую картинку, чтобы рисовать поверх (EXT_shader_framebuffer_fetch: цветовая матрица над тем, что позади, — наша программа vibrant); с P3 поверхности кадров копировались, и этого не было. Теперь userland/src/eagl.c даёт CoreAnimation рисовать в собственную текстуру Mesa, а в конце кадра GPU одним blit переносит её в память поверхности (драйвер экрана ждёт эту отправку, как раньше ждал последнюю отрисовку). По записи экрана жестов RC_JOBS=rc.jobs.gfx — ни одного такого кадра (без этого — десятки), и кадров больше: по rc.jobs.fps 3113 шагов против 2733, дольше 16,7 мс — 25 % против 30 %. glblit=0 — рисовать прямо в поверхность, как раньше. Конец кадра к тому же просит у Mesa забор с флагом сброса, не дожидаясь его, чтобы придержанные отправки сразу ушли в ядро (glsync=0 — без этого). Для проверок есть и gpu_keep=0 (ядро не держит буферы программ для повторной выдачи; gpu_keep=2 — держит, но выдаёт очищенными) и параметр загрузчика gamut=p3 (в дереве устройств остаётся «artwork-display-gamut» P3 самого iPhone — Safari тогда рисует чёрные страницы). Пункт управления при появлении: модули и их части проявляются (анимации прозрачности, в основном пружины, до секунды), и пока прозрачность слоя меньше 1, CoreAnimation рисует его со всем содержимым в отдельную картинку — группу — и кладёт её на экран с этой прозрачностью, а при 1 рисует прямо на экран. Круглые кнопки модуля связи и заголовок «Не исполняется» — «живые» (функция 45, vcm: цветовая матрица над тем, что позади, — наша программа, читающая рисуемую картинку), а в группе позади них — сама группа, очищенная в ничто: матрица от ничего — ничто, и диски кнопок с заголовком появлялись разом примерно через полсекунды после остального, когда проявление кончалось. Теперь userland/src/eagl.c (group_draw_begin()) в такую пустую группу записывает, что «живой» слой покрывает (его прозрачность на маску), и запоминает матрицу; когда группу кладут на экран (или на картинку, где уже что-то есть), наша программа накладывает матрицу на то, что там, по этому покрытию, умноженному на прозрачность группы, — то же, что дала бы группа, начатая с того, что позади неё, и то же, что даёт отрисовка при полной непрозрачности. Группа, в которую после этого рисуют или которую читают иначе, сначала делается тем, чем была бы (матрица от ничего по покрытию). По записи экрана (RC_JOBS=rc.jobs.cc) диски и заголовок видны с первых кадров и проявляются вместе с модулем, анимации iOS не тронуты. glgroup=0 — как раньше. Штрихи значков, которые по счёту отрисовок будто бы приходили через 0,4 с, на деле на месте с первого кадра: при прозрачности 1 CoreAnimation лишь отделяет полностью покрытые отрезки заливки в свои отрисовки без смешивания (концов отрезков в кадре до и после столько же). cc_fade=МС (userland/src/inject_sb.c: анимации прозрачности в окне Пункта управления заменяются плавными за МС) остаётся для проверки, по умолчанию выключен; RC_JOBS=rc.jobs.cc трижды открывает и закрывает Пункт управления — для записи экрана и glframes=1. glframes=1 — строка на каждый кадр оконного сервера: когда он кончен (время ядра), сколько отрисовок, в какую поверхность экрана, прямоугольник отсечения первой отрисовки в неё и отрисовки размытия с переменным радиусом, в конце — отрисовки отрезками (так CoreAnimation заливает значки) в экран без смешивания и со смешиванием, концы их отрезков и такие же отрисовки в другие картинки; следующая строка, если она не та же, что у кадра до него, — в каких функциях CoreAnimation отказано, отрисовки без программы по функциям и что сделано с группами с «живым» слоем; вместе с rec_ring=1 кадры записи сопоставляются со строками. glcensus=1 ведёт перепись: каждая отрисовка без программы (номер и имя функции, где на экране, с какими текстурами и числами, чем нарисована здесь) — при первой встрече, и каждые десять секунд счёт по функциям; туда же попадают функции, о которых CoreAnimation спросил и получил отказ. Прерываний нет (ожидание опросом). GPU выключается при любом штатном конце сеанса (таймер, ./xl stop, пять пальцев); после паники следующий запуск сам останавливает его и запускает заново. Приложения, использующие Metal сами, от этого ничего не получают.

Metal: компилятор шейдеров есть, Metal'я ещё нет

Граница, которую надо перейти, разобрана в docs/metal.md: имя класса в реестре IOKit (IOAcceleratorES) и бандл в /System/Library/Extensions с подклассом приватного _MTLDevice, а за ними три подсистемы — компилятор шейдеров AIR, граф объектов _MTLDevice/IOGPUMetalDevice (53 класса и 1158 методов у AGXMetalA11) и ABI ядра для отправки работы.

Первая из трёх написана (docs/air.md, ветка air): фронтенд AIR→NIR внутри Mesa, рядом с src/compiler/spirv. Прежняя формулировка «готового читателя AIR в мире нет» верна только про сегодняшний день чужих проектов; здесь он есть. По корпусу из 456 модулей системных .metallib:

что сколько из 456
читает наш собственный читатель битового кода, побайтово как llvm-dis 456
транслируется в NIR и проходит nir_validate 321
компилируется ir3 в машинный код Adreno 321
совпадает с LLVM численно и рисуется (softpipe) 255
запущено на самом GPU телефона (Adreno 7c+ Gen 3, через msm в ядре): 7 отрисовкой, 47 диспатчем 54

Это все 321 точки входа корпуса (остальные 135 модулей — библиотечные функции air.visible, не точки входа): 271 фрагментный шейдер, 47 вычислительных ядер и 3 вершинных. Смысл каждой операции air.* взят из словаря самого компилятора Apple (AGXCompilerCore, 137 строк-суффиксов), а не угадан — так нашлась и одна ошибка в наших догадках.

Операции проверены численно — на хосте: из 119 разных air.*, что встречаются в корпусе, 78 прогнаны по одной против эталона вне этого дерева, 71 из них совпадает с функциями C на каждом из 45 697 случаев, 47 — ещё и когда их исполняет softpipe, а не складывает оптимизатор (2 586 случаев), 10 атомиков и барьер — на написанных для них ядрах, 13 текстурных операций — точно, по нескольким текселям каждая. И все 78 прогнаны на самом Adreno телефона, причём дважды: как порт стоял, и с однострочной починкой округления в ir3, которую это дерево теперь несёт (tools/mesa/ir3-round-default.patch).

прогон совпадают расходятся отложены случаев
как порт стоял (logs/090218-run.log) 56 5 10 714
с починкой (logs/090918-run.log) 61 0 10 714

Все пять расходившихся — преобразования целого в float: air.convert.f.{f32,v2f32,v3f32}.u.*, а после того как пул научился спрашивать про середины, ещё и знаковые air.convert.f.{f32,v2f32}.s.*, то есть i2f усекает так же, как u2f. С починкой сходятся все пять, и больше ничего не сдвинулось — то есть починка чинит ровно то, что было неверно.

Отложенные десять возвращают half, и проверить их прогоном air_run нельзя ни до, ни после: он читает результат через 32-битный таргет, 16-битного у него нет вовсе, поэтому половина сразу расширяется, NIR сворачивает преобразование в 32-битное, и эталон описывает операцию, которой шейдер не выполняет. Раньше здесь стояло «66 совпадают на 808 случаях» и отдельно — что все 24 узких объявления прошли на Adreno и совпали; это было неверно ровно на эти десять, и неверно в той части, которая и была интересна. Из узких типов i1, i8 и i16 действительно прошли и совпали, а f16 не спрашивали. 61 + 0 + 10 = 71, счёт сходится.

Из того же пропуска вылезла находка, которую он глотал: air.fast_pow на Adreno даёт +inf для pow(-0, -1), где C даёт -inf. Вердикта нет (fast:: вне области определения не определены), но теперь это хотя бы видно. И три объявления — air.all.v2i1, air.all.v3i1, air.convert.f.f32.u.i1 — сравнились всего на двух случаях каждое: «совпадает на каждом случае» из двух стоит читать иначе, чем те же слова из двенадцати.

Эти 3 — единственное за всю работу, что нельзя было найти на хосте никаким движением, и ровно за этим набор и ехал на телефон: Adreno усекает u32 → f32 там, где C, NIR и softpipe округляют. 0xFFFFFFFF даёт 4 294 967 040 вместо 4 294 967 296 — ровно отброшенные младшие 8 бит, то есть усечение к нулю вместо округления к ближайшему, которого требует OpenCL (а Metal наследует его математику). Наш фронтенд при этом выдаёт правильный опкод nir_u2f32, а ir3 превращает его в одиночную cov.u32f32 без доводки — машинный код модуля это пять инструкций, четыре из них cov, и между загрузкой и преобразованием ничего. То есть виноваты не мы и не понижение в ir3, а сама инструкция a6xx — и это было неверно. У инструкции cov есть поле режима округления (ROUND_ZERO = 0, ROUND_EVEN = 1), и ir3 ставит его для назначения F16/F32 из float_controls_execution_mode, то есть из объявления SPIR-V; когда не объявлено ничего, nir_get_rounding_mode_from_float_controls возвращает undef, ни одна ветка не срабатывает, и поле остаётся нулём — то есть усечением. Железо умеет округлять; ir3 спрашивает режим только у того, кто его объявил, и не ставит умолчания, которого требуют IEEE-754 и GLSL. Поэтому оба пути, GLES и AIR, и дают одно и то же неверное число. Починка — одна строка, и, в отличие от бага с ir3_cmdline, этот файл Mesa сохраняет, а воспроизведение есть с двух независимых сторон: это первое за всю работу, что можно отправить вверх. И это не особенность нашего пина: в Mesa 26.2.5 — новейшем выпуске на момент проверки, на восемь минорных версий новее нашей 25.0.7 — и в самом main, взятом с GitLab 20 октября, этот кусок create_cov() совпадает с нашим дословно, посимвольно: тот же else if без завершающего else. Три дерева, один и тот же пропуск. (ROUND_ZERO = 0 в instr-a3xx.h сверено у нашего пина и у 26.2.5; с main взят только ir3_compiler_nir.c, и о его заголовке мы судить не можем.)

Предсказание, которое из этого следовало, проверено и сбылось: bin/u2f_test (src/u2f_test.c, RC=rc.u2f) спрашивает то же самое у GLSL ES 3.0 через ту же Mesa и тот же ir3, мимо AIR целиком, — значение идёт униформой, чтобы его нечего было свернуть на хосте, а ответ возвращается четырьмя байтами через RGBA8, чтобы биты были точными. На freedreno; Adreno 7c+ Gen 3; OpenGL ES 3.2 Mesa 25.0.7 из шести значений разошлись четыре:

вход GPU C
0xFFFFFFFF 4 294 967 040 4 294 967 296
0xFFFFFF80 (ровно середина) 4 294 967 040 4 294 967 296
0x01000001 (середина, оба правила вниз) 16 777 216 16 777 216
0x01000003 (середина, округление вверх) 16 777 218 16 777 220
0x7FFFFFFF 2 147 483 520 2 147 483 648
0x00FFFFFF (точно представимо) 16 777 215 16 777 215

Каждый расходящийся ответ — это вход с отброшенными младшими битами, а совпадают ровно те два, где усечение и округление дают одно и то же. (Это измерение без починки, ниже — с ней.) Значит это не «Metal считает иначе»: всё, что на этом телефоне компилирует ir3, включая рисование оконного сервера, преобразует беззнаковое в float усечением, и наш фронтенд AIR к этому непричастен — его в этой проверке нет вовсе.

Видимой разницы на экране починка при этом не делает — и это измерено, а не предположено. Три прогона домашнего экрана на одном образе и одной командной строке (gpu=1, 20 октября): с починкой, без неё и ещё раз без неё; снимки сравнены попиксельно.

пара отличий на 1..8 отличий больше 8
с починкой против без 10 835 2 508
без против без (контроль) 13 738 2 286

Контроль различается сильнее, чем сама пара, значит единичные отличия принадлежат прогону, а не починке — хотя лежат они ровно там, где лежало бы изменившееся размытие (поиск, док, точки страниц), и без контроля я приписал бы их округлению. Крупные отличия в обеих парах опознаны по картинке, а не по координатам: живые стрелки на значке Часов и время в строке состояния. Сдвига геометрии или цветов нет ни в одной паре. Это ограничивает действие починки сверху разбросом между прогонами, а не доказывает, что на экране не изменилось ничего.

Строка таблицы «запущено на самом GPU телефона» этих модулей не считает: она про модули корпуса из 456, а набор — сгенерированные, в корпус не входящие, и складывать два измерения нельзя. Сама строка теперь 54 по другой причине: 7 фрагментных шейдеров отрисовкой и 47 вычислительных ядер диспатчем, множества не пересекаются — ядро не фрагментный шейдер. До прогона ядер там стояло 7, и некоторое время после него тоже, и это третий раз, когда именно эту цифру оставляет позади работа, которая её и сдвинула.

Чего нет: 64-битное слово состояния семплера (constexpr sampler) не разобрано; все 47 вычислительных ядер Apple отправлены на Adreno, но сравнить их результат не с чем: softpipe не выполняет 41 из них, так что «отправлено» — это не «посчитано верно»; air.fast_pow на Adreno даёт NaN при отрицательном основании, где softpipe даёт точное значение — по стандарту это разрешено (fast:: в Metal, как native_ в OpenCL, вне своей области определения не определены), но всякий шейдер корпуса, у которого основание может уйти ниже нуля, поведёт себя на телефоне иначе, чем в любой проверке на этой машине. Полная сводка — docs/air.md и docs/air-report.md.

CoreImage при этом уже работает без всякого Metal (раздел 7 docs/metal.md): нужен был один метод -sharegroup в src/eagl.c, и +[CIContext contextWithEAGLContext:] компилирует и считает ядро фильтра на Mesa. Что система думает про Metal, печатает bin/metal_test (RC_JOBS=rc.jobs.metal).

Sileo и пакеты

На этом порте работает менеджер пакетов — Sileo 2.5.1 и префикс Procursus с dpkg и apt: приложение открывается, показывает витрину репозитория из сети и ставит пакеты.

tools/mk-jb.sh скачивает «rootless»-бутстрап Procursus (bootstraps/1800/bootstrap-iphoneos-arm64.tar.zst) и .deb самого Sileo и собирает из них префикс в jb/root/var/jb (124 МБ, в git не попадает, как fw-*). Rootless — единственное, что здесь подходит: корень — образ APFS только для чтения, а /private/var — файловая система в памяти, так что весь префикс живёт под /var/jb. Дальше userland/build.sh кладёт его в образ системы как /jb (только чтение), а init при каждой загрузке приращивает его к /private/var/jb: каждый каталог делается настоящим, а каждый файл и ссылка становятся символической ссылкой на двойника в /jb (431 каталог, 6361 ссылка) — так каталоги оказываются записываемыми, а 114 МБ программ и библиотек остаются общими и не занимают памяти. То, что dpkg и apt перезаписывают целиком (их базы и настройки — Library, etc, var, tmp), копируется по-настоящему; /var/jb/var/mobile принадлежит mobile (501) со всем, что внутри, — Sileo работает от него и держит там свою базу.

Что пришлось сделать помимо этого:

  • PATH для запускаемых команд: программы префикса зовут друг друга по имени (dpkg → dpkg-query).
  • подготовка бутстрапа один раз за загрузку — /var/jb/prep.sh (его пишет mk-jb.sh): usr/libexec/firmware заводит в базе dpkg пакет firmware с версией iOS, от которого зависят все остальные (у Sileo — firmware (>= 11.0)), плюс pwd_mkdb и несколько postinst. Своя prep_bootstrap.sh бутстрапа не годится: она спрашивает пароль терминала в своём окне и правит /etc, который здесь и так новый при каждой загрузке.
  • сертификаты для apt: gnutls собран доверять /var/jb/etc/ssl/certs/cacert.pem из пакета ca-certificates, но спрашивает их у системного хранилища, а trustd в этом порте уходит в сеть и не получает ответа (5687 байт отправлено, 0 принято, перепосылки) — apt говорил No system certificates available и обрывал рукопожатие. etc/apt/apt.conf.d/10redwood.conf указывает ему прямо на файл, и хранилище перестаёт быть нужно.
  • libkrw0, libdimentio0 и shshd выброшены из бутстрапа (файлы и записи в базе): первые два читают и пишут ядро взломанного телефона через плагин, который даёт взлом (libkrw0-plugin), а shshd ими сохраняет подписи; здесь ядро своё, плагина нет ни у кого, а apt с одной неудовлетворённой зависимостью в базе отказывается ставить что угодно вообще.
  • режим каталога в приращённом префиксе: обход каталогов в init.c в конце ставил каталогу режим последней его записи (обычно файла, 644), и каталог становился drw-r--r-- — в такой нельзя войти даже его владельцу. Из-за этого apt под своим _apt не доходил ни до сертификата, ни до связки ключей, а Sileo (он работает от mobile) не находил свои папки. Теперь режим каталога запоминается до обхода.

И ещё три, без которых Sileo открывается, но ничего не делает:

  • setuid-помощник: всё, что Sileo меняет в системе (apt, dpkg, свой каталог индексов), он делает, запуская giveMeRoot из своего бандла, а тот в пакете лежит как -rwsr-xr-x root/root. Распаковка архива обычным пользователем бит setuid теряет, и без него Sileo читает репозитории, собирает очередь — и останавливается на этом. mk-jb.sh возвращает бит (в образе файл принадлежит root, так что он значит ровно то, что написано).
  • свой каталог индексов: Sileo зовёт apt с -oDir::State::lists=sileolists/, то есть /var/jb/var/lib/apt/sileolists, которого в бутстрапе нет. Создаёт его init и отдаёт mobile, иначе Sileo показывает «The folder … doesn't exist» и видит только установленные пакеты вместо витрины.
  • nsurlsessiond: фоновые загрузки URLSession выполняет этот демон iOS, а он стоял в DENY в userland/mkjobs.py вместе со службами Apple-аккаунта. Без него загрузка пакета не начинается и не обрывается — Sileo вечно показывает «Queued», без единой ошибки.

И то, без чего Sileo доходил до установки, но не ставил:

  • /bin/mv, cp, rm, mkdir, chmod: всё, что Sileo перекладывает от root (скачанный пакет — в архивы apt, индексы — в lists), он перекладывает этими программами из /bin, а в /bin iOS только df и ps. Каждый такой запуск молча не удавался, пакет до архивов apt не доходил, и apt с --no-download отвечал «Unable to fetch some archives». Теперь это ссылки на программы префикса (SILEO_BIN в build.sh).
  • счёт процессов root: Sileo запускает apt, dpkg и эти пять программ от root через «персону», которой здесь нет; ядро давало процессу учётные данные root, но не переносило его в счёте процессов по uid. Пока /bin/mv не запускался, это не было заметно; когда запустился, счёт root ушёл в ноль на первых десятках программ, и ядро паниковало («chgproccnt: lost user», kern_exec.c).
  • страницы ramfs: файлы в памяти лежали в зоне data.kalloc.16384, а у всех зон ядра один потолок: запись кончалась ENOSPC задолго до собственного предела ramfs (так не записывалась база dpkg, и пакета firmware не было — «Depends: firmware» почти у всех пакетов), а потом исчерпывалась карта зон. Теперь страница файла — своя страница kernel_map, как у RAM-диска.

Sileo с корнем на флеше

С корнем на флеше (FLASHBOOT=1 CRYPTEXES=flash) префикс лежит в загрузочном томе: /jb, после смены корня — /private/preboot/jb. Его записываемая копия — в отдельной файловой системе в памяти, смонтированной на /private/preboot/jb-rw, как procursus, а /var/jb — ссылка туда: как на iPhone с rootless-взломом, где /var/jb ведёт в /private/preboot/…/procursus. Ссылки на пять программ лежат в jb-bin загрузочного тома, и init кладёт этот каталог поверх /bin тома iOS объединяющим монтированием (MNT_UNION): чего в нём нет (df, ps), находится в /bin под ним. В памяти от префикса — 42 МБ сжатого образа.

Приложений префикса в /Applications тома iOS нет, поэтому их регистрирует uicache, как при взломе (prep.sh, в фоне, с повторами: первые полминуты после SpringBoard LaunchServices занят системными приложениями). Чтобы это заработало:

  • настоящий путь: в приращённом префиксе файлы приложения — ссылки в загрузочный том, и такое приложение LaunchServices не берёт; prep.sh регистрирует бандл там, где его файлы на самом деле.
  • номера файлов ramfs: LaunchServices хранит приложение по номеру тома и файла и восстанавливает путь по нему, а приложения с тома, который не заявляет постоянных номеров (VOL_CAP_FMT_PERSISTENTOBJECTIDS, VOL_CAP_FMT_PATH_FROM_ID), не регистрирует вовсе, даже не заглянув в файлы. Всё, что пакет ставит в /var/jb/Applications, было «Unable to register». Теперь ramfs находит узел по номеру (vfs_vget), отдаёт имя каталога по записи в родителе и заявляет обе возможности.
  • /var/mobile/tmp: у Sileo, зарегистрированного так, нет своего контейнера данных, и временные файлы — а с ними каждая загрузка — идут в $HOME/tmp, которого в шаблоне /private/var iOS нет. Каждая загрузка кончалась «No such file or directory». build.sh добавляет его в шаблон.
CRYPTEXES=flash FLASHBOOT=1 GL=1 REST=1 QUIET=1 RC_JOBS=rc.jobs.jb \
  INJECT="$(printf '%s\n' '/redwood/lib/libhakios.dylib backboardd containermanagerd containermanagerd_system' \
    '/redwood/lib/libhakios.sb.dylib SpringBoard' '/redwood/lib/libhakios.media.dylib mediaserverd' \
    '/redwood/lib/libhakios.app.dylib Sileo')" userland/build.sh
./xl ramdisk userland/flashboot.apfs
XL_CMDLINE="root_apfs=ufs_darwin ufs=2 ufs_native=1 bootcpu=7 cpus=8 cpufreq=1 touchscreen=1 gpu=1 adsp=1 sound=4 wifi=1" XL_SECS=1800 ./xl run

Проверено на телефоне с корнем на флеше: Sileo появляется на домашнем экране и открывается, обновляет источники и ставит пакеты (2048 — консольная игра, TrollStore Helper — с приложением); приложение TrollStore Helper, поставленное dpkg -i (rc.jobs.jbreg), регистрируется, и его значок появляется рядом с Sileo. С образом системы в памяти tree ставится из Sileo так же.

Твики

Твики работают. Твик — это библиотека, которую надо подгрузить внутрь чужой программы и дать ей заменить там функции и методы. Ни того, ни другого в порте не было, поэтому пакеты с Depends: mobilesubstrate (а это почти все твики, и preferenceloader среди них) даже не ставились.

  • ElleKit — современная замена Cydia Substrate — теперь часть префикса (tools/mk-jb.sh берёт его с собственного сайта: в репозитории apt такого пакета нет, его ставит сам взлом). Библиотеки у него «толстые», arm64 и arm64e, и на этом телефоне работает срез arm64. В базе dpkg он объявляет, что предоставляет mobilesubstrate и org.coolstar.libhooker, — это и есть зависимость почти всех твиков; а его libellekit (он же libsubstrate, libhooker и CydiaSubstrate.framework) — тот самый движок, который и выполняет перехват.
  • Загрузчик наш (src/inject_tweaks.c, libhakios.tweaks.dylib): внутри программы он читает /var/jb/usr/lib/TweakInject, берёт каждый список, чей Filter называет эту программу (Bundles, Executables, Classes, Mode), и открывает лежащий рядом твик, записывая в журнал, что открыл. У ElleKit свой такой есть (ellekit/libinjector.dylib), но здесь он открывается и молча не грузит ничего, не говоря почему; наш виден в журнале.
  • Как он попадает в программу: либо его вставляет ядро как единственную библиотеку (etc/inject.conf, по умолчанию — Настройки), либо его открывает уже вставленная библиотека наша (src/inject_sb.c для SpringBoard, у которого вставлена своя). Надёжно работает только одна вставленная библиотека на программу. Чтобы твики работали ещё в какой-то программе, добавьте её имя в INJECT.
  • В ядре exec_inject теперь склеивает библиотеки всех групп, назвавших программу, в один DYLD_INSERT_LIBRARIES через : (kern_exec.c) — иначе программу нельзя было назвать дважды.

Проверено на телефоне с корнем на флеше: наш твик (src/tweaks) открывается в SpringBoard по своему Filter, перехватывает -[SBIconView setIcon:] через MSHookMessageEx, и перехват срабатывает; apt-get install preferenceloader проходит, а его PreferenceLoader.dylib открывается внутри Настроек (RC_JOBS=rc.jobs.pl).

Проверки: RC_JOBS=rc.jobs.sileo — подготовка, apt-get update, затем весь путь человека в Sileo до установки tree и что о нём говорит dpkg; rc.jobs.jbreg с EXTRA=<пакет с приложением>.deb — dpkg -i и регистрация его приложения; rc.jobs.jb — ничего по сценарию, телефон ваш на XL_SECS. Поставленное не переживает перезагрузку (как и всё в /private/var).

Накопитель телефона (UFS)

Ядро читает флеш телефона — все 112 разделов GPT шести логических единиц UFS, только чтение, до 920 МБ/с запросами по 1 МБ (по 4 КБ — 31 мкс на запрос; см. ниже «Скорость: почтовый ящик»). Своего драйвера UFS в ядре нет, и он не нужен: загрузчик никогда не выходит из UEFI, драйвер Qualcomm (UFSDxe) уже поднял контроллер, его PHY, такты и питание и остаётся жить на ядре 0, где загрузчик и так обслуживает ядро порта (батарея). С ufs=2 загрузчик выкладывает общую страницу (ufs_shared_pa=) с таблицей разделов, запросом и ответом и буфер на 4 МБ; ядро пишет запрос — какой раздел, какие блоки, — а ядро 0 читает их через ReadBlocks этого драйвера (xnuloader/loader.c, block_poll; ядро — drivers/storage/hakios.ufs.c). Каждый раздел — /dev/ufs_ИМЯ (блочное) и /dev/rufs_ИМЯ (символьное), имя — как в таблице разделов. Нужен bootcpu=7: служба живёт в цикле, которым ядро 0 следит за ядром порта, а он есть, только когда ядро порта работает на другом ядре.

Только чтение — по построению: открытие на запись отказывается, запись, дошедшая до драйвера, отказывается, а в странице просто нет поля, которым можно было бы попросить записать, — загрузчик зовёт ReadBlocks и больше ничего. Поля, которые пишет ядро, и поля, которые пишет загрузчик, лежат в разных строках кэша, а в буфер данных ядро 0 само не пишет никогда — поэтому очистка кэша одной стороны не затирает записи другой.

Проверка — ufs=1 (только загрузчик) или ufs=2 ufs_check=1 (и ядро), затем tools/ufs-survey.py:

  • заголовок GPT каждой логической единицы сам себя проверяет (CRC-32 заголовка и таблицы разделов) — у всех шести обе суммы верны: прочитанные байты — те, что на флеше, независимо от версии прошивки;
  • шестнадцать разделов (xbl, tz, hyp, abl, aop, bluetooth, dsp, modem, оба слота) ядро прочитало через почтовый ящик — 568 запросов, 586 735 616 байт, 0 ошибок — и их хеши совпали с тем, что загрузчик прочитал сам до старта ядра. С образами из fw/fw.zip они не совпадают: на телефоне стоит другая версия прошивки (начала разделов при этом верные — ELF у загрузчиков, FAT у modem и bluetooth).

Что лежит внутри super (8,5 ГБ, где у Android system, vendor, product и прочие логические разделы): с ufs=1 загрузчик выводит метаданные liblp как есть, tools/ufs-super.py проверяет их суммы SHA-256 (все верны) и раскладывает. Телефон живёт на слоте _b (Virtual A/B: разделов _a в super нет вовсе), семь логических разделов занимают 8,42 ГБ, и не покрыто ни одним экстентом 85 МБ — 12 + 2,6 МБ дырами после прошлого обновления и 70 МБ в хвосте. Своему тому порта места в super нет. Вне super на флеше свободного места не было тоже: userdata доходил до конца единицы 0.

Раздел порта — darwin, 20 ГиБ, отрезан от конца userdata (тип — контейнер APFS, LUN 0, LBA 25374720..30617594, под Android — sda34). userdata на этом телефоне — f2fs без шифрования (суперблок лежит на флеше открытым текстом, ro.crypto.state=unsupported, в fstab нет fileencryption), а его последние ~51 ГиБ не занимал ни один блок, так что ничего переносить не пришлось. Как это сделано, из root-шелла adb под Android, за один сеанс — между шагами перезагружаться нельзя: при каждой загрузке fs_mgr запускает resize.f2fs, и тот растягивает f2fs до размера раздела (сжатую f2fs в старом разделе он так и вернул обратно):

  1. tools/android/f2fs-resize /data БЛОКОВ — ядро Android само сжимает смонтированную f2fs (F2FS_IOC_RESIZE_FS);
  2. tools/userdata-carve.py по сохранённым копиям таблицы и суперблоку уже сжатой f2fs делает новую таблицу: userdata ровно по f2fs, darwin — с ближайших 2 МБ до конца; меняются две записи и больше ничего, обе копии таблицы проверяются здесь так же, как их проверит прошивка; записываются dd (у каждой копии сначала таблица, потом заголовок) и читаются с флеша обратно.

После перезагрузки Android поднялся, /data — 89,6 ГБ, resize.f2fs ничего не тронул, загрузчик через UEFI видит darwin, суммы GPT верны. В darwin записан корневой контейнер APFS iOS 16.5 (rootfs.apfs, 5 628 755 968 байт) — из Android, adb exec-in 'dd of=/dev/block/sda34 bs=4194304 conv=fsync', ~13 МБ/с; SHA-256 прочитанного с флеша обратно (после сброса кешей) совпал с файлом. Резервные копии (обе копии старой таблицы, первые 458 МБ userdata — все метаданные f2fs, файлы /sdcard) и то, как вернуть таблицу, — в fw-backup/userdata-shrink/ основной копии репозитория, вне git.

Загрузчик пишет на флеш с первого коммита проекта ровно одно — свой журнал, в служебный раздел logdump (write_logs); к этому драйверу это не относится.

Скорость: почтовый ящик

ufs=2 ufs_bench=1 (с bootcpu=7) читает раздел darwin с начала запросами разного размера, каждый размер — по своему участку, и раскладывает запрос на три части: ReadBlocks на ядре 0 (по часам самого загрузчика в странице), остальное ожидание ответа и копирование из буфера загрузчика. Микросекунд на запрос:

размер было: МБ/с ReadBlocks ожидание сверх копирование стало: МБ/с на запрос ReadBlocks копирование
4 КБ 37 27 74 4 129 31 27 1
64 КБ 275 85 76 74 677 96 88 5
1 МБ 422 1213 68 1198 923 1134 1031 100
4 МБ 365 6240 423 4810 831 5044 4197 607

Ни то ни другое не было флешем: ядро 0 смотрело в почтовый ящик раз за проход своего цикла (а в проходе ещё опрос USB и пауза на 20 000 оборотов) — запрос ждал 60–75 мкс, пока его заметят; теперь пауза — это 64 взгляда в ящик. И ядро порта копировало данные из некэшируемого отображения буфера со скоростью 870 МБ/с; теперь буфер отображён кэшируемым, а то, что покрывает ответ, перед копированием выбрасывается из кэша (эта сторона в буфер не пишет, ядро 0 после каждого чтения его чистит). Только чтение — по-прежнему по построению. kern.hakios.ufs_* (и строки ufs: … waiting … of it in ReadBlocks … copying до и после обхода apfs_check=6) говорят, куда ушло время. Остаётся сам ReadBlocks: около 25 мкс на запрос плюс около 1 мс на МБ, произвольное чтение 4 КБ — около 110 мкс, и в полёте всегда один запрос (драйвер Qualcomm через BlockIo).

Корневой том iOS с флеша

Весь том за один проход, с /dev/ufs_darwin (bootcpu=7 cpus=7 cpufreq=1 ufs=2 apfs_check=6 apfs_dev=ufs_darwin apfs_mnt=/lib; теперь — apfs_mnt=/System/Volumes/iOS, каталог образа системы): 318 098 записей, 8 285 771 220 байт, сумма ceb8a53c349a011f — та же, что на компьютере, ошибок 0. Время по шагам:

обход МБ/с содержимого
сразу после слияния 83 982 мс 98,7
почтовый ящик: ответ сразу, кэшируемый буфер 60 167 мс 137,7
2048 блоков метаданных в кеше вместо 64 52 887 мс 156,7
соседние куски сжатых файлов — одним запросом 46 275 мс 179,1
метаданные по 4 блока за запрос 44 814 мс 184,9
то же без хеша проверки (apfs_hash=0) 34 367 мс 241,1

Из 34,4 с без хеша 24,3 с — ReadBlocks: дальше упирается в флеш с одним запросом в полёте. Где запрос дорог (флеш, virtio в QEMU), драйвер читает соседние куски и блоки метаданных вместе; с диска в памяти — нет (cheap_requests; иначе один читатель ramdisk терял 8 %).

В обычном сеансе том виден пользовательскому пространству: с apfs_volume=ufs_darwin ядро монтирует его только для чтения (и nosuid,nodev) на /System/Volumes/iOS — сразу после корня и devfs, до launchd (apfs_volume_at= — другое место; каталог есть в образе системы). Корнем он не становится.

SYSTEM=1 REST=1 RC_JOBS=rc.jobs.ios userland/build.sh
./xl ramdisk userland/system.apfs
XL_CMDLINE="touchscreen=1 cpufreq=1 bootcpu=7 cpus=8 ufs=2 apfs_volume=ufs_darwin" XL_SECS=180 ./xl run

iOS доходит до домашнего экрана как обычно, а rc.jobs.ios показывает из пространства пользователя: /dev/ufs_darwin on /System/Volumes/iOS (apfs, local, nodev, nosuid, read-only) (/sbin/mount, df — 278 712 файлов), листинги vol_ls ls (своего ls в iOS нет: в /bin только df и ps) корня тома и /System/Library/CoreServices, SystemVersion.plist целиком (iPhone OS 16.5, 20F66), хеши трёх файлов через read(2) и через отображение — совпадают между собой и с компьютером, и все 32 файла /usr/sbin (50 096 208 байт) обоими способами — каждый размер и хеш как на компьютере, ошибок 0, 208 и 244 МБ/с. Путь отображения — vnop_pagein: 227 подкачек, 50 МБ, 146 мс — на телефоне он впервые и отработал.

iOS с корнем на флеше

С root_apfs=ufs_darwin корень системы — сам том iOS на флеше, только для чтения, а в памяти телефона только то, чего на нём нет: образ flashboot.apfs — загрузочный том (stage0, прошивки звукового процессора, точки монтирования), 21 МБ. Оба криптекса — образы Apple как есть — лежат на флеше, в разделе darwin сразу за томом iOS (см. «Криптексы на флеше»). Без CRYPTEXES=flash образ несёт их сам, тремя контейнерами подряд (994 МБ), — для телефона, на флеше которого их нет; init берёт криптексы из памяти, если они там есть, иначе с флеша.

CRYPTEXES=flash FLASHBOOT=1 GL=1 REST=1 RC_JOBS=rc.jobs.flashcheck userland/build.sh
./xl ramdisk userland/flashboot.apfs
XL_CMDLINE="root_apfs=ufs_darwin ufs=2 ufs_native=1 bootcpu=7 cpus=8 cpufreq=1 touchscreen=1 gpu=1 adsp=1 sound=4 wifi=1" XL_SECS=180 ./xl run

iOS доходит до того же домашнего экрана, что и с образа в памяти (SpringBoard готов через 6,1–6,3 с против 4,5–4,6 с с образа в памяти; см. «Только APFS, как на iPhone»), и из пространства пользователя видно (/sbin/mount):

/dev/ufs_darwin on / (apfs, local, read-only)
devfs on /dev (devfs, local, nobrowse)
root_device on /private/preboot (apfs, local, read-only)
/dev/md1 on /private/preboot/redwood (apfs, local, read-only)
/redwood/LaunchDaemons on /System/Library/LaunchDaemons (bindfs, local, nosuid, read-only, nobrowse)
/redwood/empty on /System/Library/NanoLaunchDaemons (bindfs, local, nosuid, read-only, nobrowse)
/redwood/empty on /System/Library/LaunchAngels (bindfs, local, nosuid, read-only, nobrowse)
/redwood/xpc on /System/Library/xpc (bindfs, local, nosuid, read-only, nobrowse)
/redwood/hal on /System/Library/Audio/Plug-Ins/HAL (bindfs, local, nosuid, read-only, nobrowse)
/fw on /fw (bindfs, local, nosuid, read-only, nobrowse)
/dev/ufs_darwin,c=1 on /private/preboot/Cryptexes/OS (apfs, local, read-only)
/dev/ufs_darwin,c=2 on /private/preboot/Cryptexes/App (apfs, local, read-only)
/private/preboot/redwood on /redwood (bindfs, local, nosuid, read-only, nobrowse)
ramfs on /private/var (ramfs, local)

Как это устроено. На том ничего не пишется, поэтому то, что большой образ системы меняет в iOS, ставится вокруг него при загрузке:

  • ядро стартует как всегда, с образа в памяти (rd=md0 — ровно по этой строке launchd понимает, что задачи загрузки настоящего устройства не нужны: с rd=md1 он выполнял их все и вставал на keybag); stage0 монтирует маленький образ на /redwood и запускает его init;
  • init (flash_root) монтирует том на /flash, кладёт поверх его каталогов (bindfs) наш список заданий launchd, пустые NanoLaunchDaemons и LaunchAngels, xpc без launchd.plist и только наш аудиоплагин, даёт /Applications 32 приложения, которые iOS ставит при первом запуске, и просит ядро сделать том корнем (sysctl kern.hakios_switch_root, внутри — vfs_switch_root);
  • загрузочный том из памяти уезжает в /private/preboot тома, а init монтирует на его пустые Cryptexes/OS и Cryptexes/App криптексы — контейнеры 1 и 2 раздела darwin (/dev/ufs_darwin,c=1, c=2) или, если образ в памяти их несёт, его (/dev/md0,c=1, c=2); туда ведут /System/Cryptexes/OS и App самого тома, как на iPhone, где их прививает graftdmg; кроме корня, ядро принимает общий кеш только оттуда; маленький образ на загрузочном томе привязывается обратно на /redwood.

Для этого в драйвере APFS появились синтетические каталоги (sysctl kern.apfs_synthetic=ПУТЬ), как synthetic.conf у macOS для запечатанного тома: пустые каталоги для точек монтирования (/redwood) или ПУТЬ=ЦЕЛЬ — другое имя для каталога того же тома; так приложения из staged_system_apps и стоят в /Applications (точками монтирования их тот, кто регистрирует системные приложения, пропускал). Ещё: lookup APFS находит существующее при любой операции и отказывает (EROFS) только в создании нового имени, — иначе mkdir существующего каталога давал EROFS вместо EEXIST, и xpcproxy отказывал всем службам; ioctl отвечает ENOTTY; bindfs доступен процессу 1 без entitlement.

Проверено: SystemVersion.plist, usr/lib/dyld, usr/sbin/mediaserverd и все 32 файла /usr/sbin прямо с корня — через read(2) и через отображение, каждый как на компьютере. Загрузка с образа в памяти работает как раньше.

Звук при корне на флеше. Прошивка звукового процессора и настройки усилителей лежат в /fw загрузочного тома flashboot.apfs, как в образе системы (ядро читает прошивку, как только есть корень), а flash_root привязывает этот /fw к синтетическому /fw тома до смены корня — для того, что читается после. На телефоне (root_apfs=ufs_darwin ufs_native=1 adsp=1 sound=4, RC_JOBS=rc.jobs.flashsound): «[ ok ] the firmware on /fw», в записи (rec_sound) тон sound_test — 3,00 с, 1000 Гц слева и 440 Гц справа, за ним системные звуки, усилители включаются и выключаются вокруг каждого. В том же сеансе — чтение через отображение (подкачка страниц): /usr/libexec, 338 файлов, 209 950 173 байта, через read(2) по 64 КБ — 948 мс (211 МБ/с), через отображение — 1518 мс (132 МБ/с), сумма у обоих 978ed0d4dda1a256, как на компьютере; за всю загрузку iOS с флеша — 144 499 поисков имён, все по хешу (vol_ls sysctl kern.apfs_stats).

Несколько запросов к флешу сразу — через UEFI нельзя. Block I/O 2 у драйвера Qualcomm есть (ufs_probe=1 загрузчику), и вызовы возвращаются сразу (восемь случайных чтений по 4 КБ — за 160 мкс), но завершаются все восемь только через 61,5 мс против 1,0 мс подряд через Block I/O: асинхронные чтения драйвер доводит по таймеру UEFI, а тот на ядре 0, пока работает ядро порта, и не тикает. Значит, один синхронный запрос за раз; больше — только со своим драйвером контроллера UFS в ядре. Упреждающее чтение в этих рамках проверено и проигрывает: из 154 275 запросов обхода тома 62 310 продолжают предыдущий, но между ними ядру нечего делать, а ненужное упреждение задерживает следующий запрос (обход без хеша 35 132 → 36 172 мс); оно убрано.

Свой драйвер контроллера UFS: свой список запросов на время сеанса

ufs_native=1 (drivers/storage/hakios.ufshc.c, со стороны загрузчика — hc_* в xnuloader/loader.c) читает флеш самим ядром, многими запросами сразу, а не по одному через ядро 0. Контроллер остаётся у UEFI: ни сброса, ни старта линка, ни UIC-команд, ни прерываний, агрегации, тактов, питания; список задач управления не трогается, IS и UTRLCNR только читаются.

Почему свой список. Проба (ufs_native=2, ни одной команды): контроллер на 0x1d84000, UFSHCI 3.0, 32 слота, 64-битные адреса, IE=0, без агрегации и автоматического hibern8 — но список UEFI на 0xccf96000 рассчитан на один запрос: дескриптор команды слота 0 лежит на 0xccf96100, внутри килобайта, что заняли бы остальные 31 («слоты» 8–14 — UPIU команды UEFI, его ответ и таблица PRD прямо в буфер загрузчика, 1–7 и 15–17 — прочие данные драйвера). Поэтому на время сеанса ядро ставит в контроллер свой список:

  • список (32 слота) и дескрипторы команд — в памяти загрузчика (hc_area, предлагается в его странице, версия 3, вместе с адресом списка UEFI), так что возврат списка UEFI не зависит от памяти ядра; буферы данных — страницы ядра;
  • переключение — только когда UTRLDBR == 0, под замком почтового ящика: UTRLRSR=0, UTRLBA/UTRLBAU — свой список, UTRLRSR=1, всё прочитано обратно и записано в журнал со старым и новым адресом; пока свой список стоит, ядро не просит загрузчик ни о чём;
  • обратно — в loader_return (и при xl stop, и при панике): дождаться своих запросов, остановить список, записать адрес UEFI, запустить, прочитать обратно. Загрузчик запоминает адрес UEFI до первого сеанса и проверяет его на каждом пути возврата (ядро вернулось, зависло, не стартовало, перед сбросом), а если там не список UEFI, забирает контроллер сам: говорит ядру ничего больше не слать, ждёт 50 мс, ждёт пустого UTRLDBR (1 с), при необходимости UTRLCLR на слотах ядра, останавливает список, пишет адрес, запускает, читает обратно. Читать для ядра, писать журнал в logdump и опрашивать зарядку он соглашается только при списке UEFI в контроллере.

Только чтение по построению. Команда собирается в своём буфере и попадает в слот, только если её пропускает белый список; затем содержимое слота читается обратно, должно совпасть и снова пройти список — и только тогда звонок, с выключенными прерываниями, после проверки, что в контроллере свой список, он запущен и загрузчик его не забирает. Список проверяет у каждой команды: операцию SCSI (READ(10)/(16), TEST UNIT READY, REQUEST SENSE, INQUIRY, READ CAPACITY — никаких QUERY, задач управления, NOP), направление данных в дескрипторе (от устройства или нет данных — никогда к устройству), флаги UPIU (никогда W), логический блок (никаких W-LUN: REPORT LUNS, BOOT, RPMB, устройство; и только разрешённые сейчас — обычные LUN 0–5, сопоставленные с блоками загрузчика) и совпадение длины чтения. На старте он проверяется на 35 придуманных командах (WRITE(10)/(16), UNMAP, READ с направлением «к устройству», READ с флагом W, все W-LUN, LUN 3, FORMAT, START STOP, SYNCHRONIZE CACHE, WRITE BUFFER, SECURITY PROTOCOL, MODE SELECT, QUERY, задачи управления, NOP — отказ; чтения — проходят). В каждом слоте всегда разрешённая команда (TEST UNIT READY, пока слот не занят чтением). После каждого переключения, до первого чтения, — доказательство: TEST UNIT READY в слоте 0 своего списка должен вернуться в свой список (если бы контроллер не заметил смены, он выполнил бы слот 0 UEFI — а там, как проверяется заранее, тоже чтение: последнее, что загрузчик делает с флешем перед сеансом, — читает обратно записанный журнал).

Как проверено (на телефоне, по шагам; после каждого шага — отпечаток флеша tools/flash-fingerprint.sh: обзор загрузчика с CRC всех шести GPT, хешами разделов с образами, метаданными super и суперблоком userdata, и из Android — концы GPT всех шести LUN, sgdisk --android-dump, первый МБ userdata, хеш образа darwin, число файлов /sdcard; всё одинаково с исходным, кроме трёх новых журналов приложений Android в /sdcard):

  • холостые прогоны (ufs_native=3, ни одной команды) дважды: список туда-обратно и снова туда, назад — ядром в конце сеанса и (ufs_native_leave=1) самим загрузчиком; пока стоял свой список, загрузчик дважды отказался писать журнал; после — список UEFI, журнал записан и прочитан обратно тем же;
  • ufs_native_check=1: чтения своим драйвером против почтового ящика байт в байт — основные и резервные GPT всех шести LUN (unit k = LUN k по совпадению заголовка GPT), xbl, aop, tz, hyp, modem (469 МБ), bluetooth, abl, dsp в обоих слотах целиком (FNV по длине образа — те же, что считает загрузчик), первые 8 МБ super, 32 МБ, 256 блоков и 32 невыровненных куска darwin: 377 кусков, 1 176 564 110 байт, различий 0, ошибок 0, до 31 запроса в полёте, 61 переключение и 61 доказательство;
  • плохие концовки посреди чтения тома: xl stop (в момент входа в loader_return в полёте был один запрос — он дождан) и паника нарочно (return_panic=1): список UEFI на месте, журнал в logdump; после паники обычный прогон через почтовый ящик (ufs_bench=1) — как всегда;
  • весь том с /dev/ufs_darwin (apfs_check=6; apfs_walkers=N — N потоков обхода с общим стеком каталогов, суммы построчных хешей складываются, так что итог тот же): 318 098 записей, 8 285 771 220 байт, сумма ceb8a53c349a011f, ошибок 0 в каждом прогоне;
путь потоков обхода время в полёте не больше
почтовый ящик 1 46 171 мс (раньше 44 814) 1
почтовый ящик 4 40 313 мс 1
свой драйвер 1 42 644 мс 1
свой драйвер 4 22 265 мс 4
свой драйвер 8 23 351 мс 8

Так было после первого шага: сам по себе драйвер с одним читателем даёт около 8 %, выигрыш — когда запросов много сразу, но 8 потоков не быстрее 4. Почему — ниже;

  • обычная загрузка с корнем на флеше (root_apfs=ufs_darwin ufs=2 ufs_native=1 …, образ — тогда ещё flashboot.packfs): домашний экран, /dev/ufs_darwin on / (apfs, local, read-only), 32 файла /usr/sbin через read(2) и через отображение — сумма 3095b9e298a4df17, как раньше; своим драйвером 42 690 чтений, 965 МБ, ошибок 0, до 7 в полёте, отказов 0, к загрузчику ни одного запроса;
  • после всего Android загружается как обычно (sys.boot_completed=1, /data смонтирован), журнал последнего прогона в logdump читается из Android целиком.

Восемь быстрее четырёх. Куда уходило время (по потокам обхода — на процессоре, поиск, чтение, хеш, ожидание каталога; у замка тома — ожидания и удержания по длине; у драйвера — опросы UTRLDBR, ожидание слота, сколько в полёте): не драйвер (ожиданий слота 0, замок слотов — 0,1 с) и не раздача каталогов (потоки ждали работу 0,1 с), а единственный замок тома APFS. Его держали 12 с из 22,7 с обхода, и 9,2 с из них — чтение метаданных с устройства под замком; с восемью потоками данные в полёте замедляли и эти чтения (147 → 223 мкс), а все остальные стояли в очереди: ожидание замка 77–105 с на всех, 14–28 тыс. ожиданий дольше 1 мс (до 58 мс). Ещё каждый ждущий флеш крутился на UTRLDBR до 5 мс, и восемь потоков занимали все 7 процессоров. Два изменения, по одному, каждое — с суммой ceb8a53c349a011f и 0 ошибок:

  1. ждущий смотрит на свой бит 40 мкс, потом спит; один поток-опросчик будит тех, чьи слоты готовы. Ожиданий замка дольше 1 мс стало вдвое меньше, самое долгое — 10 мс вместо 58, но время то же;
  2. блок метаданных, которого нет в кеше, читается без замка тома — в свою память, и вставляется, когда замок снова взят (если его не вставил кто-то раньше). Безопасно по построению, а не по разбору всех мест: слот кеша, в который приходит новый блок, получает новую память, а старая уходит в список «отставленных» с номером эпохи и освобождается, только когда не осталось операции, начатой раньше (apfs_lock/apfs_unlock отмечают начало и конец операций, apfs_lib отпускает замок посередине через apfs_unlock_inner). Места, которые что-то вычисляли через чтение дерева и рассчитывали, что замок не отпускается, теперь так не делают: состояние сжатого файла собирается в копии и ставится, только если его никто не поставил раньше, опережающее чтение занимает свой буфер до планирования, синтетический каталог проверяется ещё раз, том при монтировании открывается под замком. Сначала проверено на компьютере (tools/apfs/apfs-tool из той же библиотеки, замок — pthread mutex, который падает, если его отпускают не взявши): обход образа тома 8 потоками — ceb8a53c349a011f; под ThreadSanitizer ни одного сообщения (весь том, только метаданные, и новый cfrace — сжатые файлы тома, каждый 8 потоками сразу через одно общее состояние, кеш пустеет каждый круг, 0 расхождений); под AddressSanitizer утечек нет, а прежняя сборка состояния в cfrace теряет 120 выделений.
потоков обхода после шага 1, с хешем после шага 2, с хешем после шага 2, без хеша
1 43 484 мс 43 726 мс 32 950 мс
2 29 416 мс 27 779 мс 23 155 мс
4 22 740 мс 19 452 мс 18 585 мс
8 22 673 мс 18 094 мс 17 929 мс
16 — 18 537 мс —

Ожидание замка тома при 8 потоках — 5 с вместо 77, удержание 3,4 с вместо 12. Дальше упирается в сам флеш: потоки на процессоре 42 с из 145, чтение данных ждёт в среднем 534 мкс (179 при одном потоке), 16 потоков не быстрее 8. Что флеш даёт при N запросах в полёте (ufs_native_bench=1, случайные чтения из darwin), МБ/с:

размер 1 2 4 8 16 31
4 КБ 83 140 201 247 261 267
32 КБ 288 495 739 858 883 862
128 КБ 511 862 1021 1047 1018 1027
512 КБ 786 1094 1097 1094 1086 1089

Мелкие и средние чтения перестают прибавлять между 8 и 16 в полёте (около 65 тыс. чтений в секунду по 4 КБ), крупные упираются в 1,1 ГБ/с — предел UFS 2.2 по двум линиям — уже при 2–4.

Остальное.

  • UTRLCNR (уведомления о завершении) теперь возвращается: при возврате списка — ядром, а после зависания — загрузчиком (ядро пишет в странице, каким он был) — пишутся единицы ровно в те биты, что выставили запросы ядра; читается обратно 1, как было. IS не меняется вовсе.
  • LUN 1–5 тоже: ufs_native=1 сопоставляет все блоки (unit k — LUN k, только если заголовок GPT читается обоими путями одинаково), чтения идут в LUN 0–5, только обычные, особые по-прежнему отказываются. Проверено: ufs_native_check=1 — GPT всех шести, разделы с образами целиком, super, darwin — 377 кусков, 1 176 564 110 байт, различий 0; ufs_check=2 (проверка сразу и назад) обычным путём — хеши 16 разделов с образами, в том числе xbl на LUN 1/2, как у загрузчика.
  • Настоящее зависание: ufs_native_hang=N (проверка) — после N чтений поток, только что позвонивший в слот, останавливает все процессоры с выключенными прерываниями. На телефоне при 8 потоках обхода: «slot 2 just rung, doorbell de, 6 reads in flight»; через 50 с загрузчик сказал «THE KERNEL HUNG», нашёл в контроллере список ядра, очистил биты завершения, вернул список UEFI (run 1, doorbell 0) и записал журнал в logdump, прочитан обратно тем же; потом xl перезапустил телефон.
  • Отпечаток флеша (tools/flash-fingerprint.sh) после каждого шага — тот же, кроме новых файлов самого Android в /sdcard (журналы приложений, видео магазина Xiaomi; список файлов теперь тоже сохраняется).

Загрузчик после каждой записи журнала в logdump читает его обратно и пишет в копию для компьютера log: N bytes written to logdump (0), read back the same (0).

Замок тома при обычной загрузке с флеша

Как мерилось. kern.apfs_stats теперь разбирает единственный замок тома APFS: по тому, кто его берёт (функция: взятий, повторных взятий из apfs_lib, ожидание и удержание — всего, самое долгое, сколько дольше 1 мс), сколько других ждало в момент взятия, удержания по длине, ожидания по базовому приоритету ждущего, у ожиданий дольше 1 мс — куда ушло время (заблокирован, замок не дают / в очереди, готов к работе без процессора / на процессоре; по таймеру готовности потока и recount) и на каком процессоре кончилось, и по процессам — ожидания, вызовы read(2) и pagein, байты и время в них. Список заданий userland/rc.jobs.flashlock делит прогон на три отрезка: загрузка (до 25 с после начала списка — нашего ничего, чтобы не делить старт SpringBoard), следующие 30 с (iOS сама по себе; vol_ls cpuload 30 смотрит загрузку процессоров) и наши проходы. Счётчики после первого отрезка обнуляются (vol_ls zero kern.apfs_stats), результаты копятся в /private/var/results (>> у init) и показываются в конце (vol_ls cat): середину журнала ядра вытесняют строки launchd.

FLASHBOOT=1 GL=1 REST=1 RC_JOBS=rc.jobs.flashlock userland/build.sh
./xl ramdisk userland/flashboot.apfs
XL_CMDLINE="root_apfs=ufs_darwin ufs=2 ufs_native=1 bootcpu=7 cpus=8 cpufreq=1 touchscreen=1 gpu=1 adsp=1" XL_SECS=120 ./xl run

Числа этого раздела и следующего сняты ещё с образами packfs, с battery=1 и без gpu=1 (программный рендер): сравнивать их между собой можно, с нынешними — нет. Те же замеры с gpu=1 adsp=1 — в «Только APFS, как на iPhone».

Прежние «~20 с ожидания за загрузку» — не загрузка: apfs_mmap_test обнуляет счётчики перед каждым проходом, и то число было за время после загрузки. Сама загрузка ждёт замок 0,14–0,43 с — в сумме по всем потокам. После загрузки (стартуют виджеты и постеры экрана блокировки: WeatherWidget читает 136 МБ через read(2), CollectionsPoster, PosterBoard…) за 30 с — от 3,7 до 44,6 с в сумме (до изменений — 69 с), и при одном и том же ядре разброс в разы (3,7–20,1 с). Удержания короткие (93–99 % — до 100 мкс, самые долгие — 3,5–24 мс, когда держащего вытесняют), а у ожиданий дольше 1 мс 75–99 % времени — очередь за процессором, не замок: поток, уступивший процессор на замке, потом ждёт его в общей очереди, а окно после загрузки упирается в процессоры (у фоновых потоков такие ожидания доходят до секунд). Поэтому меры — меньше взятий. Пять изменений, по одному, каждое с загрузкой до домашнего экрана и суммами проходов:

взятий за загрузку из них read / pagein / lookup / readlink / inactive взятий за 30 с после SpringBoard
до 315 415 55 220 / 4 594 / 93 845 / 15 655 / 34 969 263 698 6,32–6,59 с
1. цель ссылки хранится у узла 301 310 55 819 / 4 592 / 93 857 / 19 / 35 469 195 333 6,06 с
2. «имени нет» — в кеш имён, vnop_lookup сначала спрашивает его 268 085 55 414 / 4 592 / 62 230 / 19 / 35 283 149 912 6,32 с
3. inactive без сжатого состояния — без замка 251 894 56 343 / 4 602 / 62 899 / 19 / 15 283 141 645 6,13 с
4. учёт read(2) и pagein — без замка 214 085–216 629 26 302 / 2 297 / 62 239 / 19 / 14 926 111 575–115 900 5,97–6,29 с
5. найденный поиском vnode — в кеш имён 213 935–216 624 26 277 / 2 297 / 62 178 / 19 / 15 030 113 726–118 674 6,18–6,61 с
  • 1: readlink читает цель один раз под замком и дальше без него; 2: пространство имён namei отдаёт отрицательные записи файловой системе, так что без cache_lookup в vnop_lookup их никто не спрашивал (после загрузки 36 тыс. ответов «нет» за 30 с); 3: у большинства файлов отдавать нечего; 4: буферы read(2) — со своим спин-замком, счётчики атомарные, замок берётся снова, только если за uiomove следует ещё круг, pagein не берёт его ради счётчиков; 5: около 700 на загрузку, заметного действия нет — до драйвера доходят поиски, которые делают vnode впервые (25,8 тыс. новых на 6,8 тыс. найденных).
  • Ожидание замка за 30 с после загрузки по прогонам: до изменений 69 с; с 1–3 — 7,5–44,6 с; с 4 — 3,7 / 20,1 / 5,6 с; с 5 — 5,9 / 15,5 / 13,6 / 9,5 / 14,2 с. Из этого «заблокирован» — 0,04–2,2 с (один раз 5,1), остальное — очередь.
  • SpringBoard с флеша — 5,97–6,61 с во всех 20 прогонах этой серии, ни одно изменение его заметно не сдвинуло. С образа в памяти тем же ядром и теми же параметрами — 4,45 / 4,47 / 4,49 с. Разница — не замок, а чтения с флеша; кто ждёт их за загрузку: lsd — 1 333 мс в 17 734 мелких read(2) (50,8 МБ, ~75 мкс на вызов) при 17 мс ожидания замка, UserEventAgent — 466 мс в read(2) и 254 мс в pagein, launchd — 458 мс, nanoregistryd — 450 мс в pagein. Это и есть следующая цель для времени до SpringBoard.

Проходы после 30 с покоя, во всех 17 прогонах с этим списком одинаково (/usr/libexec: 338 файлов, 209 950 173 байта, сумма 978ed0d4dda1a256; /usr/sbin: 32 файла, сумма 3095b9e298a4df17):

read(2), куски по 64 КБ через отображение
/usr/sbin 188–190 мс (253 МБ/с) 208–212 мс (228 МБ/с)
/usr/libexec, первый проход 847–854 мс (235 МБ/с) 850–877 мс (229–235 МБ/с)
/usr/libexec, второй (тёплый) 795–802 мс (250 МБ/с) 302–306 мс (655–663 МБ/с)

Второй проход через отображение берёт страницы из кеша страниц; read(2) тогда кешем страниц не пользовался и каждый раз читал с флеша и распаковывал заново — поэтому почти не быстрее. Теперь пользуется — следующий раздел.

Проверено с последним ядром: домашний экран (снимок с телефона), суммы проходов в каждом прогоне, обход всего тома 8 потоками — 318 098 записей, ceb8a53c349a011f, 0 ошибок, 18,1 с (213 547 чтений своим драйвером, ошибок 0); отпечаток флеша после серии тот же, что после прошлой (обзор загрузчика, список файлов /sdcard, данные Android).

Побочное: у медленных ядер (4–6) счётчик простоя (PROCESSOR_CPU_LOAD_INFO) идёт примерно в 56 раз быстрее времени, так что их «99 % простоя» ничего не значит — IPI к ним идут (22–30 тыс. за 30 с на каждое), и половина долгих ожиданий кончается на них.

read(2) через кеш страниц

Раньше read(2) шёл мимо кеша страниц: каждое чтение, тёплое или нет, читало файл с флеша и распаковывало заново в буфер драйвера; lsd за загрузку делает 17,7 тыс. мелких read(2), и все шли на флеш. Теперь — как decmpfs читает сжатый файл HFS (decmpfs_read_compressed): что есть в кеше, копируется оттуда (cluster_copy_ubc_data); для остального страницы файла берутся в UPL, ещё не действительные заполняются apfs_read_file — тем же, что у pagein, — запрошенное копируется (cluster_copy_upl_data), и UPL фиксируется: страницы остаются и для следующего read(2), и для отображения того же файла — это те же страницы. Для простых и сжатых файлов одинаково, не меньше 64 КБ с выравниванием (сжатый файл хранится кусками по 64 КБ), а читающему по порядку — вдвое больше вперёд с каждым разом, до 1 МБ. apfs_ubc_read=0 оставляет старый путь, чтобы сравнивать одним ядром.

Список userland/rc.jobs.flashcache: загрузка, 30 с покоя, потом для /usr/sbin и /usr/libexec — выбросить страницы из кеша (apfs_mmap_test drop: msync(MS_INVALIDATE) на общем отображении каждого файла), read(2) холодный и тёплый, отображение поверх того, что оставил read(2), снова выбросить, отображение холодное и тёплое, read(2) поверх страниц отображения. Каждый проход — со своей суммой (3095b9e298a4df17, 978ed0d4dda1a256), и все они сошлись: страницы, заполненные read(2), верны для отображения, и наоборот. kern.hakios_memory — строка ядра о памяти.

старый путь (apfs_ubc_read=0) через кеш страниц
SpringBoard готов 6,29 с (раньше 5,97–6,61 в 20 прогонах) 5,43 / 5,44 / 5,72 / 5,56 с
read(2) за загрузку, всего 2 831 мс 1 036–1 117 мс
из них lsd 1 343 мс 716–736 мс
read(2) за 30 с после 22 261 мс, замок взят 111 тыс. раз 1 171–1 897 мс, 47–49 тыс. раз
/usr/sbin, read(2) холодный / тёплый 222 / 194 мс 205–208 / 71 мс
/usr/sbin, отображение холодное / тёплое 213 / 73 мс 214–215 / 73 мс
/usr/libexec, read(2) холодный / тёплый 836 / 796 мс 888–919 / 307–309 мс
/usr/libexec, отображение поверх read(2) 926 мс 437–761 мс
/usr/libexec, read(2) поверх отображения 1 129 мс 397–421 мс
память через 25 с: свободно / файлы / доступно 1 869 / 986 / 2 856 МБ 1 607–1 642 / 1 161–1 190 / 2 797–2 808 МБ
память в конце: свободно / доступно 278 / 2 029 МБ 162–187 / 2 024–2 043 МБ

Холодный read(2) на 6 % медленнее: страницы кеша надо завести (под ядром это 600 мс против 543 у буфера, который не меняется). Холодное отображение /usr/libexec в этом порядке проходов — 1,1–1,6 с при обоих путях: свободной памяти к этому месту 0,2–0,5 ГБ, и страницы приходится отбирать. Доступной памяти (свободной и файловой) столько же, кеш её не съедает — файловые страницы отдаются по первому требованию; в QEMU после обхода всех 8,29 ГБ тома через read(2) строка памяти вернулась к 1,9 ГБ свободных.

Проверено: обход всего тома на телефоне — через тот же read(2) — 318 098 записей, ceb8a53c349a011f, 0 ошибок, 18,0 с; в QEMU тоже; домашний экран; отпечаток флеша после сеанса тот же.

Только APFS, как на iPhone

Раньше образы порта были в своём формате packfs (mkpackfs.py: только чтение, сжатые куски по 64 КБ, целиком в ОЗУ). Теперь packfs нет ни в ядре (bsd/miscfs/packfs, его строки в vfs_conf.c и bsd/conf/files, параметр конфигурации, декодер Zstandard), ни в сборке (mkpackfs.py, tools/mk-zstddec.sh): каждый образ — контейнер APFS, и все их читает тот же драйвер APFS ядра, что и том iOS на флеше. Раскладка — как у iPhone:

iPhone, iOS 16.5 порт
системный том System: запечатанный том на флеше он же из раздела darwin (root_apfs=ufs_darwin, только чтение) или system.apfs в памяти — отобранная часть iOS 16.5
криптекс OS (общий кеш dyld) образ Cryptex1,SystemOS на томе Preboot, привит (graftdmg) на /private/preboot/Cryptexes/OS, куда ведёт /System/Cryptexes/OS образ Apple как есть (2,5 ГБ) — контейнер 1 раздела darwin на флеше, на том же месте (или второй контейнер flashboot.apfs: пересобранный, 1 ГБ); в system.apfs — каталог /System/Cryptexes/OS
криптекс App (службы Safari) Cryptex1,AppOS на /private/preboot/Cryptexes/App, куда ведёт /System/Cryptexes/App образ Apple байт в байт (10 МБ) — контейнер 2 раздела darwin, на том же месте (или третий контейнер flashboot.apfs); в system.apfs — каталог /System/Cryptexes/App
Preboot том Preboot загрузочный том flashboot.apfs (stage0, прошивки звукового процессора, точки монтирования), после смены корня — на /private/preboot
Data (/private/var) том Data ramfs; с var_store=1 его содержимое переживает перезагрузку — архивом в свободном месте раздела darwin (см. «Том данных между загрузками»)
— — root.apfs на /redwood: наш init, программы, библиотеки-вставки, задания launchd, прошивки GPU и Wi-Fi
образ контейнеров размер был packfs
root.apfs 1 13,4 МБ 31,5 МБ
system.apfs 1 2372 МБ, 62 299 записей, заливается за 77 с 2170 МБ (Zstandard) или 2689 МБ (LZ4)
flashboot.apfs 1 (CRYPTEXES=flash) или 3 21 МБ, заливается за 1 с; с криптексами — 994 МБ, 33 с 881 МБ, с криптексом OS, без App

Сборка. tools/apfs/mkapfs.py -o ОБРАЗ [-z [--plain КАТАЛОГ]] ТОМ=КАТАЛОГ[,manifest=ФАЙЛ] … пишет контейнер одной транзакцией; раскладка объектов (контрольная точка, менеджер пространства, карта объектов, деревья тома) — как у mkapfs из apfsprogs. -z — zlib, как Apple сжимает корень: маленький файл целиком в атрибуте com.apple.decmpfs (тип 3), остальные кусками по 64 КБ в ресурсной вилке (тип 4) — там, где это экономит блоки; --plain fw оставляет прошивки как есть, и драйверы ядра берут их прямо из памяти диска (drivers/storage/hakios.image.c; то, что лежит не одним куском, копируется). Манифест (sysimage/system.manifest) даёт владельцев и права. Хеш имён каталога — CRC-32C по NFD, как у Apple. Жёстких ссылок нет: имя — отдельный файл.

Проверка. Каждый контейнер, который делает сборка (CHECK=1), проверяют два читателя, которые не он: apfsck -cw из apfsprogs должен найти его чистым (контрольные суммы, деревья, битовые карты пространства против каждого занятого блока, счётчики ссылок, экстенты), а библиотека APFS ядра, собранная программой для компьютера (apfs-tool census), — прочитать каждый том ровно таким, каким был каталог: путь, вид, права, владелец, размер и хеш содержимого каждой записи (tools/apfs/check-image.sh). Сейчас: root.apfs — 476 записей, system.apfs — 62 299, загрузочный том — 45, криптекс OS — 2086, всё чисто и всё на месте. Образы Apple (криптексы OS — в QEMU, App — и на телефоне) читаются ядром как есть: это запечатанные тома — узлы деревьев без заголовка, SHA-256 каждого узла в родителе (ядро сверяет), экстенты в отдельном дереве fext, сжатие LZBITMAP (apfs_zbitmap.c, libzbitmap); криптекс App библиотека ядра (apfs-tool) и 7-Zip с unzbm читают одинаково, все 1047 записей.

Ядро.

  • несколько контейнеров подряд на одном устройстве: ядро проходит их по nx_block_count × nx_block_size суперблока; монтирование — УСТРОЙСТВО[,c=N][,v=M] (контейнер и том), корень — apfs_root_c=/apfs_root_v=; второе монтирование уже открытого устройства (корень с md0 и криптексы с md0,c=1, c=2) делит с первым его vnode;
  • общий кеш dyld ядро принимает (кроме корня) с тома, смонтированного в /private/preboot/Cryptexes — там, куда iOS прививает криптекс OS (bsd/vm/vm_unix.c);
  • drivers/storage/hakios.image.c: драйверы GPU, звукового процессора и Wi-Fi находят свои прошивки в образах APFS дисков в памяти.

Что проверено на телефоне (gpu=1 adsp=1, образ GL=1; с корнем на флеше root_apfs=ufs_darwin ufs=2 ufs_native=1, rc.jobs.flashcheck):

  • корень на флеше (прогоны 165449 и 172245 — криптексы в памяти, 175638 и 175933 — на флеше, см. «Криптексы на флеше»): все проверки init, том iOS — корень, загрузочный том на /private/preboot, оба криптекса на своих местах (mount выше); Wi-Fi (wifi=1) — адрес по DHCP через 12,4–12,9 с, имя, две страницы по HTTPS (200); звук (sound=4) — тон, системные звуки, фанфары; батарея — 100 % от звукового процессора; домашний экран рисует GPU; Safari открывает example.com, а его службы SafariBookmarksSyncAgent и com.apple.Safari.History запускаются из /System/Cryptexes/App (раньше этих заданий не было вовсе: их программ не было в системе);
  • корень в памяти (165856; 172713 — ещё и с wifi=1 sound=4): домашний экран, батарея, Wi-Fi, звук, Safari со службами из /System/Cryptexes/App — каталога образа системы;
  • обход всего тома iOS с флеша (170142): 318 098 записей, 8 285 771 220 байт, ceb8a53c349a011f, ошибок 0, 17,2 с;
  • отпечаток флеша после каждого сеанса — тот же, что до (на флеш ничего не пишется).

Цена — память. Одни и те же сеансы одним ядром (оно ещё знало packfs), образы packfs и APFS (gpu=1 adsp=1, без Wi-Fi и звука):

packfs APFS
корень в памяти: SpringBoard готов 4,61 с 4,53 с (и 4,50 / 4,59 в других прогонах)
корень в памяти: доступно памяти 758–906 МБ 618–765 МБ
корень в памяти: wired 2769–2836 МБ 3041–3108 МБ
корень на флеше: SpringBoard готов 5,85 с 6,28 с (и 6,52 / 5,89)
корень на флеше: доступно памяти 1760–1930 МБ 1611–1744 МБ
корень на флеше: wired 1615–1651 МБ 1759–1785 МБ

Около 200 МБ — сам образ: zlib жмёт хуже Zstandard (2372 МБ против 2170), остальное — кеш метаданных каждого смонтированного контейнера (2048 блоков, 8 МБ). Время до SpringBoard — по одному прогону на образ, а разброс между прогонами доходит до 0,6 с (5,89–6,52 с у APFS с флеша).

Числа SpringBoard в разделах «Замок тома при обычной загрузке с флеша» и «read(2) через кеш страниц» сняты ещё с battery=1 и без gpu=1: battery=1 — батарея через UEFI-драйверы загрузчика, и при нём ядро не спрашивает батарею у звукового процессора (adsp: battmgr: not with battery=1), так что iOS показывала не тот заряд; без gpu=1 рисует программный рендер. Все прогоны этого раздела — gpu=1 adsp=1.

Дальше:

  • кеш метаданных поменьше для дисков в памяти — вернуть часть памяти, когда корень в памяти;
  • префикс Sileo (/var/jb) живёт в ветке camera; в этой ветке его нет, и с APFS он не проверялся.

Криптексы на флеше

На iPhone криптексы — файлы-образы на томе Preboot, которые iOS прививает (graftdmg) на /private/preboot/Cryptexes/OS и App. У порта те же образы — байт в байт, их SHA-384 те же, что в BuildManifest IPSW, — лежат в разделе darwin сразу за томом iOS, контейнерами подряд, и ядро монтирует их прямо с раздела тем же способом, каким находит второй и третий контейнер образа в памяти (/dev/ufs_darwin,c=1, c=2). В памяти остаётся только загрузочный том.

блоки darwin (по 4 КБ) с байта что sha256
0–1 374 207 0 том iOS (rootfs.apfs), не тронут 9f430a58…
1 374 208–2 039 807 5 628 755 968 криптекс OS: 096-04666-078.dmg (Cryptex1,SystemOS), том SydneyF20F66.D22D221SystemCryptex 2279bf44…
2 039 808–2 042 367 8 355 053 568 криптекс App: 096-03536-083.dmg (Cryptex1,AppOS), том AppCryptex 6b9d1f09…
2 042 368–5 242 874 8 365 539 328 нули: контейнера 3 нет, ядро отвечает EINVAL; с 2 097 152 здесь лежит архив тома данных (см. «Том данных между загрузками») — единственное, куда порт пишет

Как записано. Только из Android и только tools/darwin-write.py. Перед каждой записью он читает с телефона заново и сверяет с константами: GPT логического устройства 0 (CRC заголовка и таблицы, запись 34 «darwin», её GUID типа и уникальный, LBA 25374720..30617594, ничья другая запись не заходит на неё); что sda — логическое устройство 0 контроллера UFS; что /dev/block/by-name/darwin — sda34 с теми же номером, именем, началом и размером в sysfs и у blockdev. Пишет только dd в /dev/block/by-name/darwin (имя проверяется ещё раз в той же оболочке прямо перед dd), только в блоки внутри darwin и после тома iOS, с копии образа на телефоне, чья sha256 проверена, и читает записанное обратно. Без DARWIN_WRITE=yes не пишет ничего, plan показывает, что было бы сделано, selftest проверяет отказы (диапазоны, номера, GPT с изменённым полем), каждая попытка — в logs/darwin-write.log. Порядок был такой: отпечаток флеша; check и selftest; четыре запроса за пределы (в том iOS, на блок за концом раздела, нули в суперблок тома, криптекс OS, не помещающийся до конца) — все отказаны до обращения к телефону; пробная запись 1 МБ в середину свободного места, чтение обратно (и после сброса кешей), возврат нулей; оба криптекса (2,5 ГБ за 11,3 с) с чтением обратно; отпечаток флеша. Всё за пределами darwin — как было, том iOS — 9f430a58…. Ядро на флеш по-прежнему не пишет ничего.

Что это дало. Корень на флеше, gpu=1 adsp=1 sound=4 wifi=1, rc.jobs.flashcheck, тот же root.apfs:

криптексы в памяти (flashboot.apfs, 994 МБ) криптексы на флеше (21 МБ)
прогоны 172245, 174603 175638, 175933
SpringBoard готов 5,89 / 6,50 с 6,08 / 6,28 с
доступно памяти 1312–1378 МБ 2239–2310 МБ
wired 1855–1899 МБ 892–959 МБ
Wi-Fi: адрес по DHCP 12,2–12,4 с 11,9–12,2 с

Около 930 МБ памяти вернулось системе; время до SpringBoard — в пределах разброса. В обоих случаях одинаково: все проверки init, звук (тон, системные звуки), батарея 100 % от звукового процессора, две страницы по HTTPS, Safari открывает example.com, а SafariBookmarksSyncAgent и com.apple.Safari.History работают из /System/Cryptexes/App.

Обход ядром прямо с флеша (apfs_check=6 apfs_dev=ufs_darwin[,c=N] apfs_mnt=/flash apfs_walkers=8), против того же на компьютере (verify.py sum для тома iOS, apfs-tool census для образов Apple), ошибок 0:

записей сумма с флеша на компьютере время
том iOS (контейнер 0) 318 098 ceb8a53c349a011f ceb8a53c349a011f 17,6 с
криптекс OS (контейнер 1) 2093 25a8c994a06a8b7b 25a8c994a06a8b7b 10,8 с
криптекс App (контейнер 2) 1047 df01e45a334513ee df01e45a334513ee 0,05 с

Отпечаток флеша после всех этих загрузок (обзор загрузчика и сторона Android) — тот же, что до записи.

Том данных между загрузками

/private/var — файловая система в памяти (bsd/miscfs/ramfs): init монтирует её пустой при каждой загрузке и создаёт в ней каталоги шаблона, который iOS носит в своём образе (sysimage/mkvarskel.py → sysimage/var.skel, init.c, mount_writable). Поэтому ничто, что iOS туда записала, не переживало ту самую загрузку, в которую было записано: ни раскладка домашнего экрана (/var/mobile/Library/SpringBoard/IconState.plist), ни данные приложений (/var/mobile/Containers/Data/Application/…), ни снимки окон, ни запечённые обои — каждая загрузка была «новым устройством». Том в памяти потому, что драйвер APFS ядра читает и не пишет, а оба пути ядра к накопителю телефона были «только чтение по построению».

Сделан не «APFS на запись», а копия дерева. /redwood/bin/var_store (userland/src/var_store.c) упаковывает /private/var в простой архив и пишет его в единственное место флеша, которое принадлежит порту и которое ничто в нём не читает, — в раздел darwin, за томом iOS и двумя криптексами; init при следующей загрузке распаковывает архив поверх шаблона (var_store=1). Имя, которое есть и в шаблоне, и в архиве, выходит таким, каким его сохранили; что сделал шаблон, а в архиве этого нет, остаётся нетронутым.

блоки darwin (по 4 КБ) с байта что
0–2 042 367 0 том iOS и криптексы (см. «Криптексы на флеше»); запись сюда отказана и ядром, и загрузчиком
2 042 368–2 097 151 8 365 539 328 свободно
2 097 152–2 621 439 8 589 934 592 слот 0 архива: заголовок (один блок), за ним архив, 2 ГиБ всего
2 621 440–3 145 727 10 737 418 240 слот 1 архива
3 145 728–5 242 874 12 884 901 888 свободно

Два слота по очереди. Пишется тот, в котором не лежит самый свежий хороший архив: сначала его заголовок затирается нулями, потом идёт архив, и только в конце записывается заголовок. Поэтому прерванное сохранение (телефон сброшен посередине) теряет сохранение, а не хранилище: в другом слоте остаётся архив с прошлого раза, а load берёт слот с наибольшим номером поколения, у которого сходятся и контрольная сумма заголовка, и сумма содержимого. Архив при загрузке читается дважды: первый раз — только чтобы проверить сумму, и лишь потом создаются файлы. Так порванное хранилище оставляет /private/var ровно таким, каким его сделал шаблон, и не может помешать телефону загрузиться.

Что в архиве. На каждую запись — вид (каталог, файл, символьная или жёсткая ссылка), права, владелец и группа, флаги файла, время изменения, расширенные атрибуты (в них iOS держит метаданные контейнеров и многое другое) и содержимое; путь — относительно /private/var, и распаковка отказывается делать имя, которое ведёт за его пределы. Каталог попадает в архив дважды: перед своим содержимым и, маленькой записью, после него — иначе его время изменения сбивалось бы созданием того, что в нём лежит. Жёсткая ссылка — это имя и имя, под которым этот же inode встретился впервые. Не сохраняются сокеты, очереди и устройства (демоны новой загрузки делают свои) и содержимое каталогов tmp, run, vm, log, logs, db/diagnostics, db/uuidtext, jb — там либо то, что имеет смысл только в своей загрузке, либо то, что и так прививается из образа при каждой загрузке.

Запись во флеш из ядра — новое, и ограничена трижды, и по умолчанию выключена (ufs_write=1).

  • Блочные устройства драйвера по-прежнему не пишут вовсе: ufs_strategy отвечает EROFS, а открытие на запись отказывается, — так что ничто, что смонтировано с такого устройства, писать не может.
  • Записи принимает одно символьное устройство — /dev/rufs_darwin, — целыми блоками и только с блока 2 042 368: том iOS, криптексы и всё за концом раздела отказываются (EROFS). Раздел при этом должен быть тем самым, с которым порт настраивали: имя, первый LBA, число блоков, размер блока, первая логическая единица.
  • Загрузчик проверяет то же самое заново и вдобавок GUID типа и уникальный GUID записи GPT (те же константы, что у tools/darwin-write.py), пишет только через BlockIo самого раздела, который UEFI ограничивает его границами, и каждую запись читает обратно и сверяет по CRC-32, прежде чем ответить ядру. Без ufs_write=1 на его собственной командной строке он не предлагает ядру ни одного раздела, и ядро тогда не просит записи.
  • У своего драйвера контроллера (ufs_native=1) в белом списке команд записи нет и не появилось. Поэтому на время записи его список запросов уходит из контроллера, и флеш снова ведёт драйвер UEFI, которого просит загрузчик; чтения в это время идут через загрузчик — медленнее и правильно. Список уходит один раз, с первой записи, и возвращается, когда устройство закрыли.
  • Страница, через которую ядро говорит с загрузчиком, осталась той же версии и того же размера: поля о записи легли в то, что было выравнивающим зазором. Телефон один на несколько рабочих копий, и ядро, собранное до записи, читает через загрузчик, собранный после неё, и наоборот.

Сохраняется само, просить не надо. Задание com.hakios.varstore (var_store watch) остаётся на весь прогон и объявляется ядру (kern.hakios.var_save). Когда прогон кончается по-хорошему — вышло время loader_return_after или хозяин сказал ./xl stop, — ядро в том самом потоке, который сейчас вернёт управление загрузчику, просит том и ждёт ответа (drivers/storage/hakios.ufs.c, hakios_var_save_on_return), и только потом отдаёт GPU, процессоры звука и Wi-Fi и уходит. Чего при этом не должно случиться и как это не случается:

  • Ждать некого. Если никто не объявился, ядро не просит и не ждёт ни миллисекунды: прогон без хранителя возвращается точно как раньше. Измерено: прогон без var_store=1 (logs/185052-run.log) — в журнале var_store: not asked to keep the data volume: nothing to watch, строки «the run is ending» нет вовсе, и ./xl run занял 204,90 с при XL_SECS=200, то есть ровно столько же, сколько занимал всегда; с хранителем (logs/203020-run.log и далее) те же 200 с плюс 8,5–9,0 с на сам том. Паника этим путём не идёт вовсе — спрашивать уже некого, — и хранилище остаётся с тем, что в нём было.
  • Ждать слишком долго. Не дольше 20 с (var_save_ms=N), и в журнале сказано, сколько ждали. Загрузчик кормит сторожевой таймер ещё минуту после срока прогона, а хозяин ждёт ответа сто секунд после него, так что эти секунды с запасом внутри обоих. Упаковка и запись 120–160 МБ занимают 6–7 с.
  • Два сохранения разом. Запись в хранилище отказывается (EPERM), если пишущий не держит заявку (kern.hakios.var_busy), а заявку держит один процесс; заявку, оставленную умершим процессом, следующий забирает себе. Да и столкнувшись, двое испортили бы только тот слот, в который пишут, — а это никогда не тот, в котором лежит последний хороший архив.
  • Сохранение, прерванное на середине. Заголовок слота сначала обнуляется, архив пишется, заголовок — последним; поэтому недописанный слот просто не проходит проверку, и load берёт предыдущий.
  • Прогон, который после сохранения продолжается. Устройство открывается на время записи и сразу закрывается: список запросов этого ядра возвращается в контроллер, и iOS снова читает флеш через него, а не через загрузчик.

Сохранение «каждые N секунд» тоже есть (var_store watch N), но по умолчанию его нет: каждое стоит 6–7 с прогона и сотню с лишним мегабайт записи во флеш, а пока оно идёт, накопитель читается через загрузчик.

А если прогон не вернулся. Сохранение привязано к тому, как прогон кончается, и из трёх способов кончиться два покрыты, а третий — нет.

как кончился прогон сохраняется
вышло время loader_return_after да, проверено (logs/204450-run.log и далее)
хозяин сказал ./xl stop да, проверено (logs/220747-run.log: «the host asked for the end of the run», затем «the run is ending» и сохранение)
ядро повисло так, что не идут даже thread_call, или паника нет

Почему третье — нет, и почему это не чинится «сохранением по сторожевому таймеру». Упаковывает том программа в пространстве пользователя, а не ядро. Ядро, повисшее настолько, что у него не идут thread_call, не запустит и её; на пути паники пространства пользователя уже нет вовсе. Перенести упаковку в ядро — значит брать замок тома ramfs, а именно его повисшее ядро скорее всего и держит, так что сохранение повисло бы там же. Безопасного способа нет, и это окончательный ответ, а не «пока не сделано». Загрузчик, заметив тишину, может только забрать журнал и сбросить телефон — что он и делает.

Зато «повисло» часто означает «повис один поток»: возврат по времени идёт из thread_call, и такой прогон сохраняется как обычно. Без сохранения остаётся только полный затык и паника.

Чего стоит сохранять почаще (var_store watch N; rc.jobs.persist6 — три сохранения с минутным промежутком в одном прогоне):

записей изменилось байт изменилось сохранение заняло записано во флеш
первое за загрузку (через 355–413 с после прошлого поколения) 520–578 37–41 МБ 8,7 с 175–179 МБ
через минуту 3–37 68–480 КБ 8,7 с 175–179 МБ
ещё через минуту 0 0 8,7 с 175–179 МБ

То есть том успокаивается за пару минут, а сохранение всё равно стоит полных 175–179 МБ: архив — снимок целиком. Отсюда цена постоянного сохранения: раз в 60 с за двадцатиминутный прогон — около двадцати сохранений, три минуты прогона и 3,5 ГБ записи; раз в 300 с — четыре сохранения, 36 с и 700 МБ. Поэтому по умолчанию его нет, но для долгого прогона, где повисание вероятно, var_store watch 300 — разумный размен.

Почему не пишется только разница. Два приёма пробовались и измерены.

  • Не писать блок, который в слоте и так лежит — сделано: перед записью буфер сравнивается с тем, что в слоте (чтение этого накопителя в разы дешевле записи). На неизменном дереве это пропускает всё: на компьютере второе сохранение подряд пишет 0 блоков из 1250. На живом томе — только 1024 блока из 43 000, потому что iOS переписывает расширенные атрибуты на месте: архив выходит той же длины и с другой контрольной суммой, хотя ни у одного файла не сдвинулось время записи.
  • Складывать имена каталога по порядку, чтобы одно и то же дерево давало одни и те же байты — сделано (sort_names); без этого порядок getdirentries плавает и сдвигает весь архив после себя. На живом томе это само по себе не помогло — см. выше про атрибуты.
  • Журнал изменений с опорой на прошлое поколение — не сделано и, по этим числам, не стоит того: сами изменения крошечные (0–3 записи в минуту), но из-за атрибутов журнал пришлось бы вести на уровне байтов, а слоты из двух независимых превратились бы в цепочку, где потеря одного поколения ломает следующие. Против полного снимка за 9 с это плохой размен.

Как запускать.

CRYPTEXES=flash FLASHBOOT=1 GL=1 REST=1 RC_JOBS=rc.jobs.persist userland/build.sh
./xl ramdisk userland/flashboot.apfs
XL_CMDLINE="root_apfs=ufs_darwin ufs=2 ufs_native=1 ufs_write=1 var_store=1 bootcpu=7 cpus=8 cpufreq=1 touchscreen=1 gpu=1 adsp=1" XL_SECS=200 ./xl run

Что показал телефон. Три прогона, между каждыми двумя — настоящий холодный сброс: ./xl reboot в Android, снова в загрузчик, ./xl ramdisk userland/flashboot.apfs (21 МБ за секунду) и загрузка заново. Корень iOS при этом никуда не девается — он на флеше; в ОЗУ телефона между прогонами только загрузочный том.

прогон 1 (14:51) прогон 2 (14:56) прогон 3 (15:06)
хранилище в начале загрузки no store yet слот 0, поколение 1, 15 580 записей, 157 749 256 байт слот 1, поколение 2, 15 384 записи, 134 996 904 байта
распаковано — 6490 каталогов, 2401 файл (155 150 360 байт), 0 ссылок 6419 каталогов, 2347 файлов (132 432 052 байта), 2 ссылки, ничего не потеряно
/private/var/hakios-stamp в начале нет written by run of 1792410683 — метка прогона 1 метки прогонов 1 и 2 (1792410683, 1792410962)
SpringBoard в начале нет IconState.plist 3630 байт, время 1792410708 — прогона 1 SBLastRestoreIdentifier время 1792410692 и TodayViewArchive.plist 1792410708 — прогона 1, через два холодных сброса; IconState.plist — прогона 2
сохранено в конце поколение 1 в слот 0 поколение 2 в слот 1 поколение 3 в слот 0
записей во флеш (со стороны загрузчика) 40, 157 761 536 байт 35, 135 008 256 байт 31, 120 610 816 байт
всё прочитано обратно и совпало да, 0 отказано да, 0 отказано да, 0 отказано
ramfs к концу прогона 190 МБ 182 МБ 160 МБ
упаковка и запись тома ~7 с ~7 с ~6 с

То есть на каждой следующей загрузке в /private/var лежат файлы предыдущих, с их временами: и собственный файл-метка, который копит по строке за прогон, и то, что писала сама iOS — раскладка домашнего экрана (IconState.plist), TodayViewArchive.plist, SBLastRestoreIdentifier, контейнеры приложений.

Что показали ещё два прогона, уже с сохранением само собой: в сценарии (rc.jobs.persist) не осталось ни одной команды, которая сохраняет, — том пишет задание com.hakios.varstore, когда ядро говорит, что прогон кончается. Между прогонами — холодный сброс, доведённый до Android и обратно (в журнале прогона видно, как телефон пропал и поднялся в Android, после чего загрузчик поднят заново и 21 МБ загрузочного тома залиты снова).

прогон 4 (16:12) прогон 5 (16:37)
хранилище в начале загрузки слот 0, поколение 3, 14 503 записи, 120 601 040 байт слот 1, поколение 4 — то, что прогон 4 записал сам, хотя его об этом не просили
распаковано 6061 каталог, 2182 файла (118 295 215 байт), 2 ссылки 6304 каталога, 2311 файлов (131 977 440 байт), 2 ссылки
не поместилось ничего ничего
/private/var/hakios-stamp в начале метки прогонов 1–3 метки прогонов 1–4, последняя за 1519 с и один холодный сброс до начала этого
SBLastRestoreIdentifier SpringBoard время 1792410692 — прогона 1 время 1792410692 — прогона 1, через пять загрузок и четыре холодных сброса
сценарий сохранял нет нет
ядро попросило том на 224,9 с на 225,0 с
хранитель ответил через 6820 мс через 7320 мс
сохранено поколение 4 в слот 1, 15 118 записей, 134 457 232 байта поколение 5 в слот 0, 15 412 записей, 144 322 648 байт
записей во флеш (со стороны загрузчика) 35, 134 467 584 байта, прочитано обратно и совпало, 0 отказано 37, 144 334 848 байт, то же, 0 отказано
ramfs к концу прогона 274 МБ 285 МБ

Том дорос до 285 МБ — почти вдвое против того, на чём раньше начинались отказы, — и ни один файл не потерялся.

Что это даёт: пять загрузок подряд. Хранилище забыто (var_store erase), потом пять прогонов одного и того же образа, первый из которых ещё ничего не находит. Для сравнения — та же загрузка без var_store=1 вовсе, какой этот порт загружался всегда. «SpringBoard поднялся» — от первой строки launchd до первой строки «Service stub created» самого SpringBoard; «процессов» — ps -A через две минуты после начала прогона.

без хранилища загрузка 1 2 3 4 5
хранилище в начале нет нет поколение 1 2 3 4
SpringBoard поднялся (часы launchd) 29,5 с 29,4 с 10,2 с 10,2 с 10,2 с 10,2 с
IconState.plist записан (часы файловой системы) +28 с +65 с (значок двигали на 55-й) +14 с +14 с +14 с +14 с
RecentAppLayouts записан (там же) +34 с +34 с +24 с +16 с +16 с +16 с
процессов 161 164 144 116 114 105
запусков заданий launchd 121 120 153 137 136 137
контейнеров приложений 6 6 156 156 156 156
групп приложений нет нет 43 43 43 43
связка ключей пусто (−25300), записали пусто, записали читается запись загрузки 1 она же она же она же
Photos строит индексы своей базы 10 запросов 10 0 0 0 0
SplashBoard запускался — 11 строк 0 0 0 0
при распаковке потеряно — — ничего ничего ничего ничего
ramfs к концу 293 МБ 293 МБ 312 МБ 315 МБ 314 МБ 318 МБ
сохранено в конце — 168 МБ за 8,5 с 175 МБ за 8,7 с 178 МБ за 8,9 с 176 МБ за 8,8 с 179 МБ за 9,0 с

Коротко, что покупается:

  • От четырнадцати до девятнадцати секунд загрузки, по двум независимым часам. Первые — журнал launchd: от его первой строки до первой строки «Service stub created» самого SpringBoard, 10,2 с против 29,5, и это повторилось в четырёх загрузках подряд с разбросом в 20 мс. Вторые — время, которое сам SpringBoard ставит на файлы, которые пишет, считая от epoch= (часы хозяина в момент сборки полезной нагрузки, за несколько секунд до старта, одни и те же для всех прогонов): IconState.plist записан на 28-й секунде холодной загрузки и на 14-й у каждой тёплой, RecentAppLayouts — на 34-й против 16-й. Часы разные — журнал демона и метки времени файловой системы, — вехи разные, и обе говорят одно: тёплая загрузка доходит до работы SpringBoard на 14–19 с раньше. Холодная загрузка и первая загрузка с пустым хранилищем дают одно и то же (29,5 и 29,4 с), так что дело именно в том, что том не пуст. Кстати, те же метки времени показывают и обратное: TodayViewArchive и SBLastRestoreIdentifier на тёплых загрузках имеют время до начала прогона (−214, −428, −644, −860 с) — их не переписывают, их вернули из хранилища.
  • Первая работа делается один раз. Photos на холодную строит схему и индексы своей базы (10 запросов CREATE INDEX ... SEARCHINDEXREBUILDSTATE) — со второй загрузки ни одного. SplashBoard, который печёт снимки запуска, на холодную поднимается, дальше нет. Из 199 заданий launchd, запускавшихся на холодной загрузке, 51 на пятой уже не запускается, зато появился 61 другой — те, которым теперь есть с чем работать.
  • Приложения не теряют своего. 6 контейнеров на чистой системе против 156, и это те же самые 156 с теми же идентификаторами на каждой следующей загрузке, плюс 43 группы приложений. iOS не заводит их заново.
  • Связка ключей переживает перезагрузку. Это было главным неизвестным: ключи классов защиты у порта — не Secure Enclave, а обратимое преобразование в ядре. Запись, сделанная загрузкой 1, читается загрузками 2–5 дословно («written by run of 1792431023»). keychain_test keep пишет только если записи нет.
  • Раскладка домашнего экрана держится. На первой загрузке значок FaceTime перенесён долгим нажатием с первого места на последнее (rwtouch drag ... GRAB, rc.jobs.persist1); на пятой он там же, а остальные подвинулись на одно место вперёд, как iOS и делает (./xl screenshot: logs/home-boot1.png, logs/home-boot5.png). Сам файл IconState.plist SpringBoard переписывает каждую загрузку — его хеш всякий раз новый, — но начинается каждая загрузка ровно с того хеша, которым кончилась предыдущая.
  • Цена — 8,5–9,0 с в конце каждого прогона и 168–179 МБ записи во флеш.

Чего это не даёт: карточки переключателя приложений остаются сиреневыми. PosterBoard за три с половиной минуты простоя не печёт ничего — в /var/mobile/Library нет ни PaperBoard, ни PosterBoard, ни Wallpaper, — так что хранить ещё нечего (проверено в другой ветке; здесь это не считается проверкой этой работы).

Та же беда с другой стороны, и чей ramfs.c здесь теперь. В ветке sileo2 её нашли независимо и глубже: все зоны ядра делят один потолок, и несколько сотен мегабайт файлов в data.kalloc.16384 в него упёрлись — сперва ENOSPC на записи, а потом паника на выделении, которому нельзя не удаться. Там страницы содержимого берутся не из зоны, а прямо у VM (ramfs_page_alloc, kmem_alloc в kernel_map), и массив страниц тоже выращивается без замка (ramfs_reserve_slots: вырастить до взятия замка, под замком перепроверить). Это покрывает и то, что чинила здешняя правка, и причину, которой здешняя не знала, — поэтому ramfs.c здесь теперь оттуда целиком, а здешнее (ramfs_grow_slots с EAGAIN и массивом от зовущего) убрано. Пояс в var_store — выжидание короткой записи — остался: путь подкачки по-прежнему не может ждать.

Отказ не по месту, а по странице. В прогоне 2 восемь файлов из 2401 не поместились (No space left on device, около 2,6 МБ), хотя место было: vol_ls fs /private/var показывает 68 608 блоков по 16 КБ — 1,07 ГБ, четверть памяти телефона, — а том занимал 180 МБ. Весь том идёт в память одним потоком, быстрее, чем ramfs успевает находить память под массив страниц файла, а под замком тома она ждать не может: ожидание может кончиться в демоне подкачки, который придёт сюда же за этим замком, — поэтому массив брался с Z_NOWAIT, и неудача становилась ENOSPC. Теперь массив — забота того, кто пишет, и находится он без замка, как и запасная страница рядом (ramfs_grow_slots отвечает EAGAIN: «принеси такой массив и приходи снова», ramfs_write его приносит; rn_nslots читается без замка как подсказка, и если другой пишущий успел вырастить тот же файл, подсказка мала — тогда спрашивают ещё раз, до четырёх попыток). Путь подкачки ждать по-прежнему не может и по-прежнему берёт с Z_NOWAIT, а его EAGAIN остаётся прежним ENOSPC — страницы остаются грязными, и их предложат ещё раз. Ссылки в прогоне 2 не восстанавливались вовсе: имя уже было создано шаблоном, и symlink отказывал, — теперь имя шаблона уступает хранилищу.

var_store=1 — init распаковывает хранилище при загрузке, а задание com.hakios.varstore сохраняет его в конце прогона; ufs_write=1 — ядру и загрузчику разрешено писать, без него сохранить не получится, а распаковать — получится. Сам var_store: save — упаковать /private/var сейчас, watch [N] — остаться и сохранять само, load — распаковать, show — что лежит в обоих слотах, erase — забыть оба слота, stamp ПУТЬ — дописать в файл строку о том, когда (метка прогона). Сценарий userland/rc.jobs.persist сам не сохраняет ничего: он печатает хранилище, место в томе, строки прошлых прогонов и содержимое /var/mobile/Library/SpringBoard с временами, ставит свою метку и две с половиной минуты не мешает iOS.

Сборка из исходников

Готовые бинарники в prebuilt/ собраны из этого репозитория; пересобрать всё можно самому.

Ядро

Нужны theos в ~/theos (cctools и iPhoneOS16.5.sdk) и пакеты base-devel git curl clang lld python perl xz. Дальше три команды, вместе около двух минут:

xnu/apply.sh             # исходники XNU (тег xnu-8796.121.2) с GitHub Apple + наши правки → xnu-src/
xnu-deps/setup.sh        # зависимости, инструменты и SDK для сборки на Linux стоковым clang
./xnu-build.sh -j8 -k    # → xnu-src/xnu-8796.121.2/BUILD/obj/DEVELOPMENT_ARM64_BCM2837/mach.development.bcm2837

Подробности, подводные камни и обзор того, что добавлено в ядро, — в xnu/README.md.

Загрузчик

xnuloader/build.sh        # clang + lld-link → xnuloader/loader.efi (EFI-приложение без EDK2)

xnuloader/identity.h генерируется при сборке xnuloader/mkidentity.sh (через mkidentity.py) из дерева устройств iPhone X в вашем IPSW (так система узнаёт, «какой она iPhone»); в репозитории его нет. Вручную: python3 sysimage/ipsw-member.py ВАШ.ipsw Firmware/all_flash/DeviceTree.d22ap.im4p dt.im4p, ipsw img4 im4p extract dt.im4p -o dt.bin, xnuloader/mkidentity.py dt.bin > xnuloader/identity.h. Кроме свойств корня и /product оттуда же берётся узел /product/haptics (closed-loop, supports-3rd-party-haptics): по нему MobileGestalt отвечает DeviceSupportsClosedLoopHaptics, и iOS играет вибрацию и отклики волнами на устройстве «Actuator»; без него она ищет в IOKit старый вибромотор («vibrator»), которого у iPhone X нет, и не вибрирует вовсе. Из узла /product/camera загрузчик берёт (только с torch=N) одно свойство flash — MobileGestalt camera-flash, кнопка фонарика в Пункте управления; с cam=5 сам узел есть и без него (MobileGestalt video-camera). С cam=5 есть ещё и /arm-io/isp — узел ISP айфона, из его свойств только camera-rear = 1, без compatible и device_type (ни один драйвер его не берёт): по нему MobileGestalt отвечает still-camera и rear-facing-camera, а SpringBoard показывает Camera.app (на первой странице, справа вверху). Остального о камере айфона здесь нет, и дерево этого не говорит.

Userland и образы

REST=1 userland/build.sh

Без кросс-компилятора (см. раздел про ядро — он тот же) скрипт берёт наши программы из prebuilt/userland, с ним — компилирует userland/src. Получаются образы APFS — контейнеры, как тома iPhone (tools/apfs/mkapfs.py, сжатие zlib, как у Apple):

  • userland/root.apfs — маленький (наш init, библиотеки-вставки, задания launchd, прошивки); едет вместе с ядром (/dev/md1, на /redwood), пересобирается за секунды;
  • userland/system.apfs — корень: отобранная часть iOS 16.5 (sysimage/select-system.py), криптексы OS (с кэшем dyld) и App на /System/Cryptexes. Пересобирается только при изменении входных данных или с SYSTEM=1;
  • userland/flashboot.apfs (FLASHBOOT=1) — то, что нужно в памяти, когда корень — том iOS на флеше: загрузочный том и оба криптекса, три контейнера подряд (см. «iOS с корнем на флеше»).

CHECK=1 проверяет каждый контейнер, как только он собран (tools/apfs/check-image.sh): apfsck -cw из apfsprogs (скачивается и собирается при первом вызове) должен найти его чистым, а библиотека APFS ядра (apfs-tool census) — прочитать каждый том ровно таким, каким был каталог.

REST=1 добавляет ~375 штатных служб iOS «по требованию» (без них часть приложений зависает на старте; с корнем на флеше — на семь больше, см. FLASHBOOT=1 ниже); список исключений — DENY в userland/mkjobs.py, пропустить службу из него для опыта: ALLOW="слово". Две вещи про этот список стоит знать заранее. Во-первых, демон может вести себя плохо из-за другой строки того же списка, и судить о нём по одному слову за раз нельзя: cloudd выглядел службой, которая стоит домашнего экрана — и на телефоне, и в QEMU, — пока не выяснилось, что он тысячами переспрашивает com.apple.nsurlsessiond, которого в образе не было, потому что тот стоял в том же DENY. Убрали одно слово — cloudd перестал крутиться и домашний экран вернулся. Всё, что написано про демона ниже, измерено при том наборе запретов, какой был тогда; если с тех пор какое-то слово ушло, измеряйте заново. Во-вторых, сравнивать надо с опытом того же дня. Все неудачные прогоны оказались позже всех удачных, а на машине в это время крутился чужой QEMU, и напрашивалось объяснение «машина занята». Оно было неверным: один удачный прогон стоял ровно между двумя неудачными. Порядок во времени — не причина; рядом с изменением должен стоять контроль. Остальные переменные (JOBS, OSLOG, INJECT, RC_JOBS) описаны в начале userland/build.sh.Остальные переменные (JOBS, OSLOG, INJECT, RC_JOBS, EXTRA, EXTRA_SHARE) описаны в начале userland/build.sh.

Список заданий (RC_JOBS=файл, он же /redwood/etc/rc.jobs) выполняет не оболочка, а наш init (run_command в userland/src/init.c), и рассчитывать в нём нельзя на четыре вещи:

  • на оболочку. В корне самой iOS 16.5 её нет вообще: /bin — это df и ps, и больше ничего. Строка, начинающаяся с /bin/sh, кончается No such file or directory и кодом 127. /var/jb/bin/sh, который запускают rc.jobs.jb и rc.jobs.sileo, — оболочка префикса пакетного менеджера, и её несёт только образ с JB=1;
  • на переменные окружения. Присваивание в начале строки — не присваивание, а первое слово команды. Программе, которой нужно передать значение, нужен для этого параметр: именно поэтому у air_run появился --buffer вместо AIR_RUN_BUFFER;
  • на кавычки. Строка делится по пробелам и табуляциям, кавычки ничего не группируют; из перенаправлений понятны только >> ФАЙЛ и & в конце;
  • на длину. Файл читается целиком в буфер фиксированного размера (64 КБ). Раньше их было три разных — и во всех трёх местах хвост просто не читался, молча: inject.conf в 512 байт уходил в kern.exec_inject половиной имени программы, которая ни с чем не совпадёт, а список заданий на 21 КБ выполнялся до 94-й строки, что выглядит как тест, ничего не нашедший. Теперь чтение одно (read_whole), и файл, который не влез, говорит об этом строкой init: ... does not fit in N bytes.

Служба push-уведомлений (apsd). Из всех служб, которых в образе не было, её спрашивали чаще всех: 29 % всех строк failed lookup в журналах запусков — это com.apple.apsd, в одном сеансе 250–300 строк от 22 разных демонов (securityd, akd, amsaccountsd, imagent, remindd, healthd, fmfd — весь учётный слой спрашивает её прежде, чем что-нибудь сделать). В DENY она стояла с первого дня, и её ни разу не пробовали. Теперь она в образе «по требованию»: демон поднимается за 60 мс, когда его первый раз спрашивают, объявляет com.apple.apsd и com.apple.apsd.nsxpc и остаётся работать; строк failed lookup про него в сеансе больше нет ни одной, а клиенты получают свои порты (com.apple.securityd.aps, com.apple.coreservices.appleid.aps, com.apple.icloud.fmfd.aps, com.apple.coreduet.knowledge.sync.push). Самих уведомлений от Apple при этом нет и быть не может: курьеру нужен клиентский сертификат устройства, который apsd берёт из связки ключей, а если его там нет — у mobileactivationd («No identity found in keychain. Copying activation identity» → «Device is probably unactivated»). И этого сертификата здесь не будет: цепочка активации растёт из Secure Enclave — mobileactivationd проверяет UCRT против UIK устройства по SEPRootCA.pem, — а Secure Enclave у этого телефона нет, ключи классов защиты заменяет обратимое преобразование в ядре (MiniAppleKeyStore.cpp, см. «Связка ключей»). Польза ровно в том, что учётный слой получает ответ вместо ошибки и перестаёт переспрашивать.

App Store: открывается и ждёт. appstored, itunesstored и installcoordinationd теперь в образе — первых двух App Store просит, как только его открыть, — и с ними сеанс на телефоне чистый: 135 процессов, 17 процессов виджетов и постеров, ни одной аварии. Сам App Store открывается и показывает свой «LOADING» с вертушкой, и на этом остаётся (logs/app3-*.png). Это не стена FairPlay и не вход в учётную запись. appstored говорит о себе так:

[bag] AMSBagActiveAccountProvider: The local account doesn't have a storefront.
      localAccount = <ACAccount type = iTunesStore | username = local | storefront = (null)>
[bag] AMSBagNetworkDataSource: Could not fetch initial cached storefront
[Bag]  Error creating snapshot of bag: Timed out while waiting for snapshot bag to load

Нет витрины (storefront) — нет «мешка» (bag, конфигурация Apple) — не загружается и главная страница. Здесь ранее стоял вывод, что путей к витрине ровно два и оба закрыты. Вывод был неверен, и то, как он получился, стоит помнить. Два способа действительно есть и действительно закрыты: com.apple.commcenter.coretelephony.xpc — страна из SIM через CommCenter, который падает по кругу на двух отсутствующих драйверах IOKit, и com.apple.mobileactivationd — регион из записи активации, растущей из Secure Enclave, которого у этого телефона нет. Но есть и третий, и именно им пользуется iPhone без SIM, в который никогда не входили: витрину выдаёт по HTTP сам сервер мешка. В AppleMediaServices лежат заголовок X-Set-Apple-Store-Front, строка «Attempting to load the bag again because the response had a x-set-apple-store-front header» и адрес https://bag.itunes.apple.com/bag.xml; мешок запрашивается без витрины, сервер выбирает её по адресу запроса, а AMSBagDataSource _setStorefrontFromURLResponse:bagData: записывает её в местную учётную запись. «The local account doesn't have a storefront» — сообщение отката, а не стена; в iTunesStore рядом лежит и «The account has no storefront, but the device does». Проверки активации в магазинном слое нет вообще нигде.

Ошибка вышла из двух мест сразу. Первое: наблюдение «витрина не открывает ни одного TCP-соединения» верно, но означает не то — appstored и itunesstored ходят в сеть через NSURLSession, а она передаёт перенос nsurlsessiond; своих сокетов у них не бывает никогда.

Второе и важное для всей дальнейшей работы: /dev/console на телефоне теряет строки, и ни один вывод «в журнале этого нет» через него не доказателен. В одном и том же сеансе видно, что шаги сценария выполнялись — init напечатал командную строку, напечатался и следующий за ними шаг, — а собственного вывода net_test у resolve, reach и двух fetch в журнале просто нет; в это же окно launchd сыплет «Last log repeated 2312 times» своих ненайденных обращений. Значит, отсутствие строк про мешок ничего не говорит о том, запрашивался ли мешок.

А что консоль всё-таки донесла — обратное тому, что здесь стояло: сеть на телефоне работает. У en0 адрес 192.168.9.243, mDNSResponder разрешает имена по TCP к 192.168.9.1:53 (четыре запроса, ответы 159, 842, 200 и 107 байт), а trustd, geod, maild, gamed, cloudd, StocksWidget и nsurlsessiond открывают TLS к Apple и Akamai (17.248.214.x, 17.253.39.x, 2.16.56.93, 23.11.40.157, 95.100.176.233). Строка «net_test: en0: flags 0x8802» читалась через 0,1 с после начала пользовательского пространства, до того как configd настроил интерфейс: с wifi=1 это делает само задание com.hakios.wifi, так что просить DHCP второй раз сценарию не нужно — довольно waitaddr en0 60.

Измерение переделали по каналу, который теряет меньше: шаги сценария ждут, пока утихнет начальный поток (launchd поднимает две сотни процессов), и каждый fetch — отдельная команда. Тогда на телефоне, с корнем на флеше и присоединённым радио:

net_test: waitaddr en0: an address after 0.0 s
net_test: en0: flags 0x8843 up running ... inet 192.168.9.243 netmask 255.255.255.0
net_test: resolve bag.itunes.apple.com, 0.02 s: 17.253.39.138 17.253.39.140 ...
net_test: fetch https://www.apple.com/library/test/success.html: status 200, 68 bytes in 0.32 s
net_test: fetch https://bag.itunes.apple.com/bag.xml: status 200, 56219 bytes in 0.53 s
net_test: fetch https://bag.itunes.apple.com/bag.xml: x-set-apple-store-front: 143469-2,8
net_test: fetch https://init.itunes.apple.com/bag.xml: status 200, 56219 bytes in 0.36 s
net_test: fetch https://init.itunes.apple.com/bag.xml: x-set-apple-store-front: 143469-2,8

То есть третий путь открыт: без учётной записи, без SIM и без активации Apple сама говорит, в какой витрине это устройство — выбирает её по адресу, с которого пришёл запрос, и отдаёт заголовком. Мешок — 56 КБ XML, за полсекунды; DNS, TLS и хранилище доверия работают.

И магазин этим не пользуется. В том же сеансе App Store открыт и крутит вертушку, appstored поднят и сам пишет «[Network] Network available (interface = wifi)» — и не открывает ни одного сокета: в журнале соединений ядра за этот сеанс есть net_test, trustd, akd, cloudd, passd, coreidvd, gamed, geod, maild, promotedcontentd, familycircled, StocksWidget, mDNSResponder — и нет ни appstored, ни nsurlsessiond. AMS за первые 40 мс жизни демона читает пустой кеш («Could not fetch initial cached storefront. error = (null)») и на этом останавливается, а через 30 с клиент получает «Timed out while waiting for snapshot bag to load». Стена — внутри AppleMediaServices, не в сети и не у Apple.

Куда смотреть дальше, по строкам самой AMS (AMSCore::LoadNetworkBagTask): perform() прежде всего зовёт getAccount(), и рядом лежат «Attempted a bag load but an account wasn't provided» и «Failed to fetch local account. This will prevent initial bag requests from specifying storefront». В журнале их не видно — они ниже уровня, который печатает OS_ACTIVITY_DT_MODE. При этом amsaccountsd работает и отвечает: в журнале видно и подключение appstored к нему, и его транзакции AMSDCookieService.getCookies. Сценарий — userland/rc.jobs.store.

Витрину руками положить некуда. Раз сервер выдал номер именно этому устройству (x-set-apple-store-front: 143469-2,8), оставалось проверить, не в нём ли одном дело: положить его на учётную запись самим. Для этого есть userland/src/store_test.m — он подписан теми же правами, что и appstored (ldid -e с его двоичного файла: com.apple.accounts.appleaccount.fullaccess, com.apple.private.accounts.allaccounts), сам загружает AppleMediaServices и зовёт её же методы. Ответ отрицательный, и он много объясняет:

store_test: uid 501, euid 501; AppleMediaServices loaded
store_test: before: ams_localiTunesAccount: user local
store_test: before: ams_localiTunesAccount: id 7BA65770-7DC2-488D-8B57-81B17A8B12F0
store_test: before: ams_localiTunesAccount: STOREFRONT (null)
store_test: set: in memory now 143469-2,8
store_test: set: ams_saveAccount: done
store_test: after: ams_localiTunesAccount: STOREFRONT (null)

Установщик работает, значение не сохраняется. В accountsd нет ни одной записи типа com.apple.account.iTunesStore — для телефона, в который не входили, это правильно, — а та «местная» учётная запись, которую отдаёт AMS, не её строка: её идентификатор каждый раз новый (в трёх сеансах appstored это 143B8A92…, 8D31AF5C…, C976F334…, у нас 7BA65770…). То есть она создаётся заново при каждой загрузке, с пустой витриной, и записать её негде: единственное, что её заполняет, — ответ сервера мешка (_setStorefrontFromURLResponse:bagData:), тот самый запрос, которого не происходит.

Значит, снаружи процесса витрину этому устройству не отдать, и следующее место — внутри appstored: в порту уже есть механизм подсадки своей библиотеки в штатные программы (INJECT в build.sh, src/inject_sb.c и соседние). Она и положила бы витрину на ту самую учётную запись, которой пользуется AMS, и заодно показала бы, строится ли запрос мешка вообще.

Внутри appstored: витрина отвечена, запрос мешка строится, переносом не становится. Раз снаружи витрину не отдать, её отвечают изнутри: userland/src/inject_store.c — библиотека, которую сборка с STOREFRONT=1 подсаживает в appstored (тот же механизм INJECT, что и для SpringBoard). Она ничего не записывает: она подменяет геттеры, через которые AMS спрашивает витрину (-[ACAccount ams_storefront] и соседей), и заодно считает входы в шаги запроса. За 150 с после старта демона, уже после начального потока на консоли:

entered    3 -[ACAccount ams_storefront]                            (было (nil), стало 143469-2,8)
entered    3 -[AMSBagActiveAccountProvider _bagAccountForAccountMediaType:mustContainStorefront:]
entered    1 -[AMSBagNetworkTask _performFetchWithAttemptedCount:account:storefront:]
entered    1 -[AMSBagNetworkTask _createRequestWithQueryItems:]  ->  <AMSMutablePromise: ...>
entered    0 -[AMSBagNetworkTask URLSessionPromise]
entered    0 -[AMSURLSession dataTaskPromiseWithRequest:]
entered    0 -[AMSURLSession dataTaskWithRequest:]
entered    0 -[NSURLSessionTask resume]

То есть витрина и была воротами: с ней AMS запрос мешка строит («BAG FETCH attempt 0, storefront 143469-2,8, account local (…)»). И переносом этот запрос не становится: он собирается в AMSMutablePromise, который никто не разрешает, поэтому сессия не запрашивается, задача не создаётся и resume не зовётся ни разу — ни для мешка, ни вообще, за весь шестиминутный сеанс. Отсюда и пустота в журнале соединений ядра. Магазин по-прежнему показывает «LOADING»: мешок не загрузился.

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

entered    1 returned    1 -[AMSBagNetworkTask _createRequestWithQueryItems:]
entered    0 returned    0 четыре шага, которые дописывают запрос (куки, заголовок витрины,
                           заголовки anisette, -[AMSURLSession _prepareRequest:properties:error:])
entered   12 returned   12 -[AMSMutablePromise continueWithBlock:]
entered    5 returned    5 -[AMSMutablePromise thenWithBlock:]
entered    1 returned    1 -[AMSMutablePromise _finishWithResult:error:logDuplicateFinishes:]

Ничего не висит. _createRequestWithQueryItems: возвращается сразу — промисом, и этот промис никто никогда не завершает. За сеанс к промисам привязано семнадцать продолжений и завершён ровно один, и по адресу это не промис запроса:

PROMISE 0x101709240 waited on (continue)
STEP _createRequestWithQueryItems: -> <AMSMutablePromise: 0x10170b930>
PROMISE 0x10170b930 waited on (then) / (continue)
PROMISE 0x101414370 finished          <- единственный, и другой

То есть запрос мешка вообще не изготавливается: шаг, который его изготовил бы, ждёт чего-то, что не приходит, — оттого и ни одного добавленного заголовка, ни запрошенной сессии, ни задачи, ни resume. Догадка про anisette не подтвердилась вместе с остальными тремя: ни один из четырёх шагов не вызывается.

И потоки объясняют почему — это больше, чем история про мешок. Та же прогулка по кадрам стека, что у сторожа аварий, но по всем потокам демона (task_threads + thread_get_state), на 150-й секунде: все одиннадцать потоков ждут (состояние 3), причём один — простаивающий рабочий libdispatch (__workq_kernreturn), так что потоков очередям хватает. Четыре стоят в одном и том же месте:

-[AMSBagValue valueWithError:] -> -[AMSPromise resultWithError:] -> resultBeforeDate:error:
  -> -[NSConditionLock lockWhenCondition:beforeDate:] -> -[NSCondition waitUntilDate:]

то есть поток, заблокированный до загрузки мешка. Адреса через ipsw dyld a2s называют круг:

-[AMSBagNetworkTask performFetch]_block_invoke
  -> -[AMSBagActiveAccountProvider bagStorefrontForAccountMediaType:]
    -> _bagAccountForAccountMediaType:mustContainStorefront:
      -> -[ACAccountStore _ams_localiTunesAccountAfterUpdatingStorefront:]
        -> -[AMSPromise resultWithError:], навсегда

а промис, которого он ждёт, завершается продолжением _fetchLocaliTunesAccountForAccountType:shouldUpdateStorefront:, выполняющим _updateStorefrontInLocalAccount:, — и оно само заблокировано в resultWithError: на другом потоке. Поток, который загрузил бы мешок, ждёт обновления учётной записи, которое ждёт мешка.

Та же прогулка разъяснила и загадку помельче: куки добавляет четырёхаргументный ams_addCookiesForAccount:clientInfo:bag:cleanupGlobalCookies: внутри -[AMSURLRequestEncoder requestByEncodingRequest:parameters:], поэтому перехват трёхаргументного никогда и не срабатывал.

Если отвечать на _ams_localiTunesAccountAfterUpdatingStorefront: сразу (библиотека так и делает), это ожидание снимается — _bagAccountForAccountMediaType: становится «3 входа, 3 выхода» вместо заблокированного, — но мешок всё равно не грузится: четыре потока по-прежнему стоят в resultBeforeDate:error: в других местах. Значит, дело не в одном методе: промисы AMS в этом демоне не завершаются вообще.

Корень, и он вне AMS. Вместо того чтобы узнавать следующий круг глазами, библиотека ведёт граф промисов: каждый промис запоминается по адресу — где впервые увиден (кадры стека), сколько к нему привязано продолжений, какие потоки стоят в нём синхронно и завершался ли он вообще; таблица печатается на 150-й секунде рядом с дампом потоков. За сеанс — шестнадцать промисов, семь из них помечены «никто его не исполняет», среди них и запрос мешка, и сама выборка мешка. Спуск по потокам помеченных промисов выводит за пределы AMS:

thread 4: mach_msg2_trap <- _AMSFairPlayGetDeviceType <- _CjHbHx <- (три функции FairPlay)

— поток AMS, заблокированный в Mach-вызове в FairPlay и ждущий ответа. А в журнале launchd того же сеанса:

com.apple.fairplayd.H2: 34 запуска, каждый — «Service only ran for 0 seconds»,
                        «exited due to exit(0)», «service throttled by 10 seconds»

fairplayd не падает: он стартует, решает, что здесь ему делать нечего, и сразу уходит — и так снова и снова. Ответа, которого ждёт AMS, не будет никогда; промис за ним не исполняется; семнадцать ожиданий стоят в очереди за этим одним. Тупик с витриной сидит поверх этого, а не рядом.

А нужен ли FairPlay вообще, чтобы просто смотреть магазин? Нужен — ровно для одного запомненного once-ответа, и если ответить на него, затор расходится весь. Вершина той стопки — обычный метод Objective-C, -[AMSUserAgent _sharedComponentFairPlayDeviceType], так что подменить его стоит одного method_setImplementation. Значение подставляется заведомо неправдоподобное (REDWOOD-INJECTED-NOT-A-FAIRPLAY-DEVICE) и в журнале помечено как подставленное — ноль или пустая строка уехали бы в строку User-Agent и в тракт Mescal и выглядели бы данными. И тогда в том же сеансе, где раньше был один исполненный промис на семнадцать ожиданий:

BAG FETCH attempt 0, storefront 143469-2,8   затем   attempt 1, storefront 143469-2,29
STEP _createRequestWithQueryItems: -> промис, URLSessionPromise -> промис,
dataTaskPromiseWithRequest: -> промис,
TASK RESUME (NSURLSessionTask) <AMSURLRequest> { URL: https://bag.itunes.apple.com/bag.xml?... }
22 промиса завершены, 45 синхронных ожиданий вошли и 45 вышли, ни одного «STILL IN IT»

Запрос уходит в сеть, Apple отвечает, витрина, которую ставит сервер, возвращается, и мешок грузится заново уже с нею (-2,8 → -2,29 — это и есть применённый заголовок x-set-apple-store-front). «Timed out while waiting for snapshot bag to load» исчезло совсем; осталось два необязательных ключа, которых в мешке нет. То есть fairplayd — корень, но не стена: магазину не нужен работающий FairPlay, нужен ответ на один вопрос о нём.

Библиотеку приходится класть и в appstored, и в AppStore.app: у приложения свой экземпляр AppleMediaServices, и оно встаёт там же. Первый прогон с приложением выглядел точно как прогон без него — потому что у приложения, запущенного SpringBoard, на стандартной ошибке нет ничего; с тех пор библиотека пишет прямо в /dev/console. Ещё одна тишина, означавшая «не тот канал», а не «не случилось».

Что осталось — и сказано это теперь по ответам, а не по запросам: «забирает содержимое и всё равно LOADING» — это четыре разные истории (запрос не завершается; завершается ошибкой; завершается телом, которое отвергает отрисовщик; завершается нормально и никто не рисует), и различает их одна строка. Каждая запущенная задача запоминается и на отчёте опрашивается:

AppStore:  task 0 COMPLETED status 200,  56859 байт, application/json,  bag.xml?…product=AppStore…
           task 1 COMPLETED status 200,  69263 байт, application/json,  (он же, перезагруженный)
           task 2 COMPLETED status 200, 177786 байт, application/octet-stream,
                    https://apps.mzstatic.com/content/…/ios-appstore.jetpack
           task 3 COMPLETED status 200, 177786 байт, application/octet-stream, (ещё раз)
appstored: task 0 COMPLETED status 200,  43713 байт, application/json,  bag.xml
           task 1 COMPLETED status 200,  52654 байт, application/json,  bag.xml

Не падает ничего. Главная страница приходит целиком — 177 786 байт, — а на экране всё равно «LOADING». Значит, история четвёртая: содержимое приходит, и его никто не рисует. Страница эта не код UIKit в приложении: ios-appstore.jetpack — документ для JetEngine, а JetEngine здесь есть на томе (System/Library/PrivateFrameworks/JetEngine.framework, рядом _JetEngine_SwiftUI.framework) и это фреймворк, а не демон — пропавшего процесса, на который можно свалить, нет, приложение рисует само. Это и есть следующее расследование, и оно уже названо. Первые шаги в нём уже сделаны: тот же документ в 177 786 байт скачивается дважды — так ведёт себя то, что загрузили, отвергли и попробовали снова, — а подписанного провала JetEngine при этом не объявляет: за сеанс приложение рассылает 169–197 уведомлений, и ни одного про jetpack, подпись, проверку или brotli (единственное от JetEngine вообще — JetEngine.Unstable.Accounts.didChangeNotification, то есть движок жив и работает). JEBrotliDecoder в процессе App Store есть (в appstored его нет) и отвечает на -decoder и -decompressStreamWithAvailableIn:nextIn:availableOut:nextOut:, перехват на него встал, и ни одной строки о распаковке так и не появилось. Со счётчиками, напечатанными отдельной строкой и рано (а не одной из шестидесяти в общем отчёте), ответ получен — и он снимает сразу три подозрения:

hakios: AppStore: JETPACK processStream: in
hakios: AppStore: JETPACK processStream: out, a result, error (none)
hakios: AppStore: KEY brotli entered 0 returned 0, processStream entered 2 returned 2
hakios: appstored: KEY brotli entered 0 returned 0, processStream entered 0 returned 0

Распаковывает и разбирает пакет не JetEngine, а образ JetPack: -[JPReader processStream:signatureVerifier:options:error:] входится дважды — по разу на каждую загрузку — и оба раза возвращает результат без ошибки. То есть страница приходит целиком и разбирается без возражений: дело не в подписи, не в формате пакета и не в brotli (он не вызывается вообще). Опыт с подменой +[JPSignatureVerifier defaultSignatureVerifier] на +unsafeIgnoreSignatureVerifier (Apple держит проверяльщик, который игнорирует подписи, для своих испытаний) поэтому не нужен: проверять нечего, она и так проходит, а образ, не проверяющий подписи, — вещь куда худшая, чем опыт, который их не проверяет.

Единственное исключение C++, которое вообще бросают в этих процессах, — std::future_error (перехвачен __cxa_throw, имя типа лежит вторым словом в его std::type_info). Посчитанное, оно выглядело уликой: 95 бросков в appstored и 68 в App Store за сеанс против 4 и 2 NSException, а «семнадцать ожиданий на один исполненный промис» — ровно та форма, которую дал бы поток сломанных обещаний. Проверено и опровергнуто. Кадры стека первых бросков всегда одни и те же:

std::make_exception_ptr<std::future_error>
  <- std::promise<std::tuple<PromiseResult>>::~promise
    <- std::__shared_ptr_emplace<AMSCore::Future<PromiseResult>::FutureImp>::__on_zero_shared
      <- std::shared_ptr<...FutureImp>::~shared_ptr
        <- -[AMSPromise .cxx_destruct]

и все они укладываются в первую четверть секунды жизни процесса (0,11–0,26 с), задолго до любой загрузки. Это освобождение AMSPromise: деструктор std::promise, уничтожаемого без значения, обязан положить в общее состояние broken_promise — так велит стандарт, а make_exception_ptr здесь способ это положить, а не способ пожаловаться. Сто шестьдесят три штуки за сеанс — это стиль AMS, а не поток отказов; само число не значит ничего, и связь с «никто не просит содержимого» не подтвердилась.

App Store рисует свой интерфейс. Когда последние ожидания AMS были сняты (см. ниже) и перехват перестал ронять сам демон, магазин прошёл свой первый запуск по шагам и нарисовал их все: — карточка «Welcome to the App Store» с кнопкой «Continue»; — выбор про персонализированную рекламу («Turn Off» — честный ответ для телефона без учётной записи); — собственно магазин: панель вкладок Today, Games, Apps, Arcade, Search, выбрана Today. Это JetEngine, рисующий тот самый документ, который мы видели приходящим целиком. Содержимое вкладки Today на этом снимке ещё грузится.

Дальше магазин говорит, чего ему не хватает, сам: после онбординга он рисует свою панель вкладок и показывает в теле «Cannot Connect to App Store» с кнопкой Retry (). И это не сеть. В таблице задач по-прежнему те же пять запросов (мешок трижды, jetpack дважды), а среди всех ошибок, которые приложение создаёт за сеанс, нет ни одной из NSURLErrorDomain — то есть ничего сетевого не падало, запрос за содержимым просто не делается. Что приложение создаёт: AMSErrorDomain 204 (значения нет в мешке) — 114 раз, AMSErrorDomain 7 — 20, 1 — 18, 3 — 10, 300 — 6, AMSSQLiteErrorDomain 1 — 16, NSCocoaErrorDomain 4099 (XPC-соединение оборвано) — 24, 260 — 10, NSPOSIXErrorDomain 2 — 14, com.apple.AppSSO.AuthorizationError −1000 — 4. Ненайденных служб у AppStore и appstored при этом нет ни одной.

И 4099 ведут туда же, куда всё остальное ночью, — в fairplayd. NSCocoaErrorDomain 4099 — это NSXPCConnectionInterrupted, а не 4097 (NSXPCConnectionInvalid): собеседника не «не нашли», он умер посреди разговора, что сходится с полным отсутствием failed lookup у магазина. Кто умирает, видно в том же журнале: за прогон умирает ровно одна служба — com.apple.fairplayd.H2, двадцать шесть раз, каждый раз exited due to exit(0) и «Service only ran for 0 seconds». У AppStore прерванных соединений за тот же прогон — двадцать четыре. А appstored при этом жив: его собственные отчёты идут непрерывно с 315-й по 510-ю секунду, часы не сбрасываются, то есть он не перезапускался ни разу (счётчик стартов для этого не годится — строка старта попадает в выброшенную середину журнала, а монотонные часы в повторяющемся отчёте годятся).

Отсюда связная картина: запрос за содержимым требует подписи FairPlay, служба подписи умирает мгновенно, соединение рвётся, запрос не изготавливается — и приложение показывает «Cannot Connect to App Store», ни разу не сходив в сеть. То есть и здесь корень тот же: fairplayd надо удержать живым, и это по-прежнему та же маленькая ограниченная задача, что и утром.

И отсюда объяснение прежней загадки: за содержимым не ходили потому, что приложение стояло на первом экране приветствия и ждало нажатия, а не потому, что что-то сломано. Прогоны до этого нажимали на иконку и смотрели на «LOADING», не понимая, что под ним — карточка, ожидающая ответа. После трёх нажатий (Continue, Turn Off, и сам вход во вкладку) запросов становится пять вместо четырёх.

Старое наблюдение, теперь объяснённое: содержимого приложение не просило вообще. В отчёте о задачах, прочитанном не на тот вопрос, под который он строился, видно: App Store делает ровно четыре запроса за сеанс — и так в трёх сеансах подряд: дважды мешок и дважды apps.mzstatic.com/.../ios-appstore.jetpack. Но jetpack — это интерфейс магазина, а не его содержимое; элементы главной страницы приходят отдельной лентой, и такого запроса не делается ни одного. Значит, правильнее не «страницу разобрали и не нарисовали», а никто никогда не просит того, что рисовать. Вопрос переезжает с «почему JetEngine не рисует документ» на «кто должен был запросить содержимое, когда документ загрузился, и почему не запросил».

Что до самого рисования: 177 786 байт приходят, JPReader разбирает их в результат, и ничего не появляется. Кто берёт результат дальше — видно по кадрам стека на выходе из processStream: (ipsw dyld a2s называет хотя бы образ): обратно в JetEngine, /System/Library/PrivateFrameworks/JetEngine.framework/JetEngine, в функции Swift, имён которых в кеше нет. Отсюда и продолжать, и это работа статическая — по той же проторённой дорожке с Ghidra.

И это оправдано, потому что последние ожидания в приложении сняты. Граф показывал, что приложение исполнено не всё: 192 промиса, два потока стоят каждый на двух, которых никто не исполняет, — один в -[ACAccountStore _updateStorefrontInLocalAccount:], другой в ams_addCookiesForAccount:clientInfo:bag:cleanupGlobalCookies: внутри -[AMSURLRequestEncoder requestByEncodingRequest:parameters:] (та же семья, что и тупик в appstored; осталась потому, что снявший тот тупик перехват отвечает только на _ams_localiTunesAccountAfterUpdatingStorefront:), а -[AMSBagValue valueWithError:] стоял на 92 входах и 91 выходе. Оба ответили сразу — обновление витрины отдаёт местную учётную запись, куки отдают пусто (для устройства без учётной записи это законный ответ, и в журнале обе строки помечены как подставленные), — и в следующем прогоне:

entered   89 returned   89 -[AMSBagValue valueWithError:]
entered  104 returned  104 -[AMSPromise resultBeforeDate:error:]
и ни одного «STILL IN IT» во всём приложении

Все ожидания завершаются, страница по-прежнему приходит и по-прежнему разбирается без ошибки — и на экране по-прежнему «LOADING». Значит, рисование не ждёт от AppleMediaServices ничего: подозреваемые вне JetEngine кончились, и чтение его Swift статикой теперь обосновано, а не предположено.

И SwiftUI тут ни при чём: виджеты «Погода» и «Календарь» на домашнем экране — это WidgetKit, то есть SwiftUI, и своё содержимое они на этом телефоне рисуют. Значит, мешает что-то своё именно этому пути.

Значит, работа всё-таки не в AMS: понять, почему fairplayd.H2 выходит с exit(0) сразу, и удержать его живым; подмена выше — опыт, показывающий, что это починит.

Что fairplayd делает за свои десять миллисекунд (userland/src/inject_fairplay.c — библиотека в нём самом, только наблюдает, через подмену вызовов __DATA,__interpose): читает флаги возможностей CoreFoundation и containermanagerd, открывает свой Info.plist — и уходит. Ни одного обращения к IOKit (IOServiceMatching, IOServiceGetMatchingService, IOServiceOpen, IOConnectCallMethod), ни одного xpc_connection_create_mach_service, ни sysctlbyname, ни dlopen. Обработчик atexit срабатывает — значит, ушёл сам; но ни exit, ни _exit, ни abort подменёнными не оказались, то есть main просто вернулся. А выборка счётчика команд главного потока (13–25 проб по полмиллисекунды) вся лежит внутри его собственного двоичного файла (0x1000d9a40…0x1008565ac, то есть смещения 0xd9a40…0x8565ac), ни одной пробы в общем кеше.

Отсюда важное: он решает кодом, который не перехватить, и системные вызовы, видимо, встроены прямо в него — чего и ждёшь от обфусцированного FairPlay. И он не спрашивает IOKit вообще, так что заглушка com_apple_driver_FairPlayIOKit в ядре — а это и был напрашивающийся следующий шаг — просто не была бы достигнута. (Право com_apple_driver_FairPlayIOKitUserClient у него в подписи есть, оттого и казалась.) Проверено и отброшено ещё до прогона: дело не в флаге platform binary — у iOS свои двоичные файлы подписаны ad hoc, и это ядро помечает именно такие (bsd/kern/kern_minimac.c), так что fairplayd здесь платформенный.

Проверено и то, не в флагах ли возможностей дело — для этого у каждого перехвата печатается и результат («прочитал флаги» и «попытался и получил ENOENT» без него выглядят одинаково): четыре файла, которые на томе есть, открываются успешно (Global.plist, GlobalDisclosures.plist, Domain/CoreFoundation.plist, Domain/containermanagerd.plist → fd 3), а все -1 — это файлы, которых нет и на обычном iPhone (/Library/Preferences/FeatureFlags/…, Settings.plist, Profiles.plist и /usr/sbin/Info.plist, которого нет ни у одного демона без бандла). Ничего нужного образу не не хватает: уходит он не из-за флагов.

Значит, следующий прибор — по ту сторону стены: ядро наше, и журнал системных вызовов процесса fairplayd.H2 видит сквозь встроенные вызовы, которые сводят на нет подмену в пользовательском пространстве. Второй путь — прочитать двоичный файл по проторённой в этом проекте дорожке (ею разбирали CMCapture): Ghidra из ~/.local/share/ghidra, импорт с -noanalysis, проект в ~/ghidra-projects (в пути не должно быть компонента с точкой), ~/.local/share/ghidra/tools/dec.sh для декомпиляции по символам, рядом got.py, cfstr.py, callers.py; ipsw dyld — только dump, a2s, a2f. Окно узкое: смещения 0xd9a40…0x8565ac в /usr/sbin/fairplayd.H2, искать ранний возврат из main и строки того, с чем он сравнивал прямо перед этим — именно сравнение, а не неудавшийся вызов.

Ещё два предупреждения, добытые дорого. Право в подписи говорит, что программе можно, и никогда — что она делает: у fairplayd в подписи есть com.apple.security.iokit-user-client-class = com_apple_driver_FairPlayIOKitUserClient, и ровно поэтому заглушка драйвера выглядела очевидным следующим шагом; IOKit она не трогает ни разу. И mkjobs.py выбрасывает KeepAlive из заданий (в остальном наш plist совпадает с Apple'овским поле в поле, MachServices включительно) — здесь это безвредно, launchd всё равно поднимает fairplayd по требованию, но знать стоит: демон, который должен оставаться резидентным и которого не держат живым, выглядит точно так же, как демон, который сам уходит.

Две вещи, которые стоили по прогону и которые стоит знать тому, кто будет писать следующую такую библиотеку. class_getInstanceMethod идёт вверх по цепочке, и подмена метода, который класс только наследует, подменяет его всем остальным наследникам: подмена категории NSObject перезапустила appstored 31 раз за прогон. И исключение Objective-C кончается abort(), а не SIGSEGV, так что сторож аварий, который слушает только сигналы памяти, о такой смерти не скажет ничего (теперь слушает SIGABRT, SIGILL и SIGTRAP тоже).

mobileactivationd — ещё одна служба из списка «никогда не пробовали». Её спрашивал как раз appstored (четыре ненайденных обращения за сеанс и двадцать строк «[HRNSupport] Failed to copy activation record ... MobileActivation.ErrorDomain Code=-1 "IPC Error"» из каждого потока, который трогает мешок). Теперь она в образе по требованию: ненайденных обращений нет ни одного, демон поднимается один раз за сеанс и не падает (199–200 процессов против 198 без неё), а учётный слой получает вместо обрыва связи настоящий ответ — Code=-8 «Device is not activated: Unactivated». Дальше этого здесь не пройти: сам демон пишет «Failed to bump UIK: 0xe00002c7», потому что цепочка активации растёт из Secure Enclave, которого у этого телефона нет. Магазин это не открывает: мешок с ней по-прежнему не запрашивается. Проверено заодно и то, не в пропущенном ли ассистенте настройки дело — порт его отключает (src/inject_sb.c обнуляет -[SBSetupManager _setSetupRequiredReason:]). Не в нём: файл com.apple.purplebuddy.plist с SetupDone = True мы и так кладём, а MobileGestalt и так отвечает RegionInfo = LL/A.

Телефония (CommCenter) — пробовали, не вышло. Следующей по числу обращений была она: com.apple.commcenter.xpc SpringBoard спрашивает десять тысяч раз за все сеансы. iOS держит два задания с одним и тем же именем и выбирает по дереву устройств, и узла baseband у нас нет, так что launchd сам берёт com.apple.CommCenter-noncellular — тот вариант, который предназначен для устройства без модема. Он и берёт. CommCenter доходит далеко: отвечает на com.apple.commcenter.xpc и .coretelephony.xpc (85 и 18 ненайденных обращений за сеанс становятся нулём), отдаёт geod его политику, загружает настройки сетевых расширений. А потом падает — каждый раз, на одном из двух драйверов IOKit сотового тракта, которых в этом ядре нет: «Could not find AppleIPAppender service» (пользовательский клиент ApplePDPHelperInterface — контексты PDP, потоки по классам трафика, выделенные несущие) и через 50 мс SIGSEGV, либо «Unable to communicate with AppleBaseband driver» (com.apple.driver.AppleBasebandPCI) и SIGABRT. 18 аварий за семиминутный сеанс против нуля у того же образа без него; загрузку это не задерживает (118 процессов против 119), но процесс, умирающий каждые 15 секунд, того не стоит. Заглушить первый драйвер — работа того же рода, что HakiosBattery; второй — это модем Apple на PCIe, а модем этого телефона сидит на самом кристалле и говорит по QMI с подсистемой Qualcomm, так что за драйвером нет и того, к чему он ведёт. Текущий план подключения MPSS и проверка аппаратных ресурсов: docs/cellular.md.

Остальные из того же списка «никогда не пробовали». cloudd и mobileassetd не падают, и 42 ненайденных обращения за сеанс с ними исчезают (com.apple.cloudd — 26, com.apple.mobileassetd.v2 — 16). И всё-таки домашний экран с ними не появляется: загрузка доходит до 76–78 процессов против 119, и ни одного процесса виджета или постера, тогда как без них за те же 420 с поднимаются Spotlight, PosterBoard и десяток виджетов. Сначала это было списано на эмулируемый процессор, и здесь было написано, что на телефоне, где восемь настоящих ядер, цены, скорее всего, не будет. Телефон измерили — там хуже: тот же рецепт, те же четыре с половиной минуты, 42 процесса против 128, домашнего экрана нет, приложения, которые сценарий нажимает, не открываются вовсе. Дело оказалось не в процессоре. cloudd без остановки переспрашивает com.apple.nsurlsessiond — 2351 раз за сеанс на телефоне, 120–406 в QEMU, против десятка в сеансе без него, — потому что сам nsurlsessiond стоял в том же DENY тремя строками выше. В копии sileo2 его оттуда уже убрали (без него у приложения не начинается ни одна фоновая загрузка — Sileo вечно показывает «Queued»); убран он теперь и здесь. Что nsurlsessiond сам по себе ничего не стоит, видно по телефонному сеансу той копии: 118 процессов и 20 виджетов. С nsurlsessiond в образе cloudd загрузку больше не держит: шторм исчезает совсем (ноль переспросов против 406), и домашний экран приходит — 100 процессов и 12 процессов виджетов и постеров против 76–78 и ни одного за весь сеанс без него. То есть cloudd и не был виноват: виновато было ожидание демона, которого не было. Проверено и на телефоне: с cloudd и appstored в образе и с nsurlsessiond рядом сеанс даёт 130 процессов и 20 процессов виджетов и постеров без единой аварии — лучше, чем образ без них (128 и 19). Оба теперь не в DENY. Что именно доказано про каждого, — а это меньше, чем «работает»: appstored точно работает, он запускается, и com.apple.appstored.xpc.request, единственная служба, которой App Store не находил, когда его открывали пальцем, теперь находится (ноль ненайденных обращений против одного). Про cloudd доказано только, что он больше не вредит: в том телефонном сеансе его никто ни разу не спросил (ноль обращений против 2352, когда он крутился), так что польза от него не показана. Вместе с cloudd из списка уходит и itunescloudd — его ни в одном измеренном сеансе никто не спрашивал. syncdefaultsd (com.apple.kvsd) падает сам по себе: отдаёт cloudkeychainproxy3 его хранилище и аварийно завершается внутри changeTokenForStoreWithConfiguration:reply: — «-[NSURL initFileURLWithPath:isDirectory:]: nil string parameter», то есть строит файловый URL из пути, который ему дали пустым. Первым подозрением был каталог SyncedPreferences, который он запрашивает по имени и которого здесь никто не создаёт; это проверили — build.sh умеет сделать пустой каталог на томе данных строкой d в var.skel, как он делает это для /var/root/.ssh, — и дело не в нём: с созданным /private/var/mobile/Library/SyncedPreferences syncdefaultsd аварийно завершается ровно там же и ровно тем же исключением. Значит, пустым приходит что-то другое, и следующая догадка — путь контейнера com.apple.security.cloudkeychainproxy3, а не каталог пользователя. bird и duetexpertd собрались в образ без происшествий, но за пустую загрузку их никто ни разу не спросил, так что они не проверены, а не хороши: нужен сеанс с приложениями. Наконец, единственный случай, который выглядел второй, независимой причиной, оказался той же самой: ALLOW="syncdefaultsd appstored" тоже терял домашний экран (77 процессов, ни одного виджета), причём безо всякого шторма переспросов, — но тот прогон был собран ещё до того, как nsurlsessiond ушёл из DENY. С ним в образе та же пара доходит до 112 процессов и 15 процессов виджетов и постеров, включая PosterBoard. Шторм — только у cloudd; syncdefaultsd и appstored ничего не переспрашивают, они просто ждут того, кто ждёт nsurlsessiond. Отсутствие симптома не означало отсутствия болезни.

GL=1 REST=1 userland/build.sh — образ, в котором оконный сервер рисует через OpenGL ES, а не программным растеризатором CoreAnimation: за EAGLContext встаёт Mesa (прослойка — userland/src/eagl.c). На телефоне с параметром ядра gpu=1 рисует сам Adreno: драйвер freedreno из Mesa говорит с ядром (drivers/gpu/hakios.gpu.cpp) на языке линуксового msm DRM. Без него и в QEMU библиотека не загружается, и образ ведёт себя как собранный без GL=1 (gl=1 — всё же загрузить, с программным softpipe: для отладки прослойки). Mesa в репозитории нет: tools/mk-mesa.sh один раз скачивает и собирает её (нужны meson, ninja, bison, flex). GL=test RC_JOBS=rc.jobs.gltest — только библиотека и gl_test: один треугольник на экран.

После пересборки ядра/загрузчика/программ tools/update-prebuilt.sh обновляет prebuilt/.

UEFI для телефона

См. uefi/README.md (порт Project Silicium на redwood). Для запуска пересобирать не нужно: mkxnuimg.py берёт готовое из devices/redwood/prebuilt.

Как это устроено

fastboot boot ──► UEFI (Mu-Silicium, redwood) ──► xnuloader (EFI-приложение)
                                                    │  остаётся резидентным: USB-канал (протокол fastboot),
                                                    │  сторожевой таймер, приём журнала от ядра
                                                    ▼
                         XNU (конфигурация BCM2837 как основа + своя платформа)
                           ├─ GICv3, таймер, видеоконсоль, возврат в загрузчик
                           ├─ APFS (образы в ОЗУ, том iOS на флеше), ramfs (/private/var, /tmp)
                           ├─ minicrypto вместо corecrypto, заглушки AMFI и Sandbox (MAC-политики)
                           ├─ замены драйверов: IOSurface, IOMobileFramebuffer, AppleKeyStore, IONetworkStack
                           ├─ ввод: /dev/hakios-touch ◄─ Goodix GT9916 (телефон) / virtio-input (QEMU)
                           └─ звук: /dev/hakios-audio ─► virtio-sound (QEMU) / запись в память (rec_sound)
                                                    ▼
              launchd iOS 16.5 → logd, notifyd, cfprefsd, runningboardd, backboardd, SpringBoard, приложения
                           ├─ в backboardd, SpringBoard и mediaserverd внедряются наши библиотеки
                           │  (libredwood*.dylib): виртуальные HID-службы (сенсор, кнопки) и подмена
                           │  нескольких ObjC-методов
                           └─ mediaserverd получает наше устройство вывода (HakiosAudio.driver — подключаемый
                              модуль CoreAudio), которое пишет смикшированный звук в /dev/hakios-audio

Программы и библиотеки iOS (исполняемые файлы, фреймворки, общий кэш dyld) не изменены ни в одном байте: всё, чего им не хватает, либо делает ядро (замены драйверов), либо библиотека, которую ядро подсовывает через DYLD_INSERT_LIBRARIES (sysctl kern.exec_inject) в backboardd, SpringBoard, mediaserverd и containermanagerd и которая меняет там кое-что только в памяти. Из файлов iOS изменены два шрифта: AppleColorEmoji-160px.ttc и PingFang.ttc урезаны по размеру (tools/font_trim.py, лишние размеры и начертания выброшены), чтобы поместиться в образ. Задания launchd собирает userland/mkjobs.py из заданий iOS, а настройки в userland/var — наши файлы.

Что где лежит

Путь Что
xl управление телефоном по USB: загрузчик, запуск ядра, журнал, снимки экрана, запись экрана и звука
xnuloader/ загрузчик: loader.c (UEFI-приложение), mkpayload.py (контейнер «ядро + образ + аргументы»)
xnu/ наши правки XNU: патч, новые файлы, apply.sh/export.sh
xnu-deps/ всё для сборки XNU на Linux: setup.sh, обёртки инструментов, заглушки
xnu-build.sh, xnu-env.sh сборка ядра
userland/ наш init, вставки (src/inject.c — backboardd, src/inject_sb.c — SpringBoard, src/inject_media.c — mediaserverd), звуковое устройство (src/audio_plugin.c), тесты, сборка образов
sysimage/ добыча файлов iOS из IPSW и выбор того, что идёт в образ
qemu/ запуск в QEMU, касания, снимки экрана, разбор стеков
uefi/, mkxnuimg.py порт UEFI на redwood и упаковка загрузочного образа для fastboot boot
tools/ recording.py (запись экрана → mp4), sound.py (запись звука → wav), update-prebuilt.sh, new-workspace.sh (вторая рабочая копия со своим ядром и QEMU), mk-mesa.sh (Mesa для GL=1), mk-ath11k.sh (драйвер Wi-Fi для ядра), mk-jb.sh (бутстрап Procursus и Sileo, см. «Sileo и пакеты»), apfs/ (APFS на компьютере: чтение — «Чтение APFS», запись — «Запись в APFS», сборка образов mkapfs.py и их проверка check-image.sh — «Только APFS, как на iPhone»), darwin-write.py (запись в раздел darwin и только в него — «Криптексы на флеше»), qmi-test/ и wpa-test/ (проверки на компьютере)
wifi/lx/ прослойка, на которой драйвер Wi-Fi из Linux собирается для нашего ядра (см. «Драйвер Wi-Fi»)
jb/ префикс менеджера пакетов: бутстрап Procursus с Sileo (tools/mk-jb.sh), в git не попадает
prebuilt/ собранные ядро, загрузчик, наши программы и куски UEFI

Скрипты не зависят от того, куда склонирован репозиторий.

Отладка

  • Журнал сеанса: stdout QEMU или logs/ЧЧММСС-run.log с телефона. Драйвер экрана раз в 5 с пишет частоту кадров, драйвер сенсора раз в минуту — сводку касаний, ядро раз в минуту — строку memory: (куда ушла память и при каком остатке оно начнёт убивать процессы).
  • RC_JOBS=rc.jobs.tour REST=1 userland/build.sh — образ со сценарием: сам разблокирует экран и по очереди открывает все приложения первой страницы (проверка стабильности и памяти, сеанс ~9 минут).
  • userabort: в журнале — стек процесса, вызвавшего abort(); usertrap: (с systrace=1) — аварии.
  • Стеки зависшего процесса на лету (QEMU): qemu/touch.py stacks ИМЯ, затем qemu/symstack.py ЖУРНАЛ ИМЯ (нужен ipsw). qemu/touch.py stacks top — кто занимал процессор с прошлого такого запроса (строки top: в журнале): «тормозит» система или чего-то ждёт, на экране не отличить.
  • userland/jobinfo.py [-s СЛУЖБА | МЕТКА…] — штатные задания launchd и кто даёт Mach-службу.
  • OSLOG=all userland/build.sh — os_log всех заданий на консоль (очень многословно).
  • Если что-то ждёт вечно — смотрите не только на то, чего launchd не нашёл, но и на то, что он перезапускает. Служба, которую launchd успешно запустил и которая сама вышла с exit(0), не даёт ни одной строки failed lookup: он её нашёл и поднял, клиент послал сообщение и ждёт ответа, которого не будет. Тем способом, которым нашлась apsd (счёт ненайденных обращений), такая форма не находится в принципе — и её не замечают, потому что свидетельство тут есть отсутствие свидетельств. Так полдня искали, почему пуст App Store: com.apple.fairplayd.H2 — 34 запуска за шесть минут, каждый «Service only ran for 0 seconds», exited due to exit(0), «service throttled by 10 seconds». Громкий сигнал там, куда никто не смотрел. Ищите в журнале Service only ran for, throttled by и exited due to.
  • /dev/console на телефоне теряет строки под начальным потоком launchd, и QUIET=1 этого не чинит (7755 строк launchd за 320 с против 8013 за 263 с без него). Поэтому: каждый шаг сценария — отдельной командой, измерения — после того как старт утихнет, а «в журнале этого нет» само по себе не доказывает ничего.

Как врут собственные проверки

Одиннадцать способов, которыми проверка врёт, и один приём против них; каждый за 19–20 октября стоил прогона или неверной строки в этом файле. Записаны потому, что одиннадцать из двенадцати выглядят одинаково: отсутствие сведений читается как отсутствие проблемы.

  • Пропуск без счёта. continue, который ничего не прибавляет к итогу, превращает «не проверено» в «проверено и сошлось». Десять объявлений AIR числились совпавшими, сравнившись на одном случае из четырёх (остальные три пропускались по бесконечному ожиданию); if idx >= len(flat): continue тихо выбрасывал короткие строки; read() в фиксированный буфер терял хвост файла. Сумма при этом сходилась — 68 + 3 + 7 = 78, — потому что отложенные были посчитаны как совпавшие. Ловит не сумма, а сумма, в которой у каждого исключения своя графа с именем и числом. Крайний случай той же беды — проверка по пустому множеству: цикл по нулю итераций печатает «0 из 47» и выглядит ровно как честный ноль. Однажды так и вышло — список ядер лежал в каталоге, вычищенном между сеансами, — и спасло только то, что оболочка отдельно сказала «нет такого файла». Знаменатель лжёт так же тихо, как слагаемое.

  • Заглушённая команда. patch -s --dry-run вернул 0 и промолчал, и молчание прочлось как «применяется точно»; без -s там стояло succeeded at 410 (offset 2 lines).

  • Вывод по отсутствию. «Нет в журнале» и «не было» — разные вещи: журнал загрузчика теряет середину (признак — размер на пределе и точная строка the log between the first 256 KB and the last 1 MB was dropped, а не слова was dropped: их пишет и драйвер кнопок). Так же читается поле зрения grep: шесть совпадений bin/sh были /var/jb/bin/sh, а «у xl ramdisk нет защиты» — защитой двадцатью строками выше. И у молчащего прибора надо сперва доказать, что он умеет говорить: сторож, который «не сработал ни разу», доказывает что-нибудь только вместе с числом исполнений (у нас вышло 26 из 26 с нулевым смещением); а замер, напечатавший ноль строк, однажды оказался grep по шаблону, которого printf не выводил никогда — замер, который не может сработать, выглядит точно как событие, которого не было. Поймался тот ноль не сомнением в приборе, а противоречием с уже измеренным: три модуля транслируются благодаря привязке указателей, значит нуля там быть не может. Когда контроля нет, его роль играет согласованность с прежними числами — и работает она только если прежнее записано числами, а не словами.

  • Пара без контроля. «До и после» без «до и до» отвечает не на тот вопрос: 10 835 отличающихся пикселей выглядели следствием патча, пока два прогона на одной сборке не дали 13 738.

  • Своё дерево вместо предмета. Сравнивать с чужим деревом — только из чистой распаковки: своя копия Mesa пропатчена (202 487 байт против 202 144), и расхождение нашлось бы «у них».

  • Устаревший прибор в одной колонке. В таблице по корпусу колонка «переводится» считалась фронтендом от 10:57, а колонка «компилирует ir3» — бинарником от 09:16: ir3_compiler не входит в цель по умолчанию, и обычная пересборка его не трогала. Вышло «315 переводится, 313 через ir3», и это складывалось в правдоподобную историю — «ir3 двух чего-то не умеет», — пока не выяснилось, что сообщение об отказе было текстом, удалённым из фронтенда часом раньше. На деле 315/315. Две цифры одной таблицы, измеренные разными поколениями прибора, дают не ошибку, а находку, которой нет. Лечится отказом, а не предупреждением: corpus.py теперь не запускается, если ir3_compiler старше любого исходника фронтенда.

  • Верная жалоба не о том. Отладочный прибор может сообщать правду, не относящуюся к делу, и этим закрыть настоящую причину. Когда в AIR три модуля падали в nir_repair_ssa, я предложил прогнать nir_validate_shader: он выдал четыре ошибки про индексы блоков — и ровно те же на модуле, который транслируется безупречно. Это состояние неструктурированного impl до lower_goto_ifs, безвредное; приняв жалобу за ответ, чинили бы индексацию и не сдвинулись бы. Настоящая причина (значения, созданные в impl точки входа и использованные в колбэке) нашлась печатью, а не валидатором. Отсюда правило: у любого диагностического прибора сперва снять показания на заведомо исправном входе — без этого его вывод не отличить от ответа.

  • Прибор, подменённый во время измерения. check.sh шёл, и под ним пересобрали air2nir. Прогон вышел с кодом 0 — и проверял бинарник, которого к концу уже не существовало. От устаревшего прибора это отличается тем, что там он был старым и честным, а здесь валиден до и после и не тот же самый в середине; зелёный цвет при этом неотличим от настоящего. Лечится тем же, чем всё остальное: измерение — на дереве, которое никто не трогает, пока оно идёт.

  • Утверждение, которое некому опровергнуть. Фронтенд AIR отказывался переводить вызовы функций, определённых в самом модуле, и в комментарии стояло основание: системным шейдерам это не нужно, инлайнер Apple всё уплощает. Неправда — так делают шесть точек входа, пять из них вычислительные ядра. Держалось оно не потому, что никто не смотрел, а потому, что проверить было нечем: пока air_run не умел отправлять вычислительные ядра, эти пять никто не мог запустить. Ложное утверждение, прикрытое отсутствующей возможностью, живёт дольше всех прочих: его не ловит ни контроль, ни сумма, ни чтение кода — только появление того самого прибора, которого не было.

  • Исправление, которое никто не перепроверяет. Точность теряется чаще не в первом утверждении, а в том, которым его чинят: исправление приходит с чувством добросовестности, и потому на него не смотрят теми же глазами. Здесь это выглядело так: сняв с себя «порог две текстуры», автор поставил на его место «среди сорока наверняка есть читающие две» — догадку, в том же абзаце, где каялся за рассуждение вместо числа, и при том что числа уже лежали в журнале, который он же научил их печатать. Их оказалось восемь с двумя текстурами и два с тремя, и все десять отправлялись и до починки, то есть порога не было вовсе. Правило простое: исправленное утверждение проверять так же, как исходное, и по тем же данным.

  • Прибор, у чьей ошибки есть сторона. grep по битовому коду LLVM на имя глобала находит ноль всегда: битовый код упакован по битам, и имя совпадает только когда случайно ложится на границы байтов. То есть любой счёт по корпусу, сделанный таким греп'ом, может только недосчитать — и его ноль неотличим от честного. Так в документе и появилось «два модуля объявляют constexpr sampler» там, где их десять. Польза от знания стороны двойная: ноль от недосчитывающего прибора ничего не значит, а ненулевой его ответ — нижняя граница, и это уже факт. Прежде чем считать чем-нибудь, стоит спросить, в какую сторону оно ошибается.

  • Написать для того, кто будет проверять. Самый дешёвый из шести: переформулировать утверждение для того, кто станет его перепроверять. Контроль стоит прогона, переформулировка — ничего, а ловит то же самое: «проверено на main» рассыпалось на «проверен один файл из двух» в ту минуту, когда понадобилось отдать файл соседней сессии. Цена у приёма всё же не нулевая: нужен тот, кому отдаёшь. В одиночку проверяющего приходится воображать, а это хуже всего получается именно тогда, когда уверен в своей правоте, — так что это скорее про устройство работы, чем про самодисциплину.

Драйвер Wi-Fi

Радио телефона (WCN6750) в Linux обслуживает драйвер ath11k. Мы его не переписываем, а собираем как есть: tools/mk-ath11k.sh скачивает ath11k из Linux 7.0.14 (kernel.org) и исходники FreeBSD 15.1 (заголовки LinuxKPI, с которыми FreeBSD собирает драйверы Linux), сверяет контрольные суммы, накладывает wifi/lx/patches/*.patch на заголовки FreeBSD и компилирует файлы драйвера без единой правки, как код ядра FreeBSD. Чего заголовкам не хватает и всё, что под ними (блокировки, сон, очереди работ, таймеры, память для радио, буферы, файлы прошивки, шина радио), — наше, в wifi/lx/, и стоит на двух с небольшим десятках вызовов, которые даёт ядро (bsd/sys/hakios.lx.h, drivers/wifi/hakios.lx.c). Получается один объектный файл xnu-deps/build/ath11k/out/ath11k.o с единственным видимым именем; xnu-build.sh линкует его в ядро, если он есть, а без него ядро собирается как раньше и на wifi_lx отвечает, что драйвера в нём нет.

Собрано восемнадцать файлов драйвера из трёх с лишним десятков — всё, что нужно станции (core, hal, qmi, ce, hw, fw, htc, wmi, mac, reg, peer, dp, dp_rx, dp_tx, hal_rx, hal_tx, dbring, p2p); заглушек не осталось (если появятся, их список — out/stubs.txt после сборки). Над драйвером вместо mac80211 из Linux стоит наша станция (wifi/lx/src/lx_80211.c): регистрация радио, регуляторный домен, один интерфейс, сканирование и подключение к сети WPA2 с паролем; арифметика рукопожатия — отдельно, в wifi/lx/src/lx_wpa.c, и проверяется на компьютере (tools/wpa-test/run.sh: примеры из стандартов и целые рукопожатия против точки доступа, написанной на Python). Кому нужна сеть, тот просит станцию не напрямую, а через ядро (bsd/sys/hakios.sta.h, drivers/wifi/hakios.sta.c: один поток, по одному вызову за раз, и список того, что услышало последнее сканирование): так при старте подключается сеть из файла, и так же ядро отвечает службе Wi-Fi iOS на запросы Apple80211 (drivers/wifi/hakios.apple80211.c). Прерываний радио драйвер не получает: кольца опрашивает поток, раз в миллисекунду и сразу снова, пока есть работа. Что это делает на телефоне, сказано в строке wifi=N таблицы параметров ядра.

Чтение APFS

Сначала корневая файловая система у нас была не APFS: sysimage/select-system.py выбирал нужные файлы iOS из уже распакованного sysimage/rootfs/, а mkpackfs.py складывал их в наш образ packfs (только чтение, целиком в ОЗУ). Чтобы ядро читало настоящий образ iOS как есть, нужен был свой APFS; теперь он — единственная файловая система образов (см. «Только APFS, как на iPhone»). Пишется он снизу вверх, и каждый слой сначала проверяется на компьютере — в ядро идёт только то, что уже сошлось с настоящим образом.

Описание формата — bsd/miscfs/apfs/apfs_format.h ядра (в репозитории — xnu/files/bsd/miscfs/apfs/apfs_format.h), написано по «Apple File System Reference» Apple. Копия одна: по этому же заголовку проверяются и инструменты на компьютере, и будущий драйвер. Имена структур с приставкой apfs_, чтобы ничего не задеть внутри ядра.

Инструменты в tools/apfs/ (Python, как и остальные сборщики образов в репозитории):

dmg.py sysimage/rootfs.dmg — не файловая система, а образ диска UDIF: сначала сжатые куски, потом список блочных таблиц в XML, а в последних 512 байтах — хвост koly, который говорит, где этот список. Читает любое место распакованного образа и вынимает из него раздел. Виды кусков: как есть, нули, zlib, bzip2, LZMA, ADC, LZFSE (в нашем образе — LZFSE и «как есть»)
apfs.py контейнер → контрольная точка → карта объектов → том → B-деревья → inode, каталоги, экстенты, расширенные атрибуты, дырки, сжатие decmpfs. Fletcher-64 проверяется у каждого прочитанного блока метаданных. Команды: info, ls, tree, cat, stat, census
verify.py сверка с чужим читателем: тот же образ при распаковке читал 7-Zip, и от него остались sysimage/rootfs.slt (список всего тома) и sysimage/rootfs/ (распакованные файлы)
run-tests.sh всё, что проверяется на компьютере, одной командой
write-tests.sh то же для записи: 81 проверка apfs_write.c против apfsck, apfs.py, 7-Zip и списка изменившихся блоков — «Запись в APFS»
txn-probe.py настоящая транзакция APFS на образе mkapfs.py: одна запись изменена и зафиксирована новой контрольной точкой — «Запись в APFS»
mkstore.py том хранилища: слоты постоянной длины, выделенные на всю длину одним экстентом — «Хранилище»

Распаковать раздел и проверить (образ нужен целиком, около 5,3 ГБ, в git он не кладётся):

tools/apfs/dmg.py extract sysimage/rootfs.dmg /куда/rootfs.apfs --apfs
tools/apfs/run-tests.sh /куда/rootfs.apfs

Что в образе iOS 16.5 (20F66, iPhone 10,3): один том SydneyF20F66.D22D221OS на 5 628 755 968 байт, блок 4096 байт, без шифрования и без пломбы (не sealed), 142 299 файлов под 318 098 именами (остальное — жёсткие ссылки), 136 413 каталогов, 2324 символьные ссылки. Сжатие только zlib и в двух видах: тип 9 — байты лежат в самом атрибуте com.apple.decmpfs без сжатия, тип 4 — zlib кусками по 64 КБ в com.apple.ResourceFork. Ни LZVN, ни LZFSE, ни LZBITMAP внутри файлов не встречается.

Что проверено на компьютере:

  • 119 смещений полей и 19 размеров структур: apfs_format.h против того, по каким смещениям читает apfs.py (заголовок компилируется, программа печатает offsetof, Python сверяет);
  • хвост UDIF и таблица разделов — против того, что о том же файле говорит 7-Zip;
  • все 318 098 записей тома против списка 7-Zip: путь, права, владелец, группа, размер, цель символьной ссылки и номер inode совпали все до одной, и ни один путь не увидел только один из двух читателей;
  • все 43 228 файлов, которые 7-Zip тогда же распаковал, — побайтово: 2,81 ГБ, совпало всё; из них 16 138 сжаты zlib в ресурсной вилке и 18 403 лежат в атрибуте;
  • 36 287 блоков метаданных — Fletcher-64 верен у каждого.

Чего проверить не на чем: разреженных файлов в этом томе нет вовсе (обход всех 318 098 записей не нашёл ни одного inode с INODE_IS_SPARSE), поэтому чтение дырок проверено на придуманных списках экстентов — test_sparse.py, девять случаев. Шифрование, снимки и Fusion не читаются совсем.

Чтение формата есть и в ядре — отдельно от всего, что знает про vnode, чтобы его можно было собрать программой для компьютера и прогнать по настоящему образу прежде, чем оно попадёт в ядро:

bsd/miscfs/apfs/apfs_lib.{h,c} контейнер, контрольная точка, карты объектов, B-деревья, записи, inode, каталоги, символьные ссылки, расширенные атрибуты, экстенты и дырки, decmpfs. Про vnode не знает ничего. Проверяет всё по дороге: Fletcher-64 у каждого блока метаданных, каждое смещение и длину внутри узла, длину каждой записи против полей, вынутых из неё, — на порче чтение кончается EIO, а не диким указателем
bsd/miscfs/apfs/apfs.h, apfs_vfsops.c, apfs_vnops.c прослойка до VFS: монтирование, vnode на объект (ищется по хешу номеров), lookup, getattr, read, readdir, readlink, getxattr, listxattr, pagein. Только чтение. apfs_lib выдаёт указатели в свой кеш блоков, а его курсоры сами не блокируются, поэтому каждый вызов в него — под замком тома
tools/apfs/apfs-tool.c, build-tool.sh тот же apfs_lib.c, без правок, собранный программой для компьютера. Её census печатает по строке на запись каталога — путь, вид, права, владелец, группа, размер и хеш содержимого — в том же виде, что verify.py clines, так что два читателя сравниваются построчно

В таблице файловых систем ядра он стоит под именем apfs (раньше — перед packfs): устройство с настоящим контейнером узнаётся как APFS, а на любом другом apfs_mountroot отвечает EINVAL, и vfs_mountroot() спокойно пробует следующую.

Две вещи, которых в спецификации Apple нет, а образ их показал:

  • таблица кусков в ресурсной вилке лежит не по постоянному смещению, а на четыре байта дальше того смещения данных, что записано в её заголовке HFS+ (с обратным порядком байтов): здесь это 260, а не 256;
  • спецификация даёт две формы ключа записи каталога и не говорит, какая используется. Хеш, который она описывает, нужен только для поиска имени без учёта регистра и нормализации, и этот том (normalization-insensitive) его несёт — значит, хешированный ключ там, где включено одно из этих двух свойств. Неверная догадка не пройдёт молча: длина имени не сойдётся с длиной ключа и запись будет отвергнута.

Как это проверяется в QEMU

Том на 5,6 ГБ не влезает в память гостя так, как влезал образ packfs, да и смысл чтения APFS в том, чтобы читать настоящий образ, а не урезанный. Поэтому гостю даётся блочное устройство:

drivers/storage/hakios.virtio_blk.c virtio-blk по шине virtio-mmio машины virt QEMU, в том же виде, что соседние драйверы ввода, сети и звука. /dev/rvblk0, только чтение, параметр ядра virtio_blk=1. По одному запросу за раз, опросом, без прерываний: запросы — это промахи кеша блоков самого APFS, а хост отвечает на них из своего страничного кеша
drivers/storage/hakios.apfs_check.c монтирует /dev/rvblk0 и обходит том через VFS (vnode_lookup, vnode_getattr, VNOP_READDIR, VNOP_READLINK, vn_rdwr), печатая на запись ту же строку, что apfs-tool census и verify.py clines. Параметры ядра: apfs_check=1 — только итоги, =2 — каждая строка, apfs_subtree=/путь — часть тома. 318 098 строк — это 47 МБ через последовательный порт, поэтому по умолчанию каждая строка хешируется отдельно, а хеши складываются: сложению всё равно, в каком порядке обход видел записи. verify.py sum считает то же самое на хосте
tools/apfs/dmg.py extract sysimage/rootfs.dmg /куда/rootfs.apfs --apfs
CMDLINE="virtio_blk=1 apfs_check=1" qemu/run-ios.sh 1200 \
  -drive file=/куда/rootfs.apfs,format=raw,if=none,id=apfsd,readonly=on \
  -device virtio-blk-device,drive=apfsd
python3 tools/apfs/verify.py sum /куда/rootfs.apfs      # с чем сравнивать

Один прогон тома целиком в QEMU против хоста (всё слева пришло из ядра через VFS):

в QEMU на хосте
записей 318 098 318 098
каталогов 136 413 136 413
файлов 179 361 179 361 (имён, то есть жёсткие ссылки посчитаны по разу на имя)
символьных ссылок 2324 2324
байт содержимого 8 285 771 220 8 285 771 220
сумма хешей строк ceb8a53c349a011f ceb8a53c349a011f
ошибок 0 —

Сумма берётся по строке на запись, в которой путь, вид, права, владелец, группа, логический размер и хеш содержимого, — значит, сошлось всё это для всех 318 098 записей, и все 8,29 ГБ содержимого прочитались побайтово верно (142 299 файлов, почти все сжатые zlib, и 2324 цели ссылок). Обход занял 416 с. Ничего больше в сеансе не случилось: ни паники, ни жалоб virtio-blk, и строка memory: ядра не сдвинулась от начала к концу (1451 МБ свободно, 2803 МБ wired) — то есть чтение 8,29 ГБ не течёт ни блоками, ни vnode. iOS рядом с этим монтированием продолжала загружаться до backboardd как обычно.

Отображение в память и скорость

vnop_pagein обходом внутри ядра не задействуется: тот читает через vnop_read, а система тогда грузилась с packfs, так что с тома APFS ничего не исполнялось. Чтобы страничная дорога заработала, файлы нужно отобразить в память из пространства пользователя: это делает userland/src/apfs_mmap_test.c — проходит каталог на монтировании и считает хеш каждого обычного файла либо через отображение (тогда байты приносит vnop_pagein), либо через read(2) кусками заданного размера. Печатает строку на файл, так что строки сверяются с хостовой читалкой. userland/rc.jobs.apfs запускает его тремя способами по одному набору файлов; в ядре для этого есть apfs_check=3 — смонтировать и не обходить, оставив том пространству пользователя.

Проверено этим: через отображение прочитаны 1189 файлов и 567 629 521 байт каталога /usr, и путь, размер и хеш содержимого у каждого совпали с тем, что даёт verify.py clines; то же для 23 файлов, прочитанных кусками по 4096 байт. Ошибок 0 в обоих случаях. То есть vnop_pagein не только вызывается, но и приносит верные байты.

Скорость мерилась по одному набору (/usr/sbin: 32 файла, 50 096 208 байт в распакованном виде) на трёх ядрах, отличающихся только кешированием, на одной машине и с одним образом:

дорога без кешей кеш куска и таблицы плюс кеш inode
отображение (vnop_pagein) 23 761 мс 30 885 мс 24 530 / 23 151 мс
read(2) по 4096 байт 54 469 мс 48 815 мс 35 254 / 38 206 мс
read(2) по 65 536 байт 17 330 мс 29 456 мс 17 140 / 15 947 мс

Во всех прогонах прочитаны одни и те же байты: сумма хешей строк всюду 3095b9e298a4df17, ошибок 0. Два числа в последнем столбце — два прогона одного и того же ядра: разброс от прогона к прогону около ±8 %, потому что машина общая. На этом фоне:

  • мелкие чтения стали быстрее на треть (54 469 → 35 254…38 206 мс) — это единственная дорога, которая и правда делала одну работу много раз; больше половины выигрыша даёт кеш inode, а не куска;
  • крупные чтения не изменились (17 330 → 15 947…17 140 мс), и так и должно быть: чтение на 64 КБ всегда накрывало кусок целиком, повторять было нечего;
  • отображение тоже не изменилось (23 761 → 23 151…24 530 мс). Значит, опасение, с которого всё началось — будто подкачка распаковывает кусок заново на каждую страницу, — было неверным: ядро просит у vnop_pagein по 32 768 байт за раз при странице 16 384 (драйвер печатает это один раз за сеанс), то есть 64-КБ кусок закрывается двумя запросами, и кеш может убрать самое большее одну распаковку из двух. Время уходит не туда;
  • средний столбец веры не заслуживает: его 17 330 → 29 456 → 17 140 по крупным чтениям не может быть следствием добавления кеша, тот прогон делил машину с чужой работой.

Чтение целым экстентом: пробовали, стало медленнее

Казалось, что время уходит на запросы к устройству: apfs_read_stream берёт данные файла блоками через кеш блоков, то есть по 4096 байт на запрос virtio, а у virtio-blk в полёте один запрос. Это проверено — struct apfs_dev получила read_bulk, драйвер делал на запрос один VNOP_STRATEGY со своим буфером, мимо кеша и прямо в память вызывающего, а read_stream звала её для целых блоков в середине запроса, оставляя кешу только невыровненные концы.

Число запросов и правда упало: по всему тому на хосте блоков метаданных прочитано 1 441 810 → 238 864, а 4 776 120 320 байт данных файлов пришли 128 588 запросами — по 37 КБ вместо 4 КБ, примерно в девять раз меньше. Но быстрее не стало:

дорога блоками через кеш целым экстентом
отображение (vnop_pagein) 24 530 / 23 151 мс 30 614 / 32 080 мс
read(2) по 4096 байт 35 254 / 38 206 мс 36 007 / 32 551 мс
read(2) по 65 536 байт 17 140 / 15 947 мс 23 288 / 17 922 мс
обход всего тома в ядре 415 903 мс 449 860 мс

При разбросе одного и того же ядра около ±8 % дорога отображения медленнее на 30 % в обоих прогонах — это вне разброса; дороги read(2) перекрываются и не говорят ничего; обход всего тома медленнее на 8 %, то есть на границе. Читалось при этом всё верно: суммы хешей совпадают во всех прогонах, обход тома по-прежнему даёт 318 098 записей, 8 285 771 220 байт содержимого и сумму ceb8a53c349a011f при 0 ошибок.

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

Поэтому изменение откачено (в истории оставлено: коммит «read a file's data a whole extent at a time», чтобы к нему можно было вернуться для устройства, где запрос стоит заметно дороже).

Куда уходит время: ожидание устройства

В драйвере есть счётчики, которые разводят время по трём местам; читаются и обнуляются через kern.apfs_stats (чтение печатает, запись чего угодно обнуляет), а apfs_mmap_test обнуляет их перед каждой дорогой и печатает после. По /usr/sbin (32 файла, 50 096 208 байт в распакованном виде), один прогон:

отображение read по 4096 read по 65 536
всего (из пространства пользователя) 33 294 мс 43 200 мс 25 551 мс
внутри vnop 31 206 мс 41 453 мс 25 229 мс
ожидание устройства 29 303 мс 39 263 мс 24 261 мс
распаковка 829 мс 630 мс 647 мс
запросов к устройству 8662 8310 8313
байт с устройства 35 479 552 34 037 760 34 050 048
попаданий в кеш блоков 12 694 100 948 13 652

На всех трёх дорогах ответ один: 94–96 % времени — ожидание устройства, распаковка — 2–3 %. На работу самого драйвера и на механизм подкачки остаются считанные проценты. Каждая дорога забирает одни и те же ~8300 блоков по 4096 байт и получает с устройства 1,2–1,4 МБ/с — вот куда уходит всё.

Это же числами объясняет, почему проиграло чтение целым экстентом: против ~8300 забранных блоков стоят 12 694–13 652 попадания в кеш блоков, то есть около 60 % всех обращений за блоком до устройства вообще не доходили. Чтение середины запроса мимо кеша превращало эти попадания в запросы к устройству — а устройство как раз и есть то единственное, чего стоит избегать.

На живом железе

Целиком том на 5,6 ГБ на телефоне не удержать (по частям его прочитать можно — см. ниже), и не из-за APFS: в ядре нет драйвера собственного накопителя телефона — блочных устройств в нём всего два, диск в памяти и virtio (а он только в QEMU), — а самая большая пригодная область памяти телефона, по его же прошивке (xl memmap), 4288 МБ против 5368 МБ у тома. Передним куском тоже не обойтись: apfs-tool печатает, до какого блока дотягивается чтение, и даже один небольшой каталог /usr/sbin дотягивается до блока 1 339 121, то есть на 5,49 ГБ вглубь — нужные блоки размазаны по всему образу. Положить образ в раздел флеша не поможет по той же причине: читать его нечем, пока нет драйвера накопителя (это отдельная и большая работа, зато она же дала бы порту постоянное хранение).

Зато влезает другой настоящий том APFS: восстановительный ramdisk iOS 16.5. Это IM4P, контейнер начинается с 27-го байта — 58 880 блоков по 4096, ровно 241 172 480 байт; том называется ramdisk, в нём 1497 файлов, 582 каталога и 307 символьных ссылок, а содержимого в них 1,24 ГБ в распакованном виде. Если отдать его ядру как корневое устройство, его монтирует vfs_mountroot через apfs_mountroot — вторая точка входа драйвера, которой до этого не пользовались ни разу.

Для этого есть apfs_check=4: обойти корневую файловую систему — ничего не монтируя и не делая точки монтирования, потому что на телефоне пространства пользователя тогда не было (bsd_init запускал launchd только с нашего образа packfs). Обход идёт прямо в этом потоке, до этой попытки, и затем зовёт loader_return(), так что прогон кончается ответом, а не паникой. Обход не выходит за пределы тома (NOCROSSMOUNT, поэтому /dev считается тем пустым каталогом, какой он на этом томе, а не смонтированной на него devfs).

./xl ramdisk /куда/ramdisk.apfs
XL_CMDLINE="apfs_check=4" XL_SECS=120 ./xl run
python3 tools/apfs/verify.py sum /куда/ramdisk.apfs      # с чем сравнивать

Два прогона на телефоне (на одном ядре и с cpus=8) против того же тома, прочитанного на компьютере — обе хостовые читалки, кстати, сошлись на нём друг с другом построчно (2386 записей):

телефон хост
записей 2386 2386
каталогов 582 582
файлов 1497 1497
символьных ссылок 307 307
байт содержимого 1 239 229 501 1 239 229 501
сумма хешей строк 56dfa19788c9fd6d 56dfa19788c9fd6d
ошибок 0 —

То есть на живом железе сошлись все пути, вид, права, владелец, группа и размер, и все 1,24 ГБ содержимого. Паник не было ни в одном прогоне; cpus=8 ничего не изменил — правда, сам обход однопоточный, так что одновременные читатели по-прежнему не проверены.

А вот куда уходит время — наоборот, чем в QEMU. Тот же обход, 14 658 мс:

телефон (диск в памяти) QEMU (virtio-blk)
внутри vnop_read 9336 мс 41 453 мс
ожидание устройства 318 мс (3,4 %) 39 263 мс (95 %)
распаковка 7821 мс (84 %) 630 мс (1,5 %)

Устройство здесь даёт 620 МБ/с против 1,3 МБ/с там, и узким местом становится распаковка: 179 484 725 байт на входе, 1 228 638 464 на выходе, 19 137 вызовов — по одному на 64-КБ кусок, то есть уже минимум возможного. zlib на одном ядре, около 157 МБ/с, — это и есть скорость драйвера сейчас. Этим же закрыт и откаченный 5258aa2 для этого устройства: чтение целыми экстентами не может помочь, когда на устройство уходит 3,4 % времени.

Несколько читателей сразу

apfs_check=5 apfs_threads=N — N потоков обходят том одновременно, каждый целиком. На телефоне, тот же том, cpus=8:

читателей всё вместе вместе замок: ожидание замок: удержание удержание / время
1 5917 мс 204 МБ/с 15 мс 4092 мс 69 %
2 9998 мс 242 МБ/с 8060 мс 8016 мс 80 %
4 16 733 мс 289 МБ/с 42 795 мс 15 677 мс 94 %

Правильность: каждый читатель в каждом прогоне вернул 2386 записей, 1 239 229 501 байт и сумму 56dfa19788c9fd6d при 0 ошибок — замок своё дело делает. Но выигрыша почти нет: четыре читателя делают на 42 % больше одного, потому что весь apfs_lib идёт под одним замком, а при четырёх читателях он занят 94 % времени, и из 15 677 мс удержания 14 580 мс — это zlib. Распаковка шла под замком. Теперь не идёт, и не только она — см. «Скорость чтения» ниже: те же четыре читателя дают 1692 МБ/с вместо 289.

Настоящий том на телефоне — по частям

Целиком том на телефон не помещается, но обход читает не все его блоки, а те, что читает, можно сложить отдельно. tools/apfs/mksparse.py проходит нужную часть тома читалкой на компьютере, записывает каждый тронутый блок и выкладывает их вместе с таблицей «какой блок контейнера где»; drivers/storage/hakios.sparse.c (/dev/rsp0, параметр apfs_sparse=<смещение>) отдаёт их по их настоящим адресам, а драйвер APFS разницы не видит. Образ ехал в хвосте резидентного диска в памяти, за образом packfs, который был корнем (packfs читал ровно столько, сколько сказано в его суперблоке); apfs_check=6 монтирует /dev/rsp0 на каталог корня (apfs_mnt=/lib) и обходит его. Блок, которого в образе нет, читается нулями и считается — таких не было ни одного.

Всего обход тома трогает 1 282 462 блока, 5,25 ГБ, а у телефона 4,49 ГБ. Поэтому части: apfs_subtree — откуда начинать, apfs_skip — в какие каталоги не входить (их собственная строка считается), apfs_depth — до какой глубины; у mksparse.py и verify.py то же самое. Проверка — сумма хешей строк, так что части просто складываются:

часть записей байт содержимого сумма хешей строк образ
A: /, без /System 26 400 1 378 199 574 5bd06e9e45a89ce0 0,88 ГБ
B: /System, без PrivateFrameworks 192 769 3 990 734 011 1145708680be3303 2,40 ГБ
C: /System/Library/PrivateFrameworks 98 929 2 916 837 635 61a2c6176e33313c 2,02 ГБ
телефон, вместе 318 098 8 285 771 220 ceb8a53c349a011f
хост, весь том 318 098 8 285 771 220 ceb8a53c349a011f

Все три части сняты на телефоне одним и тем же ядром, ошибок 0, недостающих блоков 0; 24,5 с, 98,5 с и 61,6 с — на одном ядре (./xl run без cpus= даёт cpus=1) и на частоте, какую оставил прошлый прогон. С cpus=8 cpufreq=1 и нынешним драйвером те же части проходят за 3,0, 10,4 и 7,0 с с теми же суммами (см. ниже). То есть настоящий корневой том iOS 16.5 — тот, что нужен порту, — прочитан на живом железе целиком и совпал с хостом до байта.

Чтобы до этого дойти, пришлось выяснить, почему любой обход дольше полминуты сбрасывал телефон без следа в журнале. Не память (при обходе на 30 000 записей свободно 2229–2238 МБ, wired 2048–2058 МБ, ровно) и не драйвер: обход шёл прямо в загрузочном потоке, а тот стоит выше потоков, обслуживающих thread call, и обход с диска в памяти ни разу не засыпает — так что, пока он шёл, не выполнялся ни один thread call, включая тот, что раз в 10 с кормит сторожевой таймер телефона. В QEMU сторожа нет, поэтому там те же обходы проходили; проверено прямо там: таймер, который должен срабатывать раз в 6 с, за 72 с обхода не сработал ни разу. Теперь обход идёт в своём потоке обычного приоритета, а загрузочный спит, пока он не кончится: тот же таймер срабатывает 12 раз в QEMU и 10 и 16 раз в частях C и B на телефоне. Заодно остались apfs_max=N (остановиться после N записей), строка хода каждые 10 000 записей (время, память, vnode) и детектор зависания: если обход 6 с не двигается, он говорит, на какой записи и в какой операции, и возвращается в загрузчик с журналом раньше сторожа.

Отображение в память (vnop_pagein) этими обходами не проверяется — они читают через vnop_read; на телефоне оно проверено на томе с флеша (см. «Корневой том iOS с флеша»).

Скорость чтения

Всё ниже — на телефоне (QEMU по просьбе не трогал: на машине мало памяти; из QEMU есть только старая точка, 50,8 / 56,4 / 55,8 МБ/с для 1/2/4 читателей с zlib под замком). Мерит tools/apfs/bench-phone.sh "1 2 4 8" [аргументы]: apfs_check=5, N читателей обходят восстановительный ramdisk каждый целиком (2386 записей, 1 239 229 501 байт), одна строка на прогон — время, МБ/с всех вместе, ожидание и удержание замка, распаковка, суммы.

Две вещи, без которых числа врут:

  • cpufreq=1 (скрипт ставит сам). Без него ядра работают на той частоте, какую оставил прошлый прогон — загрузчик её не сбрасывает, — и одно и то же ядро вечером намерило 60,6 МБ/с против 204,5 днём (распаковка 13 167 мс против 3820, устройство 220 против 97 — всё медленнее в одно и то же число раз).
  • Проверка сама стоит времени. Каждый байт она прогоняет через FNV-1a по байту, и это 1550 мс из 3570 у одного читателя. apfs_hash=0 читает всё так же, но не хеширует (суммы тогда не те, с чем сравнивать: 778f98f13b268e4a), — так видна скорость самой файловой системы. apfs_chunk=N — сколько просит одно чтение (по умолчанию 64 КБ, один кусок), apfs_contents=0 — только обход и stat, без чтения содержимого.

Что сделано, по шагу, и что каждый дал (один читатель, apfs_hash=0, кроме первой строки; мс / МБ/с):

шаг 1 читатель 4 читателя 8 читателей
zlib под замком (было), с хешем 5916 / 204 16 731 / 289 36 933 / 262
zlib без замка (ecc4b2b), с хешем 6120 / 198 6588 / 735 13 373 / 724
libdeflate вместо zlib 1989 / 608 3164 / 1530 9339 / 1037
+ его Adler-32 на NEON 1632 / 741 3113 / 1555 9848 / 983
+ целый кусок — сразу в буфер читателя 1479 / 818 1939 / 2496 4858 / 1993
+ без выделения памяти на каждое чтение 1122 / 1078 1275 / 3795 2761 / 3507
+ данные файлов с устройства без замка 1020 / 1186 1122 / 4312 1976 / 4898
+ опережающее чтение (64 КБ за раз) 663 / 1825 1123 / 4310 1988 / 4869
+ поиск имени по хешу, деление пополам в узлах, ответы карты объектов 612 / 1977 1122 / 4312 1937 / 4996
  • libdeflate (MIT, tools/mk-libdeflate.sh) вместо zlib из libkern: один читатель распаковывает за 1248 мс вместо 3834, а с Adler-32 на NEON — за 856. Поток, который libdeflate не примет, уходит в zlib и считается («by zlib» в строке apfs: inflate) — таких 0 из 19 149 на ramdisk и 0 из 108 367 на всём корневом томе.
  • Целый кусок — сразу в буфер читателя: чтение 64 КБ по смещению, кратному 64 КБ, распаковывается прямо туда, а не в кеш куска vnode с копированием 64 КБ под замком. Удержание замка одним читателем 497 → 228 мс.
  • Без выделения памяти: распаковщик, буфер куска и 64-КБ буфер выделялись и освобождались на каждом чтении, а при таком размере ядро идёт за ними в VM. Теперь лежат на контейнере и на монтировании.
  • Данные файлов без замка: раньше они шли через кеш блоков метаданных по 4096 байт под замком (и вытесняли из него узлы B-деревьев). Теперь экстенты превращаются в куски на устройстве под замком (stream_plan), а читаются без него (stream_fetch, dev.read_data — один VNOP_STRATEGY прямо в память читателя). Кусок, лежащий одним куском на устройстве, — один запрос. Блоков метаданных на одного читателя 48 139 → 1181, удержание 153 → 56 мс. (В QEMU это же проигрывало — 5258aa2: там читалась мимо кеша только середина запроса и под замком.)
  • Куски одного запроса на нескольких ядрах: большой read(2) или подкачка многих страниц — это много целых кусков; они собираются (до 32), замок отпускается, и их читают и распаковывают вызывающий и потоки-помощники вместе (nx->parallel, в ядре — свои потоки в apfs_vfsops.c). Четыре потока на запрос (apfs_par): с восемью хуже, потому что четыре малых ядра тратят на кусок в 2,5 раза больше, а запрос ждёт самого медленного. Круг vnop_read — 1 МБ, когда читатель один, и 128 КБ, когда читают несколько: большой круг не помещается в кеш процессора до uiomove, и нескольким читателям это дороже, чем выгода от помощников (apfs_round). Помощники делятся между запросами в полёте: одному — все, двоим — по два, с трёх — никому.
  • Опережающее чтение: читатель, который просит кусок сразу за тем, на котором кончилось его прошлое чтение, и у которого есть свободные потоки (один на томе или один из двух), получает в той же пачке распакованными ещё семь следующих кусков — в буфер файла, откуда его следующие чтения копируют. Всё это внутри того самого запроса, в фоне ничего не идёт. Использованы все: 15 836 из 15 836.
  • Поиск имени по хешу: apfs_lookup читал каталог с начала до совпадения. На iOS у записи каталога в ключе 22-битный хеш имени, и записи по нему отсортированы; для имени из ASCII это CRC-32C по имени в UTF-32LE от всех единиц без инверсии в конце — сверено со всеми 317 749 ASCII-именами корневого тома (tools/apfs/test_namehash.py). Теперь и остальные: хеш любого имени — после канонической декомпозиции Unicode (NFD), на томе без учёта регистра — от канонической формы без регистра, тем же кодом ядра, что у Apple для APFS (utf8_normalizeOptCaseFoldAndHash, bsd/vfs/vfs_unicode.c, Unicode 9.0), и найденное под хешем сравнивается так же (utf8_normalizeOptCaseFoldAndCompare): другая композиция того же имени находит его. Каталог читается, только если имя не хешируется (не UTF-8). Проверено по всем 318 100 записям каталогов тома (apfs-tool namehash, 351 — не ASCII): хеш как записанный у всех; tools/apfs/test_namenorm.py считает их по-своему (unicodedata Python: NFD и NFD-casefold-NFD) — совпадает у всех, и с учётом регистра, и без; 14 018 поисков (apfs-tool lookupnames) — каждое не-ASCII имя в NFC, NFD, верхнем, нижнем, перевёрнутом регистре и с «#», и выборка остальных — все как ожидалось, все по хешу. Тома без учёта регистра нет, чтобы сверить с записанными хешами: его хеш сверен с независимым расчётом. Плюс деление пополам внутри узлов B-деревьев вместо перебора с начала и кеш ответов карты объектов (виртуальный id узла → блок). На компьютере обход всего тома без содержимого 2,1 → 0,59 с, все 318 098 поисков 762 → 142 мс; на телефоне часть C без содержимого 1972 → 857 мс.

Итог на телефоне, тем же ядром (МБ/с всех вместе; замок: ожидание / удержание, мс):

читателей с проверкой, по 64 КБ замок без хеша, по 64 КБ без хеша, по 1 МБ было (zlib под замком, с проверкой) замок было
1 551,7 10 / 147 1977 2372 204,5 15 / 4082
2 1009,3 67 / 272 2791 2790 244,5 7990 / 7946
4 1692,0 269 / 454 4312 3646 289,3 42 921 / 15 671
8 1945,3 7883 / 597 4996 4126 262,1 239 666 / 34 570

С проверкой каждый читатель каждого прогона вернул 2386 записей, 1 239 229 501 байт, 56dfa19788c9fd6d, 0 ошибок. Настоящий корневой том по частям (cpus=8 cpufreq=1, с проверкой): A / B / C — 7212 / 27 686 / 17 492 мс ядром ecc4b2b и 2989 / 10 358 / 7006 мс нынешним, весь том 52,4 → 20,4 с, суммы те же (ceb8a53c349a011f вместе); время внутри vnop_read 33,4 → 7,8 с, остальное — сама проверка (FNV по 8,29 ГБ) и обход.

Чего это не говорит:

  • подкачка страниц (vnop_pagein) мерилась только с флеша, не с диска в памяти: 32 файла /usr/sbin, 227 подкачек, 208 МБ/с (см. «Корневой том iOS с флеша»);
  • восемь читателей упираются в малые ядра и в пробуждения ожидающих замка (ожидание 2,5–7,9 с при удержании 0,2–0,6 с): само удержание уже мало, а очередь к замку остаётся;
  • опережающее чтение отнимает ядра у читателей, которые и сами заняты: четыре читателя с проверкой — 1756,8 МБ/с ядром до него (и до пачек, которые при чтении по 64 КБ не включаются) и 1634,2 с ним (−7 %); при одном и двух читателях — +19 % и +15 %;
  • имена не из ASCII и тома без учёта регистра ищутся по-старому, чтением каталога.

Запись в APFS

Драйвер APFS читает и не пишет, и из этого растёт остальное: /private/var — файловая система в памяти (bsd/miscfs/ramfs), которую каждая загрузка создаёт заново пустой, а чтобы хоть что-то пережило перезагрузку, дерево приходится паковать в архив и класть в раздел darwin мимо всякой файловой системы (ветка persist, userland/src/var_store.c). Если APFS научится писать, не нужно ни то ни другое, а у iOS появится том данных, которого она и ждёт. Здесь — первый шаг к этому и честная мера того, сколько до остального.

Чего стоит транзакция

APFS никогда не перезаписывает уже опубликованную структуру. Изменение — это транзакция: свежие копии всего от изменённой записи до суперблока контейнера, в свободном месте, и один блок, который делает их все живыми сразу. Для одной записи одного файла, снизу вверх:

что нужно обновить в заголовке драйвера
блоки данных файла ни в одном дереве
лист дерева файловой системы и каждый узел над ним до корня apfs_btree_node_phys, виртуальные объекты
по записи на каждый новый узел в карте объектов тома, ключ — (объект, транзакция); сама карта — дерево из физических узлов, значит и её путь до корня apfs_omap_phys, apfs_omap_key, apfs_omap_val
дерево ссылок на экстенты — если набор физических экстентов изменился apfs_j_phys_ext_val
новая копия суперблока тома и запись о ней в карте объектов контейнера apfs_superblock
менеджер пространства, на каждый выделенный и освобождённый блок: битовые карты чанков во внутреннем пуле, блоки сведений о чанках с их счётчиками свободного, итоги в самом менеджере и две очереди освобождения, которые держат освобождённые блоки, пока их может хотеть более старая контрольная точка тип APFS_OBJECT_TYPE_SPACEMAN
эфемерные объекты (менеджер пространства, reaper, очереди) — копиями в кольцо данных контрольной точки, и карта контрольной точки в кольцо описателей, которая говорит, куда каждый из них лёг apfs_checkpoint_map_phys
суперблок контейнера — последним, в следующий блок кольца описателей, с nx_next_xid на единицу больше и суммой Флетчера поверх всего apfs_nx_superblock

Последний шаг и есть фиксация — только потому, что apfs_nx_open перебирает всё кольцо описателей и берёт суперблок с самым большим номером транзакции, чья контрольная сумма сходится, молча проходя мимо всего остального (apfs_lib.c). Пока новый суперблок не лёг целиком, живёт прежняя контрольная точка и ничего из предыдущих шагов недостижимо; как только лёг — живо всё сразу. Это и вся защита APFS от пропадания питания, и она дешёвая — но лишь если все предыдущие шаги легли в блоки, которых прежняя контрольная точка не занимает, а за это отвечает менеджер пространства.

Что читатель уже понимает: заголовок объекта и его сумму Флетчера; кольцо описателей и выбор живого суперблока из него; обе карты объектов (только поиск по (объект, транзакция)); узлы B-дерева всех форм, что встречаются у томов; записи дерева файловой системы — иноды, записи каталогов, атрибуты, потоки данных, экстенты; отдельное дерево экстентов запечатанного тома; дыры; содержимое сжатых файлов.

Чего он не моделирует вовсе — даже определением структуры: менеджер пространства и каждую его часть (битовые карты чанков, блоки сведений о чанках, внутренний пул и его кольцо копий битовой карты, очереди освобождения) — слово встречается в apfs_format.h дважды, оба раза как константа типа объекта; кольцо данных контрольной точки; саму карту контрольной точки (в заголовке так и написано: нужна только менеджеру пространства, и путь чтения её пропускает); дерево метаданных снимков; дерево ссылок на экстенты; метаданные целостности и хеши файлов запечатанного тома. И никакого понятия о «грязном» объекте, о выделении блока, о транзакции: ничто в apfs_lib.c не держит блок, чтобы записать его позже.

Значит, самое малое изменение одной записи метаданных требует менеджера пространства, колец контрольной точки и транзакции — то есть почти всего записываемого APFS. Это и есть настоящая цена, и поэтому первый шаг сделан в другом месте.

Содержимое файла поверх себя самого

bsd/miscfs/apfs/apfs_write.c заменяет байты внутри уже существующего файла, не меняя ни его длины, ни его экстентов, ни какой бы то ни было другой структуры. Из таблицы выше не происходит ничего, кроме первой строки: пишутся только блоки, про которые экстенты самого файла и дерево ссылок на экстенты вместе говорят, что это содержимое именно этого одного файла.

Поэтому это безопасно при пропадании питания — и не потому, что атомарно: все структуры контейнера остаются точно такими, какими были, том монтируется, каждое дерево и каждая сумма сходятся, любой другой файл побайтово тот же. Единственное, что может остаться, — смесь старого и нового содержимого того файла, который писали; это потеря, которую вызывающая сторона в состоянии заметить и обработать, как var_store.c уже делает со своими сырыми блоками: два слота и сумма, записанная последней. Блоки идут по возрастанию смещения, и каждый читается обратно и сравнивается до того, как пойдёт следующий, — так что тот, кто кладёт свою сумму в последние байты, узнает, доехала ли она.

Отказывается — и всё это читается с самого тома до первого записанного байта:

  • устройство не умеет писать (dev.write = NULL, как при каждом монтировании этого драйвера);
  • том запечатан, имеет снимки или метаданные целостности (у запечатанного есть хеш содержимого каждого файла, а мы его не обновляем);
  • у тома нет дерева ссылок на экстенты — тогда ничто не может сказать, что блоки ни с кем не разделены;
  • это не обычный файл, или у него нет потока данных, или он сжат (его байты лежат в атрибуте или в ветви ресурсов, а размер объявлен в заголовке decmpfs);
  • запись вышла бы за конец файла (увеличение файла меняет его записи);
  • в диапазоне дыра (заполнить её — значит выделить блок, то есть всё из таблицы выше);
  • запись дерева ссылок на экстенты начинается не с первого блока экстента, покрывает меньше, чем экстент, названа принадлежащей другому потоку или имеет больше одной ссылки — клон, снимок или дерево, прочитанное не так;
  • блок вне контейнера или внутри блока 0, колец контрольной точки, карты объектов контейнера — граница, которая держит даже если дерево прочитано неверно.

Границ, таким образом, три, как у записей на флеш (drivers/storage/hakios.ufs.c): экстенты файла говорят, какие блоки его содержимое; дерево ссылок на экстенты говорит, что эти же блоки принадлежат этому потоку и больше никому; простая проверка диапазона отказывает всему вблизи собственных структур контейнера, что бы деревья ни сказали. И потом — чтение обратно.

Ещё одно правило — порта, а не APFS: том с ролью системного или preboot отвергается. Это стоит сказать вслух, потому что остальные проверки его не ловят. Корневой том iOS 16.5 в том виде, в каком его берёт порт (sysimage/rootfs.dmg, он же записанный в darwin), не запечатан, без снимков и без метаданных целостности (incompatible 0x8 — только нечувствительность к нормализации, роль 0x0001): он прошёл бы любую проверку APFS, какая здесь есть. На телефоне его хранит не она, а то, куда записи вообще разрешено идти: BLOCK_WRITE_FIRST загрузчика и ядро, у которого блочные устройства отказывают в любой записи.

Чего здесь нет: время изменения файла не трогается (устаревшее время — неправда, но не порча, а поставить его — это транзакция), а в ядре страницы файла в кеше пришлось бы выбросить тому, кто вызывает, — в ядре вызывающего пока нет, поэтому этого и не делается.

Чем это проверяется

tools/apfs/write-tests.sh — 81 проверка на этой машине, где испорченный образ ничего не стоит, и ни одна не на слово той программы, которую проверяют: образ собирается mkapfs.py, пишется ядерным же apfs_write.c (apfs-tool write, собранный для этой машины), а дальше спрашиваются все остальные.

кто смотрит что он такое
apfsck -cw linux-apfs/apfsprogs: суммы, все деревья, битовые карты менеджера пространства против каждого блока, на который хоть что-то ссылается, счётчики ссылок, экстенты. О нас не знает ничего
tools/apfs/apfs.py свой читатель на Python, уже сверенный с 7-Zip по всему корневому тому iOS (verify.py)
7-Zip третья реализация целиком
apfs-tool ядерный apfs_lib.c; единственный, кто частью и есть проверяемое, поэтому никогда не свидетель в одиночку
какие блоки образа изменились свидетель, которому читатель вообще не нужен: запись поверх себя обязана изменить блоки названного файла и ни одного блока больше

Одиннадцать групп. Записи: целый блок, пролёт через границу блока, середина мегабайта, один байт, последний байт файла нецелой длины. Отказы: за конец, в каталог, в сжатый файл, в файл нулевой длины. Запреты по тому — каждый ставится правкой суперблока тома: запечатан, снимки, метаданные целостности, роли системного и preboot. Запреты по блокам — каждый ставится правкой записи дерева так, чтобы она говорила то, чего здоровый том не говорит: дыра там, где лягут байты; экстент, переставленный в кольцо описателей; экстент, который дерево ссылок называет разделённым; экстент, который оно отдаёт другому потоку. Настоящие образы — что о них говорят запреты.

И ещё одно — устройство, которое соврало: --corrupt-after N после N блоков пишет не то, что ему дали. Этого не поймать ничем, что прочитано до записи, — поэтому каждый блок читается обратно и сравнивается прежде, чем пойдёт следующий, и запись останавливается на том же блоке.

И пропадание питания, ради которого всё и делалось: --fail-after N останавливает устройство после N блоков, и запись в двенадцать блоков прогоняется остановленной после каждого из них. После каждой apfsck по-прежнему находит контейнер чистым, любой другой файл побайтово тот же, а новые байты лежат ровно в тех блоках, которые уже успели записаться.

Транзакция: измерено, а не оценено

tools/apfs/txn-probe.py меняет одно поле одного инода и фиксирует это новой контрольной точкой на образе mkapfs.py; apfsck -cw после этого находит контейнер чистым. Двадцать таких подряд — тоже чистые, и это как раз то, что важно: в кольце описателей таких контрольных точек четыре, в кольце битовой карты внутреннего пула — шестнадцать, и там, где они заворачиваются, недопонятый менеджер пространства и разваливается.

Во что обошлась одна изменённая запись на самом простом томе, какой бывает (один чанк пространства, один блок сведений о чанках, по единственному узлу в каждом дереве) — 15 блоков и ещё один после:

блоков
узел дерева файловой системы 1
узел и объект карты объектов тома 2
суперблок тома 1
узел и объект карты объектов контейнера 2
битовая карта чанка и блок сведений о чанках — копиями внутри внутреннего пула 2
копия битовой карты внутреннего пула — в следующий слот кольца из 16 1
эфемерные объекты — копиями в кольцо данных 3
менеджер пространства — туда же 1
карта контрольной точки 1
суперблок контейнера — фиксация 1
копия в блоке 0, какую оставляет чистое размонтирование (не часть фиксации) 1

Фиксация держится: питание, пропавшее после каждого из шестнадцати блоков по очереди, каждый раз оставляет контейнер здоровым, и пока суперблок не лёг — живёт прежняя контрольная точка и том читается точно как раньше. Со старой копией в блоке 0 apfsck -w всё ещё находит контейнер здоровым, и только -c говорит — и правильно, — что том не был размонтирован чисто.

Чего проба не делает, и это и есть оставшееся расстояние: блоки, которыми пользовалась прежняя контрольная точка, помечаются свободными сразу, а не уходят в очередь освобождения с номером транзакции, — живая контрольная точка согласована, но к предыдущей вернуться уже небезопасно; и проба знает только ту форму, какую делает mkapfs.py, так что здесь ничто не делит и не сливает узел дерева, не выделяет из нескольких чанков и не ведёт reaper. Поэтому 15 блоков — это пол, а не оценка.

В ядре: тот же код на блочном устройстве

Всё перечисленное — apfs_write.c, собранный программой для компьютера. Что так не проверить — обвязку ядра: устройство, открытое на запись, buf_t и VNOP_STRATEGY на каждый блок, apfs_lib под ядерным распределителем памяти. Для этого есть apfs_check=7 (drivers/storage/hakios.apfs_check.c): он ничего не монтирует и ничего не обходит — заменяет байты в файле контейнера на apfs_dev, закрывает контейнер, открывает его заново с нуля и читает файл обратно, так что сказанное относится к устройству, а не к кешу блоков apfs_lib; и хеш, с которым он сравнивает, считается из файла, каким тот был до записи, а не из результата.

python3 tools/apfs/mkapfs.py -o apfs-work/wroot.apfs redwood=КАТАЛОГ    # с обычным, несжимаемым файлом scratch
IMG=apfs-work/wroot.apfs ROOT=apfs-work/wroot.apfs QEMU_RAM_GB=4 CMDLINE="apfs_check=7 apfs_dev=md1 apfs_write_path=/scratch apfs_write_len=8192 apfs_write_byte=165" qemu/run-ios.sh 60 > logs/apfswrite.log

В журнале:

apfs-write: /scratch on redwood, inode 18, 65536 bytes, hash 511049d0f82a1598
apfs-write: 8192 bytes of 0xa5 at 0: written; 2 blocks from 92, 0 of them patched whole, 2 writes
apfs-write: read back from a container opened again: 65536 bytes, hash 1b14170583dc71a3 (was 511049d0f82a1598);
            the bytes written are all there and nothing else changed

И то же на компьютере, на копии того же образа: apfs-tool write пишет те же два блока с 92-го, после чего apfs-tool, apfs.py и 7-Zip читают файл с хешем 1b14170583dc71a3 — тем самым, — а apfsck -cw находит контейнер чистым. Ядро и компьютер сходятся побайтово, и подтверждают это три читателя, ни один из которых не проверяемый код.

Куда это может дотянуться — и есть главное. Устройство открывается на запись только здесь: любое монтирование этого драйвера оставляет dev.write = NULL, а без него apfs_write_in_place отказывает во всём. И открыть можно только блочное устройство — флеш телефона под этим ядром не блочное (он читается через почтовый ящик загрузчика, а там, где в него пишут, — через свой символьный с собственными границами, drivers/storage/hakios.ufs.c ветки persist), так что отсюда до флеша не добраться. И в QEMU, и на телефоне блочные устройства — только диски в памяти. Больше того: md0 — смонтированный корень, и сама система не даёт открыть его на запись (EBUSY), так что под смонтированным томом запись не пройдёт даже случайно; проба пишет в md1, который не смонтирован. Проверено и то, что ядро отказывает: запись на 60000 от начала файла в 65536 байт — «the write would go past the end of the file», 0 блоков, 0 записей.

Хранилище: файлы постоянной длины вместо смещения в комментарии

Данные iOS переживают перезагрузку так: дерево /private/var пакуется в архив и пишется во флеш по числу, записанному в комментарии, — два слота по 2 ГиБ с байта 8 589 934 592 раздела darwin, прямо на символьное устройство (ветка persist: userland/src/var_store.c, drivers/storage/hakios.ufs.c). Запись там надёжная: её ограничивает ядро, ограничивает загрузчик, и загрузчик читает её обратно прежде чем ответить. Слабое место не запись, а адресация: на самом носителе ничто не говорит, что эти байты — хранилище, какой длины слот и что их не займёт что-то другое. Говорит только константа, в двух местах, которые обязаны совпадать.

bsd/miscfs/apfs/apfs_store.c кладёт те же байты в именованные файлы тома APFS, а где они лежат — берёт из записей самого тома: слоты — два файла корня тома, длина слота — длина его инода, и каждая запись идёт через apfs_write_in_place, который не тронет блок, пока экстенты файла и дерево ссылок на экстенты вместе не скажут, что это содержимое именно этого файла. Там, где стояла одна константа, стоят пять границ.

Транзакции для этого не нужны: файл слота создаётся один раз, постоянной длины, и дальше меняются только байты внутри него — ни одна запись тома не перезаписывается, значит нечего упорядочивать и нет окна, в котором том несогласован.

Как находится свежий слот — без транзакции. Порядок var_store переносится без изменений, потому что он никогда не зависел от носителя:

  • сохранение идёт в тот слот, который не держит свежий хороший заголовок;
  • первым делом блок заголовка этого слота забивается нулями — с этого момента и до конца слот не говорит ничего, и читатель возьмёт другой;
  • потом содержимое, с начала;
  • последним — заголовок, в отдельном блоке: номер поколения, длина содержимого, его сумма и сумма самого заголовка.

Заголовок — один блок, содержимое начинается со следующего, так что ни одна запись не задевает оба. Поэтому худшее, что может сделать незаконченное сохранение, — потерять само себя. Читатель берёт слот с наибольшим поколением, у которого сходится и сумма заголовка, и сумма содержимого; у надорванного не сходится одна из них, а слот до него сохранение не трогало. Суммы — FNV-1a на 64 бита, как и всюду в этом порте, где нужно знать, те ли это байты (tools/apfs/fnvsum.c, строки обхода); они здесь против надорванной записи, а не против злого умысла.

Том делает не ядро. Создать файл — это транзакция, и записываемый APFS для этого не нужен: tools/apfs/mkstore.py собирает том тем же mkapfs.py, которым собраны все остальные образы порта, со слотами, выделенными на всю длину — по одному экстенту, без дыр и без сжатия (дыра или сжатый файл означали бы выделение блока, то есть ровно то, чего мы избегаем). Этот образ один раз кладётся во флеш, в свободное место darwin за блоком, до которого и загрузчик, и ядро отказываются писать (BLOCK_WRITE_FIRST/UFS_WRITE_FIRST, блок 2 042 368), и дальше структура тома не меняется никогда. Цена идеи — этот один подготовительный шаг на компьютере; выход из тупика в том, что он делается однажды.

tools/apfs/mkstore.py -o store.apfs --slot-bytes 256M     # том HakiosStore, два слота
tools/apfs/check-image.sh store.apfs HakiosStore=КАТАЛОГ  # apfsck + чтение ядерной библиотекой
apfs-work/apfs-tool store info store.apfs                  # что о нём скажет драйвер
apfs-work/apfs-tool store save store.apfs архив 42         # сохранить; load — прочитать свежий

Образ на этой машине почти весь из дыр (256 МБ слоты — 88 КБ на диске), на флеш он всё равно пишется целиком. Чтение хранилища записываемого устройства не требует и не должно требовать: его читают при каждой загрузке с тома, смонтированного только для чтения, — а это каждое монтирование этого драйвера. Поэтому apfs_store_open про запись не спрашивает вовсе, а apfs_write_in_place спрашивает на первом блоке, где этот вопрос и уместен.

Чем проверено. Формат, проверенный только тем кодом, который его написал, не проверен ничем, поэтому в tools/apfs/write-tests.py есть его второй читатель, написанный по одному apfs_store.h: он находит метку, считает сумму заголовка, сумму содержимого и сам выбирает свежий хороший слот. Каждое утверждение о хранилище делают и он, и C, а apfsck говорит, выжил ли контейнер. Всего в наборе 81 проверка, из них про хранилище:

  • пять сохранений по очереди: оба читателя сходятся каждый раз, apfsck чист после каждого, слоты чередуются 0 1 0 1 0, и из всего тома меняются только блоки файлов слотов;
  • отказы, каждый собран таким, а не испорчен до такого, чтобы причина в отчёте была причиной в проверке: том не с именем HakiosStore, слоты разной длины, хранилище с одним слотом, слот короче заголовка, слот, который том сохранил сжатым (здесь сообщение называет ошибку: собрать том с -z — самый вероятный способ это испортить), и содержимое длиннее, чем слот вместит, — после которого слот, куда оно шло, по-прежнему не держит ничего хорошего;
  • пропадание питания на каждом блоке сохранения: после каждого контейнер чист, вне файлов слотов не изменилось ничего, а оба читателя видят либо предыдущее сохранение, либо это — но не смесь;
  • холодный случай: первое сохранение в пустое хранилище, остановленное на каждом из блоков, — оба читателя говорят, что там ничего нет, а не что-то недописанное (это и нужно услышать init, чтобы оставить /private/var таким, каким он его сделал).

В ядре. apfs_check=8 — то же самое там, где этому жить: в ядре, на блочном устройстве, под ядерным распределителем памяти. В QEMU, на томе с слотами по 1 МБ:

apfs-store: HakiosStore: 2 slots of 1048576 bytes, 1044480 of payload; the newest good slot is -1 (none)
apfs-store: 40000 bytes into slot 0 as generation 1: saved; 12 blocks
apfs-store: read back from a container opened again: slot 0, generation 1, 40000 bytes, note 7,
            sum 660557ccb6cba125; the payload is the one that was saved

На копии того же исходного образа здесь apfs-tool store save сохранил то же содержимое, и второй читатель формата говорит: слот 0, поколение 1, 40000 байт, note 7, сумма 660557ccb6cba125, содержимое — тот файл побайтово; apfsck -cw находит контейнер чистым. Ядро и компьютер сходятся в каждом поле.

Чего ещё нет. var_store пока пишет по-старому: перевести его на это хранилище — работа на телефоне (и ветка persist его как раз меняет), а здесь всё держится на образах этого компьютера. Что для этого останется сделать: дать ядру открывать том хранилища на /dev/ufs_darwin с dev.write, ведущим в ufs_write() — у которого уже есть и своя граница, и граница загрузчика, — и отдать пространству пользователя не смещения, а имя слота; тогда арифметика по смещениям раздела из var_store уходит совсем.

Что дальше

Полезное подмножество достижимо — и работает, на компьютере и в ядре: содержимое файла поверх себя самого и хранилище из слотов постоянной длины на нём. Общая реализация записываемого APFS — нет, по крайней мере не как побочная работа. Ближайшее, что осталось от хранилища до дела: ядро открывает том хранилища на /dev/ufs_darwin с dev.write, ведущим в ufs_write(), и var_store вместо смещений в разделе называет слот по имени. Том данных iOS, которого она ждёт, — это уже другое: создание, удаление и переименование файлов, то есть транзакция на каждую операцию, менеджер пространства с очередями освобождения, деление и слияние узлов деревьев, и всё это под замком тома, в ядре, с кешем страниц, который надо согласовать. Проба говорит, что формат для этого понят; объём — порядка того же, что сам читатель APFS, и браться за него стоит отдельной работой, а не продолжением этой.

Про файлы Apple и лицензии

  • Файлов iOS в репозитории нет: IPSW вы берёте сами, sysimage/unpack.sh распаковывает его на вашей машине; ничего не скачивается.
  • xnu/ — производная работа от XNU, распространяется на условиях APSL 2.0, как и сам XNU; prebuilt/mach.* — ядро, собранное из этих исходников.
  • xnu/files/bsd/miscfs/apfs/libzbitmap/ — декодер LZBITMAP libzbitmap (Corellium LLC, GPL-2.0+ OR MIT; здесь — на условиях MIT), которым сжаты файлы криптексов Apple;
  • apfsprogs (Ernesto A. Fernández и др., GPL-2.0) в репозитории нет: tools/apfs/check-image.sh берёт его с GitHub (linux-apfs/apfsprogs, закреплённая версия) и собирает только apfsck для проверки образов на компьютере;
  • xnu/files/bsd/miscfs/apfs/libdeflate/ — распаковывающая половина libdeflate 1.24 (Eric Biggers, Google LLC; лицензия MIT, COPYING там же), взята tools/mk-libdeflate.sh из выпуска без изменений;
  • Mesa (лицензия MIT) в репозитории нет: tools/mk-mesa.sh берёт выпуск 25.0.7 с archive.mesa3d.org, tools/mesa/*.patch — наши правки к нему;
  • ath11k (Qualcomm; лицензия BSD-3-Clause-Clear, один заголовок — ISC) и исходников FreeBSD (LinuxKPI и заголовки ядра, BSD-2-Clause) в репозитории нет: tools/mk-ath11k.sh берёт Linux 7.0.14 с cdn.kernel.org и src.txz выпуска FreeBSD 15.1 с download.freebsd.org и проверяет, что каждый файл ath11k сам называет свою лицензию. wifi/lx/patches/*.patch — наши правки к заголовкам FreeBSD (в них есть строки FreeBSD, BSD-2-Clause); остальное в wifi/lx/ написано нами. Ядро, собранное после tools/mk-ath11k.sh, содержит код ath11k и встроенные функции из заголовков LinuxKPI: кто распространяет такое ядро в собранном виде, прикладывает их уведомления об авторских правах (они в заголовках файлов ath11k и в COPYRIGHT из архива FreeBSD);
  • xnu-deps/build/lzbitmap/libzbitmap.* — libzbitmap (Corellium, GPL-2.0+ OR MIT); xnu-deps/hostinc/mach-o/{loader,nlist,fat}.h — заголовки из XNU (APSL 2.0).
  • devices/redwood/uefi/files/Binaries/redwood и devices/redwood/prebuilt/SILICIUM_UEFI.fd содержат драйверы UEFI из прошивки Xiaomi (так же, как их распространяет Project Silicium для других устройств); devices/redwood/prebuilt/mkbootimg.py — AOSP, Apache-2.0.

Вихідний код цієї сторінки