A natív build nem „a fordító lefuttatása”, hanem egy irányított gráf bejárása: a forrásokból, headerekből, generált fájlokból objektumok, könyvtárak és végül futtatható állomány lesz. Az inkrementális build helyessége ezen a gráfon múlik, nem a compiler sebességén. Az ODR és a több fordítási egység korábbi szabályai ODR, inline és több fordítási egység (33.3) itt kapnak build szintű következményt; a warning- és optimalizálási profilok Warningok, optimalizálási profilok és link-driver (2.5), valamint a feature-detection Feature-test, standard library és build capability (33.5) pedig a recept-ujjlenyomat részei.
A build gráf csomópontjai fájlok és célok, élei receptek. Egy cél közvetlen, deklarált függőségeit , receptjét jelöli. A gyakorlatban akkor használható fel újra, ha minden érvényes és változatlan, valamint ujjlenyomata is változatlan. A változást a build rendszer többnyire módosítási idővel (mtime), tartalom-hash-sel vagy ezek kombinációjával észleli. Az mtime csak közelítés: nem szemantikai változás, hanem egy olcsó jel.
A fordítási egység (translation unit, TU) egy forrás preprocesszor utáni állapota. A compile él bemenete a TU összes include-olt fájlja, a compiler és a ténylegesen ható kapcsolók; kimenete jellemzően egy objektumfájl. A link él bemenete az objektumok, statikus és megosztott könyvtárak, linkerkapcsolók és linker scriptek; kimenete futtatható vagy megosztott könyvtár. Egy header változása ezért csak azoknak a TU-oknak a compile élét invalidálja, amelyek ténylegesen include-olják; a linket viszont az érintett objektumokon keresztül.
A gráf akkor helyes, ha a deklarált függőségek lefedik a tényleges bemeneteket, és a recept változását is észleli a rendszer. A hiányzó él elavult objektumhoz (stale object) vezet: a build „sikeres”, de a kimenet nem az aktuális forrásból származik. A feleslegesen deklarált él biztonságosabb, de újrafordítási vihart okozhat. A célok topológikus sorrendben épülnek; kör a gráfban érvénytelen.
Bizonyításvázlat. Tegyük fel, hogy minden tényleges bemenet benne van a deklarált lezárásban, és a recept-ujjlenyomat minden kimenetet befolyásoló változást tartalmaz. Ha egy bemenet megváltozik, a topológikus sorrendben az összes érintett cél invalidálódik, majd újraépül; az eredmény ugyanaz, mint egy tiszta buildnél. Ha viszont hiányzik egy él, konstruálható olyan változás, amely nem invalidálja a célt, mégis más kimenetet adna; ez a stale object. A vázlat feltételezi a recept determinizmusát és azt, hogy a környezet hatásai is a kulcs részei; ezek hiányában a garancia gyengül.
A build rendszer a fordítás előtt nem tudja, mely headereket fogja
beolvasni a preprocesszor. Ezért a compiler melléktermékként függőségi
fájlt (depfile) ír. GCC és Clang esetén erre a -MMD vagy
-MD, valamint a -MP szolgál; a
-MMD kihagyja a rendszerheadereket, a -MD
hozzáveszi őket. A pontos viselkedés compiler-verziófüggő, ezért a
laborban a telepített verzió dokumentációjával kell ellenőrizni.
g++ -std=c++20 -MMD -MP -Isrc -Ibuild/generated \
-c src/greet.cpp -o build/greet.cpp.oA keletkező build/greet.cpp.d illusztratív tartalma:
build/greet.cpp.o: src/greet.cpp src/greet.hpp build/generated/config.h
src/greet.hpp:
build/generated/config.h:
A -MP általában phony szabályokat ad a headerekhez, hogy
egy törölt header ne okozzon azonnal „no rule to make target” hibát. A
Makefájlba a depfile-t be kell vonni, például
-include $(DEPS) formában, hogy a következő futás már
ismerje a headerfüggőségeket. A depfile csak akkor keletkezik, amikor a
compiler már lefutott; generált header esetén ezért annak előállítását
explicit gráfélként kell megadni. Különben az első build nem tudja
kielégíteni a még nem létező include-ot. CMake-ben ezt jellemzően
add_custom_command(OUTPUT ...) és a fogyasztó target
közötti függőség, vagy configure idejű configure_file
adja.
A Make általános szabályértelmező: a felhasználó írja le a célokat,
előfeltételeket és recepteket. Jól használható kis projektekhez, de a
headerfüggőségek, a generált fájlok és a kapcsolóváltozások követése a
felhasználó felelőssége. Az általános Make szabály nem követi
automatikusan a recept szövegét: ha csak a CFLAGS változik,
a cél újabb maradhat, és nem indul újra a fordítás.
A Ninja alacsony szintű, gyors végrehajtó; a build.ninja
fájlt jellemzően nem kézzel írjuk, hanem egy meta-build generálja. A
Ninja pontos éleket, depfile-t és parancs-hash-t használ, ezért a recept
változását általában észleli. Hasznos diagnosztikai kapcsolói például a
-d explain, a -t deps és a
-t commands; ezek pontos neve és kimenete verziófüggő.
A CMake meta-build: target-alapú modellt ír le, majd Make- vagy
Ninja-fájlt generál. A targetek közötti használati követelmények (usage
requirements) adják a include-utak és kapcsolók transzparens terjedését:
a PUBLIC beállítás a fogyasztókhoz is eljut, a
PRIVATE nem. A CMake configure- és build-fázisa különül el;
a cmake --build a kiválasztott generátort hívja. A
compile_commands.json a configure eredménye: fordítási
egységenként rögzíti a tényleges parancsot, és kiváló clangd, clang-tidy
vagy saját ellenőrzés céljára. Nem függőségi gráf, és nem a build
bemenete; könnyen elavulhat, ha a build könyvtárat kézzel
módosítjuk.
Az alábbi példa Linuxon, GCC vagy Clang mellett, C++20-cal értelmezendő. A cél a gráf és a generált header bemutatása; a kimenet elvárt, nem mért eredmény.
cmake_minimum_required(VERSION 3.20)
project(build_demo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(BUILD_ID "demo-1")
configure_file(config.h.in generated/config.h @ONLY)
add_library(greet STATIC src/greet.cpp)
target_include_directories(greet PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
${CMAKE_CURRENT_BINARY_DIR}/generated)
target_compile_options(greet PRIVATE -Wall -Wextra -Wpedantic)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE greet)// config.h.in
#pragma once
#define BUILD_ID "@BUILD_ID@"// src/greet.hpp
#pragma once
#include <string>
std::string greeting();// src/greet.cpp
#include "greet.hpp"
#include "config.h"
std::string greeting() {
return std::string{"build "} + BUILD_ID;
}// src/main.cpp
#include "greet.hpp"
#include <iostream>
int main() {
std::cout << greeting() << '\n';
}cmake -S . -B build -G Ninja
cmake --build build --verbose
./build/appA program logikailag build demo-1 sort ír ki. A
config.h a build könyvtárban generálódik; a
greet target PUBLIC include-útja miatt az app
is látja a szükséges headereket. A compile_commands.json a
build könyvtárban keletkezik, és a tényleges fordítóp
parancsokat tartalmazza.
A ccache és a sccache gyorsítótár. A cache-keynek tartalmaznia kell mindent, ami a kimenetet befolyásolja: a compiler identity-jét és verzióját, a targetet, a nyelvi szabványt, az include-utakat, a preprocesszort érintő define-okat, a forrás és a ténylegesen beolvasott headerek tartalmát, valamint a releváns kapcsolókat. A konkrét implementáció direct és preprocesszor módja eltérően építi a kulcsot; a biztonságos követelmény az, hogy a tényleges bemenetek hiánytalanul benne legyenek. A cache nem függőségi oracle: a hiányzó depfile-él és a cache beállításai együtt nehezen reprodukálható elavulást okozhatnak.
A __DATE__, __TIME__, __FILE__
és az abszolút útvonalak rontják a reprodukálhatóságot és a
cache-találati arányt. A -fmacro-prefix-map,
-fdebug-prefix-map és -ffile-prefix-map
jellegű kapcsolók compilerfüggők, de hasznosak lehetnek. A
SOURCE_DATE_EPOCH támogatása szintén eszközfüggő; nem
szabad automatikusan minden toolchainre kiterjeszteni.
Generált header esetén gyakori hiba, hogy a generátor minden buildnél
újraírja a fájlt, megváltoztatva az mtime-ot, és felesleges
újrafordítást indít. A Ninja restat mechanizmusa és a
tartalomstabil generálás segíthet; a Make-nél a generátort érdemes csak
tartalomváltozáskor íratni. A flags változása különösen veszélyes: a
Make gyakran nem indít újrafordítást, míg a Ninja parancs-hash alapján
igen. A compile_commands.json két konfigurációjának
összehasonlítása megmutatja, hogy valóban megváltozott-e a fordítóp
parancs.
Ha a tiszta build helyes, az inkrementális viszont hibás, a hiba a
gráfban vagy a kulcsban van, nem a compilerben. Első lépés a két build
összehasonlítása külön könyvtárban, lehetőleg a cache megkerülésével, a
választott cache dokumentált kapcsolójával. Ezután Ninja esetén
ninja -C build -d explain,
ninja -C build -t deps <objektum> és
ninja -C build -t commands; Make esetén
make -n, make -d vagy make -B
segíthet. Ellenőrizni kell a generált header producer élét, a depfile
tartalmát, a compile_commands.json frissességét és az
mtime-anomáliákat: óraeltérés, régi időbélyeggel kicsomagolt fa,
cp -a, hálózati fájlrendszer.
Tipikus hibák: a header nincs a depfile-ban; a generált headernek
nincs előállító szabálya; a Make nem követi a flags változását; a
compile_commands.json-t tévesen build-bemenetnek tekintik;
a cache-t függőségkövetőnek használják; törölt header után
-MP miatt nem jelenik meg azonnal a hiba, csak később; a
rendszerheaderek kihagyása (-MMD) miatt
toolchain-frissítésnél nem indul újra minden. A „touch mindent” és a
„clean mindig” taktika elrejti az okot; a cél a hiányzó él vagy kulcs
megtalálása.
A build gráf csomópontjai fájlok és célok, élei receptek. A compile
él a TU összes tényleges include-ját, a link él az objektumokat és
könyvtárakat fogyasztja. A helyes inkrementális build feltétele a teljes
deklarált függőségi lezárás és a recept-ujjlenyomat követése. A depfile
a headerfüggőségeket rögzíti, de generált headerhez explicit producer él
kell. A Make egyszerű, de a fegyelmet a felhasználóra bízza; a Ninja
gyors végrehajtó, a CMake target-alapú meta-build. A
compile_commands.json diagnosztikai adat, nem build-gráf. A
cache csak akkor biztonságos, ha a tényleges bemenetek a kulcsban
vannak.
Alap – gráfmodellezés. Adott
main.cpp, amely include-olja a greet.hpp és
config.h fájlokat; greet.cpp include-olja a
greet.hpp-t; a config.h generált; az
app a greet statikus könyvtárhoz linkel.
Rajzold fel a csomópontokat és éleket, majd mondd meg, mely változás
mely célokat invalidálja.
Középhaladó – Make és generált header. Adott egy
C17-es src/main.c, amely #include "config.h"-t
használ, és egy config.h.in, amelyből a build során kell
build/config.h-t előállítani. Írj olyan Makefájlt, amely
első buildnél is működik, és a headerek változását is követi.
Haladó – elavult objektum diagnózisa. Egy
Make-alapú projektben a CFLAGS értékét -O0-ról
-O2-re írod, de a build nem fordít újra; a tiszta build
viszont helyes. Magyarázd meg az okot, adj javítást, és add meg az
ellenőrzés módját.
A csomópontok: main.cpp, greet.cpp,
greet.hpp, config.h, main.o,
greet.o, libgreet.a, app. A
compile élek: main.o függ a main.cpp-től, a
greet.hpp-tól és a config.h-tól;
greet.o függ a greet.cpp-től, a
greet.hpp-tól és a config.h-tól. A link él:
app függ a main.o-tól és a
libgreet.a-tól. A config.h változása mindkét
objektumot invalidálja, majd a linket is. A greet.hpp
változása szintén mindkét objektumot érinti, mert mindkét TU
include-olja. Ha csak a greet.cpp implementációja változik,
csak a greet.o és az app linkje indul újra.
Ellenőrzés: a build rendszer magyarázó vagy száraz futású módjában a
várt compile/link élek jelennek meg.
CC = gcc
CFLAGS = -std=c17 -Wall -Wextra -MMD -MP -Ibuild
OBJ = build/main.o
DEPS = $(OBJ:.o=.d)
all: build/app
build/app: $(OBJ)
$(CC) $^ -o $@
build/%.o: src/%.c build/config.h | build
$(CC) $(CFLAGS) -c $< -o $@
build/config.h: config.h.in
mkdir -p build
sed 's/@VERSION@/1.0/' $< > $@
build:
mkdir -p build
-include $(DEPS)
.PHONY: all clean
clean:
rm -rf build
A kulcs a build/config.h explicit szabálya: így az első
buildnél is létezik a header, mielőtt a compile elindul. A
-MMD -MP depfile-t generál, a -include pedig a
következő futásra behozza a headerek függőségeit. A | build
order-only előfeltétel a könyvtárat biztosítja anélkül, hogy annak
mtime-ja felesleges újrafordítást okozna. Ellenőrzés: tiszta build után
érintsd meg a config.h.in-t vagy egy include-olt headert; a
száraz futásnak a megfelelő compile élt kell mutatnia, tiszta builddel
pedig ugyanazt a kimenetet kell kapni.
Az általános Make nem követi automatikusan a recept szövegét. Ha csak
a CFLAGS változik, az objektum előfeltételei nem válnak
újabbá, ezért a Make nem hívja meg a compilert; a régi objektum marad. A
javítás lehet a flag-ek lenyomatának explicit függőségként való
bevezetése, például egy build/flags.stamp fájllal, amelynek
tartalma a flag-ekből származik, és amelyet az objektumok
előfeltételeként használunk. Robusztusabb megoldás a CMake/Ninja
használata, amely a parancs-hash változását észleli, vagy
flag-profilonként külön build könyvtár. Ellenőrzés: a flagváltás után a
buildnek ki kell adnia a compile parancsot; a tiszta és az inkrementális
build kimenetének összevetése azonos kell legyen. A ccache/sccache ebben
az esetben nem segít, mert a build rendszer el sem indítja a
compilert.