Диспетчеризація у статичних і динамічних фреймворках (частина 2 з 3): фреймворки SPM та Xcode

Диспетчеризація у статичних і динамічних фреймворках (частина 2 з 3): фреймворки SPM та Xcode

Практичні експерименти зі статичними й динамічними фреймворками, частина 2 з 3

Це частина 2 серії з трьох статей:

У частині 1 ми простежили виклик із застосунку до коду бібліотеки. Тепер я хочу знайти цей виклик у скомпільованому застосунку. Де опинилася статична реалізація? Який образ Mach-O містить динамічну реалізацію? І що насправді може довести команда в терміналі?

У прикладах є два невеликі застосунки: один із таргетами фреймворків у Xcode, інший – із продуктами Swift Package Manager. Обидва викликають функцію add зі статичної та динамічної бібліотек. Арифметика навмисно проста. Цікаве тут – усе, що відбувається навколо виклику.

Що ми перевіримо

Питання Корисні свідчення
Ця бібліотека – архів чи динамічний образ Mach-O? file, otool -hv
Який образ Mach-O визначає функцію? nm із деманглованими іменами
Які динамічні залежності записано в цьому образі Mach-O? otool -L, otool -l
Цей конкретний виклик переходить до локального коду чи до заглушки символу (symbol stub)? Дизасемблювання коду, що викликає функцію
На який імпортований символ посилається комірка вказівника? dyld_info -imports -fixups
Які образи Mach-O цей процес дійсно завантажив? LLDB image list

Одне розрізнення буде важливим упродовж усієї статті: статичне й динамічне компонування – це окреме питання від диспетчеризації методів Swift або Objective-C. Статично скомпонована бібліотека все одно може використовувати таблиці відповідності протоколам (protocol witness tables), диспетчеризацію класів, замикання та обмін повідомленнями Objective-C. Тут ми досліджуємо конкретні виклики простих функцій, а не всі можливі виклики, які може робити бібліотека.

Готуємо експеримент

У двох проєктах використано такі назви:

Конфігурація збірки Застосунок Статична залежність Динамічна залежність
Таргети Xcode Library-linking-Test-Xcode-Framework XcodeFrameworkStatic XcodeFrameworkDynamic
Продукти SwiftPM Library-linking-Test SDK-SPM-Static SDK-SPM-Dynamic

Бібліотеки Xcode надають MyMathStatic.add і MyMathDynamic.add. Обидва модулі SwiftPM надають MyMath.add, тому застосунок уточнює виклики через SDK_SPM_Static або SDK_SPM_Dynamic. Деякі реалізації також виводять повідомлення: так їх легше знайти й підтвердити виконання.

Ось основна форма API, без виведення повідомлень:

public struct MyMathStatic {
    public static func add(_ a: Int, _ b: Int) -> Int {
        a + b
    }
}

Щоб відтворити випадок із Xcode, створіть застосунок для iOS і додайте два таргети фреймворків з наведеними вище назвами. Розмістіть статичний API в одному таргеті, а відповідний API MyMathDynamic – в іншому. Установіть для них типи Mach-O, як показано нижче, скомпонуйте обидва із застосунком і викличте обидві функції із застосунку, щоб компонувальник мав підставу включити їх:

import XcodeFrameworkStatic
import XcodeFrameworkDynamic

print(MyMathStatic.add(2, 4))
print(MyMathDynamic.add(2, 4))

Розмістіть ці виклики у callback життєвого циклу застосунку, наприклад у onAppear демонстраційного view. Це відтворить зв’язки компонування; адреси й супутні імпорти runtime відрізнятимуться від моєї збірки.

Для першого дослідження використайте збірку без оптимізації. Оптимізація, вбудовування функцій, видалення невикористаного коду та видалення символів можуть змінити або прибрати свідчення, які ми шукаємо. Відсутність символу у виводі nm сама по собі не доводить, що код ніколи не було скомпоновано.

Наведені нижче фрагменти бінарних файлів Xcode повторно перевірено з Xcode 26.6 (17F113), Swift 6.3.3 і зрізом arm64 симулятора iOS. Адреси стосуються саме цієї збірки. Попередні знімки екрана показують налаштування, а не гарантують точну відповідність поточному інтерфейсу Xcode чи його виводу.

Офісні персонажі LEGO зібралися навколо мікроскопа й комп’ютера, щоб дослідити свій експеримент

Час дослідити результат. Налаштування збірки показує, що ми замовили; бінарний файл – що отримали.

Таргети фреймворків у Xcode

Для таргетів із цього прикладу Mach-O Type має значення Static Library для XcodeFrameworkStatic і Dynamic Library для XcodeFrameworkDynamic.

