У цій роботі розглянемо організацію виконуваного коду в операційних системах. Спочатку зупинимося на створенні виконуваних файлів під час компонування і на тому, як цей процес впливає на структуру таких файлів, потім на понятті динамічної бібліотеки і різних способах завантаження коду таких бібліотек.
Загальні принципи компонування. Компонувальники і принципи їх роботи
Компонуванням (linking) називають процес створення фізичного або логічного виконуваного файлу (модуля) із набору об'єктних файлів і файлів бібліотек для подальшого виконання або під час виконання і вирішення проблеми неоднозначності імен, що виникає при цьому.
У разі створення фізичного виконуваного файлу для подальшого виконання компонування називають статичним; у такому файлі міститься все потрібне для виконання програми. У разі створення логічного виконуваного файлу під час виконання програми компонування називають динамічним; у цьому випадку образ виконуваного модуля збирають «на ходу».
Компілятор створює один об'єктний файл за один запуск, при цьому до інших об'єктних файлів або бібліотек не звертається, тому він ніколи не пов'язує зовнішні посилання із конкретними адресами, тобто не розв'язує їх, а отже, не може створити виконуваний файл. Це робота компонувальника (linker).
Його основні функції такі: об'єднує всі частини програми у виконуваний файл; збирає разом код і дані секцій одного призначення з різних об'єктних файлів; задає адреси для коду і даних, розв'язуючи при цьому зовнішні посилання.
У результаті за статичного компонування на диск записують виконуваний файл, готовий до запуску, за динамічного — виконуваний файл теж буде створено, але йому для виконання потрібні додаткові файли.
Статичне компонування виконуваних файлів
Об'єктні файли
Під час компонування виконуваний файл будують із об'єктних файлів (object files), які створює компілятор. Об'єктний файл має заголовок, що містить розмір ділянок коду і даних, а також зсув таблиці символів; об'єктний код (інструкції і дані, згенеровані компілятором), який звичайно розділений на поіменовані ділянки (секції) залежно від призначення; таблицю символів (symbol table).
Таблиця символів — це спеціальний розділ об'єктного файлу, що містить визначення зовнішніх імен, які задають імена та відносні адреси файлових об'єктів, призначених для використання в інших файлах; зовнішні посилання (глобальні символи, що використовуються у файлі), які зазвичай містять зсув відповідної інструкції та необхідний символ.
Інформацію про зовнішні посилання називають також інформацією для налаштування адрес (relocation information).
Завантаження виконуваних файлів за статичного компонування
Виконуваний файл, отриманий внаслідок описаного раніше статичного компонування, містить усе необхідне для створення процесу. Завантаження такого файлу у пам'ять виконує окремий компонент ОС — програмний завантажувач (program loader). Він звичайно відображає виконуваний файл в адресний простір процесу (здебільшого файл відображають не як єдине ціле, а секціями, причому ділянки пам'яті для секцій виділяє також завантажувач) та ініціалізує керуючий блок процесу таким чином, щоб процес був у стані готовності до виконання.
Зазначимо, що під час відображення виконуваного файлу у пам'яті автоматично розміщають його код та ініціалізовані дані. Стек і динамічну ділянку па м'яті зазвичай створюють заново, при цьому для динамічної ділянки компілятор і компонувальник можуть тільки задати її початок, а всю інформацію із керування стеком визначає компілятор із використанням адресації щодо покажчика стека (який буде встановлено завантажувачем).
Динамічне компонування
Поняття динамічної бібліотеки
Статичне компонування виконуваних файлів має низку недоліків.
Якщо кілька застосувань використовують спільний код із бібліотечних фукцій, то кожний виконуваний файл міститиме окрему копію цього коду; у результаті такі файли займатимуть значне місце на диску і у пам'яті.
Під час кожного оновлення застосування потрібно перекомпілювати, перекомпонувати і перевстановити.
Неможливо реалізувати динамічне завантаження програмного коду під час виконання, подібно до того, як це зроблено у ядрі Linux.
Для вирішення цих і подібних проблем було запропоновано концепцію динамічного компонування із використанням динамічних або розділюваних бібліотек (dynamic-link libraries (DLL), shared libraries).
Динамічна бібліотека - набір функцій, скомпонованих разом у вигляді бінарного файлу, який може бути динамічно завантажений в адресний простір процесу, що використовує ці функції.
Динамічне завантаження (dynamic Loading) - завантаження під час виконання процесу (зазвичай реалізоване як відображення файлу бібліотеки в його адресний простір),
Динамічне компонування (dynamic linking) - компонування образу виконуваного файлу під час виконання процесу із використанням динамічних бібліотек.
Переваги використання динамічних бібліотек:
¦ Оскільки бібліотечні функції містяться в окремому файлі, розмір виконуваного файлу стає меншим і так заощаджують дуже багато дискового простору.
¦ Якщо динамічну бібліотеку використовують кілька процесів, у пам'ять завантажують лише одну її копію, після чого сторінки коду бібліотеки відображаються в адресний простір кожного з цих процесів. Це дає змогу ефективніше використовувати пам'ять.
¦ Оновлення застосування може бути зведене до встановлення нової версії динамічної бібліотеки без необхідності перекомпонування тих його частин, які не змінилися.
¦ Динамічні бібліотеки дають змогу застосуванню реалізувати динамічне завантаження модулів на вимогу. На базі цього може бути реалізований розширюваний АРІ застосування. Для додавання нових функцій до такого АРІ стороннім розробникам достатньо буде створити і встановити нову динамічну бібліотеку.
Динамічні бібліотеки дають можливість спільно використовувати ресурси застосування (наприклад, набір піктограм), спростити локалізацію застосування, якщо всі машинно-залежні фрагменти програми, помістити в окрему DLL.
Оскільки динамічні бібліотеки є двійковими файлами, можна організувати спільну роботу бібліотек, розроблених із використанням різних мов програмування і програмних засобів, що спрощує створення застосувань на основі програмних компонентів.
Недоліки при використанні динамічних бібліотек:
¦ Використання DLL сповільнює завантаження застосування. Що більше таких бібліотек потрібно процесу, то більше файлів треба йому відобразити у свій адресний простір під час завантаження. Для прискорення завантаження рекомендують укрупнювати DLL, об'єднуючи кілька взаємозалежних бібліотек в одну загальну.
¦ У деяких ситуаціях (наприклад, під час аварійного завантаження системи із дискети) використання спільних системних DLL неприйнятне через нестачу дискового простору для їхнього зберігання (такі системні DLL можуть займати кілька мегабайтів дискового простору, при цьому застосування часто потребують усього по кілька функцій із них).
¦ Найбільшою проблемою у використанні динамічного компонування є проблема зворотної сумісності динамічних бібліотек.
Неявне і явне зв'язування
Є два основні способи завантаження динамічних бібліотек в адресний простір процесу - неявне і явне зв'язування (implicit і explicit binding).
Неявне - основний спосіб завантаження динамічних бібліотек у сучасних ОС. При цьому бібліотеку завантажують автоматично до початку виконання застосування під час завантаження виконуваного файлу, за це відповідає завантажувач виконуваних файлів ОС. У деяких системах такий завантажувач є частиною ядра ОС, у деяких - окремим застосуванням. Список бібліотек, потрібних для завантаження, зберігають у виконуваному файлі. До переваг цього методу належать:
простота і прозорість з погляду програміста (йому не потрібно писати код завантаження бібліотек, а достатньо у налаштуваннях компонувальника вказати список потрібних бібліотек);
висока ефективність роботи процесу після початкового завантаження (усі необхідні бібліотеки до цього часу вже завантажені у його адресний простір).
Недоліком неявного зв'язування можна вважати зниження гнучкості (так, наприклад, якщо хоча б однієї з необхідних бібліотек не буде на місці, процес завантаження не обійдеться без проблем, навіть коли для виконання конкретної задачі ця бібліотека не потрібна). Крім того, збільшуються час завантаження і початковий обсяг необхідної пам'яті.
Альтернативним для неявного є явне зв'язування, коли динамічну бібліотеку завантажують в адресний простір процесу виконанням системного виклику із його коду. Після цього, використовуючи інший системний виклик, застосування отримує адресу необхідної йому функції бібліотеки і може її викликати. Після використання бібліотеку можна вилучити з пам'яті. Здебільшого неявне зв'язування зводиться до автоматичного виконання тих самих викликів, які сам програміст виконує за явного. Такий підхід вимагає від програміста додаткових зусиль, але має більшу гнучкість. Однак складність реалізації призводить до того, що його використовують лише тоді, коли застосуванню справді потрібно завантажувати і вивантажувати додаткові бібліотеки під час виконання.
Динамічні бібліотеки та адресний простір процесу. Особливості об'єктного коду динамічних бібліотек
Після того, як динамічна бібліотека була відображена в адресний простір процесу, вона стає майже прозорою для програмного коду, що тут виконується.
Усі функції бібліотеки стають доступними для всіх потоків цього процесу, фактично її код і дані набувають вигляду доданих до адресного простору процесу. Зазначимо, що під час відображення бібліотеки у пам'ять використовують технологію копіювання під час записування, тому кожен процес матиме свою копію стека і даних бібліотеки.
З іншого боку, для коду бібліотечної функції будуть доступні такі ресурси, як дескриптори відкритих файлів процесу і стек потоку, що викликав дану функцію. Не слід, однак, забувати про те, що під час роботи із даними потоку із коду бібліотеки потрібно виявляти обережність, зокрема, ніколи не вивільняти пам'ять, розподілену не в цій бібліотеці. Іншими словами, код бібліотек, розрахованих на використання у багато потокових застосуваннях, має бути безпечним з погляду потоків (thread-safe).
Код динамічних бібліотек звичайно зберігають у виконуваних файлах формату, стандартного для цієї ОС, але з погляду характеру цього коду є одна важлива відмінність між кодом DLL і кодом звичайних виконуваних файлів. Вона полягає в тому, що код DLL в один і той самий час повинен мати можливість завантажуватися за різними адресами. Для того щоб це було можливо, такий код потрібно робити позиційно-незалежним.
Позиційно-незалежний код завжди використовує відносну адресацію (базову адресу додають до зсуву). Базову адресу налаштовують у момент завантаження DLL в адресний простір процесу і називають також базовою адресою бібліотеки; такі адреси відрізняються для різних процесів. Зсув у цьому разі називають внутрішнім зсувом об'єкта.
Одна із функцій динамічної бібліотеки може бути позначена як її точка входу. Така функція автоматично виконуватиметься завжди, коли цю DLL відображають в адресний простір процесу (явно або неявно); у неї можна поміщати код ініціалізації структур даних бібліотеки. Багато систем дають змогу задавати також і функцію, що викличеться в разі вивантаження DLL із пам'яті.
Структура виконуваних файлів
У сучасних ОС є тенденція до спрощення процедури завантаження виконуваних файлів через наближення їхнього формату до образу процесу у пам'яті. Описати загальну структуру виконуваного файлу доволі складно, тому лише зупинимося на деяких спільних компонентах таких файлів, а потім розглянемо конкретні формати.
Оскільки виконувані файли створює компонувальник на базі об'єктних файлів, то у структурі цих файлів є багато спільного. Насамперед це стосується того, що обидва види файлів складаються з набору секцій різного призначення.
Деякі спільні елементи структури виконуваних файлів (їхній порядок і точний зміст розрізняють для різних форматів цих файлів) наведено нижче.
Насамперед виконувані файли мають заголовок. Він найчастіше містить «магічні символи», які дають змогу ОС швидко визначити його тип; базову адресу відображення виконуваного коду у пам'ять; ознаку відмінності між незалежним виконуваним файлом і DLL; адреси найважливіших елементів файлу.
Майже завжди в такі файли включають інформацію для динамічного компонувальника. Звичайно ця інформація складається зі списку імпорту, що містить інформацію про всі DLL, потрібні для виконання цього файлу (для неявного зв'язування) та списку експорту, що містить інформацію про всі функції, доступні для використання іншими виконуваними файлами.
Деякі формати виконуваних файлів використовують зовнішній динамічний завантажувач; у цьому разі всередині файлу також зберігають інформацію про його місцезнаходження.
Інформацію про всі секції файлу зберігають у списку секцій, який називають таблицею секцій (section table). Елементи цієї таблиці описують різні секції файлу.
Нарешті, у файлі розташовані самі секції, що містять дані різного призначення. Назви секцій та їхній вміст розрізняються для різних форматів, але майже завжди є секції для коду та ініціалізованих даних.
Виконувані файли в Linux. Формат ELF
Формат ELF є основним форматом виконуваних файлів для Linux та інших сучасних UNIX-систем. Цей формат можна використати для таких типів файлів:
¦ об'єктних, призначених для статичного компонування ;
¦ виконуваних, котрі описують, яким чином виклик exec() створює образ процесу;
¦ розділюваних (динамічних) бібліотек, які компонувальник збирає в образ процесу.
Загальну структуру файлу у форматі ELF показано на рис. 1.
Заголовок файлу. Заголовок файлу у форматі ELF описує його організацію. Він починається із чотирьох обов'язкових «магічних» символів 0x7F, 'E', 'L', 'F'; крім цього, містить інформацію про тип файлу (об'єктний файл, виконуваний файл, динамічна бібліотека), архітектуру процесора, версію файлу, адресу точки входу (за яким ОС передасть керування після завантаження), нарешті, про зсув у файлі таблиці програмних заголовків і таблиці заголовків секцій (ці таблиці розглянемо окремо).
Секції файлу. Інформація про всі секції ELF-файлу перебуває у таблиці заголовків секцій. Вона містить заголовки секцій, кожен із яких описує одну секцію і включає її ім'я, тип, розмір, адресу, на яку вона має відображатися, інформацію про те, чи дозволене записування у секцію після її завантаження у пам'ять.
Розглянемо деякі наперед визначені секції ELF-файлу: .text містить виконуваний код програми, . data і . datal - ініціалізовані дані; . bss призначена для організації сегмента неініціалізованих даних у пам'яті; вона реалізована як «діра» у розрідженому файлі, після завантаження якого у пам'ять спроба звернутися до даних секції спричиняє повернення сторінки, заповненої нулями; . symtab містить таблицю символів цього файлу; . strtab - таблицю рядків цього файлу, в якій роз ташовані використовувані в коді програми рядкові константи.
Об'єктні файли містять також спеціальну секцію . геіос із даними для налаштування адрес. Крім цього, можна виокремити кілька секцій, що зберігають інформацію для системного завантажувача і динамічного компонувальника, що буде розглянуто окремо. Застосування можуть задавати і свої власні секції.
Зазначимо, що в термінології ELF секція означає поіменований розділ у виконуваному файлі; після відображення у пам'ять секції відповідає сегмент.
Програмний заголовок. ELF-файли містять інформацію, яка буде використана для створення образу процесу після завантаження цього файлу у пам'ять. Кажуть про два відображення інформації у форматі ELF — як дискового файлу і як образу процесу. Структурою даних, що керує завантаженням ELF-файлів, є програмний заголовок (pro gram header).
Після завантаження у пам'ять на базі секцій ELF-файлу створюють сегменти пам'яті, причому один сегмент може відповідати кільком секціям. Програмний заголовок — це масив елементів, кожен із яких описує один сегмент. Такий елемент може містити тип сегмента (код, дані тощо), його зсув від початку файлу, віртуальну адресу початку сегмента в пам'яті, розмір сегмента на диску і в пам'яті. Інформацію про динамічне компонування завантажують в особливі сегменти.
Базову адресу бібліотеки або виконуваного файлу обчислюють на основі адреси завантаження та віртуальної адреси першого завантаженого сегмента, визначеного в програмному заголовку (при цьому його округляють до розміру сторінки).
Динамічне компонування в Linux
Спочатку розглядимо елементи ELF-файлу, відповідальні за динамічне компонування, а потім перейдемо до опису всього процесу такого компонування бібліотек формату ELF.
Структури даних підтримки динамічного компонування
Для того щоб забезпечити динамічне компонування, ELF-файл містить ряд структур даних. Розглянемо деякі з них.
¦ Шлях до динамічного компонувальника задають тому, що ELF-формат передбачає реалізацію такого компонувальника окремою програмою. Цей шлях розміщають у спеціальній секції . іnterp і завантажують в особливий сегмент під час виконання. Про принципи роботи такого компонувальника йтиметься окремо.
¦ Інформацію про необхідні динамічні бібліотеки зберігають у спеціальній секції . dynamic, там також міститься інформація про точку входу динамічної бібліотеки. Зазначимо, що в ELF-файлі для динамічних бібліотек не задають окремої інформації про набір функцій, експортованих цією бібліотекою, вважаючи, що бібліотека експортує всі функції, інформація про які наявна у її таблиці символів.
Імена і розташування динамічних бібліотек
До розділюваних бібліотек можна звертатися в різних ситуаціях за трьома різни ми іменами.
Основним (soname). Використовується динамічним компонувальником і має таку структуру: libname.so.N, де name — ім'я бібліотеки, N - базовий номер версії (зміна цього номера зазвичай пов'язана із несумісними змінами в інтерфейсі бібліотеки). Повне основне ім'я включає каталог, у якому перебуває бібліотека. Приклад основного імені: libdl.so.l.
Реальним (real name). Це ім'я файлу, у якому зберігається виконуваний код бібліотеки. Воно додає до основного імені .М1.М2, де М1 — молодший номер версії, М2 — номер реалізації (останній можна пропускати). Прикладом реального імені може бути libdl.so.1.9.5. Повне основне ім'я задають як символічний зв'язок, що вказує на цей файл.
Для компонувальника (linker name). Використовують під час компонування застосування, що потребує бібліотеки; воно — основне ім'я без номера версії: libdl.so. Таке ім'я задають як символічний зв'язок, що вказує на повне основне ім'я. Компонувальник отримує ім'я libxx.so у вигляді параметра -lхх, для libdl.so цей параметр матиме такий вигляд: -ldl.
Ось фрагмент відображення вмісту каталогу, на якому видно всі три імені бібліотеки:
Lrwxrwxrwx libdi.so -> libdl.so.l*
Irwxrwxrwx libdl.so.l -> libdl.so.1.9.5*
-rwxr-xr-x libdl.so.1.9.5*
Під час розробки бібліотеки створюють файл із реальним іменем. Коли встановлюють нову версію динамічної бібліотеки, його поміщають в один із наперед визначених каталогів, після чого запускають спеціальну утиліту Idconfig, яка перевіряє наявні файли й автоматично створює символічні зв'язки для основних імен на підставі інформації про версії з ELF-заголовка. Ця утиліта також обновлює кеш динамічного компонувальника, який розглянемо пізніше.
Зв'язки, Що відповідають іменам для компонувальника, задають вручну.
Використання динамічних бібліотек із застосувань
Коли компонують застосування, що використовує динамічну бібліотеку, необхідно вказати ім'я для компонувальника, яке посилається на основне ім'я. Основне ім'я збережеться у виконуваному файлі застосування у списку необхідних бібліо тек (у секції . dynamic). Жодних додаткових дій у коді застосування виконувати не потрібно — усі функції бібліотеки будуть доступні в ньому як глобальні функції. Ось приклад запуску компілятора gсс для збирання застосування, яке використовує бібліотеку з іменем для компонувальника libabc.so, що перебуває в каталозі /usr/local/lib:
$ gcc myprog.c -о mурrоg -labc -L /usr/local/lib
Розробка динамічних бібліотек
Динамічні бібліотеки в Linux створюють за допомогою стандартного компілятора мови С, подібно до будь-якого виконуваного файлу. Тут докладно не розглядатимемо цей процес, зазначимо тільки, що компонувальнику треба вказати на необхідність генерації позиційно-незалежного коду (для gсс це параметр — fpic), а також задати ім'я, яке потрібно використати як основне ім'я бібліотеки (soname). Для gсс його треба задавати так: -Wl,-soname, основне_ім'я.
Наведемо приклад запуску компілятора gсс для збирання файлу бібліотеки libabc.so.1.0.0 з основним іменем libabc.so. 1:
$ gcc -shared -о libabc.so.1.0.0 -fpic -Wl.-soname,libabc.so.1 abc.c
Розробка коду бібліотеки не потребує додаткових дій — усі створені глобальні функції будуть доступні для застосувань, що використовують бібліотеку.
Точка входу у бібліотеку має реалізовуватись як функція іnit (). її викликатимуть щоразу під час відображення бібліотеки у пам'ять процесу. Функція, яку викликатимуть у разі вивантаження бібліотеки із пам'яті, називається fini ().
Динамічний компонувальник і неявне зв'язування бібліотек
Запуск виконуваного файлу у форматі ELF призводить до того, що керування от римує динамічний компонувальник, шлях до якого зазначений у секції . interp ELF-файлу. У Linux такий компонувальник називають /lib/ld-linux.so.N (N — номер версії). Він у свою чергу відшукує і завантажує всі динамічні бібліотеки, пот рібні для виконання застосування, використовуючи список необхідних бібліотек (секцію .dynamic); під час пошуку він використовує основні імена. Так реалізоване неявне зв'язування.
Список каталогів, у яких динамічний компонувальник має шукати бібліотеки, задають у його конфігураційному файлі /etc/ld.so.conf. Стандартними каталогами для динамічних бібліотек є /lib і /usr/lib, пошук у них здійснюють, навіть якщо не заданий конфігураційний файл.
Пошук у всіх цих каталогах під час кожного запуску застосування неефективний, тому організовують кеш імен динамічних бібліотек і зберігають у файлі /etc/ ld.so.cache. Його створює утиліта ldconfig після того, як встановить усі символічні зв'язки. У разі необхідності шлях до потрібної бібліотеки вибирають із кеша, що підвищує ефективність завантаження застосувань. На жаль, підтримка коректно го стану кеша вимагає ручного запуску ldconfig під час операцій додавання, вилучення або зміни будь-якої динамічної бібліотеки, інакше динамічний компонувальник не врахує цих змін. Це видається недостатньо зручним.
Можна змусити застосування використати для конкретного запуску іншу версію динамічної бібліотеки. Для цього використовують змінну оточення LD_LIB_RARY_PATH, що є списком каталогів. За наявності такої змінної саме із каталогів цього списку починає пошук бібліотек динамічний компонувальник. Якщо в одному із каталогів цього списку буде розміщена інша версія бібліотеки, саме вона буде використана замість стандартної версії, наприклад з /usr/lib.
Ця змінна зручна також тоді, коли зміна у бібліотеці, що не зачіпає основного імені, призвела до порушення роботи деякого застосування. Для цього потрібно перенести стару версію бібліотеки в інший каталог і задати перед запуском застосування значення LD_LIBRARY_PATH, що включає каталог зі старою версією.
Дізнатися, які динамічні бібліотеки потрібні застосуванню, можна за допомогою утиліти ldd, параметром якої є ім'я виконуваного файлу.
Явне зв'язування динамічних бібліотек
Для явного завантаження бібліотеки під час виконання застосування та виклику її функцій потрібно виконати кілька кроків.
1. Завантажити бібліотеку за допомогою системного виклику dlopen(libpath, flags). Як параметри цей виклик приймає:
шлях до бібліотеки libpath (у разі задання повного шляху виклик відразу знаходить файл, якщо задано тільки ім'я — здійснює пошук, аналогічний до того, що виконує динамічний компонувальник);
набір прапорців flags, які керують розв'язанням посилань під час завантаження (наприклад, вмикання прапорця RTLDNOW означає, що всі зовнішні посилання мають розв'язуватися під час завантаження і в разі відмови у розв'язанні хоча б одного посилання завантаження не відбудеться).
Цей виклик повертає дескриптор бібліотеки, який використовується в інших функціях.
Знайти символ у бібліотеці за іменем шляхом виклику dlsym(libd. sym). Першим параметром цього виклику є дескриптор бібліотеки, другим — ім'я символу. Цей виклик повертає адресу символу (функції) у бібліотеці, через цей покажчик можна викликати бібліотечну функцію.
Закрити дескриптор бібліотеки за допомогою виклику dlclose( 1іbd). Бібліотеку вивантажать із пам'яті, якщо для неї не залишиться жодного відкритого дескриптора.
Ось приклад завантаження бібліотеки і виклику функції з неї:
#include <dlfcn.h>
typedef int(*fint)(int);
fint fun; //оголошення покажчика на функцію
void *libd = dlopen(“libmy.so.1”, RTLD_NOW);
if (libd){
fun = dlsyn(libd, “fun”);
int res = (*fun)(100);
dlclose(libd)
}
14.6.3. Автоматичний виклик інтерпретаторів
Під скриптами звичайно розуміють програми, написані на різних інтерпретованих мовах — Perl, Python, TCL, мові командного інтерпретатора UNIX (shell-скрипти). Звичайний спосіб запуску таких скриптів потребує явного задання імені інтерпретатора в командному рядку
$ perl test.pl
У цьому разі буде викликано інтерпретатор мови Perl, а йому на вхід подано Perl-скрипт test.pl, після чого інтерпретатор починає виконувати цей скрипт. Зазначати інтерпретатор щоразу під час запуску скрипта не зовсім зручно.
У всіх UNIX-системах є спосіб виконувати такі файли без вказання імені інтерпретатора в командному рядку. Ця можливість реалізована на рівні завантажувача виконуваних файлів.
Для того щоб файл скрипта був поданий завантажувачем виконуваних файлів на вхід інтерпретатору автоматично, у першому рядку цього файлу треба зазначити повний шлях до інтерпретатора після комбінації символів #! і, крім того, для цього файлу задати права на виконання.
Ось приклад скрипта на Perl, яка використовує цю технологію:
#!/usr/bin/perl
# далі йде звичайний текст скрипта test.pl
Спробу виконання такого файлу перехоплює завантажувач, який завантажує у пам'ять заданий інтерпретатор, а як параметр командного рядка передає йому файл, запуск якого він перехопив.
Виконувані файли у Windows XP. Формат РЕ
Файли формату РЕ (Portable Executable) з'явилися разом із Windows NT. Вони мають багато спільного із традиційними форматами UNIX-систем (об'єктні файли, на основі яких будують такий файл, можуть бути у форматі COFF — одному зі стандартних форматів об'єктних файлів у UNIX). Загальна структура файлу в форматі РЕ наведена на рис. 1
Основна подібність між двома форматами полягає у їхній секційній організації та описі списку секцій таблицею секцій. Зазначимо, що навіть назви і призначення багатьох основних секцій збігаються — це стосується text, .data, .bss, .reloc. Окрема секція, як і в ELF, виділена під опис списку імпорту.
Основні відмінності між двома форматами наведені нижче.
Перед основним заголовком РЕ-файлу поміщають так званий DOS-заголовок. Він містить невелику програму, яка у разі запуску під MS-DOS виводить повідомлення і завершується. Цей заголовок залишився відтоді, коли РЕ-файли часто намагалися запускати під керуванням MS-DOS або Windows 3.x. Під час звичайного запуску (під Windows XP) керування негайно передають за адресою початку виконання, заданою в основному заголовку.
Інформацію, необхідну для виконання файлу, не виносять в окрему структуру даних, а зберігають у стандартних структурах — основному заголовку, заголовках секцій тощо.
Для зручності доступу адреси найважливіших об'єктів усередині файлу (секцій експорту, імпорту, ресурсів тощо) утримують у заголовку файлу як окремий масив DataDirectory.
Окрема секція . rsrc зарезервована для зберігання ресурсів програми. Ресурси всередині цієї секції мають деревоподібну організацію із каталогами і підкаталогами.
Найсуттєвішими є відмінності у структурі інформації для динамічного компонування, про які йтиметься в наступному розділі.
Динамічне компонування у Windows XP. Формат РЕ і динамічне компонування.
Спочатку розглянемо відмінності підтримки динамічного компонування у форматі РЕ від підтримки формату ELF.
У Windows XP динамічне компонування здійснює система, а не окремий динамічний компонувальник; тому в всередині виконуваного файлу ім'я компонувальника не задають.
Окрема секція .edata виділена для опису експортованих символів. Це досить важлива відмінність від ELF, для якого всі символи за замовчуванням є експортованими.
Відмінності має і секція імпортованих функцій. Тут створена таблиця імпорту адрес (ІАТ), що визначає всі імпортовані функції; після запуску її заповнюють адресами функцій з DLL. ІАТ дає змогу звести всю інформацію про імпортовані адреси в одне місце у файлі.
Зазначимо, що як і для ELF, відмінностей між динамічними бібліотеками і виконуваними файлами із погляду формату файлу немає, фактично їх розрізняють за значенням поля типу в заголовку.
Процес компонування у разі неявного зв'язування
Для реалізації неявного зв'язування все, що потрібно від розробника застосування, — це вказати компонувальнику список необхідних DLL і впевнитися, що під час виконання всі вони можуть бути знайдені. Завантажувач ОС забезпечить пошук і виконання потрібного коду. Зауважимо, що якщо під час завантаження DLL виникла помилка, весь процес завантаження переривається. До початку виконання основного потоку процесу всі необхідні DLL мають бути відображені в його адресний простір. У розробці DLL [31], на відміну від UNIX, основним завданням програміста є задання списку експортованих функцій. Цього можна домогтися такими способами:
створити спеціальний файл із розширенням .DEF, у якому перелічити всі такі функції, і передати його компонувальнику;
у разі використання Visual C++ скористатися спеціальною конструкцією _declspec (dllexport), яку потрібно поміщати перед оголошенням експортованої функції:
_declspec(dllexport) DWORD fun0 {
printf("Виклик fun()\n"):
return 100; .
}
При цьому додатково до створення DLL компонувальник згенерує спеціальний файл заглушок — статичну бібліотеку із розширенням .LIB, яку компонують із клієнтським застосуванням і яка містить код заглушок для створення зв'язків із DLL під час завантаження. Ім'я цієї бібліотеки має бути явно задане як один із параметрів виклику компонувальника під час компонування клієнтського застосування.
Компонуючи DLL за допомогою Visual C++, потрібно вмикати прапорець -LD:
c:\mydl1> сі -LD -о mydll.dll mydll.c
У разі використання функцій із DLL їх, на відміну від UNIX, потрібно імпортувати. Це може мати такий вигляд:
_declspec(dllimport) DWORD funO;
void mainO {
printf("Xd\n". funO):
}
Тоді під час компонування будуть узяті функції із .LIB-файлу, а в разі виконання заглушки звертатимуться до справжніх функцій із DLL.
Виклик компілятора Visual C++ для компонування клієнтського застосування, що використовує DLL, матиме вигляд:
c:\dTlclient> сі -о dllclient dllclient.c c:\myd11\mydll .lib
Точка входу в DLL
Точку входу у DLL у Win32 АРІ описують як функцію:
BOOL WINAPI D11Main(HINSTANCE libh. DWORD reason. LPVOID reserved);
Першим параметром для неї є дескриптор екземпляра бібліотеки, другим - індикатор причини виклику (DLLPROCESSATTACH — під час завантаження бібліотеки, DLL_PROCESS_DETACH - у разі вивантаження), третій параметр не використовують.
BOOL WINAPI DllMain(HINSTANCE libh. DWORD reason. LPVOID reserved) { switch (reason) {
case DLL_PROCESS_ATTACH: ргШтСзавантаження DLL\n"); break:
case DLL_PROCESS_DETACH: printf("вивантаження DLL\n"); break;
}
return TRUE:
}
Відкладене завантаження DLL
Для прискорення завантаження застосувань Windows XP надає можливість відкладеного зaвaнтaжeння DLL. У цьому разі бібліотеку пов'язують із виконуваним файлом неявно, але завантажують у пам'ять тільки під час першого звертання до одного із символів, визначених у ній. Для того щоб задати відкладене за вантаження для DLL під час компонування клієнта, потрібно вказати її ім'я як аргумент параметра компонувальника DelayLoad; крім того, необхідно скомпонувати із проектом бібліотеку delayimp.lib.
C:\dllclient>cl -о dllclient dllclient.c c:\mydll\mydll.lib delayimp.lib -link -DelayLoad:mydll.dll
Процес розробки самої бібліотеки залишається незмінним.
Явне зв'язування
Процес явного зв'язування DLL у Windows XP складається кроків:
1. Для відображення DLL в адресний простір процесу використовують функцію HINSTANCE LoadLibrary(libpath) з параметром, який задає шлях до файлу бібліотеки. Ця функція повертає дескриптор екземпляра бібліотеки. Вона є аналогом dlopenO.
Аналогом dlsym() для отримання покажчика на функцію за її іменем є функція GetProcAddress (1іbh, sym). її параметри за змістом ті самі, що і для dl sym() - дескриптор екземпляра і рядок з іменем функції.
Для вивантаження бібліотеки із пам'яті використовують функцію FreeLibrary(libh).
Ось приклад застосування явного зв'язування у Win32 АРІ (він майже нічим не відрізняється від прикладу для Linux):
typedef int(*fint)(int);
fint fun;
HINSTANCE libh - LoadLibrary("my.dH");
if (libh) {
fun = (fint) GetProcAddress(libh."fun"):
int res = fun(lOO);
FreeLibrary(libh);
Зворотна сумісність динамічних бібліотек
Ця проблема виникає в ситуації, коли застосування встановлює нову версію DLL поверх попередньої. Якщо нова версія не має зворотної сумісності із попередні ми, застосування, розраховані на використання попередніх версій бібліотеки, можуть припинити роботу. Досягти такої сумісності досить складно, особливо коли попередня версія містила відомі помилки, і застосування, що використовують бібліотеку, розробили код їхнього обходу — виправлення помилки у бібліотеці може зробити код застосування невірним (кажуть, що в цьому разі порушується сумісність за помилками - bug-to-bug compatibility).
Деякі ОС (переважно це стосується Windows-систем, але певні проблеми є й в UNIX) ускладнювали цю проблему через те, що не давали можливості кіль ком версіям однієї й тієї самої бібліотеки одночасно бути завантаженими у па м'ять; виділяли для динамічних бібліотек усіх застосувань спільний каталог, цим «запрошуючи» застосування перезаписувати динамічні бібліотеки один одного (можлива була навіть ситуація, коли стару версію бібліотеки записували поверх нової); не зберігали в динамічних бібліотеках і застосуваннях інформації про точні версії бібліотек, від яких вони залежать.
Усе це призвело до ситуації, котра стосовно Windows-систем (насамперед Consumer Windows) дістала назву «пекло DLL» (DLL hell), коли із часом виявлялося неможливо визначити, що за версія бібліотеки була встановлена і яким застосуванням і що за версія і якому застосуванню потрібна насправді. По суті не було способу гарантовано забезпечити використання застосуванням тієї версії бібліотеки, з розрахунком на яку воно розроблялося.
Розроблювачі сучасних ОС намагаються виправити цю ситуацію (блокуванням і резервним копіюванням важливих DLL, дозволом кільком версіям DLL бути одночасно завантаженими у пам'ять, а також підтримкою використання застосуванням тільки бібліотек із його робочого каталогу).
Зворотна сумісність DLL у Windows 2000 і Windows XP
Розглянемо проблему зворотної сумісності динамічних бібліотек в ОС лінії Windows.
Переспрямування DLL
Для того щоб вирішити проблему засмічення системних каталогів динамічним бібліотеками застосувань, у Windows 2000 з'явилася можливість змусити завантажувач спочатку переглядати під час пошуку необхідних DLL робочий каталог застосування. Таку можливість називають перетримуванням DLL (DLL redirection), для її реалізації достатньо помістити в робочий каталог застосування файл із іменем, отриманим із імені виконуваного файлу додаванням суфікса .local (на приклад, МуАрр.ехе.Lосаl, якщо виконуваний файл називають MyApp.exe).
Ізольовані застосування і паралельні збірки
У Windows XP запропоновано повніше вирішення даної проблеми. У цій системі з'явилася можливість розробляти застосування, залежність яких від зовнішніх компонентів задають явно, — ізольовані застосування (isolated applications).
Опис залежностей ізольованого застосування зберігають у спеціальному файлі маніфесту застосування (MyApp.exe.manifest). У ньому перераховані компоненти, від яких залежить це застосування. Кожен такий компонент відображають паралельною збіркою (side-by-side assembly) — набором взаємозалежних ресурсів, описаних файлом маніфесту збірки (assembly manifest). Звичайно збірку відображають окремою DLL.
Кожна збірка має версію, при цьому у Windows XP можливе одночасне завантаження у пам'ять кількох версій однієї й тієї самої збірки. За це відповідає спеціальний компонент динамічного завантажувача ОС - менеджер паралельного завантаження (side-by-side manager). Для визначення правильності зв'язування використовують інформацію із файлу маніфесту застосування. Якщо в маніфесті описана залежність від конкретної версії збірки, завантажують цю версію, інакше — версію за замовчуванням.
Якщо задано конкретну версію збірки, вона не може бути перезаписана іншою версією тієї самої збірки: нова версія буде доступна паралельно зі старою. Цим вирішують проблему зворотної сумісності — гарантують наявність саме тієї версії динамічної бібліотеки, з розрахунком на яку розроблене застосування.
Контрольні запитання та завдання
1. На якому етапі роботи компонувальника (на першому проході, після першого проходу, на другому проході, після другого проходу) розробник може бути повідомлений про такі особливі ситуації:
а) глобальна змінна повторно визначена в декількох об'єктних файлах;
б) програма не може поміститися у віртуальному адресному просторі;
в) визначена глобальна змінна, котру жодного разу не використовували;
г) задане посилання на неіснуючу зовнішню змінну?
Чому в сучасних ОС виконувані файли відображають у пам'ять не одним блоком, а секціями?
Опишіть, яким чином використання позиційно-незалежного коду спрощує розробку динамічних бібліотек.
Як під час компонування виконуваного файлу, у якому є звертання до динамічних бібліотек, забезпечити видачу попереджень про нерозв'язані зовнішні посилання?
Чому динамічну бібліотеку завжди відображають в адресний простір процесу повністю, хоча з метою економії пам'яті мало б сенс витягати з неї лише ті функції, які використовує процес?
Чому в системі, що використовує динамічне компонування, перший виклик функції з динамічної бібліотеки може виконуватися значно довше, ніж наступні?
Чому в разі переходу до використання динамічного компонування розмір балансового набору системи може зменшитися? Як зміниться в цьому випадку розмір робочих наборів окремих процесів?
Назвіть переваги і недоліки реалізації динамічного компонування в Linux і Windows XP.
Розробіть динамічну бібліотеку для Linux і Windows XP, яка міститиме набір функцій із завдання 8 розділу 11. Створіть тестове застосування, що використовує цю бібліотеку.
Реалізуйте застосування для Linux і Windows XP, що може бути розширене під час виконання. Інтерфейс модуля розширення задають набором функцій типу void без параметрів. Після запуску застосування видає на екран підказку й очікує введення команди з клавіатури. Можливі такі команди: load ім'я_модуля (завантаження модуля в пам'ять), unload ім'я_модуля (вилучення модуля з пам'яті), сall ім' я_функці ї (виклик функції з модуля). Кожен модуль розширення повинен містити код, який виконується під час його завантаження в пам'ять та вилучення з пам'яті. Якщо під час завантаження модуля буде встановлено, що імена його функцій збігаються з іменами функцій, завантажених раніше в складі іншого модуля, треба видавати повідомлення про помилку.