Движок с нуля
Часть 1 · Старт · глава 1.4

Компиляция и линковка

Разберёшься, как текст на C++ становится файлом, который можно запустить.

Ты собрал программу двумя командами, и через пару секунд появился app.exe, который печатает Hello, Engine!.

Но процессор не понимает C++. Процессор выполняет только машинные команды, а строка std::cout << "Hello, Engine!\n"; для него просто набор букв. Что же происходит за эти секунды, пока текст превращается в программу?

Что происходит при сборке

Что происходит за эти секунды, показано на схеме ниже. Рядом со схемой твой main.cpp: наведи на слово в коде, и схема покажет, где это слово оказывается; щёлкни по слову, и откроется реплика про это слово.

main.cppкоммит 157d8bd
#include <iostream>int main(){    std::cout << "Hello, Engine!\n";    return 0;}
Слева твой main.cpp: 7 строк текста на C++. Справа app.exe: файл с машинными командами, который можно запустить. Между ними работают две программы, и у каждой свой шаг: cl.exe переводит C++ в машинные команды, а link.exe собирает из результата готовую программу. На схеме обе программы пока скрыты в пустых рамках со знаком вопроса, но в выводе сборки ты уже видел следы работы обеих программ: строку main.cpp написала первая из них, cl.exe, а строка app.vcxproj -> появилась после работы второй, link.exe. Обе программы запускает сборщик MSBuild: версию MSBuild печатает первая строка того же вывода.
Первым работает препроцессор — первая часть cl.exe. C++ препроцессор не понимает и обращается с main.cpp как с обычным текстом: находит строки, которые начинаются со знака #, и выполняет их. Такие строки называются директивами. В твоём файле директива одна: #include <iostream>. На место этой строки препроцессор вставляет весь текст заголовочного файла iostream, который приходит вместе с Visual Studio. Файл iostream сам подключает другие файлы, и текст этих файлов тоже вставляется, поэтому на схеме за main.cpp вырастает стопка листов. Было 7 строк, стало около 84 тысяч.
Текст main.cpp со вставленными заголовками получает компилятор — вторая часть cl.exe. В отличие от препроцессора, компилятор понимает C++: он проверяет, правильно ли написан код, и переводит код в машинные команды. Результат компилятор записывает в файл, и на схеме этот файл появляется справа от cl.exe. Если в коде ошибка, cl.exe останавливается, пишет об ошибке в вывод сборки, и файл не появляется. У каждой ошибки есть код, и у ошибок cl.exe он начинается с буквы C, например C2065. Коды ошибок препроцессора тоже начинаются с C, например C1083 — «не найден файл из #include», потому что препроцессор — часть той же программы cl.exe.
Получился main.obj — объектный файл с машинными командами. Запустить main.obj пока нельзя, потому что в этом файле не хватает кода. Твоя функция main в main.obj есть, и оператор << тоже: его код пришёл с текстом заголовочных файлов. А вот самого объекта std::cout в тексте после препроцессора нет: в заголовках только объявление. Поэтому компилятор не жалуется, а оставляет в main.obj пометку: «здесь используется std::cout, а где он лежит — не знаю». Такие же пометки стоят на служебных функциях библиотеки, например на flush. На схеме пометки подписаны знаком вопроса.
Дальше main.obj получает линкер — программа link.exe. Линкер закрывает пометки: для каждой пометки находит нужный код и подставляет на место пометки, где этот код лежит. Ищет линкер во всех файлах .obj проекта (у тебя пока есть только main.obj, а в движке таких файлов будут десятки) и в библиотеках .lib. Когда пометок не осталось, линкер склеивает main.obj и найденный в библиотеках код в один файл — app.exe. Если для какой-то пометки код не нашёлся, линкер останавливается с ошибкой, код которой начинается с LNK, от слова linker. Такая ошибка, LNK2019, появляется, например, если в программе нет функции main: код запуска вызывает main, а линкер не находит, что подставить.
app.exe — исполняемый файл, то есть файл, который Windows умеет запускать. Внутри app.exe два вида записей: машинные команды, которые получились из твоего main.cpp и из кода запуска стандартной библиотеки, и список файлов .dll, которые понадобятся программе. При запуске Windows загружает app.exe в память, подключает файлы из этого списка и передаёт управление коду запуска, а код запуска вызывает main. Поэтому для запуска app.exe уже не нужны ни main.cpp, ни main.obj, ни cl.exe, ни link.exe: app.exe можно скопировать в любую папку и запустить, а исходники удалить. Нужна только часть стандартной библиотеки, но эта часть ставится вместе с Visual Studio и уже есть на твоём компьютере.
Зачем два шага, компиляция и линковка, а не одна программа, которая делает всё сразу? В движке будут сотни файлов .cpp, и компилировать их все после каждой правки пришлось бы минутами. Поэтому компилятор обрабатывает каждый .cpp вместе со вставленными в него заголовками отдельно от остальных, и из каждого .cpp получается свой .obj. Эти файлы остаются в папке сборки. Поменял один .cpp — перекомпилируется только этот файл, а линкер быстро склеивает новый .obj с остальными, уже готовыми. На схеме так выглядит будущий движок: изменённый файл проходит весь путь от main.cpp до app.exe, а остальные файлы в этот путь не попадают: готовые .obj этих файлов линкер берёт из папки сборки без перекомпиляции. Что именно пересобрать, решает сборщик, сейчас это MSBuild: сборщик сравнивает даты изменения файлов. А CMake только записал для сборщика список дел — файл app.vcxproj в папке build.

Проверка на своём проекте

Если не получилось1
После добавления пустой строки в выводе всё равно нет строки main.cpp.
Почему
Сборщик сравнивает дату изменения main.cpp с датой main.obj. Если правка не сохранена на диск, файл остался прежним, main.obj новее main.cpp, и компилятор запускать незачем.
Что сделать

Сохрани main.cpp в редакторе и снова выполни cmake --build build. В заголовке вкладки с файлом при несохранённой правке стоит знак: в Visual Studio звёздочка, в VS Code белая точка.