Диспетчеризація у статичних і динамічних фреймворках (частина 1 із 3): що відбувається за лаштунками
Це частина 1 серії із трьох статей:
- Частина 1: Диспетчеризація за лаштунками
- Частина 2: Практичні експерименти з фреймворками SPM і Xcode
- Частина 3: Debug, Release, @rpath і debug dylib
Примітка про виправлення, 30 вересня 2026: У початковій версії я вважав запис GOT доказом відкладеного зв’язування під час першого виклику й перебільшував гарантії статичного компонування. У цій редакції я відокремлюю класичне відкладене зв’язування від схеми chained fixups, яку спостерігав у прикладі, виправляю схеми та розрізняю виклик заглушки й саму заглушку.
Коротко
- Статичне компонування включає вибраний код бібліотеки до образу Mach-O, який її використовує. Цим образом може бути основний виконуваний файл, debug dylib застосунку або інша бібліотека.
- За динамічного компонування реалізація залишається в окремому образі. У розглянутому тут виклику код, що викликає функцію, переходить до заглушки, створеної компонувальником; вона завантажує вказівник на імпортовану функцію й переходить за його адресою.
- Щойно цей вказівник готовий, звичайний виклик через заглушку не змушує
dyldповторно визначати адресу символу. - Сама наявність заглушки або секції GOT не доводить, що відкладене зв’язування відбувається під час першого виклику. Перевіряйте метадані зв’язування, а за потреби – процес під час виконання.
- Компонування – окреме питання від диспетчеризації методів Swift і Objective-C. Статичні бібліотеки теж можуть містити непрямі виклики.
Вступ
Коли я пишу MyMath.add(1, 2), мені хочеться знати, куди потрапить реалізація і як код, що її викликає, до неї дістанеться. Сам рядок вихідного коду не дає відповіді на жодне із цих питань.
Функція може бути скомпонована в той самий образ, що й код, який її викликає. А може міститися в окремому фреймворку. Оптимізація може навіть прибрати виклик, вбудувавши функцію. Межа модуля Swift не обов’язково збігається з межею між образами під час виконання.
У цій серії я простежую один невеликий приклад через усі ці рівні. У частині 1 вибудовую модель і перевіряю кілька інструкцій. У частині 2 порівнюю продукти Xcode і SwiftPM. Частина 3 присвячена випадку, коли компонування проходить успішно, але запущений застосунок не може знайти свій фреймворк.
Загальна картина: від вихідного коду до реального виклику функції
Варто розрізняти два питання:
- Де після компонування міститься реалізація бібліотеки?
- Які інструкції виконує саме цей виклик?
Статичний архів є вхідним файлом для компонувальника. Вибраний із нього код стає частиною образу, що його використовує. Для нашої простої функції код, що її викликає, може перейти безпосередньо до реалізації в цьому образі.
Для динамічної функції в цьому прикладі код спершу переходить до локальної заглушки. Заглушка читає комірку з вказівником і переходить до функції у фреймворку. Завдання завантажувача – підготувати образи та їхнє зв’язування так, щоб цей вказівник можна було використати.
Останній крок може реалізовуватися по-різному. За класичного відкладеного зв’язування адресу функції можна визначити під час першого виклику. У розглянутому нижче прикладі є chained fixups і запис зв’язування в __DATA_CONST,__got. Це різні свідчення, і я не маю називати обидва випадки «відкладеними» лише тому, що в обох є непрямий виклик.
Пояснення ключових понять
Що таке компонування?
Компонувальник об’єднує скомпільовані вхідні файли у вихідний образ, розміщує код і дані та обробляє посилання на символи. Він визначає адреси для посилань, які може задовольнити з цих файлів, і записує інформацію, потрібну для динамічних імпортів.
Тут стане в пригоді термін образ: це виконуваний файл Mach-O або динамічна бібліотека, завантажена в процес. Один процес може містити багато образів.
Статичне компонування
Архів зазвичай містить кілька об’єктних файлів. Компонувальник вибирає потрібні для компонування файли; опції можуть примусово додати й інші, а dead stripping – прибрати невикористаний код і дані. «Скомпонувати бібліотеку» не означає «скопіювати кожен байт архіву».
Вибраний код можна також скомпонувати в dylib. Це має значення в новіших збірках Xcode: код статичної бібліотеки застосунку може міститися в App.debug.dylib разом із кодом самого застосунку. Від цього початкова статична бібліотека не стає динамічним фреймворком, який завантажують окремо.
Динамічне компонування
Динамічна залежність зберігає власний образ. Образ, що її використовує, містить записи імпорту й команди завантаження залежностей. Під час виконання завантажувач знаходить залежність і організовує її зв’язування. Явне завантаження через dlopen – інший випадок, окремий від звичайних залежностей застосунку під час запуску.
Основний виконуваний файл може зменшитися, коли код винесено з нього, але до застосунку, який постачають користувачам, входять і його приватні фреймворки. Говорячи про розмір, порівнюйте продукти повністю. Так само два компоненти можуть спільно використовувати одну динамічну залежність, але граф залежностей однаково треба правильно спроєктувати: вибір динамічного компонування автоматично не розв’язує всі проблеми з дубльованими символами.
Системні бібліотеки Apple часто входять до спільного кешу dyld. Це інша ситуація, ніж постачання приватного фреймворку в пакеті кожного застосунку. Ні пісочниця iOS, ні саме слово «динамічний» не дають відповіді на питання про спільне використання пам’яті.
Чому це важливо
Спосіб компонування впливає на вихідні образи, імпорти, пакування та можливості оптимізації. Він не гарантує швидшого запуску чи виклику функції. Такі твердження треба перевіряти вимірюваннями на реальному продукті з його налаштуваннями збірки.
Фреймворк і бібліотека
Бібліотека надає код для повторного використання. Фреймворк – це структура пакета, яка може містити бінарний файл, інформацію про модуль, заголовки, ресурси й метадані. Суфікс .framework не визначає, статичний його бінарний файл чи динамічний.
SwiftPM, таргети Xcode і CocoaPods налаштовують продукти та кроки збірки. Їхні налаштування можуть змінити результат, але назва інструмента інтеграції не означає нового механізму диспетчеризації на рівні CPU. Перевіряйте створений бінарний файл.
Mach-O
Mach-O – формат виконуваних файлів на платформах Apple. Заголовки й команди завантаження описують образ, а його сегменти й секції містять код, дані та інформацію, зокрема символи й fixups (коригування адрес).
Наприклад, __TEXT,__stubs – назва секції всередині сегмента __TEXT. Команда LC_LOAD_DYLIB записує динамічну залежність. Для ідентифікації самої dylib використовується LC_ID_DYLIB – інша команда.

