Найцікавіше, що в жодній літературі про компіляцію не розповідається (у зовсім базовій вважається, що це складно, а в просунутій — що ви знаєте), а всі, хто це знає, кажуть, що прийшло з досвідом. Зазвичай ми компілюємо програму як g++ program.cpp. А ось чого ми поки не знаємо, так це того, що g++ не робить всю роботу самостійно, а викликає інші команди, які виконують компіляцію частинами. І якщо подивитися, що там, то відбувається. cc1plus, потім as, наприкінці collect2, який викликає ld. Давайте спробуємо це повторити. Далі буде перерахування стадій із зазначенням двох моментів: як їх можна виконати руками та яке розширення зазвичай має результат цієї стадії. Класична схема етапів компіляції виглядає так: Є схожа стаття на хабрі на тему. Дуже хочеться з'єднати ось це: Це не компілюється, а точніше, помилка відбувається на етапі трансляції. a.cpp. У тексті помилки написано, що f не визначено у сфері видимості. Все тому, що для того, щоб викликати функцію, треба щось про неї знати. Наприклад, якщо ми передаємо в функцію int - це один асемблерний код, а якщо double - то зовсім інший (бо різні calling convention'и можуть бути). Тому на етапі трансляції слід знати сигнатуру функції. Щоб вказати цю сигнатуру, C++ є оголошення: Коли ми пишемо функцію та крапку з комою - це оголошення/декларація (declaration). Це означає, що у програмі така функція є. А коли ми пишемо тіло функції у фігурних дужках – це визначення (definition). До речі, написати оголошення буває корисним навіть якщо у нас один файл. Наприклад, у такому файлі: Це не компілюється, і справа в тому, що компілятор дивиться файл зверху вниз, і коли доходить до виклику функції f всередині main він ще не дійшов до її визначення. Тут можна переставити функції місцями, так, але якщо ми маємо взаєморекурсивні функції, то там переставити їх не вийде — тільки написати декларацію. Розглянемо такий приклад: Тут на етапі лінківки напишуть, що функція f() визначається двічі. Щоб гарно подивитися, як це працює, можна використати утиліту nm. Коли ви згенеруєте a.o і викличте nm -C a.o, то побачите щось таке: Що робить ключ -C, залишимо потім. На те, що тут знаходиться puts замість printf, теж звертати увагу не треба, це просто така оптимізація компілятора - коли можна замінити printf на puts, замінюємо. Якщо ми хочемо подивитись на об'єктні файли детальніше, нам знадобиться утиліта objdump. Вона має безліч ключів, які кажуть, що ми хочемо побачити. Наприклад, -x - Видати взагалі все. Нам зараз потрібно -d - дизасемблювання та -r - Релокації. Коли ми викличемо objdump -dr -Mintel -C main.o, ми побачимо, що на місці виклику функції f знаходиться call та нулі. Тому що невідомо, де ця функція, треба на етапі лінківки підставити її адресу. А щоб дізнатися, що саме підставити, є релокації, які інформацію про це містять. У загальному випадку релокація — інформація про те, які зміни потрібно зробити із програмою, щоб файл можна було запустити. Давайте тепер на що подивимося. Нехай у нашому файлі визначено функцію f(). І десь за випадковим збігом далеко-далеко також визначено функцію f() . Зрозуміло, що воно так не з'єднується. Але ми можемо на увазі, що наша функція f потрібна тільки нам і ніяк назовні не стирчить. Для цього є спеціальний модифікатор: static. Якщо зробити такі функції nm, то можна побачити символ t замість T, Який саме означає локальність для одиниці трансляції. Взагалі функції, локальні для одного файлу варто позначати як static у будь-якому випадку, тому що це ще допомагає компілятору зробити оптимізації.. Для глобальних змінних все те саме, що й для функцій: наприклад, ми можемо також послатися на глобальну змінну з іншого файла. Тільки тут інший синтаксис: І так само в глобальних змінних можна писати static. А тепер приклад: Вперше вам виведуть 0, тому що глобальні змінні ініціалізуються нулямиЛокальні змінні зберігаються на стеку, і там які дані були до заходу в функцію, ті там і будуть. Обговорена нами модель компіляції дозволяє використовувати різні мови програмування. Поки ЯП може транслюватися в об'єктні файли, проблеми можуть виникнути тільки на етапі лінківки. Наприклад, ніхто не заважає вам взяти вже готовий асемблерник і скомпілювати його з .cpp файлом. Але в виклику асемблера є одна проблема. Те ім'я символу, яке ми побачимо в nm буде fooА в C++ з'явилася перевантаження функцій, тобто void foo(int) і void foo(double) - це дві різні функції, обидві з яких можна викликати. компілятор mangle'іт/декорує імена, тобто змінює їх так, щоб символи вийшли унікальними. nm навіть може видати вам ці імена (в даному випадку вийде _Z3fooi і _Z3food). Але у вас є можливість побачити їх по-людськи: для цього існує вже згаданий ключ. -C, який якщо передати програмі nm, то вона роздекорує все назад і видасть вам імена людину. objdumpЦей ключ дати теж можна. c++filt, яка на ім'я символу дає сигнатуру функції. Так ось, extern "C" каже, що при лінковці нам не потрібно проводити декорацію. І якщо у нас в асемблерному файлі написано fibonacci: , то вам і потрібно залишити ім'я символу як є: У функцій, що мають різні сигнатури, але позначені як extern "C", після компіляції не буде інформації про типи їх аргументів, тому це з'єднується, але працювати не буде (ну або буде, але тут UB, так як, наприклад, типи аргументів очікуються різні). Візьмемо тепер оголошення printf із cstdio і вставимо його оголошення вручну: Така програма також працює. А де визначення printf виникає питання? А ось дивіться. На етапі зв'язування не тільки зв'язуються ваші файли. Крім цього до параметрів зв'язування додаються ще кілька об'єктних файлів і кілька бібліотек. У нашій моделі світу вистачить інформації про те, що бібліотека просто набір об'єктних файлів. І ось при лінківці вам дають стандартну бібліотеку C++ (-lstdc++), математичну бібліотеку (-lm), бібліотеку -libgcc, щоб якщо ви робите арифметику в 128-бітових числах, то компілятор міг викликати функцію __udivti3 (поділ), і купу ще. У нашому випадку потрібна одна - -lc, у якій лежить printf . А ще один з об'єктних файлів, з якими ви лінкуєтеся, містить функцію _start (це може бути файл crt1.o), яка викликає main. Якщо ми використовуємо одну функцію у багатьох файлах, нам треба писати її сигнатуру скрізь. А якщо ми її змінюємо, то взагалі можна повіситися. Тож так не роблять. А як це роблять? А так: декларація виділяється в окремий файл. Цей файл має розширення .h і називається заголовним. По суті це відбувається в стандартній бібліотеці. Підключаються заголовні файли директивою #include, якщо вони з якоїсь бібліотеки, або #include "filename", якщо вони ваші.У чому різниця? Стандартне пояснення – тим, що трикутні дужки спочатку шукають у бібліотеках, а потім у вашій програмі, а лапки – навпаки. Насправді обидва варіанти просто мають список шляхів, де шукати файл, і ці списки різні. Але із заголовками потрібно правильно працювати. Наприклад, не можна робити #include "a.cpp" . Чому? Тому що всі визначені в a.cpp функції та змінні проникнуть туди, куди ви його підключили. І якщо файл у вас один, то ще нічого, а якщо більше, то в кожному, де написано #include "a.cpp" буде визначення, а значить визначення одного і того ж об'єкта буде написано кілька разів. Аналогічний ефект буде, якщо писати визначення відразу в заголовному файлі, не треба так. На жаль, директива #include має кілька нюансів. Давайте поговоримо про структури. Що буде, якщо ми в заголовному файлі створимо struct і підключимо цей файл? Та нічого. Абсолютно нічого. Згенерований асемблерний код буде однаковим. У структур немає визначення по суті, тому що вони не генерують код. Тому їх пишуть у заголовках. При цьому їх методи можна (але не потрібно) визначати там, тому що вони сприймаються компілятором як inline. А хто такий цей inline і як він працює, дивись далі. Але із структурами є один нюанс. Розглянемо ось що: Стандартний спосіб це виправити виглядає так: Це називається include guard. Ще всі можливі компілятори підтримують #pragma once (ефект як у include guard, Але простіше). І справді #pragma once працює краще, тому що не спирається на ім'я файлу, наприклад. Але його немає у стандарті, що сумно. Є один нюанс із #pragma once'ом. Якщо у вас є два жорсткі посилання на один файл, то у нього проблеми. Якщо у вас include guard, то інтуїтивно зрозуміло, що таке різні файли – коли макроси вони різні. А ось чи вважати різними файлами два жорсткі посилання на те саме — питання складне. Інша річ, що робити так, щоб джерела містили жорсткі чи символічні посилання вже досить дивно. Зрозуміло, у чому проблема полягає. Ми підключаємо a.h, в ньому - b.h, а в ньому, оскільки ми вже зайшли в a.h, include guard нам його блокує. І ми спочатку визначаємо структуру b, а потім – a. І при перегляді структури b , ми не знатимемо, що таке a . Для цього є конструкція, яка називається forward-декларацією. Вона виглядає так: Щоб завести покажчик, нам не потрібно знати вміст структури. Тому ми просто говоримо, що b — це певна структура, яку ми визначимо далі. Взагалі forward-декларацію у будь-якому випадку краще використовувати замість підключення заголовних файлів (якщо можливо, звісно). Чому? Поки структуру не визначили, структура це incomplete type. Наприклад, на момент оголошення struct b; у коді вище, b - incomplete. До речі, в той момент, коли ви перебуваєте в середині визначення класу, він все ще вскладне. Все, що можна з incomplete типами робити - це оголошувати функції з їх використанням і створювати покажчик. Уповній тип стає повним типом після визначення. Поки що інформація про incomplete-типи нам ні до чого, але вона вистрілить пізніше. А тепер такий приклад: Тут варто згадати, що структури при лінковці не відіграють жодної ролі, тобто лінковника все одно, що у нас структура x визначена у двох місцях. Тому така програма відмінно скомпілюється і запуститься, проте вона є некоректною. За стандартом така програма працюватиме невідомо як, а життя дані поїдуть. А саме 2 пропаде через вирівнювання double , 3 і 4 перетворяться на одне число ( double ), а 5 буде на своєму місці, а x::e з файлу a.cpp буде просто не проініціалізовано. Правило, згідно з яким так не можна, називається one-definition rule/правило єдиного визначення. До речі, порушенням ODR є навіть тасування полів. Якщо подивитися на асемблер код для bar , то там не буде виклику функції foo , а буде return b; . Це називається inlining коли ми беремо тіло однієї функції і вставляємо всередину іншої як воно є. Це пов'язано, наприклад, зі стилем програмування в поточному світі (багато маленьких функцій, які роблять маленькі речі) — ми прибираємо всі ці абстракції, зливаємо функції в одну і потім оптимізуємо, що там є. Тут не станеться inlining, а чому? А тому що компілятор вміє підставляти тіло функцій лише всередині однієї одиниці трансляції (оскільки inlining відбувається на момент трансляції, а тоді компілятор не має функцій з інших одиниць). Тут помірковано замовчується, що модель компіляції, яку ми обговорюємо – давня та бородата. Ми можемо передати ключ -flto у компілятор, тоді все буде за'inline'. Справа в тому, що при включеному режимі linking time optimization, ми відкладаємо на потім генерацію коду та генеруємо його на етапі лінківки. У такому разі лінківка може тривати багато часу, тому застосовується при складанні з оптимізаціями. Докладніше про режим LTO – значно пізніше. Проте давайте розглянемо, як без LTO виправити проблему з відсутністю inlining'а. Ми можемо написати в заголовку тіло, це допоможе, але це, як ми знаємо, помилка компіляції. Хм-м, ну, можна не тільки написати функцію в заголовному файлі, але і позначити її як static, але це дає вам свою функцію на кожну одиницю трансляції, що, по-перше, буває просто не тим, що ви хочете, а в -друге, кратно збільшує розмір вихідного файла. Тому є модифікатор inline . Він потрібний для того, щоб лінковник не дав помилку порушення ODR. Модифікатор inline безпосередньо не впливає на те, що функції вбудовуються. Якщо подивитися на inline через nm, то там побачимо W (weak) — з кількох функцій можна вибрати будь-яку (передбачається, що вони однакові). По суті inline – вказівка компілятору, що тепер за дотриманням ODR стежите ви, а не він І якщо ODR ви порушуєте, то це невизначена поведінкаill-formed, не diagnostic required). ill-formed, не diagnostic required - Це ситуація, коли програма некоректна, але ніхто не змушує компілятор вам про це говорити. Він може (у GCC є така можливість: якщо дати g++ ключі -flto -Wodr, він вам про це скаже), але не зобов'язаний. А за життям лінковник вибере довільну з наявних функцій (наприклад, з першої одиниці трансляції або взагалі різні в різних місцях): Якщо скомпілювати цей код з оптимізацією, обидві функції f будуть за'inline'і, і все буде добре, якщо без, то залежить від порядку файлів: g++ a.cpp b.cpp може цілком видавати Hello, a.cpp! двічі, а g++ b.cpp a.cpp — Hello, b.cpp! двічі. Якщо потрібно саме за 'inline' функцію, тобто нестандартизовані модифікатори типу __forceinline , проте навіть вони можуть ігноруватися компілятором. #include обговорили вже вздовж і впоперек.Тобто, якщо виконано якусь умову, можна виконати один код, а інакше — інший. І ще є макроси: визначити макрос ( #define ) і визначити макрос ( #undef ): Текст, що йде після імені макросу, називається replacement. Replacement відокремлюється від імені макросу пропуском і поширюється до кінця рядка. Всі входження ідентифікатора PI нижче цієї директиви будуть замінені на replacement. Найпростіший макрос object-like, його ви бачите вище, трохи складніший function-like: Що нам потрібно про це знати. макроси працюють із токенами. Вони не знають взагалі нічого про те, що ви робите. Ви можете написати І отримати відмовлене від реальності повідомлення про помилку. А справа в тому, що це на етапі препроцессингу розкривається, наприклад, так: І тут компілятор бачить більш запеклий марення, ніж назва змінної так, як не можна. Що ще не бачить препроцесор, то це синтаксичну структуру і пріоритет операцій. Більш страшні речі виходять, коли пишеться щось таке: Тому що розкривається це в Це не те, що ви хочете. Тому коли ви таке пишіть, потрібно, по-перше, всі аргументи запихати в дужки, по-друге — вираз тежа по-третє, це вас ніяк не врятує від чогось такого: Тому перед написанням макросів три рази подумайте, чи потрібно воно, а якщо потрібно, будьте дуже обережні. А ще, якщо ви використовуєте відладчик, він нічого не знає про макроси, навіщо йому знати. Тому в налагоджувачі написати «виклик макросу» Ви зазвичай не можете. Див. також FAQ Б'ярна Страуструпа про те, чому макроси це погано. Ще #define дозволяє перевизначати макроси. Replacement макросу не препроцессується щодо макросу, але результат розкриття макросу препроцессируется повторно: Також за специфікацією препроцесор ніколи не повинен розкривати макрос зсередини самого себе, а залишати вкладений ідентифікатор такий: Директиви #ifdef, #ifndef, #if, #else, #elif, #endif дозволяють відпрепроцесувати частину файлу, лише за певної умови. Директиви #ifdef, #ifndef перевіряють чи визначений зазначений макрос. Наприклад, вони корисні для різної компіляції: Директива #if дозволяє перевірити довільний арифметичний вираз. Директива #if препроцесує свій аргумент, а потім парсить те, що вийшло як арифметичний вираз. Якщо після препроцессування в аргументі #if залишаються ідентифікатори, вони замінюються на 0, крім ідентифікатора true , який замінюється на 1. Одне із застосувань #ifndef – це include guard, які вже обговорювалися раніше. Знадобилася нам, наприклад, $pi $. Традиційно в C це робилося через #define. Але препроцесор, як ми знаємо, має купу проблем. У випадку з константою PI нічого не станеться, навряд чи хтось називатиме змінну так, особливо великими літерами, але все ж таки. А в C ++ (а пізніше і в C) з'явився const. Але все-таки, навіщо він потрібен, чому не можна просто написати глобальну змінну double PI = 3.141592; ? До речі, як взагалі взаємодіє const із покажчиками? Подивимося такий приклад: Так не можна, це має фундаментальну проблему: ви можете потім записати *p = 3 і це все порушить. Тому другий рядок не компілюється, і його треба замінити на Але тут треба на що подивитися. У покажчика у певному сенсі два поняття незмінності. Ми можемо зробити так: Хто нам заважає зробити так? Так ніхто, нам не можна міняти вміст p, а не його самого. А якщо ви хочете написати, що не можна змінювати саме сам покажчик, то це не const int * / int const *, а int * const. Якщо вам потрібно заборонити обидва варіанти використання, те, що логічно, const int * const або int const * const. Тобто Тепер на що подивимося: Що з цього містить помилку? Ну, в третьому, точно помилка, це ми вже обговорили. Також перше та четверте точно коректне. А що з другим? Ну, чи порушує друге чиїсь права? Ну ні. Або як би сказали на парадигмах програмування, ніхто не порушує контракт, ми тільки його розширюємо (додатково обіцяючи незмінність), а отже, все має бути добре. Ну, так і працюють неявні перетворення на C++, ви можете навішувати const скрізь, куди хочете, але не можете його прибирати. Константними можуть бути складові типи (зокрема, структури). Тоді ця структура просто матиме константні всі поля. За замовчуванням компіляція не відображає жодних попереджень. Проте попередження компілятора можуть підказати про наявність певних проблем у коді, навіть якщо код успішно компілюється. Найпростіший приклад: у програмі визначено змінну, але вона ніде не використовується. І при компіляції компілятор може підказати про цю проблему, що допоможе розробнику ідентифікувати проблему і відразу відреагувати на неї. Для компіляції з попередженнями застосовується прапор-Wall: Є різні версії стандарту мови Сі, і кожен із них може додавати додатковий функціонал, який ми, можливо, захочемо використовувати у програмі. За допомогою прапора -std= можна вказати конкретний стандарт, додавши c++11 c++14 c++17 або c++20 . Наприклад, для компіляції до стандарту c++17 потрібно написати: Аналогічно для компіляції стандарт C++20 використовується команда: Щоб гарантувати, що програма суворо відповідатиме певному стандарту, можна вказати прапор -pedantic У цьому випадку компілятор генеруватиме попередження, якщо код не відповідає правилам стандарту. Для того, щоб автоматично запустити програму після компіляції, можна використати таку команду: Можна наліпити в одну команду різні опції: Основні параметри компілятора Clang часом повторюють параметри для gcc. Наприклад, компіляція за допомогою Clang під певний стандарт із виведенням помилок: За замовчуванням компіляція не відображає жодних попереджень. Проте попередження компілятора можуть підказати про наявність певних проблем у коді, навіть якщо код успішно компілюється. Найпростіший приклад: у програмі визначено змінну, але вона ніде не використовується. І при компіляції компілятор може підказати про цю проблему, що допоможе розробнику ідентифікувати проблему і відразу відреагувати на неї. Для компіляції з попередженнями застосовується прапор-Wall: Є різні версії стандарту мови Сі, і кожен із них може додавати додатковий функціонал, який ми, можливо, захочемо використовувати у програмі. За допомогою прапора -std= можна вказати конкретний стандарт, додавши c99 c11 або c17 . Наприклад, для компіляції до стандарту c99 потрібно написати: Аналогічно для компіляції стандарт C11 використовується команда: Щоб гарантувати, що програма суворо відповідатиме певному стандарту, можна вказати прапор -pedantic У цьому випадку компілятор генеруватиме попередження, якщо код не відповідає правилам стандарту. Для того, щоб автоматично запустити програму після компіляції, можна використати таку команду: Можна наліпити в одну команду різні опції: Опція -S дозволяє згенерувати файл із асемблерним кодом: В даному випадку за вмістом app.c буде згенеровано файл, який називається як і вихідний файл, тільки має розширення .s, тобто в даному випадку буде згенеровано файл app.s. Цей файл міститиме код на асемблері, причому як синтаксис застосовується синтаксис асемблера GAS (ассемблера від GNU). Основні параметри компілятора Clang часом повторюють параметри для gcc. Наприклад, компіляція за допомогою Clang під певний стандарт із виведенням помилок:Процес компіляції програм
Базові знання про етапи компіляції.
Варто сказати, що інформація про лінківці вірна до появи модулів у C++20, де можна діставати дані одного файлу для іншого. Там виникає залежність файлів один від одного, а значить компілювати їх треба в певному порядку.Оголошення та визначення.
// a.cpp: void f(); // Ось це оголошення. int main()
#include int main() < f(); >void f()
Помилки лінківки. Інструменти nm і objdump. Ключове слово static.
// main.cpp void f(); int main()
U puts 0000000000000000 T f()
А звернути увагу треба на те, що puts не визначено (про це нам каже літера U), а функція f() - визначена в секції .text (літера T). У main.cpp, зрозуміло, буде невизначена функція f() та певна main . Тому, маючи ці об'єктні файли, можна об'єднати main.cpp і a.cpp. Або main.cpp і b.cpp. Без перекомпіляції. Але не можна всі три разом, адже f() буде визначено двічі.Світові змінні.
extern int x; // Оголошення. int x; // Визначення.
// a.cpp extern int a; void f(); int main()
// b.cpp #include int a; void f()
Декорування імен.
extern "C" uint32_t fibonacci(uint32_t n);
Лінківка зі стандартною бібліотекою.
extern "C" int printf(const char*, . ); int main()
Headers (заголовні файли). Директива #include.
Запобігання повторному включенню.
// a.cpp: #include "y.h" // --> `struct x<>;`. #include "z.h" // --> `struct x<>;` помилка компіляції, повторне визначення.
// x.h: #ifndef X_H // Якщо ми визначили макрос, то заголовок цілком ігнорується. #define X_H // Якщо ігнорується, то помічаємо, що файл ми підключили. struct x <>; #endif // До блоку #ifndef. #endif полягає весь файл повністю.
Наперед-декларації.
// a.h #ifndef A_H #define A_H #include "b.h" // Nothing, then `struct b < . >;` struct a < b * bb; >; #endif
// b.h #ifndef B_H #define B_H #include "a.h" // Nothing, then `struct a < . >;` struct b < a* aa; >; #endif
// main.cpp #include "a.h" // `struct b <. >; struct a < . >;` #include "b.h" // Nothing.
// a.h #ifndef A_H #define A_H struct b; struct a < b * bb; >; #endif
// b.h #ifndef B_H #define B_H struct a; struct b < a * aa; >; #endif
Правило єдиного визначення.
// a.cpp #include struct x < int a; // Padding double b; int c; int d; >; x f(); int main()
Inlining.
int foo(int a, int b) < return a + b; >int bar(int a, int b)
Модифікатор inline.
// a.c void say_hello(); int main()
// b.c #include void say_hello()
// a.cpp #include inline void f() < printf("Hello, a.cpp!\n"); >void g();
// b.cpp inline void f() < printf("Hello, b.cpp!\n");
Інші команди препроцесора.
Визначення макросу.
#define PI 3.14159265 double circumference(double r)
#define MUL(x, y) x * y int main()
# define STR "abc" const char * first = STR; // "abc". #define STR "def" const char * second = STR; // "def".
#define Y foo #define X Y // Це не `#define X foo`. #define Y bar// Це не `#define foo bar`. X // Розкривається `X` -> `Y` -> `bar`.
#define M < M >M // Розкривається в < M >.
#define A a < B >#define B b < C >#define C c < A >A // a < b< c< A >> > B // b < c< a< B >> > C // c < a < b < C >> >
Умовна компіляція. Перевірка макросу.
#ifdef __x86_64__ typedef unsigned long uint64_t; #else typedef unsigned long long uint64_t; #endif
#define TWO 2 #if TWO + TWO == 4 // . #endif
Константи.
Як називається файл із додатком після компіляції?
g++ -std=c++11 -Wall -pedantic app.cpp g++ -std=c++14 -Wall -pedantic app.cpp g++ -std=c++17 -Wall -pedantic app.cpp g++ -std=c ++20 -Wall-pedantic app.cpp
g++ -std=c++20 -Wall -pedantic app.cpp -o app & app
Параметри компілятора clang
clang++ -std=c++20 -Wall -pedantic app.cpp -o app.exe & app.exe
Як називається файл із додатком після компіляції?
gcc -std=c99 -Wall -pedantic source.c gcc -std=c11 -Wall -pedantic source.c gcc -std=c17 -Wall -pedantic source.c gcc -std=c23 -Wall -pedantic source.c
gcc -std=c17 -Wall -pedantic app.c -o app.exe & app
Параметри компілятора clang
clang -std=c17 -Wall -pedantic app.c -o app.exe & app.exe