Книги о том, как связывать, компилировать и т. Д. И как все это сочетается? - PullRequest
1 голос
/ 10 июня 2009

У меня проблемы с пониманием работы компиляторов и компоновщиков и создаваемых ими файлов. В частности, как все .cpp, .h, .lib, .dll, .o, .exe работают вместе? Я в основном интересуюсь C ++, но также интересовался Java и C #. Любые книги / ссылки будут оценены!

Ответы [ 4 ]

6 голосов
/ 10 июня 2009

На эту тему удивительно мало книг. Вот некоторые мысли:

  • Не беспокойтесь о Dragon Book , если вы на самом деле не пишете компилятор с использованием табличного подхода. Это очень трудно читать, и он не охватывает простейший подход к синтаксическому анализу - рекурсивный спуск - ни в одной детали. Предостережение: я не читал последнее издание.

  • Если вы действительно хотите написать компилятор, взгляните на «Бринч Хансен о компиляторах Pascal », который легко читается и предоставляет полный исходный код для небольшого компилятора pascal. Не позволяйте материалу паскаля оттолкнуть вас - уроки, которые он преподает, применимы ко всем компилируемым языкам.

  • Когда дело доходит до ссылок, ресурсов очень мало. Лучшая книга на эту тему, которую я читал, - Линкеры и загрузчики .

3 голосов
/ 11 июня 2009

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

Но вот версия высокого уровня:

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

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

Сначала вы пишете код. Этот код сохраняется в нескольких файлах (обычно с расширением .c, .cc или .cpp) и нескольких заголовках (.h, .hh или .hpp). Эти расширения не требуются, хотя. Это просто общее соглашение, но технически вы можете назвать ваши файлы как угодно.

Для примера, давайте предположим, что у нас есть следующие файлы:

foo.h:

void foo();

foo.cpp:

#include "foo.h"
#include "bar.h"
void foo() {
  bar();
}

bar.h:

void bar();

bar.cpp:

#include "bar.h"
void bar() {
}

Компилятор берет один .cpp файл и обрабатывает его. Допустим, мы сначала скомпилируем foo.cpp. Первое, что он делает, - это предварительная обработка: расширение всех макросов, обработка директив #include путем копирования / вставки содержимого включенного файла в место, из которого он # include'd. Когда это будет сделано, у вас будет модуль перевода или модуль компиляции, и он будет выглядеть так:

void foo(); //#include "foo.h"
void bar(); //#include "bar.h"
void foo() {
  bar();
}

По сути, все, что произошло в нашем простом примере, состоит в том, что заголовки копируются / вставляются.

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

Как он должен реализовать вызов bar() в нашем случае? Не может, потому что не видит, что bar делает . Все, что он может видеть (потому что он включал bar.h, это то, что функция bar существует , и что она не принимает аргументов и возвращает void. Таким образом, компилятор в основном генерирует небольшую метку «заполните позже», по сути говоря, «перейти к адресу этой функции, как только мы узнаем, что это за адрес».

Теперь мы собрали foo.cpp.

Результатом этого процесса является объектный файл, обычно с расширением .o или .obj.

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

Итак, теперь у нас остались foo.o и bar.o, содержащие скомпилированный код для каждого из двух модулей компиляции.

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

Мы передаем все наши объектные файлы, и они читают их и, по сути, заполняют пробелы. При чтении foo.o он заметит, что есть звонок на bar(), где адрес bar() был неизвестен. Но компоновщик имеет доступ к функции bar.o``as well, so it is able to look up the definition of bar () , and determine its address, which it can paste into the call site inside the foo () `. Это в основном связывает воедино эти автономные объектные файлы. После того, как все эти проблемы были решены, он объединяет весь код в один двоичный файл (с расширением .exe в Windows), который является вашей программой. Фактический код генерируется компилятором, и компоновщик затем подключается и связывает определения из одного файла со ссылками на него в других файлах.

2 голосов
/ 10 июня 2009

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

В C ++ фазами компиляции являются (1) предварительная обработка, (2) фактическая компиляция и (3) компоновка.

Фаза предварительной обработки принимает в качестве входного файла cpp и выполняет текстовые замены, руководствуясь директивами, такими как "#include" и "#define". В частности, содержимое файлов h копируется дословно вместо директив "#include".

Фактическая компиляция создает машинный код, который находится в o файлах. Большинство инструкций, которые появляются в o файлах, являются инструкциями, о которых знает процессор, за исключением call имя_функции . Процессор не знает об именах, он знает только об адресах.

В (статической) фазе связывания несколько o файлов объединяются. Теперь мы знаем, где заканчивается определение функции. То есть мы знаем его адрес. Инструкции call имя_функции преобразуются в инструкции call function_address , которые процессор знает, как выполнить. Файлы lib представляют собой предварительно скомпилированные пакеты файлов o , и они принимаются в качестве входных данных (статическим) компоновщиком. Они содержат машинный код для таких функций, как printf , memset и т. Д.

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

В Java история немного другая. Во-первых, нет предварительной обработки. Во-вторых, результатом компиляции является не машинный код, а байт-код, и он находится в файлах class (не в файлах o ). Байт-код похож на машинный код, но на более высоком уровне абстракции. В частности, в байт-коде можно сказать call имя_функции . Это означает, что нет статической фазы связывания и что поиск функции по имени всегда выполняется во время выполнения. Байт-код работает не на реальной машине, а на виртуальной машине. C # похож на Java, главное отличие в том, что байт-код (называемый Common Intermediate Language в случае C #) немного отличается.

2 голосов
/ 10 июня 2009

Для объяснения , независимого от операционной системы и , не зависящего от языка , но все же немного POSIXy:

Tanenbaum - Современные операционные системы, 3-е издание.

Это охватывает все это.

...