logo
Языки программирования

3.2. Компилятор

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

Язык L1 эффективнее языка L2.

Правильно же то, что компилятор С1 может сгенерировать более эффек­тивный код, чем компилятор С2, или что легче эффективно откомпилировать конструкции L1, чем соответствующие конструкции L2. Одна из целей этой книги — показать соотношение между конструкциями языка и получающим­ся после компиляции машинным кодом.

Структура компилятора показана на рис. 3.1. Входная часть компилятора

«понимает» программу, анализируя синтаксис и семантику согласно прави­лам языка. Синтаксический анализатор отвечает за преобразование последова­тельности символов в абстрактные синтаксические объекты, называемые лек­семами. Например, символ «=» в языке С преобразуется в оператор присваива­ния, если за ним не следует другой «=»; в противном случае оба соседних сим­вола «=» (т.е. «==») преобразуются в операцию проверки равенства. Анализа­тор семантики отвечает за придание смысла этим абстрактным объектам. На­пример, в следующей программе семантический анализатор выделит глобаль­ный адрес для первого i и вычислит смещение параметра — для второго i:

с

static int i;

void proc(inti) {... }

Результат работы входной части компилятора — абстрактное представ­ление программы, которое называется промежуточным представлением. По нему можно восстановить исходный текст программы, за исключением имен идентификаторов и физического формата строк, пробелов, коммента­риев и т.д.

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

Выходная часть компилятора берет промежуточное представление про­граммы и генерирует машинный код для конкретного компьютера. Таким об­разом, входная часть является языковозависимой, в то время как выходная — машиннозависимой. Поставщик компиляторов может получить семейство ком­пиляторов некоторого языка L для ряда самых разных компьютеров Cl, C2,..., написав несколько выходных частей, использующих промежуточное представление общей входной части. Точно так же поставщик компьютеров может создать высококачественную выходную часть для компьютера С и затем под­держивать большое число языков LI, L2,..., написав входные части, которые компилируют исходный текст каждого языка в общее промежуточное представление. В этом случае фактически не имеет смысла спрашивать, какой язык на компьютере эффективнее.

С генератором объектного кода связан оптимизатор, который пытается улучшать код, чтобы сделать его более эффективным. Возможны несколько способов оптимизации:

• Оптимизация промежуточного представления, например нахождение общего подвыражения:

a = f1 (x + y) + f2(x + y);

Вместо того чтобы вычислять выражение х + у дважды, его можно вы­числить один раз и сохранить во временной переменной или регистре. Подобная оптимизация не зависит от конкретного компьютера и может быть сделана до генерации кода. Это означает, что даже компоненты выходной части могут быть общими в компиляторах разных компьюте­ров.

• Машинно-ориентированная оптимизация. Такая оптимизация, как со­хранение промежуточных результатов в регистрах, а не в памяти, явно должна выполняться при генерации объектного кода, потому что число и тип регистров в разных компьютерах различны.

Локальная оптимизация обычно выполняется для сгенерированных ко­манд, хотя иногда ее можно проводить для промежуточного представле­ния. В этой методике делается попытка заменять короткие последователь­ности команд одной, более эффективной командой. Например, в языке С выражение n++ может быть скомпилировано в следующую последователь­ность:

load R1,n

add R1,#1

store R1,n

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

incr n

Использование оптимизаторов требует осторожности. Поскольку оптими­затор по определению изменяет программу, ее, возможно, будет трудно отла­живать с помощью отладчика исходного кода, так как порядок выполнения команд может отличаться от их порядка в исходном коде. Обычно оптимиза­тор при отладке лучше отключать. Кроме того, из-за сложности оптимизато­ра вероятность содержания в нем ошибки больше, чем в любом другом ком­поненте компилятора. Ошибку оптимизатора трудно обнаружить, потому что отладчик создан для работы с исходным текстом, а не с оптимизированным (то есть измененным) объектным кодом. Ни в коем случае нельзя сначала тестировать программу без оптимизатора, а после оптимизации отдавать в работу без тестирования. Наконец, оптимизатор в какой-либо ситуации мо­жет сделать неправильные предположения. Например, для устройства ввода-вывода с регистрами, «отображенными» на память, значение переменной мо­жет присваиваться дважды без промежуточного чтения:

с

transmit_register = 0x70; /* Ждать 1 секунду */ transmit_register = 0x70;

Оптимизатор предположит, что второе присваивание лишнее и удалит его из сгенерированного объектного кода.