Раздельная компиляция в С/С++

Для компиляции программ, чей исходный код разделен на несколько файлов, в С используется механизм раздельной компиляции. Однако, далеко не все понимают, как он работает, что порождает кучу однотипных вопросов и ошибок, особенно у тех, кто раньше работал с языками с модульной компиляцией (Object Pascal/Delphi, Java, C# и другие) не особо вникая в суть и различия этих механизмов.

Под символами в этой статье я понимаю именованные сущности программы — функции, переменные и т.п.
Основная идея раздельной компиляции — процесс трансляции кода разделяется на два этапа:
  1. Компиляция отдельных файлов исходного кода в объектные файлы. Причем файлы компилируются независимо — компилятор работает только с одним файлом и ему глубоко плевать на все остальные. Поэтому каждый файл должен быть полностью самодостаточен и содержать определения всех используемых в нем символов.
  2. Линковка (связывание) скомпилированных файлов. На этом этапе машинный код из всех объектных файлов объединяется в один исполнимый файл и разрешаются все ссылки на символы. Причем информация о том, где оные символы искать в объектных файлах отсутствует.

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

Именно поэтому, по одному лишь исходнику нельзя определить, какие файлы он требует для удовлетворения зависимостей. Их нужно отдельно, вручную, указать линковщику. По этой причине появились системы сборки — они занимаются тем, что следят за обновлением файлов, перекомпилируют обновившиеся и указывают линковщику, какие файлы нужно связать. Позже, с появлением IDE появилась необходимость в хранении информации о взаимосвязях файлов — так появились проекты. По сути — просто список исходных файлов, которые нужно откомпилировать и связать, а также информация о настройках компилятора/линковщика/IDE.

Примерно здесь у пришедших с модульных языков начинаются проблемы. Дело в том, что модуль содержит полную информацию о своих взаимосвязях — что и откуда он берет и что предоставляет. Поэтому объявления uses MySuperLib вполне достаточно для присоединения к программе нового модуля. Однако директива #include <MySuperLib.h> — далеко не аналог этого объявления, она лишь включит файл с определениями. А тот факт, что программа использует эту библиотеку, придется отдельно указать линковщику — напрямую, через систему сборки или проект. Отсюда и типичный вопрос — «я подключил gl.h, а оно говорит „символ glBegin не найден“, почему?».

Теперь о том, как осуществляется взаимосвязь между отдельными файлами. Для этого введено понятие внешнего символа — это такой символ, для которого есть определение (оно содержит необходимую информацию о том, как с ним работать — тип переменной, набор аргументов функции и т.п.), но нету собственно реализации символа. Встретив обращение к такому символу, компилятор работает с ним в соответствии с имеющимся определением, а вместо ссылки на него подставляет заметку линковщику — «вставь сюда ссылку на символ такой-то». Если символ в реальности будет иметь определение, отличное от указанного компилятору — трансляция пройдет успешно, но при запуске начнутся лулзы — порча памяти, порча стека и просто неведомые глюки. Именно поэтому символы объявляют в заголовочных файлах — чтобы не было нескольких объявлений, которые могут рассинхронизироваться.

Внешний символ объвляется ключевым словом extern. Именно оно указывает компилятору, что не надо искать реализацию этого символа (либо выделять под него место, если это переменная). Функции являются внешними по умолчанию, так что указывать для них extern — необязательно.

Следущая забавная вещь — множественные реализации символа. Поскольку символ определяется только собственным именем (в отличие от модульных языков, где указывается не только сам символ, но и откуда его брать) — когда линкер встречает несколько одноименных символов, реализованных в разных файлах, он не может определить к какому из них отсылается компилятор и выдает ошибку. Отсюда появились пляски, обеспечивающие уникальность имен — префиксы в С, пространства имен в С++.

Но кроме случайного совпадения имен символов в разных файлах есть еще одна причина появления этой ошибки — реализация символа в заголовочном файле. Если объявить переменную или функцию (полностью, а не только ее заголовок) — она окажется включена во все файлы, куда включен этот заголовочный файл и, так как есть и реализация — то и реализована в них всех. Ну а дальше, обнаружив реализации нескольких одноименных символов линкер выдаст ошибку. Поэтому в заголовочный файл помещают только extern-переменные и заголовки функций (за исключением inline функций — их на место вызова подставляет сам компилятор, и потому должен иметь их реализацию в момент компиляции). Еще забавней получается, если объявить статическую (static — глобальная, но видна только в том файле, где объявлена) переменную в заголовочнике — все скомпилируется без ошибок, но в каждом объектнике будет своя копия переменной, не связанная с другими, хотя подразумевалась одна глобальная переменная. Как результат появляется вопрос «а почему если я гружу текстуры в main.cpp, то все работает, а если в other.cpp — то текстуры не грузятся?».

Вот. Как-то примерно так. Стоит заметить, в С++ сей механизм усложнен и работает несколько иначе, но я недостаточно с этими отличиями знаком. Но, в первом приближении, он работает похоже.

P.S. Жду гнилых помидоров от neiver'а и других знатоков С :)
  • +4
  • 23 июня 2011, 01:26
  • Vga

Комментарии (29)

RSS свернуть / развернуть
Да ничего так.
Примеров не хватает)
0
статья получилась какая то логически не завершенная,
видно автор хотел много сказать но не хватило терпения, чтобы высказать все что хотел.

