Вступ у створення компілятора C для тих, хто хоче знати про низькорівневі речі
Це переклад цієї дуже чудової книги про будування компіляторів
Rui Ueyama ruiu@cs.stanford.edu
2020-03-16
- Вступ
- Машинна мова та асемблер
- Створення мови рівня калькулятора
- Крок 1: Створення мови, що компілює лише одне ціле число
- Крок 2: Створення компілятора який може додавати та віднімати
- Крок 3: Впровадження токенізатора
- Крок 4: Покращення повідомлень про помилки
- Опис граматики та рекурсивний спусковий розбір
- Представлення граматичної структури за допомогою деревоподібної структури
- Визначення граматики за допомогою правил породження
- Опис правил породження за допомогою BNF
- Прості правила породження
- Вираження пріоритету операторів за допомогою правил породженння
- Правила породженння із рекурсією
- Сінтаксічний аналіз через рекурсивний спуск
- Стекова машина
- Крок 5: Створення мови з підтримкою чотирьох арифметичних операцій
- Крок 6: Унарний плюс та унарний мінус
- Крок 7: Оператори порівняння
- Роздільна компіляція та лінкування
- Функції та локальні змінні
- Представлення цілих чисел у комп’ютері
- Вказівники та рядкові літерали
- Крок 16: Унарні
&
та*
- Крок 17: Скасування неявного визначення змінних та запровадження ключового слова
int
- Крок 18: Запровадження типу вказівника
- Крок 19: Реалізація додавання та віднімання вказівників
- Крок 20: Оператор sizeof
- Крок 21: Реалізація масивів
- Крок 22: Реалізація індексації масиву
- Крок 23: Реалізація глобальних змінних
- Крок 24: Реалізація типу char
- Крок 25: Реалізація рядкових літералів
- Крок 26: Зчитування вхідних даних із файлу
- Крок 27: Рядкові та блокові коментарі
- Крок 28: Переписування тестів на C
- Крок 16: Унарні
- Образ виконання програми та ініціалізатори змінних
- Крок 29 і далі: [Потрібне доповнення]
- Статичне та динамічне зв’язування
- Синтаксис типів у C
- Заключення
- Додаток 1: Шпаргалка по набору інструкцій x86-64
- Список регістрів цілих чисел
- memory access
- function call
- conditional branch
- conditional assignment
- Integer/logical operations
- Appendix 2: Version control with Git
- Workflow using Git
- Points to note when committing
- Internal structure of Git
- Appendix 3: Creating a development environment using Docker
- Setup steps
- Build using containers
- Add new application to container
- Reference materials
- index
Вступ
Ця онлайн-книга зараз пишеться. Це не остаточна версія. форма зворотного зв’язку
Ця книга наповнена вмістом, який занадто багато, щоб включити в одну книгу. У цій книзі ми створимо програму, яка перетворює вихідний код, написаний мовою Сі, на мову асемблера, тобто компілятор Сі. Сам компілятор також розроблено з використанням C. Найближчою метою є можливість самостійного розміщення, тобто компіляції власного вихідного коду за допомогою домашнього компілятора.
У цій книзі я вирішив пояснити різні теми в прогресивній манері, щоб не ускладнювати пояснення компіляторів. Ось чому:
Концептуально компілятори можна розділити на кілька етапів: аналіз, проміжні проходи та генерація коду. Загальний підхід у підручниках полягає в тому, щоб пояснювати кожну тему розділами, але книги з таким підходом, як правило, стають занадто вузькими та глибокими посередині, що ускладнює для читачів слідування.
Крім того, за допомогою методу розробки для створення кожного етапу неможливо запустити компілятор, доки не будуть завершені всі етапи, тому важко помітити, чи є певна помилка у вашому розумінні чи коді, доки весь етап не почне працювати. Недоліком є те, що ви не можете. По-перше, ви насправді не знаєте, якими будуть вхідні дані наступного етапу, доки ви не створите його самі, тому ви насправді не знаєте, що виводити на попередньому етапі. Інша проблема полягає в тому, що важко залишатися мотивованим, оскільки ви не можете скомпілювати жодного коду, поки він не буде завершений.
У цій книзі я вирішив застосувати інший підхід, щоб уникнути цієї пастки. На початку книги ви реалізуєте ``власну мову’’ з дуже простою специфікацією мови. Мова настільки проста, що вам не потрібно багато знати про те, як написати компілятор для її реалізації. Після цього читач продовжуватиме додавати функції до «власної мови» за допомогою цієї книги, і зрештою розвине її до чогось, що сумісно з C.
У такому методі інкрементальної розробки ви створюєте компілятор крок за кроком, роблячи невеликі коміти. За допомогою цього методу розробки компілятор завжди в якомусь сенсі «завершений» при кожному коміті. На одному етапі це може бути лише рівень калькулятора, на іншому це може бути дуже обмежена підмножина C, а на іншому це може бути мова, яку майже можна назвати C. Справа в тому, що на кожному етапі ми прагнемо до мови з прийнятними специфікаціями, які відповідають рівню завершеності на цьому етапі. Під час розробки ми не наголошуємо лише на деяких функціях, щоб це виглядало як мова C.
Ми також пояснимо структури даних, алгоритми та знання з інформатики поетапно відповідно до стадії розробки.
Поступовий розвиток досягає мети, щоб у будь-який момент часу під час читання цієї книги читач мав повне знання про те, як створити розумну мову на цьому рівні. Це набагато краще, ніж стан, коли лише деякі теми створення компілятора надзвичайно детальні. До того часу, коли ви закінчите читати цю книгу, ви будете добре обізнані з усіма темами.
Ця книга також пояснює, як писати великі програми з нуля. Уміння створювати великі програми - унікальне вміння, яке відрізняється від вивчення структур даних і алгоритмів, але я не думаю, що існує багато книг, які пояснюють такі речі. Крім того, навіть якщо хтось пояснить вам це, ви не знатимете, чи є метод розробки хорошим чи поганим, якщо ви не випробували його насправді. Ця книга розроблена таким чином, що процес розробки вашої власної мови на мову C дасть вам практичний досвід хорошого методу розробки.
Якщо план автора вдасться, прочитавши цю книгу, читачі не тільки дізнаються про техніку створення компіляторів і набору інструкцій центрального процесора, а й дізнаються, як розбивати великі програми на маленькі кроки і створювати їх потроху. Ви дізнаєтесь про методи тестування, методи контролю версій і навіть про те, як підготуватися до такого амбітного проекту, як написання компілятора.
Цільова аудиторія цієї книги - звичайні програмісти на C. Вам не потрібно бути суперпрограмістом на C, який добре знає специфікацію мови C. Достатньо, щоб ви розбиралися в покажчиках і масивах і могли принаймні приділити трохи часу читанню невеликих програм C, написаних іншими.
Під час написання цієї книги я намагався не лише пояснити специфікації мови та специфікації процесора, але й якомога докладніше пояснити, чому було обрано саме такий дизайн. Ми також включили колонки про компілятори, центральні процесори, комп’ютерну індустрію та її історію, які зацікавлять читачів і зроблять її читанням приємним.
Створення компілятора - це дуже весело. На початку моя саморобна мова могла робити лише неймовірно прості речі, але, продовжуючи її розвивати, вона швидко стала нагадувати мову C настільки, що навіть я був здивований, і вона почала працювати, наче за помахом чарівної палички. стати. Під час фактичної розробки я часто дивуюся тому, що великий тестовий код, який, на мою думку, не буде добре скомпільований у той час, компілюється без помилок і працює ідеально правильно. Такий код нелегко зрозуміти самому, навіть дивлячись на скомпільовану збірку. Іноді я навіть відчуваю, що мій компілятор має більше розуму, ніж я, автор. Компілятор - це програма, яка, навіть якщо ви знаєте, як вона працює, все одно дивуєтеся, чому вона працює так добре. Я впевнений, що ви теж закохаєтесь у його чарівність.
Отже, без зайвих слів, давайте разом з автором стрибнемо у світ розробки компілятора!
Стаття: Чому мова C?
Чому серед багатьох доступних мов програмування ви вибрали C для цієї книги? Або чому б не рідна мова? Стосовно цього моменту немає жодних причин, чому це обов’язково має бути C, але якщо вам потрібно вибрати мову, щоб навчитися створювати компілятор, який виводить рідний код, C є розумним вибором, який не дуже поширений. Я думаю, що це один із них.
Інтерпретовані мови не дозволяють дізнатися багато про нижчі рівні. З іншого боку, C зазвичай компілюється в асемблер, тому, створивши компілятор, ви можете дізнатися про набір інструкцій процесора та роботу програм, а також про сам C.
C широко використовується, тому, коли у вас є робочий компілятор, ви можете пограти з компіляцією стороннього вихідного коду, який ви завантажуєте з Інтернету. Наприклад, ви можете зібрати та грати в mini Unix xv6. Якщо компілятор достатньо зрілий, можна буде скомпілювати навіть ядро Linux. Така насолода неможлива з другорядними або доморощеними мовами.
C++ - це статично типізована мова, яка компілюється до рідної машинної мови, як-от C, і використовується принаймні так само широко, як і C. Однак специфікації мови для C++ настільки великі, що неможливо легко створити власний компілятор, тому це нереалістичний варіант.
Розробка та реалізація оригінальної мови - це добре з точки зору вдосконалення відчуття мовного дизайну, але є й підводні камені. Речі, які важко реалізувати, можна уникнути, уникаючи їх у специфікації мови. Це не стосується таких мов, як C, де специфікація мови дається як стандарт. Я вважаю, що це обмеження досить добре з точки зору навчання.
Умовні позначення, використані в цій книзі
Функції, вирази, команди тощо відображаються в тексті моноширинним шрифтом, наприклад, main
, foo=3
, make
.
Код, який охоплює кілька рядків, відображається у рамці за допомогою моноширинного шрифту, як показано нижче.
int main() {
printf("Hello world!\n");
return 0;
}
Якщо рамковий код є командою оболонки, яку користувач має дослівно ввести, рядок $, що починається з, представляє підказку. $Введіть решту цього рядка ($але не решту) в оболонку. $Інші рядки представляють результат введеної вами команди. Наприклад, блок нижче make є прикладом того, що відбувається, коли користувач вводить рядок make
і натискає Enter. Результатом команди є make: Nothing to be done for 'all'
:
Середовище розробки передбачається в цій книзі
У цій книзі передбачається, що 64-розрядне середовище Linux працює на так званому звичайному ПК, такому як Intel або AMD. Будь ласка, інсталюйте інструменти розробки, такі як gcc, і створіть заздалегідь відповідно до дистрибутива, який ви використовуєте. Якщо ви користуєтеся Ubuntu, ви можете встановити команди, які використовуються в цій книзі, виконавши таку команду.
$ sudo apt update
$ sudo apt install -y gcc make git binutils libc6-dev
Хоча macOS досить сумісна з Linux на рівні джерела збірки, вона не є повністю сумісною (зокрема, функція під назвою «статичне зв’язування» не підтримується). Хоча можна створити компілятор C для macOS, використовуючи інформацію з цієї книги, якщо ви спробуєте це, ви, ймовірно, зіткнетеся з низкою незначних несумісностей. Не рекомендується одночасно вивчати прийоми створення компілятора C і відмінності між macOS і Linux. Коли щось не працює, важко зрозуміти, яке розуміння неправильне.
Тому ця книга не стосується macOS. У macOS використовуйте якесь віртуальне середовище, щоб підготувати середовище Linux. Якщо ви вперше готуєте віртуальне середовище Linux, зверніться до Додатку 3, у якому підсумовано, як створити середовище розробки за допомогою Docker.
Windows не сумісна з Linux на рівні джерела складання. Однак у Windows 10 можна запускати Linux у Windows як одну програму, і, використовуючи це, ви можете продовжувати розробку у Windows. Програма під назвою Windows Subsystem for Linux (WSL) є таким Linux-сумісним середовищем. Впроваджуючи вміст цієї книги в Windows, інсталюйте WSL і продовжуйте розробку в ньому.
Стаття: крос-компілятор
Машина, на якій працює компілятор, називається «хостом», а машина, на якій виконується код, виведений компілятором, називається «цільовою». Хоча в цій книзі обидва є 64-розрядними середовищами Linux, хост і ціль не обов’язково повинні бути однаковими.
Компілятор, хост і ціль якого відрізняються, називається крос-компілятором. Наприклад, компілятор, який працює в Windows і створює виконуваний файл для Raspberry Pi, є крос-компілятором. Крос-компілятори часто використовуються, коли цільова машина занадто слабка або спеціалізована для запуску компілятора.
Про автора
Руї Уеяма ( @rui314 ). Він є оригінальним автором і поточним супроводжувачем високошвидкісного компонувальника lld, який використовується як стандартний компонувальник для створення виконуваних файлів у багатьох ОС і проектах, включаючи Android (версія Q або новіша), FreeBSD (12 або новіша), Nintendo Switch, Chrome і Firefox. (Отже, існує висока ймовірність того, що двійковий файл, створений інструментом, який я написав, уже є на вашому комп’ютері.) Він також є автором компактного компілятора C 8cc. В основному я пишу есе про програмне забезпечення в нотатках.
Стаття: компілятор, який компілює компілятор
Ситуації з самопосиланням, такі як компілятор C, написаний мовою C, не є рідкістю. Багато реалізацій мови, крім C, написані з використанням самої мови.
Якщо вже існує реалізація мови X, немає ніяких логічних протиріч у створенні нового компілятора X за допомогою самої мови. Якщо ви вирішите самостійно розміщувати компілятор, ви можете просто розробляти його за допомогою існуючого компілятора, а коли закінчите, переключитися на свій власний. Саме це ми намагаємося зробити в цій книзі.
Але що, якщо у вас немає наявного компілятора? У такому випадку у вас немає іншого вибору, як писати іншою мовою. Під час написання вашого першого компілятора для мови X з наміром самостійного розміщення вам потрібно буде написати його за допомогою існуючої мови Y, яка відрізняється від X, і коли компілятор буде завершено, вам потрібно буде переписати сам компілятор з мови Y на мову X.
Компілятори для сучасних складних мов програмування також є іншими компіляторами, які використовувалися для компіляції реалізацій цієї мови, і так далі, поки на початку комп’ютерів хтось не зміг безпосередньо написати машинний код. Ви повинні отримати простий асемблер, який ви написали. Ми не знаємо, чи існував один або кілька асемблерів, які в певному сенсі є основними предками всіх існуючих реалізацій мови, але немає сумніву, що сучасні компілятори починалися з дуже невеликої кількості предків. Шо. Виконувані файли, крім компіляторів, також зазвичай генеруються компіляторами, тому майже всі існуючі виконувані файли є непрямими нащадками вихідного асемблера. Це цікава історія, схожа на походження життя.
Машинна мова та асемблер
Мета цього розділу - дати вам приблизне уявлення про компоненти, з яких складається комп’ютер, і про те, який код ми повинні виводити з компілятора C, який ми створюємо. Ми поки не будемо вдаватися в особливості інструкцій ЦП. По-перше, важливо зрозуміти концепцію.
ЦП і пам’ять
Компоненти, з яких складається комп’ютер, можна умовно розділити на центральний процесор і пам’ять. Пам’ять - це пристрій, який може зберігати дані, а центральний процесор - це пристрій, який читає та записує цю пам’ять для виконання певної обробки.
Концептуально, пам’ять виглядає для центрального процесора як великий масив байтів, до якого можна отримати довільний доступ. Коли центральний процесор звертається до пам’яті, він визначає, до якого байту пам’яті він хоче отримати доступ, вказуючи число, і це число називається «адресою». Наприклад, «прочитати 8 байт даних з адреси 16» означає зчитування 8 байт даних, починаючи з 16-го байта пам’яті, який виглядає як масив байтів. Те саме можна сказати як «прочитати 8 байт даних з адреси 16».
У пам’яті зберігаються як програми, які виконує ЦП, так і дані, які ці програми читають і записують. Центральний процесор зберігає адресу інструкції, що виконується в даний момент, зчитує інструкцію з цієї адреси, виконує те, що там записано, а потім читає та виконує наступну інструкцію. Адреса інструкції, що виконується в даний момент, називається «Лічильником програми» (PC) або «Покажчиком інструкції» (IP). Фактичний формат програми, яку виконує центральний процесор, називається «машинним кодом».
Лічильник програм не обов’язково лінійно переходить до наступної інструкції. Тип інструкції центрального процесора, який називається «інструкція розгалуження», дозволяє встановити лічильник програм на будь-яку адресу, окрім наступної інструкції. Ця функція дозволяє реалізувати оператори if, цикли тощо. Встановлення лічильника програм у місце, відмінне від наступної інструкції, називається «стрибком» або «розгалуженням».
Крім лічильника програм, центральний процесор також має невелику кількість областей зберігання даних. Наприклад, процесори Intel і AMD мають 16 розташувань, які можуть зберігати 64-розрядні цілі числа. Ця область називається «реєстр». Пам’ять є зовнішнім пристроєм центрального процесора, і для читання та запису в неї потрібен деякий час, але регістри знаходяться всередині центрального процесора, і до них можна отримати доступ без затримки.
Більшість машинних кодів відформатовано таким чином, що деякі операції виконуються з використанням значень двох регістрів, а результат записується назад у регістр. Таким чином, виконання програми передбачає зчитування ЦП даних із пам’яті в регістр, виконання якогось обчислення між регістрами та запис результату назад у пам’ять.
Конкретна інструкція машинної мови разом називається «архітектурою набору інструкцій» (ISA) або «набором інструкцій». Існує не лише один тип набору інструкцій; кожен ЦП може створювати його як завгодно. Однак, оскільки ту саму програму неможливо запустити без сумісності на рівні машинного коду, існує не так багато варіацій у наборах інструкцій. Комп’ютери використовують набір інструкцій під назвою x86-64, розроблений компанією Intel та її виробником сумісних мікросхем AMD. Хоча x86-64 є одним із основних наборів інструкцій, він не домінує на ринку. Наприклад, iPhone і Android використовують набір інструкцій під назвою ARM.
Стаття: імена набору інструкцій x86-64
x86-64 також іноді називають AMD64, Intel 64, x64 тощо. Існує історична причина, чому той самий набір інструкцій має кілька таких назв.
Набір інструкцій x86 був створений Intel у 1978 році, але AMD розширила його до 64-розрядного. Приблизно в 2000 році, коли виникла потреба в 64-розрядних процесорах, Intel була прихильна до абсолютно нового набору інструкцій під назвою Itanium і не потрудилася вирішувати конкуруючу 64-розрядну версію x86. Скориставшись цією можливістю, AMD сформулювала та випустила специфікацію 64-bit x86. Це x86-64. Пізніше AMD перейменувала x86-64 на AMD64, можливо, як стратегію брендингу.
Після цього провал Itanium став очевидним, і Intel не мала іншого вибору, окрім як створити 64-розрядну версію x86. Однак на той час було випущено досить багато чіпів AMD64, тому було важко розробити розширений набір інструкцій, який був би схожим, але не ідентичним, і Intel вирішила прийняти набір інструкцій, сумісний з AMD. Кажуть, що Microsoft також чинила тиск з метою підтримки сумісності. У той час Intel прийняла набір інструкцій, який був майже ідентичний AMD64, і назвала його IA-32e. Той факт, що його назвали IA-32e (розширення Intel Architecture 32) замість 64, здається, показує тривалу прихильність до невдалого набору інструкцій, оскільки Itanium все ще є основою 64-розрядних процесорів. Тоді Intel вирішила повністю відмовитися від Itanium, і IA-32e було перейменовано на більш традиційну Intel 64. Microsoft називає x86-64 x64, можливо тому, що їм не подобаються надто довгі імена.
Ось чому x86-64 має так багато різних назв.
Проекти з відкритим кодом часто віддають перевагу назві x86-64, яка не містить назв конкретних компаній. У цій книзі постійно використовується назва x86-64.
Що таке асемблер?
Оскільки машинний код зчитується безпосередньо ЦП, він призначений лише для зручності ЦП, а не для простоти використання людьми. Написання такого типу машинного коду в двійковому редакторі є дуже складним завданням, хоча це й не неможливо. Тому і був винайдений асемблер. Асемблювання - це мова, яка майже однозначно відповідає машинному коду, але її набагато легше читати людям.
Для компіляторів, які виводять власні двійкові файли (на відміну від віртуальної машини чи інтерпретатора), метою зазвичай є виведення асамблеї. Типовий компілятор, який, здається, безпосередньо виводить машинний код, виведе збірку, а потім запустить асемблер у фоновому режимі. Компілятор C, який ми створимо в цій книзі, також виводить збірку.
Перетворення асемблерного коду в машинний код іноді називають «компіляцією», але іноді його також називають «ассемблером», щоб підкреслити, що вхідні дані є асамблеєю.
Можливо, ви вже десь бачили збірку. Якщо ви ще не бачили збірку, зараз саме час подивитися. Давайте скористаємося командою objdump
, щоб розібрати виконуваний файл і відобразити машинний код, що міститься в ньому, як код складання. Нижче наведено результат розбирання команди ls
.
$ objdump -d -M intel /bin/ls
/bin/ls: file format elf64-x86-64
Disassembly of section .init:
0000000000003d58 <_init@@Base>:
3d58: 48 83 ec 08 sub rsp,0x8
3d5c: 48 8b 05 7d b9 21 00 mov rax,QWORD PTR [rip+0x21b97d]
3d63: 48 85 c0 test rax,rax
3d66: 74 02 je 366a <_init@@Base+0x12>
3d68: ff d0 call rax
3d6a: 48 83 c4 08 add rsp,0x8
3d6e: c3 ret
...
У моєму середовищі команда ls
містить близько 20 000 машинних інструкцій, тому дизассемблований результат також досить довгий, майже 20 000 рядків. Ми включили лише перші кілька сюди.
Код складання зазвичай структурований як один рядок на машинний код. Наприклад, розглянемо такий рядок:
3d58: 48 83 ec 08 sub rsp,0x8
Що означає цей рядок? 3d58
- це адреса пам’яті, яка містить машинний код. Це означає, що під час виконання команди ls інструкції в цьому рядку будуть розміщені в пам’яті за адресою 0x3d58
і виконуватимуться, коли програмний лічильник буде 0x3d58
. Наступні чотири шістнадцяткові числа є фактичним машинним кодом. ЦП зчитує ці дані та виконує їх як інструкції. sub rsp,0x8
- збірка, яка відповідає цій машинній інструкції. Набір інструкцій центрального процесора буде пояснено в окремій главі, але ця інструкція віднімає 8 з регістру під назвою RSP.
C та відповідна асемблерна програма
Простий приклад
Щоб отримати уявлення про те, який саме вивід генерує C-компілятор, порівняймо C-код із відповідним йому асемблерним кодом. Розглянемо найпростіший приклад - наступну програму на C:
int main() {
return 42;
}
Припустимо, що ця програма записана у файл test1.c
. Її можна скомпілювати таким чином і перевірити, що вона дійсно повертає значення 42:
$ cc -o test1 test1.c
$ ./test1
$ echo $?
42
У C значення, яке повертає функція main
, стає кодом завершення всієї програми. Цей код завершення не виводиться на екран, але неявно зберігається у змінній оболонки $?
, тож одразу після завершення команди можна вивести значення цієї змінної командою echo $?
. Тут ми бачимо, що було повернуто саме 42
.
Асемблерна програма, що відповідає цій C-програмі, виглядає наступним чином:
.intel_syntax noprefix
.globl main
main:
mov rax, 42
ret
У цьому асемблері оголошено глобальну мітку main
, після якої йде код функції. Значення 42 записується в регістр RAX
, і потім здійснюється повернення з функції. Існує загалом 16 регістрів, які можуть містити цілі числа, включаючи RAX
. За домовленістю, значення, яке знаходиться в RAX
після повернення з функції, вважається її результатом. Тому в цьому випадку число 42 записується саме в RAX
.
Спробуймо скомпілювати й виконати цю асемблерну програму. Файл з асемблерним кодом має розширення .s
, тож запишіть наведений вище код у файл test2.s
і виконайте наступні команди:
$ cc -o test2 test2.s
$ ./test2
$ echo $?
42
Як і у випадку з C-програмою, результатом виконання програми став код завершення 42.
Грубо кажучи, C-компілятор - це програма, яка зчитує C-код, такий як у test1.c
, і генерує асемблерний код на кшталт test2.s
.
Приклад із викликом функції
Розглянемо трохи складніший приклад, у якому показано, як код із викликом функції перетворюється на асемблер.
Виклик функції - це не просто перехід (jump); після завершення викликаної функції програма повинна повернутися до місця, з якого цей виклик був зроблений. Адресу, за якою потрібно відновити виконання, називають адресою повернення (return address). Якщо виклик функції здійснюється лише один раз, цю адресу можна зберегти в якомусь реєстрі процесора. Але оскільки функції можуть викликатися рекурсивно або вкладено будь-яку кількість разів, адресу повернення потрібно зберігати в пам’яті - конкретно у стеку.
Стек можна реалізувати, використовуючи лише одну змінну, яка зберігає адресу його вершини. Ця змінна зберігається у спеціальному регістрі, що називається стековим вказівником (stack pointer). Архітектура x86-64 підтримує роботу з функціями, маючи спеціальний регістр стекового вказівника та відповідні інструкції. Занесення даних у стек називається пуш (push), а зняття даних зі стека - поп (pop).
Розглянемо приклад на C:
int plus(int x, int y) {
return x + y;
}
int main() {
return plus(3, 4);
}
Відповідний асемблерний код виглядає так:
.intel_syntax noprefix
.globl plus, main
plus:
add rsi, rdi
mov rax, rsi
ret
main:
mov rdi, 3
mov rsi, 4
call plus
ret
1-й рядок вказує на використання Intel-синтаксису. 2-й рядок з .globl
оголошує функції plus
і main
як глобальні, тобто видимі в усій програмі. Це можна тимчасово проігнорувати.
Зосередимось на функції main
. У C вона викликає plus
з аргументами. У асемблері є правило: перший аргумент передається через регістр RDI
, другий - через RSI
. Тому перші два рядки функції main
присвоюють значення 3 і 4 цим реєстрам відповідно.
Інструкція call
викликає функцію. Вона виконує два кроки:
- Заносить адресу наступної інструкції (
ret
)` у стек - це і є адреса повернення. - Переходить до функції, вказаної як аргумент (у цьому випадку -
plus
).
Функція plus
Функція plus
складається з трьох інструкцій.
add rsi, rdi
: додає значення вrdi
доrsi
і зберігає результат уrsi
. У x86-64 арифметичні інструкції зазвичай працюють із двома операндами, де результат зберігається в першому.mov rax, rsi
: копіює результат ізrsi
уrax
, оскільки значення, яке повертається з функції, повинно бути в регістріRAX
.ret
: ця інструкція:- Знімає зі стека адресу повернення.
- Переходить за цією адресою.
Іншими словами, ret
є зворотною операцією до call
, і разом вони забезпечують правильну передачу керування між функціями.
Після повернення з plus
, керування знову опиняється в main
, а значення в rax
уже є результатом виклику функції plus
. Тому інструкція ret
в main
повертає це саме значення - як і в оригінальному коді на C.
Підсумки цього розділу
У цьому розділі ми стисло пояснили, як комп’ютер працює «під капотом» і що саме має робити C‑компілятор. Коли дивишся на асемблер чи машинний код, вони здаються громіздкими й далекими від C, проте, як виявилося, їхня структура доволі прямо відображає ту саму логіку, що й у C‑коді - це, мабуть, помітили багато читачів.
Оскільки в книжці ми ще майже не розглядали конкретні машинні інструкції, значення окремих команд асемблера, показаних через objdump, можуть бути незрозумілими. Та й не потрібно їх одразу знати напам’ять: важливо лише відчути, що кожна інструкція сама по собі робить небагато. На цьому етапі такого інтуїтивного розуміння цілком достатньо.
Нижче наведено ключові моменти розділу у вигляді тез:
- CPU виконує програму, читаючи й записуючи дані до пам’яті.
- І сам виконуваний код, і дані, з якими він працює, зберігаються в оперативній пам’яті; процесор послідовно зчитує з неї машинні інструкції та виконує їх.
- У CPU є невеликі комірки пам’яті - реєстри, і більшість машинних інструкцій описує саме операції між реєстрами.
- Асемблер - це «читабельна» форма машинного коду, і типовий C‑компілятор передусім генерує саме асемблерний текст.
- Функції в C залишаються функціями й в асемблері; їхні межі та виклики чітко зберігаються.
- Виклик функцій реалізовано через стек, де зберігаються адреси повернення та інші дані, потрібні для коректного відновлення виконання.
Колонка: Онлайн-компілятор
Спостерігати за C‑кодом і результатом його компіляції - чудовий спосіб вивчати асемблерну мову. Але постійно редагувати вихідний код, компілювати його і вручну переглядати асемблерний вивід - це доволі клопіткий процес.
На щастя, існує дуже зручний вебсайт, який значно спрощує цю задачу - Compiler Explorer (відомий також як godbolt). На цьому сайті, якщо ввести C‑код у текстове поле на лівій половині екрана, то в правій половині миттєво з’являється відповідний асемблерний код.
Коли хочете швидко перевірити, у що саме перетворюється ваш C‑код, цей сервіс стане надзвичайно корисним.
Створення мови рівня калькулятора
У цьому розділі, як перший крок до створення компілятора C, ми реалізуємо підтримку чотирьох основних арифметичних операцій та інших арифметичних операторів, щоб мати змогу компілювати вирази на кшталт:
30 + (4 - 2) * -5
На перший погляд, це може здаватися простим завданням, але насправді воно досить складне. У математичних виразах існує структура: наприклад, вирази в дужках мають вищий пріоритет, множення має вищий пріоритет за додавання тощо. Якщо не зрозуміти цю структуру належним чином, обчислення буде неправильним. Проте вхідний вираз - це всього лише плоска послідовність символів, а не структуровані дані. Щоб правильно оцінити вираз, потрібно проаналізувати послідовність символів і вивести приховану в ній структуру.
Такі задачі синтаксичного аналізу дуже складно розв’язати без попередніх знань. У минулому, особливо в період з 1950-х по 1970-ті роки, ці задачі вважалися складними, і над ними активно працювали, розробляючи різноманітні алгоритми. Завдяки тим зусиллям сьогодні синтаксичний аналіз уже не є настільки складною задачею, якщо знати, як до неї підходити.
У цьому розділі ми пояснимо один з найпоширеніших алгоритмів синтаксичного аналізу - парсер рекурсивного спуску (recursive descent parsing). Цей метод використовують компілятори C/C++, якими ви, ймовірно, користуєтеся щодня, такі як GCC і Clang.
Потреба аналізувати текст із певною структурою виникає не лише при створенні компіляторів, а й у багатьох інших ситуаціях програмування. Техніки, які ви вивчите в цьому розділі, можна застосовувати і до таких задач. Без перебільшення можна сказати, що методи синтаксичного аналізу, які ми розглянемо, - це інструменти на все життя. Прочитайте цей розділ, зрозумійте алгоритм і додайте техніку синтаксичного аналізу до вашої програмістської скарбнички навичок.
Крок 1: Створення мови, що компілює лише одне ціле число
Розгляньмо найпростіший можливий підмножину мови C. Якою мовою ви її уявляєте? Мовою, що містить лише функцію main? Або мовою, яка складається лише з одного виразу? Якщо звести все до абсолютного мінімуму, можна сказати, що мова, яка складається лише з одного цілого числа, є найпростішим можливим підмножиною.
У цьому кроці ми реалізуємо саме таку - найпростішу - мову.
Програма, яку ми створимо на цьому етапі, буде компілятором, що зчитує одне число з вхідних даних і генерує асемблерний код, який завершує виконання програми з цим числом як кодом завершення. Іншими словами, якщо вхідні дані - це просто рядок, наприклад 42, то компілятор має вивести такий асемблерний код:
.intel_syntax noprefix
.globl main
main:
mov rax, 42
ret
Рядок .intel_syntax noprefix
- це директива асемблера, яка вказує, що ми використовуємо стиль Intel для запису асемблерного коду (існує кілька стилів, і цей - той, який використовується в цій книзі). У компіляторі, який ви створюєте, завжди додавайте цей рядок на початку як стандартну частину виводу. Інші рядки - ті самі, що ми вже пояснювали в попередньому розділі.
Можливо, дехто з читачів подумає: “Такий простий програмний код - хіба це компілятор?” Чесно кажучи, автор теж так думає. Але з технічної точки зору, ця програма приймає на вхід мову, що складається з одного цілого числа, і генерує відповідний машинний код - отже, за визначенням, це цілком справжній компілятор. І навіть така проста програма стане основою, яку ми зможемо поступово ускладнювати, тож почати з неї - слушне рішення.
Насправді, якщо дивитися на загальну структуру процесу розробки, цей крок є дуже важливим. Саме цей базовий компілятор ми будемо використовувати як скелет для подальшого розвитку. На цьому етапі ми не лише створимо ядро компілятора, а й налаштуємо файл збірки (Makefile), автоматичне тестування і репозиторій Git. Давайте розглянемо кожен із цих процесів по черзі.
До речі, компілятор, який ми створюємо в цій книзі, має назву 9cc. cc - це загальноприйнята абревіатура для “C compiler”. Число 9 особливого значення не має - попередня версія компілятора, створена автором, мала назву 8cc, тож 9cc - це наступна ітерація. Звісно, ви можете вибрати будь-яку назву для власного компілятора. Головне - не витрачайте надто багато часу на вибір назви і не відкладайте через це саму розробку. Назву, включно з назвою репозиторію на GitHub, завжди можна змінити пізніше, тому почніть із чогось простого.
Колонка: Нотація Intel та AT&T
Окрім Intel-нотації, яка використовується в цій книзі, існує також AT&T-нотація, що широко застосовується в Unix-системах. Наприклад, gcc та objdump за замовчуванням виводять асемблерний код саме в AT&T-нотації.
В AT&T-нотації результат записується як другий аргумент інструкції. Це означає, що в командах з двома аргументами порядок аргументів буде обернений порівняно з Intel-нотацією. Крім того:
Імена регістрів починаються з префікса
%
, наприклад%rax
.Числові значення мають префікс
$
, наприклад$42
.Для доступу до пам’яті замість квадратних дужок
[]
використовуються круглі()
, і синтаксис має свій особливий вигляд.Нижче наведено кілька прикладів для порівняння:
mov rbp, rsp // Intel
mov %rsp, %rbp // AT&T
mov rax, 8 // Intel
mov $8, %rax // AT&T
mov [rbp + rcx * 4 - 8], rax // Intel
mov %rax, -8(rbp, rcx, 4) // AT&T
У цьому компіляторі ми обрали Intel-нотацію, оскільки вона є читабельнішою, особливо для новачків. Крім того, офіційна документація Intel щодо інструкцій також використовує саме цей стиль, що дозволяє легко переносити приклади з мануалів без перетворення.
Щодо виразності обох нотацій - вона абсолютно однакова. Яку б нотацію ви не використали, згенерований машинний код буде ідентичним.
Створення ядра компілятора
Зазвичай компілятор отримує вхідні дані у вигляді файлу, але на цьому етапі, щоб не ускладнювати задачу відкриттям і зчитуванням файлів, ми передаватимемо код безпосередньо як перший аргумент командного рядка.
C-програму, яка зчитує це значення як число, і вбудовує його в стандартну структуру асемблерного коду, можна написати дуже просто. Вона буде виглядати приблизно так:
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "Неправильна кількість аргументів.\n");
return 1;
}
printf(".intel_syntax noprefix\n");
printf(".globl main\n");
printf("main:\n");
printf(" mov rax, %d\n", atoi(argv[1]));
printf(" ret\n");
return 0;
}
Створіть порожній каталог із назвою 9cc
, і в ньому створіть файл 9cc.c
, який міститиме програму, описану вище. Після цього спробуйте виконати 9cc
з командного рядка, як показано нижче, щоб перевірити, чи програма працює належним чином.
$ cc -o 9cc 9cc.c
$ ./9cc 123 > tmp.s
У першому рядку ми компілюємо файл 9cc.c
і створюємо виконуваний файл з назвою 9cc
. У другому рядку ми передаємо число 123
як вхідний аргумент програмі 9cc
, яка генерує асемблерний код і записує його у файл tmp.s
.
Давайте перевіримо вміст файлу tmp.s
, щоб переконатися, що все працює правильно.
$ cat tmp.s
.intel_syntax noprefix
.globl main
main:
mov rax, 123
ret
Як бачите, асемблерний код було успішно згенеровано. Тепер ми можемо передати цей асемблерний файл асемблеру, щоб створити виконуваний файл.
У системах Unix команда cc
(або gcc
) слугує не лише компілятором C чи C++, а й універсальним інтерфейсом (фронтендом) до багатьох мов. Вона визначає мову за розширенням вхідного файлу і автоматично викликає відповідний компілятор або асемблер. Тому, як і у випадку компіляції 9cc.c
, якщо ми передамо асемблерний файл з розширенням .s
до cc
, він його засемблює.
Нижче наведено приклад, як виконується асемблювання, запуск програми та перевірка коду завершення:
$ cc -o tmp tmp.s
$ ./tmp
$ echo $?
123
У Unix оболонках (наприклад, bash) змінна $?
містить код завершення попередньої команди. У прикладі вище виводиться число 123
- саме те, яке ми передали компілятору 9cc як аргумент. Це означає, що програма працює правильно.
Спробуйте передати інші числа в межах 0–255
(оскільки у Unix коди завершення процесів обмежені цим діапазоном) і переконайтесь, що 9cc
працює як слід у кожному випадку.
Створення автоматичних тестів
Багато хто з читачів, особливо якщо програмує для себе як хобі, можливо, ніколи не писав тести. Але в цій книзі ми писатимемо тест-код щоразу, коли будемо розширювати компілятор. Спочатку це може здаватися зайвим або нудним, але дуже швидко ви зрозумієте, наскільки це зручно й корисно.
Якщо не писати тести, вам доведеться вручну запускати одні й ті самі перевірки знову й знову після кожної зміни - і це, повірте, набагато виснажливіше та менш надійно.
Багато хто вважає написання тестів нудним саме через ускладнені або надто формалізовані тест-фреймворки. Наприклад, JUnit - потужний інструмент, але його налаштування та вивчення займає час. Тому в цьому розділі ми не використовуватимемо ніяких зовнішніх фреймворків.
Натомість ми написатимемо дуже простий “тестовий фреймворк” на shell-скрипті, яким будемо користуватись протягом розробки.
Нижче наведено приклад shell-скрипта test.sh:
#!/bin/bash
assert() {
expected="$1"
input="$2"
./9cc "$input" > tmp.s
cc -o tmp tmp.s
./tmp
actual="$?"
if [ "$actual" = "$expected" ]; then
echo "$input => $actual"
else
echo "$input => $expected expected, but got $actual"
exit 1
fi
}
assert 0 0
assert 42 42
echo OK
Створіть файл test.sh з вмістом, наведеним вище, і зробіть його виконуваним за допомогою команди chmod a+x test.sh
. Спробуйте запустити test.sh. Якщо не виникне жодних помилок, скрипт завершиться з відображенням OK наприкінці.
$ ./test.sh
0 => 0
42 => 42
OK
Якщо станеться помилка, test.sh не відобразить OK. Натомість test.sh покаже очікуване значення та фактичне значення для невдалого тесту у такому форматі:
$ ./test.sh
0 => 0
42 expected, but got 123
Якщо ви хочете налагодити тестовий скрипт, запустіть його в bash з опцією -x. З цією опцією bash відображатиме трасування виконання, як показано нижче.
$ bash -x test.sh
+ assert 0 0
+ expected=0
+ input=0
+ cc -o 9cc 9cc.c
+ ./9cc 0
+ cc -o tmp tmp.s
+ ./tmp
+ actual=0
+ '[' 0 '!=' 0 ']'
+ assert 42 42
+ expected=42
+ input=42
+ cc -o 9cc 9cc.c
+ ./9cc 42
+ cc -o tmp tmp.s
+ ./tmp
+ actual=42
+ '[' 42 '!=' 42 ']'
+ echo OK
OK
«Тестовий фреймворк», який ми будемо використовувати протягом цієї книги, - це звичайний шелл-скрипт, подібний до наведеного вище. Можливо, цей скрипт здасться надто простим у порівнянні з повноцінними тестовими фреймворками на кшталт JUnit, але така простота добре узгоджується з простотою самого 9cc, тому саме така легкість і є бажаною.
Суть автоматичного тестування полягає в тому, щоб можна було миттєво запустити свій код і автоматично порівняти результат. Тож не варто ускладнювати - головне, почати писати тести.
Збірка за допомогою make
Протягом читання цієї книги вам доведеться зібрати 9cc сотні або навіть тисячі разів. Оскільки процес створення виконуваного файлу 9cc і запуску тестового скрипта щоразу однаковий, зручно доручити цю роботу інструменту.
Стандартним інструментом для таких цілей є команда make.
Після запуску make читає файл з назвою Makefile у поточній директорії та виконує команди, записані в ньому.
Makefile складається з правил, що закінчуються двокрапкою, і набору команд, які виконуються для кожного правила.
Нижченаведений Makefile автоматизує команди, які ми хочемо виконувати на цьому етапі.
CFLAGS=-std=c11 -g -static
9cc: 9cc.c
test: 9cc
./test.sh
clean:
rm -f 9cc *.o *~ tmp*
.PHONY: test clean
Створіть файл з назвою Makefile в тій самій директорії, де знаходиться 9cc.c, використовуючи наведений вище вміст.
Після цього, достатньо буде просто виконати команду make
, щоб зібрати 9cc, а команду make test
- щоб запустити тести.
make розуміє залежності між файлами, тому після зміни 9cc.c немає потреби вручну запускати make
перед make test
. Якщо виконуваний файл 9cc старіший за 9cc.c, make
автоматично збере 9cc перед запуском тестів.
make clean
- це правило для видалення тимчасових файлів. Хоча можна видалити ці файли вручну за допомогою rm
, це небезпечно, оскільки можна випадково видалити щось важливе. Тому такі службові завдання теж зручно прописати у Makefile.
Зверніть увагу: в Makefile для відступів обов’язково використовуються табуляції, а не пробіли. Використання пробілів (навіть чотирьох або восьми) призведе до помилки. Це незручність пов’язана з тим, що make
- дуже старий інструмент, розроблений ще в 1970-х роках.
Нарешті, компілятору cc
обов’язково потрібно передавати опцію -static
. Її призначення буде пояснено в розділі про динамічне лінкування. Наразі вам не потрібно вдаватися в деталі - просто включайте цю опцію завжди.
Управління версіями за допомогою Git
У цій книзі ми будемо використовувати Git як систему контролю версій. Оскільки компілятор створюється поступово, крок за кроком, створюйте коміт у Git на кожному етапі з відповідним коментарем-коментарем (коміт-меседжем).
Коментарі до комітів можна писати українською або японською - головне, щоб вони чітко підсумовували зроблені зміни в одному рядку. Якщо ви хочете додати детальніше пояснення, зробіть один порожній рядок після першого рядка, а потім напишіть розгорнутий опис змін.
За допомогою Git необхідно відстежувати лише ті файли, які ви створюєте вручну. Наприклад, згенеровані файли, які з’являються після запуску 9cc, не потрібно додавати в Git, оскільки їх можна легко згенерувати знову тими ж командами.
Якщо ви включите такі файли до репозиторію, коміти стануть перевантаженими непотрібними змінами. Тому важливо виключити тимчасові файли та файли резервного копіювання з відстеження Git.
У Git для цього використовується спеціальний файл з назвою .gitignore
, де ви вказуєте шаблони файлів, які слід ігнорувати.
Створіть файл .gitignore у тій самій директорії, де знаходиться 9cc.c, і додайте до нього такі шаблони, щоб Git ігнорував тимчасові файли, резервні копії редактора тощо:
*~
*.o
tmp*
a.out
9cc
Якщо ви використовуєте Git вперше, вам потрібно вказати своє ім’я та електронну адресу, щоб Git міг зберігати ці дані в журналі комітів.
Нижче наведено приклад того, як автор налаштовує своє ім’я та email у Git. Замість них введіть свої власні дані:
$ git config --global user.name "Rui Ueyama"
$ git config --global user.email "ruiu@cs.stanford.edu"
Щоб створити коміт у Git, спочатку потрібно додати змінені файли за допомогою команди git add.
Оскільки цього разу ми створюємо перший коміт, спершу потрібно ініціалізувати Git-репозиторій за допомогою git init, а потім додати всі файли, створені до цього моменту, за допомогою git add.
$ git init
Initialized empty Git repository in /home/ruiu/9cc
$ git add 9cc.c test.sh Makefile .gitignore
Потім зафіксуйте його за допомогою git commit.
$ git commit -m "Створіть компілятор, який компілює одне ціле число"
Укажіть повідомлення коміту за допомогою опції -m
. Без параметра -m
git запустить редактор. Ви можете підтвердити, що фіксація була успішною, запустивши git log -p
наступним чином:
$ git log -p
commit 0942e68a98a048503eadfee46add3b8b9c7ae8b1 (HEAD -> master)
Author: Rui Ueyama <ruiu@cs.stanford.edu>
Date: Sat Aug 4 23:12:31 2018 +0000
整数1つをコンパイルするコンパイラを作成
diff --git a/9cc.c b/9cc.c
new file mode 100644
index 0000000..e6e4599
--- /dev/null
+++ b/9cc.c
@@ -0,0 +1,16 @@
+#include <stdio.h>
+#include <stdlib.h>
+
+int main(int argc, char **argv) {
+ if (argc != 2) {
...
Нарешті, давайте завантажимо git-репозиторій, який ми створили досі, на GitHub. Немає особливої причини завантажувати на GitHub, але також немає причин не робити цього, і GitHub корисний для резервного копіювання вашого коду. Щоб завантажити на GitHub, створіть нове сховище (у цьому прикладі я створив сховище під назвою 9cc за допомогою користувача rui314) і додайте його як віддалений репозиторій за допомогою такої команди:
$ git remote add origin git@github.com:rui314/9cc.git
Після цього, коли ви виконуєте git push, вміст вашого репозиторію буде відправлено на GitHub. Після виконання git push
відкрийте GitHub у браузері та переконайтеся, що ваш вихідний код завантажено.
На цьому створення компілятора на першому кроці завершено. Компілятор на цьому етапі є надто простою програмою, щоб її можна було назвати компілятором, але це гарна програма, яка містить усі елементи, необхідні для компілятора. Відтепер ми продовжуватимемо розширювати функції цього компілятора, і, хоча в це все ще важко повірити, ми розробимо з нього чудовий компілятор C. По-перше, насолоджуйтеся завершенням першого кроку.
Еталонна реалізація
Крок 2: Створення компілятора який може додавати та віднімати
На цьому етапі ми розширимо компілятор, створений на попередньому кроці, щоб він міг обробляти не лише значення накшталт 42
, а й вирази, що містять додавання та віднімання, як-от 2+11
чи 5+20-4
.
Вираз накшталт 5+20-4
можна обчислити під час компіляції й вставити в асемблер готове число (в цьому випадку - 21
), але тоді компілятор почне поводитися більше як інтерпретатор. Тому потрібно згенерувати асемблерний код, який виконує додавання та віднімання під час виконання програми. Асемблерні інструкції для додавання та віднімання - це add
і sub
. Команда add
приймає два регістри, додає їхні значення та записує результат у перший регістр. sub
працює так само, але виконує віднімання. Використовуючи ці інструкції, вираз 5+20-4
можна скомпілювати таким чином:
.intel_syntax noprefix
.globl main
main:
mov rax, 5
add rax, 20
sub rax, 4
ret
В наведеному вище асемблері спочатку за допомогою команди mov
у регістр RAX
записується значення 5
, потім до RAX
додається 20
, а потім віднімається 4
. На момент виконання команди ret
, значення в RAX
має бути 5 + 20 - 4
, тобто 21
.
Спробуймо це перевірити на практиці. Збережемо вищенаведений код у файл із назвою tmp.s, скомпілюємо його та запустимо на виконання.
$ cc -o tmp tmp.s
$ ./tmp
$ echo $?
21
Правильно - як показано вище, якщо все зроблено правильно, то на виході має з’явитися значення 21.
Тепер постає питання: як створити такий асемблерний файл автоматично?
Розглянемо вирази з додаванням та відніманням як мову програмування. Цю мову можна визначити наступним чином:
- Спочатку йде одне
число
. - Далі може йти нуль або більше
термів
(тобто частин виразу). - Кожен терм - це або
+
ічисло
, або-
ічисло
.
Якщо прямо перекласти це визначення у програму на C, отримаємо такий код (далі, як приклад, буде наведено простий компілятор, що читає такий вираз і генерує відповідний асемблер):
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char **argv) {
if (argc != 2) {
fprintf(stderr, "Неправильна кількість аргументів\n");
return 1;
}
char *p = argv[1];
printf(".intel_syntax noprefix\n");
printf(".globl main\n");
printf("main:\n");
printf(" mov rax, %ld\n", strtol(p, &p, 10));
while (*p) {
if (*p == '+') {
p++;
printf(" add rax, %ld\n", strtol(p, &p, 10));
continue;
}
if (*p == '-') {
p++;
printf(" sub rax, %ld\n", strtol(p, &p, 10));
continue;
}
fprintf(stderr, "Неочікуваний символ: '%c'\n", *p);
return 1;
}
printf(" ret\n");
return 0;
}
Це стало трохи довшою програмою, але початкова частина та рядок із ret залишилися такими ж, як і раніше. В середину було додано код для зчитування термів (тобто додаткових чисел з операціями + або -).
Оскільки тепер програма зчитує не просто одне число, важливо знати, до якого місця в рядку вже було прочитано. Функція atoi
не повертає інформацію про кількість прочитаних символів, тому, якщо використовувати її, ми не знатимемо, з якого місця починати читати наступний терм.
З цієї причини тут використано функцію strtol
з C стандартної бібліотеки.
Як працює strtol
: strtol
після зчитування числа оновлює вказівник, переданий як другий аргумент, так, щоб він вказував на символ, що йде після числа. Тобто, якщо після числа йде + або -, то вказівник p після виклику strtol
буде вказувати саме на цей символ.
У наведеній програмі це використовується так:
- Зчитується число за допомогою
strtol
- Перевіряється, чи далі йдуть символи
+
або-
- Якщо так, то в циклі
while
зчитуються наступні терми - Для кожного терму генерується одна інструкція асемблера
Тепер спробуймо запустити цю оновлену версію компілятора
Після того як ти оновив файл 9cc.c, достатньо просто виконати make
. Це згенерує новий виконуваний файл 9cc.
Нижче наведено приклад його використання:
$ make
$ ./9cc '5+20-4'
.intel_syntax noprefix
.globl main
main:
mov rax, 5
add rax, 20
sub rax, 4
ret
Схоже, що асемблер справді правильно генерується - чудова новина!
Тепер, щоб перевірити нову функціональність автоматично, доцільно додати тестовий рядок у файл test.sh
.
assert 21 "5+20-4"
Коли ви це зробите, зафіксуйте зміни в git. Для цього виконайте таку команду:
$ git add test.sh 9cc.c
$ git commit
Коли ви виконаєте команду git commit
, відкриється текстовий редактор. У ньому введіть повідомлення «Додано додавання та віднімання», збережіть файл і закрийте редактор.
Потім скористайтеся командою git log -p
, щоб переконатися, що коміт було зроблено так, як ви очікували.
Нарешті, виконайте git push
, щоб відправити коміт на GitHub - після цього цей етап буде завершено!
Еталонна реалізація
Крок 3: Впровадження токенізатора
У компіляторі, створеному на попередньому кроці, є один недолік - якщо у вхідному рядку містяться пробіли, програма видає помилку. Наприклад, якщо подати рядок із пропусками, як-от 5 - 3
, під час спроби прочитати знак +
або -
компілятор натрапить на пробіл і завершить роботу з помилкою.
$ ./9cc '5 - 3' > tmp.s
Неочікуваний символ: ' '
Існує кілька способів вирішення цієї проблеми. Один із очевидних підходів - пропускати пробіли перед тим, як читати символи +
чи -
. Цей метод працює і не викликає особливих складнощів, проте на цьому кроці ми розглянемо інший підхід. Цей підхід полягає в тому, щоб до початку обробки виразу розбити вхідний рядок на окремі «слова» (токени) - тобто створити спеціальний токенізатор, який виділяє числа, оператори та інші елементи як окремі одиниці.
Як і в японській чи англійській мовах, арифметичні вирази та мови програмування можна розглядати як послідовність слів. Наприклад, вираз 5+20-4
складається з п’яти «слів»: 5
, +
, 20
, -
, 4
. Ці «слова» називають токенами (tokens). Пробіли між токенами існують лише для розділення цих токенів і не є частиною жодного з них. Тому природно під час розбиття рядка на токени ігнорувати пробіли. Процес розбиття рядка на послідовність токенів називають токенізацією (tokenize).
Розбиття рядка на токени має й інші переваги. Під час токенізації можна не лише розділити вхідний рядок на окремі частини, а й класифікувати ці токени, присвоюючи їм типи. Наприклад, символи +
і -
однозначно позначають відповідні оператори, а рядок 123
означає число 123. Завдяки такому підходу, коли ми вже маємо типізовані токени, подальша обробка виразу спрощується - менше потрібно думати про те, як інтерпретувати кожен фрагмент під час споживання токенів.
У граматиці для виразів з додаванням і відніманням, які ми розглядаємо, типи токенів можна визначити як три основні категорії: +
, -
та числа
. Для зручності реалізації компілятора також доцільно ввести спеціальний тип токена, який позначатиме кінець послідовності токенів (аналогічно тому, як рядок закінчується символом '\0'
). Це допоможе зробити код простішим і зручнішим. Токени будемо зв’язувати між собою у вигляді однозв’язного списку, де кожен токен містить посилання на наступний. Завдяки цьому можна обробляти вхід будь-якої довжини, і при цьому легко рухатися по послідовності токенів.
Ось покращена версія компілятора з доданим токенізатором. Програма стала дещо довшою, але тепер вона спочатку розбиває вхідний рядок на токени, які зберігаються у зв’язному списку, і вже потім працює з цією структурою.
#include <ctype.h>
#include <stdarg.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Типи токенів
typedef enum {
TK_RESERVED, // Символ
TK_NUM, // Токен цілих чисел
TK_EOF, // Кінець послідовності даних
} TokenKind;
typedef struct Token Token;
// Тип для токена
struct Token {
TokenKind kind; // Тип токена
Token *next; // Наступний вхідний токен
int val; // Якщо тип TK_NUM, то значення
char *str; // Рядок токена
};
// Поточний токен
Token *token;
// Функція для повідомлення про помилки.
// Приймає ті самі аргументи, що й printf.
void error(char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
vfprintf(stderr, fmt, ap);
fprintf(stderr, "\n");
exit(1);
}
// Якщо наступний токен є очікуваним символом,
// прочитати один токен і повернути true. В іншому випадку поверніть false.
bool consume(char op) {
if (token->kind != TK_RESERVED || token->str[0] != op)
return false;
token = token->next;
return true;
}
// Якщо наступний токен є очікуваним символом, читайте один токен вперед.
// В іншому випадку повідомити про помилку.
void expect(char op) {
if (token->kind != TK_RESERVED || token->str[0] != op)
error("Очікувався '%c'", op);
token = token->next;
}
// Якщо наступний токен є числом, просунутися на один токен вперед і повернути це число.
// В іншому випадку повідомити про помилку.
int expect_number() {
if (token->kind != TK_NUM)
error("Це не число");
int val = token->val;
token = token->next;
return val;
}
bool at_eof() {
return token->kind == TK_EOF;
}
// Створіть новий токен і підцепіть його до cur
Token *new_token(TokenKind kind, Token *cur, char *str) {
Token *tok = calloc(1, sizeof(Token));
tok->kind = kind;
tok->str = str;
cur->next = tok;
return tok;
}
// Токенізує вхідний рядок p і поверне посилання на перший токен
Token *tokenize(char *p) {
Token head;
head.next = NULL;
Token *cur = &head;
while (*p) {
// Пропустити пробіли
if (isspace(*p)) {
p++;
continue;
}
if (*p == '+' || *p == '-') {
cur = new_token(TK_RESERVED, cur, p++);
continue;
}
if (isdigit(*p)) {
cur = new_token(TK_NUM, cur, p);
cur->val = strtol(p, &p, 10);
continue;
}
error("Неможливо токенізувати");
}
new_token(TK_EOF, cur, p);
return head.next;
}
int main(int argc, char **argv) {
if (argc != 2) {
error("Неправильна кількість аргументів");
return 1;
}
// Токенізуємо
token = tokenize(argv[1]);
// Виведіть першу частину ассемблера
printf(".intel_syntax noprefix\n");
printf(".globl main\n");
printf("main:\n");
// Перша частина виразу має бути числом, тому ми перевіряємо це
// та виводимо першу інструкцію mov.
printf(" mov rax, %d\n", expect_number());
// Візьміть послідовність токенів `+ <число>` або `- <число>` та виведіть ассемблер.
while (!at_eof()) {
if (consume('+')) {
printf(" add rax, %d\n", expect_number());
continue;
}
expect('-');
printf(" sub rax, %d\n", expect_number());
}
printf(" ret\n");
return 0;
}
Це приблизно 150 рядків коду - не дуже коротко, але й не надто складно. Тут немає жодних хитрощів, тож якщо читати зверху вниз, усе має бути зрозуміло.
Давайте пояснимо кілька прийомів програмування, які використовуються в наведеному вище коді.
-
Потік токенів, які зчитує парсер, представлено глобальною змінною
token
. Парсер рухається вперед, переходячи по зв’язному списку токенів. Такий стиль програмування з використанням глобальних змінних може виглядати не надто акуратним. Однак на практиці часто виявляється, що представлення вхідних токенів як потоку (stream), подібно до стандартного введення, робить код парсера більш читабельним. Тому ми застосували саме такий підхід. -
Код, який безпосередньо працює з
token
, винесено у функціїconsume
таexpect
, а інші частини програми безпосередньо не торкаютьсяtoken
. -
Функція
tokenize
створює зв’язний список токенів. Під час побудови зв’язного списку часто зручно створити «порожній» (dummy) елементhead
, до якого приєднуються нові елементи, а потім повертатиhead->next
. Такий прийом спрощує код. Хоча пам’ять, зайнята самим head, фактично не використовується, вартість виділення локальної змінної практично дорівнює нулю, тому турбуватися про це не варто. -
calloc
- це функція для виділення пам’яті, схожа наmalloc
. На відміну відmalloc
,calloc
додатково ініціалізує виділену пам’ять нулями. Тут ми використалиcalloc
, щоб не витрачати час на ручне очищення пам’яті.
У цій покращеній версії тепер має бути можливість пропускати пробіли, тому давайте додамо такий однорядковий тест до test.sh
:
assert 41 " 12 + 34 - 5 "
Код завершення процесу в Unix - це число від 0 до 255, тому при написанні тестів переконайтеся, що результат всього виразу знаходиться в цьому діапазоні.
Якщо додасте тестові файли до git-репозиторію, цей крок буде вважатися виконаним.
Еталонна реалізація
Крок 4: Покращення повідомлень про помилки
У компіляторі, який ми створили до цього моменту, при наявності синтаксичної помилки можна було лише зрозуміти, що помилка є, але не було зрозуміло, де саме вона сталася. На цьому кроці ми виправимо цю проблему. Зокрема, ми додамо більш інтуїтивні повідомлення про помилки, які будуть показувати конкретне місце помилки та допомагати легше її знайти.
$ ./9cc "1+3++" > tmp.s
1+3++
^ Це не число
$ ./9cc "1 + foo + 5" > tmp.s
1 + foo + 5
^ Неможливо токенізувати
Щоб показувати такі повідомлення про помилки, потрібно знати, на якому саме байті вхідного рядка сталася помилка. Для цього ми збережемо весь вхідний рядок у змінну user_input
, а функція виведення помилок буде приймати вказівник на місце у цьому рядку, де сталася помилка.Нижче наведено приклад такої функції:
// Вхідна програма
char *user_input;
// Повідомити про помилку
void error_at(char *loc, char *fmt, ...) {
va_list ap;
va_start(ap, fmt);
int pos = loc - user_input;
fprintf(stderr, "%s\n", user_input);
fprintf(stderr, "%*s", pos, " "); // pos пробілів на вхідному рядку
fprintf(stderr, "^ ");
vfprintf(stderr, fmt, ap);
fprintf(stderr, "\n");
exit(1);
}
Функція error_at
отримує вказівник на позицію в рядку, де сталася помилка. Віднявши від цього вказівника початок рядка (тобто user_input
), можна визначити, на якому саме байті вхідних даних сталася помилка. Потім цю позицію можна виділити символом ^
, щоб зробити помилку більш помітною.
Якщо зберегти argv[1]
у змінну user_input
, а всі виклики помилок оновити з error("Це не число")
на error_at(token->str, "Це не число")
, то цей крок вважається завершеним.
У реальних компіляторах також потрібно писати тести на поведінку при помилках введення, але зараз повідомлення про помилки служать лише для допомоги під час налагодження, тому писати тести на них поки що необов’язково.
Еталонна реалізація
Колонка: Форматування коду
Як і у випадку з текстом японською, де багато помилок у пунктуації роблять його важким для читання, так і у програмному коді - якщо відступи неправильні або пропуски між словами нестабільні, то навіть без урахування логіки коду його складно назвати акуратним. Форматування коду - це, здавалося б, дрібниця, але застосування чітких правил автоматично допомагає робити код зрозумілішим і приємнішим для читання.
При розробці в команді важливо домовитися про стиль форматування, але в цій книзі, де ви розробляєте поодинці, можна обрати будь-який популярний стиль, який вам подобається.
У нових мовах програмування часто намагаються зовсім позбавити розробників необхідності сперечатися про стиль, надаючи офіційні інструменти форматування. Наприклад, у мові Go є команда
gofmt
, яка автоматично форматуватиме код за єдиним офіційним стилем. Вона не дає вибору опцій - це єдиний “офіційний формат Go”, і це повністю вирішує питання стилю.Для C і C++ існує
clang-format
, але в цій книзі ми не настільки наполягаємо на використанні подібних інструментів. Головне - намагайтеся писати код з чистим і послідовним стилем одразу, а не виправляти погане форматування потім.
Колонка: Уразливість через помилку відступів у коді
Іноді неправильні відступи у вихідному коді можуть призводити до серйозних проблем із безпекою. Так сталося, зокрема, у iOS та macOS, де через таку помилку виникла критична вразливість.
Нижче наведено фрагмент коду, де була допущена ця помилка:
if ((err = ReadyHash(&SSLHashSHA1, &hashCtx)) != 0) goto fail; if ((err = SSLHashSHA1.update(&hashCtx, &clientRandom)) != 0) goto fail; if ((err = SSLHashSHA1.update(&hashCtx, &serverRandom)) != 0) goto fail; if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0) goto fail; goto fail; if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0) goto fail;
Чи помітили ви, де тут помилка? З першого погляду цей код виглядає звичайним, але якщо придивитись уважніше, то другий знизу оператор
goto
не входить до тілаif
і тому виконується завжди.На жаль, цей код відповідав за перевірку TLS-сертифікатів, і через цю помилку більша частина коду перевірки сертифіката просто пропускалася за допомогою безумовного переходу
goto
. В результаті iOS і macOS приймали недійсні сертифікати за дійсні, що дозволяло проводити атаку типу “людина посередині” (MITM) на HTTPS-сайти.Цю помилку виявили і виправили у 2014 році. Через повторення слова
goto fail
її назвали «помилкою goto fail» - своєрідна гра слів, бо через зайвийgoto fail
програма фактично “провалювалася”.
Опис граматики та рекурсивний спусковий розбір
Отже, далі ми хочемо додати до мови множення, ділення та дужки для керування пріоритетом операцій - тобто *
, /
, ()
. Але для цього постає одна велика технічна проблема: множення і ділення повинні обчислюватися раніше, ніж додавання чи віднімання. Наприклад, вираз 1 + 2 * 3
повинен інтерпретуватись як 1 + (2 * 3)
, а не як (1 + 2) * 3
.
Такі правила, що визначають, які оператори «зчіплюються» першими, називаються пріоритетом операторів (operator precedence).
Як же обробляти пріоритет операторів?
У компіляторі, який ми створювали до цього моменту, ми просто послідовно читаємо вхідні токени з початку і одразу генеруємо асемблер. Якщо ми просто розширимо його підтримкою *
та /
, то вираз 1 + 2 * 3
буде оброблений як (1 + 2) * 3
, що неправильно.
Звісно, справжні компілятори можуть коректно обробляти пріоритети операторів. Синтаксичний аналіз компілятора (парсинг) - це потужна технологія, яка дозволяє правильно інтерпретувати навіть дуже складний код, якщо він відповідає граматиці. Іноді поведінка компілятора здається майже надлюдською, але насправді комп’ютер не має людської здатності розуміти мову - весь синтаксичний аналіз здійснюється за допомогою чітких, механічних алгоритмів.
Тож як саме це працює?
У цьому розділі ми зробимо паузу від кодування і вивчимо техніки синтаксичного аналізу. Ми розглянемо ці техніки у такому порядку:
- Зрозуміємо, яку структуру даних має повертати парсер - тобто, яка кінцева мета аналізу.
- Вивчимо правила визначення граматики - як описуються синтаксичні конструкції.
- На основі цих правил - навчимося писати парсер за допомогою техніки рекурсивного спуску.
Представлення граматичної структури за допомогою деревоподібної структури
При реалізації парсерів для мов програмування типовим є такий підхід: вхідними даними є плоска послідовність токенів, а вихід - дерево, яке відображає вкладену (ієрархічну) структуру. Компілятор, який ми створюємо в цій книзі, також дотримується цього підходу.
У мовах програмування на зразок C синтаксичні конструкції на кшталт if або while можуть бути вкладеними одна в одну. Використання дерев для їхнього представлення - цілком природне й наочне рішення.
Арифметичні вирази також мають внутрішню структуру: наприклад, вирази в дужках обчислюються першими, а множення й ділення мають вищий пріоритет, ніж додавання й віднімання. На перший погляд, така структура може не виглядати деревоподібною, але насправді дерево чудово й наочно передає ці відносини.
Наприклад, вираз 1 * (2 + 3) можна зобразити у вигляді такого дерева:
Дерево, яке представляє 1*(2+3)
Якщо ми приймаємо правило, за яким обчислення відбувається від листків дерева до кореня, тоді наведене вище дерево чітко виражає зміст виразу 1 * (2 + 3)
як: спочатку обчислити 2 + 3
, а потім помножити результат на 1
. Іншими словами, порядок обчислення прямо закладений у структурі самого дерева.
Розглянемо ще один приклад. Наступне дерево відповідає виразу 7 - 3 - 3
:
Дерево, яке представляє 7-3-3
У наведеному вище дереві правило “обчислювати зліва направо” для віднімання чітко зафіксоване у самій формі дерева. Воно представляє вираз (7 - 3) - 3 = 1
, а не 7 - (3 - 3) = 7
.
Якщо б ми мали на увазі останній випадок (тобто обчислення праворуч спочатку), дерево було б глибшим з правого боку. Таким чином, структура дерева точно відображає асоціативність оператора, тобто порядок, у якому виконуються однакові операції. Оператори, які обчислюються зліва направо, називаються лівозв’язними (left-associative). Оператори, що обчислюються справа наліво, називаються правозв’язними (right-associative).
У мові C, майже всі оператори (за винятком операції присвоєння =) є лівозв’язними.
Дерева дуже добре масштабуються: їх можна зробити скільки завгодно глибокими, щоб виразити довгі вирази. Розглянемо приклад виразу 1 * 2 + 3 * 4 * 5
, який можна представити наступним деревом:
Дерево, яке представляє 1 * 2 + 3 * 4 * 5
Таке дерево, як наведене вище, називається синтаксичним деревом (syntax tree). Особливо, якщо з дерева видалено зайві елементи на кшталт дужок, які потрібні лише для групування в тексті, і воно виражає тільки суттєву структуру виразу, таке дерево називається абстрактним синтаксичним деревом (abstract syntax tree, AST). Усі приклади, які ми розглядали раніше, - це саме абстрактні синтаксичні дерева.
AST - це внутрішнє представлення програми в компіляторі, і тому його структура може бути вільно визначена з міркувань зручності реалізації. Проте є деякі природні відповідності: Арифметичні оператори, як-от додавання чи множення, є бінарними (мають лівий і правий операнд), тому у більшості компіляторів вони представляються як двійкові дерева. Функції або блоки коду, де послідовно виконується багато інструкцій, зазвичай представляються як дерева з довільною кількістю дочірніх вузлів - «плоскі» структури, де послідовність має значення.
Головна мета синтаксичного аналізу - побудова абстрактного синтаксичного дерева (AST). Компілятор спочатку виконує синтаксичний аналіз, щоб перетворити вхідну послідовність токенів на AST. Потім це дерево слугує основою для наступного етапу - генерації асемблерного коду або іншої форми виконуваного представлення.
Визначення граматики за допомогою правил породження
Тепер давайте дізнаємося, як описуються синтаксичні правила мов програмування. Більшість граматик мов програмування визначається за допомогою правил породження (production rules). Правила породження - це спосіб рекурсивного визначення граматики.
Спробуємо спочатку подивитися на це з точки зору природної мови. У українській мові, як і в багатьох інших, граматичні конструкції мають вкладену (ієрархічну) структуру. Наприклад, у реченні “Квітка гарна”:
Слово “квітка” - іменник. Його можна розширити до “червона квітка” - іменна фраза. Ще далі: “трохи червона квітка”. І навіть вставити все це в інше речення: “Я подумав, що трохи червона квітка гарна”.
Таку структуру можна формалізувати як набір граматичних правил: “Речення” складається з “підмета” і “присудка”. “Іменна фраза” - це або іменник, або прикметник + іменна фраза. Починаючи з початкового символу (“речення”), ми можемо за цими правилами поступово розгортати конструкцію, створюючи нескінченну кількість граматично правильних речень.
А ще - у зворотному напрямку - ми можемо взяти готове речення і, аналізуючи його за цими правилами, з’ясувати його структуру. Цей процес і є суттю синтаксичного аналізу (парсингу).
Спочатку такі правила породження були розроблені для природних мов, але вони виявилися дуже сумісними з обробкою даних у комп’ютерах. Тому сьогодні широко використовуються у мовах програмування, компіляторах, інтерпретаторах та багатьох інших галузях інформатики.
Колонка: Генеративна граматика Чомського
Ідею генеративної граматики (тобто граматики на основі правил породження) вперше запропонував лінгвіст Ноам Чомскі. Його ідеї мали величезний вплив як на лінгвістику, так і на комп’ютерні науки.
Згідно з гіпотезою Чомського, люди здатні говорити тому, що в мозку людини вроджено існує спеціальний механізм, який дозволяє засвоювати правила породження мови. Іншими словами, люди мають природжену здатність до опанування рекурсивних граматичних структур, що й дає змогу оволодівати мовою. Інші тварини, за Чомським, не здатні до мовлення саме тому, що в їхньому мозку відсутній такий механізм. Він припустив, що ця здатність - унікальна для виду Homo sapiens. Минуло вже майже 60 років від моменту, коли Чомскі оприлюднив цю гіпотезу. Хоча її не вдалося ні остаточно довести, ні спростувати, вона і сьогодні залишається впливовою та переконливою концепцією в лінгвістиці та когнітивних науках.
Опис правил породження за допомогою BNF
Існує одна нотація для компактного і зрозумілого опису правил породження - це BNF (Backus–Naur form) і її розширення EBNF (Extended BNF). У цій книзі граматику мови C пояснюватимемо за допомогою EBNF. У цьому розділі спочатку буде пояснено BNF, а потім - розширення EBNF.
У BNF кожне правило породження записується у вигляді A = α₁α₂⋯. Це означає, що символ A можна розкласти у послідовність α₁α₂⋯. Послідовність α₁α₂⋯ складається з нуля або більше символів, які можуть бути як символами, що не підлягають подальшому розкладу, так і символами, які можуть бути далі розкладені (тобто зустрічаються зліва у деяких правилах породження).
Символи, які не підлягають подальшому розкладу, називають «термінальними символами» (terminal symbol), а символи, які з’являються зліва у правилах і можуть розкладатися - «нетермінальними символами» (nonterminal symbol). Граматику, визначену такими правилами, зазвичай називають «контекстно-вільною граматикою» (context free grammar).
Один нетермінальний символ може відповідати декільком правилам породження. Наприклад, якщо є правила A = α₁ і A = α₂, це означає, що A може бути розкладений або в α₁, або в α₂.
Праворуч від знака рівності може бути порожньо. У такому випадку символ зліва розкладається в порожню послідовність (тобто в нічого). Але для зручності сприйняття замість пропуску правої частини зазвичай пишуть символ ε (епсилон), що означає «нічого». Ця нотація використовується і в цій книзі.
Рядки беруться у подвійні лапки, наприклад “foo”. Рядки завжди є термінальними символами.
Ось основні правила BNF. У EBNF до них додаються ще такі символи, які дозволяють коротко і зручно описувати складніші правила.
Форма запису | Значення |
---|---|
A* |
A повторюється 0 або більше разів |
А? |
A або ε |
A \| B |
A або B |
( ... ) |
Групування |
Наприклад, A = ("fizz" | "buzz")*
означає, що A
є рядком, який містить 0 або більше випадків "fizz"
або "buzz"
; тобто,
- ””
- “fizz”
- “buzz”
- “fizzfizz”
- “fizzbuzz”
- “buzzfizz”
- “buzzbuzz”
- “fizzfizzfizz”
- “fizzfizzbuzz”
- ⋯⋯
і це можна розширити до списка будь-якої довжини
Колонка: BNF і EBNF
У звичайній BNF (тобто без розширення Extended) немає таких зручних скорочених записів, як *, ?, , або ( … ). Проте, множина речень, які можна згенерувати за допомогою BNF, є такою ж, як і в EBNF. Це можливо тому, що конструкції EBNF можна переписати у вигляді BNF, як показано нижче:
EBNF Відповідна форма у BNF A = α*
A = αA
таA = ε
A = α?
A = α
таA = ε
A = α \| β
A = α
таA = β
A = α (β₁β₂⋯) γ
A = α B γ
таB = β₁β₂⋯
Наприклад, щоб згенерувати рядок ααα з правила
A = αA
таA = ε
, розгортання виглядатиме так:A → αA → ααA → αααA → ααα
Як видно, записи
*
і?
є лише зручними скороченнями, але завдяки їм запис > граматики стає значно зрозумілішим і компактнішим. Саме тому, коли є можливість, зазвичай використовують саме скорочену нотацію.
Прості правила породження
Як приклад опису граматики за допомогою EBNF, розглянемо наступні правила породження:
expr = num ("+" num | "-" num)*
num вважається попередньо визначеним символом, що позначає числове значення.
У цій граматиці expr
(вираз) означає: спочатку йде один num
, за яким може йти нуль або більше послідовностей типу: "+"
і num
, або "-"
і num
.
Це правило фактично описує граматику арифметичних виразів з додаванням і відніманням.
Починаючи з expr
, можна створити будь-який рядок, що складається з додавання або віднімання чисел - наприклад: 1
, 10 + 5
, 42 - 30 + 2
Перевіримо результати розгортання:
expr → num → "1"
expr → num "+" num
→ "10" "+" "5"
expr → num "-" num "+" num
→ "42" "-" "30" "+" "2"
Порядок такого розгортання можна не лише показати послідовно за допомогою стрілок, а й представити у вигляді дерева розбору (синтаксичного дерева).
Синтаксичне дерево для 1
Синтаксичне дерево для 10+5
Синтаксичне дерево для 42-30+2
Представлення у вигляді дерева дає змогу легко зрозуміти, який нетермінальний символ розгортається в які саме символи.
Синтаксичне дерево, подібне до наведеного вище, яке містить усі токени з вхідного рядка та повністю відповідає граматичним правилам один до одного, часто називають конкретним синтаксичним деревом (concrete syntax tree). Цей термін зазвичай використовують, коли хочуть протиставити його абстрактному синтаксичному дереву (abstract syntax tree).
У конкретному синтаксичному дереві, наведеному вище, правило обчислення додавання та віднімання зліва направо не відображене у самій структурі дерева.
Таке правило не задається за допомогою EBNF, а зазвичай вказується окремо у текстовому описі мови, наприклад: «Операції додавання та віднімання виконуються зліва направо». Під час розбору (парсингу) парсер враховує як граматику EBNF, так і ці додаткові зауваження зі специфікації мови. На основі цього він будує абстрактне синтаксичне дерево (AST), яке правильно відображає порядок обчислення виразу.
Таким чином, структура конкретного синтаксичного дерева (CST), отриманого за EBNF, і абстрактного синтаксичного дерева (AST), що відображає семантику виконання, не повністю збігаються.
Отже, у наведеній вище граматиці форма конкретного синтаксичного дерева (CST), яке відповідає EBNF, лише загалом збігається з формою абстрактного синтаксичного дерева (AST), яке будує парсер. Можна визначити граматику так, щоб структура абстрактного та конкретного дерева максимально збігалася, але це зробить граматику надмірно складною та громіздкою, і, як наслідок, ускладнить розробку парсера. Наведена граматика - це приклад вираження, що досягає балансу між строгою формалізацією (через EBNF) та зрозумілими уточненнями у природній мові. Такий підхід робить граматику зручною та практичною для використання як у документації, так і в реалізації.
Вираження пріоритету операторів за допомогою правил породженння
Правила породження - це дуже потужний інструмент для опису граматики. Якщо граматику правильно побудувати, то пріоритет операторів також можна виразити всередині правил породження. Нижче наведено таку граматику.
expr = mul ("+" mul | "-" mul)*
mul = num ("*" num | "/" num)*
Раніше правило передбачало, що expr
безпосередньо розгортається в num
, а тепер expr
розгортається через mul
до num
. mul
- це правило продукції для множення і ділення, а expr
, яке виконує додавання і віднімання, використовує mul
як свого роду складову частину. У цій граматиці правило, що множення і ділення виконуються раніше, природним чином відображається у синтаксичному дереві. Давайте розглянемо декілька прикладів.
Синтаксичне дерево для 1*2+3
Синтаксичне дерево для 1+2*3
Синтаксичне дерево для 1*2+3*4*5
У наведеній вище деревоподібній структурі множення завжди з’являється ближче до листків дерева, ніж додавання. Насправді, оскільки немає правила повернення від mul
до expr
, неможливо побудувати дерево, де під множенням було б додавання. Проте те, що такі прості правила дозволяють чітко відобразити пріоритет операторів у вигляді структури дерева, здається досить цікавим. Запрошую читачів самостійно зіставити правила продукції та синтаксичні дерева, щоб переконатися в правильності побудови дерева.
Правила породженння із рекурсією
У генеративній граматиці можна звичайно записувати рекурсивні граматики. Нижче наведені правила породження граматики, яка додає пріоритет дужок до арифметичних операцій.
expr = mul ("+" mul | "-" mul)*
mul = primary ("*" primary | "/" primary)*
primary = num | "(" expr ")"
Якщо порівняти цю граматику з попередньою, то там, де раніше дозволявся лише num
, тепер може з’являтися primary
, тобто num
або "(" expr ")"
. Іншими словами, у цій новій граматиці вираз у круглих дужках обробляється так само, як і окреме число. Давайте подивимось на приклад.
Наступне дерево є синтаксичним деревом для виразу 1*2
.
Синтаксичне дерево для 1*2
Синтаксичне дерево для 1*(2+3)
Якщо порівняти два дерева, видно, що відрізняється лише розгортка правої гілки mul
у вигляді primary
. Правило, яке дозволяє primary
в кінцевому результаті розгортатися або в одне число, або в будь-який вираз, взятий у дужки, чітко відображається у структурі дерева. Те, що пріоритет дужок можна обробляти за допомогою таких простих правил породження, є досить вражаючим, чи не так?
Сінтаксічний аналіз через рекурсивний спуск
Якщо задані правила породження для мови C, то за їх допомогою можна послідовно розгортати і механічно генерувати будь-яку правильну програму на C з точки зору цих правил. Проте в 9cc ми хочемо зробити навпаки. Нам подається програма на C у вигляді рядка, і ми хочемо дізнатися структуру синтаксичного дерева - тобто порядок розгортання, який дасть у результаті саме цей вхідний рядок.
Насправді для певних типів правил породження, якщо правило задано, можна автоматично написати код, який знаходить синтаксичне дерево, що відповідає реченню, згенерованому цим правилом. Метод, який ми тут описуємо - «сінтаксічний аналіз через рекурсивний спуск» - є одним із таких прийомів.
Як приклад, розглянемо граматику для арифметичних операцій. Повторно наведемо граматику для арифметичних операцій.
expr = mul ("+" mul | "-" mul)*
mul = primary ("*" primary | "/" primary)*
primary = num | "(" expr ")"
Основна стратегія написання парсера за допомогою рекурсивного спуску полягає в тому, щоб кожен нетермінал безпосередньо відобразити у відповідну функцію. Таким чином, парсер матиме три функції: expr
, mul
і primary
. Кожна з цих функцій розбирає послідовність токенів відповідно до свого імені.
Розглянемо це конкретно на коді. Вхідними даними для парсера є послідовність токенів. Оскільки парсер повинен повернути абстрактне синтаксичне дерево, визначимо тип вузла абстрактного синтаксичного дерева. Нижче наведено визначення типу вузла.
// Типи вузлів у абстрактному синтаксичному дереві
typedef enum {
ND_ADD, // +
ND_SUB, // -
ND_MUL, // *
ND_DIV, // /
ND_NUM, // Ціле число
} NodeKind;
typedef struct Node Node;
// Вузел у абстрактному синтаксичному дереві
struct Node {
NodeKind kind; // Тип вузла
Node *lhs; // Ліви частина дерева
Node *rhs; // Права частина дерева
int val; // Використовується якщо kind дорівнює ND_NUM
};
lhs
і rhs
означають ліву сторону (left-hand side) та праву сторону(right hand side) відповідно.
Ми також визначаємо функцію для створення нового вузла. У цій граматиці є два типи арифметичних операцій: двійкові оператори, які беруть ліву та праву частини, і числа, тому ми готуємо дві функції, які відповідають цим двом типам.
Node *new_node(NodeKind kind, Node *lhs, Node *rhs) {
Node *node = calloc(1, sizeof(Node));
node->kind = kind;
node->lhs = lhs;
node->rhs = rhs;
return node;
}
Node *new_node_num(int val) {
Node *node = calloc(1, sizeof(Node));
node->kind = ND_NUM;
node->val = val;
return node;
}
Отже, використовуючи ці функції та типи даних, давайте напишемо парсер. Оператори +
та -
вважаються лівоасоціативними. Функція, яка розбирає лівоасоціативні оператори, зазвичай має такий шаблон:
Node *expr() {
Node *node = mul();
for (;;) {
if (consume('+'))
node = new_node(ND_ADD, node, mul());
else if (consume('-'))
node = new_node(ND_SUB, node, mul());
else
return node;
}
}
Функція consume
- це функція, визначена раніше, яка, якщо наступний токен у вхідному потоці співпадає з аргументом, просуває потік на один токен вперед і повертає true.
Ретельно прочитайте функцію expr
. Ви побачите, що правило продукції expr = mul ("+" mul | "-" mul)*
безпосередньо відображається у виклики функцій і цикл. Абстрактне синтаксичне дерево, яке повертає функція expr
, будується так, що оператори є лівоасоціативними - гілка зліва у вузлі глибша.
Тепер давайте визначимо функцію mul
, яку використовує expr
. Оператор *
та /
також є лівоасоціативними, тому їх можна описати за тією ж схемою. Наводжу визначення цієї функції нижче.
Node *mul() {
Node *node = primary();
for (;;) {
if (consume('*'))
node = new_node(ND_MUL, node, primary());
else if (consume('/'))
node = new_node(ND_DIV, node, primary());
else
return node;
}
}
Виклики функцій у наведеному вище коді безпосередньо відповідають правилу породження mul = primary ("" primary | "/" primary)
.
Нарешті, визначимо функцію primary
. Оскільки primary
не розбирає лівоасоціативні оператори, її код не відповідає шаблону, описаному раніше. Однак, якщо безпосередньо відобразити правило продукції primary = "(" expr ")" | num
у виклики функцій, то функція primary
може бути записана так:
Node *primary() {
// Якщо наступним маркером є "(", це має бути "(" expr ")".
if (consume('(')) {
Node *node = expr();
expect(')');
return node;
}
// В іншому випадку це має бути число.
return new_node_num(expect_number());
}
Отже, тепер у нас є всі необхідні функції, але чи справді вони можуть розібрати послідовність токенів? На перший погляд це може здатися незрозумілим, проте з їх допомогою можна коректно розпарсити послідовність токенів. Розглянемо приклад виразу 1+2*3
.
Спочатку викликається функція expr
. Ми вважаємо, що весь вираз - це expr
(у нашому випадку це так), і починаємо читати вхід. Далі відбуваються послідовні виклики expr → mul → primary
, завдяки чому зчитується токен 1
, і функція expr
повертає синтаксичне дерево, що відповідає числу 1
.
Далі у функції expr
вираз consume('+')
повертає true
, тому токен «+» споживається, і знову викликається функція mul
. На цьому етапі у вхідному потоці залишився рядок 2*3
.
З mul
, як і раніше, викликається функція primary
, яка зчитує токен 2
, але цього разу mul
не повертається одразу. Оскільки вираз consume('*')
в mul
також повертає true
, mul
знову викликає primary
, яка зчитує токен 3
. У результаті функція mul
повертає синтаксичне дерево, що представляє вираз 2*3
.
Після повернення у функцію expr
, синтаксичні дерева для 1
та для 2*3
поєднуються, формуючи дерево для виразу 1+2*3
, яке і стає результатом функції expr
. Отже, вираз 1+2*3
було правильно розпарсено.
Якщо подати виклики функцій і токени, які вони зчитують, у вигляді схеми, вона виглядає приблизно так. На нижній діаграмі є рівень expr
, що відповідає всьому виразу 1+2*3
- це виклик expr
, який розбирає весь вхід. Над ним розташовані два виклики mul
, які відповідають розбору частин 1
та 2*3
відповідно.
Відносини викликів функцій під час розбору 1*(2+3)
Нижче наведено трохи складніший приклад. Наведена діаграма показує взаємозв’язок викликів функцій під час розбору виразу 1*2+(3+4)
.
Відносини викликів функцій під час розбору 1*2+(3+4)
Для програмістів, які не звикли до рекурсії, такі рекурсивні функції, як наведені вище, можуть здаватися складними для розуміння. Чесно кажучи, навіть автор, який добре знайомий із рекурсією, іноді сприймає подібний код майже як магію. Рекурсивний код, навіть коли його принципи зрозумілі, все одно викликає певне відчуття загадковості - мабуть, це просто така його природа. Тож радимо кілька разів уважно прогнати код у голові, щоб переконатися, що він дійсно працює.
Метод синтаксичного аналізу, при якому одне правило породження відображається в одну функцію, називається синтаксичним аналізом із рекурсивним спускомі (recursive descent parsing). У наведеному парсері для прийняття рішення про виклик функції або повернення використовується перегляд лише одного токена вперед, такий парсер називають LL(1)-парсером. А граматики, які можна розпарсити LL(1)-парсером, називають LL(1)-граматиками.
Стекова машина
У попередньому розділі ми розглядали алгоритм перетворення послідовності токенів на абстрактне синтаксичне дерево. Завдяки вибору граматики з урахуванням пріоритетів операторів стало можливим формувати таке абстрактне синтаксичне дерево, у якому оператори накшталт *
або /
завжди розташовані ближче до листків дерева, ніж +
чи -
. Але як саме перетворити це дерево на асемблерний код? У цьому розділі ми розглянемо відповідний метод.
Спершу давайте розглянемо, чому не можна просто перетворити це на асемблер так само, як ми це робили з операціями додавання та віднімання. У компіляторі, що підтримує додавання та віднімання, регістр RAX
використовувався як регістр для результату - саме в ньому виконувалося додавання або віднімання. Тобто скомпільована програма зберігала лише один проміжний результат обчислень.
Однак у випадку, коли у виразі присутні множення або ділення, зберігання лише одного проміжного результату вже може бути недостатньо. Розгляньмо приклад: 2 * 3 + 4 * 5
. Щоб виконати додавання, спочатку необхідно обчислити обидві його частини - 2 * 3
та 4 * 5
. Тобто перед тим, як виконати додавання, ми повинні зберігати два окремі проміжні результати. Без цього обчислити весь вираз неможливо.
Комп’ютери, на яких подібні обчислення виконуються просто, називаються «стековими машинами». У цьому розділі ми на деякий час відволічемось від абстрактного синтаксичного дерева, яке створив парсер, і розглянемо, як працює стекова машина.
Поняття стекової машини
Стекова машина - це комп’ютер, що використовує стек як область для зберігання даних. Відповідно, основними операціями в стековій машині є «додавання до стеку (push)» і «вилучення зі стеку (pop)». Під час операції push новий елемент додається на верхівку стеку. Під час операції pop елемент видаляється з верхівки стеку.
Операції обчислення в стековій машині діють на елементи, що знаходяться на вершині стеку. Наприклад, команда ADD
у стековій машині бере два елементи з вершини стеку (виконує pop двічі), додає їх, і результат знову поміщає в стек (виконує push). (Щоб уникнути плутанини з інструкціями x86-64, усі команди віртуальної стекової машини будемо записувати великими літерами.) Іншими словами, команда ADD
замінює два верхні елементи стеку одним, що є результатом їх додавання.
Команди SUB
, MUL
, DIV
працюють аналогічно: вони замінюють два верхні елементи стеку результатом їх віднімання, множення або ділення відповідно.
Команда PUSH
розміщує переданий їй аргумент на вершину стеку. Хоча ми не будемо її тут використовувати, можна також уявити команду POP
, яка просто видаляє верхній елемент стеку без збереження.
Тепер спробуємо обчислити вираз 2 * 3 + 4 * 5
, використовуючи ці команди. На основі описаної вище стекової машини це можна зробити приблизно так:
// Обчислення 2*3
PUSH 2
PUSH 3
MUL
// Обчислення 4*5
PUSH 4
PUSH 5
MUL
// Обчислення 2*3 + 4*5
ADD
Давайте розглянемо цей код трохи детальніше. Припустимо, що в стеку вже заздалегідь є деякі значення. Оскільки ці значення зараз не мають значення, ми позначимо їх як «⋯». У схемах будемо вважати, що стек розширюється згори донизу.
Перші дві команди PUSH
додають у стек значення 2 та 3, тому на момент виконання команди MUL
стан стеку виглядатиме так:
⋯ |
---|
2 |
3 |
Команда MUL
видаляє два верхні значення зі стеку, тобто 3 і 2, і обчислює їхній добуток. Результат, тобто 6, потім додається назад у стек. Отже, після виконання MUL
стан стеку буде таким:
⋯ |
---|
6 |
Далі команди PUSH
додають у стек значення 4 та 5, тож перед виконанням другої команди MUL
стек має виглядати наступним чином:
⋯ |
---|
6 |
4 |
5 |
Після виконання команди MUL
, значення 5 і 4 будуть видалені зі стеку, а їхній добуток - тобто 20 - буде додано замість них. Таким чином, після виконання MUL
стек набуде наступного вигляду:
⋯ |
---|
6 |
20 |
Зверни увагу, що результати обчислень 2 * 3
і 4 * 5
успішно збережені у стеку. У цьому стані, коли виконується команда ADD
, обчислюється 20 + 6
, і результат додається до стеку. Таким чином, у фіналі стек має виглядати наступним чином:
⋯ |
---|
26 |
Якщо вважати, що результат обчислення на стековій машині - це значення, яке залишається на вершині стеку, то число 26 є результатом виразу 2 * 3 + 4 * 5
, а отже, обчислення було виконано правильно.
Стекова машина дозволяє обчислювати не лише цей вираз, а й будь-який інший, який має кілька проміжних результатів. Використовуючи стекову машину, можна компілювати вирази будь-якої складності, за умови, що кожна частина виразу залишає після виконання рівно один елемент у стеку як свій результат.
Колонка: CISC та RISC
x86-64
- це набір інструкцій, який поступово еволюціонував, починаючи з випуску процесора 8086 у 1978 році. Це типовий приклад процесора стилю, що називається CISC (Complex Instruction Set Computer - комп’ютер із складним набором інструкцій). Характерні риси CISC-процесорів включають:
- можливість виконання арифметичних операцій не лише між регістрами, а й безпосередньо з пам’яттю (тобто операнди можуть бути адресами пам’яті),
- змінна довжина машинних інструкцій,
- наявність складних інструкцій, які виконують зручні для програміста на асемблері дії в рамках однієї команди.
На противагу CISC, у 1980-х роках було створено архітектуру RISC (Reduced Instruction Set Computer - комп’ютер зі спрощеним набором інструкцій). Основні риси RISC-процесорів:
- усі арифметичні операції виконуються тільки між регістрами,
- звернення до пам’яті можливе лише через окремі інструкції завантаження (load) і збереження (store),
- усі машинні інструкції мають однакову довжину,
- немає складних інструкцій: лише прості, які зручно генерувати компілятору.
x86-64
- один із небагатьох CISC-процесорів, що залишилися. Усі інші основні сучасні процесори базуються на RISC-архітектурі. Зокрема, до них належать: ARM, PowerPC, SPARC, MIPS, RISC-V тощо.У RISC-процесорах відсутні операції між пам’яттю і регістрами, як у x86-64. Також немає “аліасів” регістрів (тобто декількох назв для одного регістра) чи особливих правил, за якими окремі регістри мають спеціальне використання в певних інструкціях. З точки зору сучасного підходу до архітектури процесорів, команда x86-64 здається архаїчною.
Простота дизайну RISC-процесорів дозволяє легко підвищувати їхню швидкодію, завдяки чому RISC завоював ринок процесорів. Але чому ж
x86-64
вдалося вижити? Причина полягає у величезному попиті на потужні процесори, здатні запускати вже існуюче програмне забезпечення для архітектури x86. Щоб задовольнити цей попит, компанія Intel та виробники сумісних чипів здійснили низку технічних нововведень. Зокрема, Intel реалізувала декодування x86-інструкцій у спеціальні внутрішні інструкції, схожі на RISC, що фактично зробило сучасні x86-процесори внутрішньо RISC-сумісними. Це дозволило застосувати ті ж самі оптимізації, які зробили RISC-процесори такими швидкими.
Компіляція до стекової машини
У цьому розділі ми розглянемо, як перетворити абстрактне синтаксичне дерево на код для стекової машини. Опанувавши цей процес, ви зможете парсити вирази, що складаються з чотирьох основних арифметичних операцій, створювати їхні абстрактні синтаксичні дерева, а потім компілювати їх у код стекової машини, яка використовує інструкції x86-64, і виконувати цей код. Інакше кажучи, ви зможете написати компілятор, здатний виконувати обчислення з використанням чотирьох арифметичних дій.
У стековій машині передбачається, що після обчислення будь-якого підвиразу його результат - незалежно від складності - залишається у вигляді одного значення на вершині стеку. Наприклад, розглянемо наступне дерево:
A
та B
- це абстрактні позначення піддерев, які насправді представляють собою вузли певного типу. Однак під час компіляції всього дерева точний тип або структура цих піддерев не мають значення. Щоб скомпілювати таке дерево, потрібно виконати такі кроки:
- Скомпілювати ліве піддерево.
- Скомпілювати праве піддерево.
- Вивести код, який замінює два верхні значення в стеку результатом їхнього додавання.
Після виконання кроку 1, незалежно від того, яким є конкретний код, на вершині стеку повинен залишитися результат обчислення лівого піддерева у вигляді одного значення. Після виконання кроку 2 на вершині стеку буде результат правого піддерева. Таким чином, щоб обчислити значення всього дерева, достатньо замінити ці два значення їхньою сумою.
Таким чином, при компіляції абстрактного синтаксичного дерева в код для стекової машини, ми мислимо рекурсивно, спускаючись по дереву та поступово генеруючи асемблерний код. Для тих, хто не звик до рекурсивного мислення, це може здатися складним. Але коли мова йде про роботу з деревоподібними або самоподібними структурами даних, рекурсія це стандартна і дуже зручна техніка.
Давайте розглянемо це на конкретному прикладі.
Функція, яка займається генерацією коду, отримує на вхід кореневий вузол дерева.
Відповідно до описаного вище алгоритму, перше, що робить ця функція, компілює ліве піддерево. У цьому випадку це просто число 2. Оскільки результат обчислення 2 це просто 2, то результатом компіляції цього піддерева буде інструкція PUSH 2
.
Далі функція генерації коду переходить до компіляції правого піддерева. Це призводить до рекурсивного виклику для лівої гілки цього піддерева, результатом якого буде PUSH 3
. Потім рекурсивно компілюється права гілка піддерева, що дає PUSH 4
.
Після цього функція генерації коду повертається з рекурсії і починає виводити інструкції, що відповідають операторам у вузлах дерева. Спочатку виводиться інструкція, яка замінює два верхні значення в стеку їхнім добутком. Потім виводиться інструкція, яка замінює два верхні значення їхньою сумою.
У результаті буде згенеровано такий асемблерний код:
PUSH 2
PUSH 3
PUSH 4
MUL
ADD
Використовуючи такий підхід, можна автоматично, крок за кроком, перетворювати абстрактне синтаксичне дерево на асемблерний код.
Реалізація стекової машини на x86-64
До цього моменту ми розглядали лише віртуальну стекову машину. Але насправді архітектура x86-64
— це не стекова, а регістрова машина. У x86-64
арифметичні операції зазвичай визначаються між двома регістрами і не працюють безпосередньо зі значеннями на вершині стеку. Отже, щоб застосувати техніку стекової машини у x86-64
, потрібно певним чином емулювати стекову поведінку на регістровій машині.
Емулювати стекову машину на регістровій доволі просто: потрібно лише реалізувати те, що у стековій машині робиться однією інструкцією, як послідовність кількох інструкцій.
Розглянемо конкретну техніку для цього.
Спочатку треба зарезервувати один регістр, який буде вказувати на вершину стеку — так званий стековий покажчик (stack pointer). Якщо ми хочемо зчитати два верхні значення зі стеку (зробити pop), то беремо два значення за адресами, на які вказує стековий покажчик, і змінюємо його значення відповідно. Аналогічно, щоб зробити push — оновлюємо значення покажчика і записуємо нове значення у відповідну пам’ять.
У x86-64
для ролі стекового покажчика спеціально передбачений регістр RSP
. Інструкції push
і pop
в x86-64
неявно використовують RSP
як стековий покажчик, автоматично змінюють його значення і звертаються до пам’яті, яку він вказує. Тому при використанні інструкцій x86-64
у стилі стекової машини, найприродніше використовувати RSP
як стековий покажчик.
Отже, давайте спробуємо скомпілювати простий вираз 1 + 2
, розглядаючи x86-64
як стекову машину. Нижче наведено відповідний асемблерний код.
// Затовкайте ліву та праву сторони на стек
push 1
push 2
// Вставте ліву та праву сторони в RAX та RDI та додайте їх
pop rdi
pop rax
add rax, rdi
// Завантажте результат на стек
push rax
У x86-64
не існує інструкції, яка б одразу «додавала два елементи, на які вказує RSP
». Тому спочатку потрібно завантажити ці значення в регістри, виконати додавання, а потім результат знову помістити в стек. Саме такі дії і виконує наведена вище інструкція add
.
Аналогічним чином, якщо реалізувати вираз 2 * 3 + 4 * 5
у x86-64
, результат буде виглядати наступним чином:
// Обчисліть 2*3 та покладіть результат на стек
push 2
push 3
pop rdi
pop rax
mul rax, rdi
push rax
// Обчисліть 4*5 та покладіть результат на стек
push 4
push 5
pop rdi
pop rax
mul rax, rdi
push rax
// Додайте два значення із верхньої частини стека
// тобто обчисліть 2*3+4*5
pop rdi
pop rax
add rax, rdi
push rax
Таким чином, використовуючи інструкції для роботи зі стеком у x86-64
, навіть у цій архітектурі можна реалізувати код, що дуже нагадує поведінку стекової машини.
Наведена нижче функція gen
— це пряме втілення цього підходу у вигляді функції на мові C.
void gen(Node *node) {
if (node->kind == ND_NUM) {
printf(" push %d\n", node->val);
return;
}
gen(node->lhs);
gen(node->rhs);
printf(" pop rdi\n");
printf(" pop rax\n");
switch (node->kind) {
case ND_ADD:
printf(" add rax, rdi\n");
break;
case ND_SUB:
printf(" sub rax, rdi\n");
break;
case ND_MUL:
printf(" imul rax, rdi\n");
break;
case ND_DIV:
printf(" cqo\n");
printf(" idiv rdi\n");
break;
}
printf(" push rax\n");
}
Це не є критично важливою частиною для парсингу чи генерації коду, але оскільки в наведеному вище коді використовується дещо хитра інструкція idiv
, варто пояснити її поведінку.
idiv
— це інструкція для ділення зі знаком. Якби idiv
у x86-64
мала просту і звичну семантику, то ми могли б просто написати щось на кшталт idiv rax, rdi
. Проте в x86-64
немає інструкції ділення, яка б явно приймала два регістри. Натомість idiv
неявно використовує регістри RDX
і RAX
, інтерпретуючи їх разом як 128-бітове число. Це число ділиться на 64-бітове значення, яке задається як операнд інструкції, а результат ділення зберігається в RAX
, а остача — в RDX
.
Інструкція cqo
допомагає підготувати значення до idiv
: вона розширює 64-бітове значення з RAX
до 128-бітового, записуючи старші біти у RDX
. Саме тому в коді перед викликом idiv
викликається cqo
.
Отже, на цьому завершено пояснення стекової машини. Пройшовши цей матеріал, ви, шановні читачі, повинні вже мати змогу не лише здійснювати складний синтаксичний аналіз, а й перетворювати отримане абстрактне синтаксичне дерево на машинний код. Тепер час застосувати ці знання на практиці — повернемося до створення компілятора!
Колонка: Оптимізувальний компілятор
Асемблерний код
x86-64
, який автор використовував для пояснень у цьому розділі, може здатися досить неефективним. Наприклад, команди, якіpush
-ять значення в стек і потім одразуpop
-ають його, можна було б замінити однією командоюmov
, яка напряму поміщає значення в регістр. У багатьох читачів може виникнути природне бажання прибрати подібну надмірність і оптимізувати код. Але не поспішайте піддаватися цій спокусі. На етапі початкової генерації коду — навмисне створювати зайвий, але зрозумілий код — це правильна стратегія, оскільки вона спрощує реалізацію компілятора.Якщо буде потрібно, в компілятор
9cc
можна пізніше додати фазу оптимізації. Повторне сканування згенерованого асемблера з метою заміни певних шаблонів інструкцій на більш ефективні — не така вже й складна задача. Наприклад, можна застосувати правило: «pop
одразу післяpush
можна замінити наmov
», або «кілька послідовнихadd
, які додають константи до одного й того самого регістра, можна об’єднати в одинadd
з сумарною константою». Такі правила можна застосовувати автоматично, не змінюючи семантику програми, але роблячи код значно ефективнішим.Якщо ж змішати генерацію коду з оптимізацією з самого початку, то компілятор стане надто складним. У такому разі додавати оптимізаційні етапи згодом буде значно важче. Як казав Дональд Кнут: «Передчасна оптимізація — це корінь усього зла». Тож, створюючи власний компілятор, зосередьтесь передусім на простоті реалізації. Очевидну надмірність у згенерованому коді завжди можна прибрати пізніше — переживати про це не варто.
Крок 5: Створення мови з підтримкою чотирьох арифметичних операцій
У цьому розділі ми модифікуємо компілятор, який будували до цього, щоб він міг обробляти арифметичні вирази з дужками та пріоритетами операцій. Усі необхідні компоненти для цього ми вже створили, тож написати доведеться лише зовсім небагато нового коду.
Зокрема, потрібно змінити функцію main
компілятора, щоб вона використовувала новий парсер та генератор коду, які ми щойно реалізували. У підсумку код має виглядати приблизно так:
int main(int argc, char **argv) {
if (argc != 2) {
error("Невірна кількість аргументів");
return 1;
}
// Токенізація та розбір
user_input = argv[1];
token = tokenize(user_input);
Node *node = expr();
// Виведіть першу половину ассемблера
printf(".intel_syntax noprefix\n");
printf(".globl main\n");
printf("main:\n");
// Генерація коду шляхом спуску по абстрактному синтаксичному дереву
gen(node);
// Значення всього виразу має залишатися на вершині стека.
// Завантажте його в RAX та використовуйте як значення, що повертається функцією.
printf(" pop rax\n");
printf(" ret\n");
return 0;
}
На цьому етапі ми повинні вміти правильно компілювати вирази, використовуючи додавання, віднімання, множення, ділення та дужки пріоритету. Додамо кілька тестів.
assert 47 '5+6*7'
assert 15 '5*(9-6)'
assert 4 '(3+5)/2'
Заради зручності пояснення, до цього моменту ми розглядали впровадження *
, /
та ()
ніби всі разом одразу. Але на практиці краще не впроваджувати все одразу.
Оскільки у вас вже є реалізація додавання і віднімання, спершу інтегруйте абстрактне синтаксичне дерево (AST) та новий генератор коду, не змінюючи існуючу функціональність. На цьому етапі ви не додаєте нових можливостей, тому нові тести не потрібні — просто переконайтесь, що старі не зламані.
Після цього можна поступово додати підтримку *
, /
та ()
, забезпечивши для кожної функції відповідні тести.
Еталонна реалізація
Колонка: Управління памʼяттю в компіляторі 9cc
На цьому етапі читач, ймовірно, зацікавився: як саме у цьому компіляторі реалізоване управління памʼяттю? У наведеному коді ми використовували
calloc
(різновидmalloc
), однак жодного разу не викликалиfree
. Тобто виділена памʼять не звільняється. Чи це не є недоглядом? Насправді, така політика — “не керувати памʼяттю взагалі” — є свідомим дизайнерським рішенням автора, зробленим після зважування різних компромісів. Перевагами такої конструкціє є те що відмова від звільнення памʼяті дозволяє писати код так, ніби ми працюємо у мові з збірником сміття (garbage collector). Що не тільки звільняє вас від необхідності писати код для керування пам’яттю, але й усуває загадкові помилки, які виникають при ручному управлінні пам’яттю в його витоках.З іншого боку, практично немає проблем, які виникають через те, що не викликається
free
, якщо врахувати його запуск на комп’ютері, як-от звичайний ПК. Компілятор - це короткочасна програма, яка просто читає один файл C та виводить асемблерний код. Коли програма завершується, вся виділена пам’ять автоматично звільняється ОС. Тому єдине питання полягає в тому, скільки пам’яті виділити загалом, але за фактичними вимірюваннями автора, навіть при компіляції досить великого файлу C використання пам’яті становить лише близько 100 МБ. Тому не викликатиfree
є реально ефективною стратегією. Наприклад, компілятор мови D DMD також застосовує політику лишеmalloc
та не викликаєfree
, виходячи з тієї ж ідеї. 1
Крок 6: Унарний плюс та унарний мінус
Оператор віднімання -
використовується не лише між двома операндами, як у виразі 5 - 3
, але також може стояти перед одним операндом, наприклад -3
. Аналогічно, оператор додавання +
може зʼявлятися у формі +3
, де відсутній лівий операнд. Такі оператори, що діють лише на один операнд, називаються унарними операторами (unary operators). Натомість оператори, що вимагають два операнди, називаються бінарними операторами (binary operators).
У мові C, крім +
та -
, існують й інші унарні оператори — наприклад, &
для отримання адреси та *
для розіменування вказівника. Але у цьому кроці ми реалізуємо лише унарний +
і -
.
Хоча унарні +
і -
використовують ті самі символи, що й бінарні, їхнє значення відрізняється. Бінарний -
означає віднімання (ліве - праве). Проте унарний -
не має лівого операнда, і тому не може означати те саме. У C унарний -
визначений як інверсія знаку (зміна на протилежний), а унарний +
— просто повертає операнд як є. Унарний +
не є обов’язковим, але існує за інерцією як парний до унарного -
.
Таким чином, хоча символи +
і -
виглядають однаково, насправді вони представляють різні оператори в залежності від контексту — унарні або бінарні. Розрізнення між ними визначається граматикою контексту.
Граматика, яка підтримує унарні +
та -
, виглядає наступним чином:
expr = mul ("+" mul | "-" mul)*
mul = unary ("*" unary | "/" unary)*
unary = ("+" | "-")? primary
primary = num | "(" expr ")"
У наведеній вище новій граматиці зʼявився новий нетермінальний символ — unary
. Тепер правило mul
використовує unary
, а не primary
.
Запис X?
є синтаксисом EBNF і означає, що X
є необов’язковим — може зустрічатися нуль або один раз. У правилі
unary = ("+" | "-")? primary
вказано, що нетермінал unary
може бути:
- просто
primary
, - або
+ primary
, - або
- primary
.
Інакше кажучи, вираз може починатися з унарного плюса або мінуса (або без них), після чого слідує primary
.
Спробуйте перевірити, що такі вирази, як:
-3
,-(3 + 5)
,-3 * +5
відповідають новій граматиці.
Нижче наведено синтаксичне дерево (AST) для виразу -3 * +5
:
Давайте змінимо парсер так, щоб він відповідав цій новій граматиці.
Як і раніше, ми зможемо оновити парсер, просто відобразивши граматичні правила на виклики функцій. Інакше кажучи, для кожного нового правила ми просто створюємо відповідну функцію.
Нижче наведено функцію, яка виконує парсинг нетермінала unary
:
Node *unary() {
if (consume('+'))
return primary();
if (consume('-'))
return new_node(ND_SUB, new_node_num(0), primary());
return primary();
}
У цьому кроці ми вирішили ще на етапі парсингу перетворювати вирази:
+x
на простоx
,-x
на0 - x
.
Завдяки цьому, кодогенератор не потребує жодних змін.
Щоби завершити цей крок, залишилось лише:
- написати кілька тестів,
- додати код, що підтримує унарні
+
і-
, - і все це закомітити.
Під час написання тестів переконайтесь, що результат обчислень лежить у межах від 0
до 255
, оскільки саме цей діапазон підтримується вашим середовищем виконання.
Наприклад, вираз -10 + 20
використовує унарний -
, але загальне значення є додатним, тому він чудово підходить як тест.
Еталонна реалізація
Крок 7: Оператори порівняння
У цьому розділі ми реалізуємо такі оператори порівняння: <
, <=
, >
, >=
, ==
, !=
Хоча ці оператори можуть здаватися особливими, насправді вони — це звичайні бінарні оператори, як і +
чи -
.
Вони приймають два цілих операнди і повертають одне ціле число як результат.
Наприклад:
==
повертає1
, якщо обидві сторони рівні, і0
— якщо ні.- Аналогічно інші оператори повертають
1
або0
, в залежності від результату порівняння.
Це означає, що в обчисленні виразів ці оператори можна обробляти точно так само, як арифметичні.
Зміни у токенізаторі
До цього моменту всі символи, які ми обробляли як токени, були однобайтовими (одна літера або знак), і наша реалізація токенізатора це припускала.
Однак для підтримки операторів порівняння, таких як ==
чи !=
, потрібно загальніше рішення, адже ці токени мають довжину два символи.
Щоби впоратись з цим, ми модифікуємо структуру Token
, додавши до неї нове поле — len
, яке зберігає довжину токена.
Нижче наведено оновлену структуру Token
:
struct Token {
TokenKind kind; // Тип токена
Token *next; // Наступний вхідний токен
int val; // Якщо тип TK_NUM, то значення
char *str; // Рядок токена
int len; // Довжина токена
}
У зв’язку з цією зміною, потрібно також оновити функції, які перевіряють токени - такі як consume
і expect
.
До цього вони працювали з одним символом, але тепер вони мають працювати з рядками (наприклад, "=="
або "!="
), оскільки нові оператори складаються з кількох символів.
Нижче наведено приклад змінених функцій:
bool consume(char *op) {
if (token->kind != TK_RESERVED ||
strlen(op) != token->len ||
memcmp(token->str, op, token->len))
return false;
token = token->next;
return true;
}
Коли ви токенізуєте символи, що складаються з декількох символів, важливо перевіряти спочатку довші токени. Наприклад, якщо залишок рядка починається з >
, спершу слід перевірити, чи це >=
, а не просто >
. Якщо спочатку перевірити лише >
(наприклад, *p == '>'
), тоді >=
буде помилково розбито на два токени — >
і =
, замість одного токена >=
.
Тому токенайзер має виконувати перевірку в такому порядку:
>=
,<=
,==
,!=
спочатку,- а
>
,<
після.
Нова граматика
Щоб додати підтримку операторів порівняння до парсера, спершу подивимось, яким має бути порядок пріоритетів (від найнижчого до найвищого):
==
,!=
<
,<=
,>
,>=
+
,-
*
,/
- унарні
+
,-
- дужки
()
Пріоритети можна виразити у граматиці, як ми вже робили раніше. Оператори з різним пріоритетом будуть оброблятися через окремі нетермінальні символи.
З урахуванням цього, граматика з порівняннями виглядає приблизно так:
expr = equality
equality = relational ("==" relational | "!=" relational)*
relational = add ("<" add | "<=" add | ">" add | ">=" add)*
add = mul ("+" mul | "-" mul)*
mul = unary ("*" unary | "/" unary)*
unary = ("+" | "-")? primary
primary = num | "(" expr ")"
У цій граматиці:
equality
відповідає за оператори==
і!=
,relational
— за оператори<
,<=
,>
,>=
.
Ці нетермінальні символи (тобто правила граматики) можна напряму перетворити у функції за шаблоном лівоасоціативного парсингу (left-associative parsing), який ми вже використовували для add
і mul
.
Також зверни увагу, що:
- Ми відокремили
expr
іequality
, аби показати, що повний вираз починається зexpr
, а не безпосередньо зequality
. - Теоретично ми могли б написати
expr
= … прямо з вмістомequality
, але такий розділ робить граматику чистішою та легшою для читання.
Колонка: Простий і надлишковий код проти витонченого і лаконічного коду
У рекурсивному спусковому парсері код, як правило, майже один в один відповідає правилам граматики.
Через це функції, які розбирають подібні правила, зазвичай мають схожий вигляд. Функції, які ми вже розглядали —relational
,equality
,add
,mul
також мають дуже схожу структуру.Можливо, природно запитати, чи можемо ми абстрагуватися від поширених шаблонів у таких функціях, використовуючи методи метапрограмування, такі як макроси в C, шаблони в C++, функції вищого порядку, генерація коду тощо. Насправді це можливо зробити. Однак ми вирішили не робити цього в цій книзі з таких причин:
Простий код легко зрозуміти, навіть якщо він трохи багатослівний. Якщо вам пізніше доведеться вносити подібні зміни до подібної функції, це насправді не так вже й багато роботи. З іншого боку, високоабстрагований код може бути важко зрозуміти, оскільки спочатку потрібно зрозуміти механізм абстракції, а потім те, як він використовується. Наприклад, якби я почав цю книгу з написання функції, яка використовує метапрограмування для генерації функції рекурсивного спуску, це було б набагато складніше.
Написання елегантного та лаконічного коду не завжди є метою; це, як правило, робить код складнішим, ніж це можливо.
Людина, яка пише код, стає експертом у цьому коді, тому вона схильна вважати, що лаконічний, ефективний код з точки зору експерта є хорошим кодом, але більшість читачів коду не поділяють тих самих уподобань, що й автор, і немає потреби ставати таким вправним спочатку, тому певною мірою потрібно скептично ставитися до власних уподобань як автора коду. Навмисне написання «простого коду, який можна було б написати краще», коли це необхідно, є важливим методом створення програм, які легко зрозуміти та підтримувати.
Генерація асемблерного коду
У x86-64 порівняння виконується за допомогою інструкції cmp
. Наступний код витягує два цілі числа зі стеку, порівнює їх та встановлює RAX
у 1
, якщо вони однакові, або у 0
, якщо вони не однакові:
pop rdi
pop rax
cmp rax, rdi
sete al
movzb rax, al
Цей код хоч і короткий, але містить досить багато, тож давайте розглянемо його покроково.
У перших двох рядках значення витягуються (pop) зі стеку. У третьому рядку ці витягнуті значення порівнюються (compare). Куди ж іде результат порівняння? У x86-64 результат команди порівняння встановлюється в особливий “регістр прапорців” (flag register). Цей регістр оновлюється щоразу при виконанні цілочисельних або порівняльних інструкцій і містить біти, що показують, наприклад, чи дорівнює результат нулю, чи виникло переповнення, або чи є результат меншим за нуль.
Регістр прапорців не є звичайним цілочисельним регістром, тому якщо потрібно встановити результат порівняння в регістр RAX, слід скопіювати певний біт із регістру прапорців до RAX. Це виконується за допомогою інструкції sete
. Інструкція sete
встановлює значення 1 у вказаний регістр (у цьому випадку AL), якщо два значення, перевірені попередньою командою cmp
, були однаковими. В іншому випадку в регістр записується 0.
AL — це нове ім’я регістру, яке ще не з’являлося в цій книжці, але насправді AL — це просто інше ім’я для молодших 8 бітів регістру RAX. Тому, коли команда sete
встановлює значення в AL, регістр RAX також частково оновлюється. Однак, якщо оновлювати RAX через AL, то старші 56 бітів залишаються незмінними. Тож якщо потрібно повністю встановити RAX у значення 0 або 1, необхідно очистити старші біти. Це виконується за допомогою інструкції movzb
. Було б зручно, якби sete
могла напряму записувати в RAX, але вона приймає лише 8-бітні регістри, тому для встановлення значення в RAX після порівняння використовуються дві інструкції: sete
і movzb
.
Замість sete
можна використовувати інші інструкції для реалізації інших операторів порівняння. Для <
використовуйте setl
, для <=
— setle
, а для !=
— setne
.
Оператори >
і >=
не обов’язково підтримувати в генераторі коду. Натомість, у парсері потрібно міняти місцями обидві сторони порівняння й трактувати їх як <
або <=
.
Еталонна реалізація
Колонка: Регістр прапорців і апаратне забезпечення
Специфікація x86-64, згідно з якою результат порівняння значень неявно зберігається в спеціальному регістрі, відмінному від звичайних цілочисельних регістрів, спершу може здатися складною для розуміння. Насправді ж існують процесори типу RISC, які уникають використання регістру прапорців і замість цього мають інструкції, що зберігають результат порівняння в звичайний регістр. Наприклад, набір інструкцій RISC-V працює саме так.
Проте з погляду реалізації апаратного забезпечення, якщо взяти просту схему, то створення регістру прапорців справа доволі легка. Під час виконання цілочисельної операції можна просто відгалузити сигнали результату до іншої логіки, яка перевіряє, чи дорівнює результат нулю (усі біти 0), чи є результат від’ємним (найстарший біт дорівнює 1) тощо, і на основі цього оновлює відповідні біти регістру прапорців. Саме так реалізовані CPU, які мають регістр прапорців — він оновлюється щоразу при виконанні арифметичних операцій.
У такій системі не лише
cmp
, але йadd
,sub
та інші арифметичні інструкції також оновлюють регістр прапорців. Насправді, інструкціяcmp
є особливим варіантомsub
, яка оновлює лише прапори, не змінюючи регістрів. Наприклад, замістьcmp rax, rdi
можна було б використатиsub rax, rdi
і потім зчитати прапори, щоб дізнатись про відношення між RAX і RDI, але це б змінило значення в RAX, томуcmp
була створена як «чиста» версіяsub
.У програмному забезпеченні виконання «зайвих» обчислень завжди коштує часу. Але в апаратному забезпеченні відгалуження ліній і додаткове використання транзисторів не викликає затримок, тож оновлення регістру прапорців у простій реалізації не має вартості з точки зору продуктивності.
Роздільна компіляція та лінкування
До цього етапу ми розробляли програму у вигляді структури файлів, яка містить лише один C-файл та один shell-скрипт для тестування. Така структура не є проблемною, проте з часом вихідний код стає дедалі більшим, тож настав час розділити його на кілька C-файлів для кращої читабельності.
На цьому етапі ми розділимо файл 9cc.c на наступні п’ять файлів:
- 9cc.h: заголовковий файл
- main.c: функція main
- parse.c: парсер
- codegen.c: генератор коду
Функція main є досить маленькою, тож її можна було б включити до одного з інших C-файлів, але з огляду на те, що вона не належить логічно ні до parse.c, ні до codegen.c, ми вирішили виділити її в окремий файл.
У цьому розділі буде пояснено поняття роздільної компіляції та її значення, а також описано конкретні кроки реалізації.
Що таке роздільна компіляція?
Роздільна компіляція та її необхідність
Роздільна компіляція — це підхід, за якого одна програма розбивається на кілька вихідних файлів, які компілюються окремо. У такому процесі компілятор читає не всю програму цілком, а її фрагменти, й відповідно створює окремі частини результату. Ці фрагменти, які самі по собі не є виконуваними програмами, називаються об’єктними файлами (мають розширення .o). На фінальному етапі всі об’єктні файли з’єднуються в один виконуваний файл. Програма, яка виконує це з’єднання, називається лінкером (або зв’язувачем, англ. linker).
Давайте розберемося, чому необхідна окрема компіляція. Із технічної точки зору, немає обов’язкової потреби розбивати програму на частини. Якщо компілятор отримує весь вихідний код одразу, то теоретично він може створити повноцінний виконуваний файл без участі лінкера. Однак у такому випадку компілятор повинен знати весь використовуваний код, включаючи такі функції, як printf з стандартної бібліотеки. Це означає, що кожного разу довелося б компілювати не тільки власний код, а й весь код бібліотек. Компіляція однієї й тієї ж функції знову і знову часто є просто марною тратою часу. З цієї причини стандартні бібліотеки зазвичай постачаються вже у вигляді скомпільованих об’єктних файлів, і компілятору не потрібно кожного разу їх обробляти. Тобто, навіть якщо у вас програма складається з одного файлу, але використовує стандартну бібліотеку — насправді ви вже користуєтесь роздільною компіляцією.
Без окремої компіляції весь код довелося б перекомпілювати, навіть якщо ви змінили лише один рядок. Компіляція коду довжиною десятки тисяч рядків зайняла б десятки секунд. Великий проект може мати понад 10 мільйонів рядків вихідного коду, тому його компіляція як єдиного цілого не буде завершена за день. Пам’ять також знадобиться одиницями по 100 ГіБ. Така процедура збірки є нереалістичною.
Ще одна проблема полягає в тому, що простий запис усіх функцій і змінних разом в одному файлі важко реалізувати людям.
Окрема компіляція необхідна з причин, зазначених вище.
Колонка: Історія лінкера
Функція лінкера — об’єднувати кілька фрагментів машинних процедур у єдину програму — була необхідною ще від самого початку епохи комп’ютерів. У 1947 році Джон Моклі (керівник проєкту першого цифрового комп’ютера ENIAC) описав програму, яка зчитувала підпрограми з плівки, переміщувала їх і об’єднувала в одну програму.
Навіть на найперших комп’ютерах розробники прагнули написати універсальні підпрограми лише один раз і використовувати їх у різних програмах. Але для цього потрібно було мати лінкер — засіб, який з’єднує програмні фрагменти в один виконуваний файл. У 1947 році ще не використовувалися асемблери, і код писали безпосередньо машинною мовою, тому насправді програмісти хотіли створити лінкер ще до появи асемблера.
Необхідність заголовкових файлів та їхній вміст
При розділеній компіляції компілятор бачить лише частину коду програми, але це не означає, що він може скомпілювати будь-який, навіть найменший, фрагмент програми. Наприклад, розглянемо такий код:
void print_bar(struct Foo *obj) {
printf("%d\n", obj->bar);
}
У наведеному вище коді, якщо компілятор знає тип структури Foo
, він зможе згенерувати відповідний асемблерний код. Але якщо тип невідомий, скомпілювати цю функцію неможливо.
При розділеній компіляції потрібно забезпечити кожен C-файл достатньою кількістю інформації, щоб компілятор міг обробити його окремо. Проте, якщо просто скопіювати увесь код з інших файлів, то сенс розділеної компіляції втрачається. Тому необхідно вибірково включати лише ту інформацію, яка дійсно потрібна.
Розгляньмо приклад: яку саме інформацію потрібно надати, щоб компілятор міг згенерувати код, який викликає функцію з іншого C-файлу. Компілятор потребує таку інформацію:
- Передусім, потрібно знати, що певний ідентифікатор це ім’я функції.
- Щоб згенерувати код для виклику функції, компілятор повинен налаштувати аргументи у відповідних регістрах у визначеному порядку, а потім використати команду
call
, щоб передати керування на початок викликуваної функції. Якщо аргументи мають різні типи, наприклад, якщо потрібно перетворити ціле число на число з плаваючою комою, компілятор має це враховувати. Також, якщо кількість або типи аргументів неправильні, потрібно вивести повідомлення про помилку. Отже, потрібна інформація про кількість аргументів та тип кожного з них. - Що саме відбувається всередині викликуваної функції, не має значення для функції, яка її викликає — достатньо знати, що після завершення вона просто повертається назад. Тому сам код функції не потрібен під час компіляції викликаючої функції.
- Хоча під час розділеної компіляції адреса, на яку здійснюється
call
, ще невідома, асемблер може створити інструкцію виклику, яка умовно стрибає на адресу 0, і при цьому в об’єктному файлі залишити інформацію на зразок: «X-й байт цього файлу потрібно замінити адресою функції з іменем Y». Лінкер переглядає ці вказівки, і після того як визначає остаточне розташування кожної частини програми, він оновлює відповідні місця в коді — цей процес називається релокація. Отже, для розділеної компіляції потрібне ім’я функції, але сама її адреса — ні.
Підсумовуючи вищенаведені вимоги, можна сказати, що достатньо мати функцію без її тіла — без блоку { ... }
, щоб мати всю необхідну інформацію для виклику цієї функції. Такий запис функції без тіла називається оголошенням (declaration). Оголошення лише повідомляє компілятору про тип і ім’я функції, але не містить її реалізації. Наприклад, ось оголошення функції strncmp
:
int strncmp(const char *s1, const char *s2, size_t n);
Компiлятор, побачивши наведений вище рядок, отримує інформацію про існування функції strncmp
та її тип. Запис, який включає лише таку інформацію без тіла функції, називається оголошенням (declaration). На противагу цьому, запис, який містить сам код функції, називається визначенням (definition).
Щоб явно вказати, що це саме оголошення, до нього можна додати ключове слово extern
:
extern int strncmp(const char *s1, const char *s2, size_t n);
Можна додати ключове слово extern
, але у випадку функцій зазвичай розрізняють оголошення і визначення за наявністю чи відсутністю тіла функції, тому extern
не є обов’язковим.
Також, оскільки для аргументів достатньо знати їхні типи, у оголошенні можна опустити імена параметрів. Проте для зручності людей зазвичай імена все ж вказують.
Розглянемо інший приклад — оголошення структур. Якщо одна і та ж структура використовується у двох і більше C-файлах, у кожному з них потрібно прописати однакове оголошення структури. Якщо структура використовується лише в одному файлі, інші файли про неї не повинні знати.
У мові C усі такі оголошення, які потрібні для компіляції інших файлів, збирають у заголовкові файли (файли з розширенням .h
). Наприклад, якщо в foo.h
записати потрібні оголошення, то у іншому файлі, де це потрібно, пишуть: #include "foo.h"
. У результаті директива #include замінюється вмістом файлу foo.h.
Крім функцій і структур, у заголовкових файлах часто містяться оголошення через typedef
та інші конструкції, які надають інформацію про типи. Якщо такі оголошення використовуються в кількох C-файлах, їх також слід розміщувати у заголовкових файлах.
При читанні оголошень компілятор не генерує асемблерний код, оскільки оголошення — це лише інформація для використання функцій чи змінних з інших файлів, але не їх визначення.
З огляду на все це, вислів на кшталт «коли використовуємо printf
, треба додати #include <stdio.h>
» набуває більш чіткого змісту. Стандартна бібліотека C автоматично передається лінкеру, тому він може зв’язати виклики printf
з відповідною реалізацією. Водночас компілятор за замовчуванням нічого не знає про printf
. Це не вбудована функція, і її оголошення не підвантажується автоматично. Без додавання #include <stdio.h>
компілятор просто не знатиме про існування printf
та її тип.
Отже, включення заголовкових файлів стандартної бібліотеки дозволяє компілятору дізнатися про існування та сигнатуру printf і правильно згенерувати код виклику цієї функції.
Колонка: Однопрохідний компілятор і передні оголошення
Навіть якщо всі функції зібрані в одному файлі, в C іноді потрібні оголошення. За специфікацією мови компілятор не повинен читати увесь файл цілком, а компілює функції по черзі, зверху вниз. Тому для компіляції кожної функції достатньо мати інформацію, яка з’явилася до неї у файлі. Отже, якщо потрібно викликати функцію, визначену нижче у файлі, її оголошення має бути записане раніше. Такі оголошення називаються передніми оголошеннями (forward declarations).
Зазвичай правильне впорядкування функцій у файлі дозволяє уникнути більшості передніх оголошень, але у випадках взаємного рекурсивного виклику вони необхідні.
Така специфікація, що дозволяє компілятору не зчитувати весь файл одразу, мала сенс за часів, коли оперативна пам’ять була дуже обмежена. Сьогодні ця вимога виглядає застарілою, і більш «розумний» компілятор міг би автоматично знаходити оголошення серед усіх визначень у файлі, але оскільки це частина стандарту мови, її треба знати і враховувати.
Помилки лінкування
Коли об’єктні файли об’єднуються лінкером для створення виконуваної програми, у них має бути достатньо і точно необхідної інформації для формування повної програми.
Якщо в програмі є лише оголошення функції foo
, але немає її визначення, окремі C-файли можуть нормально компілюватися, включно з кодом виклику foo
. Але під час лінкування, коли лінкер намагається створити повну програму, він не зможе знайти адресу foo
для заміни відповідних посилань, і виникне помилка.
Такі помилки під час лінкування називаються помилками лінкування (linker errors).
Також помилка виникає, якщо одна і та сама функція або змінна визначена у кількох об’єктних файлах одночасно. Лінкер не знає, яку версію вибрати, тому викидає помилку.
Часто подібні помилки пов’язані з тим, що у заголовковому файлі помилково записано визначення замість оголошення. Оскільки заголовкові файли інклюдяться у кілька C-файлів, це призводить до дублювання визначень.
Щоб уникнути таких помилок, у заголовкових файлах слід писати лише оголошення, а визначення розміщувати в одному-єдиному C-файлі.
Колонка: Дубльовані визначення та помилки лінкування
Іноді лінкер може працювати так, що при наявності дубльованих визначень він обирає одне з них і ігнорує решту. У таких лінкерах дублікати не викликають помилок.
У сучасних об’єктних файлах можна вказувати, чи дозволяти дублювання для конкретних визначень. Наприклад, для inline-функцій або розгортання шаблонів у C++ дублікати дозволяються і включаються в об’єктні файли. Формат об’єктних файлів та поведінка лінкерів доволі складні і мають багато винятків, але в більшості випадків за замовчуванням дубльовані визначення вважаються помилкою.
Оголошення та визначення глобальних змінних
Наш компілятор поки що не підтримує глобальні змінні, тому прикладів асемблерного коду для них ще не було. Проте глобальні змінні на рівні асемблера дуже схожі на функції. Відповідно, для глобальних змінних також існує різниця між оголошенням і визначенням.
Якщо визначення змінної дублюються у кількох C-файлах, зазвичай це призводить до помилки лінкування.
За замовчуванням глобальні змінні розміщуються в області пам’яті без прав на виконання, тому спроба перейти за їхньою адресою спричинить аварійне завершення програми через помилку сегментації. Проте суттєвої різниці між даними і кодом немає: під час виконання можна читати функцію як дані, а можна змінити атрибути пам’яті, щоб дозволити виконання даних.
Перевіримо цю ідею на прикладі коду. У наведеному нижче прикладі ідентифікатор main
визначено як глобальну змінну, що містить машинний код для архітектури x86-64.
char main[] = "\x48\xc7\xc0\x2a\x00\x00\x00\xc3";
Збережіть наведений вище C-код у файл з ім’ям foo.c
, скомпілюйте його, а потім за допомогою утиліти objdump
перегляньте вміст. За замовчуванням objdump
виводить вміст глобальних змінних у вигляді шістнадцяткових чисел. Проте якщо передати опцію -D
, objdump
спробує примусово розібрати ці дані як код (зробить дизасемблювання). Це допоможе побачити, що глобальні змінні і функції по суті представлені однаково у пам’яті — як послідовність байтів, які можна інтерпретувати і як дані, і як інструкції.
$ cc -c foo.c
$ objdump -D -M intel foo.o
Disassembly of section .data:
0000000000000000 <main>:
0: 48 c7 c0 2a 00 00 00 mov rax,0x2a
7: c3 ret
За замовчуванням дані розміщуються в області пам’яті з забороною на виконання, але цю поведінку можна змінити, передавши компілятору опцію лінкеру -Wl,--omagic
. Спробуймо створити виконуваний файл, використовуючи цю опцію.
$ cc -static -Wl,--omagic -o foo foo.o
Оскільки функції та змінні є лише мітками в асемблері та належать до одного простору імен, лінкеру, під час об’єднання кількох об’єктних файлів, байдуже, які з них є функціями, а які даними. Тож навіть якщо main визначено як дані на рівні C, компонування буде успішним так само, як якщо б main була функцією.
Спробуйте запустити згенерований файл.
$ ./foo
$ echo $?
42
Як показано вище, значення 42
було правильно повернене. Це означає, що вміст глобальної змінної main
було виконано як код.
У синтаксисі C для глобальних змінних ключове слово extern
означає оголошення змінної без її визначення. Нижче наведено приклад оголошення глобальної змінної foo
типу int
:
extern int foo;
Якщо ви пишете програму, яка містить foo
, вам слід помістити наведені вище рядки у заголовковий файл. Потім ви повинні визначити foo
в одному зі своїх C-файлів. Ось визначення foo:
int foo;
У мові C глобальні змінні, для яких не задано ініціалізаційний вираз, за замовчуванням ініціалізуються нулями. Таким чином, такі змінні семантично еквівалентні тим, що ініціалізовані як 0
, {0, 0, ...}
або "\0\0\0\0..."
.
Якщо ви використовуєте ініціалізаційний вираз, як-от int foo = 3;
, то пишіть його лише при визначенні змінної. Оголошення лише повідомляє компілятору про тип змінної і не вимагає конкретного ініціалізаційного виразу. Коли компілятор бачить оголошення глобальної змінної, він зазвичай не генерує асемблерного коду, тож на цьому етапі не має значення, як саме вона ініціалізується.
Коли ініціалізаційний вираз пропущено, оголошення і визначення глобальної змінної виглядають схожими, бо різниця полягає лише в наявності або відсутності extern
. Проте оголошення і визначення це різні речі. Обов’язково добре це усвідомте.
Колонка: Баг F00F у процесорах Intel
До 1997 року в процесорах Intel Pentium існував серйозний баг: при виконанні 4-байтної інструкції
F0 0F C7 C8
процесор повністю зависав.Ця інструкція формально не відповідає жодній дійсній команді асемблера, але якщо її все ж таки інтерпретувати, вона відповідала б команді
lock cmpxchg8b eax
. Байти0F C7 C8
— цеcmpxchg8b eax
, інструкція, яка атомарно (тобто без можливості спостереження проміжного стану, навіть у багатоядерних системах) обмінює 8-байтове значення між регістром і памʼяттю. ПрефіксF0
, відомий якlock
, додає атомарність до наступної інструкції.Однак
cmpxchg8b
і так є атомарною командою, тожlock cmpxchg8b eax
— це надлишкова та некоректна інструкція. Тому така команда не вважається граматично допустимою, і байтова послідовністьF0 0F C7 C8
зазвичай не зʼявляється у звичайному програмному коді. Через це Intel не виявила цю помилку до початку масового виробництва процесорів.Скориставшись трюком, де функцію main записують як дані, можна відтворити баг
F00F
за допомогою такого однорядкового C-коду:char main[] = "\xf0\x0f\xc7\xc8";
На сучасних процесорах x86 ця функція нешкідлива, але у 1997 році така проста програма могла легко повісити всю систему на Pentium-процесорі.
Якщо користувач повністю контролює свій ПК, F00F-баг не є великою проблемою. Але в умовах спільного використання CPU (як у сучасних хмарних обчисленнях) цей баг стає критичним. Спочатку вважалося, що його неможливо виправити без заміни процесорів. Однак пізніше було розроблено нестандартне рішення на рівні обробника виключень у ядрі ОС, що дозволило обійти проблему й уникнути масового відкликання продукції, чим Intel дуже пощастило.
Стандартна бібліотека та архівні файли C
Крок 8: Поділ на файли та зміни у Makefile
Поділ на файли
Спробуйте розділити код на файли згідно зі структурою, показаною на початку цього розділу. Файл 9cc.h є заголовковим файлом (header file).
У деяких структурах програм може використовуватися окремий .h-файл для кожного .c-файлу. Однак навіть якщо в заголовку є зайві оголошення, це не створює особливих проблем. Тому у цьому випадку немає потреби ретельно управляти залежностями між файлами.
Достатньо створити один файл 9cc.h, і підключати його в кожному .c-файлі за допомогою #include "9cc.h"
.
Зміни у Makefile
Отже, після того як ви розділили програму на кілька файлів, слід також оновити Makefile. Нижче наведено приклад Makefile, який компілює й лінкує всі .c-файли, що знаходяться в поточній директорії, щоб створити виконуваний файл під назвою 9cc. Припускається, що в проєкті є лише один заголовковий файл — 9cc.h, і він інклудиться в усіх .c-файлах.
CFLAGS=-std=c11 -g -static
SRCS=$(wildcard *.c)
OBJS=$(SRCS:.c=.o)
9cc: $(OBJS)
$(CC) -o 9cc $(OBJS) $(LDFLAGS)
$(OBJS): 9cc.h
test: 9cc
./test.sh
clean:
rm -f 9cc *.o *~ tmp*
.PHONY: test clean
Зверніть увагу: індентація в Makefile має бути символом табуляції. У файлах Makefile важливо використовувати табуляцію для відступів — звичайні пробіли тут не підходять.
make
це потужний інструмент. Повністю його опановувати не обов’язково, але вміти читати хоча б приклади на кшталт наведеного вище Makefile
дуже корисно в різних ситуаціях. Тому в цьому розділі пояснюється, як він працює.
У Makefile
одна правила складається з рядка, розділеного двокрапкою, та нуля або більше рядків команд, які мають бути відступлені табуляцією.
Назва до двокрапки — це ціль (target).
Один або кілька файлів після двокрапки — це файли-залежності (dependencies).
Коли ви виконуєте команду make foo
, утиліта make
намагається створити файл з іменем foo
. Якщо такий файл уже існує, make
перевіряє, чи він старіший за будь-який із файлів-залежностей. Якщо так — виконується правило, щоб оновити ціль. Це дозволяє, наприклад, автоматично пересобирати лише ті частини проєкту, які були змінені.
.PHONY
– це спеціальне ім’я, яке представляє фіктивну ціль. Команди make test
та make clean
не виконуються для створення файлів, таких як test
та clean
, але зазвичай make цього не знає, тому, якщо файли з іменами test
та clean
існують, make test
та make clean
нічого не робитимуть. Вказавши таку фіктивну ціль за допомогою .PHONY
, ви можете повідомити make
, що насправді не хочете створювати файли з цими іменами, і що команди правила повинні виконуватися незалежно від того, чи існує вказаний цільовий файл чи ні.
CFLAGS
, SRCS
та OBJS
– це змінні.
CFLAGS
– це змінна, яка розпізнається вбудованими правилами make
та містить параметри командного рядка, які слід передати компілятору C. Тут передаються такі прапорці:
-std=c11
: Повідомляє компілятору, що вихідний код написаний на C11, найновішому стандарті для C (прим. перекл. на час написання книги. Зараз ми маємо С23)-g
: Виводить налагоджувальну інформацію-static
: Статично лінкується
Підстановочний знак, що використовується у правій частині SRCS
, це функція, що надається make
, яка розширюється до імені файлу, що відповідає аргументу функції. Наразі $(wildcard *.c)
розширюється до main.c parse.c codegen.c
.
У правій частині OBJS
використовуються правила підстановки змінних для генерації значення шляхом заміни .c
в SRC
на .o
. SRCS
— це main.c parse.c codegen.c
, тому OBJS
стає main.o parse.o codegen.o
.
З огляду на це, давайте простежимо, що відбувається, коли ми запускаємо make 9cc
. make
намагається згенерувати цілі, вказані як аргументи, тому кінцевою метою команди є створення файлу 9cc
(без аргументів вибирається перше правило, тому в цьому випадку нам не потрібно вказувати 9cc
). Для цього make
проходить по залежностям та намагається зібрати будь-які файли, які відсутні або застарілі.
Залежності в 9cc
– це файли .o
, що відповідають файлам .c
у поточному каталозі. Якщо файл .o
з попереднього запуску make
все ще доступний і має новішу позначку часу, ніж відповідний файл .c
, make
не буде повторно виконувати команду. Він запустить компілятор для створення файлу .o
, лише якщо файл .o
не існує або повинен бути новішим.
Правило $(OBJS): 9cc.h
говорить, що всі файли .o
залежать від 9cc.h
. Тому, якщо ви зміните 9cc.h
, всі файли .o
будуть перекомпільовані.
Колонка: Різні значення ключового слова static у C
У мові C ключове слово
static
зазвичай використовується у двох основних випадках:
- Для локальних змінних — щоб значення зберігалося навіть після виходу з функції.
- Для глобальних змінних та функцій — щоб обмежити їхню видимість лише межами одного файлу (тобто зробити їх доступними лише в тому файлі, де вони оголошені).
Попри те, що ці два варіанти використання мають абсолютно різні цілі, для них використовується одне й те саме ключове слово
static
. Це є однією з причин, чому вивчення C може заплутувати людей.Ідеальним варіантом було б використовувати окремі ключові слова для кожного випадку: наприклад,
persistent
— для збереження значення змінної, аprivate
— для обмеження області видимості. Ще краще — зробити функції й змінні з файловою областю видимості приватними за замовчуванням, і використовуватиpublic
, коли треба зробити їх доступними глобально.Але C обрав інший шлях — не вводити нові ключові слова, щоб не ламати сумісність із вже існуючими програмами. Якщо б, наприклад, у мову додали
private
, то всі старі програми, які використовували це слово як ім’я змінної або функції, перестали б компілюватися.У 1970-х роках, коли ці рішення приймалися, обсяг коду був менший, і можна було безболісно додати нові ключові слова. Але якщо подумати, як би ви вчинили на місці авторів C — стає зрозуміло, що це насправді доволі складне питання.
Функції та локальні змінні
У цьому розділі ми реалізуємо функції та локальні змінні. Також буде додано підтримку простих керуючих конструкцій (control structures).
Після завершення цього розділу компілятор зможе обробляти код на кшталт такого:
// Складає числа від m до n
sum(m, n) {
acc = 0;
for (i = m; i <= n; i = i + 1)
acc = acc + i;
return acc;
}
main() {
return sum(1, 10); // Повертає 55
}
У наведеному вище коді все ще є деякі відмінності від C, але я думаю, що він вже досить близький до C.
Крок 9: Локальні змінні з односимвольними іменами
У попередніх розділах ми створили компілятор для мови, яка підтримує арифметичні операції. У цьому розділі ми додамо нову можливість — використання змінних.
Конкретна мета — навчитися компілювати програми, які містять кілька операторів із змінними, як у прикладі нижче:
a = 3;
b = 5 * 6 - 8;
a + b / 2;
Результатом останнього виразу буде результат обчислення всієї програми. Порівняно з мовами, які використовують лише арифметичні операції, ця мова має набагато більш «реальне» відчуття, чи не так?
У цьому розділі ми спочатку пояснимо, як реалізувати змінні, а потім реалізуємо їх поступово.
Область змінних на стеку
У мові C змінні існують у пам’яті. Можна сказати, що змінна — це просто іменований адресу пам’яті. Завдяки цьому можна не казати «доступ до пам’яті за адресою 0x6080», а замість цього — «доступ до змінної a».
Проте локальні змінні функції мають існувати окремо для кожного виклику функції. З технічної точки зору найпростіше було б, наприклад, жорстко прив’язати локальну змінну a функції f
до адреси 0x6080
. Але це не працюватиме, якщо f
викликає саму себе рекурсивно. Щоб для кожного виклику функції мати окремий набір локальних змінних, у C локальні змінні розміщуються у стеку. Розглянемо конкретний приклад. Припустимо, функція f
має локальні змінні a
та b
, і якась інша функція викликає f
.
Команда call
, що виконує виклик функції, кладе адресу повернення (return address) у стек, тому на момент виклику функції f
вершина стеку містить саме цю адресу повернення.
Також у стеку вже можуть бути якісь інші значення. Деталі цих значень не важливі, тому позначимо їх як «⋯⋯»
.
Графічно це виглядає приблизно так:
⋯⋯ | |
Адреса повернення | ← RSP |
Тут ми будемо використовувати позначення 「← RSP」, щоб показати, що поточне значення регістра RSP вказує на цю адресу.
Припустимо, що розмір змінних a
і b
становить по 8 байт кожна.
Стек росте вниз (адреси зменшуються). Щоб зарезервувати місце для двох змінних, потрібно зменшити значення RSP на сумарно 16 байт (2 змінні × 8 байт).
Після цього стек виглядатиме так:
⋯⋯ | |
Адреса повернення | |
a | |
b | ← RSP |
Якщо використати таке розташування, то за адресою RSP + 8
буде змінна a
, а за RSP
— змінна b
. Пам’ять, виділена під змінні для кожного виклику функції, називається кадром функції (function frame) або активаційним записом (activation record).
Розмір, на який треба змінити RSP
, та порядок розміщення змінних у виділеній пам’яті — це внутрішні деталі реалізації компілятора, які не мають значення для інших функцій. Тож компілятор може обирати їх на свій розсуд.
В цілому локальні змінні в C реалізуються саме таким простим способом.
Однак цей підхід має один недолік, тому в реальній реалізації використовується ще один регістр. Пам’ятайте, що під час виконання функції RSP може змінюватися, оскільки наш компілятор (як і інші) використовує стек для збереження проміжних результатів обчислень (push/pop), і RSP
змінюється досить часто. Через це неможливо звертатися до a
чи b
за фіксованим зміщенням від RSP
.
Щоб це вирішити, поряд із RSP
використовується регістр бази, який завжди містить адресу початку поточного фрейму функції.
Цей регістр називається базовим регістром, а значення в ньому — базовим вказівником (base pointer). У архітектурі x86-64 зазвичай використовують регістр RBP
як базовий регістр.
Базовий вказівник не має змінюватися протягом виконання функції (саме тому він потрібен). Якщо під час виклику іншої функції він зміниться, це порушить роботу. Тому перед викликом іншої функції старе значення базового вказівника зберігається, а після повернення — відновлюється.
Нижче наведена ілюстрація стану стеку під час виклику функції із базовим вказівником. Припустимо, що функція g
має локальні змінні x
і y
, і під час її виконання вона викликає функцію f
. Стек тоді виглядає так:
⋯⋯ | |
Адреса повернення g | |
RBP на момент виклика g | ← RBP |
x | |
y | ← RSP |
Якщо ви викличете f
звідси, станеться наступне:
⋯⋯ | |
Адреса повернення g | |
RBP на момент виклика g | |
x | |
y | |
Адреса повернення f | |
RBP на момент виклика f | ← RBP |
a | |
b | ← RSP |
Завдяки цьому до змінних a
і b
можна завжди звертатися за адресами RBP - 8
та RBP - 16
відповідно.
Щоб створити такий стан стеку, компілятор має на початку кожної функції генерувати приблизно такий асемблерний код:
push rbp
mov rbp, rsp
sub rsp, 16
Такі стандартні інструкції, які компілятор вставляє на початку функції, називаються прологом (prologue).
Значення 16 у команді sub rsp, 16
має підбиратися індивідуально для кожної функції залежно від кількості та розміру локальних змінних.
Перевіримо, що при виконанні цих інструкцій зі стану, коли RSP вказує на адресу повернення (return address), утворюється очікуваний фрейм функції. Нижче наведено стан стеку після виконання кожної інструкції:
- Стек одразу після виклику
f
за допомогоюcall
⋯⋯ Адреса повернення g RBP на момент виклика g ← RBP x y fのリターンアドレス ← RSP - Стек після виконання push rbp
⋯⋯ Адреса повернення g RBP на момент виклика g ← RBP x y Адреса повернення f RBP на момент виклика f ← RSP - Стек після виконання
mov rbp, rsp
⋯⋯ Адреса повернення g RBP на момент виклика g x y Адреса повернення f RBP на момент виклика f ← RSP, RBP - Стек після виконання
sub rsp, 16
⋯⋯ Адреса повернення g RBP на момент виклика g x y Адреса повернення f/td> </tr> RBP на момент виклика f ← RBP a b ← RSP
Коли функція завершується і виконується повернення (return), потрібно відновити початкове значення RBP
, повернути RSP
до стану, коли він вказує на адресу повернення, і викликати інструкцію ret (яка знімає адресу повернення зі стеку і переходить за нею).
Цей код можна записати так:
mov rsp, rbp
pop rbp
ret
Такі стандартні інструкції, які компілятор вставляє в кінець функції, називаються епілогом (epilogue).
Нижче наведено стан стеку під час виконання епілогу.
Сегмент стеку, розташований нижче за адресу, на яку вказує RSP
, вважається недійсними даними, тому в схемі він опущений.
- Стек перед виконанням
mov rsp, rbp
⋯⋯ Адреса повернення g RBP на момент виклика g x y Адреса повернення f RBP на момент виклика f ← RBP a b ← RSP - Стек після виконання
mov rsp, rbp
⋯⋯ Адреса повернення g RBP на момент виклика g x y Адреса повернення f RBP на момент виклика f ← RSP, RBP - Стек після виконання
pop rbp
⋯⋯ Адреса повернення g RBP на момент виклика g ← RBP x y Адреса повернення f ← RSP - Стек після виконання
ret
⋯⋯ Адреса повернення g RBP на момент виклика g ← RBP x y ← RSP
Таким чином, шляхом виконання епілогу, стан стеку функції, що викликає g
, відновлюється. Інструкція виклику поміщає адресу інструкції, що йде після інструкції виклику, у стек. Інструкція ret
епілогу витягує цю адресу та переходить до неї, тому виконання функції g
відновлюється з інструкції, що йде після виклику. Така поведінка повністю відповідає поведінці функцій, як ми їх знаємо.
Саме так реалізуються виклики функцій та локальні змінні функцій.
Колонка: Напрямок росту стеку
Як було описано вище, стек в архітектурі x86-64 росте від більшої адреси до меншої. Здається, що природніше, якби стек зростав вгору, проте чому ж стек проектують так, що він росте вниз?
Технічної необхідності для росту стеку вниз немає. Насправді, у більшості процесорів і ABI стек починається з високих адрес і росте вниз. Проте існують архітектури, хоч і рідкісні, де стек росте вгору — наприклад, мікроконтролер 8051, ABI3 для PA-RISC, Multics тощо.
Втім, дизайн зі стеком, який росте вниз, не є неприроднім.
Після увімкнення живлення процесор зазвичай починає виконання програми з низьких адрес (наприклад, з адреси 0). Програма, як правило, розміщується на нижчих адресах. Щоб стек, який росте, не накладався на код програми, його розташовують на вищих адресах, і він росте до центру адресного простору — тобто вниз.
Звісно, можна спроектувати систему інакше, де стек росте вгору, і це теж буде логічно. Але фактично загальноприйнятою у промисловості є саме модель, де стек росте вниз.
Зміни в токенайзері
Тепер, коли ми знаємо, як реалізувати змінні, спробуємо це реалізувати. Однак підтримка довільної кількості змінних раптово стає занадто складною, тому ми обмежимо змінні на цьому кроці одними малими літерами та припустимо, що всі змінні завжди існують, наприклад, змінна a - RBP-8
, змінна b - RBP-16
, змінна c - RBP-24
тощо. Оскільки в алфавіті 26 літер, якщо ми змістимо RSP
вниз на 26 x 8, або 208 байт, під час виклику функції, ми можемо забезпечити місце для всіх односимвольних змінних.
Спробуємо реалізувати це одразу. Спочатку ми модифікуємо токенайзер таким чином, щоб на додаток до граматичних елементів, які ми використовували досі, ми могли токенізувати односимвольні змінні. Для цього нам потрібно додати новий тип токена. Оскільки ім’я змінної можна прочитати з члена str, немає потреби додавати новий член до типу Token. В результаті тип токена буде таким.
enum {
TK_RESERVED, // Символ
TK_IDENT, // Ідентифікатор
TK_NUM, // Ціле число
TK_EOF, // Кінець входного потоку
} TokenKind;
Змініть токенайзер так, щоб він створював токен типу TK_IDENT
, якщо знаходить маленьку літеру латинського алфавіту.
Для цього достатньо додати наступну умову if у функцію токенізації:
if ('a' <= *p && *p <= 'z') {
cur = new_token(TK_IDENT, cur, p++);
cur->len = 1;
continue;
}
Зміни в парсері
У рекурсивному спусковому парсері, як тільки граматика відома, її можна механічно перетворити на виклики функцій. Отже, щоб внести потрібні зміни до парсера, спершу слід визначити оновлену граматику, яка включає ідентифікатори (змінні).
Позначимо змінну як ident
. Це термінальний символ (на зразок того як num
означає числа).
Оскільки змінні можуть використовуватися всюди, де раніше були числа, треба замінити num
на num | ident
.
Таким чином, у тих самих позиціях можуть з’являтися як числа, так і змінні.
Крім того, нам потрібно додати вирази присвоєння до граматики.
Оскільки змінні не можна присвоїти, ми хочемо, щоб граматика дозволяла вирази типу a=1
. Тут давайте зробимо граматику сумісною з C і запишемо її як a=b=1
.
Крім того, ми хочемо мати можливість писати кілька операторів, розділених крапкою з комою, тому отримана нова граматика виглядатиме так:
program = stmt*
stmt = expr ";"
expr = assign
assign = equality ("=" assign)?
equality = relational ("==" relational | "!=" relational)*
relational = add ("<" add | "<=" add | ">" add | ">=" add)*
add = mul ("+" mul | "-" mul)*
mul = unary ("*" unary | "/" unary)*
unary = ("+" | "-")? primary
primary = num | ident | "(" expr ")"
Спочатку перевірте, чи такі програми, як 42;
, a=b=2;
та a+b;
, відповідають цій граматиці. Після цього змініть створений вами синтаксичний аналізатор, щоб він міг розбирати вищевказану граматику. На цьому етапі також можна розбирати вирази, такі як a+1=5
, але це правильна поведінка. Такі семантично недійсні вирази будуть виключені на наступному проході. Немає нічого особливо складного в модифікації синтаксичного аналізатора, і це має бути можливо зробити, просто зіставляючи елементи граматики з викликами функцій, як і раніше.
Оскільки застосовується кілька виразів, розділених крапкою з комою, кілька вузлів як результат розбору потрібно десь зберегти. Наразі підготуйте наступний глобальний масив і збережіть у ньому вузли результату розбору по порядку. Заповніть останній вузол значенням NULL
, щоб ви могли сказати, де кінець. Деякий новий код, який буде додано, показано нижче.
Node *code[100];
Node *assign() {
Node *node = equality();
if (consume("="))
node = new_node(ND_ASSIGN, node, assign());
return node;
}
Node *expr() {
return assign();
}
Node *stmt() {
Node *node = expr();
expect(";");
return node;
}
void program() {
int i = 0;
while (!at_eof())
code[i++] = stmt();
code[i] = NULL;
}
Нам потрібно мати можливість представляти нові “вузли, що представляють локальні змінні” в абстрактному синтаксичному дереві. Для цього ми додаємо нові типи для локальних змінних та нові члени для вузлів. Наприклад, це має виглядати так. У цій структурі даних синтаксичний аналізатор створить та поверне вузол типу ND_LVAR
для кожного токена ідентифікатора.
typedef enum {
ND_ADD, // +
ND_SUB, // -
ND_MUL, // *
ND_DIV, // /
ND_ASSIGN, // =
ND_LVAR, // Локальні змінні
ND_NUM, // Ціле число
} NodeKind;
typedef struct Node Node;
// Вузол в абстрактному синтаксичному дереві
struct Node {
NodeKind kind; // Типи вузла
Node *lhs; // Ліва сторона
Node *rhs; // Права сторона
int val; // Використовувати, лише якщо тип — ND_NUM
int offset; // Використовувати, лише якщо тип — ND_LVAR
};
Зсув – це член, який представляє зсув від базового вказівника локальної змінної. Наразі локальні змінні знаходяться у фіксованих позиціях, що визначаються їхніми іменами, наприклад, змінна a
– це RBP-8
, b
– це RBP-16
тощо, тому зсув можна визначити на етапі синтаксичного аналізу. Нижче наведено код, який зчитує ідентифікатор і повертає вузол типу ND_LVAR
.
Node *primary() {
...
Token *tok = consume_ident();
if (tok) {
Node *node = calloc(1, sizeof(Node));
node->kind = ND_LVAR;
node->offset = (tok->str[0] - 'a' + 1) * 8;
return node;
}
...
Колонка: Код ASCII
У коді ASCII літери присвоюються числам від 0 до 127. Нижче наведено таблицю, що показує призначення символів у коді ASCII.
0 NUL SOH STX ETX EOT ENQ ACK BEL 8 BS HT NL VT NP CR SO SI 16 DLE DC1 DC2 DC3 DC4 NAK SYN ETB 24 CAN EM SUB ESC FS GS RS US 32 sp ! " # $ % & ' 40 ( ) * + , - . / 48 0 1 2 3 4 5 6 7 56 8 9 : ; < = > ? 64 @ A B C D E F G 72 H I J K L M N O 80 P Q R S T U V W 88 X Y Z [ \ ] ^ _ 96 ` a b c d e f g 104 h i j k l m n o 112 p q r s t u v w 120 x y z { | } ~ DEL Символи від 0 до 31 є керуючими символами. Сьогодні, за винятком символів NUL та символів нового рядка, можливостей використовувати ці керуючі символи мало, і більшість із них просто витрачають основну площу в кодах символів, але коли в 1963 році було встановлено код ASCII, ці керуючі символи насправді часто використовувалися. Коли було встановлено стандарт ASCII, навіть було запропоновано включити більше керуючих символів замість малих літер.
48-58 призначаються числам, 65-90 - великим літерам, а 97-122 - малим літерам. Зверніть увагу, що ці символи призначаються послідовним кодам. Іншими словами, 0123456789 та abcdefg… є послідовними в коді символу. Здається природним розміщувати символи з визначеним порядком у послідовних позиціях, як це, але в кодах символів, які були основними на той час, таких як EBCDIC, алфавіт не був послідовним у коді через вплив перфокарт.
У C символи — це просто невеликі цілі числа, значення яких таке ж, як і запис коду, що відповідає символу, у вигляді числа. Наприклад, якщо припустити, що ASCII — це «a» еквівалентно 97, а «0» — 48. У наведеному вище коді є формула, яка віднімає a від символу як числа, тож можна обчислити, на скільки символів далі знаходиться заданий символ. Це можливо завдяки послідовному розташування алфавіту в коді ASCII.
Ліві та праві значення
На відміну від інших двооперандних операторів, оператор присвоєння вимагає особливого підходу до обробки лівої сторони, тому розглянемо це питання докладніше.
Ліва частина виразу присвоєння не може бути будь-яким виразом. Наприклад, не можна зробити 1 рівним 2, як у виразі 1=2
. Присвоєння на кшталт a=2
є припустимим, але вираз типу (a+1)=2
— некоректний. У мові 9cc ще немає покажчиків або структур, але якщо б вони були, то вирази на кшталт *p=2
(присвоєння за адресою, на яку вказує покажчик) або a.b=2
(присвоєння члену структури) мали б вважатися допустимими. Як же розрізняти правильні та неправильні вирази?
Відповідь проста: у C ліва сторона присвоєння повинна бути виразом, що вказує на адресу в пам’яті.
Змінна існує в пам’яті та має адресу, тому її можна використовувати зліва від оператора присвоєння. Так само вираз *p
(розіменування покажчика) вказує на адресу — отже, також припустимий. Вираз a.b
також вказує на адресу в пам’яті: це зсув від початку структури a
до члена b
.
З іншого боку, результат виразу a+1
— не змінна, отже, не є адресою в пам’яті, і не може використовуватись як ліва сторона присвоєння. Такі тимчасові значення можуть існувати лише в регістрах, а якщо й у пам’яті, то доступ до них за фіксованим зсувом від відомої змінної, як правило, неможливий. Через це, наприклад, вираз &(a+1)
також є помилкою компіляції: не дозволено отримувати адресу результату a+1
. Такі вирази не можна використовувати зліва в присвоєнні.
Значення, які можуть бути зліва у присвоєнні, називаються лівими значеннями (англ. left value, lvalue), а ті, що не можуть — правими значеннями (англ. right value, rvalue). У нашій поточній мові лише змінні є лівими значеннями, всі інші — правими.
При генерації коду для змінних можна починати з лівих значень. Якщо змінна з’являється зліва у присвоєнні, необхідно обчислити її адресу, а потім записати в цю адресу результат обчислення правої сторони. Це реалізує присвоєння. Якщо ж змінна з’являється не в контексті присвоєння, то спочатку обчислюється її адреса, а потім з неї завантажується значення — таким чином ліве значення перетворюється на праве, і ми отримуємо значення змінної.
Завантаження значення з довільної адреси
До цього моменту генерація коду здійснювалась лише для доступу до вершини стеку, але у випадку з локальними змінними потрібно мати змогу звертатися до довільної позиції в стеку. У цьому розділі розглянемо, як здійснюється доступ до пам’яті.
CPU здатен не лише звертатися до вершини стеку, а й завантажувати або зберігати значення за будь-якою адресою в пам’яті.
Для завантаження значення з пам’яті використовується синтаксис mov dst, [src]
. Ця інструкція означає: “вважати значення в регістрі src
адресою, завантажити значення за цією адресою та зберегти його в dst
”. Наприклад, mov rdi, [rax]
означає, що значення, що зберігається за адресою, яка міститься в регістрі RAX
, буде завантажено в RDI
.
Для зберігання значення в пам’ять використовується синтаксис mov [dst], src
. Ця інструкція означає: “вважати значення в регістрі dst
адресою та зберегти в неї значення з регістра src
”. Наприклад, mov [rdi], rax
означає, що значення з RAX
буде збережене за адресою, яка міститься в RDI
.
Інструкції push
та pop
неявно використовують регістр RSP
як адресу для доступу до пам’яті, тож їх можна переписати у вигляді звичайних інструкцій доступу до пам’яті. Тобто, наприклад, pop rax
– це
mov rax, [rsp]
add rsp, 8
те саме що і ці дві команди, а push rax
це
sub rsp, 8
mov [rsp], rax
еквівалентно наступним двом командам.
Зміни в генераторі коду
Використовуючи знання, отримані до цього моменту, давайте змінимо генератор коду так, щоб він міг обробляти вирази зі змінними. Для цього потрібно додати функцію, яка оцінює вираз як лівий операнд (lvalue). У наведеному нижче коді таку роль виконує функція gen_lval
.
Функція gen_lval
перевіряє, чи заданий вузол (node) представляє змінну, і якщо так — обчислює її адресу та поміщає її в стек. Якщо вузол не є змінною, то функція виводить повідомлення про помилку. Таким чином, вирази типу (a+1)=2
будуть вважатися помилковими та відкидатимуться.
Коли змінна використовується як правий операнд (rvalue), спочатку її слід обробити як лівий операнд (тобто отримати її адресу), після чого значення за цією адресою завантажується в регістр. Код показано нижче.
void gen_lval(Node *node) {
if (node->kind != ND_LVAR)
error("Ліве значення присвоєння не є змінною");
printf(" mov rax, rbp\n");
printf(" sub rax, %d\n", node->offset);
printf(" push rax\n");
}
void gen(Node *node) {
switch (node->kind) {
case ND_NUM:
printf(" push %d\n", node->val);
return;
case ND_LVAR:
gen_lval(node);
printf(" pop rax\n");
printf(" mov rax, [rax]\n");
printf(" push rax\n");
return;
case ND_ASSIGN:
gen_lval(node->lhs);
gen(node->rhs);
printf(" pop rdi\n");
printf(" pop rax\n");
printf(" mov [rax], rdi\n");
printf(" push rdi\n");
return;
}
gen(node->lhs);
gen(node->rhs);
printf(" pop rdi\n");
printf(" pop rax\n");
switch (node->kind) {
case '+':
printf(" add rax, rdi\n");
break;
case '-':
printf(" sub rax, rdi\n");
break;
case '*':
printf(" imul rax, rdi\n");
break;
case '/':
printf(" cqo\n");
printf(" idiv rdi\n");
}
printf(" push rax\n");
}
Зміна функції main
Тепер, коли всі частини підготовлені, давайте змінимо функцію main
та запустимо компілятор.
int main(int argc, char **argv) {
if (argc != 2) {
error("Неправильна кількість аргументів");
return 1;
}
// Токенізація та розбір
// Результат зберігається в коді
user_input = argv[1];
tokenize();
program();
// Виведіть першу половину збірки
printf(".intel_syntax noprefix\n");
printf(".globl main\n");
printf("main:\n");
// Пролог
// Виділити місце для 26 змінних
printf(" push rbp\n");
printf(" mov rbp, rsp\n");
printf(" sub rsp, 208\n");
// Генерація коду, починаючи з першого виразу
for (int i = 0; code[i]; i++) {
gen(code[i]);
// В результаті обчислення виразу на стеку має залишитися одне значення.
// Тож ми виштовхуємо його, щоб запобігти переповненню стеку.
printf(" pop rax\n");
}
// Епілог
// Результат останнього виразу залишається в RAX, тому це буде повернене значення.
printf(" mov rsp, rbp\n");
printf(" pop rbp\n");
printf(" ret\n");
return 0;
}
Крок 10: Локальні змінні з багатосимвольними іменами
У попередніх розділах імена змінних були жорстко обмежені одним символом, і вважалося, що існує рівно 26 локальних змінних з іменами від a
до z
. У цьому розділі ми додамо підтримку ідентифікаторів довжиною більше одного символу, щоб мати змогу компілювати код такого вигляду:
foo = 1;
bar = 2 + 3;
return foo + bar; // Поверне 6
Змінні вважаються такими, що можуть використовуватися без попереднього визначення. Тому парсер повинен перевіряти для кожного ідентифікатора, чи зустрічався він раніше, і якщо це новий — автоматично виділяти йому область у стеку.
Спершу потрібно змінити токенайзер, щоб він міг розпізнавати ідентифікатори з кількох символів як токени типу TK_IDENT
.
Змінні будуть представлені у вигляді зв’язного списку. Для цього створимо структуру LVar
, яка описуватиме одну змінну, а вказівник на початок списку назвемо locals
. Приклад коду наведено нижче:
typedef struct LVar LVar;
// Тип для локальних змінних
struct LVar {
LVar *next; // Наступна змінна або NULL
char *name; // Ім'я змінної
int len; // Довжина імені
int offset; // Зсув від RBP
};
// Локальні змінні
LVar *locals;
У парсері, коли зустрічається токен типу TK_IDENT
, потрібно перевірити, чи цей ідентифікатор уже з’являвся раніше. Для цього проходять список locals
і порівнюють імена змінних. Якщо змінна існує, використовують її offset
. Якщо ні — створюють новий LVar
, призначають йому новий offset
і використовують його.
Функція для пошуку змінної за іменем виглядає так:
// Шукає змінну за іменем, повертаючи NULL, якщо не знайдено.
LVar *find_lvar(Token *tok) {
for (LVar *var = locals; var; var = var->next)
if (var->len == tok->len && !memcmp(tok->str, var->name, var->len))
return var;
return NULL;
}
У вашому парсері слід додати такий код:
Token *tok = consume_ident();
if (tok) {
Node *node = calloc(1, sizeof(Node));
node->kind = ND_LVAR;
LVar *lvar = find_lvar(tok);
if (lvar) {
node->offset = lvar->offset;
} else {
lvar = calloc(1, sizeof(LVar));
lvar->next = locals;
lvar->name = tok->str;
lvar->len = tok->len;
lvar->offset = locals->offset + 8;
node->offset = lvar->offset;
locals = lvar;
}
return node;
}
Колонка: Частота появи машинних інструкцій
Якщо подивитись на асемблерний код, згенерований 9cc, можна помітити, що інструкції передачі даних, такі як
mov
чиpush
, зустрічаються значно частіше, ніж “справжні обчислювальні” інструкції на кшталтadd
абоmul
. Частково це пов’язано з тим, що 9cc не виконує оптимізацію і генерує зайві команди переміщення даних.Проте навіть оптимізовані компілятори найбільше генерують саме інструкцій передачі даних. Нижче наведено графік, отриманий після дизасемблювання всіх виконуваних файлів у каталозі
/bin
на моєму комп’ютері та підрахунку кількості інструкцій за типами.
Як видно, лише інструкції
mov
складають близько 30% від усіх команд. Комп’ютер — це машина для обробки даних, але найбільш частою операцією в обробці даних є саме їх переміщення. Якщо розглядати “переміщення даних у потрібне місце” як одну з основних сутностей обробки інформації, то велика кількість інструкційmov
виглядає цілком логічною. Проте, багато читачів можуть здивуватися такому факту.
Крок 11: Оператор return
У цьому розділі ми додамо підтримку оператора return
, щоб можна було компілювати код такого виду:
a = 3;
b = 5 * 6 - 8;
return a + b / 2;
Оператор return
можна писати в будь-якому місці програми. Як і в стандартній мові C, виконання програми припиняється при першому виконаному return
, і функція повертає управління з відповідним значенням. Наприклад, у наступній програмі буде повернено значення першого return
, тобто 5.
return 5;
return 8;
Щоб реалізувати цю функціональність, спочатку розглянемо, як зміниться граматика для оператора return
. Раніше оператори розглядалися просто як вирази, але тепер потрібно дозволити конструкцію return <вираз>;
. Таким чином, нова граматика матиме вигляд:
program = stmt*
stmt = expr ";"
| "return" expr ";"
...
Для реалізації цієї функції потрібно внести зміни у токенайзер, парсер і генератор коду.
Спочатку змініть токенайзер, щоб він міг розпізнавати ключове слово return
і позначати його токеном типу TK_RETURN
. Ключові слова (return
, while
, int
тощо) мають спеціальне граматичне значення, тому для них доцільно використовувати окремі типи токенів.
Щоб визначити, чи наступний токен — це return
, токенайзер перевіряє, чи починається поточний рядок з return
. Проте, щоб уникнути помилкової обробки, наприклад, щоб returnx
не розпізнавався як return
і x
, потрібно додатково переконатися, що після слова return
йде символ, який не може бути частиною ідентифікатора.
Нижче наведена функція, яка визначає, чи є символ частиною токена (буква, цифра або підкреслення):
int is_alnum(char c) {
return ('a' <= c && c <= 'z') ||
('A' <= c && c <= 'Z') ||
('0' <= c && c <= '9') ||
(c == '_');
}
Цю функцію можна використати в tokenize
, щоб додати розпізнавання ключового слова return
і позначати його токеном TK_RETURN
. Нижче наведено приклад коду, який слід додати у функцію tokenize
:
if (strncmp(p, "return", 6) == 0 && !is_alnum(p[6])) {
tokens[i].ty = TK_RETURN;
tokens[i].str = p;
i++;
p += 6;
continue;
}
Далі, давайте модифікуємо синтаксичний аналізатор, щоб він міг аналізувати послідовності токенів, що містять TK_RETURN
. Для цього спочатку додамо вузол типу ND_RETURN
для представлення оператора return
. Далі ми модифікуємо функцію читання операторів, щоб вона могла аналізувати оператори return
. Як завжди, ми можемо розбирати граматику, безпосередньо зіставляючи її з викликом функції. Нова функція stmt
показана нижче.
Node *stmt() {
Node *node;
if (consume(TK_RETURN)) {
node = calloc(1, sizeof(Node));
node->kind = ND_RETURN;
node->lhs = expr();
} else {
node = expr();
}
if (!consume(';'))
error_at(tokens[pos].str, "Токен не є ';'");
return node;
}
Оскільки вузли типу ND_RETURN
створюються тільки в цьому місці, замість виділення окремої функції ми одразу тут виконуємо malloc
і заповнюємо значення.
Нарешті, потрібно змінити генератор коду, щоб для вузлів типу ND_RETURN
він виводив відповідний асемблерний код. Ось приклад частини оновленої функції gen
:
void gen(Node *node) {
if (node->kind == ND_RETURN) {
gen(node->lhs);
printf(" pop rax\n");
printf(" mov rsp, rbp\n");
printf(" pop rbp\n");
printf(" ret\n");
return;
}
...
В виклику gen(node->lhs)
генерується код для виразу, що є значенням, яке повертає return
. Цей код залишає одне значення на верхівці стеку. Після виклику gen(node->lhs)
у наступних інструкціях асемблера це значення знімається зі стеку в регістр rax
, після чого виконується повернення з функції.
У реалізації, описаній у попередніх розділах, в кінці функції завжди виводилася одна інструкція ret
. З додаванням підтримки return
у коді з’являться додаткові інструкції ret
для кожного оператора return
. Ці інструкції можна об’єднати, але для простоти реалізації наразі дозволяється виводити кілька ret
.
Такі дрібниці не варто ускладнювати на цьому етапі — простота реалізації важливіша. Здатність писати складний код — корисний навик, але вміння не ускладнювати код зайвими деталями іноді є ще більш цінним.
Колонка: Ієрархія граматик
Для визначення, чи відповідає вхідні дані певним правилам, часто використовують «регулярні вирази», але граматики, складніші за певний рівень, не можуть бути виражені регулярними виразами. Наприклад, регулярним виразом принципово неможливо описати перевірку збалансованості дужок у рядку.
Безконтекстна граматика (граматика, яку можна описати за допомогою BNF) потужніша за регулярні вирази, і, наприклад, може описувати рядки з правильно збалансованими дужками (в BNF це записується як
S → SS | "(" S ")" | ε)
. Однак, як і регулярні вирази, безконтекстна граматика має обмеження і не може виразити складні правила, що зустрічаються у звичайних мовах програмування. Наприклад, правило «змінна має бути оголошена до використання» є частиною граматики мови C, але таке правило не можна описати безконтекстною граматикою.Якщо написати компілятор для мови C, то, за відсутності багів у компіляторі, можна стверджувати, що «вхідні дані, прийняті компілятором, є правильними програмами C, а відхилені — неправильними». Тобто, за наявності звичайного комп’ютера, задача визначення відповідності граматиці мови C є розв’язуваною, і компілятор у цілому можна вважати більш потужним граматичним аналізатором, ніж безконтекстна граматика. Такі граматики, для яких завжди можна дати відповідь «так» або «ні», називають розв’язуваними (Decidable).
Існують граматики, що не є розв’язуваними. Наприклад, задача «чи виконає комп’ютерна програма, що подається на вхід, врешті-решт виклик функції exit і завершиться, або виконуватиметься нескінченно» загалом доведено неможливо розв’язати без фактичного запуску програми (припускаючи, що виконуємо на віртуальній машині з необмеженою пам’яттю). Тобто, для питання про зупинку програми можна відповісти «так», якщо вона зупиняється, але не можна однозначно відповісти «ні», якщо вона не зупиняється, бо тоді аналізатор буде виконуватися вічно. Такі граматики, для яких аналізатор може не завершувати роботу, називають Turing-recognizable.
Отже, існує ієрархія граматик: регулярні вирази < безконтекстні граматики < розв’язувані (Decidable) < Turing-recognizable. Ця ієрархія широко досліджується в комп’ютерних науках. Відома нерозв’язана проблема P≟NP також стосується цієї ієрархії граматик.
Компілятор C 1973 року
До цього моменту ми поступово створювали компілятор. У певному сенсі цей процес розробки можна назвати відтворенням самої історії мови C.
Сьогодні, якщо подивитися на мову C, можна знайти місця, що виглядають незрозуміло або надмірно складно. Проте такі особливості неможливо зрозуміти без урахування історії. Навіть ті заплутані аспекти сучасної C можуть стати зрозумілішими, якщо прочитати код на ранній C, подивитися на первісну форму мови, а також простежити її подальший розвиток разом з еволюцією компіляторів.
Мова C почала розроблятися у 1972 році як мова для операційної системи Unix. В Інтернеті опубліковані файли, зчитані з магнітних стрічок, які містять надзвичайно ранній вихідний код — десь з 1972 чи 1973 року, тобто з самого початку історії C. Давайте трохи зазирнемо в код компілятора C того часу. Нижче наведена функція, яка приймає повідомлення у форматі printf
і відображає його як повідомлення про помилку компіляції.
error(s, p1, p2) {
extern printf, line, fout, flush, putchar, nerror;
int f;
nerror++;
flush();
f = fout;
fout = 1;
printf("%d: ", line);
printf(s, p1, p2);
putchar('\n');
fout = f;
}
Ця мова виглядає дещо дивною — схожою на C, але водночас і не зовсім на C. Саме такою була мова C у ті часи. Перше, що впадає в око при читанні цього коду — це відсутність типів у повертаних значеннях та аргументах функцій, що нагадує початкові стадії компілятора, який ми створювали. Наприклад, змінна s
тут — це, очевидно, вказівник на рядок, а p1
і p2
— цілочисельні значення, але всі ці змінні оголошено без вказання типу. На той час усі значення на машині мали однаковий розмір, тож програмісти могли використовувати їх без явного визначення типу.
На другому рядку вказано оголошення глобальних змінних і функцій, до яких звертається error
. У ті часи в C ще не було ані заголовкових файлів (header files), ані препроцесора, тому програміст мав сам явно повідомляти компілятор про існування змінних і функцій.
Як і в нашому поточному компіляторі, тодішній компілятор C перевіряв лише наявність імені функції, але не перевіряв кількість або типи аргументів. Достатньо було покласти передбачену кількість аргументів на стек, а потім просто перейти до тіла функції — цього вважалося достатньо для успішного виклику функції.
fout
— це глобальна змінна, яка містить номер файлового дескриптора для виводу. Тоді ще не існувало fprintf
, і щоб вивести текст не у стандартний вивід, а у стандартний потік помилок, потрібно було змінити місце призначення виводу через глобальну змінну.
У тілі функції error
printf
викликається двічі. При другому виклику разом із форматним рядком передаються два значення. Що ж відбувається, якщо потрібно вивести повідомлення про помилку лише з одним значенням?
Насправді, функція error
справно працює навіть тоді, коли викликається з меншою кількістю аргументів. Згадайте, що на той час ще не існувало перевірки аргументів функцій. Аргументи s
, p1
, p2
— це просто посилання на перші, другі та треті слова у стеку. Чи було фактично передано значення для p2
— компілятор не перевіряв. printf
просто звертався до тієї кількості аргументів, скільки %d
або %s
міститься у форматному рядку. Тому, якщо формат містить лише один %d
, то p2
взагалі не буде використано. Отже, навіть якщо кількість аргументів не збігається, проблем не виникало.
Таким чином, у початкових компіляторах C є багато спільного з нашим компілятором 9cc
на поточному етапі.
Розгляньмо ще один приклад коду. Нижче наведено функцію, яка копіює переданий рядок у статично виділену область пам’яті та повертає вказівник на початок цієї області. Інакше кажучи, це функція на зразок strdup
, але з використанням статичної пам’яті.
copy(s)
char s[]; {
extern tsp;
char tsp[], otsp[];
otsp = tsp;
while(*tsp++ = *s++);
return(otsp);
}
У ті часи ще не було синтаксису оголошення на кшталт int *p
. Натомість типи вказівників оголошували у формі int p[]
. Між списком аргументів функції та її тілом присутнє щось подібне до визначення змінної — це потрібно, щоби оголосити s
як змінну вказівникового типу.
У цьому ранньому компіляторі C були й інші цікаві особливості.
- На цьому етапі ще не існувало структур (
struct
). - Такі оператори, як
&&
та||
, також ще не були впроваджені. Умовні вирази на кшталтif
використовували&
та|
, які в цьому контексті мали значення логічних операторів — їхня поведінка залежала від контексту. - Оператор
+=
записувався як=+
. Через це виникала проблема: якщо ви хотіли записатиi = -1
(присвоїти -1), але написали без пробілуi=-1
, компілятор трактував це якi =- 1
, що означає “декрементувати i”, і призводило до неочікуваної поведінки. - Із цілих типів були лише
char
таint
; типиshort
іlong
ще не існували. Не існувало й синтаксису для оголошення таких складних типів, як «масив покажчиків на функції», тож записати складні типи було неможливо.
Крім вищезазначеного, у C початку 70-х бракувало ще багатьох функцій. І все ж, як видно з наведеного вище прикладу, цей компілятор був написаний на C. Тобто, навіть у добу, коли ще не існувало структур, C вже був мовою, здатною до самохостингу.
Розглядаючи старий код, можна припустити, чому деякі малозрозумілі конструкції сучасної C мають саме такий вигляд. Наприклад, якщо після extern
, auto
, int
або char
завжди іде ім’я змінної — це спрощує парсинг визначень змінних. []
, які позначають покажчики, також легко парсити, якщо вони одразу після імені змінної. Однак, якщо продовжити розвивати цей синтаксис у напрямку, видимому в ранньому компіляторі, то зрозуміло, як це могло призвести до нинішньої надмірної складності мови.
Отже, десь у 1973 році співрозробник Unix і C, Деніс Рітчі, займався саме інкрементальною розробкою. Він одночасно розвивав саму мову C і писав на ній компілятор. Сучасна C — це не якась остаточна форма мови, що досягла особливої завершеності. Це просто мова, яка в якийсь момент задовольнила Рітчі за набором функцій, і він вирішив, що на цьому її можна вважати завершеною.
Ми теж не прагнули одразу створити завершену версію компілятора. Оскільки кінцева форма C не має якогось особливого сакрального значення, немає й потреби фанатично до неї прагнути. Якщо ми розвиватимемо мову поступово, забезпечуючи на кожному етапі розумний набір можливостей, а в підсумку прийдемо до C — то це цілком поважна, історично обґрунтована стратегія розробки, яку застосовували ще в епосі зародження C. Тож упевнено продовжуймо нашу роботу!
Колонка: П’ять правил програмування від Rob Pike
Компіллятор 9cc розроблявся під впливом підходу до програмування, який сповідує Rob Pike — колишній колега автора мови C, Dennis Ritchie, співавтор мови Go, а також співавтор UTF-8 разом з Ken Thompson, творцем Unix.
Нижче наведено “П’ять правил програмування Rob Pike”:
Не можна передбачити, яка частина програми буде повільною.
Вузькі місця (bottlenecks) виникають у несподіваних місцях. Тому не варто навмання здогадуватись і завчасно додавати оптимізації — спершу потрібно знати, де саме знаходиться проблема.Вимірюй.
Ніколи не оптимізуй до того, як проведеш вимірювання. А навіть коли виміряв — оптимізуй лише ті частини, які катастрофічно повільні.Складні алгоритми повільні при малих
n
, аn
зазвичай мале.
Складні алгоритми мають великий коефіцієнт при постійних. Якщо ти не впевнений, щоn
зазвичай велике — не ускладнюй. (І навіть якщоn
велике — спершу застосуй правило 2.)Складні алгоритми частіше містять помилки і їх складніше реалізовувати.
Тому варто надавати перевагу простим алгоритмам і простим структурам даних.Дані — це найважливіше.
Якщо ти обереш правильну структуру даних і правильно представиш дані, алгоритм зазвичай стає очевидним. Структури даних — це серце програмування, а не самі алгоритми.
Крок 12: Додаємо керуючі конструкції
Розділи, що йдуть далі, ще перебувають у процесі написання. Попередні розділи я намагався написати якомога ретельніше, але чесно кажучи, вважаю, що наступні розділи ще не досягли рівня, гідного публікації. Проте, якщо ви вже дійшли до цього моменту, то, ймовірно, зможете самостійно доповнити необхідне і продовжити читання. До того ж, комусь може бути корисно побачити орієнтовний план подальших кроків — з цією метою я й публікую цей матеріал.
У цьому розділі ми додамо до мови керуючі конструкції, такі як if
, if ... else
, while
, for
. Ці конструкції можуть виглядати складними на перший погляд, але якщо компілювати їх безпосередньо в асемблер, реалізація насправді є доволі простою.
В асемблері не існує конструкцій, аналогічних керуючим структурам C, тому такі конструкції реалізуються за допомогою команд умовного переходу та міток. У певному сенсі це те саме, що переписати керуючу структуру, використовуючи goto
. І так само, як людина може вручну переписати if
чи while
у вигляді goto
, компілятор також може реалізувати керуючі конструкції, просто генеруючи код за визначеними шаблонами.
Існують і інші керуючі конструкції, такі як do ... while
, goto
, continue
, break
тощо, але їх поки не потрібно реалізовувати на цьому етапі.
Нижче наведено оновлену граматику з підтримкою if
, while
та for
:
program = stmt*
stmt = expr ";"
| "if" "(" expr ")" stmt ("else" stmt)?
| "while" "(" expr ")" stmt
| "for" "(" expr? ";" expr? ";" expr? ")" stmt
| ...
...
Під час розбору expr? ";"
можна діяти таким чином: зробити попередній перегляд одного токена наперед, і якщо наступний токен — це ;
, тоді вважається, що expr
відсутній. Інакше потрібно прочитати вираз expr
.
Конструкція if (A) B
компілюється в асемблер наступним чином:
Скомпільований код для A // Результат має бути на вершині стеку
pop rax
cmp rax, 0
je .LendXXX
Скомпільований код для B
.LendXXX:
Іншими словами, if (A) B
,
if (A == 0)
goto end;
B;
end:
Таким чином, ця конструкція розгортається аналогічним чином. Значення XXX
слід замінити на послідовні номери або інші унікальні ідентифікатори, щоб усі мітки були унікальними.
Конструкція if (A) B else C
компілюється в асемблер наступним чином:
Скомпільований код для A // Результат має бути на вершині стеку
pop rax
cmp rax, 0
je .LelseXXX
Скомпільований код для B
jmp .LendXXX
.LelseXXX
Скомпільований код для C
.LendXXX
Тобто, if (A) B else C
розгорнеться до:
if (A == 0)
goto els;
B;
goto end;
els:
C;
end:
Під час розбору if
-конструкції слід виконати попереднє зчитування одного токена, щоб перевірити, чи присутній else
. Якщо else
є — компілюється конструкція if ... else
, якщо ж else
відсутній — компілюється if
без else
.
Конструкція while (A) B
компілюється наступним чином:
.LbeginXXX:
Скомпільований код для A
pop rax
cmp rax, 0
je .LendXXX
Скомпільований код для B
jmp .LbeginXXX
.LendXXX:
Інакше кажучи, конструкція while (A) B
розгортається так само, як наступний код:
begin:
if (A == 0)
goto end;
B;
goto begin;
end:
Конструкція for (A; B; C) D
компілюється наступним чином:
Скомпільований код для A
.LbeginXXX:
Скомпільований код для B
pop rax
cmp rax, 0
je .LendXXX
Скомпільований код для D
Скомпільований код для C
jmp .LbeginXXX
.LendXXX:
Нижче наведено C-код, який відповідає конструкції for (A; B; C) D
:
A;
begin:
if (B == 0)
goto end;
D;
C;
goto begin;
end:
До речі, мітки, що починаються з .L
, є спеціальними і розпізнаються асемблером як імена з файловою областю видимості (file scope). Такі мітки можна використовувати всередині того самого файлу, але вони не можуть бути викликані з інших файлів.
Тому, якщо мітки, які компілятор створює для if
чи for
, починати з .L
, то можна не хвилюватися про конфлікти з мітками з інших файлів.
Спробуйте скомпілювати простий цикл за допомогою cc
(компілятора C) та використати отриманий асемблерний код як приклад.
Колонка: Виявлення помилок виконання за допомогою компілятора
Під час програмування на C досить часто трапляються помилки, коли дані записуються за межі кінця масиву або через помилки з вказівниками пошкоджуються сторонні структури даних. Такі баги можуть перетворитися на серйозні вразливості безпеки. У зв’язку з цим виникла ідея — виявляти такі помилки під час виконання програми за допомогою можливостей компілятора.
Наприклад, якщо передати GCC опцію
-fstack-protector
, компілятор вставить у пролог функції запис так званого «канарейкового значення» (canary) — випадкового цілого розміру вказівника — у фрейм функції. У епілозі функції перевіряється, чи не змінилося це значення. Якщо через переповнення буфера в масиві щось випадково перепише стек, то, з великою ймовірністю, буде змінено і канарейкове значення. У такому випадку компілятор зможе виявити помилку під час повернення з функції. Якщо помилку виявлено — програма зазвичай негайно завершує роботу.У LLVM існує інструмент під назвою TSan (ThreadSanitizer), який дозволяє виявляти у виконуваному коді ситуації, коли кілька потоків одночасно доступаються до спільної структури даних без належного блокування. Також є UBSan (UndefinedBehaviorSanitizer), який вставляє в код перевірки на предмет того, чи не порушуються правила C щодо невизначеної поведінки. Наприклад, у C переповнення знакових цілих чисел вважається невизначеною поведінкою. Якщо це станеться, UBSan згенерує повідомлення про помилку.
Застосування таких інструментів, як TSan, може уповільнити програму у кілька разів, тому додавати їх у звичайні параметри компіляції не завжди доцільно. Проте такі механізми, як стекові канарейки, які мають порівняно низькі накладні витрати, можуть бути ввімкнені за замовчуванням у деяких середовищах.
Такі методи виявлення динамічних помилок із підтримкою компілятора активно досліджуються в останні роки і суттєво допомагають писати відносно безпечні програми навіть на таких мовах, як C чи C++, які не гарантують безпечної роботи з пам’яттю.
Крок 13: Блок
У цьому кроці ми додамо підтримку «блоку» — множини операторів, вкладених у { ... }
. Формально блок називається «блочним оператором» (compound statement), але це довге слово, тому частіше його просто називають блоком.
Блок дозволяє об’єднати кілька операторів у один оператор. У попередньому кроці реалізовані if
та while
дозволяли виконувати лише по одному оператору при виконанні умови, але з додаванням блоку, як у C, можна писати всередині {}
кілька операторів.
Тіло функції насправді теж є блоком. З граматичної точки зору, тіло функції має бути обов’язково блоком. Тож { ... }
у визначенні функції синтаксично ідентичні до { ... }
, які йдуть після if
чи while
.
Нижче наведено граматику з доданою підтримкою блоків:
program = stmt*
stmt = expr ";"
| "{" stmt* "}"
| ...
...
У цій граматиці, якщо stmt
починається з "{"
, тоді можна мати 0 або більше stmt
до появи "}"
. Щоб розпарсити stmt* "}"
, потрібно в циклі while
викликати розбір stmt
, доки не зустрінеться "}"
, і повертати результати у вигляді вектора.
Для реалізації блоку додайте тип вузла ND_BLOCK
. У структурі, що представляє вузол (Node
), необхідно додати вектор, який зберігатиме вирази (оператори), що входять до блоку. У генераторі коду, якщо тип вузла — ND_BLOCK
, потрібно послідовно згенерувати код для кожного оператору, що міститься в ньому. При цьому не забувайте, що кожен оператор залишає одне значення у стеку, тож після кожного потрібно робити операцію pop
, щоб очищати стек.
Крок 14: Підтримка виклику функцій
У цьому кроці ми реалізуємо розпізнавання викликів функцій без аргументів, як-от foo()
, і метою буде компіляція таких викликів у інструкцію call foo
.
Нижче наведено оновлену граматику з доданою підтримкою викликів функцій:
...
primary = num
| ident ("(" ")")?
| "(" expr ")"
Після читання ident можна зробити попереднє зчитування одного токена, щоб визначити, чи є цей ident ім’ям змінної, чи ім’ям функції.
У тестах готують C-файл із вмістом на кшталт int foo() { printf("OK\n"); }
, компілюють його командою cc -c
у об’єктний файл, а потім лінкують з виводом власного компілятора. Таким чином можна переконатися, що лінкування відбулося коректно і виклик функції працює правильно.
Якщо це працює, наступним кроком слід реалізувати можливість написання виклику функції, наприклад foo(3, 4)
. Перевірка кількості та типів аргументів не потрібна. Просто потрібно послідовно оцінити аргументи, після чого на стеку сформуються значення, які треба передати у функцію. Потім ці значення копіюються в регістри у порядку, визначеному ABI для x86-64, і відбувається виклик функції за допомогою інструкції call
. Підтримка більше ніж 6 аргументів не обов’язкова.
У тестах так само готують функцію на кшталт int foo(int x, int y) { printf("%d\n", x + y); }
і лінкують її, щоб перевірити працездатність.
ABI для виклику функцій у x86-64 (за умови описаного вище підходу) досить простий, але є один важливий нюанс. Перед викликом функції регістр RSP має бути вирівняний по 16 байтах. Оскільки push
і pop
змінюють RSP по 8 байт, під час виклику через call
RSP не завжди буде кратним 16. Якщо це правило порушується, функції, які припускають вирівняння RSP по 16 байтах, можуть аварійно завершуватися з ймовірністю близько 50%. Тому перед викликом функції потрібно відкоригувати RSP так, щоб він був кратним 16.
Крок 15: Підтримка визначення функцій
Після завершення попередніх кроків наступним буде реалізація визначення функцій. Однак, оскільки синтаксичний розбір визначень функцій у C доволі складний, ми не будемо одразу реалізовувати весь функціонал. На даний момент у нашій мові існує лише тип int
, тому замість синтаксису int foo(int x, int y) { ... }
реалізуємо спрощений синтаксис без вказання типу, тобто foo(x, y) { ... }
.
У тілі функції потрібно мати можливість звертатися до аргументів за іменами x
, y
тощо, але наразі неможливо напряму звертатися до значень у регістрах за іменами. Щоб вирішити це, слід компілювати функцію так, ніби x
, y
— це локальні змінні, а в прологу функції потрібно записати значення з регістрів у відповідні області на стеку для цих локальних змінних. Після цього аргументи і локальні змінні можна буде обробляти однаково.
Раніше вся програма неявно вважалась укладеною в main() { ... }
, але тепер цей підхід відкидається. Увесь код повинен бути розміщений у межах якоїсь функції. Тому під час синтаксичного розбору верхнього рівня перший зчитаний токен завжди вважатиметься ім’ям функції, за яким слідуватиме список аргументів, а потім тіло функції — це дозволяє легко прочитати структуру.
Після завершення цього кроку можна буде, наприклад, обчислювати та виводити числа Фібоначчі рекурсивно, що значно підвищить цікавість.
Інтерфейс бінарного коду
Специфікація мови C визначає правила на рівні вихідного коду. Наприклад, вона описує, як можна визначити функцію або які файли включати, щоб оголосити певні функції. Проте стандарт мови не регламентує, як саме цей код перетворюється на машинні інструкції. Це природньо, оскільки стандарт не орієнтований на конкретний набір команд.
Здавалося б, що на рівні машинного коду немає потреби у суворих правилах, але насправді для кожної платформи існують певні домовленості, які називаються ABI (Application Binary Interface).
У книзі було описано, що аргументи передаються у певному порядку у регістри, а результат функції повертається в регістр RAX. Ці правила виклику функції називаються “конвенцією виклику функцій” (function calling convention) і є частиною ABI.
ABI мови C включає також такі аспекти:
- Які регістри можуть змінюватися при виклику функції, а які повинні бути збережені (наприклад, RBP має бути відновлений перед поверненням, а деякі регістри можна не відновлювати).
- Розміри типів, таких як
int
абоlong
. - Правила розташування полів у структурах (яким чином члени структури розташовуються в пам’яті).
- Правила розташування бітових полів (наприклад, чи починаються бітові поля з молодшого або старшого біта).
ABI — це по суті домовленість на рівні програмного забезпечення. Можна уявити інші ABI, відмінні від описаного тут, але код, що не сумісний з ABI, не зможе коректно викликати або бути викликаним іншим кодом. Тому виробники процесорів і операційних систем визначають стандартний ABI для платформи. У x86-64 широко використовуються дві ABI: System V ABI (для Unix-подібних систем і macOS) та Microsoft ABI (для Windows). Вони не відокремлені з необхідності, а виникли через незалежну розробку різними групами.
До цього моменту у книзі ми вже викликали функції, скомпільовані іншими компіляторами, зі свого компілятора. Це було можливо завдяки сумісності ABI нашого компілятора з ABI інших компіляторів.
Представлення цілих чисел у комп’ютері
Тут варто зрозуміти, як у комп’ютері представляються цілі числа, особливо від’ємні. У цьому розділі буде пояснено представлення беззнакових чисел та представлення знакових чисел за допомогою “доповнення до двійки” (two’s complement).
У цій книзі двійкові бітові патерни записуватимуться з префіксом 0b
і для кращої читабельності розбиватимуться підкресленнями через кожні 4 біти, наприклад: 0b0001_1010
. Префікс 0b
фактично підтримується як розширення багатьма компіляторами C (хоча звичайно підкреслення в них не дозволяються).
Беззнакові цілі числа
Подання беззнакових цілих чисел (unsigned integer) збігається з поданням звичайних двійкових чисел. Так само, як у десятковій системі число представляється цифрами з вагами 1, 10, 100, 1000 і так далі (тобто (10^0, 10^1, 10^2, 10^3, \ldots)), у двійковій системі число складається з бітів з вагами 1, 2, 4, 8 і так далі (тобто (2^0, 2^1, 2^2, 2^3, \ldots)).
Наприклад, бітовий патерн 0b1110
у беззнаковому поданні означає суму ваг встановлених бітів. Тут встановлені біти на 2-й, 3-й і 4-й позиціях, тобто з вагами 2, 4 і 8 відповідно. Отже, 0b1110
відповідає числу (2 + 4 + 8 = 14).
Нижче наведено кілька прикладів.
Якщо додавати 1 до беззнакового цілого числа, його значення буде циклічно повторюватися, як показано на наступному графіку. Це приклад для 4-бітного числа.
Коли результат операції виходить за межі розрядів числа і відрізняється від того, що було б при нескінченній кількості бітів, це називається “переповненням” (overflow). Наприклад, для 8-бітних цілих чисел вираз 1 + 3
не викликає переповнення, але 200 + 100
або 20 - 30
призводять до переповнення і дають результати 44 та 246 відповідно. З математичної точки зору це означає, що результат дорівнює остачі від ділення на 256.
Колонка: Цікава помилка через переповнення
Переповнення чисел іноді призводить до несподіваних помилок. Тут наведено приклад з першої версії гри “Civilization”.
Civilization — стратегічна симуляція, у якій гравець обирає цивілізацію (наприклад, Чингізхана чи королеву Єлизавету) і бореться за світове панування або перемогу в космічних перегонах.
У першій версії гри був баг, через який ненасильницький Ганді раптом починав ядерну атаку. Причина полягала у логіці, що прийняття демократії цивілізацією знижує її агресивність на 2. Агресивність Ганді була найнижчою — 1. Коли індійська цивілізація ставала демократією, агресивність зменшувалась на 2, що призводило до переповнення і числового значення 255 (за модулем 256). В результаті Ганді став надзвичайно агресивним гравцем.
До того часу технології у грі були настільки розвинуті, що всі цивілізації мали ядерну зброю. Через це Ганді починав раптову ядерну війну. Цей “ядерний Ганді” виявився досить кумедним, і пізніше став фірмовим жартом у наступних частинах серії, хоча у першій грі це був випадковий баг.
Знакові цілі числа
Для знакових цілих чисел (signed integer) використовується подання з “доповненням до двійки” (two’s complement), де найстарший біт (most significant bit) має спеціальне значення. Для числа з (n) бітів всі біти, крім найстаршого, інтерпретуються так само, як у беззнаковому поданні, але найстарший, (n)-й біт, замість ваги (2^{n-1}) має вагу (-2^{n-1}).
Розглянемо конкретно випадок 4-бітного двійкового числа. Кожен біт і його значення наведені у таблиці нижче.
4 | 3 | 2 | 1 | |
Беззнакове | 8 | 4 | 2 | 1 |
Знакове | -8 | 4 | 2 | 1 |
Як і у випадку з беззнаковими числами, знакове значення, яке представляє певний бітовий патерн, можна визначити, подивившись на позиції встановлених бітів.
Наприклад, якщо розглядати 0b1110
як 4-бітне знакове число, то встановлені біти знаходяться на 2-й, 3-й і 4-й позиціях, тобто з вагами 2, 4 та -8 відповідно. Таким чином, 0b1110
дорівнює (2 + 4 + (-8) = -2).
Нижче наведено кілька прикладів.
За цим правилом, якщо найстарший біт не встановлений, значення знакового цілого числа збігається з його беззнаковим поданням. Для 4-бітних чисел числа від 0 до 7 мають однаковий бітовий патерн і для знакових, і для беззнакових чисел.
Якщо ж найстарший (4-й) біт увімкнений, цей патерн відповідає числам від -8 до -1 (від 0b1000
до 0b1111
). Оскільки увімкнений найстарший біт означає від’ємне число, цей біт часто називають “біт знаку” (sign bit).
Якщо додавати 1 до знакового цілого числа, його значення циклічно повторюється, як показано на наступному графіку. Це приклад для 4-бітних чисел.
Розуміючи наведене правило, можна пояснити багато на перший погляд дивних поведінок знакових цілих чисел, з якими програмісти часто стикаються.
Коли до знакового цілого числа додають 1, і воно переповнюється, воно різко змінюється з великого позитивного числа на дуже маленьке негативне. Багато хто з читачів уже стикалися з цим. Якщо подивитися на це крізь призму подання з доповненням до двійки, стає зрозуміло, що саме відбувається.
Наприклад, для 8-бітного знакового цілого максимальне значення — 0b0111_1111
, тобто 127. Додавши 1, отримаємо 0b1000_0000
, що в поданні з доповненням до двійки відповідає -128 — найбільшому за абсолютним значенням від’ємному числу.
Якщо в тесті у функції main повернути, наприклад, -3 у якості коду завершення, то фактичним кодом виходу програми стане 253. Це відбувається тому, що main встановлює в регістр RAX значення -3, тобто 0b1111_1111_1111_1111_1111_1111_1111_1101
, а інша сторона читає лише молодші 8 бітів і трактує їх як беззнакове число, отримуючи 0b1111_1101
, тобто 253.
Таким чином, те, яке число представляє бітовий патерн, залежить від інтерпретації читача. Подібно до того, як плями чорнила на папері набувають сенсу лише тому, що люди читають їх як текст, у комп’ютерній пам’яті бітові послідовності самі по собі не мають значення. Для коректної передачі значення необхідно, щоб і той, хто записує, і той, хто читає, погодилися на один спосіб інтерпретації.
До того ж, у поданні з доповненням до двійки кількість від’ємних чисел на 1 більша, ніж кількість додатних. Наприклад, для 8-бітних чисел представлено -128, але +128 вже поза межами діапазону. Це обумовлено тим, що при (n) бітах можливих комбінацій (2^n) — завжди парна кількість. Оскільки для числа 0 резервується один патерн, залишається непарна кількість патернів для додатних і від’ємних чисел, тож один із діапазонів виявляється більшим.
Розширення знаку
У комп’ютерах часто трапляється операція розширення ширини бітів числа. Наприклад, коли 8-бітне число читають із пам’яті і записують у 64-бітовий регістр, потрібно розширити 8-бітове значення до 64-бітового.
Якщо ми працюємо з беззнаковими цілими числами, розширення просте — достатньо доповнити старші біти нулями. Наприклад, 4-бітове число 0b1110
(14 у десятковій) розширюється до 8 біт як 0b0000_1110
, що теж дорівнює 14.
Однак, для знакових цілих просте доповнення нулями призведе до зміни числа. Наприклад, 4-бітове 0b1110
у знаковому представленні — це -2. Якщо розширити його до 8 біт, заповнивши старші біти нулями (0b0000_1110
), отримаємо 14, тобто зовсім інше число. Це тому, що при такому розширенні знак не зберігається.
Щоб коректно розширити знакове число, потрібно перевірити знак: якщо старший біт (біт знаку) дорівнює 1, то нові старші біти заповнюються одиницями, якщо 0 — нулями. Цю операцію називають розширенням знаку (sign extension). Наприклад, 4-бітове 0b1110
(-2) при розширенні до 8 біт стане 0b1111_1110
, що теж дорівнює -2.
Для беззнакових чисел можна уявити, що ліворуч нескінченно багато нулів, і при розширенні просто додаються ці нулі.
Аналогічно, у знакових цілих числах можна уявити, що зліва нескінченно повторюється біт знаку (старший біт), і при розширенні ширини числа ми просто беремо цей біт і доповнюємо ним нові старші біти.
Таким чином, коли потрібно помістити певне число у ширший за розрядністю формат, важливо усвідомлювати, чи є це число знаковим чи беззнаковим.
Колонка: Подання від’ємних чисел без необхідності розширення знаку
Двійкове додатне доповнення (two’s complement) — це широко використовуваний у комп’ютерах спосіб подання знакових цілих чисел, але якщо подумати про те, як відображати додатні й від’ємні цілі числа у бітові шаблони, то це не єдиний можливий метод. Наприклад, можна розглянути так звані від’ємні двійкові числа (negative base-2 numbers), де розряди знизу вгору позначають значення (-2)^0, (-2)^1, (-2)^2, ⋯⋯. Нижче наведена порівняльна таблиця для 4-бітового випадку, що ілюструє значення окремих розрядів.
4 3 2 1 Беззнакові 8 4 2 1 Доповнення до двійки -8 4 2 1 Негативне двійкове число -8 4 -2 1 4-бітна негативна двійкова система числення може представляти 16 цілих чисел від -10 до 5 наступним чином:
5 0b0101 4 0b0100 3 0b0111 2 0b0110 1 0b0001 0 0b0000 -1 0b0011 -2 0b0010 -3 0b1101 -4 0b1100 -5 0b1111 -6 0b1110 -7 0b1001 -8 0b1000 -9 0b1011 -10 0b1010 Мінус-двійкове подання (з основою -2) має свої недоліки: обробка переносу виглядає складною, а нуль не розташований посередині діапазону представлених значень. Проте воно має цікаву властивість — йому не потрібен окремий біт знаку.
Завдяки цьому, при розширенні мінус-двійкового числа до більшої кількості бітів достатньо просто доповнити старші біти нулями — незалежно від того, яке число кодується.
Таким чином, хоча представлення цілих чисел на комп’ютері не обмежується лише поданням з доповненням до двійки, саме воно стало стандартом. Причина цього в тому, що таке подання найзручніше для реалізації в апаратному забезпеченні — саме тому його використовують практично всі сучасні комп’ютери.
Інверсія знаку
Деталі представлення у доповненні до двійки (2’s complement) не є обов’язковими знаннями для створення компілятора, однак знання деяких прийомів, пов’язаних із цим представленням, може стати в пригоді у різних ситуаціях. Тут пояснюється простий спосіб інверсії знаку числа.
У представленні в доповненні до двійки інверсія знаку виконується за допомогою операції «інверсувати всі біти та додати 1». Наприклад, щоб отримати бітовий шаблон для числа -3 із числа 3 у 8-бітовому знаковому представленні, слід виконати такі кроки:
- Представити число у двійковому вигляді. Для 3 це буде
0b0000_0011
. - Інверсувати всі біти. У цьому випадку це дає
0b1111_1100
. - Додати 1. У результаті отримаємо
0b1111_1101
. Це і є бітовий шаблон для -3.
Цей метод дозволяє легко отримати бітовий шаблон для від’ємного числа.
Крім того, якщо виконати таку саму операцію над шаблоном із встановленим бітом знаку (тобто від’ємним числом), то можна отримати відповідне додатне число, яке воно представляє. Наприклад, якщо подивитися на 0b1111_1101
і хочеться дізнатись, яке саме число воно представляє, то просте додавання може бути клопітким. Але якщо інверсувати біти і додати 1, отримаємо 0b0000_0011
, що є числом 3, а отже, початкове значення — це -3.
Причина, чому цей трюк працює, досить проста. Хоча ми ще не дали математичного визначення операціям у представленні в доповненні до двійки, і пояснення буде дещо приблизним, ідея така:
Інверсія всіх бітів еквівалентна відніманню від -1, тобто від шаблону, де всі біти дорівнюють 1. Наприклад, бітовий шаблон 0b0011_0011
можна інверсувати наступним чином:
1111 1111
- 0011 0011
= 1100 1100
Іншими словами, інверсія бітового шаблону, що представляє число n
, еквівалентна обчисленню -1 - n
. Якщо до цього результату додати 1, отримаємо: (-1 - n) + 1 = -n
Тобто ми фактично отримали обернене за знаком число до n
.
Таким чином, виконуючи операцію “інверсія всіх бітів + 1”, ми можемо з числа n
отримати -n
.
Колонка: Система числення літералів
У стандарті мови C числа можуть записуватися у восьмеричній, десятковій або шістнадцятковій системах числення. Якщо число записано звичайним способом, як-от
123
, воно розглядається як десяткове. Якщо перед числом вказано префікс0x
, як у0x8040
, то це шістнадцяткове число. Якщо перед числом стоїть0
, як у0737
, то воно інтерпретується як восьмеричне.Можливо, багато хто з читачів подумає: «Я ніколи не використовував восьмеричні числа в C». Але насправді, у цій системі запису, навіть простий нуль (
0
) вважається восьмеричним числом. Отже, кожен C-програміст дуже часто — хоча й несвідомо — використовує восьмеричну систему числення. Це невеличкий факт із розряду цікавих дрібниць, але якщо задуматися, він має якесь (не зовсім глибоке, але й не зовсім поверхневе) пояснення.Річ у тім, що 0 — це особливий випадок у записі чисел. Зазвичай, ніхто не пише число 1 як
01
або001
, оскільки це не має сенсу: нулі перед числом вважаються зайвими. Але якщо застосувати це ж правило до нуля, то отримаємо порожній рядок. А оскільки не можна нічого не писати, коли потрібно позначити 0, для цього випадку передбачено спеціальне правило — писати саме0
.Однак у контексті граматики мови C це робить нуль дещо особливою категорією, адже він формально підпадає під правила запису восьмеричних літералів.
Вказівники та рядкові літерали
У попередніх розділах ми поступово створили мову програмування, яка вже здатна виконувати певні осмислені обчислення, але все ще не може навіть вивести "Hello world"
. Пора нарешті додати підтримку рядків, щоб програма могла виводити змістовні повідомлення.
Рядкові літерали в C тісно пов’язані з типом char
, глобальними змінними та масивами. Розгляньмо, наприклад, таку функцію:
int main(int argc, char **argv) {
printf("Hello, world!\n");
}
Наведений вище код компілюється так само, як і наступний код. Однак ім’я msg
має бути унікальним і не повинно конфліктувати з іншими ідентифікаторами.
char msg[15] = "Hello, world!\n";
int main(int argc, char **argv) {
printf(msg);
}
Нашому компілятору ще бракує кількох функцій для підтримки рядкових літералів. Щоб забезпечити підтримку рядкових літералів і можливість виведення повідомлень за допомогою printf
тощо, у цьому розділі ми поступово реалізуємо наступні функції:
- Унарний
&
та унарний*
- Вказівники
- Масиви
- Глобальні змінні
- Тип
char
- Рядкові літерали
Крім того, у цьому розділі ми також додамо інші необхідні можливості для тестування зазначених функцій.
Крок 16: Унарні &
та *
У цьому кроці ми зробимо перший крок до реалізації вказівників, а саме — реалізуємо унарні оператори &
(отримання адреси) та *
(розіменування адреси).
Ці оператори зазвичай повертають або приймають значення вказівникового типу. Однак у нашому компіляторі поки що немає типів, окрім цілих чисел, тому ми будемо використовувати цілі числа як заміну вказівникам. Тобто:
&x
повертає адресу змінноїx
як звичайне ціле число.*x
розглядаєx
як адресу та читає значення з цієї адреси.
Завдяки реалізації цих операторів, можна буде запускати ось такий код:
x = 3;
y = &x;
return *y; // Повернення 3
Крім того, скориставшись тим, що локальні змінні розміщуються в пам’яті послідовно, можна непрямо отримати доступ до змінної на стеку через вказівник, навіть у дещо примусовий спосіб.
У наведеному нижче коді передбачається, що змінна x
знаходиться на 8 байтів вище в стеку від змінної y
.
x = 3;
y = 5;
z = &y + 8;
return *z; // Повернення 3
У такій реалізації, де немає розділення між типом вказівника і цілим типом, вираз на кшталт *4
інтерпретується як читання значення з адреси 4. Це, звісно, не зовсім безпечно або коректно, але на даному етапі ми вирішимо змиритися з цим.
Реалізація доволі проста. Нижче наведено граматику з доданими унарними операторами &
та *
. Згідно з цією граматикою, потрібно внести зміни в парсер, щоб він розпізнавав унарні &
та *
як вузли типу ND_ADDR
та ND_DEREF
відповідно.
unary = "+"? primary
| "-"? primary
| "*" unary
| "&" unary
Зміни, які потрібно внести до генератора коду, мінімальні. Нижче наведено ці зміни.
case ND_ADDR:
gen_lval(node->lhs);
return;
case ND_DEREF:
gen(node->lhs);
printf(" pop rax\n");
printf(" mov rax, [rax]\n");
printf(" push rax\n");
return;
Крок 17: Скасування неявного визначення змінних та запровадження ключового слова int
До цього моменту всі змінні та значення, що повертаються з функцій, неявно вважалися типу int
. Тобто, не було потреби явно оголошувати змінну разом із її типом, наприклад, int x;
— кожен новий ідентифікатор автоматично розглядався як ім’я нової змінної.
Відтепер така домовленість більше не діє. Тому насамперед потрібно внести відповідні зміни. Реалізуйте наступну функціональність:
- Припиніть вважати нові ідентифікатори іменами змінних. Якщо з’являється змінна, яка не була явно оголошена, слід виводити помилку.
- Переконайтеся, що ви визначаєте змінні у формі
int x;
. Немає потреби підтримувати вирази ініціалізації, такі якint x = 3;
. Аналогічно, немає потреби у виразах, таких якint x, y;
. Реалізуйте лише найпростіші варіанти із можливих. -
До цього моменту функції записувалися у вигляді
foo(x, y)
, але тепер їх потрібно переписати у форміint foo(int x, int y)
. Оскільки на верхньому рівні мови визначаються лише функції, парсер повинен поводитися так:- Спочатку прочитати ключове слово
int
. - Потім обов’язково має йти ім’я функції — його також слід прочитати.
- Далі слід зчитати список аргументів у форматі
int <ім’я аргументу>
, що може повторюватися.
Не потрібно підтримувати складніші конструкції або передбачати майбутні розширення. Достатньо реалізувати мінімальний необхідний код для обробки конструкції типу:
int <ім’я функції>(int <ім’я аргументу>, int <ім’я аргументу>, ...)
- Спочатку прочитати ключове слово
Крок 18: Запровадження типу вказівника
Визначте тип, який представляє вказівник
На цьому кроці замість того, щоб дозволяти лише тип int
, тепер дозволяється тип, що складається з int
, за яким йде нуль або більше символів *
. Тобто парсер повинен підтримувати оголошення на кшталт int *x
або int ***x
.
Типи на кшталт “вказівник на int” мають обов’язково підтримуватися всередині компілятора. Наприклад, якщо змінна x
є вказівником на int
, компілятор повинен знати, що вираз *x
має тип int
. Оскільки типи можуть бути довільно вкладеними, наприклад “вказівник на вказівник на вказівник на int”, їх не можна представити як тип фіксованого розміру.
Щоб це реалізувати, використовують вказівники. Раніше для змінних у відображенні зберігався лише офсет від базового покажчика стеку (RBP). Тепер цю структуру потрібно розширити, щоб вона містила також інформацію про тип змінної. Приблизно тип змінної можна представити такою структурою:
struct Type {
enum { INT, PTR } ty;
struct Type *ptr_to;
};
Тут ty
може мати одне з двох значень: цілочисельний тип або “вказівник на”. ptr_to
є значущим членом лише тоді, коли ty має тип “вказівник на”, і в цьому випадку він зберігає вказівник на об’єкт Type
, на який вказує “~”. Наприклад, якщо це “вказівник на ціле число”, структура даних, що представляє цей тип, буде мати наступний внутрішній вигляд.
Для типу «вказівник на вказівник на int» структура буде такою:
Таким чином, всередині компілятора можна представляти довільно складні типи будь-якої глибини вкладеності.
Присвоєння через вказівник
Як компілювати вираз, де ліворуч стоїть не проста змінна, а, наприклад, вираз на кшталт *p = 3
? Основна ідея не відрізняється від присвоєння звичайній змінній. У цьому випадку потрібно згенерувати адресу, на яку вказує p
, і використати її як ліву частину присвоєння.
Під час компіляції дерева синтаксису вираз *p = 3
обробляється рекурсивно. Спершу викликається генератор коду для лівої частини — *p
.
Цей генератор коду аналізує тип вузла: якщо це проста змінна, він генерує код, який виводить адресу змінної, якщо це оператор розіменування (*
), то всередині нього дерево компілюється як праве значення (rvalue). В результаті згенерується код, який обчислює адресу, на яку вказує вираз.
Отриману адресу слід залишити на стеку, щоб потім використати для запису значення.
Після реалізації цього підходу можна буде компілювати такі оператори:
int x;
int *y;
y = &x;
*y = 3;
return x; // → 3
Крок 19: Реалізація додавання та віднімання вказівників
У цьому кроці ми зробимо так, щоб можна було писати вирази на кшталт p+1
або p-5
для значень типу вказівник p
. Зовні це схоже на звичайне додавання цілих чисел, але насправді це досить інша операція. p+1
не означає додати 1 до адреси, що зберігає p
, а означає отримати вказівник на наступний елемент, тому потрібно додати до p
розмір типу даних, на який він вказує. Наприклад, якщо p
вказує на int
, то в нашому ABI p+1
означає додати 4 байти до адреси. Якщо ж p
— вказівник на вказівник на int
, то p+1
означатиме додати 8 байтів.
Отже, для додавання і віднімання вказівників потрібен спосіб дізнатися розмір типу, але наразі вважайте, що int має розмір 4, а вказівник — 8, і напишіть код, який буде це жорстко враховувати.
На цьому етапі у нас ще немає способу послідовно виділяти пам’ять (у нашому компіляторі ще немає масивів), тому писати тести трохи складно. Тут можна просто скористатися допомогою зовнішнього компілятора, який зробить malloc, а у вихідному коді нашого компілятора використовувати цю допоміжну функцію для написання тестів. Наприклад, тести можна зробити приблизно так.
int *p;
alloc4(&p, 1, 2, 4, 8);
int *q;
q = p + 2;
*q; // → 4
q = p + 3;
return *q; // → 8
Колонка: Розмір int і long
У таких моделях даних, як x86-64 System V ABI, де int — 32 біти, а long і вказівник — 64 біти, використовується модель LP64. Це означає, що long і вказівник мають розмір 64 біти. Водночас у тій же архітектурі x86-64, але під Windows, застосовується модель LLP64, де int і long — 32 біти, а long long і вказівник — 64 біти.
Оскільки розміри long у LP64 і LLP64 відрізняються, ABI не сумісні між собою. Наприклад, якщо створити структуру з членом типу long, записати всю структуру у файл і потім читати цей файл, безпосередньо приводячи дані до структури, то файли, створені на Unix, не можна буде коректно прочитати на Windows і навпаки.
За специфікацією C, int — це «розмір природного цілого числа для даної машини» (A “plain” int object has the natural size suggested by the architecture of the execution environment). З огляду на це, можна подумати, що на 64-бітній машині int повинен бути 64-бітним, але питання, що є природним, є суб’єктивним. Навіть на 64-бітних машинах 32-бітні операції зазвичай природно підтримуються, тому робити int 32-бітним на 64-бітних машинах — не така вже й помилка. Крім того, з практичної точки зору, роблячи int 64-бітним, виникають такі проблеми:
- Випадки, коли потрібен 64-бітний int, трапляються рідко, тому 64-бітний int просто призводить до марної витрати пам’яті.
- Якщо short 16-бітний, а int і long — 64-бітні, то тип для представлення 32-бітних цілих зникне.
З цих причин у більшості сучасних 64-бітних машин int є 32-бітним. Однак існують системи з 64-бітним int — ILP64. Наприклад, старі суперкомп’ютери Cray використовували ILP64.
Крок 20: Оператор sizeof
sizeof
виглядає як функція, але граматично це унарний оператор. У мові C більшість операторів представлені символами, однак немає суворої вимоги, щоб оператори були саме символами, і sizeof
є винятком із цього правила.
Давайте коротко пригадаємо, як працює оператор sizeof
. Він повертає кількість байтів, яку займає тип виразу в пам’яті. Наприклад, згідно з нашим ABI, sizeof(x)
повертає 4, якщо x
має тип int
, і 8, якщо це вказівник.
У sizeof
можна передавати довільні вирази. Наприклад, sizeof(x+3)
поверне 4, якщо тип виразу x+3
— це int
, і 8, якщо результат — вказівник.
У нашому компіляторі масиви ще не реалізовані, але в мові C вираз sizeof(x)
повертає розмір у байтах всього масиву, якщо x
є масивом. Наприклад, якщо x
оголошено як int x[10]
, то sizeof(x)
поверне 40. Якщо x
оголошено як int x[5][10]
, то sizeof(x)
дорівнює 200, sizeof(x[0])
— 40, а sizeof(x[0][0])
— 4.
Аргумент оператора sizeof
використовується лише для визначення типу, і сам вираз фактично не виконується. Наприклад, якщо написати sizeof(x[3])
, доступ до x[3]
не відбудеться насправді. Тип x[3]
компілятор може визначити під час компіляції, тому вираз sizeof(x[3])
буде на цьому ж етапі замінений на відповідне значення розміру. Отже, конкретний вираз, переданий у sizeof
, узагалі не буде присутній у виконуваному коді.
Поведінку sizeof показано нижче.
int x;
int *y;
sizeof(x); // 4
sizeof(y); // 8
sizeof(x + 3); // 4
sizeof(y + 3); // 8
sizeof(*y); // 4
// Вираз, що передається до sizeof, може бути будь-яким
sizeof(1); // 4
// Результатом sizeof тепер є ціле число, тобто воно таке ж, як sizeof(int).
sizeof(sizeof(1)); // 4
Ну що ж, давайте реалізуємо оператор sizeof
. Для цього доведеться внести зміни як у токенайзер, так і в парсер.
Почнемо з токенайзера: потрібно додати розпізнавання ключового слова sizeof
і створити для нього окремий тип токена — TK_SIZEOF
.
Далі переходимо до парсера: тут потрібно реалізувати обробку оператора sizeof
як унарного виразу, і під час парсингу одразу ж замінювати його на цілочисельну константу типу int
. Це значення — розмір типу аргументу у байтах — визначається під час компіляції.
Нижче наведено оновлену граматику, що включає sizeof
як унарний оператор. У цій граматиці sizeof
має ту ж пріоритетність, що й унарні +
та -
, як і в стандарті C:
unary = "sizeof" unary
| ("+" | "-")? primary
Ця граматика дозволяє використовувати не лише sizeof(x)
, але й sizeof x
, і це те саме відбувається і в мові C.
Коли синтаксичний аналізатор зустрічає оператор sizeof
, він аналізує вираз, який є його аргументом, як завжди, і якщо тип, пов’язаний з результуючим синтаксичним деревом, є цілим числом, він замінює його числом 4, а якщо це вказівник, він замінює його числом 8. Немає потреби вносити жодних змін до дерева генерації коду, оскільки синтаксичний аналізатор замінює його константою.
Крок 21: Реалізація масивів
Визначення типу масиву
У цьому кроці ми реалізуємо підтримку масивів. До цього моменту компілятор працював лише з даними, які вміщуються в регістр — але тепер ми вперше стикаємося з даними, що мають більший розмір.
Однак граматика мови C обмежує використання масивів. Ви не можете передавати масив як аргумент функції або повертати масив як значення, що повертається функцією. Якщо ви пишете код з цією метою, вказівник на масив буде автоматично створено та передано, а не сам масив за значенням. Пряме присвоєння масиву іншому масиву для копіювання також не підтримується (ви повинні використовувати memcpy
).
Тому немає потреби передавати дані, які не поміщаються в регістри між функціями або змінними. Все, що вам потрібно, це можливість виділити область пам’яті, більшу за одне слово на стеку.
Переконайтеся, що ви можете читати визначення змінних, як-от наступне:
int a[10];
Тип a
у наведеному вище прикладі – це масив, довжина якого дорівнює 10, а тип елементів – int
. Як і у випадку з типами-вказівниками, тип масиву може бути як завгодно складним, тому, як і в кроці 7, ви можете використовувати ptr_to
, щоб вказати тип елементів масиву. Структура, що представляє тип, має виглядати так:
struct Type {
enum { INT, PTR, ARRAY } ty;
struct Type *ptr_to;
size_t array_size;
};
Поле array_size
має сенс лише тоді, коли тип є масивом (TY_ARRAY
). Воно зберігає кількість елементів у масиві.
Як тільки ви дійдете до цього моменту, має бути легко виділити місце для масиву в стеку. Щоб знайти розмір масиву в байтах, просто помножте розмір елементів масиву в байтах на кількість елементів у масиві. Досі місце в стеку виділялося для всіх змінних одним словом, але ми змінимо це так, щоб масиви виділяли розмір, необхідний для масиву.
Реалізація неявного перетворення типів з масиву у вказівник
Оскільки масиви і вказівники часто використовуються разом, у мові C синтаксично передбачено, що вказівники і масиви не потрібно строго розрізняти, і код ніби працює. Однак це має зворотній бік — програмістам стає важко зрозуміти, які саме відносини існують між масивами і вказівниками. Тому тут ми пояснимо, як пов’язані масиви та вказівники.
По-перше, у мові C масиви і вказівники є повністю різними типами.
Вказівник — це тип даних, який у (x86-64) займає 8 байт. Як і для типу int визначені оператори + та -, для вказівників також визначені оператори + та - (хоч і дещо інакше). Крім того, для вказівника визначений унарний оператор *, який дозволяє звернутися до того, на що вказує вказівник. Окрім унарного *, у вказівника немає якихось особливих властивостей. Можна сказати, що вказівник — це звичайний тип, подібний до int.
З іншого боку, масив — це тип, який може містити довільну кількість байтів. На відміну від вказівників, для масивів визначено дуже мало операторів. Серед них — оператор sizeof, який повертає розмір масиву, та оператор & (адреса), який повертає вказівник на перший елемент масиву. Інші оператори для масивів не визначені.
То чому ж можна скомпілювати вираз на кшталт a[3]
? У C вираз a[3]
визначено як рівнозначний *(a + 3)
. Але ж оператор + для масивів не визначений, чи не так? Ось тут вступає в дію граматичне правило неявного перетворення масиву у вказівник. Якщо масив використовується не як операнд операторів sizeof або унарного &, він неявно перетворюється у вказівник на свій перший елемент. Тому вираз *(a + 3)
означає: взяти вказівник на початок масиву a, додати 3, а потім розіменувати — фактично це доступ до четвертого елемента масиву.
У мові C оператор [] для доступу до елементів масиву як такого не існує. [] — це просто зручний синтаксис для доступу до елементів через вказівник. Так само, коли масив передається як аргумент функції, він неявно перетворюється у вказівник на свій перший елемент. Через це можна писати, наче вказівник і масив можна присвоювати один одному безпосередньо — все це пояснюється описаним правилом.
Отже, компілятору потрібно в більшості реалізацій операторів виконувати перетворення типу масиву у вказівник. Це не повинно бути надто складно реалізувати. Окрім випадків реалізації операторів sizeof та унарного &, при розборі операндів операторів, якщо тип операнду є масивом типу T, його слід розглядати як вказівник на T.
У генераторі коду значення масиву слід генерувати як код, що кладе адресу цього значення на стек. Після реалізації цього механізму повинні коректно працювати такі приклади коду.
int a[2];
*a = 1;
*(a + 1) = 2;
int *p;
p = a;
return *p + *(p + 1) // → 3
Колонка: Адвокат мови програмування
Особи, які добре розуміють формальні специфікації мови, іноді називають «адвокатами мови» (language lawyer), порівнюючи специфікації мови з юридичними законами. У сленговому словнику програмістів «Jargon File» адвокат мови описується так:
- Адвокат мови [іменник]: досвідчений або старший інженер програмного забезпечення, який добре знайомий (майже) зі всіма корисними та дивними особливостями однієї або кількох мов програмування і їх обмеженнями. Визначити, чи є людина адвокатом мови, можна за тим, чи може вона на питання відповісти, посилаючись на п’ять речень із 200-сторінкового посібника, кажучи «ось тут треба було подивитися».
Термін «language lawyer» іноді використовується як дієслово — «language lawyering» (буквально «адвокатствувати» в контексті мови програмування).
Досвідчені адвокати мови часто користуються повагою серед інших програмістів. Коли автор працював у команді C++ компілятора в Google, у команді був людина, яку можна було б назвати «ультимативним адвокатом мови». Коли виникали складні питання з C++, зазвичай вирішували звертатися саме до нього (навіть серед розробників компіляторів не всі аспекти C++ завжди зрозумілі). Ця людина була одним з основних розробників популярного C++ компілятора Clang, а також провідним автором стандарту C++, найкраще обізнаним у світі експертом з цієї мови. Проте навіть він казав, що «здається, розумієш C++, а потім раптом — ні», що свідчить про величезний обсяг і складність деталей мови.
У цій книзі навмисно не заглиблюються у тонкощі специфікації C до того, як компілятор стане достатньо завершеним. Це зроблено з причини того, що при реалізації мови зі специфікацією певною мірою потрібно стати адвокатом мови, але надмірна увага до дрібних деталей на початкових етапах розробки — не найкраща практика. Як при малюванні, спочатку не варто детально опрацьовувати одну ділянку, а краще зробити загальний чорновий ескіз. Так само при розробці мови програмування потрібно підтримувати баланс і не надто «адвокатствувати» на початку, поступово підвищуючи рівень деталізації.
Крок 22: Реалізація індексації масиву
У C вираз x[y]
визначений як рівнозначний *(x + y)
. Тому реалізувати індексацію досить просто: у парсері слід замінювати x[y]
на *(x + y)
. Наприклад, a[3]
перетвориться на *(a + 3)
.
За цією граматикою вираз 3[a]
розкладається у *(3 + a)
, отже, якщо працює a[3]
, то має працювати і 3[a]
. І дійсно, у C вирази типу 3[a]
є цілком легальними. Спробуйте перевірити це самі.
Крок 23: Реалізація глобальних змінних
Настав час додати можливість використовувати літеральні рядки у програмі. У C літеральний рядок — це масив типу char
. Масиви у нас уже реалізовані, але літеральні рядки відрізняються тим, що вони не розміщуються на стеку. Літеральні рядки зберігаються у фіксованому місці пам’яті, а не на стеку. Тому для підтримки рядкових літералів спочатку потрібно додати підтримку глобальних змінних.
Раніше у верхньому рівні (top-level) дозволялися лише оголошення функцій. Змініть граматику так, щоб у верхньому рівні можна було оголошувати також і глобальні змінні.
Оголошення змінних схожі на оголошення функцій, тому розбір буде дещо складнішим. Порівняйте, наприклад, ці 4 оголошення:
int *foo;
int foo[10];
int *foo() {}
int foo() {}
Перші два оголошення foo — це визначення змінних, а другі два — визначення функцій. Розрізнити їх неможливо до того моменту, поки не буде прочитано ідентифікатор (ім’я змінної або функції), а потім не подивитися наступний токен. Тому спочатку потрібно викликати функцію, яка зчитає першу частину імені типу, після чого очікується ідентифікатор, який теж зчитується. Потім виконується один крок перегляду вперед (lookahead) — якщо наступним токеном буде (
, це означає, що це визначення функції, інакше — визначення змінної.
Імена розпарсених глобальних змінних слід зберігати у мапу, щоб можна було їх швидко знаходити за іменем. Якщо при пошуку змінної ім’я не знайдено серед локальних змінних, слід шукати серед глобальних. Таким чином реалізується природна поведінка, коли локальна змінна з однаковим ім’ям приховує глобальну.
У парсері посилання на локальні та глобальні змінні трансформуються у різні вузли абстрактного синтаксичного дерева. Оскільки імена розрізняються вже на етапі парсингу, то тип вузла можна визначити відразу.
До цього моменту всі змінні знаходилися на стеку, тому читання і запис змінних виконувалися за відносними адресами від RBP
(базового вказівника). Глобальні змінні — це не значення на стеку, а фіксовані в пам’яті адреси, тому під час компіляції потрібно генерувати код безпосереднього звернення до цих адрес. Ознайомтеся з реальним виводом gcc для прикладу.
Після реалізації ви помітите, що локальні та глобальні змінні суттєво відрізняються за своєю природою. Те, що у коді вони виглядають подібно, — результат вдалої абстракції у C. Насправді, внутрішня реалізація локальних і глобальних змінних значно різниться.
Крок 24: Реалізація типу char
Масив — це тип, розмір якого може бути більшим за слово, а символ (char
) — тип, менший за слово. До цього моменту ви, напевно, писали функцію, що приймає об’єкт типу і повертає розмір цього типу в байтах. Спочатку додайте тип char
, а потім змініть цю функцію так, щоб для типу char
вона повертала 1.
У цьому кроці не потрібно реалізовувати літеральні символи (символи в одинарних лапках). Намагайтеся обмежити зміни лише цим додаванням, щоб не ускладнювати реалізацію.
Отже, тип char
— це просто маленьке ціле число. Для читання байта за адресою в RAX
можна використати команду: movsx ecx, BYTE PTR [rax]
. Ця команда зчитає 1 байт за адресою RAX
і розширить його зі знаком у регістр ECX
. Якщо розширення зі знаком не потрібне, використовуйте команду movzx ecx, BYTE PTR [rax]
. Для запису байта використовуйте 8-бітовий регістр, наприклад: mov [rax], cl
Ознайомтеся з виводом реального компілятора для прикладу.
Після реалізації цього кроку повинні працювати такі приклади коду:
char x[3];
x[0] = -1;
x[1] = 2;
int y;
y = 4;
return x[0] + y; // → 3
Колонка: Відмінності між 8-бітовими та 32-бітовими регістрами
Чому для зчитування 1-байтового значення потрібно використовувати movsx або movzx? При зчитуванні 4-байтового значення достатньо простої команди mov у 32-бітовий регістр, наприклад
EAX
. Здається, що для char можна було б просто завантажити значення уAL
(нижні 8 бітRAX
) через звичайний mov. Проте це працює некоректно. Відповідь криється у специфікації x86-64.У x86-64, при записі у 32-бітовий регістр (наприклад
EAX
), старші 32 біти 64-бітового регістру (RAX
) автоматично скидаються в 0. Натомість, при записі у 8-бітовий регістр (наприкладAL
), старші 56 бітRAX
залишаються незмінними. Це непослідовна особливість, що є наслідком довгої історії розвитку інструкційного набору.Архітектура x86-64 походить від 16-бітового процесора 8086, який спочатку мав 8-бітовий регістр
AL
, потім 32-бітовийEAX
, а пізніше 64-бітовийRAX
. Існуюча специфікація передбачає, що при завантаженні уAL
, верхні бітиEAX
не скидаються, що зберігає стару поведінку. Коли додалиRAX
, вирішили, що при завантаженні уEAX
верхні 32 бітиRAX
скидатимуться, щоб уникнути певних проблем.Чому зробили так? Сучасні процесори аналізують залежності між інструкціями і намагаються виконувати незалежні інструкції паралельно. Якби при завантаженні у 32-бітовий регістр не скидалися старші біти, то між інструкціями виникли б хибні залежності через непотрібні старші біти, що знижувало б продуктивність. Скидаючи старші 32 біти при записі у 32-бітові регістри, залежності розриваються і продуктивність підвищується. Таким чином, незважаючи на непослідовність, ця особливість покращує швидкодію процесорів.
Крок 25: Реалізація рядкових літералів
У цьому кроці ми реалізуємо парсинг і компіляцію рядків у подвійних лапках. Оскільки у вас вже є масиви, глобальні змінні та тип char
, це буде відносно просто.
Спочатку змініть токенайзер так, щоб при зустрічі символу подвійної лапки він читав усі символи аж до наступної подвійної лапки і створював токен рядка. Тут не потрібно реалізовувати escape-послідовності з бекслешем — залишайте це на пізніші кроки.
Дані рядкових літералів не можна безпосередньо вставляти в код, який виконується процесором. В асемблері потрібно розділяти глобальні дані (рядкові літерали) та код. Тому перед виведенням коду потрібно спершу вивести всі рядкові літерали.
Для цього доцільно зберігати всі рядкові літерали у векторі під час парсингу — просто додавати кожен новий рядок до цього списку. Потім, при генерації асемблера, вивести всі ці літерали в окремому сегменті даних.
Ознайомтеся з виводом реального компілятора для прикладу.
До цього моменту ви вже зможете виводити рядки за допомогою printf
. Це гарна нагода написати більш складну програму, ніж простий тестовий код. Наприклад, розв’язувач задачі 8 ферзів можна спробувати реалізувати власною мовою програмування.
Варто пам’ятати, що від моменту винаходу цифрових комп’ютерів минуло багато десятиліть, перш ніж з’явилися мови програмування, які дозволяють писати код так просто, як зараз. Те, що ви реалізуєте це за кілька тижнів — величезний прогрес людства і ваш особистий успіх.
(Для виклику функцій з довільною кількістю аргументів у x86-64 потрібно в регістр AL
передавати кількість аргументів з плаваючою точкою. У нашому компіляторі поки що немає підтримки плаваючих чисел, тому перед викликом функції AL
потрібно завжди встановлювати у 0.)
Крок 26: Зчитування вхідних даних із файлу
До цього моменту ми передавали код безпосередньо як рядок у аргументах, але з ростом обсягу коду настав час зробити компілятор схожим на звичайний C-компілятор — приймати ім’я файлу через командний рядок.
Функція, що відкриває файл, читає його вміст і повертає рядок, закінчений символом ‘\0’, може виглядати так:
#include <errno.h>
#include <stdio.h>
#include <string.h>
// Повертає вміст зазначеного файлу.
char *read_file(char *path) {
// Відкриття файлу
FILE *fp = fopen(path, "r");
if (!fp)
error("cannot open %s: %s", path, strerror(errno));
// Знаходження довжини файлу
if (fseek(fp, 0, SEEK_END) == -1)
error("%s: fseek: %s", path, strerror(errno));
size_t size = ftell(fp);
if (fseek(fp, 0, SEEK_SET) == -1)
error("%s: fseek: %s", path, strerror(errno));
// Зчитати вміст файлу
char *buf = calloc(1, size + 2);
fread(buf, size, 1, fp);
// Переконайтеся, що файли закінчуються на "\n\0"
if (size == 0 || buf[size - 1] != '\n')
buf[size++] = '\n';
buf[size] = '\0';
fclose(fp);
return buf;
}
З міркувань зручності реалізації компілятора обробляти всі рядки, що завершуються символом нового рядка, легше, ніж мати справу з даними, які завершуються або символом нового рядка, або EOF. Тому, якщо останній байт файлу не є \n
, ми вирішили автоматично додавати \n
.
Строго кажучи, ця функція не працює належним чином, якщо їй передано спеціальні файли, до яких не можна здійснювати випадковий доступ. Наприклад, якщо вказати файл пристрою /dev/stdin
, що відповідає стандартному вводу, або іменований канал як ім’я файлу, можна побачити повідомлення про помилку на кшталт /dev/stdin: fseek: Illegal seek
.
Втім, на практиці ця функція цілком придатна для використання. Змініть код так, щоб він використовував цю функцію для зчитування вмісту файлу і розглядав його як вхідні дані.
Оскільки вхідний файл зазвичай містить кілька рядків, варто також удосконалити функцію виведення повідомлень про помилки. Якщо під час обробки виникає помилка, будемо виводити ім’я вхідного файлу, номер рядка, де сталася помилка, та вміст цього рядка. Тоді повідомлення про помилку матиме такий вигляд:
foo.c:10: x = y + + 5;
^ Не формула
Функція, яка виводить таке повідомлення про помилку, виглядатиме наступним чином:
// Ім'я вхідного файлу
char *filename;
// Функція для повідомлення про помилку яка виникла
// Відобразіть повідомлення про помилку в такому форматі:
//
// foo.c:10: x = y + + 5;
// ^ Не формула
void error_at(char *loc, char *msg) {
// Отримати початкову та кінцеву точки лінії, що містить loc
char *line = loc;
while (user_input < line && line[-1] != '\n')
line--;
char *end = loc;
while (*end != '\n')
end++;
// Знайдіть, в якому рядку з усього списку знаходиться знайдений рядок
int line_num = 1;
for (char *p = user_input; p < line; p++)
if (*p == '\n')
line_num++;
// Показати знайдені рядки разом з назвою файлу та номером рядка
int indent = fprintf(stderr, "%s:%d: ", filename, line_num);
fprintf(stderr, "%.*s\n", (int)(end - line), line);
// Відобразити повідомлення про помилку, позначивши місце помилки символом "^"
int pos = loc - line + indent;
fprintf(stderr, "%*s", pos, ""); // Вихідні пробіли у кількості pos
fprintf(stderr, "^ %s\n", msg);
exit(1);
}
Ця процедура виведення повідомлень про помилки має досить просту реалізацію, проте, незважаючи на це, можна сказати, що вона виводить повідомлення у досить професійному форматі.
Колонка: Відновлення після помилок
Якщо вхідний код містить синтаксичні помилки, багато компіляторів намагаються пропустити проблемну ділянку та продовжити розбір далі. Метою цього є виявлення якомога більшої кількості помилок, а не зупинка на першій-ліпшій. Механізм, який дозволяє парсеру відновитися після помилки та продовжити роботу, називається відновленням після помилок (error recovery).
У старих компіляторах відновлення після помилок було надзвичайно важливою функцією. У 1960–1970-х роках програмісти користувалися великими обчислювальними машинами у центрах обробки даних із розподілом часу. Іноді доводилося чекати результатів компіляції протягом цілого вечора після подачі коду. У таких умовах компілятор повинен був виявити якомога більше помилок за один запуск. У підручниках з компіляторобудування того часу відновлення після помилок розглядалося як одна з основних тем у синтаксичному аналізі.
Нині ж розробка із використанням компіляторів стала набагато більш інтерактивною, тому відновлення після помилок уже не вважається настільки критичним. У компіляторі, який ми розробляємо, ми лише виводимо перше повідомлення про помилку. У сучасних умовах цього, як правило, цілком достатньо.
Крок 27: Рядкові та блокові коментарі
Наш компілятор поступово еволюціонує, і ми вже можемо писати доволі серйозний код. На цьому етапі виникає природне бажання — мати підтримку коментарів. У цьому розділі ми реалізуємо підтримку коментарів.
У мові C існує два типи коментарів. Один із них — рядковий коментар, який починається з //
і триває до кінця рядка. Інший — блоковий коментар, який починається з /*
і завершується */
. Усі символи всередині блокового коментаря ігноруються, за винятком послідовності */
, яка означає завершення коментаря.
З погляду синтаксису, коментарі розглядаються як один пробіл. Тому природно реалізувати їх так, щоб токенізатор (лексичний аналізатор) просто пропускав коментарі, як і пробільні символи. Нижче наведено код для пропуску коментарів.
void tokenize() {
char *p = user_input;
while (*p) {
// Пропускати пробіли
if (isspace(*p)) {
p++;
continue;
}
// Пропустити коментарі рядка
if (strncmp(p, "//", 2) == 0) {
p += 2;
while (*p != '\n')
p++;
continue;
}
// Пропустити блок коментарів
if (strncmp(p, "/*", 2) == 0) {
char *q = strstr(p + 2, "*/");
if (!q)
error_at(p, "Коментар не закритий");
p = q + 2;
continue;
}
...
У цьому випадку для пошуку кінця блокового коментаря ми використали функцію strstr
, яка входить до стандартної бібліотеки C. strstr
здійснює пошук одного рядка в іншому: якщо переданий рядок (підрядок) знайдено, функція повертає вказівник на його початок у межах вихідного рядка; якщо ж підрядок не знайдено — повертається NULL
.
Колонка: Рядкові коментарі
В оригінальній мові C існували лише блокові коментарі. Рядкові коментарі були офіційно додані до специфікації лише у 1999 році — майже через 30 років після створення мови C. Початково вважалося, що це зміна, яка не порушує зворотну сумісність. Однак на практиці з’ясувалося, що в деяких тонких випадках код, який раніше працював, може змінити своє трактування.
Конкретно, розглянемо наступний код:
a//b
. Якщо підтримуються лише блокові коментарі, цей код розбирається якa / b
. А якщо підтримуються рядкові коментарі, тоді все після//
ігнорується, і залишається лише a.a//* // */ b
Колонка: Блокові коментарі та вкладення
Коментарі блоків не можуть бути вкладеними.
/*
не має спеціального значення всередині коментаря, тому ви можете просто закоментувати існуючий коментар блоку за допомогою/* /* ... */ */
У цьому випадку перший
*/
завершить коментар, а другий*/
спричинить синтаксичну помилку.Якщо ви хочете закоментувати всі рядки, які можуть містити блочні коментарі, ви можете скористатися препроцесором C:
#if 0 ... #endif
Один із способів зробити це — взяти коментар в
#if 0
як вказано вище.
Крок 28: Переписування тестів на C
У цьому кроці ми перепишемо тести на мові C, щоб прискорити виконання make test
. До цього моменту у вас, імовірно, вже накопичилося понад 100 тестів, написаних у вигляді shell-скриптів. Кожен з таких тестів запускає кілька процесів: власноруч написаний компілятор, асемблер, лінкер, а також сам тест як виконуваний файл.
Запуск процесів — навіть для невеликих програм — не є миттєвим. А коли це відбувається сотні разів, сумарний час стає суттєвим. Імовірно, ваші скрипти вже займають кілька секунд на виконання, і з часом ця затримка тільки зростатиме.
На ранніх етапах ми використовували shell-скрипти для тестування, тому що мова, яку ми розробляли, була занадто обмежена. В ній не було конструкцій на кшталт if
чи ==
, тож перевірити правильність результату «всередині» самої мови було неможливо. Однак тепер ми маємо достатньо функціоналу для цього.
Тепер ми можемо порівнювати значення результатів прямо в програмі та, якщо результат неправильний, виводити повідомлення про помилку (у вигляді рядка) й завершувати виконання через exit
.
Отже, у цьому кроці перепишіть усі тести, які були реалізовані у вигляді shell-скриптів, як C-програми.
Образ виконання програми та ініціалізатори змінних
До цього моменту наш компілятор уже підтримує основні конструкції програмування: функції, глобальні змінні та локальні змінні.
Крім того, ви вже дізналися про роздільну компіляцію та компонування (лінкування), тож маєте уявлення про те, як розбити програму на частини, компілювати їх окремо й об’єднувати в один виконуваний файл.
У цьому розділі ми розглянемо, як операційна система виконує бінарні файли.
Прочитавши цей розділ, ви зрозумієте, яка саме інформація міститься у виконуваному файлі, та що відбувається з моменту запуску програми до виклику функції main
.
Також ми розглянемо, як компілюються ініціалізатори змінних — тобто вирази на кшталт:
int x = 3;
int y[3] = {1, 2, 3};
char *msg1 = "foo";
char msg2[] = "bar";
Це може бути дивно, але знання того, як працює програма до того, як вона досягне main
, є важливим для підтримки виразів ініціалізації.
У цьому розділі ми пояснимо простий формат виконуваного файлу, в якому весь код і дані містяться разом в одному виконуваному файлі. Такий виконуваний файл називається “статично зв’язаним” виконуваним файлом. На відміну від статичного зв’язування, поширеним форматом виконуваного файлу є “динамічне зв’язування”, в якому фрагменти однієї програми розділяються на кілька файлів, які об’єднуються в пам’яті під час виконання та виконуються, але ми пояснимо це в окремому розділі. Спочатку давайте чітко розберемося зі статичним зв’язуванням, яке є базовою моделлю.
Структура виконуваного файлу
Виконуваний файл складається з заголовка файлу та однієї або кількох областей, які називаються «сегментами» (segment). Зазвичай один виконуваний файл містить щонайменше два сегменти, що містять окремо виконуваний код і дані. Сегмент, що містить виконуваний код, називається «текстовим сегментом» (text segment), а сегмент, що містить дані, — «сегментом даних» (data segment). Насправді виконуваний файл може містити й інші сегменти, але для розуміння принципу їх можна опустити.
Щодо термінології: слово «текст» тут збігається зі словом «текст» у текстовому файлі, але значення має інше, тому слід бути уважним. Традиційно на нижчому рівні дані, що представляють машинний код, називають «текстом». Оскільки машинний код — це лише послідовність байтів, текст також є різновидом даних. Однак, коли говорять «текст і дані», то під «даними» зазвичай мають на увазі саме «дані, відмінні від тексту». У цьому розділі термін «дані» також означає все, крім тексту.
У об’єктному файлі, який є вхідним для лінкера, текст і дані зберігаються окремо. Лінкер об’єднує текст з кількох об’єктних файлів в один текстовий сегмент, а також дані з кількох об’єктних файлів — в один сегмент даних.
У заголовку виконуваного файлу для кожного сегмента зазначено, за якою адресою в пам’яті його слід розмістити під час виконання. Коли виконуваний файл запускається, програма ОС під назвою «завантажувач програм» (program loader) або просто «завантажувач» (loader) копіює текст і дані з виконуваного файлу до пам’яті згідно з цією інформацією.
Нижче показано схему виконуваного файлу та стану, в якому завантажувач завантажив цей файл у пам’ять.
У цій схемі виконуваного файлу припускається, що в заголовку файлу міститься інформація про те, що текстовий сегмент потрібно завантажити за адресою 0x41000, а сегмент даних — за адресою 0x50000.
У заголовку файлу також міститься інформація про адресу, з якої слід починати виконання. Наприклад, якщо вказано, що виконання має починатися з адреси 0x41040, то завантажувач після завантаження виконуваного файлу у пам’ять, як показано на схемі вище, встановлює вказівник стека (stack pointer) на адресу 0x7fff_ffff_ffff_ffff, а потім переходить за адресою 0x41040, і таким чином починається виконання програми користувача.
Зміст сегмента даних
Зміст текстового сегмента очевидно є машинним кодом, але що ж міститься в сегменті даних? Відповідь: у сегменті даних зберігаються глобальні змінні, літеральні рядки та інші подібні дані.
Локальні змінні безпосередньо не містяться ні в текстовому, ні в сегменті даних. Локальні змінні створюються динамічно під час виконання програми в стековій області пам’яті, тому одразу після завантаження виконуваного файлу в пам’ять їх ще немає.
У моделі виконання C програма практично просто завантажується у пам’ять у вигляді виконуваного файлу, після чого можна відразу починати виконання функції main. Тому глобальні змінні мають бути попередньо ініціалізовані правильними значеннями шляхом копіювання з сегмента даних виконуваного файлу в пам’ять.
Через це обмеження в C не можна використовувати у ініціалізаторах глобальних змінних, наприклад, виклики функцій, як наведено нижче.
int foo = bar();
Якщо існує глобальна змінна, що потребує такої динамічної ініціалізації, як описано вище, то цю ініціалізацію потрібно виконати до запуску функції main. Однак у C немає механізму ініціалізації, який виконується раніше за main, тому така ініціалізація неможлива.
Іншими словами, глобальні змінні повинні мати значення, які формуються під час лінкування і записуються у виконуваний файл у вигляді готового байтового набору. До таких значень належать лише:
- Константні вирази
- Адреси глобальних змінних або функцій
- Адреси глобальних змінних або функцій з доданим константним зсувом
Очевидно, що константні вирази, такі як числові літерали або рядкові літерали, можуть бути безпосередньо задані як фіксовані значення в текстовому сегменті.
Адреси глобальних змінних або функцій зазвичай не визначаються під час компіляції, але лінкер визначає їх під час створення виконуваного файлу. Тому ініціалізація глобального вказівника іншою глобальною змінною, наприклад int *x = &y;
, є допустимою. Лінкер самостійно вирішує розташування сегментів програми, знає адреси функцій і змінних, і тому може підставити значення x
під час лінкування.
Також лінкер підтримує операції додавання констант до адрес, тому ініціалізація на кшталт int *x = &y + 3;
теж є законною.
Однак, ініціалізатори, що виходять за межі цих патернів, не допускаються. Наприклад, не можна використовувати у ініціалізаторах значення глобальних змінних (а не їхні адреси). Вирази на кшталт ptrdiff_t x = &y - &z;
, які обчислюють різницю адрес двох глобальних змінних, теоретично можуть бути обчислені під час лінкування, але на практиці лінкер не підтримує таку операцію, тому такі вирази не можна використовувати в ініціалізаторах глобальних змінних. Ініціалізація глобальних змінних обмежена лише описаними вище випадками.
Приклад виразу, який можна записати як вираз ініціалізації для глобальної змінної, вираженої мовою C, виглядає наступним чином:
int a = 3;
char b[] = "foobar";
int *c = &a;
char *d = b + 3;
Збірки, що відповідають кожному виразу, такі:
a:
.long 3
b:
.byte 0x66 // 'f'
.byte 0x6f // 'o'
.byte 0x6f // 'o'
.byte 0x62 // 'b'
.byte 0x61 // 'a'
.byte 0x72 // 'r'
.byte 0 // '\0'
c:
.quad a
d:
.quad b + 3
Послідовні .bytes
також можна записувати, використовуючи нотацію .ascii
, ось так: .ascii "foobar\0".
Колонка: Динамічна ініціалізація глобальних змінних
У C значення глобальних змінних повинні бути визначені статично, тоді як у C++ можна ініціалізувати глобальні змінні будь-якими виразами. Тобто у C++ ініціалізація глобальних змінних виконується до виклику функції main. Це працює за таким механізмом:
- C++ компілятор збирає ініціалізатори глобальних змінних у функції та виводить вказівники на ці функції у спеціальний розділ під назвою
.init_array
.- Лінкер об’єднує
.init_array
розділи з кількох вхідних файлів у єдиний сегмент.init_array
, який містить масив вказівників на функції.- Завантажувач перед передачею управління у main виконує послідовно всі функції, адреси яких знаходяться у сегменті
.init_array
.Таким чином, завдяки співпраці компілятора, лінкера та завантажувача, у C++ реалізовано динамічну ініціалізацію глобальних змінних.
Якби C використовував аналогічний механізм, він також міг би підтримувати динамічну ініціалізацію глобальних змінних, проте ця можливість свідомо виключена із специфікації мови C.
Вибір дизайну мови C накладає певні обмеження на програміста, але дає переваги у тому, що програма може працювати навіть у середовищах з примітивним або відсутнім завантажувачем (наприклад, код, що виконується безпосередньо з ROM при запуску комп’ютера). Це питання компромісу, і не можна сказати, що один підхід кращий за інший.
Граматика ініціалізаторів
Ініціалізатори на перший погляд схожі на прості оператори присвоєння, але граматично ініціалізатори суттєво відрізняються від операторів присвоєння. Існують особливі способи запису, дозволені тільки в ініціалізаторах. Давайте детально розглянемо ці особливості.
По-перше, в ініціалізаторах можна задавати значення для масивів. Наприклад, наступний ініціалізатор задає значення елементам масиву x
так, щоб x[0] = 0
, x[1] = 1
, x[2] = 2
.
int x[3] = {0, 1, 2};
Якщо ініціалізатор заданий, довжина масиву може бути визначена за кількістю елементів у правій частині, тому її можна опустити. Наприклад, наведений вище вираз і наступний мають однакове значення.
int x[] = {0, 1, 2};
Якщо довжина масиву задана явно, а ініціалізатор містить лише частину елементів, то решта елементів має бути ініціалізована нулями. Тому наступні два вирази мають однаковий зміст.
int x[5] = {1, 2, 3, 0, 0};
int x[5] = {1, 2, 3};
Крім того, для ініціалізації масивів типу char
існує спеціальна граматика: дозволяється використовувати рядковий літерал як ініціалізатор, як у наступному прикладі.
char msg[] = "foo";
Наведений вище запис має те саме значення, що й наступний вираз.
char msg[4] = {'f', 'o', 'o', '\0'};
Ініціалізатори глобальних змінних
Ініціалізатори глобальних змінних повинні бути обчислені під час компіляції. Результат обчислення може бути або простою послідовністю байтів, або вказівником на функцію чи глобальну змінну. У випадку вказівника може міститися також ціле число, що представляє зсув (офсет) від цієї адреси.
Глобальні змінні, для яких ініціалізатор не заданий, повинні бути ініціалізовані таким чином, щоб усі біти мали значення 0. Це визначено граматикою мови C.
Якщо ініціалізатор не відповідає вказаним вище вимогам, компілятор повинен видати помилку.
Ініціалізатори локальних змінних
Ініціалізатори локальних змінних зовні схожі на ініціалізатори глобальних, але їх значення суттєво відрізняється. Ініціалізатори локальних змінних — це вирази, які виконуються безпосередньо під час виконання програми, тому їх значення не обов’язково має бути відомим на момент компіляції.
Зазвичай запис на кшталт int x = 5;
компілюється так, ніби це два окремі рядки: int x;
x = 5;
Аналогічно, вираз int x[] = {1, 2, foo()};
компілюється як послідовність операцій, еквівалентних наступному коду:
int x[3];
x[0] = 1;
x[1] = 2;
x[2] = foo();
Якщо локальній змінній зовсім не задано ініціалізатор, її вміст є невизначеним. Тому такі змінні не потребують обов’язкової ініціалізації.
Колонка: Розмір слова (word)
У архітектурі x86-64 термін «слово» (word) може означати як 16-бітні, так і 64-бітні дані. Це може здаватися заплутаним, але така ситуація виникла через історичні причини.
Спочатку термін «слово» позначав найбільший розмір цілочисельного значення або адреси, який комп’ютер міг природно обробляти. Оскільки x86-64 є 64-бітним процесором, там слово означає 64 біти.
Водночас, 16-бітне слово походить від архітектури 16-бітного процесора 8086. Коли інженери Intel розширили 8086 до 32-бітної архітектури з процесором 386, щоб уникнути плутанини через зміну розміру слова, вони ввели термін «подвійне слово» (double word або dword) для позначення 32 бітів. Аналогічно, при розширенні 386 до 64-бітної архітектури x86-64, 64 біти почали називати «чотири слова» (quad word або qword).
Таким чином, через необхідність зберегти сумісність виникло два різних значення терміна «слово».
Крок 29 і далі: [Потрібне доповнення]
Статичне та динамічне зв’язування
До цього моменту в книзі розглядався лише механізм статичного зв’язування. Статичне зв’язування — це проста модель виконання, яка дозволяє легко пояснити асемблерний код і образ пам’яті виконуваного файлу. Однак на практиці при створенні звичайних виконуваних файлів статичне зв’язування використовується не так часто. Зазвичай застосовується динамічне зв’язування.
У цьому розділі ми розглянемо і статичне, і динамічне зв’язування.
За замовчуванням компілятор і лінкер намагаються створити виконувані файли з динамічним зв’язуванням. Можливо, ви вже стикалися з помилками, які виникають, якщо не додати опцію -static
до команди cc
(якщо ні — спробуйте видалити -static
з Makefile і запустити make
).
$ cc -o tmp tmp.s
/usr/bin/ld: /tmp/ccaRuuub.o: relocation R_X86_64_32S against `.data' can not be used when making a PIE object; recompile with -fPIC
/usr/bin/ld: final link failed: Nonrepresentable section on output
За замовчуванням лінкер намагається виконати динамічне зв’язування, але для цього компілятор повинен згенерувати відповідний асемблерний код, який підтримує динамічне зв’язування. Наразі 9cc не генерує такого коду, тому якщо забути додати опцію -static
, з’явиться наведена вище помилка. Після прочитання цього розділу ви зрозумієте, що означає ця помилка і що потрібно зробити для її усунення.
Статичне зв’язування
Статично зв’язані виконувані файли є самодостатніми й не потребують додаткових файлів під час виконання. Наприклад, функція printf
не є функцією, написаною користувачем, а належить до стандартної бібліотеки libc
. При створенні виконуваного файлу зі статичним зв’язуванням код printf
копіюється з libc
безпосередньо у виконуваний файл. Тому під час запуску такого виконуваного файлу бібліотека libc
не потрібна — весь необхідний код і дані вже включені до файлу.
Тепер давайте подивимося, як простенька програма hello.c
перетворюється на статично зв’язаний виконуваний файл.
#include <stdio.h>
int main() {
printf("Hello world!\n");
}
Для компіляції та лінкування цього файлу hello.c
в виконуваний файл з ім’ям hello
потрібно виконати таку команду:
$ cc -c hello.c
$ cc -o hello hello.o
У наведеній вище команді на першому рядку відбувається компіляція hello.c
у об’єктний файл hello.o
, а на другому — лінкування цього файлу для створення виконуваного файлу. Ці дві команди можна об’єднати в одну команду cc -o hello hello.c
, але навіть у такому випадку всередині компілятора відбуваються ті ж самі дії, що й у двокроковому процесі.
У hello.c
підключено заголовочний файл stdio.h
, але, як ми бачили раніше, у заголовкових файлах немає коду функцій, лише їх оголошення. Тож під час створення hello.o
компілятор знає про існування функції printf
та її тип, але не має інформації про її реальний код. Через це код printf
не входить до hello.o
. Насправді у hello.o
міститься тільки визначення main
. Об’єднання hello.o
і об’єктних файлів із кодом printf
відбувається на етапі лінкування.
Під час запуску лінкера через команду cc
другий рядок передає не лише файл hello.o
, а й системну стандартну бібліотеку /usr/lib/x86_64-linux-gnu/libc.a
. Функція printf
знаходиться у цій бібліотеці libc.a
. .a
— це архівний файл, подібний до .tar
чи .zip
. Давайте трохи заглянемо всередину цього архіву.
$ ar t /usr/lib/x86_64-linux-gnu/libc.a
...
printf_size.o
fprintf.o
printf.o
snprintf.o
sprintf.o
...
Архівний файл містить:
Модель виконання статично зв’язаного виконуваного файлу є простою. Під час виконання в пам’яті існує лише цей виконуваний файл, тому кожен сегмент виконуваного файлу може бути завантажений у будь-яке місце пам’яті. Завантаження за стандартними адресами, визначеними під час лінкування, не призведе до помилки, оскільки до завантаження виконуваного файлу пам’ять порожня. Таким чином, при статичному лінкуванні всі адреси глобальних змінних і функцій можуть бути визначені під час лінкування.
Переваги статичного лінкування:
- Простота та швидкість завантаження
- Відсутність залежностей від інших файлів, тому виконуваний файл можна просто скопіювати і він буде працювати
- Навіть якщо існують різні версії бібліотек з незначними відмінностями, під час статичного лінкування код і дані бібліотеки фіксуються, тому однаковий виконуваний файл працюватиме однаково в будь-якому середовищі
Недоліки статичного лінкування:
- Код і функції бібліотек копіюються у виконуваний файл, що може призводити до зайвого використання дискового простору і пам’яті
- Якщо у бібліотеках виправляються помилки, щоб застосувати ці зміни до існуючих виконуваних файлів, потрібно виконати повторне лінкування
З іншого боку, динамічно лінковані виконувані файли потребують під час виконання інших файлів з розширенням .so (в Unix) або .dll (в Windows). Файли .so або .dll містять код функцій, таких як printf, а також глобальні змінні на кшталт errno. Такі файли називаються динамічними бібліотеками, просто бібліотеками або DSO (dynamic shared object).
Синтаксис типів у C
Синтаксис типів у C відомий своєю необґрунтованою складністю. У книзі «Мова програмування C» (популярно відомій як «K&R»), співавтором якої є розробник C Деніс Рітчі, зазначено: «Синтаксис оголошень у C, особливо оголошень, що стосуються вказівників на функції, часто піддається критиці».
Таким чином, синтаксис типів у C є, по суті, погано спроектованим, про що навіть автори натякають, але, незважаючи на це, цей синтаксис не є надто складним, якщо зрозуміти його правила.
У цьому розділі буде пояснено, як читати синтаксис типів у C. Крок за кроком, заглиблюючись у матеріал, до кінця цього розділу читачі зможуть розшифровувати складні типи, такі як void (*x)(int)
або void (*signal(int, void (*)(int)))(int)
.
Діаграма, що ілюструє типи
Типи, які можна виразити у C, самі по собі досить прості. Щоб розділити складність синтаксису типів і складність самих типів, спочатку відкладемо синтаксис убік і зосередимось лише на типах.
Складні типи, такі як вказівники або масиви, можна представити у вигляді діаграми, де прості типи з’єднані стрілками. Наприклад, наступна діаграма ілюструє тип, який є «вказівником на вказівник на int».
У українській мові читання відбувається уздовж напрямку стрілки: «вказівник на вказівник на int». В англійській це робиться так само — читають уздовж напрямку стрілки як «a pointer to a pointer to an int».
Припустимо, що змінна x має тип, показаний на попередній діаграмі. Найкоротша відповідь на питання «Який тип у x?» буде «вказівник». Адже тип, на який вказує перша стрілка, — це тип вказівника. Важливо зрозуміти, що x спочатку є вказівником, а не типом int чи іншим. Якщо ж запитати «Який тип вказує цей вказівник?» — відповідь знову буде «вказівник», оскільки друга стрілка також веде на тип вказівника. І нарешті, якщо знову запитати «Який тип вказує цей вказівник?» — відповідь буде «int».
Наступна діаграма ілюструє «масив з 20 елементів, кожен з яких є вказівником на int». У реальних компіляторах довжина масиву подається як член типу, що описує масив, як показано на цій діаграмі.
Якщо змінна x має тип, показаний на попередній діаграмі, то x є масивом довжиною 20, елементи якого — це вказівники, які вказують на int.
Типи функцій також можна представити у вигляді діаграм. Наступна діаграма ілюструє тип функції, яка приймає два аргументи: int та вказівник на int, і повертає вказівник на void.
Наостанок наведемо більш складний приклад. Наступна діаграма ілюструє тип: вказівник на функцію, яка приймає int як аргумент і повертає int, а ця функція сама повертає вказівник на таку функцію.
Хоча опис словами здається складним, на діаграмі це просто довга, але структурно проста конструкція.
Якщо змінна x має тип, показаний на попередній діаграмі, то x є типом вказівника, який вказує на функцію. Ця функція приймає аргумент типу int і повертає тип вказівника, який, у свою чергу, вказує на функцію, що повертає int.
Всередині компілятора типи представляються саме так, як на цих діаграмах. Тобто складні типи, що включають вказівники, масиви та функції, у компіляторі реалізовані як структура простих типів, пов’язаних вказівниками у визначеному порядку. Тому можна сказати, що ця діаграма є справжнім відображенням типу.
Нотація для представлення типів
Як було показано вище, діаграми роблять розуміння типів набагато простішим, але малювати діаграму щоразу, щоб зрозуміти тип, досить незручно. У цьому розділі розглянемо нотацію, яка дозволяє компактніше записувати типи, не втрачаючи зручності розуміння.
Якщо тип не містить функцій, то на діаграмі всі блоки розташовуються послідовно, без гілкувань. Отже, для типів, що містять лише вказівники або масиви, можна просто записати послідовно назви типів зліва направо, що відповідатиме діаграмі у текстовому вигляді.
Розглянемо конкретні правила запису. Блок, що позначає вказівник, позначаємо символом *
. Блок, що позначає масив довжини n, позначаємо як [n]
. Вбудовані типи, такі як int, записуємо без змін їхніми назвами. Таким чином, наведена раніше діаграма може бути записана рядком **int
.
Оскільки на діаграмі по порядку від початку стрілки йдуть: вказівник, ще один вказівник і int, запис у вигляді **int
відповідає цій структурі. Навпаки, якщо задано запис **int
, можна побудувати відповідну діаграму. Тобто цей текстовий запис є компактною нотацією, яка несе ту ж інформацію, що й діаграма.
Наступна діаграма може бути записана як [20]*int
.
Для функцій приймемо запис у форматі: func(типи_аргументів, ...) тип_повернення
. Наприклад, тип, який зображено на наступній діаграмі, можна записати як: func(int, *int) *void
Закликаємо читачів перевірити, що ця нотація відповідає наведеній діаграмі.
Наостанок, тип, зображений на наступній діаграмі, записується як *func(int) *func() int
.
Нотація, яку ми розглянули до цього моменту, ймовірно, є найпрямішим і найпростішим способом текстового представлення типів. Насправді, синтаксис типів у мові програмування Go повністю відповідає цій нотації.
Go — це мова, до розробки якої долучилися ті самі люди, що створювали C. У Go враховано уроки, винесені з C, і синтаксис типів був непомітно, але суттєво вдосконалений.
Як читати типи в C
У цьому розділі ми розглянемо, як зіставити вивчену нами нотацію типів із синтаксисом типів у C, щоб навчитися правильно читати типи в C.
Тип у C можна розділити на наступні чотири частини, якщо читати з початку:
- Базовий тип
- Зірочка (астеріск), що позначає вказівник
- Ідентифікатор або вкладений тип, взятий у дужки
- Дужки, що позначають функцію або масив
Наприклад, int x
має базовий тип int
, без зірочки, що вказує на вказівник, ідентифікатор x
та без дужок, що вказують на функцію або масив. unsigned int *x()
має базовий тип unsigned int
, зірочкою, що вказує на вказівник, є *
, ідентифікатором x
, а дужками, що вказують на функцію, є ()
. void **(*x)()
має базовий тип void
, зірочкою, що вказує на вказівник, є **
, вкладений тип *x
, а дужками, що вказують на функцію, є ()
.
Як читати невкладені типи
Коли все, що йде за вказівником, є ідентифікатором, розшифрувати тип відносно легко.
Без дужок функцій або масивів, позначення типу та його значення такі. Ми запишемо значення в тому ж позначенні, що й Go, поясненому вище.
Нотація типу C | Значення |
---|---|
int x |
int |
int *x |
* int |
int **x |
* * int |
Коли у вас є дужки функції, базовий тип та зірочка, що вказує на вказівник, представляють тип повернення, наприклад:
Нотація типу C | Значення |
---|---|
int x() |
func() int |
int *x() |
func() * int |
int **x(int) |
func(int) * * int |
Ви можете бачити, що тип функції при розборі без дужок, тобто типи int x
, int *x
та int **x
, просто наступні після func(...)
.
Аналогічно, якщо є дужки для масиву, це означає “масив з”. Ось приклад:
Нотація типу C | Значення |
---|---|
int x[5] |
[5] int |
int *x[5] |
[5] * int |
int **x[4][5] |
[4] [5] * * int |
Наприклад, якщо у вас є тип int *x[5]
, тип x — це масив довжини 5, елементи цього масиву — вказівники, а вказівники вказують на цілі числа. Як і у випадку з функціями, ви можете бачити, що тип масиву без квадратних дужок — це конструкція, яка йде після [...]
.
Читання вкладених типів
Якщо після покажчика йде не ідентифікатор, а дужки, то ці дужки позначають вкладений тип. У разі наявності вкладеного типу, тип всередині дужок і тип зовні дужок розбираються окремо, а потім комбінуються, щоб отримати загальний тип.
Як приклад, розглянемо розбір оголошення int (*x)()
. int (*x)()
інтерпретується так, що якщо розглядати всю першу дужку як один ідентифікатор (назвемо його, наприклад, y
), то це буде виглядати як int y()
. Отже, зовнішній тип — це func() int
(функція, яка повертає int). З іншого боку, внутрішній тип *x
у дужках позначає тип * ___
. У дужках немає базового типу, наприклад int, тому тип неповний, але тут пропущена базова частина позначена як ___
.
Розбираючи тип int (*x)()
окремо зсередини і ззовні дужок, ми отримуємо два типи: func() int
і * ___
. Загальний тип формується шляхом підстановки зовнішнього типу у пропущену частину внутрішнього типу. В цьому випадку виходить тип * func() int
. Тобто в оголошенні int (*x)()
x
— це покажчик, який вказує на функцію, що повертає int.
Наведемо ще один приклад. void (*x[20])(int)
— якщо розглядати першу дужку як ідентифікатор, це буде виглядати як void y(int)
, що відповідає типу зовні дужок func(int) void
. Внутрішній тип *x[20]
позначає [20] * ___
. Комбінуючи обидва, отримуємо тип [20] * func(int) void
. Тобто x
— це масив довжиною 20, елементи якого — покажчики на функції, що приймають int і повертають void.
Застосовуючи описаний підхід, можна прочитати будь-який складний тип. Як крайній приклад, розглянемо тип функції signal
в Unix, яка відома своїм складним і незрозумілим на перший погляд типом. Нижче наведено оголошення функції signal
.
void (*signal(int, void (*)(int)))(int);
Навіть такі складні типи можна розібрати, поділивши їх на частини. Спочатку розглянемо тип всередині дужок і зовні дужок окремо. Якщо вважати першу дужку ідентифікатором, тип буде відповідати шаблону void y(int)
, отже зовнішній тип — func(int) void
.
Всередині дужок маємо тип *signal(int, void (*)(int))
. Це тип без базового типу, зірочка *
позначає покажчик, signal
— ідентифікатор, а далі одна пара дужок, в якій описано два аргументи. Загальний тип можна записати як func(аргумент1, аргумент2) * ___
. Розглянемо типи аргументів.
Перший аргумент — int
. Його тип просто int
. Другий аргумент — void (*)(int)
. Це тип з базовим типом void
, вкладеним типом в дужках — *
, і однією парою дужок функції. Якщо вважати дужку ідентифікатором, тип зовні дужок буде func(int) void
. Внутрішній тип *
позначає покажчик, отже тип другого аргументу — * func(int) void
.
Підставляючи типи аргументів у функцію, отримуємо: func(int, * func(int) void) * ___
. Поєднуючи цей тип з зовнішнім, отримуємо остаточний тип: func(int, * func(int) void) * func(int) void
.
Отже, signal
— це функція, яка приймає два аргументи:
int
,- покажчик на функцію, що приймає
int
і повертаєvoid
.
Тип повернення функції signal
– це вказівник на функцію, яка приймає int
та повертає значення void
.
Колонка: Ідея синтаксису типів у C
Синтаксис типів у C був розроблений з думкою: «Якщо писати тип так, як його використовують, буде зрозуміліше». За цією концепцією, наприклад, оголошення
int *x[20]
означає, що типx
вибирається так, щоб вираз*x[20]
мав типint
. Іншими словами, це схоже на розв’язання задачі: який тип маєx
, щоб у виразіint foo = *x[20]
типи збігалися?Розглянемо оголошення
int *(*x[20])()
. Якщо розглядати його як завдання визначити типx
так, щоб виразint foo = *(*x[20])()
не викликав помилку типу, можна зробити такі висновки:
x
має бути масивом (довжиною 20).- Елементи масиву — такі, що їх можна розіменувати (
*
), тобто покажчики.- Ці покажчики використовуються для виклику функції — отже, вказують на функції.
- Результат виклику функції — покажчик, який знову розіменовується, тобто функція повертає покажчик.
Хоча синтаксис типів у C здається безглуздим, в дизайні лежить логіка. Проте, у кінцевому результаті це можна вважати не дуже вдалим дизайном.
Практичні завдання
Нотація типу C | Значення |
---|---|
int x |
int |
int *x |
* int |
int x[] |
[] int |
int x() |
func() int |
int **x |
* * int |
int (*x)[] |
* [] int |
int (*x)() |
* func() int |
int *x[] |
[] * int |
int x[][] |
[] [] int |
int *x() |
func() * int |
int ***x |
* * * int |
int (**x)[] |
* * [] int |
int (**x)() |
* * func() int |
int *(*x)[] |
* [] * int |
int (*x)[][] |
* [] [] int |
int *(*x)() |
* func() * int |
int **x[] |
[] * * Int |
int (*x[])[] |
[] * [] int |
int (*x[])() |
[] * func() int |
int *x[][] |
[] [] * int |
int x[][][] |
[] [] [] int |
int **x() |
func() * * int |
int (*x())[] |
func() * [] int |
int (*x())() |
func() * func() int |
Заключення
Основний текст цієї книги написаний у форматі Markdown. Для конвертації Markdown у HTML використовувався Pandoc, для створення діаграм синтаксичних дерев — Graphviz, а для інших діаграм — draw.io.
Додаток 1: Шпаргалка по набору інструкцій x86-64
У цьому розділі наведено узагальнення функцій набору інструкцій x86-64, що використовуватиметься в компіляторі, який створюється в цій книзі. Для стислості тут використані наступні умовні позначення:
src
,dst
: два регістри однакового розміруr8
,r16
,r32
,r64
: регістри розміром відповідно 8, 16, 32 та 64 бітиimm
: безпосереднє значення (іммедіат)reg1:reg2
: позначення, коли два регістриreg1
таreg2
використовуються як старші та молодші біти відповідно, щоб представити значення, яке не вміщується в один регістр (наприклад, 128-бітне число)
Список регістрів цілих чисел
У таблиці наведено список 64-бітних регістрів цілих чисел та їх псевдонімів (аліасів).
64 | 32 | 16 | 8 |
---|---|---|---|
RAX | EAX | AX | AL |
RDI | EDI | DI | DIL |
RSI | ESI | SI | SIL |
RDX | EDX | DX | DL |
RCX | ECX | CX | CL |
RBP | EBP | BP | BPL |
RSP | ESP | SP | SPL |
RBX | EBX | BX | BL |
R8 | R8D | R8W | R8B |
R9 | R9D | R9W | R9B |
R10 | R10D | R10W | R10B |
R11 | R11D | R11W | R11B |
R12 | R12D | R12W | R12B |
R13 | R13D | R13W | R13B |
R14 | R14D | R14W | R14B |
R15 | R15D | R15W | R15B |
Використання у ABI наведено нижче. Регістри, значення яких не потрібно відновлювати при поверненні з функції, позначені ✔.
Регістр | Використання | |
---|---|---|
RAX | Повернене значення / кількість аргументів | ✔ |
RDI | Перший аргумент | ✔ |
RSI | Другий аргумент | ✔ |
RDX | Третій аргумент | ✔ |
RCX | Четвертий аргумент | ✔ |
RBP | Базовий вказівник | |
RSP | Вказівник стека | |
RBX | (Нічого конкретного) | |
R8 | П'ятий аргумент | ✔ |
R9 | Шостий аргумент | ✔ |
R10 | (Нічого конкретного) | ✔ |
R11 | (Нічого конкретного) | ✔ |
R12 | (Нічого конкретного) | |
R13 | (Нічого конкретного) | |
R14 | (Нічого конкретного) | |
R15 | (Нічого конкретного) |
При виклику функції необхідно, щоб регістр RSP
був кратним 16 (вирівняним за 16 байт) у момент виконання команди call
. Виклик функції, що не задовольняє цю умову, не відповідає ABI і може спричинити аварійне завершення роботи деяких функцій.