Раніший знімок прикладу в MachOView. Вивід термінала нижче я перевірив окремо; адреси й розміщення залежать від збірки.
Розширення файлу .a
Файл .a зазвичай є архівом об’єктних файлів. Сам по собі він не є образом, який dyld завантажує під час виконання. У нашому експерименті вибрана статична реалізація стає частиною образу з кодом застосунку.
Це говорить нам, де міститься код. Але не означає, що кожен виклик усередині нього прямий: код може використовувати замикання, диспетчеризацію через протоколи, вказівники на функції або динамічно імпортовані системні функції.
Розширення файлу .dylib
.dylib – це образ динамічної бібліотеки. Динамічний фреймворк теж містить динамічну бібліотеку Mach-O, часто без суфікса .dylib у назві внутрішнього файлу.
Необов’язкова .debug.dylib застосунку – ще один вихідний образ. Її наявність залежить від відповідних налаштувань Xcode і таргета, а не лише від назви конфігурації. Вона містить виконуваний код і не є пакетом налагоджувальних символів .dSYM. Цю структуру розглянуто в частині 3.
dyld
dyld – динамічний завантажувач Apple. Він знаходить і відображає потрібні образи в пам’ять, визначає адреси імпортованих символів, координує fixups (коригування адрес) і запускає ініціалізатори. Він також підтримує бібліотеки, які завантажуються пізніше.
Завантаження образу, визначення адреси символу й виклик функції – різні операції. Якщо виклик іде до динамічної бібліотеки, це не означає, що завантажувач шукає символ щоразу.
Практика: тестові проєкти
У прикладах використано два невеликі застосунки:
| Конфігурація | Застосунок | Статична залежність | Динамічна залежність |
|---|---|---|---|
| Таргети Xcode | Library-linking-Test-Xcode-Framework |
XcodeFrameworkStatic |
XcodeFrameworkDynamic |
| Продукти SwiftPM | Library-linking-Test |
SDK-SPM-Static |
SDK-SPM-Dynamic |
Кожна бібліотека надає просту функцію додавання. Деякі реалізації в прикладах також виводять повідомлення про виконання. У частині 2 наведено інструкції, як відтворити варіант із Xcode в мінімальному застосунку; команди тут не потребують доступу до репозиторію для завантаження.
Наведені далі фрагменти я перевірив на зрізі arm64 для iOS Simulator, зібраному в Xcode 26.6 (17F113), Swift 6.3.3. Це конкретні спостереження з цієї збірки, а не адреси, які слід очікувати в кожному застосунку.
Спершу виберіть образ, який справді містить код застосунку. Задайте для APP шлях до каталогу зібраного застосунку iOS:
APP="/path/to/Library-linking-Test-Xcode-Framework.app"
EXECUTABLE=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Info.plist")
if [ -f "$APP/$EXECUTABLE.debug.dylib" ]; then
APP_CODE="$APP/$EXECUTABLE.debug.dylib"
else
APP_CODE="$APP/$EXECUTABLE"
fi
DYNAMIC="$APP/Frameworks/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic"Ця структура стосується прикладу для iOS. Пакет застосунку macOS має іншу структуру. Наведені нижче команди використовують ті самі змінні оболонки й явно вибирають arm64; змініть архітектуру відповідно до своєї збірки.
Символ і мангльований символ
Символ дає ім’я функції або іншій сутності в скомпільованій програмі. Манглінг Swift кодує в цьому імені, зокрема, модуль, контекст і тип оголошення.
Наприклад:
public struct MyMathStatic {
public static func add(_ a: Int, _ b: Int) -> Int {
a + b
}
}Функція в нашому статичному фреймворку має такий символ:
_$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZЦе ім’я побудоване за граматикою манглінгу Swift, зокрема з підстановками. Сприймати MyMathC0V як буквальне ім’я типу у вихідному коді було б помилкою. Замість того щоб розбирати кожен фрагмент на око, я використовую деманглер. Граматику описано в довіднику з манглінгу Swift ABI.
xcrun nm -arch arm64 "$APP_CODE" | grep 'MyMath' | xcrun swift-demangleПотрібне нам ім’я функції:
static XcodeFrameworkStatic.MyMathStatic.add(Swift.Int, Swift.Int) -> Swift.IntСлово static у цьому демангльованому оголошенні описує метод типу Swift. Воно не визначає тип компонування бібліотеки.
Щоб порівняти, якому образу належать символи, залишимо їхні початкові імена:
xcrun nm -arch arm64 "$APP_CODE" | grep 'addyS2i_SitFZ'
xcrun nm -arch arm64 "$DYNAMIC" | grep 'addyS2i_SitFZ'В образі з кодом застосунку нас цікавлять такі рядки:
0000000000004188 T _$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZ
U _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZТут T позначає визначення в секції коду, а U – невизначене посилання в цьому образі. Другу функцію визначено в динамічному фреймворку. Імпорт тут очікуваний: її визначення належить іншому образу.
Оптимізація або вилучення символів може прибрати ці свідчення. Якщо ім’я не знайдено через nm, цього недостатньо, щоб довести, що реалізацію взагалі не було скомпоновано.
Заглушка
Заглушка символу (symbol stub), створена компонувальником, – це невеликий блок коду в образі, що викликає функцію. У нашому прикладі вона завантажує вказівник на імпортовану функцію й переходить за його адресою.
На момент виконання заглушки вказівник уже може бути готовий. Отже, заглушка не обов’язково веде до коду визначення адреси символу.

