Написание компилятора на своем родном языке

Интуитивно, кажется, что компилятор для языка Foo сам не может быть записан в Foo. Более конкретно, первый компилятор для языка Foo не может быть записан в Foo, но любой последующий компилятор может быть записан для Foo.

Но действительно ли это так? У меня есть очень смутное воспоминание о том, как читать язык, чей первый компилятор был написан в "самом себе". Возможно ли это, и если да, то как?

Ответ 1

Это называется "начальной загрузкой". Сначала вы должны создать компилятор (или интерпретатор) для своего языка на каком-то другом языке (обычно Java или C). Как только это будет сделано, вы можете написать новую версию компилятора на языке Foo. Вы используете первый компилятор bootstrap для компиляции компилятора, а затем используете этот компилируемый компилятор для компиляции всего остального (включая будущие версии самого себя).

Большинство языков действительно созданы таким образом, частично потому, что языковые дизайнеры любят использовать язык, который они создают, а также потому, что нетривиальный компилятор часто служит полезным ориентиром для того, как "полный" язык может быть.

Примером этого может быть Scala. Его первый компилятор был создан в Пицце, экспериментальном языке Мартина Одерского. Начиная с версии 2.0, компилятор был полностью переписан в Scala. С этого момента старый компилятор Pizza может быть полностью отброшен из-за того, что новый компилятор Scala может быть использован для компиляции для будущих итераций.

Ответ 2

Я вспоминаю прослушивание подкаста Software Engineering Radio, в котором Дик Габриэль говорил о начальной загрузке оригинального интерпретатора LISP, написав "чистую" версию на LISP на бумаге и вручную собрав ее в машинный код. С тех пор остальные функции LISP были написаны и интерпретированы с помощью LISP.

Ответ 3

Добавление любопытства к предыдущим ответам.

Вот цитата из руководства Linux From Scratch, на шаге, где начинается сборка компилятора GCC из его источника. (Linux From Scratch - это способ установки Linux, который радикально отличается от установки дистрибутива тем, что вам нужно скомпилировать каждый отдельный двоичный файл целевой системы.)

make bootstrap

Цель bootstrap не просто компилирует GCC, но компилирует его несколько раз. Он использует программы, скомпилированные в первом     раунд, чтобы скомпилировать себя во второй раз, а затем снова в третий раз. Затем он сравнивает эти второй и третий     компилируется, чтобы убедиться, что он может воспроизводить себя безупречно. Это также означает, что он был скомпилирован правильно.

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

Ответ 4

Когда вы пишете свой первый компилятор для C, вы пишете на другом языке. Теперь у вас есть компилятор для C, скажем, на ассемблере. В конце концов, вы придете к тому месту, где вам придется анализировать строки, особенно экранирующие последовательности. Вы напишите код для преобразования \n в символ с десятичным кодом 10 (и \r в 13 и т.д.).

После того, как этот компилятор будет готов, вы начнете переопределять его в C. Этот процесс называетсяначальной загрузкой.

Код разбора строки станет следующим:

...
if (c == 92) { // backslash
    c = getc();
    if (c == 110) { // n
        return 10;
    } else if (c == 92) { // another backslash
        return 92;
    } else {
        ...
    }
}
...

Когда он компилируется, у вас есть двоичный файл, который понимает '\n'. Это означает, что вы можете изменить исходный код:

...
if (c == '\\') {
    c = getc();
    if (c == 'n') {
        return '\n';
    } else if (c == '\\') {
        return '\\';
    } else {
        ...
    }
}
...

Так где же информация о том, что \n - это код для 13? Это в двоичном виде! Это похоже на ДНК: компиляция исходного кода C с этим двоичным файлом унаследует эту информацию. Если компилятор сам компилируется, он передает эти знания своим потомкам. С этого момента нет никакого способа увидеть из одного источника, что будет делать компилятор.

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

