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

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

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

12 ответов


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

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

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


Я помню, как слушал a Программная инженерия Радио подкаст в котором Дик Гэбриэл говорил о загрузке оригинального интерпретатора LISP, написав версию с голыми костями на LISP на бумаге и рука собирая его в код машины. С тех пор остальные функции LISP были записаны и интерпретированы с LISP.


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

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

make bootstrap

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

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


когда вы пишете свой первый компилятор для C, вы пишете его на каком-то другом языке. Теперь у вас есть компилятор для C, скажем, ассемблер. В конце концов, вы придете к месту, где вам нужно разобрать строки, в частности escape-последовательности. Вы напишете код для преобразования \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 в дополнение к обычному паролю.

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

первоначальный источник идеи:http://cm.bell-labs.com/who/ken/trust.html


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

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

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

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


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

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

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

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


обновление 1

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


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

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


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

Это также идея PyPy и Рубиниус:

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


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


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

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


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

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

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

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


может быть, вы можете написать BNF описание BNF.