Заглушку ми шукаємо в бінарному файлі, з якого викликають функцію.
Виклик заглушки
Дизасемблюйте код, що викликає функції, і знайдіть два виклики додавання:
xcrun otool -arch arm64 -tvV "$APP_CODE" | grep 'addyS2i_SitFZ'Відповідні фрагменти в місцях виклику:
00000000000015dc bl 0x4b38 ; symbol stub for: _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ
0000000000001748 bl _$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZІнструкція за адресою 0x15dc викликає заглушку за адресою 0x4b38. Це не тіло самої заглушки. Другий виклик переходить до статичної функції в тому самому образі.
Це доводить різницю між двома місцями виклику. Але не означає, що весь статично скомпонований код обходиться без опосередкованих звернень.
Інструкції самої заглушки
Знайдіть секцію, а потім дизасемблюйте її:
xcrun otool -arch arm64 -l "$APP_CODE" | grep -A3 'sectname __stubs'
xcrun otool -arch arm64 -v -s __TEXT __stubs "$APP_CODE"Заголовок секції містить:
sectname __stubs
segname __TEXT
addr 0x0000000000004b08
size 0x0000000000000390За адресою, на яку спрямований виклик, містяться три інструкції:
0000000000004b38 adrp x16, 4 ; 0x8000
0000000000004b3c ldr x16, [x16]
0000000000004b40 br x16У цій збірці adrp обчислює сторінку, що містить комірку з вказівником, ldr завантажує значення з неї, а br переходить за цією адресою. Це інструкції arm64; на іншій архітектурі або в іншому варіанті заглушки послідовність може відрізнятися.
Таблиця непрямих символів пов’язує із цим імпортом і заглушку, і комірку з вказівником:
xcrun otool -arch arm64 -Iv "$APP_CODE" | grep 'MyMath'0x0000000000004b38 659 _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ
0x0000000000008000 659 _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZТепер ми знаємо, що читає заглушка. Але те, як комірка отримує своє значення, потребує окремих доказів.
Класичне відкладене зв’язування та визначення адреси символу
«Трамплін» – загальна назва проміжного коду, який перенаправляє виконання. Раніше я вживав її так, ніби вона позначає весь механізм зв’язування dyld. Тут допоможуть точніші назви: заглушка символу, допоміжний код заглушки, комірка відкладеного вказівника і код визначення адреси символу.
У класичній схемі відкладеного зв’язування Mach-O комірка спочатку веде до допоміжного коду. Він звертається до механізму зв’язування dyld, який визначає адресу імпортованого символу й оновлює комірку. Наступні виклики можуть переходити до визначеної цілі без повторного пошуку.