void compileFunction(char * name, char * filename, char * code) {
    if (strcmp("compileFunction", name) == 0 && strcmp("compile.c", filename) == 0) {
        code = A;
    } else if (strcmp("xxx", name) == 0 && strcmp("yyy.c", filename) == 0) {
        code = B;
    }

    ... code to compile the function body from the string in "code" ...
}

Интересными частями являются A и B. A - это исходный код для compileFunction, включая вирус, вероятно, каким-то образом зашифрованный, так что это не очевидно из поиска в полученном двоичном файле. Это гарантирует, что компиляция с самим компилятором сохранит код внедрения вируса.

B - то же самое для функции, которую мы хотим заменить нашим вирусом. Например, это может быть функция "login" в исходном файле "login.c", которая, вероятно, из ядра Linux. Мы могли бы заменить его версией, которая будет принимать пароль "joshua" для учетной записи root в дополнение к обычному паролю.

Если вы скомпилируете это и распространите в виде бинарного файла, вы не сможете найти вирус, посмотрев на источник.

Первоначальный источник идеи: https://web.archive.org/web/20070714062657/http://www.acm.org/classics/sep95/

Ответ 5

Вы не можете написать компилятор сам по себе, потому что вам нечего скомпилировать исходный исходный код. Существует два подхода к решению этого.

Наименее благоприятным является следующее. Вы пишете минимальный компилятор в ассемблере (yuck) для минимального набора языка, а затем используете этот компилятор для реализации дополнительных функций языка. Создайте свой путь до тех пор, пока у вас не будет компилятор со всеми языковыми функциями. Болезненный процесс, который обычно делается только тогда, когда у вас нет другого выбора.

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

Не известно, что компилятор GNU С++ имеет реализацию, которая использует только подмножество C. Причина в том, что обычно легко найти компилятор C для новой целевой машины, который позволяет вам затем собрать из него полный компилятор GNU С++. Теперь вы загрузились с помощью компилятора С++ на целевой машине.

Ответ 6

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

Из того, что я помню из "моно", вполне вероятно, что им нужно будет добавить несколько вещей к размышлению, чтобы заставить его работать: моно-команда продолжает указывать, что некоторые вещи просто невозможны с Reflection.Emit; конечно, команда MS может доказать, что они ошибаются.

У этого есть несколько реальных преимуществ: это довольно хороший unit test, для начинающих! И у вас есть только один язык, о котором можно беспокоиться (т.е. Возможно, что эксперт С# может не знать много С++, но теперь вы можете исправить компилятор С#). Но мне интересно, нет ли здесь здесь никакой профессиональной гордости: они просто хотят, чтобы это был хостинг.

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


Обновление 1

Я только что просмотрел это видео Андерса в PDC, и (примерно час) он дает несколько более веских причин - все о компиляторе как услуге. Только для записи.

Ответ 7

В теории компиляторов вы можете использовать T-диаграммы для описания процесса начальной загрузки. Например, см. здесь.

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

Ответ 8

Здесь дамп (сложная тема для поиска на самом деле):

Это также идея PyPy и Rubinius:

(Я думаю, что это может также относиться к Forth, но я ничего не знаю о Forth.)

Ответ 9

GNAT, компилятор GNU Ada, требует, чтобы компилятор Ada был полностью построен. Это может быть болью при переносе на платформу, где нет доступного двоичного файла GNAT.

Ответ 10

Компилятор проекта Mono Project С# уже давно "сам по себе", что значит, что он был написан на самом С#.

Я знаю, что компилятор был запущен как чистый C-код, но после того, как были реализованы "основные" функции ECMA, они начали переписывать компилятор в С#.

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

Вы можете найти дополнительную информацию здесь.

Ответ 11

Собственно, большинство компиляторов написано на языке, который они компилируют, по причинам, указанным выше.

Первый компилятор начальной загрузки обычно записывается на C, С++ или Assembly.

Ответ 12

Возможно, вы можете написать BNF, описывающий BNF.