Files
Info/2 курс/Языки программирования/Цикл компиляции, препроцессор, утилита make.md
T

30 KiB

Содержание

  • #Введение
  • #Цикл компиляции
    • #Препроцессинг
      • #Однострочные команды препроцессора с аргументами
      • #Команды ветвления и циклы
      • #Многострочные команды препроцессора
    • #Компилятор
  • #Описание интерфейсов
    • #Заголовочные файлы
    • #Компоновщик
      • #Таблица символов
      • #Таблица релокации
  • #Задание зависимостей процесса сборки
    • #Оптимизация процесса сборки
    • #makefile
      • #Phony targets
      • #Декларативность make
      • #Переменные
      • #Автоматические переменные
      • #Шаблоны
      • #Функции

Введение

Как было сказано на лекциях, для того, чтобы создать и развивать хоть сколько-нибудь хорошую большую систему, необходимо следовать двум важным принципам: делить систему на модули и абстрагировать эти модули (то есть максимально скрывать их внутреннее устройство).

Исходный код программы после трансляции в машинные инструкции и запуска приложения "встраивается" в вычислительную систему. Когда программа написана в одном большом файле (по факту, монолит), при ее запуске в программу добавляется один большой модуль, который сложно изучать и отслеживать. Если же разделить исходный код на файлы, то запуск программы можно мысленно воспринимать как внесение в систему нескольких небольших модулей. Это существенно облегчает труд разработчиков системы.

Цикл компиляции

Как было сказано на лекции, трансляция программы в машинный код происходит в несколько больших этапов (которые, в свою очередь, состоят из множества маленьких).

Первым этапом компиляции программы является преобразование исходного текста программы на ассемблере с директивами макропроцессора в текстовый файл уже без этих директив. Этот этап называется #Препроцессинг и выполняется препроцессором - отдельной программой или частью компилятора.

Вторым этапом компиляции программы является преобразование текстового файла на ассемблере в объектный файл (то есть в файл с машинными инструкциями, но без проставленных адресов). Этот этап называется #Компилятор и выполняется самим компилятором.

Третьим этапом компиляции программы является сборка всех объектных файлов в один исполняемый файл. Этот этап называется #Компоновщик (или линковкой) и выполняется компоновщиком (или линкером).

В первом семинаре были вкратце описаны необходимые команды для компиляции одного файла на ассемблере. Тем не менее, подробности команд там не раскрывались. Команда 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 из самого исходного файла библиотеки.

Как говорилось ранее, метку _start тоже необходимо помечать с помощью директивы global. Это важно, так как при составлении исполняемого файла компоновщик должен найти эту метку и поставить на нее адрес входа в программу. Если же метка окажется локально (не global), то компоновщик просто не сможет экспортировать ее, а значит не будет знать, что она существует.

Компоновщик

Компоновщик работает с объектными файлами (по факту, полуфабрикатами) и собирает их в исполняемый файл, параллельно заполняя все необходимые адреса.

В данном курсе рассматриваются объектные файлы формата ELF. Помимо простого исполняемого кода, они также содержат и большое количество мета-информации. К примеру, какие строчки исходной программы на языке высокого уровня соответствуют каким ассемблерным инструкциям, какие функции начинаются с каких адресов, где находятся глобальные переменные, какие данные изменяемые, какие данные должны быть защищены от перезаписи, по какому адресу программа загружается в память, и так далее.

Таблица символов

Информация про ассемблерные метки после компиляции аккумулируется в таблице символов (до этого про нее упоминалось в лекции) и становится частью ELF-файла. Компоновщик использует таблицу символов для того, чтобы связать обращение к адресам и определения меток. С помощью утилиты nm можно посмотреть содержание таблицы символов объектного файла (помимо этого, там также указывается тип метки для каждой метки таблицы символов).

Из объектного файла можно также достать и инструкции ассемблерного кода. Для этого можно использовать, к примеру, утилиту objdump, тогда команда будет выглядеть примерно как objdump -M intel-mnemonic -d file.o. Важно помнить, что такой код еще не компонован, а значит адреса меток там не проставлены, то есть установлены в ноль.

В таблицу символов попадает и другая служебная информация. Исполняемые файлы тоже имеют формат ELF, только в них больше мета-информации.

Таблица релокации

Также в мета-информации файла ELF существуют и другие данные. Одной из частей этих данных также является и таблица релокации (она также упоминалась ранее в лекции). Она служит для того, чтобы компоновщик знал, куда и какие необходимо подставить адреса, чтобы получить исполняемый файл.

Задание зависимостей процесса сборки

Как было описано #Описание интерфейсов (и #Цикл компиляции), процесс получения исполняемых файлов и исходников является очень трудоемким. Но что происходит в по-настоящему больших системах, где различные файлы могут быть написаны на разных языках, а весь процесс сборки может занимать по несколько часов?

Тогда хочется, чтобы пересобирались только необходимые и измененные файлы. Иными словами, необходимо средство, которое смогло бы контролировать процесс пересборки и пересобирать именно то, что нужно, а не все подряд.

Самым распространенным, хотя и не особо хорошо спроектированным и не самым современным, является инструмент для организации процесса сборки make.

Оптимизация процесса сборки

Для оптимизации процесса сборки ассемблерных файлов можно придерживаться стандартных правил и здравого смысла. Нужно просто пересобирать только те файлы, которые были изменены, а также все зависящие от них файлы, вплоть до интересующего главного файла. Остальные же файлы можно не перекомпилировать, ведь в них все равно ничего не изменилось, а значит и в скомпилированных файлах тоже ничего не изменится (кроме, конечно, времени компиляции).

То есть, если процесс пересборки необходимо оптимизировать, то надо стороннюю программу заставить просчитывать оптимальную последовательность команд для пересборки проекта именно в соответствии с этими принципами.

makefile

Программа make отслеживает взаимосвязи между файлами. Если изменяется какой-либо один файл, то необходимо изменить и другой, от него зависящий. Она может просчитывать транзитивные зависимости, а значит таким образом в конечном итоге пересоберет все файлы, связанные с измененным каким-либо образом.

Зависимость одного файла от другого программа make берет из графа зависимостей. Сам этот граф зависимостей строится по специальному конфигу, указанному в проекте - файлу, который называется makefile. Про синтаксис такого конфига упоминалось в лекции.

В контексте 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 и возвращает ее результат