Налаштування збірки Xcode для статичного таргета фреймворку

Налаштування збірки Xcode для динамічного таргета фреймворку

Суфікс .framework позначає формат пакета. Сам по собі він не доводить, що бінарний файл усередині динамічний. Дослідіть цей бінарний файл:

file "$BUILD_PRODUCTS/XcodeFrameworkStatic.framework/XcodeFrameworkStatic"
file "$BUILD_PRODUCTS/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic"

Спочатку встановіть для BUILD_PRODUCTS шлях до каталогу продуктів вашої збірки. У цьому експерименті статичний результат – архів, а динамічний – динамічно скомпонована спільна бібліотека. Для універсальної збірки може відображатися кілька архітектур.

Продукти Swift Package Manager

Маніфести прикладу явно задають тип компонування:

.library(
    name: "SDK-SPM-Static",
    type: .static,
    targets: ["SDK-SPM-Static"]
)
.library(
    name: "SDK-SPM-Dynamic",
    type: .dynamic,
    targets: ["SDK-SPM-Dynamic"]
)

Це оголошення продуктів, а не повні маніфести. Відповідні таргети й шляхи до вихідного коду також мають існувати. Якщо для продукту не вказано type, SwiftPM дозволяє споживачеві вибрати тип компонування; не трактуйте відсутність типу як явне оголошення .static. Дивіться API продуктів SwiftPM.

Інтеграція SwiftPM у Xcode може пакувати динамічний продукт як фреймворк. Збірка SwiftPM із командного рядка може створити .dylib. Досліджуйте продукт, створений інструментом, який ви насправді використали, замість того щоб робити висновок про його розширення з Package.swift.

Застосунок SwiftPM також було перебудовано для цієї редакції. Його образ Mach-O з кодом застосунку визначає статичну MyMath.add та імпортує динамічну:

T _$s14SDK_SPM_Static6MyMathV3addyS2i_SitFZ
U _$s15SDK_SPM_Dynamic6MyMathV3addyS2i_SitFZ

Адреси тут опущено. otool -L фіксує @rpath/SDK-SPM-Dynamic.framework/SDK-SPM-Dynamic. У цій конфігурації прикладу фреймворк не було вбудовано в застосунок, тому самої успішної збірки було недостатньо, щоб продукт був готовий до самостійного запуску. У наступному розділі пояснюється, якого кроку бракувало.

Джим і Двайт за одним столом із позначкою Static, а Пем і Майкл – за окремими столами з позначкою Dynamic

Один спільний робочий простір і окремі робочі простори – метафора розміщення реалізацій, а не швидкості їхньої роботи.

Компонування та вбудовування – окремі кроки

Компонування з’єднує код і записує залежності. Вбудовування розміщує залежність часу виконання в пакеті застосунку. Успішне компонування не гарантує, що застосунок містить усе необхідне для запуску.

У прикладі з фреймворками Xcode таргет застосунку використовує Embed & Sign для власного динамічного фреймворку. Для статичного фреймворку він використовує Do Not Embed: архів є вхідними даними для компонувальника, а не образом часу виконання, який потрібно постачати разом із виконуваним файлом.

Налаштування компонування та вбудовування фреймворків Xcode для застосунку з прикладу

Це не означає, що кожен елемент із позначкою “Do Not Embed” обов’язково статичний. Системні фреймворки Apple динамічні й постачаються з операційною системою. Менеджери пакетів також можуть організовувати копіювання та підписування через згенеровані фази збірки. Перевіряйте і кінцевий пакет, і екран налаштувань. Посібник Apple із вбудовування фреймворків пояснює це розрізнення та поширені збої через відсутній фреймворк.

Ресурси – ще одне окреме питання. Статичне компонування коду не копіює автоматично всі зображення, локалізації чи пакети ресурсів, потрібні цьому коду.

Пем і Майкл стоять біля відкритого кейса з написом App bundle, усередині якого лежать документи

Компонування записує залежність. Вбудовування забезпечує доставку файлу часу виконання разом із застосунком.

Знаходимо образ Mach-O з кодом застосунку

Перш ніж запускати nm, визначте потрібний файл. У збірці з підтримкою налагоджувальної dylib від Xcode виконуваний файл застосунку – невеликий завантажувач, а більша частина коду застосунку міститься в App.debug.dylib. Туди входить і код, статично скомпонований із нашої бібліотеки з прикладу.

Для цього застосунку iOS установіть для APP шлях до каталогу зібраного .app, а потім виконайте:

APP="/path/to/Library-linking-Test-Xcode-Framework.app"
EXECUTABLE=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Info.plist")
APP_CODE="$APP/$EXECUTABLE"