короче свод простых правил для заголовочных файлов:
Вот элементы данных, которые обычно содержатся в заголовочных файлах:
Прототипы функций
Символьные константы, при определении которых была использована директива #define или const
Объявления структур
Объявления классов
Объявления шаблонов
Встроенные функции
Не помещайте определения функций или объявления переменных в заголовочный файл!

в общем желательно продолжение об областях видимости и со ввсеми вытекающими…
и конечно примеры должны присутствовать

:)
люблю кстати читать статьи от neivera
из-за них стал изучать с++
тоже с удовольствием прочитаю его коментарии

Компактность языка С в сочетании с большим количеством операторов дает возможность создавать программный код, понимание которого чрезвычайно затруднительно. Никто не заставляет программиста сoздавать непонятные программы, но все возможности для этого имеются.
;)
0
Нет-нет. Про заголовочные файлы я писать и не собрался. Я описывал механизм раздельной компиляции и возникающие при этом ошибки.
А инклюд — просто средство продублировать некоторые куски текста в нескольких файлах.
линковщику надо указывать object file? отсюда другой вопрос — у обжект файла и заголовочного файла должны быть одинаковые имена?
Линковщику глубоко пофиг заголовочные файлы. Это просто кусок текста, который вставляется препроцессором в файл исходника. У хедеров, объктных файлов и исходников имя одинаковое просто для удобства, чтобы знать что к чему относится. Можно сделать заголовочник MySuperLib.h, реализовать его объявления в исходнике foo.c и скомпилив с опцией -o «lol.o» получить объектник с именем lol.o. Но когда пользователь библиотеки поймет, что для решения ошибки «symbol „myFunc“ not found» нужно скормить линковщику lol.o — он будет долго и смачно материться :)
0
под с# теж самые нюансы компиляции. на и в общем для тех кто хочет изучить си с нуля со всем что здесь обсуждается, идите книгу: Язык программирования Си, Керниган, Ритчи. имхо лучше для старта не найти.
0
  • avatar
  • pkm
  • 23 июня 2011, 10:52
Разве? Насколько я знаю шарп, от этого анахронизма там отказались и используют нечто вроде модульной компиляции. Там вроде даже заголовочников обычно нет, подключаются .cs модули или целые .dll сборки. Его, все-таки, не столько с С перли, сколько с явы и дельфи.
0
А тот факт, что программа использует эту библиотеку, придется отдельно указать линковщику
… линковщику надо указывать object file? отсюда другой вопрос — у обжект файла и заголовочного файла должны быть одинаковые имена?
0
По правилам хорошего тона — да, вообще необязательно.
0
… а еще такой вопрос: если обжект файлы находятся в той-же папке что и проект, их все равно надо указывать линковщику?
0
Да. Он их сам не ищет. Он линкует те файлы, что указано. Если указано только имя — ищет в своих директориях поиска. Еще объектные файлы могут находиться в архиве — библиотеке. Тогда ее нужно указать линкеру как библиотеку — для gcc это -l gl.a. Из библиотеки он сам разберется какие файлы включать (грубо говоря — все, но «умная линковка» выкинет те, на символы из которых нету ссылок).
0
Конечно, надо. Линковщик о ваших проектах ничего не знает, он линкует, только то, что ему явно говорят, плюс стандартную библиотеку.
0
Кстати, проект сам по себе для того и предназначен, чтобы хранить список объектников и скармливать их линковщику. Все файлы, что включены в проект (ну, обычно, они фильтруются по расширениям, так что все .c и .cpp файлы в проекте) будут по порядку откомпилированы и затем переданы линковщику.
Кстати, в ассемблере точно такая же система. Поэтому его довольно легко совмещать с С/С++ — надо только почитать как компилятор реализует высокоуровневые штучки вроде передачи параметров в/из функции. Разве что видимые снаружи символы там надо специально объявлять с директивой GLOBAL (причем это зависит от конкретного ассемблера). Внешние символы — тем же EXTERN.
0
… тоесть, одним словом — подстановкой всех неизвесных имен функций, переменных и т.д. в мэин занимается препроцессор, который в свою очередь шарит только в тех файлах что указаны линкеру, только после окончания зборки, функция мэин транслирется в ассемблер а потом компилится?
.c -> .o -> .asm -> .hex или .exe — я правельно понял?
0
… не наоборот: .c -> .asm -> .o -> .hex или .exe?
0
Ой каша. Даже не понял до конца :)
Процесс такой
(.c + .h) => preprocessor => compiler => (.o)
(.asm + .inc + .h) => assembler => (.o)
(.o + .a) => linker => (.exe/.elf)
(.elf) => objcopy => (.hex/.bin)
main — такая же функция, как и все остальные. Просто в сборке есть файл стартап-кода, для МК — это обычно явно существующий в проекте startup.asm (или .s). В нем есть точка входа (для МК — это размещенная по определенному адресу таблица прерываний, включая вектор RESET), от которой и начинается исполнение. Он выполняет необходимую инициализацию (как минимум — стек) и затем вызывает функцию main().
main компилируется в нечто вида
main:
0000:  call 0
0001:  push 10
0002:  push 0
0003:  call 0

