Добавлен семинар 3 по языкам программирования
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
## Содержание
|
||||
- [[#Введение]]
|
||||
- [[#Цикл компиляции]]
|
||||
- [[#Препроцессинг]]
|
||||
- [[#Однострочные команды препроцессора с аргументами]]
|
||||
- [[#Команды ветвления и циклы]]
|
||||
- [[#Многострочные команды препроцессора]]
|
||||
- [[#Компилятор]]
|
||||
- [[#Описание интерфейсов]]
|
||||
- [[#Заголовочные файлы]]
|
||||
- [[#Компоновщик]]
|
||||
- [[#Таблица символов]]
|
||||
- [[#Таблица релокации]]
|
||||
- [[#Задание зависимостей процесса сборки]]
|
||||
- [[#Оптимизация процесса сборки]]
|
||||
- [[#makefile]]
|
||||
- [[#Phony targets]]
|
||||
- [[#Декларативность make]]
|
||||
- [[#Переменные]]
|
||||
- [[#Автоматические переменные]]
|
||||
- [[#Шаблоны]]
|
||||
- [[#Функции]]
|
||||
|
||||
## Введение
|
||||
|
||||
Как было сказано на [лекциях](Языки%20программирования.md#Лекции), для того, чтобы создать и развивать хоть сколько-нибудь хорошую большую систему, необходимо следовать двум важным принципам: делить систему на модули и абстрагировать эти модули (то есть максимально скрывать их внутреннее устройство).
|
||||
|
||||
Исходный код программы после трансляции в машинные инструкции и запуска приложения "встраивается" в вычислительную систему. Когда программа написана в одном большом файле (по факту, монолит), при ее запуске в программу добавляется один большой модуль, который сложно изучать и отслеживать. Если же разделить исходный код на файлы, то запуск программы можно мысленно воспринимать как внесение в систему нескольких небольших модулей. Это существенно облегчает труд разработчиков системы.
|
||||
## Цикл компиляции
|
||||
|
||||
Как было сказано на [лекции](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Этапы), трансляция программы в машинный код происходит в несколько больших этапов (которые, в свою очередь, состоят из множества маленьких).
|
||||
|
||||
Первым этапом компиляции программы является преобразование исходного текста программы на ассемблере с директивами макропроцессора в текстовый файл уже без этих директив. Этот этап называется [[#Препроцессинг|препроцессингом]] и выполняется препроцессором - отдельной программой или частью компилятора.
|
||||
|
||||
Вторым этапом компиляции программы является преобразование текстового файла на ассемблере в объектный файл (то есть в файл с машинными инструкциями, но без проставленных адресов). Этот этап называется [[#Компилятор|трансляцией]] и выполняется самим компилятором.
|
||||
|
||||
Третьим этапом компиляции программы является сборка всех объектных файлов в один исполняемый файл. Этот этап называется [[#Компоновщик|компоновкой]] (или линковкой) и выполняется компоновщиком (или линкером).
|
||||
|
||||
В [первом семинаре](Среда%20выполнения.%20Основы%20языка%20ассемблера.md#Компиляция%20ассемблерных%20программ) были вкратце описаны необходимые команды для компиляции одного файла на ассемблере. Тем не менее, подробности команд там не раскрывались. Команда `nasm` выполняет последовательно препроцессинг и компиляцию для одного исходного файла, получая объектный файл. Команда `ld` выполняет компоновку, делая из объектного файла исполняемый.
|
||||
|
||||
Если программа состоит из нескольких файлов, то для каждого отдельного файла отдельно выполняется препроцессинг и компиляция (команда `nasm`), а потом все необходимые файлы соединяются в один исполняемый файл с помощью компоновщика (команда `ld`).
|
||||
### Препроцессинг
|
||||
|
||||
Препроцессинг производит в файле текстовые замены, описанные специальными командами - макросами. Это подготавливает программу к компиляции и позволяет полуавтоматически генерировать рутинный код, который иначе пришлось бы иначе писать вручную. В исходном ассемблерном коде макросы обозначаются через знак `%`.
|
||||
|
||||
Фактически, после выполнения препроцессинга программа становится окончательно готовой к компиляции. Команда `nasm` позволяет просмотреть результат работы препроцессора, если при компиляции указать ключ `-E` (что на выходе даст код программы после препроцессинга, но еще не скомпилированный).
|
||||
|
||||
Одним из самых часто используемых макросов является `%difine`, который определяет замену одной строчки на другую. Но у него есть одна особенность: раскрытие значения происходит именно в момент использования макроса, а не в момент создания замены. Это означает, что по ходу выполнения программы замену директивы `%define` можно заменить на другую. Чтобы избежать этого, существует директива `%xdefine`, раскрытие которой происходит в момент объявления замены. Чтобы отменить замену на определенном этапе кода, можно использовать директиву `%undef`.
|
||||
|
||||
Для сохранения значения математических выражений препроцессор умеет выполнять простые вычисления и сохранять полученное значение для последующей замены с помощью директивы `%assign`.
|
||||
#### Однострочные команды препроцессора с аргументами
|
||||
|
||||
Команды препроцессора могут использовать аргументы, имена которых записываются в круглых скобках после имени команды.
|
||||
|
||||
При этом необходимо не забывать, что препроцессор - это прежде всего текстовая подстановка, а значит ответственность за итоговый результат лежит именно на программисте и на том, правильно ли он реализует подстановку текста из макроса.
|
||||
#### Команды ветвления и циклы
|
||||
|
||||
Помимо вышеперечисленных команд, препроцессор также поддерживает такие директивы, как директива `%if`, директива `%elif`, директива `%else` и завершающую директиву `%endif`. Помимо этого, существуют также различные специализированные версии ветвления, к примеру директива `ifdef` (проверить, определено ли имя в препроцессоре), директива `%ifmacro` (проверить, что имя определено в препроцесссоре как [[#Многострочные команды препроцессора|многострочный макрос]]), и так далее.
|
||||
|
||||
Помимо этого, существует также директива `%rep`, которая позволяет повторить код, написанный после нее, указанное в аргументе количество раз. Чтобы завершить блок повторяемого кода, используется директива `%endrep`.
|
||||
#### Многострочные команды препроцессора
|
||||
|
||||
Помимо однострочных макросов, рассмотренных ранее, существуют также и многострочные макросы. Для их задания используется директива `%macro`, а завершается блок многострочного макроса с использованием директивы `%endmacro`.
|
||||
|
||||
Конечно, таким макросам тоже можно передавать аргументы. Количество передаваемых в макрос аргументов указывается после названия макроса. Для доступа к аргументам внутри многострочного макроса используются директивы с соответствующим номером необходимого аргумента (к примеру, `%1` для первого аргумента, `%2` для второго и так далее).
|
||||
|
||||
Внутри многострочного макроса можно использовать локальные имена задаваемых значений, дописав им в начало символы `%%`. Такое задание переменных помогает гарантировать, что можно задавать одну и ту же переменную в разных макросах, и пересечений имен не будет. Чаще всего такую конструкцию используют для задания имен локальных меток, но можно использовать и по-другому.
|
||||
|
||||
Количество аргументов также можно указать в виде диапазона через символ '-'. Это значит, что макрос может принимать любое количество аргументов, указанных в этом диапазоне, включая крайние значения.
|
||||
|
||||
Помимо этого, у многострочного макроса может быть неопределенное значение аргументов. Одним из способов задания неопределенного количества аргументов является "жадный" аргумент '+', который все аргументы, превышающие заданное количество, присоединяет к последнему аргументу через запятую.
|
||||
|
||||
Другим способом задать неопределенное количество значений является аргумент '\*'. В отличие от предыдущего "жадного" аргумента, этот просто сохранит все заданные значения как разные аргументы, и указывается через диапазон. Для того, чтобы узнать количество переданных аргументов, можно использовать `%0`. Также можно вращать все аргументы, меняя их местами по кругу. Для этого используется директива `%rotate`.
|
||||
### Компилятор
|
||||
|
||||
Задача компилятора - перевести исходный код в набор исполняемых инструкций. В случае с языком ассемблера, этот процесс достаточно прозрачен: каждой мнемонике ассемблера соответствует одна машинная инструкция.
|
||||
|
||||
Для программ из нескольких файлов-модулей, компилируемых по-отдельности, возникает необходимость явно описывать, какие именно внешние функции или данные нужно добавить в каждый отдельный файл. Для этого используются директива `global`, которая устанавливает возможность экспортировать функцию или данные в другие файлы, и директива `extern`, которая устанавливает, какие именно функции или данные необходимо импортировать в файл.
|
||||
## Описание интерфейсов
|
||||
|
||||
Иногда возникают ситуации, когда необходимо из одного файла перенести определенное число функций в другой. Это легко сделать, когда функций всего единицы. А что если десятки? Если сотни? А если набор функций изменится, то неужели все это придется пересматривать и переписывать? На все эти вопросы существуют относительно простые решения, описанные ниже.
|
||||
### Заголовочные файлы
|
||||
|
||||
Наиболее продуктивным подходом к разработке библиотек с большим количеством экспортируемых функций заключается в том, чтобы создавать для них заголовочные файлы, в которых будет указываться, какие именно функции необходимо импортировать из этой библиотеки. В ассемблере такой специальный файл использует расширение `.inc`.
|
||||
|
||||
Тогда с использованием препроцессорной директивы `%include` (которая копирует содержимое указанного после нее файла и заменяет себя этим содержимым) можно будет просто включить в программу этот заголовочный файл, и все функции из данной библиотеки будут успешно импортированы.
|
||||
|
||||
Фактически, один единственный заголовочный файл почти полностью описывает интерфейс взаимодействия с модулем библиотеки. Для того, чтобы получить полный таковой интерфейс, достаточно лишь учесть все метки, помеченные директивой `global` из самого исходного файла библиотеки.
|
||||
|
||||
Как говорилось [ранее](Среда%20выполнения.%20Основы%20языка%20ассемблера#Секции,%20метки%20и%20комментарии), метку `_start` тоже необходимо помечать с помощью директивы `global`. Это важно, так как при составлении исполняемого файла компоновщик должен найти эту метку и поставить на нее адрес входа в программу. Если же метка окажется локально (не `global`), то компоновщик просто не сможет экспортировать ее, а значит не будет знать, что она существует.
|
||||
### Компоновщик
|
||||
|
||||
Компоновщик работает с объектными файлами (по факту, полуфабрикатами) и собирает их в исполняемый файл, параллельно заполняя все необходимые адреса.
|
||||
|
||||
В данном курсе рассматриваются объектные файлы формата ELF. Помимо простого исполняемого кода, они также содержат и большое количество мета-информации. К примеру, какие строчки исходной программы на языке высокого уровня соответствуют каким ассемблерным инструкциям, какие функции начинаются с каких адресов, где находятся глобальные переменные, какие данные изменяемые, какие данные должны быть защищены от перезаписи, по какому адресу программа загружается в память, и так далее.
|
||||
#### Таблица символов
|
||||
|
||||
Информация про ассемблерные метки после компиляции аккумулируется в таблице символов (до этого про нее упоминалось в [лекции](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы)) и становится частью ELF-файла. Компоновщик использует таблицу символов для того, чтобы связать обращение к адресам и определения меток. С помощью утилиты `nm` можно посмотреть содержание таблицы символов объектного файла (помимо этого, там также указывается тип метки для каждой метки таблицы символов).
|
||||
|
||||
Из объектного файла можно также достать и инструкции ассемблерного кода. Для этого можно использовать, к примеру, утилиту `objdump`, тогда команда будет выглядеть примерно как `objdump -M intel-mnemonic -d file.o`. Важно помнить, что такой код еще не компонован, а значит адреса меток там не проставлены, то есть установлены в ноль.
|
||||
|
||||
В таблицу символов попадает и другая служебная информация. Исполняемые файлы тоже имеют формат ELF, только в них больше мета-информации.
|
||||
#### Таблица релокации
|
||||
|
||||
Также в мета-информации файла ELF существуют и другие данные. Одной из частей этих данных также является и таблица релокации (она также упоминалась ранее в [лекции](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Таблицы)). Она служит для того, чтобы компоновщик знал, куда и какие необходимо подставить адреса, чтобы получить исполняемый файл.
|
||||
## Задание зависимостей процесса сборки
|
||||
|
||||
Как было описано [[#Описание интерфейсов|ранее]] (и [[#Цикл компиляции|ранее]]), процесс получения исполняемых файлов и исходников является очень трудоемким. Но что происходит в по-настоящему больших системах, где различные файлы могут быть написаны на разных языках, а весь процесс сборки может занимать по несколько часов?
|
||||
|
||||
Тогда хочется, чтобы пересобирались только необходимые и измененные файлы. Иными словами, необходимо средство, которое смогло бы контролировать процесс пересборки и пересобирать именно то, что нужно, а не все подряд.
|
||||
|
||||
Самым распространенным, хотя и не особо хорошо спроектированным и не самым современным, является инструмент для организации процесса сборки make.
|
||||
### Оптимизация процесса сборки
|
||||
|
||||
Для оптимизации процесса сборки ассемблерных файлов можно придерживаться стандартных правил и здравого смысла. Нужно просто пересобирать только те файлы, которые были изменены, а также все зависящие от них файлы, вплоть до интересующего главного файла. Остальные же файлы можно не перекомпилировать, ведь в них все равно ничего не изменилось, а значит и в скомпилированных файлах тоже ничего не изменится (кроме, конечно, времени компиляции).
|
||||
|
||||
То есть, если процесс пересборки необходимо оптимизировать, то надо стороннюю программу заставить просчитывать оптимальную последовательность команд для пересборки проекта именно в соответствии с этими принципами.
|
||||
### makefile
|
||||
|
||||
Программа make отслеживает взаимосвязи между файлами. Если изменяется какой-либо один файл, то необходимо изменить и другой, от него зависящий. Она может просчитывать транзитивные зависимости, а значит таким образом в конечном итоге пересоберет все файлы, связанные с измененным каким-либо образом.
|
||||
|
||||
Зависимость одного файла от другого программа make берет из графа зависимостей. Сам этот граф зависимостей строится по специальному конфигу, указанному в проекте - файлу, который называется makefile. Про синтаксис такого конфига упоминалось в [лекции](Цикл%20трансляции,%20лексический%20и%20синтаксический%20анализ.md#Утилита%20make).
|
||||
|
||||
В контексте make существуют специальные понятия. Правило (rule) - это то, что нужно делать для того, чтобы пересобрать файл, и когда его нужно пересобирать. Цель (target) - это тот файл, который нужно пересобрать. Зависимости (prerequisites) - это те файлы, которые необходимы для сборки цели.
|
||||
#### Phony targets
|
||||
|
||||
Помимо целей, которые создаются в виде файла, также существуют цели, которые итоговый файл не создают. Такие цели называются phony targets. Они могут служить для различных дополнительных задач в процессе сборки, к примеру удаления временных файлов, которые больше не нужны.
|
||||
|
||||
Тем не менее, если такую цель указать как обычную цель в makefile, то она будет правильно работать лишь до того момента, пока в проекте не существует файла с таким же названием, как и у такой цели. Поэтому чтобы не путать phony targets с обычными целями, они указывается после специального ключевого слова `.PHONY`.
|
||||
#### Декларативность make
|
||||
|
||||
С точки зрения программирования, make является декларативным языком. То есть в нем описывается лишь набор ограничений (необходимые зависимости), а он уже сам по измененным файлам и графу зависимостей выбирает, какие и в каком порядке пересобирать файлы.
|
||||
|
||||
Также при сборке можно использовать ключ `-j`, чтобы запустить сборку в несколько потоков. Тогда вся сборка (все правила) будет выполнятся в рандомном порядке, но зависимости будут все также идти одна за другой (будут синхронизироваться между собой).
|
||||
|
||||
По умолчанию (при сборке с помощью команды `make`) собираться будет первая указанная в файле makefile цель, а также все необходимые для нее зависимости. Также можно явно указать, какую именно цель необходимо собрать, тогда make соберет все зависимости для нее, а также пересоберет все цели, которые зависят от нее.
|
||||
#### Переменные
|
||||
|
||||
В makefile можно задавать переменные. Переменные определяются через знак `=` а используются через конструкцию `$()`, внутри которой пишется нужная переменная.
|
||||
#### Автоматические переменные
|
||||
|
||||
Помимо этого, make также умеет подставлять переменные автоматически там, где это нужно. Для этого используется ряд специальных символов, результат которых зависит от собираемой цели.
|
||||
- `$@` - цель
|
||||
- `$<` - первая зависимость
|
||||
- `$?` - все зависимости, которые новее цели, разделенные запятой и пробелом
|
||||
- `$^` - все различные зависимости, разделенные запятой и пробелом
|
||||
- `$+` - все указанные (с повторениями, если так указано) зависимости, разделенные запятой и пробелом
|
||||
|
||||
Конечно, это не все автоматические переменные, но самые основные из них, и их хватит для большинства сборок.
|
||||
#### Шаблоны
|
||||
|
||||
Шаблон является еще одним полезным инструментом makefile, который позволяет задать правило для группы файлов, которые собираются одинаковым образом, а не расписывать каждый файл отдельно. Для указания шаблона используется символ `%`.
|
||||
#### Функции
|
||||
|
||||
Также утилита make предоставляет широкий набор функций для работы со строками и файлами. Вызов функции записывается в круглых скобках, а после имени функции указываются передаваемые аргументы, указанные через запятую.
|
||||
- $(wildcard T) - возвращает список имен файлов, соответствующих шаблону T
|
||||
- $(subst, X,Y,T) - возвращает строку T, где все вхождения X изменены на Y
|
||||
- $(patsubst, X,Y,T) - возвращает строку T, где все вхождения шаблона X изменены на шаблон Y
|
||||
- $(strip T) - возвращает строку T без лишних пробельных символов
|
||||
- $(shell C) - выполняет команду C и возвращает ее результат
|
||||
Reference in New Issue
Block a user