if [ -f "$APP/$EXECUTABLE.debug.dylib" ]; then
    APP_CODE="$APP/$EXECUTABLE.debug.dylib"
fi

DYNAMIC_CODE="$APP/Frameworks/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic"

Цей шлях умовний; замініть його шляхом до власного продукту збірки. Така структура характерна для прикладу з iOS. Пакет застосунку macOS має іншу структуру.

Вибір налагоджувальної dylib не означає, що статичний фреймворк став динамічною залежністю. Це означає, що статично скомпонований код потрапив до іншого вихідного образу Mach-O. Частина 3 детально розглядає таку структуру.

Експеримент 1: знаходимо визначення та імпорти

xcrun nm -arch arm64 "$APP_CODE" | grep MyMath | xcrun swift-demangle
xcrun nm -arch arm64 "$DYNAMIC_CODE" | grep MyMath | xcrun swift-demangle

В образі Mach-O з кодом застосунку відповідні рядки з іменами до деманглінгу з дослідженої збірки Debug такі:

0000000000004188 T _$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZ
                 U _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ

T позначає визначення в секції коду цього образу Mach-O. U позначає невизначений символ, який цей образ імпортує. Бінарний файл динамічного фреймворку надає друге визначення.

Отже, маємо два різні зв’язки:

  • Статична бібліотека додала код до APP_CODE; вона не має власного окремого образу часу виконання.
  • APP_CODE імпортує динамічну функцію з іншого образу Mach-O.

Для цієї збірки це корисний результат. Перш ніж робити такий самий висновок щодо оптимізованих продуктів або продуктів із видаленими символами, поєднайте дослідження символів із даними про залежності, картою компонувальника чи відповідними налагоджувальними символами.

Експеримент 2: досліджуємо записані залежності

xcrun otool -arch arm64 -L "$APP_CODE"
xcrun otool -arch arm64 -L "$DYNAMIC_CODE"

В образі Mach-O з кодом застосунку записано таку відповідну залежність:

@rpath/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic

Для XcodeFrameworkStatic немає відповідної команди завантаження. Вибраний із нього код було скомпоновано у вихідний образ Mach-O.

Дві деталі допомагають не помилитися в тлумаченні цього виводу:

  1. otool -L читає файл. Вона не перелічує всі образи Mach-O, завантажені процесом під час виконання, і не розгортає рекурсивно кожну залежність.
  2. Для dylib вона також показує її власне інсталяційне ім’я з LC_ID_DYLIB. Рядок App.debug.dylib у її власному виводі – це її ідентифікатор, а не залежність від іншої копії самої себе. Використовуйте otool -D, щоб перевірити ідентифікатор, або otool -l, щоб розрізнити типи команд завантаження.

Експеримент 3: простежуємо інструкцію виклику

Дослідіть код, що викликає функцію: саме там містяться місця виклику та заглушки імпортованих символів:

xcrun otool -arch arm64 -tvV "$APP_CODE" | grep -A6 addyS2i_SitFZ

Відповідні рядки з тієї самої збірки:

00000000000015dc bl 0x4b38 ; symbol stub for: _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ
0000000000001748 bl _$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZ

В обох випадках використано bl – інструкцію arm64 branch-and-link. Різниця в адресі переходу. Один перехід веде до заглушки імпортованого символу, другий – до реалізації статичної функції в межах цього образу Mach-O.

Перший рядок – це виклик заглушки, а не тіло самої заглушки. Щоб дослідити інструкції заглушки й секції вказівників, виконайте:

xcrun dyld_info -arch arm64 -disassemble "$APP_CODE"
xcrun otool -arch arm64 -Iv "$APP_CODE"

Статичний код, скомпонований в образ Mach-O з кодом застосунку, порівняно з імпортованою динамічною функцією, до якої звертаються через заглушку та вказівник

Цей приклад демонструє прямий виклик статичної реалізації. Він не доводить твердження «статичне компонування означає повну відсутність опосередкованих звернень». Оптимізація компілятора, диспетчеризація на рівні мови, вказівники на функції та інші динамічні залежності й далі мають значення.

Запис GOT не доводить відкладене зв’язування

Саме тут дамп бінарного файлу може спокусити нас заявити більше, ніж він доводить. Наявність __stubs і __got підтверджує опосередковане звернення. Вона не доводить, що адресу функції визначають під час першого виклику.

Дослідіть фактичні імпорти й fixups (коригування адрес):

xcrun dyld_info -arch arm64 -imports -fixups "$APP_CODE" | grep MyMath
xcrun otool -arch arm64 -l "$APP_CODE" | grep -A3 LC_DYLD_CHAINED_FIXUPS