Це аналогія для етапу визначення адреси за класичного відкладеного зв’язування. Вона не означає, що кожен динамічний виклик проходить через dyld або що розглянутий бінарний файл із chained fixups використовує цей шлях під час першого виклику.
Архівований посібник Apple із динамічних бібліотек описує відкладене й негайне зв’язування. Його класичні приклади дають корисний контекст, але не варто вважати їх універсальним описом результатів сучасних інструментів збірки.
Що таке таблиця відкладених вказівників на символи?
У класичних схемах Mach-O може бути секція з назвою __la_symbol_ptr, що містить комірки відкладених вказівників. Для звичайного класичного шляху через допоміжний код, описаного тут:
| Етап | Куди вказує комірка |
|---|---|
| До визначення адреси цього імпорту | Шлях через допоміжний код і механізм зв’язування |
| Після успішного зв’язування | Адреса імпортованої функції |
Невизначена комірка відкладеного вказівника зазвичай не містить нульового вказівника на функцію. Виклик через звичайний нульовий вказівник не змусить dyld автоматично знайти функцію. Відсутні слабкі імпорти – інша ситуація; не варто пояснювати ними звичайне відкладене визначення адреси.
Після класичного відкладеного визначення адреси виклик усе ще може проходити через заглушку й комірку з вказівником. Зникає повторний пошук символу, а не обов’язково опосередкованість виклику.
GOT і chained fixups: що показує ця збірка
GOT – це таблиця вказівників для доступу до імпортованих сутностей. Сама її наявність не говорить, коли відбувається зв’язування цих імпортів.
Для бінарного файлу вище:
xcrun otool -arch arm64 -l "$APP_CODE" | grep 'LC_DYLD_CHAINED_FIXUPS'
xcrun dyld_info -arch arm64 -imports -fixups "$APP_CODE" | grep 'MyMath'Команда завантаження присутня. Відповідні фрагменти інформації про імпорт і fixup:
0x0000 _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ (from XcodeFrameworkDynamic)
__DATA_CONST __got 0x00008000 bind XcodeFrameworkDynamic/_$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZЦе визначає імпортовану ціль, пов’язану з коміркою за адресою 0x8000. Разом із дизасембльованим кодом ці дані пов’язують місце виклику, заглушку, комірку з вказівником і залежність. Тут ми бачимо chained bind, а не доказ визначення адреси символу під час першого виклику.
Chained fixups компактно кодують розташування й цілі коригувань адрес, частково – у ділянках файлу розміром із вказівник. Тому сирі байти, зчитані з __got на диску, не обов’язково є придатними адресами під час виконання. Порівнюючи адреси у файлі з адресами живого процесу, треба також враховувати ASLR та обробку fixups.
Apple описує chained fixups і page-in linking у Link fast, WWDC22. На підтримуваних шляхах ядро може застосувати заздалегідь підготовлені fixups під час завантаження сторінки даних у пам’ять. Така відкладена операція над сторінкою відрізняється від запуску механізму пошуку функції за ім’ям під час першого виклику.
З тієї ж причини твердження «усі записи GOT фізично виправляються під час запуску» теж занадто широке. Для цього виклику суттєво те, що вказівник придатний до використання, коли його завантажує заглушка. Конкретна реалізація залежить від бінарного файлу й середовища виконання.
Відсутність __la_symbol_ptr в одній збірці не означає, що ця секція зникла з усіх бінарних файлів Apple. У реалізації dyld від Apple є підтримка кількох форм зв’язування. Визначайте форму, яку використовує саме досліджуваний образ.
Модель диспетчеризації: повна картина
У перевіреному прикладі є два шляхи:
Static function:
call site -> implementation in the same app-code image
Dynamic function, with a prepared imported pointer:
call site -> local symbol stub -> pointer load -> implementation in another imageКласичне відкладене зв’язування може додати перехід через допоміжний код і механізм визначення адреси, поки імпорт ще не зв’язано. Диспетчеризація Swift або Objective-C може додати власні механізми. Жоден із них не слід домальовувати до кожної стрілки на кожній схемі компонування.
| Свідчення | Що воно встановлює | Чого воно не встановлює |
|---|---|---|
nm: визначення або імпорт |
Якому образу належить символ | Усі можливі шляхи виклику під час виконання |
otool -L |
Записані динамічні залежності | Які саме образи завантажено в процес |
| Дизасембльований код, що викликає функцію | Інструкції в досліджуваному місці виклику | Зв’язування під час першого виклику чи завчасно |
| Дизасембльована заглушка й непрямі символи | Який вказівник використовує заглушка | Коли виконується fixup цього вказівника |
dyld_info -imports -fixups |
Метадані імпорту та fixup | Виміряну вартість під час запуску |
LLDB image list та огляд пам’яті |
Стан процесу під час виконання | Поведінку іншої збірки або іншого контексту запуску |
Саме тому я розглядаю компонування та диспетчеризацію на рівні мови окремо. Статична бібліотека може містити виклик через таблицю відповідності протоколу Swift (protocol witness table). Динамічна бібліотека може безпосередньо викликати власні локальні функції. Аналіз бінарного файлу відповідає на вужче й корисніше питання, ніж «тут усе статичне чи динамічне?»
Що далі
У частині 2 ми повторимо перевірку для таргетів Xcode і продуктів SwiftPM, а потім з’ясуємо, чи справді потрібний динамічний фреймворк було вбудовано.
Частина 3 відокремлює це питання пакування від @rpath, середовища запуску та необов’язкової debug dylib. Успішно скомпонований виклик має сенс лише тоді, коли під час роботи застосунок може знайти свою залежність.