Плюс в объектном файле имется информация для линковщика:
Подставить адрес функции Init в команду по адресу 0000
Подставить адрес переменной UARTBuffer в инструкцию по адресу 0002
Подставить адрес функции SetUARTBuffer в инструкцию по адресу 0003

Тогда линкер, после того как собрал все функции в одну кучу и знает по какому адресу какая — подставляет вместо забитых компилером нулей реальные адреса функций и переменных.
0
Поправка:
0001: push #10. Это константа.
0
… понятно, главное довести все файлы до .o, дальше линкер начинает вышивать крестиком: свежеизготовленные + те что мы ему указали
0
1. The compiler compiles each C source file to an AVR assembly file.
2. The assembler translates each assembly file (either from the compiler or assembly files that you have written) into a relocatable object file.
3. After all the files have been translated into object files, the linker combines them together to form an executable file. In addition, a map file, a listing file, and debug information files are also output.

All these details are handled by the compiler driver. You give it a list of files and ask it to compile them into an executable file (default) or to some intermediate stage (e.g. to the object files). The driver invokes the compiler, the assembler and the linker as needed.
0
Ну тут по разному бывает. Иногда компилер напрямую выдает объектники, иногда — генерирует ассемблерный файл, который затем скармливается ассемблеру. Но в случае GCC .asm на диске даже не появляется — он передается через пайп обычно. Дампится только если включен вывод листингов. Так что с точки зрения юзера компилятор жует.с и выдает.о.
свежеизготовленные + те что мы ему указали
В смысле? Он собирает только те, что указали. Среди них и свежеизготовленные, и несвежеизготовленные. Линковка вообще абсолютно независимая от компиляции/ассемблирования стадия и работает с .o/.a/.res (последнее, впрочем, для винды).
0
Кроме того, не редкость, когда для здоровой библиотекииз кучи объектников всего один заголовочник. Взять, например, тот же WinAPI — на весь здоровенный API — один windows.h, который надо включить в свой проект. А вот объектников (точнее даже библиотек) — куча, по одной на каждую dll, реализующую WinAPI. Поэтому документация на него указывает для каждой функции, где она реализована.
0
В общем всё правильно, если под «файл» понимать «единица трансдяции», которая очень часто не из одного файла состоит. Например, множество исходных файлов могут компилироваться скопом за один вызов компилятора и это будет, впринципе одна единица трансляции… Ну это уже так, занудство…
В С++ сам процесс компиляци ничем не отличается от того, что есть в Си, там просто имена функций и переменных кодирюутся с учётом их типа, принадлежности к классам и т.д. — «Name mangling». А так всё тоже самое.
0
А они транслируются не независимо? Насколько я знаю, всякие инклюды еще препроцессором сливаются в один файл, а что будет, если компилятору одновременно скормить несколько .c(pp) — не знаю.
0
Логически выглядит, что они транслируются независимо(с точки зрения областей видимости), однако генерируется один объектный файл и компилятор может производить «межфайловую» оптимизяцию, например, заинлайнить функцию из одного .c(pp) файда в другом и т.д.
0
Ну, я описывал в первом приближении, где всю эту логику видно. Так да, фокусы оптимизации и прочего этот механизм усложняют, но с точки зрения программиста — логика та же, файлы компилируются независимо.
0
>> генерируется один объектный файл и компилятор может производить «межфайловую» оптимизяцию, например, заинлайнить функцию из одного .c(pp) файда в другом и т.д.

Это, ИМХО, зависит от компилятора. В gcc ключик -combine указывает как именно транслировать несколько файлов, переданных компилятору — независимо, или совместно.
Кстати, полезная фича, я под AVR сейчас так и собираю. Но в новых версиях gcc вместо combine сделали link-level оптимизацию.
0
Забыл рассказать про static объявления.
0
Вне темы статьи.
Почти по теме можно было бы написать про name mangling. Быть может и напишу… Потом.
0
Почему вне темы?
Это про «множественные реализации символа».
Так в разных CPP файлах могут быть переменные с одинаковыми именами, можно хоть в заголовочном файле их объявить.
0
Потому что статья не про области видимости. Она про технологию компиляции и сборки.
0
Хе, таки понял о чем ты, потому как наткнулся на такой случай) Добавил о static-переменных в заголовочнике.
0
Посоветуйте пожалуйста литературу в которой описаны такие подробности, очень интересно почитать. Особенно про makefile'ы
0
  • avatar
  • 286
  • 14 июня 2012, 23:19
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.