Для дослідженого бінарного файлу dyld_info пов’язує комірку в __DATA_CONST,__got із XcodeFrameworkDynamic.MyMathDynamic.add, а команди завантаження містять LC_DYLD_CHAINED_FIXUPS.

Сучасні chained fixups описують вказівники, що потребують зв’язування або перебазування. Їхні байти на диску можуть кодувати інформацію для коригування адреси; це не обов’язково остаточні значення вказівників, які бачить запущений процес. Сирий шістнадцятковий дамп __got не може показати стан до і після першого виклику.

Класичне відкладене зв’язування – інший випадок: нерозв’язаний виклик проходить через механізм визначення адреси, а наступні виклики використовують визначений вказівник. Не застосовуйте цю модель до кожної сучасної заглушки. Apple розглядає chained fixups і page-in linking у Link fast: Improve build and launch times. Page-in linking стосується того, як сторінки пам’яті стають доступними, і не обіцяє визначати адресу кожної функції під час її першого виклику.

Також не використовуйте кількість секцій __got як критерій статичного чи динамічного компонування. Навіть застосунок, який використовує лише нашу статичну математичну бібліотеку, імпортує системні функції та функції середовища виконання Swift, тому його кінцевий образ Mach-O може містити і заглушки, і GOT.

Експеримент 4: перевіряємо запущений процес

Призупиніть застосунок у налагоджувачі Xcode, а потім скористайтеся LLDB:

(lldb) image list
(lldb) image lookup -rn 'MyMathStatic.*add'
(lldb) image lookup -rn 'MyMathDynamic.*add'

Подивіться, якому образу Mach-O належить символ і який шлях до цього образу. У збірці з підтримкою налагоджувальної dylib очікуйте знайти статичну реалізацію в налагоджувальній dylib застосунку, а динамічну – в образі її фреймворку. Без такої підтримки статична реалізація натомість може міститися в основному виконуваному файлі.

Індекси образів Mach-O та адреси різняться. Індекс на кшталт [22] на чужому знімку екрана не визначає, де має міститися код застосунку. Якщо пошук не вдався, перевірте оптимізацію та наявні символи, перш ніж тлумачити це як відсутність коду.

Шлях до образу Mach-O також показує, яку саме копію фреймворку завантажено. Копія в каталозі продуктів збірки й копія всередині встановленого застосунку можуть мати однакову назву. Це розрізнення стане важливим в експерименті з відсутнім фреймворком у частині 3.

Де тут місце CocoaPods

CocoaPods може визначати, як збирати й пакувати залежності з вихідного коду:

use_frameworks! :linkage => :static
# Or, in an alternative configuration:
# use_frameworks! :linkage => :dynamic

Довідник Podfile описує ці варіанти компонування. Попередньо скомпільований бінарний файл, доданий як залежність, усе одно має власний тип компонування; налаштування Podfile не може перетворити готовий динамічний бінарний файл на статичний архів. Досліджуйте отриманий бінарний файл тими самими командами.

Що ці експерименти говорять про продуктивність

Статичне компонування може зменшити кількість окремо завантажуваних приватних образів Mach-O. Динамічне компонування залишає залежність у власному образі Mach-O й може запобігти дублюванню спільного коду для кількох споживачів в одному процесі. Жодне з цих спостережень не дає нам вимірювання часу запуску.

Компонувальник може витягти вибрані складові архіву, видалити невикористані секції та працювати разом з оптимізаціями компілятора. Він не обов’язково копіює кожну функцію зі статичної бібліотеки в застосунок. І навпаки, перенесення коду в динамічний фреймворк може зменшити основний виконуваний файл, тоді як загальний розмір застосунку залишиться таким самим або зросте.

Порівнюйте кінцевий пакет застосунку або експортований артефакт, а не лише один виконуваний файл. Використовуйте size для визначення розмірів сегментів, а не як заміну даним про розмір завантаження чи обсяг резидентної пам’яті. Щоб оцінити продуктивність запуску, вимірюйте еквівалентні оптимізовані збірки на пристрої за допомогою Instruments і повторних запусків.

Що ми встановили

Для досліджених викликів у цьому прикладі статичний код міститься в образі Mach-O з кодом застосунку, тоді як динамічна функція залишається імпортом, який надає окремий фреймворк. Таргети Xcode і продукти SwiftPM підводять до тих самих питань про кінцевий бінарний файл: де міститься визначення, яку залежність записано і куди веде виклик?

Наступна проблема підступніша: що, як бінарний файл містить правильний запис про залежність, фреймворку немає в застосунку, але запуск із Xcode все одно вдається? У частині 3 ми відокремимо структуру збірки від середовища запуску й дослідимо саме цей випадок.

Востаннє оновлено

На цій сторінці