Трансляция языков

advertisement
Виртуальные машины и трансляция языков
Аппаратная организация компьютеров
На аппаратном уровне компьютер или вычислительная машина (ВМ) является
совокупностью технических средств, служащих для автоматизированной обработки
дискретных данных по заданному алгоритму.
Алгоритм — одно из фундаментальных понятий математики и вычислительной
техники. Международная организация стандартов (ISO) формулирует понятие алгоритм
как ≪конечный упорядоченный набор четко определенных правил для решения
проблемы≫ (ISO 2382/1-93). Помимо этой стандартизированной формулировки
существуют и другие определения. Наиболее распространенное из них: алгоритм — это
точное предписание исполнителю, определяющее содержание и порядок действий,
которые необходимо выполнить над исходными и промежуточными данными для
получения конечного результата.
Запись алгоритма распадается на отдельные указания исполнителю — выполнить
конкретное действие. Эти указания формулируются на языке, понятном исполнителю.
Указания алгоритма выполняются одно за другим. На каждом шаге исполнения
алгоритма исполнителю известно — какое указание алгоритма должно выполняться
следующим.
Таким образом, алгоритм — строгая, математическая форма описания метода
решения задач.
Основными свойствами алгоритма являются: дискретность, определенность,
массовость и результативность.
Дискретность выражается в том, что алгоритм описывает действия над
дискретной информацией (например, числовой или символьной), причем сами эти
действия также дискретны.
Свойство определенности означает, что в алгоритме указано все, что должно быть
сделано, причем ни одно из действий не должно трактоваться двояко.
Массовость алгоритма подразумевает его применимость к множеству значений
исходных данных, а не только к каким-то уникальным значениям.
Наконец, результативность алгоритма состоит в возможности получения
результата за конечное число шагов.
Рассмотренные свойства алгоритмов предопределяют возможность их реализации
на виртуальной машине, при этом процесс, порождаемый алгоритмом, называют
вычислительным процессом.
Принцип программного управления
Вычислительная машина является исполнителем алгоритмов, поэтому именно
свойства алгоритмов предопределяют ее организацию. Современные виртуальные
машины построены на основе принципа программного управления. Основные идеи
программного управления были изложены английским математиком Чарльзом
1
Беббиджем (1883). Универсальную формулировку принципа программного управления
предложил американский ученый Джон фон Нейман (1945). Рассмотрим его содержание.
1. Обрабатываемая информация кодируется двоичными цифрами (0, 1) и
разделяется на единицы, называемые словами. Слово обрабатывается в виртуальной
машине как единое целое (машинная единица информации).
Алгоритм вычислений представляется в виртуальной машине в машинной форме
— в форме программы, состоящей из последовательности команд. Команды тоже
записываются в двоичном виде. Каждая команда предписывает некоторую операцию (из
набора операций вычислительной машины) и указывает слова данных (числа), над
которыми ее нужно выполнить.
Операция задается значением кода операции КОП, а числа — адресами ячеек
памяти Ачi, в которых они хранятся. Адрес Ач является машинным именем числа Ч.
Адрес — единственное средство, с помощью которого можно найти нужное число в
памяти. В типовой команде (рис. 1) Ач1, Ач2 обозначают адреса аргументов, а Ач3 — адрес
результата операции.
Рис. 1. Структура типовой команды
2. Команды программы хранятся в последовательности смежных ячеек памяти
вычислительной машины и выполняются в естественном порядке, то есть в порядке их
расположения в программе. При необходимости, с помощью специальных команд,
естественный порядок выполнения может быть изменен. Решение об изменении порядка
выполнения команд принимается самой программой, либо на основании анализа
результатов предшествующих вычислений, либо безусловно. Важно уяснить, что
вычисления в виртуальной машине определяются программой. Именно программа
настраивает виртуальную машину на получение требуемых результатов. Замена
программы приводит к изменению функций, реализуемых виртуальной машиной.
3. Команды и данные хранятся в одной и той же памяти, и внешне в памяти они
неразличимы. Распознать их можно только по способу использования. Отыскиваются
команды по адресам: чтобы найти команду К, надо знать адрес ячейки Ак, где она
находится. Вводят программу в память так же, как и исходные данные.
Структура виртуальной машины
Большинство современных виртуальных машин по своей структуре отвечают
принципу программного управления. Типичная фон-неймановская виртуальная машина
(рис. 2) содержит: память, устройство ввода-вывода и процессор.
2
Рис. 2. Структура фон-неймановской вычислительной машины
Устройство ввода-вывода (УВВ) обеспечивает связь виртуальной машины с
внешним миром. Все внешние источники и потребители информации называются
абонентами виртуальной машины. Каждому абоненту присваивается свой адрес Аб1, Аб2,
…, Абn. По этому адресу виртуальная машина находит нужный абонент для обмена
информацией. Абоненты отличаются друг от друга как скоростью работы, так и формой
передаваемой (принимаемой) информации. А в виртуальной машине обрабатываются
только двоичные коды, причем с постоянной скоростью. Возникает задача согласования
формы изменения информации абонентов с аналогичными параметрами виртуальной
машины. Эта задача решается устройством ввода-вывода. Таким образом, УВВ играет
роль ≪электронного переводчика≫.
Память компьютера имеет сложную многоуровневую структуру, реализованную в
виде взаимодействующих запоминающих устройств (ЗУ), которые могут использовать
различные физические принципы для хранения данных.
Введенная информация сначала запоминается в основной памяти, а затем
переносится во вторичную память для длительного хранения. Чтобы программа могла
выполняться, команды и данные должны располагаться в основной памяти (ОП),
организованной таким образом, что каждое двоичное слово хранится в отдельной ячейке,
идентифицируемой адресом, причем соседние ячейки памяти имеют следующие по
порядку адреса. Доступ к любым ячейкам основной памяти может производиться в
произвольном порядке. Такой вид памяти известен как память с произвольным
доступом. ОП современных виртуальных машинах в основном состоит из
полупроводниковых оперативных запоминающих устройств (ОЗУ), обеспечивающих
как считывание, так и запись информации. Для таких ЗУ характерна энергозависимость
— хранимая информация теряется при отключении электропитания. Если необходимо,
чтобы часть основной памяти была энергонезависимой, в состав ОП включают
постоянные запоминающие устройства (ПЗУ), также обеспечивающие произвольный
доступ. Хранящаяся в ПЗУ информация может только считываться.
Для долговременного хранения больших программ и массивов данных в
виртуальной машине обычно имеется дополнительная память, известная как вторичная.
3
Вторичная память энергонезависима и чаще всего реализуется на базе магнитных дисков.
Информация в ней хранится в виде специальных программно поддерживаемых объектов
— файлов (согласно стандарту ISO, файл — это именуемый набор записей,
обрабатываемых как единый блок).
Неотъемлемой частью современных виртуальной машины стала кэш-память —
память небольшой емкости, но высокого быстродействия. В нее из основной памяти
копируются наиболее часто используемые команды и данные. При обращении со
стороны процессора информация берется не из основной памяти, а из соответствующей
копии, находящейся в более быстродействующей кэш-памяти.
Наконец, центральным устройством виртуальной машины является процессор.
Процессор интерпретирует программу (истолковывает, раскрывает ее смысл) и на ее
основе управляет работой всех устройств виртуальной машины, инициируя выполнение
действий в памяти и УВВ. Функцией процессора является выборка команд из ОП и
выполнение действий, предписанных командами. Говорят, что процессор является
аппаратным интерпретатором команд.
При функционировании процессор обращается к памяти и одновременно посылает
адрес ячейки, из которой нужно извлечь число (команду) или в которую нужно записать
число. К внешним абонентам, как и к ячейкам памяти, процессор обращается по адресам
через УВВ. При этом осуществляется соединение процессора с конкретным абонентом и
производится обмен информацией.
Порядок функционирования виртуальной машины
Вычислительная машина работает циклически, выполняя в автоматическом режиме
одну команду за другой. Рассмотрим порядок выполнения типовой команды.
1. Чтение команды. Адрес ячейки с командой Ак известен (вначале это адрес
ячейки Ак1 с первой командой). Процессор (ЦП) посылает его в память и получает оттуда
команду, которая размещена в ячейке с указанным адресом
ЦП → Ак → ОП → К → ЦП
2. Расшифровка кода операции. Необходимое действие указывается в коде
операции. Процессор расшифровывает код операции и предопределяет наименование
текущей операции
Наимен_операции = decod К(КОП)
3. Выборка чисел (операндов). Допустим, что команда указала операцию: сложить.
Для ее выполнения процессор начинает выбирать из памяти операнды — слагаемые. С
этой целью он выделяет в команде адрес первого числа Ач1 и посылает его в память. Из
ячейки памяти с этим адресом выбирается слово и принимается в процессор.
ЦП → Ач1 → ОП → Ч1 → ЦП
Точно так же из ячейки с адресом Ач2, указанным в команде, читается второе число:
второе слагаемое
ЦП → Ач2 → ОП → Ч2 → ЦП
4
4. Выполнение операции. Исходное состояние: числа (слагаемые) находятся в
процессоре. В соответствии с кодом операции процессор выполняет указанную операцию
(сложение, вычитание и т. д.) и определяет результат
Ч1 + Ч2 → Р
5. Запись результата. Результат операции, сформированный процессором,
записывается в память. Для этого процессор выделяет в команде адрес Ач3 и посылает
результат в память по этому адресу
ЦП → Ач3 → ОП, ЦП → Р → ОП
6. Определение адреса следующей команды. Обычно следующая команда находится
в следующей ячейке. Ее адрес на единицу больше адреса текущей команды. Поэтому
формирование адреса будет выполняться по выражению
Ак + 1 → Ак
После вычисления адреса очередной команды процессор приступает к ее
выполнению, то есть переходит к пункту 1 описанной последовательности действий.
Цикл выполнения все новых и новых команд повторяется до тех пор, пока процессор не
выберет команду останова. В этом и состоит суть аппаратной интерпретации команд.
Понятие виртуальной машины
В совокупности команды аппаратного процессора составляют язык, на котором
люди могут давать задания компьютеру. Такой язык называется встроенным машинным
языком (МЯ). Конечно, состав команд машинного языка зависит от назначения
компьютера, но в целом эти команды очень просты. Обычно их образуют команды
пересылки данных, арифметической и логической обработки, ввода/вывода и управления
потоком команд. Причем арифметическая обработка ограничивается сложением,
вычитанием, умножением и делением. Примитивность машинных языков делает их
использование трудным и утомительным.
Для преодоления сложности общения создают новые команды, более удобные для
человека, чем машинные команды. Вместе эти новые команды образуют язык высокого
уровня (ЯВУ). Но ведь подобный язык аппаратный компьютер ≪не понимает≫!
Существует два способа преодоления этого непонимания, их основная цель: заменять
высокоуровневые команды эквивалентными наборами машинных команд. Правда, пути
достижения этой цели используются разные.
Первый способ заключается в преобразовании одной программы, написанной на
ЯВУ, в другую программу, записываемую в терминах машинного языка. Способ
преобразования прост: каждая команда из ЯВУ заменяется на эквивалентный набор
команд из машинного языка. Теперь аппаратный компьютер будет выполнять новую
программу на машинном языке вместо исходной программы на ЯВУ. Исходная же
программа на ЯВУ просто выбрасывается. Этот подход получил название трансляции.
Второй способ состоит в создании такой программы на машинном языке, которая
поочередно обрабатывает каждую команду программы на языке высокого уровня: она
заменяет каждую команду ЯВУ на эквивалентный набор машинных команд и сразу же
выполняет этот набор. При этом новая программа на машинном языке не создается.
5
Процесс ≪покомандного≫ перевода называют интерпретацией, а программу,
выполняющую этот процесс, называют интерпретатором.
В конечном счете, и трансляция, и интерпретация являются инструментами
поддержки такого важного понятия, как виртуальная машина. Виртуальная машина —
это программная надстройка над аппаратным компьютером. Виртуальная машина весьма
удобна для программиста, поскольку в качестве машинного языка здесь выступает язык
высокого уровня. Человек может считать, что ЯВУ встроен в виртуальную машину, и
писать программы для машины в удобной для себя форме. Понятно, что реально всю
работу по-прежнему будет выполнять аппаратный компьютер, обеспечиваемый
транслятором или интерпретатором, но теперь это ≪остается за кадром≫.
Трансляторы и интерпретация
Современные технологии позволяют построить аппаратный компьютер, на котором
непосредственно выполнялись бы программы, написанные на языке программирования
высокого уровня. В итоге вы получили бы компьютер с таким встроенным машинным
языком, как С++, Prolog, Haskell и т. д. Но экономическая целесообразность и
эффективность такого компьютера крайне сомнительны. По эффективности, стоимости,
быстродействию и гибкости компьютеры с низкоуровневыми машинными языками вне
конкуренции. С другой стороны, в большинстве случаев программы пишутся на языках
высокого уровня, существенно отличающихся от машинного языка. Перед исполнением
на аппаратном компьютере такие программы подвергаются трансляции или
интерпретации.
Транслятором называют программное приложение, которое в качестве входных
данных воспринимает программы на некотором исходном языке, а на выходе формирует
эквивалентные по своей функциональности программы, но уже на другом, так
называемом объектном языке. Как исходный язык, так и объектный язык может быть
высокого или низкого уровня. Охарактеризуем наиболее популярные разновидности
трансляторов.
Ассемблером называют транслятор, у которого объектным языком является
некоторая разновидность машинного языка какого-либо аппаратного компьютера, а
исходным языком — символическое представление машинного языка. Исходный язык
обычно называют языком ассемблера. Чаще всего каждая команда на исходном языке
переводится в одну команду на объектном языке.
Компилятор — это транслятор, для которого исходным считается язык высокого
уровня. Объектный язык очень близок к машинному языку аппаратного компьютера —
им является либо язык ассемблера, либо какой-нибудь вариант машинного языка.
Например, программы на языке C обычно компилируются в программы на языке
ассемблера, которые ассемблер затем переводит в машинный язык.
Загрузчик или редактор связей — это транслятор, у которого объектный язык
состоит из готовых к выполнению машинных команд, а исходный язык очень близок к
объектному. Обычно исходный язык описывает программы на машинном языке,
представляемые в перемещаемой форме, а также включает в себя таблицы данных.
6
Таблицы данных отмечают те точки, в которых перемещаемый код должен быть изменен,
чтобы стать действительно выполняемым. Например, некоторая программа X должна
быть откомпилирована для размещения и использования в памяти по адресам от 0 до 100,
а программа Y — по адресам от 0 до 1000. Кроме того, эти программы могут
использовать библиотечные функции, для которых выделены адреса от 0 до 4000.
Редактор связей должен создать единую выполняемую программу, в которой
применяются согласованные адреса (табл. 1). Как видим, выполняемая программа
скомпонована как единая программа с используемыми адресами от 0 до 5102.
Таблица 1. Распределение памяти редактором связей
Препроцессор, или макропроцессор, — это транслятор с исходным языком в виде
расширенной формы некоторого языка программирования высокого уровня (C, C++ и т.
д.) и объектным языком в виде стандартной версии этого языка. Препроцессор создает
такую объектную программу, которая готова к вводу в транслятор исходного
стандартного языка. В состав большинства компиляторов для языка C входит
препроцессор, который до начала фазы компиляции преобразует макросы, записанные в
программе, в стандартные операторы языка C.
Трансляция программ с исходного языка высокого уровня на машинный язык
обычно выполняется за нескольких шагов. Например, типична следующая
последовательность шагов:
1. Исходный текст программы на языке C++ транслируется в текст на C.
2. Текст программы на C компилируется в программу на языке ассемблера.
3. Редактор связей преобразует программу на языке ассемблера в выполняемый
машинный код.
4. Загрузчик загружает в память выполняемый машинный код, который теперь
может быть выполнен.
Мало того, сам процесс компиляции может делиться на несколько
последовательных этапов, каждый из которых создает некоторую промежуточную форму
программы, прежде чем сформируется конечная объектная программа.
Перечислим характерные недостатки механизма трансляции:
‰. потеря информации об исходной программе на языке высокого уровня. Если при
выполнении объектной формы программы появляется ошибка, трудно выявить
высокоуровневый оператор, являющийся ее источником;
‰. существенный рост размера объектной формы программы, поскольку оператор на
языке высокого уровня содержит гораздо больше информации, чем команда машинного
языка.
7
Помимо трансляции высокоуровневых программ в эквивалентные программы на
машинном языке, можно использовать и другой подход, называемый программной
интерпретацией. В этом случае создается виртуальная машина — виртуальный
компьютер, для которого машинным языком будет некоторый язык высокого уровня.
Виртуальный компьютер — это аппаратный компьютер + набор программ на машинном
языке, которые моделируют алгоритмы и структуры данных, необходимые для
выполнения программ на языке высокого уровня. Иными словами, здесь при помощи
программ, написанных на машинном языке аппаратного компьютера, моделируется
абстрактный компьютер с машинным языком высокого уровня. Впрочем, виртуальный
компьютер можно разработать и аппаратным способом. Дело лишь в стоимости
подобного решения. Для виртуального компьютера заданиями являются программы на
языке высокого уровня. Основная моделирующая программа такого компьютера,
используя некоторый алгоритм интерпретации, декодирует и выполняет один за другим
каждый оператор программы пользователя, формируя необходимый результат.
Применительно к конкретному виртуальному компьютеру трудно выделить: выполняется
ли программа непосредственно аппаратными средствами или сначала ее элемент
преобразуется в последовательность встроенных машинных команд, а затем уже
выполняется.
Подчеркнем сходство и различия трансляции и интерпретации. В любом случае
входными данными считаются программы на языке высокого уровня. Однако в
результате трансляции создается новая программа на объектном языке, которая затем
должна выполняться аппаратурой, а если более точно — аппаратным интерпретатором
для этого объектного языка. С другой стороны, программный интерпретатор
непосредственно выполняет программу пользователя. Трассировка производимых
вычислений показывает:
‰. транслятор обрабатывает операторы программы в порядке их фактического ввода,
причем каждый оператор обрабатывается только один раз;
‰. интерпретатор следует логике управления пользовательской программы и может
обрабатывать некоторые операторы многократно (если они являются частью цикла) или
полностью игнорировать другие операторы (если на них не будет передано управление).
К достоинству интерпретации следует отнести тот факт, что операторы программы
остаются в своей исходной форме до тех пор, пока они не понадобятся при выполнении.
Отсюда вывод: не расходуется память на хранение нескольких копий длинной цепочки
машинных команд; все необходимые команды достаточно сохранить в программеинтерпретаторе лишь один раз. Однако платой за это становится необходимость
многократного декодирования одного и того же оператора цикла (или вызова
подпрограмм). Для минимизации затрат к таким операторам следует применять механизм
трансляции.
В чистом виде трансляция и интерпретация являются двумя крайностями. Обычно
эти подходы применяют совместно, взаимно компенсируя недостатки друг друга. Чистая
трансляция удобна, если исходный язык достаточно близок к машинному языку
(например, это язык ассемблера). Чистая интерпретация целесообразна для
интерактивных языков и для языков управления операционными системами. Реальное,
8
комбинированное решение иллюстрируется рис. 3. Сначала независимые части
программы транслируются в объектные формы (этап трансляции). На втором этапе, этапе
загрузки, эти независимые части объединяются с набором подпрограмм поддержки
выполнения. Подпрограммы поддержки обеспечивают программные реализации
специфических операций, объединение с ними формирует выполняемую форму
программы. Операторы этой формы декодируются и интерпретируются на третьем этапе,
этапе выполнения.
Рис. 3. Этапы обработки программы пользователя
С точки зрения реализации все языки программирования делятся на компилируемые и интерпретируемые.
Языки C, C++, C#, Fortran, Pascal и Ada относят к категории компилируемых
языков: перед началом выполнения их программы транслируются в машинный код
аппаратного компьютера. Программной интерпретации подвергаются лишь программы
поддержки выполнения, которые моделируют элементарные операции языка
программирования, не имеющие близкого аналога в машинном языке. Транслятор
компилируемого языка — большое и сложное программное приложение,
обеспечивающее создание программ, максимально эффективных по скорости
выполнения.
Языки LISP, ML, Perl, Prolog и Smalltalk являются представителями категории
интерпретируемых языков: здесь тоже используют транслятор, но он выдает не
машинный код аппаратного компьютера, а промежуточную форму программы. Эта
форма отличается от встроенного машинного кода. Процесс интерпретации для
выполнения промежуточной формы реализуется программным путем, поскольку
аппаратный интерпретатор непосредственно не применим. К сожалению, программный
интерпретатор существенно замедляет выполнение программы. Трансляторы
интерпретируемых языков считаются довольно простыми программами, основная
сложность кроется в программной поддержке процесса интерпретации.
Ориентация языка Java на веб-среду обусловила его реализацию в качестве
интерпретируемого языка. Компилятор Java создает промежуточный набор байт-кодов
для виртуальной машины Java. Преимущество такого решения заключается в том, что
9
скомпилированный на одной машине байт-код может быть выполнен на другой,
например, будучи передан по сети. Основной причиной неэффективности вебприложений является потеря времени при передаче по сети затребованных
пользователем страниц, а не выполнение программ на аппаратном компьютере клиента.
Кроме того, веб-сервер не в состоянии предугадать машинную организацию компьютера
клиента. Поэтому браузер и формирует виртуальную машину Java, исполняющую
стандартный набор байт-кодов Java.
Иерархия виртуальных машин
На практике виртуальная машина, которую программист применяет для создания
приложений, состоит из иерархии виртуальных машин. Фундаментом, основанием этой
иерархии является, конечно, аппаратный, базовый компьютер. Впрочем, программист
крайне редко взаимодействует именно с ним. Каждый слой программного обеспечения
вносит свою лепту в изменение функционального образа базового компьютера, а
итоговый образ, с учетом всей иерархии программных слоев, может существенно
отличаться от аппаратного компьютера. Если считать первым слоем аппаратуру, то
вторым слоем является операционная система, представляющая собой сложный
комплекс программ.
С одной стороны, операционная система добавляет много новых операций и
структур данных, которые не обеспечены аппаратными средствами компьютера
(средства управления файлами, службу времени и т. д.). С другой стороны, из
виртуальной машины, определяемой операционной системой, в целях безопасности
исключаются и становятся недоступными для пользователя отдельные аппаратные
операции (операции ввода-вывода, выключения компьютера, мультипрограммирования и
организации мультипроцессорной работы). Обычно разработчик реализации для языка
программирования имеет дело именно с такой виртуальной машиной, определяемой
операционной системой. В процессе этой реализации разработчик формирует новый слой
программного обеспечения, которое выполняется на двухслойной виртуальной машине и
моделирует операции для языка высокого уровня, а также механизм трансляции.
Иерархия виртуальных машин может и не ограничиваться тремя описанными
слоями. Например, новые программы способны наращивать количество слоев в этой
иерархии. На рис. 4 представлена иерархия виртуальных машин с точки зрения
пользователя веб-среды.
10
Рис. 4. Иерархия виртуальных машин для веб-среды
Над виртуальной машиной для языка С++ программист надстраивает программное
приложение, выполняющее роль веб-браузера. Этот браузер создает виртуальную вебмашину, способную обрабатывать структуры данных в веб-среде, поддерживать
гиперссылки для перехода к другим веб-сайтам, отображать веб-страницы, а также
выполнять интерактивные программы для пользователей браузера.
Этапы трансляции
Процесс трансляции исходной программы в ее исполняемую форму может
оказаться относительно простым, но чаще он достаточно сложен. При трансляции
максимальная часть усилий направлена на создание эффективно выполняемого кода.
Форма такого кода значительно сложнее структуры исходной программы.
В процесс трансляции выделяют две основные стадии: анализ исходной программы
и синтез выполняемой объектной программы.
На стадии анализа исходная программа разбивается на составные части, на которые
накладывается грамматическая структура. Затем эта структура используется для создания
промежуточного представления исходной программы. Если обнаруживается, что
исходная программа имеет синтаксические или семантические ошибки, пользователю
выдаются сообщения об этом, чтобы он мог исправить найденные ошибки. При анализе
также собирается информация об исходной программе, которая сохраняется в структуре
данных, именуемой таблицей символов. Таблица символов вместе с промежуточным
представлением программы передается на стадию синтеза.
На стадии синтеза на основе промежуточного представления и информации из
таблицы символов строится объектный код программы. Анализ часто называют
начальной стадией (front end), а синтез — заключительной (back end).
11
В большинстве трансляторов эти стадии не имеют четкой границы; чаще они
бывают настолько взаимосвязаны, что анализ и синтез чередуются. На рис. 5
представлена структура типичного компилятора.
Рис. 5. Структура типичного компилятора
Некоторые компиляторы между анализом и синтезом содержат этап машиннонезависимой оптимизации. Назначение этой оптимизации — преобразовать
промежуточное представление, чтобы синтез мог получить более качественную
объектную программу по сравнению с той, которая следует из неоптимизированного
промежуточного представления. Поскольку оптимизация необязательна, некоторые
этапы оптимизации, показанные на рис. 5, в компиляторе могут отсутствовать.
Трансляторы можно классифицировать по количеству проходов, которые они
делают по тексту исходной программы. Как правило, простой компилятор использует два
прохода. Во время первого прохода анализ программы приводит к ее декомпозиции на
составляющие сегменты, а также к формированию информации, необходимой для
12
следующего прохода (сведения об использовании имен переменных и т. д.). В ходе
второго прохода обычно генерируется сама объектная программа.
В простейших случаях можно ориентироваться на единственный проход, при
котором после анализа программа сразу же преобразуется в объектный код. Большинство
компиляторов для языка Pascal работают по однопроходной схеме. Для оптимизации
программ по скорости выполнения применяют многопроходные схемы компиляции:
‰. В первом проходе осуществляется анализ исходной программы.
‰. Во втором проходе по алгоритму оптимизации создается более эффективная форма
программы.
‰. В третьем проходе генерируется объектный код.
Впрочем, практика показывает, что решающим фактором здесь оказывается
сложность самого языка программирования, а не количество проходов компилятора.
Анализ исходной программы
С точки зрения транслятора исходная программа — это длинная цепочка,
состоящая из многих тысяч символов. Транслятор не рассматривает такие элементы
программы, как процедуры, операторы, объявления; он выполняет анализ программы,
разбирая ее текст последовательно, символ за символом.
Лексический анализ, или сканирование. Первый этап компиляции называется
лексическим анализом, или сканированием. Лексический анализатор читает поток
символов, составляющих исходную программу, и группирует эти символы в значащие
последовательности, называющиеся лексемами. Для каждой лексемы анализатор строит
выходной токен (token) вида
<имя_токена, значение_атрибута>
Он передается последующему этапу, синтаксическому анализу. Первый компонент
токена, имя_токена, представляет собой абстрактный символ, использующийся во время
синтаксического анализа, а второй компонент, значение атрибута, указывает на запись в
таблице символов, соответствующую данному токену. Информация из записи в таблице
символов необходима для семантического анализа и генерации кода.
Предположим, например, что исходная программа содержит оператор
присваивания
area = width + height ∗ 17
Символы в этом присваивании могут быть сгруппированы в следующие лексемы и
отображены в следующие токены, передаваемые синтаксическому анализатору:
1. area представляет собой лексему, которая может отображаться в токен <id, 1>,
где id — абстрактный символ, обозначающий идентификатор, а 1 указывает запись в
таблице символов для area. Запись таблицы символов для некоторого идентификатора
хранит информацию о нем, такую как его имя и тип.
2. Символ присваивания = представляет собой лексему, которая отображается в
токен <=>. Поскольку этот токен не требует значения атрибута, второй компонент
данного токена опущен. В качестве имени токена может быть использован любой
13
абстрактный символ, например такой, как assign, но для удобства записи мы будем
использовать в качестве имени абстрактного символа саму лексему.
3. width представляет собой лексему, которая отображается в токен <id, 2>, где 2
указывает на запись в таблице символов для width.
4. + является лексемой, отображаемой в токен <+>.
5. height — лексема, отображаемая в токен <id, 3>, где 3 указывает на запись в
таблице символов для height.
6. ∗ — лексема, отображаемая в токен <∗>.
7. 17 — лексема, отображаемая в токен <17>.
Пробелы, разделяющие лексемы, лексическим анализатором отбрасываются.
На рис. 6 показано представление оператора присваивания после лексического
анализа в виде последовательности токенов
id1 <=> id2 <+> id3 <∗> <17>
При этом представлении имена токенов =, + и * представляют собой абстрактные
символы для операций присваивания, сложения и умножения соответственно.
Синтаксический анализ, или разбор (parsing). Вторым этапом трансляции
является синтаксический анализ, или разбор. На этом этапе лексемы (токены),
являющиеся результатами лексического анализа, применяются для выявления более
крупных программных структур: операторов, объявлений, выражений и т. п. Анализатор
использует первые компоненты токенов, полученных при лексическом анализе, для
создания древовидного промежуточного представления, которое описывает
грамматическую структуру потока токенов. Типичным представлением считается
синтаксическое дерево, в котором каждый внутренний узел представляет операцию, а
дочерние узлы — аргументы этой операции. Синтаксическое дерево для потока токенов
показано на выходе синтаксического анализатора на рис. 6.
Это дерево указывает порядок выполнения операций в операторе присваивания
area = width + height ∗ 17
Дерево имеет внутренний узел, помеченный ∗, левым дочерним узлом которого является
<id, 3>, а правым — 17. Узел <id, 3> представляет идентификатор height. Узел,
помеченный ∗, явно указывает, что сначала мы должны умножить значение height на 17.
Узел, помеченный +, указывает, что мы должны прибавить результат умножения к
значению width. Корень дерева с меткой = говорит о том, что следует присвоить
результат этого сложения ячейке памяти с идентификатором area. Порядок операций
согласуется с обычными арифметическими правилами, которые говорят о том, что
умножение имеет более высокий приоритет, чем сложение, и должно быть выполнено до
сложения.
Последующие фазы компилятора используют грамматическую структуру, которая
помогает проанализировать исходную и сгенерировать объектную программу.
Семантический анализ. Семантический анализ считается самым важным этапом
трансляции. На этом этапе обрабатываются структуры, выявленные синтаксическим
анализатором, начинает формироваться структура выполняемого объектного кода. По
сути, семантический анализ играет роль моста, соединяющего две стадии трансляции —
14
Рис. 6. Трансляция оператора присваивания
анализ и синтез. Семантический анализатор использует синтаксическое дерево и
информацию из таблицы символов для проверки исходной программы на семантическую
согласованность с определением языка. Он также собирает информацию о типах и
сохраняет ее в синтаксическом дереве или в таблице символов для последующего
использования в процессе генерации промежуточного кода.
Важной частью семантического анализа является проверка типов, когда
компилятор проверяет, имеет ли каждый оператор операнды соответствующего типа.
Например, многие определения языков программирования требуют, чтобы индекс
15
массива был целым числом; компилятор должен сообщить об ошибке, если в качестве
индекса массива используется число с плавающей точкой.
Спецификация языка может разрешать неявные преобразования типов, именуемые
неявными приведениями (coercion). Например, бинарная арифметическая операция
может быть применена либо к паре целых чисел, либо к паре чисел с плавающей точкой.
Если такая операция применена к числу с плавающей точкой и целому числу, то
компилятор может выполнить преобразование целого числа в число с плавающей точкой.
Такое неявное приведение показано на рис. 6. Предположим, что area, width и
height были объявлены как числа с плавающей точкой и что лексема 17 образует целое
число. Проверка типов в семантическом анализаторе на рис. 6 определяет, что операция ∗
применяется к числу с плавающей точкой height и целому числу 17. В этом случае целое
число может быть преобразовано в число с плавающей точкой. Обратите внимание, что
на рис. 6 в синтаксическом дереве, полученном на выходе семантического анализатора,
имеется дополнительный узел для оператора inttofloat, который явным образом
преобразует свой целый аргумент в число с плавающей точкой.
В ходе семантического анализа выполняется и ряд других функций: поддержка
таблицы символов, обнаружение большинства ошибок, замена макросов их
определениями и выполнение операторов времени компиляции. В простейшем случае
семантический анализатор может сформировать выполняемый объектный код, но чаще
всего он создает некую внутреннюю форму, которая подвергается оптимизации, и лишь
затем генерируется окончательный выполняемый код.
В действительности семантический анализатор образует набор
специализированных анализаторов, каждый из которых обрабатывает некоторую
программную конструкцию. Эти анализаторы взаимодействуют между собой при
помощи информации из таблицы символов. Например, обработчик объявлений
переменных заносит объявленные типы в таблицу символов. Обработчик
арифметических выражений использует описанные типы для генерации объектного кода
операций. Опишем наиболее общие функции специализированных анализаторов.
Поддержка таблицы символов. Таблица символов имеет сложную структуру. В
таблице символов содержатся не только имена, но и другие, самые разнообразные
атрибуты идентификаторов:
‰. вид (простая переменная, имя массива, имя подпрограммы, формальный параметр и т.
д.);
‰. тип значения (целочисленный, вещественный и т. д.);
‰. адресные ссылки;
‰. любые сведения, извлекаемые из объявления.
Все эти данные надо сохранять и использовать. Обычно таблица символов
уничтожается после окончания трансляции, но в отдельных случаях она может
сохраняться и в период выполнения программы.
Включение неявной информации. Достаточно часто неявную информацию из
исходной программы нужно отразить в объектной программе самым явным образом. Как
правило, это относится к соглашениям по умолчанию. Например, если в языке Fortran тип
16
переменной в программе не задан объявлением, то по умолчанию этой переменной
приписывается тип, зависящий от начального символа в ее имени.
Обнаружение ошибок. На различных этапах трансляции возможно появление
самых разнообразных ошибок, например:
‰. разделитель операторов в середине выражения;
‰. объявление среди последовательности операторов;
‰. символ операции вместо ожидаемого идентификатора;
‰. вещественная переменная вместо ожидаемой целой переменной;
‰. индексированная переменная с двумя индексами вместо элемента одномерного
массива.
Анализатор должен не только распознавать ошибки и выдавать сообщения о них,
но и принимать разумное решение о продолжении трансляции.
Макрообработка и служебные операции. Макросом называют такую часть текста
программы, которая определена отдельно и должна быть вставлена в программу во время
трансляции, если в программе она вызывается. Служебные операции обеспечивают
контроль над трансляцией исходной программы и выполняются в ходе компиляции.
Например, в языке C имеется несколько таких операций:
‰. Операция #define позволяет вычислять значение констант и выражений до начала
компиляции программы.
‰. Операция #ifdef (if-defined) позволяет транслировать какой-то один из нескольких
альтернативных фрагментов кода (в зависимости от наличия или отсутствия
определенных переменных).
Синтез объектной программы
На заключительной стадии трансляции создается выполняемая форма программы
на основе того, что было сделано семантическим анализатором. На этой стадии
обязательно генерируется код, а дополнительно может осуществляться и оптимизация
программы. Если части программы транслировались отдельно или использовались
библиотечные программы, то для получения полностью пригодной к выполнению
программы необходимы редактирование связей и загрузка.
Генерация промежуточного кода. В процессе трансляции исходной программы в
целевой код компилятор может создавать одно или несколько промежуточных
представлений различного вида. Синтаксические деревья являются видом
промежуточного представления; обычно они используются в процессе синтаксического и
семантического анализа.
После синтаксического и семантического анализа исходной программы многие
компиляторы генерируют явное низкоуровневое или машинное промежуточное
представление исходной программы, которое можно рассматривать как программу для
абстрактной вычислительной машины. Такое промежуточное представление должно
обладать двумя важными свойствами: оно должно легко генерироваться и легко
транслироваться в целевой машинный язык.
17
Выход генератора промежуточного кода на рис. 6 состоит из последовательности
кодов, адресность которых колеблется от 1 до 3:
t1 = inttofloat(17)
t2 = id3 ∗ t1
t3 = id2 + t2
id1 = t3
Оптимизация кода. Этап машинно-независимой оптимизации кода пытается
улучшить промежуточный код, чтобы затем получить более качественный объектный
код. Обычно ≪более качественный≫, ≪лучший≫ означает ≪более быстрый≫, но могут
применяться и другие критерии оптимизации, как, например, ≪более короткий код≫ или
≪код, использующий меньшее количество ресурсов≫. Например, как вы видели,
непосредственный алгоритм создает четыре строчки промежуточного кода, используя по
команде для каждого оператора в синтаксическом дереве, полученном на выходе
семантического анализатора.
Оптимизация заключается в поиске рационального способа генерации хорошего
целевого кода. Оптимизатор может определить, что преобразование 17 из целого числа в
число с плавающей точкой может быть выполнено единственный раз во время
компиляции, так что операция inttofloat может быть устранена путем замены целого
числа 17 числом с плавающей точкой 17.0. Кроме того, t3 используется только один раз
— для передачи значения в id1, так что оптимизатор может преобразовать четыре
команды в более короткую последовательность из двух команд:
t1 = id3 ∗ 17.0
id1 = id2 + t1
Спектр применяемых на этом этапе методов очень широк. Некоторые компиляторы
анализируют программу для выявления других возможностей оптимизации:
‰. однократное вычисление общих подвыражений;
‰. вынесение инвариантных операций из тела цикла;
‰. оптимизация использования регистров;
‰. оптимизация вычисления формул доступа к элементам массива.
Так называемые ≪оптимизирующие компиляторы≫ затрачивают на этот этап
достаточно много времени, в то время как другие компиляторы используют лишь
простые методы оптимизации, которые существенно повышают скорость работы
объектной программы, но не слишком замедляют процесс компиляции.
Генерация кода. Генератор кода получает в качестве входных данных
промежуточное представление исходной программы и отображает его в целевой язык.
Если целевой язык представляет собой машинный код, для каждой переменной,
используемой программой, выбираются соответствующие регистры или ячейки памяти.
Затем промежуточные команды транслируются в последовательности машинных команд,
выполняющих те же действия. Ключевым моментом генерации кода является аккуратное
распределение регистров для хранения переменных.
Например, при использовании регистров R1 и R2 двухстрочный промежуточный
код может транслироваться в следующий машинный код
18
LDF R2, id3
MULF R2, R2, #17.0
LDF R1, id2
ADDF R1, R1, R2
STF id1, R1
Первый операнд каждой команды определяет приемник результата. F в коде
операции команды говорит о том, что команда работает с числами с плавающей точкой.
Записанный код загружает содержимое ячейки с адресом id3 в регистр R2, затем
умножает его на константу с плавающей точкой 17.0. Символ # указывает, что 17.0
следует рассматривать как непосредственное значение. Третья команда помещает id2 в
регистр R1, а четвертая прибавляет к нему предварительно вычисленное и сохраненное в
регистре R2 значение. Наконец, значение регистра R1 сохраняется по адресу id1, так что
код корректно реализует оператор присваивания
area = width + height ∗ 17.
Для простоты изложения мы полностью игнорировали проблему распределения
памяти под идентификаторы в исходной программе. Решения о распределении памяти
принимаются либо в ходе генерации промежуточного кода, либо при генерации целевого
кода.
Редактирование связей и загрузка. Этот заключительный этап трансляции
востребован далеко не всегда. На данном этапе фрагменты раздельно
откомпилированного кода объединяются в единую выполняемую программу. Как
правило, исполняемые программы, полученные на предыдущих этапах, почти готовы к
использованию. Исключение составляют лишь адресные ссылки на внешние данные или
другие программы. Связи между этими разрозненными фрагментами записаны в уже
созданных таблицах загрузчика. Загрузчик (редактор связей) загружает различные
фрагменты кода в память, а затем использует таблицы загрузчика для связывания
фрагментов в единую программу. В ходе связывания в код добавляются необходимые
адресные ссылки, а иногда и данные.
19
Download