38.1 Build graph, Make/Ninja és incremental build

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.

38.1.1 A build gráf és az inkrementális érvényesség

A build gráf csomópontjai fájlok és célok, élei receptek. Egy tt cél közvetlen, deklarált függőségeit D(t)D(t), receptjét r(t)r(t) jelöli. A gyakorlatban tt akkor használható fel újra, ha minden d∈D(t)d\in D(t) érvényes és változatlan, valamint r(t)r(t) 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.

38.1.2 Headerfüggőség, depfile és generált header

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.o

A 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.

38.1.3 Make, Ninja és a meta-build CMake

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.

38.1.3.1 Példaprojekt

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/app

A 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.

38.1.4 Cache, flag és elavult objektum

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.

38.1.5 Diagnosztika és tipikus hibák

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.

38.1.6 Összefoglaló

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.

38.1.7 Feladatok

  1. 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.

  2. 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.

  3. 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.

38.1.8 Megoldások

38.1.8.1 1. feladat megoldása

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.

38.1.8.2 2. feladat megoldása

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.

38.1.8.3 3. feladat megoldása

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.